Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/campaign-member-shadowed-basic-group.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 31 additions & 8 deletions src/objects/campaign_member.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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,
Expand All @@ -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',
Expand Down
64 changes: 64 additions & 0 deletions test/field-groups-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AnyRec>;
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.
Expand Down