|
1 | 1 | 'use client' |
2 | 2 |
|
3 | 3 | import { useState } from 'react' |
4 | | -import { Button, ChipCombobox, ChipInput, cn, FieldDivider, Label, Switch, toast } from '@sim/emcn' |
| 4 | +import { |
| 5 | + Button, |
| 6 | + ChipCombobox, |
| 7 | + ChipInput, |
| 8 | + cn, |
| 9 | + FieldDivider, |
| 10 | + Label, |
| 11 | + Switch, |
| 12 | + Tooltip, |
| 13 | + toast, |
| 14 | +} from '@sim/emcn' |
5 | 15 | import { X } from '@sim/emcn/icons' |
6 | 16 | import { toError } from '@sim/utils/errors' |
7 | 17 | import { findValidationIssue, isValidationError } from '@/lib/api/client/errors' |
@@ -59,6 +69,15 @@ interface ColumnConfigSidebarProps { |
59 | 69 | /** Notify parent of a rename so it can rewrite local `columnOrder` / |
60 | 70 | * `columnWidths` keys that reference the old name. */ |
61 | 71 | onColumnRename?: (oldName: string, newName: string) => void |
| 72 | + /** |
| 73 | + * Opens the panel for reading only — every field is inert and Save is |
| 74 | + * disabled behind {@link readOnlyReason}. The header click that opens this |
| 75 | + * sidebar is a primary affordance, so a schema-locked (or read-only) table |
| 76 | + * shows the column's settings rather than swallowing the click. |
| 77 | + */ |
| 78 | + readOnly?: boolean |
| 79 | + /** Why saving is unavailable; surfaced on the disabled Save button. */ |
| 80 | + readOnlyReason?: string |
62 | 81 | } |
63 | 82 |
|
64 | 83 | /** |
@@ -109,6 +128,8 @@ function ColumnConfigBody({ |
109 | 128 | workspaceId, |
110 | 129 | tableId, |
111 | 130 | onColumnRename, |
| 131 | + readOnly, |
| 132 | + readOnlyReason, |
112 | 133 | }: ColumnConfigBodyProps) { |
113 | 134 | const updateColumn = useUpdateColumn({ workspaceId, tableId }) |
114 | 135 | const addColumn = useAddTableColumn({ workspaceId, tableId }) |
@@ -154,6 +175,8 @@ function ColumnConfigBody({ |
154 | 175 | } |
155 | 176 |
|
156 | 177 | async function handleSave() { |
| 178 | + // Belt and braces: the button is disabled, and the server refuses too. |
| 179 | + if (readOnly) return |
157 | 180 | if (!trimmedName) { |
158 | 181 | setShowValidation(true) |
159 | 182 | return |
@@ -254,118 +277,136 @@ function ColumnConfigBody({ |
254 | 277 | </div> |
255 | 278 |
|
256 | 279 | <div className='flex-1 overflow-y-auto overflow-x-hidden px-2 pt-3 pb-2 [overflow-anchor:none]'> |
257 | | - <div className='flex flex-col gap-[9.5px]'> |
258 | | - <RequiredLabel htmlFor='column-sidebar-name'>Column name</RequiredLabel> |
259 | | - <ChipInput |
260 | | - id='column-sidebar-name' |
261 | | - value={nameInput} |
262 | | - onChange={(e) => { |
263 | | - setNameInput(e.target.value) |
264 | | - if (nameError) setNameError(null) |
265 | | - }} |
266 | | - spellCheck={false} |
267 | | - autoComplete='off' |
268 | | - error={Boolean((showValidation && !trimmedName) || nameError)} |
269 | | - aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined} |
270 | | - /> |
271 | | - {showValidation && !trimmedName && <FieldError message='Column name is required' />} |
272 | | - {nameError && !(showValidation && !trimmedName) && <FieldError message={nameError} />} |
273 | | - </div> |
274 | | - |
275 | | - {config.mode === 'edit' && ( |
276 | | - <> |
277 | | - <FieldDivider /> |
278 | | - <div className='flex flex-col gap-[9.5px]'> |
279 | | - <RequiredLabel>Type</RequiredLabel> |
280 | | - <ChipCombobox |
281 | | - options={columnTypeOptionsForTable(allColumns, existingColumn, { |
282 | | - tableRowTtlEnabled, |
283 | | - }) |
284 | | - .filter((option) => option.type !== 'workflow') |
285 | | - .map((option) => ({ |
286 | | - label: option.label, |
287 | | - value: option.type, |
288 | | - icon: option.icon, |
289 | | - disabled: option.disabledReason !== undefined, |
290 | | - }))} |
291 | | - value={typeInput} |
292 | | - onChange={(v) => setTypeInput(v as ColumnDefinition['type'])} |
293 | | - placeholder='Select type' |
294 | | - maxHeight={300} |
295 | | - /> |
296 | | - </div> |
297 | | - </> |
298 | | - )} |
| 280 | + {/* `disabled` on the fieldset reaches every native control inside, |
| 281 | + including the comboboxes' trigger buttons; `contents` keeps the |
| 282 | + existing layout. Values stay readable and selectable. */} |
| 283 | + <fieldset disabled={readOnly} className='contents'> |
| 284 | + <div className='flex flex-col gap-[9.5px]'> |
| 285 | + <RequiredLabel htmlFor='column-sidebar-name'>Column name</RequiredLabel> |
| 286 | + <ChipInput |
| 287 | + id='column-sidebar-name' |
| 288 | + value={nameInput} |
| 289 | + onChange={(e) => { |
| 290 | + setNameInput(e.target.value) |
| 291 | + if (nameError) setNameError(null) |
| 292 | + }} |
| 293 | + spellCheck={false} |
| 294 | + autoComplete='off' |
| 295 | + error={Boolean((showValidation && !trimmedName) || nameError)} |
| 296 | + aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined} |
| 297 | + /> |
| 298 | + {showValidation && !trimmedName && <FieldError message='Column name is required' />} |
| 299 | + {nameError && !(showValidation && !trimmedName) && <FieldError message={nameError} />} |
| 300 | + </div> |
299 | 301 |
|
300 | | - {wantsCurrency && ( |
301 | | - <> |
302 | | - <FieldDivider /> |
303 | | - <div className='flex flex-col gap-[9.5px]'> |
304 | | - <RequiredLabel>Currency</RequiredLabel> |
305 | | - <ChipCombobox |
306 | | - options={CURRENCY_COMBOBOX_OPTIONS} |
307 | | - value={currencyInput} |
308 | | - onChange={setCurrencyInput} |
309 | | - placeholder='Select currency' |
310 | | - searchable |
311 | | - searchPlaceholder='Search currencies' |
312 | | - maxHeight={260} |
313 | | - /> |
314 | | - </div> |
315 | | - </> |
316 | | - )} |
| 302 | + {config.mode === 'edit' && ( |
| 303 | + <> |
| 304 | + <FieldDivider /> |
| 305 | + <div className='flex flex-col gap-[9.5px]'> |
| 306 | + <RequiredLabel>Type</RequiredLabel> |
| 307 | + <ChipCombobox |
| 308 | + options={columnTypeOptionsForTable(allColumns, existingColumn, { |
| 309 | + tableRowTtlEnabled, |
| 310 | + }) |
| 311 | + .filter((option) => option.type !== 'workflow') |
| 312 | + .map((option) => ({ |
| 313 | + label: option.label, |
| 314 | + value: option.type, |
| 315 | + icon: option.icon, |
| 316 | + disabled: option.disabledReason !== undefined, |
| 317 | + }))} |
| 318 | + value={typeInput} |
| 319 | + onChange={(v) => setTypeInput(v as ColumnDefinition['type'])} |
| 320 | + placeholder='Select type' |
| 321 | + maxHeight={300} |
| 322 | + /> |
| 323 | + </div> |
| 324 | + </> |
| 325 | + )} |
317 | 326 |
|
318 | | - {wantsOptions && ( |
319 | | - <> |
320 | | - <FieldDivider /> |
321 | | - <div className='flex flex-col gap-[9.5px]'> |
322 | | - <RequiredLabel>Options</RequiredLabel> |
323 | | - <SelectOptionsEditor |
324 | | - options={optionsInput} |
325 | | - onChange={(next) => { |
326 | | - setOptionsInput(next) |
327 | | - if (optionsError) setOptionsError(null) |
328 | | - }} |
329 | | - /> |
330 | | - {optionsError && <FieldError message={optionsError} />} |
331 | | - </div> |
332 | | - <FieldDivider /> |
333 | | - <div className='flex items-center justify-between pl-0.5'> |
334 | | - <Label htmlFor='column-sidebar-multiple'>Multiselect</Label> |
335 | | - <Switch |
336 | | - id='column-sidebar-multiple' |
337 | | - checked={multipleInput} |
338 | | - onCheckedChange={(v) => setMultipleInput(!!v)} |
339 | | - /> |
340 | | - </div> |
341 | | - </> |
342 | | - )} |
| 327 | + {wantsCurrency && ( |
| 328 | + <> |
| 329 | + <FieldDivider /> |
| 330 | + <div className='flex flex-col gap-[9.5px]'> |
| 331 | + <RequiredLabel>Currency</RequiredLabel> |
| 332 | + <ChipCombobox |
| 333 | + options={CURRENCY_COMBOBOX_OPTIONS} |
| 334 | + value={currencyInput} |
| 335 | + onChange={setCurrencyInput} |
| 336 | + placeholder='Select currency' |
| 337 | + searchable |
| 338 | + searchPlaceholder='Search currencies' |
| 339 | + maxHeight={260} |
| 340 | + /> |
| 341 | + </div> |
| 342 | + </> |
| 343 | + )} |
343 | 344 |
|
344 | | - {/* Select columns don't expose a unique constraint. */} |
345 | | - {!wantsOptions && ( |
346 | | - <> |
347 | | - <FieldDivider /> |
348 | | - <div className='flex flex-col gap-[9.5px]'> |
| 345 | + {wantsOptions && ( |
| 346 | + <> |
| 347 | + <FieldDivider /> |
| 348 | + <div className='flex flex-col gap-[9.5px]'> |
| 349 | + <RequiredLabel>Options</RequiredLabel> |
| 350 | + <SelectOptionsEditor |
| 351 | + options={optionsInput} |
| 352 | + onChange={(next) => { |
| 353 | + setOptionsInput(next) |
| 354 | + if (optionsError) setOptionsError(null) |
| 355 | + }} |
| 356 | + /> |
| 357 | + {optionsError && <FieldError message={optionsError} />} |
| 358 | + </div> |
| 359 | + <FieldDivider /> |
349 | 360 | <div className='flex items-center justify-between pl-0.5'> |
350 | | - <Label htmlFor='column-sidebar-unique'>Unique</Label> |
| 361 | + <Label htmlFor='column-sidebar-multiple'>Multiselect</Label> |
351 | 362 | <Switch |
352 | | - id='column-sidebar-unique' |
353 | | - checked={uniqueInput} |
354 | | - onCheckedChange={(v) => setUniqueInput(!!v)} |
| 363 | + id='column-sidebar-multiple' |
| 364 | + checked={multipleInput} |
| 365 | + onCheckedChange={(v) => setMultipleInput(!!v)} |
355 | 366 | /> |
356 | 367 | </div> |
357 | | - </div> |
358 | | - </> |
359 | | - )} |
| 368 | + </> |
| 369 | + )} |
| 370 | + |
| 371 | + {/* Select columns don't expose a unique constraint. */} |
| 372 | + {!wantsOptions && ( |
| 373 | + <> |
| 374 | + <FieldDivider /> |
| 375 | + <div className='flex flex-col gap-[9.5px]'> |
| 376 | + <div className='flex items-center justify-between pl-0.5'> |
| 377 | + <Label htmlFor='column-sidebar-unique'>Unique</Label> |
| 378 | + <Switch |
| 379 | + id='column-sidebar-unique' |
| 380 | + checked={uniqueInput} |
| 381 | + onCheckedChange={(v) => setUniqueInput(!!v)} |
| 382 | + /> |
| 383 | + </div> |
| 384 | + </div> |
| 385 | + </> |
| 386 | + )} |
| 387 | + </fieldset> |
360 | 388 | </div> |
361 | 389 |
|
362 | 390 | <div className='flex items-center justify-end gap-2 border-[var(--border)] border-t px-2 py-3'> |
363 | 391 | <Button variant='default' size='sm' onClick={onClose}> |
364 | | - Cancel |
365 | | - </Button> |
366 | | - <Button variant='primary' size='sm' onClick={handleSave} disabled={saveDisabled}> |
367 | | - {saveDisabled ? 'Saving…' : 'Save'} |
| 392 | + {readOnly ? 'Close' : 'Cancel'} |
368 | 393 | </Button> |
| 394 | + {readOnly ? ( |
| 395 | + <Tooltip.Root> |
| 396 | + <Tooltip.Trigger asChild> |
| 397 | + <span className='inline-flex'> |
| 398 | + <Button variant='primary' size='sm' disabled> |
| 399 | + Save |
| 400 | + </Button> |
| 401 | + </span> |
| 402 | + </Tooltip.Trigger> |
| 403 | + {readOnlyReason && <Tooltip.Content>{readOnlyReason}</Tooltip.Content>} |
| 404 | + </Tooltip.Root> |
| 405 | + ) : ( |
| 406 | + <Button variant='primary' size='sm' onClick={handleSave} disabled={saveDisabled}> |
| 407 | + {saveDisabled ? 'Saving…' : 'Save'} |
| 408 | + </Button> |
| 409 | + )} |
369 | 410 | </div> |
370 | 411 | </div> |
371 | 412 | ) |
|
0 commit comments