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
13 changes: 13 additions & 0 deletions .changeset/24701-by-object-group-headers.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string | { field: string }>;
sort?: ReadonlyArray<{ field: string; order?: string }>;
grouping?: { fields?: ReadonlyArray<{ field: string }> };
};
type ObjectDef = {
name: string;
fields: Record<string, { widget?: unknown; referenceVia?: unknown } | undefined>;
listViews?: Record<string, ViewDef>;
};

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<string>();
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<string, ViewDef> | 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<string, { widget?: unknown }>;
expect(fields.object_name.widget).toBe('object-ref');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
59 changes: 59 additions & 0 deletions packages/plugins/plugin-webhooks/src/sys-webhook.by-object.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | { field: string }>;
sort?: ReadonlyArray<{ field: string; order?: string }>;
grouping?: { fields?: ReadonlyArray<{ field: string }> };
};

const fields = SysWebhook.fields as Record<string, { widget?: unknown; referenceVia?: unknown } | undefined>;
const views = (SysWebhook.listViews ?? {}) as Record<string, ViewDef>;

/** The fields whose stored value is an object's API name. */
function apiNameFields(): string[] {
const names = new Set<string>();
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');
});
});
6 changes: 5 additions & 1 deletion packages/plugins/plugin-webhooks/src/sys-webhook.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading