mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 05:14:54 +00:00
feat: improve admin panel (#14180)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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()}
|
||||
>
|
||||
166
packages/frontend/admin/src/components/shared/data-table.tsx
Normal file
166
packages/frontend/admin/src/components/shared/data-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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."
|
||||
|
||||
17
packages/frontend/admin/src/hooks/use-debounced-value.ts
Normal file
17
packages/frontend/admin/src/hooks/use-debounced-value.ts
Normal 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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
46
packages/frontend/admin/src/modules/workspaces/index.tsx
Normal file
46
packages/frontend/admin/src/modules/workspaces/index.tsx
Normal 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 };
|
||||
17
packages/frontend/admin/src/modules/workspaces/schema.ts
Normal file
17
packages/frontend/admin/src/modules/workspaces/schema.ts
Normal 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[];
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
14
packages/frontend/admin/src/modules/workspaces/utils.ts
Normal file
14
packages/frontend/admin/src/modules/workspaces/utils.ts
Normal 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]}`;
|
||||
}
|
||||
Reference in New Issue
Block a user