From 51b4d6110b1e8377b9ee64a53663ac1d3a87316a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:10:45 +0000 Subject: [PATCH 1/4] feat(lead-page): reference crm_lead's fieldGroups from the Details tab The lead record page's record:details sections become ten `{ group }` references to crm_lead's fieldGroups (ruling C, decision batch #21), so membership has one declaration site and the page curates only the order. Notes, Do Not Call / Email Opt Out, the follow-up dates and the conversion block become reachable on the Details tab. The four page-only section names leave the locale packs with the sections (`os validate` refuses a translation keyed to an undeclared section), and the two pins that read the sections resolve the group's members instead. Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT Co-authored-by: Claude --- src/sales/pages/lead_detail.page.ts | 207 +++++------------- src/sales/translations/en/objects.pipeline.ts | 7 +- .../translations/es-ES/objects.pipeline.ts | 11 +- .../translations/ja-JP/objects.pipeline.ts | 8 +- .../translations/zh-CN/objects.pipeline.ts | 8 +- test/detail-section-dedup.test.ts | 48 ++-- test/lead-duplicate-visibility.test.ts | 24 +- 7 files changed, 102 insertions(+), 211 deletions(-) diff --git a/src/sales/pages/lead_detail.page.ts b/src/sales/pages/lead_detail.page.ts index cb30c7b15..447f47a70 100644 --- a/src/sales/pages/lead_detail.page.ts +++ b/src/sales/pages/lead_detail.page.ts @@ -350,165 +350,62 @@ export const LeadDetailPage: Page = { columns: '2', // `layout` was REMOVED from `record:details` in // @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) and is - // deleted with no successor: its declared `auto` | - // `custom` semantics were never implemented. objectui's - // `RecordDetailsRenderer` tests `layout` only against - // `inline` | `compact` — two values the enum never - // permitted — so both legal values took the same branch - // and the key selected nothing. The body is chosen by what - // is authored — and on an authored `record:details` the - // `sections` below ARE the body, not a preference over - // some default one. + // deleted with no successor: its `auto` | `custom` + // semantics were never implemented. // - // ⛔ There is NO fallback. Omitting `sections` here does - // not fall back to the object's `highlightFields`, nor to - // its `fieldGroups`, nor to a bare auto-detected header - // chip. It renders an EMPTY body. + // Every section is a GROUP REFERENCE (#806, maintainer + // ruling C, decision batch #21, 2026-09-03). `{ group }` + // names one of crm_lead's `fieldGroups`, and the renderer + // derives that section's members, label, icon and collapse + // state from the object (`deriveFieldGroupLayout`, + // ADR-0085 §5). Membership has ONE declaration site — the + // `group:` on each field in lead.object.ts — and this page + // curates only the ORDER, so a field added to a group shows + // up here with no edit to this file. ⛔ Do not enumerate + // `fields:` again, and do not restate a key the group owns + // (`name` / `label` / `icon` / `collapsible` / + // `defaultCollapsed`) beside `group`: the spec refuses both. // - // ⚠️ Read that twice before editing, because both of the - // wrong answers were once written down as fact. THIS - // comment used to assert the `highlightFields` fallback; - // a `crm_forecast` reading recorded on #1452 asserted the - // `fieldGroups` one. Both read as authoritative, they - // contradicted each other, and neither had ever been run - // against the pinned version — so #806's ruling was - // written on a mechanism that does not exist, and that - // cost a full round before anybody measured it. #1521 is - // the card that replaced both guesses with the numbers - // below. ⛔ Do not restore a fallback claim here without - // re-measuring it first. + // ⛔ `sections` stays load-bearing. Omitting it renders an + // EMPTY Details tab, not a `fieldGroups` fallback (#806 R28 + // on 17.2.0, #1521); derivation is per section, through + // `group:`, never page-level. // - // How it was measured (#806's R28 os-dev report) — - // measured, not inferred: headless Chromium driving a - // real `objectstack start` against a wiped DB, - // `@objectstack/console` 17.2.0, two full runs over the - // same records. Run A, unmutated: 6 sections, 20 field - // rows. Run B, `properties.sections` deleted: 0 and 0 — - // the entire body between the tab strip and the - // "Created by" footer simply absent. Negative control in - // both runs: `crm_contact`, which authors no record page, - // kept rendering its five `fieldGroups`-derived headings - // in A and in B alike, so run B's nothing is this page's - // nothing and not a dead instrument. - // - // Why the two wrong answers looked right: `fieldGroups` - // derivation is real, but it lives in the console's page - // SYNTHESIZER — the path that fabricates a record page - // for an object that has none authored (which is exactly - // why the `crm_contact` control shows it). The synthesizer - // takes authored sections when they are non-empty and - // derives from `fieldGroups` otherwise. The - // `record:details` RENDERER, which is what draws THIS - // page, reads neither `fieldGroups` nor `highlightFields` - // at all: it forwards `sections`/`fields`, and the detail - // view guards each one with `.length > 0` and no else - // branch. An authored page opts out of the synthesizer, - // so it opts out of the derivation with it. - // - // ⚠️ Version caveat: those browser numbers are 17.2.0, - // two and more pins ago (17.4.0 came with PR #1814, then - // 17.5.0, then 17.6.0). #1521 did - // NOT re-run the - // browser measurement. What it did do is read the - // 17.3.0 console bundle (static, not run) — a pin that is - // itself now behind, and nobody has re-read the 17.4.0, - // 17.5.0 or 17.6.0 bundle — and - // the mechanism is unchanged there: both guards are still - // `sections.length > 0` and `fields.length > 0` with no - // else, the renderer still reads neither of the two - // fallback sources, and the synthesizer still hands the - // derived `highlightFields` to the details node as - // `hideFields` — highlight fields are SUBTRACTED from - // this body, never substituted into it, which is the - // opposite of what the old sentence claimed. Treat the - // 0/0 as a 17.2.0 reading corroborated statically at - // 17.3.0; re-measure in a browser before quoting it for - // any later version. (`fieldGroups` does reach a section - // one way, per the installed spec: a section may name - // `group:` to inherit one group's members and - // presentation. That is a per-section opt-in, not a - // page-level fallback, and this page does not use it.) - // - // So the sections below are load-bearing, not decorative: - // they are the Salesforce-style structured field grid the - // Details tab presents, and with them gone the tab draws - // nothing at all. ⛔ Whether this page should keep - // authoring them is #806's subject and a maintainer - // decision — not a drive-by edit from here. Field names - // map to lead.object.ts. + // Measured on @objectstack/console 17.6.0 (#806 R70, + // headless Chromium, with a control leg; on 17.4.0 R61 + // measured this form crashing the component). What a + // group section does there is the renderer's, not ours: + // - it lays out its own column count (the page-level + // `columns` above does not reach it); + // - a group whose members are all empty renders nothing + // (`address` on a lead with no address); + // - members the highlights strip registered are dropped, + // so `assignment` (only `owner_id`) renders nothing on + // this page — the owner is in the strip; + // - a group declared `collapse: 'collapsed'` starts + // collapsed; changing that is a `fieldGroups` decision + // in lead.object.ts, not a key on this page. sections: [ - { - name: 'info', - label: 'Lead Information', - fields: ['salutation', 'first_name', 'last_name', 'title', 'company', 'industry'], - }, - { - name: 'crm_contact', - label: 'Contact', - fields: ['email', 'phone', 'mobile', 'website'], - }, - { - name: 'detail', - label: 'Lead Detail', - // `disqualification_reason` is mandatory on an - // Unqualified lead (see the validation on - // crm_lead) — the detail page has to show the - // recorded reason, not just the red status chip. - fields: ['status', 'disqualification_reason', 'rating', 'lead_source', 'owner_id', 'annual_revenue', 'number_of_employees'], - }, - { - name: 'address', - label: 'Address', - fields: ['address'], - }, - // The LINK half of the duplicate banner (#1207) — - // the banner says a record is repeated, this names it - // and lets the rep open it to compare. - // - // ⚠️ NOT the highlights strip, which is where this - // card's dispatch suggested it: `record:highlights` - // caps `fields` at 7 and the strip already carries 6, - // so the three duplicate fields would not fit without - // evicting a chip every lead needs to serve a state - // most leads are not in (measured: `objectstack - // validate` reports `fields: Too big: expected array - // to have <=7 items`, and - // `test/metadata-references.test.ts` parses the same - // props strictly, so it is a hard cap, not advice). - // - // A section costs nothing on a clean lead either, for - // a better reason than the strip's: `record:details` - // hides empty fields (`hideEmpty` defaults true in the - // renderer) and a section whose fields are ALL empty - // renders nothing — no heading, no empty shell (the - // measurement is in `test/detail-section-dedup.test.ts`). - // So this block appears exactly on the leads that carry - // a duplicate claim. - // - // All four fields, not just `duplicate_of_lead`: - // `lead_duplicate_check` matches CONTACTS first and - // only then open leads, so a suspected lead's survivor - // is a `crm_contact` at least as often as a `crm_lead` - // — naming only the lead link would leave the commoner - // half of the flagged population with a banner and - // nothing to click. `duplicate_status` also covers the - // state the banner deliberately does not: a `confirmed` - // verdict, and the `erased` tombstone that outlives the - // record it named. - { - name: 'duplicates', - label: 'Duplicate Management', - fields: [ - 'duplicate_status', 'duplicate_of_type', - 'duplicate_of_lead', 'duplicate_of_contact', - ], - }, - { - name: 'description', - label: 'Description', - fields: ['description'], - columns: 1, - }, + { group: 'identity' }, + { group: 'company_info' }, + { group: 'contact_info' }, + // Do Not Call / Email Opt Out, right under the numbers + // a rep is about to dial (#806). + { group: 'preferences' }, + { group: 'qualification' }, + { group: 'assignment' }, + { group: 'address' }, + { group: 'conversion' }, + // The LINK half of the duplicate banners (#1207): the + // group carries both survivor lookups, because + // `lead_duplicate_check` matches contacts before leads. + // On a clean lead every member is empty, so the section + // renders nothing. ⚠️ Not the highlights strip: + // `record:highlights` caps `fields` at 7 and holds 6. + { group: 'duplicates' }, + // Description and Notes — kept last, where the page's + // Description section always sat. + { group: 'additional' }, ], }, }, diff --git a/src/sales/translations/en/objects.pipeline.ts b/src/sales/translations/en/objects.pipeline.ts index be10486e7..74dbe165b 100644 --- a/src/sales/translations/en/objects.pipeline.ts +++ b/src/sales/translations/en/objects.pipeline.ts @@ -161,11 +161,8 @@ export const pipeline: Record = { preferences: { label: 'Communication Preferences' }, conversion: { label: 'Conversion' }, duplicates: { label: 'Duplicate Management' }, - // Detail-page sections (src/pages/lead_detail.page.ts) - info: { label: 'Lead Information' }, - crm_contact: { label: 'Contact' }, - detail: { label: 'Lead Detail' }, - description: { label: 'Description' }, + // The detail page's sections reference the groups above (#806), so it + // has no section names of its own. // Form section names on lead.view.ts (#1100). The sections of the // default form and of every named formView live here — add a form // there, add its section names here. `address` / `qualification` above diff --git a/src/sales/translations/es-ES/objects.pipeline.ts b/src/sales/translations/es-ES/objects.pipeline.ts index ea721b5e4..399c263b3 100644 --- a/src/sales/translations/es-ES/objects.pipeline.ts +++ b/src/sales/translations/es-ES/objects.pipeline.ts @@ -168,15 +168,10 @@ export const pipeline: Record = { }, }, _sections: { - // Nombres de sección de `record:details` en la página de detalle - // (lead_detail.page.ts). - info: { label: 'Información del Prospecto' }, - crm_contact: { label: 'Contacto' }, - detail: { label: 'Detalle del Prospecto' }, - address: { label: 'Dirección' }, - description: { label: 'Descripción' }, // Claves de sección del objeto (lead.object.ts) que usan los - // formularios de registro. + // formularios de registro y la página de detalle, cuyas secciones + // hacen referencia a estos grupos (#806). + address: { label: 'Dirección' }, identity: { label: 'Identidad' }, company_info: { label: 'Información de la Empresa' }, contact_info: { label: 'Información de Contacto' }, diff --git a/src/sales/translations/ja-JP/objects.pipeline.ts b/src/sales/translations/ja-JP/objects.pipeline.ts index 2219f9a23..86879e8b7 100644 --- a/src/sales/translations/ja-JP/objects.pipeline.ts +++ b/src/sales/translations/ja-JP/objects.pipeline.ts @@ -153,13 +153,9 @@ export const pipeline: Record = { }, }, _sections: { - // 詳細ページの `record:details` セクション名(lead_detail.page.ts) - info: { label: 'リード情報' }, - crm_contact: { label: '連絡先' }, - detail: { label: 'リード詳細' }, + // オブジェクト定義のセクションキー(lead.object.ts)— 入力フォームと、 + // これらのグループを参照する詳細ページのセクション(#806)で使用 address: { label: '住所' }, - description: { label: '説明' }, - // オブジェクト定義のセクションキー(lead.object.ts)— 入力フォームで使用 identity: { label: '基本情報' }, company_info: { label: '会社情報' }, contact_info: { label: '連絡先情報' }, diff --git a/src/sales/translations/zh-CN/objects.pipeline.ts b/src/sales/translations/zh-CN/objects.pipeline.ts index 4f93e14dd..e3a68bec6 100644 --- a/src/sales/translations/zh-CN/objects.pipeline.ts +++ b/src/sales/translations/zh-CN/objects.pipeline.ts @@ -164,13 +164,9 @@ export const pipeline: Record = { }, }, _sections: { - // Detail-page `record:details` section names (lead_detail.page.ts) - info: { label: '线索信息' }, - crm_contact: { label: '联系方式' }, - detail: { label: '线索详情' }, + // Object-level section keys (lead.object.ts), used by record forms and + // by the detail page, whose sections reference these groups (#806) address: { label: '地址' }, - description: { label: '描述' }, - // Object-level section keys (lead.object.ts) used by record forms identity: { label: '身份信息' }, company_info: { label: '公司信息' }, contact_info: { label: '联系方式' }, diff --git a/test/detail-section-dedup.test.ts b/test/detail-section-dedup.test.ts index 2c78b8510..7a8eedad0 100644 --- a/test/detail-section-dedup.test.ts +++ b/test/detail-section-dedup.test.ts @@ -37,18 +37,19 @@ import { type AnyRec, objects, pages, walk } from './helpers/metadata-fixtures'; * * The check reads the resolved metadata, so it also covers a section that * inherits a duplicate through a future page refactor. + * + * ## Group-reference sections (#806 ruling C) + * + * A section written `{ group: '' }` enumerates nothing: the renderer + * derives its members from the object's `fieldGroups`, so there is no authored + * field list to promise a field the tab never shows. Dropping the strip's + * fields from a derived list is the renderer doing its job — measured on + * 17.6.0 (#806 R70), the lead page's `assignment` group (`owner_id` only) + * renders nothing because the owner is in the strip. Such a section is held + * to the one thing it does author: its `group` must name a group the page's + * object declares, with at least one visible member. */ -/** - * Pages knowingly left out, with the card that owns them. `lead_detail_page` - * has the same duplicates (`email` / `phone` in its Contact section, `status` / - * `rating` / `lead_source` / `owner_id` in its Lead Detail section) but - * `src/pages/lead_detail.page.ts` is claimed by #1209 and #1207, so #1211 does - * not touch it. The entry asserts nothing about the page — it only skips it — - * so it stays green when those cards land and remove the duplicates. - */ -const EXEMPT_PAGES = new Set(['lead_detail_page']); - /** The renderer's title-field resolution, in its order. */ const TITLE_CANDIDATES = ['name', 'full_name', 'title', 'subject', 'display_name', 'label']; @@ -66,7 +67,7 @@ type DetailPage = { page: string; object: string; highlights: string[]; - sections: { name: string; fields: string[] }[]; + sections: { name: string; group?: string; fields: string[] }[]; }; const fieldNames = (list: unknown): string[] => @@ -88,7 +89,8 @@ const detailPages: DetailPage[] = pages.flatMap((page) => { highlights: highlights.flatMap((c) => fieldNames(c.properties?.fields)), sections: details.flatMap((c) => (c.properties?.sections ?? []).map((s: AnyRec) => ({ - name: (s.name ?? s.label ?? '(unnamed)') as string, + name: (s.name ?? s.group ?? s.label ?? '(unnamed)') as string, + group: typeof s.group === 'string' ? s.group : undefined, fields: fieldNames(s.fields), })), ), @@ -105,16 +107,9 @@ describe('record:details sections list only fields the tab renders', () => { ); }); - it('every exempt page still exists', () => { - for (const name of EXEMPT_PAGES) { - expect(detailPages.map((p) => p.page)).toContain(name); - } - }); - it('no section repeats a field the page highlights', () => { const offenders: string[] = []; for (const { page, highlights, sections } of detailPages) { - if (EXEMPT_PAGES.has(page)) continue; const strip = new Set(highlights); for (const section of sections) { for (const field of section.fields) { @@ -128,7 +123,6 @@ describe('record:details sections list only fields the tab renders', () => { it('no section names the record title field the page header already shows', () => { const offenders: string[] = []; for (const { page, object, sections } of detailPages) { - if (EXEMPT_PAGES.has(page)) continue; const title = titleFieldOf(object); if (!title) continue; for (const section of sections) { @@ -140,10 +134,18 @@ describe('record:details sections list only fields the tab renders', () => { it('every section still carries at least one field', () => { // A section trimmed down to nothing should be deleted, not left as a - // heading the renderer will drop anyway. - for (const { page, sections } of detailPages) { + // heading the renderer will drop anyway. A group reference carries its + // members on the object, so it is counted there: the renderer drops a + // `group` that names no declared group, silently. + for (const { page, object, sections } of detailPages) { + const obj = objects.find((o) => o.name === object); for (const section of sections) { - expect(`${page}.${section.name}:${section.fields.length}`).not.toMatch(/:0$/); + const count = section.group + ? Object.values((obj?.fields ?? {}) as Record).filter( + (f) => f?.group === section.group && f?.hidden !== true, + ).length + : section.fields.length; + expect(`${page}.${section.name}:${count}`).not.toMatch(/:0$/); } } }); diff --git a/test/lead-duplicate-visibility.test.ts b/test/lead-duplicate-visibility.test.ts index e799db29e..71b3dc347 100644 --- a/test/lead-duplicate-visibility.test.ts +++ b/test/lead-duplicate-visibility.test.ts @@ -351,28 +351,36 @@ describe('lead record page — the duplicate banners, one per verdict', () => { // (`test/detail-section-dedup.test.ts` carries that measurement). const details = leadPageComponents.filter((c) => c.type === 'record:details'); const sections: AnyRec[] = details.flatMap((d) => (d.properties?.sections ?? []) as AnyRec[]); - const duplicates = sections.find((s) => s.name === 'duplicates'); - expect(duplicates, 'the Details tab has no `duplicates` section').toBeDefined(); + const duplicates = sections.find((s) => s.group === 'duplicates'); + expect(duplicates, 'the Details tab has no `{ group: \'duplicates\' }` section').toBeDefined(); + // The section is a group reference (#806 ruling C): it names the group and + // the renderer derives the members from crm_lead's `fieldGroups`, so the + // members asserted below are the fields whose `group` is `duplicates`. + expect(duplicates!.fields, 'the duplicates section enumerates `fields` again').toBeUndefined(); + const members = Object.entries( + (objects.find((o) => o.name === 'crm_lead')?.fields ?? {}) as Record, + ) + .filter(([, f]) => f?.group === 'duplicates' && f?.hidden !== true) + .map(([name]) => name); // BOTH survivor lookups, not just `duplicate_of_lead`: the intake hook // matches CONTACTS first and only then open leads, so a suspected lead's // survivor is a `crm_contact` at least as often as a `crm_lead`. - expect(duplicates!.fields).toEqual( + expect(members).toEqual( expect.arrayContaining([ 'duplicate_status', 'duplicate_of_type', 'duplicate_of_lead', 'duplicate_of_contact', ]), ); - for (const field of duplicates!.fields as string[]) { + for (const field of members) { expect(leadFields, `crm_lead has no field \`${field}\``).toContain(field); } // The renderer drops any field the highlights strip already registered, so - // a field listed in both places renders in neither reliably. Keep them - // disjoint (the standing rule of `test/detail-section-dedup.test.ts`, which - // exempts this page only for the duplicates it already had). + // a field in both places renders only in the strip — and a duplicates + // group emptied that way would drop the link entirely. Keep them disjoint. const highlights = leadPageComponents.find((c) => c.type === 'record:highlights'); const strip: string[] = highlights?.properties?.fields ?? []; - expect(strip.filter((f) => (duplicates!.fields as string[]).includes(f))).toEqual([]); + expect(strip.filter((f) => members.includes(f))).toEqual([]); }); }); From 6543e05568d1c226b559519525cad3b2bd862ddc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:11:53 +0000 Subject: [PATCH 2/4] feat(opportunity-page): reference crm_opportunity's fieldGroups from the Details tab The opportunity record page's three hand-written record:details sections become seven `{ group }` references to crm_opportunity's fieldGroups, the same class ruling as the lead page. Primary Contact, the win/loss fields, probability, approval status and the forecast metrics become reachable on the Details tab; the strip's fields stay out of it because the renderer drops what record:highlights registered. The two page-only section names leave the locale packs with the sections. Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT Co-authored-by: Claude --- src/sales/pages/opportunity_detail.page.ts | 65 +++++++------------ src/sales/translations/en/objects.pipeline.ts | 5 +- .../translations/es-ES/objects.pipeline.ts | 18 +++-- .../translations/ja-JP/objects.pipeline.ts | 6 +- .../translations/zh-CN/objects.pipeline.ts | 6 +- 5 files changed, 37 insertions(+), 63 deletions(-) diff --git a/src/sales/pages/opportunity_detail.page.ts b/src/sales/pages/opportunity_detail.page.ts index 515ebb2fc..f95211a63 100644 --- a/src/sales/pages/opportunity_detail.page.ts +++ b/src/sales/pages/opportunity_detail.page.ts @@ -128,51 +128,32 @@ export const OpportunityDetailPage: Page = { label: 'Opportunity Details', properties: { // `columns` is a STRING enum ('1'|'2'|'3'|'4') in - // @objectstack/spec 17; the number form was rejected by the - // props schema and only survived because `properties` is an - // open bag. `layout` is gone entirely (removed in spec - // 17.0.0, #6946 / ADR-0087 D2) — the body is chosen by what - // you author, so the key selected nothing. + // @objectstack/spec 17. `layout` is gone (removed in spec + // 17.0.0, #6946 / ADR-0087 D2). columns: '2', - // A section lists only the fields it is ACTUALLY responsible - // for — never one the highlights strip above already shows, - // and never the record's title field (#1211). + // Every section references one of crm_opportunity's + // `fieldGroups` (#1452, the #806 class ruling C): the + // renderer derives members, label, icon and collapse state + // from the object, so this page curates only the order. + // The full note — what a group section does on 17.6.0 and + // what may not sit beside `group` — is on + // `lead_detail.page.ts`. // - // Measured in the shipped console (17.1.0, - // plugins-views bundle → objectui `RecordDetailsRenderer`): - // a mounted `record:highlights` registers its field names in - // HighlightFieldsContext, and `record:details` drops every - // registered name from its sections; it then drops the first - // non-empty title candidate (primaryField → name → full_name - // → title → subject → …) because the page H1 already shows - // it. `DetailSection` renders NOTHING at all when every field - // it is left with is empty, so a section built only from - // duplicates disappears silently — which is how this tab came - // to author fourteen fields and render two. - // - // So `name` / `crm_account` / `owner_id` (header + strip) and - // `amount` / `close_date` / `probability` / `expected_revenue` - // (strip) are NOT repeated here. Object-level - // `highlightFields` is a different list and is not consulted - // by this component — `stage` sits in it and still renders. + // The highlights strip still wins (#1211): the renderer + // drops every field `record:highlights` registered, and the + // record title, from the derived members. So `basic` shows + // `primary_contact` only, and `financials` (`amount`, + // `expected_revenue`, both in the strip) renders nothing + // here — it stays referenced so that a field added to the + // group reaches this tab without an edit to this file. sections: [ - { - name: 'info', - label: 'Opportunity Information', - fields: ['type', 'lead_source', 'crm_campaign'], - }, - { - name: 'crm_forecast', - label: 'Stage & Forecast', - fields: ['stage', 'forecast_category'], - }, - { - name: 'description', - label: 'Description', - columns: 1, - collapsible: true, - fields: ['description', 'next_step'], - }, + { group: 'basic' }, + { group: 'financials' }, + { group: 'classification' }, + { group: 'campaign' }, + { group: 'sales_process' }, + { group: 'crm_forecast' }, + { group: 'notes' }, ], }, }, diff --git a/src/sales/translations/en/objects.pipeline.ts b/src/sales/translations/en/objects.pipeline.ts index 74dbe165b..fc433c565 100644 --- a/src/sales/translations/en/objects.pipeline.ts +++ b/src/sales/translations/en/objects.pipeline.ts @@ -314,9 +314,8 @@ export const pipeline: Record = { campaign: { label: 'Campaigns' }, notes: { label: 'Notes & Next Steps' }, crm_forecast: { label: 'Forecast & Metrics' }, - // Detail-page sections (src/pages/opportunity_detail.page.ts) - info: { label: 'Opportunity Information' }, - description: { label: 'Description' }, + // The detail page's sections reference the groups above (#1452), so it + // has no section names of its own. // Form section names on opportunity.view.ts (#1100) overview: { label: 'Overview' }, forecast: { label: 'Forecast' }, diff --git a/src/sales/translations/es-ES/objects.pipeline.ts b/src/sales/translations/es-ES/objects.pipeline.ts index 399c263b3..e7c990071 100644 --- a/src/sales/translations/es-ES/objects.pipeline.ts +++ b/src/sales/translations/es-ES/objects.pipeline.ts @@ -342,17 +342,15 @@ export const pipeline: Record = { stale_opportunities: { label: '⚠️ Oportunidades Estancadas · Más Tiempo en Etapa Primero' }, }, _sections: { - // Nombres de sección de `record:details` en la página de detalle - // (opportunity_detail.page.ts). - info: { label: 'Información de la Oportunidad' }, - // La clave `crm_forecast` la comparten la sección de la página de - // detalle («Stage & Forecast») y el grupo de campos del objeto - // («Forecast & Metrics»). Ambas cubren etapa, probabilidad y - // categoría de pronóstico, así que se traduce una sola vez. - crm_forecast: { label: 'Etapa y Previsión' }, - description: { label: 'Descripción' }, // Claves de sección del objeto (opportunity.object.ts) que usan los - // formularios de registro. + // formularios de registro y la página de detalle, cuyas secciones + // hacen referencia a estos grupos (#1452). + // + // `crm_forecast` es la clave del grupo de campos («Forecast & Metrics» + // en inglés). Esta traducción es anterior a #1452, cuando una sección + // de la página de detalle con el mismo nombre se titulaba «Stage & + // Forecast»; desde entonces solo la lee el grupo. + crm_forecast: { label: 'Etapa y Previsión' }, basic: { label: 'Información Básica' }, financials: { label: 'Datos Financieros' }, sales_process: { label: 'Proceso de Venta' }, diff --git a/src/sales/translations/ja-JP/objects.pipeline.ts b/src/sales/translations/ja-JP/objects.pipeline.ts index 86879e8b7..0638d4f47 100644 --- a/src/sales/translations/ja-JP/objects.pipeline.ts +++ b/src/sales/translations/ja-JP/objects.pipeline.ts @@ -306,11 +306,9 @@ export const pipeline: Record = { }, }, _sections: { - // 詳細ページの `record:details` セクション名(opportunity_detail.page.ts) - info: { label: '商談情報' }, + // オブジェクト定義のセクションキー(opportunity.object.ts)— 入力フォームと、 + // これらのグループを参照する詳細ページのセクション(#1452)で使用 crm_forecast: { label: 'ステージ・売上予測' }, - description: { label: '説明' }, - // オブジェクト定義のセクションキー(opportunity.object.ts)— 入力フォームで使用 basic: { label: '基本情報' }, financials: { label: '財務情報' }, sales_process: { label: '営業プロセス' }, diff --git a/src/sales/translations/zh-CN/objects.pipeline.ts b/src/sales/translations/zh-CN/objects.pipeline.ts index e3a68bec6..2f7e75781 100644 --- a/src/sales/translations/zh-CN/objects.pipeline.ts +++ b/src/sales/translations/zh-CN/objects.pipeline.ts @@ -324,11 +324,9 @@ export const pipeline: Record = { }, }, _sections: { - // Detail-page `record:details` section names (opportunity_detail.page.ts) - info: { label: '商机信息' }, + // Object-level section keys (opportunity.object.ts), used by record forms + // and by the detail page, whose sections reference these groups (#1452) crm_forecast: { label: '阶段与预测' }, - description: { label: '描述' }, - // Object-level section keys (opportunity.object.ts) used by record forms basic: { label: '基本信息' }, financials: { label: '财务信息' }, sales_process: { label: '销售流程' }, From ca778782080e541ede07418f14b46a0a408ea70d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:14:17 +0000 Subject: [PATCH 3/4] feat(case-page): reference crm_case's fieldGroups from the Details tab The case record page's three hand-written record:details sections become six `{ group }` references to crm_case's fieldGroups, the same class ruling as the lead and opportunity pages. escalated_date, written by three flows and shown nowhere, arrives with the Escalation group; first_response_date and resolved_by_article arrive with theirs. The three page-only section names leave the locale packs with the sections. The create-form pin resolves a group section to its members, and first_response_date's roster entry records the surface it gained. Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT Co-authored-by: Claude --- src/sales/translations/en/objects.service.ts | 6 +- .../translations/es-ES/objects.service.ts | 9 +- .../translations/ja-JP/objects.service.ts | 7 +- .../translations/zh-CN/objects.service.ts | 7 +- src/service/pages/case_detail.page.ts | 127 +++++------------- test/case-create-form-narrowing.test.ts | 18 ++- test/field-groups-coverage.test.ts | 14 +- 7 files changed, 66 insertions(+), 122 deletions(-) diff --git a/src/sales/translations/en/objects.service.ts b/src/sales/translations/en/objects.service.ts index db0acab3c..876494b24 100644 --- a/src/sales/translations/en/objects.service.ts +++ b/src/sales/translations/en/objects.service.ts @@ -91,10 +91,8 @@ export const service: Record = { resolution: { label: 'Resolution' }, escalation: { label: 'Escalation' }, system: { label: 'System' }, - // Detail-page sections (src/pages/case_detail.page.ts) - info: { label: 'Case Information' }, - status: { label: 'Status & SLA' }, - description: { label: 'Description' }, + // The detail page's sections reference the groups above (#970), so it + // has no section names of its own. // Form section names on case.view.ts (#1100). case: { label: 'Case' }, how_can_we_help: { label: 'How can we help?' }, diff --git a/src/sales/translations/es-ES/objects.service.ts b/src/sales/translations/es-ES/objects.service.ts index 1fb068a87..05f8ffaf9 100644 --- a/src/sales/translations/es-ES/objects.service.ts +++ b/src/sales/translations/es-ES/objects.service.ts @@ -99,17 +99,12 @@ export const service: Record = { sla_at_risk: { label: '⏰ SLA en Riesgo' }, }, _sections: { - // Nombres de sección de `record:details` en la página de detalle - // (case_detail.page.ts). - info: { label: 'Información del Caso' }, - status: { label: 'Estado y SLA' }, - description: { label: 'Descripción' }, // Nombres de sección del formulario en case.view.ts (#1100). case: { label: 'Caso' }, how_can_we_help: { label: '¿Cómo podemos ayudarte?' }, // Claves de sección del objeto (case.object.ts) que usan los - // formularios de registro. `basic` repite el inglés de `info` - // («Case Information»), así que comparte traducción. + // formularios de registro y la página de detalle, cuyas secciones + // hacen referencia a estos grupos (#970). basic: { label: 'Información del Caso' }, origin: { label: 'Origen y Asignación' }, sla: { label: 'SLA y Prioridad' }, diff --git a/src/sales/translations/ja-JP/objects.service.ts b/src/sales/translations/ja-JP/objects.service.ts index 488718550..bf4a31702 100644 --- a/src/sales/translations/ja-JP/objects.service.ts +++ b/src/sales/translations/ja-JP/objects.service.ts @@ -97,14 +97,11 @@ export const service: Record = { sla_at_risk: { label: '⏰ SLA リスクあり' }, }, _sections: { - // 詳細ページの `record:details` セクション名(case_detail.page.ts) - info: { label: 'ケース情報' }, - status: { label: 'ステータス・SLA' }, - description: { label: '説明' }, // case.view.ts のフォームセクション名 (#1100)。 case: { label: 'ケース' }, how_can_we_help: { label: 'どのようなご用件でしょうか?' }, - // オブジェクト定義のセクションキー(case.object.ts)— 入力フォームで使用 + // オブジェクト定義のセクションキー(case.object.ts)— 入力フォームと、 + // これらのグループを参照する詳細ページのセクション(#970)で使用 basic: { label: 'ケース情報' }, origin: { label: '発生元・振り分け' }, sla: { label: 'SLA・優先度' }, diff --git a/src/sales/translations/zh-CN/objects.service.ts b/src/sales/translations/zh-CN/objects.service.ts index 2775c00c4..a3f45e223 100644 --- a/src/sales/translations/zh-CN/objects.service.ts +++ b/src/sales/translations/zh-CN/objects.service.ts @@ -122,14 +122,11 @@ export const service: Record = { }, }, _sections: { - // Detail-page `record:details` section names (case_detail.page.ts) - info: { label: '工单信息' }, - status: { label: '状态与 SLA' }, - description: { label: '描述' }, // case.view.ts 表单区块名称 (#1100)。 case: { label: '工单' }, how_can_we_help: { label: '我们能帮您什么?' }, - // Object-level section keys (case.object.ts) used by record forms + // Object-level section keys (case.object.ts), used by record forms and + // by the detail page, whose sections reference these groups (#970) basic: { label: '工单信息' }, origin: { label: '来源与路由' }, sla: { label: 'SLA 与优先级' }, diff --git a/src/service/pages/case_detail.page.ts b/src/service/pages/case_detail.page.ts index acc26278c..68165b08d 100644 --- a/src/service/pages/case_detail.page.ts +++ b/src/service/pages/case_detail.page.ts @@ -133,98 +133,45 @@ export const CaseDetailPage: Page = { properties: { // `columns` is a STRING enum ('1'|'2'|'3'|'4') in // @objectstack/spec 17, and `layout` was removed there - // (#6946 / ADR-0087 D2) — see the same note on - // opportunity_detail.page.ts. + // (#6946 / ADR-0087 D2). columns: '2', - // Same rule as the opportunity page (#1211): a section lists - // only what it is responsible for. `record:details` drops - // every field the mounted `record:highlights` registered - // (`status`, `priority`, `sla_due_date`, `is_sla_violated`, - // `owner_id`, `crm_account`) plus the title candidate - // `subject` (the page H1 is `{case_number} · {subject}`), and - // a section left holding only empty fields renders nothing. - // Listing those names here therefore promised fields the tab - // never showed. + // Every section references one of crm_case's `fieldGroups` + // (#970, the #806 class ruling C): the renderer derives + // members, label, icon and collapse state from the object, + // so this page curates only the order. The full note — + // what a group section does on 17.6.0 and what may not sit + // beside `group` — is on `lead_detail.page.ts`. + // + // The highlights strip still wins (#1211): the renderer + // drops what `record:highlights` registered (`status`, + // `priority`, `sla_due_date`, `is_sla_violated`, + // `owner_id`, `crm_account`) and the title candidate + // `subject` from the derived members. + // + // `escalated_date` — written by three flows and shown + // nowhere until now (#970) — arrives with the `escalation` + // group, beside `is_escalated` and `escalation_reason`. + // + // `internal_notes` (#1428) is in the `system` group with + // `is_closed`. A group whose members are ALL empty renders + // nothing, but a boolean always holds a value, so `system` + // renders on every case and an unwritten `internal_notes` + // stays reachable the way every empty field here is: the + // section's "Show N empty fields" toggle, then inline edit. + // ⛔ Not the create form — `/new` and edit both resolve + // `view.form`, and `case.hook.ts` nulls the column for + // anonymous web-to-case submissions. + // + // `resolution` / `resolved_by_article` render once the case + // carries one; `close_case` is the flow that collects the + // resolution. sections: [ - { - name: 'info', - label: 'Case Information', - fields: [ - 'case_number', - 'crm_contact', - 'type', - 'origin', - ], - }, - { - name: 'status', - label: 'Status & SLA', - fields: [ - 'is_escalated', - 'escalation_reason', - 'resolution_time_hours', - ], - }, - { - // `internal_notes` lives HERE, in the prose section - // that always renders, and NOT in an "Internal - // Notes" section of its own (#1428). A section is not - // a container an author can rely on: measured in the - // shipped console (`@objectstack/console` 17.2.0, - // `dist/assets/plugins-views-*.js` -> `DetailSection`), - // a section whose fields are ALL empty returns - // `null` — no heading, no shell, no toggle. A section - // holding only `internal_notes` would therefore - // render nothing until the field is non-empty, and - // the only way to make it non-empty is to author it - // in that section. That circle is the whole reason - // this field had no surface to begin with, and a - // dedicated section would have re-created it while - // looking like a fix. - // - // This section escapes it because `description` is - // REQUIRED on the object, so it is never empty and - // the section always renders. An unwritten - // `internal_notes` is then reachable the same way - // every other empty field on this page is: the - // section's own "Show N empty fields" toggle - // (rendered whenever `hideEmpty` — default true — - // hid at least one field), and inline edit - // (`inlineEdit` defaults true where the profile - // grants update) authors it in place. - // - // ⛔ Inline edit is the surface on purpose; the - // header's Edit button is not an alternative. Both - // `/new` and edit resolve `view.form`, so the record - // form IS the create form (`src/views/case.view.ts`), - // and #1427 narrowed it to what a creator legitimately - // authors at intake. Putting `internal_notes` back on - // that form would hand it to the intake path, where - // `case.hook.ts` nulls the column for anonymous - // web-to-case submissions anyway. - // - // ⛔ `customer_rating` / `customer_feedback` are not - // here because they no longer exist: #1428 retired - // both under ADR-0049 enforce-or-remove (maintainer - // ruling, decision batch #21, 2026-09-03), together - // with the `case_csat_followup` flow whose only job - // was to prompt for them. Do not "complete the set" - // here: a satisfaction score the customer did not - // give is a different fact from one they did, and - // neither field was named in any profile, so an input - // would have been open to every profile that can edit - // a case. - // - // Field-level security is untouched by any of this — - // `crm_case.internal_notes` stays editable for - // `service_agent`, read-only for `sales_manager` and - // unreadable for `sales_rep` (`src/profiles/`). - name: 'description', - label: 'Description', - columns: 1, - collapsible: true, - fields: ['description', 'resolution', 'internal_notes'], - }, + { group: 'basic' }, + { group: 'origin' }, + { group: 'sla' }, + { group: 'escalation' }, + { group: 'resolution' }, + { group: 'system' }, ], }, }, diff --git a/test/case-create-form-narrowing.test.ts b/test/case-create-form-narrowing.test.ts index cf8fc2192..dde2f4570 100644 --- a/test/case-create-form-narrowing.test.ts +++ b/test/case-create-form-narrowing.test.ts @@ -77,8 +77,10 @@ const CREATOR_AUTHORABLE = new Set([ * * An empty `keeps` is a claim in its own right — the field is reachable from * NO surface in the roster below — and since #1428 it is asserted in that - * direction too, not just documented. One field carries it today: - * `first_response_date`, which no human ever authors, so it is not a debt. + * direction too, not just documented. No field carries it today: + * `first_response_date` did until #970 rewrote the record page's sections as + * `fieldGroups` references, which put the `sla` group — and with it this + * field — on the Details tab (maintainer ruling C: group membership decides). * * The other thing that used to produce an empty `keeps` was a field whose * surface was an OPEN PRODUCT QUESTION — `customer_rating` and @@ -94,8 +96,8 @@ const LIFECYCLE_MAINTAINED: Record = { keeps: ['case_timeline.startDateField'], }, first_response_date: { - why: '`event.hook.ts` is its single writer — no human surface by design', - keeps: [], + why: '`event.hook.ts` is its single writer; shown on the record page through the `sla` group (#970)', + keeps: ['detail.details'], }, sla_due_date: { why: '`case.hook.ts` stamps it from the priority x account-tier matrix', @@ -166,6 +168,14 @@ const surfaces = (): Record> => { if (name) (next === 'highlights' ? highlightFields : detailFields).add(name); } } + // A `{ group }` section (#970, ruling C) enumerates nothing: the renderer + // derives its members from crm_case's `fieldGroups` — every visible field + // whose `group` is that key — so those members are this surface's fields. + if (next === 'details' && typeof node.group === 'string') { + for (const [name, f] of Object.entries(objectFields)) { + if (f?.group === node.group && f?.hidden !== true) detailFields.add(name); + } + } for (const value of Object.values(node)) walk(value, next); }; walk(CaseDetailPage, null); diff --git a/test/field-groups-coverage.test.ts b/test/field-groups-coverage.test.ts index a184d36da..71dca2dd5 100644 --- a/test/field-groups-coverage.test.ts +++ b/test/field-groups-coverage.test.ts @@ -17,10 +17,9 @@ import stack from '../objectstack.config'; * * ⛔ Two sentences stood here before and both were wrong. "`fieldGroups` is * what turns a detail page into the sectioned layout" holds only on the - * synthesized path — `crm_lead` is the standing counter-example, with - * `src/pages/lead_detail.page.ts` authoring six sections while - * `src/objects/lead.object.ts` declares ten groups the detail renderer never - * consults. And "an object with no groups renders, it just renders badly" + * synthesized path — `crm_lead` was the standing counter-example, its + * record page authoring six enumerated sections while the object declared ten + * groups the detail renderer never consulted (#806). And "an object with no groups renders, it just renders badly" * understated the failure in the one direction that mattered: an authored * `record:details` that omits `sections` renders 0 sections and 0 field rows * — an empty body, not an ugly one. ⭐ Understating a failure until it reads @@ -34,8 +33,7 @@ import stack from '../objectstack.config'; * statically on the installed 17.3.0 bundle. ⛔ Neither #1521 nor this card * re-ran the browser, so treat it as a 17.2.0 reading corroborated at 17.3.0 * rather than a standing fact, and re-measure before quoting it for a later - * pin. `src/pages/lead_detail.page.ts` carries the long form and - * `src/views/case.view.ts` states the same split for forms. ⛔ Do not write a + * pin. `src/views/case.view.ts` states the same split for forms. ⛔ Do not write a * fourth account of this mechanism without measuring it first. * * ⚠️ ⛔ Do not over-read the correction. `fieldGroups` DOES reach an authored @@ -43,7 +41,9 @@ import stack from '../objectstack.config'; * `fields:` and inherit that group's members and presentation * (`deriveFieldGroupLayout`, ADR-0085 §5 — verified on the installed 17.3.0 * spec, which makes the two keys mutually exclusive). That is a per-section - * opt-in, not a page-level fallback. + * opt-in, not a page-level fallback — and since #806 it is the form all three + * authored record pages (lead, opportunity, case) use, rendered on 17.6.0 + * (#806 R70, browser, with a control leg). * * ⇒ So these assertions still stand: `fieldGroups` is load-bearing for every * FORM and every SYNTHESIZED detail page, where a field pointing at a group From 42a658192edcd3971cfaae9dadb2f64b2ea5919a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:21:39 +0000 Subject: [PATCH 4/4] chore(changeset): record the Details-tab field-group change per page Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT Co-authored-by: Claude --- .changeset/806-detail-sections-group-ref.md | 41 +++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .changeset/806-detail-sections-group-ref.md diff --git a/.changeset/806-detail-sections-group-ref.md b/.changeset/806-detail-sections-group-ref.md new file mode 100644 index 000000000..aca4fbf46 --- /dev/null +++ b/.changeset/806-detail-sections-group-ref.md @@ -0,0 +1,41 @@ +--- +'hotcrm': patch +--- + +The Details tab of the lead, opportunity and case record pages now shows the object's own field groups + +Each of the three record pages used to list its own hand-picked sections, and +those sections had drifted from the field groups the object declares. Fields +that existed on the record were missing from the page. Each section now points +at one of the object's field groups, so the Details tab shows the same groups, +with the same headings and icons, as the rest of the app. A field added to a +group appears on the record page too. + +**Leads.** The Details tab now shows Identity, Company Information, Contact +Information, Communication Preferences, Qualification, Assignment, Address, +Conversion, Duplicate Management and Additional Info. These fields could not be +seen on the Details tab before and now can: **Notes**, **Do Not Call**, **Email +Opt Out**, **Next Follow-up Date**, **Last Contacted**, **Need Type**, +**Estimated Amount**, **Conversion Approval** and the conversion result +(**Converted**, the converted account, contact and opportunity, and the +conversion date). **Description** is now inside **Additional Info**, together +with Notes. Address, Additional Info, Communication Preferences, Conversion and +Duplicate Management start collapsed: click the heading to open them. + +**Opportunities.** The Details tab now shows Basic Information, Financials, +Classification, Campaigns, Sales Process, Forecast & Metrics and Notes & Next +Steps. Newly visible: **Primary Contact**, **Stage Entry Date**, **Approval +Status**, **Approved Date**, **Win Reason**, **Loss Reason**, **Loss/Win +Details**, **Days in Current Stage** and **Private**. Campaigns and Forecast & +Metrics start collapsed. The Campaigns group appears once the opportunity has a +campaign; set it from **Edit**. + +**Cases.** The Details tab now shows Case Information, Origin & Routing, SLA & +Priority, Escalation, Resolution and System. **Escalated Date** is shown for +the first time, under Escalation. **First Response Date** and **Resolved by +Article** are also new. Internal Notes moved to the System group. Escalation +and System start collapsed. SLA & Priority and Resolution appear once the case +has a value in them, for example when **Close Case** records the resolution. + +Fields already shown in the strip at the top of the page (status, owner, +amount, priority and similar) still appear only there.