@object-ui/react 11.4.0 → 13.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,77 @@
1
1
  # @object-ui/react
2
2
 
3
+ ## 13.1.0
4
+
5
+ ### Patch Changes
6
+
7
+ - @object-ui/types@13.1.0
8
+ - @object-ui/core@13.1.0
9
+ - @object-ui/i18n@13.1.0
10
+ - @object-ui/data-objectstack@13.1.0
11
+
12
+ ## 13.0.0
13
+
14
+ ### Patch Changes
15
+
16
+ - Updated dependencies [9e38270]
17
+ - Updated dependencies [619097e]
18
+ - @object-ui/i18n@13.0.0
19
+ - @object-ui/types@13.0.0
20
+ - @object-ui/core@13.0.0
21
+ - @object-ui/data-objectstack@13.0.0
22
+
23
+ ## 12.1.0
24
+
25
+ ### Patch Changes
26
+
27
+ - Updated dependencies [6cbccf3]
28
+ - Updated dependencies [e1840bf]
29
+ - Updated dependencies [c31874d]
30
+ - @object-ui/i18n@12.1.0
31
+ - @object-ui/types@12.1.0
32
+ - @object-ui/core@12.1.0
33
+ - @object-ui/data-objectstack@12.1.0
34
+
35
+ ## 12.0.0
36
+
37
+ ### Patch Changes
38
+
39
+ - Updated dependencies [226fde9]
40
+ - Updated dependencies [e4de456]
41
+ - @object-ui/types@12.0.0
42
+ - @object-ui/core@12.0.0
43
+ - @object-ui/data-objectstack@12.0.0
44
+ - @object-ui/i18n@12.0.0
45
+
46
+ ## 11.5.0
47
+
48
+ ### Minor Changes
49
+
50
+ - 6fffd3d: Client-side data-invalidation bus — refresh data, don't rebuild UI (objectui#2269 P1).
51
+
52
+ - `@object-ui/react` gains the bus: `notifyDataChanged({objectName, recordId?})`, `useDataInvalidation(objectName, recordId?)` (reader nonce), `subscribeDataChanges`, and `useMutationInvalidationBridge(dataSource)` which fans every dataSource write (`MutationEvent`) onto the bus. The bus also dispatches the legacy `objectui:related-changed` window event, so pre-bus listeners keep working.
53
+ - The `key={refreshKey}` remount of `RecordDetailView` (AppContent) and the `key={actionRefreshKey}` remount of `DetailView` (RecordDetailView) are GONE: record data now refetches in place via the bus — scroll, collapsed sections, tabs and in-progress inline edits survive every save/action/undo. All nine action-success bumps became precisely-scoped `notifyDataChanged` calls; undo/redo use the operation's own `objectName`/`recordId`.
54
+ - `RelatedCountStore` is wired to the bus (tab count badges refetch after any change to their object) and its `useSyncExternalStore` snapshot is now a monotonic version — previously it returned the same `Map` reference, so `emit()` never re-rendered subscribers and invalidations left badges stale; `useRelatedCountVersion()` is exported and drives the probe effect's re-fetch.
55
+ - app-shell also gains the reserved URL-param registry (`urlParams.ts` — `form`/`formObject`/`formLink`/`tab`/`recordId`/`palette`/`shortcuts` constants replace scattered string literals) and AGENTS.md Commandment #8 (UI-state classification: state that must survive a data refresh may never live only in an uncontrolled component).
56
+
57
+ ### Patch Changes
58
+
59
+ - fae75e2: Fix two bugs verified still-present after #2254 claimed to resolve them (framework#2620 / framework#2616 Showcase UX pass, tracked in #2268):
60
+
61
+ - **Wizard/form `submitBehavior: 'thank-you'` allowed duplicate resubmission.** #2254 fixed the spec-bridge dropping `submitBehavior` before it reached the renderer, so the configured toast message started appearing — but `WizardForm`'s last step and `ObjectForm`'s submit handler only ever called `toast.success(...)` for `thank-you`/`next-record`; the form stayed mounted and fully filled with its submit button re-enabled once the request settled, so a second click created a second record. Both components now track a terminal `submitted` state and, when set, replace the form with a confirmation panel (using the behavior's `title`/`message`, which were also never read before) — mirroring the pattern `apps/console/src/components/FormPage.tsx` already used for its own standalone forms.
62
+
63
+ - **Command Center-style 3-up chart bands stayed collapsed to ~100-130px, and a dataset-bound chart's measure leaked its raw field name.**
64
+ - `responsiveStyles` (and `style`) were declared on the page-spec `PageComponent` bridge input type but never copied onto the `SchemaNode` in `spec-bridge/bridges/page.ts::mapComponent()` — so a page author's ADR-0065 layout override (e.g. forcing `display: 'grid'` on a `type: 'flex'` band) never reached `SchemaRenderer`, and the node silently fell back to its default flex layout. Both fields are now mapped through.
65
+ - `ObjectChart`'s dataset-bound fetch path (`schema.dataset` + `ds.queryDataset(...)`) discarded the response's `fields` array (which carries each measure's `label`, e.g. `{ name: 'task_count', label: 'Tasks' }`) before it ever reached `buildChartSeries()` — whose `fields` param already resolves this correctly (see `chart-series.test.ts`) — so the legend/tooltip always fell back to the raw field name. The fetched `fields` are now captured and threaded through.
66
+
67
+ - Updated dependencies [544d8eb]
68
+ - Updated dependencies [9255686]
69
+ - Updated dependencies [1072701]
70
+ - @object-ui/i18n@11.5.0
71
+ - @object-ui/types@11.5.0
72
+ - @object-ui/data-objectstack@11.5.0
73
+ - @object-ui/core@11.5.0
74
+
3
75
  ## 11.4.0
4
76
 
5
77
  ### Patch Changes
@@ -66,6 +66,15 @@ export interface RelatedRecordHandlers {
66
66
  rowActions?: RelatedRowActionDef[];
67
67
  /** Execute one of {@link rowActions} against a specific child row. */
68
68
  onRowAction?: (action: RelatedRowActionDef, record: unknown) => void | Promise<void>;
69
+ /**
70
+ * Child object list-level actions (`locations: ['list_toolbar']`),
71
+ * localized — rendered as header buttons on the related list (e.g.
72
+ * `invite_user` on an organization's Invitations list). Same bridge
73
+ * contract as {@link rowActions}, minus the row context.
74
+ */
75
+ toolbarActions?: RelatedRowActionDef[];
76
+ /** Execute one of {@link toolbarActions} (no row context). */
77
+ onToolbarAction?: (action: RelatedRowActionDef) => void | Promise<void>;
69
78
  }
70
79
  /** Input to {@link RelatedRecordActionsValue.resolve}. */
71
80
  export interface ResolveRelatedRecordActionsInput {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /** What changed. `objectName: '*'` means "unknown — treat everything as stale". */
9
+ export interface DataChange {
10
+ objectName: string;
11
+ recordId?: string;
12
+ }
13
+ type DataChangeListener = (change: DataChange) => void;
14
+ /** Legacy window event kept for pre-#2269 listeners (see RelatedList). */
15
+ export declare const RELATED_CHANGED_EVENT = "objectui:related-changed";
16
+ /**
17
+ * Declare that data changed. Call this from every WRITE path after the
18
+ * mutation succeeds; matching {@link useDataInvalidation} readers refetch.
19
+ */
20
+ export declare function notifyDataChanged(change: DataChange): void;
21
+ /** Subscribe to every change (unfiltered). Returns the unsubscribe function. */
22
+ export declare function subscribeDataChanges(listener: DataChangeListener): () => void;
23
+ /** True when `change` makes data for (objectName, recordId?) stale. */
24
+ export declare function dataChangeMatches(change: DataChange, objectName: string, recordId?: string): boolean;
25
+ /**
26
+ * Reader hook: returns a nonce that bumps whenever matching data changes.
27
+ * Put it in your fetch effect's dependency array — the refetch happens in
28
+ * place, no remount:
29
+ *
30
+ * ```ts
31
+ * const invalidationNonce = useDataInvalidation(objectName, recordId);
32
+ * useEffect(() => { void load(); }, [objectName, recordId, invalidationNonce]);
33
+ * ```
34
+ */
35
+ export declare function useDataInvalidation(objectName?: string, recordId?: string): number;
36
+ /**
37
+ * Bridge `DataSource.onMutation` (the automatic event every dataSource write
38
+ * already emits — see `MutationEvent` in `@object-ui/types`) onto this bus.
39
+ * Mount ONCE per app (the console does it in `AppContent`); after that, every
40
+ * create/update/delete performed through the dataSource reaches all
41
+ * {@link useDataInvalidation} readers with no manual `notifyDataChanged`
42
+ * call. Manual calls remain for writes that BYPASS the dataSource (server
43
+ * actions over raw HTTP, flow completions, approvals).
44
+ */
45
+ export declare function useMutationInvalidationBridge(dataSource: unknown): void;
46
+ export {};
@@ -0,0 +1,124 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * data-invalidation — the client-side data invalidation bus (objectui#2269).
10
+ *
11
+ * "Refresh data, don't rebuild UI": after a write (create / update / delete /
12
+ * record action), the WRITER declares what changed — `notifyDataChanged({
13
+ * objectName, recordId? })` — and every mounted READER of that data refetches
14
+ * in place via {@link useDataInvalidation}. The component tree is never
15
+ * remounted for a data refresh, so UI state that lives in components (scroll,
16
+ * collapsed sections, in-progress inline edits) survives every save.
17
+ *
18
+ * This generalizes two pre-existing one-off mechanisms into one seam:
19
+ * - the `objectui:related-changed` window event (RelatedList refetch) — the
20
+ * bus still dispatches it for backward compatibility, and
21
+ * `notifyRelatedChanged` in app-shell now delegates here;
22
+ * - `RelatedCountStore.invalidate` (tab count badges) — wired to the bus by
23
+ * `@object-ui/components`.
24
+ *
25
+ * It deliberately replaces the `key={refreshKey}`-style "bump a key to
26
+ * remount the subtree" pattern, which conflated data refresh with UI
27
+ * reconstruction (AGENTS.md Commandment #8).
28
+ *
29
+ * Scope semantics:
30
+ * - `notifyDataChanged({ objectName })` — object-wide: lists, counts, and
31
+ * every record of that object may be stale;
32
+ * - `notifyDataChanged({ objectName, recordId })` — one record changed
33
+ * (lists of that object are still refreshed — membership/ordering may
34
+ * have changed);
35
+ * - `notifyDataChanged({ objectName: '*' })` — everything (undo/redo of an
36
+ * unknown operation).
37
+ */
38
+ import { useEffect, useState } from 'react';
39
+ const listeners = new Set();
40
+ /** Legacy window event kept for pre-#2269 listeners (see RelatedList). */
41
+ export const RELATED_CHANGED_EVENT = 'objectui:related-changed';
42
+ /**
43
+ * Declare that data changed. Call this from every WRITE path after the
44
+ * mutation succeeds; matching {@link useDataInvalidation} readers refetch.
45
+ */
46
+ export function notifyDataChanged(change) {
47
+ for (const l of Array.from(listeners)) {
48
+ try {
49
+ l(change);
50
+ }
51
+ catch {
52
+ // one bad listener must not starve the rest
53
+ }
54
+ }
55
+ // Backward-compat bridge: pre-bus consumers listen for this window event.
56
+ if (typeof window !== 'undefined') {
57
+ window.dispatchEvent(new CustomEvent(RELATED_CHANGED_EVENT, { detail: { objectName: change.objectName, recordId: change.recordId } }));
58
+ }
59
+ }
60
+ /** Subscribe to every change (unfiltered). Returns the unsubscribe function. */
61
+ export function subscribeDataChanges(listener) {
62
+ listeners.add(listener);
63
+ return () => listeners.delete(listener);
64
+ }
65
+ /** True when `change` makes data for (objectName, recordId?) stale. */
66
+ export function dataChangeMatches(change, objectName, recordId) {
67
+ if (change.objectName === '*')
68
+ return true;
69
+ if (change.objectName !== objectName)
70
+ return false;
71
+ // An object-scoped change staled every record; a record-scoped change
72
+ // stales that record and the object's lists (a hook WITHOUT recordId is a
73
+ // list/aggregate reader — membership or ordering may have shifted).
74
+ if (recordId === undefined || change.recordId === undefined)
75
+ return true;
76
+ return change.recordId === recordId;
77
+ }
78
+ /**
79
+ * Reader hook: returns a nonce that bumps whenever matching data changes.
80
+ * Put it in your fetch effect's dependency array — the refetch happens in
81
+ * place, no remount:
82
+ *
83
+ * ```ts
84
+ * const invalidationNonce = useDataInvalidation(objectName, recordId);
85
+ * useEffect(() => { void load(); }, [objectName, recordId, invalidationNonce]);
86
+ * ```
87
+ */
88
+ export function useDataInvalidation(objectName, recordId) {
89
+ const [nonce, setNonce] = useState(0);
90
+ useEffect(() => {
91
+ if (!objectName)
92
+ return;
93
+ return subscribeDataChanges((change) => {
94
+ if (dataChangeMatches(change, objectName, recordId)) {
95
+ setNonce((n) => n + 1);
96
+ }
97
+ });
98
+ }, [objectName, recordId]);
99
+ return nonce;
100
+ }
101
+ /**
102
+ * Bridge `DataSource.onMutation` (the automatic event every dataSource write
103
+ * already emits — see `MutationEvent` in `@object-ui/types`) onto this bus.
104
+ * Mount ONCE per app (the console does it in `AppContent`); after that, every
105
+ * create/update/delete performed through the dataSource reaches all
106
+ * {@link useDataInvalidation} readers with no manual `notifyDataChanged`
107
+ * call. Manual calls remain for writes that BYPASS the dataSource (server
108
+ * actions over raw HTTP, flow completions, approvals).
109
+ */
110
+ export function useMutationInvalidationBridge(dataSource) {
111
+ useEffect(() => {
112
+ const ds = dataSource;
113
+ if (!ds?.onMutation)
114
+ return;
115
+ return ds.onMutation((e) => {
116
+ if (!e?.resource)
117
+ return;
118
+ notifyDataChanged({
119
+ objectName: e.resource,
120
+ recordId: e.id != null ? String(e.id) : undefined,
121
+ });
122
+ });
123
+ }, [dataSource]);
124
+ }
@@ -60,4 +60,6 @@ export declare function useExpression(expression: string | boolean | number | nu
60
60
  * const isVisible = useCondition('${data.status === "active"}', { data });
61
61
  * ```
62
62
  */
63
- export declare function useCondition(condition: string | boolean | undefined, context?: Record<string, any>): boolean;
63
+ export declare function useCondition(condition: string | boolean | undefined, context?: Record<string, any>, options?: {
64
+ throwOnError?: boolean;
65
+ }): boolean;
@@ -95,11 +95,23 @@ export function useExpression(expression, context = {}) {
95
95
  * const isVisible = useCondition('${data.status === "active"}', { data });
96
96
  * ```
97
97
  */
98
- export function useCondition(condition, context = {}) {
98
+ export function useCondition(condition, context = {}, options) {
99
99
  const scope = usePredicateScope();
100
100
  // We evaluate directly without caching the evaluator to avoid issues with context changes
101
101
  return useMemo(() => {
102
102
  const evaluator = new ExpressionEvaluator({ ...scope, ...context });
103
+ if (options?.throwOnError) {
104
+ // Fail-closed: a predicate that can't be evaluated hides/disables
105
+ // rather than defaulting to visible — mirrors ActionEngine's
106
+ // getActionsForLocation contract, opted into by callers gating a
107
+ // real action rather than passive display content.
108
+ try {
109
+ return evaluator.evaluateCondition(condition, { throwOnError: true });
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ }
103
115
  return evaluator.evaluateCondition(condition);
104
- }, [condition, context, scope]);
116
+ }, [condition, context, scope, options?.throwOnError]);
105
117
  }
package/dist/index.d.ts CHANGED
@@ -11,5 +11,6 @@ export * from './context';
11
11
  export * from './components/form';
12
12
  export * from './LazyPluginLoader';
13
13
  export * from './spec-bridge';
14
+ export * from './data-invalidation';
14
15
  export { resolveI18nLabel } from './utils/i18n';
15
16
  export { I18nProvider, useObjectTranslation, useObjectLabel, useSafeFieldLabel, useI18nContext, createI18n, getDirection, getAvailableLanguages, formatDate, formatDateTime, formatRelativeTime, formatCurrency, formatNumber, type I18nConfig, type I18nProviderProps, type TranslationKeys, type DateFormatOptions, type CurrencyFormatOptions, type NumberFormatOptions, } from '@object-ui/i18n';
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ export * from './context'; // will be empty for now
11
11
  export * from './components/form';
12
12
  export * from './LazyPluginLoader';
13
13
  export * from './spec-bridge';
14
+ export * from './data-invalidation';
14
15
  // i18n utilities
15
16
  export { resolveI18nLabel } from './utils/i18n';
16
17
  // Built-in i18n support
@@ -38,6 +38,7 @@ interface FormViewSpec {
38
38
  ariaDescribedBy?: string;
39
39
  role?: string;
40
40
  };
41
+ submitBehavior?: any;
41
42
  }
42
43
  /** Transforms a FormView spec into a Form SchemaNode */
43
44
  export declare const bridgeFormView: BridgeFn<FormViewSpec>;
@@ -68,6 +68,8 @@ export const bridgeFormView = (spec, _context) => {
68
68
  node.groups = spec.groups;
69
69
  if (spec.defaultSort)
70
70
  node.defaultSort = spec.defaultSort;
71
+ if (spec.submitBehavior)
72
+ node.submitBehavior = spec.submitBehavior;
71
73
  // P1.6 — i18n & ARIA
72
74
  if (spec.sharing)
73
75
  node.sharing = spec.sharing;
@@ -13,6 +13,7 @@ interface PageComponent {
13
13
  properties?: Record<string, any>;
14
14
  events?: Record<string, any>;
15
15
  style?: Record<string, any>;
16
+ responsiveStyles?: Record<string, any>;
16
17
  className?: string;
17
18
  visibility?: string;
18
19
  dataSource?: any;
@@ -14,6 +14,15 @@ function mapComponent(component) {
14
14
  node.label = component.label;
15
15
  if (component.className)
16
16
  node.className = component.className;
17
+ if (component.style)
18
+ node.style = component.style;
19
+ // ADR-0065 scoped per-breakpoint styles — declared on the page-spec
20
+ // component (sibling of className/style) but previously dropped here
21
+ // before reaching SchemaRenderer, so a layout override like
22
+ // `{ large: { display: 'grid', gridTemplateColumns: '...' } }` never
23
+ // compiled to CSS and the node fell back to its default layout.
24
+ if (component.responsiveStyles)
25
+ node.responsiveStyles = component.responsiveStyles;
17
26
  if (component.properties) {
18
27
  // Avoid overwriting the component dispatch keys (`type`/`id`) with inner
19
28
  // renderer-specific props. E.g. PageTabsProps.properties.type is the tab
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/react",
3
- "version": "11.4.0",
3
+ "version": "13.1.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "React bindings and SchemaRenderer component for Object UI",
@@ -24,12 +24,12 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "@objectstack/spec": "^11.7.0",
28
- "react-hook-form": "^7.80.0",
29
- "@object-ui/core": "11.4.0",
30
- "@object-ui/data-objectstack": "11.4.0",
31
- "@object-ui/i18n": "11.4.0",
32
- "@object-ui/types": "11.4.0"
27
+ "@objectstack/spec": "^13.0.0",
28
+ "react-hook-form": "^7.81.0",
29
+ "@object-ui/core": "13.1.0",
30
+ "@object-ui/data-objectstack": "13.1.0",
31
+ "@object-ui/i18n": "13.1.0",
32
+ "@object-ui/types": "13.1.0"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "react": "^18.0.0 || ^19.0.0",