@actuate-media/cms-admin 0.1.4 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +17 -11
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +2 -0
- package/dist/components/TipTapEditor.js +78 -78
- package/dist/lib/useApiData.d.ts +8 -1
- package/dist/lib/useApiData.d.ts.map +1 -1
- package/dist/lib/useApiData.js +39 -7
- package/dist/lib/useApiData.js.map +1 -1
- package/dist/views/Dashboard.d.ts +2 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +52 -7
- package/dist/views/Dashboard.js.map +1 -1
- package/package.json +10 -5
- package/src/AdminRoot.tsx +312 -0
- package/src/__tests__/lib/search.test.ts +138 -0
- package/src/__tests__/lib/utils.test.ts +19 -0
- package/src/__tests__/router/match-route.test.ts +47 -0
- package/src/__tests__/router/strip-base.test.ts +30 -0
- package/src/components/Breadcrumbs.tsx +92 -0
- package/src/components/CommandPalette.tsx +384 -0
- package/src/components/ErrorBoundary.tsx +52 -0
- package/src/components/FocalPointPicker.tsx +54 -0
- package/src/components/FolderTree.tsx +427 -0
- package/src/components/LivePreview.tsx +136 -0
- package/src/components/LocaleProvider.tsx +51 -0
- package/src/components/LocaleSwitcher.tsx +51 -0
- package/src/components/MediaPickerModal.tsx +183 -0
- package/src/components/PresenceIndicator.tsx +71 -0
- package/src/components/SEOPanel.tsx +767 -0
- package/src/components/ThemeProvider.tsx +98 -0
- package/src/components/TipTapEditor.tsx +469 -0
- package/src/components/VersionHistory.tsx +167 -0
- package/src/components/ui/Avatar.tsx +42 -0
- package/src/components/ui/Badge.tsx +25 -0
- package/src/components/ui/Button.tsx +52 -0
- package/src/components/ui/CommandPalette.tsx +119 -0
- package/src/components/ui/ConfirmDialog.tsx +52 -0
- package/src/components/ui/DataTable.tsx +194 -0
- package/src/components/ui/EmptyState.tsx +29 -0
- package/src/components/ui/Modal.tsx +48 -0
- package/src/components/ui/Pagination.tsx +79 -0
- package/src/components/ui/SearchInput.tsx +44 -0
- package/src/components/ui/Skeleton.tsx +48 -0
- package/src/components/ui/Toast.tsx +66 -0
- package/src/components/ui/index.ts +24 -0
- package/src/fields/ArrayField.tsx +92 -0
- package/src/fields/BlockBuilderField.tsx +421 -0
- package/src/fields/DateField.tsx +41 -0
- package/src/fields/FieldRenderer.tsx +84 -0
- package/src/fields/GroupField.tsx +41 -0
- package/src/fields/MediaField.tsx +48 -0
- package/src/fields/NavBuilderField.tsx +78 -0
- package/src/fields/NumberField.tsx +45 -0
- package/src/fields/RelationshipField.tsx +245 -0
- package/src/fields/RichTextField.tsx +26 -0
- package/src/fields/SelectField.tsx +117 -0
- package/src/fields/SlugField.tsx +65 -0
- package/src/fields/TextField.tsx +48 -0
- package/src/fields/ToggleField.tsx +36 -0
- package/src/fields/block-types.ts +95 -0
- package/src/fields/index.ts +17 -0
- package/src/hooks/useContentLock.ts +52 -0
- package/src/hooks/useDebounce.ts +14 -0
- package/src/hooks/useKeyboardShortcuts.ts +32 -0
- package/src/index.ts +55 -0
- package/src/layout/Header.tsx +135 -0
- package/src/layout/Layout.tsx +77 -0
- package/src/layout/Sidebar.tsx +216 -0
- package/src/lib/api.ts +67 -0
- package/src/lib/search.ts +59 -0
- package/src/lib/useApiData.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/router/index.ts +81 -0
- package/src/styles/build-input.css +11 -0
- package/src/styles/tailwind.css +11 -6
- package/src/styles/theme.css +182 -181
- package/src/views/CollectionList.tsx +270 -0
- package/src/views/Dashboard.tsx +300 -0
- package/src/views/DocumentEdit.tsx +377 -0
- package/src/views/FormEditor.tsx +533 -0
- package/src/views/FormSubmissions.tsx +316 -0
- package/src/views/Forms.tsx +106 -0
- package/src/views/Login.tsx +322 -0
- package/src/views/MediaBrowser.tsx +774 -0
- package/src/views/PageEditor.tsx +192 -0
- package/src/views/Pages.tsx +354 -0
- package/src/views/PostEditor.tsx +251 -0
- package/src/views/Posts.tsx +243 -0
- package/src/views/Redirects.tsx +293 -0
- package/src/views/SEO.tsx +458 -0
- package/src/views/Settings.tsx +811 -0
- package/src/views/SetupWizard.tsx +207 -0
- package/src/views/Users.tsx +282 -0
package/dist/views/Dashboard.js
CHANGED
|
@@ -1,17 +1,62 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { FileText, Layout, Image, Users, User, Calendar, Loader2, AlertTriangle } from 'lucide-react';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { FileText, File, Layout, Image, Users, User, Calendar, Loader2, AlertTriangle, Database } from 'lucide-react';
|
|
4
4
|
import { useApiData } from '../lib/useApiData.js';
|
|
5
|
-
|
|
5
|
+
function resolveCollections(config) {
|
|
6
|
+
if (!config?.collections)
|
|
7
|
+
return [];
|
|
8
|
+
const raw = config.collections;
|
|
9
|
+
const list = Array.isArray(raw) ? raw : Object.values(raw);
|
|
10
|
+
return list
|
|
11
|
+
.filter((c) => !c.admin?.hidden)
|
|
12
|
+
.map((c) => ({
|
|
13
|
+
slug: c.slug,
|
|
14
|
+
type: c.type,
|
|
15
|
+
labels: c.labels,
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
function collectionLabel(col, plural = true) {
|
|
19
|
+
if (plural)
|
|
20
|
+
return col.labels?.plural ?? col.slug.charAt(0).toUpperCase() + col.slug.slice(1);
|
|
21
|
+
return col.labels?.singular ?? col.slug.charAt(0).toUpperCase() + col.slug.slice(1);
|
|
22
|
+
}
|
|
23
|
+
const STAT_COLORS = [
|
|
24
|
+
{ bg: 'bg-blue-100', text: 'text-blue-600' },
|
|
25
|
+
{ bg: 'bg-purple-100', text: 'text-purple-600' },
|
|
26
|
+
{ bg: 'bg-indigo-100', text: 'text-indigo-600' },
|
|
27
|
+
{ bg: 'bg-teal-100', text: 'text-teal-600' },
|
|
28
|
+
];
|
|
29
|
+
function collectionIcon(col) {
|
|
30
|
+
if (col.type === 'page')
|
|
31
|
+
return File;
|
|
32
|
+
return FileText;
|
|
33
|
+
}
|
|
34
|
+
export function Dashboard({ config, onNavigate }) {
|
|
6
35
|
const nav = (path) => onNavigate?.(path);
|
|
7
|
-
const { data, loading, error, refetch } = useApiData('/stats');
|
|
8
|
-
const
|
|
36
|
+
const { data, loading, error, exhausted, refetch } = useApiData('/stats');
|
|
37
|
+
const { data: health } = useApiData('/health');
|
|
38
|
+
const collections = resolveCollections(config);
|
|
39
|
+
const hasCollections = collections.length > 0;
|
|
9
40
|
const totalMedia = data?.totalMedia ?? 0;
|
|
10
41
|
const totalUsers = data?.totalUsers ?? 0;
|
|
11
|
-
const
|
|
42
|
+
const collectionCounts = data?.collectionCounts ?? {};
|
|
43
|
+
const recentDocs = data?.recentDocuments ?? [];
|
|
44
|
+
const primaryCollection = hasCollections ? collections[0] : null;
|
|
12
45
|
if (loading) {
|
|
13
46
|
return (_jsx("div", { className: "p-3 pr-6 sm:p-4 sm:pr-8 flex items-center justify-center h-64", children: _jsx(Loader2, { className: "w-6 h-6 animate-spin text-blue-600" }) }));
|
|
14
47
|
}
|
|
15
|
-
return (_jsxs("div", { className: "p-3 pr-6 sm:p-4 sm:pr-8", children: [
|
|
48
|
+
return (_jsxs("div", { className: "p-3 pr-6 sm:p-4 sm:pr-8", children: [health && health.status === 'degraded' && (_jsxs("div", { className: "mb-4 flex items-center gap-3 rounded-lg border border-blue-200 bg-blue-50 p-3", children: [_jsx(Database, { className: "w-5 h-5 text-blue-600 shrink-0" }), _jsxs("div", { className: "flex-1", children: [_jsx("span", { className: "text-sm font-medium text-blue-900", children: "Database Setup Required" }), _jsx("p", { className: "text-xs text-blue-700 mt-0.5", children: !health.databaseConnected
|
|
49
|
+
? 'Cannot connect to the database. Check your DATABASE_URL environment variable.'
|
|
50
|
+
: !health.secretConfigured
|
|
51
|
+
? 'CMS secret not configured. Set CMS_SECRET or CMS_SESSION_SECRET (min 32 characters).'
|
|
52
|
+
: `Some CMS models are missing: ${Object.entries(health.models).filter(([, v]) => !v).map(([k]) => k).join(', ')}. Run your database migrations to create the required tables.` })] })] })), error && exhausted && (_jsxs("div", { className: "mb-4 flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3", children: [_jsx(AlertTriangle, { className: "w-5 h-5 text-amber-600 shrink-0" }), _jsx("span", { className: "text-sm text-amber-800 flex-1", children: "Some dashboard data may be unavailable. This is normal if your database hasn't been set up yet." }), _jsx("button", { onClick: refetch, className: "px-3 py-1 text-sm text-amber-700 border border-amber-300 rounded-lg hover:bg-amber-100 transition-colors", children: "Retry" })] })), _jsxs("div", { className: "mb-4 sm:mb-6", children: [_jsx("h1", { className: "text-xl sm:text-2xl font-semibold text-gray-900 mb-1", children: "Dashboard" }), _jsx("p", { className: "text-sm text-gray-600", children: "Welcome back! Here's what's happening." })] }), _jsxs("div", { className: "grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-4 sm:mb-6", children: [hasCollections ? (collections.slice(0, 2).map((col, i) => {
|
|
53
|
+
const colors = STAT_COLORS[i % STAT_COLORS.length];
|
|
54
|
+
const Icon = collectionIcon(col);
|
|
55
|
+
return (_jsx("div", { className: "bg-white rounded-lg border border-gray-200 p-3 sm:p-4", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("p", { className: "text-xs text-gray-600 mb-1", children: collectionLabel(col) }), _jsx("p", { className: "text-xl sm:text-2xl font-semibold text-gray-900", children: (collectionCounts[col.slug] ?? 0).toLocaleString() }), _jsx("p", { className: "text-xs text-gray-400 mt-1", children: "\u2014" })] }), _jsx("div", { className: `w-8 h-8 sm:w-10 sm:h-10 ${colors.bg} rounded-lg flex items-center justify-center`, children: _jsx(Icon, { className: `w-4 h-4 sm:w-5 sm:h-5 ${colors.text}` }) })] }) }, col.slug));
|
|
56
|
+
})) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "bg-white rounded-lg border border-gray-200 p-3 sm:p-4", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("p", { className: "text-xs text-gray-600 mb-1", children: "Documents" }), _jsx("p", { className: "text-xl sm:text-2xl font-semibold text-gray-900", children: (data?.totalDocuments ?? 0).toLocaleString() }), _jsx("p", { className: "text-xs text-gray-400 mt-1", children: "\u2014" })] }), _jsx("div", { className: "w-8 h-8 sm:w-10 sm:h-10 bg-blue-100 rounded-lg flex items-center justify-center", children: _jsx(FileText, { className: "w-4 h-4 sm:w-5 sm:h-5 text-blue-600" }) })] }) }), _jsx("div", { className: "bg-white rounded-lg border border-gray-200 p-3 sm:p-4", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("p", { className: "text-xs text-gray-600 mb-1", children: "Pages" }), _jsx("p", { className: "text-xl sm:text-2xl font-semibold text-gray-900", children: (collectionCounts['pages'] ?? 0).toLocaleString() }), _jsx("p", { className: "text-xs text-gray-400 mt-1", children: "\u2014" })] }), _jsx("div", { className: "w-8 h-8 sm:w-10 sm:h-10 bg-purple-100 rounded-lg flex items-center justify-center", children: _jsx(Layout, { className: "w-4 h-4 sm:w-5 sm:h-5 text-purple-600" }) })] }) })] })), _jsx("div", { className: "bg-white rounded-lg border border-gray-200 p-3 sm:p-4", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("p", { className: "text-xs text-gray-600 mb-1", children: "Media Files" }), _jsx("p", { className: "text-xl sm:text-2xl font-semibold text-gray-900", children: totalMedia.toLocaleString() }), _jsx("p", { className: "text-xs text-gray-400 mt-1", children: "\u2014" })] }), _jsx("div", { className: "w-8 h-8 sm:w-10 sm:h-10 bg-green-100 rounded-lg flex items-center justify-center", children: _jsx(Image, { className: "w-4 h-4 sm:w-5 sm:h-5 text-green-600" }) })] }) }), _jsx("div", { className: "bg-white rounded-lg border border-gray-200 p-3 sm:p-4", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("p", { className: "text-xs text-gray-600 mb-1", children: "Total Users" }), _jsx("p", { className: "text-xl sm:text-2xl font-semibold text-gray-900", children: totalUsers }), _jsx("p", { className: "text-xs text-gray-400 mt-1", children: "\u2014" })] }), _jsx("div", { className: "w-8 h-8 sm:w-10 sm:h-10 bg-amber-100 rounded-lg flex items-center justify-center", children: _jsx(Users, { className: "w-4 h-4 sm:w-5 sm:h-5 text-amber-600" }) })] }) })] }), _jsxs("div", { className: "grid grid-cols-1 lg:grid-cols-12 gap-3 sm:gap-4", children: [_jsxs("div", { className: "lg:col-span-8 bg-white rounded-lg border border-gray-200", children: [_jsx("div", { className: "p-3 sm:p-4 border-b border-gray-200", children: _jsxs("h2", { className: "text-sm sm:text-base font-semibold text-gray-900", children: ["Recent ", primaryCollection ? collectionLabel(primaryCollection) : 'Documents'] }) }), _jsx("div", { className: "divide-y divide-gray-200", children: recentDocs.length === 0 ? (_jsxs("div", { className: "p-8 text-center", children: [_jsxs("p", { className: "text-sm text-gray-500 mb-2", children: ["No ", primaryCollection ? collectionLabel(primaryCollection).toLowerCase() : 'documents', " yet"] }), primaryCollection && (_jsxs("button", { onClick: () => nav(`/${primaryCollection.slug}/new`), className: "px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors", children: ["Create your first ", collectionLabel(primaryCollection, false).toLowerCase()] }))] })) : recentDocs.map((doc) => (_jsx("div", { className: "p-3 sm:p-4 hover:bg-gray-50 transition-colors", children: _jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h3", { className: "text-sm font-medium text-gray-900 mb-1 truncate", children: doc.title ?? 'Untitled' }), _jsxs("div", { className: "flex flex-wrap items-center gap-2 sm:gap-3 text-xs text-gray-600", children: [_jsxs("span", { className: "flex items-center gap-1", children: [_jsx(User, { className: "w-3 h-3" }), _jsx("span", { className: "hidden sm:inline", children: doc.createdById })] }), _jsxs("span", { className: "flex items-center gap-1", children: [_jsx(Calendar, { className: "w-3 h-3" }), new Date(doc.updatedAt).toLocaleDateString()] }), _jsx("span", { className: `px-2 py-0.5 rounded-full text-xs font-medium ${doc.status === 'PUBLISHED' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`, children: doc.status })] })] }), _jsx("button", { onClick: () => nav(`/${doc.collection}/${doc.id}`), className: "text-xs sm:text-sm text-blue-600 hover:text-blue-700 whitespace-nowrap", children: "Edit" })] }) }, doc.id))) })] }), _jsxs("div", { className: "lg:col-span-4 bg-white rounded-lg border border-gray-200", children: [_jsx("div", { className: "p-3 sm:p-4 border-b border-gray-200", children: _jsx("h2", { className: "text-sm sm:text-base font-semibold text-gray-900", children: "Quick Actions" }) }), _jsxs("div", { className: "p-3 sm:p-4 space-y-2", children: [hasCollections ? (collections.slice(0, 4).map((col, i) => {
|
|
57
|
+
const colors = STAT_COLORS[i % STAT_COLORS.length];
|
|
58
|
+
const Icon = collectionIcon(col);
|
|
59
|
+
return (_jsxs("button", { onClick: () => nav(`/${col.slug}/new`), className: "flex items-center gap-3 p-2.5 sm:p-3 rounded-lg hover:bg-gray-50 transition-colors group w-full text-left", children: [_jsx("div", { className: `w-8 h-8 ${colors.bg} rounded-lg flex items-center justify-center group-hover:opacity-80 transition-colors`, children: _jsx(Icon, { className: `w-4 h-4 ${colors.text}` }) }), _jsxs("span", { className: "text-sm font-medium text-gray-900", children: ["New ", collectionLabel(col, false)] })] }, col.slug));
|
|
60
|
+
})) : (_jsxs(_Fragment, { children: [_jsxs("button", { onClick: () => nav('/posts/new'), className: "flex items-center gap-3 p-2.5 sm:p-3 rounded-lg hover:bg-gray-50 transition-colors group w-full text-left", children: [_jsx("div", { className: "w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center group-hover:bg-blue-200 transition-colors", children: _jsx(FileText, { className: "w-4 h-4 text-blue-600" }) }), _jsx("span", { className: "text-sm font-medium text-gray-900", children: "New Post" })] }), _jsxs("button", { onClick: () => nav('/pages/new'), className: "flex items-center gap-3 p-2.5 sm:p-3 rounded-lg hover:bg-gray-50 transition-colors group w-full text-left", children: [_jsx("div", { className: "w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center group-hover:bg-purple-200 transition-colors", children: _jsx(Layout, { className: "w-4 h-4 text-purple-600" }) }), _jsx("span", { className: "text-sm font-medium text-gray-900", children: "New Page" })] })] })), _jsxs("button", { onClick: () => nav('/media'), className: "flex items-center gap-3 p-2.5 sm:p-3 rounded-lg hover:bg-gray-50 transition-colors group w-full text-left", children: [_jsx("div", { className: "w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center group-hover:bg-green-200 transition-colors", children: _jsx(Image, { className: "w-4 h-4 text-green-600" }) }), _jsx("span", { className: "text-sm font-medium text-gray-900", children: "Upload Media" })] }), _jsxs("button", { onClick: () => nav('/users'), className: "flex items-center gap-3 p-2.5 sm:p-3 rounded-lg hover:bg-gray-50 transition-colors group w-full text-left", children: [_jsx("div", { className: "w-8 h-8 bg-amber-100 rounded-lg flex items-center justify-center group-hover:bg-amber-200 transition-colors", children: _jsx(Users, { className: "w-4 h-4 text-amber-600" }) }), _jsx("span", { className: "text-sm font-medium text-gray-900", children: "Manage Users" })] })] })] })] })] }));
|
|
16
61
|
}
|
|
17
62
|
//# sourceMappingURL=Dashboard.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Dashboard.js","sourceRoot":"","sources":["../../src/views/Dashboard.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"Dashboard.js","sourceRoot":"","sources":["../../src/views/Dashboard.tsx"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actuate-media/cms-admin",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/actuate-media/actuatecms.git",
|
|
@@ -19,11 +19,14 @@
|
|
|
19
19
|
"default": "./dist/index.js"
|
|
20
20
|
},
|
|
21
21
|
"./styles": "./src/styles/tailwind.css",
|
|
22
|
-
"./styles/theme.css": "./src/styles/theme.css"
|
|
22
|
+
"./styles/theme.css": "./src/styles/theme.css",
|
|
23
|
+
"./styles/precompiled.css": "./dist/actuate-admin.css"
|
|
23
24
|
},
|
|
24
25
|
"files": [
|
|
25
26
|
"dist",
|
|
26
|
-
"src/styles"
|
|
27
|
+
"src/styles",
|
|
28
|
+
"src/**/*.tsx",
|
|
29
|
+
"src/**/*.ts"
|
|
27
30
|
],
|
|
28
31
|
"dependencies": {
|
|
29
32
|
"@dnd-kit/core": "^6.3.1",
|
|
@@ -55,9 +58,10 @@
|
|
|
55
58
|
"react-dom": "^19.2.0",
|
|
56
59
|
"sonner": "^2.0.7",
|
|
57
60
|
"tailwind-merge": "^3.5.0",
|
|
58
|
-
"@actuate-media/cms-core": "0.
|
|
61
|
+
"@actuate-media/cms-core": "0.3.1"
|
|
59
62
|
},
|
|
60
63
|
"devDependencies": {
|
|
64
|
+
"@tailwindcss/cli": "^4.0.0",
|
|
61
65
|
"@types/react": "^19.0.0",
|
|
62
66
|
"@types/react-dom": "^19.0.0",
|
|
63
67
|
"tailwindcss": "^4.0.0",
|
|
@@ -65,7 +69,8 @@
|
|
|
65
69
|
"vitest": "^3.0.0"
|
|
66
70
|
},
|
|
67
71
|
"scripts": {
|
|
68
|
-
"build": "tsc --project tsconfig.json",
|
|
72
|
+
"build": "tsc --project tsconfig.json && npx @tailwindcss/cli -i src/styles/build-input.css -o dist/actuate-admin.css --minify",
|
|
73
|
+
"build:css": "npx @tailwindcss/cli -i src/styles/build-input.css -o dist/actuate-admin.css --minify",
|
|
69
74
|
"type-check": "tsc --noEmit",
|
|
70
75
|
"test": "vitest run",
|
|
71
76
|
"clean": "rm -rf dist .turbo"
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo, useEffect, useRef } from 'react';
|
|
4
|
+
import { Layout } from './layout/Layout.js';
|
|
5
|
+
import { useAdminRouter } from './router/index.js';
|
|
6
|
+
import { Dashboard } from './views/Dashboard.js';
|
|
7
|
+
import { CollectionList } from './views/CollectionList.js';
|
|
8
|
+
import { DocumentEdit } from './views/DocumentEdit.js';
|
|
9
|
+
import { MediaBrowser } from './views/MediaBrowser.js';
|
|
10
|
+
import { Settings } from './views/Settings.js';
|
|
11
|
+
import { Forms } from './views/Forms.js';
|
|
12
|
+
import { FormEditor } from './views/FormEditor.js';
|
|
13
|
+
import { FormSubmissions } from './views/FormSubmissions.js';
|
|
14
|
+
import { Users } from './views/Users.js';
|
|
15
|
+
import { SEO } from './views/SEO.js';
|
|
16
|
+
import { SetupWizard } from './views/SetupWizard.js';
|
|
17
|
+
import { Login } from './views/Login.js';
|
|
18
|
+
import { ErrorBoundary } from './components/ErrorBoundary.js';
|
|
19
|
+
import { ThemeProvider } from './components/ThemeProvider.js';
|
|
20
|
+
import { LocaleProvider } from './components/LocaleProvider.js';
|
|
21
|
+
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts.js';
|
|
22
|
+
|
|
23
|
+
export interface AdminRootProps {
|
|
24
|
+
config: any;
|
|
25
|
+
session: any;
|
|
26
|
+
basePath?: string;
|
|
27
|
+
initialPath?: string;
|
|
28
|
+
setupRequired?: boolean;
|
|
29
|
+
onSetupComplete?: (data: { name: string; email: string; password: string }) => Promise<{ success: boolean; error?: string }>;
|
|
30
|
+
onLogin?: (email: string, password: string, captchaToken?: string) => Promise<{ success: boolean; error?: string }>;
|
|
31
|
+
captchaConfig?: { provider: 'recaptcha' | 'turnstile' | 'none'; siteKey: string | null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function AdminShell({ config, session, basePath = '/admin', initialPath = '/', setupRequired, onSetupComplete, onLogin, captchaConfig }: AdminRootProps) {
|
|
35
|
+
const { currentPath, navigate, matchRoute } = useAdminRouter(basePath, initialPath);
|
|
36
|
+
const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
|
|
37
|
+
|
|
38
|
+
useBranding(config);
|
|
39
|
+
useAdminPageTitle(config, currentPath);
|
|
40
|
+
|
|
41
|
+
const shortcuts = useMemo(() => ({
|
|
42
|
+
'mod+k': () => {
|
|
43
|
+
const searchInput = document.querySelector<HTMLInputElement>('input[placeholder*="Search"]');
|
|
44
|
+
searchInput?.focus();
|
|
45
|
+
},
|
|
46
|
+
'mod+s': () => {
|
|
47
|
+
const saveBtn = document.querySelector<HTMLButtonElement>('[data-shortcut="save"]');
|
|
48
|
+
saveBtn?.click();
|
|
49
|
+
},
|
|
50
|
+
'escape': () => {
|
|
51
|
+
const closeBtn = document.querySelector<HTMLButtonElement>('[data-shortcut="close"]');
|
|
52
|
+
closeBtn?.click();
|
|
53
|
+
},
|
|
54
|
+
'mod+/': () => {
|
|
55
|
+
setShortcutHelpOpen(prev => !prev);
|
|
56
|
+
},
|
|
57
|
+
}), []);
|
|
58
|
+
|
|
59
|
+
useKeyboardShortcuts(shortcuts);
|
|
60
|
+
|
|
61
|
+
if (setupRequired && onSetupComplete) {
|
|
62
|
+
return <SetupWizard onComplete={onSetupComplete} siteName={config?.admin?.branding?.name ?? 'Actuate CMS'} />;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!session && !setupRequired) {
|
|
66
|
+
if (onLogin) {
|
|
67
|
+
return <Login onLogin={onLogin} onNavigate={navigate} captchaConfig={captchaConfig} />;
|
|
68
|
+
}
|
|
69
|
+
return (
|
|
70
|
+
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
71
|
+
<div className="text-center">
|
|
72
|
+
<h1 className="text-xl font-semibold text-foreground mb-2">Unauthorized</h1>
|
|
73
|
+
<p className="text-sm text-muted-foreground">Please log in to access the admin panel.</p>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const collectionSlugs = config?.collections
|
|
80
|
+
? Object.values(config.collections as Record<string, { slug: string }>).map((c) => c.slug)
|
|
81
|
+
: ['pages', 'posts'];
|
|
82
|
+
|
|
83
|
+
function renderView() {
|
|
84
|
+
if (matchRoute('/')) {
|
|
85
|
+
return <Dashboard config={config} onNavigate={navigate} />;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const slug of collectionSlugs) {
|
|
89
|
+
const newMatch = matchRoute(`/${slug}/new`);
|
|
90
|
+
if (newMatch) {
|
|
91
|
+
return <DocumentEdit collectionSlug={slug} config={config} />;
|
|
92
|
+
}
|
|
93
|
+
const editMatch = matchRoute(`/${slug}/:id`);
|
|
94
|
+
if (editMatch?.id) {
|
|
95
|
+
return <DocumentEdit collectionSlug={slug} documentId={editMatch.id} config={config} />;
|
|
96
|
+
}
|
|
97
|
+
if (matchRoute(`/${slug}`)) {
|
|
98
|
+
return <CollectionList collectionSlug={slug} config={config} onNavigate={navigate} />;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const newCollMatch = matchRoute('/collections/:slug/new');
|
|
103
|
+
if (newCollMatch?.slug) {
|
|
104
|
+
return <DocumentEdit collectionSlug={newCollMatch.slug} config={config} />;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const editCollMatch = matchRoute('/collections/:slug/:id');
|
|
108
|
+
if (editCollMatch?.slug && editCollMatch.id) {
|
|
109
|
+
return <DocumentEdit collectionSlug={editCollMatch.slug} documentId={editCollMatch.id} config={config} />;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const collectionMatch = matchRoute('/collections/:slug');
|
|
113
|
+
if (collectionMatch?.slug) {
|
|
114
|
+
return <CollectionList collectionSlug={collectionMatch.slug} config={config} onNavigate={navigate} />;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (matchRoute('/media')) {
|
|
118
|
+
return <MediaBrowser onNavigate={navigate} />;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (matchRoute('/forms/new')) {
|
|
122
|
+
return <FormEditor onNavigate={navigate} />;
|
|
123
|
+
}
|
|
124
|
+
const formEdit = matchRoute('/forms/:id/edit');
|
|
125
|
+
if (formEdit?.id) {
|
|
126
|
+
return <FormEditor formId={formEdit.id} onNavigate={navigate} />;
|
|
127
|
+
}
|
|
128
|
+
const formSubmissions = matchRoute('/forms/:id/submissions');
|
|
129
|
+
if (formSubmissions?.id) {
|
|
130
|
+
return <FormSubmissions formId={formSubmissions.id} onNavigate={navigate} />;
|
|
131
|
+
}
|
|
132
|
+
if (matchRoute('/forms')) {
|
|
133
|
+
return <Forms onNavigate={navigate} />;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (matchRoute('/seo/redirects')) {
|
|
137
|
+
return <SEO onNavigate={navigate} initialTab="redirects" />;
|
|
138
|
+
}
|
|
139
|
+
if (matchRoute('/seo/canonicals')) {
|
|
140
|
+
return <SEO onNavigate={navigate} initialTab="canonicals" />;
|
|
141
|
+
}
|
|
142
|
+
if (matchRoute('/seo/links')) {
|
|
143
|
+
return <SEO onNavigate={navigate} initialTab="links" />;
|
|
144
|
+
}
|
|
145
|
+
if (matchRoute('/seo')) {
|
|
146
|
+
return <SEO onNavigate={navigate} initialTab="pages" />;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (matchRoute('/users')) {
|
|
150
|
+
return <Users onNavigate={navigate} />;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (matchRoute('/settings')) {
|
|
154
|
+
return <Settings onNavigate={navigate} />;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div className="flex flex-col items-center justify-center min-h-[400px] text-center p-6">
|
|
159
|
+
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
|
|
160
|
+
<p className="text-muted-foreground mb-4">The page you are looking for does not exist.</p>
|
|
161
|
+
<button
|
|
162
|
+
onClick={() => navigate('/')}
|
|
163
|
+
className="px-4 py-2 text-sm font-medium text-primary-foreground bg-primary rounded-md hover:opacity-90"
|
|
164
|
+
>
|
|
165
|
+
Back to Dashboard
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<Layout config={config} session={session} currentPath={currentPath} onNavigate={navigate}>
|
|
173
|
+
<ErrorBoundary>
|
|
174
|
+
{renderView()}
|
|
175
|
+
</ErrorBoundary>
|
|
176
|
+
{shortcutHelpOpen && <ShortcutHelp onClose={() => setShortcutHelpOpen(false)} />}
|
|
177
|
+
</Layout>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function ShortcutHelp({ onClose }: { onClose: () => void }) {
|
|
182
|
+
return (
|
|
183
|
+
<div
|
|
184
|
+
className="fixed inset-0 z-100 flex items-center justify-center bg-black/50"
|
|
185
|
+
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
186
|
+
>
|
|
187
|
+
<div className="bg-card text-card-foreground rounded-xl shadow-2xl border border-border p-6 max-w-sm w-full mx-4">
|
|
188
|
+
<h3 className="text-lg font-semibold mb-4">Keyboard Shortcuts</h3>
|
|
189
|
+
<div className="space-y-3 text-sm">
|
|
190
|
+
{[
|
|
191
|
+
['⌘ K', 'Open search'],
|
|
192
|
+
['⌘ S', 'Save document'],
|
|
193
|
+
['⌘ /', 'Toggle this help'],
|
|
194
|
+
['Esc', 'Close panel / modal'],
|
|
195
|
+
].map(([key, desc]) => (
|
|
196
|
+
<div key={key} className="flex items-center justify-between">
|
|
197
|
+
<span className="text-muted-foreground">{desc}</span>
|
|
198
|
+
<kbd className="px-2 py-1 text-xs font-mono bg-muted rounded">{key}</kbd>
|
|
199
|
+
</div>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
<button
|
|
203
|
+
onClick={onClose}
|
|
204
|
+
className="mt-4 w-full py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90"
|
|
205
|
+
>
|
|
206
|
+
Close
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function useBranding(config: any) {
|
|
214
|
+
const originalFaviconRef = useRef<string | null>(null);
|
|
215
|
+
const originalTitleRef = useRef<string | null>(null);
|
|
216
|
+
const injectedLinkRef = useRef<HTMLLinkElement | null>(null);
|
|
217
|
+
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
const branding = config?.admin?.branding;
|
|
220
|
+
if (!branding) return;
|
|
221
|
+
|
|
222
|
+
// --- Favicon ---
|
|
223
|
+
if (branding.favicon) {
|
|
224
|
+
const existing = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
|
225
|
+
originalFaviconRef.current = existing?.href ?? null;
|
|
226
|
+
|
|
227
|
+
if (existing) {
|
|
228
|
+
existing.href = branding.favicon;
|
|
229
|
+
} else {
|
|
230
|
+
const link = document.createElement('link');
|
|
231
|
+
link.rel = 'icon';
|
|
232
|
+
link.href = branding.favicon;
|
|
233
|
+
document.head.appendChild(link);
|
|
234
|
+
injectedLinkRef.current = link;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --- Document title ---
|
|
239
|
+
originalTitleRef.current = document.title;
|
|
240
|
+
if (branding.title) {
|
|
241
|
+
document.title = branding.title;
|
|
242
|
+
} else if (branding.name) {
|
|
243
|
+
document.title = `${branding.name} Admin`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- Primary color ---
|
|
247
|
+
const adminEl = document.querySelector<HTMLElement>('.actuate-admin');
|
|
248
|
+
if (branding.primaryColor && adminEl) {
|
|
249
|
+
adminEl.style.setProperty('--primary', branding.primaryColor);
|
|
250
|
+
adminEl.style.setProperty('--sidebar-primary', branding.primaryColor);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return () => {
|
|
254
|
+
// Restore favicon
|
|
255
|
+
if (branding.favicon) {
|
|
256
|
+
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
|
257
|
+
if (link && originalFaviconRef.current) {
|
|
258
|
+
link.href = originalFaviconRef.current;
|
|
259
|
+
}
|
|
260
|
+
if (injectedLinkRef.current) {
|
|
261
|
+
injectedLinkRef.current.remove();
|
|
262
|
+
injectedLinkRef.current = null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Restore title
|
|
267
|
+
if (originalTitleRef.current !== null) {
|
|
268
|
+
document.title = originalTitleRef.current;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Remove primary color override
|
|
272
|
+
if (branding.primaryColor && adminEl) {
|
|
273
|
+
adminEl.style.removeProperty('--primary');
|
|
274
|
+
adminEl.style.removeProperty('--sidebar-primary');
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
}, [config]);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function useAdminPageTitle(config: any, currentPath: string) {
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
const branding = config?.admin?.branding;
|
|
283
|
+
const baseName = branding?.title ?? (branding?.name ? `${branding.name} Admin` : null);
|
|
284
|
+
if (!baseName) return;
|
|
285
|
+
|
|
286
|
+
const segment = currentPath === '/' ? 'Dashboard' : currentPath.split('/').filter(Boolean)[0];
|
|
287
|
+
const pageName = segment ? segment.charAt(0).toUpperCase() + segment.slice(1) : 'Dashboard';
|
|
288
|
+
document.title = `${pageName} — ${baseName}`;
|
|
289
|
+
}, [config, currentPath]);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const ISOLATION_STYLE: React.CSSProperties = {
|
|
293
|
+
position: 'fixed',
|
|
294
|
+
inset: '0',
|
|
295
|
+
zIndex: 50,
|
|
296
|
+
overflow: 'auto',
|
|
297
|
+
isolation: 'isolate',
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
export function AdminRoot(props: AdminRootProps) {
|
|
301
|
+
const defaultDarkMode = props.config?.admin?.branding?.darkMode;
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<div style={ISOLATION_STYLE} className="actuate-admin">
|
|
305
|
+
<ThemeProvider defaultDarkMode={defaultDarkMode}>
|
|
306
|
+
<LocaleProvider config={props.config}>
|
|
307
|
+
<AdminShell {...props} />
|
|
308
|
+
</LocaleProvider>
|
|
309
|
+
</ThemeProvider>
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
scoreRelevance,
|
|
4
|
+
sortByRelevance,
|
|
5
|
+
sortByColumn,
|
|
6
|
+
toggleSort,
|
|
7
|
+
} from '../../lib/search.js';
|
|
8
|
+
|
|
9
|
+
describe('scoreRelevance', () => {
|
|
10
|
+
it('returns 100 for exact match (case-insensitive)', () => {
|
|
11
|
+
expect(scoreRelevance('Hello', 'hello')).toBe(100);
|
|
12
|
+
expect(scoreRelevance('test', 'test')).toBe(100);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns 80 when text starts with the query', () => {
|
|
16
|
+
expect(scoreRelevance('hello world', 'hel')).toBe(80);
|
|
17
|
+
expect(scoreRelevance('Alpha', 'al')).toBe(80);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns 60 when query matches at a word boundary but not at start', () => {
|
|
21
|
+
expect(scoreRelevance('say hello', 'hello')).toBe(60);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns 40 when query appears only as a substring without word-boundary match', () => {
|
|
25
|
+
expect(scoreRelevance('axxhello', 'hello')).toBe(40);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns 0 when there is no match', () => {
|
|
29
|
+
expect(scoreRelevance('abc def', 'xyz')).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns 0 for an empty query', () => {
|
|
33
|
+
expect(scoreRelevance('anything', '')).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('sortByRelevance', () => {
|
|
38
|
+
it('sorts items by best field relevance score descending', () => {
|
|
39
|
+
const items = [
|
|
40
|
+
{ id: 'a', title: 'zzz' },
|
|
41
|
+
{ id: 'b', title: 'hello' },
|
|
42
|
+
{ id: 'c', title: 'say hello' },
|
|
43
|
+
];
|
|
44
|
+
const sorted = sortByRelevance(items, 'hello', item => [item.title]);
|
|
45
|
+
// exact match > word-boundary match > no match
|
|
46
|
+
expect(sorted.map(i => i.id)).toEqual(['b', 'c', 'a']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns the original array reference for an empty query', () => {
|
|
50
|
+
const items = [{ id: '1' }, { id: '2' }];
|
|
51
|
+
const result = sortByRelevance(items, '', item => [item.id]);
|
|
52
|
+
expect(result).toBe(items);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns the original array when query is whitespace-only', () => {
|
|
56
|
+
const items = [{ id: '1' }];
|
|
57
|
+
expect(sortByRelevance(items, ' ', item => [item.id])).toBe(items);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('uses the maximum score across multiple search fields', () => {
|
|
61
|
+
const items = [
|
|
62
|
+
{ id: 'low', a: 'x', b: 'x' },
|
|
63
|
+
{ id: 'high', a: 'x', b: 'exact' },
|
|
64
|
+
];
|
|
65
|
+
const sorted = sortByRelevance(items, 'exact', item => [item.a, item.b]);
|
|
66
|
+
expect(sorted[0]!.id).toBe('high');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('sortByColumn', () => {
|
|
71
|
+
it('returns the original array when sortConfig is null', () => {
|
|
72
|
+
const items = [{ n: 2 }, { n: 1 }];
|
|
73
|
+
expect(sortByColumn(items, null, (item, key) => item[key as 'n'])).toBe(items);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('sorts strings ascending', () => {
|
|
77
|
+
const items = [{ name: 'b' }, { name: 'a' }, { name: 'c' }];
|
|
78
|
+
const sorted = sortByColumn(
|
|
79
|
+
items,
|
|
80
|
+
{ key: 'name', direction: 'asc' },
|
|
81
|
+
(item, key) => item[key as 'name'],
|
|
82
|
+
);
|
|
83
|
+
expect(sorted.map(i => i.name)).toEqual(['a', 'b', 'c']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('sorts strings descending', () => {
|
|
87
|
+
const items = [{ name: 'a' }, { name: 'c' }, { name: 'b' }];
|
|
88
|
+
const sorted = sortByColumn(
|
|
89
|
+
items,
|
|
90
|
+
{ key: 'name', direction: 'desc' },
|
|
91
|
+
(item, key) => item[key as 'name'],
|
|
92
|
+
);
|
|
93
|
+
expect(sorted.map(i => i.name)).toEqual(['c', 'b', 'a']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('sorts numbers ascending and descending', () => {
|
|
97
|
+
const items = [{ v: 3 }, { v: 1 }, { v: 2 }];
|
|
98
|
+
const asc = sortByColumn(
|
|
99
|
+
items,
|
|
100
|
+
{ key: 'v', direction: 'asc' },
|
|
101
|
+
(item, key) => item[key as 'v'],
|
|
102
|
+
);
|
|
103
|
+
expect(asc.map(i => i.v)).toEqual([1, 2, 3]);
|
|
104
|
+
const desc = sortByColumn(
|
|
105
|
+
items,
|
|
106
|
+
{ key: 'v', direction: 'desc' },
|
|
107
|
+
(item, key) => item[key as 'v'],
|
|
108
|
+
);
|
|
109
|
+
expect(desc.map(i => i.v)).toEqual([3, 2, 1]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('toggleSort', () => {
|
|
114
|
+
it('returns ascending sort when current is null', () => {
|
|
115
|
+
expect(toggleSort(null, 'title')).toEqual({ key: 'title', direction: 'asc' });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('toggles same key from asc to desc', () => {
|
|
119
|
+
expect(toggleSort({ key: 'title', direction: 'asc' }, 'title')).toEqual({
|
|
120
|
+
key: 'title',
|
|
121
|
+
direction: 'desc',
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('toggles same key from desc to asc', () => {
|
|
126
|
+
expect(toggleSort({ key: 'title', direction: 'desc' }, 'title')).toEqual({
|
|
127
|
+
key: 'title',
|
|
128
|
+
direction: 'asc',
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('starts ascending when switching to a different key', () => {
|
|
133
|
+
expect(toggleSort({ key: 'foo', direction: 'desc' }, 'bar')).toEqual({
|
|
134
|
+
key: 'bar',
|
|
135
|
+
direction: 'asc',
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|