@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 +45 -0
- package/dist/components/ManagedByBadge.d.ts +10 -0
- package/dist/components/ManagedByBadge.js +44 -0
- package/dist/layout/ActivityFeed.js +3 -3
- package/dist/utils/managedByEmptyState.d.ts +29 -0
- package/dist/utils/managedByEmptyState.js +24 -0
- package/dist/utils/resolveActionParams.d.ts +86 -0
- package/dist/utils/resolveActionParams.js +77 -0
- package/dist/views/ObjectView.js +113 -11
- package/dist/views/RecordDetailView.js +24 -35
- package/dist/views/RecordFormPage.js +2 -2
- package/dist/views/ReportView.js +21 -19
- package/package.json +24 -24
- package/dist/components/ManagedByBanner.d.ts +0 -16
- package/dist/components/ManagedByBanner.js +0 -38
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 {
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/dist/views/ObjectView.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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 {
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
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: [
|
|
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 {
|
|
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 })
|
|
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,
|
package/dist/views/ReportView.js
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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.
|
|
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.
|
|
31
|
-
"@object-ui/collaboration": "4.
|
|
32
|
-
"@object-ui/components": "4.
|
|
33
|
-
"@object-ui/core": "4.
|
|
34
|
-
"@object-ui/data-objectstack": "4.
|
|
35
|
-
"@object-ui/fields": "4.
|
|
36
|
-
"@object-ui/i18n": "4.
|
|
37
|
-
"@object-ui/layout": "4.
|
|
38
|
-
"@object-ui/permissions": "4.
|
|
39
|
-
"@object-ui/react": "4.
|
|
40
|
-
"@object-ui/types": "4.
|
|
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.
|
|
47
|
-
"@object-ui/plugin-charts": "^4.
|
|
48
|
-
"@object-ui/plugin-chatbot": "^4.
|
|
49
|
-
"@object-ui/plugin-dashboard": "^4.
|
|
50
|
-
"@object-ui/plugin-designer": "^4.
|
|
51
|
-
"@object-ui/plugin-detail": "^4.
|
|
52
|
-
"@object-ui/plugin-form": "^4.
|
|
53
|
-
"@object-ui/plugin-grid": "^4.
|
|
54
|
-
"@object-ui/plugin-kanban": "^4.
|
|
55
|
-
"@object-ui/plugin-list": "^4.
|
|
56
|
-
"@object-ui/plugin-report": "^4.
|
|
57
|
-
"@object-ui/plugin-view": "^4.
|
|
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
|
-
}
|