Compare commits
15
Commits
v1.0.2
...
d84784c9d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d84784c9d9 | ||
|
|
c40d1c69d8 | ||
|
|
189e270785 | ||
|
|
f18f3012aa | ||
|
|
5eea77346a | ||
|
|
ef99135cae | ||
|
|
5ce2a5acf7 | ||
|
|
e080a6e061 | ||
|
|
574de03e42 | ||
|
|
a7fda8bd6b | ||
|
|
001a1f3a15 | ||
|
|
782dde0573 | ||
|
|
cbe77f9e4a | ||
|
|
e9b9efa084 | ||
|
|
dee0f7fbe3 |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:8080
|
VITE_API_BASE_URL=http://mx.astralmail.org:8080
|
||||||
VITE_OAUTH_CLIENT_ID=stalwart-webui
|
VITE_OAUTH_CLIENT_ID=stalwart-webui
|
||||||
#VITE_ACCESS_TOKEN=OPEN_SESAME
|
#VITE_ACCESS_TOKEN=OPEN_SESAME
|
||||||
VITE_OAUTH_SCOPES=
|
VITE_OAUTH_SCOPES=
|
||||||
|
|||||||
@@ -2,6 +2,67 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
## [1.0.7] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `Ctrl+K` / `Cmd+K` command palette for global search (credits @LinkPhoenix).
|
||||||
|
- Calendar date picker for date and time fields (credits @LinkPhoenix).
|
||||||
|
- Dynamic document titles per page (credits @LinkPhoenix).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Code-split the admin shell and heavy feature pages to speed up the initial load (credits @LinkPhoenix).
|
||||||
|
- Center forms horizontally on wide screens (credits @LinkPhoenix).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Redirect URLs without a view to the first accessible page of their section (credits @LinkPhoenix).
|
||||||
|
- Sidebar groups auto-open and scroll the active item into view after navigation (credits @LinkPhoenix).
|
||||||
|
- Keep the sidebar section synced with the URL on full page loads (credits @LinkPhoenix).
|
||||||
|
- Date and time fields no longer shift values by the UTC offset when editing (credits @LinkPhoenix).
|
||||||
|
- Clip the table header background inside the rounded card border (credits @LinkPhoenix).
|
||||||
|
- Keep the selected account across page reloads (#17).
|
||||||
|
- Refresh open views when switching accounts (#17).
|
||||||
|
- Custom logos no longer flash the default logo while loading.
|
||||||
|
|
||||||
|
## [1.0.6] - 2026-07-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- WebUI version is now displayed when hovering over the logo.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Properly serialize `date` filters when applying them to the list filter.
|
||||||
|
|
||||||
|
## [1.0.5] - 2026-06-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Redirect to `/login` when there is no refresh token.
|
||||||
|
- Include required JMAP capabilities in `using`.
|
||||||
|
- Default scopes omit `offline_access`.
|
||||||
|
|
||||||
|
## [1.0.4] - 2026-05-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Align `base32` alphabet with the server.
|
||||||
|
|
||||||
|
## [1.0.3] - 2026-05-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Broken "Delivery History" link on OSS/Community editions.
|
||||||
|
- Resolve object ids in map keys.
|
||||||
|
|
||||||
## [1.0.2] - 2026-04-30
|
## [1.0.2] - 2026-04-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Portal</title>
|
<title>Portal</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Generated
+1501
-1531
File diff suppressed because it is too large
Load Diff
+42
-41
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stalwart-webui",
|
"name": "stalwart-webui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.1",
|
"version": "1.0.7",
|
||||||
"description": "Stalwart WebUI",
|
"description": "Stalwart WebUI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -16,55 +16,56 @@
|
|||||||
"format:check": "prettier --check src/"
|
"format:check": "prettier --check src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@daypicker/react": "^10.0.1",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-collapsible": "^1.1.20",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-popover": "^1.1.23",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-separator": "^1.1.15",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-switch": "^1.3.7",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"i18next": "^26.0.4",
|
"i18next": "^26.3.6",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.28.0",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.8",
|
||||||
"react-i18next": "^17.0.2",
|
"react-i18next": "^17.0.11",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.18.2",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.10.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^10.8.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.3",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.8.0",
|
||||||
"happy-dom": "^20.9.0",
|
"happy-dom": "^20.11.1",
|
||||||
"prettier": "^3.8.2",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.58.0",
|
"typescript-eslint": "^8.65.0",
|
||||||
"vite": "^8.0.4",
|
"vite": "^8.2.0",
|
||||||
"vitest": "^4.1.4"
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
@@ -4,14 +4,18 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Suspense } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||||
|
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||||
import { Toaster } from '@/components/ui/toaster';
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
<Suspense fallback={<LoadingFallback fullScreen />}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -88,9 +88,9 @@ export function BootstrapWizard() {
|
|||||||
const { obj, sch } = resolved;
|
const { obj, sch } = resolved;
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const accountId = getAccountId(obj.objectName);
|
const accountId = getAccountId(obj.objectName);
|
||||||
const responses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties, ctrl.signal);
|
const responses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties, ctrl.signal);
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||||
|
import { friendlyName, getActionInfo, getObjectKind, useGlobalSearch } from '@/hooks/useGlobalSearch';
|
||||||
|
import type { SearchIndexEntry } from '@/stores/schemaStore';
|
||||||
|
|
||||||
|
interface CommandPaletteProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const closePalette = useCallback(() => onOpenChange(false), [onOpenChange]);
|
||||||
|
const { query, setQuery, debouncedQuery, groups, selectEntry, reset, schema } = useGlobalSearch(closePalette);
|
||||||
|
|
||||||
|
const groupLabels: Record<SearchIndexEntry['type'], string> = useMemo(
|
||||||
|
() => ({
|
||||||
|
link: t('globalSearch.pages', 'Pages'),
|
||||||
|
form: t('globalSearch.formSections', 'Form Sections'),
|
||||||
|
field: t('globalSearch.fields', 'Fields'),
|
||||||
|
}),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) reset();
|
||||||
|
}, [open, reset]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="top-[15%] translate-y-0 overflow-hidden p-0" showCloseButton={false}>
|
||||||
|
<DialogTitle className="sr-only">{t('globalSearch.title', 'Search')}</DialogTitle>
|
||||||
|
<Command
|
||||||
|
shouldFilter={false}
|
||||||
|
loop
|
||||||
|
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||||
|
>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t('globalSearch.placeholder', 'Search pages, fields, settings...')}
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
trailing={
|
||||||
|
<kbd className="pointer-events-none ml-2 inline-flex h-5 shrink-0 select-none items-center rounded-md border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||||
|
ESC
|
||||||
|
</kbd>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
{debouncedQuery.trim()
|
||||||
|
? t('globalSearch.noResults', 'No results found.')
|
||||||
|
: t('globalSearch.typeToSearch', 'Type to search the admin panel.')}
|
||||||
|
</CommandEmpty>
|
||||||
|
{Array.from(groups.entries()).map(([type, entries]) => (
|
||||||
|
<CommandGroup key={type} heading={groupLabels[type]}>
|
||||||
|
{entries.map((entry, idx) => {
|
||||||
|
const objectKind = schema ? getObjectKind(schema, entry.viewName) : null;
|
||||||
|
const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t);
|
||||||
|
const itemValue = `${type}-${idx}-${entry.viewName}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandItem key={itemValue} value={itemValue} onSelect={() => selectEntry(entry)}>
|
||||||
|
<ActionIcon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span>
|
||||||
|
</div>
|
||||||
|
<span className="ml-auto shrink-0 pl-2 text-xs text-muted-foreground">{actionLabel}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Search, List, Settings, Plus } from 'lucide-react';
|
|
||||||
import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore';
|
|
||||||
import { useAccountStore } from '@/stores/accountStore';
|
|
||||||
import { resolveObject } from '@/lib/schemaResolver';
|
|
||||||
import type { Schema } from '@/types/schema';
|
|
||||||
|
|
||||||
const MAX_RESULTS = 15;
|
|
||||||
|
|
||||||
const TYPE_ORDER: Record<SearchIndexEntry['type'], number> = {
|
|
||||||
link: 0,
|
|
||||||
form: 1,
|
|
||||||
field: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getObjectKind(schema: Schema, viewName: string): 'singleton' | 'object' | null {
|
|
||||||
const resolved = resolveObject(schema, viewName);
|
|
||||||
if (!resolved) return null;
|
|
||||||
return resolved.objectType.type === 'singleton' ? 'singleton' : 'object';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActionInfo(
|
|
||||||
entryType: SearchIndexEntry['type'],
|
|
||||||
objectKind: 'singleton' | 'object' | null,
|
|
||||||
t: (key: string, fallback: string) => string,
|
|
||||||
): { label: string; Icon: typeof List } {
|
|
||||||
if (entryType === 'link') {
|
|
||||||
return objectKind === 'singleton'
|
|
||||||
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
|
|
||||||
: { label: t('globalSearch.list', 'List'), Icon: List };
|
|
||||||
}
|
|
||||||
return objectKind === 'singleton'
|
|
||||||
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
|
|
||||||
: { label: t('globalSearch.create', 'Create'), Icon: Plus };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNavigationPath(
|
|
||||||
entryType: SearchIndexEntry['type'],
|
|
||||||
objectKind: 'singleton' | 'object' | null,
|
|
||||||
section: string,
|
|
||||||
viewName: string,
|
|
||||||
): string {
|
|
||||||
const encodedView = viewName;
|
|
||||||
if (entryType === 'link') {
|
|
||||||
return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}`;
|
|
||||||
}
|
|
||||||
return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}/new`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function friendlyName(viewName: string): string {
|
|
||||||
const stripped = viewName.replace(/^x:/, '');
|
|
||||||
const parts = stripped.split('/');
|
|
||||||
return parts[parts.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GlobalSearchProps {
|
|
||||||
onAfterSelect?: () => void;
|
|
||||||
autoFocus?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GlobalSearch({ onAfterSelect, autoFocus }: GlobalSearchProps = {}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const GROUP_LABELS: Record<SearchIndexEntry['type'], string> = {
|
|
||||||
link: t('globalSearch.pages', 'Pages'),
|
|
||||||
form: t('globalSearch.formSections', 'Form Sections'),
|
|
||||||
field: t('globalSearch.fields', 'Fields'),
|
|
||||||
};
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
||||||
const [activeIndex, setActiveIndex] = useState(-1);
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const schema = useSchemaStore((s) => s.schema);
|
|
||||||
const searchIndex = useSchemaStore((s) => s.searchIndex);
|
|
||||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
|
||||||
|
|
||||||
const handleQueryChange = useCallback((value: string) => {
|
|
||||||
setQuery(value);
|
|
||||||
setActiveIndex(-1);
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = setTimeout(() => setDebouncedQuery(value), 300);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
||||||
setDropdownOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const results = useMemo(() => {
|
|
||||||
if (!debouncedQuery.trim() || !schema) return [];
|
|
||||||
|
|
||||||
const tokens = debouncedQuery
|
|
||||||
.toLowerCase()
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter((s) => s.length > 0);
|
|
||||||
if (tokens.length === 0) return [];
|
|
||||||
|
|
||||||
const filtered = searchIndex.filter((entry) => {
|
|
||||||
const haystack = (entry.text + ' ' + (entry.keywords?.join(' ') ?? '')).toLowerCase();
|
|
||||||
for (const token of tokens) {
|
|
||||||
if (!haystack.includes(token)) return false;
|
|
||||||
}
|
|
||||||
const resolved = resolveObject(schema, entry.viewName);
|
|
||||||
if (!resolved) return false;
|
|
||||||
return hasObjectPermission(resolved.permissionPrefix, 'Get');
|
|
||||||
});
|
|
||||||
|
|
||||||
filtered.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
|
|
||||||
return filtered.slice(0, MAX_RESULTS);
|
|
||||||
}, [debouncedQuery, searchIndex, schema, hasObjectPermission]);
|
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
|
||||||
const map = new Map<SearchIndexEntry['type'], SearchIndexEntry[]>();
|
|
||||||
for (const entry of results) {
|
|
||||||
const arr = map.get(entry.type);
|
|
||||||
if (arr) arr.push(entry);
|
|
||||||
else map.set(entry.type, [entry]);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [results]);
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
|
||||||
(entry: SearchIndexEntry) => {
|
|
||||||
if (!schema) return;
|
|
||||||
const objectKind = getObjectKind(schema, entry.viewName);
|
|
||||||
const path = getNavigationPath(entry.type, objectKind, entry.section, entry.viewName);
|
|
||||||
setDropdownOpen(false);
|
|
||||||
setQuery('');
|
|
||||||
setDebouncedQuery('');
|
|
||||||
navigate(path);
|
|
||||||
onAfterSelect?.();
|
|
||||||
},
|
|
||||||
[schema, navigate, onAfterSelect],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
if (!dropdownOpen || results.length === 0) return;
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
setActiveIndex((i) => (i + 1) % results.length);
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
setActiveIndex((i) => (i - 1 + results.length) % results.length);
|
|
||||||
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSelect(results[activeIndex]);
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setDropdownOpen(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[dropdownOpen, results, activeIndex, handleSelect],
|
|
||||||
);
|
|
||||||
|
|
||||||
const showDropdown = dropdownOpen && debouncedQuery.trim().length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full max-w-md" ref={containerRef}>
|
|
||||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
onChange={(e) => {
|
|
||||||
handleQueryChange(e.target.value);
|
|
||||||
setDropdownOpen(true);
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
if (query.trim()) setDropdownOpen(true);
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 pl-9 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showDropdown && (
|
|
||||||
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
|
|
||||||
{results.length === 0 ? (
|
|
||||||
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
|
|
||||||
{t('globalSearch.noResults', 'No results found.')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="max-h-80 overflow-y-auto py-1">
|
|
||||||
{Array.from(groups.entries()).map(([type, entries]) => (
|
|
||||||
<div key={type}>
|
|
||||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{GROUP_LABELS[type]}</div>
|
|
||||||
{entries.map((entry) => {
|
|
||||||
const flatIdx = results.indexOf(entry);
|
|
||||||
const objectKind = schema ? getObjectKind(schema, entry.viewName) : null;
|
|
||||||
const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${type}-${entry.viewName}-${flatIdx}`}
|
|
||||||
type="button"
|
|
||||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent ${
|
|
||||||
flatIdx === activeIndex ? 'bg-accent' : ''
|
|
||||||
}`}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSelect(entry);
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setActiveIndex(flatIdx)}
|
|
||||||
>
|
|
||||||
<ActionIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
|
||||||
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span>
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">{actionLabel}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function LoadingFallback({ fullScreen = false }: { fullScreen?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col items-center justify-center gap-3', fullScreen ? 'min-h-screen' : 'p-8')}>
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-muted-foreground">{t('common.loading')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -77,5 +77,9 @@ export default function Logo() {
|
|||||||
return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
|
return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!failed) {
|
||||||
|
return <div className="h-7" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
return <DefaultLogo />;
|
return <DefaultLogo />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useBufferedValue } from '@/hooks/useBufferedValue';
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -21,11 +22,7 @@ function BufferedExprInput({
|
|||||||
React.InputHTMLAttributes<HTMLInputElement>,
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
'onChange' | 'value'
|
'onChange' | 'value'
|
||||||
>) {
|
>) {
|
||||||
const [local, setLocal] = useState(value);
|
const [local, setLocal] = useBufferedValue(value);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocal(value);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (local !== value) onCommit(local);
|
if (local !== value) onCommit(local);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
import { useNavigate, useBlocker } from 'react-router-dom';
|
import { useNavigate, useBlocker } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -166,11 +167,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
return Array.from(set);
|
return Array.from(set);
|
||||||
}, [schema, resolved, viewName]);
|
}, [schema, resolved, viewName]);
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevCreateInitKey, setPrevCreateInitKey] = useState<typeof resolved | undefined>(undefined);
|
||||||
if (!schema || !resolved) return;
|
if (isCreate && schema && resolved) {
|
||||||
|
if (resolved !== prevCreateInitKey) {
|
||||||
|
setPrevCreateInitKey(resolved);
|
||||||
const { obj, sch } = resolved;
|
const { obj, sch } = resolved;
|
||||||
|
|
||||||
if (isCreate) {
|
|
||||||
const staticFilters = resolved.list?.filtersStatic;
|
const staticFilters = resolved.list?.filtersStatic;
|
||||||
if (sch.type === 'multiple') {
|
if (sch.type === 'multiple') {
|
||||||
const variantFromFilter = typeof staticFilters?.['@type'] === 'string' ? staticFilters['@type'] : undefined;
|
const variantFromFilter = typeof staticFilters?.['@type'] === 'string' ? staticFilters['@type'] : undefined;
|
||||||
@@ -184,13 +185,19 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
setFormData(defaults);
|
setFormData(defaults);
|
||||||
setOriginalData(defaults);
|
setOriginalData(defaults);
|
||||||
}
|
}
|
||||||
return;
|
}
|
||||||
|
} else if (prevCreateInitKey !== undefined) {
|
||||||
|
setPrevCreateInitKey(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!schema || !resolved || isCreate) return;
|
||||||
|
const { obj, sch } = resolved;
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const accountId = getAccountId(obj.objectName);
|
const accountId = getAccountId(obj.objectName);
|
||||||
const ids = isSingleton ? ['singleton'] : [objectId];
|
const ids = isSingleton ? ['singleton'] : [objectId];
|
||||||
@@ -251,13 +258,14 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
|
|
||||||
const blocker = useBlocker(isDirty && !saving);
|
const blocker = useBlocker(isDirty && !saving);
|
||||||
|
|
||||||
const [pendingNavAfterCreate, setPendingNavAfterCreate] = useState(false);
|
const navigateAfterCreate = useCallback(() => {
|
||||||
useEffect(() => {
|
flushSync(() => {
|
||||||
if (!pendingNavAfterCreate) return;
|
setOriginalData({ ...formData });
|
||||||
setPendingNavAfterCreate(false);
|
setServerCreatedProps(null);
|
||||||
|
});
|
||||||
const section = viewToSection[viewName] ?? '';
|
const section = viewToSection[viewName] ?? '';
|
||||||
navigate(`/${section}/${viewName}`);
|
navigate(`/${section}/${viewName}`);
|
||||||
}, [pendingNavAfterCreate, viewName, viewToSection, navigate]);
|
}, [formData, viewToSection, viewName, navigate]);
|
||||||
|
|
||||||
const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
|
const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
|
||||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||||
@@ -350,9 +358,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
newFieldErrors[ve.property] = validationErrorMessage(ve);
|
newFieldErrors[ve.property] = validationErrorMessage(ve);
|
||||||
} else if (ve.property) {
|
} else if (ve.property) {
|
||||||
const detail = ve.value && ve.value.length > 0 ? ve.value : ve.type;
|
const detail = ve.value && ve.value.length > 0 ? ve.value : ve.type;
|
||||||
setGeneralError((prev) =>
|
setGeneralError((prev) => (prev ? `${prev}\n${ve.property}: ${detail}` : `${ve.property}: ${detail}`));
|
||||||
prev ? `${prev}\n${ve.property}: ${detail}` : `${ve.property}: ${detail}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -736,7 +742,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition);
|
const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
@@ -930,9 +936,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
open={serverCreatedProps !== null && createdObjectId !== null}
|
open={serverCreatedProps !== null && createdObjectId !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setOriginalData({ ...formData });
|
navigateAfterCreate();
|
||||||
setServerCreatedProps(null);
|
|
||||||
setPendingNavAfterCreate(true);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -965,15 +969,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button onClick={navigateAfterCreate}>{t('common.continue', 'Continue')}</Button>
|
||||||
onClick={() => {
|
|
||||||
setOriginalData({ ...formData });
|
|
||||||
setServerCreatedProps(null);
|
|
||||||
setPendingNavAfterCreate(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('common.continue', 'Continue')}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, type KeyboardEvent } from 'react';
|
import { useState, useEffect, useMemo, type KeyboardEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -19,8 +20,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
|
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
|
||||||
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
|
||||||
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight } from 'lucide-react';
|
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ import {
|
|||||||
DURATION_UNITS,
|
DURATION_UNITS,
|
||||||
} from '@/lib/durationFormat';
|
} from '@/lib/durationFormat';
|
||||||
import { resolveSchema, resolveVariantForm, resolveObject, buildEmbeddedDefaults } from '@/lib/schemaResolver';
|
import { resolveSchema, resolveVariantForm, resolveObject, buildEmbeddedDefaults } from '@/lib/schemaResolver';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { useAccountStore } from '@/stores/accountStore';
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
import { useEffectiveEdition } from '@/components/forms/FormEditionContext';
|
import { useEffectiveEdition } from '@/components/forms/FormEditionContext';
|
||||||
import { useObjectList, useObjectLabel, useNoPermissionMessage, type ObjectOption } from '@/lib/objectOptions';
|
import { useObjectList, useObjectLabel, useNoPermissionMessage, type ObjectOption } from '@/lib/objectOptions';
|
||||||
@@ -243,11 +246,7 @@ interface BufferedInputProps extends Omit<React.InputHTMLAttributes<HTMLInputEle
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BufferedInput({ value, onCommit, onBlur, onKeyDown, ...rest }: BufferedInputProps) {
|
function BufferedInput({ value, onCommit, onBlur, onKeyDown, ...rest }: BufferedInputProps) {
|
||||||
const [local, setLocal] = useState(value);
|
const [local, setLocal] = useBufferedValue(value);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocal(value);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (local !== value) onCommit(local);
|
if (local !== value) onCommit(local);
|
||||||
@@ -278,11 +277,7 @@ interface BufferedTextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTe
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BufferedTextarea({ value, onCommit, onBlur, ...rest }: BufferedTextareaProps) {
|
function BufferedTextarea({ value, onCommit, onBlur, ...rest }: BufferedTextareaProps) {
|
||||||
const [local, setLocal] = useState(value);
|
const [local, setLocal] = useBufferedValue(value);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocal(value);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -463,8 +458,7 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
|
|||||||
const [localValue, setLocalValue] = useState(() => (value === SECRET_MASK || value === '' ? '' : value));
|
const [localValue, setLocalValue] = useState(() => (value === SECRET_MASK || value === '' ? '' : value));
|
||||||
const [isMasked, setIsMasked] = useState(() => value === SECRET_MASK);
|
const [isMasked, setIsMasked] = useState(() => value === SECRET_MASK);
|
||||||
|
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
useResetOnChange(value, () => {
|
||||||
useEffect(() => {
|
|
||||||
if (value === SECRET_MASK || value === '') {
|
if (value === SECRET_MASK || value === '') {
|
||||||
setLocalValue('');
|
setLocalValue('');
|
||||||
setIsMasked(value === SECRET_MASK);
|
setIsMasked(value === SECRET_MASK);
|
||||||
@@ -472,8 +466,7 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
|
|||||||
setLocalValue(value);
|
setLocalValue(value);
|
||||||
setIsMasked(false);
|
setIsMasked(false);
|
||||||
}
|
}
|
||||||
}, [value]);
|
});
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
|
|
||||||
const handleLocalChange = (v: string) => {
|
const handleLocalChange = (v: string) => {
|
||||||
setLocalValue(v);
|
setLocalValue(v);
|
||||||
@@ -513,6 +506,10 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
|
|||||||
minLength={minLength}
|
minLength={minLength}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
rows={4}
|
rows={4}
|
||||||
|
autoComplete="off"
|
||||||
|
data-1p-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore
|
||||||
className={visible ? '' : 'tracking-widest'}
|
className={visible ? '' : 'tracking-widest'}
|
||||||
style={visible ? undefined : ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)}
|
style={visible ? undefined : ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)}
|
||||||
/>
|
/>
|
||||||
@@ -535,6 +532,10 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
|
|||||||
placeholder={displayPlaceholder}
|
placeholder={displayPlaceholder}
|
||||||
minLength={minLength}
|
minLength={minLength}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
|
autoComplete="off"
|
||||||
|
data-1p-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
{toggleBtn}
|
{toggleBtn}
|
||||||
@@ -607,13 +608,7 @@ function BufferedNumberInput({
|
|||||||
disabled,
|
disabled,
|
||||||
onCommit,
|
onCommit,
|
||||||
}: BufferedNumberInputProps) {
|
}: BufferedNumberInputProps) {
|
||||||
const [local, setLocal] = useState<string>(value != null ? String(value) : '');
|
const [local, setLocal] = useBufferedValue(value, (v) => (v != null ? String(v) : ''));
|
||||||
|
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
|
||||||
useEffect(() => {
|
|
||||||
setLocal(value != null ? String(value) : '');
|
|
||||||
}, [value]);
|
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (local === '') {
|
if (local === '') {
|
||||||
@@ -666,13 +661,11 @@ function SizeInputEditable({ value, onChange, nullable }: Omit<SizeInputProps, '
|
|||||||
const [unit, setUnit] = useState(initHuman.unit);
|
const [unit, setUnit] = useState(initHuman.unit);
|
||||||
const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value));
|
const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value));
|
||||||
|
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
useResetOnChange(value, () => {
|
||||||
useEffect(() => {
|
|
||||||
const h = bytesToHuman(typeof value === 'number' ? value : 0);
|
const h = bytesToHuman(typeof value === 'number' ? value : 0);
|
||||||
setUnit(h.unit);
|
setUnit(h.unit);
|
||||||
setLocalStr(value == null ? '' : String(h.value));
|
setLocalStr(value == null ? '' : String(h.value));
|
||||||
}, [value]);
|
});
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (localStr === '') {
|
if (localStr === '') {
|
||||||
@@ -771,13 +764,11 @@ function DurationInputEditable({ value, onChange, nullable }: Omit<DurationInput
|
|||||||
const [unit, setUnit] = useState(initHuman.unit);
|
const [unit, setUnit] = useState(initHuman.unit);
|
||||||
const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value));
|
const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value));
|
||||||
|
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
useResetOnChange(value, () => {
|
||||||
useEffect(() => {
|
|
||||||
const h = msToHuman(typeof value === 'number' ? value : 0);
|
const h = msToHuman(typeof value === 'number' ? value : 0);
|
||||||
setUnit(h.unit);
|
setUnit(h.unit);
|
||||||
setLocalStr(value == null ? '' : String(h.value));
|
setLocalStr(value == null ? '' : String(h.value));
|
||||||
}, [value]);
|
});
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (localStr === '') {
|
if (localStr === '') {
|
||||||
@@ -842,66 +833,69 @@ interface DateTimeFieldProps {
|
|||||||
nullable?: boolean;
|
nullable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DATE_TIME_FORMAT = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
|
||||||
function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) {
|
function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const strValue = typeof value === 'string' ? value : '';
|
const strValue = typeof value === 'string' ? value : '';
|
||||||
|
|
||||||
const toLocal = (iso: string): string => {
|
const selected = useMemo(() => {
|
||||||
if (!iso) return '';
|
const d = strValue ? new Date(strValue) : null;
|
||||||
try {
|
return d && !isNaN(d.getTime()) ? d : null;
|
||||||
const d = new Date(iso);
|
|
||||||
if (isNaN(d.getTime())) return '';
|
|
||||||
return d.toISOString().slice(0, 16);
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toIso = (local: string): string | null => {
|
|
||||||
if (!local) return nullable ? null : '';
|
|
||||||
return new Date(local).toISOString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const [local, setLocal] = useState(() => toLocal(strValue));
|
|
||||||
|
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
|
||||||
useEffect(() => {
|
|
||||||
setLocal(toLocal(strValue));
|
|
||||||
}, [strValue]);
|
}, [strValue]);
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
|
|
||||||
const commit = () => {
|
const localTime = selected ? selected.toTimeString().slice(0, 5) : '';
|
||||||
const iso = toIso(local);
|
|
||||||
if (iso !== strValue) onChange(iso);
|
const commit = (day: Date, time: string) => {
|
||||||
|
const [hours, minutes] = time.split(':').map(Number);
|
||||||
|
const next = new Date(day);
|
||||||
|
next.setHours(hours || 0, minutes || 0, 0, 0);
|
||||||
|
onChange(next.toISOString());
|
||||||
};
|
};
|
||||||
|
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
if (!strValue) {
|
if (!strValue) {
|
||||||
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
|
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
|
||||||
}
|
}
|
||||||
let formatted = strValue;
|
return <span className="text-sm">{selected ? DATE_TIME_FORMAT.format(selected) : strValue}</span>;
|
||||||
try {
|
|
||||||
const d = new Date(strValue);
|
|
||||||
if (!isNaN(d.getTime())) {
|
|
||||||
formatted = new Intl.DateTimeFormat(undefined, {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short',
|
|
||||||
}).format(d);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line no-empty
|
|
||||||
} catch {}
|
|
||||||
return <span className="text-sm">{formatted}</span>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Popover>
|
||||||
type="datetime-local"
|
<PopoverTrigger asChild>
|
||||||
value={local}
|
<Button
|
||||||
onChange={(e) => setLocal(e.target.value)}
|
type="button"
|
||||||
onBlur={commit}
|
variant="outline"
|
||||||
className="flex-1"
|
className={cn('flex-1 justify-start text-left font-normal', !selected && 'text-muted-foreground')}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{selected ? DATE_TIME_FORMAT.format(selected) : t('field.pickDate', 'Pick a date')}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={selected ?? undefined}
|
||||||
|
defaultMonth={selected ?? undefined}
|
||||||
|
autoFocus
|
||||||
|
onSelect={(day) => {
|
||||||
|
if (day) commit(day, localTime || '00:00');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="border-t p-3">
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={localTime}
|
||||||
|
disabled={!selected}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (selected && e.target.value) commit(selected, e.target.value);
|
||||||
|
}}
|
||||||
|
aria-label={t('field.time', 'Time')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
{nullable && strValue && (
|
{nullable && strValue && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1005,9 +999,9 @@ function BlobField({ value, onChange, readOnly }: BlobFieldProps) {
|
|||||||
if (!blobId || loaded) return;
|
if (!blobId || loaded) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const accountId = getAccountId('x:Blob');
|
const accountId = getAccountId('x:Blob');
|
||||||
const responses = await jmapGet('Blob', accountId, [blobId], ['data:asText']);
|
const responses = await jmapGet('Blob', accountId, [blobId], ['data:asText']);
|
||||||
@@ -1253,12 +1247,8 @@ function RateField({ value, onChange, readOnly, nullable }: RateFieldProps) {
|
|||||||
const [localCount, setLocalCount] = useState(String(count));
|
const [localCount, setLocalCount] = useState(String(count));
|
||||||
const [localPeriod, setLocalPeriod] = useState(String(human.value));
|
const [localPeriod, setLocalPeriod] = useState(String(human.value));
|
||||||
|
|
||||||
useEffect(() => {
|
useResetOnChange(count, () => setLocalCount(String(count)));
|
||||||
setLocalCount(String(count));
|
useResetOnChange(human.value, () => setLocalPeriod(String(human.value)));
|
||||||
}, [count]);
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalPeriod(String(human.value));
|
|
||||||
}, [human.value]);
|
|
||||||
|
|
||||||
const commitCount = () => {
|
const commitCount = () => {
|
||||||
const n = parseInt(localCount, 10);
|
const n = parseInt(localCount, 10);
|
||||||
@@ -1979,6 +1969,29 @@ function ObjectIdMultiSelectPill({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MapEntryKeyLabel({ keyClass, keyValue, schema }: { keyClass: ScalarType; keyValue: string; schema: Schema }) {
|
||||||
|
if (keyClass.type === 'enum') {
|
||||||
|
const variants = schema.enums[keyClass.enumName] ?? [];
|
||||||
|
const variant = variants.find((v) => v.name === keyValue);
|
||||||
|
return <>{variant?.label ?? keyValue}</>;
|
||||||
|
}
|
||||||
|
if (keyClass.type === 'objectId') {
|
||||||
|
return <ObjectIdKeyLabel objectName={keyClass.objectName} keyValue={keyValue} schema={schema} />;
|
||||||
|
}
|
||||||
|
return <>{keyValue}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ObjectIdKeyLabel({ objectName, keyValue, schema }: { objectName: string; keyValue: string; schema: Schema }) {
|
||||||
|
const list = useObjectList(objectName, schema);
|
||||||
|
const fromList = list.options.find((o) => o.id === keyValue)?.label;
|
||||||
|
const { label: cheapLabel, loading } = useObjectLabel(objectName, fromList ? null : keyValue, schema);
|
||||||
|
const display = fromList ?? cheapLabel;
|
||||||
|
if (loading && !display) {
|
||||||
|
return <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
return <>{display ?? keyValue}</>;
|
||||||
|
}
|
||||||
|
|
||||||
interface MapFieldProps {
|
interface MapFieldProps {
|
||||||
keyClass: ScalarType;
|
keyClass: ScalarType;
|
||||||
valueClass: MapValueType;
|
valueClass: MapValueType;
|
||||||
@@ -2038,15 +2051,6 @@ function MapField({ keyClass, valueClass, value, onChange, readOnly, schema, min
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getKeyLabel = (key: string): string => {
|
|
||||||
if (keyClass.type === 'enum') {
|
|
||||||
const variants = schema.enums[keyClass.enumName] ?? [];
|
|
||||||
const variant = variants.find((v) => v.name === key);
|
|
||||||
if (variant) return variant.label;
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
};
|
|
||||||
|
|
||||||
const existingKeys = new Set(Object.keys(mapValue));
|
const existingKeys = new Set(Object.keys(mapValue));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2063,7 +2067,7 @@ function MapField({ keyClass, valueClass, value, onChange, readOnly, schema, min
|
|||||||
className="flex flex-1 items-center gap-2 p-3 text-sm font-medium hover:bg-accent/50 rounded-t-md transition-colors [&[data-state=closed]>svg]:rotate-0 [&[data-state=open]>svg]:rotate-90"
|
className="flex flex-1 items-center gap-2 p-3 text-sm font-medium hover:bg-accent/50 rounded-t-md transition-colors [&[data-state=closed]>svg]:rotate-0 [&[data-state=open]>svg]:rotate-90"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
<ChevronRight className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||||
{getKeyLabel(key)}
|
<MapEntryKeyLabel keyClass={keyClass} keyValue={key} schema={schema} />
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { lazy, Suspense, useEffect, type ComponentType, type ReactNode } from 'react';
|
||||||
import { useSchemaStore } from '@/stores/schemaStore';
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
import { useCacheStore } from '@/stores/cacheStore';
|
import { useCacheStore } from '@/stores/cacheStore';
|
||||||
import { useAccountStore } from '@/stores/accountStore';
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
@@ -12,11 +12,33 @@ import { resolveObject } from '@/lib/schemaResolver';
|
|||||||
import { DynamicList } from '@/components/lists/DynamicList';
|
import { DynamicList } from '@/components/lists/DynamicList';
|
||||||
import { DynamicForm } from '@/components/forms/DynamicForm';
|
import { DynamicForm } from '@/components/forms/DynamicForm';
|
||||||
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
|
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
|
||||||
import { DashboardView } from '@/features/dashboard/components/DashboardView';
|
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||||
import { DeliveryTracePage } from '@/features/troubleshoot/DeliveryTracePage';
|
import type { Schema } from '@/types/schema';
|
||||||
import { LiveTracingPage } from '@/features/tracing/components/LiveTracingPage';
|
|
||||||
import { TraceDetailView } from '@/features/tracing/components/TraceDetailView';
|
function lazyFeature<M, P>(load: () => Promise<M>, select: (module: M) => ComponentType<P>) {
|
||||||
import { ActionPage } from '@/features/actions/ActionPage';
|
return lazy(() => load().then((module) => ({ default: select(module) })));
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardView = lazyFeature(
|
||||||
|
() => import('@/features/dashboard/components/DashboardView'),
|
||||||
|
(m) => m.DashboardView,
|
||||||
|
);
|
||||||
|
const DeliveryTracePage = lazyFeature(
|
||||||
|
() => import('@/features/troubleshoot/DeliveryTracePage'),
|
||||||
|
(m) => m.DeliveryTracePage,
|
||||||
|
);
|
||||||
|
const LiveTracingPage = lazyFeature(
|
||||||
|
() => import('@/features/tracing/components/LiveTracingPage'),
|
||||||
|
(m) => m.LiveTracingPage,
|
||||||
|
);
|
||||||
|
const TraceDetailView = lazyFeature(
|
||||||
|
() => import('@/features/tracing/components/TraceDetailView'),
|
||||||
|
(m) => m.TraceDetailView,
|
||||||
|
);
|
||||||
|
const ActionPage = lazyFeature(
|
||||||
|
() => import('@/features/actions/ActionPage'),
|
||||||
|
(m) => m.ActionPage,
|
||||||
|
);
|
||||||
|
|
||||||
interface MainContentProps {
|
interface MainContentProps {
|
||||||
viewName?: string;
|
viewName?: string;
|
||||||
@@ -32,6 +54,10 @@ export function MainContent({ viewName, id, section }: MainContentProps) {
|
|||||||
invalidateAllObjectLists();
|
invalidateAllObjectLists();
|
||||||
}, [viewName, invalidateAllObjectLists]);
|
}, [viewName, invalidateAllObjectLists]);
|
||||||
|
|
||||||
|
return <Suspense fallback={<LoadingFallback />}>{renderView(schema, viewName, id, section)}</Suspense>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderView(schema: Schema | null, viewName?: string, id?: string, section?: string): ReactNode {
|
||||||
if (!viewName) {
|
if (!viewName) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8 text-muted-foreground">Select a view from the sidebar.</div>
|
<div className="flex items-center justify-center p-8 text-muted-foreground">Select a view from the sidebar.</div>
|
||||||
@@ -84,7 +110,7 @@ export function MainContent({ viewName, id, section }: MainContentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
if (resolved.objectName === 'x:Trace' && id !== 'new') {
|
if (resolved.objectName === 'x:Trace') {
|
||||||
return <TraceDetailView viewName={viewName} objectId={id} />;
|
return <TraceDetailView viewName={viewName} objectId={id} />;
|
||||||
}
|
}
|
||||||
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
|
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { Navigate, useLocation } from 'react-router-dom';
|
|||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
|
||||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const accessToken = useAuthStore((s) => s.accessToken);
|
const authenticated = useAuthStore((s) => s.isAuthenticated());
|
||||||
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
|
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
if (!accessToken && !bypassToken) {
|
if (!authenticated && !bypassToken) {
|
||||||
const originalPath = location.pathname + location.search;
|
const originalPath = location.pathname + location.search;
|
||||||
return <Navigate to="/login" replace state={{ from: originalPath }} />;
|
return <Navigate to="/login" replace state={{ from: originalPath }} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
const { ChevronDown, Lock } = LucideIcons;
|
const { ChevronDown, Lock } = LucideIcons;
|
||||||
@@ -74,6 +74,25 @@ function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AutoOpenCollapsibleProps {
|
||||||
|
containsActive: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutoOpenCollapsible({ containsActive, children }: AutoOpenCollapsibleProps) {
|
||||||
|
const [open, setOpen] = useState(containsActive);
|
||||||
|
const [prevContainsActive, setPrevContainsActive] = useState(containsActive);
|
||||||
|
if (containsActive !== prevContainsActive) {
|
||||||
|
setPrevContainsActive(containsActive);
|
||||||
|
if (containsActive) setOpen(true);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
{children}
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function checkLinkVisible(viewName: string): boolean {
|
function checkLinkVisible(viewName: string): boolean {
|
||||||
const schema = useSchemaStore.getState().schema;
|
const schema = useSchemaStore.getState().schema;
|
||||||
if (!schema) return true;
|
if (!schema) return true;
|
||||||
@@ -95,6 +114,8 @@ function checkIsEnterprise(viewName: string): boolean {
|
|||||||
return isLinkEnterprise(schema, viewName, edition);
|
return isLinkEnterprise(schema, viewName, edition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ActiveItemRef = (el: HTMLButtonElement | null) => void;
|
||||||
|
|
||||||
interface SidebarSubItemProps {
|
interface SidebarSubItemProps {
|
||||||
item: LayoutSubItem;
|
item: LayoutSubItem;
|
||||||
depth: number;
|
depth: number;
|
||||||
@@ -103,9 +124,19 @@ interface SidebarSubItemProps {
|
|||||||
navigate: ReturnType<typeof useNavigate>;
|
navigate: ReturnType<typeof useNavigate>;
|
||||||
edition: string;
|
edition: string;
|
||||||
onUpsell: () => void;
|
onUpsell: () => void;
|
||||||
|
activeItemRef: ActiveItemRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: SidebarSubItemProps) {
|
function SidebarSubItem({
|
||||||
|
item,
|
||||||
|
depth,
|
||||||
|
sectionName,
|
||||||
|
currentPath,
|
||||||
|
navigate,
|
||||||
|
edition,
|
||||||
|
onUpsell,
|
||||||
|
activeItemRef,
|
||||||
|
}: SidebarSubItemProps) {
|
||||||
if (item.type === 'link') {
|
if (item.type === 'link') {
|
||||||
if (!checkLinkVisible(item.viewName)) return null;
|
if (!checkLinkVisible(item.viewName)) return null;
|
||||||
|
|
||||||
@@ -120,6 +151,7 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
ref={isActive ? activeItemRef : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-start gap-2 font-normal',
|
'w-full justify-start gap-2 font-normal',
|
||||||
isActive && 'bg-accent text-accent-foreground',
|
isActive && 'bg-accent text-accent-foreground',
|
||||||
@@ -145,7 +177,7 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
|||||||
|
|
||||||
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
|
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
|
||||||
return (
|
return (
|
||||||
<Collapsible defaultOpen={containsActive}>
|
<AutoOpenCollapsible containsActive={containsActive}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -167,10 +199,11 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
|||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
edition={edition}
|
edition={edition}
|
||||||
onUpsell={onUpsell}
|
onUpsell={onUpsell}
|
||||||
|
activeItemRef={activeItemRef}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</AutoOpenCollapsible>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,9 +217,18 @@ interface SidebarTopItemProps {
|
|||||||
navigate: ReturnType<typeof useNavigate>;
|
navigate: ReturnType<typeof useNavigate>;
|
||||||
edition: string;
|
edition: string;
|
||||||
onUpsell: () => void;
|
onUpsell: () => void;
|
||||||
|
activeItemRef: ActiveItemRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: SidebarTopItemProps) {
|
function SidebarTopItem({
|
||||||
|
item,
|
||||||
|
sectionName,
|
||||||
|
currentPath,
|
||||||
|
navigate,
|
||||||
|
edition,
|
||||||
|
onUpsell,
|
||||||
|
activeItemRef,
|
||||||
|
}: SidebarTopItemProps) {
|
||||||
if ('link' in item) {
|
if ('link' in item) {
|
||||||
const { name, icon, viewName } = item.link;
|
const { name, icon, viewName } = item.link;
|
||||||
|
|
||||||
@@ -203,6 +245,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
ref={isActive ? activeItemRef : undefined}
|
||||||
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
|
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
@@ -226,7 +269,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
|
|||||||
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible defaultOpen={containsActive}>
|
<AutoOpenCollapsible containsActive={containsActive}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
|
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
|
||||||
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
||||||
@@ -245,10 +288,11 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
|
|||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
edition={edition}
|
edition={edition}
|
||||||
onUpsell={onUpsell}
|
onUpsell={onUpsell}
|
||||||
|
activeItemRef={activeItemRef}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</AutoOpenCollapsible>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,23 +308,19 @@ export function Sidebar() {
|
|||||||
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
|
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
|
||||||
const schema = useSchemaStore((s) => s.schema);
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
const edition = useAccountStore((s) => s.edition);
|
const edition = useAccountStore((s) => s.edition);
|
||||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
const permissions = useAccountStore((s) => s.permissions);
|
||||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||||
|
const activeItem = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const activeItemRef = useCallback<ActiveItemRef>((el) => {
|
||||||
|
activeItem.current = el;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const layouts = useMemo(
|
const layouts = useMemo(() => {
|
||||||
() =>
|
if (!schema) return [];
|
||||||
schema ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) : [],
|
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
||||||
[schema, edition, hasObjectPermission, hasPermission],
|
return visibleLayouts(schema, edition, canGet, hasPermission);
|
||||||
);
|
}, [schema, edition, permissions, hasPermission]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!schema) return;
|
|
||||||
if (layouts.length === 0) return;
|
|
||||||
if (!layouts.find((l) => l.name === activeSection)) {
|
|
||||||
setActiveSection(layouts[0].name);
|
|
||||||
}
|
|
||||||
}, [schema, layouts, activeSection, setActiveSection]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -289,6 +329,10 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
}, [location.pathname, setSidebarOpen]);
|
}, [location.pathname, setSidebarOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeItem.current?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}, [location.pathname, activeSection]);
|
||||||
|
|
||||||
if (!sidebarOpen || !schema) return null;
|
if (!sidebarOpen || !schema) return null;
|
||||||
|
|
||||||
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
|
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
|
||||||
@@ -296,7 +340,7 @@ export function Sidebar() {
|
|||||||
|
|
||||||
const handleSectionClick = (target: Layout) => {
|
const handleSectionClick = (target: Layout) => {
|
||||||
setActiveSection(target.name);
|
setActiveSection(target.name);
|
||||||
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
||||||
const first =
|
const first =
|
||||||
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
|
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
|
||||||
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
|
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
|
||||||
@@ -322,6 +366,7 @@ export function Sidebar() {
|
|||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
edition={edition}
|
edition={edition}
|
||||||
onUpsell={() => setUpsellOpen(true)}
|
onUpsell={() => setUpsellOpen(true)}
|
||||||
|
activeItemRef={activeItemRef}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons;
|
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons;
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { GlobalSearch } from '@/components/common/GlobalSearch';
|
import { CommandPalette } from '@/components/common/CommandPalette';
|
||||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -21,15 +20,18 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import Logo from '@/components/common/Logo';
|
import Logo from '@/components/common/Logo';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||||
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
|
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
|
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useAccountStore } from '@/stores/accountStore';
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
import { useSchemaStore } from '@/stores/schemaStore';
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
|
||||||
|
const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||||
|
|
||||||
function getIcon(name: string): LucideIcons.LucideIcon {
|
function getIcon(name: string): LucideIcons.LucideIcon {
|
||||||
const formatted = name
|
const formatted = name
|
||||||
.split('-')
|
.split('-')
|
||||||
@@ -54,7 +56,18 @@ export function TopBar() {
|
|||||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||||
const schema = useSchemaStore((s) => s.schema);
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleGlobalKeyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
setPaletteOpen((open) => !open);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleGlobalKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const navigableLayouts = schema
|
const navigableLayouts = schema
|
||||||
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
|
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
|
||||||
@@ -66,12 +79,31 @@ export function TopBar() {
|
|||||||
<Menu className="h-4 w-4" />
|
<Menu className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Link to="/" className="flex shrink-0 items-center">
|
<Link to="/" className="flex shrink-0 items-center">
|
||||||
<Logo />
|
<Logo />
|
||||||
</Link>
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{t('version.label', 'Stalwart WebUI v{{version}}', { version: __APP_VERSION__ })}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
<div className="hidden min-w-0 flex-1 items-center justify-center px-4 md:flex">
|
<div className="hidden min-w-0 flex-1 items-center justify-center px-4 md:flex">
|
||||||
<GlobalSearch />
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaletteOpen(true)}
|
||||||
|
className="flex h-9 w-full max-w-md items-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
<span className="flex-1 text-left">{t('globalSearch.placeholder', 'Search pages, fields, settings...')}</span>
|
||||||
|
<kbd className="pointer-events-none flex h-5 select-none items-center rounded border bg-muted px-1.5 font-mono text-[10px] font-medium">
|
||||||
|
{IS_MAC ? '⌘K' : 'Ctrl K'}
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2 md:ml-0">
|
<div className="ml-auto flex items-center gap-2 md:ml-0">
|
||||||
@@ -81,18 +113,13 @@ export function TopBar() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="md:hidden"
|
className="md:hidden"
|
||||||
onClick={() => setMobileSearchOpen(true)}
|
onClick={() => setPaletteOpen(true)}
|
||||||
aria-label={t('search', 'Search')}
|
aria-label={t('search', 'Search')}
|
||||||
>
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog open={mobileSearchOpen} onOpenChange={setMobileSearchOpen}>
|
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
||||||
<DialogContent className="top-4 translate-y-0 max-w-[calc(100vw-2rem)] p-4">
|
|
||||||
<DialogTitle className="sr-only">{t('search', 'Search')}</DialogTitle>
|
|
||||||
<GlobalSearch autoFocus onAfterSelect={() => setMobileSearchOpen(false)} />
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
|
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
|
||||||
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
Filter,
|
Filter,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Lock,
|
||||||
Search,
|
Search,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -46,9 +47,12 @@ import {
|
|||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||||
import { ObjectPicker } from '@/components/common/ObjectPicker';
|
import { ObjectPicker } from '@/components/common/ObjectPicker';
|
||||||
|
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { friendlySetError } from '@/lib/jmapErrors';
|
import { friendlySetError } from '@/lib/jmapErrors';
|
||||||
import { coerceLabel } from '@/lib/objectOptions';
|
import { coerceLabel } from '@/lib/objectOptions';
|
||||||
|
import { buildJmapFilter } from '@/lib/listFilter';
|
||||||
|
import { useResetOnChange } from '@/hooks/useBufferedValue';
|
||||||
|
|
||||||
import { useSchemaStore } from '@/stores/schemaStore';
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
@@ -302,6 +306,24 @@ interface SortState {
|
|||||||
ascending: boolean;
|
ascending: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readUrlFilters(): Record<string, string> {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const filters: Record<string, string> = {};
|
||||||
|
params.forEach((value, key) => {
|
||||||
|
if (key.startsWith('f.')) {
|
||||||
|
filters[key.slice(2)] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUrlSort(): SortState | null {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const sortParam = params.get('sort');
|
||||||
|
const sortDir = params.get('sortDir');
|
||||||
|
return sortParam ? { field: sortParam, ascending: sortDir !== 'desc' } : null;
|
||||||
|
}
|
||||||
|
|
||||||
interface ConfirmAction {
|
interface ConfirmAction {
|
||||||
label: string;
|
label: string;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
@@ -319,6 +341,8 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
const schema = useSchemaStore((s) => s.schema);
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
||||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||||
|
const edition = useAccountStore((s) => s.edition);
|
||||||
|
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||||
|
|
||||||
const resolved = useMemo(() => {
|
const resolved = useMemo(() => {
|
||||||
if (!schema) return null;
|
if (!schema) return null;
|
||||||
@@ -347,15 +371,15 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [selectAllMode, setSelectAllMode] = useState(false);
|
const [selectAllMode, setSelectAllMode] = useState(false);
|
||||||
|
|
||||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
const [filtersOpen, setFiltersOpen] = useState(() => Object.keys(readUrlFilters()).length > 0);
|
||||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
const [filterValues, setFilterValues] = useState<Record<string, string>>(readUrlFilters);
|
||||||
const [appliedFilters, setAppliedFilters] = useState<Record<string, string>>({});
|
const [appliedFilters, setAppliedFilters] = useState<Record<string, string>>(readUrlFilters);
|
||||||
|
|
||||||
const [sort, setSort] = useState<SortState | null>(null);
|
const [sort, setSort] = useState<SortState | null>(readUrlSort);
|
||||||
|
|
||||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useResetOnChange(viewName, () => {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
setTotal(null);
|
setTotal(null);
|
||||||
setAnchorStack([]);
|
setAnchorStack([]);
|
||||||
@@ -364,44 +388,22 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
setSelectAllMode(false);
|
setSelectAllMode(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const initialFilters = readUrlFilters();
|
||||||
const initialFilters: Record<string, string> = {};
|
|
||||||
params.forEach((value, key) => {
|
|
||||||
if (key.startsWith('f.')) {
|
|
||||||
initialFilters[key.slice(2)] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setFilterValues(initialFilters);
|
setFilterValues(initialFilters);
|
||||||
setAppliedFilters(initialFilters);
|
setAppliedFilters(initialFilters);
|
||||||
setFiltersOpen(Object.keys(initialFilters).length > 0);
|
setFiltersOpen(Object.keys(initialFilters).length > 0);
|
||||||
|
setSort(readUrlSort());
|
||||||
|
});
|
||||||
|
|
||||||
const sortParam = params.get('sort');
|
const objectName = resolved?.obj.objectName;
|
||||||
const sortDir = params.get('sortDir');
|
|
||||||
setSort(sortParam ? { field: sortParam, ascending: sortDir !== 'desc' } : null);
|
|
||||||
}, [viewName]);
|
|
||||||
|
|
||||||
const buildFilter = useCallback((): Record<string, unknown> => {
|
const buildFilter = useCallback((): Record<string, unknown> => {
|
||||||
const filter: Record<string, unknown> = {};
|
return buildJmapFilter({
|
||||||
const list = resolved?.list;
|
appliedFilters,
|
||||||
if (list?.filtersStatic) {
|
filters: resolved?.list?.filters,
|
||||||
Object.assign(filter, list.filtersStatic);
|
filtersStatic: resolved?.list?.filtersStatic,
|
||||||
}
|
isXPrefixed: objectName?.startsWith('x:') ?? false,
|
||||||
const opSuffix: Record<string, string> = {
|
});
|
||||||
eq: '',
|
}, [appliedFilters, resolved?.list, objectName]);
|
||||||
gt: 'IsGreaterThan',
|
|
||||||
gte: 'IsGreaterThanOrEqual',
|
|
||||||
lt: 'IsLessThan',
|
|
||||||
lte: 'IsLessThanOrEqual',
|
|
||||||
};
|
|
||||||
for (const [key, val] of Object.entries(appliedFilters)) {
|
|
||||||
if (val === '' || val == null) continue;
|
|
||||||
if (key.endsWith('Op')) continue;
|
|
||||||
const op = appliedFilters[`${key}Op`];
|
|
||||||
const suffix = op ? (opSuffix[op] ?? '') : '';
|
|
||||||
filter[`${key}${suffix}`] = val;
|
|
||||||
}
|
|
||||||
return filter;
|
|
||||||
}, [appliedFilters, resolved?.list]);
|
|
||||||
|
|
||||||
const buildSort = useCallback((): Record<string, unknown>[] | undefined => {
|
const buildSort = useCallback((): Record<string, unknown>[] | undefined => {
|
||||||
if (!sort) return undefined;
|
if (!sort) return undefined;
|
||||||
@@ -473,6 +475,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resolved?.list) return;
|
if (!resolved?.list) return;
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setAnchorStack([]);
|
setAnchorStack([]);
|
||||||
setCurrentAnchor(null);
|
setCurrentAnchor(null);
|
||||||
fetchData(null);
|
fetchData(null);
|
||||||
@@ -1020,17 +1023,20 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
function renderItemActions(item: Record<string, unknown>): React.ReactNode {
|
function renderItemActions(item: Record<string, unknown>): React.ReactNode {
|
||||||
if (!hasItemActions || !list.itemActions) return null;
|
if (!hasItemActions || !list.itemActions) return null;
|
||||||
|
|
||||||
const filteredActions = list.itemActions.filter((action) => {
|
const filteredActions = list.itemActions.flatMap((action): { action: ItemAction; locked: boolean }[] => {
|
||||||
if (action.type === 'separator') return true;
|
if (action.type === 'separator') return [{ action, locked: false }];
|
||||||
if (action.type === 'delete') return canDelete;
|
if (action.type === 'delete') return canDelete ? [{ action, locked: false }] : [];
|
||||||
if (action.type === 'setProperty') return canUpdate;
|
if (action.type === 'setProperty') return canUpdate ? [{ action, locked: false }] : [];
|
||||||
if (action.type === 'view' || action.type === 'query') {
|
if (action.type === 'view' || action.type === 'query') {
|
||||||
const targetObj = resolveObject(schema!, action.objectName);
|
const targetObj = resolveObject(schema!, action.objectName);
|
||||||
if (targetObj && !hasObjectPermission(targetObj.permissionPrefix, 'Get')) {
|
if (!targetObj) return [];
|
||||||
return false;
|
if (targetObj.enterprise) {
|
||||||
|
if (edition === 'oss') return [];
|
||||||
|
if (edition === 'community') return [{ action, locked: true }];
|
||||||
}
|
}
|
||||||
|
if (!hasObjectPermission(targetObj.permissionPrefix, 'Get')) return [];
|
||||||
}
|
}
|
||||||
return true;
|
return [{ action, locked: false }];
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filteredActions.length === 0) return null;
|
if (filteredActions.length === 0) return null;
|
||||||
@@ -1043,7 +1049,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
{filteredActions.map((action, idx) => {
|
{filteredActions.map(({ action, locked }, idx) => {
|
||||||
if (action.type === 'separator') {
|
if (action.type === 'separator') {
|
||||||
return <DropdownMenuSeparator key={`sep-${idx}`} />;
|
return <DropdownMenuSeparator key={`sep-${idx}`} />;
|
||||||
}
|
}
|
||||||
@@ -1057,7 +1063,9 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
className={isDestructive ? 'text-destructive' : undefined}
|
className={isDestructive ? 'text-destructive' : undefined}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (needsConfirmation) {
|
if (locked) {
|
||||||
|
setUpsellOpen(true);
|
||||||
|
} else if (needsConfirmation) {
|
||||||
setConfirmAction({
|
setConfirmAction({
|
||||||
label: action.label,
|
label: action.label,
|
||||||
onConfirm: () => executeItemAction(action, item),
|
onConfirm: () => executeItemAction(action, item),
|
||||||
@@ -1068,6 +1076,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
|
{locked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1206,7 +1215,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="rounded-lg border bg-background shadow-sm">
|
<div className="rounded-lg border bg-background shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto rounded-[calc(var(--radius-lg)-1px)]">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted">
|
<tr className="border-b bg-muted">
|
||||||
@@ -1352,6 +1361,8 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { DayPicker, getDefaultClassNames, type DayButton } from '@daypicker/react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button, buttonVariants } from '@/components/ui/button';
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = 'label',
|
||||||
|
buttonVariant = 'ghost',
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=popover-content]_&]:bg-transparent',
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) => date.toLocaleString('default', { month: 'short' }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn('w-fit', defaultClassNames.root),
|
||||||
|
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
|
||||||
|
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
|
||||||
|
nav: cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', defaultClassNames.nav),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||||
|
defaultClassNames.button_previous,
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||||
|
defaultClassNames.button_next,
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
|
||||||
|
defaultClassNames.month_caption,
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
|
||||||
|
defaultClassNames.dropdowns,
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
|
||||||
|
defaultClassNames.dropdown_root,
|
||||||
|
),
|
||||||
|
dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown),
|
||||||
|
caption_label: cn(
|
||||||
|
'font-medium select-none',
|
||||||
|
captionLayout === 'label'
|
||||||
|
? 'text-sm'
|
||||||
|
: 'flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
|
||||||
|
defaultClassNames.caption_label,
|
||||||
|
),
|
||||||
|
month_grid: cn('w-full border-collapse', defaultClassNames.month_grid),
|
||||||
|
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
'flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none',
|
||||||
|
defaultClassNames.weekday,
|
||||||
|
),
|
||||||
|
week: cn('mt-2 flex w-full', defaultClassNames.week),
|
||||||
|
week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
|
||||||
|
week_number: cn('text-[0.8rem] text-muted-foreground select-none', defaultClassNames.week_number),
|
||||||
|
day: cn(
|
||||||
|
'group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md',
|
||||||
|
props.showWeekNumber
|
||||||
|
? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-md'
|
||||||
|
: '[&:first-child[data-selected=true]_button]:rounded-l-md',
|
||||||
|
defaultClassNames.day,
|
||||||
|
),
|
||||||
|
range_start: cn('rounded-l-md bg-accent', defaultClassNames.range_start),
|
||||||
|
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||||
|
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none',
|
||||||
|
defaultClassNames.today,
|
||||||
|
),
|
||||||
|
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
|
||||||
|
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
|
||||||
|
hidden: cn('invisible', defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => (
|
||||||
|
<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
|
||||||
|
),
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
const Icon = orientation === 'left' ? ChevronLeft : orientation === 'right' ? ChevronRight : ChevronDown;
|
||||||
|
return <Icon className={cn('size-4', className)} {...props} />;
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus();
|
||||||
|
}, [modifiers.focused]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70',
|
||||||
|
defaultClassNames.day,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton };
|
||||||
@@ -41,8 +41,8 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
|
|||||||
|
|
||||||
const CommandInput = React.forwardRef<
|
const CommandInput = React.forwardRef<
|
||||||
React.ComponentRef<typeof CommandPrimitive.Input>,
|
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & { trailing?: React.ReactNode }
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, trailing, ...props }, ref) => (
|
||||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
@@ -53,6 +53,7 @@ const CommandInput = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
{trailing}
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
@@ -48,10 +48,12 @@ const DialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -6,15 +6,16 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import { Check, X, HelpCircle, ChevronRight } from 'lucide-react';
|
import { Check, X, HelpCircle, ChevronRight, Loader2 } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { jmapMapToArray, SECRET_MASK } from '@/lib/jmapUtils';
|
import { jmapMapToArray, SECRET_MASK } from '@/lib/jmapUtils';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { resolveSchema, resolveVariantForm, resolveForm } from '@/lib/schemaResolver';
|
import { resolveSchema, resolveVariantForm, resolveForm } from '@/lib/schemaResolver';
|
||||||
|
import { useObjectList, useObjectLabel } from '@/lib/objectOptions';
|
||||||
import { formatSize, formatDuration } from '@/lib/durationFormat';
|
import { formatSize, formatDuration } from '@/lib/durationFormat';
|
||||||
import type { Schema, Field, FieldType, FormField, Form, Fields, EnumVariant } from '@/types/schema';
|
import type { Schema, Field, FieldType, FormField, Form, Fields, EnumVariant, ScalarType } from '@/types/schema';
|
||||||
|
|
||||||
export interface DynamicViewProps {
|
export interface DynamicViewProps {
|
||||||
schema: Schema;
|
schema: Schema;
|
||||||
@@ -401,7 +402,7 @@ function MapValue({
|
|||||||
schema,
|
schema,
|
||||||
}: {
|
}: {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
keyClass: { type: string; enumName?: string };
|
keyClass: ScalarType;
|
||||||
valueClass: { type: string; objectName?: string };
|
valueClass: { type: string; objectName?: string };
|
||||||
schema: Schema;
|
schema: Schema;
|
||||||
}) {
|
}) {
|
||||||
@@ -417,12 +418,7 @@ function MapValue({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{entries.map(([k, v]) => {
|
{entries.map(([k, v]) => {
|
||||||
let keyLabel = k;
|
const keyLabel = <MapKeyLabel keyClass={keyClass} keyValue={k} schema={schema} />;
|
||||||
if (keyClass.type === 'enum' && keyClass.enumName) {
|
|
||||||
const variants = schema.enums[keyClass.enumName] ?? [];
|
|
||||||
const variant = variants.find((e: EnumVariant) => e.name === k);
|
|
||||||
if (variant) keyLabel = variant.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (valueClass.type === 'object' && valueClass.objectName) {
|
if (valueClass.type === 'object' && valueClass.objectName) {
|
||||||
return (
|
return (
|
||||||
@@ -446,6 +442,29 @@ function MapValue({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MapKeyLabel({ keyClass, keyValue, schema }: { keyClass: ScalarType; keyValue: string; schema: Schema }) {
|
||||||
|
if (keyClass.type === 'enum') {
|
||||||
|
const variants = schema.enums[keyClass.enumName] ?? [];
|
||||||
|
const variant = variants.find((e: EnumVariant) => e.name === keyValue);
|
||||||
|
return <>{variant?.label ?? keyValue}</>;
|
||||||
|
}
|
||||||
|
if (keyClass.type === 'objectId') {
|
||||||
|
return <ObjectIdKeyLabel objectName={keyClass.objectName} keyValue={keyValue} schema={schema} />;
|
||||||
|
}
|
||||||
|
return <>{keyValue}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ObjectIdKeyLabel({ objectName, keyValue, schema }: { objectName: string; keyValue: string; schema: Schema }) {
|
||||||
|
const list = useObjectList(objectName, schema);
|
||||||
|
const fromList = list.options.find((o) => o.id === keyValue)?.label;
|
||||||
|
const { label: cheapLabel, loading } = useObjectLabel(objectName, fromList ? null : keyValue, schema);
|
||||||
|
const display = fromList ?? cheapLabel;
|
||||||
|
if (loading && !display) {
|
||||||
|
return <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
return <>{display ?? keyValue}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function FieldTooltip({ description }: { description: string }) {
|
function FieldTooltip({ description }: { description: string }) {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { CircleDot, CircleX, Clock } from 'lucide-react';
|
|||||||
import { useSchemaStore } from '@/stores/schemaStore';
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
import type { TraceEvent, TraceKeyValue, TraceValue } from '../types';
|
import type { TraceEvent, TraceKeyValue, TraceValue } from '../types';
|
||||||
|
|
||||||
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
|
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013';
|
||||||
|
|
||||||
function intToBase32(input: number | string): string {
|
function intToBase32(input: number | string): string {
|
||||||
let n: bigint;
|
let n: bigint;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, type Dispatch, type SetStateAction } from 'react';
|
||||||
|
|
||||||
|
export function useBufferedValue<T, S = T>(
|
||||||
|
value: T,
|
||||||
|
transform: (v: T) => S = (v) => v as unknown as S,
|
||||||
|
): [S, Dispatch<SetStateAction<S>>] {
|
||||||
|
const [prev, setPrev] = useState(value);
|
||||||
|
const [local, setLocal] = useState<S>(() => transform(value));
|
||||||
|
if (!Object.is(value, prev)) {
|
||||||
|
setPrev(value);
|
||||||
|
setLocal(transform(value));
|
||||||
|
}
|
||||||
|
return [local, setLocal];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResetOnChange<K>(key: K, reset: () => void): void {
|
||||||
|
const [prev, setPrev] = useState(key);
|
||||||
|
if (!Object.is(key, prev)) {
|
||||||
|
setPrev(key);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
const APP_NAME = 'Stalwart WebUI';
|
||||||
|
|
||||||
|
export function useDocumentTitle(title?: string | null) {
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = title ? `${title} · ${APP_NAME}` : APP_NAME;
|
||||||
|
return () => {
|
||||||
|
document.title = APP_NAME;
|
||||||
|
};
|
||||||
|
}, [title]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { List, Plus, Settings } from 'lucide-react';
|
||||||
|
import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore';
|
||||||
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
|
import { resolveObject } from '@/lib/schemaResolver';
|
||||||
|
import type { Schema } from '@/types/schema';
|
||||||
|
|
||||||
|
const MAX_RESULTS = 15;
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
const TYPE_ORDER: Record<SearchIndexEntry['type'], number> = {
|
||||||
|
link: 0,
|
||||||
|
form: 1,
|
||||||
|
field: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ObjectKind = 'singleton' | 'object' | null;
|
||||||
|
|
||||||
|
export function getObjectKind(schema: Schema, viewName: string): ObjectKind {
|
||||||
|
const resolved = resolveObject(schema, viewName);
|
||||||
|
if (!resolved) return null;
|
||||||
|
return resolved.objectType.type === 'singleton' ? 'singleton' : 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActionInfo(
|
||||||
|
entryType: SearchIndexEntry['type'],
|
||||||
|
objectKind: ObjectKind,
|
||||||
|
t: (key: string, fallback: string) => string,
|
||||||
|
): { label: string; Icon: typeof List } {
|
||||||
|
if (objectKind === 'singleton') {
|
||||||
|
return { label: t('globalSearch.settings', 'Settings'), Icon: Settings };
|
||||||
|
}
|
||||||
|
return entryType === 'link'
|
||||||
|
? { label: t('globalSearch.list', 'List'), Icon: List }
|
||||||
|
: { label: t('globalSearch.create', 'Create'), Icon: Plus };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNavigationPath(
|
||||||
|
entryType: SearchIndexEntry['type'],
|
||||||
|
objectKind: ObjectKind,
|
||||||
|
section: string,
|
||||||
|
viewName: string,
|
||||||
|
): string {
|
||||||
|
if (objectKind === 'singleton') return `/${section}/${viewName}/singleton`;
|
||||||
|
return entryType === 'link' ? `/${section}/${viewName}` : `/${section}/${viewName}/new`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function friendlyName(viewName: string): string {
|
||||||
|
const stripped = viewName.replace(/^x:/, '');
|
||||||
|
const parts = stripped.split('/');
|
||||||
|
return parts[parts.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalSearch(onAfterSelect?: () => void) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
|
const searchIndex = useSchemaStore((s) => s.searchIndex);
|
||||||
|
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||||
|
|
||||||
|
const handleQueryChange = useCallback((value: string) => {
|
||||||
|
setQuery(value);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => setDebouncedQuery(value), DEBOUNCE_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
if (!debouncedQuery.trim() || !schema) return [];
|
||||||
|
|
||||||
|
const tokens = debouncedQuery
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
if (tokens.length === 0) return [];
|
||||||
|
|
||||||
|
const filtered = searchIndex.filter((entry) => {
|
||||||
|
const haystack = (entry.text + ' ' + (entry.keywords?.join(' ') ?? '')).toLowerCase();
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (!haystack.includes(token)) return false;
|
||||||
|
}
|
||||||
|
const resolved = resolveObject(schema, entry.viewName);
|
||||||
|
if (!resolved) return false;
|
||||||
|
return hasObjectPermission(resolved.permissionPrefix, 'Get');
|
||||||
|
});
|
||||||
|
|
||||||
|
filtered.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
|
||||||
|
return filtered.slice(0, MAX_RESULTS);
|
||||||
|
}, [debouncedQuery, searchIndex, schema, hasObjectPermission]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const map = new Map<SearchIndexEntry['type'], SearchIndexEntry[]>();
|
||||||
|
for (const entry of results) {
|
||||||
|
const arr = map.get(entry.type);
|
||||||
|
if (arr) arr.push(entry);
|
||||||
|
else map.set(entry.type, [entry]);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
const selectEntry = useCallback(
|
||||||
|
(entry: SearchIndexEntry) => {
|
||||||
|
if (!schema) return;
|
||||||
|
const objectKind = getObjectKind(schema, entry.viewName);
|
||||||
|
navigate(getNavigationPath(entry.type, objectKind, entry.section, entry.viewName));
|
||||||
|
onAfterSelect?.();
|
||||||
|
},
|
||||||
|
[schema, navigate, onAfterSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
setQuery('');
|
||||||
|
setDebouncedQuery('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { query, setQuery: handleQueryChange, debouncedQuery, results, groups, selectEntry, reset, schema };
|
||||||
|
}
|
||||||
+12
-1
@@ -60,6 +60,7 @@
|
|||||||
"preset30d": "Last 30 days",
|
"preset30d": "Last 30 days",
|
||||||
"preset7d": "Last 7 days",
|
"preset7d": "Last 7 days",
|
||||||
"preset90d": "Last 90 days",
|
"preset90d": "Last 90 days",
|
||||||
|
"title": "Dashboard",
|
||||||
"to": "To"
|
"to": "To"
|
||||||
},
|
},
|
||||||
"deliveryTrace": {
|
"deliveryTrace": {
|
||||||
@@ -169,10 +170,12 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"notSet": "Not set",
|
"notSet": "Not set",
|
||||||
"optional": "(optional)",
|
"optional": "(optional)",
|
||||||
|
"pickDate": "Pick a date",
|
||||||
"required": "required",
|
"required": "required",
|
||||||
"selectEllipsis": "Select...",
|
"selectEllipsis": "Select...",
|
||||||
"selectKey": "Select key...",
|
"selectKey": "Select key...",
|
||||||
"selectOptions": "Select options...",
|
"selectOptions": "Select options...",
|
||||||
|
"time": "Time",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"typeAndPressEnter": "Type and press Enter...",
|
"typeAndPressEnter": "Type and press Enter...",
|
||||||
"unknownObjectType": "Unknown object type: {{name}}"
|
"unknownObjectType": "Unknown object type: {{name}}"
|
||||||
@@ -222,7 +225,10 @@
|
|||||||
"list": "List",
|
"list": "List",
|
||||||
"noResults": "No results found.",
|
"noResults": "No results found.",
|
||||||
"pages": "Pages",
|
"pages": "Pages",
|
||||||
"settings": "Settings"
|
"placeholder": "Search pages, fields, settings...",
|
||||||
|
"settings": "Settings",
|
||||||
|
"title": "Search",
|
||||||
|
"typeToSearch": "Type to search the admin panel."
|
||||||
},
|
},
|
||||||
"jmapErrors": {
|
"jmapErrors": {
|
||||||
"addressBookHasContents": "This address book has contacts. Remove them first.",
|
"addressBookHasContents": "This address book has contacts. Remove them first.",
|
||||||
@@ -287,6 +293,7 @@
|
|||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"error": "An unexpected error occurred",
|
"error": "An unexpected error occurred",
|
||||||
"prompt": "Enter your account name to continue",
|
"prompt": "Enter your account name to continue",
|
||||||
|
"title": "Sign in",
|
||||||
"usernamePlaceholder": "user@example.com"
|
"usernamePlaceholder": "user@example.com"
|
||||||
},
|
},
|
||||||
"logo": {
|
"logo": {
|
||||||
@@ -294,6 +301,9 @@
|
|||||||
"stalwartAlt": "Stalwart Logo"
|
"stalwartAlt": "Stalwart Logo"
|
||||||
},
|
},
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
|
"version": {
|
||||||
|
"label": "Stalwart WebUI v{{version}}"
|
||||||
|
},
|
||||||
"oauth": {
|
"oauth": {
|
||||||
"backToLogin": "Back to login",
|
"backToLogin": "Back to login",
|
||||||
"discoveryFailed": "Discovery failed for \"{{username}}\": {{status}} {{statusText}}",
|
"discoveryFailed": "Discovery failed for \"{{username}}\": {{status}} {{statusText}}",
|
||||||
@@ -302,6 +312,7 @@
|
|||||||
"missingParams": "Missing authorization code or state parameter",
|
"missingParams": "Missing authorization code or state parameter",
|
||||||
"processing": "Completing sign in...",
|
"processing": "Completing sign in...",
|
||||||
"stateMismatch": "State parameter mismatch. Please try logging in again.",
|
"stateMismatch": "State parameter mismatch. Please try logging in again.",
|
||||||
|
"title": "Signing in",
|
||||||
"tokenExchangeFailed": "Token exchange failed: {{status}} {{statusText}}"
|
"tokenExchangeFailed": "Token exchange failed: {{status}} {{statusText}}"
|
||||||
},
|
},
|
||||||
"otp": {
|
"otp": {
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildJmapFilter } from './listFilter';
|
||||||
|
import type { Filter } from '@/types/schema';
|
||||||
|
|
||||||
|
const dateFilter: Filter = { type: 'date', field: 'timestamp', label: 'Date' };
|
||||||
|
const textFilter: Filter = { type: 'text', field: 'text', label: 'Text' };
|
||||||
|
const intFilter: Filter = { type: 'integer', field: 'size', label: 'Size' };
|
||||||
|
|
||||||
|
describe('buildJmapFilter', () => {
|
||||||
|
it('returns an empty object when nothing is applied', () => {
|
||||||
|
expect(buildJmapFilter({ appliedFilters: {}, isXPrefixed: true })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges filtersStatic', () => {
|
||||||
|
const filter = buildJmapFilter({
|
||||||
|
appliedFilters: {},
|
||||||
|
filtersStatic: { hasErrors: true },
|
||||||
|
isXPrefixed: true,
|
||||||
|
});
|
||||||
|
expect(filter).toEqual({ hasErrors: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips empty and Op-only keys', () => {
|
||||||
|
const filter = buildJmapFilter({
|
||||||
|
appliedFilters: { text: '', textOp: 'eq' },
|
||||||
|
filters: [textFilter],
|
||||||
|
isXPrefixed: true,
|
||||||
|
});
|
||||||
|
expect(filter).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes text filters through verbatim', () => {
|
||||||
|
const filter = buildJmapFilter({
|
||||||
|
appliedFilters: { text: 'hello' },
|
||||||
|
filters: [textFilter],
|
||||||
|
isXPrefixed: true,
|
||||||
|
});
|
||||||
|
expect(filter).toEqual({ text: 'hello' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the operator suffix for integer filters', () => {
|
||||||
|
const filter = buildJmapFilter({
|
||||||
|
appliedFilters: { size: '1024', sizeOp: 'gt' },
|
||||||
|
filters: [intFilter],
|
||||||
|
isXPrefixed: true,
|
||||||
|
});
|
||||||
|
expect(filter).toEqual({ sizeIsGreaterThan: '1024' });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('date filters (x-prefixed, with operator UI)', () => {
|
||||||
|
it('expands a default date-only equality into a full-day range', () => {
|
||||||
|
const filter = buildJmapFilter({
|
||||||
|
appliedFilters: { timestamp: '2026-06-17' },
|
||||||
|
filters: [dateFilter],
|
||||||
|
isXPrefixed: true,
|
||||||
|
});
|
||||||
|
expect(filter).toEqual({
|
||||||
|
timestampIsGreaterThanOrEqual: '2026-06-17T00:00:00Z',
|
||||||
|
timestampIsLessThan: '2026-06-18T00:00:00Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an explicit eq operator the same as the default', () => {
|
||||||
|
const filter = buildJmapFilter({
|
||||||
|
appliedFilters: { timestamp: '2026-06-17', timestampOp: 'eq' },
|
||||||
|
filters: [dateFilter],
|
||||||
|
isXPrefixed: true,
|
||||||
|
});
|
||||||
|
expect(filter).toEqual({
|
||||||
|
timestampIsGreaterThanOrEqual: '2026-06-17T00:00:00Z',
|
||||||
|
timestampIsLessThan: '2026-06-18T00:00:00Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends midnight UTC for the After (gt) operator', () => {
|
||||||
|
const filter = buildJmapFilter({
|
||||||
|
appliedFilters: { timestamp: '2026-06-17', timestampOp: 'gt' },
|
||||||
|
filters: [dateFilter],
|
||||||
|
isXPrefixed: true,
|
||||||
|
});
|
||||||
|
expect(filter).toEqual({ timestampIsGreaterThan: '2026-06-17T00:00:00Z' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends midnight UTC for the Before (lt) operator', () => {
|
||||||
|
const filter = buildJmapFilter({
|
||||||
|
appliedFilters: { timestamp: '2026-06-17', timestampOp: 'lt' },
|
||||||
|
filters: [dateFilter],
|
||||||
|
isXPrefixed: true,
|
||||||
|
});
|
||||||
|
expect(filter).toEqual({ timestampIsLessThan: '2026-06-17T00:00:00Z' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls over month boundaries', () => {
|
||||||
|
const filter = buildJmapFilter({
|
||||||
|
appliedFilters: { timestamp: '2026-06-30' },
|
||||||
|
filters: [dateFilter],
|
||||||
|
isXPrefixed: true,
|
||||||
|
});
|
||||||
|
expect(filter).toEqual({
|
||||||
|
timestampIsGreaterThanOrEqual: '2026-06-30T00:00:00Z',
|
||||||
|
timestampIsLessThan: '2026-07-01T00:00:00Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls over year boundaries', () => {
|
||||||
|
const filter = buildJmapFilter({
|
||||||
|
appliedFilters: { timestamp: '2026-12-31' },
|
||||||
|
filters: [dateFilter],
|
||||||
|
isXPrefixed: true,
|
||||||
|
});
|
||||||
|
expect(filter).toEqual({
|
||||||
|
timestampIsGreaterThanOrEqual: '2026-12-31T00:00:00Z',
|
||||||
|
timestampIsLessThan: '2027-01-01T00:00:00Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles leap-year February', () => {
|
||||||
|
const filter = buildJmapFilter({
|
||||||
|
appliedFilters: { timestamp: '2028-02-28' },
|
||||||
|
filters: [dateFilter],
|
||||||
|
isXPrefixed: true,
|
||||||
|
});
|
||||||
|
expect(filter).toEqual({
|
||||||
|
timestampIsGreaterThanOrEqual: '2028-02-28T00:00:00Z',
|
||||||
|
timestampIsLessThan: '2028-02-29T00:00:00Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a full datetime through with only the operator suffix applied', () => {
|
||||||
|
const filter = buildJmapFilter({
|
||||||
|
appliedFilters: { timestamp: '2026-06-17T08:30:00Z', timestampOp: 'gt' },
|
||||||
|
filters: [dateFilter],
|
||||||
|
isXPrefixed: true,
|
||||||
|
});
|
||||||
|
expect(filter).toEqual({ timestampIsGreaterThan: '2026-06-17T08:30:00Z' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('date filters (non-x-prefixed, no operator UI)', () => {
|
||||||
|
it('appends midnight UTC as an exact match without range expansion', () => {
|
||||||
|
const filter = buildJmapFilter({
|
||||||
|
appliedFilters: { timestamp: '2026-06-17' },
|
||||||
|
filters: [dateFilter],
|
||||||
|
isXPrefixed: false,
|
||||||
|
});
|
||||||
|
expect(filter).toEqual({ timestamp: '2026-06-17T00:00:00Z' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Filter } from '@/types/schema';
|
||||||
|
|
||||||
|
const OP_SUFFIX: Record<string, string> = {
|
||||||
|
eq: '',
|
||||||
|
gt: 'IsGreaterThan',
|
||||||
|
gte: 'IsGreaterThanOrEqual',
|
||||||
|
lt: 'IsLessThan',
|
||||||
|
lte: 'IsLessThanOrEqual',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
function utcDayStart(date: string): string {
|
||||||
|
return `${date}T00:00:00Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcNextDayStart(date: string): string {
|
||||||
|
const dt = new Date(`${date}T00:00:00Z`);
|
||||||
|
dt.setUTCDate(dt.getUTCDate() + 1);
|
||||||
|
const y = dt.getUTCFullYear();
|
||||||
|
const m = String(dt.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(dt.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}T00:00:00Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDateFilter(
|
||||||
|
filter: Record<string, unknown>,
|
||||||
|
field: string,
|
||||||
|
value: string,
|
||||||
|
op: string | undefined,
|
||||||
|
isXPrefixed: boolean,
|
||||||
|
): void {
|
||||||
|
if (!DATE_ONLY.test(value)) {
|
||||||
|
const suffix = isXPrefixed && op ? (OP_SUFFIX[op] ?? '') : '';
|
||||||
|
filter[`${field}${suffix}`] = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isXPrefixed) {
|
||||||
|
filter[field] = utcDayStart(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveOp = op ?? 'eq';
|
||||||
|
if (effectiveOp === 'eq') {
|
||||||
|
filter[`${field}IsGreaterThanOrEqual`] = utcDayStart(value);
|
||||||
|
filter[`${field}IsLessThan`] = utcNextDayStart(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = OP_SUFFIX[effectiveOp] ?? '';
|
||||||
|
filter[`${field}${suffix}`] = utcDayStart(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildJmapFilterArgs {
|
||||||
|
appliedFilters: Record<string, string>;
|
||||||
|
filters?: Filter[];
|
||||||
|
filtersStatic?: Record<string, unknown>;
|
||||||
|
isXPrefixed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildJmapFilter({
|
||||||
|
appliedFilters,
|
||||||
|
filters,
|
||||||
|
filtersStatic,
|
||||||
|
isXPrefixed,
|
||||||
|
}: BuildJmapFilterArgs): Record<string, unknown> {
|
||||||
|
const filter: Record<string, unknown> = {};
|
||||||
|
if (filtersStatic) {
|
||||||
|
Object.assign(filter, filtersStatic);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFields = new Set((filters ?? []).filter((f) => f.type === 'date').map((f) => f.field));
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(appliedFilters)) {
|
||||||
|
if (val === '' || val == null) continue;
|
||||||
|
if (key.endsWith('Op')) continue;
|
||||||
|
|
||||||
|
const op = appliedFilters[`${key}Op`];
|
||||||
|
|
||||||
|
if (dateFields.has(key)) {
|
||||||
|
applyDateFilter(filter, key, val, op, isXPrefixed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = op ? (OP_SUFFIX[op] ?? '') : '';
|
||||||
|
filter[`${key}${suffix}`] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
@@ -97,7 +97,7 @@ async function fetchObjectList(viewOrObjectName: string, schema: Schema): Promis
|
|||||||
const accountId = getAccountId(objectName);
|
const accountId = getAccountId(objectName);
|
||||||
const displayProp = getDisplayProperty(schema, viewOrObjectName);
|
const displayProp = getDisplayProperty(schema, viewOrObjectName);
|
||||||
|
|
||||||
let items: Array<Record<string, unknown>> = [];
|
let items: Array<Record<string, unknown>>;
|
||||||
try {
|
try {
|
||||||
const result = await jmapQueryAllAndGet(
|
const result = await jmapQueryAllAndGet(
|
||||||
objectName,
|
objectName,
|
||||||
|
|||||||
+1
-1
@@ -12,8 +12,8 @@ import './index.css';
|
|||||||
import App from './App';
|
import App from './App';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import OAuthCallback from './pages/OAuthCallback';
|
import OAuthCallback from './pages/OAuthCallback';
|
||||||
import AdminPanel from './pages/AdminPanel';
|
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
|
import { AdminPanel } from './pages/AdminPanel.lazy';
|
||||||
import { ProtectedRoute } from './components/layout/ProtectedRoute';
|
import { ProtectedRoute } from './components/layout/ProtectedRoute';
|
||||||
import { getBasePath } from './lib/basePath';
|
import { getBasePath } from './lib/basePath';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
export const AdminPanel = lazy(() => import('./AdminPanel'));
|
||||||
+64
-41
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
@@ -17,7 +17,7 @@ import { TopBar } from '@/components/layout/TopBar';
|
|||||||
import { Sidebar } from '@/components/layout/Sidebar';
|
import { Sidebar } from '@/components/layout/Sidebar';
|
||||||
import { MainContent } from '@/components/layout/MainContent';
|
import { MainContent } from '@/components/layout/MainContent';
|
||||||
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||||
import { BootstrapWizard } from '@/components/bootstrap/BootstrapWizard';
|
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||||
import {
|
import {
|
||||||
findFirstAccessibleLinkInLayout,
|
findFirstAccessibleLinkInLayout,
|
||||||
findFirstVisibleLinkInLayout,
|
findFirstVisibleLinkInLayout,
|
||||||
@@ -25,7 +25,12 @@ import {
|
|||||||
visibleLayouts,
|
visibleLayouts,
|
||||||
} from '@/lib/layout';
|
} from '@/lib/layout';
|
||||||
import { usePermissions } from '@/hooks/usePermissions';
|
import { usePermissions } from '@/hooks/usePermissions';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||||
|
import { friendlyName } from '@/hooks/useGlobalSearch';
|
||||||
|
|
||||||
|
const BootstrapWizard = lazy(() =>
|
||||||
|
import('@/components/bootstrap/BootstrapWizard').then((m) => ({ default: m.BootstrapWizard })),
|
||||||
|
);
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -69,9 +74,10 @@ export default function AdminPanel() {
|
|||||||
const setSchema = useSchemaStore((s) => s.setSchema);
|
const setSchema = useSchemaStore((s) => s.setSchema);
|
||||||
const setAccountInfo = useAccountStore((s) => s.setAccountInfo);
|
const setAccountInfo = useAccountStore((s) => s.setAccountInfo);
|
||||||
const edition = useAccountStore((s) => s.edition);
|
const edition = useAccountStore((s) => s.edition);
|
||||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
const permissions = useAccountStore((s) => s.permissions);
|
||||||
const setSession = useAuthStore((s) => s.setSession);
|
const setSession = useAuthStore((s) => s.setSession);
|
||||||
const accessToken = useAuthStore((s) => s.accessToken);
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||||
const { canViewObject } = usePermissions();
|
const { canViewObject } = usePermissions();
|
||||||
@@ -79,6 +85,26 @@ export default function AdminPanel() {
|
|||||||
const [initError, setInitError] = useState<string | null>(null);
|
const [initError, setInitError] = useState<string | null>(null);
|
||||||
const [initializing, setInitializing] = useState(!isSchemaLoaded);
|
const [initializing, setInitializing] = useState(!isSchemaLoaded);
|
||||||
|
|
||||||
|
const searchIndex = useSchemaStore((s) => s.searchIndex);
|
||||||
|
const pageTitle = useMemo(() => {
|
||||||
|
if (!section) return t('dashboard.title', 'Dashboard');
|
||||||
|
if (!viewName) return section;
|
||||||
|
let label: string | undefined;
|
||||||
|
for (const entry of searchIndex) {
|
||||||
|
if (entry.type !== 'link' || entry.viewName !== viewName) continue;
|
||||||
|
if (entry.section === section) {
|
||||||
|
label = entry.text;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
label ??= entry.text;
|
||||||
|
}
|
||||||
|
const name = label ?? friendlyName(viewName);
|
||||||
|
const title = id === 'new' ? t('form.createTitle', 'Create {{name}}', { name }) : name;
|
||||||
|
return `${title} · ${section}`;
|
||||||
|
}, [section, viewName, id, searchIndex, t]);
|
||||||
|
|
||||||
|
useDocumentTitle(pageTitle);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
|
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
|
||||||
if (bypassToken && !accessToken) {
|
if (bypassToken && !accessToken) {
|
||||||
@@ -140,20 +166,19 @@ export default function AdminPanel() {
|
|||||||
};
|
};
|
||||||
}, [isSchemaLoaded, setSession, setSchema, setAccountInfo, t]);
|
}, [isSchemaLoaded, setSession, setSchema, setAccountInfo, t]);
|
||||||
|
|
||||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
|
||||||
const isBootstrapMode = useMemo(() => {
|
const isBootstrapMode = useMemo(() => {
|
||||||
if (!schema) return false;
|
if (!schema) return false;
|
||||||
if (!canViewObject('x:Bootstrap')) return false;
|
if (!canViewObject('x:Bootstrap')) return false;
|
||||||
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
||||||
const hasPerm = (perm: string) => hasPermission(perm);
|
const hasPerm = (perm: string) => permissions.includes(perm);
|
||||||
return visibleLayouts(schema, edition, canGet, hasPerm).length === 0;
|
return visibleLayouts(schema, edition, canGet, hasPerm).length === 0;
|
||||||
}, [schema, canViewObject, edition, hasObjectPermission, hasPermission]);
|
}, [schema, canViewObject, edition, permissions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!schema) return;
|
if (!schema) return;
|
||||||
if (isBootstrapMode) return;
|
if (isBootstrapMode) return;
|
||||||
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
||||||
const hasPerm = (perm: string) => hasPermission(perm);
|
const hasPerm = (perm: string) => permissions.includes(perm);
|
||||||
const layouts = visibleLayouts(schema, edition, canGet, hasPerm);
|
const layouts = visibleLayouts(schema, edition, canGet, hasPerm);
|
||||||
const pickDefault = (): { layoutName: string; link: string | null } | null => {
|
const pickDefault = (): { layoutName: string; link: string | null } | null => {
|
||||||
for (const layout of layouts) {
|
for (const layout of layouts) {
|
||||||
@@ -169,50 +194,46 @@ export default function AdminPanel() {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const redirectTo = (layoutName: string, link: string | null) => {
|
||||||
|
setActiveSection(layoutName);
|
||||||
|
if (link) navigate(`/${layoutName}/${link}`, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
if (section && viewName && !isLinkAccessible(schema, viewName, edition, canGet, hasPerm)) {
|
if (section && viewName && !isLinkAccessible(schema, viewName, edition, canGet, hasPerm)) {
|
||||||
const fallback = pickDefault();
|
const fallback = pickDefault();
|
||||||
if (fallback && (fallback.layoutName !== section || fallback.link !== viewName)) {
|
if (fallback && (fallback.layoutName !== section || fallback.link !== viewName)) {
|
||||||
setActiveSection(fallback.layoutName);
|
redirectTo(fallback.layoutName, fallback.link);
|
||||||
if (fallback.link) {
|
|
||||||
navigate(`/${fallback.layoutName}/${fallback.link}`, { replace: true });
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (section && viewName) {
|
||||||
|
const ownLayout = layouts.find((l) => l.name.toLowerCase() === section.toLowerCase());
|
||||||
|
setActiveSection(ownLayout?.name ?? layouts[0]?.name ?? section);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (section) {
|
if (section) {
|
||||||
setActiveSection(section);
|
const ownLayout = layouts.find((l) => l.name.toLowerCase() === section.toLowerCase());
|
||||||
|
const ownLink = ownLayout
|
||||||
|
? (findFirstAccessibleLinkInLayout(schema, ownLayout, edition, canGet, hasPerm) ??
|
||||||
|
findFirstVisibleLinkInLayout(schema, ownLayout, edition, canGet, hasPerm))
|
||||||
|
: null;
|
||||||
|
if (ownLayout && ownLink) {
|
||||||
|
redirectTo(ownLayout.name, ownLink);
|
||||||
|
} else {
|
||||||
|
const target = pickDefault();
|
||||||
|
if (target) redirectTo(target.layoutName, target.link);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = pickDefault();
|
const target = pickDefault();
|
||||||
if (target) {
|
if (target) redirectTo(target.layoutName, target.link);
|
||||||
setActiveSection(target.layoutName);
|
}, [section, viewName, schema, setActiveSection, navigate, edition, permissions, isBootstrapMode]);
|
||||||
if (target.link) {
|
|
||||||
navigate(`/${target.layoutName}/${target.link}`, { replace: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
section,
|
|
||||||
viewName,
|
|
||||||
schema,
|
|
||||||
setActiveSection,
|
|
||||||
navigate,
|
|
||||||
edition,
|
|
||||||
hasObjectPermission,
|
|
||||||
hasPermission,
|
|
||||||
isBootstrapMode,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (initializing) {
|
if (initializing) {
|
||||||
return (
|
return <LoadingFallback fullScreen />;
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
||||||
<p className="text-muted-foreground">{t('common.loading')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initError) {
|
if (initError) {
|
||||||
@@ -237,7 +258,9 @@ export default function AdminPanel() {
|
|||||||
if (isBootstrapMode) {
|
if (isBootstrapMode) {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
<Suspense fallback={<LoadingFallback fullScreen />}>
|
||||||
<BootstrapWizard />
|
<BootstrapWizard />
|
||||||
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -250,7 +273,7 @@ export default function AdminPanel() {
|
|||||||
<main
|
<main
|
||||||
className={`flex-1 overflow-auto bg-content-background p-6 transition-[margin] ${sidebarOpen ? 'md:ml-64' : ''}`}
|
className={`flex-1 overflow-auto bg-content-background p-6 transition-[margin] ${sidebarOpen ? 'md:ml-64' : ''}`}
|
||||||
>
|
>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary key={activeAccountId ?? 'none'}>
|
||||||
<MainContent viewName={viewName} id={id} section={section} />
|
<MainContent viewName={viewName} id={id} section={section} />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { ArrowRight, Loader2 } from 'lucide-react';
|
import { ArrowRight, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import Logo from '@/components/common/Logo';
|
import Logo from '@/components/common/Logo';
|
||||||
|
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
@@ -23,6 +24,8 @@ export default function LoginPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useDocumentTitle(t('login.title', 'Sign in'));
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = username.trim();
|
const trimmed = username.trim();
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useDocumentTitle(t('errors.notFound'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||||
import { getBasePath } from '@/lib/basePath';
|
import { getBasePath } from '@/lib/basePath';
|
||||||
import { exchangeCode, getStoredOAuthData, clearStoredOAuthData, getOAuthRedirectUri } from '@/services/auth/oauth';
|
import { exchangeCode, getStoredOAuthData, clearStoredOAuthData, getOAuthRedirectUri } from '@/services/auth/oauth';
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ export default function OAuthCallback() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useDocumentTitle(t('oauth.title', 'Signing in'));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function handleCallback() {
|
async function handleCallback() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+7
-1
@@ -107,13 +107,19 @@ export async function apiFetch(path: string, options?: RequestInit): Promise<Res
|
|||||||
|
|
||||||
let response = await makeRequest();
|
let response = await makeRequest();
|
||||||
|
|
||||||
if (response.status === 401 && useAuthStore.getState().refreshToken) {
|
if (response.status === 401) {
|
||||||
|
if (useAuthStore.getState().refreshToken) {
|
||||||
try {
|
try {
|
||||||
await refreshAccessToken();
|
await refreshAccessToken();
|
||||||
response = await makeRequest();
|
response = await makeRequest();
|
||||||
} catch {
|
} catch {
|
||||||
throw new ApiError(401, 'Unauthorized', null);
|
throw new ApiError(401, 'Unauthorized', null);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
window.location.href = `${getBasePath()}/login`;
|
||||||
|
throw new ApiError(401, 'Unauthorized', null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -125,8 +125,7 @@ function getRedirectUri(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startAuthFlow(username: string, returnUrl?: string | null): Promise<void> {
|
export async function startAuthFlow(username: string, returnUrl?: string | null): Promise<void> {
|
||||||
const { authorization_endpoint, token_endpoint, end_session_endpoint, scopes_supported } =
|
const { authorization_endpoint, token_endpoint, end_session_endpoint, scopes_supported } = await discover(username);
|
||||||
await discover(username);
|
|
||||||
|
|
||||||
const codeVerifier = generateCodeVerifier();
|
const codeVerifier = generateCodeVerifier();
|
||||||
const { challenge: codeChallenge, method: codeChallengeMethod } = await generateCodeChallenge(codeVerifier);
|
const { challenge: codeChallenge, method: codeChallengeMethod } = await generateCodeChallenge(codeVerifier);
|
||||||
@@ -167,7 +166,7 @@ export async function startAuthFlow(username: string, returnUrl?: string | null)
|
|||||||
if (SCOPES && SCOPES.length > 0) {
|
if (SCOPES && SCOPES.length > 0) {
|
||||||
scope = SCOPES;
|
scope = SCOPES;
|
||||||
} else if (scopes_supported?.includes('openid')) {
|
} else if (scopes_supported?.includes('openid')) {
|
||||||
scope = ['openid', 'email', 'profile'].filter((s) => scopes_supported.includes(s)).join(' ');
|
scope = ['openid', 'email', 'profile', 'offline_access'].filter((s) => scopes_supported.includes(s)).join(' ');
|
||||||
} else {
|
} else {
|
||||||
scope = '';
|
scope = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,17 @@ import { logJmapExchange } from '@/lib/debug';
|
|||||||
import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap';
|
import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap';
|
||||||
import type { Schema } from '@/types/schema';
|
import type { Schema } from '@/types/schema';
|
||||||
|
|
||||||
const JMAP_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
|
const JMAP_USING = [
|
||||||
|
'urn:ietf:params:jmap:core',
|
||||||
|
'urn:stalwart:jmap',
|
||||||
|
'urn:ietf:params:jmap:blob',
|
||||||
|
'urn:ietf:params:jmap:mail',
|
||||||
|
'urn:ietf:params:jmap:calendars',
|
||||||
|
'urn:ietf:params:jmap:contacts',
|
||||||
|
'urn:ietf:params:jmap:principals',
|
||||||
|
'urn:ietf:params:jmap:sieve',
|
||||||
|
'urn:ietf:params:jmap:vacationresponse',
|
||||||
|
];
|
||||||
|
|
||||||
export function getAccountId(objectType: string): string {
|
export function getAccountId(objectType: string): string {
|
||||||
const { primaryAccountId, activeAccountId } = useAuthStore.getState();
|
const { primaryAccountId, activeAccountId } = useAuthStore.getState();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
|
import { useCacheStore } from './cacheStore';
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
@@ -21,6 +22,7 @@ const initialState = {
|
|||||||
describe('authStore', () => {
|
describe('authStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useAuthStore.setState(initialState);
|
useAuthStore.setState(initialState);
|
||||||
|
useCacheStore.getState().clearAll();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,6 +126,29 @@ describe('authStore', () => {
|
|||||||
|
|
||||||
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
|
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves an activeAccountId that exists in the new accounts', () => {
|
||||||
|
useAuthStore.setState({ activeAccountId: 'acc-2' });
|
||||||
|
|
||||||
|
useAuthStore.getState().setSession(
|
||||||
|
{
|
||||||
|
'acc-1': { name: 'Personal', isPersonal: true },
|
||||||
|
'acc-2': { name: 'Group', isPersonal: false },
|
||||||
|
},
|
||||||
|
'acc-1',
|
||||||
|
'https://api',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(useAuthStore.getState().activeAccountId).toBe('acc-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to primaryAccountId when the previous activeAccountId is unknown', () => {
|
||||||
|
useAuthStore.setState({ activeAccountId: 'gone' });
|
||||||
|
|
||||||
|
useAuthStore.getState().setSession({ 'acc-1': { name: 'A', isPersonal: true } }, 'acc-1', 'https://api');
|
||||||
|
|
||||||
|
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('switchAccount', () => {
|
describe('switchAccount', () => {
|
||||||
@@ -149,6 +174,35 @@ describe('authStore', () => {
|
|||||||
useAuthStore.getState().switchAccount('nonexistent');
|
useAuthStore.getState().switchAccount('nonexistent');
|
||||||
expect(useAuthStore.getState().activeAccountId).toBe('a1');
|
expect(useAuthStore.getState().activeAccountId).toBe('a1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears cached objects when the account changes', () => {
|
||||||
|
useCacheStore.getState().setDisplayNames('Mailbox', { m1: 'Inbox' });
|
||||||
|
useCacheStore.getState().setObjectList('Mailbox', [{ id: 'm1', label: 'Inbox' }]);
|
||||||
|
useAuthStore.setState({
|
||||||
|
accounts: {
|
||||||
|
a1: { name: 'A1', isPersonal: true },
|
||||||
|
a2: { name: 'A2', isPersonal: false },
|
||||||
|
},
|
||||||
|
activeAccountId: 'a1',
|
||||||
|
});
|
||||||
|
|
||||||
|
useAuthStore.getState().switchAccount('a2');
|
||||||
|
|
||||||
|
expect(useCacheStore.getState().displayNames).toEqual({});
|
||||||
|
expect(useCacheStore.getState().objectLists).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the cache when switching to the already active account', () => {
|
||||||
|
useCacheStore.getState().setDisplayNames('Mailbox', { m1: 'Inbox' });
|
||||||
|
useAuthStore.setState({
|
||||||
|
accounts: { a1: { name: 'A1', isPersonal: true } },
|
||||||
|
activeAccountId: 'a1',
|
||||||
|
});
|
||||||
|
|
||||||
|
useAuthStore.getState().switchAccount('a1');
|
||||||
|
|
||||||
|
expect(useCacheStore.getState().getDisplayName('Mailbox', 'm1')).toBe('Inbox');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('logout', () => {
|
describe('logout', () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { useCacheStore } from '@/stores/cacheStore';
|
||||||
|
|
||||||
interface AccountInfo {
|
interface AccountInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -71,10 +72,11 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
setSession: (accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet) => {
|
setSession: (accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet) => {
|
||||||
|
const current = get().activeAccountId;
|
||||||
set({
|
set({
|
||||||
accounts,
|
accounts,
|
||||||
primaryAccountId,
|
primaryAccountId,
|
||||||
activeAccountId: primaryAccountId,
|
activeAccountId: current && accounts[current] ? current : primaryAccountId,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
...(maxObjectsInGet !== undefined ? { maxObjectsInGet } : {}),
|
...(maxObjectsInGet !== undefined ? { maxObjectsInGet } : {}),
|
||||||
...(maxObjectsInSet !== undefined ? { maxObjectsInSet } : {}),
|
...(maxObjectsInSet !== undefined ? { maxObjectsInSet } : {}),
|
||||||
@@ -82,9 +84,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
switchAccount: (accountId) => {
|
switchAccount: (accountId) => {
|
||||||
const { accounts } = get();
|
const { accounts, activeAccountId } = get();
|
||||||
if (accounts[accountId]) {
|
if (accounts[accountId] && accountId !== activeAccountId) {
|
||||||
set({ activeAccountId: accountId });
|
set({ activeAccountId: accountId });
|
||||||
|
useCacheStore.getState().clearAll();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -134,6 +137,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
tokenExpiresAt: state.tokenExpiresAt,
|
tokenExpiresAt: state.tokenExpiresAt,
|
||||||
tokenEndpoint: state.tokenEndpoint,
|
tokenEndpoint: state.tokenEndpoint,
|
||||||
endSessionEndpoint: state.endSessionEndpoint,
|
endSessionEndpoint: state.endSessionEndpoint,
|
||||||
|
activeAccountId: state.activeAccountId,
|
||||||
}) as AuthState,
|
}) as AuthState,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface CacheState {
|
|||||||
getObjectList: (key: string) => ObjectListEntry[] | undefined;
|
getObjectList: (key: string) => ObjectListEntry[] | undefined;
|
||||||
invalidateObjectList: (key: string) => void;
|
invalidateObjectList: (key: string) => void;
|
||||||
invalidateAllObjectLists: () => void;
|
invalidateAllObjectLists: () => void;
|
||||||
|
clearAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCacheStore = create<CacheState>()((set, get) => ({
|
export const useCacheStore = create<CacheState>()((set, get) => ({
|
||||||
@@ -75,4 +76,8 @@ export const useCacheStore = create<CacheState>()((set, get) => ({
|
|||||||
invalidateAllObjectLists: () => {
|
invalidateAllObjectLists: () => {
|
||||||
set({ objectLists: {} });
|
set({ objectLists: {} });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearAll: () => {
|
||||||
|
set({ displayNames: {}, objectLists: {} });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
+1
-5
@@ -256,11 +256,7 @@ export interface MassActionSeparator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ItemAction =
|
export type ItemAction =
|
||||||
| ItemActionDelete
|
ItemActionDelete | ItemActionSetProperty | ItemActionQuery | ItemActionView | ItemActionSeparator;
|
||||||
| ItemActionSetProperty
|
|
||||||
| ItemActionQuery
|
|
||||||
| ItemActionView
|
|
||||||
| ItemActionSeparator;
|
|
||||||
|
|
||||||
export interface ItemActionDelete {
|
export interface ItemActionDelete {
|
||||||
type: 'delete';
|
type: 'delete';
|
||||||
|
|||||||
Vendored
+2
@@ -18,3 +18,5 @@ interface ImportMetaEnv {
|
|||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv;
|
readonly env: ImportMetaEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|||||||
+5
-1
@@ -3,13 +3,17 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { version } from './package.json' with { type: 'json' }
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: './',
|
base: './',
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(version),
|
||||||
|
},
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(import.meta.dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
Reference in New Issue
Block a user