diff --git a/.changeset/24701-by-object-group-headers.md b/.changeset/24701-by-object-group-headers.md new file mode 100644 index 00000000000..6d3cde2cdfd --- /dev/null +++ b/.changeset/24701-by-object-group-headers.md @@ -0,0 +1,13 @@ +--- +'@objectstack/plugin-sharing': patch +'@objectstack/plugin-webhooks': patch +--- + +fix(plugin-sharing, plugin-webhooks): the `by_object` lists of sharing rules and webhooks no longer head each group with an object API name + +Clause-②: no + +- **`sys_sharing_rule.by_object` and `sys_webhook.by_object` sort by object instead of grouping on it.** Both grouped on `object_name`, and a group header draws its value as stored text, so each group was headed by an API name such as `crm_account`. The sort on `object_name`, then `label`, still keeps one object's rows together, and the Object column still draws each object's icon and label. +- **Unchanged:** `object_name` stays a declared field on both objects, with the same widget, and no stored column, index or query changes. + +A patch: one list view key is removed on each of two shipped platform objects. No export, type, accepted value, stored column or authorable key changes. diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.by-object.test.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.by-object.test.ts new file mode 100644 index 00000000000..12ef8637541 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.by-object.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SysSharingRule } from './sys-sharing-rule.object.js'; +import * as SharingObjects from './index.js'; + +/** + * [#24701] No sharing list view groups on a field whose stored value is an + * object's API name. + * + * The console (objectui at this repository's `.objectui-sha`) heads each group + * with the grouping value as text: its group formatter turns a select value + * into its option label and a boolean into Yes / No, and draws anything else + * as stored. An `object-ref` field's CELL draws the object's icon and label, + * but its group header drew the API name (`crm_account`), so `by_object` read + * one machine name per group. #24611 removed the same grouping from + * `sys_record_share.by_object`, where the field is a pointer pair's type half. + * + * A field stores an object's API name when it declares the `object-ref` + * widget (the object picker) or when it is the `referenceVia` sibling of a + * pointer pair. Enumerated over every object this package ships + * (`objects/index.ts`), so a view added later is judged without anyone + * listing it here. + */ +describe('sharing list views — no grouping on a field that stores an object API name (#24701)', () => { + type ViewDef = { + columns?: ReadonlyArray; + sort?: ReadonlyArray<{ field: string; order?: string }>; + grouping?: { fields?: ReadonlyArray<{ field: string }> }; + }; + type ObjectDef = { + name: string; + fields: Record; + listViews?: Record; + }; + + const objects = Object.values(SharingObjects) as unknown as ObjectDef[]; + + /** The fields of `object` whose stored value is an object's API name. */ + function apiNameFields(object: ObjectDef): string[] { + const names = new Set(); + for (const [name, def] of Object.entries(object.fields)) { + if (def?.widget === 'object-ref') names.add(name); + if (typeof def?.referenceVia === 'string') names.add(def.referenceVia); + } + return [...names].sort(); + } + + it('finds the API-name field on every sharing object, so the case below cannot pass on an empty set', () => { + const found = Object.fromEntries(objects.map((o) => [o.name, apiNameFields(o)])); + expect(found).toEqual({ + sys_record_share: ['object_name'], + sys_sharing_rule: ['object_name'], + sys_share_link: ['object_name'], + }); + }); + + it('no list view groups on a field that stores an object API name', () => { + const hits = objects.flatMap((o) => { + const apiNames = new Set(apiNameFields(o)); + return Object.entries(o.listViews ?? {}).flatMap(([view, def]) => + (def.grouping?.fields ?? []) + .filter((g) => apiNames.has(g.field)) + .map((g) => `${o.name}.${view}: ${g.field}`)); + }); + expect(hits).toEqual([]); + }); + + it('control: sys_sharing_rule.by_object still keeps one object\'s rules together and shows the Object column', () => { + const view = (SysSharingRule.listViews as Record | undefined)?.by_object; + expect(view?.sort?.[0]).toEqual({ field: 'object_name', order: 'asc' }); + expect(view?.columns?.[0]).toBe('object_name'); + const fields = SysSharingRule.fields as Record; + expect(fields.object_name.widget).toBe('object-ref'); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index 66b0adb1244..5597d023128 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -70,8 +70,12 @@ export const SysSharingRule = ObjectSchema.create({ label: 'By Object', data: { provider: 'object', object: 'sys_sharing_rule' }, columns: ['object_name', 'label', 'recipient_type', 'access_level', 'active'], + // [#24701] Ordered by the target object, not grouped on it: a group header + // draws `object_name` as its stored API name, while the Object cell draws + // the object's icon and label (its `object-ref` widget). No column stores + // the label, so no grouping key could carry it. The sort keeps one + // object's rules together (`sys-sharing-rule.by-object.test.ts`). sort: [{ field: 'object_name', order: 'asc' }, { field: 'label', order: 'asc' }], - grouping: { fields: [{ field: 'object_name', order: 'asc', collapsed: false }] }, pagination: { pageSize: 100 }, }, all_rules: { diff --git a/packages/plugins/plugin-webhooks/src/sys-webhook.by-object.test.ts b/packages/plugins/plugin-webhooks/src/sys-webhook.by-object.test.ts new file mode 100644 index 00000000000..64c8bada013 --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/sys-webhook.by-object.test.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SysWebhook } from './sys-webhook.object.js'; + +/** + * [#24701] No `sys_webhook` list view groups on a field whose stored value is + * an object's API name. + * + * The console (objectui at this repository's `.objectui-sha`) heads each group + * with the grouping value as text: its group formatter turns a select value + * into its option label and a boolean into Yes / No, and draws anything else + * as stored. `object_name` declares the `object-ref` widget, so its CELL draws + * the object's icon and label, but its group header drew the API name + * (`crm_account`), so `by_object` read one machine name per group. + * + * A field stores an object's API name when it declares the `object-ref` + * widget (the object picker) or when it is the `referenceVia` sibling of a + * pointer pair. Enumerated over every view the object declares, so a view + * added later is judged without anyone listing it here. + */ +describe('sys_webhook list views — no grouping on a field that stores an object API name (#24701)', () => { + type ViewDef = { + columns?: ReadonlyArray; + sort?: ReadonlyArray<{ field: string; order?: string }>; + grouping?: { fields?: ReadonlyArray<{ field: string }> }; + }; + + const fields = SysWebhook.fields as Record; + const views = (SysWebhook.listViews ?? {}) as Record; + + /** The fields whose stored value is an object's API name. */ + function apiNameFields(): string[] { + const names = new Set(); + for (const [name, def] of Object.entries(fields)) { + if (def?.widget === 'object-ref') names.add(name); + if (typeof def?.referenceVia === 'string') names.add(def.referenceVia); + } + return [...names].sort(); + } + + it('finds the API-name field, so the case below cannot pass on an empty set', () => { + expect(apiNameFields()).toEqual(['object_name']); + }); + + it('no list view groups on a field that stores an object API name', () => { + const apiNames = new Set(apiNameFields()); + const hits = Object.entries(views).flatMap(([view, def]) => + (def.grouping?.fields ?? []) + .filter((g) => apiNames.has(g.field)) + .map((g) => `sys_webhook.${view}: ${g.field}`)); + expect(hits).toEqual([]); + }); + + it('control: by_object still keeps one object\'s webhooks together and shows the Object column', () => { + expect(views.by_object?.sort?.[0]).toEqual({ field: 'object_name', order: 'asc' }); + expect(views.by_object?.columns?.[0]).toBe('object_name'); + }); +}); diff --git a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts index 18789f7cccb..1e3bf6397b9 100644 --- a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts +++ b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts @@ -85,8 +85,12 @@ export const SysWebhook = ObjectSchema.create({ label: 'By Object', data: { provider: 'object', object: 'sys_webhook' }, columns: ['object_name', 'label', 'url', 'active', 'updated_at'], + // [#24701] Ordered by the target object, not grouped on it: a group header + // draws `object_name` as its stored API name, while the Object cell draws + // the object's icon and label (its `object-ref` widget). No column stores + // the label, so no grouping key could carry it. The sort keeps one + // object's webhooks together (`sys-webhook.by-object.test.ts`). sort: [{ field: 'object_name', order: 'asc' }, { field: 'label', order: 'asc' }], - grouping: { fields: [{ field: 'object_name', order: 'asc', collapsed: false }] }, pagination: { pageSize: 100 }, }, all_webhooks: {