diff --git a/.changeset/23561-duplicate-object-name-token.md b/.changeset/23561-duplicate-object-name-token.md new file mode 100644 index 00000000000..b8bf41cadfa --- /dev/null +++ b/.changeset/23561-duplicate-object-name-token.md @@ -0,0 +1,5 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +`duplicatePackage` no longer renames, in free text, a name that is both a prefixed package object and a field key of one of the package's objects. Such a name (for example a field keyed `acme_deal` on the object `acme_deal`) may name the field, so it is now rewritten only at object-reference properties (`object`, `reference`, `objectName`, a permission set's `objects` keys, …), the way a bare object name already is. A copied list view's `columns` and a copied form's `sections[].fields` keep naming the field the copy has, instead of a field named after the renamed object: the list view is no longer refused at the copy with `list-view-field-unknown`, and the form no longer copies a field the copy does not have. Free text naming such an object (a description) is left as stored; a prefixed object no field key spells is still renamed in free text as before. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 6abd847e4df..68e7721f8c5 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -27888,7 +27888,32 @@ export class ObjectStackProtocolImplementation implements // and `name` disagreed. `x_iojn_task` is another identifier, never the // object: `renameName` swaps the source prefix only at the START of a // name, and this rewrite now matches only a whole identifier too. - const olds = prefixed.filter((name) => renameMap.has(name)).sort((a, b) => b.length - a.length); + // + // ⭐ A PREFIXED NAME THAT IS ALSO A FIELD KEY IS NOT DISTINCTIVE. When a + // package object declares a field keyed like a prefixed package object + // (`fields.iojn_customer` beside the object `iojn_customer`), a free-text + // value spelled that way may name the field — a list view's `columns`, + // a form's `sections[].fields` — and the field keeps its key. Renaming + // it as the object copied a column or form field the copy does not + // have. Such a name is treated as a bare name is: rewritten only at an + // object-reference property (below), never in free text. + const fieldKeys = new Set(); + for (const row of rows) { + if (row?.type !== 'object') continue; + let body: any; + try { + body = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; + } catch { + continue; // the copy loop reports an unparseable row at its own name + } + const fields = body?.fields; + if (fields && typeof fields === 'object' && !Array.isArray(fields)) { + for (const key of Object.keys(fields)) fieldKeys.add(key); + } + } + const olds = prefixed + .filter((name) => renameMap.has(name) && !fieldKeys.has(name)) + .sort((a, b) => b.length - a.length); const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = olds.length ? new RegExp(`(? ({ type: 'grid', data: { provider: 'object', object }, columns }); @@ -149,6 +158,14 @@ const customerBody = { x_dupsrc_customer: { name: 'x_dupsrc_customer', type: 'text', label: 'Extra' }, }, }; +/** A prefixed object no field key spells: free text naming it follows the copy. */ +const noteBody = { + name: 'dupsrc_note', label: 'Note', description: NOTE_PROSE, sharingModel: 'private', + fields: { + x_dupsrc_note: { name: 'x_dupsrc_note', type: 'text', label: 'Extra' }, + customer: { name: 'customer', type: 'lookup', reference: 'dupsrc_customer', label: 'Customer' }, + }, +}; /** The object whose name lacks the `dupsrc_` prefix, with a lookup to the prefixed one. */ const ticketBody = { name: 'ticket', label: 'Ticket', sharingModel: 'private', @@ -224,12 +241,13 @@ describe('ADR-0070 D4 — duplicatePackage keeps a copied name in step with what expect(engine.registry.getPackage(P)?.manifest?.namespace).toBe('dupsrc'); await save(P, 'object', customerBody); await save(P, 'object', ticketBody); + await save(P, 'object', noteBody); await save(P, 'view', viewItem('ticket', 'board', 'list', grid('ticket', ['title']))); - await save(P, 'view', viewItem('dupsrc_customer', 'board', 'list', grid('dupsrc_customer', ['full_name', 'x_dupsrc_customer']))); + await save(P, 'view', viewItem('dupsrc_customer', 'board', 'list', grid('dupsrc_customer', ['full_name', 'dupsrc_customer', 'x_dupsrc_customer']))); await save(P, 'view', viewItem('dupext_account', 'board', 'list', grid('dupext_account', ['name']))); await save(P, 'view', viewItem('dupsrc_ext', 'board', 'list', grid('dupsrc_ext', ['name']))); await save(P, 'view', viewItem('ticket', 'entry', 'form', form('ticket', ['title']))); - await save(P, 'view', viewItem('dupsrc_customer', 'entry', 'form', form('dupsrc_customer', ['full_name']))); + await save(P, 'view', viewItem('dupsrc_customer', 'entry', 'form', form('dupsrc_customer', ['full_name', 'dupsrc_customer']))); await save(P, 'action', formAction('dupsrc_new_ticket', 'ticket', 'ticket.entry')); await save(P, 'action', formAction('dupsrc_new_customer', 'dupsrc_customer', 'dupsrc_customer.entry')); await save(P, 'action', formAction('dupsrc_new_account', 'dupext_account', 'dupext_account.entry')); @@ -265,14 +283,48 @@ describe('ADR-0070 D4 — duplicatePackage keeps a copied name in step with what it('free text renames an object\'s name only as a whole identifier, never inside a longer one', async () => { await duplicate(); - expect(storedBody('object', 'dupdst_customer', Q).description) - .toBe('Linked from dupdst_customer; see x_dupsrc_customer.'); + expect(storedBody('object', 'dupdst_note', Q).description) + .toBe('Attached to dupdst_note; see x_dupsrc_note.'); + expect(Object.keys(storedBody('object', 'dupdst_note', Q).fields)).toEqual(['x_dupsrc_note', 'customer']); + expect(storedBody('object', 'dupdst_note', Q).fields.x_dupsrc_note.name).toBe('x_dupsrc_note'); // A copied view's column naming the field stays in step with the field. - expect(storedBody('view', 'dupdst_customer.board', Q).config.columns).toEqual(['full_name', 'x_dupsrc_customer']); + expect(storedBody('view', 'dupdst_customer.board', Q).config.columns) + .toEqual(['full_name', 'dupsrc_customer', 'x_dupsrc_customer']); // The source is untouched. expect(storedBody('object', 'dupsrc_customer', P)).toMatchObject({ description: PROSE, fields: customerBody.fields }); }); + it('a name that is both a prefixed object and a field key is renamed only at object-reference properties', async () => { + const res = await duplicate(); + + // The copied list view is no longer refused for a column the copy lacks. + expect(res.failed).toEqual([]); + expect(res.copied).toEqual(expect.arrayContaining([ + { type: 'view', name: 'dupdst_customer.board' }, + { type: 'view', name: 'dupdst_customer.entry' }, + ])); + // A value naming the FIELD keeps naming it: list-view columns and form fields. + const board = storedBody('view', 'dupdst_customer.board', Q); + expect(board.config.columns).toEqual(['full_name', 'dupsrc_customer', 'x_dupsrc_customer']); + const entry = storedBody('view', 'dupdst_customer.entry', Q); + expect(entry.config.sections[0].fields).toEqual(['full_name', 'dupsrc_customer']); + expect(Object.keys(storedBody('object', 'dupdst_customer', Q).fields)).toContain('dupsrc_customer'); + // A value naming the OBJECT at a reference property follows the copy. + expect(board).toMatchObject({ object: 'dupdst_customer', config: { data: { object: 'dupdst_customer' } } }); + expect(entry).toMatchObject({ object: 'dupdst_customer', config: { data: { object: 'dupdst_customer' } } }); + expect(storedBody('object', 'dupdst_ticket', Q).fields.customer.reference).toBe('dupdst_customer'); + expect(storedBody('object', 'dupdst_note', Q).fields.customer.reference).toBe('dupdst_customer'); + expect(storedBody('action', 'dupdst_new_customer', Q).objectName).toBe('dupdst_customer'); + expect(Object.keys(storedBody('permission', 'dupdst_ops', Q).objects)).toEqual(['dupdst_customer']); + // Free text naming it is left as stored (the ruled semantics: such a name + // may name the field); free text naming an ordinary prefixed object still + // follows the copy (the negative control, `dupsrc_note`, in the case above). + expect(storedBody('object', 'dupdst_customer', Q).description).toBe(PROSE); + // The copied record can be written through the copied form's fields. + await engine.insert('dupdst_customer', { id: 'c2', full_name: 'Ada', dupsrc_customer: 'C-2' }); + expect(stores.get('dupdst_customer')?.get('c2')).toMatchObject({ full_name: 'Ada', dupsrc_customer: 'C-2' }); + }); + it('the copy of a bare object\'s view item takes the renamed object\'s prefix, and is the one a by-name read serves', async () => { const res = await duplicate();