diff --git a/src/lib/skills/SkillInputForm.svelte b/src/lib/skills/SkillInputForm.svelte index 5a28b377..a8f5e06e 100644 --- a/src/lib/skills/SkillInputForm.svelte +++ b/src/lib/skills/SkillInputForm.svelte @@ -1,4 +1,5 @@
{skillTitle} — inputs
{#each req as def (def.name)} - + {@render field(def, true)} {/each} {#if opt.length} @@ -54,15 +77,50 @@ {#if showOptional} {#each opt as def (def.name)} - + {@render field(def, false)} {/each} {/if} {/if}
+{#snippet labelLine(def: SkillInputDef, isRequired: boolean)} + + {fieldLabel(def.name)}{#if isRequired}*{/if} + {#if isRequired && def.type !== 'document' && !provided(values[def.name])} + ⚠ required{/if} + + {#if helpText(def)} + {helpText(def)} + {/if} +{/snippet} + +{#snippet field(def: SkillInputDef, isRequired: boolean)} + {#if def.type === 'document'} + +
+ {@render labelLine(def, isRequired)} + + +
+ {:else} + + {/if} +{/snippet} + {#snippet widget(def: SkillInputDef)} {#if def.type === 'enum' && def.enum} + onchange(def.name, e.currentTarget.value === '' ? undefined : e.currentTarget.value)} + class="rounded-mlq-control border border-mlq-subtle bg-transparent px-2 py-1 text-sm text-mlq-text outline-none focus:border-mlq-workflow" + > + + {#each enumFromDescription(def) ?? [] as o (o)}{/each} + {:else} { expect(screen.queryByLabelText('doc')).toBeNull(); }); + it('renders the input name as a title-cased label with a required marker and the description as help text', () => { + render(SkillInputForm, { + props: { + skillTitle: 'NDA', + required: [ + def({ + name: 'perspective', + type: 'text', + required: true, + description: 'The vantage point to review from' + }) + ], + optional: [], + values: {}, + onchange: vi.fn() + } + }); + expect(screen.getByText('Perspective')).toBeInTheDocument(); + expect(screen.getByText('*')).toBeInTheDocument(); + expect(screen.getByText('The vantage point to review from')).toBeInTheDocument(); + // The raw name still drives the control's accessible name. + expect(screen.getByLabelText('perspective')).toBeInTheDocument(); + }); + + it('does not render a required marker for optional inputs', async () => { + render(SkillInputForm, { + props: { + skillTitle: 'NDA', + required: [], + optional: [def({ name: 'extra_notes', type: 'text' })], + values: {}, + onchange: vi.fn() + } + }); + await fireEvent.click(screen.getByRole('button', { name: /optional \(1\)/i })); + expect(screen.getByText('Extra Notes')).toBeInTheDocument(); + expect(screen.queryByText('*')).toBeNull(); + }); + + it('renders a select with parsed options for a pipe-enum description', async () => { + const onchange = vi.fn(); + render(SkillInputForm, { + props: { + skillTitle: 'NDA', + required: [ + def({ + name: 'depth', + type: 'text', + required: true, + description: 'quick | standard | deep. How thorough the review should be.' + }) + ], + optional: [], + values: {}, + onchange + } + }); + const select = screen.getByLabelText('depth') as HTMLSelectElement; + expect(select.tagName).toBe('SELECT'); + expect(screen.getByRole('option', { name: 'quick' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'standard' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'deep' })).toBeInTheDocument(); + // An empty choice exists so the value can be left unset. + expect((select.options[0] as HTMLOptionElement).value).toBe(''); + // The option list is not repeated in the help text; the remainder is. + expect(screen.getByText('How thorough the review should be.')).toBeInTheDocument(); + expect(screen.queryByText(/quick \| standard \| deep/)).toBeNull(); + await fireEvent.change(select, { target: { value: 'deep' } }); + expect(onchange).toHaveBeenCalledWith('depth', 'deep'); + }); + + it('keeps a free-text input when the description has no pipe-enum head', () => { + render(SkillInputForm, { + props: { + skillTitle: 'NDA', + required: [ + def({ + name: 'focus', + type: 'text', + required: true, + description: 'What to focus on. E.g. liability or IP.' + }) + ], + optional: [], + values: {}, + onchange: vi.fn() + } + }); + expect((screen.getByLabelText('focus') as HTMLInputElement).tagName).toBe('INPUT'); + }); + + it('renders an attach hint instead of a text box for a document input', () => { + render(SkillInputForm, { + props: { + skillTitle: 'NDA', + required: [ + def({ + name: 'contract', + type: 'document', + required: true, + description: 'The contract to review' + }) + ], + optional: [], + values: {}, + onchange: vi.fn() + } + }); + expect(screen.getByText('Contract')).toBeInTheDocument(); + expect(screen.queryByRole('textbox')).toBeNull(); + expect(screen.getByTestId('doc-hint-contract')).toHaveTextContent( + 'Attach the document to the message — the clip button' + ); + // Not fillable inline, so it must not warn as missing. + expect(screen.queryByText(/⚠ required/)).toBeNull(); + }); + it('pre-fills a text input from values', () => { render(SkillInputForm, { props: { diff --git a/src/lib/skills/attach.svelte.test.ts b/src/lib/skills/attach.svelte.test.ts index 22f9d634..5f753aa4 100644 --- a/src/lib/skills/attach.svelte.test.ts +++ b/src/lib/skills/attach.svelte.test.ts @@ -189,6 +189,17 @@ describe('createSkillAttach', () => { expect(s.allRequiredFilled).toBe(true); }); + it('does not let a required document-type input block sending (documents travel as attachments)', async () => { + const s = createSkillAttach(); + const f = vi + .fn() + .mockImplementation(() => + inputsRes([{ name: 'contract', type: 'document', required: true }]) + ); + await s.attach(NDA, f); + expect(s.allRequiredFilled).toBe(true); + }); + it('blocks sending while a skill is still loading its inputs', () => { const s = createSkillAttach(); let resolveFetch: (r: Response) => void = () => {}; diff --git a/src/lib/skills/attach.svelte.ts b/src/lib/skills/attach.svelte.ts index 49290bb7..763b0a29 100644 --- a/src/lib/skills/attach.svelte.ts +++ b/src/lib/skills/attach.svelte.ts @@ -78,13 +78,15 @@ export function createSkillAttach() { return out; }, /** True when no skill is still loading inputs and every attached skill's required - * (non-file) inputs are provided. File-type inputs are never rendered (separate - * channel), so they must not block sending. */ + * (non-file) inputs are provided. File- and document-type inputs are never rendered + * as typed fields (they travel as message attachments), so they must not block sending. */ get allRequiredFilled() { return attached.every( (a) => !a.inputsLoading && - a.required.filter((d) => d.type !== 'file').every((d) => provided(a.values[d.name])) + a.required + .filter((d) => d.type !== 'file' && d.type !== 'document') + .every((d) => provided(a.values[d.name])) ); }, open: (fetchFn: typeof fetch = fetch) => fetchResults('', fetchFn),