From bbd6008c684ceceefdea116e6c49b7742de08e9b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:11:49 +0000 Subject: [PATCH 1/4] fix: gate Service Overview on case read, offer Resolution when closing, bind the marketing set to its leaders, drop the toolbar Update Stage (#2338 items 1-4) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAk32RuJpqjMZ6Lqc7k --- src/sales/actions/opportunity.actions.ts | 18 +++- src/sales/apps/crm.app.ts | 11 ++ src/sales/sharing/positions.ts | 18 +++- src/service/index.ts | 30 ++++-- src/service/views/case.view.ts | 26 ++++- test/app-navigation-audience.test.ts | 42 ++++++++ test/case-create-form-narrowing.test.ts | 78 +++++++++++++- test/demo-staffing.test.ts | 5 +- ...marketing-leadership-campaign-edit.test.ts | 102 ++++++++++++++++++ 9 files changed, 309 insertions(+), 21 deletions(-) create mode 100644 test/marketing-leadership-campaign-edit.test.ts diff --git a/src/sales/actions/opportunity.actions.ts b/src/sales/actions/opportunity.actions.ts index cb9b386ea..5c6cd1ad2 100644 --- a/src/sales/actions/opportunity.actions.ts +++ b/src/sales/actions/opportunity.actions.ts @@ -90,8 +90,10 @@ export const CloneOpportunityAction: Action = { * * Both invocation halves work: * - * - SINGLE record (row selection / toolbar with one row): the console POSTs - * `{ recordId, params: { stage } }` and the body reads `ctx.recordId`. + * - SINGLE record (a REST or MCP caller naming one `recordId`; the list + * header copy that also reached this half is gone, see `locations`): the + * call carries `{ recordId, params: { stage } }` and the body reads + * `ctx.recordId`. * - MULTI record: the selection arrives in the BUILT-IN `_selectedIds` * param — leading underscore — injected by the grid renderer for the * view's `bulkActionDefs` entry with `execution: 'aggregate'`, which @@ -199,7 +201,17 @@ export const MassUpdateStageAction: Action = { capabilities: ['api.write'], timeoutMs: 10000, }, - locations: ['list_toolbar'], + // Headless (#2338 item 4): drawn only on the SELECTION BAR, which the + // opportunity list opens by naming this action in its `bulkActionDefs` + // (`src/sales/views/opportunity.view.ts`) and which places it by name, with + // no location here. Every list view of the object inherits that def. + // ⛔ Do not add `list_toolbar` back. Measured on 18.2.0, the console's server + // dispatch from the list header injects no `_selectedIds`: with two rows + // ticked it refused ("This action runs on a single record — select exactly + // one row"), and with none it opened the New Stage dialog over no record and + // the body threw. The header copy failed on every selection, while the bar's + // aggregate run moved them all. + locations: [], params: [ { name: 'stage', diff --git a/src/sales/apps/crm.app.ts b/src/sales/apps/crm.app.ts index d1d014716..ee63cea5c 100644 --- a/src/sales/apps/crm.app.ts +++ b/src/sales/apps/crm.app.ts @@ -44,6 +44,17 @@ const HOTCRM_ICON = * read. Since objectui#12426 (in the 18.0.0-next.2 console) the gate moves the * landing too: the console opens an app on the first entry the member is * SHOWN, not the first one served. + * + * Two dashboards stay open to the marketing user, measured on 18.2.0 (#2338 + * item 1). Sales Activity carries no gate: every member reads their own events + * and tasks through `crm_user`, so its tiles answer, and the zeros a marketer + * sees are their own activity, not a refusal. Sales Performance refuses that + * member 1 of its 15 tiles, Quota Attainment by Rep (`forecast_metrics`; the + * set grants no `crm_forecast`), and is kept open like Customer Churn Signals + * below rather than hidden for one tile. No tile can be gated on read here: a + * widget's `requiresObject` asks whether the object is installed (the console + * filters on the served object list, which `/meta/object` serves whole to the + * marketing user), and a widget takes no `visible`. */ /** diff --git a/src/sales/sharing/positions.ts b/src/sales/sharing/positions.ts index 61073fe37..6bfc84f4c 100644 --- a/src/sales/sharing/positions.ts +++ b/src/sales/sharing/positions.ts @@ -20,8 +20,9 @@ * A position names the permission sets it distributes in its own * `permissionSets` (ADR-0131 D3/D4): every holder holds every set listed, and * Setup shows the binding on the position's page and the holders on each set's - * Assigned Users panel. Five positions distribute a set. Until #2200 they - * declared none and reached their sets only through the platform's undeclared + * Assigned Users panel. Seven positions distribute a set: five since #2200, + * and the two marketing rungs since #2338. Until #2200 those five declared + * none and reached their sets only through the platform's undeclared * fold of a held position's name onto a same-named set, which logs * `position_name_fold_grant` on every boot and which the platform has ruled to * retire (objectstack#13419, ruling B). A position added here that should grant @@ -90,15 +91,28 @@ export const CrmPositions = [ description: 'Handles customer cases, receives new cases by round-robin assignment and pulls unassigned cases from triage.', permissionSets: ['service_agent'], }, + // The two marketing rungs hold the marketing staff set (#2338 item 3). They + // declared none, so a holder carried `crm_user` alone, whose campaign grant + // is read-only: the object-level gate refused every campaign update with + // 403 before the `campaign_leadership_*` edit share + // (`src/marketing/sharing/campaign.sharing.ts`) was ever consulted, and the + // campaign record offered no Edit and no Enroll. A rung that leads the + // marketers does the marketers' work, enrolling members and tracking + // responses included, so the existing set is bound rather than a new one + // made. Measured on 18.2.0: that set alone writes every campaign (live and + // completed alike), so for these two rungs the leadership share now grants + // nothing beyond it. { name: 'marketing_director', label: 'Marketing Director', description: 'Leads marketing and edits every live campaign, whoever owns it.', + permissionSets: ['marketing_user'], }, { name: 'marketing_manager', label: 'Marketing Manager', description: 'Manages marketing campaigns and edits every live campaign, whoever owns it.', + permissionSets: ['marketing_user'], }, { name: 'marketing_user', diff --git a/src/service/index.ts b/src/service/index.ts index 1d66fc796..7a952165d 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -86,8 +86,19 @@ export type HotCrmServiceCollections = Pick; */ const NOT_A_SALES_REP = P`!("sales_rep" in current_user.positions)`; -/** The read gate on this package's report entries (#2073 item 1), narrowed to the audience above. */ -const CASE_REPORT_AUDIENCE = P`current_user.can("crm_case", "read") && !("sales_rep" in current_user.positions)`; +/** + * The read gate on this package's dashboard and report entries (#2073 item 1, + * #2338 item 1), narrowed to the audience above. + * + * The console prunes an object entry by read access, but not a dashboard or a + * report entry. Service Overview carried `NOT_A_SALES_REP` alone until #2338, + * so a member who reads no cases (the marketing user, whose set "see[s] no + * cases") was offered it, and every one of its tiles answered "You don't have + * permission to view the data behind this widget" (14 refused dataset queries, + * measured on 18.2.0). Cases and My Cases were already hidden from that member + * by the prune. The dashboard now asks the question the case reports ask. + */ +const CASE_READ_AUDIENCE = P`current_user.can("crm_case", "read") && !("sales_rep" in current_user.positions)`; /** * `app.objectstack.hotcrm.service` — the SUPPORT module of this artifact @@ -187,9 +198,10 @@ export const createHotCrmServiceStack = ( * anywhere, and adding one to work around a platform merge rule is what * AGENTS.md forbids. * - * The report entries ask for case read: the console prunes object entries - * by read access but not report entries, so a member who reads no cases - * (the marketing user) would otherwise get three pages of 403s. The app's + * The dashboard and report entries ask for case read: the console prunes + * object entries by read access but not dashboard or report entries, so a + * member who reads no cases (the marketing user) would otherwise get a + * dashboard and three pages of 403s (`CASE_READ_AUDIENCE` above). The app's * `READS_OPPORTUNITIES` note in `crm.app.ts` says why it is `visible` and * not `requiredPermissions`. Every case entry but Knowledge also asks that * the member is not a sales rep (`NOT_A_SALES_REP` above, #2244). @@ -206,7 +218,7 @@ export const createHotCrmServiceStack = ( // first group and declares no children, so this item is the first // entry a service agent or manager is shown, and the app opens on it. items: [ - { id: 'nav_service_dashboard', type: 'dashboard', dashboardName: 'service_dashboard', label: 'Service Overview', icon: 'gauge', visible: NOT_A_SALES_REP }, + { id: 'nav_service_dashboard', type: 'dashboard', dashboardName: 'service_dashboard', label: 'Service Overview', icon: 'gauge', visible: CASE_READ_AUDIENCE }, { id: 'nav_case', type: 'object', objectName: 'crm_case', label: 'Cases', icon: 'life-buoy', visible: NOT_A_SALES_REP }, { id: 'nav_knowledge', type: 'object', objectName: 'crm_knowledge_article', label: 'Knowledge', icon: 'book-open' }, ], @@ -226,9 +238,9 @@ export const createHotCrmServiceStack = ( group: 'group_reports', priority: 30, items: [ - { id: 'nav_report_cases_by_status', type: 'report', reportName: 'cases_by_status_priority', label: 'Cases by Status and Priority', icon: 'chart-column', visible: CASE_REPORT_AUDIENCE }, - { id: 'nav_report_sla', type: 'report', reportName: 'sla_performance', label: 'SLA Performance', icon: 'timer', visible: CASE_REPORT_AUDIENCE }, - { id: 'nav_report_cases_opened_by_day', type: 'report', reportName: 'cases_opened_by_day_priority', label: 'Cases Opened by Day', icon: 'calendar-days', visible: CASE_REPORT_AUDIENCE }, + { id: 'nav_report_cases_by_status', type: 'report', reportName: 'cases_by_status_priority', label: 'Cases by Status and Priority', icon: 'chart-column', visible: CASE_READ_AUDIENCE }, + { id: 'nav_report_sla', type: 'report', reportName: 'sla_performance', label: 'SLA Performance', icon: 'timer', visible: CASE_READ_AUDIENCE }, + { id: 'nav_report_cases_opened_by_day', type: 'report', reportName: 'cases_opened_by_day_priority', label: 'Cases Opened by Day', icon: 'calendar-days', visible: CASE_READ_AUDIENCE }, ], }, ], diff --git a/src/service/views/case.view.ts b/src/service/views/case.view.ts index e1550ed31..b535d26e8 100644 --- a/src/service/views/case.view.ts +++ b/src/service/views/case.view.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { P } from '@objectstack/spec'; import { defineView } from '@objectstack/spec/ui'; /** @@ -343,6 +344,7 @@ export const CaseViews = defineView({ * * ⇒ Therefore the create-safe field set has to BE this form's field set. * That is why the SLA and Resolution sections are gone rather than gated. + * One field came back, gated (#2338 item 2): `resolution`, see below. * * ## ⛔ Why the tab strip was not the object's `fieldGroups` * @@ -398,9 +400,9 @@ export const CaseViews = defineView({ * is_escalated, escalation_reason written by `case_escalation` / * `case_sla_monitor` / `escalate_case`; * detail page's Status & SLA section - * resolution detail page's Description section - * (the `resolution_required_on_close` - * validation still has its surface) + * resolution detail page's Description section, + * and back on this form behind a status + * gate since #2338 item 2 (below) * is_closed readonly; derived from `status` * * ⚠️ `internal_notes`, `customer_rating` and `customer_feedback` left with @@ -460,6 +462,24 @@ export const CaseViews = defineView({ 'supplied_name', 'supplied_email', { field: 'status', required: true }, + // Offered once the status says the case is being resolved (#2338 + // item 2). This is the edit form, and it offers Status "Closed", + // which `resolution_required_for_closed` on `crm_case` refuses + // without a resolution. With the field off this form that save was a + // dead end ("Resolution is required when closing a case", nothing to + // fill in, the record page hiding its Resolution section while + // empty), so the only way out was Cancel, then Close Case. Gated, + // not always on, because this form is also the create dialog, and a + // new case is `new`. The predicate is TOTAL (`has()` first): an + // unevaluable one fails OPEN in the console (the house rule at the + // top of `lead.view.ts`), which would only show the field. Not + // `required` here: the object's rule asks for it on `closed` only, + // and a client-side `required` would also demand it on `resolved`. + { + field: 'resolution', + span: 'full', + visibleWhen: P`has(record.status) && (record.status == "resolved" || record.status == "closed")`, + }, 'priority', 'origin', 'owner_id', diff --git a/test/app-navigation-audience.test.ts b/test/app-navigation-audience.test.ts index ac22ff749..8003e83dd 100644 --- a/test/app-navigation-audience.test.ts +++ b/test/app-navigation-audience.test.ts @@ -115,6 +115,12 @@ beforeAll(async () => { }); who[key] = { ...person, positions, objects: await permissionsOf(person.token) }; } + // Staffed by nobody in the demo, so signed up here the same way (#2338 item 1). + const marketer = await signUpPerson(verify, 'marketing_user@nav-audience.test', { + name: 'Nav marketing_user', positions: ['marketing_user'], + permissionSets: CrmPositions.find((c) => c.name === 'marketing_user')?.permissionSets ?? [], + }); + who.marketing_user = { ...marketer, positions: ['marketing_user'], objects: await permissionsOf(marketer.token) }; const token = await verify.signIn(); const context = (await verify.contextFor(token)) as Rec; who.admin = { @@ -266,6 +272,42 @@ describe('a service persona is not shown the opportunity dashboards and reports }); }); +/** + * Service Overview is offered only to a member who reads cases (#2338 item 1). + * + * The console prunes an object entry by read access, but not a dashboard + * entry. The marketing user reads no cases, so Cases and My Cases were already + * pruned from their sidebar, while Service Overview, gated on the rep position + * alone (`BEFORE_2338`), was offered and refused every tile (measured on + * 18.2.0). It now asks the read question the case reports ask. + */ +describe('a member who reads no cases is not offered Service Overview (#2338 item 1)', () => { + const BEFORE_2338: Record = { nav_service_dashboard: P`!("sales_rep" in current_user.positions)` }; + const OTHERS = [...PERSONAS, 'admin']; + + it('the marketing user reads no cases and holds no sales_rep position', () => { + expect(who.marketing_user!.objects.crm_case?.allowRead === true).toBe(false); + expect(who.marketing_user!.positions).not.toContain('sales_rep'); + }); + + it('the gate before #2338 offered them Service Overview, beside no Cases and no My Cases', () => { + const before = shownTo('marketing_user', BEFORE_2338); + expect(before).toContain('nav_service_dashboard'); + expect(before).not.toContain('nav_case'); + expect(before).not.toContain('nav_my_cases'); + }); + + it('they are shown their old sidebar less Service Overview, nothing else', () => { + expect(shownTo('marketing_user')).toEqual( + shownTo('marketing_user', BEFORE_2338).filter((id) => id !== 'nav_service_dashboard'), + ); + }); + + it.each(OTHERS)('%s is shown exactly the sidebar it was shown before', (persona) => { + expect(shownTo(persona)).toEqual(shownTo(persona, BEFORE_2338)); + }); +}); + /** * A member who holds no HotCRM grant lands on the platform's access page, not * on Service Overview (#2250, the HotCRM half of objectstack#25338). diff --git a/test/case-create-form-narrowing.test.ts b/test/case-create-form-narrowing.test.ts index e2d2e854b..990de628c 100644 --- a/test/case-create-form-narrowing.test.ts +++ b/test/case-create-form-narrowing.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { ExpressionEngine } from '@objectstack/formula'; import { CaseViews } from '../src/service/views/case.view'; import { Case } from '../src/service/objects/case.object'; import { CaseDetailPage } from '../src/service/pages/case_detail.page'; @@ -46,11 +47,40 @@ type AnyRec = Record; const fieldNamesOf = (section: AnyRec): string[] => (section.fields ?? []).map((f: any) => (typeof f === 'string' ? f : f?.field)).filter(Boolean); -const formFields = (): string[] => - ((CaseViews as AnyRec).form?.sections ?? []).flatMap(fieldNamesOf); - const objectFields = (Case as AnyRec).fields as Record; +/** The form's entries, each as `{ field, visibleWhen? }`. */ +const formEntries = (): AnyRec[] => + ((CaseViews as AnyRec).form?.sections ?? []) + .flatMap((s: AnyRec) => s.fields ?? []) + .map((f: unknown) => (typeof f === 'string' ? { field: f } : (f as AnyRec))); + +/** Does `entry` show for `record`, as the console evaluates a field's `visibleWhen`? */ +const shows = (entry: AnyRec, record: AnyRec): boolean => { + if (entry.visibleWhen === undefined) return true; + const result = ExpressionEngine.evaluate(entry.visibleWhen as never, { record } as never); + expect(result.ok, `${entry.field}.visibleWhen did not evaluate on ${JSON.stringify(record)}: ${JSON.stringify(result)}`).toBe(true); + return (result as AnyRec).value === true; +}; + +/** + * A new case as the create dialog opens it: every select at its default. Only + * `status` matters to a gate on this form today (#2338 item 2). + */ +const NEW_CASE: AnyRec = Object.fromEntries( + Object.entries(objectFields) + .map(([name, f]) => [name, (f.options ?? []).find((o: AnyRec) => o.default === true)?.value]) + .filter(([, value]) => value !== undefined), +); + +/** + * The fields the CREATE dialog shows: every entry of the one form whose + * `visibleWhen`, if any, holds for a new case, and for a record with no keys + * at all (a gate must not depend on a default being filled in to stay shut). + */ +const formFields = (): string[] => + formEntries().filter((e) => shows(e, NEW_CASE) || shows(e, {})).map((e) => e.field as string); + /** * The fields a person raising a case actually has in hand. CLOSED on purpose: * adding a field to the create form is a decision, and it should be made here @@ -130,6 +160,8 @@ const LIFECYCLE_MAINTAINED: Record = { keeps: ['detail.details'], }, resolution: { + // On the one form since #2338 item 2, behind a status gate that keeps it + // off the create dialog (`formFields` reads only what a new case shows). why: 'authored when CLOSING a case, not when raising one', keeps: ['detail.details'], }, @@ -370,3 +402,43 @@ describe('crm_case create form — retention direction', () => { expect(sort).toContain('sla_due_date'); }); }); + +/** + * The edit form offers Resolution once the status says the case is being + * resolved (#2338 item 2). + * + * `resolution_required_for_closed` refuses a closed case with no resolution, + * and this form offers Status "Closed". With Resolution off the form, that save + * was a dead end: refused with "Resolution is required when closing a case", + * and nothing on the form to fill in. The same form is the create dialog, so + * the field is gated rather than always on; the removal direction above + * already pins that a new case does not show it. + */ +describe('crm_case edit form — Resolution when closing (#2338 item 2)', () => { + const resolution = (): AnyRec | undefined => formEntries().find((e) => e.field === 'resolution'); + const STATUSES: string[] = (objectFields.status?.options ?? []).map((o: AnyRec) => o.value); + + it('the object refuses a closed case with no resolution, so the form that offers Closed must offer the field', () => { + const rule = ((Case as AnyRec).validations ?? []).find((v: AnyRec) => v.name === 'resolution_required_for_closed'); + expect(rule, 'resolution_required_for_closed is gone: this suite judges nothing').toBeDefined(); + expect(STATUSES).toEqual(expect.arrayContaining(['resolved', 'closed'])); + expect(resolution(), 'the case form no longer offers Resolution').toBeDefined(); + }); + + it.each(['resolved', 'closed'])('shows on a case moved to %s', (status) => { + expect(shows(resolution()!, { ...NEW_CASE, status })).toBe(true); + }); + + it('stays off for every other status, for a new case and for a record with no status', () => { + const others = STATUSES.filter((s) => s !== 'resolved' && s !== 'closed'); + expect(others.length).toBeGreaterThan(3); + for (const status of others) expect(shows(resolution()!, { status }), status).toBe(false); + expect(NEW_CASE.status).toBe('new'); + expect(shows(resolution()!, NEW_CASE)).toBe(false); + expect(shows(resolution()!, {})).toBe(false); + }); + + it('is not required on the form: the object asks for it on closed only', () => { + expect(resolution()!.required === true).toBe(false); + }); +}); diff --git a/test/demo-staffing.test.ts b/test/demo-staffing.test.ts index 42635f884..cae2e0ffc 100644 --- a/test/demo-staffing.test.ts +++ b/test/demo-staffing.test.ts @@ -455,7 +455,7 @@ describe('staffing lines up with the rules that grant, and the nodes that route' * roster pin alone. */ describe('each position names the sets it distributes, and none is reached by the name fold (#2200)', () => { - it('declares the five bindings, each to a set the app ships', () => { + it('declares the seven bindings, each to a set the app ships', () => { const declared = Object.fromEntries( [...positionNames].filter((p) => declaredSets(p).length > 0).map((p) => [p, declaredSets(p)]), ); @@ -465,6 +465,9 @@ describe('each position names the sets it distributes, and none is reached by th // The #1779 ruling: the escalation pool works tickets with the agent's grants. service_manager: ['service_agent'], service_agent: ['service_agent'], + // The two marketing rungs lead the marketers and hold their set (#2338 item 3). + marketing_director: ['marketing_user'], + marketing_manager: ['marketing_user'], marketing_user: ['marketing_user'], }); expect(Object.values(declared).flat().filter((s) => !shippedSetNames.has(s)), 'a binding names a set the app does not ship').toEqual([]); diff --git a/test/marketing-leadership-campaign-edit.test.ts b/test/marketing-leadership-campaign-edit.test.ts new file mode 100644 index 000000000..adbeca5ff --- /dev/null +++ b/test/marketing-leadership-campaign-edit.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll } from 'vitest'; +import type { VerifyStack } from '@objectstack/verify'; +import { CrmPositions } from '../src/sales/sharing/positions'; +import { hotcrmStack, signUpPerson, daysFromNow, type Person } from './helpers/verify-stack'; + +type Rec = Record; + +/** + * The Marketing Manager and the Marketing Director edit a live campaign a + * specialist owns (#2338 item 3). + * + * Both positions promise it ("edits every live campaign, whoever owns it"), + * and `campaign_leadership_*` (`src/marketing/sharing/campaign.sharing.ts`) + * shares every live campaign to them at `edit`. Until #2338 neither position + * declared a permission set, so a holder carried `crm_user` alone, whose + * campaign grant is read-only: the object-level gate refused the update with + * 403 before any share was consulted. Measured on 18.2.0 in a browser: the + * record offered no Edit and no Enroll. + * + * Each rung is signed up the way Setup staffs it, holding the position and + * every set the position declares, and drives the REST door the edit form + * uses. The control holds the same position and no set, the pre-#2338 shape, + * so the refusal it gets is what the binding removes. + */ + +const RUNGS = ['marketing_manager', 'marketing_director'] as const; +const declaredSets = (position: string): string[] => + ((CrmPositions.find((p) => p.name === position) as Rec | undefined)?.permissionSets ?? []) as string[]; + +let verify: VerifyStack; +let specialist: Person; +const rung: Record = {}; +const bare: Record = {}; +let k = 0; + +beforeAll(async () => { + verify = await hotcrmStack(); + specialist = await signUpPerson(verify, 'specialist@marketing-leadership.test', { + name: 'Leadership Specialist', positions: ['marketing_user'], permissionSets: declaredSets('marketing_user'), + }); + for (const position of RUNGS) { + const sets = declaredSets(position); + rung[position] = { + ...(await signUpPerson(verify, `${position}@marketing-leadership.test`, { + name: `Leadership ${position}`, positions: [position], permissionSets: sets, + })), + sets, + }; + bare[position] = await signUpPerson(verify, `${position}.bare@marketing-leadership.test`, { + name: `Leadership ${position} without a set`, positions: [position], + }); + } +}, 180_000); + +/** A live campaign the specialist owns: the record `campaign_leadership_*` shares to both rungs. */ +const liveCampaign = async (): Promise => { + const [campaign] = await verify.seed('crm_campaign', [{ + name: `Leadership ${++k}`, status: 'planning', is_active: true, + start_date: daysFromNow(7), end_date: daysFromNow(37), owner_id: specialist.id, + }]); + return campaign!; +}; + +const permissionsOf = async (who: Person): Promise => { + const res = await verify.apiAs(who.token, 'GET', '/auth/me/permissions'); + expect(res.status, await res.clone().text()).toBe(200); + const body: Rec = await res.json(); + return (body.data ?? body).objects; +}; + +describe('the marketing rungs edit a live campaign a specialist owns (#2338 item 3)', () => { + it.each(RUNGS)('%s declares the marketing staff set', (position) => { + expect(rung[position]!.sets).toEqual(['marketing_user']); + }); + + it.each(RUNGS)('%s holds campaign edit and member create, the grants Edit and Enroll need', async (position) => { + const objects = await permissionsOf(rung[position]!); + expect(objects.crm_campaign).toMatchObject({ allowRead: true, allowEdit: true }); + expect(objects.crm_campaign_member).toMatchObject({ allowCreate: true, allowEdit: true }); + }); + + it.each(RUNGS)('%s updates it, and the change is stored', async (position) => { + const campaign = await liveCampaign(); + const res = await verify.apiAs(rung[position]!.token, 'PATCH', `/data/crm_campaign/${campaign.id}`, { + description: `Edited by ${position}`, + }); + expect(res.status, await res.clone().text()).toBe(200); + expect((await verify.rows('crm_campaign', { id: campaign.id }))[0]?.description).toBe(`Edited by ${position}`); + }); + + it.each(RUNGS)('%s holding the position alone, as before #2338, is refused and nothing is stored', async (position) => { + const campaign = await liveCampaign(); + const res = await verify.apiAs(bare[position]!.token, 'PATCH', `/data/crm_campaign/${campaign.id}`, { + description: `Edited by a bare ${position}`, + }); + const body: Rec = await res.json(); + expect([res.status, body?.code]).toEqual([403, 'PERMISSION_DENIED']); + expect((await verify.rows('crm_campaign', { id: campaign.id }))[0]?.description ?? null).toBeNull(); + }); +}); From a9bc4177e448b274f3ecf21e991c75380796d03c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:22:00 +0000 Subject: [PATCH 2/4] test, docs, changeset: pin the four #2338 fixes and say what changed for users Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01J8VVAk32RuJpqjMZ6Lqc7k --- .changeset/2338-browser-pass-dead-ends.md | 13 +++++ content/docs/administration/profiles.mdx | 2 + .../docs/administration/profiles.zh-Hans.mdx | 2 + .../docs/administration/profiles.zh-Hant.mdx | 2 + content/docs/getting-started/quick-tour.mdx | 2 +- .../getting-started/quick-tour.zh-Hans.mdx | 2 +- .../getting-started/quick-tour.zh-Hant.mdx | 2 +- content/docs/service/cases.mdx | 2 +- content/docs/service/cases.zh-Hans.mdx | 2 +- content/docs/service/cases.zh-Hant.mdx | 2 +- test/app-navigation-audience.test.ts | 8 +++- ...marketing-leadership-campaign-edit.test.ts | 48 ++++++++----------- 12 files changed, 50 insertions(+), 37 deletions(-) create mode 100644 .changeset/2338-browser-pass-dead-ends.md diff --git a/.changeset/2338-browser-pass-dead-ends.md b/.changeset/2338-browser-pass-dead-ends.md new file mode 100644 index 000000000..780e91d0f --- /dev/null +++ b/.changeset/2338-browser-pass-dead-ends.md @@ -0,0 +1,13 @@ +--- +"hotcrm": patch +--- + +Clause-②: no +Metadata-protocol: none + +**Four dead ends from the 18.2.0 browser pass are gone (#2338 items 1–4).** + +- **Service Overview is offered only to people who can read cases.** A marketing user, who reads no cases, was offered it in the sidebar, and every tile said "You don't have permission to view the data behind this widget". It now asks the same question the three case reports already ask. Sales Activity stays open to marketing users: it reads their own activity, so it works. Sales Performance also stays open; one of its tiles, Quota Attainment by Rep, still refuses a member who cannot read forecasts, because the platform cannot hide a single tile by access yet. +- **Edit Case shows Resolution when you close the case.** Picking Status "Closed" on the edit form used to end in "Resolution is required when closing a case" with nowhere to type one. The form now shows Resolution as soon as Status is Resolved or Closed. A new case still opens without it. +- **Marketing Managers and Marketing Directors can edit campaigns.** Both positions now give their holders the Marketing User profile, so the people who lead marketing can edit a live campaign a marketer owns and enroll its members, as their position descriptions promise. Before, they could only read campaigns. +- **"Update Stage" is no longer a list header button on opportunities.** That button failed whatever was ticked: with two rows it said it runs on a single record, and with none it opened a dialog that targeted nothing. Tick the deals and use **Update Stage** on the selection bar, which moves them all in one run. diff --git a/content/docs/administration/profiles.mdx b/content/docs/administration/profiles.mdx index a69c0e63e..684de291e 100644 --- a/content/docs/administration/profiles.mdx +++ b/content/docs/administration/profiles.mdx @@ -71,6 +71,8 @@ Besides the six profiles above, HotCRM ships one more permission set, **CRM User - Read **knowledge articles** - Cannot delete records +The **marketing_manager** and **marketing_director** positions give this profile to everyone who holds them, so the people who lead marketing can do everything a marketer does, editing any campaign and enrolling members included. Adding someone to either position in **Setup → Positions** is enough; there is no profile to assign by hand. + ### 🌐 Guest (Public Forms) - **Insert only** on **leads** and **cases**, through the public Web-to-Lead / Web-to-Case forms diff --git a/content/docs/administration/profiles.zh-Hans.mdx b/content/docs/administration/profiles.zh-Hans.mdx index 9c407bcb9..da74033ef 100644 --- a/content/docs/administration/profiles.zh-Hans.mdx +++ b/content/docs/administration/profiles.zh-Hans.mdx @@ -71,6 +71,8 @@ description: 六种内置权限集,以及每一种能查看、编辑和执行 - 读取 **知识文章** - 不能删除记录 +**marketing_manager** 与 **marketing_director** 两个岗位会把这个权限档案授予岗位上的每个人,所以带领营销团队的人能做营销人员能做的一切,包括编辑任意营销活动、登记活动成员。在 **Setup → Positions** 里把人加进这两个岗位之一就够了,不需要再手动分配权限档案。 + ### 🌐 Guest (Public Forms) - 通过公开的 Web-to-Lead / Web-to-Case 表单,对 **线索** 和 **工单** **仅可插入** diff --git a/content/docs/administration/profiles.zh-Hant.mdx b/content/docs/administration/profiles.zh-Hant.mdx index bf9ddba90..47cd8ccd3 100644 --- a/content/docs/administration/profiles.zh-Hant.mdx +++ b/content/docs/administration/profiles.zh-Hant.mdx @@ -73,6 +73,8 @@ description: 六種內建權限集,以及每一種能檢視、編輯和執行 - 讀取 **知識文章** - 不能刪除記錄 +**marketing_manager** 與 **marketing_director** 兩個職位會把這個權限設定檔授予職位上的每個人,所以帶領行銷團隊的人能做行銷人員能做的一切,包括編輯任意行銷活動、登記活動成員。在 **Setup → Positions** 裡把人加進這兩個職位之一就夠了,不需要再手動指派權限設定檔。 + ### 🌐 Guest (Public Forms) - 透過公開的 Web-to-Lead / Web-to-Case 表單,對 **線索** 和 **工單** **僅可插入** diff --git a/content/docs/getting-started/quick-tour.mdx b/content/docs/getting-started/quick-tour.mdx index 3160f9e27..959d74d79 100644 --- a/content/docs/getting-started/quick-tour.mdx +++ b/content/docs/getting-started/quick-tour.mdx @@ -60,7 +60,7 @@ Inside HotCRM, the left nav opens with two pinned items — **Home**, which is t **Reports** is collapsed when the app loads — **Service**, **Sales**, **My Work**, **Activity**, **Marketing** and **Insights** open themselves. Click it open before deciding a report isn't there. -The entries built on opportunities — **Home**, **Sales Home**, **Sales Performance**, **CRM Overview** and the four opportunity reports under **Reports** — are left out of the sidebar for anyone who cannot read opportunities, and for a service agent or service manager by position: they can still open their own deals from **Opportunities** and **My Deals**, but these dashboards and reports are not shown to them. Every other report asks the same of its own records: a service agent still gets the lead, account and case reports, and a user who cannot read cases does not get the three case reports. **Home** is left out for a sales rep as well, which is why a rep's sidebar starts at **Sales Home**. A sales rep works no cases, so a rep's sidebar also leaves out **Service Overview**, **Cases**, **My Cases** and the three case reports — **Cases by Status and Priority**, **SLA Performance** and **Cases Opened by Day**; **Knowledge** stays, because articles are read while selling too. **Service** is the first group because the app opens on the first entry a sidebar shows: for a service agent or service manager that is **Service Overview**. +The entries built on opportunities — **Home**, **Sales Home**, **Sales Performance**, **CRM Overview** and the four opportunity reports under **Reports** — are left out of the sidebar for anyone who cannot read opportunities, and for a service agent or service manager by position: they can still open their own deals from **Opportunities** and **My Deals**, but these dashboards and reports are not shown to them. Every other report asks the same of its own records: a service agent still gets the lead, account and case reports, and a user who cannot read cases does not get **Service Overview** or the three case reports. **Home** is left out for a sales rep as well, which is why a rep's sidebar starts at **Sales Home**. A sales rep works no cases, so a rep's sidebar also leaves out **Service Overview**, **Cases**, **My Cases** and the three case reports — **Cases by Status and Priority**, **SLA Performance** and **Cases Opened by Day**; **Knowledge** stays, because articles are read while selling too. **Service** is the first group because the app opens on the first entry a sidebar shows: for a service agent or service manager that is **Service Overview**. One sidebar row per destination is deliberate. Where an object offers several ways of looking at the same records, those live as tabs across the top of that object's list page, not as extra rows in the nav — so the sidebar stays short enough to scan. diff --git a/content/docs/getting-started/quick-tour.zh-Hans.mdx b/content/docs/getting-started/quick-tour.zh-Hans.mdx index abe7a4edc..721f92727 100644 --- a/content/docs/getting-started/quick-tour.zh-Hans.mdx +++ b/content/docs/getting-started/quick-tour.zh-Hans.mdx @@ -64,7 +64,7 @@ description: HotCRM 的 5 分钟演练 —— 主页、你的第一条线索、 **Reports** 这个分组在应用加载时是折叠的——**Service**、**Sales**、**My Work**、**Activity**、**Marketing**、**Insights** 都会自动展开。先把它点开,再判断某份报表在不在这里。 -建立在商机之上的条目——**Home**、**Sales Home**、**Sales Performance**、**CRM Overview**,以及 **Reports** 下的四张商机报表——不能读取商机的人在侧边栏里看不到;服务坐席与服务经理也按岗位看不到它们:他们仍可从 **Opportunities** 与 **My Deals** 打开自己的商机,但这些仪表盘与报表不对他们显示。其余报表对各自的数据也是同样的要求:服务坐席照样看得到线索、客户与工单报表,而不能读取工单的人看不到那三张工单报表。销售代表也看不到 **Home**,所以销售代表的侧边栏从 **Sales Home** 开始。销售代表不处理工单,所以销售代表的侧边栏也看不到 **Service Overview**、**Cases**、**My Cases** 以及那三张工单报表——**Cases by Status and Priority**、**SLA Performance**、**Cases Opened by Day**;**Knowledge** 仍然保留,因为销售时也要查阅知识文章。**Service** 排在所有分组之首,是因为应用打开时落在侧边栏显示的第一个条目上:对服务坐席与服务经理来说,那就是 **Service Overview**。 +建立在商机之上的条目——**Home**、**Sales Home**、**Sales Performance**、**CRM Overview**,以及 **Reports** 下的四张商机报表——不能读取商机的人在侧边栏里看不到;服务坐席与服务经理也按岗位看不到它们:他们仍可从 **Opportunities** 与 **My Deals** 打开自己的商机,但这些仪表盘与报表不对他们显示。其余报表对各自的数据也是同样的要求:服务坐席照样看得到线索、客户与工单报表,而不能读取工单的人看不到 **Service Overview** 和那三张工单报表。销售代表也看不到 **Home**,所以销售代表的侧边栏从 **Sales Home** 开始。销售代表不处理工单,所以销售代表的侧边栏也看不到 **Service Overview**、**Cases**、**My Cases** 以及那三张工单报表——**Cases by Status and Priority**、**SLA Performance**、**Cases Opened by Day**;**Knowledge** 仍然保留,因为销售时也要查阅知识文章。**Service** 排在所有分组之首,是因为应用打开时落在侧边栏显示的第一个条目上:对服务坐席与服务经理来说,那就是 **Service Overview**。 一个去处只占一行,这是刻意的。同一个对象如果有多种查看方式,它们是该对象列表页顶部的标签页,而不是导航里另起一行——这样侧边栏才短到能一眼扫完。 diff --git a/content/docs/getting-started/quick-tour.zh-Hant.mdx b/content/docs/getting-started/quick-tour.zh-Hant.mdx index 3803e2731..97dbd461f 100644 --- a/content/docs/getting-started/quick-tour.zh-Hant.mdx +++ b/content/docs/getting-started/quick-tour.zh-Hant.mdx @@ -64,7 +64,7 @@ description: HotCRM 的 5 分鐘演練 —— 主頁、你的第一條線索、 **Reports** 這個群組在應用載入時是摺疊的——**Service**、**Sales**、**My Work**、**Activity**、**Marketing**、**Insights** 都會自動展開。先把它點開,再判斷某份報表在不在這裡。 -建立在商機之上的條目——**Home**、**Sales Home**、**Sales Performance**、**CRM Overview**,以及 **Reports** 下的四張商機報表——不能讀取商機的人在側邊欄裡看不到;服務專員與服務經理也按職位看不到它們:他們仍可從 **Opportunities** 與 **My Deals** 打開自己的商機,但這些儀表板與報表不對他們顯示。其餘報表對各自的資料也是同樣的要求:服務專員照樣看得到線索、客戶與工單報表,而不能讀取工單的人看不到那三張工單報表。銷售代表也看不到 **Home**,所以銷售代表的側邊欄從 **Sales Home** 開始。銷售代表不處理工單,所以銷售代表的側邊欄也看不到 **Service Overview**、**Cases**、**My Cases** 以及那三張工單報表——**Cases by Status and Priority**、**SLA Performance**、**Cases Opened by Day**;**Knowledge** 仍然保留,因為銷售時也要查閱知識文章。**Service** 排在所有群組之首,是因為應用打開時落在側邊欄顯示的第一個條目上:對服務專員與服務經理來說,那就是 **Service Overview**。 +建立在商機之上的條目——**Home**、**Sales Home**、**Sales Performance**、**CRM Overview**,以及 **Reports** 下的四張商機報表——不能讀取商機的人在側邊欄裡看不到;服務專員與服務經理也按職位看不到它們:他們仍可從 **Opportunities** 與 **My Deals** 打開自己的商機,但這些儀表板與報表不對他們顯示。其餘報表對各自的資料也是同樣的要求:服務專員照樣看得到線索、客戶與工單報表,而不能讀取工單的人看不到 **Service Overview** 和那三張工單報表。銷售代表也看不到 **Home**,所以銷售代表的側邊欄從 **Sales Home** 開始。銷售代表不處理工單,所以銷售代表的側邊欄也看不到 **Service Overview**、**Cases**、**My Cases** 以及那三張工單報表——**Cases by Status and Priority**、**SLA Performance**、**Cases Opened by Day**;**Knowledge** 仍然保留,因為銷售時也要查閱知識文章。**Service** 排在所有群組之首,是因為應用打開時落在側邊欄顯示的第一個條目上:對服務專員與服務經理來說,那就是 **Service Overview**。 一個去處只佔一列,這是刻意的。同一個物件如果有多種檢視方式,它們是該物件列表頁頂部的標籤頁,而不是導覽裡另起一列——這樣側邊欄才短到能一眼掃完。 diff --git a/content/docs/service/cases.mdx b/content/docs/service/cases.mdx index 5e586aa10..375002eaa 100644 --- a/content/docs/service/cases.mdx +++ b/content/docs/service/cases.mdx @@ -106,7 +106,7 @@ The two are not one screen wearing different chrome — they overlap in only sev ## Built-in rules -- **Closed cases must have a resolution** — you can't close a case with an empty Resolution field. +- **Closed cases must have a resolution** — you can't close a case with an empty Resolution field. The edit form shows **Resolution** as soon as **Status** is set to *Resolved* or *Closed*, so you can fill it in on the same save; a new case does not show it. - **Escalated cases must have an escalation reason** — explains why a senior agent was pulled in. - **Status progression is advice, not a gate** — the transition table says *New* leads to *In Progress*, *Waiting on Customer*, *Escalated* or *Closed*, so jumping straight to *Resolved* is outside it. That does not stop the save: the case is stored as *Resolved* and the engine writes one line to the server log, `WARN Validation rule 'case_status_progression' (warning): Invalid status transition`. Unlike the two rules above — both `error` severity, both of which do reject the save — this one only advises. See [State Machines](/docs/administration/state-machines) for what the table is good for. diff --git a/content/docs/service/cases.zh-Hans.mdx b/content/docs/service/cases.zh-Hans.mdx index 11374c5c0..322002e4a 100644 --- a/content/docs/service/cases.zh-Hans.mdx +++ b/content/docs/service/cases.zh-Hans.mdx @@ -106,7 +106,7 @@ description: 客户支持工单——主题、优先级、状态、解决方案 ## 内置规则 -- **已关闭工单必须有解决方案**——解决方案字段为空时无法关闭工单。 +- **已关闭工单必须有解决方案**——解决方案字段为空时无法关闭工单。编辑表单在状态选为「已解决」或「已关闭」时就会显示解决方案字段,可以在同一次保存里填写;新建工单时不显示它。 - **升级工单必须有升级原因**——解释为何引入了资深客服。 - **状态推进是建议,不是闸门**——转换表写的是 *New* 之后可以去 *In Progress*、*Waiting on Customer*、*Escalated* 或 *Closed*,所以直接跳到 *Resolved* 是走出了这张表。但这拦不下保存:工单照样以 *Resolved* 落库,引擎只在服务端日志里写一行 `WARN Validation rule 'case_status_progression' (warning): Invalid status transition`。与上面两条不同——那两条是 `error` 级、确实会拒绝保存——这一条只做提示。转换表到底有什么用,见[状态机](/zh-Hans/docs/administration/state-machines)。 diff --git a/content/docs/service/cases.zh-Hant.mdx b/content/docs/service/cases.zh-Hant.mdx index c24df744d..0a64bc9a1 100644 --- a/content/docs/service/cases.zh-Hant.mdx +++ b/content/docs/service/cases.zh-Hant.mdx @@ -108,7 +108,7 @@ description: 客戶支援工單——主題、優先順序、狀態、解決方 ## 內建規則 -- **已關閉工單必須有解決方案**——解決方案欄位為空時無法關閉工單。 +- **已關閉工單必須有解決方案**——解決方案欄位為空時無法關閉工單。編輯表單在狀態選為 *Resolved* 或 *Closed* 時就會顯示解決方案欄位,可以在同一次儲存裡填寫;新建工單時不顯示它。 - **升級工單必須有升級原因**——解釋為何引入了資深客服。 - **狀態推進是建議,不是閘門**——轉換表寫的是 *New* 之後可以去 *In Progress*、*Waiting on Customer*、*Escalated* 或 *Closed*,所以直接跳到 *Resolved* 是走出了這張表。但這攔不下儲存:工單照樣以 *Resolved* 落庫,引擎只在伺服器日誌裡寫一行 `WARN Validation rule 'case_status_progression' (warning): Invalid status transition`。與上面兩條不同——那兩條是 `error` 級、確實會拒絕儲存——這一條只做提示。轉換表到底有什麼用,見[狀態機](/zh-Hant/docs/administration/state-machines)。 diff --git a/test/app-navigation-audience.test.ts b/test/app-navigation-audience.test.ts index 8003e83dd..87cccfc5c 100644 --- a/test/app-navigation-audience.test.ts +++ b/test/app-navigation-audience.test.ts @@ -373,8 +373,12 @@ describe('a member with no HotCRM grant lands on the platform access page (#2250 expect(NAMED_OBJECTS.filter((o) => who.no_grant!.objects[o]?.allowRead === true)).toEqual([]); }); - it('the gates alone would open the app on Service Overview: the reported first screen', () => { - expect(shownTo('no_grant')[0]).toBe('nav_service_dashboard'); + // Service Overview was the reported first screen. Since #2338 it asks for + // case read, so the gates alone now open the app further down, on the + // Approvals Inbox, which is still a HotCRM entry the platform page preempts. + it('the gates alone would open the app on a HotCRM entry: the Approvals Inbox since #2338', () => { + expect(shownTo('no_grant')).not.toContain('nav_service_dashboard'); + expect(shownTo('no_grant')[0]).toBe('nav_approval_requests'); }); it('so the member lands on the platform access page, not on any HotCRM entry', () => { diff --git a/test/marketing-leadership-campaign-edit.test.ts b/test/marketing-leadership-campaign-edit.test.ts index adbeca5ff..3333ca33e 100644 --- a/test/marketing-leadership-campaign-edit.test.ts +++ b/test/marketing-leadership-campaign-edit.test.ts @@ -19,10 +19,13 @@ type Rec = Record; * 403 before any share was consulted. Measured on 18.2.0 in a browser: the * record offered no Edit and no Enroll. * - * Each rung is signed up the way Setup staffs it, holding the position and - * every set the position declares, and drives the REST door the edit form - * uses. The control holds the same position and no set, the pre-#2338 shape, - * so the refusal it gets is what the binding removes. + * Each rung is signed up the way Setup staffs it: added as a holder of the + * position, with no set assigned by hand. The position distributes the sets it + * declares (ADR-0131 D3/D4), so what the holder can do is what the binding + * gives. The pre-#2338 refusal is not re-run here, because the binding cannot + * be removed from a booted stack: it was measured on `main` on 18.2.0 against + * a dev server, the same holder answering 403 `PERMISSION_DENIED` on every + * campaign, and is quoted in the PR. */ const RUNGS = ['marketing_manager', 'marketing_director'] as const; @@ -31,25 +34,17 @@ const declaredSets = (position: string): string[] => let verify: VerifyStack; let specialist: Person; -const rung: Record = {}; -const bare: Record = {}; +const rung: Record = {}; let k = 0; beforeAll(async () => { verify = await hotcrmStack(); specialist = await signUpPerson(verify, 'specialist@marketing-leadership.test', { - name: 'Leadership Specialist', positions: ['marketing_user'], permissionSets: declaredSets('marketing_user'), + name: 'Leadership Specialist', positions: ['marketing_user'], }); for (const position of RUNGS) { - const sets = declaredSets(position); - rung[position] = { - ...(await signUpPerson(verify, `${position}@marketing-leadership.test`, { - name: `Leadership ${position}`, positions: [position], permissionSets: sets, - })), - sets, - }; - bare[position] = await signUpPerson(verify, `${position}.bare@marketing-leadership.test`, { - name: `Leadership ${position} without a set`, positions: [position], + rung[position] = await signUpPerson(verify, `${position}@marketing-leadership.test`, { + name: `Leadership ${position}`, positions: [position], }); } }, 180_000); @@ -67,36 +62,31 @@ const permissionsOf = async (who: Person): Promise => { const res = await verify.apiAs(who.token, 'GET', '/auth/me/permissions'); expect(res.status, await res.clone().text()).toBe(200); const body: Rec = await res.json(); - return (body.data ?? body).objects; + return body.data ?? body; }; describe('the marketing rungs edit a live campaign a specialist owns (#2338 item 3)', () => { it.each(RUNGS)('%s declares the marketing staff set', (position) => { - expect(rung[position]!.sets).toEqual(['marketing_user']); + expect(declaredSets(position)).toEqual(['marketing_user']); + }); + + it.each(RUNGS)('a holder of %s resolves that set through the position alone', async (position) => { + expect((await permissionsOf(rung[position]!)).permissionSets).toContain('marketing_user'); }); it.each(RUNGS)('%s holds campaign edit and member create, the grants Edit and Enroll need', async (position) => { - const objects = await permissionsOf(rung[position]!); + const { objects } = await permissionsOf(rung[position]!); expect(objects.crm_campaign).toMatchObject({ allowRead: true, allowEdit: true }); expect(objects.crm_campaign_member).toMatchObject({ allowCreate: true, allowEdit: true }); }); it.each(RUNGS)('%s updates it, and the change is stored', async (position) => { const campaign = await liveCampaign(); + expect(campaign.owner_id).toBe(specialist.id); const res = await verify.apiAs(rung[position]!.token, 'PATCH', `/data/crm_campaign/${campaign.id}`, { description: `Edited by ${position}`, }); expect(res.status, await res.clone().text()).toBe(200); expect((await verify.rows('crm_campaign', { id: campaign.id }))[0]?.description).toBe(`Edited by ${position}`); }); - - it.each(RUNGS)('%s holding the position alone, as before #2338, is refused and nothing is stored', async (position) => { - const campaign = await liveCampaign(); - const res = await verify.apiAs(bare[position]!.token, 'PATCH', `/data/crm_campaign/${campaign.id}`, { - description: `Edited by a bare ${position}`, - }); - const body: Rec = await res.json(); - expect([res.status, body?.code]).toEqual([403, 'PERMISSION_DENIED']); - expect((await verify.rows('crm_campaign', { id: campaign.id }))[0]?.description ?? null).toBeNull(); - }); }); From 4537d02c0a1e10c3ef0482ad1e8f911689f7448d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:34:05 +0000 Subject: [PATCH 3/4] fix(opportunity): every grid list offers Update Stage on its selection bar (#2338 item 4) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01J8VVAk32RuJpqjMZ6Lqc7k --- src/sales/views/opportunity.view.ts | 24 +++++++++++++++++++++--- test/bulk-action-dispatch.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/sales/views/opportunity.view.ts b/src/sales/views/opportunity.view.ts index ec4212de6..f5412efdb 100644 --- a/src/sales/views/opportunity.view.ts +++ b/src/sales/views/opportunity.view.ts @@ -13,6 +13,21 @@ import { defineView } from '@objectstack/spec/ui'; * • timeline — chronological deal flow grouped by owner * • gallery — pinboard / executive review cards */ +/** + * The selection bar's Update Stage, declared on every grid list of the object + * (#2338 item 4). The full note on the dispatch contract is on `list` below. + * + * A named list view does NOT inherit the `list` view's `bulkActionDefs` + * (measured on 18.2.0: All Opportunities' bar offered only Delete). While + * `mass_update_stage` was also drawn in the list header, every view showed an + * Update Stage button, but that copy failed on any selection (see `locations` + * on the action), so it was dropped, and each grid list declares the bar entry + * instead. Kanban, calendar, timeline and gallery lists have no row selection. + */ +const UPDATE_STAGE_BAR = [ + { name: 'mass_update_stage', operation: 'custom', execution: 'aggregate' }, +] as const; + export const OpportunityViews = defineView({ // The landing view is OPEN deals, not every deal ever recorded. // @@ -93,9 +108,7 @@ export const OpportunityViews = defineView({ // promoted from the action itself by the renderer, so the dialog and the // toolbar button cannot drift apart — and the label stays i18n-resolved, // which an inline `label:` here would not be. - bulkActionDefs: [ - { name: 'mass_update_stage', operation: 'custom', execution: 'aggregate' }, - ], + bulkActionDefs: [...UPDATE_STAGE_BAR], }, listViews: { @@ -108,6 +121,7 @@ export const OpportunityViews = defineView({ */ all_opportunities: { name: 'all_opportunities', + bulkActionDefs: [...UPDATE_STAGE_BAR], type: 'grid', label: 'All Opportunities', userActions: { editInline: true, hideFields: true }, @@ -198,6 +212,7 @@ export const OpportunityViews = defineView({ /** Personal pipeline */ my_open_deals: { name: 'my_open_deals', + bulkActionDefs: [...UPDATE_STAGE_BAR], type: 'grid', label: 'My Open Deals', userActions: { editInline: true, hideFields: true }, @@ -216,6 +231,7 @@ export const OpportunityViews = defineView({ /** Stale Opportunities — still-open deals, longest-parked first */ stale_opportunities: { name: 'stale_opportunities', + bulkActionDefs: [...UPDATE_STAGE_BAR], type: 'grid', // Honest label: this view does not apply the 14-day cut its automation // twin (`opportunity_stagnation`, STALE_THRESHOLD_DAYS = 14) uses, and @@ -290,6 +306,7 @@ export const OpportunityViews = defineView({ */ tender_this_quarter: { name: 'tender_this_quarter', + bulkActionDefs: [...UPDATE_STAGE_BAR], type: 'grid', label: 'Tender This Quarter', userActions: { editInline: true, hideFields: true }, @@ -314,6 +331,7 @@ export const OpportunityViews = defineView({ /** Closing this quarter (commit + best_case) — sales-manager forecast */ closing_this_quarter: { name: 'closing_this_quarter', + bulkActionDefs: [...UPDATE_STAGE_BAR], type: 'grid', label: 'Closing This Quarter', userActions: { editInline: true, hideFields: true }, diff --git a/test/bulk-action-dispatch.test.ts b/test/bulk-action-dispatch.test.ts index e01a4d6a2..ad75b3838 100644 --- a/test/bulk-action-dispatch.test.ts +++ b/test/bulk-action-dispatch.test.ts @@ -83,6 +83,30 @@ describe('bulk dispatch is declared the way the renderer reads it', () => { ).toEqual([]); }); + // #2338 item 4. Measured on 18.2.0: a named list view does not inherit the + // `list` view's `bulkActionDefs` (All Opportunities' bar offered Delete + // only), and the list-header copy that every view did show failed on any + // selection, so it was dropped. Each grid list therefore declares the bar + // entry itself. A grid list is the kind the console draws row selection on; + // kanban, calendar, timeline and gallery lists draw none. + it('every opportunity grid list offers Update Stage on its selection bar (#2338 item 4)', () => { + const oppView = views.find((v) => (v.list?.data?.object ?? v.object) === 'crm_opportunity'); + const grids = listsOf(oppView!).filter((l) => (l.type ?? 'grid') === 'grid'); + expect(grids.length, 'the opportunity views ship fewer grid lists than when this was pinned').toBeGreaterThanOrEqual(6); + const missing = grids + .filter((l) => !((l.bulkActionDefs ?? []) as AnyRec[]).some( + (d) => d?.name === 'mass_update_stage' && d.operation === 'custom' && d.execution === 'aggregate', + )) + .map((l) => l.name); + expect(missing, 'these opportunity grid lists offer no Update Stage on the selection bar').toEqual([]); + }); + + it('mass_update_stage is not drawn in the list header, where it gets no selection (#2338 item 4)', () => { + const action = actions.find((a) => a.name === 'mass_update_stage'); + expect(action, 'no mass_update_stage action').toBeTruthy(); + expect(action!.locations ?? []).not.toContain('list_toolbar'); + }); + it('every custom bulk def names a real action', () => { // Coverage nothing else has. The guard in `metadata-references.test.ts` // ("every rowAction / bulkAction names a defined action") reads From 9568b7c3cb80e5776274eb87c571869a15767efa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 23:12:24 +0000 Subject: [PATCH 4/4] fix(sales): keep bulk Delete on every opportunity grid list (#2338 item 4) Any authored bulk entry hides the console's default bulk Delete, so the Update Stage defs left the six opportunity grid lists without it (Open Deals already lacked it). The shared constant becomes GRID_BAR and carries an explicit operation: 'delete' def, modelled on all_accounts, with its labels in the zh-CN, es-ES and ja-JP packs. The selection-bar pin now requires the delete def too; ablating it turned the pin red. The mass_update_stage comment no longer claims that list views inherit the def. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01J8VVAk32RuJpqjMZ6Lqc7k --- .changeset/2338-browser-pass-dead-ends.md | 2 +- src/sales/actions/opportunity.actions.ts | 3 +- .../translations/es-ES/objects.pipeline.ts | 22 +++++++++--- .../translations/ja-JP/objects.pipeline.ts | 22 +++++++++--- .../translations/zh-CN/objects.pipeline.ts | 22 +++++++++--- src/sales/views/opportunity.view.ts | 34 ++++++++++++++----- test/bulk-action-dispatch.test.ts | 10 ++++-- 7 files changed, 90 insertions(+), 25 deletions(-) diff --git a/.changeset/2338-browser-pass-dead-ends.md b/.changeset/2338-browser-pass-dead-ends.md index 780e91d0f..933c21873 100644 --- a/.changeset/2338-browser-pass-dead-ends.md +++ b/.changeset/2338-browser-pass-dead-ends.md @@ -10,4 +10,4 @@ Metadata-protocol: none - **Service Overview is offered only to people who can read cases.** A marketing user, who reads no cases, was offered it in the sidebar, and every tile said "You don't have permission to view the data behind this widget". It now asks the same question the three case reports already ask. Sales Activity stays open to marketing users: it reads their own activity, so it works. Sales Performance also stays open; one of its tiles, Quota Attainment by Rep, still refuses a member who cannot read forecasts, because the platform cannot hide a single tile by access yet. - **Edit Case shows Resolution when you close the case.** Picking Status "Closed" on the edit form used to end in "Resolution is required when closing a case" with nowhere to type one. The form now shows Resolution as soon as Status is Resolved or Closed. A new case still opens without it. - **Marketing Managers and Marketing Directors can edit campaigns.** Both positions now give their holders the Marketing User profile, so the people who lead marketing can edit a live campaign a marketer owns and enroll its members, as their position descriptions promise. Before, they could only read campaigns. -- **"Update Stage" is no longer a list header button on opportunities.** That button failed whatever was ticked: with two rows it said it runs on a single record, and with none it opened a dialog that targeted nothing. Tick the deals and use **Update Stage** on the selection bar, which moves them all in one run. +- **"Update Stage" is no longer a list header button on opportunities.** That button failed whatever was ticked: with two rows it said it runs on a single record, and with none it opened a dialog that targeted nothing. Tick the deals and use **Update Stage** on the selection bar, which moves them all in one run. It is now on the selection bar of every opportunity table list, not only Open Deals, and so is **Delete**, which Open Deals had lost. diff --git a/src/sales/actions/opportunity.actions.ts b/src/sales/actions/opportunity.actions.ts index 5c6cd1ad2..e4b0c895a 100644 --- a/src/sales/actions/opportunity.actions.ts +++ b/src/sales/actions/opportunity.actions.ts @@ -204,7 +204,8 @@ export const MassUpdateStageAction: Action = { // Headless (#2338 item 4): drawn only on the SELECTION BAR, which the // opportunity list opens by naming this action in its `bulkActionDefs` // (`src/sales/views/opportunity.view.ts`) and which places it by name, with - // no location here. Every list view of the object inherits that def. + // no location here. List views do not inherit the def: each grid list of + // the object declares it itself, through `GRID_BAR` in that file. // ⛔ Do not add `list_toolbar` back. Measured on 18.2.0, the console's server // dispatch from the list header injects no `_selectedIds`: with two rows // ticked it refused ("This action runs on a single record — select exactly diff --git a/src/sales/translations/es-ES/objects.pipeline.ts b/src/sales/translations/es-ES/objects.pipeline.ts index 2ebb4abbf..537d7856b 100644 --- a/src/sales/translations/es-ES/objects.pipeline.ts +++ b/src/sales/translations/es-ES/objects.pipeline.ts @@ -22,6 +22,18 @@ const DUPLICATE_STATUS_HELP = 'Confirmado significa que una persona revisó los * advisory band `pnpm hygiene` prints. Full rule and rationale: * `src/sales/translations/es-ES.ts`. */ +/** + * The bulk Delete on every opportunity grid list (#2338 item 4, `GRID_BAR` in + * `src/sales/views/opportunity.view.ts`), translated once and named by each view. + */ +const OPPORTUNITY_BULK_DELETE = { + delete: { + label: 'Eliminar', + confirmLabel: 'Eliminar', + confirmText: '¿Eliminar permanentemente {{count}} oportunidad(es)? Esta acción no se puede deshacer.', + }, +}; + export const pipeline: Record = { crm_lead: { _validations: { @@ -412,26 +424,28 @@ export const pipeline: Record = { _views: { tender_this_quarter: { label: 'Licitaciones de Este Trimestre', + bulkActions: OPPORTUNITY_BULK_DELETE, emptyState: { title: 'No hay licitaciones previstas este trimestre', message: 'Esta pestaña muestra los negocios abiertos cuya fecha de licitación del cliente cae dentro del trimestre actual. Registra la Fecha Prevista de Licitación en un negocio para verlo aquí.', }, }, - open_opportunities: { label: 'Oportunidades Abiertas' }, - all_opportunities: { label: 'Todas las Oportunidades' }, + open_opportunities: { label: 'Oportunidades Abiertas', bulkActions: OPPORTUNITY_BULK_DELETE }, + all_opportunities: { label: 'Todas las Oportunidades', bulkActions: OPPORTUNITY_BULK_DELETE }, pipeline_kanban: { label: 'Pipeline de Ventas' }, close_date_calendar: { label: 'Calendario de Pronóstico' }, deal_timeline: { label: 'Línea de Tiempo' }, deal_gallery: { label: 'Galería de Negocios' }, - my_open_deals: { label: 'Mis Negocios Abiertos' }, + my_open_deals: { label: 'Mis Negocios Abiertos', bulkActions: OPPORTUNITY_BULK_DELETE }, closing_this_quarter: { label: 'Cierres de Este Trimestre', + bulkActions: OPPORTUNITY_BULK_DELETE, emptyState: { title: 'No hay negocios que cierren este trimestre', message: 'Esta pestaña muestra los negocios abiertos en categoría Compromiso o Mejor Caso cuya fecha de cierre cae dentro del trimestre actual. Ahora mismo no hay ninguno; los negocios que cierran más adelante están en la pestaña Negocios Abiertos.', }, }, - stale_opportunities: { label: '⚠️ Oportunidades Estancadas · Más Tiempo en Etapa Primero' }, + stale_opportunities: { label: '⚠️ Oportunidades Estancadas · Más Tiempo en Etapa Primero', bulkActions: OPPORTUNITY_BULK_DELETE }, }, _sections: { // Claves de sección del objeto (opportunity.object.ts) que usan los diff --git a/src/sales/translations/ja-JP/objects.pipeline.ts b/src/sales/translations/ja-JP/objects.pipeline.ts index 0a3671844..4c7a0167b 100644 --- a/src/sales/translations/ja-JP/objects.pipeline.ts +++ b/src/sales/translations/ja-JP/objects.pipeline.ts @@ -28,6 +28,18 @@ const opportunityStageOptions = { negotiation: '交渉', closed_won: '成立', closed_lost: '不成立', }; +/** + * The bulk Delete on every opportunity grid list (#2338 item 4, `GRID_BAR` in + * `src/sales/views/opportunity.view.ts`), translated once and named by each view. + */ +const OPPORTUNITY_BULK_DELETE = { + delete: { + label: '削除', + confirmLabel: '削除', + confirmText: '{{count}} 件の商談を完全に削除しますか?この操作は取り消せません。', + }, +}; + export const pipeline: Record = { crm_lead: { _validations: { @@ -391,21 +403,23 @@ export const pipeline: Record = { _views: { tender_this_quarter: { label: '今四半期に入札予定', + bulkActions: OPPORTUNITY_BULK_DELETE, emptyState: { title: '今四半期に入札予定の商談はありません', message: 'このタブには、顧客側の入札予定日が今四半期内にあるオープン商談が表示されます。商談に「入札予定日」を入力すると、ここに表示されます。', }, }, - open_opportunities: { label: '進行中の商談' }, - all_opportunities: { label: '全商談' }, + open_opportunities: { label: '進行中の商談', bulkActions: OPPORTUNITY_BULK_DELETE }, + all_opportunities: { label: '全商談', bulkActions: OPPORTUNITY_BULK_DELETE }, pipeline_kanban: { label: 'セールスパイプライン' }, close_date_calendar: { label: '予測カレンダー' }, deal_timeline: { label: '商談タイムライン' }, deal_gallery: { label: '商談カード' }, - my_open_deals: { label: '私のオープン商談' }, - stale_opportunities: { label: '⚠️ 停滞商談 · ステージ滞在が長い順' }, + my_open_deals: { label: '私のオープン商談', bulkActions: OPPORTUNITY_BULK_DELETE }, + stale_opportunities: { label: '⚠️ 停滞商談 · ステージ滞在が長い順', bulkActions: OPPORTUNITY_BULK_DELETE }, closing_this_quarter: { label: '今四半期にクローズ予定', + bulkActions: OPPORTUNITY_BULK_DELETE, emptyState: { title: '今四半期にクローズ予定の商談はありません', message: 'このタブには、完了予定日が今四半期内にあるコミット/ベストケースのオープン商談が表示されます。現在該当する商談はありません。それ以降にクローズする商談は「オープン商談」タブにあります。', diff --git a/src/sales/translations/zh-CN/objects.pipeline.ts b/src/sales/translations/zh-CN/objects.pipeline.ts index 8f7ca7395..f371dfebd 100644 --- a/src/sales/translations/zh-CN/objects.pipeline.ts +++ b/src/sales/translations/zh-CN/objects.pipeline.ts @@ -22,6 +22,18 @@ const DUPLICATE_STATUS_HELP = '「已确认重复」表示有人核对过这两 * advisory band `pnpm hygiene` prints. Full rule and rationale: * `src/sales/translations/zh-CN.ts`. */ +/** + * The bulk Delete on every opportunity grid list (#2338 item 4, `GRID_BAR` in + * `src/sales/views/opportunity.view.ts`), translated once and named by each view. + */ +const OPPORTUNITY_BULK_DELETE = { + delete: { + label: '删除', + confirmLabel: '删除', + confirmText: '确定要永久删除 {{count}} 个商机吗?此操作无法撤销。', + }, +}; + export const pipeline: Record = { crm_lead: { _validations: { @@ -381,21 +393,23 @@ export const pipeline: Record = { _views: { tender_this_quarter: { label: '本季度预计招标', + bulkActions: OPPORTUNITY_BULK_DELETE, emptyState: { title: '本季度暂无预计招标的商机', message: '本标签页列出客户侧预计招标日期落在当前季度内的进行中商机。在商机上填写“预计招标日期”后,它就会出现在这里。', }, }, - open_opportunities: { label: '进行中商机' }, - all_opportunities: { label: '全部商机' }, + open_opportunities: { label: '进行中商机', bulkActions: OPPORTUNITY_BULK_DELETE }, + all_opportunities: { label: '全部商机', bulkActions: OPPORTUNITY_BULK_DELETE }, pipeline_kanban: { label: '销售流水线' }, close_date_calendar: { label: '预测日历' }, deal_timeline: { label: '商机时间线' }, deal_gallery: { label: '商机卡片' }, - my_open_deals: { label: '我的进行中商机' }, - stale_opportunities: { label: '⚠️ 停滞商机 · 按阶段停留时间排序' }, + my_open_deals: { label: '我的进行中商机', bulkActions: OPPORTUNITY_BULK_DELETE }, + stale_opportunities: { label: '⚠️ 停滞商机 · 按阶段停留时间排序', bulkActions: OPPORTUNITY_BULK_DELETE }, closing_this_quarter: { label: '本季度待成交商机', + bulkActions: OPPORTUNITY_BULK_DELETE, emptyState: { title: '本季度暂无待成交商机', message: '本标签页列出预计成交日期落在当前季度内、且处于承诺(Commit)或最佳可能(Best Case)的进行中商机。当前没有符合条件的记录——预计成交日期更晚的商机请见“进行中商机”标签页。', diff --git a/src/sales/views/opportunity.view.ts b/src/sales/views/opportunity.view.ts index f5412efdb..560afbc78 100644 --- a/src/sales/views/opportunity.view.ts +++ b/src/sales/views/opportunity.view.ts @@ -14,8 +14,9 @@ import { defineView } from '@objectstack/spec/ui'; * • gallery — pinboard / executive review cards */ /** - * The selection bar's Update Stage, declared on every grid list of the object - * (#2338 item 4). The full note on the dispatch contract is on `list` below. + * The selection bar of every grid list of the object: Update Stage and Delete + * (#2338 item 4). The full note on the Update Stage dispatch contract is on + * `list` below. * * A named list view does NOT inherit the `list` view's `bulkActionDefs` * (measured on 18.2.0: All Opportunities' bar offered only Delete). While @@ -23,9 +24,24 @@ import { defineView } from '@objectstack/spec/ui'; * Update Stage button, but that copy failed on any selection (see `locations` * on the action), so it was dropped, and each grid list declares the bar entry * instead. Kanban, calendar, timeline and gallery lists have no row selection. + * + * Delete is declared here because any authored bulk entry hides the console's + * default bulk Delete on that list, so a list naming Update Stage alone loses + * Delete (Open Deals had, before #2338). The def mirrors `all_accounts`; the + * grid offers it only to a user who may delete opportunities, and its labels + * are in the locale packs under each view's `bulkActions`. */ -const UPDATE_STAGE_BAR = [ +const GRID_BAR = [ { name: 'mass_update_stage', operation: 'custom', execution: 'aggregate' }, + { + name: 'delete', + label: 'Delete', + icon: 'trash-2', + variant: 'danger', + operation: 'delete', + confirmText: 'Permanently delete {{count}} opportunity(ies)? This cannot be undone.', + confirmLabel: 'Delete', + }, ] as const; export const OpportunityViews = defineView({ @@ -108,7 +124,7 @@ export const OpportunityViews = defineView({ // promoted from the action itself by the renderer, so the dialog and the // toolbar button cannot drift apart — and the label stays i18n-resolved, // which an inline `label:` here would not be. - bulkActionDefs: [...UPDATE_STAGE_BAR], + bulkActionDefs: [...GRID_BAR], }, listViews: { @@ -121,7 +137,7 @@ export const OpportunityViews = defineView({ */ all_opportunities: { name: 'all_opportunities', - bulkActionDefs: [...UPDATE_STAGE_BAR], + bulkActionDefs: [...GRID_BAR], type: 'grid', label: 'All Opportunities', userActions: { editInline: true, hideFields: true }, @@ -212,7 +228,7 @@ export const OpportunityViews = defineView({ /** Personal pipeline */ my_open_deals: { name: 'my_open_deals', - bulkActionDefs: [...UPDATE_STAGE_BAR], + bulkActionDefs: [...GRID_BAR], type: 'grid', label: 'My Open Deals', userActions: { editInline: true, hideFields: true }, @@ -231,7 +247,7 @@ export const OpportunityViews = defineView({ /** Stale Opportunities — still-open deals, longest-parked first */ stale_opportunities: { name: 'stale_opportunities', - bulkActionDefs: [...UPDATE_STAGE_BAR], + bulkActionDefs: [...GRID_BAR], type: 'grid', // Honest label: this view does not apply the 14-day cut its automation // twin (`opportunity_stagnation`, STALE_THRESHOLD_DAYS = 14) uses, and @@ -306,7 +322,7 @@ export const OpportunityViews = defineView({ */ tender_this_quarter: { name: 'tender_this_quarter', - bulkActionDefs: [...UPDATE_STAGE_BAR], + bulkActionDefs: [...GRID_BAR], type: 'grid', label: 'Tender This Quarter', userActions: { editInline: true, hideFields: true }, @@ -331,7 +347,7 @@ export const OpportunityViews = defineView({ /** Closing this quarter (commit + best_case) — sales-manager forecast */ closing_this_quarter: { name: 'closing_this_quarter', - bulkActionDefs: [...UPDATE_STAGE_BAR], + bulkActionDefs: [...GRID_BAR], type: 'grid', label: 'Closing This Quarter', userActions: { editInline: true, hideFields: true }, diff --git a/test/bulk-action-dispatch.test.ts b/test/bulk-action-dispatch.test.ts index ad75b3838..9c75a2f78 100644 --- a/test/bulk-action-dispatch.test.ts +++ b/test/bulk-action-dispatch.test.ts @@ -88,8 +88,10 @@ describe('bulk dispatch is declared the way the renderer reads it', () => { // only), and the list-header copy that every view did show failed on any // selection, so it was dropped. Each grid list therefore declares the bar // entry itself. A grid list is the kind the console draws row selection on; - // kanban, calendar, timeline and gallery lists draw none. - it('every opportunity grid list offers Update Stage on its selection bar (#2338 item 4)', () => { + // kanban, calendar, timeline and gallery lists draw none. Any authored bulk + // entry hides the console's default bulk Delete, so each list also declares + // Delete itself; losing it is as much a regression as losing Update Stage. + it('every opportunity grid list offers Update Stage and Delete on its selection bar (#2338 item 4)', () => { const oppView = views.find((v) => (v.list?.data?.object ?? v.object) === 'crm_opportunity'); const grids = listsOf(oppView!).filter((l) => (l.type ?? 'grid') === 'grid'); expect(grids.length, 'the opportunity views ship fewer grid lists than when this was pinned').toBeGreaterThanOrEqual(6); @@ -99,6 +101,10 @@ describe('bulk dispatch is declared the way the renderer reads it', () => { )) .map((l) => l.name); expect(missing, 'these opportunity grid lists offer no Update Stage on the selection bar').toEqual([]); + const noDelete = grids + .filter((l) => !((l.bulkActionDefs ?? []) as AnyRec[]).some((d) => d?.operation === 'delete')) + .map((l) => l.name); + expect(noDelete, 'these opportunity grid lists lost the bulk Delete on the selection bar').toEqual([]); }); it('mass_update_stage is not drawn in the list header, where it gets no selection (#2338 item 4)', () => {