Skip to content

Commit 421e301

Browse files
committed
feat(tables): configure reference columns in sidebar
1 parent 8e897e3 commit 421e301

4 files changed

Lines changed: 312 additions & 40 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
interface ComboboxOption {
9+
label: string
10+
value: string
11+
}
12+
13+
interface ComboboxProps {
14+
options: ComboboxOption[]
15+
value?: string
16+
placeholder?: string
17+
searchable?: boolean
18+
searchPlaceholder?: string
19+
onChange?: (value: string) => void
20+
}
21+
22+
interface SelectOptionsEditorProps {
23+
options: Array<{ id: string; name: string }>
24+
onChange: (options: Array<{ id: string; name: string }>) => void
25+
}
26+
27+
const {
28+
capturedComboboxes,
29+
capturedSelectEditor,
30+
mockAddColumn,
31+
mockUpdateColumn,
32+
mockUseTablesList,
33+
} = vi.hoisted(() => ({
34+
capturedComboboxes: { current: [] as ComboboxProps[] },
35+
capturedSelectEditor: { current: null as SelectOptionsEditorProps | null },
36+
mockAddColumn: vi.fn(),
37+
mockUpdateColumn: vi.fn(),
38+
mockUseTablesList: vi.fn(),
39+
}))
40+
41+
vi.mock('@sim/emcn', () => ({
42+
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
43+
<button {...props}>{children}</button>
44+
),
45+
ChipCombobox: (props: ComboboxProps) => {
46+
capturedComboboxes.current.push(props)
47+
return <div data-placeholder={props.placeholder} />
48+
},
49+
ChipInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
50+
FieldDivider: () => <hr />,
51+
Label: ({ children, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) => (
52+
<label htmlFor={props.htmlFor ?? 'test-field'} {...props}>
53+
{children}
54+
</label>
55+
),
56+
Switch: ({ checked }: { checked?: boolean }) => (
57+
<button type='button' aria-pressed={checked}>
58+
Toggle
59+
</button>
60+
),
61+
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(' '),
62+
toast: { error: vi.fn(), success: vi.fn() },
63+
}))
64+
65+
vi.mock('@sim/emcn/icons', () => ({
66+
PlayOutline: () => <svg />,
67+
X: () => <svg />,
68+
}))
69+
70+
vi.mock('@/lib/table/column-types', () => ({
71+
ALL_COLUMN_TYPES: [
72+
{ id: 'string', label: 'Text', icon: () => null },
73+
{ id: 'select', label: 'Select', icon: () => null },
74+
{ id: 'reference', label: 'Reference', icon: () => null },
75+
],
76+
columnTypeOf: (type: string) => ({ supportsUnique: type !== 'select' }),
77+
}))
78+
79+
vi.mock('@/hooks/queries/tables', () => ({
80+
useAddTableColumn: () => ({ isPending: false, mutateAsync: mockAddColumn }),
81+
useTablesList: mockUseTablesList,
82+
useUpdateColumn: () => ({ isPending: false, mutateAsync: mockUpdateColumn }),
83+
}))
84+
85+
vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/components/select-field', () => ({
86+
SelectOptionsEditor: (props: SelectOptionsEditorProps) => {
87+
capturedSelectEditor.current = props
88+
return <div data-testid='select-options-editor' />
89+
},
90+
}))
91+
92+
import { ColumnConfigSidebar } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar'
93+
94+
let container: HTMLDivElement
95+
let root: Root
96+
97+
function findCombobox(placeholder: string): ComboboxProps | undefined {
98+
return capturedComboboxes.current.find((combobox) => combobox.placeholder === placeholder)
99+
}
100+
101+
function findButton(label: string): HTMLButtonElement | undefined {
102+
return [...container.querySelectorAll<HTMLButtonElement>('button')].find(
103+
(button) => button.textContent === label
104+
)
105+
}
106+
107+
beforeEach(() => {
108+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
109+
container = document.createElement('div')
110+
document.body.appendChild(container)
111+
root = createRoot(container)
112+
capturedComboboxes.current = []
113+
capturedSelectEditor.current = null
114+
mockUseTablesList.mockReturnValue({
115+
data: [
116+
{ id: 'table-current', name: 'Current table' },
117+
{ id: 'table-customers', name: 'Customers' },
118+
],
119+
})
120+
mockAddColumn.mockResolvedValue({ data: { columns: [] } })
121+
mockUpdateColumn.mockResolvedValue({ data: { columns: [] } })
122+
})
123+
124+
afterEach(() => {
125+
act(() => root.unmount())
126+
container.remove()
127+
vi.clearAllMocks()
128+
})
129+
130+
describe('ColumnConfigSidebar', () => {
131+
it('creates a Reference column with the selected workspace table', async () => {
132+
await act(async () => {
133+
root.render(
134+
<ColumnConfigSidebar
135+
config={{ mode: 'create', proposedName: 'Related row', type: 'reference' }}
136+
onClose={vi.fn()}
137+
existingColumn={null}
138+
workspaceId='workspace-1'
139+
tableId='table-current'
140+
/>
141+
)
142+
})
143+
144+
expect(mockUseTablesList).toHaveBeenCalledWith('workspace-1', 'active', { enabled: true })
145+
expect(container.querySelector<HTMLInputElement>('#column-sidebar-name')?.value).toBe(
146+
'Related row'
147+
)
148+
expect(findCombobox('Select table')).toMatchObject({
149+
options: [
150+
{ label: 'Current table', value: 'table-current' },
151+
{ label: 'Customers', value: 'table-customers' },
152+
],
153+
searchable: true,
154+
searchPlaceholder: 'Search tables',
155+
})
156+
157+
act(() => findCombobox('Select table')?.onChange?.('table-customers'))
158+
await act(async () => findButton('Save')?.click())
159+
160+
expect(mockAddColumn).toHaveBeenCalledWith({
161+
name: 'Related row',
162+
type: 'reference',
163+
referenceTableId: 'table-customers',
164+
})
165+
})
166+
167+
it('edits Reference configuration without exposing column renaming', async () => {
168+
await act(async () => {
169+
root.render(
170+
<ColumnConfigSidebar
171+
config={{ mode: 'edit', columnName: 'col-reference' }}
172+
onClose={vi.fn()}
173+
existingColumn={{
174+
id: 'col-reference',
175+
name: 'Related row',
176+
type: 'reference',
177+
referenceTableId: 'table-current',
178+
}}
179+
workspaceId='workspace-1'
180+
tableId='table-current'
181+
/>
182+
)
183+
})
184+
185+
expect(container).not.toHaveTextContent('Column name')
186+
expect(container.querySelector('#column-sidebar-name')).toBeNull()
187+
188+
act(() => findCombobox('Select table')?.onChange?.('table-customers'))
189+
await act(async () => findButton('Save')?.click())
190+
191+
expect(mockUpdateColumn).toHaveBeenCalledWith({
192+
columnName: 'col-reference',
193+
updates: { referenceTableId: 'table-customers' },
194+
})
195+
})
196+
197+
it('keeps Select options in the edit sidebar', async () => {
198+
await act(async () => {
199+
root.render(
200+
<ColumnConfigSidebar
201+
config={{ mode: 'edit', columnName: 'col-status' }}
202+
onClose={vi.fn()}
203+
existingColumn={{
204+
id: 'col-status',
205+
name: 'Status',
206+
type: 'select',
207+
options: [{ id: 'option-ready', name: 'Ready' }],
208+
}}
209+
workspaceId='workspace-1'
210+
tableId='table-current'
211+
/>
212+
)
213+
})
214+
215+
expect(container).toHaveTextContent('Options')
216+
expect(container).toHaveTextContent('Multiselect')
217+
act(() =>
218+
capturedSelectEditor.current?.onChange([
219+
{ id: 'option-ready', name: 'Ready' },
220+
{ id: 'option-done', name: 'Done' },
221+
])
222+
)
223+
await act(async () => findButton('Save')?.click())
224+
225+
expect(mockUpdateColumn).toHaveBeenCalledWith({
226+
columnName: 'col-status',
227+
updates: {
228+
options: [
229+
{ id: 'option-ready', name: 'Ready' },
230+
{ id: 'option-done', name: 'Done' },
231+
],
232+
},
233+
})
234+
})
235+
})

0 commit comments

Comments
 (0)