feat(C): Phase C — Core UI prüfen, vervollständigen, testen
C-ERR-BOUNDARY: ErrorBoundary erweitert (trace_id, Fallback-UI, PluginErrorBoundary) C-NOTIF: NotificationDropdown mit System-Channel-Link C-DOCS: ApiDocs.tsx Seite (Swagger UI iframe) C-A11Y: aria-label, min-h-touch, focus-visible ergänzt C-FE-TEST: 29 neue Tests (ErrorBoundary, ApiDocs, PrintButton, themeStore, NotificationDropdown) C-DOC: ui-design-guidelines.md aktualisiert Verifiziert (keine Änderungen nötig): - C-TAGS, C-CF, C-FILTER, C-DEDUP, C-ONBOARD, C-THEME, C-PWA, C-PRINT TSC: 0 errors, Build: erfolgreich, Vitest: 29/29 passed
This commit is contained in:
+23
-2
@@ -11,7 +11,7 @@
|
||||
|-------|-------|-------|------|------------|-------------|
|
||||
| A — Stabilität verifizieren | `done` | 2026-08-13 | 2026-08-13 | 5 | 5 |
|
||||
| B — System-Konsolidierung | `done` | 2026-08-13 | 2026-08-13 | ~50 | ~50 |
|
||||
| C — Core UI | `not_started` | — | — | 0 | ~18 |
|
||||
| C — Core UI | `done` | 2026-08-13 | 2026-08-13 | 14 | 14 |
|
||||
| C.5 — Import/Export | `not_started` | — | — | 0 | 8 |
|
||||
| D — Undo/Restore | `not_started` | — | — | 0 | ~12 |
|
||||
| E — Search | `not_started` | — | — | 0 | ~24 |
|
||||
@@ -191,7 +191,28 @@
|
||||
|
||||
---
|
||||
|
||||
## Phasen C-J
|
||||
## Phase C — Core UI prüfen, vervollständigen, testen
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| C-ERR-BOUNDARY | `done` | — | ✅ ErrorBoundary (common) erweitert: trace_id, Tailwind Fallback-UI, Retry+Neu laden Buttons, role=alert, aria-live. PluginErrorBoundary in PluginLoader erweitert mit trace_id. PluginRouteRenderer in routes mit ErrorBoundary umschlossen. AppShell+StartLayout auf common/ErrorBoundary umgestellt. 7 Tests |
|
||||
| C-NOTIF | `done` | — | ✅ NotificationDropdown erweitert: System-Channel-Link Button (→ /communication?channel=system), MessageSquare Icon. Bestehende /notifications API beibehalten (delegiert an comm post_system_message). 5 Tests |
|
||||
| C-TAGS | `done` | — | ✅ Verifiziert — Tags.tsx funktional: CRUD, Color Picker, Delete Confirmation, Usage Count |
|
||||
| C-CF | `done` | — | ✅ Verifiziert — CustomFields.tsx funktional: CRUD, Entity Selector, Field Types, Auto-slug |
|
||||
| C-FILTER | `done` | — | ✅ Verifiziert — SavedFilters.tsx funktional: Save/Load/Delete, Modal |
|
||||
| C-DEDUP | `done` | — | ✅ Verifiziert — DedupMerge.tsx funktional: Threshold Slider, Search, MergeDialog, MergeHistory |
|
||||
| C-PRINT | `done` | — | ✅ Verifiziert — PrintButton in ContactDetailPage, Reports, Calendar. print.ts mit printElement/printCurrentPage/exportToPDF. print.css mit @media print. 6 Tests |
|
||||
| C-DOCS | `done` | — | ✅ ApiDocs.tsx erstellt: iframe mit /docs, External-Link, Route /api-docs. 3 Tests |
|
||||
| C-ONBOARD | `done` | — | ✅ Verifiziert — OnboardingTour (8 Steps, CSS Overlay, Keyboard Nav) + WelcomeDialog funktional |
|
||||
| C-THEME | `done` | — | ✅ Verifiziert — SettingsTheme.tsx + themeStore: CSS Custom Properties, Dark Mode (class), Color Scale Generation, localStorage. 8 Tests |
|
||||
| C-A11Y | `done` | — | ✅ ARIA audit: TopBar aria-labels, Sidebar aria-label, AppShell role=main+tabIndex, Skip-Link, min-h-touch. Fixed: minimized window buttons aria-label, AppShell ErrorBoundary import |
|
||||
| C-PWA | `done` | — | ✅ Verifiziert — vite-plugin-pwa konfiguriert, sw.js+registerSW.js in dist/, Cache-Strategie: App-Shell+statische Assets, NetworkOnly für API |
|
||||
| C-FE-TEST | `done` | — | ✅ 29 neue Tests in 5 Dateien: ErrorBoundary (7), ApiDocs (3), PrintButton (6), themeStore (8), NotificationDropdown (5). Alle grün |
|
||||
| C-DOC | `done` | — | ✅ docs/ui-design-guidelines.md aktualisiert: Error Boundaries, Print/PDF, API Docs, Notification-System, A11Y Patterns |
|
||||
|
||||
---
|
||||
|
||||
## Phasen D-J
|
||||
|
||||
Detaillierte Task-Listen werden beim Start der jeweiligen Phase eingetragen. Siehe `PLATFORM_ROADMAP.md` für alle Tasks.
|
||||
|
||||
|
||||
@@ -511,7 +511,118 @@ function ContactForm({ open, onClose }) {
|
||||
|
||||
---
|
||||
|
||||
## 10. Datei-Struktur
|
||||
## 10. Error Boundaries & Plugin-Fehlerbehandlung
|
||||
|
||||
### ErrorBoundary (common)
|
||||
|
||||
Die zentrale `ErrorBoundary` in `components/common/ErrorBoundary.tsx` fängt React-Render-Fehler ab und zeigt eine Fallback-UI mit:
|
||||
|
||||
- **Trace-ID**: Jeder Fehler erhält eine eindeutige `trace_id` (Format: `err-<timestamp>-<random>`), die im Fehlerbuffer und im Backend-Log gespeichert wird
|
||||
- **Fehlerdetails**: Ausklappbare Details mit Fehlermeldung und Stack-Trace
|
||||
- **Retry-Button**: Setzt den Error-State zurück und versucht erneut zu rendern
|
||||
- **Neu laden-Button**: Lädt die Seite neu (`window.location.reload()`)
|
||||
- **ARIA**: `role="alert"` und `aria-live="assertive"` für Screenreader
|
||||
|
||||
```tsx
|
||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
||||
|
||||
<ErrorBoundary>
|
||||
<MyComponent />
|
||||
</ErrorBoundary>
|
||||
|
||||
// Custom Fallback
|
||||
<ErrorBoundary fallback={(error, retry, traceId) => <CustomErrorUI error={error} retry={retry} traceId={traceId} />}>
|
||||
<MyComponent />
|
||||
</ErrorBoundary>
|
||||
```
|
||||
|
||||
### PluginErrorBoundary
|
||||
|
||||
Plugin-Seiten werden zusätzlich durch `PluginErrorBoundary` in `PluginLoader.tsx` umschlossen. Diese zeigt den Plugin-Namen im Fehlerfall und loggt den Fehler mit `pluginName` und `trace_id`.
|
||||
|
||||
### PluginRouteRenderer
|
||||
|
||||
Der `PluginRouteRenderer` (Catch-All Route) ist mit `ErrorBoundary` umschlossen, sodass Plugin-Seiten-Fehler nicht die gesamte App crashen.
|
||||
|
||||
---
|
||||
|
||||
## 11. Print & PDF-Export
|
||||
|
||||
### PrintButton
|
||||
|
||||
Die `PrintButton`-Komponente in `components/common/PrintButton.tsx` bietet ein Dropdown mit Druck- und PDF-Export-Funktionen:
|
||||
|
||||
- **Drucken**: Ruft `printElement(targetId)` oder `printCurrentPage()` auf
|
||||
- **Als PDF**: Ruft `exportToPDF(targetId, filename)` auf (Browser-Print-to-PDF)
|
||||
- **Print-CSS**: `public/print.css` mit `@media print` Regeln — versteckt Sidebars, TopBar, Buttons und formatiert Tabellen/Card für Druck
|
||||
|
||||
```tsx
|
||||
import { PrintButton } from '@/components/common/PrintButton';
|
||||
|
||||
<PrintButton targetId="contact-detail" filename="kontakt-john-doe" />
|
||||
```
|
||||
|
||||
Verwendung in: `ContactDetailPage`, `Reports`, `Calendar`.
|
||||
|
||||
---
|
||||
|
||||
## 12. API-Dokumentation (Swagger UI)
|
||||
|
||||
Die `ApiDocsPage` in `pages/ApiDocs.tsx` bettet die FastAPI Swagger UI in einem iframe ein:
|
||||
|
||||
- Route: `/api-docs` (erfordert `settings:read` Permission)
|
||||
- iframe src: `/docs` (FastAPI Swagger UI)
|
||||
- External-Link: Öffnet `/docs` in neuem Tab
|
||||
- Vollbild-iframe mit Header-Leiste
|
||||
|
||||
---
|
||||
|
||||
## 13. Notification-System
|
||||
|
||||
### NotificationBell + Dropdown
|
||||
|
||||
Die `NotificationBell` in `components/layout/NotificationBell.tsx` zeigt ungelesene Benachrichtigungen mit:
|
||||
|
||||
- **Unread-Badge**: Roter Zähler (max. 99+) mit 30s Polling
|
||||
- **Dropdown**: Liste der Benachrichtigungen, „Alle als gelesen"-Button
|
||||
- **System-Channel-Link**: Öffnet den Kommunikations-System-Channel unter `/communication?channel=system`
|
||||
- **Alle anzeigen**: Navigiert zu `/settings/notifications`
|
||||
|
||||
Das Backend delegiert `/notifications` an den Kommunikations-System-Channel (`post_system_message`).
|
||||
|
||||
---
|
||||
|
||||
## 14. Accessibility (A11Y) Patterns
|
||||
|
||||
### Verbindliche ARIA-Regeln
|
||||
|
||||
| Element | Attribut | Wert |
|
||||
|---|---|---|
|
||||
| Buttons | `aria-label` | Beschreibung der Aktion |
|
||||
| Icon-only Buttons | `aria-label` + `aria-hidden` auf Icon | Icon dekorativ |
|
||||
| Dropdowns | `aria-haspopup="menu"` + `aria-expanded` | true/false |
|
||||
| Menüs | `role="menu"` + `aria-label` | Menüname |
|
||||
| Error-Bereiche | `role="alert"` + `aria-live="assertive"` | Für Screenreader |
|
||||
| Navigation | `aria-label` auf `<nav>` und `<aside>` | „Hauptnavigation" etc. |
|
||||
| Skip-Link | `sr-only focus:not-sr-only` | „Zum Hauptinhalt springen" |
|
||||
|
||||
### Touch Targets
|
||||
|
||||
Alle interaktiven Elemente müssen `min-h-touch` (44px) und `min-w-touch` (44px) haben:
|
||||
|
||||
```tsx
|
||||
<button className="min-h-touch min-w-touch ...">Klick</button>
|
||||
```
|
||||
|
||||
### Focus-Management
|
||||
|
||||
- Sichtbarer Focus-Ring: `focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500`
|
||||
- Skip-Link in `App.tsx`: `sr-only focus:not-sr-only`
|
||||
- `main`-Element mit `tabIndex={-1}` für Tastatur-Fokus
|
||||
|
||||
---
|
||||
|
||||
## 15. Datei-Struktur
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* ErrorBoundary tests — verifies error catching, trace_id display, and retry.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
||||
|
||||
// Component that throws on render
|
||||
function ThrowOnRender({ message }: { message: string }) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// Component that toggles throwing
|
||||
function ConditionalThrow({ shouldThrow }: { shouldThrow: boolean }) {
|
||||
if (shouldThrow) throw new Error('Conditional error');
|
||||
return <div data-testid="safe-content">Safe content</div>;
|
||||
}
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('renders children when no error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div data-testid="child">Hello World</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('catches render errors and shows fallback UI', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender message="Test error" />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Etwas ist schiefgelaufen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays a trace_id when error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender message="Trace test" />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
const traceEl = screen.getByTestId('error-trace-id');
|
||||
expect(traceEl).toBeInTheDocument();
|
||||
expect(traceEl.textContent).toMatch(/Trace-ID: err-/);
|
||||
});
|
||||
|
||||
it('shows error details in a details element', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender message="Detailed error" />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByText('Fehlerdetails')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has retry and reload buttons', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender message="Button test" />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByText('Erneut versuchen')).toBeInTheDocument();
|
||||
expect(screen.getByText('Neu laden')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('retries and resets error state when retry is clicked', () => {
|
||||
let shouldThrow = true;
|
||||
|
||||
function Wrapper() {
|
||||
if (shouldThrow) throw new Error('Retry test error');
|
||||
return <div data-testid="safe-content">Safe content</div>;
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<ErrorBoundary>
|
||||
<Wrapper />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
|
||||
// Stop throwing, then click retry — ErrorBoundary re-renders children without error
|
||||
shouldThrow = false;
|
||||
fireEvent.click(screen.getByText('Erneut versuchen'));
|
||||
|
||||
expect(screen.getByTestId('safe-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses custom fallback when provided', () => {
|
||||
const customFallback = vi.fn(() => <div data-testid="custom-fallback">Custom</div>);
|
||||
render(
|
||||
<ErrorBoundary fallback={customFallback}>
|
||||
<ThrowOnRender message="Custom fallback" />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByTestId('custom-fallback')).toBeInTheDocument();
|
||||
expect(customFallback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* PrintButton tests — verifies dropdown, print and PDF export actions.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { PrintButton } from '@/components/common/PrintButton';
|
||||
|
||||
// Mock print utilities
|
||||
vi.mock('@/utils/print', () => ({
|
||||
printElement: vi.fn(),
|
||||
printCurrentPage: vi.fn(),
|
||||
exportToPDF: vi.fn(),
|
||||
}));
|
||||
|
||||
import { printElement, printCurrentPage, exportToPDF } from '@/utils/print';
|
||||
|
||||
describe('PrintButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the trigger button', () => {
|
||||
render(<PrintButton />);
|
||||
expect(screen.getByTestId('print-button-trigger')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens dropdown on click', () => {
|
||||
render(<PrintButton />);
|
||||
fireEvent.click(screen.getByTestId('print-button-trigger'));
|
||||
expect(screen.getByTestId('print-button-dropdown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls printElement when targetId is set and print is clicked', () => {
|
||||
render(<PrintButton targetId="my-section" />);
|
||||
fireEvent.click(screen.getByTestId('print-button-trigger'));
|
||||
fireEvent.click(screen.getByTestId('print-button-print'));
|
||||
expect(printElement).toHaveBeenCalledWith('my-section');
|
||||
});
|
||||
|
||||
it('calls printCurrentPage when no targetId and print is clicked', () => {
|
||||
render(<PrintButton />);
|
||||
fireEvent.click(screen.getByTestId('print-button-trigger'));
|
||||
fireEvent.click(screen.getByTestId('print-button-print'));
|
||||
expect(printCurrentPage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls exportToPDF with targetId and filename', () => {
|
||||
render(<PrintButton targetId="report" filename="monthly-report" />);
|
||||
fireEvent.click(screen.getByTestId('print-button-trigger'));
|
||||
fireEvent.click(screen.getByTestId('print-button-pdf'));
|
||||
expect(exportToPDF).toHaveBeenCalledWith('report', 'monthly-report');
|
||||
});
|
||||
|
||||
it('has aria-haspopup and aria-expanded attributes', () => {
|
||||
render(<PrintButton />);
|
||||
const btn = screen.getByTestId('print-button-trigger');
|
||||
expect(btn).toHaveAttribute('aria-haspopup', 'menu');
|
||||
expect(btn).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* NotificationDropdown tests — verifies list, mark-read, system channel link.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { NotificationDropdown } from '@/components/notifications/NotificationDropdown';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string, fallback: string) => fallback }),
|
||||
}));
|
||||
|
||||
// Mock notifications API
|
||||
vi.mock('@/api/notifications', () => ({
|
||||
useNotifications: vi.fn(() => ({
|
||||
data: {
|
||||
items: [
|
||||
{ id: 'n1', type: 'info', title: 'Test Notification', body: 'Body text', read_at: null, created_at: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'n2', type: 'warning', title: 'Read Notification', body: 'Already read', read_at: '2026-01-01T01:00:00Z', created_at: '2026-01-01T00:00:00Z' },
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})),
|
||||
useMarkNotificationRead: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock NotificationItem to simplify rendering
|
||||
vi.mock('@/components/notifications/NotificationItem', () => ({
|
||||
NotificationItem: ({ notification }: { notification: any }) => (
|
||||
<div data-testid={`notif-item-${notification.id}`}>
|
||||
{notification.title}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('NotificationDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderDropdown() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<NotificationDropdown />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
it('renders notification items from API', () => {
|
||||
renderDropdown();
|
||||
expect(screen.getByTestId('notif-item-n1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notif-item-n2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows mark-all-read button when there are unread notifications', () => {
|
||||
renderDropdown();
|
||||
expect(screen.getByText('Alle als gelesen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has a system channel link button', () => {
|
||||
renderDropdown();
|
||||
expect(screen.getByText('System-Channel öffnen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has a view-all button', () => {
|
||||
renderDropdown();
|
||||
expect(screen.getByText('Alle anzeigen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has proper ARIA role and label', () => {
|
||||
renderDropdown();
|
||||
const menus = screen.getAllByRole('menu');
|
||||
const outerMenu = menus.find((m) => m.getAttribute('aria-label') === 'Benachrichtigungen');
|
||||
expect(outerMenu).toBeDefined();
|
||||
expect(outerMenu).toHaveAttribute('aria-label', 'Benachrichtigungen');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* ApiDocs page tests — verifies iframe embedding and external link.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ApiDocsPage } from '@/pages/ApiDocs';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string, fallback: string) => fallback }),
|
||||
}));
|
||||
|
||||
describe('ApiDocsPage', () => {
|
||||
it('renders the page with title', () => {
|
||||
render(<ApiDocsPage />);
|
||||
expect(screen.getByTestId('api-docs-page')).toBeInTheDocument();
|
||||
expect(screen.getByText('API-Dokumentation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('embeds an iframe pointing to /docs', () => {
|
||||
render(<ApiDocsPage />);
|
||||
const iframe = screen.getByTitle('API-Dokumentation');
|
||||
expect(iframe.tagName).toBe('IFRAME');
|
||||
expect(iframe).toHaveAttribute('src', '/docs');
|
||||
});
|
||||
|
||||
it('has a link to open /docs in a new tab', () => {
|
||||
render(<ApiDocsPage />);
|
||||
const link = screen.getByRole('link', { name: 'In neuem Tab öffnen' });
|
||||
expect(link).toHaveAttribute('href', '/docs');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* themeStore tests — verifies theme application, dark mode, and persistence.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
|
||||
describe('themeStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
// Reset store to defaults
|
||||
useThemeStore.setState({
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#d946ef',
|
||||
fontFamily: 'Inter',
|
||||
borderRadius: '0.5rem',
|
||||
darkMode: false,
|
||||
});
|
||||
// Clear CSS custom properties
|
||||
const root = document.documentElement;
|
||||
root.className = '';
|
||||
// Clear all custom properties
|
||||
for (let i = root.style.length - 1; i >= 0; i--) {
|
||||
const prop = root.style[i];
|
||||
if (prop && prop.startsWith('--')) {
|
||||
root.style.removeProperty(prop);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('has default theme values', () => {
|
||||
const state = useThemeStore.getState();
|
||||
expect(state.primaryColor).toBe('#2563eb');
|
||||
expect(state.accentColor).toBe('#d946ef');
|
||||
expect(state.fontFamily).toBe('Inter');
|
||||
expect(state.borderRadius).toBe('0.5rem');
|
||||
expect(state.darkMode).toBe(false);
|
||||
});
|
||||
|
||||
it('setTheme updates primary color and applies CSS variables', () => {
|
||||
useThemeStore.getState().setTheme({ primaryColor: '#ff0000' });
|
||||
expect(useThemeStore.getState().primaryColor).toBe('#ff0000');
|
||||
// Check CSS variable was set
|
||||
const root = document.documentElement;
|
||||
expect(root.style.getPropertyValue('--color-primary-default')).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('toggleDarkMode adds dark class to html element', () => {
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
useThemeStore.getState().toggleDarkMode();
|
||||
expect(useThemeStore.getState().darkMode).toBe(true);
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
it('toggleDarkMode twice removes dark class', () => {
|
||||
useThemeStore.getState().toggleDarkMode();
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
useThemeStore.getState().toggleDarkMode();
|
||||
expect(useThemeStore.getState().darkMode).toBe(false);
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
|
||||
it('saves theme to localStorage', () => {
|
||||
useThemeStore.getState().setTheme({ primaryColor: '#00ff00' });
|
||||
const stored = localStorage.getItem('leocrm-theme');
|
||||
expect(stored).not.toBeNull();
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed.primaryColor).toBe('#00ff00');
|
||||
});
|
||||
|
||||
it('loadFromStorage restores saved theme', () => {
|
||||
localStorage.setItem('leocrm-theme', JSON.stringify({
|
||||
primaryColor: '#123456',
|
||||
accentColor: '#abcdef',
|
||||
fontFamily: 'Roboto',
|
||||
borderRadius: '1rem',
|
||||
darkMode: true,
|
||||
}));
|
||||
useThemeStore.getState().loadFromStorage();
|
||||
const state = useThemeStore.getState();
|
||||
expect(state.primaryColor).toBe('#123456');
|
||||
expect(state.accentColor).toBe('#abcdef');
|
||||
expect(state.fontFamily).toBe('Roboto');
|
||||
expect(state.borderRadius).toBe('1rem');
|
||||
expect(state.darkMode).toBe(true);
|
||||
});
|
||||
|
||||
it('setTheme applies font family CSS variable', () => {
|
||||
useThemeStore.getState().setTheme({ fontFamily: 'Roboto' });
|
||||
const root = document.documentElement;
|
||||
expect(root.style.getPropertyValue('--font-sans')).toContain('Roboto');
|
||||
});
|
||||
|
||||
it('setTheme applies border radius CSS variable', () => {
|
||||
useThemeStore.getState().setTheme({ borderRadius: '1rem' });
|
||||
const root = document.documentElement;
|
||||
expect(root.style.getPropertyValue('--radius-md')).toBe('1rem');
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,19 @@ import { logError } from '@/utils/errorLogger';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
/** Optional fallback render function; receives the error and a retry callback */
|
||||
fallback?: (error: Error, retry: () => void) => ReactNode;
|
||||
/** Optional fallback render function; receives the error, a retry callback, and a trace_id */
|
||||
fallback?: (error: Error, retry: () => void, traceId: string) => ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
traceId: string | null;
|
||||
}
|
||||
|
||||
/** Generate a short unique trace ID for error tracking */
|
||||
function generateTraceId(): string {
|
||||
return 'err-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,79 +27,78 @@ interface ErrorBoundaryState {
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
this.state = { hasError: false, error: null, traceId: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
return { hasError: true, error, traceId: generateTraceId() };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// Log to console + backend + sessionStorage buffer
|
||||
logError(error, { componentStack: errorInfo.componentStack });
|
||||
// Log to console + backend + sessionStorage buffer with trace_id
|
||||
logError(error, { componentStack: errorInfo.componentStack, trace_id: this.state.traceId });
|
||||
}
|
||||
|
||||
handleRetry = (): void => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
this.setState({ hasError: false, error: null, traceId: null });
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
const traceId = this.state.traceId || '';
|
||||
|
||||
// Custom fallback if provided
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback(this.state.error, this.handleRetry);
|
||||
return this.props.fallback(this.state.error, this.handleRetry, traceId);
|
||||
}
|
||||
|
||||
// Default fallback UI
|
||||
// Default fallback UI — Tailwind classes, trace_id, retry + reload
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '300px',
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
className="flex flex-col items-center justify-center min-h-[300px] p-8 text-center"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<h2 style={{ marginBottom: '0.5rem', color: '#dc2626' }}>
|
||||
<div
|
||||
className="text-danger-500 text-5xl mb-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
⚠️
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-secondary-900 mb-2">
|
||||
Etwas ist schiefgelaufen
|
||||
</h2>
|
||||
<p style={{ marginBottom: '1rem', color: '#6b7280', maxWidth: '400px' }}>
|
||||
<p className="text-sm text-secondary-600 mb-4 max-w-md">
|
||||
Ein unerwarteter Fehler ist aufgetreten. Sie können es erneut versuchen oder die Seite aktualisieren.
|
||||
</p>
|
||||
<details style={{ marginBottom: '1rem', maxWidth: '600px', color: '#9ca3af' }}>
|
||||
<summary style={{ cursor: 'pointer', fontSize: '0.875rem' }}>
|
||||
{traceId && (
|
||||
<p className="text-xs text-secondary-400 mb-4 font-mono" data-testid="error-trace-id">
|
||||
Trace-ID: {traceId}
|
||||
</p>
|
||||
)}
|
||||
<details className="mb-4 max-w-lg text-left text-secondary-500">
|
||||
<summary className="cursor-pointer text-sm hover:text-secondary-700">
|
||||
Fehlerdetails
|
||||
</summary>
|
||||
<pre
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.75rem',
|
||||
overflow: 'auto',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<pre className="mt-2 p-3 bg-secondary-50 rounded text-xs overflow-auto max-h-32">
|
||||
{this.state.error.message}
|
||||
{this.state.error.stack ? `\n\n${this.state.error.stack}` : ''}
|
||||
</pre>
|
||||
</details>
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
style={{
|
||||
padding: '0.5rem 1.5rem',
|
||||
backgroundColor: '#2563eb',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '0.375rem',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="inline-flex items-center px-5 py-2.5 bg-primary-600 text-white font-medium rounded-md hover:bg-primary-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 transition-colors min-h-touch"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center px-5 py-2.5 bg-secondary-100 text-secondary-700 font-medium rounded-md hover:bg-secondary-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400 focus-visible:ring-offset-2 transition-colors min-h-touch"
|
||||
>
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useAIUIControl } from '@/hooks/useAIUIControl';
|
||||
import { PluginRegistry } from '@/components/plugins/PluginRegistry';
|
||||
import { AIUIControlIndicator } from '@/components/ai-ui-control/AIUIControlIndicator';
|
||||
import { WindowContainer } from '@/components/window/WindowContainer';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
||||
import { WelcomeDialog } from '@/components/onboarding/WelcomeDialog';
|
||||
import { OnboardingTour } from '@/components/onboarding/OnboardingTour';
|
||||
import { useOnboardingStore } from '@/store/onboardingStore';
|
||||
|
||||
@@ -7,7 +7,7 @@ import React from 'react';
|
||||
import { TopBar } from './TopBar';
|
||||
import { ToastContainer } from '@/components/ui/Toast';
|
||||
import { PluginRegistry } from '@/components/plugins/PluginRegistry';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export function StartLayout() {
|
||||
|
||||
@@ -98,8 +98,9 @@ export function TopBar() {
|
||||
<button
|
||||
key={win.id}
|
||||
onClick={() => restoreWindow(win.id)}
|
||||
className="flex items-center gap-1.5 bg-secondary-100 text-secondary-700 px-3 py-1 rounded-md text-sm hover:bg-secondary-200"
|
||||
className="flex items-center gap-1.5 bg-secondary-100 text-secondary-700 px-3 py-1 rounded-md text-sm hover:bg-secondary-200 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
title={win.title}
|
||||
aria-label={`Fenster wiederherstellen: ${win.title}`}
|
||||
>
|
||||
<Layers className="w-3.5 h-3.5" />
|
||||
<span className="max-w-32 truncate">{win.title}</span>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Bell, CheckCheck, Settings, AlertCircle } from 'lucide-react';
|
||||
import { Bell, CheckCheck, Settings, AlertCircle, MessageSquare } from 'lucide-react';
|
||||
import { useNotifications, useMarkNotificationRead } from '@/api/notifications';
|
||||
import { NotificationItem } from './NotificationItem';
|
||||
|
||||
@@ -37,6 +37,10 @@ export function NotificationDropdown() {
|
||||
navigate('/settings/notifications');
|
||||
};
|
||||
|
||||
const handleOpenSystemChannel = () => {
|
||||
navigate('/communication?channel=system');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-full right-0 mt-1 w-80 bg-white rounded-md shadow-lg border border-secondary-200 z-50"
|
||||
@@ -97,10 +101,18 @@ export function NotificationDropdown() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-secondary-200 px-3 py-2">
|
||||
<div className="border-t border-secondary-200 px-3 py-2 space-y-1">
|
||||
<button
|
||||
onClick={handleOpenSystemChannel}
|
||||
className="w-full flex items-center justify-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 font-medium py-1.5 rounded-md hover:bg-primary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('notifications.openSystemChannel', 'System-Channel öffnen')}
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
{t('notifications.openSystemChannel', 'System-Channel öffnen')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNavigateAll}
|
||||
className="w-full flex items-center justify-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 font-medium py-1.5 rounded-md hover:bg-primary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
className="w-full flex items-center justify-center gap-1.5 text-sm text-secondary-600 hover:text-secondary-700 font-medium py-1.5 rounded-md hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('notifications.viewAll', 'Alle anzeigen')}
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { Suspense, lazy, Component, ReactNode } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { logError } from '@/utils/errorLogger';
|
||||
|
||||
// ── Error Boundary ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -10,20 +11,79 @@ interface PluginErrorBoundaryProps {
|
||||
|
||||
interface PluginErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
traceId: string | null;
|
||||
}
|
||||
|
||||
function generateTraceId(): string {
|
||||
return 'plugin-err-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
class PluginErrorBoundary extends Component<PluginErrorBoundaryProps, PluginErrorBoundaryState> {
|
||||
state = { hasError: false };
|
||||
state: PluginErrorBoundaryState = { hasError: false, error: null, traceId: null };
|
||||
|
||||
static getDerivedStateFromError(): PluginErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
static getDerivedStateFromError(error: Error): PluginErrorBoundaryState {
|
||||
return { hasError: true, error, traceId: generateTraceId() };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
logError(error, {
|
||||
componentStack: errorInfo.componentStack,
|
||||
pluginName: this.props.pluginName,
|
||||
trace_id: this.state.traceId,
|
||||
source: 'PluginErrorBoundary',
|
||||
});
|
||||
}
|
||||
|
||||
handleRetry = (): void => {
|
||||
this.setState({ hasError: false, error: null, traceId: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-red-600" role="alert">
|
||||
Failed to load plugin: {this.props.pluginName}
|
||||
<div
|
||||
className="flex flex-col items-center justify-center min-h-[300px] p-8 text-center"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="text-danger-500 text-4xl mb-3" aria-hidden="true">⚠️</div>
|
||||
<h2 className="text-lg font-bold text-secondary-900 mb-2">
|
||||
Plugin konnte nicht geladen werden
|
||||
</h2>
|
||||
<p className="text-sm text-secondary-600 mb-3">
|
||||
Das Plugin „{this.props.pluginName}" hat einen Fehler verursacht.
|
||||
</p>
|
||||
{this.state.traceId && (
|
||||
<p className="text-xs text-secondary-400 mb-3 font-mono" data-testid="plugin-error-trace-id">
|
||||
Trace-ID: {this.state.traceId}
|
||||
</p>
|
||||
)}
|
||||
{this.state.error && (
|
||||
<details className="mb-4 max-w-lg text-left text-secondary-500">
|
||||
<summary className="cursor-pointer text-sm hover:text-secondary-700">
|
||||
Fehlerdetails
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-secondary-50 rounded text-xs overflow-auto max-h-32">
|
||||
{this.state.error.message}
|
||||
{this.state.error.stack ? `\n\n${this.state.error.stack}` : ''}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-md hover:bg-primary-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 transition-colors min-h-touch"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center px-4 py-2 bg-secondary-100 text-secondary-700 text-sm font-medium rounded-md hover:bg-secondary-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400 transition-colors min-h-touch"
|
||||
>
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* ApiDocs page — embeds the FastAPI Swagger UI at /docs in an iframe.
|
||||
* Provides in-app access to API documentation without leaving LeoCRM.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BookOpen, ExternalLink } from 'lucide-react';
|
||||
|
||||
export function ApiDocsPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="api-docs-page">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-secondary-200 bg-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<BookOpen className="w-5 h-5 text-primary-600" aria-hidden="true" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-secondary-900">
|
||||
{t('apiDocs.title', 'API-Dokumentation')}
|
||||
</h1>
|
||||
<p className="text-sm text-secondary-500">
|
||||
{t('apiDocs.description', 'Swagger UI — Interaktive API-Referenz')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 hover:bg-primary-50 rounded-md transition-colors min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('apiDocs.openInNewTab', 'In neuem Tab öffnen')}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" aria-hidden="true" />
|
||||
{t('apiDocs.openInNewTab', 'In neuem Tab öffnen')}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<iframe
|
||||
src="/docs"
|
||||
title={t('apiDocs.title', 'API-Dokumentation')}
|
||||
className="w-full h-full border-0"
|
||||
aria-label={t('apiDocs.title', 'API-Dokumentation')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApiDocsPage;
|
||||
@@ -11,7 +11,7 @@ import { PasswordResetRequestPage } from '@/pages/PasswordResetRequest';
|
||||
import { PasswordResetConfirmPage } from '@/pages/PasswordResetConfirm';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { PluginRouteRenderer } from '@/components/plugins/PluginRouteRenderer';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
||||
|
||||
// Lazy-loaded pages (code-splitting)
|
||||
const DashboardPage = React.lazy(() => import('@/pages/Dashboard').then(m => ({ default: m.DashboardPage })));
|
||||
@@ -68,6 +68,7 @@ const WorkspaceManagerPage = React.lazy(() => import('@/pages/SettingsWorkspaces
|
||||
const SettingsRechtePage = React.lazy(() => import('@/pages/SettingsRechte').then(m => ({ default: m.SettingsRechtePage })));
|
||||
const NoAccessPage = React.lazy(() => import('@/pages/NoAccessPage').then(m => ({ default: m.NoAccessPage })));
|
||||
const StartPage = React.lazy(() => import('@/pages/StartPage').then(m => ({ default: m.StartPage })));
|
||||
const ApiDocsPage = React.lazy(() => import('@/pages/ApiDocs').then(m => ({ default: m.ApiDocsPage })));
|
||||
|
||||
/** Centered spinner fallback for lazy-loaded routes */
|
||||
function PageLoader() {
|
||||
@@ -167,6 +168,7 @@ const router = createBrowserRouter([
|
||||
{ path: '/contacts/dedup', element: <PermissionRoute permission="contacts:read">{withSuspense(<DedupMergePage />)}</PermissionRoute> },
|
||||
{ path: '/import-export', element: <PermissionRoute permission="contacts:read">{withSuspense(<ImportExportPage />)}</PermissionRoute> },
|
||||
{ path: '/tags', element: <PermissionRoute permission="tags:read">{withSuspense(<TagsPage />)}</PermissionRoute> },
|
||||
{ path: '/api-docs', element: <PermissionRoute permission="settings:read">{withSuspense(<ApiDocsPage />)}</PermissionRoute> },
|
||||
{ path: '/activity', element: <PermissionRoute permission="activity:read">{withSuspense(<ActivityTimelinePage />)}</PermissionRoute> },
|
||||
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
||||
{
|
||||
@@ -197,10 +199,10 @@ const router = createBrowserRouter([
|
||||
{ path: 'workspaces', element: withSuspense(<WorkspaceManagerPage />) },
|
||||
{ path: 'backup', element: withSuspense(<SettingsBackupPage />) },
|
||||
{ path: 'rechte', element: <PermissionRoute permission="settings:read">{withSuspense(<SettingsRechtePage />)}</PermissionRoute> },
|
||||
{ path: '*', element: <PluginRouteRenderer /> },
|
||||
{ path: '*', element: <ErrorBoundary>{<PluginRouteRenderer />}</ErrorBoundary> },
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <PluginRouteRenderer /> },
|
||||
{ path: '*', element: <ErrorBoundary>{<PluginRouteRenderer />}</ErrorBoundary> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user