feat: improve admin panel (#14180)

This commit is contained in:
DarkSky
2025-12-30 05:22:54 +08:00
committed by GitHub
parent d6b380aee5
commit 95a5e941e7
94 changed files with 3146 additions and 1114 deletions

View File

@@ -23,6 +23,9 @@ export const Setup = lazy(
export const Accounts = lazy(
() => import(/* webpackChunkName: "accounts" */ './modules/accounts')
);
export const Workspaces = lazy(
() => import(/* webpackChunkName: "workspaces" */ './modules/workspaces')
);
export const AI = lazy(
() => import(/* webpackChunkName: "ai" */ './modules/ai')
);
@@ -91,6 +94,10 @@ export const App = () => {
<Route path={ROUTES.admin.setup} element={<Setup />} />
<Route element={<AuthenticatedRoutes />}>
<Route path={ROUTES.admin.accounts} element={<Accounts />} />
<Route
path={ROUTES.admin.workspaces}
element={<Workspaces />}
/>
<Route path={ROUTES.admin.ai} element={<AI />} />
<Route path={ROUTES.admin.about} element={<About />} />
<Route

View File

@@ -0,0 +1,66 @@
import { Button, type ButtonProps } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
import type { ReactNode } from 'react';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: ReactNode;
cancelText?: string;
confirmText?: string;
confirmButtonVariant?: ButtonProps['variant'];
onConfirm: () => void;
onClose?: () => void;
}
export const ConfirmDialog = ({
open,
onOpenChange,
title,
description,
cancelText = 'Cancel',
confirmText = 'Confirm',
confirmButtonVariant = 'default',
onConfirm,
onClose,
}: ConfirmDialogProps) => {
const handleClose = () => {
onOpenChange(false);
onClose?.();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[460px]">
<DialogHeader>
<DialogTitle className="leading-7">{title}</DialogTitle>
<DialogDescription className="leading-6">
{description}
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-6">
<div className="flex justify-end gap-2 items-center w-full">
<Button type="button" onClick={handleClose} variant="outline">
<span>{cancelText}</span>
</Button>
<Button
type="button"
onClick={onConfirm}
variant={confirmButtonVariant}
>
<span>{confirmText}</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -111,7 +111,7 @@ export function DataTablePagination<TData>({
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
className="h-8 w-8 p-0"
onClick={handleLastPage}
disabled={!table.getCanNextPage()}
>

View File

@@ -0,0 +1,166 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@affine/admin/components/ui/table';
import {
type ColumnDef,
type ColumnFiltersState,
flexRender,
getCoreRowModel,
type OnChangeFn,
type PaginationState,
type RowSelectionState,
type Table as TableInstance,
useReactTable,
} from '@tanstack/react-table';
import { type ReactNode, useEffect, useState } from 'react';
import { DataTablePagination } from './data-table-pagination';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
totalCount: number;
pagination: PaginationState;
onPaginationChange: OnChangeFn<PaginationState>;
// Row Selection
rowSelection?: RowSelectionState;
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
// Toolbar
renderToolbar?: (table: TableInstance<TData>) => ReactNode;
// External State Sync (for filters etc to reset internal state if needed)
// In the original code, columnFilters was reset when keyword/features changed.
// We can expose onColumnFiltersChange or just handle it internally if we don't need to lift it.
// The original code reset columnFilters on keyword change.
// We can pass a dependency array to reset column filters?
// Or just let the parent handle it if they want to control column filters.
// For now, let's keep columnFilters internal but allow resetting.
resetFiltersDeps?: any[];
}
export function SharedDataTable<TData extends { id: string }, TValue>({
columns,
data,
totalCount,
pagination,
onPaginationChange,
rowSelection,
onRowSelectionChange,
renderToolbar,
resetFiltersDeps = [],
}: DataTableProps<TData, TValue>) {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
useEffect(() => {
setColumnFilters([]);
}, [resetFiltersDeps]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: row => row.id,
manualPagination: true,
rowCount: totalCount,
enableFilters: true,
onPaginationChange: onPaginationChange,
onColumnFiltersChange: setColumnFilters,
// Row Selection
enableRowSelection: !!onRowSelectionChange,
onRowSelectionChange: onRowSelectionChange,
state: {
pagination,
columnFilters,
rowSelection: rowSelection ?? {},
},
});
return (
<div className="flex flex-col gap-4 py-5 px-6 h-full overflow-auto">
{renderToolbar?.(table)}
<div className="rounded-md border h-full flex flex-col overflow-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id} className="flex items-center">
{headerGroup.headers.map(header => {
// Use meta.className if available, otherwise default to flex-1
const meta = header.column.columnDef.meta as
| { className?: string }
| undefined;
const className = meta?.className ?? 'flex-1 min-w-0';
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={`${className} py-2 text-xs flex items-center h-9`}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
</Table>
<div className="overflow-auto flex-1">
<Table>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map(row => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className="flex items-center"
>
{row.getVisibleCells().map(cell => {
const meta = cell.column.columnDef.meta as
| { className?: string }
| undefined;
const className = meta?.className ?? 'flex-1 min-w-0';
return (
<TableCell
key={cell.id}
className={`${className} py-2`}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center flex-1"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} />
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { ConfirmDialog } from './confirm-dialog';
export const DiscardChanges = ({
open,
onClose,
onConfirm,
onOpenChange,
description = 'Changes will not be saved.',
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
description?: string;
}) => {
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title="Discard Changes"
description={description}
confirmText="Discard"
confirmButtonVariant="destructive"
onConfirm={onConfirm}
onClose={onClose}
/>
);
};

View File

@@ -0,0 +1,91 @@
import { Button } from '@affine/admin/components/ui/button';
import { Checkbox } from '@affine/admin/components/ui/checkbox';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@affine/admin/components/ui/popover';
import type { FeatureType } from '@affine/graphql';
import { useCallback } from 'react';
type FeatureFilterPopoverProps = {
selectedFeatures: FeatureType[];
availableFeatures: FeatureType[];
onChange: (features: FeatureType[]) => void;
align?: 'start' | 'center' | 'end';
buttonLabel?: string;
};
export const FeatureFilterPopover = ({
selectedFeatures,
availableFeatures,
onChange,
align = 'start',
buttonLabel = 'Features',
}: FeatureFilterPopoverProps) => {
const handleFeatureToggle = useCallback(
(feature: FeatureType, checked: boolean) => {
if (checked) {
onChange([...new Set([...selectedFeatures, feature])]);
} else {
onChange(selectedFeatures.filter(enabled => enabled !== feature));
}
},
[onChange, selectedFeatures]
);
const handleClearFeatures = useCallback(() => {
onChange([]);
}, [onChange]);
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-2 lg:px-3 space-x-1"
>
<span>{buttonLabel}</span>
{selectedFeatures.length > 0 ? (
<span className="text-xs text-muted-foreground">
({selectedFeatures.length})
</span>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent
align={align}
className="w-[240px] p-2 flex flex-col gap-2"
>
<div className="text-xs font-medium px-1">Filter by feature</div>
<div className="flex flex-col gap-1 max-h-64 overflow-auto">
{availableFeatures.map(feature => (
<label
key={feature}
className="flex items-center gap-2 px-1 py-1.5 cursor-pointer"
>
<Checkbox
checked={selectedFeatures.includes(feature)}
onCheckedChange={checked =>
handleFeatureToggle(feature, !!checked)
}
/>
<span className="text-sm truncate">{feature}</span>
</label>
))}
</div>
<div className="flex justify-end gap-2 px-1">
<Button
variant="ghost"
size="sm"
onClick={handleClearFeatures}
disabled={selectedFeatures.length === 0}
>
Clear
</Button>
</div>
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,91 @@
import { Checkbox } from '@affine/admin/components/ui/checkbox';
import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import { Switch } from '@affine/admin/components/ui/switch';
import type { FeatureType } from '@affine/graphql';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback } from 'react';
import { cn } from '../../utils';
type FeatureToggleListProps = {
features: FeatureType[];
selected: FeatureType[];
onChange: (features: FeatureType[]) => void;
control?: 'checkbox' | 'switch';
controlPosition?: 'left' | 'right';
showSeparators?: boolean;
className?: string;
};
export const FeatureToggleList = ({
features,
selected,
onChange,
control = 'checkbox',
controlPosition = 'left',
showSeparators = false,
className,
}: FeatureToggleListProps) => {
const Control = control === 'switch' ? Switch : Checkbox;
const handleToggle = useCallback(
(feature: FeatureType, checked: boolean) => {
if (checked) {
onChange([...new Set([...selected, feature])]);
} else {
onChange(selected.filter(item => item !== feature));
}
},
[onChange, selected]
);
if (!features.length) {
return (
<div
className={cn(className, 'px-3 py-2 text-xs')}
style={{ color: cssVarV2('text/secondary') }}
>
No configurable features.
</div>
);
}
return (
<div className={className}>
{features.map((feature, index) => (
<div key={feature}>
<Label
className={cn(
'cursor-pointer',
controlPosition === 'right'
? 'flex items-center justify-between p-3 text-[15px] gap-2 font-medium leading-6 overflow-hidden'
: 'flex items-center gap-2 px-3 py-2 text-sm'
)}
>
{controlPosition === 'left' ? (
<>
<Control
checked={selected.includes(feature)}
onCheckedChange={checked => handleToggle(feature, !!checked)}
/>
<span className="truncate">{feature}</span>
</>
) : (
<>
<span className="overflow-hidden text-ellipsis" title={feature}>
{feature}
</span>
<Control
checked={selected.includes(feature)}
onCheckedChange={checked => handleToggle(feature, !!checked)}
/>
</>
)}
</Label>
{showSeparators && index < features.length - 1 && <Separator />}
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,98 @@
import { Button, type ButtonProps } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
import { Input } from '@affine/admin/components/ui/input';
import { type ReactNode, useCallback, useEffect, useState } from 'react';
interface TypeConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: ReactNode;
targetText: string;
inputPlaceholder?: string;
cancelText?: string;
confirmText?: string;
confirmButtonVariant?: ButtonProps['variant'];
onConfirm: () => void;
onClose?: () => void;
}
export const TypeConfirmDialog = ({
open,
onOpenChange,
title,
description,
targetText,
inputPlaceholder = 'Please type to confirm',
cancelText = 'Cancel',
confirmText = 'Confirm',
confirmButtonVariant = 'destructive',
onConfirm,
onClose,
}: TypeConfirmDialogProps) => {
const [input, setInput] = useState('');
const handleInput = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setInput(event.target.value);
},
[]
);
useEffect(() => {
if (!open) {
setInput('');
}
}, [open]);
const handleClose = () => {
onOpenChange(false);
onClose?.();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Input
type="text"
value={input}
onChange={handleInput}
placeholder={inputPlaceholder}
className="placeholder:opacity-50 mt-4 h-9"
/>
<DialogFooter className="mt-6">
<div className="flex justify-end gap-2 items-center w-full">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleClose}
>
{cancelText}
</Button>
<Button
type="button"
onClick={onConfirm}
disabled={input !== targetText}
size="sm"
variant={confirmButtonVariant}
>
{confirmText}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -228,10 +228,6 @@
}
},
"flags": {
"earlyAccessControl": {
"type": "Boolean",
"desc": "Only allow users with early access features to access the app"
},
"allowGuestDemoWorkspace": {
"type": "Boolean",
"desc": "Whether allow guest users to create demo workspaces."

View File

@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -66,6 +66,9 @@ export const useColumns = ({
return [
{
id: 'select',
meta: {
className: 'w-[40px] flex-shrink-0',
},
header: ({ table }) => (
<Checkbox
checked={
@@ -127,6 +130,9 @@ export const useColumns = ({
},
{
accessorKey: 'info',
meta: {
className: 'w-[250px] flex-shrink-0',
},
header: ({ column }) => (
<DataTableColumnHeader
className="text-xs"
@@ -190,7 +196,7 @@ export const useColumns = ({
<DataTableColumnHeader
className="text-xs max-md:hidden"
column={column}
title="UUID"
title="User Detail"
/>
),
cell: ({ row: { original: user } }) => (
@@ -233,12 +239,40 @@ export const useColumns = ({
textFalse="Email Not Verified"
/>
</div>
<div className="flex flex-wrap gap-2 items-center">
{user.features.length ? (
user.features.map(feature => (
<span
key={feature}
className="rounded px-2 py-0.5 text-xs h-5 border inline-flex items-center"
style={{
borderRadius: '4px',
backgroundColor: cssVarV2('chip/label/white'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
>
{feature}
</span>
))
) : (
<span
style={{
color: cssVarV2('text/secondary'),
}}
>
No features
</span>
)}
</div>
</div>
</div>
),
},
{
id: 'actions',
meta: {
className: 'w-[80px]',
},
header: ({ column }) => (
<DataTableColumnHeader
className="text-xs"

View File

@@ -16,11 +16,11 @@ import {
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import { DiscardChanges } from '../../../components/shared/discard-changes';
import { useRightPanel } from '../../panel/context';
import type { UserType } from '../schema';
import { DeleteAccountDialog } from './delete-account';
import { DisableAccountDialog } from './disable-account';
import { DiscardChanges } from './discard-changes';
import { EnableAccountDialog } from './enable-account';
import { ResetPasswordDialog } from './reset-password';
import {
@@ -41,7 +41,14 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
const [enableDialogOpen, setEnableDialogOpen] = useState(false);
const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
const { openPanel, isOpen, closePanel, setPanelContent } = useRightPanel();
const {
openPanel,
isOpen,
closePanel,
setPanelContent,
hasDirtyChanges,
setHasDirtyChanges,
} = useRightPanel();
const deleteUser = useDeleteUser();
const disableUser = useDisableUser();
@@ -118,44 +125,42 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
setEnableDialogOpen(false);
}, []);
const handleDiscardChangesCancel = useCallback(() => {
setDiscardDialogOpen(false);
}, []);
const handleConfirm = useCallback(() => {
setHasDirtyChanges(false);
setPanelContent(
<UpdateUserForm
user={user}
onComplete={closePanel}
onResetPassword={openResetPasswordDialog}
onDeleteAccount={openDeleteDialog}
onDirtyChange={setHasDirtyChanges}
/>
);
if (discardDialogOpen) {
handleDiscardChangesCancel();
}
if (!isOpen) {
openPanel();
}
openPanel();
}, [
closePanel,
discardDialogOpen,
handleDiscardChangesCancel,
isOpen,
openDeleteDialog,
openPanel,
openResetPasswordDialog,
setPanelContent,
user,
setHasDirtyChanges,
]);
const handleEdit = useCallback(() => {
if (isOpen) {
if (hasDirtyChanges) {
setDiscardDialogOpen(true);
} else {
handleConfirm();
return;
}
}, [handleConfirm, isOpen]);
setHasDirtyChanges(false);
handleConfirm();
}, [handleConfirm, hasDirtyChanges, setHasDirtyChanges]);
const handleDiscardConfirm = useCallback(() => {
setDiscardDialogOpen(false);
setHasDirtyChanges(false);
handleConfirm();
}, [handleConfirm, setHasDirtyChanges]);
return (
<div className="flex justify-end items-center">
@@ -242,8 +247,8 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
<DiscardChanges
open={discardDialogOpen}
onOpenChange={setDiscardDialogOpen}
onClose={handleDiscardChangesCancel}
onConfirm={handleConfirm}
onClose={() => setDiscardDialogOpen(false)}
onConfirm={handleDiscardConfirm}
/>
</div>
);

View File

@@ -1,126 +1,99 @@
import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input';
import { useQuery } from '@affine/admin/use-query';
import { getUserByEmailQuery } from '@affine/graphql';
import type { FeatureType } from '@affine/graphql';
import { ExportIcon, ImportIcon, PlusIcon } from '@blocksuite/icons/rc';
import type { Table } from '@tanstack/react-table';
import type { Dispatch, SetStateAction } from 'react';
import {
startTransition,
type ChangeEvent,
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { DiscardChanges } from '../../../components/shared/discard-changes';
import { FeatureFilterPopover } from '../../../components/shared/feature-filter-popover';
import { useDebouncedValue } from '../../../hooks/use-debounced-value';
import { useServerConfig } from '../../common';
import { useRightPanel } from '../../panel/context';
import type { UserType } from '../schema';
import { DiscardChanges } from './discard-changes';
import { ExportUsersDialog } from './export-users-dialog';
import { ImportUsersDialog } from './import-users';
import { CreateUserForm } from './user-form';
interface DataTableToolbarProps<TData> {
data: TData[];
usersCount: number;
selectedUsers: UserType[];
setDataTable: (data: TData[]) => void;
setRowCount: (rowCount: number) => void;
setMemoUsers: Dispatch<SetStateAction<UserType[]>>;
table?: Table<TData>;
}
const useSearch = () => {
const [value, setValue] = useState('');
const { data } = useQuery({
query: getUserByEmailQuery,
variables: { email: value },
});
const result = useMemo(() => data?.userByEmail, [data]);
return {
result,
query: setValue,
};
};
function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
keyword: string;
onKeywordChange: Dispatch<SetStateAction<string>>;
selectedFeatures: FeatureType[];
onFeaturesChange: Dispatch<SetStateAction<FeatureType[]>>;
}
export function DataTableToolbar<TData>({
data,
usersCount,
selectedUsers,
setDataTable,
setRowCount,
setMemoUsers,
table,
keyword,
onKeywordChange,
selectedFeatures,
onFeaturesChange,
}: DataTableToolbarProps<TData>) {
const [value, setValue] = useState('');
const [value, setValue] = useState(keyword);
const [dialogOpen, setDialogOpen] = useState(false);
const [exportDialogOpen, setExportDialogOpen] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const debouncedValue = useDebouncedValue(value, 1000);
const { setPanelContent, openPanel, closePanel, isOpen } = useRightPanel();
const { result, query } = useSearch();
const debouncedValue = useDebouncedValue(value, 500);
const {
setPanelContent,
openPanel,
closePanel,
isOpen,
hasDirtyChanges,
setHasDirtyChanges,
} = useRightPanel();
const serverConfig = useServerConfig();
const availableFeatures = serverConfig.availableUserFeatures ?? [];
const handleConfirm = useCallback(() => {
setPanelContent(<CreateUserForm onComplete={closePanel} />);
setPanelContent(
<CreateUserForm
onComplete={closePanel}
onDirtyChange={setHasDirtyChanges}
/>
);
if (dialogOpen) {
setDialogOpen(false);
}
if (!isOpen) {
openPanel();
}
}, [setPanelContent, closePanel, dialogOpen, isOpen, openPanel]);
useEffect(() => {
query(debouncedValue);
}, [debouncedValue, query]);
useEffect(() => {
startTransition(() => {
if (!debouncedValue) {
setDataTable(data);
setRowCount(usersCount);
} else if (result) {
setMemoUsers(prev => [...new Set([...prev, result])]);
setDataTable([result as TData]);
setRowCount(1);
} else {
setDataTable([]);
setRowCount(0);
}
});
}, [
data,
debouncedValue,
result,
setDataTable,
setMemoUsers,
setRowCount,
usersCount,
setPanelContent,
closePanel,
dialogOpen,
isOpen,
openPanel,
setHasDirtyChanges,
]);
const onValueChange = useCallback(
(e: { currentTarget: { value: SetStateAction<string> } }) => {
setValue(e.currentTarget.value);
useEffect(() => {
setValue(keyword);
}, [keyword]);
useEffect(() => {
onKeywordChange(debouncedValue.trim());
}, [debouncedValue, onKeywordChange]);
const onValueChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setValue(e.currentTarget.value);
}, []);
const handleFeatureToggle = useCallback(
(features: FeatureType[]) => {
onFeaturesChange(features);
},
[]
[onFeaturesChange]
);
const handleCancel = useCallback(() => {
@@ -128,11 +101,11 @@ export function DataTableToolbar<TData>({
}, []);
const handleOpenConfirm = useCallback(() => {
if (isOpen) {
if (hasDirtyChanges) {
return setDialogOpen(true);
}
return handleConfirm();
}, [handleConfirm, isOpen]);
}, [handleConfirm, hasDirtyChanges]);
const handleExportUsers = useCallback(() => {
if (!table) return;
@@ -192,9 +165,15 @@ export function DataTableToolbar<TData>({
</div>
<div className="flex items-center gap-y-2 flex-wrap justify-end gap-2">
<FeatureFilterPopover
selectedFeatures={selectedFeatures}
availableFeatures={availableFeatures}
onChange={handleFeatureToggle}
align="end"
/>
<div className="flex">
<Input
placeholder="Search Email"
placeholder="Search Email / UUID"
value={value}
onChange={onValueChange}
className="h-8 w-[150px] lg:w-[250px]"

View File

@@ -1,25 +1,13 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@affine/admin/components/ui/table';
import type { FeatureType } from '@affine/graphql';
import type {
ColumnDef,
ColumnFiltersState,
PaginationState,
} from '@tanstack/react-table';
import {
flexRender,
getCoreRowModel,
useReactTable,
RowSelectionState,
} from '@tanstack/react-table';
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
import { SharedDataTable } from '../../../components/shared/data-table';
import type { UserType } from '../schema';
import { DataTablePagination } from './data-table-pagination';
import { DataTableToolbar } from './data-table-toolbar';
interface DataTableProps<TData, TValue> {
@@ -28,7 +16,10 @@ interface DataTableProps<TData, TValue> {
pagination: PaginationState;
usersCount: number;
selectedUsers: UserType[];
setMemoUsers: Dispatch<SetStateAction<UserType[]>>;
keyword: string;
onKeywordChange: Dispatch<SetStateAction<string>>;
selectedFeatures: FeatureType[];
onFeaturesChange: Dispatch<SetStateAction<FeatureType[]>>;
onPaginationChange: Dispatch<
SetStateAction<{
pageIndex: number;
@@ -43,139 +34,46 @@ export function DataTable<TData extends { id: string }, TValue>({
pagination,
usersCount,
selectedUsers,
setMemoUsers,
keyword,
onKeywordChange,
selectedFeatures,
onFeaturesChange,
onPaginationChange,
}: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = useState({});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [tableData, setTableData] = useState(data);
const [rowCount, setRowCount] = useState(usersCount);
const table = useReactTable({
data: tableData,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: row => row.id,
manualPagination: true,
rowCount: rowCount,
enableFilters: true,
onPaginationChange: onPaginationChange,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onColumnFiltersChange: setColumnFilters,
state: {
pagination,
rowSelection,
columnFilters,
},
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
useEffect(() => {
setTableData(data);
}, [data]);
setRowSelection({});
}, [keyword, selectedFeatures]);
useEffect(() => {
setRowCount(usersCount);
}, [usersCount]);
const selection: Record<string, boolean> = {};
selectedUsers.forEach(user => {
selection[user.id] = true;
});
setRowSelection(selection);
}, [selectedUsers]);
return (
<div className="flex flex-col gap-4 py-5 px-6 h-full overflow-auto">
<DataTableToolbar
setDataTable={setTableData}
data={data}
usersCount={usersCount}
table={table}
selectedUsers={selectedUsers}
setRowCount={setRowCount}
setMemoUsers={setMemoUsers}
/>
<div className="rounded-md border h-full flex flex-col overflow-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id} className="flex items-center">
{headerGroup.headers.map(header => {
let columnClassName = '';
if (header.id === 'select') {
columnClassName = 'w-[40px] flex-shrink-0';
} else if (header.id === 'info') {
columnClassName = 'flex-1';
} else if (header.id === 'property') {
columnClassName = 'flex-1';
} else if (header.id === 'actions') {
columnClassName =
'w-[40px] flex-shrink-0 justify-center mr-6';
}
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={`${columnClassName} py-2 text-xs flex items-center h-9`}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
</Table>
<div className="overflow-auto flex-1">
<Table>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map(row => (
<TableRow key={row.id} className="flex items-center">
{row.getVisibleCells().map(cell => {
let columnClassName = '';
if (cell.column.id === 'select') {
columnClassName = 'w-[40px] flex-shrink-0';
} else if (cell.column.id === 'info') {
columnClassName = 'flex-1';
} else if (cell.column.id === 'property') {
columnClassName = 'flex-1';
} else if (cell.column.id === 'actions') {
columnClassName =
'w-[40px] flex-shrink-0 justify-center mr-6';
}
return (
<TableCell
key={cell.id}
className={`${columnClassName} flex items-center`}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} />
</div>
<SharedDataTable
columns={columns}
data={data}
totalCount={usersCount}
pagination={pagination}
onPaginationChange={onPaginationChange}
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
resetFiltersDeps={[keyword, selectedFeatures]}
renderToolbar={table => (
<DataTableToolbar
table={table}
selectedUsers={selectedUsers}
keyword={keyword}
onKeywordChange={onKeywordChange}
selectedFeatures={selectedFeatures}
onFeaturesChange={onFeaturesChange}
/>
)}
/>
);
}

View File

@@ -1,14 +1,4 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
import { Input } from '@affine/admin/components/ui/input';
import { useCallback, useEffect, useState } from 'react';
import { TypeConfirmDialog } from '../../../components/shared/type-confirm-dialog';
export const DeleteAccountDialog = ({
email,
@@ -23,55 +13,23 @@ export const DeleteAccountDialog = ({
onDelete: () => void;
onOpenChange: (open: boolean) => void;
}) => {
const [input, setInput] = useState('');
const handleInput = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setInput(event.target.value);
},
[setInput]
);
useEffect(() => {
if (!open) {
setInput('');
}
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>Delete Account ?</DialogTitle>
<DialogDescription>
<span className="font-bold">{email}</span> will be permanently
deleted. This operation is irreversible. Please proceed with
caution.
</DialogDescription>
</DialogHeader>
<Input
type="text"
value={input}
onChange={handleInput}
placeholder="Please type email to confirm"
className="placeholder:opacity-50 mt-4 h-9"
/>
<DialogFooter className="mt-6">
<div className="flex justify-end gap-2 items-center w-full">
<Button type="button" variant="outline" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
type="button"
onClick={onDelete}
size="sm"
variant="destructive"
disabled={input !== email}
>
Delete
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<TypeConfirmDialog
open={open}
onOpenChange={onOpenChange}
title="Delete Account ?"
description={
<>
<span className="font-bold">{email}</span> will be permanently
deleted. This operation is irreversible. Please proceed with caution.
</>
}
targetText={email}
inputPlaceholder="Please type email to confirm"
confirmText="Delete"
confirmButtonVariant="destructive"
onConfirm={onDelete}
onClose={onClose}
/>
);
};

View File

@@ -1,14 +1,4 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
import { Input } from '@affine/admin/components/ui/input';
import { useCallback, useEffect, useState } from 'react';
import { TypeConfirmDialog } from '../../../components/shared/type-confirm-dialog';
export const DisableAccountDialog = ({
email,
@@ -23,55 +13,24 @@ export const DisableAccountDialog = ({
onDisable: () => void;
onOpenChange: (open: boolean) => void;
}) => {
const [input, setInput] = useState('');
const handleInput = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setInput(event.target.value);
},
[setInput]
);
useEffect(() => {
if (!open) {
setInput('');
}
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>Disable Account ?</DialogTitle>
<DialogDescription>
The data associated with <span className="font-bold">{email}</span>{' '}
will be deleted and cannot be used for logging in. This operation is
irreversible. Please proceed with caution.
</DialogDescription>
</DialogHeader>
<Input
type="text"
value={input}
onChange={handleInput}
placeholder="Please type email to confirm"
className="placeholder:opacity-50 mt-4 h-9"
/>
<DialogFooter className="mt-6">
<div className="flex justify-end gap-2 items-center w-full">
<Button type="button" variant="outline" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
type="button"
onClick={onDisable}
disabled={input !== email}
size="sm"
variant="destructive"
>
Disable
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<TypeConfirmDialog
open={open}
onOpenChange={onOpenChange}
title="Disable Account ?"
description={
<>
The data associated with <span className="font-bold">{email}</span>{' '}
will be deleted and cannot be used for logging in. This operation is
irreversible. Please proceed with caution.
</>
}
targetText={email}
inputPlaceholder="Please type email to confirm"
confirmText="Disable"
confirmButtonVariant="destructive"
onConfirm={onDisable}
onClose={onClose}
/>
);
};

View File

@@ -1,44 +0,0 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
export const DiscardChanges = ({
open,
onClose,
onConfirm,
onOpenChange,
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[460px]">
<DialogHeader>
<DialogTitle>Discard Changes</DialogTitle>
<DialogDescription className="leading-6 text-[15px]">
Changes to this user will not be saved.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-6">
<div className="flex justify-end gap-2 items-center w-full">
<Button type="button" onClick={onClose} variant="outline">
<span>Cancel</span>
</Button>
<Button type="button" onClick={onConfirm} variant="destructive">
<span>Discard</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,12 +1,4 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
import { ConfirmDialog } from '../../../components/shared/confirm-dialog';
export const EnableAccountDialog = ({
open,
@@ -22,27 +14,21 @@ export const EnableAccountDialog = ({
onOpenChange: (open: boolean) => void;
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[460px]">
<DialogHeader>
<DialogTitle className="leading-7">Enable Account</DialogTitle>
<DialogDescription className="leading-6">
Are you sure you want to enable the account? After enabling the
account, the <span className="font-bold">{email}</span> email can be
used to log in.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-6">
<div className="flex justify-end gap-2 items-center w-full">
<Button type="button" onClick={onClose} variant="outline">
<span>Cancel</span>
</Button>
<Button type="button" onClick={onConfirm} variant="default">
<span>Enable</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title="Enable Account"
description={
<>
Are you sure you want to enable the account? After enabling the
account, the <span className="font-bold">{email}</span> email can be
used to log in.
</>
}
confirmText="Enable"
confirmButtonVariant="default"
onConfirm={onConfirm}
onClose={onClose}
/>
);
};

View File

@@ -2,7 +2,6 @@ import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input';
import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import { Switch } from '@affine/admin/components/ui/switch';
import type { FeatureType } from '@affine/graphql';
import { cssVarV2 } from '@toeverything/theme/v2';
import { ChevronRightIcon } from 'lucide-react';
@@ -10,6 +9,7 @@ import type { ChangeEvent } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { FeatureToggleList } from '../../../components/shared/feature-toggle-list';
import { useServerConfig } from '../../common';
import { RightPanelHeader } from '../../header';
import type { UserInput, UserType } from '../schema';
@@ -24,6 +24,7 @@ type UserFormProps = {
onValidate: (user: Partial<UserInput>) => boolean;
actions?: React.ReactNode;
showOption?: boolean;
onDirtyChange?: (dirty: boolean) => void;
};
function UserForm({
@@ -34,6 +35,7 @@ function UserForm({
onValidate,
actions,
showOption,
onDirtyChange,
}: UserFormProps) {
const serverConfig = useServerConfig();
@@ -67,6 +69,24 @@ function UserForm({
return onValidate(changes);
}, [onValidate, changes]);
useEffect(() => {
const normalize = (value: Partial<UserInput>) => ({
name: value.name ?? '',
email: value.email ?? '',
password: value.password ?? '',
features: [...(value.features ?? [])].sort(),
});
const current = normalize(changes);
const baseline = normalize(defaultUser);
const dirty =
(current.name !== baseline.name ||
current.email !== baseline.email ||
current.password !== baseline.password ||
current.features.join(',') !== baseline.features.join(',')) &&
!!onDirtyChange;
onDirtyChange?.(dirty);
}, [changes, defaultUser, onDirtyChange]);
const handleConfirm = useCallback(() => {
if (!canSave) {
return;
@@ -77,14 +97,9 @@ function UserForm({
setChanges(defaultUser);
}, [canSave, changes, defaultUser, onConfirm]);
const onFeatureChanged = useCallback(
(feature: FeatureType, checked: boolean) => {
setField('features', (features = []) => {
if (checked) {
return [...features, feature];
}
return features.filter(f => f !== feature);
});
const handleFeaturesChange = useCallback(
(features: FeatureType[]) => {
setField('features', features);
},
[setField]
);
@@ -138,52 +153,21 @@ function UserForm({
)}
</div>
<div className="border rounded-md">
{serverConfig.availableUserFeatures.map((feature, i) => (
<div key={feature}>
<ToggleItem
name={feature}
checked={changes.features?.includes(feature) ?? false}
onChange={onFeatureChanged}
/>
{i < serverConfig.availableUserFeatures.length - 1 && (
<Separator />
)}
</div>
))}
</div>
<FeatureToggleList
className="border rounded-md"
features={serverConfig.availableUserFeatures}
selected={changes.features ?? []}
onChange={handleFeaturesChange}
control="switch"
controlPosition="right"
showSeparators={true}
/>
{actions}
</div>
</div>
);
}
function ToggleItem({
name,
checked,
onChange,
}: {
name: FeatureType;
checked: boolean;
onChange: (name: FeatureType, value: boolean) => void;
}) {
const onToggle = useCallback(
(checked: boolean) => {
onChange(name, checked);
},
[name, onChange]
);
return (
<Label className="flex items-center justify-between p-3 text-[15px] gap-2 font-medium leading-6 overflow-hidden">
<span className="overflow-hidden text-ellipsis" title={name}>
{name}
</span>
<Switch checked={checked} onCheckedChange={onToggle} />
</Label>
);
}
function InputItem({
label,
field,
@@ -241,7 +225,13 @@ const validateUpdateUser = (user: Partial<UserInput>) => {
return !!user.name || !!user.email;
};
export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
export function CreateUserForm({
onComplete,
onDirtyChange,
}: {
onComplete: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const { create, creating } = useCreateUser();
const serverConfig = useServerConfig();
const passwordLimits = serverConfig.credentialsRequirement.password;
@@ -278,6 +268,7 @@ export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
onConfirm={handleCreateUser}
onValidate={validateCreateUser}
showOption={true}
onDirtyChange={onDirtyChange}
/>
);
}
@@ -287,11 +278,13 @@ export function UpdateUserForm({
onResetPassword,
onDeleteAccount,
onComplete,
onDirtyChange,
}: {
user: UserType;
onResetPassword: () => void;
onDeleteAccount: () => void;
onComplete: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const { update, updating } = useUpdateUser();
@@ -321,6 +314,7 @@ export function UpdateUserForm({
onClose={onComplete}
onConfirm={onUpdateUser}
onValidate={validateUpdateUser}
onDirtyChange={onDirtyChange}
actions={
<>
<Button

View File

@@ -1,3 +1,4 @@
import { FeatureType } from '@affine/graphql';
import { useEffect, useMemo, useState } from 'react';
import { Header } from '../header';
@@ -7,7 +8,12 @@ import type { UserType } from './schema';
import { useUserList } from './use-user-list';
export function AccountPage() {
const { users, pagination, setPagination, usersCount } = useUserList();
const [keyword, setKeyword] = useState('');
const [featureFilters, setFeatureFilters] = useState<FeatureType[]>([]);
const { users, pagination, setPagination, usersCount } = useUserList({
keyword,
features: featureFilters,
});
// Remember the user temporarily, because userList is paginated on the server side,can't get all users at once.
const [memoUsers, setMemoUsers] = useState<UserType[]>([]);
@@ -17,9 +23,20 @@ export function AccountPage() {
const columns = useColumns({ setSelectedUserIds });
useEffect(() => {
setMemoUsers(prev => [...new Set([...prev, ...users])]);
setMemoUsers(prev => {
const map = new Map(prev.map(user => [user.id, user]));
users.forEach(user => {
map.set(user.id, user);
});
return Array.from(map.values());
});
}, [users]);
useEffect(() => {
setMemoUsers([]);
setSelectedUserIds(new Set<string>());
}, [featureFilters, keyword]);
const selectedUsers = useMemo(() => {
return memoUsers.filter(user => selectedUserIds.has(user.id));
}, [selectedUserIds, memoUsers]);
@@ -35,7 +52,10 @@ export function AccountPage() {
usersCount={usersCount}
onPaginationChange={setPagination}
selectedUsers={selectedUsers}
setMemoUsers={setMemoUsers}
keyword={keyword}
onKeywordChange={setKeyword}
selectedFeatures={featureFilters}
onFeaturesChange={setFeatureFilters}
/>
</div>
);

View File

@@ -1,23 +1,46 @@
import { useQuery } from '@affine/admin/use-query';
import { listUsersQuery } from '@affine/graphql';
import { useState } from 'react';
import { FeatureType, listUsersQuery } from '@affine/graphql';
import { useEffect, useMemo, useState } from 'react';
export const useUserList = () => {
export const useUserList = (filter?: {
keyword?: string;
features?: FeatureType[];
}) => {
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
});
const filterKey = useMemo(
() =>
`${filter?.keyword ?? ''}-${[...(filter?.features ?? [])]
.sort()
.join(',')}`,
[filter?.features, filter?.keyword]
);
useEffect(() => {
setPagination(prev => ({ ...prev, pageIndex: 0 }));
}, [filterKey]);
const {
data: { users, usersCount },
} = useQuery({
query: listUsersQuery,
variables: {
filter: {
first: pagination.pageSize,
skip: pagination.pageIndex * pagination.pageSize,
} = useQuery(
{
query: listUsersQuery,
variables: {
filter: {
first: pagination.pageSize,
skip: pagination.pageIndex * pagination.pageSize,
keyword: filter?.keyword || undefined,
features:
filter?.features && filter.features.length > 0
? filter.features
: undefined,
},
},
},
});
{ keepPreviousData: true }
);
return {
users,

View File

@@ -1,44 +0,0 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
export const DiscardChanges = ({
open,
onClose,
onConfirm,
onOpenChange,
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[460px]">
<DialogHeader>
<DialogTitle className="leading-7">Discard Changes</DialogTitle>
<DialogDescription className="leading-6">
Changes to this prompt will not be saved.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-6">
<div className="flex justify-end gap-2 items-center w-full">
<Button type="button" onClick={onClose} variant="outline">
<span>Cancel</span>
</Button>
<Button type="button" onClick={onConfirm} variant="destructive">
<span>Discard</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -3,8 +3,8 @@ import { Separator } from '@affine/admin/components/ui/separator';
import type { CopilotPromptMessageRole } from '@affine/graphql';
import { useCallback, useState } from 'react';
import { DiscardChanges } from '../../components/shared/discard-changes';
import { useRightPanel } from '../panel/context';
import { DiscardChanges } from './discard-changes';
import { EditPrompt } from './edit-prompt';
import { usePrompt } from './use-prompt';

View File

@@ -8,8 +8,9 @@ import { cn } from '@affine/admin/utils';
import { cssVarV2 } from '@toeverything/theme/v2';
import { AlignJustifyIcon } from 'lucide-react';
import type { PropsWithChildren, ReactNode, RefObject } from 'react';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { ImperativePanelHandle } from 'react-resizable-panels';
import { useLocation } from 'react-router-dom';
import { Button } from '../components/ui/button';
import {
@@ -31,12 +32,16 @@ import {
} from './panel/context';
export function Layout({ children }: PropsWithChildren) {
const [rightPanelContent, setRightPanelContent] = useState<ReactNode>(null);
const [rightPanelContent, setRightPanelContentState] =
useState<ReactNode>(null);
const [leftPanelContent, setLeftPanelContent] = useState<ReactNode>(null);
const [leftOpen, setLeftOpen] = useState(false);
const [rightOpen, setRightOpen] = useState(false);
const [rightPanelHasDirtyChanges, setRightPanelHasDirtyChanges] =
useState(false);
const rightPanelRef = useRef<ImperativePanelHandle>(null);
const leftPanelRef = useRef<ImperativePanelHandle>(null);
const location = useLocation();
const [activeTab, setActiveTab] = useState('');
const [activeSubTab, setActiveSubTab] = useState('server');
@@ -88,6 +93,14 @@ export function Layout({ children }: PropsWithChildren) {
setRightOpen(false);
}, [rightPanelRef]);
const handleSetRightPanelContent = useCallback(
(content: ReactNode) => {
setRightPanelHasDirtyChanges(false);
setRightPanelContentState(content);
},
[setRightPanelContentState, setRightPanelHasDirtyChanges]
);
const openRightPanel = useCallback(() => {
handleRightExpand();
rightPanelRef.current?.expand();
@@ -98,7 +111,8 @@ export function Layout({ children }: PropsWithChildren) {
handleRightCollapse();
rightPanelRef.current?.collapse();
setRightOpen(false);
}, [handleRightCollapse]);
setRightPanelHasDirtyChanges(false);
}, [handleRightCollapse, setRightPanelHasDirtyChanges]);
const toggleRightPanel = useCallback(
() =>
@@ -108,6 +122,12 @@ export function Layout({ children }: PropsWithChildren) {
[closeRightPanel, openRightPanel]
);
// auto close right panel when route changes
useEffect(() => {
handleSetRightPanelContent(null);
closeRightPanel();
}, [location.pathname, closeRightPanel, handleSetRightPanelContent]);
return (
<PanelContext.Provider
value={{
@@ -122,10 +142,12 @@ export function Layout({ children }: PropsWithChildren) {
rightPanel: {
isOpen: rightOpen,
panelContent: rightPanelContent,
setPanelContent: setRightPanelContent,
setPanelContent: handleSetRightPanelContent,
togglePanel: toggleRightPanel,
openPanel: openRightPanel,
closePanel: closeRightPanel,
hasDirtyChanges: rightPanelHasDirtyChanges,
setHasDirtyChanges: setRightPanelHasDirtyChanges,
},
}}
>
@@ -140,7 +162,7 @@ export function Layout({ children }: PropsWithChildren) {
}}
>
<TooltipProvider delayDuration={0}>
<div className="flex">
<div className="flex h-screen w-full overflow-hidden">
<ResizablePanelGroup direction="horizontal">
<LeftPanel
panelRef={leftPanelRef as RefObject<ImperativePanelHandle>}
@@ -278,7 +300,7 @@ export const RightPanel = ({
</SheetDescription>
</SheetHeader>
<SheetContent side="right" className="p-0" withoutCloseButton>
{panelContent}
<div className="h-full overflow-y-auto">{panelContent}</div>
</SheetContent>
</Sheet>
);
@@ -297,7 +319,7 @@ export const RightPanel = ({
onCollapse={onCollapse}
className="border-l max-w-96"
>
{panelContent}
<div className="h-full overflow-y-auto">{panelContent}</div>
</ResizablePanel>
);
};

View File

@@ -2,6 +2,7 @@ import { buttonVariants } from '@affine/admin/components/ui/button';
import { cn } from '@affine/admin/utils';
import { AccountIcon, SelfhostIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import { LayoutDashboardIcon } from 'lucide-react';
import { NavLink } from 'react-router-dom';
import { ServerVersion } from './server-version';
@@ -80,7 +81,7 @@ export function Nav({ isCollapsed = false }: NavProps) {
>
<nav
className={cn(
'flex flex-1 flex-col gap-1 px-2 flex-grow overflow-hidden',
'flex flex-1 flex-col gap-1 px-2 flex-grow overflow-y-auto overflow-x-hidden',
isCollapsed && 'items-center px-0 gap-1 overflow-visible'
)}
>
@@ -90,6 +91,12 @@ export function Nav({ isCollapsed = false }: NavProps) {
label="Accounts"
isCollapsed={isCollapsed}
/>
<NavItem
to="/admin/workspaces"
icon={<LayoutDashboardIcon size={18} />}
label="Workspaces"
isCollapsed={isCollapsed}
/>
{/* <NavItem
to="/admin/ai"
icon={<AiOutlineIcon fontSize={20} />}

View File

@@ -1,7 +1,9 @@
import {
createContext,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
useContext,
} from 'react';
import type { ImperativePanelHandle } from 'react-resizable-panels';
@@ -15,9 +17,14 @@ export type SinglePanelContextType = {
closePanel: () => void;
};
export type RightPanelContextType = SinglePanelContextType & {
hasDirtyChanges: boolean;
setHasDirtyChanges: Dispatch<SetStateAction<boolean>>;
};
export interface PanelContextType {
leftPanel: SinglePanelContextType;
rightPanel: SinglePanelContextType;
rightPanel: RightPanelContextType;
}
export type ResizablePanelProps = {

View File

@@ -0,0 +1,172 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@affine/admin/components/ui/avatar';
import { AccountIcon, LinkIcon } from '@blocksuite/icons/rc';
import type { ColumnDef } from '@tanstack/react-table';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useMemo } from 'react';
import type { WorkspaceListItem } from '../schema';
import { formatBytes } from '../utils';
import { DataTableRowActions } from './data-table-row-actions';
export const useColumns = () => {
const columns: ColumnDef<WorkspaceListItem>[] = useMemo(() => {
return [
{
accessorKey: 'workspace',
header: () => <div className="text-xs font-medium">Workspace</div>,
cell: ({ row }) => {
const workspace = row.original;
return (
<div className="flex flex-col gap-1 max-w-[40vw] min-w-0 overflow-hidden">
<div className="flex items-center gap-2 text-sm font-medium overflow-hidden">
<span className="truncate">
{workspace.name || workspace.id}
</span>
{workspace.public ? (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded border"
style={{
backgroundColor: cssVarV2('chip/label/white'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
>
<LinkIcon fontSize={14} />
Public
</span>
) : null}
</div>
<div
className="text-xs font-mono truncate w-full"
style={{ color: cssVarV2('text/secondary') }}
>
{workspace.id}
</div>
<div className="flex flex-wrap gap-2 text-[11px]">
{workspace.features.length ? (
workspace.features.map(feature => (
<span
key={feature}
className="px-2 py-0.5 rounded border"
style={{
backgroundColor: cssVarV2('chip/label/white'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
>
{feature}
</span>
))
) : (
<span style={{ color: cssVarV2('text/secondary') }}>
No features
</span>
)}
</div>
</div>
);
},
},
{
accessorKey: 'owner',
header: () => <div className="text-xs font-medium">Owner</div>,
cell: ({ row }) => {
const owner = row.original.owner;
if (!owner) {
return (
<div
className="text-xs"
style={{ color: cssVarV2('text/secondary') }}
>
Unknown
</div>
);
}
return (
<div className="flex items-center gap-3 min-w-[180px] min-w-0">
<Avatar className="w-9 h-9">
<AvatarImage src={owner.avatarUrl ?? undefined} />
<AvatarFallback>
<AccountIcon fontSize={16} />
</AvatarFallback>
</Avatar>
<div className="flex flex-col overflow-hidden min-w-0">
<div className="text-sm font-medium truncate">{owner.name}</div>
<div
className="text-xs truncate"
style={{ color: cssVarV2('text/secondary') }}
>
{owner.email}
</div>
</div>
</div>
);
},
},
{
accessorKey: 'usage',
header: () => <div className="text-xs font-medium">Usage</div>,
cell: ({ row }) => {
const ws = row.original;
return (
<div className="flex flex-col gap-1 text-xs">
<div className="flex gap-3">
<span>Snapshot {formatBytes(ws.snapshotSize)}</span>
<span style={{ color: cssVarV2('text/secondary') }}>
({ws.snapshotCount})
</span>
</div>
<div className="flex gap-3">
<span>Blobs {formatBytes(ws.blobSize)}</span>
<span style={{ color: cssVarV2('text/secondary') }}>
({ws.blobCount})
</span>
</div>
</div>
);
},
},
{
accessorKey: 'members',
header: () => <div className="text-xs font-medium">Members</div>,
cell: ({ row }) => {
const ws = row.original;
return (
<div className="flex flex-col text-xs gap-1">
<div className="flex gap-2">
<span className="font-medium">{ws.memberCount}</span>
<span style={{ color: cssVarV2('text/secondary') }}>
members
</span>
</div>
<div className="flex gap-2">
<span className="font-medium">{ws.publicPageCount}</span>
<span style={{ color: cssVarV2('text/secondary') }}>
shared pages
</span>
</div>
</div>
);
},
},
{
id: 'actions',
meta: {
className: 'w-[80px] justify-end',
},
header: () => (
<div className="text-xs font-medium text-right">Actions</div>
),
cell: ({ row }) => (
<div className="flex justify-end w-full">
<DataTableRowActions workspace={row.original} />
</div>
),
},
];
}, []);
return columns;
};

View File

@@ -0,0 +1,76 @@
import { Button } from '@affine/admin/components/ui/button';
import { EditIcon } from '@blocksuite/icons/rc';
import { useCallback, useState } from 'react';
import { DiscardChanges } from '../../../components/shared/discard-changes';
import { useRightPanel } from '../../panel/context';
import type { WorkspaceListItem } from '../schema';
import { WorkspacePanel } from './workspace-panel';
export function DataTableRowActions({
workspace,
}: {
workspace: WorkspaceListItem;
}) {
const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
const {
setPanelContent,
openPanel,
isOpen,
closePanel,
hasDirtyChanges,
setHasDirtyChanges,
} = useRightPanel();
const handleConfirm = useCallback(() => {
setHasDirtyChanges(false);
setPanelContent(
<WorkspacePanel workspaceId={workspace.id} onClose={closePanel} />
);
if (!isOpen) {
openPanel();
}
}, [
closePanel,
isOpen,
openPanel,
setHasDirtyChanges,
setPanelContent,
workspace.id,
]);
const handleEdit = useCallback(() => {
if (hasDirtyChanges) {
setDiscardDialogOpen(true);
return;
}
handleConfirm();
}, [handleConfirm, hasDirtyChanges]);
const handleDiscardConfirm = useCallback(() => {
setDiscardDialogOpen(false);
setHasDirtyChanges(false);
handleConfirm();
}, [handleConfirm, setHasDirtyChanges]);
return (
<>
<Button
variant="ghost"
size="sm"
className="px-2 h-8 flex items-center gap-2"
onClick={handleEdit}
>
<EditIcon fontSize={18} />
<span>Edit</span>
</Button>
<DiscardChanges
open={discardDialogOpen}
onOpenChange={setDiscardDialogOpen}
onClose={() => setDiscardDialogOpen(false)}
onConfirm={handleDiscardConfirm}
description="Changes to this workspace will not be saved."
/>
</>
);
}

View File

@@ -0,0 +1,121 @@
import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input';
import { AdminWorkspaceSort, FeatureType } from '@affine/graphql';
import type { Table } from '@tanstack/react-table';
import {
type ChangeEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { FeatureFilterPopover } from '../../../components/shared/feature-filter-popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '../../../components/ui/popover';
import { useDebouncedValue } from '../../../hooks/use-debounced-value';
import { useServerConfig } from '../../common';
interface DataTableToolbarProps<TData> {
table?: Table<TData>;
keyword: string;
onKeywordChange: (keyword: string) => void;
selectedFeatures: FeatureType[];
onFeaturesChange: (features: FeatureType[]) => void;
sort: AdminWorkspaceSort | undefined;
onSortChange: (sort: AdminWorkspaceSort | undefined) => void;
}
const sortOptions: { value: AdminWorkspaceSort; label: string }[] = [
{ value: AdminWorkspaceSort.SnapshotSize, label: 'Snapshot size' },
{ value: AdminWorkspaceSort.BlobCount, label: 'Blob count' },
{ value: AdminWorkspaceSort.BlobSize, label: 'Blob size' },
{ value: AdminWorkspaceSort.CreatedAt, label: 'Created time' },
];
export function DataTableToolbar<TData>({
keyword,
onKeywordChange,
selectedFeatures,
onFeaturesChange,
sort,
onSortChange,
}: DataTableToolbarProps<TData>) {
const [value, setValue] = useState(keyword);
const debouncedValue = useDebouncedValue(value, 400);
const serverConfig = useServerConfig();
const availableFeatures = serverConfig.availableWorkspaceFeatures ?? [];
useEffect(() => {
setValue(keyword);
}, [keyword]);
useEffect(() => {
onKeywordChange(debouncedValue.trim());
}, [debouncedValue, onKeywordChange]);
const onValueChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setValue(e.currentTarget.value);
}, []);
const handleSortChange = useCallback(
(value: AdminWorkspaceSort) => {
onSortChange(value);
},
[onSortChange]
);
const selectedSortLabel = useMemo(
() =>
sortOptions.find(option => option.value === sort)?.label ??
'Created time',
[sort]
);
return (
<div className="flex items-center justify-between gap-y-2 gap-x-4 flex-wrap">
<FeatureFilterPopover
selectedFeatures={selectedFeatures}
availableFeatures={availableFeatures}
onChange={onFeaturesChange}
align="start"
/>
<div className="flex items-center gap-y-2 flex-wrap justify-end gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 px-2 lg:px-3">
Sort: {selectedSortLabel}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[220px] p-2">
<div className="flex flex-col gap-1">
{sortOptions.map(option => (
<Button
key={option.value}
variant="ghost"
className="justify-start"
size="sm"
onClick={() => handleSortChange(option.value)}
>
{option.label}
</Button>
))}
</div>
</PopoverContent>
</Popover>
<div className="flex">
<Input
placeholder="Search Workspace / Owner"
value={value}
onChange={onValueChange}
className="h-8 w-[150px] lg:w-[250px]"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import type { AdminWorkspaceSort, FeatureType } from '@affine/graphql';
import type { ColumnDef, PaginationState } from '@tanstack/react-table';
import type { Dispatch, SetStateAction } from 'react';
import { SharedDataTable } from '../../../components/shared/data-table';
import { DataTableToolbar } from './data-table-toolbar';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pagination: PaginationState;
workspacesCount: number;
keyword: string;
onKeywordChange: (value: string) => void;
selectedFeatures: FeatureType[];
onFeaturesChange: (features: FeatureType[]) => void;
sort: AdminWorkspaceSort | undefined;
onSortChange: (sort: AdminWorkspaceSort | undefined) => void;
onPaginationChange: Dispatch<
SetStateAction<{
pageIndex: number;
pageSize: number;
}>
>;
}
export function DataTable<TData extends { id: string }, TValue>({
columns,
data,
pagination,
workspacesCount,
keyword,
onKeywordChange,
selectedFeatures,
onFeaturesChange,
sort,
onSortChange,
onPaginationChange,
}: DataTableProps<TData, TValue>) {
return (
<SharedDataTable
columns={columns}
data={data}
totalCount={workspacesCount}
pagination={pagination}
onPaginationChange={onPaginationChange}
resetFiltersDeps={[keyword, selectedFeatures, sort]}
renderToolbar={table => (
<DataTableToolbar
table={table}
keyword={keyword}
onKeywordChange={onKeywordChange}
selectedFeatures={selectedFeatures}
onFeaturesChange={onFeaturesChange}
sort={sort}
onSortChange={onSortChange}
/>
)}
/>
);
}

View File

@@ -0,0 +1,357 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@affine/admin/components/ui/avatar';
import { Input } from '@affine/admin/components/ui/input';
import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import { Switch } from '@affine/admin/components/ui/switch';
import {
adminUpdateWorkspaceMutation,
adminWorkspaceQuery,
adminWorkspacesQuery,
FeatureType,
} from '@affine/graphql';
import { AccountIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { FeatureToggleList } from '../../../components/shared/feature-toggle-list';
import { useMutateQueryResource, useMutation } from '../../../use-mutation';
import { useQuery } from '../../../use-query';
import { useServerConfig } from '../../common';
import { RightPanelHeader } from '../../header';
import { useRightPanel } from '../../panel/context';
import type { WorkspaceDetail } from '../schema';
import { formatBytes } from '../utils';
export function WorkspacePanel({
workspaceId,
onClose,
}: {
workspaceId: string;
onClose: () => void;
}) {
const { data } = useQuery({
query: adminWorkspaceQuery,
variables: {
id: workspaceId,
memberSkip: 0,
memberTake: 20,
},
});
const workspace = data.adminWorkspace;
if (!workspace) {
return (
<div className="flex flex-col h-full">
<RightPanelHeader
title="Workspace"
handleClose={onClose}
handleConfirm={onClose}
canSave={false}
/>
<div
className="p-6 text-sm"
style={{ color: cssVarV2('text/secondary') }}
>
Workspace not found.
</div>
</div>
);
}
return <WorkspacePanelContent workspace={workspace} onClose={onClose} />;
}
function WorkspacePanelContent({
workspace,
onClose,
}: {
workspace: WorkspaceDetail;
onClose: () => void;
}) {
const serverConfig = useServerConfig();
const { setHasDirtyChanges } = useRightPanel();
const revalidate = useMutateQueryResource();
const { trigger: updateWorkspace, isMutating } = useMutation({
mutation: adminUpdateWorkspaceMutation,
});
const normalizedWorkspace = useMemo(
() => ({
features: [...workspace.features],
flags: {
public: workspace.public,
enableAi: workspace.enableAi,
enableUrlPreview: workspace.enableUrlPreview,
enableDocEmbedding: workspace.enableDocEmbedding,
name: workspace.name ?? '',
},
}),
[workspace]
);
const [featureSelection, setFeatureSelection] = useState<FeatureType[]>(
normalizedWorkspace.features
);
const [flags, setFlags] = useState(normalizedWorkspace.flags);
const [baseline, setBaseline] = useState(normalizedWorkspace);
useEffect(() => {
setFeatureSelection(normalizedWorkspace.features);
setFlags(normalizedWorkspace.flags);
setBaseline(normalizedWorkspace);
}, [normalizedWorkspace]);
const hasChanges = useMemo(() => {
return (
flags.public !== baseline.flags.public ||
flags.enableAi !== baseline.flags.enableAi ||
flags.enableUrlPreview !== baseline.flags.enableUrlPreview ||
flags.enableDocEmbedding !== baseline.flags.enableDocEmbedding ||
flags.name !== baseline.flags.name ||
featureSelection.length !== baseline.features.length ||
featureSelection.some(f => !baseline.features.includes(f))
);
}, [baseline, featureSelection, flags]);
useEffect(() => {
setHasDirtyChanges(hasChanges);
}, [hasChanges, setHasDirtyChanges]);
const handleFeaturesChange = useCallback((features: FeatureType[]) => {
setFeatureSelection(features);
}, []);
const handleSave = useCallback(() => {
const update = async () => {
try {
await updateWorkspace({
input: {
id: workspace.id,
public: flags.public,
enableAi: flags.enableAi,
enableUrlPreview: flags.enableUrlPreview,
enableDocEmbedding: flags.enableDocEmbedding,
name: flags.name || null,
features: featureSelection,
},
});
await Promise.all([
revalidate(adminWorkspacesQuery),
revalidate(adminWorkspaceQuery, vars => vars?.id === workspace.id),
]);
toast.success('Workspace updated successfully');
setBaseline({
flags: { ...flags },
features: [...featureSelection],
});
setHasDirtyChanges(false);
onClose();
} catch (e) {
toast.error(`Failed to update workspace: ${(e as Error).message}`);
}
};
update().catch(() => {});
}, [
featureSelection,
flags,
onClose,
revalidate,
setBaseline,
setHasDirtyChanges,
updateWorkspace,
workspace.id,
]);
const memberList = workspace.members ?? [];
return (
<div className="flex flex-col h-full">
<RightPanelHeader
title="Update Workspace"
handleClose={onClose}
handleConfirm={handleSave}
canSave={hasChanges && !isMutating}
/>
<div className="p-4 flex flex-col gap-4 overflow-y-auto">
<div className="border rounded-md p-3 space-y-2">
<div
className="text-xs"
style={{ color: cssVarV2('text/secondary') }}
>
Workspace ID
</div>
<div className="text-sm font-mono break-all">{workspace.id}</div>
<div className="flex flex-col gap-1">
<Label
className="text-xs"
style={{ color: cssVarV2('text/secondary') }}
>
Name
</Label>
<Input
value={flags.name}
onChange={e =>
setFlags(prev => ({ ...prev, name: e.target.value }))
}
placeholder="Workspace name"
/>
</div>
</div>
<div className="border rounded-md">
<FlagItem
label="Public"
description="Allow public access to workspace pages"
checked={flags.public}
onCheckedChange={value =>
setFlags(prev => ({ ...prev, public: value }))
}
/>
<Separator />
<FlagItem
label="Enable AI"
description="Allow AI features in this workspace"
checked={flags.enableAi}
onCheckedChange={value =>
setFlags(prev => ({ ...prev, enableAi: value }))
}
/>
<Separator />
<FlagItem
label="Enable URL Preview"
description="Allow URL previews in shared pages"
checked={flags.enableUrlPreview}
onCheckedChange={value =>
setFlags(prev => ({ ...prev, enableUrlPreview: value }))
}
/>
<Separator />
<FlagItem
label="Enable Doc Embedding"
description="Allow document embedding for search"
checked={flags.enableDocEmbedding}
onCheckedChange={value =>
setFlags(prev => ({ ...prev, enableDocEmbedding: value }))
}
/>
</div>
<div className="border rounded-md p-3 space-y-3">
<div className="text-sm font-medium">Features</div>
<FeatureToggleList
features={serverConfig.availableWorkspaceFeatures ?? []}
selected={featureSelection}
onChange={handleFeaturesChange}
className="grid grid-cols-1 gap-2"
control="checkbox"
controlPosition="left"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<MetricCard
label="Snapshot Size"
value={formatBytes(workspace.snapshotSize)}
/>
<MetricCard
label="Snapshot Count"
value={`${workspace.snapshotCount}`}
/>
<MetricCard
label="Blob Size"
value={formatBytes(workspace.blobSize)}
/>
<MetricCard label="Blob Count" value={`${workspace.blobCount}`} />
<MetricCard label="Members" value={`${workspace.memberCount}`} />
<MetricCard
label="Shared Pages"
value={`${workspace.publicPageCount}`}
/>
</div>
<div className="border rounded-md">
<div className="px-3 py-2 text-sm font-medium">Members</div>
<Separator />
<div className="flex flex-col divide-y">
{memberList.length === 0 ? (
<div
className="px-3 py-3 text-xs"
style={{ color: cssVarV2('text/secondary') }}
>
No members.
</div>
) : (
memberList.map(member => (
<div
key={member.id}
className="flex items-center gap-3 px-3 py-2"
>
<Avatar className="w-9 h-9">
<AvatarImage src={member.avatarUrl ?? undefined} />
<AvatarFallback>
<AccountIcon fontSize={16} />
</AvatarFallback>
</Avatar>
<div className="flex flex-col overflow-hidden">
<div className="text-sm font-medium truncate">
{member.name || member.email}
</div>
<div
className="text-xs truncate"
style={{ color: cssVarV2('text/secondary') }}
>
{member.email}
</div>
</div>
<div className="ml-auto text-xs px-2 py-1 rounded border">
{member.role}
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}
function FlagItem({
label,
description,
checked,
onCheckedChange,
}: {
label: string;
description: string;
checked: boolean;
onCheckedChange: (value: boolean) => void;
}) {
return (
<div className="flex items-start justify-between gap-2 p-3">
<div className="flex flex-col">
<div className="text-sm font-medium">{label}</div>
<div className="text-xs" style={{ color: cssVarV2('text/secondary') }}>
{description}
</div>
</div>
<Switch checked={checked} onCheckedChange={onCheckedChange} />
</div>
);
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="border rounded-md p-3 flex flex-col gap-1">
<div className="text-xs" style={{ color: cssVarV2('text/secondary') }}>
{label}
</div>
<div className="text-sm font-semibold">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { AdminWorkspaceSort, FeatureType } from '@affine/graphql';
import { useState } from 'react';
import { Header } from '../header';
import { useColumns } from './components/columns';
import { DataTable } from './components/data-table';
import { useWorkspaceList } from './use-workspace-list';
export function WorkspacePage() {
const [keyword, setKeyword] = useState('');
const [featureFilters, setFeatureFilters] = useState<FeatureType[]>([]);
const [sort, setSort] = useState<AdminWorkspaceSort | undefined>(
AdminWorkspaceSort.CreatedAt
);
const { workspaces, pagination, setPagination, workspacesCount } =
useWorkspaceList({
keyword,
features: featureFilters,
orderBy: sort,
});
const columns = useColumns();
return (
<div className="h-screen flex-1 flex-col flex">
<Header title="Workspaces" />
<DataTable
data={workspaces}
columns={columns}
pagination={pagination}
workspacesCount={workspacesCount}
onPaginationChange={setPagination}
keyword={keyword}
onKeywordChange={setKeyword}
selectedFeatures={featureFilters}
onFeaturesChange={setFeatureFilters}
sort={sort}
onSortChange={setSort}
/>
</div>
);
}
export { WorkspacePage as Component };

View File

@@ -0,0 +1,17 @@
import type {
AdminUpdateWorkspaceMutation,
AdminWorkspaceQuery,
AdminWorkspacesQuery,
FeatureType,
} from '@affine/graphql';
export type WorkspaceListItem = AdminWorkspacesQuery['adminWorkspaces'][0];
export type WorkspaceDetail = NonNullable<
AdminWorkspaceQuery['adminWorkspace']
>;
export type WorkspaceMember = WorkspaceDetail['members'][0];
export type WorkspaceUpdateInput =
AdminUpdateWorkspaceMutation['adminUpdateWorkspace'];
export type WorkspaceFeatureFilter = FeatureType[];

View File

@@ -0,0 +1,80 @@
import { useQuery } from '@affine/admin/use-query';
import {
adminWorkspacesCountQuery,
AdminWorkspaceSort,
adminWorkspacesQuery,
FeatureType,
} from '@affine/graphql';
import { useEffect, useMemo, useState } from 'react';
export const useWorkspaceList = (filter?: {
keyword?: string;
features?: FeatureType[];
orderBy?: AdminWorkspaceSort;
}) => {
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
});
const filterKey = useMemo(
() =>
`${filter?.keyword ?? ''}-${[...(filter?.features ?? [])]
.sort()
.join(',')}-${filter?.orderBy ?? ''}`,
[filter?.features, filter?.keyword, filter?.orderBy]
);
useEffect(() => {
setPagination(prev => ({ ...prev, pageIndex: 0 }));
}, [filterKey]);
const variables = useMemo(
() => ({
filter: {
first: pagination.pageSize,
skip: pagination.pageIndex * pagination.pageSize,
keyword: filter?.keyword || undefined,
features:
filter?.features && filter.features.length > 0
? filter.features
: undefined,
orderBy: filter?.orderBy,
},
}),
[
filter?.features,
filter?.keyword,
filter?.orderBy,
pagination.pageIndex,
pagination.pageSize,
]
);
const { data: listData } = useQuery(
{
query: adminWorkspacesQuery,
variables,
},
{
keepPreviousData: true,
}
);
const { data: countData } = useQuery(
{
query: adminWorkspacesCountQuery,
variables,
},
{
keepPreviousData: true,
}
);
return {
workspaces: listData?.adminWorkspaces ?? [],
workspacesCount: countData?.adminWorkspacesCount ?? 0,
pagination,
setPagination,
};
};

View File

@@ -0,0 +1,14 @@
export function formatBytes(bytes: number) {
if (!bytes) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
const digits = value >= 10 ? 0 : 1;
return `${value.toFixed(digits)} ${units[unitIndex]}`;
}