mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-21 16:26:58 +08:00
feat: improve admin dashboard & settings
This commit is contained in:
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -148,7 +148,7 @@ jobs:
|
||||
name: Wait for approval
|
||||
with:
|
||||
secret: ${{ secrets.GITHUB_TOKEN }}
|
||||
approvers: darkskygit,pengx17,L-Sun,EYHN
|
||||
approvers: darkskygit
|
||||
minimum-approvals: 1
|
||||
fail-on-denial: true
|
||||
issue-title: Please confirm to release docker image
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
Info,
|
||||
InputType,
|
||||
Int,
|
||||
Mutation,
|
||||
@@ -14,6 +15,12 @@ import {
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import {
|
||||
type FragmentDefinitionNode,
|
||||
type GraphQLResolveInfo,
|
||||
Kind,
|
||||
type SelectionNode,
|
||||
} from 'graphql';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
|
||||
import { PaginationInput, URLHelper } from '../../../base';
|
||||
@@ -53,6 +60,44 @@ registerEnumType(AdminSharedLinksOrder, {
|
||||
name: 'AdminSharedLinksOrder',
|
||||
});
|
||||
|
||||
function hasSelectedField(
|
||||
selections: readonly SelectionNode[],
|
||||
fieldName: string,
|
||||
fragments: Record<string, FragmentDefinitionNode>
|
||||
): boolean {
|
||||
for (const selection of selections) {
|
||||
if (selection.kind === Kind.FIELD) {
|
||||
if (selection.name.value === fieldName) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (selection.kind === Kind.INLINE_FRAGMENT) {
|
||||
if (
|
||||
hasSelectedField(
|
||||
selection.selectionSet.selections,
|
||||
fieldName,
|
||||
fragments
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const fragment = fragments[selection.name.value];
|
||||
if (
|
||||
fragment &&
|
||||
hasSelectedField(fragment.selectionSet.selections, fieldName, fragments)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class ListWorkspaceInput {
|
||||
@Field(() => Int, { defaultValue: 20 })
|
||||
@@ -471,22 +516,40 @@ export class AdminWorkspaceResolver {
|
||||
})
|
||||
async adminDashboard(
|
||||
@Args('input', { nullable: true, type: () => AdminDashboardInput })
|
||||
input?: AdminDashboardInput
|
||||
input?: AdminDashboardInput,
|
||||
@Info() info?: GraphQLResolveInfo
|
||||
) {
|
||||
this.assertCloudOnly();
|
||||
const includeTopSharedLinks = Boolean(
|
||||
info?.fieldNodes.some(
|
||||
node =>
|
||||
node.selectionSet &&
|
||||
hasSelectedField(
|
||||
node.selectionSet.selections,
|
||||
'topSharedLinks',
|
||||
info.fragments
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const dashboard = await this.models.workspaceAnalytics.adminGetDashboard({
|
||||
timezone: input?.timezone,
|
||||
storageHistoryDays: input?.storageHistoryDays,
|
||||
syncHistoryHours: input?.syncHistoryHours,
|
||||
sharedLinkWindowDays: input?.sharedLinkWindowDays,
|
||||
includeTopSharedLinks,
|
||||
});
|
||||
|
||||
return {
|
||||
...dashboard,
|
||||
topSharedLinks: dashboard.topSharedLinks.map(link => ({
|
||||
...link,
|
||||
shareUrl: this.url.link(`/workspace/${link.workspaceId}/${link.docId}`),
|
||||
})),
|
||||
topSharedLinks: includeTopSharedLinks
|
||||
? dashboard.topSharedLinks.map(link => ({
|
||||
...link,
|
||||
shareUrl: this.url.link(
|
||||
`/workspace/${link.workspaceId}/${link.docId}`
|
||||
),
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export type AdminDashboardOptions = {
|
||||
storageHistoryDays?: number;
|
||||
syncHistoryHours?: number;
|
||||
sharedLinkWindowDays?: number;
|
||||
includeTopSharedLinks?: boolean;
|
||||
};
|
||||
|
||||
export type AdminAllSharedLinksOptions = {
|
||||
@@ -262,6 +263,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
||||
90,
|
||||
DEFAULT_SHARED_LINK_WINDOW_DAYS
|
||||
);
|
||||
const includeTopSharedLinks = options.includeTopSharedLinks ?? true;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -274,6 +276,66 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
||||
const storageFrom = addUtcDays(currentDay, -(storageHistoryDays - 1));
|
||||
const sharedFrom = addUtcDays(currentDay, -(sharedLinkWindowDays - 1));
|
||||
|
||||
const topSharedLinksPromise = includeTopSharedLinks
|
||||
? this.db.$queryRaw<
|
||||
{
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
title: string | null;
|
||||
publishedAt: Date | null;
|
||||
docUpdatedAt: Date | null;
|
||||
workspaceOwnerId: string | null;
|
||||
lastUpdaterId: string | null;
|
||||
views: bigint | number;
|
||||
uniqueViews: bigint | number;
|
||||
guestViews: bigint | number;
|
||||
lastAccessedAt: Date | null;
|
||||
}[]
|
||||
>`
|
||||
WITH view_agg AS (
|
||||
SELECT
|
||||
workspace_id,
|
||||
doc_id,
|
||||
COALESCE(SUM(total_views), 0) AS views,
|
||||
COALESCE(SUM(unique_views), 0) AS unique_views,
|
||||
COALESCE(SUM(guest_views), 0) AS guest_views,
|
||||
MAX(last_accessed_at) AS last_accessed_at
|
||||
FROM workspace_doc_view_daily
|
||||
WHERE date BETWEEN ${sharedFrom}::date AND ${currentDay}::date
|
||||
GROUP BY workspace_id, doc_id
|
||||
)
|
||||
SELECT
|
||||
wp.workspace_id AS "workspaceId",
|
||||
wp.page_id AS "docId",
|
||||
wp.title AS title,
|
||||
wp.published_at AS "publishedAt",
|
||||
sn.updated_at AS "docUpdatedAt",
|
||||
owner.user_id AS "workspaceOwnerId",
|
||||
sn.updated_by AS "lastUpdaterId",
|
||||
COALESCE(v.views, 0) AS views,
|
||||
COALESCE(v.unique_views, 0) AS "uniqueViews",
|
||||
COALESCE(v.guest_views, 0) AS "guestViews",
|
||||
v.last_accessed_at AS "lastAccessedAt"
|
||||
FROM workspace_pages wp
|
||||
LEFT JOIN snapshots sn
|
||||
ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id
|
||||
LEFT JOIN view_agg v
|
||||
ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT user_id
|
||||
FROM workspace_user_permissions
|
||||
WHERE workspace_id = wp.workspace_id
|
||||
AND type = ${WorkspaceRole.Owner}
|
||||
AND status = 'Accepted'::"WorkspaceMemberStatus"
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
) owner ON TRUE
|
||||
WHERE wp.public = TRUE
|
||||
ORDER BY views DESC, "uniqueViews" DESC, "workspaceId" ASC, "docId" ASC
|
||||
LIMIT 10
|
||||
`
|
||||
: Promise.resolve([]);
|
||||
|
||||
const [
|
||||
syncCurrent,
|
||||
syncTimeline,
|
||||
@@ -350,63 +412,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
||||
AND created_at >= ${sharedFrom}
|
||||
AND created_at <= ${now}
|
||||
`,
|
||||
this.db.$queryRaw<
|
||||
{
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
title: string | null;
|
||||
publishedAt: Date | null;
|
||||
docUpdatedAt: Date | null;
|
||||
workspaceOwnerId: string | null;
|
||||
lastUpdaterId: string | null;
|
||||
views: bigint | number;
|
||||
uniqueViews: bigint | number;
|
||||
guestViews: bigint | number;
|
||||
lastAccessedAt: Date | null;
|
||||
}[]
|
||||
>`
|
||||
WITH view_agg AS (
|
||||
SELECT
|
||||
workspace_id,
|
||||
doc_id,
|
||||
COALESCE(SUM(total_views), 0) AS views,
|
||||
COALESCE(SUM(unique_views), 0) AS unique_views,
|
||||
COALESCE(SUM(guest_views), 0) AS guest_views,
|
||||
MAX(last_accessed_at) AS last_accessed_at
|
||||
FROM workspace_doc_view_daily
|
||||
WHERE date BETWEEN ${sharedFrom}::date AND ${currentDay}::date
|
||||
GROUP BY workspace_id, doc_id
|
||||
)
|
||||
SELECT
|
||||
wp.workspace_id AS "workspaceId",
|
||||
wp.page_id AS "docId",
|
||||
wp.title AS title,
|
||||
wp.published_at AS "publishedAt",
|
||||
sn.updated_at AS "docUpdatedAt",
|
||||
owner.user_id AS "workspaceOwnerId",
|
||||
sn.updated_by AS "lastUpdaterId",
|
||||
COALESCE(v.views, 0) AS views,
|
||||
COALESCE(v.unique_views, 0) AS "uniqueViews",
|
||||
COALESCE(v.guest_views, 0) AS "guestViews",
|
||||
v.last_accessed_at AS "lastAccessedAt"
|
||||
FROM workspace_pages wp
|
||||
LEFT JOIN snapshots sn
|
||||
ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id
|
||||
LEFT JOIN view_agg v
|
||||
ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT user_id
|
||||
FROM workspace_user_permissions
|
||||
WHERE workspace_id = wp.workspace_id
|
||||
AND type = ${WorkspaceRole.Owner}
|
||||
AND status = 'Accepted'::"WorkspaceMemberStatus"
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
) owner ON TRUE
|
||||
WHERE wp.public = TRUE
|
||||
ORDER BY views DESC, "uniqueViews" DESC, "workspaceId" ASC, "docId" ASC
|
||||
LIMIT 10
|
||||
`,
|
||||
topSharedLinksPromise,
|
||||
]);
|
||||
|
||||
const storageHistorySeries = storageHistory.map(row => ({
|
||||
|
||||
@@ -132,6 +132,10 @@ export class IndexerJob {
|
||||
indexed: true,
|
||||
});
|
||||
}
|
||||
if (!missingDocIds.length && !deletedDocIds.length) {
|
||||
this.logger.verbose(`workspace ${workspaceId} is already indexed`);
|
||||
return;
|
||||
}
|
||||
this.logger.log(
|
||||
`indexed workspace ${workspaceId} with ${missingDocIds.length} missing docs and ${deletedDocIds.length} deleted docs`
|
||||
);
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -67,7 +68,8 @@
|
||||
"shadcn-ui": "^0.9.5",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "affine bundle",
|
||||
|
||||
@@ -134,12 +134,7 @@ export const App = () => {
|
||||
<Route
|
||||
path={ROUTES.admin.settings.index}
|
||||
element={<Settings />}
|
||||
>
|
||||
<Route
|
||||
path={ROUTES.admin.settings.module}
|
||||
element={<Settings />}
|
||||
/>
|
||||
</Route>
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@affine/admin/components/ui/select';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { Skeleton } from '@affine/admin/components/ui/skeleton';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -38,13 +39,82 @@ import {
|
||||
RefreshCwIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { type ReactNode, Suspense, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { useMutateQueryResource } from '../../use-mutation';
|
||||
import { Header } from '../header';
|
||||
import { formatBytes } from '../workspaces/utils';
|
||||
|
||||
const adminDashboardOverviewQuery: typeof adminDashboardQuery = {
|
||||
...adminDashboardQuery,
|
||||
query: `query adminDashboard($input: AdminDashboardInput) {
|
||||
adminDashboard(input: $input) {
|
||||
syncActiveUsers
|
||||
syncActiveUsersTimeline {
|
||||
minute
|
||||
activeUsers
|
||||
}
|
||||
syncWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
copilotConversations
|
||||
workspaceStorageBytes
|
||||
blobStorageBytes
|
||||
workspaceStorageHistory {
|
||||
date
|
||||
value
|
||||
}
|
||||
blobStorageHistory {
|
||||
date
|
||||
value
|
||||
}
|
||||
storageWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
generatedAt
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
const adminDashboardTopSharedLinksQuery: typeof adminDashboardQuery = {
|
||||
...adminDashboardQuery,
|
||||
query: `query adminDashboard($input: AdminDashboardInput) {
|
||||
adminDashboard(input: $input) {
|
||||
topSharedLinks {
|
||||
workspaceId
|
||||
docId
|
||||
title
|
||||
shareUrl
|
||||
publishedAt
|
||||
views
|
||||
uniqueViews
|
||||
guestViews
|
||||
lastAccessedAt
|
||||
}
|
||||
topSharedLinksWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
const intFormatter = new Intl.NumberFormat('en-US');
|
||||
const compactFormatter = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
@@ -260,7 +330,7 @@ function PrimaryMetricCard({
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="lg:col-span-5 border-primary/30 bg-gradient-to-br from-primary/10 via-card to-card shadow-sm">
|
||||
<Card className="h-full border-primary/30 bg-gradient-to-br from-primary/10 via-card to-card shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="flex items-center gap-2 text-foreground/75">
|
||||
<UsersIcon className="h-4 w-4" aria-hidden="true" />
|
||||
@@ -289,7 +359,7 @@ function SecondaryMetricCard({
|
||||
icon: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="lg:col-span-3 border-border/70 bg-card/95 shadow-sm">
|
||||
<Card className="h-full border-border/70 bg-card/95 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
@@ -322,7 +392,7 @@ function WindowSelect({
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 min-w-40">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="text-xs uppercase tracking-wide text-muted-foreground"
|
||||
@@ -348,10 +418,192 @@ function WindowSelect({
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
function DashboardPageSkeleton() {
|
||||
return (
|
||||
<div className="h-screen flex-1 flex-col flex overflow-hidden">
|
||||
<Header
|
||||
title="Dashboard"
|
||||
endFix={
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-3 w-44" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||
<Card className="border-primary/20 bg-gradient-to-r from-primary/5 via-card to-card shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<Skeleton className="h-5 w-36" />
|
||||
<Skeleton className="h-4 w-80" />
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 grid-cols-1 min-[1024px]:grid-cols-3 items-end">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-5 grid-cols-1 min-[1024px]:grid-cols-12">
|
||||
<Skeleton className="h-28 w-full min-[1024px]:col-span-5" />
|
||||
<Skeleton className="h-28 w-full min-[1024px]:col-span-3" />
|
||||
<Skeleton className="h-28 w-full min-[1024px]:col-span-4" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 grid-cols-1 xl:grid-cols-3">
|
||||
<Skeleton className="h-72 w-full xl:col-span-1" />
|
||||
<Skeleton className="h-72 w-full xl:col-span-2" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopSharedLinksCardSkeleton() {
|
||||
return (
|
||||
<Card className="border-border/70 bg-card/95 shadow-sm">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-36" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TopSharedLinksSection({
|
||||
sharedLinkWindowDays,
|
||||
}: {
|
||||
sharedLinkWindowDays: number;
|
||||
}) {
|
||||
const variables = useMemo(
|
||||
() => ({
|
||||
input: {
|
||||
sharedLinkWindowDays,
|
||||
timezone: 'UTC',
|
||||
},
|
||||
}),
|
||||
[sharedLinkWindowDays]
|
||||
);
|
||||
|
||||
const { data } = useQuery(
|
||||
{
|
||||
query: adminDashboardTopSharedLinksQuery,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: true,
|
||||
revalidateOnReconnect: true,
|
||||
}
|
||||
);
|
||||
|
||||
const topSharedLinks = data.adminDashboard.topSharedLinks;
|
||||
const topSharedLinksWindow = data.adminDashboard.topSharedLinksWindow;
|
||||
|
||||
return (
|
||||
<Card className="border-border/70 bg-card/95 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Top Shared Links</CardTitle>
|
||||
<CardDescription>
|
||||
Top {topSharedLinks.length} links in the last{' '}
|
||||
{topSharedLinksWindow.effectiveSize} days
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{topSharedLinks.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center bg-muted/20">
|
||||
<div className="text-sm font-medium">
|
||||
No shared links in this window
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
Publish pages and collect traffic, then this table will rank links
|
||||
by views.
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm" className="mt-4">
|
||||
<Link to={ROUTES.admin.workspaces}>Go to Workspaces</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Document</TableHead>
|
||||
<TableHead>Workspace</TableHead>
|
||||
<TableHead className="text-right">Views</TableHead>
|
||||
<TableHead className="text-right">Unique</TableHead>
|
||||
<TableHead className="text-right">Guest</TableHead>
|
||||
<TableHead>Last Accessed</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topSharedLinks.map(link => (
|
||||
<TableRow
|
||||
key={`${link.workspaceId}-${link.docId}`}
|
||||
className="hover:bg-muted/40"
|
||||
>
|
||||
<TableCell className="max-w-80 min-w-0">
|
||||
<a
|
||||
href={link.shareUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline-offset-4 hover:underline truncate block"
|
||||
>
|
||||
{link.title || link.docId}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs tabular-nums">
|
||||
{link.workspaceId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{compactFormatter.format(link.views)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{compactFormatter.format(link.uniqueViews)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{compactFormatter.format(link.guestViews)}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{link.lastAccessedAt
|
||||
? formatDateTime(link.lastAccessedAt)
|
||||
: '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
<div className="flex justify-between text-xs text-muted-foreground tabular-nums">
|
||||
<span>{formatDate(topSharedLinksWindow.from)}</span>
|
||||
<span>{formatDate(topSharedLinksWindow.to)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardPageContent() {
|
||||
const [storageHistoryDays, setStorageHistoryDays] = useState<number>(30);
|
||||
const [syncHistoryHours, setSyncHistoryHours] = useState<number>(48);
|
||||
const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState<number>(28);
|
||||
const shouldShowTopSharedLinks = !environment.isSelfHosted;
|
||||
const revalidateQueryResource = useMutateQueryResource();
|
||||
|
||||
const variables = useMemo(
|
||||
() => ({
|
||||
@@ -365,9 +617,9 @@ export function DashboardPage() {
|
||||
[sharedLinkWindowDays, storageHistoryDays, syncHistoryHours]
|
||||
);
|
||||
|
||||
const { data, isValidating, mutate } = useQuery(
|
||||
const { data, isValidating } = useQuery(
|
||||
{
|
||||
query: adminDashboardQuery,
|
||||
query: adminDashboardOverviewQuery,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
@@ -421,7 +673,7 @@ export function DashboardPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
mutate().catch(() => {});
|
||||
revalidateQueryResource(adminDashboardQuery).catch(() => {});
|
||||
}}
|
||||
disabled={isValidating}
|
||||
>
|
||||
@@ -444,7 +696,7 @@ export function DashboardPage() {
|
||||
automatically.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 grid-cols-1 md:grid-cols-3 items-end">
|
||||
<CardContent className="grid gap-3 grid-cols-1 min-[1024px]:grid-cols-3 items-end">
|
||||
<WindowSelect
|
||||
id="storage-history-window"
|
||||
label="Storage History"
|
||||
@@ -472,36 +724,42 @@ export function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-5 grid-cols-1 lg:grid-cols-12">
|
||||
<PrimaryMetricCard
|
||||
value={intFormatter.format(dashboard.syncActiveUsers)}
|
||||
description={`${dashboard.syncWindow.effectiveSize}h active window`}
|
||||
/>
|
||||
<SecondaryMetricCard
|
||||
title="Copilot Conversations"
|
||||
value={intFormatter.format(dashboard.copilotConversations)}
|
||||
description={`${dashboard.topSharedLinksWindow.effectiveSize}d aggregation`}
|
||||
icon={
|
||||
<MessageSquareTextIcon className="h-4 w-4" aria-hidden="true" />
|
||||
}
|
||||
/>
|
||||
<Card className="lg:col-span-4 border-border/70 bg-gradient-to-br from-card via-card to-muted/15 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<DatabaseIcon className="h-4 w-4" aria-hidden="true" />
|
||||
Managed Storage
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold tracking-tight tabular-nums">
|
||||
{formatBytes(totalStorageBytes)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Workspace {formatBytes(dashboard.workspaceStorageBytes)} • Blob{' '}
|
||||
{formatBytes(dashboard.blobStorageBytes)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-5 grid-cols-1 min-[1024px]:grid-cols-12">
|
||||
<div className="min-w-0 h-full min-[1024px]:col-span-5">
|
||||
<PrimaryMetricCard
|
||||
value={intFormatter.format(dashboard.syncActiveUsers)}
|
||||
description={`${dashboard.syncWindow.effectiveSize}h active window`}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 h-full min-[1024px]:col-span-3">
|
||||
<SecondaryMetricCard
|
||||
title="Copilot Conversations"
|
||||
value={intFormatter.format(dashboard.copilotConversations)}
|
||||
description={`${sharedLinkWindowDays}d aggregation`}
|
||||
icon={
|
||||
<MessageSquareTextIcon className="h-4 w-4" aria-hidden="true" />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 h-full min-[1024px]:col-span-4">
|
||||
<Card className="h-full border-border/70 bg-gradient-to-br from-card via-card to-muted/15 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<DatabaseIcon className="h-4 w-4" aria-hidden="true" />
|
||||
Managed Storage
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold tracking-tight tabular-nums">
|
||||
{formatBytes(totalStorageBytes)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Workspace {formatBytes(dashboard.workspaceStorageBytes)} •
|
||||
Blob {formatBytes(dashboard.blobStorageBytes)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 grid-cols-1 xl:grid-cols-3">
|
||||
@@ -557,89 +815,24 @@ export function DashboardPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/70 bg-card/95 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Top Shared Links</CardTitle>
|
||||
<CardDescription>
|
||||
Top {dashboard.topSharedLinks.length} links in the last{' '}
|
||||
{dashboard.topSharedLinksWindow.effectiveSize} days
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{dashboard.topSharedLinks.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center bg-muted/20">
|
||||
<div className="text-sm font-medium">
|
||||
No shared links in this window
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
Publish pages and collect traffic, then this table will rank
|
||||
links by views.
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm" className="mt-4">
|
||||
<Link to={ROUTES.admin.workspaces}>Go to Workspaces</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Document</TableHead>
|
||||
<TableHead>Workspace</TableHead>
|
||||
<TableHead className="text-right">Views</TableHead>
|
||||
<TableHead className="text-right">Unique</TableHead>
|
||||
<TableHead className="text-right">Guest</TableHead>
|
||||
<TableHead>Last Accessed</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dashboard.topSharedLinks.map(link => (
|
||||
<TableRow
|
||||
key={`${link.workspaceId}-${link.docId}`}
|
||||
className="hover:bg-muted/40"
|
||||
>
|
||||
<TableCell className="max-w-80 min-w-0">
|
||||
<a
|
||||
href={link.shareUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline-offset-4 hover:underline truncate block"
|
||||
>
|
||||
{link.title || link.docId}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs tabular-nums">
|
||||
{link.workspaceId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{compactFormatter.format(link.views)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{compactFormatter.format(link.uniqueViews)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{compactFormatter.format(link.guestViews)}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{link.lastAccessedAt
|
||||
? formatDateTime(link.lastAccessedAt)
|
||||
: '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
<div className="flex justify-between text-xs text-muted-foreground tabular-nums">
|
||||
<span>{formatDate(dashboard.topSharedLinksWindow.from)}</span>
|
||||
<span>{formatDate(dashboard.topSharedLinksWindow.to)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{shouldShowTopSharedLinks ? (
|
||||
<Suspense fallback={<TopSharedLinksCardSkeleton />}>
|
||||
<TopSharedLinksSection
|
||||
sharedLinkWindowDays={sharedLinkWindowDays}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<Suspense fallback={<DashboardPageSkeleton />}>
|
||||
<DashboardPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export { DashboardPage as Component };
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from '../components/ui/sheet';
|
||||
import { Logo } from './accounts/components/logo';
|
||||
import { useMediaQuery } from './common';
|
||||
import { NavContext } from './nav/context';
|
||||
import { Nav } from './nav/nav';
|
||||
import {
|
||||
PanelContext,
|
||||
@@ -43,10 +42,6 @@ export function Layout({ children }: PropsWithChildren) {
|
||||
const leftPanelRef = useRef<ImperativePanelHandle>(null);
|
||||
const location = useLocation();
|
||||
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [activeSubTab, setActiveSubTab] = useState('server');
|
||||
const [currentModule, setCurrentModule] = useState('server');
|
||||
|
||||
const handleLeftExpand = useCallback(() => {
|
||||
if (leftPanelRef.current?.getSize() === 0) {
|
||||
leftPanelRef.current?.resize(30);
|
||||
@@ -151,36 +146,25 @@ export function Layout({ children }: PropsWithChildren) {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<NavContext.Provider
|
||||
value={{
|
||||
activeTab,
|
||||
activeSubTab,
|
||||
currentModule,
|
||||
setActiveTab,
|
||||
setActiveSubTab,
|
||||
setCurrentModule,
|
||||
}}
|
||||
>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="flex h-screen w-full overflow-hidden">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<LeftPanel
|
||||
panelRef={leftPanelRef as RefObject<ImperativePanelHandle>}
|
||||
onExpand={handleLeftExpand}
|
||||
onCollapse={handleLeftCollapse}
|
||||
/>
|
||||
<ResizablePanel id="1" order={1} minSize={50} defaultSize={50}>
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<RightPanel
|
||||
panelRef={rightPanelRef as RefObject<ImperativePanelHandle>}
|
||||
onExpand={handleRightExpand}
|
||||
onCollapse={handleRightCollapse}
|
||||
/>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</NavContext.Provider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="flex h-screen w-full overflow-hidden">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<LeftPanel
|
||||
panelRef={leftPanelRef as RefObject<ImperativePanelHandle>}
|
||||
onExpand={handleLeftExpand}
|
||||
onCollapse={handleLeftCollapse}
|
||||
/>
|
||||
<ResizablePanel id="1" order={1} minSize={50} defaultSize={50}>
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<RightPanel
|
||||
panelRef={rightPanelRef as RefObject<ImperativePanelHandle>}
|
||||
onExpand={handleRightExpand}
|
||||
onCollapse={handleRightCollapse}
|
||||
/>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</PanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { buttonVariants } from '../../components/ui/button';
|
||||
import { cn } from '../../utils';
|
||||
|
||||
export const NormalSubItem = ({
|
||||
module,
|
||||
title,
|
||||
changeModule,
|
||||
indent = 'normal',
|
||||
}: {
|
||||
module: string;
|
||||
title: string;
|
||||
changeModule?: (module: string) => void;
|
||||
indent?: 'normal' | 'nested';
|
||||
}) => {
|
||||
const handleClick = useCallback(() => {
|
||||
changeModule?.(module);
|
||||
}, [changeModule, module]);
|
||||
const indentClassName = indent === 'nested' ? 'ml-12' : 'ml-8';
|
||||
return (
|
||||
<div className="w-full flex">
|
||||
<NavLink
|
||||
to={`/admin/settings/${module}`}
|
||||
onClick={handleClick}
|
||||
className={({ isActive }) => {
|
||||
return cn(
|
||||
buttonVariants({
|
||||
variant: 'ghost',
|
||||
className: cn(
|
||||
indentClassName,
|
||||
'px-2 w-full justify-start',
|
||||
isActive && 'bg-zinc-100'
|
||||
),
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
interface NavContextType {
|
||||
activeTab: string;
|
||||
activeSubTab: string;
|
||||
currentModule: string;
|
||||
setActiveTab: (tab: string) => void;
|
||||
setActiveSubTab: (tab: string) => void;
|
||||
setCurrentModule: (module: string) => void;
|
||||
}
|
||||
|
||||
export const NavContext = createContext<NavContextType | undefined>(undefined);
|
||||
export const useNav = () => {
|
||||
const context = useContext(NavContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useNav must be used within a NavProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -1,198 +1,54 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@affine/admin/components/ui/accordion';
|
||||
import { buttonVariants } from '@affine/admin/components/ui/button';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { SettingsIcon } from '@blocksuite/icons/rc';
|
||||
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { KNOWN_CONFIG_GROUPS, UNKNOWN_CONFIG_GROUPS } from '../settings/config';
|
||||
import { NormalSubItem } from './collapsible-item';
|
||||
import { useNav } from './context';
|
||||
|
||||
export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
||||
const { setCurrentModule } = useNav();
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
className="flex-none relative"
|
||||
orientation="vertical"
|
||||
<NavLink
|
||||
to="/admin/settings"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'ghost',
|
||||
className: 'w-10 h-10',
|
||||
size: 'icon',
|
||||
})
|
||||
)}
|
||||
style={({ isActive }) => ({
|
||||
backgroundColor: isActive
|
||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||
: undefined,
|
||||
})}
|
||||
>
|
||||
<NavigationMenuPrimitive.List>
|
||||
<NavigationMenuPrimitive.Item>
|
||||
<NavigationMenuPrimitive.Trigger className="[&>svg]:hidden m-0 p-0">
|
||||
<NavLink
|
||||
to={'/admin/settings'}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'ghost',
|
||||
className: 'w-10 h-10',
|
||||
size: 'icon',
|
||||
})
|
||||
)}
|
||||
style={({ isActive }) => ({
|
||||
backgroundColor: isActive
|
||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||
: undefined,
|
||||
})}
|
||||
>
|
||||
<SettingsIcon fontSize={20} />
|
||||
</NavLink>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
<NavigationMenuPrimitive.Content>
|
||||
<ul
|
||||
className="border rounded-lg w-full flex flex-col p-1 min-w-[160px] max-h-[200px] overflow-y-auto"
|
||||
style={{
|
||||
backgroundColor: cssVarV2('layer/background/overlayPanel'),
|
||||
borderColor: cssVarV2('layer/insideBorder/blackBorder'),
|
||||
}}
|
||||
>
|
||||
{KNOWN_CONFIG_GROUPS.map(group => (
|
||||
<li key={group.module} className="flex">
|
||||
<NavLink
|
||||
to={`/admin/settings/${group.module}`}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'ghost',
|
||||
className:
|
||||
'p-2 rounded-[6px] text-[14px] w-full justify-start font-normal',
|
||||
})
|
||||
)}
|
||||
style={({ isActive }) => ({
|
||||
backgroundColor: isActive
|
||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||
: undefined,
|
||||
})}
|
||||
onClick={() => setCurrentModule?.(group.module)}
|
||||
>
|
||||
{group.name}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
{UNKNOWN_CONFIG_GROUPS.length ? (
|
||||
<li className="flex px-2 pt-1 pb-0.5 text-xs font-medium opacity-70">
|
||||
Experimental
|
||||
</li>
|
||||
) : null}
|
||||
{UNKNOWN_CONFIG_GROUPS.map(group => (
|
||||
<li key={group.module} className="flex">
|
||||
<NavLink
|
||||
to={`/admin/settings/${group.module}`}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'ghost',
|
||||
className:
|
||||
'p-2 pl-6 rounded-[6px] text-[14px] w-full justify-start font-normal',
|
||||
})
|
||||
)}
|
||||
style={({ isActive }) => ({
|
||||
backgroundColor: isActive
|
||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||
: undefined,
|
||||
})}
|
||||
onClick={() => setCurrentModule?.(group.module)}
|
||||
>
|
||||
{group.name}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</NavigationMenuPrimitive.Content>
|
||||
</NavigationMenuPrimitive.Item>
|
||||
</NavigationMenuPrimitive.List>
|
||||
<NavigationMenuPrimitive.Viewport className="absolute z-10 left-11 top-0" />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
<SettingsIcon fontSize={20} />
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" className="w-full overflow-hidden">
|
||||
<AccordionItem
|
||||
value="item-1"
|
||||
className="border-b-0 h-full flex flex-col gap-1 w-full"
|
||||
>
|
||||
<NavLink
|
||||
to={'/admin/settings'}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'ghost',
|
||||
}),
|
||||
'justify-start flex-none w-full px-2'
|
||||
)}
|
||||
style={({ isActive }) => ({
|
||||
backgroundColor: isActive
|
||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||
: undefined,
|
||||
})}
|
||||
>
|
||||
<AccordionTrigger
|
||||
className={
|
||||
'flex items-center justify-between w-full [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360'
|
||||
}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="flex items-center p-0.5 mr-2">
|
||||
<SettingsIcon fontSize={20} />
|
||||
</span>
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
</NavLink>
|
||||
|
||||
<AccordionContent className="h-full overflow-hidden w-full pb-0">
|
||||
<ScrollAreaPrimitive.Root
|
||||
className={cn('relative overflow-hidden w-full h-full')}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
|
||||
{KNOWN_CONFIG_GROUPS.map(group => (
|
||||
<NormalSubItem
|
||||
key={group.module}
|
||||
module={group.module}
|
||||
title={group.name}
|
||||
changeModule={setCurrentModule}
|
||||
/>
|
||||
))}
|
||||
{UNKNOWN_CONFIG_GROUPS.length ? (
|
||||
<Accordion type="multiple" className="w-full">
|
||||
<AccordionItem value="item-1" className="border-b-0">
|
||||
<AccordionTrigger className="ml-8 py-2 px-2 rounded [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360">
|
||||
Experimental
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-1 py-1">
|
||||
{UNKNOWN_CONFIG_GROUPS.map(group => (
|
||||
<NormalSubItem
|
||||
key={group.module}
|
||||
module={group.module}
|
||||
title={group.name}
|
||||
changeModule={setCurrentModule}
|
||||
indent="nested"
|
||||
/>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
) : null}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]'
|
||||
)}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<NavLink
|
||||
to="/admin/settings"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'ghost',
|
||||
}),
|
||||
'justify-start flex-none text-sm font-medium px-2'
|
||||
)}
|
||||
style={({ isActive }) => ({
|
||||
backgroundColor: isActive
|
||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||
: undefined,
|
||||
'&:hover': {
|
||||
backgroundColor: cssVarV2('selfhost/button/sidebarButton/bg/hover'),
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span className="flex items-center p-0.5 mr-2">
|
||||
<SettingsIcon fontSize={20} />
|
||||
</span>
|
||||
Settings
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { ConfigRow } from './config-input-row';
|
||||
|
||||
describe('ConfigRow', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
if (!Element.prototype.hasPointerCapture) {
|
||||
Object.defineProperty(Element.prototype, 'hasPointerCapture', {
|
||||
value: () => false,
|
||||
});
|
||||
}
|
||||
if (!Element.prototype.setPointerCapture) {
|
||||
Object.defineProperty(Element.prototype, 'setPointerCapture', {
|
||||
value: () => {},
|
||||
});
|
||||
}
|
||||
if (!Element.prototype.releasePointerCapture) {
|
||||
Object.defineProperty(Element.prototype, 'releasePointerCapture', {
|
||||
value: () => {},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('triggers onChange when enum option changes', () => {
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ConfigRow
|
||||
field="storages/blob.storage/provider"
|
||||
desc="Storage provider"
|
||||
type="Enum"
|
||||
options={['fs', 'aws-s3', 'cloudflare-r2']}
|
||||
defaultValue="fs"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' });
|
||||
fireEvent.click(screen.getByRole('option', { name: 'aws-s3' }));
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
'storages/blob.storage/provider',
|
||||
'aws-s3'
|
||||
);
|
||||
});
|
||||
|
||||
test('triggers onChange when json text becomes invalid', () => {
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ConfigRow
|
||||
field="server/hosts"
|
||||
desc="Server hosts"
|
||||
type="JSON"
|
||||
defaultValue={[]}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: { value: '[]asdasdasd' },
|
||||
});
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('server/hosts', '[]asdasdasd');
|
||||
});
|
||||
|
||||
test('shows json validation error and clears after input is fixed', () => {
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ConfigRow
|
||||
field="server/hosts"
|
||||
desc="Server hosts"
|
||||
type="JSON"
|
||||
defaultValue={[]}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.change(textarea, {
|
||||
target: { value: '[]asdasdasd' },
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Invalid JSON format')).not.toBeNull();
|
||||
expect(textarea.className).toContain('border-red-500');
|
||||
|
||||
fireEvent.change(textarea, {
|
||||
target: { value: '["localhost"]' },
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Invalid JSON format')).toBeNull();
|
||||
expect(textarea.className).not.toContain('border-red-500');
|
||||
expect(handleChange).toHaveBeenLastCalledWith('server/hosts', [
|
||||
'localhost',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
SelectValue,
|
||||
} from '@affine/admin/components/ui/select';
|
||||
import { Switch } from '@affine/admin/components/ui/switch';
|
||||
import { useCallback } from 'react';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Textarea } from '../../components/ui/textarea';
|
||||
|
||||
@@ -17,6 +18,7 @@ export type ConfigInputProps = {
|
||||
defaultValue: any;
|
||||
onChange: (field: string, value: any) => void;
|
||||
error?: string;
|
||||
onErrorChange?: (field: string, error?: string) => void;
|
||||
} & (
|
||||
| {
|
||||
type: 'String' | 'Number' | 'Boolean' | 'JSON';
|
||||
@@ -34,6 +36,7 @@ const Inputs: Record<
|
||||
onChange: (value?: any) => void;
|
||||
options?: string[];
|
||||
error?: string;
|
||||
onValidationChange?: (error?: string) => void;
|
||||
}>
|
||||
> = {
|
||||
Boolean: function SwitchInput({ defaultValue, onChange }) {
|
||||
@@ -43,7 +46,7 @@ const Inputs: Record<
|
||||
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={defaultValue}
|
||||
checked={Boolean(defaultValue)}
|
||||
onCheckedChange={handleSwitchChange}
|
||||
/>
|
||||
);
|
||||
@@ -57,43 +60,78 @@ const Inputs: Record<
|
||||
<Input
|
||||
type="text"
|
||||
minLength={1}
|
||||
defaultValue={defaultValue}
|
||||
value={defaultValue ?? ''}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Number: function NumberInput({ defaultValue, onChange }) {
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(parseInt(e.target.value));
|
||||
const next = e.target.value;
|
||||
onChange(next === '' ? undefined : parseInt(next, 10));
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
defaultValue={defaultValue}
|
||||
value={defaultValue ?? ''}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
JSON: function ObjectInput({ defaultValue, onChange }) {
|
||||
JSON: function ObjectInput({
|
||||
defaultValue,
|
||||
onChange,
|
||||
error,
|
||||
onValidationChange,
|
||||
}) {
|
||||
const fallbackText = useMemo(
|
||||
() =>
|
||||
typeof defaultValue === 'string'
|
||||
? defaultValue
|
||||
: JSON.stringify(defaultValue ?? null),
|
||||
[defaultValue]
|
||||
);
|
||||
const [text, setText] = useState(fallbackText);
|
||||
|
||||
useEffect(() => {
|
||||
setText(fallbackText);
|
||||
}, [fallbackText]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const nextText = e.target.value;
|
||||
setText(nextText);
|
||||
try {
|
||||
const value = JSON.parse(e.target.value);
|
||||
const value = JSON.parse(nextText);
|
||||
onValidationChange?.(undefined);
|
||||
onChange(value);
|
||||
} catch {}
|
||||
} catch {
|
||||
onValidationChange?.('Invalid JSON format');
|
||||
// Keep the draft "dirty" even when JSON is temporarily invalid
|
||||
// so Save/Cancel state can reflect real editing progress.
|
||||
onChange(nextText);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
defaultValue={JSON.stringify(defaultValue)}
|
||||
value={text}
|
||||
onChange={handleInputChange}
|
||||
className="w-full"
|
||||
className={cn(
|
||||
'w-full',
|
||||
error
|
||||
? 'border-red-500 hover:border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500'
|
||||
: undefined
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Enum: function EnumInput({ defaultValue, onChange, options }) {
|
||||
return (
|
||||
<Select defaultValue={defaultValue} onValueChange={onChange}>
|
||||
<Select
|
||||
value={typeof defaultValue === 'string' ? defaultValue : undefined}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
@@ -116,9 +154,11 @@ export const ConfigRow = ({
|
||||
defaultValue,
|
||||
onChange,
|
||||
error,
|
||||
onErrorChange,
|
||||
...props
|
||||
}: ConfigInputProps) => {
|
||||
const Input = Inputs[type] ?? Inputs.JSON;
|
||||
const [validationError, setValidationError] = useState<string>();
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(value?: any) => {
|
||||
@@ -127,6 +167,19 @@ export const ConfigRow = ({
|
||||
[field, onChange]
|
||||
);
|
||||
|
||||
const onValidationChange = useCallback((nextError?: string) => {
|
||||
setValidationError(nextError);
|
||||
}, []);
|
||||
|
||||
const mergedError = error ?? validationError;
|
||||
|
||||
useEffect(() => {
|
||||
onErrorChange?.(field, mergedError);
|
||||
return () => {
|
||||
onErrorChange?.(field, undefined);
|
||||
};
|
||||
}, [field, mergedError, onErrorChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-between flex-grow space-y-[10px]
|
||||
@@ -140,12 +193,13 @@ export const ConfigRow = ({
|
||||
<Input
|
||||
defaultValue={defaultValue}
|
||||
onChange={onValueChange}
|
||||
error={error}
|
||||
error={mergedError}
|
||||
onValidationChange={onValidationChange}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<div className="absolute bottom-[-25px] text-sm right-0 break-words text-red-500">
|
||||
{error}
|
||||
{mergedError && (
|
||||
<div className="mt-1 w-full text-sm break-words text-red-500">
|
||||
{mergedError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
215
packages/frontend/admin/src/modules/settings/index.spec.tsx
Normal file
215
packages/frontend/admin/src/modules/settings/index.spec.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const useAppConfigMock = vi.fn();
|
||||
|
||||
vi.mock('./use-app-config', () => ({
|
||||
useAppConfig: () => useAppConfigMock(),
|
||||
}));
|
||||
|
||||
vi.mock('../header', () => ({
|
||||
Header: ({ title }: { title: string }) => <div>{title}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./config-input-row', () => ({
|
||||
ConfigRow: ({
|
||||
field,
|
||||
onErrorChange,
|
||||
}: {
|
||||
field: string;
|
||||
onErrorChange?: (field: string, error?: string) => void;
|
||||
}) => (
|
||||
<div data-testid={`field-${field}`}>
|
||||
<div>{field}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onErrorChange?.(field, 'Invalid JSON format');
|
||||
}}
|
||||
>
|
||||
mark-error-{field}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onErrorChange?.(field, undefined);
|
||||
}}
|
||||
>
|
||||
clear-error-{field}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./config', () => ({
|
||||
ALL_CONFIG_DESCRIPTORS: {
|
||||
server: {
|
||||
name: {
|
||||
desc: 'Server Name',
|
||||
type: 'String',
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
allowSignup: {
|
||||
desc: 'Allow Signup',
|
||||
type: 'Boolean',
|
||||
},
|
||||
},
|
||||
},
|
||||
ALL_SETTING_GROUPS: [
|
||||
{
|
||||
name: 'Server',
|
||||
module: 'server',
|
||||
fields: ['name'],
|
||||
},
|
||||
{
|
||||
name: 'Auth',
|
||||
module: 'auth',
|
||||
fields: ['allowSignup'],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
import { SettingsPage } from './index';
|
||||
|
||||
describe('SettingsPage', () => {
|
||||
beforeEach(() => {
|
||||
useAppConfigMock.mockReset();
|
||||
useAppConfigMock.mockReturnValue({
|
||||
appConfig: {
|
||||
server: {
|
||||
name: 'AFFiNE',
|
||||
},
|
||||
auth: {
|
||||
allowSignup: true,
|
||||
},
|
||||
},
|
||||
patchedAppConfig: {
|
||||
server: {
|
||||
name: 'AFFiNE',
|
||||
},
|
||||
auth: {
|
||||
allowSignup: true,
|
||||
},
|
||||
},
|
||||
update: vi.fn(),
|
||||
saveGroup: vi.fn().mockResolvedValue(undefined),
|
||||
resetGroup: vi.fn(),
|
||||
isGroupDirty: vi.fn().mockReturnValue(false),
|
||||
isGroupSaving: vi.fn().mockReturnValue(false),
|
||||
getGroupVersion: vi.fn().mockReturnValue(0),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('keeps all groups collapsed by default', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/admin/settings']}>
|
||||
<Routes>
|
||||
<Route path="/admin/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const serverItem = document.getElementById('config-module-server');
|
||||
const authItem = document.getElementById('config-module-auth');
|
||||
expect(serverItem?.dataset.state).toBe('closed');
|
||||
expect(authItem?.dataset.state).toBe('closed');
|
||||
});
|
||||
|
||||
test('keeps previous group open when another group is expanded', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/admin/settings']}>
|
||||
<Routes>
|
||||
<Route path="/admin/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Server/i })[0]);
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Auth/i })[0]);
|
||||
|
||||
const serverItem = document.getElementById('config-module-server');
|
||||
const authItem = document.getElementById('config-module-auth');
|
||||
expect(serverItem?.dataset.state).toBe('open');
|
||||
expect(authItem?.dataset.state).toBe('open');
|
||||
});
|
||||
|
||||
test('disables save when group has validation errors even if group is dirty', () => {
|
||||
useAppConfigMock.mockReset();
|
||||
useAppConfigMock.mockReturnValue({
|
||||
appConfig: {
|
||||
server: {
|
||||
name: 'AFFiNE',
|
||||
},
|
||||
auth: {
|
||||
allowSignup: true,
|
||||
},
|
||||
},
|
||||
patchedAppConfig: {
|
||||
server: {
|
||||
name: 'AFFiNE',
|
||||
},
|
||||
auth: {
|
||||
allowSignup: true,
|
||||
},
|
||||
},
|
||||
update: vi.fn(),
|
||||
saveGroup: vi.fn().mockResolvedValue(undefined),
|
||||
resetGroup: vi.fn(),
|
||||
isGroupDirty: vi
|
||||
.fn()
|
||||
.mockImplementation((module: string) => module === 'server'),
|
||||
isGroupSaving: vi.fn().mockReturnValue(false),
|
||||
getGroupVersion: vi.fn().mockReturnValue(0),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/admin/settings']}>
|
||||
<Routes>
|
||||
<Route path="/admin/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Server/i })[0]);
|
||||
|
||||
const serverItem = document.getElementById('config-module-server');
|
||||
expect(serverItem).not.toBeNull();
|
||||
if (!serverItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
const saveButton = within(serverItem).getByRole('button', { name: 'Save' });
|
||||
expect(saveButton.hasAttribute('disabled')).toBe(false);
|
||||
|
||||
fireEvent.click(
|
||||
within(serverItem).getByRole('button', {
|
||||
name: 'mark-error-server/name',
|
||||
})
|
||||
);
|
||||
|
||||
expect(saveButton.hasAttribute('disabled')).toBe(true);
|
||||
|
||||
fireEvent.click(
|
||||
within(serverItem).getByRole('button', {
|
||||
name: 'clear-error-server/name',
|
||||
})
|
||||
);
|
||||
|
||||
expect(saveButton.hasAttribute('disabled')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@affine/admin/components/ui/accordion';
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
||||
import { get } from 'lodash-es';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { Header } from '../header';
|
||||
import { useNav } from '../nav/context';
|
||||
import {
|
||||
ALL_CONFIG_DESCRIPTORS,
|
||||
ALL_SETTING_GROUPS,
|
||||
@@ -15,104 +19,237 @@ import { type ConfigInputProps, ConfigRow } from './config-input-row';
|
||||
import { useAppConfig } from './use-app-config';
|
||||
|
||||
export function SettingsPage() {
|
||||
const { appConfig, update, save, patchedAppConfig, updates } = useAppConfig();
|
||||
const disableSave = Object.keys(updates).length === 0;
|
||||
|
||||
const saveChanges = useCallback(() => {
|
||||
if (disableSave) {
|
||||
return;
|
||||
}
|
||||
save();
|
||||
}, [save, disableSave]);
|
||||
const {
|
||||
appConfig,
|
||||
update,
|
||||
saveGroup,
|
||||
resetGroup,
|
||||
patchedAppConfig,
|
||||
isGroupDirty,
|
||||
isGroupSaving,
|
||||
getGroupVersion,
|
||||
} = useAppConfig();
|
||||
const [expandedModules, setExpandedModules] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex-1 flex-col flex">
|
||||
<Header
|
||||
title="Settings"
|
||||
endFix={
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="w-7 h-7"
|
||||
variant="ghost"
|
||||
onClick={saveChanges}
|
||||
disabled={disableSave}
|
||||
>
|
||||
<CheckIcon size={20} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Header title="Settings" />
|
||||
<AdminPanel
|
||||
expandedModules={expandedModules}
|
||||
onExpandedModulesChange={setExpandedModules}
|
||||
onUpdate={update}
|
||||
appConfig={appConfig}
|
||||
patchedAppConfig={patchedAppConfig}
|
||||
onSaveGroup={saveGroup}
|
||||
onResetGroup={resetGroup}
|
||||
isGroupDirty={isGroupDirty}
|
||||
isGroupSaving={isGroupSaving}
|
||||
getGroupVersion={getGroupVersion}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AdminPanel = ({
|
||||
expandedModules,
|
||||
onExpandedModulesChange,
|
||||
appConfig,
|
||||
patchedAppConfig,
|
||||
onUpdate,
|
||||
onSaveGroup,
|
||||
onResetGroup,
|
||||
isGroupDirty,
|
||||
isGroupSaving,
|
||||
getGroupVersion,
|
||||
}: {
|
||||
expandedModules: string[];
|
||||
onExpandedModulesChange: (modules: string[]) => void;
|
||||
appConfig: AppConfig;
|
||||
patchedAppConfig: AppConfig;
|
||||
onUpdate: (path: string, value: any) => void;
|
||||
onSaveGroup: (module: string) => Promise<void>;
|
||||
onResetGroup: (module: string) => void;
|
||||
isGroupDirty: (module: string) => boolean;
|
||||
isGroupSaving: (module: string) => boolean;
|
||||
getGroupVersion: (module: string) => number;
|
||||
}) => {
|
||||
const { currentModule } = useNav();
|
||||
const group = ALL_SETTING_GROUPS.find(
|
||||
group => group.module === currentModule
|
||||
);
|
||||
const [groupErrors, setGroupErrors] = useState<
|
||||
Record<string, Record<string, string>>
|
||||
>({});
|
||||
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
const onFieldErrorChange = useCallback((field: string, error?: string) => {
|
||||
const [module] = field.split('/');
|
||||
if (!module) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, module, fields, operations } = group;
|
||||
setGroupErrors(prev => {
|
||||
const moduleErrors = prev[module] ?? {};
|
||||
|
||||
if (error) {
|
||||
if (moduleErrors[field] === error) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[module]: {
|
||||
...moduleErrors,
|
||||
[field]: error,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!(field in moduleErrors)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const nextModuleErrors = { ...moduleErrors };
|
||||
delete nextModuleErrors[field];
|
||||
|
||||
if (Object.keys(nextModuleErrors).length === 0) {
|
||||
const next = { ...prev };
|
||||
delete next[module];
|
||||
return next;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[module]: nextModuleErrors,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearModuleErrors = useCallback((module: string) => {
|
||||
setGroupErrors(prev => {
|
||||
if (!prev[module]) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = { ...prev };
|
||||
delete next[module];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col h-full gap-5 py-5 px-6 w-full max-w-[800px] mx-auto">
|
||||
<div className="text-2xl font-semibold">{name}</div>
|
||||
<div className="flex flex-col gap-10" id={`config-module-${module}`}>
|
||||
{fields.map(field => {
|
||||
let desc: string;
|
||||
let props: ConfigInputProps;
|
||||
if (typeof field === 'string') {
|
||||
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field];
|
||||
desc = descriptor.desc;
|
||||
props = {
|
||||
field: `${module}/${field}`,
|
||||
desc,
|
||||
type: descriptor.type,
|
||||
options: [],
|
||||
defaultValue: get(appConfig[module], field),
|
||||
onChange: onUpdate,
|
||||
};
|
||||
} else {
|
||||
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field.key];
|
||||
<div className="flex flex-col gap-4 py-5 px-6 w-full max-w-[900px] mx-auto">
|
||||
<Accordion
|
||||
type="multiple"
|
||||
className="w-full"
|
||||
value={expandedModules}
|
||||
onValueChange={onExpandedModulesChange}
|
||||
>
|
||||
{ALL_SETTING_GROUPS.map(group => {
|
||||
const { name, module, fields, operations } = group;
|
||||
const dirty = isGroupDirty(module);
|
||||
const saving = isGroupSaving(module);
|
||||
const sourceConfig = patchedAppConfig[module] ?? appConfig[module];
|
||||
const version = getGroupVersion(module);
|
||||
const hasValidationError = Boolean(
|
||||
groupErrors[module] &&
|
||||
Object.keys(groupErrors[module] ?? {}).length > 0
|
||||
);
|
||||
|
||||
props = {
|
||||
field: `${module}/${field.key}${field.sub ? `/${field.sub}` : ''}`,
|
||||
desc: field.desc ?? descriptor.desc,
|
||||
type: field.type ?? descriptor.type,
|
||||
// @ts-expect-error for enum type
|
||||
options: field.options,
|
||||
defaultValue: get(
|
||||
appConfig[module],
|
||||
field.key + (field.sub ? '.' + field.sub : '')
|
||||
),
|
||||
onChange: onUpdate,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<AccordionItem
|
||||
key={module}
|
||||
value={module}
|
||||
id={`config-module-${module}`}
|
||||
className="border border-border rounded-xl px-5 mb-4"
|
||||
>
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<div className="flex flex-col items-start text-left gap-1">
|
||||
<div className="text-lg font-semibold">{name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Manage {name.toLowerCase()} settings
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
return <ConfigRow key={props.field} {...props} />;
|
||||
<AccordionContent className="pt-2 pb-2 px-1">
|
||||
<div
|
||||
className="flex flex-col gap-8"
|
||||
key={`${module}-${version}`}
|
||||
>
|
||||
{fields.map(field => {
|
||||
let props: ConfigInputProps;
|
||||
if (typeof field === 'string') {
|
||||
const descriptor =
|
||||
ALL_CONFIG_DESCRIPTORS[module][field];
|
||||
props = {
|
||||
field: `${module}/${field}`,
|
||||
desc: descriptor.desc,
|
||||
type: descriptor.type,
|
||||
options: [],
|
||||
defaultValue: get(sourceConfig, field),
|
||||
onChange: onUpdate,
|
||||
};
|
||||
} else {
|
||||
const descriptor =
|
||||
ALL_CONFIG_DESCRIPTORS[module][field.key];
|
||||
props = {
|
||||
field: `${module}/${field.key}${field.sub ? `/${field.sub}` : ''}`,
|
||||
desc: field.desc ?? descriptor.desc,
|
||||
type: field.type ?? descriptor.type,
|
||||
// @ts-expect-error for enum type
|
||||
options: field.options,
|
||||
defaultValue: get(
|
||||
sourceConfig,
|
||||
field.key + (field.sub ? '.' + field.sub : '')
|
||||
),
|
||||
onChange: onUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigRow
|
||||
key={props.field}
|
||||
{...props}
|
||||
onErrorChange={onFieldErrorChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{operations?.map(Operation => (
|
||||
<Operation
|
||||
key={Operation.name}
|
||||
appConfig={patchedAppConfig}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{dirty ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onResetGroup(module);
|
||||
clearModuleErrors(module);
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSaveGroup(module).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}}
|
||||
disabled={!dirty || saving || hasValidationError}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
{operations?.map(Operation => (
|
||||
<Operation key={Operation.name} appConfig={patchedAppConfig} />
|
||||
))}
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const mocked = vi.hoisted(() => {
|
||||
let queryState: {
|
||||
appConfig: {
|
||||
server: {
|
||||
name: string;
|
||||
hosts: string[];
|
||||
};
|
||||
auth: {
|
||||
allowSignup: boolean;
|
||||
};
|
||||
storages: {
|
||||
blob: {
|
||||
storage: {
|
||||
provider: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
} = {
|
||||
appConfig: {
|
||||
server: {
|
||||
name: '',
|
||||
hosts: [],
|
||||
},
|
||||
auth: {
|
||||
allowSignup: true,
|
||||
},
|
||||
storages: {
|
||||
blob: {
|
||||
storage: {
|
||||
provider: 'fs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
getQueryState: () => queryState,
|
||||
setQueryState: (next: typeof queryState) => {
|
||||
queryState = next;
|
||||
},
|
||||
mutateMock: vi.fn(),
|
||||
saveUpdatesMock: vi.fn(),
|
||||
notifySuccessMock: vi.fn(),
|
||||
notifyErrorMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@affine/admin/use-query', () => ({
|
||||
useQuery: () => ({
|
||||
data: mocked.getQueryState(),
|
||||
mutate: mocked.mutateMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@affine/admin/use-mutation', () => ({
|
||||
useMutation: () => ({
|
||||
trigger: mocked.saveUpdatesMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@affine/component', () => ({
|
||||
notify: {
|
||||
success: mocked.notifySuccessMock,
|
||||
error: mocked.notifyErrorMock,
|
||||
},
|
||||
}));
|
||||
|
||||
import { useAppConfig } from './use-app-config';
|
||||
|
||||
describe('useAppConfig', () => {
|
||||
beforeEach(() => {
|
||||
mocked.setQueryState({
|
||||
appConfig: {
|
||||
server: {
|
||||
name: 'AFFiNE',
|
||||
hosts: ['localhost'],
|
||||
},
|
||||
auth: {
|
||||
allowSignup: true,
|
||||
},
|
||||
storages: {
|
||||
blob: {
|
||||
storage: {
|
||||
provider: 'fs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mocked.mutateMock.mockReset();
|
||||
mocked.saveUpdatesMock.mockReset();
|
||||
mocked.notifySuccessMock.mockReset();
|
||||
mocked.notifyErrorMock.mockReset();
|
||||
|
||||
mocked.mutateMock.mockImplementation(async updater => {
|
||||
const currentState = mocked.getQueryState();
|
||||
if (typeof updater === 'function') {
|
||||
mocked.setQueryState(updater(currentState));
|
||||
}
|
||||
return mocked.getQueryState();
|
||||
});
|
||||
});
|
||||
|
||||
test('clears dirty state when value is changed back to original', () => {
|
||||
const { result } = renderHook(() => useAppConfig());
|
||||
|
||||
act(() => {
|
||||
result.current.update('server/name', 'AFFiNE Cloud');
|
||||
});
|
||||
expect(result.current.isGroupDirty('server')).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.update('server/name', 'AFFiNE');
|
||||
});
|
||||
expect(result.current.isGroupDirty('server')).toBe(false);
|
||||
});
|
||||
|
||||
test('resetGroup cancels only target group changes immediately', () => {
|
||||
const { result } = renderHook(() => useAppConfig());
|
||||
|
||||
act(() => {
|
||||
result.current.update('server/name', 'AFFiNE Cloud');
|
||||
result.current.update('auth/allowSignup', false);
|
||||
});
|
||||
|
||||
expect(result.current.isGroupDirty('server')).toBe(true);
|
||||
expect(result.current.isGroupDirty('auth')).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.resetGroup('server');
|
||||
});
|
||||
|
||||
expect(result.current.isGroupDirty('server')).toBe(false);
|
||||
expect(result.current.isGroupDirty('auth')).toBe(true);
|
||||
expect(result.current.patchedAppConfig.server.name).toBe('AFFiNE');
|
||||
expect(result.current.getGroupVersion('server')).toBe(1);
|
||||
});
|
||||
|
||||
test('saveGroup submits only target group updates and keeps others dirty', async () => {
|
||||
const { result } = renderHook(() => useAppConfig());
|
||||
|
||||
act(() => {
|
||||
result.current.update('server/name', 'AFFiNE Cloud');
|
||||
result.current.update('auth/allowSignup', false);
|
||||
});
|
||||
|
||||
mocked.saveUpdatesMock.mockResolvedValue({
|
||||
updateAppConfig: {
|
||||
server: {
|
||||
name: 'AFFiNE Cloud',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveGroup('server');
|
||||
});
|
||||
|
||||
expect(mocked.saveUpdatesMock).toHaveBeenCalledWith({
|
||||
updates: [
|
||||
{
|
||||
module: 'server',
|
||||
key: 'name',
|
||||
value: 'AFFiNE Cloud',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.current.isGroupDirty('server')).toBe(false);
|
||||
expect(result.current.isGroupDirty('auth')).toBe(true);
|
||||
expect(result.current.patchedAppConfig.server.name).toBe('AFFiNE Cloud');
|
||||
expect(result.current.getGroupVersion('server')).toBe(1);
|
||||
expect(mocked.notifySuccessMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('marks group dirty when nested enum-like option changes', () => {
|
||||
const { result } = renderHook(() => useAppConfig());
|
||||
|
||||
act(() => {
|
||||
result.current.update('storages/blob.storage/provider', 'aws-s3');
|
||||
});
|
||||
|
||||
expect(result.current.isGroupDirty('storages')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMutation } from '@affine/admin/use-mutation';
|
||||
import { useQuery } from '@affine/admin/use-query';
|
||||
import { notify } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import {
|
||||
appConfigQuery,
|
||||
@@ -9,13 +8,40 @@ import {
|
||||
updateAppConfigMutation,
|
||||
} from '@affine/graphql';
|
||||
import { cloneDeep, get, merge, set } from 'lodash-es';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { AppConfig } from './config';
|
||||
import { isEqual } from './utils';
|
||||
|
||||
export { type UpdateAppConfigInput };
|
||||
|
||||
export type AppConfigUpdates = Record<string, { from: any; to: any }>;
|
||||
type SaveResponse =
|
||||
| { updateAppConfig?: Partial<AppConfig> }
|
||||
| Partial<AppConfig>;
|
||||
|
||||
const getUpdateInputs = (
|
||||
entries: Array<[string, { from: any; to: any }]>
|
||||
): UpdateAppConfigInput[] => {
|
||||
return entries.map(([key, value]) => {
|
||||
const splitIndex = key.indexOf('.');
|
||||
const module = key.slice(0, splitIndex);
|
||||
const field = key.slice(splitIndex + 1);
|
||||
|
||||
return {
|
||||
module,
|
||||
key: field,
|
||||
value: value.to,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getSavedAppConfig = (response: SaveResponse): Partial<AppConfig> => {
|
||||
if ('updateAppConfig' in response) {
|
||||
return (response.updateAppConfig as Partial<AppConfig>) ?? {};
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
export const useAppConfig = () => {
|
||||
const {
|
||||
@@ -33,30 +59,70 @@ export const useAppConfig = () => {
|
||||
const [patchedAppConfig, setPatchedAppConfig] = useState<AppConfig>(() =>
|
||||
cloneDeep(appConfig)
|
||||
);
|
||||
const [savingModules, setSavingModules] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
const [groupVersions, setGroupVersions] = useState<Record<string, number>>(
|
||||
{}
|
||||
);
|
||||
|
||||
const save = useAsyncCallback(async () => {
|
||||
const updateInputs: UpdateAppConfigInput[] = Object.entries(updates).map(
|
||||
([key, value]) => {
|
||||
const splitIndex = key.indexOf('.');
|
||||
const module = key.slice(0, splitIndex);
|
||||
const field = key.slice(splitIndex + 1);
|
||||
useEffect(() => {
|
||||
if (Object.keys(updates).length === 0) {
|
||||
setPatchedAppConfig(cloneDeep(appConfig));
|
||||
}
|
||||
}, [appConfig, updates]);
|
||||
|
||||
return {
|
||||
module,
|
||||
key: field,
|
||||
value: value.to,
|
||||
};
|
||||
}
|
||||
);
|
||||
const getEntriesByModule = useCallback(
|
||||
(module: string, source: AppConfigUpdates = updates) => {
|
||||
return Object.entries(source).filter(([key]) =>
|
||||
key.startsWith(`${module}.`)
|
||||
);
|
||||
},
|
||||
[updates]
|
||||
);
|
||||
|
||||
const clearModuleUpdates = useCallback(
|
||||
(module: string) => {
|
||||
setUpdates(prev => {
|
||||
const next = { ...prev };
|
||||
Object.keys(next).forEach(key => {
|
||||
if (key.startsWith(`${module}.`)) {
|
||||
delete next[key];
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[setUpdates]
|
||||
);
|
||||
|
||||
const bumpGroupVersion = useCallback((module: string) => {
|
||||
setGroupVersions(prev => ({
|
||||
...prev,
|
||||
[module]: (prev[module] ?? 0) + 1,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const allEntries = Object.entries(updates);
|
||||
if (allEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const savedUpdates = await saveUpdates({
|
||||
updates: updateInputs,
|
||||
});
|
||||
const response = (await saveUpdates({
|
||||
updates: getUpdateInputs(allEntries),
|
||||
})) as SaveResponse;
|
||||
const savedAppConfig = getSavedAppConfig(response);
|
||||
|
||||
await mutate(prev => {
|
||||
return { appConfig: merge({}, prev, savedUpdates) };
|
||||
return {
|
||||
appConfig: merge({}, prev?.appConfig ?? {}, savedAppConfig),
|
||||
};
|
||||
});
|
||||
|
||||
setUpdates({});
|
||||
setPatchedAppConfig(prev => merge({}, prev, savedAppConfig));
|
||||
notify.success({
|
||||
title: 'Saved',
|
||||
message: 'Settings have been saved successfully.',
|
||||
@@ -71,6 +137,60 @@ export const useAppConfig = () => {
|
||||
}
|
||||
}, [updates, mutate, saveUpdates]);
|
||||
|
||||
const saveGroup = useCallback(
|
||||
async (module: string) => {
|
||||
const moduleEntries = getEntriesByModule(module);
|
||||
if (moduleEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingModules(prev => ({
|
||||
...prev,
|
||||
[module]: true,
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = (await saveUpdates({
|
||||
updates: getUpdateInputs(moduleEntries),
|
||||
})) as SaveResponse;
|
||||
const savedAppConfig = getSavedAppConfig(response);
|
||||
|
||||
await mutate(prev => {
|
||||
return {
|
||||
appConfig: merge({}, prev?.appConfig ?? {}, savedAppConfig),
|
||||
};
|
||||
});
|
||||
|
||||
clearModuleUpdates(module);
|
||||
setPatchedAppConfig(prev => merge({}, prev, savedAppConfig));
|
||||
bumpGroupVersion(module);
|
||||
notify.success({
|
||||
title: 'Saved',
|
||||
message: 'Settings have been saved successfully.',
|
||||
});
|
||||
} catch (e) {
|
||||
const error = UserFriendlyError.fromAny(e);
|
||||
notify.error({
|
||||
title: 'Failed to save',
|
||||
message: error.message,
|
||||
});
|
||||
console.error(e);
|
||||
} finally {
|
||||
setSavingModules(prev => ({
|
||||
...prev,
|
||||
[module]: false,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[
|
||||
bumpGroupVersion,
|
||||
clearModuleUpdates,
|
||||
getEntriesByModule,
|
||||
mutate,
|
||||
saveUpdates,
|
||||
]
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
(path: string, value: any) => {
|
||||
const [module, field, subField] = path.split('/');
|
||||
@@ -78,9 +198,15 @@ export const useAppConfig = () => {
|
||||
const from = get(appConfig, key);
|
||||
setUpdates(prev => {
|
||||
const to = subField
|
||||
? set(prev[key]?.to ?? { ...from }, subField, value)
|
||||
? set(cloneDeep(prev[key]?.to ?? from ?? {}), subField, value)
|
||||
: value;
|
||||
|
||||
if (isEqual(from, to)) {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[key]: {
|
||||
@@ -91,21 +217,62 @@ export const useAppConfig = () => {
|
||||
});
|
||||
|
||||
setPatchedAppConfig(prev => {
|
||||
return set(
|
||||
prev,
|
||||
`${module}.${field}${subField ? `.${subField}` : ''}`,
|
||||
value
|
||||
);
|
||||
const next = cloneDeep(prev);
|
||||
if (subField) {
|
||||
const nextValue = set(
|
||||
cloneDeep(get(next, `${module}.${field}`) ?? {}),
|
||||
subField,
|
||||
value
|
||||
);
|
||||
set(next, `${module}.${field}`, nextValue);
|
||||
return next;
|
||||
}
|
||||
set(next, `${module}.${field}`, value);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[appConfig]
|
||||
);
|
||||
|
||||
const resetGroup = useCallback(
|
||||
(module: string) => {
|
||||
clearModuleUpdates(module);
|
||||
setPatchedAppConfig(prev => {
|
||||
return {
|
||||
...prev,
|
||||
[module]: cloneDeep(appConfig[module]),
|
||||
};
|
||||
});
|
||||
bumpGroupVersion(module);
|
||||
},
|
||||
[appConfig, bumpGroupVersion, clearModuleUpdates]
|
||||
);
|
||||
|
||||
const isGroupDirty = useCallback(
|
||||
(module: string) => getEntriesByModule(module).length > 0,
|
||||
[getEntriesByModule]
|
||||
);
|
||||
|
||||
const isGroupSaving = useCallback(
|
||||
(module: string) => Boolean(savingModules[module]),
|
||||
[savingModules]
|
||||
);
|
||||
|
||||
const getGroupVersion = useCallback(
|
||||
(module: string) => groupVersions[module] ?? 0,
|
||||
[groupVersions]
|
||||
);
|
||||
|
||||
return {
|
||||
appConfig: appConfig as AppConfig,
|
||||
patchedAppConfig,
|
||||
update,
|
||||
save,
|
||||
saveGroup,
|
||||
resetGroup,
|
||||
isGroupDirty,
|
||||
isGroupSaving,
|
||||
getGroupVersion,
|
||||
updates,
|
||||
};
|
||||
};
|
||||
|
||||
22
yarn.lock
22
yarn.lock
@@ -215,6 +215,7 @@ __metadata:
|
||||
"@radix-ui/react-tooltip": "npm:^1.1.5"
|
||||
"@sentry/react": "npm:^9.47.1"
|
||||
"@tanstack/react-table": "npm:^8.20.5"
|
||||
"@testing-library/react": "npm:^16.3.2"
|
||||
"@toeverything/infra": "workspace:*"
|
||||
"@toeverything/theme": "npm:^1.1.23"
|
||||
"@types/lodash-es": "npm:^4.17.12"
|
||||
@@ -241,6 +242,7 @@ __metadata:
|
||||
tailwindcss: "npm:^4.1.17"
|
||||
tailwindcss-animate: "npm:^1.0.7"
|
||||
vaul: "npm:^1.1.2"
|
||||
vitest: "npm:^3.2.4"
|
||||
zod: "npm:^3.25.76"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
@@ -16575,6 +16577,26 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@testing-library/react@npm:^16.3.2":
|
||||
version: 16.3.2
|
||||
resolution: "@testing-library/react@npm:16.3.2"
|
||||
dependencies:
|
||||
"@babel/runtime": "npm:^7.12.5"
|
||||
peerDependencies:
|
||||
"@testing-library/dom": ^10.0.0
|
||||
"@types/react": ^18.0.0 || ^19.0.0
|
||||
"@types/react-dom": ^18.0.0 || ^19.0.0
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
"@types/react":
|
||||
optional: true
|
||||
"@types/react-dom":
|
||||
optional: true
|
||||
checksum: 10/0ca88c6f672d00c2afd1bdedeff9b5382dd8157038efeb9762dc016731030075624be7106b92d2b5e5c52812faea85263e69272c14b6f8700eb48a4a8af6feef
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@testing-library/user-event@npm:^14.6.1":
|
||||
version: 14.6.1
|
||||
resolution: "@testing-library/user-event@npm:14.6.1"
|
||||
|
||||
Reference in New Issue
Block a user