Skip to content

Commit 1dbc256

Browse files
committed
fix(tables): read-only cell editors and add-row form for update-locked tables
- Update-locked tables open cell editors read-only; the expanded editor disables Save with the reason on hover, and empty cells that can't be edited open nothing - New row opens the Add Row form when only updates are locked - Row form reads and writes values by column id and uses one date-and-time picker - ChipDatePicker follows InsideModalContext so its calendar is clickable in modals, and gains showTime/timeLabel in single mode
1 parent f911b7d commit 1dbc256

12 files changed

Lines changed: 543 additions & 130 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx

Lines changed: 112 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
77
import type { TableInfo, TableRow } from '@/lib/table'
88
import { RowModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal'
99

10-
const { mockToastError, mockUseTimezoneState, mockUpdateRow, mockDeleteRow, mockDeleteRows } =
11-
vi.hoisted(() => ({
12-
mockToastError: vi.fn(),
13-
mockUseTimezoneState: vi.fn(),
14-
mockUpdateRow: vi.fn(),
15-
mockDeleteRow: vi.fn(),
16-
mockDeleteRows: vi.fn(),
17-
}))
10+
const {
11+
mockToastError,
12+
mockUseTimezoneState,
13+
mockCreateRow,
14+
mockUpdateRow,
15+
mockDeleteRow,
16+
mockDeleteRows,
17+
} = vi.hoisted(() => ({
18+
mockToastError: vi.fn(),
19+
mockUseTimezoneState: vi.fn(),
20+
mockCreateRow: vi.fn(),
21+
mockUpdateRow: vi.fn(),
22+
mockDeleteRow: vi.fn(),
23+
mockDeleteRows: vi.fn(),
24+
}))
1825

1926
vi.mock('next/navigation', () => ({
2027
useParams: () => ({ workspaceId: 'workspace-1' }),
@@ -23,6 +30,7 @@ vi.mock('@/hooks/queries/general-settings', () => ({
2330
useTimezoneState: mockUseTimezoneState,
2431
}))
2532
vi.mock('@/hooks/queries/tables', () => ({
33+
useCreateTableRow: () => ({ mutateAsync: mockCreateRow, isPending: false }),
2634
useUpdateTableRow: () => ({ mutateAsync: mockUpdateRow, isPending: false }),
2735
useDeleteTableRow: () => ({ mutateAsync: mockDeleteRow, isPending: false }),
2836
useDeleteTableRows: () => ({ mutateAsync: mockDeleteRows, isPending: false }),
@@ -35,11 +43,12 @@ vi.mock('@sim/emcn', () => {
3543
createElement('button', { type: 'button', ...props }, children),
3644
ChipConfirmModal: passthrough,
3745
ChipDatePicker: ({ value, onChange }: { value?: string; onChange: (value: string) => void }) =>
38-
createElement(
39-
'button',
40-
{ type: 'button', 'data-testid': 'date', onClick: () => onChange(value ?? '2026-11-01') },
41-
value
42-
),
46+
createElement('input', {
47+
'data-testid': 'date',
48+
value: value ?? '',
49+
onChange: (event: { currentTarget: { value: string } }) =>
50+
onChange(event.currentTarget.value),
51+
}),
4352
ChipModal: passthrough,
4453
ChipModalBody: passthrough,
4554
ChipModalError: passthrough,
@@ -80,13 +89,6 @@ vi.mock('@sim/emcn', () => {
8089
'Update Row'
8190
),
8291
ChipModalHeader: passthrough,
83-
ChipTimePicker: ({ value, onChange }: { value?: string; onChange: (value: string) => void }) =>
84-
createElement('input', {
85-
'data-testid': 'time',
86-
value: value ?? '',
87-
onChange: (event: { currentTarget: { value: string } }) =>
88-
onChange(event.currentTarget.value),
89-
}),
9092
Label: passthrough,
9193
toast: { error: mockToastError },
9294
}
@@ -113,6 +115,85 @@ function changeInput(input: HTMLInputElement, value: string) {
113115
input.dispatchEvent(new Event('input', { bubbles: true }))
114116
}
115117

118+
describe('RowModal add mode', () => {
119+
beforeEach(() => {
120+
vi.clearAllMocks()
121+
mockCreateRow.mockResolvedValue(undefined)
122+
mockUseTimezoneState.mockReturnValue({ timezone: 'America/Los_Angeles', status: 'ready' })
123+
})
124+
125+
it('inserts the complete row under column ids in one request without updating', async () => {
126+
const container = document.createElement('div')
127+
document.body.appendChild(container)
128+
const root = createRoot(container)
129+
const props = {
130+
mode: 'add' as const,
131+
isOpen: true,
132+
onClose: vi.fn(),
133+
table: {
134+
id: 'table-3',
135+
name: 'People',
136+
schema: { columns: [{ id: 'col_name', name: 'Name', type: 'string' as const }] },
137+
},
138+
onSuccess: vi.fn(),
139+
}
140+
141+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
142+
act(() => root.render(createElement(RowModal, props)))
143+
144+
const nameInput = container.querySelector<HTMLInputElement>('[data-testid="modal-input"]')
145+
expect(nameInput?.value).toBe('')
146+
act(() => changeInput(nameInput as HTMLInputElement, 'Ada'))
147+
const submit = container.querySelector<HTMLButtonElement>('[data-testid="submit"]')
148+
await act(async () => submit?.click())
149+
150+
expect(mockCreateRow).toHaveBeenCalledWith({ data: { col_name: 'Ada' } })
151+
expect(mockUpdateRow).not.toHaveBeenCalled()
152+
expect(props.onSuccess).toHaveBeenCalledTimes(1)
153+
154+
act(() => root.unmount())
155+
container.remove()
156+
})
157+
})
158+
159+
describe('RowModal column ids', () => {
160+
beforeEach(() => {
161+
vi.clearAllMocks()
162+
mockUpdateRow.mockResolvedValue(undefined)
163+
mockUseTimezoneState.mockReturnValue({ timezone: 'America/Los_Angeles', status: 'ready' })
164+
})
165+
166+
it('shows and saves edit values stored under the column id', async () => {
167+
const container = document.createElement('div')
168+
document.body.appendChild(container)
169+
const root = createRoot(container)
170+
const props = {
171+
mode: 'edit' as const,
172+
isOpen: true,
173+
onClose: vi.fn(),
174+
table: {
175+
id: 'table-4',
176+
name: 'People',
177+
schema: { columns: [{ id: 'col_name', name: 'Name', type: 'string' as const }] },
178+
},
179+
row: { ...row, data: { col_name: 'Ada' } },
180+
onSuccess: vi.fn(),
181+
}
182+
183+
act(() => root.render(createElement(RowModal, props)))
184+
185+
const nameInput = container.querySelector<HTMLInputElement>('[data-testid="modal-input"]')
186+
expect(nameInput?.value).toBe('Ada')
187+
act(() => changeInput(nameInput as HTMLInputElement, 'Grace'))
188+
const submit = container.querySelector<HTMLButtonElement>('[data-testid="submit"]')
189+
await act(async () => submit?.click())
190+
191+
expect(mockUpdateRow).toHaveBeenCalledWith({ rowId: 'row-1', data: { col_name: 'Grace' } })
192+
act(() => root.unmount())
193+
container.remove()
194+
})
195+
})
196+
116197
describe('RowModal expiration editing', () => {
117198
beforeEach(() => {
118199
vi.clearAllMocks()
@@ -136,7 +217,9 @@ describe('RowModal expiration editing', () => {
136217
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
137218
act(() => root.render(createElement(RowModal, props)))
138219

139-
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')?.value).toBe('01:00')
220+
expect(container.querySelector<HTMLInputElement>('[data-testid="date"]')?.value).toBe(
221+
'2026-11-01T01:00:00'
222+
)
140223
expect(container.querySelector<HTMLButtonElement>('[data-testid="submit"]')?.disabled).toBe(
141224
false
142225
)
@@ -153,9 +236,9 @@ describe('RowModal expiration editing', () => {
153236
})
154237
act(() => root.render(createElement(RowModal, props)))
155238

156-
const timeInput = container.querySelector<HTMLInputElement>('[data-testid="time"]')
157-
expect(timeInput?.value).toBe('01:00')
158-
act(() => changeInput(timeInput as HTMLInputElement, '01:30'))
239+
const dateInput = container.querySelector<HTMLInputElement>('[data-testid="date"]')
240+
expect(dateInput?.value).toBe('2026-11-01T01:00:00')
241+
act(() => changeInput(dateInput as HTMLInputElement, '2026-11-01T01:30'))
159242

160243
const submit = container.querySelector<HTMLButtonElement>('[data-testid="submit"]')
161244
await act(async () => submit?.click())
@@ -193,15 +276,15 @@ describe('RowModal expiration editing', () => {
193276
expect(container.querySelector('[aria-label="Edit starts_at"]')?.textContent).toBe(
194277
'Loading timezone…'
195278
)
196-
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')).toBeNull()
279+
expect(container.querySelector<HTMLInputElement>('[data-testid="date"]')).toBeNull()
197280

198281
mockUseTimezoneState.mockReturnValue({
199282
timezone: 'America/Los_Angeles',
200283
status: 'ready',
201284
})
202285
act(() => root.render(createElement(RowModal, props)))
203286

204-
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')).not.toBeNull()
287+
expect(container.querySelector<HTMLInputElement>('[data-testid="date"]')).not.toBeNull()
205288
act(() => root.unmount())
206289
container.remove()
207290
})
@@ -226,7 +309,9 @@ describe('RowModal expiration editing', () => {
226309

227310
act(() => root.render(createElement(RowModal, props)))
228311

229-
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')?.value).toBe('01:00')
312+
expect(container.querySelector<HTMLInputElement>('[data-testid="date"]')?.value).toBe(
313+
'2026-11-01T01:00:00'
314+
)
230315
expect(container.querySelector<HTMLButtonElement>('[data-testid="submit"]')?.disabled).toBe(
231316
false
232317
)

0 commit comments

Comments
 (0)