Skip to content

Commit e607df7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-uzstmh
2 parents e228a1f + 0e0ddac commit e607df7

9 files changed

Lines changed: 461 additions & 31 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
fix(core): date macros in a filter resolve on the workspace timezone's day, so an authored view's `{today}` and a reader's panel `{today}` name the same day
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Before.** `resolveDateMacros` read the browser's clock. A list view's authored filter is resolved in the browser before it is sent, while a reader's filter-panel rule is sent with the macro unresolved and the server resolves it in the workspace `localization.timezone`. When the browser and the workspace were on different days, `close_date between ['{today}', '{today}']` authored on a view asked for one day and the same rule from the panel asked for the other; both on one list sent both days and matched nothing.
12+
- **Now.** The calendar tokens (`today`, `yesterday`, `tomorrow`, the week, month, quarter and year bounds, `N_days` / `N_weeks` / `N_months` / `N_years`) resolve on the day of the display zone `LocalizationProvider` declares from the served `localization.timezone`, the zone the server uses. The instant tokens (`now`, `N_minutes`, `N_hours`) stay on the real instant. With no workspace zone known, the browser's clock decides, as before. The list's authored filter is resolved again when the workspace zone arrives after the first render.
13+
- No signature changes: `resolveDateMacros(filter, now?)` keeps its parameters and reads the zone the date faces already read. Every surface that resolves macros through it (lists, kanban, calendar, gantt, timeline, map, tree, gallery, related lists, grids, dashboards, charts, compare-to, calendar `initialDate`) now names the workspace day too.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): `os migrate meta --stored` lists a stored dashboard that declares one global filter key twice
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** `DashboardSchema` refuses a dashboard whose `globalFilters` repeats a filter key (its `name`, or its `field` when it has no `name`), and the dashboard save door answers 422 `INVALID_METADATA`. A dashboard already stored in that shape still loads with `_diagnostics` reading `valid: false`, but the v18 stored-row preview had no detector for it, so `os migrate meta --stored` named no such row before the upgrade.
11+
- **Now.** The preview lists each stored dashboard that repeats a key, under the TODO id `ui-dashboard-global-filter-duplicate-name-refused` (the refusal's upgrade guide entry), at the second declaration's `name` (or `field`) — the site the parse refuses — naming the key and every index. The detector calls the refinement's own predicate, so a row is listed exactly when the parse refuses it. Nothing rewrites it: which definition keeps the key only the author knows.
12+
- **Not changed.** What any door accepts or refuses. Distinct keys, and distinct names over one `field`, are not listed.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #29212 — the dashboard save door refuses a dashboard that declares one
5+
* global filter key twice (the #29023 refusal), in every save mode.
6+
*
7+
* The rule lives in the spec parse (`duplicateGlobalFilterNames`, the
8+
* `globalFilters` refinement of `DashboardSchema`), and `saveMetaItem` parses
9+
* with that schema, so `PUT /api/v1/meta/dashboard/X` answers
10+
* `422 INVALID_METADATA` at the second declaration's key and stores nothing,
11+
* with no mode, with `mode: 'publish'` and with `mode: 'draft'` alike. This
12+
* pins that the door keeps reading the refusal; the v18 stored listing
13+
* (`refused-shapes.ts`) names a row already stored in the shape.
14+
*/
15+
import { describe, expect, it } from 'vitest';
16+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core';
17+
import { ObjectStackProtocolImplementation } from './protocol.js';
18+
19+
/** The engine surface the repository write path touches (as the container-issue-descent harness). */
20+
function makeProtocol() {
21+
const rows = new Map<string, Record<string, unknown>>();
22+
let nextId = 0;
23+
const engine: any = {
24+
async findOne(object: string, query?: EngineFindOneQueryInput) {
25+
assertEngineFindOnePredicate(object, query); return null; },
26+
async find() { return []; },
27+
async insert(table: string, data: Record<string, unknown>) {
28+
nextId += 1;
29+
if (table === 'sys_metadata') rows.set(`${data.type}|${data.name}`, data);
30+
return { id: `r_${nextId}` };
31+
},
32+
async update(_t: string, data: Record<string, unknown>, opts?: Record<string, unknown>) {
33+
assertEngineUpdateDispatch(data, opts);
34+
return { id: null };
35+
},
36+
async delete(_t: string, opts?: Record<string, unknown>) {
37+
assertEngineDeleteDispatch(opts);
38+
return { deleted: 0 };
39+
},
40+
registry: { registerItem: () => {}, registerObject: () => {} },
41+
};
42+
const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map());
43+
return { protocol, rows };
44+
}
45+
46+
async function rejection(promise: Promise<unknown>): Promise<any> {
47+
try {
48+
await promise;
49+
} catch (err) {
50+
return err;
51+
}
52+
throw new Error('expected the save to be rejected, but it resolved');
53+
}
54+
55+
const MODES = [
56+
['no mode', {}],
57+
['publish', { mode: 'publish' }],
58+
['draft', { mode: 'draft' }],
59+
] as const;
60+
61+
const boardWith = (globalFilters: unknown[]) => ({
62+
name: 'dup_board',
63+
label: 'Dup Board',
64+
widgets: [],
65+
globalFilters,
66+
});
67+
68+
describe('#29212 the save door refuses a dashboard that declares one global filter key twice', () => {
69+
for (const [label, mode] of MODES) {
70+
it(`${label}: two filters named \`r\` answer 422 INVALID_METADATA at globalFilters.1.name, and nothing is stored`, async () => {
71+
const { protocol, rows } = makeProtocol();
72+
73+
const err = await rejection(protocol.saveMetaItem({
74+
type: 'dashboard', name: 'dup_board', ...mode, item: boardWith([
75+
{ name: 'r', field: 'region', type: 'text' },
76+
{ name: 'r', field: 'status', type: 'text' },
77+
]),
78+
}));
79+
80+
expect(err.code).toBe('INVALID_METADATA');
81+
expect(err.status).toBe(422);
82+
expect(err.issues).toEqual([expect.objectContaining({ path: 'globalFilters.1.name', code: 'custom' })]);
83+
expect(err.issues[0].message.startsWith(
84+
"This dashboard declares the global filter 'r' twice in globalFilters: globalFilters[0] and globalFilters[1].",
85+
)).toBe(true);
86+
expect(rows.size).toBe(0);
87+
});
88+
89+
it(`${label}: control, the same dashboard with distinct filter names saves`, async () => {
90+
const { protocol, rows } = makeProtocol();
91+
92+
const result = await protocol.saveMetaItem({
93+
type: 'dashboard', name: 'dup_board', ...mode, item: boardWith([
94+
{ name: 'r', field: 'region', type: 'text' },
95+
{ name: 's', field: 'status', type: 'text' },
96+
]),
97+
});
98+
99+
expect(result.success).toBe(true);
100+
expect(rows.size).toBe(1);
101+
});
102+
}
103+
});

‎packages/spec/src/conversions/refused-shapes.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { flowNodeValueTemplateRefusals } from '../automation/flow-value-slot-tem
77
import { ObjectSchema } from '../data/object.zod.js';
88
import { MIGRATIONS_BY_MAJOR } from '../migrations/registry.js';
99
import { ComponentPropsMap } from '../ui/component.zod.js';
10+
import { DashboardSchema } from '../ui/dashboard.zod.js';
1011
import { PageSchema } from '../ui/page.zod.js';
1112
import { ViewDataSchema } from '../ui/view.zod.js';
1213
import { applyConversions, applyConversionsToFlow } from './apply.js';
@@ -71,6 +72,7 @@ describe('the refused-shape list — registered against the migration chain', ()
7172
'object-external-binding-managed-datasource-refused',
7273
'visibility-predicate-has-argument-refused',
7374
'object-duplicate-action-name-refused',
75+
'ui-dashboard-global-filter-duplicate-name-refused',
7476
]);
7577
});
7678

@@ -792,3 +794,62 @@ describe('#26725 — a stored object that declares one action name twice is list
792794
expect(dupActionIssue(row)).toBeUndefined();
793795
});
794796
});
797+
798+
// ── #29212 (the #29023 refusal) ───────────────────────────────────────────
799+
800+
const dupBoard = (globalFilters: unknown[]) => ({ name: 'crm_kpi', label: 'KPI', widgets: [], globalFilters });
801+
/** The dashboard parse's duplicate-key issue, or undefined. */
802+
const dupFilterIssue = (row: unknown) => {
803+
const parsed = DashboardSchema.safeParse(row);
804+
return parsed.success
805+
? undefined
806+
: parsed.error.issues.find((i) => i.path[0] === 'globalFilters' && /declares the global filter/.test(i.message));
807+
};
808+
809+
describe('#29212 — a stored dashboard that declares one global filter key twice is listed by dashboard', () => {
810+
it('one TODO at the second declaration\'s name, naming the key and both indexes; the row is not rewritten', () => {
811+
const row = dupBoard([
812+
{ name: 'r', field: 'region', type: 'text' },
813+
{ name: 'r', field: 'status', type: 'text' },
814+
]);
815+
const { item, todos } = storedTodos('dashboard', row);
816+
expect(item).toEqual(row);
817+
expect(todos).toHaveLength(1);
818+
const todo = todos[0]!;
819+
expect(todo.conversionId).toBe('ui-dashboard-global-filter-duplicate-name-refused');
820+
expect(todo.path).toBe('dashboards[0].globalFilters[1].name');
821+
expect(todo.reason).toMatch(/^No lossless rewrite: which definition keeps the key/);
822+
expect(todo.reason).toContain(
823+
"This dashboard declares the global filter 'r' twice in globalFilters: globalFilters[0] and globalFilters[1].",
824+
);
825+
expect(todo.reason).not.toMatch(/#\d{3,}/);
826+
expect(JSON.parse(todo.from)).toEqual({ name: 'r', indexes: [0, 1] });
827+
// The listing and the parse refuse ONE set: the stored row still fails the parse, at the same site.
828+
expect(dupFilterIssue(item)?.path).toEqual(['globalFilters', 1, 'name']);
829+
});
830+
831+
it('a key that falls back to `field` is listed at that field, as the parse locates it', () => {
832+
const row = dupBoard([
833+
{ field: 'region', type: 'text' },
834+
{ field: 'region', type: 'select', options: [{ value: 'north', label: 'North' }] },
835+
{ name: 'region', field: 'sales_region', type: 'text' },
836+
]);
837+
const { todos } = storedTodos('dashboard', row);
838+
expect(todos).toHaveLength(1);
839+
expect(todos[0]!.path).toBe('dashboards[0].globalFilters[1].field');
840+
expect(JSON.parse(todos[0]!.from)).toEqual({ name: 'region', indexes: [0, 1, 2] });
841+
expect(todos[0]!.reason).toContain("the global filter 'region' 3 times");
842+
expect(dupFilterIssue(row)?.path).toEqual(['globalFilters', 1, 'field']);
843+
});
844+
845+
it('distinct keys, and distinct names over one field, are not listed — and the parse accepts them', () => {
846+
for (const filters of [
847+
[{ name: 'r', field: 'region', type: 'text' }, { name: 's', field: 'status', type: 'text' }],
848+
[{ name: 'region_a', field: 'region', type: 'text' }, { name: 'region_b', field: 'region', type: 'text' }],
849+
]) {
850+
const row = dupBoard(filters);
851+
expect(storedTodos('dashboard', row).todos).toEqual([]);
852+
expect(DashboardSchema.safeParse(row).success).toBe(true);
853+
}
854+
});
855+
});

‎packages/spec/src/conversions/refused-shapes.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@
4343
* `external` binding judge
4444
* the object refinement and `defineStack` share
4545
* ({@link externalBindingDatasourceRefusal}), and the one-name-per-action
46-
* judge of the object refinement ({@link duplicateActionNames}). A site is listed here exactly
46+
* judge of the object refinement ({@link duplicateActionNames}), and the
47+
* one-key-per-global-filter judge of the dashboard refinement
48+
* ({@link duplicateGlobalFilterNames}). A site is listed here exactly
4749
* when that door refuses it. One door lives outside this package: the
4850
* page/view `visibleWhen` rule in `@objectstack/lint` judges a `has()`
4951
* argument on the engine's AST, which this package cannot parse, so its
@@ -68,6 +70,7 @@ import {
6870
import { duplicateActionNames } from '../data/duplicate-action-names.js';
6971
import { externalBindingDatasourceRefusal } from '../data/external-binding-datasource.js';
7072
import { ComponentPropsMap } from '../ui/component.zod.js';
73+
import { duplicateGlobalFilterNames } from '../ui/duplicate-global-filter-names.js';
7174
import { ViewDataSchema } from '../ui/view.zod.js';
7275
import { refusedHasArguments } from './cel-has-argument.js';
7376
import type { ConversionTodoDetail } from './types.js';
@@ -555,6 +558,32 @@ const objectDuplicateActionName: RefusedStoredShape = {
555558
},
556559
};
557560

561+
// ── #29212: a dashboard that declares one global filter key twice (the #29023 refusal) ──
562+
563+
const dashboardDuplicateGlobalFilterName: RefusedStoredShape = {
564+
id: 'ui-dashboard-global-filter-duplicate-name-refused',
565+
toMajor: 18,
566+
surface: 'dashboard.globalFilters — two or more entries of one dashboard under the same filter key '
567+
+ '(the entry\'s `name`, or its `field` when it has no `name`)',
568+
detect(stack, report) {
569+
mapCollection(stack, 'dashboards', (dashboard, path) => {
570+
for (const duplicate of duplicateGlobalFilterNames(dashboard.globalFilters)) {
571+
report({
572+
// The site the dashboard parse refuses: the second declaration's `name`, or its `field`.
573+
path: `${path}.globalFilters[${duplicate.indexes[1]}].${duplicate.secondKey}`,
574+
from: JSON.stringify({ name: duplicate.name, indexes: duplicate.indexes }),
575+
reason: 'No lossless rewrite: which definition keeps the key, and what the others are renamed to '
576+
+ '(with every widget `filterBindings` key that means a renamed one), only the author knows. Left as '
577+
+ 'stored, the dashboard still loads with `_diagnostics` reading `valid: false`, and its next save, '
578+
+ 'draft or publish, is refused with 422 INVALID_METADATA. '
579+
+ duplicate.message,
580+
});
581+
}
582+
return dashboard;
583+
});
584+
},
585+
};
586+
558587
/**
559588
* Every refused stored shape, in report order. A new entry is a reviewed edit:
560589
* `refused-shapes.test.ts` pins each one.
@@ -567,6 +596,7 @@ export const REFUSED_STORED_SHAPES: readonly RefusedStoredShape[] = [
567596
externalBindingOnManagedDatasource,
568597
visibilityHasArgument,
569598
objectDuplicateActionName,
599+
dashboardDuplicateGlobalFilterName,
570600
];
571601

572602
/**

‎scripts/engine-double-contract.pinned.json‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,21 @@
636636
"verb": "update",
637637
"pinned": 1
638638
},
639+
{
640+
"file": "packages/metadata-protocol/src/protocol.dashboard-duplicate-global-filter-name-refusal.test.ts",
641+
"verb": "delete",
642+
"pinned": 1
643+
},
644+
{
645+
"file": "packages/metadata-protocol/src/protocol.dashboard-duplicate-global-filter-name-refusal.test.ts",
646+
"verb": "findOne",
647+
"pinned": 1
648+
},
649+
{
650+
"file": "packages/metadata-protocol/src/protocol.dashboard-duplicate-global-filter-name-refusal.test.ts",
651+
"verb": "update",
652+
"pinned": 1
653+
},
639654
{
640655
"file": "packages/metadata-protocol/src/protocol.data-door-internal-field-delegation.test.ts",
641656
"verb": "findOne",

‎ui/packages/app-shell/src/views/objectViewListRenderer.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { ListView, type ListViewProps } from '@object-ui/plugin-list';
1919
import type { ListViewSchema, TreeViewConfig } from '@object-ui/types';
2020
import type { useAuth } from '@object-ui/auth';
2121
import type { useActionTextLocalizer } from '@object-ui/react';
22-
import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
22+
import { useLocalization, type useObjectLabel, type useObjectTranslation } from '@object-ui/i18n';
2323
import { resolveManagedByEmptyState } from '../utils/managedByEmptyState.js';
2424
import { isSavedViewId } from '../utils/viewIdentity.js';
2525
import { warnSuppressedListNav } from '../utils/warnSuppressedListNav.js';
@@ -167,6 +167,11 @@ export function useObjectViewListRenderer({
167167
const actionsRef = useRef(actions);
168168
actionsRef.current = actions;
169169
const createRecord = useCallback(() => actionsRef.current.create(), []);
170+
// The workspace zone the authored filter's date macros resolve in
171+
// (`resolveDateMacros` reads it from core). It is served after the first
172+
// render, so it is a dependency of the callback below: when it lands, the
173+
// view's filter is resolved again and a changed day reaches the list.
174+
const { timezone: workspaceTimeZone } = useLocalization();
170175

171176
const renderListView = useCallback(({ schema: listSchema, dataSource: ds, onEdit: editHandler, className, refreshKey: pluginRefreshKey }: any) => {
172177
// Combine local refreshKey with the plugin ObjectView's refreshKey for full propagation
@@ -531,7 +536,8 @@ export function useObjectViewListRenderer({
531536
// objectui#7237 — equal content keeps the identity `ListView`
532537
// names (and hands the self-querying views); a real change —
533538
// another URL filter, another session scope, a date macro that
534-
// resolves differently — still hands over the new value.
539+
// resolves differently (a new day, or the workspace zone
540+
// arriving) — still hands over the new value.
535541
if (isSameOptionsValue(heldListFilter.current, resolved)) {
536542
return heldListFilter.current;
537543
}
@@ -973,7 +979,7 @@ export function useObjectViewListRenderer({
973979
dataSource={ds}
974980
/>
975981
);
976-
}, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, createRecord, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, handleListTotalChange, managedUsers, managedUserMarker, recordOpen, openAsPage, handleListRowsChange, phoneTitle, handleListPersonalizationChange]);
982+
}, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, createRecord, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, handleListTotalChange, managedUsers, managedUserMarker, recordOpen, openAsPage, handleListRowsChange, phoneTitle, handleListPersonalizationChange, workspaceTimeZone]);
977983

978984
return { renderListView };
979985
}

0 commit comments

Comments
 (0)