v1.0.7 (fixes #17)
Этот коммит содержится в:
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
Ссылка в новой задаче
Block a user