mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-22 00:37:05 +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
|
name: Wait for approval
|
||||||
with:
|
with:
|
||||||
secret: ${{ secrets.GITHUB_TOKEN }}
|
secret: ${{ secrets.GITHUB_TOKEN }}
|
||||||
approvers: darkskygit,pengx17,L-Sun,EYHN
|
approvers: darkskygit
|
||||||
minimum-approvals: 1
|
minimum-approvals: 1
|
||||||
fail-on-denial: true
|
fail-on-denial: true
|
||||||
issue-title: Please confirm to release docker image
|
issue-title: Please confirm to release docker image
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Field,
|
Field,
|
||||||
|
Info,
|
||||||
InputType,
|
InputType,
|
||||||
Int,
|
Int,
|
||||||
Mutation,
|
Mutation,
|
||||||
@@ -14,6 +15,12 @@ import {
|
|||||||
ResolveField,
|
ResolveField,
|
||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
|
import {
|
||||||
|
type FragmentDefinitionNode,
|
||||||
|
type GraphQLResolveInfo,
|
||||||
|
Kind,
|
||||||
|
type SelectionNode,
|
||||||
|
} from 'graphql';
|
||||||
import { SafeIntResolver } from 'graphql-scalars';
|
import { SafeIntResolver } from 'graphql-scalars';
|
||||||
|
|
||||||
import { PaginationInput, URLHelper } from '../../../base';
|
import { PaginationInput, URLHelper } from '../../../base';
|
||||||
@@ -53,6 +60,44 @@ registerEnumType(AdminSharedLinksOrder, {
|
|||||||
name: '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()
|
@InputType()
|
||||||
class ListWorkspaceInput {
|
class ListWorkspaceInput {
|
||||||
@Field(() => Int, { defaultValue: 20 })
|
@Field(() => Int, { defaultValue: 20 })
|
||||||
@@ -471,22 +516,40 @@ export class AdminWorkspaceResolver {
|
|||||||
})
|
})
|
||||||
async adminDashboard(
|
async adminDashboard(
|
||||||
@Args('input', { nullable: true, type: () => AdminDashboardInput })
|
@Args('input', { nullable: true, type: () => AdminDashboardInput })
|
||||||
input?: AdminDashboardInput
|
input?: AdminDashboardInput,
|
||||||
|
@Info() info?: GraphQLResolveInfo
|
||||||
) {
|
) {
|
||||||
this.assertCloudOnly();
|
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({
|
const dashboard = await this.models.workspaceAnalytics.adminGetDashboard({
|
||||||
timezone: input?.timezone,
|
timezone: input?.timezone,
|
||||||
storageHistoryDays: input?.storageHistoryDays,
|
storageHistoryDays: input?.storageHistoryDays,
|
||||||
syncHistoryHours: input?.syncHistoryHours,
|
syncHistoryHours: input?.syncHistoryHours,
|
||||||
sharedLinkWindowDays: input?.sharedLinkWindowDays,
|
sharedLinkWindowDays: input?.sharedLinkWindowDays,
|
||||||
|
includeTopSharedLinks,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...dashboard,
|
...dashboard,
|
||||||
topSharedLinks: dashboard.topSharedLinks.map(link => ({
|
topSharedLinks: includeTopSharedLinks
|
||||||
...link,
|
? dashboard.topSharedLinks.map(link => ({
|
||||||
shareUrl: this.url.link(`/workspace/${link.workspaceId}/${link.docId}`),
|
...link,
|
||||||
})),
|
shareUrl: this.url.link(
|
||||||
|
`/workspace/${link.workspaceId}/${link.docId}`
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export type AdminDashboardOptions = {
|
|||||||
storageHistoryDays?: number;
|
storageHistoryDays?: number;
|
||||||
syncHistoryHours?: number;
|
syncHistoryHours?: number;
|
||||||
sharedLinkWindowDays?: number;
|
sharedLinkWindowDays?: number;
|
||||||
|
includeTopSharedLinks?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminAllSharedLinksOptions = {
|
export type AdminAllSharedLinksOptions = {
|
||||||
@@ -262,6 +263,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
|||||||
90,
|
90,
|
||||||
DEFAULT_SHARED_LINK_WINDOW_DAYS
|
DEFAULT_SHARED_LINK_WINDOW_DAYS
|
||||||
);
|
);
|
||||||
|
const includeTopSharedLinks = options.includeTopSharedLinks ?? true;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@@ -274,6 +276,66 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
|||||||
const storageFrom = addUtcDays(currentDay, -(storageHistoryDays - 1));
|
const storageFrom = addUtcDays(currentDay, -(storageHistoryDays - 1));
|
||||||
const sharedFrom = addUtcDays(currentDay, -(sharedLinkWindowDays - 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 [
|
const [
|
||||||
syncCurrent,
|
syncCurrent,
|
||||||
syncTimeline,
|
syncTimeline,
|
||||||
@@ -350,63 +412,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
|||||||
AND created_at >= ${sharedFrom}
|
AND created_at >= ${sharedFrom}
|
||||||
AND created_at <= ${now}
|
AND created_at <= ${now}
|
||||||
`,
|
`,
|
||||||
this.db.$queryRaw<
|
topSharedLinksPromise,
|
||||||
{
|
|
||||||
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
|
|
||||||
`,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const storageHistorySeries = storageHistory.map(row => ({
|
const storageHistorySeries = storageHistory.map(row => ({
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ export class IndexerJob {
|
|||||||
indexed: true,
|
indexed: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (!missingDocIds.length && !deletedDocIds.length) {
|
||||||
|
this.logger.verbose(`workspace ${workspaceId} is already indexed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`indexed workspace ${workspaceId} with ${missingDocIds.length} missing docs and ${deletedDocIds.length} deleted docs`
|
`indexed workspace ${workspaceId} with ${missingDocIds.length} missing docs and ${deletedDocIds.length} deleted docs`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -67,7 +68,8 @@
|
|||||||
"shadcn-ui": "^0.9.5",
|
"shadcn-ui": "^0.9.5",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "affine bundle",
|
"build": "affine bundle",
|
||||||
|
|||||||
@@ -134,12 +134,7 @@ export const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path={ROUTES.admin.settings.index}
|
path={ROUTES.admin.settings.index}
|
||||||
element={<Settings />}
|
element={<Settings />}
|
||||||
>
|
/>
|
||||||
<Route
|
|
||||||
path={ROUTES.admin.settings.module}
|
|
||||||
element={<Settings />}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@affine/admin/components/ui/select';
|
} from '@affine/admin/components/ui/select';
|
||||||
import { Separator } from '@affine/admin/components/ui/separator';
|
import { Separator } from '@affine/admin/components/ui/separator';
|
||||||
|
import { Skeleton } from '@affine/admin/components/ui/skeleton';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -38,13 +39,82 @@ import {
|
|||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from 'lucide-react';
|
} 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 { Link } from 'react-router-dom';
|
||||||
import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
|
import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
import { useMutateQueryResource } from '../../use-mutation';
|
||||||
import { Header } from '../header';
|
import { Header } from '../header';
|
||||||
import { formatBytes } from '../workspaces/utils';
|
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 intFormatter = new Intl.NumberFormat('en-US');
|
||||||
const compactFormatter = new Intl.NumberFormat('en-US', {
|
const compactFormatter = new Intl.NumberFormat('en-US', {
|
||||||
notation: 'compact',
|
notation: 'compact',
|
||||||
@@ -260,7 +330,7 @@ function PrimaryMetricCard({
|
|||||||
description: string;
|
description: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription className="flex items-center gap-2 text-foreground/75">
|
<CardDescription className="flex items-center gap-2 text-foreground/75">
|
||||||
<UsersIcon className="h-4 w-4" aria-hidden="true" />
|
<UsersIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
@@ -289,7 +359,7 @@ function SecondaryMetricCard({
|
|||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription className="flex items-center gap-2">
|
<CardDescription className="flex items-center gap-2">
|
||||||
<span aria-hidden="true">{icon}</span>
|
<span aria-hidden="true">{icon}</span>
|
||||||
@@ -322,7 +392,7 @@ function WindowSelect({
|
|||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 min-w-40">
|
<div className="flex min-w-0 flex-col gap-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
className="text-xs uppercase tracking-wide text-muted-foreground"
|
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 [storageHistoryDays, setStorageHistoryDays] = useState<number>(30);
|
||||||
const [syncHistoryHours, setSyncHistoryHours] = useState<number>(48);
|
const [syncHistoryHours, setSyncHistoryHours] = useState<number>(48);
|
||||||
const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState<number>(28);
|
const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState<number>(28);
|
||||||
|
const shouldShowTopSharedLinks = !environment.isSelfHosted;
|
||||||
|
const revalidateQueryResource = useMutateQueryResource();
|
||||||
|
|
||||||
const variables = useMemo(
|
const variables = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -365,9 +617,9 @@ export function DashboardPage() {
|
|||||||
[sharedLinkWindowDays, storageHistoryDays, syncHistoryHours]
|
[sharedLinkWindowDays, storageHistoryDays, syncHistoryHours]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isValidating, mutate } = useQuery(
|
const { data, isValidating } = useQuery(
|
||||||
{
|
{
|
||||||
query: adminDashboardQuery,
|
query: adminDashboardOverviewQuery,
|
||||||
variables,
|
variables,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -421,7 +673,7 @@ export function DashboardPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
mutate().catch(() => {});
|
revalidateQueryResource(adminDashboardQuery).catch(() => {});
|
||||||
}}
|
}}
|
||||||
disabled={isValidating}
|
disabled={isValidating}
|
||||||
>
|
>
|
||||||
@@ -444,7 +696,7 @@ export function DashboardPage() {
|
|||||||
automatically.
|
automatically.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</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
|
<WindowSelect
|
||||||
id="storage-history-window"
|
id="storage-history-window"
|
||||||
label="Storage History"
|
label="Storage History"
|
||||||
@@ -472,36 +724,42 @@ export function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="grid gap-5 grid-cols-1 lg:grid-cols-12">
|
<div className="grid gap-5 grid-cols-1 min-[1024px]:grid-cols-12">
|
||||||
<PrimaryMetricCard
|
<div className="min-w-0 h-full min-[1024px]:col-span-5">
|
||||||
value={intFormatter.format(dashboard.syncActiveUsers)}
|
<PrimaryMetricCard
|
||||||
description={`${dashboard.syncWindow.effectiveSize}h active window`}
|
value={intFormatter.format(dashboard.syncActiveUsers)}
|
||||||
/>
|
description={`${dashboard.syncWindow.effectiveSize}h active window`}
|
||||||
<SecondaryMetricCard
|
/>
|
||||||
title="Copilot Conversations"
|
</div>
|
||||||
value={intFormatter.format(dashboard.copilotConversations)}
|
<div className="min-w-0 h-full min-[1024px]:col-span-3">
|
||||||
description={`${dashboard.topSharedLinksWindow.effectiveSize}d aggregation`}
|
<SecondaryMetricCard
|
||||||
icon={
|
title="Copilot Conversations"
|
||||||
<MessageSquareTextIcon className="h-4 w-4" aria-hidden="true" />
|
value={intFormatter.format(dashboard.copilotConversations)}
|
||||||
}
|
description={`${sharedLinkWindowDays}d aggregation`}
|
||||||
/>
|
icon={
|
||||||
<Card className="lg:col-span-4 border-border/70 bg-gradient-to-br from-card via-card to-muted/15 shadow-sm">
|
<MessageSquareTextIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
<CardHeader className="pb-2">
|
}
|
||||||
<CardDescription className="flex items-center gap-2">
|
/>
|
||||||
<DatabaseIcon className="h-4 w-4" aria-hidden="true" />
|
</div>
|
||||||
Managed Storage
|
<div className="min-w-0 h-full min-[1024px]:col-span-4">
|
||||||
</CardDescription>
|
<Card className="h-full border-border/70 bg-gradient-to-br from-card via-card to-muted/15 shadow-sm">
|
||||||
</CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardContent>
|
<CardDescription className="flex items-center gap-2">
|
||||||
<div className="text-2xl font-semibold tracking-tight tabular-nums">
|
<DatabaseIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
{formatBytes(totalStorageBytes)}
|
Managed Storage
|
||||||
</div>
|
</CardDescription>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
</CardHeader>
|
||||||
Workspace {formatBytes(dashboard.workspaceStorageBytes)} • Blob{' '}
|
<CardContent>
|
||||||
{formatBytes(dashboard.blobStorageBytes)}
|
<div className="text-2xl font-semibold tracking-tight tabular-nums">
|
||||||
</p>
|
{formatBytes(totalStorageBytes)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Workspace {formatBytes(dashboard.workspaceStorageBytes)} •
|
||||||
|
Blob {formatBytes(dashboard.blobStorageBytes)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-5 grid-cols-1 xl:grid-cols-3">
|
<div className="grid gap-5 grid-cols-1 xl:grid-cols-3">
|
||||||
@@ -557,89 +815,24 @@ export function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-border/70 bg-card/95 shadow-sm">
|
{shouldShowTopSharedLinks ? (
|
||||||
<CardHeader>
|
<Suspense fallback={<TopSharedLinksCardSkeleton />}>
|
||||||
<CardTitle className="text-base">Top Shared Links</CardTitle>
|
<TopSharedLinksSection
|
||||||
<CardDescription>
|
sharedLinkWindowDays={sharedLinkWindowDays}
|
||||||
Top {dashboard.topSharedLinks.length} links in the last{' '}
|
/>
|
||||||
{dashboard.topSharedLinksWindow.effectiveSize} days
|
</Suspense>
|
||||||
</CardDescription>
|
) : null}
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<DashboardPageSkeleton />}>
|
||||||
|
<DashboardPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { DashboardPage as Component };
|
export { DashboardPage as Component };
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
} from '../components/ui/sheet';
|
} from '../components/ui/sheet';
|
||||||
import { Logo } from './accounts/components/logo';
|
import { Logo } from './accounts/components/logo';
|
||||||
import { useMediaQuery } from './common';
|
import { useMediaQuery } from './common';
|
||||||
import { NavContext } from './nav/context';
|
|
||||||
import { Nav } from './nav/nav';
|
import { Nav } from './nav/nav';
|
||||||
import {
|
import {
|
||||||
PanelContext,
|
PanelContext,
|
||||||
@@ -43,10 +42,6 @@ export function Layout({ children }: PropsWithChildren) {
|
|||||||
const leftPanelRef = useRef<ImperativePanelHandle>(null);
|
const leftPanelRef = useRef<ImperativePanelHandle>(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('');
|
|
||||||
const [activeSubTab, setActiveSubTab] = useState('server');
|
|
||||||
const [currentModule, setCurrentModule] = useState('server');
|
|
||||||
|
|
||||||
const handleLeftExpand = useCallback(() => {
|
const handleLeftExpand = useCallback(() => {
|
||||||
if (leftPanelRef.current?.getSize() === 0) {
|
if (leftPanelRef.current?.getSize() === 0) {
|
||||||
leftPanelRef.current?.resize(30);
|
leftPanelRef.current?.resize(30);
|
||||||
@@ -151,36 +146,25 @@ export function Layout({ children }: PropsWithChildren) {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<NavContext.Provider
|
<TooltipProvider delayDuration={0}>
|
||||||
value={{
|
<div className="flex h-screen w-full overflow-hidden">
|
||||||
activeTab,
|
<ResizablePanelGroup direction="horizontal">
|
||||||
activeSubTab,
|
<LeftPanel
|
||||||
currentModule,
|
panelRef={leftPanelRef as RefObject<ImperativePanelHandle>}
|
||||||
setActiveTab,
|
onExpand={handleLeftExpand}
|
||||||
setActiveSubTab,
|
onCollapse={handleLeftCollapse}
|
||||||
setCurrentModule,
|
/>
|
||||||
}}
|
<ResizablePanel id="1" order={1} minSize={50} defaultSize={50}>
|
||||||
>
|
{children}
|
||||||
<TooltipProvider delayDuration={0}>
|
</ResizablePanel>
|
||||||
<div className="flex h-screen w-full overflow-hidden">
|
<RightPanel
|
||||||
<ResizablePanelGroup direction="horizontal">
|
panelRef={rightPanelRef as RefObject<ImperativePanelHandle>}
|
||||||
<LeftPanel
|
onExpand={handleRightExpand}
|
||||||
panelRef={leftPanelRef as RefObject<ImperativePanelHandle>}
|
onCollapse={handleRightCollapse}
|
||||||
onExpand={handleLeftExpand}
|
/>
|
||||||
onCollapse={handleLeftCollapse}
|
</ResizablePanelGroup>
|
||||||
/>
|
</div>
|
||||||
<ResizablePanel id="1" order={1} minSize={50} defaultSize={50}>
|
</TooltipProvider>
|
||||||
{children}
|
|
||||||
</ResizablePanel>
|
|
||||||
<RightPanel
|
|
||||||
panelRef={rightPanelRef as RefObject<ImperativePanelHandle>}
|
|
||||||
onExpand={handleRightExpand}
|
|
||||||
onCollapse={handleRightCollapse}
|
|
||||||
/>
|
|
||||||
</ResizablePanelGroup>
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
</NavContext.Provider>
|
|
||||||
</PanelContext.Provider>
|
</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 { buttonVariants } from '@affine/admin/components/ui/button';
|
||||||
import { cn } from '@affine/admin/utils';
|
import { cn } from '@affine/admin/utils';
|
||||||
import { SettingsIcon } from '@blocksuite/icons/rc';
|
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 { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { NavLink } from 'react-router-dom';
|
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 }) => {
|
export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
||||||
const { setCurrentModule } = useNav();
|
|
||||||
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
return (
|
return (
|
||||||
<NavigationMenuPrimitive.Root
|
<NavLink
|
||||||
className="flex-none relative"
|
to="/admin/settings"
|
||||||
orientation="vertical"
|
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>
|
<SettingsIcon fontSize={20} />
|
||||||
<NavigationMenuPrimitive.Item>
|
</NavLink>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion type="multiple" className="w-full overflow-hidden">
|
<NavLink
|
||||||
<AccordionItem
|
to="/admin/settings"
|
||||||
value="item-1"
|
className={cn(
|
||||||
className="border-b-0 h-full flex flex-col gap-1 w-full"
|
buttonVariants({
|
||||||
>
|
variant: 'ghost',
|
||||||
<NavLink
|
}),
|
||||||
to={'/admin/settings'}
|
'justify-start flex-none text-sm font-medium px-2'
|
||||||
className={cn(
|
)}
|
||||||
buttonVariants({
|
style={({ isActive }) => ({
|
||||||
variant: 'ghost',
|
backgroundColor: isActive
|
||||||
}),
|
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||||
'justify-start flex-none w-full px-2'
|
: undefined,
|
||||||
)}
|
'&:hover': {
|
||||||
style={({ isActive }) => ({
|
backgroundColor: cssVarV2('selfhost/button/sidebarButton/bg/hover'),
|
||||||
backgroundColor: isActive
|
},
|
||||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
})}
|
||||||
: undefined,
|
>
|
||||||
})}
|
<span className="flex items-center p-0.5 mr-2">
|
||||||
>
|
<SettingsIcon fontSize={20} />
|
||||||
<AccordionTrigger
|
</span>
|
||||||
className={
|
Settings
|
||||||
'flex items-center justify-between w-full [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360'
|
</NavLink>
|
||||||
}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
SelectValue,
|
||||||
} from '@affine/admin/components/ui/select';
|
} from '@affine/admin/components/ui/select';
|
||||||
import { Switch } from '@affine/admin/components/ui/switch';
|
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';
|
import { Textarea } from '../../components/ui/textarea';
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export type ConfigInputProps = {
|
|||||||
defaultValue: any;
|
defaultValue: any;
|
||||||
onChange: (field: string, value: any) => void;
|
onChange: (field: string, value: any) => void;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
onErrorChange?: (field: string, error?: string) => void;
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
type: 'String' | 'Number' | 'Boolean' | 'JSON';
|
type: 'String' | 'Number' | 'Boolean' | 'JSON';
|
||||||
@@ -34,6 +36,7 @@ const Inputs: Record<
|
|||||||
onChange: (value?: any) => void;
|
onChange: (value?: any) => void;
|
||||||
options?: string[];
|
options?: string[];
|
||||||
error?: string;
|
error?: string;
|
||||||
|
onValidationChange?: (error?: string) => void;
|
||||||
}>
|
}>
|
||||||
> = {
|
> = {
|
||||||
Boolean: function SwitchInput({ defaultValue, onChange }) {
|
Boolean: function SwitchInput({ defaultValue, onChange }) {
|
||||||
@@ -43,7 +46,7 @@ const Inputs: Record<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch
|
<Switch
|
||||||
defaultChecked={defaultValue}
|
checked={Boolean(defaultValue)}
|
||||||
onCheckedChange={handleSwitchChange}
|
onCheckedChange={handleSwitchChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -57,43 +60,78 @@ const Inputs: Record<
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
minLength={1}
|
minLength={1}
|
||||||
defaultValue={defaultValue}
|
value={defaultValue ?? ''}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
Number: function NumberInput({ defaultValue, onChange }) {
|
Number: function NumberInput({ defaultValue, onChange }) {
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
onChange(parseInt(e.target.value));
|
const next = e.target.value;
|
||||||
|
onChange(next === '' ? undefined : parseInt(next, 10));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
defaultValue={defaultValue}
|
value={defaultValue ?? ''}
|
||||||
onChange={handleInputChange}
|
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 handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const nextText = e.target.value;
|
||||||
|
setText(nextText);
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(e.target.value);
|
const value = JSON.parse(nextText);
|
||||||
|
onValidationChange?.(undefined);
|
||||||
onChange(value);
|
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 (
|
return (
|
||||||
<Textarea
|
<Textarea
|
||||||
defaultValue={JSON.stringify(defaultValue)}
|
value={text}
|
||||||
onChange={handleInputChange}
|
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 }) {
|
Enum: function EnumInput({ defaultValue, onChange, options }) {
|
||||||
return (
|
return (
|
||||||
<Select defaultValue={defaultValue} onValueChange={onChange}>
|
<Select
|
||||||
|
value={typeof defaultValue === 'string' ? defaultValue : undefined}
|
||||||
|
onValueChange={onChange}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select an option" />
|
<SelectValue placeholder="Select an option" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -116,9 +154,11 @@ export const ConfigRow = ({
|
|||||||
defaultValue,
|
defaultValue,
|
||||||
onChange,
|
onChange,
|
||||||
error,
|
error,
|
||||||
|
onErrorChange,
|
||||||
...props
|
...props
|
||||||
}: ConfigInputProps) => {
|
}: ConfigInputProps) => {
|
||||||
const Input = Inputs[type] ?? Inputs.JSON;
|
const Input = Inputs[type] ?? Inputs.JSON;
|
||||||
|
const [validationError, setValidationError] = useState<string>();
|
||||||
|
|
||||||
const onValueChange = useCallback(
|
const onValueChange = useCallback(
|
||||||
(value?: any) => {
|
(value?: any) => {
|
||||||
@@ -127,6 +167,19 @@ export const ConfigRow = ({
|
|||||||
[field, onChange]
|
[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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex justify-between flex-grow space-y-[10px]
|
className={`flex justify-between flex-grow space-y-[10px]
|
||||||
@@ -140,12 +193,13 @@ export const ConfigRow = ({
|
|||||||
<Input
|
<Input
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
onChange={onValueChange}
|
onChange={onValueChange}
|
||||||
error={error}
|
error={mergedError}
|
||||||
|
onValidationChange={onValidationChange}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && (
|
{mergedError && (
|
||||||
<div className="absolute bottom-[-25px] text-sm right-0 break-words text-red-500">
|
<div className="mt-1 w-full text-sm break-words text-red-500">
|
||||||
{error}
|
{mergedError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 { Button } from '@affine/admin/components/ui/button';
|
||||||
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
||||||
import { get } from 'lodash-es';
|
import { get } from 'lodash-es';
|
||||||
import { CheckIcon } from 'lucide-react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
import { Header } from '../header';
|
import { Header } from '../header';
|
||||||
import { useNav } from '../nav/context';
|
|
||||||
import {
|
import {
|
||||||
ALL_CONFIG_DESCRIPTORS,
|
ALL_CONFIG_DESCRIPTORS,
|
||||||
ALL_SETTING_GROUPS,
|
ALL_SETTING_GROUPS,
|
||||||
@@ -15,104 +19,237 @@ import { type ConfigInputProps, ConfigRow } from './config-input-row';
|
|||||||
import { useAppConfig } from './use-app-config';
|
import { useAppConfig } from './use-app-config';
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { appConfig, update, save, patchedAppConfig, updates } = useAppConfig();
|
const {
|
||||||
const disableSave = Object.keys(updates).length === 0;
|
appConfig,
|
||||||
|
update,
|
||||||
const saveChanges = useCallback(() => {
|
saveGroup,
|
||||||
if (disableSave) {
|
resetGroup,
|
||||||
return;
|
patchedAppConfig,
|
||||||
}
|
isGroupDirty,
|
||||||
save();
|
isGroupSaving,
|
||||||
}, [save, disableSave]);
|
getGroupVersion,
|
||||||
|
} = useAppConfig();
|
||||||
|
const [expandedModules, setExpandedModules] = useState<string[]>([]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex-1 flex-col flex">
|
<div className="h-screen flex-1 flex-col flex">
|
||||||
<Header
|
<Header title="Settings" />
|
||||||
title="Settings"
|
|
||||||
endFix={
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="icon"
|
|
||||||
className="w-7 h-7"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={saveChanges}
|
|
||||||
disabled={disableSave}
|
|
||||||
>
|
|
||||||
<CheckIcon size={20} />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<AdminPanel
|
<AdminPanel
|
||||||
|
expandedModules={expandedModules}
|
||||||
|
onExpandedModulesChange={setExpandedModules}
|
||||||
onUpdate={update}
|
onUpdate={update}
|
||||||
appConfig={appConfig}
|
appConfig={appConfig}
|
||||||
patchedAppConfig={patchedAppConfig}
|
patchedAppConfig={patchedAppConfig}
|
||||||
|
onSaveGroup={saveGroup}
|
||||||
|
onResetGroup={resetGroup}
|
||||||
|
isGroupDirty={isGroupDirty}
|
||||||
|
isGroupSaving={isGroupSaving}
|
||||||
|
getGroupVersion={getGroupVersion}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const AdminPanel = ({
|
const AdminPanel = ({
|
||||||
|
expandedModules,
|
||||||
|
onExpandedModulesChange,
|
||||||
appConfig,
|
appConfig,
|
||||||
patchedAppConfig,
|
patchedAppConfig,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
onSaveGroup,
|
||||||
|
onResetGroup,
|
||||||
|
isGroupDirty,
|
||||||
|
isGroupSaving,
|
||||||
|
getGroupVersion,
|
||||||
}: {
|
}: {
|
||||||
|
expandedModules: string[];
|
||||||
|
onExpandedModulesChange: (modules: string[]) => void;
|
||||||
appConfig: AppConfig;
|
appConfig: AppConfig;
|
||||||
patchedAppConfig: AppConfig;
|
patchedAppConfig: AppConfig;
|
||||||
onUpdate: (path: string, value: any) => void;
|
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 [groupErrors, setGroupErrors] = useState<
|
||||||
const group = ALL_SETTING_GROUPS.find(
|
Record<string, Record<string, string>>
|
||||||
group => group.module === currentModule
|
>({});
|
||||||
);
|
|
||||||
|
|
||||||
if (!group) {
|
const onFieldErrorChange = useCallback((field: string, error?: string) => {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<ScrollArea className="h-full">
|
<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="flex flex-col gap-4 py-5 px-6 w-full max-w-[900px] mx-auto">
|
||||||
<div className="text-2xl font-semibold">{name}</div>
|
<Accordion
|
||||||
<div className="flex flex-col gap-10" id={`config-module-${module}`}>
|
type="multiple"
|
||||||
{fields.map(field => {
|
className="w-full"
|
||||||
let desc: string;
|
value={expandedModules}
|
||||||
let props: ConfigInputProps;
|
onValueChange={onExpandedModulesChange}
|
||||||
if (typeof field === 'string') {
|
>
|
||||||
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field];
|
{ALL_SETTING_GROUPS.map(group => {
|
||||||
desc = descriptor.desc;
|
const { name, module, fields, operations } = group;
|
||||||
props = {
|
const dirty = isGroupDirty(module);
|
||||||
field: `${module}/${field}`,
|
const saving = isGroupSaving(module);
|
||||||
desc,
|
const sourceConfig = patchedAppConfig[module] ?? appConfig[module];
|
||||||
type: descriptor.type,
|
const version = getGroupVersion(module);
|
||||||
options: [],
|
const hasValidationError = Boolean(
|
||||||
defaultValue: get(appConfig[module], field),
|
groupErrors[module] &&
|
||||||
onChange: onUpdate,
|
Object.keys(groupErrors[module] ?? {}).length > 0
|
||||||
};
|
);
|
||||||
} else {
|
|
||||||
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field.key];
|
|
||||||
|
|
||||||
props = {
|
return (
|
||||||
field: `${module}/${field.key}${field.sub ? `/${field.sub}` : ''}`,
|
<AccordionItem
|
||||||
desc: field.desc ?? descriptor.desc,
|
key={module}
|
||||||
type: field.type ?? descriptor.type,
|
value={module}
|
||||||
// @ts-expect-error for enum type
|
id={`config-module-${module}`}
|
||||||
options: field.options,
|
className="border border-border rounded-xl px-5 mb-4"
|
||||||
defaultValue: get(
|
>
|
||||||
appConfig[module],
|
<AccordionTrigger className="hover:no-underline py-4">
|
||||||
field.key + (field.sub ? '.' + field.sub : '')
|
<div className="flex flex-col items-start text-left gap-1">
|
||||||
),
|
<div className="text-lg font-semibold">{name}</div>
|
||||||
onChange: onUpdate,
|
<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 => (
|
</Accordion>
|
||||||
<Operation key={Operation.name} appConfig={patchedAppConfig} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</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 { useMutation } from '@affine/admin/use-mutation';
|
||||||
import { useQuery } from '@affine/admin/use-query';
|
import { useQuery } from '@affine/admin/use-query';
|
||||||
import { notify } from '@affine/component';
|
import { notify } from '@affine/component';
|
||||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
|
||||||
import { UserFriendlyError } from '@affine/error';
|
import { UserFriendlyError } from '@affine/error';
|
||||||
import {
|
import {
|
||||||
appConfigQuery,
|
appConfigQuery,
|
||||||
@@ -9,13 +8,40 @@ import {
|
|||||||
updateAppConfigMutation,
|
updateAppConfigMutation,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import { cloneDeep, get, merge, set } from 'lodash-es';
|
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 type { AppConfig } from './config';
|
||||||
|
import { isEqual } from './utils';
|
||||||
|
|
||||||
export { type UpdateAppConfigInput };
|
export { type UpdateAppConfigInput };
|
||||||
|
|
||||||
export type AppConfigUpdates = Record<string, { from: any; to: any }>;
|
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 = () => {
|
export const useAppConfig = () => {
|
||||||
const {
|
const {
|
||||||
@@ -33,30 +59,70 @@ export const useAppConfig = () => {
|
|||||||
const [patchedAppConfig, setPatchedAppConfig] = useState<AppConfig>(() =>
|
const [patchedAppConfig, setPatchedAppConfig] = useState<AppConfig>(() =>
|
||||||
cloneDeep(appConfig)
|
cloneDeep(appConfig)
|
||||||
);
|
);
|
||||||
|
const [savingModules, setSavingModules] = useState<Record<string, boolean>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const [groupVersions, setGroupVersions] = useState<Record<string, number>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
const save = useAsyncCallback(async () => {
|
useEffect(() => {
|
||||||
const updateInputs: UpdateAppConfigInput[] = Object.entries(updates).map(
|
if (Object.keys(updates).length === 0) {
|
||||||
([key, value]) => {
|
setPatchedAppConfig(cloneDeep(appConfig));
|
||||||
const splitIndex = key.indexOf('.');
|
}
|
||||||
const module = key.slice(0, splitIndex);
|
}, [appConfig, updates]);
|
||||||
const field = key.slice(splitIndex + 1);
|
|
||||||
|
|
||||||
return {
|
const getEntriesByModule = useCallback(
|
||||||
module,
|
(module: string, source: AppConfigUpdates = updates) => {
|
||||||
key: field,
|
return Object.entries(source).filter(([key]) =>
|
||||||
value: value.to,
|
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 {
|
try {
|
||||||
const savedUpdates = await saveUpdates({
|
const response = (await saveUpdates({
|
||||||
updates: updateInputs,
|
updates: getUpdateInputs(allEntries),
|
||||||
});
|
})) as SaveResponse;
|
||||||
|
const savedAppConfig = getSavedAppConfig(response);
|
||||||
|
|
||||||
await mutate(prev => {
|
await mutate(prev => {
|
||||||
return { appConfig: merge({}, prev, savedUpdates) };
|
return {
|
||||||
|
appConfig: merge({}, prev?.appConfig ?? {}, savedAppConfig),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
setUpdates({});
|
setUpdates({});
|
||||||
|
setPatchedAppConfig(prev => merge({}, prev, savedAppConfig));
|
||||||
notify.success({
|
notify.success({
|
||||||
title: 'Saved',
|
title: 'Saved',
|
||||||
message: 'Settings have been saved successfully.',
|
message: 'Settings have been saved successfully.',
|
||||||
@@ -71,6 +137,60 @@ export const useAppConfig = () => {
|
|||||||
}
|
}
|
||||||
}, [updates, mutate, saveUpdates]);
|
}, [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(
|
const update = useCallback(
|
||||||
(path: string, value: any) => {
|
(path: string, value: any) => {
|
||||||
const [module, field, subField] = path.split('/');
|
const [module, field, subField] = path.split('/');
|
||||||
@@ -78,9 +198,15 @@ export const useAppConfig = () => {
|
|||||||
const from = get(appConfig, key);
|
const from = get(appConfig, key);
|
||||||
setUpdates(prev => {
|
setUpdates(prev => {
|
||||||
const to = subField
|
const to = subField
|
||||||
? set(prev[key]?.to ?? { ...from }, subField, value)
|
? set(cloneDeep(prev[key]?.to ?? from ?? {}), subField, value)
|
||||||
: value;
|
: value;
|
||||||
|
|
||||||
|
if (isEqual(from, to)) {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[key]: {
|
[key]: {
|
||||||
@@ -91,21 +217,62 @@ export const useAppConfig = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setPatchedAppConfig(prev => {
|
setPatchedAppConfig(prev => {
|
||||||
return set(
|
const next = cloneDeep(prev);
|
||||||
prev,
|
if (subField) {
|
||||||
`${module}.${field}${subField ? `.${subField}` : ''}`,
|
const nextValue = set(
|
||||||
value
|
cloneDeep(get(next, `${module}.${field}`) ?? {}),
|
||||||
);
|
subField,
|
||||||
|
value
|
||||||
|
);
|
||||||
|
set(next, `${module}.${field}`, nextValue);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
set(next, `${module}.${field}`, value);
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[appConfig]
|
[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 {
|
return {
|
||||||
appConfig: appConfig as AppConfig,
|
appConfig: appConfig as AppConfig,
|
||||||
patchedAppConfig,
|
patchedAppConfig,
|
||||||
update,
|
update,
|
||||||
save,
|
save,
|
||||||
|
saveGroup,
|
||||||
|
resetGroup,
|
||||||
|
isGroupDirty,
|
||||||
|
isGroupSaving,
|
||||||
|
getGroupVersion,
|
||||||
updates,
|
updates,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
22
yarn.lock
22
yarn.lock
@@ -215,6 +215,7 @@ __metadata:
|
|||||||
"@radix-ui/react-tooltip": "npm:^1.1.5"
|
"@radix-ui/react-tooltip": "npm:^1.1.5"
|
||||||
"@sentry/react": "npm:^9.47.1"
|
"@sentry/react": "npm:^9.47.1"
|
||||||
"@tanstack/react-table": "npm:^8.20.5"
|
"@tanstack/react-table": "npm:^8.20.5"
|
||||||
|
"@testing-library/react": "npm:^16.3.2"
|
||||||
"@toeverything/infra": "workspace:*"
|
"@toeverything/infra": "workspace:*"
|
||||||
"@toeverything/theme": "npm:^1.1.23"
|
"@toeverything/theme": "npm:^1.1.23"
|
||||||
"@types/lodash-es": "npm:^4.17.12"
|
"@types/lodash-es": "npm:^4.17.12"
|
||||||
@@ -241,6 +242,7 @@ __metadata:
|
|||||||
tailwindcss: "npm:^4.1.17"
|
tailwindcss: "npm:^4.1.17"
|
||||||
tailwindcss-animate: "npm:^1.0.7"
|
tailwindcss-animate: "npm:^1.0.7"
|
||||||
vaul: "npm:^1.1.2"
|
vaul: "npm:^1.1.2"
|
||||||
|
vitest: "npm:^3.2.4"
|
||||||
zod: "npm:^3.25.76"
|
zod: "npm:^3.25.76"
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
@@ -16575,6 +16577,26 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@testing-library/user-event@npm:^14.6.1":
|
||||||
version: 14.6.1
|
version: 14.6.1
|
||||||
resolution: "@testing-library/user-event@npm:14.6.1"
|
resolution: "@testing-library/user-event@npm:14.6.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user