diff --git a/.changeset/campaign-member-shadowed-basic-group.md b/.changeset/campaign-member-shadowed-basic-group.md new file mode 100644 index 000000000..2f81e1c6d --- /dev/null +++ b/.changeset/campaign-member-shadowed-basic-group.md @@ -0,0 +1,19 @@ +--- +'hotcrm': patch +--- + +Campaign Member detail pages now show the **Basic Information** section again. + +Every field in that group was already on display in the page header — the member +number is the record title, and Campaign / Lead / Contact are the first three +entries of the highlight strip — so the group had nothing left to render below +the header and disappeared from detail pages while still appearing on forms. +The **Added Date** enrollment stamp moves from *Response Tracking* into *Basic +Information*, where it reads better anyway: it records when the membership was +created, not how the person responded. *Response Tracking* keeps the response +lifecycle (First Opened, First Clicked, Response Date, Has Responded). + +Nothing changes in the campaign's Members list or in the member header — the +highlight strip is deliberately untouched, because those same fields are what +give the members panel on a campaign its Lead / Contact / Status / Response Date +columns. diff --git a/src/objects/campaign_member.object.ts b/src/objects/campaign_member.object.ts index d057d0411..1ee0ac438 100644 --- a/src/objects/campaign_member.object.ts +++ b/src/objects/campaign_member.object.ts @@ -59,8 +59,27 @@ export const CampaignMember = ObjectSchema.create({ // auto-derivation whitelist). nameField: 'member_number', + // Load-bearing on TWO surfaces, and the second one is why this list is not + // the side that gave way to fix `field-group-shadowed` below (#715). Besides the + // detail highlight strip, it is what curates the members panel on a + // campaign's detail page: a related list takes its columns from the child's + // `highlightFields` minus the lookup it is scoped by, and this object ships + // no view at all, so dropping `crm_lead`/`crm_contact` here would leave that + // panel showing Status / Response Date and no person — measured and pinned in + // `test/view-references.test.ts` (#944). highlightFields: ['crm_campaign', 'crm_lead', 'crm_contact', 'status', 'response_date'], + // Both groups render on detail pages as well as on forms, and keeping them + // that way is a constraint on the two lists above, not a free choice (#715). + // A detail page hoists the record title plus the first four highlightFields + // out of the body, so a group whose every field is hoisted keeps its heading + // on forms and silently disappears from detail pages (`field-group-shadowed`). + // `basic` was exactly that: member_number is the title, and campaign/lead/ + // contact are the first three of the strip. `added_date` — the enrollment + // stamp, which describes the MEMBERSHIP rather than the person's response — + // sits here rather than under `response` so the group has something of its + // own to show. Same resolution as `crm_task`'s shadowed `assignment` group + // (#582): move the field to the group it reads under, leave the strip alone. fieldGroups: [ { key: 'basic', label: 'Basic Information', icon: 'info' }, { key: 'response', label: 'Response Tracking', icon: 'activity' }, @@ -110,6 +129,18 @@ export const CampaignMember = ObjectSchema.create({ description: 'Set when the member is an existing Contact', }), + // NOT `readonly`: the campaign_enrollment flow writes this stamp, and + // 16.x drops writes to readonly fields (#2948) — every member landed with + // a null Added Date while the flag was on. + // + // Grouped under `basic`, not `response` (#715): it records when the + // membership was created, which is a fact about the enrollment, not a step + // in the response lifecycle the other stamps track. + added_date: Field.datetime({ + label: 'Added Date', + group: 'basic', + }), + status: Field.select({ label: 'Status', required: true, @@ -127,14 +158,6 @@ export const CampaignMember = ObjectSchema.create({ ], }), - // NOT `readonly`: the campaign_enrollment flow writes this stamp, and - // 16.x drops writes to readonly fields (#2948) — every member landed with - // a null Added Date while the flag was on. - added_date: Field.datetime({ - label: 'Added Date', - group: 'response', - }), - first_opened_date: Field.datetime({ label: 'First Opened', group: 'response', diff --git a/test/field-groups-coverage.test.ts b/test/field-groups-coverage.test.ts index d1c8262b9..1ce615669 100644 --- a/test/field-groups-coverage.test.ts +++ b/test/field-groups-coverage.test.ts @@ -95,6 +95,70 @@ describe('fieldGroups are internally consistent', () => { expect(bad, `fields pointing at undeclared groups:\n ${bad.join('\n ')}`).toEqual([]); }); + /** + * The third way a group can be declared and still never reach a reader, and + * the only one of the three that is invisible on forms (#715). + * + * A synthesized detail page hoists the record title and the leading + * `highlightFields` into the header strip and drops them from the body, so a + * group whose every field is hoisted keeps its heading on forms and renders + * nowhere on detail pages. `crm_task` reached that state with a one-field + * `assignment` group (#582); `crm_campaign_member` reached it with all four + * fields of `basic` (member_number as the title, campaign/lead/contact in the + * strip). + * + * `objectstack lint` reports this as `field-group-shadowed`, but lint exits 0 + * on warnings, so nothing in `pnpm verify` fails when it comes back — the same + * reason the two checks above are duplicated here. The hoisting model mirrors + * the platform rule: title + the first FOUR non-title highlight entries. + */ + it('no declared group is entirely hoisted into the detail highlight strip', () => { + const TITLE_FALLBACKS = ['name', 'full_name', 'title', 'subject', 'display_name']; + const bad: string[] = []; + let checked = 0; + + for (const obj of objects) { + const fields = (obj.fields ?? {}) as Record; + const declared = ((obj.fieldGroups ?? []) as AnyRec[]).map((g) => g.key).filter(Boolean); + const highlights: string[] = ( + Array.isArray(obj.highlightFields) + ? obj.highlightFields + : Array.isArray(obj.compactLayout) + ? obj.compactLayout + : [] + ).filter((h: unknown): h is string => typeof h === 'string' && h.length > 0); + if (declared.length === 0 || highlights.length === 0) continue; + + const titleField = + [obj.nameField, obj.primaryField, obj.displayNameField].find( + (v) => typeof v === 'string' && v.length > 0 && v in fields, + ) ?? TITLE_FALLBACKS.find((c) => c in fields); + + const hiddenFromBody = new Set(highlights.filter((h) => h !== titleField).slice(0, 4)); + if (titleField) hiddenFromBody.add(titleField as string); + + for (const key of declared) { + const members = Object.entries(fields) + .filter(([, f]) => f?.group === key && f?.hidden !== true) + .map(([fieldName]) => fieldName); + if (members.length === 0) continue; + checked++; + if (members.every((m) => hiddenFromBody.has(m))) { + bad.push( + `${obj.name}: group "${key}" (${members.join(', ')}) is entirely title-or-strip — ` + + 'it renders on forms and never on detail pages', + ); + } + } + } + + // Guard the guard: an object walk that stopped resolving groups or + // highlightFields would report nothing and pass. + expect(checked, 'no grouped fields on any object with highlightFields — the walker is broken') + .toBeGreaterThan(20); + expect(bad, `groups shadowed by the highlight strip:\n ${bad.join('\n ')}`).toEqual([]); + }); + it('no declared group is left with no fields in it', () => { // An empty group renders as an empty section header — visible, and always // the residue of a rename or a deleted field.