@nocobase/portal-template-default 1.0.2 → 1.0.4
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/README.MD +10 -5
- package/package.json +1 -1
- package/registry/nocobase-ai/components/chat/chat-message.tsx +8 -6
- package/registry/nocobase-ai/components/chat/chat-messages.tsx +8 -3
- package/registry/nocobase-ai/components/chat/markdown-message.tsx +26 -0
- package/registry/nocobase-ai/components/chat/sub-agent-conversation.tsx +3 -3
- package/registry/nocobase-ai/demo/configuration-gate.tsx +83 -35
- package/registry/nocobase-i18n/README.md +23 -32
- package/registry/nocobase-i18n/components/language-switcher.tsx +1 -1
- package/registry/nocobase-i18n/demo/index.tsx +2 -2
- package/registry/nocobase-i18n/demo/prompt-generator.tsx +3 -2
- package/registry/nocobase-i18n/extension.tsx +3 -4
- package/registry/nocobase-i18n/index.ts +4 -3
- package/registry/nocobase-i18n/locales/en-US.ts +2 -52
- package/registry/nocobase-i18n/locales/index.ts +8 -0
- package/registry/nocobase-i18n/locales/zh-CN.ts +2 -52
- package/registry/nocobase-i18n/provider.tsx +18 -6
- package/registry/nocobase-i18n/server-resources.ts +13 -31
- package/registry/nocobase-i18n/tests/i18n-regression.mjs +14 -5
- package/registry/nocobase-route-surfaces/README.md +21 -0
- package/registry/nocobase-route-surfaces/demo/index.tsx +266 -0
- package/registry/nocobase-route-surfaces/demo/lazy-route.tsx +11 -0
- package/registry/nocobase-route-surfaces/demo/prompt-generator.tsx +90 -0
- package/registry/nocobase-route-surfaces/demo/scenarios.ts +95 -0
- package/registry/nocobase-route-surfaces/extension.tsx +119 -0
- package/registry/nocobase-route-surfaces/index.ts +7 -0
- package/registry/nocobase-route-surfaces/route-dialog.tsx +145 -0
- package/registry/nocobase-route-surfaces/route-drawer.tsx +186 -0
- package/registry/nocobase-route-surfaces/route-page.tsx +29 -0
- package/registry/nocobase-route-surfaces/route-surface-context.ts +13 -0
- package/registry/nocobase-route-surfaces/use-refine-unsaved-changes.tsx +106 -0
- package/registry/nocobase-route-surfaces/use-route-surface-close.ts +15 -0
- package/registry/nocobase-route-surfaces/use-route-surface-state.ts +65 -0
- package/registry.config.json +30 -3
- package/src/App.tsx +65 -35
- package/src/app/extension.ts +1 -2
- package/src/app/extensions.tsx +0 -14
- package/src/components/app-shell/breadcrumb.tsx +67 -2
- package/src/components/app-shell/sidebar.tsx +2 -2
- package/src/components/resources/views/list-view.tsx +3 -2
- package/src/components/ui/sheet.tsx +22 -21
- package/src/lib/nocobase/client.ts +6 -1
- package/src/locales/en-US.ts +81 -0
- package/src/locales/zh-CN.ts +77 -0
- package/src/pages/users/create.tsx +76 -53
- package/src/pages/users/edit.tsx +87 -56
- package/src/pages/users/index.ts +3 -0
- package/src/pages/users/layout.tsx +15 -0
- package/src/pages/users/list.tsx +19 -21
- package/src/pages/users/role-badges.tsx +43 -0
- package/src/pages/users/role-detail.tsx +272 -0
- package/src/pages/users/role-utils.ts +6 -0
- package/src/pages/users/routes.ts +24 -0
- package/src/pages/users/show.tsx +195 -104
- package/src/pages/users/types.ts +13 -0
- package/src/providers/i18n/index.ts +3 -0
- package/{registry/nocobase-i18n → src/providers/i18n}/runtime.ts +63 -16
- package/src/providers/system-settings/context.ts +26 -0
- package/src/providers/system-settings/index.ts +2 -0
- package/src/providers/system-settings/provider.tsx +94 -0
- /package/{registry/nocobase-i18n → src/providers/i18n}/i18n-provider.ts +0 -0
- /package/{registry/nocobase-i18n → src/providers/i18n}/locale-store.ts +0 -0
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
import { useEffect, useState, type PropsWithChildren } from "react";
|
|
2
2
|
|
|
3
3
|
import { LoadingState } from "@/components/app-shell/loading-state";
|
|
4
|
-
import {
|
|
4
|
+
import { nocobaseClient } from "@/lib/nocobase/client";
|
|
5
|
+
import { setLocalePersistence } from "@/providers/i18n";
|
|
6
|
+
import { useSystemSettings } from "@/providers/system-settings";
|
|
5
7
|
import { loadServerLocaleResources } from "./server-resources";
|
|
6
|
-
import { translate } from "./runtime";
|
|
7
8
|
|
|
8
|
-
export function
|
|
9
|
+
export function NocoBaseI18nBootstrap({ children }: PropsWithChildren) {
|
|
10
|
+
const { settings } = useSystemSettings();
|
|
9
11
|
const [ready, setReady] = useState(false);
|
|
10
12
|
|
|
11
|
-
useEffect(
|
|
13
|
+
useEffect(
|
|
14
|
+
() =>
|
|
15
|
+
setLocalePersistence(async (locale) => {
|
|
16
|
+
if (!nocobaseClient.getToken()) return;
|
|
17
|
+
await nocobaseClient.action("users", "updateLang", {
|
|
18
|
+
method: "POST",
|
|
19
|
+
body: { appLang: locale },
|
|
20
|
+
});
|
|
21
|
+
}),
|
|
22
|
+
[]
|
|
23
|
+
);
|
|
12
24
|
|
|
13
25
|
useEffect(() => {
|
|
14
26
|
let active = true;
|
|
15
|
-
void loadServerLocaleResources()
|
|
27
|
+
void loadServerLocaleResources(settings)
|
|
16
28
|
.catch((error) => {
|
|
17
29
|
console.warn("Unable to load NocoBase locale resources", error);
|
|
18
30
|
})
|
|
@@ -22,7 +34,7 @@ export function LocalePreferenceProvider({ children }: PropsWithChildren) {
|
|
|
22
34
|
return () => {
|
|
23
35
|
active = false;
|
|
24
36
|
};
|
|
25
|
-
}, []);
|
|
37
|
+
}, [settings]);
|
|
26
38
|
|
|
27
39
|
if (!ready) {
|
|
28
40
|
return <LoadingState className="min-h-80" />;
|
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
import { nocobaseClient } from "@/lib/nocobase/client";
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
type LocaleSystemSettings,
|
|
4
|
+
applyDocumentLocale,
|
|
5
|
+
getCurrentLocale,
|
|
6
|
+
i18n,
|
|
4
7
|
registerLocale,
|
|
5
8
|
setEnabledLocales,
|
|
6
|
-
} from "
|
|
7
|
-
import { applyDocumentLocale, getCurrentLocale, i18n } from "./runtime";
|
|
9
|
+
} from "@/providers/i18n";
|
|
8
10
|
|
|
9
11
|
type ServerLanguagePayload = {
|
|
10
12
|
lang?: string;
|
|
11
13
|
resources?: Record<string, Record<string, string>>;
|
|
12
14
|
};
|
|
13
15
|
|
|
14
|
-
type SystemSettingsPayload = {
|
|
15
|
-
enabledLanguages?: string[];
|
|
16
|
-
};
|
|
17
|
-
|
|
18
16
|
const serverResourceNamespaces = new Set(["lm-collections"]);
|
|
19
17
|
const loadedServerResourceNamespaces = new Set<string>();
|
|
20
18
|
const namespaceLoadPromises = new Map<string, Promise<void>>();
|
|
@@ -119,37 +117,23 @@ export function getServerResourceNamespaces() {
|
|
|
119
117
|
return [...serverResourceNamespaces];
|
|
120
118
|
}
|
|
121
119
|
|
|
122
|
-
export function loadServerLocaleResources() {
|
|
120
|
+
export function loadServerLocaleResources(settings?: LocaleSystemSettings) {
|
|
123
121
|
if (serverResourcesPromise) return serverResourcesPromise;
|
|
124
122
|
|
|
125
123
|
bootstrapStarted = true;
|
|
126
124
|
const requestedLocale = nocobaseClient.getStoredLocale();
|
|
127
125
|
const initialNamespaces = [...serverResourceNamespaces];
|
|
128
|
-
const
|
|
129
|
-
.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
withAclMeta: false,
|
|
133
|
-
})
|
|
134
|
-
.catch((error) => {
|
|
135
|
-
console.warn("Unable to load enabled NocoBase languages", error);
|
|
136
|
-
return undefined;
|
|
137
|
-
})
|
|
138
|
-
.then((systemSettings) => {
|
|
139
|
-
const configuredLocales = Array.isArray(systemSettings?.enabledLanguages)
|
|
140
|
-
? systemSettings.enabledLanguages
|
|
141
|
-
: [];
|
|
142
|
-
if (configuredLocales.length) setEnabledLocales(configuredLocales);
|
|
143
|
-
return systemSettings;
|
|
144
|
-
});
|
|
126
|
+
const configuredLocales = Array.isArray(settings?.enabledLanguages)
|
|
127
|
+
? settings.enabledLanguages
|
|
128
|
+
: [];
|
|
129
|
+
if (configuredLocales.length) setEnabledLocales(configuredLocales);
|
|
145
130
|
const languagePromise = requestServerResources(
|
|
146
131
|
initialNamespaces,
|
|
147
132
|
requestedLocale
|
|
148
133
|
);
|
|
149
134
|
|
|
150
|
-
serverResourcesPromise =
|
|
151
|
-
.then(async (
|
|
152
|
-
const configuredLocales = systemSettings?.enabledLanguages ?? [];
|
|
135
|
+
serverResourcesPromise = languagePromise
|
|
136
|
+
.then(async (payload) => {
|
|
153
137
|
const serverLocale = payload.lang;
|
|
154
138
|
|
|
155
139
|
if (!configuredLocales.length && serverLocale) {
|
|
@@ -173,9 +157,7 @@ export function loadServerLocaleResources() {
|
|
|
173
157
|
})
|
|
174
158
|
.catch((error) => {
|
|
175
159
|
bootstrapComplete = true;
|
|
176
|
-
if (!
|
|
177
|
-
setEnabledLocales([getCurrentLocale()]);
|
|
178
|
-
}
|
|
160
|
+
if (!configuredLocales.length) setEnabledLocales([getCurrentLocale()]);
|
|
179
161
|
serverResourcesPromise = undefined;
|
|
180
162
|
throw error;
|
|
181
163
|
});
|
|
@@ -9,13 +9,16 @@ const server = await createServer({
|
|
|
9
9
|
|
|
10
10
|
try {
|
|
11
11
|
await server.ssrLoadModule("/src/locales/index.ts");
|
|
12
|
-
const {
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const {
|
|
13
|
+
applySystemLocale,
|
|
14
|
+
getCurrentLocale,
|
|
15
|
+
i18n,
|
|
16
|
+
registerLocaleResources,
|
|
17
|
+
translate,
|
|
18
|
+
} = await server.ssrLoadModule("/src/providers/i18n/runtime.ts");
|
|
19
|
+
const { resolveTranslatableText } =
|
|
15
20
|
await server.ssrLoadModule("/src/lib/i18n.ts");
|
|
16
21
|
|
|
17
|
-
setTranslationResolver(translate);
|
|
18
|
-
|
|
19
22
|
await i18n.changeLanguage("en-US");
|
|
20
23
|
assert.equal(translate("resources.users", { ns: "app" }, "Users"), "Users");
|
|
21
24
|
|
|
@@ -36,6 +39,12 @@ try {
|
|
|
36
39
|
"示例"
|
|
37
40
|
);
|
|
38
41
|
|
|
42
|
+
await applySystemLocale({
|
|
43
|
+
appLang: "en-US",
|
|
44
|
+
enabledLanguages: ["en-US", "zh-CN"],
|
|
45
|
+
});
|
|
46
|
+
assert.equal(getCurrentLocale(), "en-US");
|
|
47
|
+
|
|
39
48
|
console.log("NocoBase i18n regression tests passed");
|
|
40
49
|
} finally {
|
|
41
50
|
await server.close();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Route surfaces
|
|
2
|
+
|
|
3
|
+
Route surfaces keep URL navigation separate from visual presentation. The same
|
|
4
|
+
business content can be hosted by a routed drawer, dialog, or full page.
|
|
5
|
+
|
|
6
|
+
The Registry also installs a lazy-loaded Demo route at `/route-surfaces` with
|
|
7
|
+
live drawer, dialog, child-page, nested-drawer, and mixed page/drawer/dialog
|
|
8
|
+
scenarios plus a Prompt generator.
|
|
9
|
+
|
|
10
|
+
## Components
|
|
11
|
+
|
|
12
|
+
- `RouteDrawer` supports URL-backed nested drawers and push-style stacking.
|
|
13
|
+
- `RouteDialog` provides the same close contract for modal routes.
|
|
14
|
+
- `RoutePage` provides the close context without an overlay.
|
|
15
|
+
- `useRouteSurfaceClose` lets content request a close without knowing how it is
|
|
16
|
+
presented.
|
|
17
|
+
- `useRefineUnsavedChangesGuard` adapts Refine's unsaved-change state to the
|
|
18
|
+
route surface close lifecycle and renders a shadcn Alert Dialog confirmation.
|
|
19
|
+
|
|
20
|
+
Route definitions, ACL guards, resource data fetching, and application-specific
|
|
21
|
+
paths remain application concerns.
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { ArrowLeft, ArrowRight, Layers3 } from "lucide-react";
|
|
2
|
+
import { useOutlet, useNavigate } from "react-router";
|
|
3
|
+
|
|
4
|
+
import { Badge } from "@/components/ui/badge";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import {
|
|
8
|
+
RouteDialog,
|
|
9
|
+
RouteDrawer,
|
|
10
|
+
RoutePage,
|
|
11
|
+
useRouteSurfaceClose,
|
|
12
|
+
} from "../index";
|
|
13
|
+
import { RouteSurfacePromptGenerator } from "./prompt-generator";
|
|
14
|
+
import { routeSurfaceScenarios } from "./scenarios";
|
|
15
|
+
|
|
16
|
+
const demoBase = "/route-surfaces";
|
|
17
|
+
|
|
18
|
+
export function RouteSurfacesDemoHome() {
|
|
19
|
+
const navigate = useNavigate();
|
|
20
|
+
const overlay = useOutlet();
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<>
|
|
24
|
+
<div className="space-y-6">
|
|
25
|
+
<div className="space-y-2">
|
|
26
|
+
<Badge variant="secondary">Route surfaces</Badge>
|
|
27
|
+
<h1 className="font-heading text-3xl font-semibold tracking-tight">
|
|
28
|
+
URL-backed pages and overlays
|
|
29
|
+
</h1>
|
|
30
|
+
<p className="max-w-3xl text-muted-foreground">
|
|
31
|
+
Keep business content independent from whether it appears as a page,
|
|
32
|
+
drawer, dialog, or nested combination. Every preview below supports a
|
|
33
|
+
direct URL and browser history.
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
|
38
|
+
{routeSurfaceScenarios.map((scenario) => (
|
|
39
|
+
<Card key={scenario.id} className="flex flex-col">
|
|
40
|
+
<CardHeader>
|
|
41
|
+
<div className="flex items-center justify-between gap-3">
|
|
42
|
+
<Badge variant="outline">Scenario {scenario.number}</Badge>
|
|
43
|
+
<span className="text-xs text-muted-foreground">
|
|
44
|
+
{scenario.routeShape}
|
|
45
|
+
</span>
|
|
46
|
+
</div>
|
|
47
|
+
<CardTitle>{scenario.title}</CardTitle>
|
|
48
|
+
<CardDescription>{scenario.description}</CardDescription>
|
|
49
|
+
</CardHeader>
|
|
50
|
+
<CardContent className="mt-auto">
|
|
51
|
+
<Button
|
|
52
|
+
type="button"
|
|
53
|
+
variant="outline"
|
|
54
|
+
className="w-full"
|
|
55
|
+
onClick={() => navigate(scenario.path)}
|
|
56
|
+
>
|
|
57
|
+
Open preview
|
|
58
|
+
<ArrowRight />
|
|
59
|
+
</Button>
|
|
60
|
+
</CardContent>
|
|
61
|
+
</Card>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<RouteSurfacePromptGenerator />
|
|
66
|
+
</div>
|
|
67
|
+
{overlay}
|
|
68
|
+
</>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function DemoDrawerRoute() {
|
|
73
|
+
const navigate = useNavigate();
|
|
74
|
+
const nested = useOutlet();
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<RouteDrawer
|
|
78
|
+
title="Customer details"
|
|
79
|
+
description="A routed drawer keeps the scenario page mounted behind it."
|
|
80
|
+
closeLabel="Close"
|
|
81
|
+
closeTo={demoBase}
|
|
82
|
+
nested={nested}
|
|
83
|
+
>
|
|
84
|
+
<DemoSurfaceBody
|
|
85
|
+
label="Drawer"
|
|
86
|
+
title="Northwind renewal"
|
|
87
|
+
description="Business content does not know that it is rendered inside a drawer."
|
|
88
|
+
>
|
|
89
|
+
<Button onClick={() => navigate("second")}>
|
|
90
|
+
Open second-level drawer
|
|
91
|
+
<ArrowRight />
|
|
92
|
+
</Button>
|
|
93
|
+
</DemoSurfaceBody>
|
|
94
|
+
</RouteDrawer>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function DemoSecondDrawerRoute() {
|
|
99
|
+
return (
|
|
100
|
+
<RouteDrawer
|
|
101
|
+
title="Renewal activity"
|
|
102
|
+
description="The lower drawer is pushed outward and remains behind the layer mask."
|
|
103
|
+
closeLabel="Close"
|
|
104
|
+
closeTo={`${demoBase}/drawer`}
|
|
105
|
+
>
|
|
106
|
+
<DemoSurfaceBody
|
|
107
|
+
label="Second-level drawer"
|
|
108
|
+
title="Latest review"
|
|
109
|
+
description="Clicking this layer's backdrop closes only the top drawer."
|
|
110
|
+
/>
|
|
111
|
+
</RouteDrawer>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function DemoDialogRoute() {
|
|
116
|
+
return (
|
|
117
|
+
<RouteDialog
|
|
118
|
+
title="Approve renewal"
|
|
119
|
+
description="A modal route for a focused decision."
|
|
120
|
+
closeLabel="Close"
|
|
121
|
+
closeTo={demoBase}
|
|
122
|
+
>
|
|
123
|
+
<DemoSurfaceBody
|
|
124
|
+
label="Dialog"
|
|
125
|
+
title="Approval required"
|
|
126
|
+
description="The page stays mounted while the URL represents the active dialog."
|
|
127
|
+
/>
|
|
128
|
+
</RouteDialog>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function DemoPageRoute() {
|
|
133
|
+
const nested = useOutlet();
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<RoutePage closeTo={demoBase}>
|
|
137
|
+
<DemoPageContent nested={nested} />
|
|
138
|
+
</RoutePage>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function DemoPageContent({ nested }: { nested: React.ReactNode }) {
|
|
143
|
+
const close = useRouteSurfaceClose();
|
|
144
|
+
const navigate = useNavigate();
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<>
|
|
148
|
+
<div className="space-y-6">
|
|
149
|
+
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
150
|
+
<div className="space-y-2">
|
|
151
|
+
<Badge variant="secondary">Child page</Badge>
|
|
152
|
+
<h1 className="font-heading text-3xl font-semibold tracking-tight">
|
|
153
|
+
Customer workspace
|
|
154
|
+
</h1>
|
|
155
|
+
<p className="text-muted-foreground">
|
|
156
|
+
This route replaces the Demo home instead of rendering inside its Outlet.
|
|
157
|
+
</p>
|
|
158
|
+
</div>
|
|
159
|
+
<Button variant="outline" onClick={() => void close()}>
|
|
160
|
+
<ArrowLeft />
|
|
161
|
+
Back to scenarios
|
|
162
|
+
</Button>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<Card>
|
|
166
|
+
<CardHeader>
|
|
167
|
+
<div className="flex items-center gap-2">
|
|
168
|
+
<Layers3 className="size-5 text-primary" />
|
|
169
|
+
<CardTitle>Page-owned workflow</CardTitle>
|
|
170
|
+
</div>
|
|
171
|
+
<CardDescription>
|
|
172
|
+
A page can still host routed overlays without becoming coupled to their content.
|
|
173
|
+
</CardDescription>
|
|
174
|
+
</CardHeader>
|
|
175
|
+
<CardContent>
|
|
176
|
+
<Button onClick={() => navigate("drawer")}>Open page drawer</Button>
|
|
177
|
+
</CardContent>
|
|
178
|
+
</Card>
|
|
179
|
+
</div>
|
|
180
|
+
{nested}
|
|
181
|
+
</>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function DemoPageDrawerRoute() {
|
|
186
|
+
const navigate = useNavigate();
|
|
187
|
+
const nested = useOutlet();
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<RouteDrawer
|
|
191
|
+
title="Customer activity"
|
|
192
|
+
description="This drawer belongs to the child page route."
|
|
193
|
+
closeLabel="Close"
|
|
194
|
+
closeTo={`${demoBase}/page`}
|
|
195
|
+
nested={nested}
|
|
196
|
+
>
|
|
197
|
+
<DemoSurfaceBody
|
|
198
|
+
label="Page drawer"
|
|
199
|
+
title="Renewal conversation"
|
|
200
|
+
description="Open a dialog above this drawer to test mixed presentation types."
|
|
201
|
+
>
|
|
202
|
+
<Button onClick={() => navigate("dialog")}>
|
|
203
|
+
Open confirmation dialog
|
|
204
|
+
<ArrowRight />
|
|
205
|
+
</Button>
|
|
206
|
+
</DemoSurfaceBody>
|
|
207
|
+
</RouteDrawer>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function DemoPageDrawerDialogRoute() {
|
|
212
|
+
return (
|
|
213
|
+
<RouteDialog
|
|
214
|
+
title="Confirm follow-up"
|
|
215
|
+
description="The dialog is nested in the drawer route subtree."
|
|
216
|
+
closeLabel="Close"
|
|
217
|
+
closeTo={`${demoBase}/page/drawer`}
|
|
218
|
+
>
|
|
219
|
+
<DemoSurfaceBody
|
|
220
|
+
label="Nested dialog"
|
|
221
|
+
title="Schedule a follow-up?"
|
|
222
|
+
description="Closing returns to the drawer; closing the drawer then returns to the page."
|
|
223
|
+
/>
|
|
224
|
+
</RouteDialog>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function DemoSurfaceBody({
|
|
229
|
+
label,
|
|
230
|
+
title,
|
|
231
|
+
description,
|
|
232
|
+
children,
|
|
233
|
+
}: {
|
|
234
|
+
label: string;
|
|
235
|
+
title: string;
|
|
236
|
+
description: string;
|
|
237
|
+
children?: React.ReactNode;
|
|
238
|
+
}) {
|
|
239
|
+
return (
|
|
240
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
|
241
|
+
<div className="space-y-5">
|
|
242
|
+
<Badge variant="outline">{label}</Badge>
|
|
243
|
+
<div className="space-y-2">
|
|
244
|
+
<h2 className="text-xl font-semibold">{title}</h2>
|
|
245
|
+
<p className="text-muted-foreground">{description}</p>
|
|
246
|
+
</div>
|
|
247
|
+
<div className="grid gap-3 rounded-lg border bg-muted/30 p-4 sm:grid-cols-2">
|
|
248
|
+
<DemoField label="Owner" value="Ada Lovelace" />
|
|
249
|
+
<DemoField label="Status" value="In review" />
|
|
250
|
+
<DemoField label="Value" value="$48,000" />
|
|
251
|
+
<DemoField label="Renewal" value="September 30" />
|
|
252
|
+
</div>
|
|
253
|
+
{children ? <div>{children}</div> : null}
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function DemoField({ label, value }: { label: string; value: string }) {
|
|
260
|
+
return (
|
|
261
|
+
<div className="space-y-1">
|
|
262
|
+
<div className="text-xs text-muted-foreground">{label}</div>
|
|
263
|
+
<div className="font-medium">{value}</div>
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Suspense, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { LoadingState } from "@/components/app-shell/loading-state";
|
|
4
|
+
|
|
5
|
+
export function LazyRouteSurfaceDemo({ children }: { children: ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<Suspense fallback={<LoadingState className="min-h-80" />}>
|
|
8
|
+
{children}
|
|
9
|
+
</Suspense>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Check, Copy } from "lucide-react";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { Label } from "@/components/ui/label";
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
} from "@/components/ui/select";
|
|
15
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
16
|
+
import {
|
|
17
|
+
getRouteSurfacePrompt,
|
|
18
|
+
routeSurfaceScenarios,
|
|
19
|
+
type RouteSurfaceScenarioId,
|
|
20
|
+
} from "./scenarios";
|
|
21
|
+
|
|
22
|
+
export function RouteSurfacePromptGenerator() {
|
|
23
|
+
const [scenarioId, setScenarioId] =
|
|
24
|
+
useState<RouteSurfaceScenarioId>("drawer");
|
|
25
|
+
const [target, setTarget] = useState("a customer detail workflow");
|
|
26
|
+
const [copied, setCopied] = useState(false);
|
|
27
|
+
const scenario =
|
|
28
|
+
routeSurfaceScenarios.find((item) => item.id === scenarioId) ??
|
|
29
|
+
routeSurfaceScenarios[0];
|
|
30
|
+
const prompt = useMemo(
|
|
31
|
+
() => getRouteSurfacePrompt(scenario, target),
|
|
32
|
+
[scenario, target]
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const copyPrompt = async () => {
|
|
36
|
+
await navigator.clipboard.writeText(prompt);
|
|
37
|
+
setCopied(true);
|
|
38
|
+
window.setTimeout(() => setCopied(false), 1500);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Card>
|
|
43
|
+
<CardHeader>
|
|
44
|
+
<CardTitle>Prompt generator</CardTitle>
|
|
45
|
+
<CardDescription>
|
|
46
|
+
Generate a complete routing scenario, not just an isolated overlay component.
|
|
47
|
+
</CardDescription>
|
|
48
|
+
</CardHeader>
|
|
49
|
+
<CardContent className="space-y-4">
|
|
50
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
51
|
+
<div className="space-y-2">
|
|
52
|
+
<Label htmlFor="route-surface-scenario">Scenario</Label>
|
|
53
|
+
<Select
|
|
54
|
+
value={scenarioId}
|
|
55
|
+
onValueChange={(value) =>
|
|
56
|
+
setScenarioId(value as RouteSurfaceScenarioId)
|
|
57
|
+
}
|
|
58
|
+
>
|
|
59
|
+
<SelectTrigger id="route-surface-scenario" className="w-full">
|
|
60
|
+
<SelectValue />
|
|
61
|
+
</SelectTrigger>
|
|
62
|
+
<SelectContent>
|
|
63
|
+
{routeSurfaceScenarios.map((item) => (
|
|
64
|
+
<SelectItem key={item.id} value={item.id}>
|
|
65
|
+
{item.number}. {item.title}
|
|
66
|
+
</SelectItem>
|
|
67
|
+
))}
|
|
68
|
+
</SelectContent>
|
|
69
|
+
</Select>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="space-y-2">
|
|
72
|
+
<Label htmlFor="route-surface-target">Business target</Label>
|
|
73
|
+
<Input
|
|
74
|
+
id="route-surface-target"
|
|
75
|
+
value={target}
|
|
76
|
+
onChange={(event) => setTarget(event.target.value)}
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<Textarea value={prompt} readOnly className="min-h-80 font-mono text-xs" />
|
|
81
|
+
<div className="flex justify-end">
|
|
82
|
+
<Button type="button" onClick={() => void copyPrompt()}>
|
|
83
|
+
{copied ? <Check /> : <Copy />}
|
|
84
|
+
{copied ? "Copied" : "Copy prompt"}
|
|
85
|
+
</Button>
|
|
86
|
+
</div>
|
|
87
|
+
</CardContent>
|
|
88
|
+
</Card>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export type RouteSurfaceScenarioId =
|
|
2
|
+
| "drawer"
|
|
3
|
+
| "dialog"
|
|
4
|
+
| "page"
|
|
5
|
+
| "nested-drawers"
|
|
6
|
+
| "page-drawer-dialog";
|
|
7
|
+
|
|
8
|
+
export type RouteSurfaceScenario = {
|
|
9
|
+
id: RouteSurfaceScenarioId;
|
|
10
|
+
number: string;
|
|
11
|
+
title: string;
|
|
12
|
+
description: string;
|
|
13
|
+
path: string;
|
|
14
|
+
routeShape: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const routeSurfaceScenarios: RouteSurfaceScenario[] = [
|
|
18
|
+
{
|
|
19
|
+
id: "drawer",
|
|
20
|
+
number: "1",
|
|
21
|
+
title: "Drawer route",
|
|
22
|
+
description: "Keep the current page mounted and open one URL-backed drawer.",
|
|
23
|
+
path: "/route-surfaces/drawer",
|
|
24
|
+
routeShape: "Page → Drawer",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "dialog",
|
|
28
|
+
number: "2",
|
|
29
|
+
title: "Dialog route",
|
|
30
|
+
description: "Open focused content in a modal while preserving the page URL history.",
|
|
31
|
+
path: "/route-surfaces/dialog",
|
|
32
|
+
routeShape: "Page → Dialog",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "page",
|
|
36
|
+
number: "3",
|
|
37
|
+
title: "Child page",
|
|
38
|
+
description: "Replace the current page instead of rendering the child inside an overlay.",
|
|
39
|
+
path: "/route-surfaces/page",
|
|
40
|
+
routeShape: "Page → Child page",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "nested-drawers",
|
|
44
|
+
number: "4",
|
|
45
|
+
title: "Second-level drawer",
|
|
46
|
+
description: "Push the lower drawer outward and keep the top drawer at a stable size.",
|
|
47
|
+
path: "/route-surfaces/drawer/second",
|
|
48
|
+
routeShape: "Page → Drawer → Drawer",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "page-drawer-dialog",
|
|
52
|
+
number: "5",
|
|
53
|
+
title: "Page, drawer, and dialog",
|
|
54
|
+
description: "Compose different presentation types without coupling business content to them.",
|
|
55
|
+
path: "/route-surfaces/page/drawer/dialog",
|
|
56
|
+
routeShape: "Child page → Drawer → Dialog",
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
export function getRouteSurfacePrompt(
|
|
61
|
+
scenario: RouteSurfaceScenario,
|
|
62
|
+
target: string
|
|
63
|
+
) {
|
|
64
|
+
const presentation = {
|
|
65
|
+
drawer:
|
|
66
|
+
"Keep the current page mounted and open the target content with RouteDrawer. Closing the drawer returns to the page route.",
|
|
67
|
+
dialog:
|
|
68
|
+
"Keep the current page mounted and open the target content with RouteDialog. The dialog route must be directly addressable and dismissible through the shared close lifecycle.",
|
|
69
|
+
page:
|
|
70
|
+
"Render the target content as a real child page with RoutePage. Register it as a page route branch rather than placing it inside a drawer outlet.",
|
|
71
|
+
"nested-drawers":
|
|
72
|
+
"Create a parent RouteDrawer with an Outlet and pass the rendered child outlet through its nested prop. The second RouteDrawer must push the lower drawer outward, keep equal widths, and close back to the parent route.",
|
|
73
|
+
"page-drawer-dialog":
|
|
74
|
+
"Render a RoutePage first, place a RouteDrawer route inside that page, then render a RouteDialog as the drawer's nested route. Closing each layer must return to the immediately lower URL.",
|
|
75
|
+
}[scenario.id];
|
|
76
|
+
|
|
77
|
+
return `Build ${target || "the requested workflow"} with the installed NocoBase route surfaces.
|
|
78
|
+
|
|
79
|
+
Scenario:
|
|
80
|
+
- ${scenario.routeShape}
|
|
81
|
+
- ${presentation}
|
|
82
|
+
|
|
83
|
+
Implementation requirements:
|
|
84
|
+
- Import RouteDrawer, RouteDialog, RoutePage, and useRouteSurfaceClose from @/extensions/nocobase-route-surfaces as needed.
|
|
85
|
+
- Keep data loading, forms, ACL guards, route paths, and business copy in application-owned files.
|
|
86
|
+
- Keep business content independent from its presentation container so the same content can move between a drawer, dialog, or page.
|
|
87
|
+
- Every surface must have an explicit closeTo URL.
|
|
88
|
+
- Use nested React Router routes and Outlet only when the visual layer is genuinely nested.
|
|
89
|
+
- A full child page must be a page route branch, not an Outlet rendered inside a drawer.
|
|
90
|
+
- For editable forms, use useRefineUnsavedChangesGuard, pass its beforeClose callback to the surface, render its confirmation node, and use useRouteSurfaceClose for Cancel and successful submission.
|
|
91
|
+
- Direct URL entry, refresh, browser back/forward, Escape, the close button, and backdrop dismissal must all restore the correct layer stack.
|
|
92
|
+
- Apply ResourceAccessGuard to each business resource/action independently; do not rely on a parent list permission for unrelated child pages.
|
|
93
|
+
|
|
94
|
+
Deliver the complete route definitions and React components using the application's existing design system.`;
|
|
95
|
+
}
|