|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectstack#27835 — the publish panel listed what a change touches as raw |
| 5 | + * keys: "+ label + name + sharingModel" for a new object, "+ config + isDefault |
| 6 | + * + viewKind" for a new view, "+ fieldGroups + validations" for an object edit, |
| 7 | + * with no word for which rule moved. |
| 8 | + * |
| 9 | + * Now a property reads as the label its item type's form gives it (the spec's |
| 10 | + * `METADATA_FORM_REGISTRY`, the form the inspector renders), its key beside it |
| 11 | + * only where the label does not already spell it; a property the form has no |
| 12 | + * row for keeps its key. A view is compared one level down, inside `config`, |
| 13 | + * where its inspector edits it. A list of named entries (`validations` by |
| 14 | + * `name`, `fieldGroups` by `key`) names each entry it adds, changes or removes, |
| 15 | + * by its label, as a flow names its nodes. |
| 16 | + * |
| 17 | + * Controls: a list of plain strings stays one key row; a flow's lists are still |
| 18 | + * only its nodes; a property no form row names reads as its key. |
| 19 | + */ |
| 20 | + |
| 21 | +import '@testing-library/jest-dom/vitest'; |
| 22 | +import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 23 | +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; |
| 24 | + |
| 25 | +vi.mock('@object-ui/i18n', async (importOriginal) => ({ |
| 26 | + ...(await importOriginal<Record<string, unknown>>()), |
| 27 | + useObjectTranslation: () => ({ |
| 28 | + t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k, |
| 29 | + language: 'en', |
| 30 | + }), |
| 31 | +})); |
| 32 | + |
| 33 | +import { DraftChangesPanel, computeChangeDetail } from '../DraftChangesPanel'; |
| 34 | +import { formKeyLabel } from '../changeKeyLabels'; |
| 35 | + |
| 36 | +const HOURS_RULE = { |
| 37 | + name: 'hours_over_40', |
| 38 | + label: 'Hours over 40', |
| 39 | + type: 'script', |
| 40 | + condition: 'hours > 40', |
| 41 | + message: 'A week holds at most 40 hours', |
| 42 | +}; |
| 43 | +const DISCOUNT_RULE = { name: 'max_discount', type: 'script', condition: 'discount > 0.4', message: 'Too much' }; |
| 44 | +const OLD_RULE = { name: 'legacy_check', label: 'Legacy check', type: 'script', condition: 'false', message: 'x' }; |
| 45 | + |
| 46 | +const PUBLISHED_OBJECT = { |
| 47 | + name: 'work_order', |
| 48 | + label: 'Work Order', |
| 49 | + fields: { hours: { type: 'number', label: 'Hours' } }, |
| 50 | + validations: [DISCOUNT_RULE, OLD_RULE], |
| 51 | + fieldGroups: [{ key: 'basics', label: 'Basics' }], |
| 52 | + highlightFields: ['hours'], |
| 53 | +}; |
| 54 | +const DRAFT_OBJECT = { |
| 55 | + ...PUBLISHED_OBJECT, |
| 56 | + validations: [{ ...DISCOUNT_RULE, condition: 'discount > 0.5' }, HOURS_RULE], |
| 57 | + fieldGroups: [{ key: 'basics', label: 'Basics', collapse: 'collapsed' }], |
| 58 | + highlightFields: ['hours', 'name'], |
| 59 | +}; |
| 60 | + |
| 61 | +const NEW_VIEW = { |
| 62 | + name: 'work_order_list', |
| 63 | + object: 'work_order', |
| 64 | + viewKind: 'list', |
| 65 | + label: 'Work Order list', |
| 66 | + isDefault: true, |
| 67 | + config: { type: 'grid', columns: ['name', 'hours'], data: { provider: 'object', object: 'work_order' } }, |
| 68 | +}; |
| 69 | + |
| 70 | +afterEach(() => { |
| 71 | + cleanup(); |
| 72 | + vi.unstubAllGlobals(); |
| 73 | +}); |
| 74 | + |
| 75 | +describe('a property reads as its label on the item type form (objectstack#27835)', () => { |
| 76 | + it('names an object property, a field property and a view body property by the form row', () => { |
| 77 | + expect(formKeyLabel('object', 'sharingModel')).toBe('Sharing Model'); |
| 78 | + expect(formKeyLabel('object', 'validations')).toBe('Validations'); |
| 79 | + expect(formKeyLabel('field', 'required')).toBe('Required'); |
| 80 | + expect(formKeyLabel('view', 'config.columns')).toBe('Columns'); |
| 81 | + expect(formKeyLabel('flow', 'edges')).toBe('Edges'); |
| 82 | + }); |
| 83 | + |
| 84 | + it('a declared sub-row label wins over the prettified key', () => { |
| 85 | + // `fieldGroups` declares its sub-rows; `relatedList` is labelled "Related List". |
| 86 | + expect(formKeyLabel('object', 'fieldGroups.relatedList')).toBe('Related List'); |
| 87 | + expect(formKeyLabel('object', 'fieldGroups.collapse')).toBe('Collapse'); |
| 88 | + }); |
| 89 | + |
| 90 | + it('CONTROL: a property no form row names has no label', () => { |
| 91 | + expect(formKeyLabel('view', 'viewKind')).toBeUndefined(); |
| 92 | + expect(formKeyLabel('object', 'no_such_key')).toBeUndefined(); |
| 93 | + expect(formKeyLabel('no_such_type', 'label')).toBeUndefined(); |
| 94 | + }); |
| 95 | +}); |
| 96 | + |
| 97 | +describe('computeChangeDetail names the entries of a named list (objectstack#27835)', () => { |
| 98 | + it('lists the rule added, the rule changed with its property, and the rule removed', () => { |
| 99 | + const d = computeChangeDetail(PUBLISHED_OBJECT, DRAFT_OBJECT, 'object'); |
| 100 | + expect(d.keys.changed).toEqual(['fieldGroups', 'highlightFields', 'validations']); |
| 101 | + expect(d.entries.get('validations')).toEqual({ |
| 102 | + added: [{ id: 'hours_over_40', label: 'Hours over 40' }], |
| 103 | + changed: [{ id: 'max_discount', keys: ['condition'] }], |
| 104 | + removed: [{ id: 'legacy_check', label: 'Legacy check' }], |
| 105 | + }); |
| 106 | + expect(d.entries.get('fieldGroups')).toEqual({ |
| 107 | + added: [], |
| 108 | + changed: [{ id: 'basics', label: 'Basics', keys: ['collapse'] }], |
| 109 | + removed: [], |
| 110 | + }); |
| 111 | + }); |
| 112 | + |
| 113 | + it('CONTROL: a list of plain strings stays one key row with no entries', () => { |
| 114 | + const d = computeChangeDetail(PUBLISHED_OBJECT, DRAFT_OBJECT, 'object'); |
| 115 | + expect(d.entries.has('highlightFields')).toBe(false); |
| 116 | + }); |
| 117 | + |
| 118 | + it('a NEW object adds every rule', () => { |
| 119 | + const d = computeChangeDetail(null, DRAFT_OBJECT, 'object'); |
| 120 | + expect(d.entries.get('validations')?.added.map((e) => e.id)).toEqual(['hours_over_40', 'max_discount']); |
| 121 | + }); |
| 122 | + |
| 123 | + it('CONTROL: a flow names only its nodes, never another list', () => { |
| 124 | + const before = { name: 'f', nodes: [], edges: [{ id: 'e1', name: 'e1' }] }; |
| 125 | + const after = { name: 'f', nodes: [], edges: [{ id: 'e1', name: 'e1' }, { id: 'e2', name: 'e2' }] }; |
| 126 | + expect(computeChangeDetail(before, after, 'flow').entries.size).toBe(0); |
| 127 | + }); |
| 128 | +}); |
| 129 | + |
| 130 | +describe('computeChangeDetail compares a view inside its body (objectstack#27835)', () => { |
| 131 | + it('a NEW view lists its body properties, not one `config` key', () => { |
| 132 | + const d = computeChangeDetail(null, NEW_VIEW, 'view'); |
| 133 | + expect(d.keys.added).toEqual([ |
| 134 | + 'config.columns', |
| 135 | + 'config.data', |
| 136 | + 'config.type', |
| 137 | + 'isDefault', |
| 138 | + 'label', |
| 139 | + 'name', |
| 140 | + 'object', |
| 141 | + 'viewKind', |
| 142 | + ]); |
| 143 | + }); |
| 144 | + |
| 145 | + it('a view edit names the body property that changed', () => { |
| 146 | + const draft = { ...NEW_VIEW, config: { ...NEW_VIEW.config, columns: ['name'] } }; |
| 147 | + expect(computeChangeDetail(NEW_VIEW, draft, 'view').keys).toEqual({ |
| 148 | + added: [], |
| 149 | + changed: ['config.columns'], |
| 150 | + removed: [], |
| 151 | + }); |
| 152 | + }); |
| 153 | + |
| 154 | + it('CONTROL: an item that is not a view compares `config` whole', () => { |
| 155 | + const draft = { ...NEW_VIEW, config: { ...NEW_VIEW.config, columns: ['name'] } }; |
| 156 | + expect(computeChangeDetail(NEW_VIEW, draft, 'page').keys.changed).toEqual(['config']); |
| 157 | + }); |
| 158 | +}); |
| 159 | + |
| 160 | +/* ─────────────── the panel ─────────────── */ |
| 161 | + |
| 162 | +function stubServer(type: string, name: string, label: string, published: unknown, draft: unknown) { |
| 163 | + const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); |
| 164 | + vi.stubGlobal( |
| 165 | + 'fetch', |
| 166 | + vi.fn(async (input: RequestInfo | URL) => { |
| 167 | + const url = String(input); |
| 168 | + if (url.includes('/_drafts')) return ok({ drafts: [{ type, name, packageId: 'com.x', label }] }); |
| 169 | + if (new RegExp(`/meta/${type}s?(\\?|$)`).test(url)) return ok(published ? [{ name }] : []); |
| 170 | + if (new RegExp(`/meta/${type}s?/${name}\\b`).test(url)) { |
| 171 | + if (url.includes('state=draft')) return ok({ item: draft }); |
| 172 | + return published ? ok(published) : { ok: false, status: 404, json: async () => ({}) }; |
| 173 | + } |
| 174 | + return { ok: false, status: 404, json: async () => ({}) }; |
| 175 | + }) as unknown as typeof fetch, |
| 176 | + ); |
| 177 | +} |
| 178 | + |
| 179 | +async function openDetail(label: string): Promise<HTMLElement> { |
| 180 | + render(<DraftChangesPanel open onOpenChange={() => {}} packageId="com.x" />); |
| 181 | + const toggle = (await screen.findByText(label)).closest('[data-testid="draft-entry-toggle"]') as HTMLElement; |
| 182 | + fireEvent.click(toggle); |
| 183 | + return screen.findByTestId('draft-entry-detail'); |
| 184 | +} |
| 185 | + |
| 186 | +describe('the publish drill-in names each change in words (objectstack#27835)', () => { |
| 187 | + it('names each validation rule under its list, by its label', async () => { |
| 188 | + stubServer('object', 'work_order', 'Work Order', PUBLISHED_OBJECT, DRAFT_OBJECT); |
| 189 | + const detail = await openDetail('Work Order'); |
| 190 | + |
| 191 | + const keyRows = await within(detail).findAllByTestId('draft-key-row'); |
| 192 | + const validations = keyRows.find((r) => r.textContent?.startsWith('~ Validations'))!; |
| 193 | + expect(validations).toBeDefined(); |
| 194 | + const entries = within(validations).getAllByTestId('draft-list-entry-row'); |
| 195 | + expect(entries.map((r) => r.textContent)).toEqual([ |
| 196 | + '+ Hours over 40 hours_over_40', |
| 197 | + // `validations` is a JSON row with no declared sub-rows: the property keeps its key. |
| 198 | + '~ max_discount · condition', |
| 199 | + '− Legacy check legacy_check', |
| 200 | + ]); |
| 201 | + expect(within(entries[0]!).getByText('hours_over_40')).toHaveClass('font-mono'); |
| 202 | + // A field group's changed property reads by its declared sub-row label. |
| 203 | + const groups = keyRows.find((r) => r.textContent?.startsWith('~ Field Groups'))!; |
| 204 | + expect(within(groups).getByTestId('draft-list-entry-row')).toHaveTextContent('~ Basics basics · Collapse'); |
| 205 | + expect(detail.textContent).not.toContain('~ validations'); |
| 206 | + }); |
| 207 | + |
| 208 | + it('heads a NEW view with its body properties by their labels, keeping a key no form row names', async () => { |
| 209 | + stubServer('view', 'work_order_list', 'Work Order list', null, NEW_VIEW); |
| 210 | + const detail = await openDetail('Work Order list'); |
| 211 | + |
| 212 | + const rows = (await within(detail).findAllByTestId('draft-key-row')).map((r) => r.textContent); |
| 213 | + expect(rows).toContain('+ Columns'); |
| 214 | + expect(rows).toContain('+ Label'); |
| 215 | + expect(rows).toContain('+ viewKind'); |
| 216 | + expect(rows).not.toContain('+ config'); |
| 217 | + }); |
| 218 | +}); |
0 commit comments