feat: improve user role management and route overlays (#22)

This commit is contained in:
YANG QIA
2026-08-07 01:17:20 +08:00
committed by GitHub
parent 756e8ea31b
commit 311827fcf1
33 changed files with 1832 additions and 129 deletions
+1
View File
@@ -1,4 +1,5 @@
node_modules/
.pnpm-store/
dist-ssr/
logs/
*.log
+2 -2
View File
@@ -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
View File
@@ -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>
);
}
+13 -1
View File
@@ -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,
});
});
});
+13
View File
@@ -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.
+165
View File
@@ -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>
);
}
+39
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
export * from "./remote-select";
export type * from "./types";
+252
View File
@@ -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();
});
});
});
+66
View File
@@ -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;
}
+15 -8
View File
@@ -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(
+53 -5
View File
@@ -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": "请输入用户名",
+40 -11
View File
@@ -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"));
});
});
+1
View File
@@ -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
View File
@@ -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,
+9
View File
@@ -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);
+3 -2
View File
@@ -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");
});
});