@object-ui/app-shell 4.2.1 → 4.3.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,50 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 4.3.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 079c3b2: feat(plugin-report): per-block field resolution for joined reports
8
+
9
+ Joined report blocks can override `objectName` to query a different
10
+ object than the container, but the editor was always offering the
11
+ container's fields — wrong field names, wrong types, broken granularity
12
+ and chart-axis filtering.
13
+
14
+ `ReportConfigPanel` now accepts an optional `getFieldsForObject`
15
+ resolver. `JoinedBlocksEditor` uses it to source fields for each
16
+ block based on `block.objectName ?? containerObjectName`, falling
17
+ back to the static `availableFields` when the resolver returns
18
+ `undefined` (unknown object).
19
+
20
+ `ReportView` wires the resolver against the app's loaded `objects`
21
+ list and reuses the same parsing path internally to derive its
22
+ top-level `availableFields`, removing the duplicated schema lookup.
23
+
24
+ 5 new RTL tests verify the resolver wiring, fallback behaviour,
25
+ add-block flow, and inline duplicate-name validation (111 plugin-report
26
+ tests green).
27
+
28
+ - 154a36c: fix
29
+ - fed4897: fix
30
+ - Updated dependencies [f196cf4]
31
+ - Updated dependencies [ee1cc96]
32
+ - Updated dependencies [0b032be]
33
+ - Updated dependencies [115d36a]
34
+ - Updated dependencies [4e7bc1b]
35
+ - Updated dependencies [8442c05]
36
+ - @object-ui/i18n@4.3.0
37
+ - @object-ui/components@4.3.0
38
+ - @object-ui/fields@4.3.0
39
+ - @object-ui/react@4.3.0
40
+ - @object-ui/layout@4.3.0
41
+ - @object-ui/types@4.3.0
42
+ - @object-ui/core@4.3.0
43
+ - @object-ui/data-objectstack@4.3.0
44
+ - @object-ui/auth@4.3.0
45
+ - @object-ui/permissions@4.3.0
46
+ - @object-ui/collaboration@4.3.0
47
+
3
48
  ## 4.2.1
4
49
 
5
50
  ### Patch Changes
@@ -0,0 +1,10 @@
1
+ export interface ManagedByBadgeProps {
2
+ /** The `managedBy` flag from the object schema. */
3
+ managedBy?: string;
4
+ /** Optional override for the human-readable system name shown in the tooltip. */
5
+ label?: string;
6
+ /** Optional extra classes. */
7
+ className?: string;
8
+ }
9
+ export declare function ManagedByBadge({ managedBy, label, className }: ManagedByBadgeProps): import("react/jsx-runtime").JSX.Element | null;
10
+ export default ManagedByBadge;
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ShieldAlert, Settings2, Lock, Archive } from 'lucide-react';
3
+ import { Badge, Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, cn, } from '@object-ui/components';
4
+ const VARIANTS = {
5
+ config: {
6
+ icon: Settings2,
7
+ short: 'Admin config',
8
+ title: 'Administrator configuration',
9
+ body: () => 'These rows define how the platform behaves at runtime. Author them here; the runtime data they produce lives in a separate table.',
10
+ tone: 'border-sky-300/60 bg-sky-50 text-sky-900 hover:bg-sky-100 dark:border-sky-500/40 dark:bg-sky-950/40 dark:text-sky-100',
11
+ },
12
+ system: {
13
+ icon: Lock,
14
+ short: 'System-managed',
15
+ title: 'Managed by the platform',
16
+ body: () => 'Rows here are created automatically when actions run on the source record. The list below is a read-only monitoring surface — row-level actions (Approve, Recall, Resend, …) live on each row.',
17
+ tone: 'border-slate-300/60 bg-slate-50 text-slate-900 hover:bg-slate-100 dark:border-slate-500/40 dark:bg-slate-950/40 dark:text-slate-100',
18
+ },
19
+ 'append-only': {
20
+ icon: Archive,
21
+ short: 'Read-only · Audit log',
22
+ title: 'Read-only historical record',
23
+ body: () => "Immutable audit log. Rows cannot be created, edited, or deleted from the UI — they're written by the platform when events occur. Use Export to download for compliance review.",
24
+ tone: 'border-zinc-300/60 bg-zinc-50 text-zinc-900 hover:bg-zinc-100 dark:border-zinc-500/40 dark:bg-zinc-950/40 dark:text-zinc-100',
25
+ },
26
+ 'better-auth': {
27
+ icon: ShieldAlert,
28
+ short: 'Identity',
29
+ title: 'Managed by the identity provider',
30
+ body: (display) => `This object's schema is owned by ${display}. Direct edits bypass password hashing, session validation, two-factor checks, and audit hooks. Use the dedicated identity workflows instead (Invite User, Reset Password, Revoke Session, Rotate Key, …).`,
31
+ tone: 'border-amber-300/60 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-100',
32
+ },
33
+ };
34
+ export function ManagedByBadge({ managedBy, label, className }) {
35
+ if (!managedBy || managedBy === 'platform')
36
+ return null;
37
+ const variant = VARIANTS[managedBy];
38
+ if (!variant)
39
+ return null;
40
+ const Icon = variant.icon;
41
+ const display = label ?? 'better-auth';
42
+ return (_jsx(TooltipProvider, { delayDuration: 200, children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs(Badge, { variant: "outline", "data-testid": "managed-by-badge", "data-bucket": managedBy, className: cn('gap-1 font-normal text-[11px] leading-none py-0.5 px-1.5 cursor-help', variant.tone, className), children: [_jsx(Icon, { className: "h-3 w-3", "aria-hidden": "true" }), _jsx("span", { children: variant.short })] }) }), _jsxs(TooltipContent, { side: "bottom", align: "start", className: "max-w-xs text-xs leading-relaxed", children: [_jsx("p", { className: "font-semibold mb-1", children: variant.title }), _jsx("p", { className: "text-muted-foreground", children: variant.body(display) })] })] }) }));
43
+ }
44
+ export default ManagedByBadge;
@@ -9,7 +9,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  */
10
10
  import { useState } from 'react';
11
11
  import { Button, Badge, Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, } from '@object-ui/components';
12
- import { Bell, Plus, Pencil, Trash2, MessageSquare, Filter } from 'lucide-react';
12
+ import { Activity, Plus, Pencil, Trash2, MessageSquare, Filter } from 'lucide-react';
13
13
  import { useObjectTranslation } from '@object-ui/i18n';
14
14
  const typeConfig = {
15
15
  create: { icon: Plus, color: 'text-green-500' },
@@ -57,11 +57,11 @@ export function ActivityFeed({ activities = [], className }) {
57
57
  delete: t('layout.activityFeed.typeDelete'),
58
58
  comment: t('layout.activityFeed.typeComment'),
59
59
  };
60
- return (_jsxs(Sheet, { open: open, onOpenChange: setOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "icon", className: className ?? 'h-8 w-8', "aria-label": t('layout.activityFeed.ariaLabel'), children: [_jsx(Bell, { className: "h-4 w-4" }), activities.length > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-primary text-[9px] font-bold text-primary-foreground flex items-center justify-center", children: activities.length > 9 ? '9+' : activities.length }))] }) }), _jsxs(SheetContent, { side: "right", className: "w-80 sm:w-96", children: [_jsx(SheetHeader, { children: _jsxs(SheetTitle, { className: "flex items-center justify-between", children: [t('layout.activityFeed.title'), _jsxs(Button, { variant: showFilters ? 'secondary' : 'ghost', size: "sm", className: "h-7 px-2", onClick: () => setShowFilters(!showFilters), children: [_jsx(Filter, { className: "h-3.5 w-3.5 mr-1" }), t('layout.activityFeed.filter')] })] }) }), showFilters && (_jsx("div", { className: "flex flex-wrap gap-1.5 mt-3 px-1", children: Object.keys(typeConfig).map(type => {
60
+ return (_jsxs(Sheet, { open: open, onOpenChange: setOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "icon", className: className ?? 'h-8 w-8', "aria-label": t('layout.activityFeed.ariaLabel'), children: [_jsx(Activity, { className: "h-4 w-4" }), activities.length > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-primary text-[9px] font-bold text-primary-foreground flex items-center justify-center", children: activities.length > 9 ? '9+' : activities.length }))] }) }), _jsxs(SheetContent, { side: "right", className: "w-80 sm:w-96", children: [_jsx(SheetHeader, { children: _jsxs(SheetTitle, { className: "flex items-center justify-between", children: [t('layout.activityFeed.title'), _jsxs(Button, { variant: showFilters ? 'secondary' : 'ghost', size: "sm", className: "h-7 px-2", onClick: () => setShowFilters(!showFilters), children: [_jsx(Filter, { className: "h-3.5 w-3.5 mr-1" }), t('layout.activityFeed.filter')] })] }) }), showFilters && (_jsx("div", { className: "flex flex-wrap gap-1.5 mt-3 px-1", children: Object.keys(typeConfig).map(type => {
61
61
  const { icon: Icon, color } = typeConfig[type];
62
62
  const active = notificationPreferences[type];
63
63
  return (_jsxs(Badge, { variant: active ? 'default' : 'outline', className: "cursor-pointer select-none gap-1", onClick: () => togglePreference(type), children: [_jsx(Icon, { className: `h-3 w-3 ${active ? '' : color}` }), typeLabels[type]] }, type));
64
- }) })), filteredActivities.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground", children: [_jsx(Bell, { className: "h-8 w-8 opacity-40" }), _jsx("p", { className: "text-sm", children: t('layout.activityFeed.empty') })] })) : (_jsx("ul", { className: "mt-4 space-y-1 overflow-y-auto max-h-[calc(100vh-8rem)]", children: filteredActivities.map((item) => {
64
+ }) })), filteredActivities.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground", children: [_jsx(Activity, { className: "h-8 w-8 opacity-40" }), _jsx("p", { className: "text-sm", children: t('layout.activityFeed.empty') })] })) : (_jsx("ul", { className: "mt-4 space-y-1 overflow-y-auto max-h-[calc(100vh-8rem)]", children: filteredActivities.map((item) => {
65
65
  const { icon: Icon, color } = typeConfig[item.type];
66
66
  return (_jsxs("li", { className: "flex items-start gap-3 rounded-md px-2 py-2 hover:bg-muted/50 transition-colors", children: [_jsx("span", { className: `mt-0.5 shrink-0 ${color}`, children: _jsx(Icon, { className: "h-4 w-4" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "text-sm leading-snug", children: item.description }), _jsxs("p", { className: "mt-0.5 text-xs text-muted-foreground", children: [item.user, " \u00B7 ", formatRelativeTime(item.timestamp, t)] })] })] }, item.id));
67
67
  }) }))] })] }));
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Derive a context-aware empty-state config for object lists whose
3
+ * `managedBy` bucket means the user can't create rows directly from
4
+ * this list view.
5
+ *
6
+ * Background: previously the entire affordance story was carried by a
7
+ * full-width banner pinned to the top of every list/detail/form page.
8
+ * That violated the principle most enterprise platforms (Salesforce,
9
+ * ServiceNow, SAP Fiori, Notion, Linear) settled on long ago: hide the
10
+ * affordance you don't want users to take, and use the *empty state* as
11
+ * the only place to explain why the list is empty and where new entries
12
+ * come from.
13
+ *
14
+ * This helper returns an `emptyState` payload compatible with the
15
+ * `ListView` schema (`{ title, message, icon }`). It only fires for the
16
+ * buckets where the default empty state ("No records yet") would be
17
+ * misleading; for `platform`/`config` it returns `undefined` so the
18
+ * caller falls back to the user-defined view-level empty state.
19
+ *
20
+ * The bucket → message mapping mirrors `ManagedByBadge` so that the badge
21
+ * (in the header) and the empty state (in the body) tell a consistent
22
+ * story without repeating themselves verbatim.
23
+ */
24
+ export interface ManagedByEmptyState {
25
+ title: string;
26
+ message: string;
27
+ icon: string;
28
+ }
29
+ export declare function resolveManagedByEmptyState(managedBy: string | undefined | null): ManagedByEmptyState | undefined;
@@ -0,0 +1,24 @@
1
+ export function resolveManagedByEmptyState(managedBy) {
2
+ switch (managedBy) {
3
+ case 'system':
4
+ return {
5
+ icon: 'Lock',
6
+ title: 'Nothing here yet',
7
+ message: 'Entries appear automatically when their source action runs (e.g. Submit for Approval, Share, Invite). Trigger one of those on a source record to create a row.',
8
+ };
9
+ case 'append-only':
10
+ return {
11
+ icon: 'Archive',
12
+ title: 'No events recorded',
13
+ message: 'This is an immutable audit log. Rows are written by the platform when events occur — you can export the history but cannot create entries from here.',
14
+ };
15
+ case 'better-auth':
16
+ return {
17
+ icon: 'ShieldAlert',
18
+ title: 'No identity records',
19
+ message: 'Identity rows are managed by the authentication provider. Use the dedicated identity workflows (Invite User, Reset Password, …) to create new entries.',
20
+ };
21
+ default:
22
+ return undefined;
23
+ }
24
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * resolveActionParams — Resolves field-backed action parameters against
3
+ * runtime object metadata.
4
+ *
5
+ * Action params (`packages/spec/src/ui/action.zod.ts ActionParamSchema`) may
6
+ * either be declared inline (`{ name, label, type, ... }`) or reference an
7
+ * existing object field via `{ field, objectOverride? }`. Field-backed
8
+ * params inherit label (i18n via `fieldLabel()`), type, options, validation,
9
+ * placeholder, and help text from the object's field definition. Inline
10
+ * properties on a field-backed param act as overrides.
11
+ *
12
+ * The resolver flattens each param to the runtime `ActionParamDef` shape
13
+ * expected by `ActionParamDialog`, so the dialog itself stays agnostic to
14
+ * field references.
15
+ */
16
+ import type { ActionParamDef } from '@object-ui/core';
17
+ /**
18
+ * `ActionParamDialog` switches on raw `FieldType` values
19
+ * (`text` / `email` / `select` / `textarea` / `number` / `url` / `date` / …),
20
+ * matching the `FieldType` enum in `@objectstack/spec`. **Do not** route
21
+ * through `mapFieldTypeToFormType()` here — that helper translates into the
22
+ * FormField widget vocabulary (`field:select`, …) which the dialog does not
23
+ * understand.
24
+ */
25
+ /** Raw param as authored on a schema action (post-zod). */
26
+ export interface RawActionParam {
27
+ name?: string;
28
+ field?: string;
29
+ objectOverride?: string;
30
+ label?: string;
31
+ type?: string;
32
+ required?: boolean;
33
+ options?: Array<{
34
+ label: string;
35
+ value: string;
36
+ }>;
37
+ placeholder?: string;
38
+ helpText?: string;
39
+ defaultValue?: unknown;
40
+ /** When true, seed defaultValue from the row record using the field name. */
41
+ defaultFromRow?: boolean;
42
+ }
43
+ /** Field metadata as exposed by `useMetadata().objects[].fields`. */
44
+ interface RuntimeField {
45
+ type?: string;
46
+ label?: string;
47
+ required?: boolean;
48
+ placeholder?: string;
49
+ help?: string;
50
+ description?: string;
51
+ options?: Array<{
52
+ label: string;
53
+ value: string;
54
+ } | string>;
55
+ multiple?: boolean;
56
+ defaultValue?: unknown;
57
+ }
58
+ interface RuntimeObject {
59
+ name?: string;
60
+ fields?: Record<string, RuntimeField>;
61
+ }
62
+ export interface ResolveActionParamsContext {
63
+ /** Default object name when a param's `objectOverride` is absent. */
64
+ objectName: string;
65
+ /** All known runtime objects (`useMetadata().objects`). */
66
+ objects: RuntimeObject[];
67
+ /** i18n resolver — `useObjectLabel().fieldLabel`. */
68
+ fieldLabel: (objectName: string, fieldName: string, fallback: string) => string;
69
+ /** Optional option-label translator — `useObjectLabel().fieldOptionLabel`. */
70
+ fieldOptionLabel?: (objectName: string, fieldName: string, optionValue: string, fallback: string) => string;
71
+ /**
72
+ * Row record providing default values for params with `defaultFromRow` set.
73
+ * Used by list_item actions (edit/delete dialogs) so the dialog opens with
74
+ * the row's current values pre-filled.
75
+ */
76
+ row?: Record<string, unknown>;
77
+ }
78
+ /**
79
+ * Resolve a single raw param against object metadata. Inline params pass
80
+ * through (with safe defaults); field-backed params inherit from the
81
+ * referenced field and accept inline overrides on top.
82
+ */
83
+ export declare function resolveActionParam(param: RawActionParam, ctx: ResolveActionParamsContext): ActionParamDef;
84
+ /** Resolve an array of raw action params. */
85
+ export declare function resolveActionParams(params: RawActionParam[] | undefined, ctx: ResolveActionParamsContext): ActionParamDef[];
86
+ export {};
@@ -0,0 +1,77 @@
1
+ /** Normalise an options entry (allowing bare strings) into label/value pairs. */
2
+ function normaliseOptions(options, objectName, fieldName, optionLabel) {
3
+ if (!Array.isArray(options) || options.length === 0)
4
+ return undefined;
5
+ return options.map((o) => {
6
+ const raw = typeof o === 'string' ? { label: o, value: o } : o;
7
+ const label = optionLabel
8
+ ? optionLabel(objectName, fieldName, raw.value, raw.label)
9
+ : raw.label;
10
+ return { label, value: raw.value };
11
+ });
12
+ }
13
+ /**
14
+ * Resolve a single raw param against object metadata. Inline params pass
15
+ * through (with safe defaults); field-backed params inherit from the
16
+ * referenced field and accept inline overrides on top.
17
+ */
18
+ export function resolveActionParam(param, ctx) {
19
+ /** Row-context default: when `defaultFromRow` and a row is present, the
20
+ * param's defaultValue is the row's value at the field key (or `name`). */
21
+ const rowKey = param.field ?? param.name;
22
+ const rowDefault = param.defaultFromRow && ctx.row && rowKey != null && Object.prototype.hasOwnProperty.call(ctx.row, rowKey)
23
+ ? ctx.row[rowKey]
24
+ : undefined;
25
+ // Inline param — no field reference, just normalise.
26
+ if (!param.field) {
27
+ return {
28
+ name: param.name ?? '',
29
+ label: param.label ?? param.name ?? '',
30
+ type: param.type ?? 'text',
31
+ required: param.required ?? false,
32
+ options: param.options,
33
+ placeholder: param.placeholder,
34
+ helpText: param.helpText,
35
+ defaultValue: rowDefault ?? param.defaultValue,
36
+ };
37
+ }
38
+ const ownerName = param.objectOverride ?? ctx.objectName;
39
+ const owner = ctx.objects.find((o) => o?.name === ownerName);
40
+ const field = owner?.fields?.[param.field];
41
+ if (!field) {
42
+ // Reference target missing — fall back to a plain text input so the
43
+ // action remains usable in environments where the metadata cache is
44
+ // partial (e.g. tests).
45
+ return {
46
+ name: param.name ?? param.field,
47
+ label: param.label ?? ctx.fieldLabel(ownerName, param.field, param.field),
48
+ type: param.type ?? 'text',
49
+ required: param.required ?? false,
50
+ options: param.options,
51
+ placeholder: param.placeholder,
52
+ helpText: param.helpText,
53
+ defaultValue: rowDefault ?? param.defaultValue,
54
+ };
55
+ }
56
+ const resolvedType = param.type ?? field.type ?? 'text';
57
+ const resolvedOptions = param.options
58
+ ?? normaliseOptions(field.options, ownerName, param.field, ctx.fieldOptionLabel);
59
+ const resolvedLabel = param.label
60
+ ?? ctx.fieldLabel(ownerName, param.field, field.label ?? param.field);
61
+ return {
62
+ name: param.name ?? param.field,
63
+ label: resolvedLabel,
64
+ type: resolvedType,
65
+ required: param.required ?? field.required ?? false,
66
+ options: resolvedOptions,
67
+ placeholder: param.placeholder ?? field.placeholder,
68
+ helpText: param.helpText ?? field.help ?? field.description,
69
+ defaultValue: rowDefault ?? param.defaultValue ?? field.defaultValue,
70
+ };
71
+ }
72
+ /** Resolve an array of raw action params. */
73
+ export function resolveActionParams(params, ctx) {
74
+ if (!Array.isArray(params))
75
+ return [];
76
+ return params.map((p) => resolveActionParam(p, ctx));
77
+ }
@@ -26,8 +26,9 @@ import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
26
26
  import { ViewConfigPanel } from './ViewConfigPanel';
27
27
  import { CreateViewDialog } from './CreateViewDialog';
28
28
  import { PageHeader } from '../layout/PageHeader';
29
- import { ManagedByBanner } from '../components/ManagedByBanner';
29
+ import { ManagedByBadge } from '../components/ManagedByBadge';
30
30
  import { resolveCrudAffordances } from '../utils/crudAffordances';
31
+ import { resolveManagedByEmptyState } from '../utils/managedByEmptyState';
31
32
  import { useObjectActions } from '../hooks/useObjectActions';
32
33
  import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
33
34
  import { usePermissions } from '@object-ui/permissions';
@@ -37,6 +38,7 @@ import { ActionProvider, useNavigationOverlay, SchemaRenderer } from '@object-ui
37
38
  import { toast } from 'sonner';
38
39
  import { ActionConfirmDialog } from './ActionConfirmDialog';
39
40
  import { ActionParamDialog } from './ActionParamDialog';
41
+ import { resolveActionParams } from '../utils/resolveActionParams';
40
42
  /** Map view types to Lucide icons (Airtable-style) */
41
43
  const VIEW_TYPE_ICONS = {
42
44
  grid: TableIcon,
@@ -346,7 +348,7 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
346
348
  const [searchParams, setSearchParams] = useSearchParams();
347
349
  const { showDebug } = useMetadataInspector();
348
350
  const { t } = useObjectTranslation();
349
- const { objectLabel, objectDescription: objectDesc, viewLabel, actionLabel, actionConfirm, actionSuccess } = useObjectLabel();
351
+ const { objectLabel, objectDescription: objectDesc, viewLabel, actionLabel, actionConfirm, actionSuccess, fieldLabel, fieldOptionLabel } = useObjectLabel();
350
352
  // Inline view config panel state (Airtable-style right sidebar)
351
353
  const [showViewConfigPanel, setShowViewConfigPanel] = useState(false);
352
354
  const [viewConfigPanelMode, setViewConfigPanelMode] = useState('edit');
@@ -483,7 +485,7 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
483
485
  // Record count tracking for footer
484
486
  const [recordCount, setRecordCount] = useState(undefined);
485
487
  // Admin users automatically get design tools (no toggle needed)
486
- const { user } = useAuth();
488
+ const { user, activeOrganization } = useAuth();
487
489
  const isAdmin = user?.role === 'admin';
488
490
  const { can } = usePermissions();
489
491
  // Get Object Definition
@@ -871,11 +873,24 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
871
873
  setConfirmState({ open: true, message, options, resolve });
872
874
  });
873
875
  }, []);
874
- const paramCollectionHandler = useCallback((params) => {
876
+ const paramCollectionHandler = useCallback((params, action) => {
875
877
  return new Promise((resolve) => {
876
- setParamState({ open: true, params, resolve });
878
+ // List_item actions stash the row record under params._rowRecord
879
+ // (see ObjectGrid → onRowAction). Pull it out so resolveActionParams
880
+ // can pre-fill `defaultFromRow` params from the row's current values.
881
+ const row = action?.params && !Array.isArray(action.params)
882
+ ? action.params._rowRecord
883
+ : undefined;
884
+ const resolved = resolveActionParams(params, {
885
+ objectName: objectName || objectDef?.name || '',
886
+ objects: objects || [],
887
+ fieldLabel,
888
+ fieldOptionLabel,
889
+ row,
890
+ });
891
+ setParamState({ open: true, params: resolved, resolve });
877
892
  });
878
- }, []);
893
+ }, [objectName, objectDef, objects, fieldLabel, fieldOptionLabel]);
879
894
  const handleDeleteView = useCallback(async (vid) => {
880
895
  if (!dataSource)
881
896
  return;
@@ -1092,10 +1107,78 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1092
1107
  navigate(url);
1093
1108
  }
1094
1109
  }, [navigate]);
1110
+ // Authenticated fetch for direct backend calls (e.g. flow trigger,
1111
+ // schema actions targeting absolute API paths). Declared before
1112
+ // apiHandler so the latter can close over it.
1113
+ const authFetch = useMemo(() => createAuthenticatedFetch(), []);
1095
1114
  const apiHandler = useCallback(async (action) => {
1096
1115
  try {
1097
1116
  const target = action.target || action.name;
1098
1117
  const params = action.params || {};
1118
+ // Absolute HTTP target (e.g. /api/v1/auth/organization/invite-member,
1119
+ // http://..., https://...) — bypass dataSource and call the API
1120
+ // directly through the authenticated fetch wrapper so:
1121
+ // - Authorization: Bearer <token> is injected
1122
+ // - X-Tenant-ID is injected (multi-tenant routing)
1123
+ // - Same-origin cookies (better-auth.session_token) ride along
1124
+ //
1125
+ // This is the canonical path for schema actions on managed-by
1126
+ // tables (sys_user/invite_user, sys_session/revoke, …) where
1127
+ // generic CRUD does not apply.
1128
+ const targetStr = typeof target === 'string' ? target : '';
1129
+ const isAbsolute = targetStr.startsWith('/') || /^https?:\/\//i.test(targetStr);
1130
+ if (isAbsolute) {
1131
+ const baseUrl = import.meta.env.VITE_SERVER_URL || '';
1132
+ const url = targetStr.startsWith('http') ? targetStr : `${baseUrl}${targetStr}`;
1133
+ // Row context is stashed on params under `_rowRecord` by the
1134
+ // row-action dispatcher (see ObjectGrid → onRowAction). Pull
1135
+ // it out before assembling the request body.
1136
+ const rawParams = { ...params };
1137
+ const rowRecord = rawParams._rowRecord;
1138
+ delete rawParams._rowRecord;
1139
+ // Apply bodyShape: 'flat' (default) keeps user params at top
1140
+ // level; { wrap: 'data' } nests them under that key while
1141
+ // `recordIdParam` / `organizationId` stay flat (better-auth
1142
+ // organization/update semantics).
1143
+ const wrap = action.bodyShape && typeof action.bodyShape === 'object' && action.bodyShape.wrap
1144
+ ? action.bodyShape.wrap
1145
+ : undefined;
1146
+ const body = wrap
1147
+ ? { [wrap]: rawParams }
1148
+ : { ...rawParams };
1149
+ // Inject row id (or chosen row field) for list_item actions.
1150
+ if (rowRecord && action.recordIdParam) {
1151
+ const rowField = action.recordIdField || 'id';
1152
+ const rowValue = rowRecord[rowField];
1153
+ if (rowValue != null)
1154
+ body[action.recordIdParam] = rowValue;
1155
+ }
1156
+ // Auto-inject organizationId when the active org is known and
1157
+ // not already set. Most better-auth org-scoped endpoints
1158
+ // accept this shape; harmless for endpoints that ignore it.
1159
+ if (!body.organizationId && activeOrganization?.id) {
1160
+ body.organizationId = activeOrganization.id;
1161
+ }
1162
+ const res = await authFetch(url, {
1163
+ method: 'POST',
1164
+ headers: { 'Content-Type': 'application/json' },
1165
+ credentials: 'include',
1166
+ body: JSON.stringify(body),
1167
+ });
1168
+ if (!res.ok) {
1169
+ let detail = `HTTP ${res.status}`;
1170
+ try {
1171
+ const j = await res.json();
1172
+ detail = j?.error || j?.message || detail;
1173
+ }
1174
+ catch { /* response body not JSON */ }
1175
+ return { success: false, error: detail };
1176
+ }
1177
+ const data = await res.json().catch(() => ({}));
1178
+ if (action.refreshAfter !== false)
1179
+ setRefreshKey(k => k + 1);
1180
+ return { success: true, data, reload: action.refreshAfter !== false };
1181
+ }
1099
1182
  // Generic list-level API handler: update/execute via dataSource
1100
1183
  if (typeof dataSource.execute === 'function') {
1101
1184
  await dataSource.execute(objectDef.name, target, params);
@@ -1112,9 +1195,7 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1112
1195
  catch (error) {
1113
1196
  return { success: false, error: error.message };
1114
1197
  }
1115
- }, [dataSource, objectDef.name]);
1116
- // Authenticated fetch for direct backend calls (e.g. flow trigger).
1117
- const authFetch = useMemo(() => createAuthenticatedFetch(), []);
1198
+ }, [dataSource, objectDef.name, authFetch, activeOrganization]);
1118
1199
  // Flow action handler — POST to /api/v1/automation/{name}/trigger.
1119
1200
  // Triggered when an Action with `type: 'flow'` is invoked from list-level
1120
1201
  // locations (list_toolbar, list_item). For list_item the row's recordId is
@@ -1385,6 +1466,17 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1385
1466
  filterableFields: viewDef.filterableFields ?? listSchema.filterableFields,
1386
1467
  resizable: viewDef.resizable ?? listSchema.resizable,
1387
1468
  rowActions: viewDef.rowActions ?? listSchema.rowActions,
1469
+ /**
1470
+ * Row-context action definitions derived from `objectDef.actions`
1471
+ * filtered by `locations.includes('list_item')`. These are full
1472
+ * `ActionDef` records (with label/icon/variant/params/recordIdParam
1473
+ * /bodyShape) the row menu renders with i18n-correct labels and
1474
+ * dispatches via the action runner; legacy `rowActions: string[]`
1475
+ * remains for back-compat where the action lives elsewhere.
1476
+ */
1477
+ rowActionDefs: (Array.isArray(objectDef?.actions)
1478
+ ? objectDef.actions.filter((a) => Array.isArray(a?.locations) && a.locations.includes('list_item'))
1479
+ : []),
1388
1480
  bulkActions: viewDef.bulkActions ?? listSchema.bulkActions,
1389
1481
  sharing: viewDef.sharing ?? listSchema.sharing,
1390
1482
  addRecord: viewDef.addRecord ?? listSchema.addRecord,
@@ -1394,7 +1486,9 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1394
1486
  showRecordCount: viewDef.showRecordCount ?? listSchema.showRecordCount,
1395
1487
  allowPrinting: viewDef.allowPrinting ?? listSchema.allowPrinting,
1396
1488
  virtualScroll: viewDef.virtualScroll ?? listSchema.virtualScroll,
1397
- emptyState: viewDef.emptyState ?? listSchema.emptyState,
1489
+ emptyState: viewDef.emptyState
1490
+ ?? listSchema.emptyState
1491
+ ?? resolveManagedByEmptyState(objectDef?.managedBy),
1398
1492
  aria: viewDef.aria ?? listSchema.aria,
1399
1493
  tabs: listSchema.tabs,
1400
1494
  // Propagate filter/sort as default filters/sort for data flow
@@ -1534,7 +1628,15 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1534
1628
  }
1535
1629
  },
1536
1630
  }), [objectDef.name, onEdit, activeView?.showSearch, activeView?.showFilters, activeView?.showSort, navigate, viewId, isAdmin]);
1537
- return (_jsxs(ActionProvider, { context: { objectName: objectDef.name, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: [_jsxs("div", { className: "h-full flex flex-col bg-background min-w-0 overflow-hidden", children: [_jsx(ManagedByBanner, { managedBy: objectDef?.managedBy }), _jsx(PageHeader, { title: objectLabel(objectDef), description: objectDef.description ? objectDesc(objectDef) : undefined, icon: (() => { const I = getIcon(objectDef?.icon); return _jsx(I, { className: "h-4 w-4" }); })(), actions: _jsxs(_Fragment, { children: [affordances.create && can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", onClick: actions.create, className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.new') })] })), affordances.import && can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => setShowImport(true), className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", title: t('console.objectView.importTitle'), "data-testid": "object-view-import-button", children: [_jsx(Upload, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.import') })] })), objectDef.actions?.some((a) => a.locations?.includes('list_toolbar')) && (_jsx(SchemaRenderer, { schema: {
1631
+ return (_jsxs(ActionProvider, { context: {
1632
+ objectName: objectDef.name,
1633
+ user: currentUser,
1634
+ // Expose active org so `type: 'url'` actions can template
1635
+ // `/organizations/${activeOrganization.slug}/...` etc.
1636
+ activeOrganization: activeOrganization
1637
+ ? { id: activeOrganization.id, slug: activeOrganization.slug, name: activeOrganization.name }
1638
+ : null,
1639
+ }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: [_jsxs("div", { className: "h-full flex flex-col bg-background min-w-0 overflow-hidden", children: [_jsx(PageHeader, { title: _jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { className: "truncate", children: objectLabel(objectDef) }), _jsx(ManagedByBadge, { managedBy: objectDef?.managedBy })] }), description: objectDef.description ? objectDesc(objectDef) : undefined, icon: (() => { const I = getIcon(objectDef?.icon); return _jsx(I, { className: "h-4 w-4" }); })(), actions: _jsxs(_Fragment, { children: [affordances.create && can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", onClick: actions.create, className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.new') })] })), affordances.import && can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => setShowImport(true), className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", title: t('console.objectView.importTitle'), "data-testid": "object-view-import-button", children: [_jsx(Upload, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.import') })] })), objectDef.actions?.some((a) => a.locations?.includes('list_toolbar')) && (_jsx(SchemaRenderer, { schema: {
1538
1640
  type: 'action:bar',
1539
1641
  location: 'list_toolbar',
1540
1642
  actions: (objectDef.actions || []).map((a) => ({
@@ -17,19 +17,22 @@ import { toast } from 'sonner';
17
17
  import { Database, Users } from 'lucide-react';
18
18
  import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
19
19
  import { SkeletonDetail } from '../skeletons';
20
- import { ManagedByBanner } from '../components/ManagedByBanner';
20
+ import { ManagedByBadge } from '../components/ManagedByBadge';
21
21
  import { resolveCrudAffordances } from '../utils/crudAffordances';
22
22
  import { ActionConfirmDialog } from './ActionConfirmDialog';
23
23
  import { ActionParamDialog } from './ActionParamDialog';
24
+ import { resolveActionParams } from '../utils/resolveActionParams';
24
25
  import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
25
26
  import { getRecordDisplayName } from '../utils';
26
27
  const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
27
28
  /**
28
29
  * Audit field names auto-injected by the framework's `applySystemFields`.
29
- * Surfaced as a dedicated, collapsed "System Information" section on the
30
- * record detail page so they don't clutter the primary content but remain
31
- * discoverable. The inline-edit drawer keeps filtering them out via
32
- * `DEFAULT_SYSTEM_FIELDS` in `@object-ui/plugin-detail/RecordDetailDrawer`.
30
+ * Filtered out of the auto-generated body sections they are rendered
31
+ * separately as a single subtle one-line `<RecordMetaFooter>` (see
32
+ * `@object-ui/plugin-detail`) so provenance stays discoverable without a
33
+ * heavy "System Information" panel. The inline-edit drawer also hides
34
+ * them via `DEFAULT_SYSTEM_FIELDS` in
35
+ * `@object-ui/plugin-detail/RecordDetailDrawer`.
33
36
  */
34
37
  const AUDIT_FIELD_NAMES = new Set(['created_at', 'created_by', 'updated_at', 'updated_by']);
35
38
  export function RecordDetailView({ dataSource, objects, onEdit }) {
@@ -38,7 +41,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
38
41
  const { user } = useAuth();
39
42
  const navigate = useNavigate();
40
43
  const { t } = useObjectTranslation();
41
- const { objectLabel, viewLabel: _vLabel, sectionLabel, actionLabel, actionConfirm, actionSuccess } = useObjectLabel();
44
+ const { objectLabel, viewLabel: _vLabel, sectionLabel, actionLabel, actionConfirm, actionSuccess, fieldLabel, fieldOptionLabel } = useObjectLabel();
42
45
  const [isLoading, setIsLoading] = useState(true);
43
46
  const [feedItems, setFeedItems] = useState([]);
44
47
  const [recordViewers, setRecordViewers] = useState([]);
@@ -65,9 +68,15 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
65
68
  }, []);
66
69
  const paramCollectionHandler = useCallback((params) => {
67
70
  return new Promise((resolve) => {
68
- setParamState({ open: true, params, resolve });
71
+ const resolved = resolveActionParams(params, {
72
+ objectName: objectName || objectDef?.name || '',
73
+ objects: objects || [],
74
+ fieldLabel,
75
+ fieldOptionLabel,
76
+ });
77
+ setParamState({ open: true, params: resolved, resolve });
69
78
  });
70
- }, []);
79
+ }, [objectName, objectDef, objects, fieldLabel, fieldOptionLabel]);
71
80
  const toastHandler = useCallback((message, options) => {
72
81
  if (options?.type === 'error')
73
82
  toast.error(message);
@@ -563,32 +572,12 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
563
572
  }),
564
573
  },
565
574
  ];
566
- // Append a dedicated, collapsed "System Information" section listing
567
- // audit fields (created/updated at/by) when the schema declares them
568
- // and no author-defined section has already surfaced them. The framework
569
- // auto-injects these as `system: true, readonly: true` via
570
- // `applySystemFields`; rendering them here gives users visibility into
571
- // record provenance without polluting the primary content area.
572
- const fieldsAlreadyShown = new Set(sections.flatMap((s) => (s.fields || []).map((f) => f.name)));
573
- const auditFieldsToShow = Array.from(AUDIT_FIELD_NAMES).filter(name => objectDef.fields?.[name] && !fieldsAlreadyShown.has(name));
574
- if (auditFieldsToShow.length > 0) {
575
- sections.push({
576
- title: sectionLabel(objectDef.name, 'system_info', 'System Information'),
577
- collapsible: true,
578
- defaultCollapsed: true,
579
- fields: auditFieldsToShow.map(key => {
580
- const fieldDef = objectDef.fields[key];
581
- const refTarget = fieldDef.reference_to || fieldDef.reference;
582
- return {
583
- name: key,
584
- label: fieldDef.label || key,
585
- type: fieldDef.type || 'text',
586
- readonly: true,
587
- ...(refTarget && { reference_to: refTarget }),
588
- };
589
- }),
590
- });
591
- }
575
+ // Audit fields (created_at/created_by/updated_at/updated_by) are NOT
576
+ // appended as a section here they are surfaced by `<RecordMetaFooter>`
577
+ // (rendered by DetailView) as a single subtle line below the content,
578
+ // replacing the old card-style "System Information" panel. The inline-edit
579
+ // drawer continues to hide them via `DEFAULT_SYSTEM_FIELDS` in
580
+ // `@object-ui/plugin-detail/RecordDetailDrawer`.
592
581
  // Filter actions for record_header location and deduplicate by name
593
582
  const recordHeaderActions = (() => {
594
583
  const seen = new Set();
@@ -740,7 +729,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
740
729
  if (!objectDef) {
741
730
  return (_jsx("div", { className: "flex h-full items-center justify-center p-4", children: _jsxs(Empty, { children: [_jsx("div", { className: "mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted", children: _jsx(Database, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('empty.objectNotFound') }), _jsx(EmptyDescription, { children: t('empty.objectNotFoundDescription', { name: objectName }) })] }) }));
742
731
  }
743
- return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsx(ManagedByBanner, { managedBy: objectDef?.managedBy }), _jsx("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: t('recordDetail.viewersTooltip'), children: [_jsx(Users, { className: "h-3.5 w-3.5 text-muted-foreground" }), _jsx(PresenceAvatars, { users: recordViewers, size: "sm", maxVisible: 4, showStatus: true })] })) }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsx("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: _jsx(ActionProvider, { context: { record: {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), onDataLoaded: (record) => {
732
+ return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsxs("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: [_jsx(ManagedByBadge, { managedBy: objectDef?.managedBy }), recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: t('recordDetail.viewersTooltip'), children: [_jsx(Users, { className: "h-3.5 w-3.5 text-muted-foreground" }), _jsx(PresenceAvatars, { users: recordViewers, size: "sm", maxVisible: 4, showStatus: true })] }))] }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsx("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: _jsx(ActionProvider, { context: { record: {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), onDataLoaded: (record) => {
744
733
  if (!record || typeof record !== 'object')
745
734
  return;
746
735
  // Resolve the same way DetailView's header does, so the
@@ -39,7 +39,7 @@ import { useMetadata } from '../providers/MetadataProvider';
39
39
  import { useAdapter } from '../providers/AdapterProvider';
40
40
  import { ExpressionProvider, evaluateVisibility } from '../providers/ExpressionProvider';
41
41
  import { SkeletonDetail } from '../skeletons';
42
- import { ManagedByBanner } from '../components/ManagedByBanner';
42
+ import { ManagedByBadge } from '../components/ManagedByBadge';
43
43
  import { useAuth } from '@object-ui/auth';
44
44
  import { ExpressionEvaluator } from '@object-ui/core';
45
45
  /**
@@ -162,7 +162,7 @@ export function RecordFormPage({ mode }) {
162
162
  if (!objectDef) {
163
163
  return (_jsx("div", { className: "flex h-full items-center justify-center p-4", children: _jsxs(Empty, { children: [_jsx("div", { className: "mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted", children: _jsx(Database, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('empty.objectNotFound') }), _jsx(EmptyDescription, { children: t('empty.objectNotFoundDescription', { name: objectName }) }), _jsx("div", { className: "mt-4", children: _jsxs(Button, { variant: "outline", onClick: () => navigate(baseUrl), children: [_jsx(ArrowLeft, { className: "mr-2 h-4 w-4" }), t('empty.back')] }) })] }) }));
164
164
  }
165
- return (_jsx(ExpressionProvider, { user: expressionUser, app: { name: appName }, data: {}, children: _jsxs("div", { className: "flex flex-col h-full overflow-hidden bg-background", "data-testid": "record-form-page", "data-mode": mode, children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-center gap-3 border-b bg-background px-4 py-3 sm:px-6", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: goBack, "data-testid": "record-form-page-back", "aria-label": t('common.back', { defaultValue: 'Back' }), children: _jsx(ArrowLeft, { className: "h-4 w-4" }) }), _jsxs("nav", { "aria-label": "Breadcrumb", className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(Link, { to: objectListUrl, className: "hover:text-foreground transition-colors", children: label }), _jsx("span", { "aria-hidden": "true", children: "/" }), _jsx("span", { className: "text-foreground font-medium", "data-testid": "record-form-page-title", children: pageTitle })] })] }), _jsx(ManagedByBanner, { managedBy: objectDef?.managedBy }), _jsx("div", { className: "flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto max-w-4xl", children: _jsx(ObjectForm, { schema: {
165
+ return (_jsx(ExpressionProvider, { user: expressionUser, app: { name: appName }, data: {}, children: _jsxs("div", { className: "flex flex-col h-full overflow-hidden bg-background", "data-testid": "record-form-page", "data-mode": mode, children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-center gap-3 border-b bg-background px-4 py-3 sm:px-6", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: goBack, "data-testid": "record-form-page-back", "aria-label": t('common.back', { defaultValue: 'Back' }), children: _jsx(ArrowLeft, { className: "h-4 w-4" }) }), _jsxs("nav", { "aria-label": "Breadcrumb", className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(Link, { to: objectListUrl, className: "hover:text-foreground transition-colors", children: label }), _jsx("span", { "aria-hidden": "true", children: "/" }), _jsx("span", { className: "text-foreground font-medium", "data-testid": "record-form-page-title", children: pageTitle }), _jsx(ManagedByBadge, { managedBy: objectDef?.managedBy, className: "ml-1" })] })] }), _jsx("div", { className: "flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto max-w-4xl", children: _jsx(ObjectForm, { schema: {
166
166
  type: 'object-form',
167
167
  formType: 'simple',
168
168
  objectName: objectDef.name,
@@ -38,29 +38,31 @@ export function ReportView({ dataSource }) {
38
38
  // State for report runtime data
39
39
  const [reportRuntimeData, setReportRuntimeData] = useState([]);
40
40
  const [dataLoading, setDataLoading] = useState(false);
41
+ const getFieldsForObject = useCallback((objName) => {
42
+ if (!objName || !objects?.length)
43
+ return undefined;
44
+ const objDef = objects.find((o) => o.name === objName);
45
+ if (!objDef?.fields)
46
+ return undefined;
47
+ const fields = objDef.fields;
48
+ if (Array.isArray(fields)) {
49
+ return fields.map((f) => typeof f === 'string'
50
+ ? { value: f, label: f, type: 'text' }
51
+ : { value: f.name, label: f.label || f.name, type: f.type || 'text' });
52
+ }
53
+ return Object.entries(fields).map(([name, def]) => ({
54
+ value: name,
55
+ label: def.label || name,
56
+ type: def.type || 'text',
57
+ }));
58
+ }, [objects]);
41
59
  // Derive available fields from object schema for filter/sort editors
42
60
  // Uses live editSchema when available to respond to objectName changes
43
61
  const availableFields = useMemo(() => {
44
62
  const liveReport = editSchema || reportData;
45
63
  const objName = liveReport?.objectName || liveReport?.dataSource?.object || liveReport?.dataSource?.resource;
46
- if (objName && objects?.length) {
47
- const objDef = objects.find((o) => o.name === objName);
48
- if (objDef?.fields) {
49
- const fields = objDef.fields;
50
- if (Array.isArray(fields)) {
51
- return fields.map((f) => typeof f === 'string'
52
- ? { value: f, label: f, type: 'text' }
53
- : { value: f.name, label: f.label || f.name, type: f.type || 'text' });
54
- }
55
- return Object.entries(fields).map(([name, def]) => ({
56
- value: name,
57
- label: def.label || name,
58
- type: def.type || 'text',
59
- }));
60
- }
61
- }
62
- return FALLBACK_FIELDS;
63
- }, [editSchema, reportData, objects]);
64
+ return getFieldsForObject(objName) ?? FALLBACK_FIELDS;
65
+ }, [editSchema, reportData, getFieldsForObject]);
64
66
  // ---- Save helper --------------------------------------------------------
65
67
  const saveSchema = useCallback(async (schema) => {
66
68
  try {
@@ -350,5 +352,5 @@ export function ReportView({ dataSource }) {
350
352
  allowExport: true,
351
353
  loading: dataLoading, // Loading state for data fetching
352
354
  };
353
- return (_jsxs("div", { className: "flex flex-col h-full overflow-hidden bg-background", children: [_jsxs("div", { className: "flex flex-col sm:flex-row justify-between sm:items-center gap-3 sm:gap-4 p-4 sm:p-6 border-b shrink-0", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h1", { className: "text-lg sm:text-xl md:text-2xl font-bold tracking-tight truncate", children: previewReport.title || previewReport.label || 'Report Viewer' }), previewReport.description && (_jsx("p", { className: "text-sm text-muted-foreground mt-1 line-clamp-2", children: previewReport.description }))] }), _jsx("div", { className: "shrink-0 flex items-center gap-1.5", children: _jsxs("button", { type: "button", onClick: handleOpenConfigPanel, className: "inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground shadow-sm hover:bg-accent hover:text-accent-foreground", "data-testid": "report-edit-button", children: [_jsx(Pencil, { className: "h-3.5 w-3.5" }), "Edit"] }) })] }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-col sm:flex-row relative", children: [_jsx("div", { className: "flex-1 min-w-0 overflow-auto p-4 sm:p-6 lg:p-8 bg-muted/5", children: _jsx("div", { className: "w-full shadow-sm border rounded-lg sm:rounded-xl bg-background overflow-hidden min-h-150", children: _jsx(Suspense, { fallback: _jsx("div", { className: "p-8 text-sm text-muted-foreground", children: "Loading report\u2026" }), children: useSpecRenderer ? (_jsx("div", { className: "p-4 sm:p-6", children: _jsx(ReportRenderer, { schema: previewReport, dataSource: dataSource }) })) : (_jsx(ReportViewer, { schema: viewerSchema })) }) }) }), _jsx(Suspense, { fallback: null, children: _jsx(ReportConfigPanel, { open: configPanelOpen, onClose: handleCloseConfigPanel, config: reportConfig, onSave: handleReportConfigSave, onFieldChange: handleReportFieldChange, availableFields: availableFields }) }), _jsx(MetadataPanel, { open: showDebug, sections: [{ title: 'Report Configuration', data: previewReport }] })] })] }));
355
+ return (_jsxs("div", { className: "flex flex-col h-full overflow-hidden bg-background", children: [_jsxs("div", { className: "flex flex-col sm:flex-row justify-between sm:items-center gap-3 sm:gap-4 p-4 sm:p-6 border-b shrink-0", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h1", { className: "text-lg sm:text-xl md:text-2xl font-bold tracking-tight truncate", children: previewReport.title || previewReport.label || 'Report Viewer' }), previewReport.description && (_jsx("p", { className: "text-sm text-muted-foreground mt-1 line-clamp-2", children: previewReport.description }))] }), _jsx("div", { className: "shrink-0 flex items-center gap-1.5", children: _jsxs("button", { type: "button", onClick: handleOpenConfigPanel, className: "inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground shadow-sm hover:bg-accent hover:text-accent-foreground", "data-testid": "report-edit-button", children: [_jsx(Pencil, { className: "h-3.5 w-3.5" }), "Edit"] }) })] }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-col sm:flex-row relative", children: [_jsx("div", { className: "flex-1 min-w-0 overflow-auto p-4 sm:p-6 lg:p-8 bg-muted/5", children: _jsx("div", { className: "w-full shadow-sm border rounded-lg sm:rounded-xl bg-background overflow-hidden min-h-150", children: _jsx(Suspense, { fallback: _jsx("div", { className: "p-8 text-sm text-muted-foreground", children: "Loading report\u2026" }), children: useSpecRenderer ? (_jsx("div", { className: "p-4 sm:p-6", children: _jsx(ReportRenderer, { schema: previewReport, dataSource: dataSource }) })) : (_jsx(ReportViewer, { schema: viewerSchema })) }) }) }), _jsx(Suspense, { fallback: null, children: _jsx(ReportConfigPanel, { open: configPanelOpen, onClose: handleCloseConfigPanel, config: reportConfig, onSave: handleReportConfigSave, onFieldChange: handleReportFieldChange, availableFields: availableFields, getFieldsForObject: getFieldsForObject }) }), _jsx(MetadataPanel, { open: showDebug, sections: [{ title: 'Report Configuration', data: previewReport }] })] })] }));
354
356
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/app-shell",
3
- "version": "4.2.1",
3
+ "version": "4.3.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Minimal application shell for ObjectUI - framework-agnostic rendering engine",
@@ -27,34 +27,34 @@
27
27
  "dependencies": {
28
28
  "lucide-react": "^1.16.0",
29
29
  "sonner": "^2.0.7",
30
- "@object-ui/auth": "4.2.1",
31
- "@object-ui/collaboration": "4.2.1",
32
- "@object-ui/components": "4.2.1",
33
- "@object-ui/core": "4.2.1",
34
- "@object-ui/data-objectstack": "4.2.1",
35
- "@object-ui/fields": "4.2.1",
36
- "@object-ui/i18n": "4.2.1",
37
- "@object-ui/layout": "4.2.1",
38
- "@object-ui/permissions": "4.2.1",
39
- "@object-ui/react": "4.2.1",
40
- "@object-ui/types": "4.2.1"
30
+ "@object-ui/auth": "4.3.0",
31
+ "@object-ui/collaboration": "4.3.0",
32
+ "@object-ui/components": "4.3.0",
33
+ "@object-ui/core": "4.3.0",
34
+ "@object-ui/data-objectstack": "4.3.0",
35
+ "@object-ui/fields": "4.3.0",
36
+ "@object-ui/i18n": "4.3.0",
37
+ "@object-ui/layout": "4.3.0",
38
+ "@object-ui/permissions": "4.3.0",
39
+ "@object-ui/react": "4.3.0",
40
+ "@object-ui/types": "4.3.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "react": "^18.0.0 || ^19.0.0",
44
44
  "react-dom": "^18.0.0 || ^19.0.0",
45
45
  "react-router-dom": "^6.0.0 || ^7.0.0",
46
- "@object-ui/plugin-calendar": "^4.2.1",
47
- "@object-ui/plugin-charts": "^4.2.1",
48
- "@object-ui/plugin-chatbot": "^4.2.1",
49
- "@object-ui/plugin-dashboard": "^4.2.1",
50
- "@object-ui/plugin-designer": "^4.2.1",
51
- "@object-ui/plugin-detail": "^4.2.1",
52
- "@object-ui/plugin-form": "^4.2.1",
53
- "@object-ui/plugin-grid": "^4.2.1",
54
- "@object-ui/plugin-kanban": "^4.2.1",
55
- "@object-ui/plugin-list": "^4.2.1",
56
- "@object-ui/plugin-report": "^4.2.1",
57
- "@object-ui/plugin-view": "^4.2.1"
46
+ "@object-ui/plugin-calendar": "^4.3.0",
47
+ "@object-ui/plugin-charts": "^4.3.0",
48
+ "@object-ui/plugin-chatbot": "^4.3.0",
49
+ "@object-ui/plugin-dashboard": "^4.3.0",
50
+ "@object-ui/plugin-designer": "^4.3.0",
51
+ "@object-ui/plugin-detail": "^4.3.0",
52
+ "@object-ui/plugin-form": "^4.3.0",
53
+ "@object-ui/plugin-grid": "^4.3.0",
54
+ "@object-ui/plugin-kanban": "^4.3.0",
55
+ "@object-ui/plugin-list": "^4.3.0",
56
+ "@object-ui/plugin-report": "^4.3.0",
57
+ "@object-ui/plugin-view": "^4.3.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^25.9.0",
@@ -1,16 +0,0 @@
1
- export interface ManagedByBannerProps {
2
- /** The `managedBy` flag from the object schema. */
3
- managedBy?: string;
4
- /** Optional override for the human-readable system name. */
5
- label?: string;
6
- /** Optional documentation link rendered as "Learn more →". */
7
- docHref?: string;
8
- /**
9
- * Optional human-readable name for the source record / parent workflow
10
- * referenced in `system`-bucket banners (e.g. "Opportunity"). When
11
- * provided the banner reads "Use the Opportunity record's Submit for
12
- * Approval action…" instead of the generic phrasing.
13
- */
14
- sourceRecordLabel?: string;
15
- }
16
- export declare function ManagedByBanner({ managedBy, label, docHref, sourceRecordLabel }: ManagedByBannerProps): import("react/jsx-runtime").JSX.Element | null;
@@ -1,38 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { ShieldAlert, Info, Lock, Archive } from 'lucide-react';
3
- const VARIANTS = {
4
- config: {
5
- icon: Info,
6
- tone: 'border-sky-300/60 bg-sky-50 text-sky-900 dark:border-sky-500/40 dark:bg-sky-950/40 dark:text-sky-100',
7
- title: 'Administrator configuration',
8
- body: () => "These rows define how the platform behaves at runtime — they're authored here, but the runtime data they produce lives in a separate table. Changes take effect once a row is marked Active.",
9
- },
10
- system: {
11
- icon: Lock,
12
- tone: 'border-slate-300/60 bg-slate-50 text-slate-900 dark:border-slate-500/40 dark:bg-slate-950/40 dark:text-slate-100',
13
- title: 'Managed by the platform',
14
- body: (_display, src) => `Rows here are created and updated automatically by the platform engine. To start a new one, use the action button on the ${src ?? 'source record'} (e.g. "Submit for Approval", "Share", "Invite"). The list below is the audit / monitoring surface — actions like Approve, Recall, or Resend live on the row.`,
15
- },
16
- 'append-only': {
17
- icon: Archive,
18
- tone: 'border-zinc-300/60 bg-zinc-50 text-zinc-900 dark:border-zinc-500/40 dark:bg-zinc-950/40 dark:text-zinc-100',
19
- title: 'Read-only historical record',
20
- body: () => "This is an immutable audit log. Rows cannot be created, edited, or deleted from the UI — they're written by the platform when events occur. Use Export to download for compliance review.",
21
- },
22
- 'better-auth': {
23
- icon: ShieldAlert,
24
- tone: 'border-amber-300/60 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-100',
25
- title: 'Managed by better-auth',
26
- body: (display) => `This object's schema is owned by ${display}. Direct edits here bypass password hashing, session validation, two-factor checks, and audit hooks — and may corrupt account state. Use the dedicated identity workflows instead (Invite User, Reset Password, Revoke Session, Rotate Key, …).`,
27
- },
28
- };
29
- export function ManagedByBanner({ managedBy, label, docHref, sourceRecordLabel }) {
30
- if (!managedBy || managedBy === 'platform')
31
- return null;
32
- const variant = VARIANTS[managedBy];
33
- if (!variant)
34
- return null;
35
- const display = label ?? managedBy;
36
- const Icon = variant.icon;
37
- return (_jsxs("div", { role: "note", "data-testid": "managed-by-banner", "data-bucket": managedBy, className: `flex items-start gap-3 border-b px-4 py-2.5 text-sm ${variant.tone}`, children: [_jsx(Icon, { className: "mt-0.5 h-4 w-4 flex-none" }), _jsxs("div", { className: "flex-1", children: [_jsxs("strong", { className: "font-semibold", children: [variant.title, "."] }), ' ', variant.body(display, sourceRecordLabel), docHref && (_jsxs(_Fragment, { children: [' ', _jsx("a", { href: docHref, target: "_blank", rel: "noreferrer", className: "underline underline-offset-2 hover:opacity-80", children: "Learn more \u2192" })] }))] })] }));
38
- }