diff --git a/src/features/workspace/components/__tests__/form-muxing-rules.test.tsx b/src/features/workspace/components/__tests__/form-muxing-rules.test.tsx new file mode 100644 index 00000000..f9431bd6 --- /dev/null +++ b/src/features/workspace/components/__tests__/form-muxing-rules.test.tsx @@ -0,0 +1,144 @@ +import { render } from '@/lib/test-utils' +import { screen, waitFor } from '@testing-library/react' +import { FormMuxRules } from '../form-mux-rules' +import userEvent from '@testing-library/user-event' + +test('all fields are disabled if the form is disabled', async () => { + render() + + expect( + screen.getByLabelText(/matcher type/i, { selector: 'button' }) + ).toBeDisabled() + expect( + screen.getByLabelText(/matcher/i, { selector: 'input' }) + ).toBeDisabled() + expect(screen.getByLabelText(/model/i, { selector: 'input' })).toBeDisabled() + expect( + screen.getByLabelText(/delete/i, { selector: 'button' }) + ).toBeDisabled() + expect( + screen.getByLabelText(/add filter/i, { selector: 'button' }) + ).toBeDisabled() + expect( + screen.getByLabelText(/revert changes/i, { selector: 'button' }) + ).toBeDisabled() + expect(screen.getByLabelText(/save/i, { selector: 'button' })).toBeDisabled() +}) + +test('muxing rules form works correctly', async () => { + render() + + // should only have 1 row initially + expect( + screen.getAllByLabelText(/matcher type/i, { selector: 'button' }).length + ).toEqual(1) + expect( + screen.getAllByLabelText(/matcher/i, { selector: 'input' }).length + ).toEqual(1) + expect( + screen.getAllByLabelText(/model/i, { selector: 'input' }).length + ).toEqual(1) + expect( + screen.getAllByLabelText(/delete/i, { selector: 'button' }).length + ).toEqual(1) + + // shouldn't be able to delete the catch-all row + expect( + screen.getAllByLabelText(/delete/i, { selector: 'button' })[0] + ).toBeDisabled() + + // populate the "catch-all" row + await userEvent.click(screen.getByLabelText('Model', { selector: 'input' })) + await userEvent.click(screen.getByRole('option', { name: /claude-3.6/i })) + await waitFor(() => { + expect(screen.getByLabelText('Model', { selector: 'input' })).toHaveValue( + 'claude-3.6' + ) + }) + + // add a new row + await userEvent.click( + screen.getByLabelText(/add filter/i, { selector: 'button' }) + ) + + // add a new row + expect( + screen.getAllByLabelText(/matcher type/i, { selector: 'button' }).length + ).toEqual(2) + expect( + screen.getAllByLabelText(/matcher/i, { selector: 'input' }).length + ).toEqual(2) + expect( + screen.getAllByLabelText(/model/i, { selector: 'input' }).length + ).toEqual(2) + expect( + screen.getAllByLabelText(/delete/i, { selector: 'button' }).length + ).toEqual(2) + + // shouldn't be able to delete the catch-all row + expect( + screen.getAllByLabelText(/delete/i, { selector: 'button' })[1] + ).toBeDisabled() + + // populate new row + const newMatcherTypeSelect = screen.getAllByLabelText(/matcher type/i, { + selector: 'button', + })[0] + const newMatcherInput = screen.getAllByLabelText(/matcher/i, { + selector: 'input', + })[0] + const newModelComboBox = screen.getAllByLabelText(/model/i, { + selector: 'input', + })[0] + + await userEvent.click(newMatcherTypeSelect as HTMLFormElement) + await userEvent.click(screen.getByRole('option', { name: 'FIM' })) + + await userEvent.type(newMatcherInput as HTMLFormElement, '.tsx') + + await userEvent.click(newModelComboBox as HTMLFormElement) + await userEvent.click(screen.getByRole('option', { name: /chatgpt-4p/i })) + + await userEvent.click(screen.getByRole('button', { name: /save/i })) + + await waitFor(() => { + expect( + screen.getByText(/muxing rules for fake-workspace updated/i) + ).toBeVisible() + }) +}) + +test('displays validation errors with invalid config', async () => { + render() + + // should only have 1 row initially + expect( + screen.getAllByLabelText(/matcher type/i, { selector: 'button' }).length + ).toEqual(1) + expect( + screen.getAllByLabelText(/matcher/i, { selector: 'input' }).length + ).toEqual(1) + expect( + screen.getAllByLabelText(/model/i, { selector: 'input' }).length + ).toEqual(1) + expect( + screen.getAllByLabelText(/delete/i, { selector: 'button' }).length + ).toEqual(1) + + // add a new row + await userEvent.click( + screen.getByLabelText(/add filter/i, { selector: 'button' }) + ) + + // populate the matcher input, but not the model + const matcherInput = screen.getAllByLabelText(/matcher/i, { + selector: 'input', + })[0] + await userEvent.type(matcherInput as HTMLFormElement, '.tsx') + + await userEvent.click(screen.getByRole('button', { name: /save/i })) + + await waitFor(() => { + expect(screen.getByText(/muxing: model is required/i)).toBeVisible() + }) +}) diff --git a/src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx b/src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx deleted file mode 100644 index e36c44d0..00000000 --- a/src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { render } from '@/lib/test-utils' -import { screen, waitFor } from '@testing-library/react' -import { WorkspaceMuxingModel } from '../workspace-muxing-model' -import userEvent from '@testing-library/user-event' - -test('renders muxing model', async () => { - render( - - ) - - expect( - screen.getByRole('button', { - name: /all types/i, - }) - ).toBeVisible() - expect(screen.getByText(/model muxing/i)).toBeVisible() - expect( - screen.getByText( - /select the model you would like to use in this workspace. This section applies only if you are using the MUX endpoint./i - ) - ).toBeVisible() - expect( - screen.getByRole('link', { - name: /learn more/i, - }) - ).toBeVisible() - - await userEvent.type( - screen.getByRole('textbox', { - name: /filter by/i, - }), - '.tsx' - ) - - await userEvent.click(screen.getByTestId(/workspace-models-dropdown/i)) - await userEvent.click( - screen.getByRole('option', { - name: /claude-3.6/i, - }) - ) - - expect(screen.getByRole('button', { name: /add filter/i })).toBeVisible() - expect(screen.getByRole('link', { name: /manage providers/i })).toBeVisible() - expect(screen.getByRole('button', { name: /revert changes/i })).toBeVisible() - expect(screen.getByRole('button', { name: /save/i })).toBeVisible() -}) - -test('disabled muxing fields and buttons for archived workspace', async () => { - render( - - ) - - expect(await screen.findByRole('button', { name: /save/i })).toBeDisabled() - expect(screen.getByTestId(/workspace-models-dropdown/i)).toBeDisabled() - expect( - screen.getByRole('button', { - name: /all types/i, - }) - ).toBeDisabled() - expect( - await screen.findByRole('button', { name: /add filter/i }) - ).toBeDisabled() -}) - -test('submit additional model overrides', async () => { - render( - - ) - - expect(screen.getAllByRole('textbox', { name: /filter by/i }).length).toEqual( - 1 - ) - - await userEvent.click(screen.getByTestId(/workspace-models-dropdown/i)) - await userEvent.click( - screen.getByRole('option', { - name: /claude-3.6/i, - }) - ) - await waitFor(() => { - expect(screen.getByText(/claude-3.6/i)).toBeVisible() - }) - - await userEvent.click(screen.getByRole('button', { name: /add filter/i })) - const textFields = await screen.findAllByRole('textbox', { - name: /filter by/i, - }) - expect(textFields.length).toEqual(2) - - const requestTypeSelect = screen.getAllByRole('button', { - name: /fim & chat/i, - })[0] - await userEvent.click(requestTypeSelect as HTMLFormElement) - await userEvent.click( - screen.getByRole('option', { - name: 'FIM', - }) - ) - expect( - screen.getByRole('button', { - name: 'FIM', - }) - ).toBeVisible() - const modelsButton = await screen.findAllByTestId( - /workspace-models-dropdown/i - ) - expect(modelsButton.length).toEqual(2) - - await userEvent.type(textFields[0] as HTMLFormElement, '.tsx') - - await userEvent.click( - (await screen.findByRole('button', { - name: /select a model/i, - })) as HTMLFormElement - ) - - await userEvent.click( - screen.getByRole('option', { - name: /chatgpt-4p/i, - }) - ) - - await userEvent.click(screen.getByRole('button', { name: /add filter/i })) - await userEvent.click( - screen.getAllByRole('button', { - name: /chat/i, - })[1] as HTMLFormElement - ) - - await userEvent.click( - screen.getByRole('option', { - name: 'Chat', - }) - ) - - await userEvent.type( - screen.getAllByRole('textbox', { - name: /filter by/i, - })[1] as HTMLFormElement, - '.ts' - ) - - await userEvent.click( - (await screen.findByRole('button', { - name: /select a model/i, - })) as HTMLFormElement - ) - - await userEvent.click( - screen.getByRole('option', { - name: /chatgpt-4o/i, - }) - ) - await userEvent.click(screen.getByRole('button', { name: /save/i })) - - await waitFor(() => { - expect( - screen.getByText(/muxing rules for fake-workspace updated/i) - ).toBeVisible() - }) -}) diff --git a/src/features/workspace/components/form-mux-button-add-row.tsx b/src/features/workspace/components/form-mux-button-add-row.tsx new file mode 100644 index 00000000..bac6258a --- /dev/null +++ b/src/features/workspace/components/form-mux-button-add-row.tsx @@ -0,0 +1,26 @@ +import { Button } from '@stacklok/ui-kit' +import { MuxMatcherType } from '@/api/generated' +import { Plus } from '@untitled-ui/icons-react' +import { useFormMuxRulesContext } from './form-mux-context-provider' + +export function FormMuxButtonAddRow({ isDisabled }: { isDisabled: boolean }) { + const { prepend } = useFormMuxRulesContext() + + return ( + + ) +} diff --git a/src/features/workspace/components/form-mux-button-delete-rule.tsx b/src/features/workspace/components/form-mux-button-delete-rule.tsx new file mode 100644 index 00000000..81b753cf --- /dev/null +++ b/src/features/workspace/components/form-mux-button-delete-rule.tsx @@ -0,0 +1,29 @@ +import { Button } from '@stacklok/ui-kit' +import { Trash01 } from '@untitled-ui/icons-react' +import { useFormMuxRulesContext } from './form-mux-context-provider' +import { FieldValuesMuxRow } from '../lib/schema-mux' +import { MuxMatcherType } from '@/api/generated' + +export function FormMuxButtonDeleteRow({ + index, + row, + isDisabled, +}: { + index: number + row: FieldValuesMuxRow & { id: string } + isDisabled: boolean +}) { + const { remove } = useFormMuxRulesContext() + + return ( + + ) +} diff --git a/src/features/workspace/components/form-mux-button-drag-to-reorder.tsx b/src/features/workspace/components/form-mux-button-drag-to-reorder.tsx new file mode 100644 index 00000000..78e3e272 --- /dev/null +++ b/src/features/workspace/components/form-mux-button-drag-to-reorder.tsx @@ -0,0 +1,38 @@ +import { useSortable } from '@dnd-kit/sortable' +import { DotsGrid } from '@untitled-ui/icons-react' +import { FieldValuesMuxRow } from '../lib/schema-mux' +import { twMerge } from 'tailwind-merge' + +export function FormMuxButtonDragToReorder({ + row, + isDisabled: controlledIsDisabled, +}: { + row: FieldValuesMuxRow & { id: string } + isDisabled: boolean +}) { + const isDisabled: boolean = + row.matcher === 'Catch-all' || controlledIsDisabled + + const { + attributes, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - typedefs say `eventListeners` it is actually `listeners` + listeners, + } = useSortable({ id: row.id, disabled: isDisabled }) + return ( +
+ +
+ ) +} diff --git a/src/features/workspace/components/form-mux-combobox-model.tsx b/src/features/workspace/components/form-mux-combobox-model.tsx new file mode 100644 index 00000000..29762979 --- /dev/null +++ b/src/features/workspace/components/form-mux-combobox-model.tsx @@ -0,0 +1,62 @@ +import { ModelByProvider } from '@/api/generated' +import { useQueryListAllModelsForAllProviders } from '@/hooks/use-query-list-all-models-for-all-providers' +import { + ComboBoxButton, + ComboBoxClearButton, + ComboBoxFieldGroup, + ComboBoxInput, + OptionsSchema, +} from '@stacklok/ui-kit' +import { groupBy, map } from 'lodash' +import { getMuxFieldName } from '../lib/get-mux-field-name' +import { SearchMd } from '@untitled-ui/icons-react' +import { FormComboBox } from './tmp/form-combobox' +import { serializeMuxModel } from '../lib/mux-model-serde' + +function groupModels( + models: ModelByProvider[] = [] +): OptionsSchema<'listbox'>[] { + return map(groupBy(models, 'provider_name'), (items, providerName) => ({ + id: providerName, + textValue: providerName, + items: items.map((item) => ({ + id: serializeMuxModel(item), + textValue: item.name, + })), + })) +} + +export function FormMuxComboboxModel({ + index, + isDisabled, +}: { + index: number + isDisabled: boolean +}) { + const { data: models = [] } = useQueryListAllModelsForAllProviders({ + select: groupModels, + }) + + return ( + + + } + isBorderless + placeholder="Type to search..." + /> + + + + + ) +} diff --git a/src/features/workspace/components/form-mux-context-provider.tsx b/src/features/workspace/components/form-mux-context-provider.tsx new file mode 100644 index 00000000..c5f534d5 --- /dev/null +++ b/src/features/workspace/components/form-mux-context-provider.tsx @@ -0,0 +1,35 @@ +import { createContext, ReactNode, useContext } from 'react' +import { useFieldArray } from 'react-hook-form' +import { WorkspaceConfigFieldValues } from '../lib/schema-mux' + +type FormMuxRulesContextValue = ReturnType< + typeof useFieldArray +> + +const FormMuxRulesContext = createContext(null) + +export const useFormMuxRulesContext = (): FormMuxRulesContextValue => { + const context = useContext(FormMuxRulesContext) + if (!context) + throw Error( + 'useFormMuxRulesContext must be used inside a FormMuxRulesContextProvider' + ) + + return context +} + +export function FormMuxRulesContextProvider({ + children, +}: { + children: ReactNode +}) { + const value = useFieldArray({ + name: 'muxing_rules', + }) + + return ( + + {children} + + ) +} diff --git a/src/features/workspace/components/form-mux-dnd-provider.tsx b/src/features/workspace/components/form-mux-dnd-provider.tsx new file mode 100644 index 00000000..e4403cb3 --- /dev/null +++ b/src/features/workspace/components/form-mux-dnd-provider.tsx @@ -0,0 +1,51 @@ +import { + closestCenter, + DndContext, + DragEndEvent, + KeyboardSensor, + PointerSensor, + UniqueIdentifier, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { + sortableKeyboardCoordinates, + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { ReactNode } from 'react' + +export function DndSortProvider({ + children, + onDragEnd, + items, + isDisabled, +}: { + children: ReactNode + onDragEnd: (event: DragEndEvent) => void + items: T[] + isDisabled: boolean +}) { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + return ( + + + {children} + + + ) +} diff --git a/src/features/workspace/components/form-mux-fields-array.tsx b/src/features/workspace/components/form-mux-fields-array.tsx new file mode 100644 index 00000000..3465bbea --- /dev/null +++ b/src/features/workspace/components/form-mux-fields-array.tsx @@ -0,0 +1,59 @@ +import { DragEndEvent } from '@dnd-kit/core' +import { useCallback } from 'react' +import { FormMuxRuleRow } from './form-mux-rule-row' +import { DndSortProvider } from './form-mux-dnd-provider' +import { useFormMuxRulesContext } from './form-mux-context-provider' +import { FormMuxFieldsLabels } from './form-mux-fields-labels' + +function getIndicesOnDragEnd(event: DragEndEvent): { + from: number + to: number +} | null { + const { active, over } = event + if (over == null || active.id == null || over.id == null) return null + // If trying to drag over the last item, no-op + if ( + over.data.current?.sortable.index >= + over.data.current?.sortable.items.length - 1 + ) + return null + + return { + from: active.data.current?.sortable.index, + to: over.data.current?.sortable.index, + } +} + +export function FormMuxFieldsArray({ isDisabled }: { isDisabled: boolean }) { + const { fields, move } = useFormMuxRulesContext() + + const onDragEnd = useCallback( + (event: DragEndEvent) => { + const { from, to } = getIndicesOnDragEnd(event) || {} + if (typeof from === 'number' && typeof to === 'number') move(from, to) + }, + [move] + ) + + return ( + <> + + +
    + {fields.map((item, index) => ( + + ))} +
+
+ + ) +} diff --git a/src/features/workspace/components/form-mux-fields-labels.tsx b/src/features/workspace/components/form-mux-fields-labels.tsx new file mode 100644 index 00000000..7f189dd3 --- /dev/null +++ b/src/features/workspace/components/form-mux-fields-labels.tsx @@ -0,0 +1,43 @@ +import { + Label, + Tooltip, + TooltipInfoButton, + TooltipTrigger, +} from '@stacklok/ui-kit' +import { muxRowGridStyles } from '../lib/mux-row-grid-styles' + +export function FormMuxFieldsLabels() { + return ( +
+
+ + + +
+ ) +} diff --git a/src/features/workspace/components/form-mux-rule-row.tsx b/src/features/workspace/components/form-mux-rule-row.tsx new file mode 100644 index 00000000..646abd6f --- /dev/null +++ b/src/features/workspace/components/form-mux-rule-row.tsx @@ -0,0 +1,49 @@ +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { twMerge } from 'tailwind-merge' +import { FormMuxComboboxModel } from './form-mux-combobox-model' +import { FormMuxTextFieldMatcher } from './form-mux-text-field-matcher' +import { FieldValuesMuxRow } from '../lib/schema-mux' +import { FormSelectMatcherType } from './form-mux-select-matcher-type' +import { FormMuxButtonDragToReorder } from './form-mux-button-drag-to-reorder' +import { muxRowGridStyles } from '../lib/mux-row-grid-styles' +import { FormMuxButtonDeleteRow } from './form-mux-button-delete-rule' + +export function FormMuxRuleRow({ + index, + row, + isDisabled, +}: { + index: number + row: FieldValuesMuxRow & { id: string } + isDisabled: boolean +}) { + const { transform, transition, setNodeRef } = useSortable({ id: row.id }) + const style = { + transform: CSS.Transform.toString({ + y: transform?.y || 0, + scaleX: 1, + scaleY: 1, + x: 0, + }), + transition, + } + + return ( +
  • + + + + + +
  • + ) +} diff --git a/src/features/workspace/components/form-mux-rules.tsx b/src/features/workspace/components/form-mux-rules.tsx new file mode 100644 index 00000000..64e9b50b --- /dev/null +++ b/src/features/workspace/components/form-mux-rules.tsx @@ -0,0 +1,191 @@ +import { + Alert, + Card, + CardBody, + CardFooter, + FormSubmitButton, + FormV2, + Link, + LinkButton, + Text, +} from '@stacklok/ui-kit' +import { twMerge } from 'tailwind-merge' +import { useMutationPreferredModelWorkspace } from '../hooks/use-mutation-preferred-model-workspace' +import { MuxMatcherType, V1GetWorkspaceMuxesResponse } from '@/api/generated' +import { + FlipBackward, + LayersThree01, + LinkExternal01, +} from '@untitled-ui/icons-react' +import { useQueryListAllModelsForAllProviders } from '@/hooks/use-query-list-all-models-for-all-providers' +import { useQueryMuxingRulesWorkspace } from '../hooks/use-query-muxing-rules-workspace' +import { FormMuxFieldsArray } from './form-mux-fields-array' +import { + schemaWorkspaceConfig, + WorkspaceConfigFieldValues, +} from '../lib/schema-mux' +import { zodResolver } from '@hookform/resolvers/zod' +import { FormMuxButtonAddRow } from './form-mux-button-add-row' +import { FormMuxRulesContextProvider } from './form-mux-context-provider' +import { deserializeMuxModel, serializeMuxModel } from '../lib/mux-model-serde' +import { SubmitHandler } from 'react-hook-form' +import { handleMuxFormErrors } from '../lib/handle-mux-form-errors' +import { FormDiscardChangesButton } from './tmp/form-discard-changes-button' + +const DEFAULT_VALUES: WorkspaceConfigFieldValues = { + muxing_rules: [ + { + // @ts-expect-error - we start with invalid state + model: undefined, + matcher: 'Catch-all', + matcher_type: MuxMatcherType.CATCH_ALL, + }, + ], +} + +const fromApiMuxingRules = ( + rules: V1GetWorkspaceMuxesResponse +): WorkspaceConfigFieldValues => { + return { + muxing_rules: rules.map( + ({ matcher_type, model, matcher, provider_name, provider_type }) => ({ + model: serializeMuxModel({ + name: model, + provider_type, + provider_name: provider_name as string, + }), + matcher_type, + matcher: matcher ?? '', + }) + ), + } +} + +function MissingProviderBanner() { + return ( + // TODO needs to update the related ui-kit component that diverges from the design + + + Configure a provider + + + ) +} + +export function FormMuxRules({ + className, + workspaceName, + isDisabled, +}: { + className?: string + workspaceName: string + isDisabled: boolean +}) { + const { data: muxRulesFromApi, isPending } = + useQueryMuxingRulesWorkspace(workspaceName) + + const { mutateAsync } = useMutationPreferredModelWorkspace() + const { data: providerModels = [] } = useQueryListAllModelsForAllProviders() + const isModelsEmpty = !isPending && providerModels.length === 0 + + const handleSubmit: SubmitHandler = (data) => { + mutateAsync({ + path: { workspace_name: workspaceName }, + body: data.muxing_rules.map(({ matcher, matcher_type, model }) => { + const { + name: modelName, + provider_name, + provider_type, + } = deserializeMuxModel(model) + return { + matcher_type, + model: modelName, + provider_type, + matcher, + provider_name, + } + }), + }) + } + + if (isModelsEmpty) { + return ( + + + Model Muxing + + + + ) + } + + const defaultValues = + muxRulesFromApi.length > 0 + ? fromApiMuxingRules(muxRulesFromApi) + : DEFAULT_VALUES + + return ( + + onSubmit={handleSubmit} + onError={handleMuxFormErrors} + options={{ + values: defaultValues, + resolver: zodResolver(schemaWorkspaceConfig), + shouldFocusError: true, + }} + > + + + +
    + Model Muxing + + Select the model you would like to use in this workspace. This + section applies only if you are using the MUX endpoint. + + Learn more + + +
    + + +
    + + +
    + + + Manage providers + +
    +
    + + + Revert changes + + + Save + +
    +
    +
    +
    + + ) +} diff --git a/src/features/workspace/components/form-mux-select-matcher-type.tsx b/src/features/workspace/components/form-mux-select-matcher-type.tsx new file mode 100644 index 00000000..25833d93 --- /dev/null +++ b/src/features/workspace/components/form-mux-select-matcher-type.tsx @@ -0,0 +1,48 @@ +import { MuxMatcherType } from '@/api/generated' +import { SelectButton } from '@stacklok/ui-kit' +import { getMuxFieldName } from '../lib/get-mux-field-name' +import { FormSelect } from './tmp/form-select' +import { FieldValuesMuxRow } from '../lib/schema-mux' + +export function FormSelectMatcherType({ + index, + row, + isDisabled, +}: { + index: number + row: FieldValuesMuxRow & { id: string } + isDisabled: boolean +}) { + return ( + + + + ) +} diff --git a/src/features/workspace/components/form-mux-text-field-matcher.tsx b/src/features/workspace/components/form-mux-text-field-matcher.tsx new file mode 100644 index 00000000..26651962 --- /dev/null +++ b/src/features/workspace/components/form-mux-text-field-matcher.tsx @@ -0,0 +1,29 @@ +import { Input } from '@stacklok/ui-kit' +import { getMuxFieldName } from '../lib/get-mux-field-name' +import { FieldValuesMuxRow } from '../lib/schema-mux' +import { MuxMatcherType } from '@/api/generated' +import { FormTextField } from './tmp/form-text-field' + +export function FormMuxTextFieldMatcher({ + index, + row, + isDisabled, +}: { + index: number + row: FieldValuesMuxRow & { id: string } + isDisabled: boolean +}) { + return ( + + + + ) +} diff --git a/src/features/workspace/components/tmp/form-checkbox-group.tsx b/src/features/workspace/components/tmp/form-checkbox-group.tsx new file mode 100644 index 00000000..c3dec8c5 --- /dev/null +++ b/src/features/workspace/components/tmp/form-checkbox-group.tsx @@ -0,0 +1,53 @@ +import { CheckboxGroup, FieldError } from '@stacklok/ui-kit' +import type { ComponentProps } from 'react' +import { useController, useFormContext } from 'react-hook-form' + +/** + * A `FormCheckboxGroup` connects a `CheckboxGroup` to a `Form` component using `react-hook-form`. + * + * [React Aria Documentation](https://react-spectrum.adobe.com/react-aria/CheckboxGroup.html) + */ +export function FormCheckboxGroup({ + children, + ...props +}: ComponentProps) { + if (props.name == null) + throw new Error('FormCheckboxGroup requires a name prop') + + const { control } = useFormContext() + + const { + field: { disabled: isDisabledByForm, name, onBlur, onChange, ref, value }, + fieldState: { error, invalid }, + } = useController({ + control, + defaultValue: props.value ?? props.defaultValue ?? [], + name: props.name, + }) + + return ( + { + onChange(k) + props.onChange?.(k) + }} + ref={ref} + defaultValue={value} + validationBehavior="aria" // Let React Hook Form handle validation instead of the browser. + > + {(renderProps) => { + return ( + <> + {typeof children === 'function' ? children(renderProps) : children} + {error?.message} + + ) + }} + + ) +} diff --git a/src/features/workspace/components/tmp/form-combobox.tsx b/src/features/workspace/components/tmp/form-combobox.tsx new file mode 100644 index 00000000..dea8ada1 --- /dev/null +++ b/src/features/workspace/components/tmp/form-combobox.tsx @@ -0,0 +1,60 @@ +import { ComboBox, FieldError, OptionsSchema } from '@stacklok/ui-kit' +import type { ComponentProps } from 'react' +import { useController, useFormContext } from 'react-hook-form' + +/** + * A `FormComboBox` connects a `ComboBox` to a `Form` component using `react-hook-form`. + * + * [React Aria Documentation](https://react-spectrum.adobe.com/react-aria/ComboBox.html) + */ +export function FormComboBox< + T extends OptionsSchema<'listbox'> = OptionsSchema<'listbox'>, +>({ + children, + shouldShowValidationError = true, + ...props +}: ComponentProps> & { + shouldShowValidationError?: boolean +}) { + if (props.name == null) throw new Error('FormComboBox requires a name prop') + + const { control } = useFormContext() + + const { + field: { disabled: isDisabledByForm, name, onBlur, onChange, ref, value }, + fieldState: { error, invalid }, + } = useController({ + control, + defaultValue: props.selectedKey ?? props.defaultSelectedKey, + name: props.name, + }) + + return ( + { + onChange(k) + props.onSelectionChange?.(k) + }} + ref={ref} + selectedKey={undefined} // react-hook-form relies on uncontrolled component + validationBehavior="aria" // Let react-hook-form handle validation + > + {(rp) => { + return ( + <> + {typeof children === 'function' ? children(rp) : children} + {shouldShowValidationError ? ( + {error?.message} + ) : null} + + ) + }} + + ) +} diff --git a/src/features/workspace/components/tmp/form-discard-changes-button.tsx b/src/features/workspace/components/tmp/form-discard-changes-button.tsx new file mode 100644 index 00000000..ff0d06a3 --- /dev/null +++ b/src/features/workspace/components/tmp/form-discard-changes-button.tsx @@ -0,0 +1,31 @@ +import { Button } from '@stacklok/ui-kit' +import { FlipBackward } from '@untitled-ui/icons-react' +import type { ComponentProps } from 'react' +import { type FieldValues, useFormContext } from 'react-hook-form' + +export function FormDiscardChangesButton({ + children = ( + <> + + Discard changes + + ), + variant = 'tertiary', + defaultValues, + ...props +}: ComponentProps & { defaultValues: T }) { + const { reset, formState } = useFormContext() + const { isDirty, isValidating } = formState || {} + + return ( + + ) +} diff --git a/src/features/workspace/components/tmp/form-radio-group.tsx b/src/features/workspace/components/tmp/form-radio-group.tsx new file mode 100644 index 00000000..ad654c84 --- /dev/null +++ b/src/features/workspace/components/tmp/form-radio-group.tsx @@ -0,0 +1,60 @@ +import { FieldError, RadioGroup } from '@stacklok/ui-kit' +import type { ComponentProps } from 'react' +import { useController, useFormContext } from 'react-hook-form' + +/** + * A `FormRadioGroup` connects a `RadioGroup` to a `Form` component using `react-hook-form`. + * + * [React Aria Documentation](https://react-spectrum.adobe.com/react-aria/RadioGroup.html) + */ +export function FormRadioGroup({ + children, + ...props +}: ComponentProps) { + if (props.name == null) throw new Error('FormRadioGroup requires a name prop') + + const { control } = useFormContext() + + const { + field: { + disabled: isDisabledByForm, + name, + onBlur, + onChange, + ref, + value = '', + }, + fieldState: { error, invalid }, + } = useController({ + control, + defaultValue: props.value ?? props.defaultValue, + name: props.name, + }) + + return ( + { + onChange(k) + props.onChange?.(k) + }} + ref={ref} + value={undefined} // react-hook-form relies on uncontrolled component + validationBehavior="aria" // Let react-hook-form handle validation + > + {(renderProps) => { + return ( + <> + {typeof children === 'function' ? children(renderProps) : children} + {error?.message} + + ) + }} + + ) +} diff --git a/src/features/workspace/components/tmp/form-reset-on-submit.tsx b/src/features/workspace/components/tmp/form-reset-on-submit.tsx new file mode 100644 index 00000000..3a8774ff --- /dev/null +++ b/src/features/workspace/components/tmp/form-reset-on-submit.tsx @@ -0,0 +1,23 @@ +import { useEffect } from 'react' +import { useFormContext } from 'react-hook-form' + +/** + * A component that resets the form after a successful submission. + * Used to test that form fields are reset after a successful submission. + */ +export function FormResetOnSubmit() { + const { + formState: { isSubmitSuccessful }, + reset, + } = useFormContext() + + // It is recommended in the React Hook Form documentation to use `useEffect` to + // handle side effects like resetting the form after a successful submission. + useEffect(() => { + if (isSubmitSuccessful) { + reset() + } + }, [isSubmitSuccessful, reset]) + + return null +} diff --git a/src/features/workspace/components/tmp/form-select.tsx b/src/features/workspace/components/tmp/form-select.tsx new file mode 100644 index 00000000..abf9dca5 --- /dev/null +++ b/src/features/workspace/components/tmp/form-select.tsx @@ -0,0 +1,58 @@ +import { FieldError, OptionsSchema, Select } from '@stacklok/ui-kit' +import type { ComponentProps } from 'react' +import { useController, useFormContext } from 'react-hook-form' + +/** + * A `FormSelect` connects a `Select` to a `Form` component using `react-hook-form`. + * + * [React Aria Documentation](https://react-spectrum.adobe.com/react-aria/Select.html) + */ +export function FormSelect< + T extends OptionsSchema<'listbox'> = OptionsSchema<'listbox'>, +>({ + children, + shouldShowValidationError = true, + ...props +}: ComponentProps> & { shouldShowValidationError?: boolean }) { + if (props.name == null) throw new Error('FormSelect requires a name prop') + + const { control } = useFormContext() + + const { + field: { disabled: isDisabledByForm, name, onBlur, onChange, ref, value }, + fieldState: { error, invalid }, + } = useController({ + control, + defaultValue: props.selectedKey ?? props.defaultSelectedKey, + name: props.name, + }) + + return ( + + ) +} diff --git a/src/features/workspace/components/tmp/form-submit-button.tsx b/src/features/workspace/components/tmp/form-submit-button.tsx new file mode 100644 index 00000000..0e0d1d7b --- /dev/null +++ b/src/features/workspace/components/tmp/form-submit-button.tsx @@ -0,0 +1,27 @@ +import { Button } from '@stacklok/ui-kit' +import type { ComponentProps } from 'react' +import { useFormState } from 'react-hook-form' + +export function FormSubmitButton({ + children = 'Submit', + variant = 'primary', + ...props +}: ComponentProps) { + const { isSubmitting, isValidating, isDirty } = useFormState() + + return ( + + ) +} diff --git a/src/features/workspace/components/tmp/form-text-field.tsx b/src/features/workspace/components/tmp/form-text-field.tsx new file mode 100644 index 00000000..e3cbb31e --- /dev/null +++ b/src/features/workspace/components/tmp/form-text-field.tsx @@ -0,0 +1,56 @@ +import { FieldError, TextField } from '@stacklok/ui-kit' +import type { ComponentProps } from 'react' +import { useController, useFormContext } from 'react-hook-form' + +/** + * A form text field connects a `TextField` to a `Form` component using `react-hook-form`. + * + * [React Aria Documentation](https://react-spectrum.adobe.com/react-aria/TextField.html) + */ +export function FormTextField({ + children, + shouldShowValidationError = true, + ...props +}: ComponentProps & { shouldShowValidationError?: boolean }) { + if (props.name == null) throw new Error('FormTextField requires a name prop') + + const { control } = useFormContext() + + const { + field: { disabled: isDisabledByForm, name, onBlur, onChange, ref, value }, + fieldState: { error, invalid }, + } = useController({ + control, + defaultValue: props.value ?? props.defaultValue, + name: props.name, + }) + + return ( + { + onChange(v) + props.onChange?.(v) + }} + ref={ref} + value={undefined} // react-hook-form relies on uncontrolled component + validationBehavior="aria" // Let react-hook-form handle validation + > + {(renderProps) => { + return ( + <> + {typeof children === 'function' ? children(renderProps) : children} + {shouldShowValidationError ? ( + {error?.message} + ) : null} + + ) + }} + + ) +} diff --git a/src/features/workspace/components/workspace-muxing-model.tsx b/src/features/workspace/components/workspace-muxing-model.tsx deleted file mode 100644 index d6dcfcf5..00000000 --- a/src/features/workspace/components/workspace-muxing-model.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { - Alert, - Button, - Card, - CardBody, - CardFooter, - Form, - Input, - Label, - Link, - LinkButton, - Select, - SelectButton, - Text, - TextField, - Tooltip, - TooltipInfoButton, - TooltipTrigger, -} from '@stacklok/ui-kit' -import { twMerge } from 'tailwind-merge' -import { useMutationPreferredModelWorkspace } from '../hooks/use-mutation-preferred-model-workspace' -import { - ProviderType, - V1ListAllModelsForAllProvidersResponse, -} from '@/api/generated' -import { FormEvent } from 'react' -import { - LayersThree01, - LinkExternal01, - Plus, - Trash01, -} from '@untitled-ui/icons-react' -import { SortableArea } from '@/components/SortableArea' -import { WorkspaceModelsDropdown } from './workspace-models-dropdown' -import { useQueryListAllModelsForAllProviders } from '@/hooks/use-query-list-all-models-for-all-providers' -import { useQueryMuxingRulesWorkspace } from '../hooks/use-query-muxing-rules-workspace' -import { - PreferredMuxRule, - useMuxingRulesFormState, -} from '../hooks/use-muxing-rules-form-workspace' -import { FormButtons } from '@/components/FormButtons' -import { getRuleData, isRequestType } from '../lib/utils' -import { z } from 'zod' - -function MissingProviderBanner() { - return ( - // TODO needs to update the related ui-kit component that diverges from the design - - - Configure a provider - - - ) -} - -type SortableItemProps = { - index: number - rule: PreferredMuxRule - models: V1ListAllModelsForAllProvidersResponse - isArchived: boolean - showRemoveButton: boolean - isDefaultRule: boolean - setRuleItem: (rule: PreferredMuxRule) => void - removeRule: (index: number) => void -} - -function SortableItem({ - rule, - index, - setRuleItem, - removeRule, - models, - showRemoveButton, - isArchived, - isDefaultRule, -}: SortableItemProps) { - const { selectedKey, placeholder, items } = getRuleData({ - isDefaultRule, - matcher_type: rule.matcher_type, - }) - - return ( -
    -
    - -
    -
    - { - setRuleItem({ ...rule, matcher }) - }} - > - - -
    -
    - { - if (provider_type === undefined) return - setRuleItem({ - ...rule, - provider_name, - provider_type: z.nativeEnum(ProviderType).parse(provider_type), - model, - }) - }} - /> - {showRemoveButton && !isDefaultRule ? ( - - ) : ( -
    - )} -
    -
    - ) -} - -export function WorkspaceMuxingModel({ - className, - workspaceName, - isArchived, -}: { - className?: string - workspaceName: string - isArchived: boolean | undefined -}) { - const { data: muxingRules, isPending } = - useQueryMuxingRulesWorkspace(workspaceName) - const { addRule, setRules, setRuleItem, removeRule, formState } = - useMuxingRulesFormState(muxingRules) - const { - values: { rules }, - } = formState - - const { mutateAsync } = useMutationPreferredModelWorkspace() - const { data: providerModels = [] } = useQueryListAllModelsForAllProviders() - const isModelsEmpty = !isPending && providerModels.length === 0 - const showRemoveButton = rules.length > 1 - - const handleSubmit = (event: FormEvent) => { - event.preventDefault() - mutateAsync( - { - path: { workspace_name: workspaceName }, - body: rules.map(({ id, provider_type, ...rest }) => { - void id - if (provider_type === undefined) - throw new Error('provider_type is required') - - return { provider_type, ...rest } - }), - }, - { - onSuccess: () => { - formState.setInitialValues({ rules }) - }, - } - ) - } - - if (isModelsEmpty) { - return ( - - - Model Muxing - - - - ) - } - - return ( -
    - - -
    - Model Muxing - - Select the model you would like to use in this workspace. This - section applies only if you are using the MUX endpoint. - - Learn more - - -
    - -
    -
    -
     
    -
    Request Type
    -
    - -
    -
    - -
    -
    - - {(rule, index) => { - const isDefaultRule = rules.length - 1 === index - return ( - - ) - }} - -
    -
    - -
    - - - - Manage providers - -
    - -
    -
    -
    - ) -} diff --git a/src/features/workspace/lib/get-mux-field-name.ts b/src/features/workspace/lib/get-mux-field-name.ts new file mode 100644 index 00000000..79a9294b --- /dev/null +++ b/src/features/workspace/lib/get-mux-field-name.ts @@ -0,0 +1,11 @@ +import { MuxFieldName, WorkspaceConfigFieldName } from './schema-mux' + +export function getMuxFieldName({ + field, + index, +}: { + index: number + field: (typeof MuxFieldName)[keyof typeof MuxFieldName] +}): string { + return `${WorkspaceConfigFieldName.muxing_rules}.${index}.${field}` as const +} diff --git a/src/features/workspace/lib/handle-mux-form-errors.ts b/src/features/workspace/lib/handle-mux-form-errors.ts new file mode 100644 index 00000000..0d921aff --- /dev/null +++ b/src/features/workspace/lib/handle-mux-form-errors.ts @@ -0,0 +1,16 @@ +import { SubmitErrorHandler } from 'react-hook-form' +import { WorkspaceConfigFieldValues } from './schema-mux' +import { toast } from '@stacklok/ui-kit' + +export const handleMuxFormErrors: SubmitErrorHandler< + WorkspaceConfigFieldValues +> = (errors) => { + if (!Array.isArray(errors.muxing_rules) || errors.muxing_rules[0] == null) + return + + Object.entries(errors.muxing_rules[0]).forEach((error) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, { message }] = error as [string, { message: string }] + toast.error(message) + }) +} diff --git a/src/features/workspace/lib/mux-row-grid-styles.ts b/src/features/workspace/lib/mux-row-grid-styles.ts new file mode 100644 index 00000000..7716b669 --- /dev/null +++ b/src/features/workspace/lib/mux-row-grid-styles.ts @@ -0,0 +1,5 @@ +import { tv } from 'tailwind-variants' + +export const muxRowGridStyles = tv({ + base: 'grid grid-cols-[2.5rem_1fr_1fr_2fr_2.5rem] items-center gap-2', +}) diff --git a/src/features/workspace/lib/schema-mux.ts b/src/features/workspace/lib/schema-mux.ts new file mode 100644 index 00000000..e50329df --- /dev/null +++ b/src/features/workspace/lib/schema-mux.ts @@ -0,0 +1,32 @@ +import { MuxMatcherType } from '@/api/generated' +import { z } from 'zod' +import { deserializeMuxModel } from './mux-model-serde' + +const schemaMuxRow = z.object({ + model: z.string({ message: 'Muxing: Model is required' }).refine( + (v) => { + try { + const model = deserializeMuxModel(v) + if (model) return true + } catch { + return false + } + }, + { message: 'Muxing: Model is required' } + ), + matcher_type: z.nativeEnum(MuxMatcherType, { + message: 'Muxing: Matcher type is required', + }), + matcher: z.string(), +}) + +export type FieldValuesMuxRow = z.infer + +export const schemaWorkspaceConfig = z.object({ + muxing_rules: z.array(schemaMuxRow), +}) + +export type WorkspaceConfigFieldValues = z.infer + +export const WorkspaceConfigFieldName = schemaWorkspaceConfig.keyof().Enum +export const MuxFieldName = schemaMuxRow.keyof().Enum diff --git a/src/hooks/use-query-list-all-models-for-all-providers.ts b/src/hooks/use-query-list-all-models-for-all-providers.ts index b6f70a5f..c75ab538 100644 --- a/src/hooks/use-query-list-all-models-for-all-providers.ts +++ b/src/hooks/use-query-list-all-models-for-all-providers.ts @@ -1,12 +1,20 @@ import { useQuery } from '@tanstack/react-query' import { v1ListAllModelsForAllProvidersOptions } from '@/api/generated/@tanstack/react-query.gen' import { getQueryCacheConfig } from '@/lib/react-query-utils' +import { V1ListAllModelsForAllProvidersResponse } from '@/api/generated' -export const useQueryListAllModelsForAllProviders = () => { +export function useQueryListAllModelsForAllProviders< + T = V1ListAllModelsForAllProvidersResponse, +>({ + select, +}: { + select?: (data: V1ListAllModelsForAllProvidersResponse) => T +} = {}) { return useQuery({ ...v1ListAllModelsForAllProvidersOptions(), ...getQueryCacheConfig('no-cache'), // eslint-disable-next-line no-restricted-syntax refetchOnMount: true, + select, }) } diff --git a/src/routes/route-workspace.tsx b/src/routes/route-workspace.tsx index 6453768f..9851e072 100644 --- a/src/routes/route-workspace.tsx +++ b/src/routes/route-workspace.tsx @@ -7,7 +7,7 @@ import { useParams } from 'react-router-dom' import { useArchivedWorkspaces } from '@/features/workspace/hooks/use-archived-workspaces' import { useRestoreWorkspaceButton } from '@/features/workspace/hooks/use-restore-workspace-button' import { WorkspaceCustomInstructions } from '@/features/workspace/components/workspace-custom-instructions' -import { WorkspaceMuxingModel } from '@/features/workspace/components/workspace-muxing-model' +import { FormMuxRules } from '@/features/workspace/components/form-mux-rules' import { PageContainer } from '@/components/page-container' import { WorkspaceActivateButton } from '@/features/workspace/components/workspace-activate-button' import { WorkspaceDownloadButton } from '@/features/workspace/components/workspace-download-button' @@ -63,9 +63,9 @@ export function RouteWorkspace() { className="mb-4" workspaceName={name} /> -