Skip to content

Commit f12e626

Browse files
committed
begin cleaning up react-hook-form-field-array
1 parent 6e5b8ec commit f12e626

15 files changed

+536
-572
lines changed

src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { render } from '@/lib/test-utils'
22
import { screen, waitFor } from '@testing-library/react'
3-
import { WorkspaceMuxingModel } from '../workspace-muxing-model'
3+
import { WorkspaceMuxingModel } from '../form-mux'
44
import userEvent from '@testing-library/user-event'
55

66
test('renders muxing model', async () => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { ModelByProvider } from '@/api/generated'
2+
import { useQueryListAllModelsForAllProviders } from '@/hooks/use-query-list-all-models-for-all-providers'
3+
import {
4+
ComboBoxButton,
5+
ComboBoxClearButton,
6+
ComboBoxFieldGroup,
7+
ComboBoxInput,
8+
FormComboBox,
9+
OptionsSchema,
10+
} from '@stacklok/ui-kit'
11+
import { groupBy, map } from 'lodash'
12+
import { getMuxFieldName } from '../lib/get-mux-field-name'
13+
import { SearchMd } from '@untitled-ui/icons-react'
14+
15+
function groupModels(
16+
models: ModelByProvider[] = []
17+
): OptionsSchema<'listbox'>[] {
18+
return map(groupBy(models, 'provider_name'), (items, providerName) => ({
19+
id: providerName,
20+
textValue: providerName,
21+
items: items.map((item) => ({
22+
id: `${item.provider_id}/${item.name}`,
23+
textValue: item.name,
24+
})),
25+
}))
26+
}
27+
28+
export function FormMuxComboboxModel({ index }: { index: number }) {
29+
const { data: models = [] } = useQueryListAllModelsForAllProviders({
30+
select: groupModels,
31+
})
32+
33+
return (
34+
<FormComboBox
35+
aria-label="Matcher"
36+
items={models}
37+
name={getMuxFieldName({
38+
index,
39+
field: 'model',
40+
})}
41+
>
42+
<ComboBoxFieldGroup>
43+
<ComboBoxInput
44+
icon={<SearchMd />}
45+
isBorderless
46+
placeholder="Type to search..."
47+
/>
48+
<ComboBoxClearButton />
49+
<ComboBoxButton />
50+
</ComboBoxFieldGroup>
51+
</FormComboBox>
52+
)
53+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {
2+
closestCenter,
3+
DndContext,
4+
DragEndEvent,
5+
KeyboardSensor,
6+
PointerSensor,
7+
UniqueIdentifier,
8+
useSensor,
9+
useSensors,
10+
} from '@dnd-kit/core'
11+
import {
12+
sortableKeyboardCoordinates,
13+
SortableContext,
14+
verticalListSortingStrategy,
15+
} from '@dnd-kit/sortable'
16+
import { ReactNode } from 'react'
17+
18+
export function DndSortProvider<T extends { id: UniqueIdentifier }>({
19+
children,
20+
onDragEnd,
21+
items,
22+
}: {
23+
children: ReactNode
24+
onDragEnd: (event: DragEndEvent) => void
25+
items: T[]
26+
}) {
27+
const sensors = useSensors(
28+
useSensor(PointerSensor),
29+
useSensor(KeyboardSensor, {
30+
coordinateGetter: sortableKeyboardCoordinates,
31+
})
32+
)
33+
34+
return (
35+
<DndContext
36+
sensors={sensors}
37+
collisionDetection={closestCenter}
38+
onDragEnd={onDragEnd}
39+
>
40+
<SortableContext items={items} strategy={verticalListSortingStrategy}>
41+
{children}
42+
</SortableContext>
43+
</DndContext>
44+
)
45+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useSortable } from '@dnd-kit/sortable'
2+
import { DotsGrid } from '@untitled-ui/icons-react'
3+
import { FieldValuesMuxRow } from '../lib/schema-mux'
4+
5+
export function FormMuxDragToReorderButton({
6+
item,
7+
}: {
8+
item: FieldValuesMuxRow
9+
}) {
10+
const { attributes, listeners, setNodeRef } = useSortable({ id: item.id })
11+
12+
return (
13+
<div
14+
ref={setNodeRef}
15+
{...attributes}
16+
{...listeners}
17+
className="pointer-events-auto rounded-sm p-1.5 hover:bg-gray-100 pressed:bg-gray-200"
18+
>
19+
<DotsGrid className="size-5" />
20+
</div>
21+
)
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {
2+
Button,
3+
Label,
4+
Tooltip,
5+
TooltipInfoButton,
6+
TooltipTrigger,
7+
} from '@stacklok/ui-kit'
8+
import { useFieldArray, useFormContext } from 'react-hook-form'
9+
import {
10+
WORKSPACE_CONFIG_FIELD_NAME,
11+
WorkspaceConfigFieldValues,
12+
} from '../lib/schema-mux'
13+
import { MuxMatcherType } from '@/api/generated'
14+
import { uniqueId } from 'lodash'
15+
import { Plus } from '@untitled-ui/icons-react'
16+
import { tv } from 'tailwind-variants'
17+
import {
18+
SortableContext,
19+
sortableKeyboardCoordinates,
20+
verticalListSortingStrategy,
21+
} from '@dnd-kit/sortable'
22+
import {
23+
closestCenter,
24+
DndContext,
25+
DragEndEvent,
26+
KeyboardSensor,
27+
PointerSensor,
28+
UniqueIdentifier,
29+
useSensor,
30+
useSensors,
31+
} from '@dnd-kit/core'
32+
import { ReactNode, useCallback } from 'react'
33+
import { FormMuxRuleRow } from './form-mux-rule-row'
34+
import { DndSortProvider } from './form-mux-dnd-provider'
35+
36+
function getIndicesOnDragEnd<T extends { id: UniqueIdentifier }>(
37+
event: DragEndEvent,
38+
items: T[]
39+
): {
40+
from: number
41+
to: number
42+
} | null {
43+
const { active, over } = event
44+
45+
if (over == null || active.id || over.id) return null // no-op
46+
47+
const from = items.findIndex(({ id }) => id === active.id)
48+
const to = items.findIndex(({ id }) => id === over.id)
49+
50+
return {
51+
from,
52+
to,
53+
}
54+
}
55+
56+
const gridStyles = tv({
57+
base: 'grid grid-cols-[2fr_1fr_2.5rem] items-center gap-2',
58+
})
59+
60+
function Labels() {
61+
return (
62+
<div className={gridStyles()}>
63+
<Label className="flex items-center gap-1">
64+
Filter by
65+
<TooltipTrigger delay={0}>
66+
<TooltipInfoButton aria-label="Filter by description" />
67+
<Tooltip placement="right" className="max-w-72 text-balance">
68+
Filters are applied in top-down order. The first rule that matches
69+
each prompt determines the chosen model. An empty filter applies to
70+
all prompts.
71+
</Tooltip>
72+
</TooltipTrigger>
73+
</Label>
74+
<Label>Preferred model</Label>
75+
</div>
76+
)
77+
}
78+
79+
export function FormMuxFieldsArray() {
80+
const { control } = useFormContext<WorkspaceConfigFieldValues>()
81+
82+
const { fields, swap, prepend } = useFieldArray<WorkspaceConfigFieldValues>({
83+
control,
84+
name: WORKSPACE_CONFIG_FIELD_NAME.muxing_rules,
85+
})
86+
console.debug('👉 fields:', fields)
87+
88+
const onDragEnd = useCallback(
89+
(event: DragEndEvent) => {
90+
const { from, to } = getIndicesOnDragEnd(event, fields) || {}
91+
if (from && to) swap(from, to)
92+
},
93+
[fields, swap]
94+
)
95+
96+
return (
97+
<>
98+
<Labels />
99+
<DndSortProvider key={fields.length} items={fields} onDragEnd={onDragEnd}>
100+
<ul>
101+
{fields.map((item, index) => {
102+
console.debug('👉 MAPPED item:', item)
103+
return <FormMuxRuleRow index={index} key={item.id} item={item} />
104+
})}
105+
</ul>
106+
</DndSortProvider>
107+
108+
<div className="flex gap-2">
109+
<Button
110+
className="w-fit"
111+
variant="tertiary"
112+
onPress={() =>
113+
prepend({
114+
id: uniqueId(),
115+
model: undefined,
116+
matcher: '',
117+
matcher_type: MuxMatcherType.FILENAME_MATCH,
118+
})
119+
}
120+
// isDisabled={isArchived}
121+
>
122+
<Plus /> Add Filter
123+
</Button>
124+
125+
{/* <LinkButton className="w-fit" variant="tertiary" href="/providers">
126+
<LayersThree01 /> Manage providers
127+
</LinkButton> */}
128+
</div>
129+
</>
130+
)
131+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { MuxMatcherType } from '@/api/generated'
2+
import { useSortable } from '@dnd-kit/sortable'
3+
import { CSS } from '@dnd-kit/utilities'
4+
import { Button } from '@stacklok/ui-kit'
5+
import { tv } from 'tailwind-variants'
6+
import { twMerge } from 'tailwind-merge'
7+
import { FormMuxComboboxModel } from './form-mux-combobox-model'
8+
import { Trash01 } from '@untitled-ui/icons-react'
9+
import { FormMuxTextFieldMatcher } from './form-mux-text-field-matcher'
10+
import { FieldValuesMuxRow } from '../lib/schema-mux'
11+
12+
const gridStyles = tv({
13+
base: 'grid grid-cols-[2fr_1fr_2.5rem] items-center gap-2',
14+
})
15+
16+
export function FormMuxRuleRow({
17+
index,
18+
item,
19+
}: {
20+
index: number
21+
item: FieldValuesMuxRow
22+
}) {
23+
const isCatchAll = item.matcher_type === MuxMatcherType.CATCH_ALL
24+
25+
const { transform, transition } = useSortable({ id: item.id })
26+
const style = {
27+
transform: CSS.Transform.toString(transform),
28+
transition,
29+
}
30+
31+
return (
32+
<li className={twMerge(gridStyles(), 'mb-2')} style={style}>
33+
<FormMuxTextFieldMatcher
34+
key={item.id}
35+
item={item}
36+
isCatchAll={isCatchAll}
37+
index={index}
38+
/>
39+
40+
<FormMuxComboboxModel index={index} />
41+
<Button
42+
aria-label="Delete"
43+
isIcon
44+
isDisabled
45+
isDestructive
46+
variant="secondary"
47+
// onPress={() => removeRule(index)}
48+
>
49+
<Trash01 />
50+
</Button>
51+
</li>
52+
)
53+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { MuxMatcherType } from '@/api/generated'
2+
import { FormSelect, SelectButton } from '@stacklok/ui-kit'
3+
import { getMuxFieldName } from '../lib/get-mux-field-name'
4+
5+
export function FormMuxComboboxModel({ index }: { index: number }) {
6+
return (
7+
<FormSelect
8+
aria-label="Matcher type"
9+
items={[
10+
{
11+
id: MuxMatcherType.CATCH_ALL,
12+
textValue: 'Catch-all',
13+
},
14+
{
15+
id: MuxMatcherType.FILENAME_MATCH,
16+
textValue: 'Filename',
17+
},
18+
{
19+
id: MuxMatcherType.REQUEST_TYPE_MATCH,
20+
textValue: 'Request type',
21+
},
22+
]}
23+
name={getMuxFieldName({
24+
index,
25+
field: 'matcher_type',
26+
})}
27+
>
28+
<SelectButton />
29+
</FormSelect>
30+
)
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { FormTextField, Input } from '@stacklok/ui-kit'
2+
import { getMuxFieldName } from '../lib/get-mux-field-name'
3+
import { FormMuxDragToReorderButton } from './form-mux-drag-to-reorder-button'
4+
import { FieldValuesMuxRow } from '../lib/schema-mux'
5+
6+
export function FormMuxTextFieldMatcher({
7+
index,
8+
isCatchAll,
9+
item,
10+
}: {
11+
index: number
12+
isCatchAll: boolean
13+
item: FieldValuesMuxRow
14+
}) {
15+
console.debug('👉 isCatchAll:', isCatchAll)
16+
return (
17+
<FormTextField
18+
aria-label="Matcher"
19+
isDisabled={isCatchAll}
20+
defaultValue={isCatchAll ? 'Catch-all' : undefined}
21+
name={getMuxFieldName({
22+
index,
23+
field: 'matcher',
24+
})}
25+
>
26+
<Input
27+
icon={
28+
isCatchAll ? undefined : <FormMuxDragToReorderButton item={item} />
29+
}
30+
/>
31+
</FormTextField>
32+
)
33+
}

0 commit comments

Comments
 (0)