mirror of
https://gh-proxy.org/https://github.com/klinsmaya/portal-template-default.git
synced 2026-11-08 00:57:57 +08:00
feat: improve user role management and route overlays (#22)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
dist-ssr/
|
||||
logs/
|
||||
*.log
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@nocobase/portal-template-default",
|
||||
"displayName": "Default Template",
|
||||
"version": "3.0.6",
|
||||
"version": "3.1.0",
|
||||
"type": "module",
|
||||
"nocobase": {
|
||||
"defaultTemplateVersion": "3.0.6"
|
||||
"defaultTemplateVersion": "3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/react": "2.0.0",
|
||||
|
||||
+29
-2
@@ -27,7 +27,7 @@
|
||||
"title": "NocoBase Route Surfaces",
|
||||
"description": "URL-backed drawer, dialog, and page containers with nested drawer stacking and a shared close lifecycle.",
|
||||
"dependencies": [
|
||||
"@nocobase/portal-sdk@^2.0.0",
|
||||
"@nocobase/portal-sdk@^2.1.0",
|
||||
"@base-ui/react@^1.6.0",
|
||||
"@refinedev/core@^5.0.8",
|
||||
"lucide-react@^0.487.0",
|
||||
@@ -50,6 +50,32 @@
|
||||
"include": ["."]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "client",
|
||||
"type": "registry:lib",
|
||||
"title": "NocoBase Client Components",
|
||||
"description": "Small reusable Portal client components with transport-agnostic APIs and shadcn UI composition.",
|
||||
"dependencies": [
|
||||
"@nocobase/portal-sdk@^2.0.0",
|
||||
"@tanstack/react-query@^5.81.5",
|
||||
"lucide-react@^0.487.0"
|
||||
],
|
||||
"registryDependencies": ["badge", "button", "card", "command", "popover"],
|
||||
"docs": "Provides dependency-light client components without NocoBase resource or Refine coupling. RemoteSelect delegates data access to a caller-provided loadOptions function and contributes only a development showcase under /dev/client/remote-select.",
|
||||
"source": {
|
||||
"root": "registry/nocobase-client",
|
||||
"target": "src/extensions/nocobase-client",
|
||||
"include": [
|
||||
"README.md",
|
||||
"demo",
|
||||
"extension.tsx",
|
||||
"index.ts",
|
||||
"remote-select.tsx",
|
||||
"types.ts",
|
||||
"use-remote-options.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users-example",
|
||||
"type": "registry:block",
|
||||
@@ -66,6 +92,7 @@
|
||||
"react-router@^7.0.2"
|
||||
],
|
||||
"registryDependencies": [
|
||||
"@nocobase/client",
|
||||
"@nocobase/route-surfaces",
|
||||
"alert",
|
||||
"badge",
|
||||
@@ -299,7 +326,7 @@
|
||||
"title": "NocoBase AI Components and Demo",
|
||||
"description": "Complete NocoBase AI component runtime with application resources, routes, and component showcase pages.",
|
||||
"dependencies": [
|
||||
"@nocobase/portal-sdk@^2.0.0",
|
||||
"@nocobase/portal-sdk@^2.1.0",
|
||||
"@refinedev/core@^5.0.8",
|
||||
"react-router@^7.0.2"
|
||||
],
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import nocobaseAIChatIcon from "../../assets/nocobase-ai-chat.svg";
|
||||
import { assetUrl, cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -17,6 +23,48 @@ export type AIChatFloatingTriggerProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const DRAG_THRESHOLD = 4;
|
||||
const VIEWPORT_MARGIN = 12;
|
||||
|
||||
type VerticalDragSession = {
|
||||
pointerId: number;
|
||||
startClientY: number;
|
||||
startTop: number;
|
||||
minTop: number;
|
||||
maxTop: number;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
export function clampFloatingTriggerTop(
|
||||
top: number,
|
||||
minTop: number,
|
||||
maxTop: number
|
||||
) {
|
||||
return Math.min(Math.max(top, minTop), Math.max(minTop, maxTop));
|
||||
}
|
||||
|
||||
function getVerticalDragBounds(
|
||||
element: HTMLElement,
|
||||
position: "fixed" | "absolute"
|
||||
) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
if (position === "absolute" && element.offsetParent instanceof HTMLElement) {
|
||||
const parentRect = element.offsetParent.getBoundingClientRect();
|
||||
return {
|
||||
startTop: rect.top - parentRect.top,
|
||||
minTop: VIEWPORT_MARGIN,
|
||||
maxTop: parentRect.height - rect.height - VIEWPORT_MARGIN,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
startTop: rect.top,
|
||||
minTop: VIEWPORT_MARGIN,
|
||||
maxTop: window.innerHeight - rect.height - VIEWPORT_MARGIN,
|
||||
};
|
||||
}
|
||||
|
||||
export function AIChatFloatingTrigger({
|
||||
aiEmployee,
|
||||
controller: providedController,
|
||||
@@ -30,6 +78,28 @@ export function AIChatFloatingTrigger({
|
||||
const globalController = useGlobalAIChatController();
|
||||
const controller = providedController ?? globalController;
|
||||
const { open } = useAIChatControllerState(controller);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const dragSessionRef = useRef<VerticalDragSession | null>(null);
|
||||
const [top, setTop] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const keepInsideBounds = () => {
|
||||
const element = triggerRef.current;
|
||||
if (!element) return;
|
||||
setTop((currentTop) => {
|
||||
if (currentTop === null) return null;
|
||||
const bounds = getVerticalDragBounds(element, position);
|
||||
return clampFloatingTriggerTop(
|
||||
currentTop,
|
||||
bounds.minTop,
|
||||
bounds.maxTop
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("resize", keepInsideBounds);
|
||||
return () => window.removeEventListener("resize", keepInsideBounds);
|
||||
}, [position]);
|
||||
|
||||
if (hideWhenOpen && open) return null;
|
||||
|
||||
@@ -42,21 +112,80 @@ export function AIChatFloatingTrigger({
|
||||
controller.open();
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||
|
||||
const bounds = getVerticalDragBounds(event.currentTarget, position);
|
||||
dragSessionRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startClientY: event.clientY,
|
||||
startTop: bounds.startTop,
|
||||
minTop: bounds.minTop,
|
||||
maxTop: bounds.maxTop,
|
||||
moved: false,
|
||||
};
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const session = dragSessionRef.current;
|
||||
if (!session || session.pointerId !== event.pointerId) return;
|
||||
|
||||
const deltaY = event.clientY - session.startClientY;
|
||||
if (!session.moved && Math.abs(deltaY) < DRAG_THRESHOLD) return;
|
||||
|
||||
session.moved = true;
|
||||
event.preventDefault();
|
||||
setTop(
|
||||
clampFloatingTriggerTop(
|
||||
session.startTop + deltaY,
|
||||
session.minTop,
|
||||
session.maxTop
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const session = dragSessionRef.current;
|
||||
if (!session || session.pointerId !== event.pointerId) return;
|
||||
|
||||
dragSessionRef.current = null;
|
||||
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerCancel = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const session = dragSessionRef.current;
|
||||
if (!session || session.pointerId !== event.pointerId) return;
|
||||
dragSessionRef.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("surface.openChat", "Open AI chat")}
|
||||
<div
|
||||
ref={triggerRef}
|
||||
data-slot="ai-chat-drag-handle"
|
||||
className={cn(
|
||||
"group/ai-floating z-40 flex items-center rounded-l-full border bg-background py-2.5 pr-5 pl-3 shadow-lg transition-[transform,opacity,box-shadow] duration-300 hover:translate-x-0 hover:opacity-100 hover:shadow-xl focus-visible:translate-x-0 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
"group/ai-floating z-[60] flex touch-none cursor-grab items-center rounded-l-full border bg-background py-2.5 pr-5 pl-3 shadow-lg transition-[transform,opacity,box-shadow] duration-300 hover:translate-x-0 hover:opacity-100 hover:shadow-xl active:cursor-grabbing",
|
||||
position === "fixed"
|
||||
? "fixed right-0 bottom-10"
|
||||
: "absolute right-0 bottom-8",
|
||||
? cn("fixed right-0", top === null && "bottom-10")
|
||||
: cn("absolute right-0", top === null && "bottom-8"),
|
||||
"translate-x-2 opacity-80",
|
||||
className
|
||||
)}
|
||||
onClick={openChat}
|
||||
style={top === null ? undefined : { top }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
>
|
||||
<span className="relative flex size-[42px] items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("surface.openChat", "Open AI chat")}
|
||||
className="relative flex size-[42px] touch-auto cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={openChat}
|
||||
>
|
||||
<span className="flex size-full overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={assetUrl(nocobaseAIChatIcon)}
|
||||
@@ -69,7 +198,7 @@ export function AIChatFloatingTrigger({
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type PropsWithChildren,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { RouteOverlayViewportContext } from "@nocobase/portal-sdk/routing";
|
||||
import {
|
||||
AIChatFloatingTrigger,
|
||||
NocoBaseAIRootProvider,
|
||||
@@ -52,6 +53,13 @@ function NocoBaseAIGlobalEntry({ children }: PropsWithChildren) {
|
||||
const ready =
|
||||
ai.configurationStatus === "ready" &&
|
||||
ai.employees.length > 0;
|
||||
const routeOverlayViewport = useMemo(
|
||||
() =>
|
||||
ready && open && !expanded
|
||||
? { inlineEnd: DEFAULT_SIDE_PANEL_WIDTH }
|
||||
: null,
|
||||
[expanded, open, ready]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -64,7 +72,11 @@ function NocoBaseAIGlobalEntry({ children }: PropsWithChildren) {
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="min-w-0">{children}</div>
|
||||
<RouteOverlayViewportContext.Provider
|
||||
value={routeOverlayViewport}
|
||||
>
|
||||
<div className="min-w-0">{children}</div>
|
||||
</RouteOverlayViewportContext.Provider>
|
||||
{ready ? (
|
||||
<AIChatProvider
|
||||
id="starter-global-ai"
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
open: vi.fn(),
|
||||
triggerTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../providers", () => ({
|
||||
useAI: () => ({ employees: [{ username: "atlas" }] }),
|
||||
useAIChatControllerState: () => ({ open: false }),
|
||||
useGlobalAIChatController: () => ({
|
||||
open: mocks.open,
|
||||
triggerTask: mocks.triggerTask,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../locales/use-ai-translate", () => ({
|
||||
useAITranslate: () => (_key: string, fallback: string) => fallback,
|
||||
}));
|
||||
|
||||
import {
|
||||
AIChatFloatingTrigger,
|
||||
clampFloatingTriggerTop,
|
||||
} from "../components/triggers/ai-chat-floating-trigger";
|
||||
|
||||
describe("AIChatFloatingTrigger", () => {
|
||||
beforeEach(() => {
|
||||
mocks.open.mockClear();
|
||||
mocks.triggerTask.mockClear();
|
||||
});
|
||||
|
||||
it("clamps its vertical position inside the available viewport", () => {
|
||||
expect(clampFloatingTriggerTop(-20, 12, 700)).toBe(12);
|
||||
expect(clampFloatingTriggerTop(320, 12, 700)).toBe(320);
|
||||
expect(clampFloatingTriggerTop(900, 12, 700)).toBe(700);
|
||||
});
|
||||
|
||||
it("moves vertically without opening chat", () => {
|
||||
const { container } = render(<AIChatFloatingTrigger />);
|
||||
const dragHandle = container.querySelector<HTMLElement>(
|
||||
'[data-slot="ai-chat-drag-handle"]'
|
||||
);
|
||||
const trigger = screen.getByRole("button", { name: "Open AI chat" });
|
||||
|
||||
expect(dragHandle).not.toBeNull();
|
||||
if (!dragHandle) throw new Error("AI chat drag handle was not rendered");
|
||||
|
||||
Object.defineProperty(dragHandle, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
bottom: 562,
|
||||
height: 62,
|
||||
left: 0,
|
||||
right: 80,
|
||||
top: 500,
|
||||
width: 80,
|
||||
x: 0,
|
||||
y: 500,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(dragHandle, "setPointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(dragHandle, "releasePointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(dragHandle, {
|
||||
button: 0,
|
||||
clientY: 500,
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
fireEvent.pointerMove(dragHandle, {
|
||||
clientY: 300,
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
fireEvent.pointerUp(dragHandle, {
|
||||
clientY: 300,
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
fireEvent.click(dragHandle);
|
||||
|
||||
expect(dragHandle.style.top).toBe("300px");
|
||||
expect(mocks.triggerTask).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(mocks.triggerTask).toHaveBeenCalledWith({
|
||||
aiEmployee: "atlas",
|
||||
open: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
# NocoBase Client Components
|
||||
|
||||
Small, reusable Portal client components composed from the template's shadcn UI foundation.
|
||||
|
||||
## RemoteSelect
|
||||
|
||||
`RemoteSelect` provides debounced remote search, paginated loading, request cancellation, retry states, and stable selected labels. It is transport-agnostic: callers provide a `loadOptions` callback and may use the NocoBase client, another REST API, GraphQL, or any other data source.
|
||||
|
||||
The selected value contains complete option records. Convert those records into IDs or association payloads at the owning form's submission boundary.
|
||||
|
||||
Pass caller-owned API identity and filter inputs through `requestKey`. Changing
|
||||
that key invalidates the current option query; search text and pagination are
|
||||
included automatically.
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { RemoteSelect, type RemoteSelectLoadParams } from "../index";
|
||||
|
||||
type Teammate = {
|
||||
id: number;
|
||||
name: string;
|
||||
team: string;
|
||||
};
|
||||
|
||||
const TEAMMATES: Teammate[] = [
|
||||
{ id: 1, name: "Avery Chen", team: "Product" },
|
||||
{ id: 2, name: "Mina Davis", team: "Product" },
|
||||
{ id: 3, name: "Noah Kim", team: "Design" },
|
||||
{ id: 4, name: "Sofia Garcia", team: "Design" },
|
||||
{ id: 5, name: "Eli Wilson", team: "Engineering" },
|
||||
{ id: 6, name: "Zoe Martin", team: "Engineering" },
|
||||
{ id: 7, name: "Leo Wang", team: "Engineering" },
|
||||
{ id: 8, name: "Ivy Thompson", team: "Sales" },
|
||||
{ id: 9, name: "Owen Brown", team: "Sales" },
|
||||
{ id: 10, name: "Nora Patel", team: "Support" },
|
||||
{ id: 11, name: "Theo Smith", team: "Support" },
|
||||
{ id: 12, name: "Maya Johnson", team: "Operations" },
|
||||
];
|
||||
|
||||
function waitForNetwork(signal: AbortSignal) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const handleAbort = () => {
|
||||
window.clearTimeout(timeout);
|
||||
reject(new DOMException("The request was aborted", "AbortError"));
|
||||
};
|
||||
const timeout = window.setTimeout(() => {
|
||||
signal.removeEventListener("abort", handleAbort);
|
||||
resolve();
|
||||
}, 350);
|
||||
|
||||
if (signal.aborted) {
|
||||
handleAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", handleAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTeammates({
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
signal,
|
||||
}: RemoteSelectLoadParams) {
|
||||
await waitForNetwork(signal);
|
||||
const normalizedSearch = search.toLowerCase();
|
||||
if (normalizedSearch === "error") {
|
||||
throw new Error("Demo request failed");
|
||||
}
|
||||
|
||||
const matches = TEAMMATES.filter((teammate) =>
|
||||
`${teammate.name} ${teammate.team}`.toLowerCase().includes(normalizedSearch)
|
||||
);
|
||||
const start = (page - 1) * pageSize;
|
||||
|
||||
return {
|
||||
items: matches.slice(start, start + pageSize),
|
||||
hasMore: start + pageSize < matches.length,
|
||||
};
|
||||
}
|
||||
|
||||
const getTeammateKey = (teammate: Teammate) => teammate.id;
|
||||
const getTeammateLabel = (teammate: Teammate) => teammate.name;
|
||||
const renderTeammate = (teammate: Teammate) => (
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<span className="truncate">{teammate.name}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{teammate.team}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function RemoteSelectDemo() {
|
||||
const [owner, setOwner] = useState<Teammate | null>(null);
|
||||
const [reviewers, setReviewers] = useState<Teammate[]>([]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Badge variant="secondary">Client component</Badge>
|
||||
<h1 className="font-heading text-3xl font-semibold tracking-tight">
|
||||
Remote select
|
||||
</h1>
|
||||
<p className="max-w-3xl text-muted-foreground">
|
||||
Supply an asynchronous loader to search and page any API. The
|
||||
component keeps complete selected records, while transport and query
|
||||
filters remain application-owned.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Single selection</CardTitle>
|
||||
<CardDescription>
|
||||
Options are loaded only when the control opens. Search by name or
|
||||
team; enter “error” to preview retry handling.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<RemoteSelect<Teammate>
|
||||
value={owner}
|
||||
onValueChange={setOwner}
|
||||
requestKey={["demo-teammates"]}
|
||||
loadOptions={loadTeammates}
|
||||
getOptionKey={getTeammateKey}
|
||||
getOptionLabel={getTeammateLabel}
|
||||
renderOption={renderTeammate}
|
||||
pageSize={4}
|
||||
placeholder="Select an owner..."
|
||||
messages={{ searchPlaceholder: "Search teammates..." }}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selected: {owner ? `${owner.name} · ${owner.team}` : "None"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Multiple selection</CardTitle>
|
||||
<CardDescription>
|
||||
Select a result, then search again. Previously selected labels
|
||||
remain available even when they are not in the current page.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<RemoteSelect<Teammate>
|
||||
multiple
|
||||
value={reviewers}
|
||||
onValueChange={setReviewers}
|
||||
requestKey={["demo-teammates"]}
|
||||
loadOptions={loadTeammates}
|
||||
getOptionKey={getTeammateKey}
|
||||
getOptionLabel={getTeammateLabel}
|
||||
renderOption={renderTeammate}
|
||||
pageSize={4}
|
||||
placeholder="Select reviewers..."
|
||||
messages={{ searchPlaceholder: "Search teammates..." }}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selected: {reviewers.length > 0
|
||||
? reviewers.map((reviewer) => reviewer.name).join(", ")
|
||||
: "None"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { AppExtension } from "@nocobase/portal-sdk/extensions";
|
||||
import { defineAppRoutes } from "@nocobase/portal-sdk/routing";
|
||||
import { Blocks, ListFilter } from "lucide-react";
|
||||
|
||||
const nocobaseClientExtension: AppExtension = {
|
||||
id: "nocobase-client",
|
||||
dev: {
|
||||
resources: [
|
||||
{
|
||||
name: "client-components",
|
||||
meta: {
|
||||
label: "Client components",
|
||||
icon: <Blocks />,
|
||||
description: "Reusable Portal client-side interaction patterns.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "client-remote-select",
|
||||
list: "client/remote-select",
|
||||
meta: {
|
||||
parent: "client-components",
|
||||
label: "Remote select",
|
||||
icon: <ListFilter />,
|
||||
description: "Searchable, paginated selection from a remote loader.",
|
||||
acl: { type: "authenticated" },
|
||||
},
|
||||
},
|
||||
],
|
||||
routes: defineAppRoutes([
|
||||
{
|
||||
name: "development.client.remote-select",
|
||||
path: "client/remote-select",
|
||||
lazy: () => import("./demo"),
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
export default nocobaseClientExtension;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./remote-select";
|
||||
export type * from "./types";
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
type ForwardedRef,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { ChevronsUpDown, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RemoteSelectMessages, RemoteSelectProps } from "./types";
|
||||
import { useRemoteOptions } from "./use-remote-options";
|
||||
|
||||
const DEFAULT_MESSAGES: RemoteSelectMessages = {
|
||||
searchPlaceholder: "Search...",
|
||||
empty: "No results found.",
|
||||
loading: "Loading...",
|
||||
loadMore: "Load more",
|
||||
loadingMore: "Loading more...",
|
||||
error: "Options could not be loaded.",
|
||||
retry: "Retry",
|
||||
};
|
||||
|
||||
function RemoteSelectInner<TOption>(
|
||||
props: RemoteSelectProps<TOption>,
|
||||
forwardedRef: ForwardedRef<HTMLButtonElement>
|
||||
) {
|
||||
const {
|
||||
className,
|
||||
containerClassName,
|
||||
debounceMs = 250,
|
||||
disabled,
|
||||
getOptionKey,
|
||||
getOptionLabel,
|
||||
loadOptions,
|
||||
messages: messageOverrides,
|
||||
onClick,
|
||||
onOpenChange,
|
||||
pageSize = 20,
|
||||
placeholder = "Select...",
|
||||
popupSide = "bottom",
|
||||
requestKey = [],
|
||||
renderOption,
|
||||
renderValue,
|
||||
multiple,
|
||||
value,
|
||||
onValueChange,
|
||||
...triggerProps
|
||||
} = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const listboxId = useId();
|
||||
const messages = useMemo(
|
||||
() => ({ ...DEFAULT_MESSAGES, ...messageOverrides }),
|
||||
[messageOverrides]
|
||||
);
|
||||
const selectedOptions = multiple
|
||||
? value
|
||||
: value
|
||||
? [value]
|
||||
: [];
|
||||
const selectedKeys = new Set(
|
||||
selectedOptions.map((option) => getOptionKey(option))
|
||||
);
|
||||
const remote = useRemoteOptions({
|
||||
debounceMs,
|
||||
getOptionKey,
|
||||
loadOptions,
|
||||
open,
|
||||
pageSize,
|
||||
requestKey,
|
||||
search,
|
||||
selectId: listboxId,
|
||||
});
|
||||
|
||||
const setTriggerRef = useCallback(
|
||||
(node: HTMLButtonElement | null) => {
|
||||
if (typeof forwardedRef === "function") forwardedRef(node);
|
||||
else if (forwardedRef) forwardedRef.current = node;
|
||||
},
|
||||
[forwardedRef]
|
||||
);
|
||||
const changeOpen = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setSearch("");
|
||||
remote.resetSearch();
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
[onOpenChange, remote.resetSearch]
|
||||
);
|
||||
|
||||
const selectOption = (option: TOption) => {
|
||||
const key = getOptionKey(option);
|
||||
if (multiple) {
|
||||
onValueChange(
|
||||
selectedKeys.has(key)
|
||||
? value.filter((item) => getOptionKey(item) !== key)
|
||||
: [...value, option]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
onValueChange(option);
|
||||
changeOpen(false);
|
||||
};
|
||||
|
||||
const selectedContent = renderValue?.(selectedOptions) ??
|
||||
selectedOptions.map(getOptionLabel).join(", ");
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={changeOpen}>
|
||||
<div className={containerClassName}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
{...triggerProps}
|
||||
ref={setTriggerRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-controls={listboxId}
|
||||
aria-expanded={open}
|
||||
aria-busy={remote.loading || remote.loadingMore}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-10 w-full min-w-0 justify-between font-normal",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-left",
|
||||
!selectedOptions.length && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{selectedOptions.length ? selectedContent : placeholder}
|
||||
</span>
|
||||
<ChevronsUpDown className="size-4 shrink-0 opacity-50" />
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side={popupSide}
|
||||
sideOffset={8}
|
||||
positionerClassName="z-[70]"
|
||||
className="w-(--anchor-width) gap-0 overflow-hidden p-0"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={messages.searchPlaceholder}
|
||||
/>
|
||||
{remote.loading ? (
|
||||
<div className="flex min-h-24 items-center justify-center gap-2 px-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{messages.loading}
|
||||
</div>
|
||||
) : remote.error && remote.options.length === 0 ? (
|
||||
<div className="flex min-h-24 flex-col items-center justify-center gap-2 px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
<span>{messages.error}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={remote.retry}
|
||||
>
|
||||
{messages.retry}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty>{messages.empty}</CommandEmpty>
|
||||
<CommandList id={listboxId}>
|
||||
<CommandGroup>
|
||||
{remote.options.map((option) => {
|
||||
const key = getOptionKey(option);
|
||||
return (
|
||||
<CommandItem
|
||||
key={key}
|
||||
value={`${getOptionLabel(option)} ${key}`}
|
||||
data-checked={selectedKeys.has(key)}
|
||||
onSelect={() => selectOption(option)}
|
||||
>
|
||||
{renderOption?.(option) ?? getOptionLabel(option)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
{remote.hasMore || remote.error ? (
|
||||
<div className="border-t p-1">
|
||||
{remote.error ? (
|
||||
<p className="px-2 py-1 text-xs text-destructive">
|
||||
{messages.error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={remote.loadingMore}
|
||||
onClick={remote.error ? remote.retry : remote.loadMore}
|
||||
>
|
||||
{remote.loadingMore ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : null}
|
||||
{remote.error
|
||||
? messages.retry
|
||||
: remote.loadingMore
|
||||
? messages.loadingMore
|
||||
: messages.loadMore}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export const RemoteSelect = forwardRef(RemoteSelectInner) as <TOption>(
|
||||
props: RemoteSelectProps<TOption> & {
|
||||
ref?: ForwardedRef<HTMLButtonElement>;
|
||||
}
|
||||
) => ReactElement;
|
||||
@@ -0,0 +1,269 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState, type ReactElement } from "react";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { RemoteSelect } from "../remote-select";
|
||||
|
||||
type Option = { id: number; label: string };
|
||||
|
||||
function RemoteMultiSelect({
|
||||
debounceMs = 0,
|
||||
loadOptions,
|
||||
}: {
|
||||
debounceMs?: number;
|
||||
loadOptions: (params: {
|
||||
search: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
signal: AbortSignal;
|
||||
}) => Promise<{ items: Option[]; hasMore: boolean }>;
|
||||
}) {
|
||||
const [value, setValue] = useState<Option[]>([]);
|
||||
return (
|
||||
<RemoteSelect<Option>
|
||||
multiple
|
||||
value={value}
|
||||
onValueChange={(nextValue) => setValue(nextValue)}
|
||||
loadOptions={loadOptions}
|
||||
getOptionKey={(option) => option.id}
|
||||
getOptionLabel={(option) => option.label}
|
||||
debounceMs={debounceMs}
|
||||
messages={{ searchPlaceholder: "Search options..." }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRemoteSelect(element: ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>{element}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("RemoteSelect", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
);
|
||||
Object.defineProperty(Element.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("loads options on demand and appends the next page", async () => {
|
||||
const loadOptions = vi.fn(async ({ page }: { page: number }) => ({
|
||||
items:
|
||||
page === 1
|
||||
? [{ id: 1, label: "One" }]
|
||||
: [{ id: 2, label: "Two" }],
|
||||
hasMore: page === 1,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderRemoteSelect(<RemoteMultiSelect loadOptions={loadOptions} />);
|
||||
|
||||
expect(loadOptions).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
expect(await screen.findByRole("option", { name: "One" })).toBeTruthy();
|
||||
await user.click(screen.getByRole("button", { name: "Load more" }));
|
||||
|
||||
expect(await screen.findByRole("option", { name: "Two" })).toBeTruthy();
|
||||
expect(loadOptions).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ page: 2, search: "" })
|
||||
);
|
||||
});
|
||||
|
||||
it("portals the option menu outside clipping containers", async () => {
|
||||
const loadOptions = vi.fn(async () => ({
|
||||
items: [{ id: 1, label: "Visible option" }],
|
||||
hasMore: false,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderRemoteSelect(
|
||||
<div data-testid="clipping-container" style={{ overflow: "hidden" }}>
|
||||
<RemoteMultiSelect loadOptions={loadOptions} />
|
||||
</div>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
const option = await screen.findByRole("option", { name: "Visible option" });
|
||||
|
||||
expect(screen.getByTestId("clipping-container").contains(option)).toBe(false);
|
||||
expect(
|
||||
option.closest("[data-side]")?.parentElement?.classList.contains("z-[70]")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("delegates debounced searching to the loader", async () => {
|
||||
const loadOptions = vi.fn(async () => ({ items: [], hasMore: false }));
|
||||
const user = userEvent.setup();
|
||||
renderRemoteSelect(<RemoteMultiSelect loadOptions={loadOptions} />);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await screen.findByText("No results found.");
|
||||
await user.type(screen.getByPlaceholderText("Search options..."), "sales");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(loadOptions).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ page: 1, search: "sales" })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the selected record label after a different search is loaded", async () => {
|
||||
const loadOptions = vi.fn(async ({ search }: { search: string }) => ({
|
||||
items: search
|
||||
? [{ id: 2, label: "Second result" }]
|
||||
: [{ id: 1, label: "First result" }],
|
||||
hasMore: false,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderRemoteSelect(<RemoteMultiSelect loadOptions={loadOptions} />);
|
||||
|
||||
const trigger = screen.getByRole("combobox");
|
||||
await user.click(trigger);
|
||||
await user.click(
|
||||
await screen.findByRole("option", { name: "First result" })
|
||||
);
|
||||
await user.type(screen.getByPlaceholderText("Search options..."), "next");
|
||||
await screen.findByRole("option", { name: "Second result" });
|
||||
|
||||
expect(trigger.textContent).toContain("First result");
|
||||
});
|
||||
|
||||
it("retries the failed operation instead of inferring it from cached pages", async () => {
|
||||
let firstPageRequests = 0;
|
||||
const loadOptions = vi.fn(({ page }: { page: number }) => {
|
||||
if (page !== 1) {
|
||||
return Promise.resolve({
|
||||
items: [{ id: 2, label: "Second page" }],
|
||||
hasMore: false,
|
||||
});
|
||||
}
|
||||
|
||||
firstPageRequests += 1;
|
||||
if (firstPageRequests === 2) {
|
||||
return Promise.reject(new Error("Unable to refresh"));
|
||||
}
|
||||
return Promise.resolve({
|
||||
items: [{ id: 1, label: "First page" }],
|
||||
hasMore: true,
|
||||
});
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderRemoteSelect(<RemoteMultiSelect loadOptions={loadOptions} />);
|
||||
|
||||
const trigger = screen.getByRole("combobox");
|
||||
await user.click(trigger);
|
||||
expect(
|
||||
await screen.findByRole("option", { name: "First page" })
|
||||
).toBeTruthy();
|
||||
await user.click(trigger);
|
||||
await user.click(trigger);
|
||||
expect(await screen.findByText("Options could not be loaded.")).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
await waitFor(() => expect(firstPageRequests).toBe(3));
|
||||
expect(loadOptions).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: 2 })
|
||||
);
|
||||
});
|
||||
|
||||
it("clears a pending debounced search before the menu is reopened", async () => {
|
||||
const loadOptions = vi.fn(async ({ search }: { search: string }) => ({
|
||||
items: search ? [{ id: 1, label: `Result for ${search}` }] : [],
|
||||
hasMore: false,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderRemoteSelect(
|
||||
<RemoteMultiSelect debounceMs={50} loadOptions={loadOptions} />
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("combobox");
|
||||
await user.click(trigger);
|
||||
const input = screen.getByPlaceholderText("Search options...");
|
||||
await user.type(input, "legacy");
|
||||
expect(
|
||||
await screen.findByRole("option", { name: "Result for legacy" })
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(trigger);
|
||||
await user.click(trigger);
|
||||
|
||||
expect(
|
||||
(screen.getByPlaceholderText("Search options...") as HTMLInputElement)
|
||||
.value
|
||||
).toBe("");
|
||||
expect(
|
||||
screen.queryByRole("option", { name: "Result for legacy" })
|
||||
).toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(loadOptions).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ page: 1, search: "" })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let a stale search response replace newer results", async () => {
|
||||
let resolveSlow:
|
||||
| ((result: { items: Option[]; hasMore: boolean }) => void)
|
||||
| undefined;
|
||||
const slowResult = new Promise<{ items: Option[]; hasMore: boolean }>(
|
||||
(resolve) => {
|
||||
resolveSlow = resolve;
|
||||
}
|
||||
);
|
||||
const loadOptions = vi.fn(({ search }: { search: string }) => {
|
||||
if (search === "slow") return slowResult;
|
||||
return Promise.resolve({
|
||||
items:
|
||||
search === "fast" ? [{ id: 2, label: "Fast result" }] : [],
|
||||
hasMore: false,
|
||||
});
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderRemoteSelect(<RemoteMultiSelect loadOptions={loadOptions} />);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
const input = screen.getByPlaceholderText("Search options...");
|
||||
await user.type(input, "slow");
|
||||
await waitFor(() =>
|
||||
expect(loadOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ search: "slow" })
|
||||
)
|
||||
);
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, "fast");
|
||||
expect(
|
||||
await screen.findByRole("option", { name: "Fast result" })
|
||||
).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveSlow?.({
|
||||
items: [{ id: 1, label: "Stale result" }],
|
||||
hasMore: false,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("option", { name: "Stale result" })).toBeNull();
|
||||
expect(screen.getByRole("option", { name: "Fast result" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type RemoteSelectLoadParams = {
|
||||
search: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
export type RemoteSelectLoadResult<TOption> = {
|
||||
items: TOption[];
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
export type RemoteSelectMessages = {
|
||||
searchPlaceholder: string;
|
||||
empty: string;
|
||||
loading: string;
|
||||
loadMore: string;
|
||||
loadingMore: string;
|
||||
error: string;
|
||||
retry: string;
|
||||
};
|
||||
|
||||
export type RemoteSelectCommonProps<TOption> = Omit<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"children" | "onChange" | "value"
|
||||
> & {
|
||||
/**
|
||||
* Values that identify the remote data source and caller-owned filters.
|
||||
* Change this key whenever those inputs change so the option query reloads.
|
||||
*/
|
||||
requestKey?: readonly unknown[];
|
||||
loadOptions: (
|
||||
params: RemoteSelectLoadParams
|
||||
) => Promise<RemoteSelectLoadResult<TOption>>;
|
||||
getOptionKey: (option: TOption) => string | number;
|
||||
getOptionLabel: (option: TOption) => string;
|
||||
renderOption?: (option: TOption) => ReactNode;
|
||||
renderValue?: (selected: TOption[]) => ReactNode;
|
||||
pageSize?: number;
|
||||
debounceMs?: number;
|
||||
placeholder?: string;
|
||||
messages?: Partial<RemoteSelectMessages>;
|
||||
popupSide?: "top" | "bottom";
|
||||
containerClassName?: string;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export type RemoteSelectSingleProps<TOption> =
|
||||
RemoteSelectCommonProps<TOption> & {
|
||||
multiple?: false;
|
||||
value: TOption | null;
|
||||
onValueChange: (value: TOption | null) => void;
|
||||
};
|
||||
|
||||
export type RemoteSelectMultipleProps<TOption> =
|
||||
RemoteSelectCommonProps<TOption> & {
|
||||
multiple: true;
|
||||
value: TOption[];
|
||||
onValueChange: (value: TOption[]) => void;
|
||||
};
|
||||
|
||||
export type RemoteSelectProps<TOption> =
|
||||
| RemoteSelectSingleProps<TOption>
|
||||
| RemoteSelectMultipleProps<TOption>;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
RemoteSelectLoadParams,
|
||||
RemoteSelectLoadResult,
|
||||
} from "./types";
|
||||
|
||||
function useDebouncedValue<T>(value: T, delay: number) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [delay, value]);
|
||||
|
||||
return [debouncedValue, setDebouncedValue] as const;
|
||||
}
|
||||
|
||||
function mergeOptionPages<TOption>(
|
||||
pages: RemoteSelectLoadResult<TOption>[],
|
||||
getOptionKey: (option: TOption) => string | number
|
||||
) {
|
||||
const options = new Map<string | number, TOption>();
|
||||
for (const page of pages) {
|
||||
for (const option of page.items) {
|
||||
options.set(getOptionKey(option), option);
|
||||
}
|
||||
}
|
||||
return [...options.values()];
|
||||
}
|
||||
|
||||
export function useRemoteOptions<TOption>({
|
||||
debounceMs,
|
||||
getOptionKey,
|
||||
loadOptions,
|
||||
open,
|
||||
pageSize,
|
||||
requestKey,
|
||||
search,
|
||||
selectId,
|
||||
}: {
|
||||
debounceMs: number;
|
||||
getOptionKey: (option: TOption) => string | number;
|
||||
loadOptions: (
|
||||
params: RemoteSelectLoadParams
|
||||
) => Promise<RemoteSelectLoadResult<TOption>>;
|
||||
open: boolean;
|
||||
pageSize: number;
|
||||
requestKey: readonly unknown[];
|
||||
search: string;
|
||||
selectId: string;
|
||||
}) {
|
||||
const [debouncedSearch, setDebouncedSearch] = useDebouncedValue(
|
||||
search.trim(),
|
||||
debounceMs
|
||||
);
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"nocobase-remote-select",
|
||||
selectId,
|
||||
...requestKey,
|
||||
pageSize,
|
||||
debouncedSearch,
|
||||
],
|
||||
enabled: open,
|
||||
initialPageParam: 1,
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
loadOptions({
|
||||
page: pageParam,
|
||||
pageSize,
|
||||
search: debouncedSearch,
|
||||
signal,
|
||||
}),
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
lastPage.hasMore ? pages.length + 1 : undefined,
|
||||
retry: false,
|
||||
});
|
||||
const options = useMemo(
|
||||
() => mergeOptionPages(query.data?.pages ?? [], getOptionKey),
|
||||
[getOptionKey, query.data?.pages]
|
||||
);
|
||||
const loading = query.isPending && query.isFetching;
|
||||
const loadingMore = query.isFetchingNextPage;
|
||||
|
||||
const retry = () => {
|
||||
if (query.isFetchNextPageError) {
|
||||
void query.fetchNextPage();
|
||||
return;
|
||||
}
|
||||
void query.refetch();
|
||||
};
|
||||
const resetSearch = useCallback(
|
||||
() => setDebouncedSearch(""),
|
||||
[setDebouncedSearch]
|
||||
);
|
||||
|
||||
return {
|
||||
error: query.error,
|
||||
hasMore: query.hasNextPage,
|
||||
loading,
|
||||
loadingMore,
|
||||
loadMore: () => void query.fetchNextPage(),
|
||||
options,
|
||||
resetSearch,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouteOverlayViewportStyle } from "./route-overlay-viewport";
|
||||
|
||||
export function RouteDialog({
|
||||
title,
|
||||
@@ -33,6 +34,7 @@ export function RouteDialog({
|
||||
nested?: ReactNode;
|
||||
}) {
|
||||
const parentOverlayDepth = useContext(RouteOverlayDepthContext);
|
||||
const overlayViewportStyle = useRouteOverlayViewportStyle();
|
||||
const isNestedOverlay = parentOverlayDepth > 0;
|
||||
const { open, setOpen, close, navigateAfterClose } = useRouteSurfaceState({
|
||||
closeTo,
|
||||
@@ -62,12 +64,15 @@ export function RouteDialog({
|
||||
if (!nextOpen) navigateAfterClose();
|
||||
}}
|
||||
>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Portal
|
||||
className="pointer-events-none fixed inset-y-0 right-0 left-0 z-50 transition-[right] duration-200 ease-in-out md:right-(--route-overlay-inline-end)"
|
||||
style={overlayViewportStyle}
|
||||
>
|
||||
{isNestedOverlay ? (
|
||||
<RouteDialogBackdrop open={open} onClose={close} />
|
||||
) : (
|
||||
<DialogPrimitive.Backdrop
|
||||
className="fixed inset-0 isolate z-50 bg-black/10 duration-150 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0"
|
||||
className="pointer-events-auto absolute inset-0 bg-black/10 duration-150 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void close();
|
||||
@@ -75,41 +80,41 @@ export function RouteDialog({
|
||||
/>
|
||||
)}
|
||||
<DialogPrimitive.Popup
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-popover text-sm text-popover-foreground ring-1 ring-foreground/10 duration-150 outline-none sm:max-w-2xl data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<header className="relative shrink-0 border-b px-5 py-4 pr-14 text-left">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<DialogPrimitive.Title className="font-heading truncate text-lg font-medium">
|
||||
{title}
|
||||
</DialogPrimitive.Title>
|
||||
{description ? (
|
||||
<DialogPrimitive.Description className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</DialogPrimitive.Description>
|
||||
className={cn(
|
||||
"pointer-events-auto absolute top-1/2 left-1/2 flex max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-popover text-sm text-popover-foreground ring-1 ring-foreground/10 duration-150 outline-none sm:max-w-2xl data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<header className="relative shrink-0 border-b px-5 py-4 pr-14 text-left">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<DialogPrimitive.Title className="font-heading truncate text-lg font-medium">
|
||||
{title}
|
||||
</DialogPrimitive.Title>
|
||||
{description ? (
|
||||
<DialogPrimitive.Description className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</DialogPrimitive.Description>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="absolute top-3 right-3"
|
||||
onClick={() => void close()}
|
||||
>
|
||||
<X />
|
||||
<span className="sr-only">{closeLabel}</span>
|
||||
</Button>
|
||||
</header>
|
||||
{children}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="absolute top-3 right-3"
|
||||
onClick={() => void close()}
|
||||
>
|
||||
<X />
|
||||
<span className="sr-only">{closeLabel}</span>
|
||||
</Button>
|
||||
</header>
|
||||
{children}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPrimitive.Portal>
|
||||
{nested}
|
||||
@@ -131,7 +136,7 @@ function RouteDialogBackdrop({
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 transition-opacity duration-150 supports-backdrop-filter:backdrop-blur-xs",
|
||||
"pointer-events-auto absolute inset-0 bg-black/10 transition-opacity duration-150 supports-backdrop-filter:backdrop-blur-xs",
|
||||
open ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
)}
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouteOverlayViewportStyle } from "./route-overlay-viewport";
|
||||
|
||||
export function RouteDrawer({
|
||||
title,
|
||||
@@ -40,6 +41,7 @@ export function RouteDrawer({
|
||||
stackOffset?: string;
|
||||
}) {
|
||||
const parentOverlayDepth = useContext(RouteOverlayDepthContext);
|
||||
const overlayViewportStyle = useRouteOverlayViewportStyle();
|
||||
const isNestedOverlay = parentOverlayDepth > 0;
|
||||
const { open, setOpen, close, navigateAfterClose } = useRouteSurfaceState({
|
||||
closeTo,
|
||||
@@ -75,67 +77,70 @@ export function RouteDrawer({
|
||||
if (!nextOpen) navigateAfterClose();
|
||||
}}
|
||||
>
|
||||
<DrawerPrimitive.Portal>
|
||||
<DrawerPrimitive.Portal
|
||||
className="pointer-events-none fixed inset-y-0 right-0 left-0 z-50 overflow-hidden transition-[right] duration-200 ease-in-out md:right-(--route-overlay-inline-end)"
|
||||
style={overlayViewportStyle}
|
||||
>
|
||||
{isNestedOverlay ? (
|
||||
<RouteDrawerBackdrop open={open} onClose={close} />
|
||||
) : (
|
||||
<DrawerPrimitive.Backdrop
|
||||
className="fixed inset-0 z-50 min-h-dvh bg-black/10 opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-starting-style:opacity-0 data-swiping:duration-0 supports-backdrop-filter:backdrop-blur-xs"
|
||||
className="pointer-events-auto absolute inset-0 bg-black/10 opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-starting-style:opacity-0 data-swiping:duration-0 supports-backdrop-filter:backdrop-blur-xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DrawerPrimitive.Viewport className="pointer-events-none fixed inset-0 z-50 select-none">
|
||||
<DrawerPrimitive.Popup
|
||||
style={stackStyle}
|
||||
className={cn(
|
||||
"group/route-drawer pointer-events-auto fixed inset-y-0 right-0 z-50 flex w-full transform-[translate3d(var(--translate-x,0px),0,0)] flex-row bg-popover text-sm text-popover-foreground shadow-xl transition-transform duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none sm:w-full lg:w-[42vw] lg:min-w-[40rem]",
|
||||
"[--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)]",
|
||||
"[--closed-transform:translate3d(calc(100%+2px),0,0)] [--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset))]",
|
||||
"data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-nested-drawer-swiping:duration-0",
|
||||
"data-nested-drawer-open:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-20 bg-black/10 opacity-0 transition-opacity duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-nested-drawer-open/route-drawer:opacity-100 supports-backdrop-filter:backdrop-blur-xs"
|
||||
/>
|
||||
<DrawerPrimitive.Content className="flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain select-text group-data-swiping/route-drawer:select-none">
|
||||
<header className="relative shrink-0 border-b px-5 py-4 pr-14 text-left">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<DrawerPrimitive.Title className="font-heading truncate text-lg font-medium text-foreground">
|
||||
{title}
|
||||
</DrawerPrimitive.Title>
|
||||
{description ? (
|
||||
<DrawerPrimitive.Description className="text-sm text-balance text-muted-foreground">
|
||||
{description}
|
||||
</DrawerPrimitive.Description>
|
||||
<DrawerPrimitive.Viewport className="pointer-events-none absolute inset-0 select-none">
|
||||
<DrawerPrimitive.Popup
|
||||
style={stackStyle}
|
||||
className={cn(
|
||||
"group/route-drawer pointer-events-auto absolute inset-y-0 right-0 flex w-full transform-[translate3d(var(--translate-x,0px),0,0)] flex-row bg-popover text-sm text-popover-foreground shadow-xl transition-transform duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none sm:w-full lg:w-[min(42vw,100%)] lg:min-w-[min(40rem,100%)]",
|
||||
"[--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)]",
|
||||
"[--closed-transform:translate3d(calc(100%+2px),0,0)] [--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset))]",
|
||||
"data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-nested-drawer-swiping:duration-0",
|
||||
"data-nested-drawer-open:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-20 bg-black/10 opacity-0 transition-opacity duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-nested-drawer-open/route-drawer:opacity-100 supports-backdrop-filter:backdrop-blur-xs"
|
||||
/>
|
||||
<DrawerPrimitive.Content className="flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain select-text group-data-swiping/route-drawer:select-none">
|
||||
<header className="relative shrink-0 border-b px-5 py-4 pr-14 text-left">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<DrawerPrimitive.Title className="font-heading truncate text-lg font-medium text-foreground">
|
||||
{title}
|
||||
</DrawerPrimitive.Title>
|
||||
{description ? (
|
||||
<DrawerPrimitive.Description className="text-sm text-balance text-muted-foreground">
|
||||
{description}
|
||||
</DrawerPrimitive.Description>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="absolute top-3 right-3"
|
||||
onClick={() => void close()}
|
||||
>
|
||||
<X />
|
||||
<span className="sr-only">{closeLabel}</span>
|
||||
</Button>
|
||||
</header>
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPrimitive.Popup>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="absolute top-3 right-3"
|
||||
onClick={() => void close()}
|
||||
>
|
||||
<X />
|
||||
<span className="sr-only">{closeLabel}</span>
|
||||
</Button>
|
||||
</header>
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPrimitive.Popup>
|
||||
</DrawerPrimitive.Viewport>
|
||||
</DrawerPrimitive.Portal>
|
||||
{nested}
|
||||
@@ -157,7 +162,7 @@ function RouteDrawerBackdrop({
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 min-h-dvh bg-black/10 transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none supports-backdrop-filter:backdrop-blur-xs",
|
||||
"pointer-events-auto absolute inset-0 bg-black/10 transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none supports-backdrop-filter:backdrop-blur-xs",
|
||||
open ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
)}
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useContext, type CSSProperties } from "react";
|
||||
import { RouteOverlayViewportContext } from "@nocobase/portal-sdk/routing";
|
||||
|
||||
export function useRouteOverlayViewportStyle() {
|
||||
const viewport = useContext(RouteOverlayViewportContext);
|
||||
const inlineEnd = viewport?.inlineEnd ?? 0;
|
||||
|
||||
return {
|
||||
"--route-overlay-inline-end":
|
||||
typeof inlineEnd === "number" ? `${inlineEnd}px` : inlineEnd,
|
||||
} as CSSProperties;
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
applyAIUserFormValues,
|
||||
getAIUserFormFields,
|
||||
getAIUserFormValues,
|
||||
getUserFormValues,
|
||||
normalizeUserFormValues,
|
||||
} from "./form-context";
|
||||
import { UserFormFields } from "./form-fields";
|
||||
import { userRoutes } from "./routes";
|
||||
@@ -66,10 +68,7 @@ function UserCreateForm() {
|
||||
},
|
||||
},
|
||||
defaultValues: {
|
||||
nickname: "",
|
||||
username: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
...getUserFormValues(),
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
@@ -89,11 +88,18 @@ function UserCreateForm() {
|
||||
<Form {...form}>
|
||||
<form
|
||||
ref={aiFormRef}
|
||||
onSubmit={form.handleSubmit(onFinish)}
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
onFinish(normalizeUserFormValues(values))
|
||||
)}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-5 py-5 [&_[data-slot=input]]:h-10 [&_[data-slot=select-trigger]]:h-10 [&_[data-slot=textarea]]:min-h-56">
|
||||
<UserFormFields form={form} includePassword translate={translate} />
|
||||
<UserFormFields
|
||||
form={form}
|
||||
includePassword
|
||||
roleAction="create"
|
||||
translate={translate}
|
||||
/>
|
||||
</div>
|
||||
<RouteDrawerFooter className="flex-row justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => close()}>
|
||||
@@ -101,8 +107,9 @@ function UserCreateForm() {
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
{...form.saveButtonProps}
|
||||
disabled={form.formState.isSubmitting}
|
||||
disabled={
|
||||
form.saveButtonProps.disabled || form.formState.isSubmitting
|
||||
}
|
||||
>
|
||||
{form.formState.isSubmitting
|
||||
? translate(
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { type HttpError, useTranslate } from "@refinedev/core";
|
||||
import { type HttpError, useOne, useTranslate } from "@refinedev/core";
|
||||
import { useForm } from "@refinedev/react-hook-form";
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useCanAccess } from "@nocobase/portal-sdk/acl";
|
||||
import { useRouteSurfaceClose } from "@nocobase/portal-sdk/routing";
|
||||
|
||||
import { AccessDenied } from "@/components/access-control/access-denied";
|
||||
import { CanAccess } from "@/components/access-control/can-access";
|
||||
import { LoadingState } from "@/components/app-shell/loading-state";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { useAIForm, type AIFormField } from "./optional-ai";
|
||||
@@ -18,10 +21,13 @@ import {
|
||||
applyAIUserFormValues,
|
||||
getAIUserFormFields,
|
||||
getAIUserFormValues,
|
||||
getUserFormValues,
|
||||
normalizeUserFormValues,
|
||||
} from "./form-context";
|
||||
import { UserFormFields } from "./form-fields";
|
||||
import { getUserShowPath, userRoutes } from "./routes";
|
||||
import type { UserFormValues, UserRecord } from "./types";
|
||||
import { useUserFormHydration } from "./use-user-form-hydration";
|
||||
|
||||
export const UserEdit = ({
|
||||
returnTo = "list",
|
||||
@@ -57,6 +63,18 @@ export const UserEdit = ({
|
||||
function UserEditForm({ id }: { id?: string }) {
|
||||
const translate = useTranslate();
|
||||
const close = useRouteSurfaceClose();
|
||||
const canListRoles = useCanAccess({ resource: "roles", action: "list" });
|
||||
const canEditRoles = useCanAccess({
|
||||
resource: "users",
|
||||
action: "edit",
|
||||
field: "roles",
|
||||
});
|
||||
const canManageRoles = canListRoles && canEditRoles;
|
||||
const { result: record, query: userQuery } = useOne<UserRecord>({
|
||||
resource: "users",
|
||||
id,
|
||||
meta: canManageRoles ? { appends: ["roles"] } : undefined,
|
||||
});
|
||||
const {
|
||||
refineCore: { onFinish },
|
||||
...form
|
||||
@@ -66,11 +84,14 @@ function UserEditForm({ id }: { id?: string }) {
|
||||
resource: "users",
|
||||
id,
|
||||
redirect: false,
|
||||
queryOptions: { enabled: false },
|
||||
onMutationSuccess: () => {
|
||||
close({ skipBeforeClose: true });
|
||||
},
|
||||
},
|
||||
defaultValues: getUserFormValues(),
|
||||
});
|
||||
useUserFormHydration({ form, id, record });
|
||||
const aiFields = useMemo<AIFormField[]>(
|
||||
() => getAIUserFormFields(translate),
|
||||
[translate]
|
||||
@@ -87,11 +108,34 @@ function UserEditForm({ id }: { id?: string }) {
|
||||
<Form {...form}>
|
||||
<form
|
||||
ref={aiFormRef}
|
||||
onSubmit={form.handleSubmit(onFinish)}
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
onFinish(normalizeUserFormValues(values))
|
||||
)}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-5 py-5 [&_[data-slot=input]]:h-10 [&_[data-slot=select-trigger]]:h-10 [&_[data-slot=textarea]]:min-h-56">
|
||||
<UserFormFields form={form} translate={translate} />
|
||||
{userQuery.isLoading ? (
|
||||
<LoadingState className="min-h-64" />
|
||||
) : userQuery.isError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>
|
||||
{translate(
|
||||
"users.detail.loadError.title",
|
||||
{ ns: "app" },
|
||||
"Unable to load user"
|
||||
)}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{translate(
|
||||
"users.detail.loadError.description",
|
||||
{ ns: "app" },
|
||||
"The user may no longer exist, or you may not have permission to view it."
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<UserFormFields form={form} roleAction="edit" translate={translate} />
|
||||
)}
|
||||
</div>
|
||||
<RouteDrawerFooter className="flex-row justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => close()}>
|
||||
@@ -99,8 +143,12 @@ function UserEditForm({ id }: { id?: string }) {
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
{...form.saveButtonProps}
|
||||
disabled={form.formState.isSubmitting}
|
||||
disabled={
|
||||
userQuery.isLoading ||
|
||||
userQuery.isError ||
|
||||
form.saveButtonProps.disabled ||
|
||||
form.formState.isSubmitting
|
||||
}
|
||||
>
|
||||
{form.formState.isSubmitting
|
||||
? translate(
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
import type { useTranslate } from "@refinedev/core";
|
||||
import type { Path, PathValue, UseFormReturn } from "react-hook-form";
|
||||
|
||||
import type { UserFormValues } from "./types";
|
||||
import type { UserFormValues, UserRecord } from "./types";
|
||||
|
||||
type Translate = ReturnType<typeof useTranslate>;
|
||||
|
||||
const AI_EDITABLE_FIELDS = ["nickname", "username", "email", "phone"] as const;
|
||||
|
||||
export function getUserFormValues(record?: UserRecord): UserFormValues {
|
||||
const values: UserFormValues = {
|
||||
nickname: record?.nickname ?? "",
|
||||
username: record?.username ?? "",
|
||||
email: record?.email ?? "",
|
||||
phone: record?.phone ?? "",
|
||||
};
|
||||
|
||||
if (record && Object.prototype.hasOwnProperty.call(record, "roles")) {
|
||||
values.roles = record.roles ?? [];
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
export function normalizeUserFormValues(values: UserFormValues): UserFormValues {
|
||||
if (!Object.prototype.hasOwnProperty.call(values, "roles")) return values;
|
||||
if (!Array.isArray(values.roles)) {
|
||||
const { roles: _roles, ...valuesWithoutRoles } = values;
|
||||
return valuesWithoutRoles;
|
||||
}
|
||||
|
||||
return {
|
||||
...values,
|
||||
roles: values.roles
|
||||
.map((role) => role.name)
|
||||
.filter(Boolean)
|
||||
.map((name) => ({ name })),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAIUserFormValues(values: UserFormValues) {
|
||||
return Object.fromEntries(
|
||||
AI_EDITABLE_FIELDS.map((name) => [name, values[name] ?? ""])
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { UserRolesField } from "./role-select-field";
|
||||
import type { UserFormValues } from "./types";
|
||||
|
||||
type Translate = ReturnType<typeof useTranslate>;
|
||||
@@ -17,10 +18,12 @@ type Translate = ReturnType<typeof useTranslate>;
|
||||
export function UserFormFields({
|
||||
form,
|
||||
includePassword,
|
||||
roleAction,
|
||||
translate,
|
||||
}: {
|
||||
form: UseFormReturn<UserFormValues>;
|
||||
includePassword?: boolean;
|
||||
roleAction: "create" | "edit";
|
||||
translate: Translate;
|
||||
}) {
|
||||
return (
|
||||
@@ -206,6 +209,8 @@ export function UserFormFields({
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<UserRolesField form={form} action={roleAction} translate={translate} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,13 @@ export const usersExample = {
|
||||
"users.form.email.placeholder": "Enter an email address",
|
||||
"users.form.phone.placeholder": "Enter a phone number",
|
||||
"users.form.password.placeholder": "Set an initial password",
|
||||
"users.form.roles.placeholder": "Select roles...",
|
||||
"users.form.roles.search": "Search roles...",
|
||||
"users.form.roles.loading": "Loading roles...",
|
||||
"users.form.roles.loadMore": "Load more",
|
||||
"users.form.roles.loadingMore": "Loading more...",
|
||||
"users.form.roles.loadError":
|
||||
"Role options could not be loaded. Existing assignments will be preserved.",
|
||||
"users.form.password.aiNotice":
|
||||
"Passwords are never included in AI page context or Form filler.",
|
||||
"users.validation.username": "Username is required",
|
||||
|
||||
@@ -32,6 +32,12 @@ export const usersExample = {
|
||||
"users.form.email.placeholder": "输入邮箱地址",
|
||||
"users.form.phone.placeholder": "输入手机号",
|
||||
"users.form.password.placeholder": "设置初始密码",
|
||||
"users.form.roles.placeholder": "选择角色…",
|
||||
"users.form.roles.search": "搜索角色…",
|
||||
"users.form.roles.loading": "正在加载角色…",
|
||||
"users.form.roles.loadMore": "加载更多",
|
||||
"users.form.roles.loadingMore": "正在加载更多…",
|
||||
"users.form.roles.loadError": "无法加载角色选项,将保留已有的角色分配。",
|
||||
"users.form.password.aiNotice":
|
||||
"密码不会包含在 AI 页面上下文中,Form filler 也无法填写密码。",
|
||||
"users.validation.username": "请输入用户名",
|
||||
|
||||
@@ -26,7 +26,44 @@ export function RoleDetailRoute({ returnTo }: { returnTo: "list" | "show" }) {
|
||||
const closeTarget =
|
||||
returnTo === "show" && id ? getUserShowPath(id) : userRoutes.list;
|
||||
|
||||
return <RoleDetailDrawer role={{ name: roleName }} closeTo={closeTarget} />;
|
||||
const role = { name: roleName };
|
||||
|
||||
return (
|
||||
<CanAccess
|
||||
resource="roles"
|
||||
action="show"
|
||||
fallback={<RoleDetailAccessDenied role={role} closeTo={closeTarget} />}
|
||||
>
|
||||
<RoleDetailDrawer role={role} closeTo={closeTarget} />
|
||||
</CanAccess>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleDetailAccessDenied({
|
||||
role,
|
||||
closeTo,
|
||||
}: {
|
||||
role: Role;
|
||||
closeTo: string;
|
||||
}) {
|
||||
const translate = useTranslate();
|
||||
|
||||
return (
|
||||
<RouteDrawer
|
||||
title={resolveRoleLabel(role)}
|
||||
description={translate(
|
||||
"roles.drawer.description",
|
||||
{ ns: "app" },
|
||||
"Review the role associated with this user."
|
||||
)}
|
||||
closeLabel={translate("buttons.close", "Close")}
|
||||
closeTo={closeTo}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
||||
<AccessDenied className="min-h-full" />
|
||||
</div>
|
||||
</RouteDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoleDetailDrawer({
|
||||
@@ -263,17 +300,9 @@ function RoleDetailSection({
|
||||
}
|
||||
|
||||
export function UserRoleDetailRoute() {
|
||||
return (
|
||||
<CanAccess resource="roles" action="show" fallback={<AccessDenied />}>
|
||||
<RoleDetailRoute returnTo="list" />
|
||||
</CanAccess>
|
||||
);
|
||||
return <RoleDetailRoute returnTo="list" />;
|
||||
}
|
||||
|
||||
export function UserShowRoleDetailRoute() {
|
||||
return (
|
||||
<CanAccess resource="roles" action="show" fallback={<AccessDenied />}>
|
||||
<RoleDetailRoute returnTo="show" />
|
||||
</CanAccess>
|
||||
);
|
||||
return <RoleDetailRoute returnTo="show" />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { nocobaseClient } from "@nocobase/portal-sdk/client";
|
||||
import type { useTranslate } from "@refinedev/core";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { CanAccess } from "@/components/access-control/can-access";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
RemoteSelect,
|
||||
type RemoteSelectLoadParams,
|
||||
type RemoteSelectLoadResult,
|
||||
} from "@/extensions/nocobase-client";
|
||||
import { resolveRoleLabel } from "./role-utils";
|
||||
import type { RoleRecord, UserFormValues } from "./types";
|
||||
|
||||
type Translate = ReturnType<typeof useTranslate>;
|
||||
|
||||
type RoleListEnvelope = {
|
||||
data?:
|
||||
| RoleRecord[]
|
||||
| {
|
||||
rows?: RoleRecord[];
|
||||
count?: number;
|
||||
};
|
||||
rows?: RoleRecord[];
|
||||
count?: number;
|
||||
meta?: {
|
||||
count?: number;
|
||||
};
|
||||
};
|
||||
|
||||
function parseRoleList(payload: unknown) {
|
||||
if (Array.isArray(payload)) {
|
||||
return { rows: payload as RoleRecord[], count: payload.length };
|
||||
}
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return { rows: [], count: 0 };
|
||||
}
|
||||
|
||||
const envelope = payload as RoleListEnvelope;
|
||||
if (Array.isArray(envelope.data)) {
|
||||
return {
|
||||
rows: envelope.data,
|
||||
count: envelope.meta?.count ?? envelope.data.length,
|
||||
};
|
||||
}
|
||||
if (envelope.data && !Array.isArray(envelope.data)) {
|
||||
const rows = envelope.data.rows ?? [];
|
||||
return { rows, count: envelope.data.count ?? rows.length };
|
||||
}
|
||||
|
||||
const rows = envelope.rows ?? [];
|
||||
return { rows, count: envelope.count ?? rows.length };
|
||||
}
|
||||
|
||||
async function loadRoleOptions({
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
signal,
|
||||
}: RemoteSelectLoadParams): Promise<RemoteSelectLoadResult<RoleRecord>> {
|
||||
const conditions: Record<string, unknown>[] = [
|
||||
{ name: { $ne: "root" } },
|
||||
];
|
||||
if (search) {
|
||||
conditions.push({
|
||||
$or: [
|
||||
{ name: { $includes: search } },
|
||||
{ title: { $includes: search } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const payload = await nocobaseClient.action<unknown>("roles", "list", {
|
||||
query: {
|
||||
page,
|
||||
pageSize,
|
||||
filter: JSON.stringify(
|
||||
conditions.length === 1 ? conditions[0] : { $and: conditions }
|
||||
),
|
||||
sort: "name",
|
||||
},
|
||||
signal,
|
||||
unwrap: "none",
|
||||
});
|
||||
const { rows, count } = parseRoleList(payload);
|
||||
|
||||
return {
|
||||
items: rows.filter((role) => role.name && role.name !== "root"),
|
||||
hasMore: page * pageSize < count,
|
||||
};
|
||||
}
|
||||
|
||||
export function UserRolesField({
|
||||
action,
|
||||
form,
|
||||
translate,
|
||||
}: {
|
||||
action: "create" | "edit";
|
||||
form: UseFormReturn<UserFormValues>;
|
||||
translate: Translate;
|
||||
}) {
|
||||
return (
|
||||
<CanAccess resource="roles" action="list">
|
||||
<CanAccess resource="users" action={action} field="roles">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field }) => {
|
||||
const roles = field.value ?? [];
|
||||
const protectedRoles = roles.filter((role) => role.name === "root");
|
||||
const selectedRoles = roles.filter((role) => role.name !== "root");
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{translate("users.fields.roles", { ns: "app" }, "Roles")}
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<RemoteSelect<RoleRecord>
|
||||
multiple
|
||||
value={selectedRoles}
|
||||
onValueChange={(nextRoles) =>
|
||||
field.onChange([...protectedRoles, ...nextRoles])
|
||||
}
|
||||
onBlur={field.onBlur}
|
||||
requestKey={["roles"]}
|
||||
loadOptions={loadRoleOptions}
|
||||
getOptionKey={(role) => role.name}
|
||||
getOptionLabel={resolveRoleLabel}
|
||||
popupSide="top"
|
||||
placeholder={translate(
|
||||
"users.form.roles.placeholder",
|
||||
{ ns: "app" },
|
||||
"Select roles..."
|
||||
)}
|
||||
messages={{
|
||||
searchPlaceholder: translate(
|
||||
"users.form.roles.search",
|
||||
{ ns: "app" },
|
||||
"Search roles..."
|
||||
),
|
||||
empty: translate(
|
||||
"users.filters.roles.noResults",
|
||||
{ ns: "app" },
|
||||
"No roles found."
|
||||
),
|
||||
loading: translate(
|
||||
"users.form.roles.loading",
|
||||
{ ns: "app" },
|
||||
"Loading roles..."
|
||||
),
|
||||
loadMore: translate(
|
||||
"users.form.roles.loadMore",
|
||||
{ ns: "app" },
|
||||
"Load more"
|
||||
),
|
||||
loadingMore: translate(
|
||||
"users.form.roles.loadingMore",
|
||||
{ ns: "app" },
|
||||
"Loading more..."
|
||||
),
|
||||
error: translate(
|
||||
"users.form.roles.loadError",
|
||||
{ ns: "app" },
|
||||
"Role options could not be loaded. Existing assignments will be preserved."
|
||||
),
|
||||
retry: translate(
|
||||
"runtimeError.retry",
|
||||
{ ns: "starter" },
|
||||
"Retry"
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</CanAccess>
|
||||
</CanAccess>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getAIUserFormValues,
|
||||
getUserFormValues,
|
||||
normalizeUserFormValues,
|
||||
} from "../form-context";
|
||||
|
||||
describe("user form values", () => {
|
||||
it("submits roles using the NocoBase association shape", () => {
|
||||
expect(
|
||||
normalizeUserFormValues({
|
||||
nickname: "Alice",
|
||||
username: "alice",
|
||||
email: "[email protected]",
|
||||
phone: "10086",
|
||||
roles: [
|
||||
{ name: "admin", title: "Admin" },
|
||||
{ name: "member", title: "Member" },
|
||||
],
|
||||
}).roles
|
||||
).toEqual([{ name: "admin" }, { name: "member" }]);
|
||||
});
|
||||
|
||||
it("does not expose role assignment to AI Form filler", () => {
|
||||
expect(
|
||||
getAIUserFormValues({
|
||||
nickname: "Alice",
|
||||
username: "alice",
|
||||
email: "[email protected]",
|
||||
phone: "10086",
|
||||
roles: [{ name: "admin" }],
|
||||
})
|
||||
).not.toHaveProperty("roles");
|
||||
});
|
||||
|
||||
it("hydrates edit fields and preserves associated role labels", () => {
|
||||
expect(
|
||||
getUserFormValues({
|
||||
id: 7,
|
||||
nickname: "Alice",
|
||||
username: "alice",
|
||||
email: "[email protected]",
|
||||
phone: "10086",
|
||||
roles: [
|
||||
{ name: "admin", title: "Admin" },
|
||||
{ name: "member", title: "Member" },
|
||||
],
|
||||
})
|
||||
).toEqual({
|
||||
nickname: "Alice",
|
||||
username: "alice",
|
||||
email: "[email protected]",
|
||||
phone: "10086",
|
||||
roles: [
|
||||
{ name: "admin", title: "Admin" },
|
||||
{ name: "member", title: "Member" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not invent an empty role assignment when roles were not returned", () => {
|
||||
const values = getUserFormValues({
|
||||
id: 7,
|
||||
nickname: "Alice",
|
||||
username: "alice",
|
||||
});
|
||||
|
||||
expect(values).not.toHaveProperty("roles");
|
||||
expect(normalizeUserFormValues(values)).not.toHaveProperty("roles");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getUserFormValues } from "../form-context";
|
||||
import type { UserFormValues, UserRecord } from "../types";
|
||||
import { useUserFormHydration } from "../use-user-form-hydration";
|
||||
|
||||
function useHydratedForm({ id, record }: { id: string; record: UserRecord }) {
|
||||
const form = useForm<UserFormValues>({ defaultValues: getUserFormValues() });
|
||||
useUserFormHydration({ form, id, record });
|
||||
return form;
|
||||
}
|
||||
|
||||
describe("useUserFormHydration", () => {
|
||||
it("hydrates once per user and preserves dirty edits across query refreshes", async () => {
|
||||
const alice = { id: 1, nickname: "Alice", username: "alice" };
|
||||
const { result, rerender } = renderHook(useHydratedForm, {
|
||||
initialProps: { id: "1", record: alice },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.getValues("nickname")).toBe("Alice"));
|
||||
act(() => result.current.setValue("nickname", "Unsaved draft"));
|
||||
|
||||
rerender({
|
||||
id: "1",
|
||||
record: { ...alice, nickname: "Alice from refreshed query" },
|
||||
});
|
||||
expect(result.current.getValues("nickname")).toBe("Unsaved draft");
|
||||
|
||||
rerender({
|
||||
id: "2",
|
||||
record: { id: 2, nickname: "Bob", username: "bob" },
|
||||
});
|
||||
await waitFor(() => expect(result.current.getValues("nickname")).toBe("Bob"));
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export type UserFormValues = {
|
||||
email: string;
|
||||
phone: string;
|
||||
password?: string;
|
||||
roles?: Role[];
|
||||
};
|
||||
|
||||
export type RoleRecord = Role & {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { getUserFormValues } from "./form-context";
|
||||
import type { UserFormValues, UserRecord } from "./types";
|
||||
|
||||
export function useUserFormHydration({
|
||||
form,
|
||||
id,
|
||||
record,
|
||||
}: {
|
||||
form: Pick<UseFormReturn<UserFormValues>, "reset">;
|
||||
id?: string;
|
||||
record?: UserRecord;
|
||||
}) {
|
||||
const hydratedIdRef = useRef<string | undefined>(undefined);
|
||||
const { reset } = form;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !record || String(record.id) !== id) return;
|
||||
if (hydratedIdRef.current === id) return;
|
||||
|
||||
reset(getUserFormValues(record));
|
||||
hydratedIdRef.current = id;
|
||||
}, [id, record, reset]);
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nocobase/portal-sdk",
|
||||
"version": "2.0.5",
|
||||
"version": "2.1.0",
|
||||
"description": "Stable runtime and extension APIs for NocoBase Portal applications.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -11,3 +11,12 @@ export type RouteSurfaceClose = (
|
||||
export const RouteSurfaceContext = createContext<RouteSurfaceClose | null>(null);
|
||||
|
||||
export const RouteOverlayDepthContext = createContext(0);
|
||||
|
||||
export type RouteOverlayViewport = {
|
||||
inlineEnd?: number | string;
|
||||
};
|
||||
|
||||
// Layouts with persistent side regions can scope route overlays to the page
|
||||
// viewport while React portals preserve the surrounding route context.
|
||||
export const RouteOverlayViewportContext =
|
||||
createContext<RouteOverlayViewport | null>(null);
|
||||
|
||||
@@ -13,6 +13,7 @@ function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
positionerClassName,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
@@ -23,7 +24,7 @@ function PopoverContent({
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
> & { keepMounted?: boolean }) {
|
||||
> & { keepMounted?: boolean; positionerClassName?: string }) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal keepMounted={keepMounted}>
|
||||
<PopoverPrimitive.Positioner
|
||||
@@ -31,7 +32,7 @@ function PopoverContent({
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
className={cn("isolate z-50", positionerClassName)}
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MemoryRouter } from "react-router";
|
||||
|
||||
import { RouteOverlayViewportContext } from "@nocobase/portal-sdk/routing";
|
||||
import { RouteDialog } from "@/extensions/nocobase-route-surfaces";
|
||||
|
||||
describe("Route overlay viewport", () => {
|
||||
it("applies the reserved side region to the rendered route surface portal", async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RouteOverlayViewportContext.Provider value={{ inlineEnd: 450 }}>
|
||||
<RouteDialog title="Details" closeLabel="Close" closeTo="/">
|
||||
<div>Dialog content</div>
|
||||
</RouteDialog>
|
||||
</RouteOverlayViewportContext.Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("dialog", { name: "Details" })
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector<HTMLElement>(
|
||||
'[style*="--route-overlay-inline-end"]'
|
||||
)
|
||||
).toHaveStyle("--route-overlay-inline-end: 450px");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user