v1.0.7 (fixes #17)

This commit is contained in:
Maurus Decimus
2026-07-30 17:16:05 +02:00
parent f18f3012aa
commit 189e270785
31 changed files with 1478 additions and 695 deletions
+87
View File
@@ -0,0 +1,87 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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>
);
}
-245
View File
@@ -1,245 +0,0 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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>
);
}
+19
View File
@@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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>
);
}
+4
View File
@@ -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" />;
}
if (!failed) {
return <div className="h-7" aria-hidden="true" />;
}
return <DefaultLogo />;
}
+1 -1
View File
@@ -742,7 +742,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition);
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">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-5 w-5" />
+51 -40
View File
@@ -4,7 +4,7 @@
* 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 { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue';
import ReactMarkdown from 'react-markdown';
@@ -20,8 +20,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
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';
@@ -39,6 +40,7 @@ import {
DURATION_UNITS,
} from '@/lib/durationFormat';
import { resolveSchema, resolveVariantForm, resolveObject, buildEmbeddedDefaults } from '@/lib/schemaResolver';
import { cn } from '@/lib/utils';
import { useAccountStore } from '@/stores/accountStore';
import { useEffectiveEdition } from '@/components/forms/FormEditionContext';
import { useObjectList, useObjectLabel, useNoPermissionMessage, type ObjectOption } from '@/lib/objectOptions';
@@ -831,60 +833,69 @@ interface DateTimeFieldProps {
nullable?: boolean;
}
const DATE_TIME_FORMAT = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' });
function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) {
const { t } = useTranslation();
const strValue = typeof value === 'string' ? value : '';
const toLocal = (iso: string): string => {
if (!iso) return '';
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
return d.toISOString().slice(0, 16);
} catch {
return '';
}
};
const selected = useMemo(() => {
const d = strValue ? new Date(strValue) : null;
return d && !isNaN(d.getTime()) ? d : null;
}, [strValue]);
const toIso = (local: string): string | null => {
if (!local) return nullable ? null : '';
return new Date(local).toISOString();
};
const localTime = selected ? selected.toTimeString().slice(0, 5) : '';
const [local, setLocal] = useBufferedValue(strValue, toLocal);
const commit = () => {
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 (!strValue) {
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
}
let formatted = strValue;
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 <span className="text-sm">{selected ? DATE_TIME_FORMAT.format(selected) : strValue}</span>;
}
return (
<div className="flex items-center gap-2">
<Input
type="datetime-local"
value={local}
onChange={(e) => setLocal(e.target.value)}
onBlur={commit}
className="flex-1"
/>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
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 && (
<Button
type="button"
+33 -7
View File
@@ -4,7 +4,7 @@
* 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 { useCacheStore } from '@/stores/cacheStore';
import { useAccountStore } from '@/stores/accountStore';
@@ -12,11 +12,33 @@ import { resolveObject } from '@/lib/schemaResolver';
import { DynamicList } from '@/components/lists/DynamicList';
import { DynamicForm } from '@/components/forms/DynamicForm';
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
import { DashboardView } from '@/features/dashboard/components/DashboardView';
import { DeliveryTracePage } from '@/features/troubleshoot/DeliveryTracePage';
import { LiveTracingPage } from '@/features/tracing/components/LiveTracingPage';
import { TraceDetailView } from '@/features/tracing/components/TraceDetailView';
import { ActionPage } from '@/features/actions/ActionPage';
import { LoadingFallback } from '@/components/common/LoadingFallback';
import type { Schema } from '@/types/schema';
function lazyFeature<M, P>(load: () => Promise<M>, select: (module: M) => ComponentType<P>) {
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 {
viewName?: string;
@@ -32,6 +54,10 @@ export function MainContent({ viewName, id, section }: MainContentProps) {
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) {
return (
<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 (resolved.objectName === 'x:Trace' && id !== 'new') {
if (resolved.objectName === 'x:Trace') {
return <TraceDetailView viewName={viewName} objectId={id} />;
}
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
+67 -22
View File
@@ -4,7 +4,7 @@
* 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 * as LucideIcons from 'lucide-react';
const { ChevronDown, Lock } = LucideIcons;
@@ -74,6 +74,25 @@ function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean
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 {
const schema = useSchemaStore.getState().schema;
if (!schema) return true;
@@ -95,6 +114,8 @@ function checkIsEnterprise(viewName: string): boolean {
return isLinkEnterprise(schema, viewName, edition);
}
type ActiveItemRef = (el: HTMLButtonElement | null) => void;
interface SidebarSubItemProps {
item: LayoutSubItem;
depth: number;
@@ -103,9 +124,19 @@ interface SidebarSubItemProps {
navigate: ReturnType<typeof useNavigate>;
edition: string;
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 (!checkLinkVisible(item.viewName)) return null;
@@ -120,6 +151,7 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
return (
<Button
variant="ghost"
ref={isActive ? activeItemRef : undefined}
className={cn(
'w-full justify-start gap-2 font-normal',
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);
return (
<Collapsible defaultOpen={containsActive}>
<AutoOpenCollapsible containsActive={containsActive}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
@@ -167,10 +199,11 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
navigate={navigate}
edition={edition}
onUpsell={onUpsell}
activeItemRef={activeItemRef}
/>
))}
</CollapsibleContent>
</Collapsible>
</AutoOpenCollapsible>
);
}
@@ -184,9 +217,18 @@ interface SidebarTopItemProps {
navigate: ReturnType<typeof useNavigate>;
edition: string;
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) {
const { name, icon, viewName } = item.link;
@@ -203,6 +245,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
return (
<Button
variant="ghost"
ref={isActive ? activeItemRef : undefined}
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
onClick={() => {
if (isLocked) {
@@ -226,7 +269,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
return (
<Collapsible defaultOpen={containsActive}>
<AutoOpenCollapsible containsActive={containsActive}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
@@ -245,10 +288,11 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
navigate={navigate}
edition={edition}
onUpsell={onUpsell}
activeItemRef={activeItemRef}
/>
))}
</CollapsibleContent>
</Collapsible>
</AutoOpenCollapsible>
);
}
@@ -264,23 +308,19 @@ export function Sidebar() {
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
const schema = useSchemaStore((s) => s.schema);
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 [upsellOpen, setUpsellOpen] = useState(false);
const activeItem = useRef<HTMLButtonElement | null>(null);
const activeItemRef = useCallback<ActiveItemRef>((el) => {
activeItem.current = el;
}, []);
const layouts = useMemo(
() =>
schema ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) : [],
[schema, edition, hasObjectPermission, 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]);
const layouts = useMemo(() => {
if (!schema) return [];
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
return visibleLayouts(schema, edition, canGet, hasPermission);
}, [schema, edition, permissions, hasPermission]);
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -289,6 +329,10 @@ export function Sidebar() {
}
}, [location.pathname, setSidebarOpen]);
useEffect(() => {
activeItem.current?.scrollIntoView({ block: 'nearest' });
}, [location.pathname, activeSection]);
if (!sidebarOpen || !schema) return null;
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
@@ -296,7 +340,7 @@ export function Sidebar() {
const handleSectionClick = (target: Layout) => {
setActiveSection(target.name);
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
const first =
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
@@ -322,6 +366,7 @@ export function Sidebar() {
navigate={navigate}
edition={edition}
onUpsell={() => setUpsellOpen(true)}
activeItemRef={activeItemRef}
/>
))}
</nav>
+29 -12
View File
@@ -9,8 +9,7 @@ import { useTranslation } from 'react-i18next';
import * as LucideIcons from 'lucide-react';
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons;
import { Button } from '@/components/ui/button';
import { GlobalSearch } from '@/components/common/GlobalSearch';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { CommandPalette } from '@/components/common/CommandPalette';
import {
DropdownMenu,
DropdownMenuContent,
@@ -27,10 +26,12 @@ import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleL
import { useUIStore } from '@/stores/uiStore';
import { useAuthStore } from '@/stores/authStore';
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useAccountStore } from '@/stores/accountStore';
import { useSchemaStore } from '@/stores/schemaStore';
const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
function getIcon(name: string): LucideIcons.LucideIcon {
const formatted = name
.split('-')
@@ -55,7 +56,18 @@ export function TopBar() {
const hasPermission = useAccountStore((s) => s.hasPermission);
const schema = useSchemaStore((s) => s.schema);
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
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
@@ -81,7 +93,17 @@ export function TopBar() {
</TooltipProvider>
<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 className="ml-auto flex items-center gap-2 md:ml-0">
@@ -91,18 +113,13 @@ export function TopBar() {
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setMobileSearchOpen(true)}
onClick={() => setPaletteOpen(true)}
aria-label={t('search', 'Search')}
>
<Search className="h-4 w-4" />
</Button>
<Dialog open={mobileSearchOpen} onOpenChange={setMobileSearchOpen}>
<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>
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
<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" />}
+1 -1
View File
@@ -1215,7 +1215,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
)}
<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">
<thead>
<tr className="border-b bg-muted">
+156
View File
@@ -0,0 +1,156 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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 };
+3 -2
View File
@@ -41,8 +41,8 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & { trailing?: React.ReactNode }
>(({ className, trailing, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
@@ -53,6 +53,7 @@ const CommandInput = React.forwardRef<
)}
{...props}
/>
{trailing}
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
+8 -6
View File
@@ -35,8 +35,8 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }
>(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
@@ -48,10 +48,12 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<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" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{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">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));