@object-ui/app-shell 7.1.0 → 7.2.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 +279 -0
- package/dist/console/AppContent.js +9 -15
- package/dist/console/ConsoleShell.d.ts +16 -0
- package/dist/console/ConsoleShell.js +43 -2
- package/dist/console/ai/AiChatPage.js +36 -9
- package/dist/console/home/HomeLayout.js +5 -7
- package/dist/console/home/HomePage.js +1 -9
- package/dist/console/organizations/CreateWorkspaceDialog.js +15 -1
- package/dist/console/organizations/OrganizationsPage.js +22 -3
- package/dist/console/organizations/provisionEnvironment.d.ts +53 -0
- package/dist/console/organizations/provisionEnvironment.js +64 -0
- package/dist/environment/EnvironmentEntitlementDialog.d.ts +34 -0
- package/dist/environment/EnvironmentEntitlementDialog.js +37 -0
- package/dist/environment/EnvironmentListToolbar.d.ts +33 -0
- package/dist/environment/EnvironmentListToolbar.js +59 -0
- package/dist/environment/entitlements.d.ts +90 -0
- package/dist/environment/entitlements.js +91 -0
- package/dist/environment/useEnvironmentEntitlements.d.ts +32 -0
- package/dist/environment/useEnvironmentEntitlements.js +108 -0
- package/dist/hooks/useActionModal.js +15 -1
- package/dist/hooks/useAiSurface.d.ts +59 -0
- package/dist/hooks/useAiSurface.js +78 -0
- package/dist/hooks/useConsoleActionRuntime.d.ts +3 -0
- package/dist/hooks/useConsoleActionRuntime.js +36 -8
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -1
- package/dist/layout/AppHeader.js +28 -4
- package/dist/layout/ConsoleFloatingChatbot.js +16 -2
- package/dist/layout/ConsoleLayout.js +5 -6
- package/dist/preview/DraftPreviewBar.js +20 -7
- package/dist/providers/ExpressionProvider.js +9 -3
- package/dist/utils/index.d.ts +2 -2
- package/dist/utils/index.js +1 -1
- package/dist/utils/recordFormNavigation.d.ts +60 -0
- package/dist/utils/recordFormNavigation.js +35 -0
- package/dist/utils/resolvePageVarTokens.d.ts +31 -0
- package/dist/utils/resolvePageVarTokens.js +72 -0
- package/dist/views/CreateViewDialog.js +14 -1
- package/dist/views/ObjectView.js +26 -12
- package/dist/views/metadata-admin/AssignedUsersSection.d.ts +28 -0
- package/dist/views/metadata-admin/AssignedUsersSection.js +151 -0
- package/dist/views/metadata-admin/PackagesPage.d.ts +5 -0
- package/dist/views/metadata-admin/PackagesPage.js +49 -4
- package/dist/views/metadata-admin/PermissionMatrixEditor.js +2 -1
- package/dist/views/metadata-admin/ResourceEditPage.js +36 -4
- package/dist/views/metadata-admin/ResourceListPage.js +21 -4
- package/dist/views/metadata-admin/createBody.d.ts +26 -0
- package/dist/views/metadata-admin/createBody.js +30 -0
- package/dist/views/metadata-admin/i18n.js +20 -0
- package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.d.ts +8 -0
- package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.js +17 -3
- package/dist/views/metadata-admin/inspectors/FlowEdgeInspector.js +16 -2
- package/dist/views/metadata-admin/inspectors/FlowExprIssue.d.ts +21 -0
- package/dist/views/metadata-admin/inspectors/FlowExprIssue.js +13 -0
- package/dist/views/metadata-admin/inspectors/FlowKeyValueField.d.ts +20 -2
- package/dist/views/metadata-admin/inspectors/FlowKeyValueField.js +71 -28
- package/dist/views/metadata-admin/inspectors/FlowNodeConfigField.d.ts +4 -1
- package/dist/views/metadata-admin/inspectors/FlowNodeConfigField.js +24 -9
- package/dist/views/metadata-admin/inspectors/FlowNodeInspector.js +15 -3
- package/dist/views/metadata-admin/inspectors/FlowObjectListField.d.ts +4 -1
- package/dist/views/metadata-admin/inspectors/FlowObjectListField.js +8 -3
- package/dist/views/metadata-admin/inspectors/VariableTextInput.d.ts +47 -0
- package/dist/views/metadata-admin/inspectors/VariableTextInput.js +95 -0
- package/dist/views/metadata-admin/inspectors/datasetFilterCondition.js +6 -1
- package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +16 -1
- package/dist/views/metadata-admin/inspectors/flow-node-config.js +21 -10
- package/dist/views/metadata-admin/inspectors/flow-ref-check.d.ts +39 -0
- package/dist/views/metadata-admin/inspectors/flow-ref-check.js +114 -0
- package/dist/views/metadata-admin/inspectors/flow-scope.d.ts +109 -0
- package/dist/views/metadata-admin/inspectors/flow-scope.js +199 -0
- package/dist/views/metadata-admin/inspectors/useDatasetFields.d.ts +14 -3
- package/dist/views/metadata-admin/inspectors/useDatasetFields.js +0 -0
- package/dist/views/metadata-admin/inspectors/useFlowScope.d.ts +23 -0
- package/dist/views/metadata-admin/inspectors/useFlowScope.js +45 -0
- package/dist/views/metadata-admin/package-scope.d.ts +15 -0
- package/dist/views/metadata-admin/package-scope.js +16 -0
- package/dist/views/metadata-admin/preview-registry.d.ts +12 -0
- package/dist/views/metadata-admin/previews/FlowCanvas.d.ts +22 -3
- package/dist/views/metadata-admin/previews/FlowCanvas.js +45 -6
- package/dist/views/metadata-admin/previews/FlowPreview.d.ts +1 -1
- package/dist/views/metadata-admin/previews/FlowPreview.js +42 -30
- package/dist/views/metadata-admin/previews/ObjectFormCanvas.js +9 -4
- package/dist/views/metadata-admin/previews/ProblemsPanel.d.ts +18 -0
- package/dist/views/metadata-admin/previews/ProblemsPanel.js +27 -0
- package/dist/views/metadata-admin/previews/ReportPreview.d.ts +9 -8
- package/dist/views/metadata-admin/previews/ReportPreview.js +33 -16
- package/dist/views/metadata-admin/previews/flow-canvas-parts.d.ts +9 -1
- package/dist/views/metadata-admin/previews/flow-canvas-parts.js +5 -3
- package/dist/views/metadata-admin/previews/flow-expr-problems.d.ts +19 -0
- package/dist/views/metadata-admin/previews/flow-expr-problems.js +97 -0
- package/dist/views/metadata-admin/previews/flow-problems.d.ts +84 -0
- package/dist/views/metadata-admin/previews/flow-problems.js +209 -0
- package/dist/views/metadata-admin/previews/simulator/flow-sim-types.d.ts +9 -0
- package/dist/views/metadata-admin/previews/simulator/flow-sim-validate.js +4 -2
- package/package.json +38 -38
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* ObjectUI
|
|
4
4
|
* Copyright (c) 2024-present ObjectStack Inc.
|
|
@@ -16,7 +16,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
16
16
|
import { useEffect, useState } from 'react';
|
|
17
17
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
18
18
|
import { Eye, X, Rocket, GitCompareArrows } from 'lucide-react';
|
|
19
|
-
import { Button } from '@object-ui/components';
|
|
19
|
+
import { Button, cn } from '@object-ui/components';
|
|
20
20
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
21
21
|
import { usePreviewDrafts, markPreviewExit, PREVIEW_QUERY_FLAG } from './PreviewModeContext';
|
|
22
22
|
import { usePublishAllDrafts } from './usePublishAllDrafts';
|
|
@@ -78,9 +78,22 @@ export function DraftPreviewBar() {
|
|
|
78
78
|
}
|
|
79
79
|
catch { /* ignore */ } }, 300);
|
|
80
80
|
};
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
// Under the auto-publish posture (and any time a draft preview is opened with
|
|
82
|
+
// nothing staged) there are zero pending drafts. Claiming "nothing is live
|
|
83
|
+
// until you publish" and offering a Publish button then is both false and a
|
|
84
|
+
// no-op, so the bar drops the publish affordance and softens to a neutral
|
|
85
|
+
// preview indicator. An UNKNOWN count (null — still loading or the fetch
|
|
86
|
+
// failed) keeps the publish path: we only relax when we KNOW the count is zero.
|
|
87
|
+
const noChanges = pendingCount === 0;
|
|
88
|
+
return (_jsxs("div", { className: cn('sticky top-0 z-40 flex items-center gap-3 border-b px-4 py-2 text-sm', noChanges
|
|
89
|
+
? 'border-slate-200 bg-slate-50 text-slate-700 dark:border-slate-700/60 dark:bg-slate-900/40 dark:text-slate-300'
|
|
90
|
+
: 'border-amber-300/70 bg-amber-50 text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/40 dark:text-amber-200'), "data-testid": "draft-preview-bar", children: [_jsx(Eye, { className: "h-4 w-4 shrink-0" }), _jsx("p", { className: "min-w-0 flex-1 truncate", children: noChanges
|
|
91
|
+
? t('preview.draftBar.messageClean', {
|
|
92
|
+
defaultValue: 'Draft preview — no unpublished changes; everything here is already live.',
|
|
93
|
+
})
|
|
94
|
+
: t('preview.draftBar.message', {
|
|
95
|
+
defaultValue: 'Draft preview — you are seeing unpublished changes. Nothing here is live until you publish.',
|
|
96
|
+
}) }), !noChanges && (_jsxs(_Fragment, { children: [_jsxs(Button, { size: "sm", variant: "outline", onClick: () => setChangesOpen(true), "data-testid": "draft-preview-changes", children: [_jsx(GitCompareArrows, { className: "mr-1 h-3.5 w-3.5" }), t('preview.draftBar.changes', { defaultValue: 'Changes' }), typeof pendingCount === 'number' ? ` (${pendingCount})` : ''] }), _jsxs(Button, { size: "sm", onClick: publish, disabled: publishing, "data-testid": "draft-preview-publish", children: [_jsx(Rocket, { className: "mr-1 h-3.5 w-3.5" }), publishing
|
|
97
|
+
? t('preview.draftBar.publishing', { defaultValue: 'Publishing…' })
|
|
98
|
+
: t('preview.draftBar.publish', { defaultValue: 'Publish' })] }), _jsx(DraftChangesPanel, { open: changesOpen, onOpenChange: setChangesOpen })] })), _jsxs(Button, { size: "sm", variant: "outline", onClick: exit, "data-testid": "draft-preview-exit", children: [_jsx(X, { className: "mr-1 h-3.5 w-3.5" }), t('preview.draftBar.exit', { defaultValue: 'Exit preview' })] })] }));
|
|
86
99
|
}
|
|
@@ -20,14 +20,19 @@ import { PredicateScopeProvider } from '@object-ui/react';
|
|
|
20
20
|
const ExprCtx = createContext(null);
|
|
21
21
|
export function ExpressionProvider({ children, user = {}, app = {}, data = {}, features = {} }) {
|
|
22
22
|
const value = useMemo(() => {
|
|
23
|
-
|
|
23
|
+
// ADR-0068: expose the SAME user object under the canonical `current_user`
|
|
24
|
+
// plus the back-compat `user` alias and the server-RLS-parity `ctx.user`
|
|
25
|
+
// alias, so a predicate authored against any one form evaluates identically
|
|
26
|
+
// on client, server-formula, and server-RLS.
|
|
27
|
+
const context = { current_user: user, user, ctx: { user }, app, data, features };
|
|
24
28
|
const evaluator = new ExpressionEvaluator(context);
|
|
25
29
|
return { user, app, data, features, evaluator };
|
|
26
30
|
}, [user, app, data, features]);
|
|
27
31
|
// Also feed the predicate scope used by useCondition/useExpression in
|
|
28
32
|
// @object-ui/react so action visibility predicates (e.g. on toolbar
|
|
29
33
|
// buttons) can see deployment-level flags like features.multiOrgEnabled.
|
|
30
|
-
|
|
34
|
+
// Mirror the canonical `current_user`/`user`/`ctx.user` aliases here too.
|
|
35
|
+
const scope = useMemo(() => ({ current_user: user, user, ctx: { user }, app, data, features }), [user, app, data, features]);
|
|
31
36
|
return (_jsx(ExprCtx.Provider, { value: value, children: _jsx(PredicateScopeProvider, { scope: scope, children: children }) }));
|
|
32
37
|
}
|
|
33
38
|
/**
|
|
@@ -39,7 +44,8 @@ export function useExpressionContext() {
|
|
|
39
44
|
if (!ctx) {
|
|
40
45
|
// Return a safe default so components can be used outside the provider
|
|
41
46
|
const fallback = { user: {}, app: {}, data: {}, features: {} };
|
|
42
|
-
|
|
47
|
+
const evalContext = { current_user: {}, ctx: { user: {} }, ...fallback };
|
|
48
|
+
return { ...fallback, evaluator: new ExpressionEvaluator(evalContext) };
|
|
43
49
|
}
|
|
44
50
|
return ctx;
|
|
45
51
|
}
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Utility functions for ObjectStack Console
|
|
3
3
|
*/
|
|
4
|
-
export { resolveRecordFormTarget, } from './recordFormNavigation';
|
|
5
|
-
export type { ObjectDefinitionForNavigation, RecordFormTarget, } from './recordFormNavigation';
|
|
4
|
+
export { resolveRecordFormTarget, resolveFormViewLayout, } from './recordFormNavigation';
|
|
5
|
+
export type { ObjectDefinitionForNavigation, RecordFormTarget, ObjectDefinitionForFormView, FormViewDefinition, FormViewModalLayout, } from './recordFormNavigation';
|
|
6
6
|
export { deriveRelatedLists } from './deriveRelatedLists';
|
|
7
7
|
export type { DerivedRelatedList } from './deriveRelatedLists';
|
|
8
8
|
export { preferLocal } from './preferLocal';
|
package/dist/utils/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Utility functions for ObjectStack Console
|
|
3
3
|
*/
|
|
4
|
-
export { resolveRecordFormTarget, } from './recordFormNavigation';
|
|
4
|
+
export { resolveRecordFormTarget, resolveFormViewLayout, } from './recordFormNavigation';
|
|
5
5
|
export { deriveRelatedLists } from './deriveRelatedLists';
|
|
6
6
|
export { preferLocal } from './preferLocal';
|
|
7
7
|
export { appRouteSegment, matchAppBySegment } from './appRoute';
|
|
@@ -55,6 +55,66 @@ export declare function resolveRecordFormTarget(opts: {
|
|
|
55
55
|
_id?: string | number;
|
|
56
56
|
} | null | undefined;
|
|
57
57
|
}): RecordFormTarget;
|
|
58
|
+
/**
|
|
59
|
+
* Subset of the default form-view shape consumed by
|
|
60
|
+
* {@link resolveFormViewLayout}. This is the flattened `config` body of the
|
|
61
|
+
* object's default `viewKind: 'form'` ViewItem (ADR-0017), merged onto the
|
|
62
|
+
* object by `MetadataProvider` as `objectDef.form` (and mirrored under
|
|
63
|
+
* `objectDef.formViews.default` for the legacy aggregated-container shape).
|
|
64
|
+
*/
|
|
65
|
+
export interface FormViewDefinition {
|
|
66
|
+
/**
|
|
67
|
+
* Layout family declared by the form view (`simple` | `tabbed` | `wizard`
|
|
68
|
+
* | `split`). Only `tabbed` changes the *modal's* internal layout; the
|
|
69
|
+
* others still render their curated sections, just stacked.
|
|
70
|
+
*/
|
|
71
|
+
type?: string;
|
|
72
|
+
/** Curated field sections — the selection, order, and grouping to render. */
|
|
73
|
+
sections?: any[];
|
|
74
|
+
/** Inline child collections (master-detail). */
|
|
75
|
+
subforms?: any[];
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Object metadata subset carrying the default form view, as merged onto the
|
|
79
|
+
* runtime objects list (`useMetadata().objects`).
|
|
80
|
+
*/
|
|
81
|
+
export interface ObjectDefinitionForFormView {
|
|
82
|
+
form?: FormViewDefinition | null;
|
|
83
|
+
formViews?: {
|
|
84
|
+
default?: FormViewDefinition | null;
|
|
85
|
+
} | null;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Layout props derived from an object's default form view, ready to spread
|
|
89
|
+
* into a `<ModalForm>` schema.
|
|
90
|
+
*/
|
|
91
|
+
export interface FormViewModalLayout {
|
|
92
|
+
/** Curated sections to render (omitted when the form view declares none). */
|
|
93
|
+
sections?: any[];
|
|
94
|
+
/** `'tabbed'` when the form view is tabbed; omitted otherwise (stacked). */
|
|
95
|
+
contentLayout?: 'tabbed';
|
|
96
|
+
/** Inline child collections for a master-detail modal. */
|
|
97
|
+
subforms?: any[];
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Resolve a `<ModalForm>`'s layout props from an object's DEFAULT FORM VIEW
|
|
101
|
+
* (curated sections + field selection/order, plus master-detail subforms).
|
|
102
|
+
*
|
|
103
|
+
* The create / edit record modal otherwise falls back to the raw object
|
|
104
|
+
* schema — rendering every field in schema order and ignoring the curated
|
|
105
|
+
* form view entirely. This resolver lets the New/Edit modal honor the same
|
|
106
|
+
* view-driven layout the full-screen record page (`RecordFormPage`) does.
|
|
107
|
+
*
|
|
108
|
+
* Resolution mirrors `RecordFormPage`: prefer `objectDef.form` (the default
|
|
109
|
+
* ViewItem) and fall back to `objectDef.formViews.default` (legacy container).
|
|
110
|
+
*
|
|
111
|
+
* Returns an EMPTY object when the object has no form view, or a form view
|
|
112
|
+
* that declares no sections — the caller then keeps its existing behavior
|
|
113
|
+
* (a flat field list, i.e. the raw object schema). Empty `sections` /
|
|
114
|
+
* `subforms` arrays are treated as absent so an empty curation never blanks
|
|
115
|
+
* out the form.
|
|
116
|
+
*/
|
|
117
|
+
export declare function resolveFormViewLayout(objectDef: ObjectDefinitionForFormView | null | undefined): FormViewModalLayout;
|
|
58
118
|
/**
|
|
59
119
|
* Action descriptor accepted by the navigate-create / navigate-edit
|
|
60
120
|
* handlers. Loose-typed because the same shape is constructed dynamically
|
|
@@ -41,6 +41,41 @@ export function resolveRecordFormTarget(opts) {
|
|
|
41
41
|
}
|
|
42
42
|
return { kind: 'page', url: `${baseUrl}/${objectDef.name}/new` };
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve a `<ModalForm>`'s layout props from an object's DEFAULT FORM VIEW
|
|
46
|
+
* (curated sections + field selection/order, plus master-detail subforms).
|
|
47
|
+
*
|
|
48
|
+
* The create / edit record modal otherwise falls back to the raw object
|
|
49
|
+
* schema — rendering every field in schema order and ignoring the curated
|
|
50
|
+
* form view entirely. This resolver lets the New/Edit modal honor the same
|
|
51
|
+
* view-driven layout the full-screen record page (`RecordFormPage`) does.
|
|
52
|
+
*
|
|
53
|
+
* Resolution mirrors `RecordFormPage`: prefer `objectDef.form` (the default
|
|
54
|
+
* ViewItem) and fall back to `objectDef.formViews.default` (legacy container).
|
|
55
|
+
*
|
|
56
|
+
* Returns an EMPTY object when the object has no form view, or a form view
|
|
57
|
+
* that declares no sections — the caller then keeps its existing behavior
|
|
58
|
+
* (a flat field list, i.e. the raw object schema). Empty `sections` /
|
|
59
|
+
* `subforms` arrays are treated as absent so an empty curation never blanks
|
|
60
|
+
* out the form.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveFormViewLayout(objectDef) {
|
|
63
|
+
const formView = objectDef?.form ?? objectDef?.formViews?.default;
|
|
64
|
+
if (!formView)
|
|
65
|
+
return {};
|
|
66
|
+
const layout = {};
|
|
67
|
+
if (Array.isArray(formView.sections) && formView.sections.length > 0) {
|
|
68
|
+
layout.sections = formView.sections;
|
|
69
|
+
// Only 'tabbed' maps to a modal content layout; 'wizard'/'split' have no
|
|
70
|
+
// modal equivalent and degrade to a stacked section list.
|
|
71
|
+
if (formView.type === 'tabbed')
|
|
72
|
+
layout.contentLayout = 'tabbed';
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(formView.subforms) && formView.subforms.length > 0) {
|
|
75
|
+
layout.subforms = formView.subforms;
|
|
76
|
+
}
|
|
77
|
+
return layout;
|
|
78
|
+
}
|
|
44
79
|
/**
|
|
45
80
|
* Resolve the URL for a `navigate_create` action.
|
|
46
81
|
*
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*
|
|
8
|
+
* resolvePageVarTokens — resolve `{{page.<path>}}` tokens against a page-variable
|
|
9
|
+
* snapshot. The data-entry bridge for SDUI forms: an interactive input
|
|
10
|
+
* (`element:text_input`, `element:record_picker`) writes a page variable; a
|
|
11
|
+
* submit action references it in its params/body as `{{page.<var>}}`; this
|
|
12
|
+
* resolves those tokens against the live snapshot — published into the action
|
|
13
|
+
* context by `PageVariableActionBridge` — just before the request body is built.
|
|
14
|
+
*
|
|
15
|
+
* - A WHOLE-VALUE token (`"{{page.amount}}"`) is replaced by the variable's RAW
|
|
16
|
+
* value, preserving its type — a number stays a number, an object stays an
|
|
17
|
+
* object — so numeric/boolean/array form fields submit with the right JSON
|
|
18
|
+
* type rather than being stringified.
|
|
19
|
+
* - An EMBEDDED token (`"/orgs/{{page.slug}}/setup"`) is string-interpolated.
|
|
20
|
+
* - Resolution walks nested objects/arrays. A whole-value miss resolves to ''
|
|
21
|
+
* (kept as a present-but-empty field); an embedded miss drops to ''.
|
|
22
|
+
*
|
|
23
|
+
* Distinct from the single-brace `{field}` row-record interpolation used in API
|
|
24
|
+
* target URLs (different brace count, different source) so the two never collide.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Deep-resolve every `{{page.<var>}}` token in `value` against `pageVariables`.
|
|
28
|
+
* Returns `value` unchanged when there is no snapshot. Non-string leaves pass
|
|
29
|
+
* through untouched; the input is never mutated (objects/arrays are copied).
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolvePageVarTokens<T>(value: T, pageVariables?: Record<string, any> | null): T;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*
|
|
8
|
+
* resolvePageVarTokens — resolve `{{page.<path>}}` tokens against a page-variable
|
|
9
|
+
* snapshot. The data-entry bridge for SDUI forms: an interactive input
|
|
10
|
+
* (`element:text_input`, `element:record_picker`) writes a page variable; a
|
|
11
|
+
* submit action references it in its params/body as `{{page.<var>}}`; this
|
|
12
|
+
* resolves those tokens against the live snapshot — published into the action
|
|
13
|
+
* context by `PageVariableActionBridge` — just before the request body is built.
|
|
14
|
+
*
|
|
15
|
+
* - A WHOLE-VALUE token (`"{{page.amount}}"`) is replaced by the variable's RAW
|
|
16
|
+
* value, preserving its type — a number stays a number, an object stays an
|
|
17
|
+
* object — so numeric/boolean/array form fields submit with the right JSON
|
|
18
|
+
* type rather than being stringified.
|
|
19
|
+
* - An EMBEDDED token (`"/orgs/{{page.slug}}/setup"`) is string-interpolated.
|
|
20
|
+
* - Resolution walks nested objects/arrays. A whole-value miss resolves to ''
|
|
21
|
+
* (kept as a present-but-empty field); an embedded miss drops to ''.
|
|
22
|
+
*
|
|
23
|
+
* Distinct from the single-brace `{field}` row-record interpolation used in API
|
|
24
|
+
* target URLs (different brace count, different source) so the two never collide.
|
|
25
|
+
*/
|
|
26
|
+
const WHOLE_RE = /^\s*\{\{\s*page\.([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}\}\s*$/;
|
|
27
|
+
function lookup(path, vars) {
|
|
28
|
+
let node = vars;
|
|
29
|
+
for (const seg of path.split('.')) {
|
|
30
|
+
if (node == null)
|
|
31
|
+
return undefined;
|
|
32
|
+
node = node[seg];
|
|
33
|
+
}
|
|
34
|
+
return node;
|
|
35
|
+
}
|
|
36
|
+
function resolveString(str, vars) {
|
|
37
|
+
if (!str.includes('{{'))
|
|
38
|
+
return str;
|
|
39
|
+
const whole = str.match(WHOLE_RE);
|
|
40
|
+
if (whole) {
|
|
41
|
+
const v = lookup(whole[1], vars);
|
|
42
|
+
return v === undefined ? '' : v;
|
|
43
|
+
}
|
|
44
|
+
// Fresh global regex per call — avoids shared `lastIndex` state.
|
|
45
|
+
return str.replace(/\{\{\s*page\.([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}\}/g, (_m, path) => {
|
|
46
|
+
const v = lookup(path, vars);
|
|
47
|
+
return v == null ? '' : String(v);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function walk(value, vars) {
|
|
51
|
+
if (typeof value === 'string')
|
|
52
|
+
return resolveString(value, vars);
|
|
53
|
+
if (Array.isArray(value))
|
|
54
|
+
return value.map((v) => walk(v, vars));
|
|
55
|
+
if (value && typeof value === 'object') {
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const [k, v] of Object.entries(value))
|
|
58
|
+
out[k] = walk(v, vars);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Deep-resolve every `{{page.<var>}}` token in `value` against `pageVariables`.
|
|
65
|
+
* Returns `value` unchanged when there is no snapshot. Non-string leaves pass
|
|
66
|
+
* through untouched; the input is never mutated (objects/arrays are copied).
|
|
67
|
+
*/
|
|
68
|
+
export function resolvePageVarTokens(value, pageVariables) {
|
|
69
|
+
if (!pageVariables)
|
|
70
|
+
return value;
|
|
71
|
+
return walk(value, pageVariables);
|
|
72
|
+
}
|
|
@@ -18,7 +18,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|
|
18
18
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Button, cn, } from '@object-ui/components';
|
|
19
19
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
20
20
|
import { deriveFieldOptions, isImageLikeField, isGeoLikeField, pickPreferredField, KANBAN_GROUP_PREFERRED, PRIMARY_DATE_PREFERRED, END_DATE_PREFERRED, } from '@object-ui/plugin-view';
|
|
21
|
-
import { LayoutGrid, KanbanSquare, Calendar as CalendarIcon, Image as ImageIcon, GanttChartSquare, Clock, Map as MapIcon, BarChart3, AlertCircle, } from 'lucide-react';
|
|
21
|
+
import { LayoutGrid, KanbanSquare, Calendar as CalendarIcon, Image as ImageIcon, GanttChartSquare, Clock, Map as MapIcon, BarChart3, ListTree, AlertCircle, } from 'lucide-react';
|
|
22
22
|
function buildViewTypeMeta(t) {
|
|
23
23
|
return [
|
|
24
24
|
{ type: 'grid', icon: LayoutGrid, label: t('console.objectView.viewTypeGrid'), description: t('console.objectView.viewTypeGridDesc') },
|
|
@@ -29,6 +29,7 @@ function buildViewTypeMeta(t) {
|
|
|
29
29
|
{ type: 'gantt', icon: GanttChartSquare, label: t('console.objectView.viewTypeGantt'), description: t('console.objectView.viewTypeGanttDesc') },
|
|
30
30
|
{ type: 'map', icon: MapIcon, label: t('console.objectView.viewTypeMap'), description: t('console.objectView.viewTypeMapDesc') },
|
|
31
31
|
{ type: 'chart', icon: BarChart3, label: t('console.objectView.viewTypeChart'), description: t('console.objectView.viewTypeChartDesc') },
|
|
32
|
+
{ type: 'tree', icon: ListTree, label: t('console.objectView.viewTypeTree'), description: t('console.objectView.viewTypeTreeDesc') },
|
|
32
33
|
];
|
|
33
34
|
}
|
|
34
35
|
/** Suggest a non-colliding default name like "Grid 1", "Grid 2", … */
|
|
@@ -154,6 +155,18 @@ const REQUIRED_FIELDS_BY_TYPE = {
|
|
|
154
155
|
filter: (f) => f.type === 'number',
|
|
155
156
|
},
|
|
156
157
|
],
|
|
158
|
+
tree: [
|
|
159
|
+
{
|
|
160
|
+
key: 'parentField',
|
|
161
|
+
i18nKey: 'console.objectView.parentField',
|
|
162
|
+
helpI18nKey: 'console.objectView.parentFieldHelp',
|
|
163
|
+
// Self-referencing pointer: a `tree` field, or a lookup/master_detail
|
|
164
|
+
// back to the same object. `rawType` carries the unnormalized field type.
|
|
165
|
+
filter: (f) => f.rawType === 'tree' ||
|
|
166
|
+
f.rawType === 'lookup' ||
|
|
167
|
+
f.rawType === 'master_detail',
|
|
168
|
+
},
|
|
169
|
+
],
|
|
157
170
|
// grid has no strictly required fields at create time
|
|
158
171
|
};
|
|
159
172
|
export function CreateViewDialog({ open, onOpenChange, onCreate, existingLabels, availableTypes, objectDef, }) {
|
package/dist/views/ObjectView.js
CHANGED
|
@@ -42,6 +42,8 @@ import { useRealtimeSubscription, useConflictResolution } from '@object-ui/colla
|
|
|
42
42
|
import { ActionProvider, useNavigationOverlay, SchemaRenderer } from '@object-ui/react';
|
|
43
43
|
import { toast } from 'sonner';
|
|
44
44
|
import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime';
|
|
45
|
+
import { useEnvironmentEntitlements } from '../environment/useEnvironmentEntitlements';
|
|
46
|
+
import { EnvironmentListToolbar } from '../environment/EnvironmentListToolbar';
|
|
45
47
|
/** Map view types to Lucide icons (Airtable-style) */
|
|
46
48
|
const VIEW_TYPE_ICONS = {
|
|
47
49
|
grid: TableIcon,
|
|
@@ -272,6 +274,27 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }) {
|
|
|
272
274
|
onRefresh: () => setRefreshKey((k) => k + 1),
|
|
273
275
|
});
|
|
274
276
|
const { confirmHandler, toastHandler } = actionRuntime;
|
|
277
|
+
// Environment list (sys_environment) is entitlement- + state-aware: born
|
|
278
|
+
// with one production env per org, its single `create_environment` action
|
|
279
|
+
// means different things — "Set up your production environment", "Add
|
|
280
|
+
// development environment", or an upgrade prompt — depending on org state.
|
|
281
|
+
// Resolved org-side here; the hook is a no-op for every other object.
|
|
282
|
+
const isEnvironmentList = objectDef.name === 'sys_environment';
|
|
283
|
+
const environmentEntitlements = useEnvironmentEntitlements({
|
|
284
|
+
enabled: isEnvironmentList,
|
|
285
|
+
dataSource,
|
|
286
|
+
authFetch: actionRuntime.authFetch,
|
|
287
|
+
apiBase: import.meta.env?.VITE_SERVER_URL || '',
|
|
288
|
+
refreshKey,
|
|
289
|
+
});
|
|
290
|
+
// Localized `list_toolbar` actions, shared by the generic action bar and the
|
|
291
|
+
// environment-aware toolbar (the action:bar renderer filters by location).
|
|
292
|
+
const localizedToolbarActions = useMemo(() => (objectDef.actions || []).map((a) => ({
|
|
293
|
+
...a,
|
|
294
|
+
label: actionLabel(objectDef.name, a.name, a.label || a.name),
|
|
295
|
+
...(a.confirmText !== undefined && { confirmText: actionConfirm(objectDef.name, a.name, a.confirmText) }),
|
|
296
|
+
...(a.successMessage !== undefined && { successMessage: actionSuccess(objectDef.name, a.name, a.successMessage) }),
|
|
297
|
+
})), [objectDef, actionLabel, actionConfirm, actionSuccess]);
|
|
275
298
|
// Resolve which generic CRUD affordances belong in the toolbar for
|
|
276
299
|
// this object's lifecycle bucket (`managedBy`). config tables show
|
|
277
300
|
// New/Edit/Delete but no CSV Import; system / append-only / better-auth
|
|
@@ -1333,19 +1356,10 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }) {
|
|
|
1333
1356
|
? t('common.removeFromFavorites', { defaultValue: 'Remove from favorites' })
|
|
1334
1357
|
: t('common.addToFavorites', { defaultValue: 'Add to favorites' }), "data-testid": `object-favorite-btn-${objectName}`, children: isFavorite(`object:${objectName}`)
|
|
1335
1358
|
? _jsx(Star, { className: "h-4 w-4 fill-amber-400 text-amber-400" })
|
|
1336
|
-
: _jsx(StarOff, { className: "h-4 w-4" }) })), 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: "hidden sm:inline-flex 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: {
|
|
1359
|
+
: _jsx(StarOff, { className: "h-4 w-4" }) })), 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: "hidden sm:inline-flex 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')) && (isEnvironmentList ? (_jsx(EnvironmentListToolbar, { actions: localizedToolbarActions, entitlements: environmentEntitlements, onUpgrade: actionRuntime.openEntitlementDialog })) : (_jsx(SchemaRenderer, { schema: {
|
|
1337
1360
|
type: 'action:bar',
|
|
1338
1361
|
location: 'list_toolbar',
|
|
1339
|
-
actions:
|
|
1340
|
-
...a,
|
|
1341
|
-
label: actionLabel(objectDef.name, a.name, a.label || a.name),
|
|
1342
|
-
...(a.confirmText !== undefined && {
|
|
1343
|
-
confirmText: actionConfirm(objectDef.name, a.name, a.confirmText),
|
|
1344
|
-
}),
|
|
1345
|
-
...(a.successMessage !== undefined && {
|
|
1346
|
-
successMessage: actionSuccess(objectDef.name, a.name, a.successMessage),
|
|
1347
|
-
}),
|
|
1348
|
-
})),
|
|
1362
|
+
actions: localizedToolbarActions,
|
|
1349
1363
|
size: 'sm',
|
|
1350
1364
|
variant: 'outline',
|
|
1351
1365
|
// On mobile, collapse all schema-driven toolbar actions
|
|
@@ -1353,7 +1367,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }) {
|
|
|
1353
1367
|
// Import buttons stay visible without pushing the page
|
|
1354
1368
|
// title off-screen.
|
|
1355
1369
|
mobileMaxVisible: 0,
|
|
1356
|
-
} }))] }) }) }), affordances.create && can(objectDef.name, 'create') && (_jsx("button", { type: "button", onClick: actions.create, className: "sm:hidden fixed right-4 bottom-36 z-40 h-12 w-12 rounded-full bg-primary text-primary-foreground shadow-lg active:scale-95 transition-transform inline-flex items-center justify-center", "aria-label": t('console.objectView.new'), "data-testid": "mobile-fab-create", children: _jsx(Plus, { className: "h-5 w-5" }) })), showImport && (_jsx(Suspense, { fallback: null, children: _jsx(ImportWizard, { open: showImport, onOpenChange: setShowImport, objectName: objectDef.name, objectLabel: objectLabel(objectDef), fields: Object.entries(objectDef.fields || {}).map(([name, def]) => ({
|
|
1370
|
+
} })))] }) }) }), affordances.create && can(objectDef.name, 'create') && (_jsx("button", { type: "button", onClick: actions.create, className: "sm:hidden fixed right-4 bottom-36 z-40 h-12 w-12 rounded-full bg-primary text-primary-foreground shadow-lg active:scale-95 transition-transform inline-flex items-center justify-center", "aria-label": t('console.objectView.new'), "data-testid": "mobile-fab-create", children: _jsx(Plus, { className: "h-5 w-5" }) })), showImport && (_jsx(Suspense, { fallback: null, children: _jsx(ImportWizard, { open: showImport, onOpenChange: setShowImport, objectName: objectDef.name, objectLabel: objectLabel(objectDef), fields: Object.entries(objectDef.fields || {}).map(([name, def]) => ({
|
|
1357
1371
|
name,
|
|
1358
1372
|
label: def?.label || name,
|
|
1359
1373
|
type: def?.type || 'text',
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*
|
|
8
|
+
* AssignedUsersSection — "Manage Assignments" for a permission set.
|
|
9
|
+
*
|
|
10
|
+
* The admin's mental model is "who holds this role / AI seat" — so this is a
|
|
11
|
+
* people-first list (name + email + remove), not a raw junction table. It reads
|
|
12
|
+
* `sys_user_permission_set` for the set, resolves each `user_id` to a real
|
|
13
|
+
* person, and uses the reusable `RecordPickerDialog` to assign more. Server-side
|
|
14
|
+
* rules on the junction insert (e.g. the AI-seat cap) are caught and shown as a
|
|
15
|
+
* friendly, localized inline message — not a raw developer error.
|
|
16
|
+
*
|
|
17
|
+
* Permission-set-agnostic: every role gets the same UI, and the AI seat
|
|
18
|
+
* (`ai_seat`) is just one of them. The generic add-by-picker engine (spec
|
|
19
|
+
* RecordRelatedListProps.add) powers the capability; this is the polished
|
|
20
|
+
* surface for the high-value case.
|
|
21
|
+
*/
|
|
22
|
+
import * as React from 'react';
|
|
23
|
+
export interface AssignedUsersSectionProps {
|
|
24
|
+
/** The permission set's machine name (e.g. `ai_seat`, `admin_full_access`). */
|
|
25
|
+
permissionSetName: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function AssignedUsersSection({ permissionSetName }: AssignedUsersSectionProps): React.JSX.Element;
|
|
28
|
+
export default AssignedUsersSection;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ObjectUI
|
|
4
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
5
|
+
*
|
|
6
|
+
* This source code is licensed under the MIT license found in the
|
|
7
|
+
* LICENSE file in the root directory of this source tree.
|
|
8
|
+
*
|
|
9
|
+
* AssignedUsersSection — "Manage Assignments" for a permission set.
|
|
10
|
+
*
|
|
11
|
+
* The admin's mental model is "who holds this role / AI seat" — so this is a
|
|
12
|
+
* people-first list (name + email + remove), not a raw junction table. It reads
|
|
13
|
+
* `sys_user_permission_set` for the set, resolves each `user_id` to a real
|
|
14
|
+
* person, and uses the reusable `RecordPickerDialog` to assign more. Server-side
|
|
15
|
+
* rules on the junction insert (e.g. the AI-seat cap) are caught and shown as a
|
|
16
|
+
* friendly, localized inline message — not a raw developer error.
|
|
17
|
+
*
|
|
18
|
+
* Permission-set-agnostic: every role gets the same UI, and the AI seat
|
|
19
|
+
* (`ai_seat`) is just one of them. The generic add-by-picker engine (spec
|
|
20
|
+
* RecordRelatedListProps.add) powers the capability; this is the polished
|
|
21
|
+
* surface for the high-value case.
|
|
22
|
+
*/
|
|
23
|
+
import * as React from 'react';
|
|
24
|
+
import { Button } from '@object-ui/components';
|
|
25
|
+
import { RecordPickerDialog } from '@object-ui/fields';
|
|
26
|
+
import { useAdapter } from '@object-ui/react';
|
|
27
|
+
import { Plus, X, Users, Loader2, AlertCircle } from 'lucide-react';
|
|
28
|
+
import { detectLocale } from './i18n';
|
|
29
|
+
/** Minimal locale-aware copy (zh vs everything-else) — keeps the surface in the user's language. */
|
|
30
|
+
function useCopy() {
|
|
31
|
+
const zh = React.useMemo(() => detectLocale().toLowerCase().startsWith('zh'), []);
|
|
32
|
+
return React.useMemo(() => zh
|
|
33
|
+
? {
|
|
34
|
+
title: '已分配用户',
|
|
35
|
+
add: '添加用户',
|
|
36
|
+
remove: '移除',
|
|
37
|
+
empty: '还没有分配任何用户。点击「添加用户」来分配。',
|
|
38
|
+
loading: '加载中…',
|
|
39
|
+
pickTitle: '选择要分配的用户',
|
|
40
|
+
seatFull: (n) => 'AI 席位已用完(' + n + '/' + n + ')。请先移除一个用户,或在许可证中提升席位上限,再分配新用户。',
|
|
41
|
+
addFailed: '分配失败,请重试。',
|
|
42
|
+
countOf: (n) => n + ' 人',
|
|
43
|
+
}
|
|
44
|
+
: {
|
|
45
|
+
title: 'Assigned Users',
|
|
46
|
+
add: 'Add user',
|
|
47
|
+
remove: 'Remove',
|
|
48
|
+
empty: 'No users assigned yet. Click "Add user" to assign.',
|
|
49
|
+
loading: 'Loading…',
|
|
50
|
+
pickTitle: 'Select users to assign',
|
|
51
|
+
seatFull: (n) => 'All ' + n + ' AI seat(s) are in use. Remove a user or raise the license cap before assigning another.',
|
|
52
|
+
addFailed: 'Failed to assign. Please try again.',
|
|
53
|
+
countOf: (n) => String(n),
|
|
54
|
+
}, [zh]);
|
|
55
|
+
}
|
|
56
|
+
const asArray = (res) => Array.isArray(res) ? res : res?.records ?? res?.items ?? res?.data ?? [];
|
|
57
|
+
const personLabel = (u) => u?.full_name || u?.name || u?.display_name || u?.email || String(u?.id ?? '');
|
|
58
|
+
export function AssignedUsersSection({ permissionSetName }) {
|
|
59
|
+
const adapter = useAdapter();
|
|
60
|
+
const c = useCopy();
|
|
61
|
+
const [setId, setSetId] = React.useState(null);
|
|
62
|
+
const [rows, setRows] = React.useState([]);
|
|
63
|
+
const [loading, setLoading] = React.useState(true);
|
|
64
|
+
const [pickerOpen, setPickerOpen] = React.useState(false);
|
|
65
|
+
const [busy, setBusy] = React.useState(false);
|
|
66
|
+
const [error, setError] = React.useState(null);
|
|
67
|
+
const load = React.useCallback(async () => {
|
|
68
|
+
setLoading(true);
|
|
69
|
+
try {
|
|
70
|
+
const sets = asArray(await adapter.find('sys_permission_set', { $filter: { name: permissionSetName }, limit: 1 }));
|
|
71
|
+
const id = sets[0]?.id ? String(sets[0].id) : null;
|
|
72
|
+
setSetId(id);
|
|
73
|
+
if (!id) {
|
|
74
|
+
setRows([]);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const grants = asArray(await adapter.find('sys_user_permission_set', { $filter: { permission_set_id: id }, $top: 500 }));
|
|
78
|
+
const userIds = [...new Set(grants.map((g) => g.user_id).filter(Boolean).map(String))];
|
|
79
|
+
const users = userIds.length
|
|
80
|
+
? asArray(await adapter.find('sys_user', { $filter: { id: { $in: userIds } }, $top: 500 }))
|
|
81
|
+
: [];
|
|
82
|
+
const byId = new Map(users.map((u) => [String(u.id), u]));
|
|
83
|
+
setRows(grants
|
|
84
|
+
.filter((g) => g.user_id)
|
|
85
|
+
.map((g) => {
|
|
86
|
+
const u = byId.get(String(g.user_id));
|
|
87
|
+
return {
|
|
88
|
+
grantId: String(g.id),
|
|
89
|
+
userId: String(g.user_id),
|
|
90
|
+
name: u ? personLabel(u) : String(g.user_id),
|
|
91
|
+
email: u?.email ?? '',
|
|
92
|
+
};
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
setRows([]);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
setLoading(false);
|
|
100
|
+
}
|
|
101
|
+
}, [adapter, permissionSetName]);
|
|
102
|
+
React.useEffect(() => {
|
|
103
|
+
void load();
|
|
104
|
+
}, [load]);
|
|
105
|
+
const assignedIds = React.useMemo(() => new Set(rows.map((r) => r.userId)), [rows]);
|
|
106
|
+
const addUsers = React.useCallback(async (records) => {
|
|
107
|
+
if (!setId)
|
|
108
|
+
return;
|
|
109
|
+
setBusy(true);
|
|
110
|
+
setError(null);
|
|
111
|
+
try {
|
|
112
|
+
for (const u of records || []) {
|
|
113
|
+
const uid = u?.id != null ? String(u.id) : null;
|
|
114
|
+
if (!uid || assignedIds.has(uid))
|
|
115
|
+
continue;
|
|
116
|
+
await adapter.create('sys_user_permission_set', { permission_set_id: setId, user_id: uid });
|
|
117
|
+
}
|
|
118
|
+
await load();
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
const raw = String(err?.body?.error ?? err?.error ?? err?.message ?? '');
|
|
122
|
+
const capMatch = raw.match(/(\d+)\s*of\s*(\d+)\s*seat/i);
|
|
123
|
+
if (/cap reached|seat cap|ai[-_ ]?seat/i.test(raw)) {
|
|
124
|
+
setError(c.seatFull(capMatch ? Number(capMatch[2]) : rows.length));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const cleaned = raw.replace(/^\s*\[[^\]]*\]\s*/, '').trim();
|
|
128
|
+
setError(cleaned || c.addFailed);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
setBusy(false);
|
|
133
|
+
setPickerOpen(false);
|
|
134
|
+
}
|
|
135
|
+
}, [adapter, setId, assignedIds, load, rows.length, c]);
|
|
136
|
+
const removeUser = React.useCallback(async (grantId) => {
|
|
137
|
+
setError(null);
|
|
138
|
+
try {
|
|
139
|
+
await adapter.delete('sys_user_permission_set', grantId);
|
|
140
|
+
await load();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
/* keep the row; a failed delete is non-destructive */
|
|
144
|
+
}
|
|
145
|
+
}, [adapter, load]);
|
|
146
|
+
return (_jsxs("div", { className: "px-4 py-4", children: [_jsxs("div", { className: "flex items-center justify-between mb-3", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx(Users, { className: "h-4 w-4 text-muted-foreground" }), _jsx("span", { children: c.title }), !loading && (_jsx("span", { className: "text-xs text-muted-foreground font-normal", children: c.countOf(rows.length) }))] }), _jsxs(Button, { variant: "outline", size: "sm", disabled: busy || !setId, onClick: () => {
|
|
147
|
+
setError(null);
|
|
148
|
+
setPickerOpen(true);
|
|
149
|
+
}, className: "gap-1 h-8 text-xs", children: [busy ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Plus, { className: "h-3.5 w-3.5" }), c.add] })] }), error && (_jsxs("div", { className: "mb-3 flex items-start gap-2 rounded-md border border-amber-300/60 bg-amber-50 dark:bg-amber-950/30 px-3 py-2 text-xs text-amber-800 dark:text-amber-200", role: "alert", children: [_jsx(AlertCircle, { className: "h-3.5 w-3.5 mt-0.5 shrink-0" }), _jsx("span", { children: error })] })), loading ? (_jsxs("div", { className: "flex items-center gap-2 text-xs text-muted-foreground py-3", children: [_jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }), c.loading] })) : rows.length === 0 ? (_jsx("div", { className: "text-xs text-muted-foreground italic py-3", children: c.empty })) : (_jsx("ul", { className: "divide-y rounded-md border", children: rows.map((r) => (_jsxs("li", { className: "flex items-center gap-3 px-3 py-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-medium shrink-0", children: (r.name || '?').slice(0, 1).toUpperCase() }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-sm truncate", children: r.name }), r.email && r.email !== r.name && (_jsx("div", { className: "text-xs text-muted-foreground truncate", children: r.email }))] }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => void removeUser(r.grantId), "aria-label": c.remove, title: c.remove, className: "h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0", children: _jsx(X, { className: "h-4 w-4" }) })] }, r.grantId))) })), setId && (_jsx(RecordPickerDialog, { open: pickerOpen, onOpenChange: (o) => setPickerOpen(o), multiple: true, dataSource: adapter, objectName: "sys_user", title: c.pickTitle, onSelect: () => { }, onSelectRecords: (records) => void addUsers(records) }))] }));
|
|
150
|
+
}
|
|
151
|
+
export default AssignedUsersSection;
|
|
@@ -14,5 +14,10 @@
|
|
|
14
14
|
* (see framework `http-dispatcher.handlePackages`).
|
|
15
15
|
*/
|
|
16
16
|
import * as React from 'react';
|
|
17
|
+
export declare function CreatePackageDialog({ open, onOpenChange, onCreated, }: {
|
|
18
|
+
open: boolean;
|
|
19
|
+
onOpenChange: (v: boolean) => void;
|
|
20
|
+
onCreated: (id: string) => void;
|
|
21
|
+
}): React.JSX.Element;
|
|
17
22
|
export declare function PackagesPage(): React.JSX.Element;
|
|
18
23
|
export default PackagesPage;
|