diff --git a/.changeset/24087-search-title-namefield-first.md b/.changeset/24087-search-title-namefield-first.md new file mode 100644 index 00000000000..96562644ee7 --- /dev/null +++ b/.changeset/24087-search-title-namefield-first.md @@ -0,0 +1,13 @@ +--- +'@objectstack/metadata-protocol': patch +'@objectstack/spec': patch +--- + +Clause-②: no + +fix(metadata-protocol): the global search palette titles a hit by its `nameField` before the deprecated `titleFormat` + +- **What changed.** `searchAll` (`GET /api/v1/search`) now resolves a hit's `title` in this order: the primary-title pointer (`nameField`, or the deprecated alias `displayNameField`), then the `titleFormat` template, then the conventional name fields, then first/last name, then the record id. Before, `titleFormat` came first. ADR-0079 D3 and the `titleFormat` schema description both say an explicit `nameField` takes precedence. +- **What it means for a deployment.** An object that declares both keys is now titled by its `nameField` value. For example, an approval request shows `Discount Sign-off · opp_45` instead of `flow:deal_desk · opp_45`. The registry also designates a `nameField` for any object that has a title-eligible field (a text-like field, or a formula returning text). So an object that declares only `titleFormat` is now titled by that designated field too. The template still titles a row when the object has no pointer, or when the pointer's value is empty on that row. +- **Fix for an author who wants the composite title back:** add a `formula` field with `returnType: 'text'` that builds the composite, set `nameField` to it, and drop `titleFormat`. This is the migration ADR-0079 prescribes (`os explain title-format-retired`). +- **`@objectstack/spec`:** the description of `SearchAllHitSchema.title` now states the same order. No schema shape changed. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 84428a0c50a..c4f6017550f 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2796,7 +2796,7 @@ Installed package with runtime lifecycle state | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Name of the object the hit belongs to. | | **id** | `string` | ✅ | ID of the matched record. | -| **title** | `string` | ✅ | Display title for the hit, resolved in order: the object's `titleFormat` template → the declared primary-title pointer (`nameField`, ADR-0079; deprecated alias `displayNameField` still honored) → conventional name fields → first/last name → the record ID as a string. | +| **title** | `string` | ✅ | Display title for the hit, resolved in order: the declared primary-title pointer (`nameField`, ADR-0079; deprecated alias `displayNameField` still honored) → the object's deprecated `titleFormat` template → conventional name fields → first/last name → the record ID as a string. | | **snippet** | `string` | optional | Excerpt cut around the first matched term in a searchable text column, ellipsized at both ends when truncated. ABSENT when no source column literally contains a term (e.g. a pinyin companion match) — absence is a correct answer, not a miss. | | **record** | `Record` | ✅ | The matched record as the engine's find path returns it (row-level security applied, internal fields already stripped). Object-specific — no cross-object field shape is promised beyond "a record of the named object". | @@ -2837,7 +2837,7 @@ Installed package with runtime lifecycle state | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Name of the object the hit belongs to. | | **id** | `string` | ✅ | ID of the matched record. | -| **title** | `string` | ✅ | Display title for the hit, resolved in order: the object's `titleFormat` template → the declared primary-title pointer (`nameField`, ADR-0079; deprecated alias `displayNameField` still honored) → conventional name fields → first/last name → the record ID as a string. | +| **title** | `string` | ✅ | Display title for the hit, resolved in order: the declared primary-title pointer (`nameField`, ADR-0079; deprecated alias `displayNameField` still honored) → the object's deprecated `titleFormat` template → conventional name fields → first/last name → the record ID as a string. | | **snippet** | `string` | optional | Excerpt cut around the first matched term in a searchable text column, ellipsized at both ends when truncated. ABSENT when no source column literally contains a term (e.g. a pinyin companion match) — absence is a correct answer, not a miss. | | **record** | `Record` | ✅ | The matched record as the engine's find path returns it (row-level security applied, internal fields already stripped). Object-specific — no cross-object field shape is promised beyond "a record of the named object". | diff --git a/packages/metadata-protocol/src/protocol.search-title-namefield.test.ts b/packages/metadata-protocol/src/protocol.search-title-namefield.test.ts index c67ed60a1a9..f06abc3d517 100644 --- a/packages/metadata-protocol/src/protocol.search-title-namefield.test.ts +++ b/packages/metadata-protocol/src/protocol.search-title-namefield.test.ts @@ -35,6 +35,7 @@ // titling and nothing else. import { describe, it, expect, vi } from 'vitest'; +import { provisionPrimary } from '@objectstack/spec/data'; import { ObjectStackProtocolImplementation } from './protocol.js'; type ObjectMeta = Record & { name: string }; @@ -174,24 +175,115 @@ describe('[#8786] searchAll titles a hit from the canonical nameField', () => { expect(hits).toHaveLength(1); expect(hits[0].title).toBe('TK-77'); }); +}); - it('titleFormat still outranks the pointer', async () => { - // Ordering above the changed line is untouched: `titleFormat` is - // resolved before the candidate list is built, so a canonical - // `nameField` does not overtake an explicit format. - const account: ObjectMeta = { - name: 'account', - titleFormat: '{company_name} ({region})', - nameField: 'company_name', - fields: { company_name: text('company_name'), region: text('region') }, - }; - const p = makeProtocol([account], { - account: [{ id: 'acc_3', company_name: 'Acme Industrial', region: 'EMEA' }], +describe('[#24087] searchAll titles nameField before the deprecated titleFormat', () => { + // [ADR-0079 D3] `titleFormat` is retired in favour of `nameField`, and the + // spec's own describe says "an explicit nameField now takes precedence". + // `renderTitle` used to render the template FIRST and return it whenever + // every placeholder resolved, so an object declaring both keys + // (`sys_approval_request`: `titleFormat: '{process_name} · {record_id}'` + // beside a `display_title` formula designated as `nameField`) was titled + // in the palette by its machine source. + // + // The fixtures below are passed through `provisionPrimary` — the + // designate-only stamp the SchemaRegistry runs on every object at + // registration — so each one is the body `registry.getAllObjects()` + // serves, not the body as authored. That stamp is why a titleFormat-only + // object with a title-eligible field is titled by the DERIVED pointer + // (pinned below): authorship is not recoverable after it, so the reader + // cannot tell an authored `nameField` from a stamped one. + const served = (o: ObjectMeta): ObjectMeta => provisionPrimary(o, { synthesize: false }); + + it('THE PIN — an object declaring both keys is titled by its nameField value', async () => { + const request = served({ + name: 'approval_request', + titleFormat: '{process_name} · {record_id}', + nameField: 'display_title', + fields: { + display_title: { name: 'display_title', type: 'formula', returnType: 'text' }, + process_name: text('process_name'), + record_id: text('record_id'), + }, + }); + const p = makeProtocol([request], { + approval_request: [{ + id: 'apr_1', + display_title: 'Discount Sign-off · opp_45', + process_name: 'flow:deal_desk', + record_id: 'opp_45', + }], }); - const { hits } = await p.searchAll({ q: 'acme', perObject: 5 }); + const { hits } = await p.searchAll({ q: 'discount', perObject: 5 }); + + expect(hits).toHaveLength(1); + expect(hits[0].title).toBe('Discount Sign-off · opp_45'); + // The defect's signature, named so a refactor of the value above + // cannot quietly re-admit it. + expect(hits[0].title).not.toBe('flow:deal_desk · opp_45'); + }); + + it('CONTROL — the template still titles an object the registry designates no pointer for', async () => { + // No title-eligible field, so `provisionPrimary` stamps nothing and + // the template is the best title there is: the reorder must demote + // `titleFormat`, not drop it. + const order = served({ + name: 'order', + titleFormat: 'Order {order_no}', + fields: { order_no: { name: 'order_no', type: 'number' } }, + // A number is not in the auto-default search set; declaring it + // keeps the object scannable so the sweep reaches its title. + searchableFields: ['order_no'], + }); + expect(order.nameField).toBeUndefined(); + const p = makeProtocol([order], { order: [{ id: 'ord_1', order_no: 42 }] }); + + const { hits } = await p.searchAll({ q: '42', perObject: 5 }); + + expect(hits).toHaveLength(1); + expect(hits[0].title).toBe('Order 42'); + }); + + it('CONTROL — an empty pointer value falls through to the template, not to the id', async () => { + const request = served({ + name: 'approval_request', + titleFormat: '{process_name} · {record_id}', + nameField: 'display_title', + fields: { + display_title: { name: 'display_title', type: 'formula', returnType: 'text' }, + process_name: text('process_name'), + record_id: text('record_id'), + }, + }); + const p = makeProtocol([request], { + approval_request: [{ id: 'apr_2', display_title: '', process_name: 'flow:deal_desk', record_id: 'opp_46' }], + }); + + const { hits } = await p.searchAll({ q: 'deal', perObject: 5 }); + + expect(hits).toHaveLength(1); + expect(hits[0].title).toBe('flow:deal_desk · opp_46'); + }); + + it('a titleFormat-only object with a title-eligible field is titled by the STAMPED pointer', async () => { + // The measured consequence of mirroring `resolveDisplayField`: the + // registry designates `ref_code` (first title-eligible field), and the + // served body cannot say whether an author wrote it, so it outranks + // the deprecated template exactly as an authored one does. + const ticket = served({ + name: 'ticket', + titleFormat: '[{ref_code}] {summary_text}', + fields: { ref_code: text('ref_code'), summary_text: text('summary_text') }, + }); + expect(ticket.nameField).toBe('ref_code'); + const p = makeProtocol([ticket], { + ticket: [{ id: 'tk_9', ref_code: 'TK-9', summary_text: 'Printer on fire' }], + }); + + const { hits } = await p.searchAll({ q: 'tk', perObject: 5 }); expect(hits).toHaveLength(1); - expect(hits[0].title).toBe('Acme Industrial (EMEA)'); + expect(hits[0].title).toBe('TK-9'); }); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 5975e10fca8..375717c98f5 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -15559,14 +15559,39 @@ export class ObjectStackProtocolImplementation implements : []); const fieldByName = new Map(fields.map(f => [f.name, f])); const hasField = (n: string) => fieldByName.has(n); - // Resolve title for a record using titleFormat → the declared - // primary-title pointer → common conventional fields → id. + // Resolve title for a record: the declared primary-title pointer → + // titleFormat → common conventional fields → id. [ADR-0079 D3] + // `nameField` takes precedence over the deprecated `titleFormat` + // (the spec's own describe says so), the same order as + // `resolveDisplayField`, which never consults `titleFormat` at all. + // The registry's `provisionPrimary` stamps a derived `nameField` + // at registration, and authorship is not recoverable after that + // stamp (see `ObjectTitleCompleteness.status`), so a stamped + // pointer outranks the template too: `titleFormat` titles a row + // only when the object has no pointer, or its value is empty. // titleFormat supports simple `{field}` placeholders (the // `template` dialect); unresolved placeholders fall through to the // next strategy. const titleFormatSource = (obj.titleFormat && (obj.titleFormat.source || obj.titleFormat)) || undefined; + // [ADR-0079] `nameField` is the canonical primary-title pointer; + // `displayNameField` is the deprecated alias (still honored). + // Reading the alias ALONE made this the one consumer a canonical + // designation could not reach: `provisionPrimary` — the + // designation seat the registry runs on every object at + // registration — stamps `nameField` only + // (`spec/src/data/display-name.ts`), so an object that declares its + // title canonically and does not also carry the alias fell through + // to `String(row.id)` and the palette showed a raw id. Same + // precedence as `resolveDisplayField`, the #4254 ingress gate, and + // this function's own search-field resolution below — a fourth + // spelling here would re-split what those merged. + const titlePointer = obj.nameField ?? obj.displayNameField; const renderTitle = (row: any): string => { + if (typeof titlePointer === 'string' && hasField(titlePointer)) { + const v = row[titlePointer]; + if (v != null && String(v).trim()) return String(v); + } if (typeof titleFormatSource === 'string') { let allResolved = true; const rendered = titleFormatSource.replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key) => { @@ -15578,23 +15603,8 @@ export class ObjectStackProtocolImplementation implements if (rendered) return rendered.replace(/\s+-\s+$/, '').replace(/^\s+-\s+/, '').trim() || row.id; } const candidates = [ - // [ADR-0079] `nameField` is the canonical primary-title - // pointer; `displayNameField` is the deprecated alias - // (still honored). Reading the alias ALONE made this the - // one consumer a canonical designation could not reach: - // `provisionPrimary` — the designation seat the registry - // runs on every object at registration — stamps - // `nameField` only (`spec/src/data/display-name.ts`), so - // an object that declares its title canonically and does - // not also carry the alias fell through this list to - // `String(row.id)` and the palette showed a raw id. Same - // precedence as `resolveDisplayField`, the #4254 ingress - // gate, and this function's own search-field resolution 44 - // lines below — a fourth spelling here would re-split what - // those merged. - obj.nameField ?? obj.displayNameField, 'name', 'full_name', 'title', 'subject', 'label', 'company', - ].filter((c): c is string => typeof c === 'string' && hasField(c)); + ].filter(hasField); for (const c of candidates) { const v = row[c]; if (v != null && String(v).trim()) return String(v); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 16881ee63aa..e0b7bb40fba 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2570,10 +2570,10 @@ export const SearchAllHitSchema = lazySchema(() => z.object({ object: z.string().describe('Name of the object the hit belongs to.'), id: z.string().describe('ID of the matched record.'), title: z.string().describe( - 'Display title for the hit, resolved in order: the object\'s `titleFormat` template → ' - + 'the declared primary-title pointer (`nameField`, ADR-0079; deprecated alias ' - + '`displayNameField` still honored) → conventional name fields → first/last name → ' - + 'the record ID as a string.' + 'Display title for the hit, resolved in order: the declared primary-title pointer ' + + '(`nameField`, ADR-0079; deprecated alias `displayNameField` still honored) → the ' + + 'object\'s deprecated `titleFormat` template → conventional name fields → first/last ' + + 'name → the record ID as a string.' ), snippet: z.string().optional().describe( 'Excerpt cut around the first matched term in a searchable text column, ellipsized at '