fix: make scoring profile creation visible

This commit is contained in:
Brad Groux 2026-07-24 12:45:43 -05:00
parent 53c5ba2df3
commit b6b8acdf07
3 changed files with 102 additions and 21 deletions

View file

@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
preventing undefined length crashes and providing a recoverable load error
with Retry instead of replacing the task surface with an error boundary
(#936).
- Made **New Profile** open a focused, validated scoring draft from both Profiles
and Score Explorer, with cancel returning to the originating tab without
creating an orphan profile (#943).
## [6.0.0] - 2026-07-24

View file

@ -528,6 +528,35 @@ describe('governance surfaces Mantine migration', () => {
expectNoLegacySlots(baseElement);
});
it('opens and cancels a focused scoring profile draft from Score Explorer', async () => {
const user = userEvent.setup();
const confirmDiscard = vi.spyOn(window, 'confirm').mockReturnValue(true);
renderWithProviders(<ScoringProfiles onBack={vi.fn()} />);
await user.click(screen.getByRole('tab', { name: /score explorer/i }));
expect(await screen.findByText('Composite Score Trend')).toBeDefined();
await user.click(screen.getByRole('button', { name: 'New Profile' }));
expect(screen.getByRole('tab', { name: 'Profiles' }).getAttribute('aria-selected')).toBe(
'true'
);
expect(screen.getByRole('heading', { name: 'New scoring profile' })).toBeDefined();
const name = screen.getByRole('textbox', { name: 'Profile name' });
const save = screen.getByRole('button', { name: 'Save Profile' });
expect(document.activeElement).toBe(name);
expect((save as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText('Profile name is required')).toBeDefined();
await user.type(name, 'Explorer profile');
expect((save as HTMLButtonElement).disabled).toBe(false);
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(await screen.findByText('Composite Score Trend')).toBeDefined();
expect(mocks.createScoringProfile).not.toHaveBeenCalled();
confirmDiscard.mockRestore();
});
it('supports compact scoring list and detail flows with unsaved-change protection', async () => {
const user = userEvent.setup();
const confirmDiscard = vi.spyOn(window, 'confirm').mockReturnValue(false);
@ -565,7 +594,9 @@ describe('governance surfaces Mantine migration', () => {
await user.click(screen.getByRole('button', { name: 'New Profile' }));
expect(screen.getByRole('heading', { name: 'New scoring profile' })).toBeDefined();
await user.type(screen.getByRole('textbox', { name: 'Profile name' }), 'Phone profile');
const profileName = screen.getByRole('textbox', { name: 'Profile name' });
expect(document.activeElement).toBe(profileName);
await user.type(profileName, 'Phone profile');
await user.click(screen.getByRole('button', { name: 'Save Profile' }));
expect(mocks.createScoringProfile).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Phone profile' })

View file

@ -115,8 +115,14 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
const [draftMode, setDraftMode] = useState<DraftMode>('edit');
const [mobileView, setMobileView] = useState<MobileView>('list');
const detailHeadingRef = useRef<HTMLHeadingElement>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
const selectedProfileButtonRef = useRef<HTMLButtonElement>(null);
const shouldFocusListRef = useRef(false);
const shouldFocusNameRef = useRef(false);
const createOriginRef = useRef<{ activeTab: string; mobileView: MobileView }>({
activeTab: 'profiles',
mobileView: 'list',
});
const [evaluationForm, setEvaluationForm] = useState<EvaluationRequest>({
profileId: '',
action: '',
@ -151,6 +157,12 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
}, [profiles, selectedProfileId]);
useEffect(() => {
if (activeTab === 'profiles' && shouldFocusNameRef.current) {
shouldFocusNameRef.current = false;
nameInputRef.current?.focus();
return;
}
if (mobileView === 'detail') {
detailHeadingRef.current?.focus();
return;
@ -160,7 +172,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
shouldFocusListRef.current = false;
selectedProfileButtonRef.current?.focus();
}
}, [mobileView, selectedProfileId]);
}, [activeTab, mobileView, selectedProfileId]);
const loadProfileIntoDraft = (profile: ScoringProfile) => {
if (!confirmDiscardChanges()) return;
@ -175,13 +187,35 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
const handleCreateNew = () => {
if (!confirmDiscardChanges()) return;
createOriginRef.current = { activeTab, mobileView };
const nextDraft = createEmptyDraft();
setDraft(nextDraft);
setCleanDraft(nextDraft);
setDraftMode('create');
shouldFocusNameRef.current = true;
setActiveTab('profiles');
setMobileView('detail');
};
const handleCancelCreate = () => {
if (!confirmDiscardChanges()) return;
if (selectedProfile) {
const nextDraft = profileToDraft(selectedProfile);
setDraft(nextDraft);
setCleanDraft(nextDraft);
setDraftMode('edit');
} else {
const nextDraft = createEmptyDraft();
setDraft(nextDraft);
setCleanDraft(nextDraft);
setDraftMode('create');
}
setActiveTab(createOriginRef.current.activeTab);
setMobileView(createOriginRef.current.mobileView);
};
const handleDuplicate = (profile: ScoringProfile) => {
if (!confirmDiscardChanges()) return;
setDraft({
@ -351,7 +385,12 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
h={48}
className="w-full flex-1 sm:w-auto sm:flex-none"
onClick={handleSave}
disabled={draftReadOnly || createProfile.isPending || updateProfile.isPending}
disabled={
!draft.name.trim() ||
draftReadOnly ||
createProfile.isPending ||
updateProfile.isPending
}
>
<Save className="mr-2 h-4 w-4" />
Save Profile
@ -462,31 +501,37 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
Define weighted scorers and a composite strategy.
</p>
</div>
{draftMode === 'edit' && selectedProfile && (
<div className="flex flex-wrap gap-2">
<Button
h={48}
className="flex-1 sm:flex-none"
variant="outline"
size="sm"
onClick={() => handleDuplicate(selectedProfile)}
>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</Button>
{!selectedProfile.builtIn && (
{draftMode === 'create' ? (
<Button h={48} variant="outline" size="sm" onClick={handleCancelCreate}>
Cancel
</Button>
) : (
selectedProfile && (
<div className="flex flex-wrap gap-2">
<Button
h={48}
className="flex-1 sm:flex-none"
variant="outline"
size="sm"
onClick={() => handleDelete(selectedProfile)}
onClick={() => handleDuplicate(selectedProfile)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
<Copy className="mr-2 h-4 w-4" />
Duplicate
</Button>
)}
</div>
{!selectedProfile.builtIn && (
<Button
h={48}
className="flex-1 sm:flex-none"
variant="outline"
size="sm"
onClick={() => handleDelete(selectedProfile)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
)}
</div>
)
)}
</div>
@ -494,11 +539,13 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
<div className="space-y-2">
<label className="text-sm font-medium">Name</label>
<TextInput
ref={nameInputRef}
aria-label="Profile name"
value={draft.name}
onChange={(event) =>
setDraft((current) => ({ ...current, name: event.target.value }))
}
error={!draft.name.trim() ? 'Profile name is required' : undefined}
disabled={draftReadOnly}
/>
</div>