|
3 | 3 | import * as React from 'react'; |
4 | 4 | import { describe, it, expect, vi, afterEach } from 'vitest'; |
5 | 5 | import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react'; |
| 6 | +import userEvent from '@testing-library/user-event'; |
6 | 7 | import { FieldSchema } from '@objectstack/spec/data'; |
7 | 8 |
|
8 | 9 | // The inspector loads the object list (published + drafts) for the lookup |
@@ -32,6 +33,7 @@ vi.mock('../previews/useObjectFields', () => ({ |
32 | 33 |
|
33 | 34 | import { ObjectFieldInspector } from './ObjectFieldInspector'; |
34 | 35 | import { __setCelFormulaLoader } from '../celAuthoring'; |
| 36 | +import { t } from '../i18n'; |
35 | 37 |
|
36 | 38 | afterEach(() => { |
37 | 39 | cleanup(); |
@@ -667,3 +669,126 @@ describe('ObjectFieldInspector — summary roll-up editor', () => { |
667 | 669 | expect(screen.queryByText('Filter 1')).not.toBeInTheDocument(); |
668 | 670 | }); |
669 | 671 | }); |
| 672 | + |
| 673 | +describe('ObjectFieldInspector — delete behavior (objectstack#29807)', () => { |
| 674 | + // Radix Select reads pointer capture and scrollIntoView, which jsdom lacks. |
| 675 | + const proto = Element.prototype as unknown as Record<string, unknown>; |
| 676 | + for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture']) { |
| 677 | + if (!proto[m]) proto[m] = m === 'hasPointerCapture' ? () => false : () => {}; |
| 678 | + } |
| 679 | + if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}; |
| 680 | + |
| 681 | + type Def = Record<string, unknown>; |
| 682 | + const LABEL = t('designer.field.deleteBehavior', 'en-US'); |
| 683 | + const NONE = t('engine.form.none', 'en-US'); |
| 684 | + const SET_NULL = t('designer.field.deleteBehavior.set_null', 'en-US'); |
| 685 | + const CASCADE = t('designer.field.deleteBehavior.cascade', 'en-US'); |
| 686 | + const RESTRICT = t('designer.field.deleteBehavior.restrict', 'en-US'); |
| 687 | + const LOOKUP: Def = { type: 'lookup', label: 'Parent', reference: 'account' }; |
| 688 | + const USER: Def = { type: 'user', label: 'Assignee' }; |
| 689 | + const MASTER: Def = { type: 'master_detail', label: 'Master', reference: 'account' }; |
| 690 | + const has = (o: object, key: string) => Object.prototype.hasOwnProperty.call(o, key); |
| 691 | + |
| 692 | + /** A host that feeds each patch back into the draft, as Studio's editor does. */ |
| 693 | + function renderStateful(def: Def) { |
| 694 | + const onPatch = vi.fn(); |
| 695 | + function Host() { |
| 696 | + const [draft, setDraft] = React.useState<Record<string, unknown>>({ |
| 697 | + name: 'ticket', |
| 698 | + fields: { title: { type: 'text', label: 'Title' }, ref: def }, |
| 699 | + }); |
| 700 | + return ( |
| 701 | + <ObjectFieldInspector |
| 702 | + type="object" |
| 703 | + name="ticket" |
| 704 | + draft={draft} |
| 705 | + selection={{ kind: 'field', id: 'ref' }} |
| 706 | + onPatch={(patch: Record<string, unknown>) => { |
| 707 | + onPatch(patch); |
| 708 | + setDraft((d) => ({ ...d, ...patch })); |
| 709 | + }} |
| 710 | + onClearSelection={vi.fn()} |
| 711 | + onSelectionChange={vi.fn()} |
| 712 | + readOnly={false} |
| 713 | + locale={'en-US'} |
| 714 | + /> |
| 715 | + ); |
| 716 | + } |
| 717 | + render(<Host />); |
| 718 | + /** The `ref` definition the editor's Save would persist after the last edit. */ |
| 719 | + const saved = () => (onPatch.mock.calls.at(-1)![0].fields as Record<string, Def>).ref; |
| 720 | + return { onPatch, saved }; |
| 721 | + } |
| 722 | + |
| 723 | + const control = () => screen.queryByRole('combobox', { name: LABEL }); |
| 724 | + |
| 725 | + async function optionNames(): Promise<string[]> { |
| 726 | + await userEvent.click(control()!); |
| 727 | + const list = await screen.findByRole('listbox'); |
| 728 | + return within(list).getAllByRole('option').map((o) => o.textContent ?? ''); |
| 729 | + } |
| 730 | + |
| 731 | + async function pick(name: string) { |
| 732 | + await userEvent.click(control()!); |
| 733 | + const list = await screen.findByRole('listbox'); |
| 734 | + await userEvent.click(within(list).getByRole('option', { name })); |
| 735 | + } |
| 736 | + |
| 737 | + async function retypeTo(typeLabel: string) { |
| 738 | + await userEvent.click(screen.getByRole('combobox', { name: t('designer.field.type', 'en-US') })); |
| 739 | + const list = await screen.findByRole('listbox', { name: t('designer.field.type', 'en-US') }); |
| 740 | + await userEvent.click(within(list).getByRole('option', { name: typeLabel })); |
| 741 | + } |
| 742 | + |
| 743 | + it.each([ |
| 744 | + ['lookup', LOOKUP, [NONE, SET_NULL, CASCADE, RESTRICT]], |
| 745 | + ['user', USER, [NONE, SET_NULL, CASCADE, RESTRICT]], |
| 746 | + // The spec refuses an authored `set_null` on a master_detail. |
| 747 | + ['master_detail', MASTER, [NONE, CASCADE, RESTRICT]], |
| 748 | + ] as const)('draws the row on a %s field with the values the spec accepts there', async (_type, def, expected) => { |
| 749 | + renderStateful(def); |
| 750 | + expect(screen.getByTestId('field-delete-behavior')).toBeTruthy(); |
| 751 | + expect(await optionNames()).toEqual(expected); |
| 752 | + }); |
| 753 | + |
| 754 | + it.each([ |
| 755 | + ['text', { type: 'text', label: 'Title' }], |
| 756 | + ['tree', { type: 'tree', label: 'Parent' }], |
| 757 | + ] as const)('CONTROL: draws no row on a %s field', (_type, def) => { |
| 758 | + renderStateful(def); |
| 759 | + expect(screen.queryByTestId('field-delete-behavior')).toBeNull(); |
| 760 | + expect(control()).toBeNull(); |
| 761 | + }); |
| 762 | + |
| 763 | + it('picking a value writes it to the draft', async () => { |
| 764 | + const { saved } = renderStateful(USER); |
| 765 | + await pick(CASCADE); |
| 766 | + expect(saved().deleteBehavior).toBe('cascade'); |
| 767 | + }); |
| 768 | + |
| 769 | + it('picking None removes the key outright (absent, never empty or null)', async () => { |
| 770 | + const { saved } = renderStateful({ ...LOOKUP, deleteBehavior: 'restrict' }); |
| 771 | + await pick(NONE); |
| 772 | + expect(has(saved(), 'deleteBehavior')).toBe(false); |
| 773 | + expect(saved().reference).toBe('account'); |
| 774 | + }); |
| 775 | + |
| 776 | + it('a retype to text drops the key', async () => { |
| 777 | + const { saved } = renderStateful({ ...LOOKUP, deleteBehavior: 'cascade' }); |
| 778 | + await retypeTo(t('engine.fieldType.text', 'en-US')); |
| 779 | + expect(saved().type).toBe('text'); |
| 780 | + expect(has(saved(), 'deleteBehavior')).toBe(false); |
| 781 | + }); |
| 782 | + |
| 783 | + it('a retype to master_detail drops set_null and keeps cascade', async () => { |
| 784 | + const first = renderStateful({ ...LOOKUP, deleteBehavior: 'set_null' }); |
| 785 | + await retypeTo(t('engine.fieldType.master_detail', 'en-US')); |
| 786 | + expect(first.saved().type).toBe('master_detail'); |
| 787 | + expect(has(first.saved(), 'deleteBehavior')).toBe(false); |
| 788 | + cleanup(); |
| 789 | + |
| 790 | + const second = renderStateful({ ...LOOKUP, deleteBehavior: 'cascade' }); |
| 791 | + await retypeTo(t('engine.fieldType.master_detail', 'en-US')); |
| 792 | + expect(second.saved().deleteBehavior).toBe('cascade'); |
| 793 | + }); |
| 794 | +}); |
0 commit comments