fix(custom-modules): null beim Aendern ablehnen, Ladefehler statt 404, Fehlertexte, Seitenleiste eingeklappt
- PATCH mit null fuer name/url/category ergibt 400 statt 500 - Modulansicht unterscheidet Ladefehler von "nicht gefunden" - Formular/Loeschdialog nennen 403 und 400 eigens - eingeklappte Seitenleiste folgt der Gruppenreihenfolge der ausgeklappten - neue Eintraege sind mit "Eigene Module" vorbelegt - Verwaltung zeigt bei Ladefehler nicht zusaetzlich "keine Eintraege" Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -64,4 +64,12 @@ describe('UpdateCustomModuleDto', () => {
|
|||||||
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
|
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
|
||||||
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
|
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'name',
|
||||||
|
'url',
|
||||||
|
'category',
|
||||||
|
])('lehnt %s: null ab statt es durchzulassen', async (field) => {
|
||||||
|
expect(await errorsFor(UpdateCustomModuleDto, { [field]: null })).toContain(field);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,7 +78,11 @@ export class CreateCustomModuleDto {
|
|||||||
* `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist,
|
* `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist,
|
||||||
* aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld
|
* aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld
|
||||||
* dank `whitelist: true`).
|
* dank `whitelist: true`).
|
||||||
|
* `skipNullProperties: false`: fehlende Felder bleiben unveraendert, ein
|
||||||
|
* ausdrueckliches `null` wird aber geprueft und damit abgelehnt (400) — sonst
|
||||||
|
* liefe `{"name": null}` bis in die Datenbank und endete als 500.
|
||||||
*/
|
*/
|
||||||
export class UpdateCustomModuleDto extends PartialType(
|
export class UpdateCustomModuleDto extends PartialType(
|
||||||
OmitType(CreateCustomModuleDto, ['shared'] as const),
|
OmitType(CreateCustomModuleDto, ['shared'] as const),
|
||||||
|
{ skipNullProperties: false },
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const messages: Record<string, Record<string, unknown>> = {
|
|||||||
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
|
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
|
||||||
saveError: 'Das Modul konnte nicht gespeichert werden.',
|
saveError: 'Das Modul konnte nicht gespeichert werden.',
|
||||||
deleteError: 'Das Modul konnte nicht gelöscht werden.',
|
deleteError: 'Das Modul konnte nicht gelöscht werden.',
|
||||||
|
forbidden: 'Sie haben keine Berechtigung für diese Aktion.',
|
||||||
|
invalidInput: 'Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.',
|
||||||
deleteConfirm: {
|
deleteConfirm: {
|
||||||
title: 'Eigenes Modul löschen',
|
title: 'Eigenes Modul löschen',
|
||||||
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
|
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
|
||||||
@@ -58,8 +60,18 @@ vi.mock('next-intl', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('next/link', () => ({
|
vi.mock('next/link', () => ({
|
||||||
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
|
default: ({
|
||||||
<a href={href} className={className}>{children}</a>
|
children,
|
||||||
|
href,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
href: string;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<a href={href} className={className}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -68,13 +80,7 @@ vi.mock('@/lib/use-category-label', () => ({
|
|||||||
({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug,
|
({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const { mockList, mockCreate, mockUpdate, mockDelete, mockBump } = vi.hoisted(() => ({
|
||||||
mockList,
|
|
||||||
mockCreate,
|
|
||||||
mockUpdate,
|
|
||||||
mockDelete,
|
|
||||||
mockBump,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
mockList: vi.fn(),
|
mockList: vi.fn(),
|
||||||
mockCreate: vi.fn(),
|
mockCreate: vi.fn(),
|
||||||
mockUpdate: vi.fn(),
|
mockUpdate: vi.fn(),
|
||||||
@@ -106,6 +112,7 @@ vi.mock('@/lib/stores/auth-store', () => ({
|
|||||||
selector({ user: { role: mockRole } }),
|
selector({ user: { role: mockRole } }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
|
||||||
import AdminCustomModulesPage from './page';
|
import AdminCustomModulesPage from './page';
|
||||||
|
|
||||||
const wiki = {
|
const wiki = {
|
||||||
@@ -136,7 +143,9 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
|
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).toBeInTheDocument();
|
expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).toBeInTheDocument();
|
||||||
expect(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length).toBeGreaterThan(0);
|
expect(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => {
|
it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => {
|
||||||
@@ -154,13 +163,17 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => {
|
it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => {
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
await userEvent.click(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||||
|
);
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||||
await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com');
|
await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
expect(await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.')).toBeInTheDocument();
|
expect(
|
||||||
|
await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
expect(mockCreate).not.toHaveBeenCalled();
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
expect(mockBump).not.toHaveBeenCalled();
|
expect(mockBump).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -168,7 +181,9 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => {
|
it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => {
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
await userEvent.click(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||||
|
);
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com');
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com');
|
||||||
@@ -183,7 +198,9 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
it('Anlegen: leerer Name zeigt die Meldung', async () => {
|
it('Anlegen: leerer Name zeigt die Meldung', async () => {
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
await userEvent.click(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||||
|
);
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Name'), ' ');
|
await userEvent.type(screen.getByLabelText('Name'), ' ');
|
||||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||||
@@ -196,7 +213,9 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => {
|
it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => {
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
await userEvent.click(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||||
|
);
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite ');
|
await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite ');
|
||||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||||
@@ -217,11 +236,62 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Anlegen: die Kategorie ist vorbelegt mit Infrastruktur', async () => {
|
it('Anlegen: die Kategorie ist vorbelegt mit „Eigene Module“', async () => {
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
await userEvent.click(
|
||||||
expect(screen.getByLabelText('Kategorie')).toHaveValue('infrastructure');
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText('Kategorie')).toHaveValue('custom-modules');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anlegen ohne Kategoriewahl sendet die Kategorie „Eigene Module“', async () => {
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||||
|
);
|
||||||
|
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||||
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ category: 'custom-modules' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Speichern mit 403 zeigt „keine Berechtigung“, nicht den allgemeinen Fehler', async () => {
|
||||||
|
mockCreate.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden'));
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||||
|
);
|
||||||
|
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||||
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'Sie haben keine Berechtigung für diese Aktion.',
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Das Modul konnte nicht gespeichert werden.')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Speichern mit 400 zeigt die Meldung des Servers', async () => {
|
||||||
|
mockList.mockResolvedValue([wiki]);
|
||||||
|
mockUpdate.mockRejectedValue(
|
||||||
|
new CustomModuleRequestError(400, 'Nur https-Adressen ohne Zugangsdaten sind erlaubt.'),
|
||||||
|
);
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
|
||||||
|
const dialog = screen.getByRole('dialog');
|
||||||
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
|
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
|
||||||
|
'Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Bearbeiten: Formular ist vorbelegt, Speichern ruft update und aktualisiert die Seitenleiste', async () => {
|
it('Bearbeiten: Formular ist vorbelegt, Speichern ruft update und aktualisiert die Seitenleiste', async () => {
|
||||||
@@ -254,13 +324,17 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
mockCreate.mockRejectedValue(new Error('500'));
|
mockCreate.mockRejectedValue(new Error('500'));
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
await userEvent.click(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||||
|
);
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument();
|
expect(
|
||||||
|
await screen.findByText('Das Modul konnte nicht gespeichert werden.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
expect(mockBump).not.toHaveBeenCalled();
|
expect(mockBump).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -271,7 +345,9 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||||
|
|
||||||
const dialog = screen.getByRole('dialog');
|
const dialog = screen.getByRole('dialog');
|
||||||
expect(within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.')).toBeInTheDocument();
|
expect(
|
||||||
|
within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
|
||||||
|
|
||||||
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
|
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
|
||||||
@@ -283,7 +359,9 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
mockList.mockResolvedValue([wiki]);
|
mockList.mockResolvedValue([wiki]);
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||||
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' }));
|
await userEvent.click(
|
||||||
|
within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' }),
|
||||||
|
);
|
||||||
|
|
||||||
expect(mockDelete).not.toHaveBeenCalled();
|
expect(mockDelete).not.toHaveBeenCalled();
|
||||||
expect(mockBump).not.toHaveBeenCalled();
|
expect(mockBump).not.toHaveBeenCalled();
|
||||||
@@ -295,13 +373,38 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
mockDelete.mockRejectedValue(new Error('500'));
|
mockDelete.mockRejectedValue(new Error('500'));
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||||
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }));
|
await userEvent.click(
|
||||||
|
within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }),
|
||||||
|
);
|
||||||
|
|
||||||
expect(await screen.findByText('Das Modul konnte nicht gelöscht werden.')).toBeInTheDocument();
|
expect(await screen.findByText('Das Modul konnte nicht gelöscht werden.')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
expect(mockBump).not.toHaveBeenCalled();
|
expect(mockBump).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Löschen mit 403 zeigt „keine Berechtigung“', async () => {
|
||||||
|
mockList.mockResolvedValue([wiki]);
|
||||||
|
mockDelete.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden'));
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||||
|
await userEvent.click(
|
||||||
|
within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'Sie haben keine Berechtigung für diese Aktion.',
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Das Modul konnte nicht gelöscht werden.')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ladefehler zeigt nur die Fehlermeldung, nicht zusätzlich den Leer-Zustand', async () => {
|
||||||
|
mockList.mockRejectedValue(new CustomModuleRequestError(500, 'Internal server error'));
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Laden fehlgeschlagen.')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Noch keine gemeinsamen Einträge')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => {
|
it('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => {
|
||||||
mockList.mockResolvedValue([
|
mockList.mockResolvedValue([
|
||||||
wiki,
|
wiki,
|
||||||
|
|||||||
@@ -129,9 +129,9 @@ describe('CustomModulesSettingsPage', () => {
|
|||||||
render(<CustomModulesSettingsPage />);
|
render(<CustomModulesSettingsPage />);
|
||||||
expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument();
|
expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument();
|
expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument();
|
||||||
expect(
|
expect(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length).toBeGreaterThan(
|
||||||
screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length,
|
0,
|
||||||
).toBeGreaterThan(0);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => {
|
it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => {
|
||||||
@@ -158,7 +158,7 @@ describe('CustomModulesSettingsPage', () => {
|
|||||||
expect(mockCreate).toHaveBeenCalledWith({
|
expect(mockCreate).toHaveBeenCalledWith({
|
||||||
name: 'Mein Wiki',
|
name: 'Mein Wiki',
|
||||||
url: 'https://example.com',
|
url: 'https://example.com',
|
||||||
category: 'infrastructure',
|
category: 'custom-modules',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared');
|
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared');
|
||||||
@@ -228,7 +228,9 @@ describe('CustomModulesSettingsPage', () => {
|
|||||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument();
|
expect(
|
||||||
|
await screen.findByText('Das Modul konnte nicht gespeichert werden.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
expect(mockBump).not.toHaveBeenCalled();
|
expect(mockBump).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
type CustomModule,
|
type CustomModule,
|
||||||
|
CustomModuleRequestError,
|
||||||
checkCustomModuleUrl,
|
checkCustomModuleUrl,
|
||||||
createCustomModule,
|
createCustomModule,
|
||||||
updateCustomModule,
|
updateCustomModule,
|
||||||
@@ -27,6 +28,23 @@ interface CustomModuleFormModalProps {
|
|||||||
const NAME_MAX = 100;
|
const NAME_MAX = 100;
|
||||||
const URL_MAX = 2048;
|
const URL_MAX = 2048;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlertext fuer Speichern und Loeschen: 403 = keine Berechtigung, 400 = die
|
||||||
|
* API hat die Angaben abgelehnt (mit ihrer Meldung), alles andere (500,
|
||||||
|
* Netzwerk) = der allgemeine „bitte erneut versuchen“-Text `fallbackKey`.
|
||||||
|
*/
|
||||||
|
export function customModuleErrorText(
|
||||||
|
err: unknown,
|
||||||
|
t: (key: string, values?: Record<string, string>) => string,
|
||||||
|
fallbackKey: 'saveError' | 'deleteError',
|
||||||
|
): string {
|
||||||
|
if (err instanceof CustomModuleRequestError) {
|
||||||
|
if (err.status === 403) return t('forbidden');
|
||||||
|
if (err.status === 400) return t('invalidInput');
|
||||||
|
}
|
||||||
|
return t(fallbackKey);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`;
|
* Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`;
|
||||||
* quick-260929-dzu: ein Formular, zwei Aufrufer — Verwaltung legt gemeinsame,
|
* quick-260929-dzu: ein Formular, zwei Aufrufer — Verwaltung legt gemeinsame,
|
||||||
@@ -47,7 +65,9 @@ export function CustomModuleFormModal({
|
|||||||
|
|
||||||
const [name, setName] = useState(mod?.name ?? '');
|
const [name, setName] = useState(mod?.name ?? '');
|
||||||
const [url, setUrl] = useState(mod?.url ?? '');
|
const [url, setUrl] = useState(mod?.url ?? '');
|
||||||
const [category, setCategory] = useState<string>(mod?.category ?? 'infrastructure');
|
// Neue Eintraege landen vorbelegt in der Gruppe „Eigene Module“; beim
|
||||||
|
// Bearbeiten bleibt die gespeicherte Kategorie.
|
||||||
|
const [category, setCategory] = useState<string>(mod?.category ?? CUSTOM_MODULE_CATEGORY);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -81,8 +101,8 @@ export function CustomModuleFormModal({
|
|||||||
}
|
}
|
||||||
onSaved();
|
onSaved();
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch (err) {
|
||||||
setError(t('saveError'));
|
setError(customModuleErrorText(err, t, 'saveError'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
||||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||||
@@ -86,9 +86,7 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1
|
<h1 className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}>
|
||||||
className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}
|
|
||||||
>
|
|
||||||
{texts.title}
|
{texts.title}
|
||||||
</h1>
|
</h1>
|
||||||
<button type="button" onClick={openCreate} className="btn btn-primary">
|
<button type="button" onClick={openCreate} className="btn btn-primary">
|
||||||
@@ -104,9 +102,11 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bei einem Ladefehler ohne Eintraege steht nur die Fehlermeldung da —
|
||||||
|
„keine Eintraege“ waere dann falsch. */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
||||||
) : modules.length === 0 ? (
|
) : modules.length === 0 && loadFailed ? null : modules.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2>
|
<h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2>
|
||||||
<p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p>
|
<p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p>
|
||||||
@@ -119,9 +119,15 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/50">
|
<thead className="bg-muted/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('name')}</th>
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('url')}</th>
|
{t('name')}
|
||||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('category')}</th>
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||||
|
{t('url')}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||||
|
{t('category')}
|
||||||
|
</th>
|
||||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
|
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
|
||||||
{tManage('actions')}
|
{tManage('actions')}
|
||||||
</th>
|
</th>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api';
|
import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api';
|
||||||
|
import { customModuleErrorText } from './custom-module-form-modal';
|
||||||
|
|
||||||
interface DeleteCustomModuleDialogProps {
|
interface DeleteCustomModuleDialogProps {
|
||||||
mod: CustomModule;
|
mod: CustomModule;
|
||||||
@@ -37,8 +38,8 @@ export function DeleteCustomModuleDialog({
|
|||||||
await deleteCustomModule(mod.id);
|
await deleteCustomModule(mod.id);
|
||||||
onDeleted();
|
onDeleted();
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch (err) {
|
||||||
setError(t('deleteError'));
|
setError(customModuleErrorText(err, t, 'deleteError'));
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
@@ -55,7 +56,11 @@ export function DeleteCustomModuleDialog({
|
|||||||
<h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground">
|
<h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground">
|
||||||
{t('deleteConfirm.title')}
|
{t('deleteConfirm.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-4 text-sm text-foreground">{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', { name: mod.name })}</p>
|
<p className="mb-4 text-sm text-foreground">
|
||||||
|
{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', {
|
||||||
|
name: mod.name,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -8,8 +8,18 @@ vi.mock('next/navigation', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('next/link', () => ({
|
vi.mock('next/link', () => ({
|
||||||
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
|
default: ({
|
||||||
<a href={href} className={className}>{children}</a>
|
children,
|
||||||
|
href,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
href: string;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<a href={href} className={className}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -100,9 +110,7 @@ const mockActiveModules = [
|
|||||||
function stubFetch() {
|
function stubFetch() {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn(() =>
|
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) })),
|
||||||
Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +324,9 @@ describe('Sidebar', () => {
|
|||||||
expect(screen.getByText('Mein Wiki')).toBeInTheDocument();
|
expect(screen.getByText('Mein Wiki')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
const groups = Array.from(
|
const groups = Array.from(
|
||||||
screen.getByRole('group', { name: 'Kategorien' }).querySelectorAll(':scope > ul > li > button span.truncate'),
|
screen
|
||||||
|
.getByRole('group', { name: 'Kategorien' })
|
||||||
|
.querySelectorAll(':scope > ul > li > button span.truncate'),
|
||||||
).map((n) => n.textContent);
|
).map((n) => n.textContent);
|
||||||
expect(groups.at(-1)).toBe('custom-modules');
|
expect(groups.at(-1)).toBe('custom-modules');
|
||||||
expect(groups).toContain('fleet');
|
expect(groups).toContain('fleet');
|
||||||
@@ -340,12 +350,40 @@ describe('Sidebar', () => {
|
|||||||
render(<Sidebar />);
|
render(<Sidebar />);
|
||||||
|
|
||||||
const hrefs = () =>
|
const hrefs = () =>
|
||||||
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) => a.getAttribute('href'));
|
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) =>
|
||||||
|
a.getAttribute('href'),
|
||||||
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(hrefs()).toContain('/modules/custom/cm-1');
|
expect(hrefs()).toContain('/modules/custom/cm-1');
|
||||||
});
|
});
|
||||||
expect(hrefs().at(-1)).toBe('/modules/custom/cm-1');
|
expect(hrefs().at(-1)).toBe('/modules/custom/cm-1');
|
||||||
expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(hrefs().indexOf('/modules/custom/cm-1'));
|
expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(
|
||||||
|
hrefs().indexOf('/modules/custom/cm-1'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eingeklappt dieselbe Gruppenfolge wie ausgeklappt (eigenes Modul bei seiner Kategorie)', async () => {
|
||||||
|
mockCustomModules = [
|
||||||
|
customModule({ id: 'cm-1', category: 'Domain-Tools', name: 'Mein Tool' }),
|
||||||
|
customModule({ id: 'cm-2', category: 'custom-modules', name: 'Mein Wiki' }),
|
||||||
|
];
|
||||||
|
mockSidebarCollapsed(true);
|
||||||
|
const Sidebar = await importSidebar();
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
const hrefs = () =>
|
||||||
|
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) =>
|
||||||
|
a.getAttribute('href'),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(hrefs()).toContain('/modules/custom/cm-2');
|
||||||
|
});
|
||||||
|
expect(hrefs()).toEqual([
|
||||||
|
'/modules/Domain-Tools/domaincheck',
|
||||||
|
'/modules/custom/cm-1',
|
||||||
|
'/modules/Utilities/converter',
|
||||||
|
'/modules/custom/cm-2',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
|
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
|
||||||
@@ -360,7 +398,10 @@ describe('Sidebar', () => {
|
|||||||
.getByText('Domain-Tools')
|
.getByText('Domain-Tools')
|
||||||
.closest('li')
|
.closest('li')
|
||||||
?.querySelectorAll('a span.truncate');
|
?.querySelectorAll('a span.truncate');
|
||||||
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual(['Domaincheck', 'Aaa Eigenes']);
|
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual([
|
||||||
|
'Domaincheck',
|
||||||
|
'Aaa Eigenes',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => {
|
it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => {
|
||||||
@@ -379,7 +420,10 @@ describe('Sidebar', () => {
|
|||||||
screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('Domaincheck').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
screen
|
||||||
|
.getByText('Domaincheck')
|
||||||
|
.closest('a')
|
||||||
|
?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
||||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
|
||||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
|
||||||
import { SidebarSearch } from '@/components/layout/sidebar-search';
|
|
||||||
import { AppVersionBadge } from '@/components/layout/app-version-badge';
|
|
||||||
import { ModuleTile } from '@/components/modules/module-tile';
|
|
||||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
|
||||||
import { useNavStore } from '@/lib/stores/nav-store';
|
|
||||||
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
|
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
|
||||||
|
import { AppVersionBadge } from '@/components/layout/app-version-badge';
|
||||||
|
import { SidebarSearch } from '@/components/layout/sidebar-search';
|
||||||
|
import { ModuleTile } from '@/components/modules/module-tile';
|
||||||
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
||||||
|
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||||
|
import { useNavStore } from '@/lib/stores/nav-store';
|
||||||
|
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||||
|
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||||
|
|
||||||
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
|
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
|
||||||
function SelectionPill() {
|
function SelectionPill() {
|
||||||
@@ -109,13 +109,17 @@ export function Sidebar() {
|
|||||||
const linkClass = (href: string) => itemClass(isActive(href));
|
const linkClass = (href: string) => itemClass(isActive(href));
|
||||||
|
|
||||||
const isEntryActive = (entry: SidebarEntry) =>
|
const isEntryActive = (entry: SidebarEntry) =>
|
||||||
isActive(entry.href) || (entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
|
isActive(entry.href) ||
|
||||||
|
(entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
|
||||||
|
|
||||||
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die
|
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die
|
||||||
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
|
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
|
||||||
// die 20-px-Modulkachel passt weiter hinein.
|
// die 20-px-Modulkachel passt weiter hinein.
|
||||||
const moduleItemClass = (active: boolean) =>
|
const moduleItemClass = (active: boolean) =>
|
||||||
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
|
itemClass(active)
|
||||||
|
.replace('h-9', 'h-8')
|
||||||
|
.replace('gap-3', 'gap-2.5')
|
||||||
|
.replace('text-sm', 'text-[13px]');
|
||||||
|
|
||||||
// Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt
|
// Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt
|
||||||
// 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch
|
// 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch
|
||||||
@@ -186,28 +190,29 @@ export function Sidebar() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — „Eigene Module“
|
// Eintraege nach Kategorie gruppiert: „Eigene Module“ steht immer zuletzt,
|
||||||
// also auch hier immer zuletzt.
|
// die uebrigen in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
|
||||||
|
const orderedCategories = useMemo(() => {
|
||||||
|
const categories = new Map<string, SidebarEntry[]>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const list = categories.get(entry.category);
|
||||||
|
if (list) list.push(entry);
|
||||||
|
else categories.set(entry.category, [entry]);
|
||||||
|
}
|
||||||
|
return Array.from(categories.entries()).sort(
|
||||||
|
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
|
||||||
|
);
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — die Eintraege
|
||||||
|
// stehen gruppenweise hintereinander, „Eigene Module“ also auch hier zuletzt.
|
||||||
const collapsedEntries = useMemo(
|
const collapsedEntries = useMemo(
|
||||||
() =>
|
() => orderedCategories.flatMap(([, categoryEntries]) => categoryEntries),
|
||||||
[...entries].sort(
|
[orderedCategories],
|
||||||
(a, b) =>
|
|
||||||
Number(a.category === CUSTOM_MODULE_CATEGORY) - Number(b.category === CUSTOM_MODULE_CATEGORY),
|
|
||||||
),
|
|
||||||
[entries],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredCategories = useMemo(() => {
|
const filteredCategories = useMemo(() => {
|
||||||
const categories = new Map<string, SidebarEntry[]>();
|
const ordered = orderedCategories;
|
||||||
for (const entry of entries) {
|
|
||||||
if (!categories.has(entry.category)) categories.set(entry.category, []);
|
|
||||||
categories.get(entry.category)!.push(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// „Eigene Module“ steht immer zuletzt, die uebrigen in Fundreihenfolge.
|
|
||||||
const ordered = Array.from(categories.entries()).sort(
|
|
||||||
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
|
|
||||||
);
|
|
||||||
if (!searchQuery) return ordered;
|
if (!searchQuery) return ordered;
|
||||||
|
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
@@ -221,10 +226,22 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [entries, searchQuery, categoryLabel]);
|
}, [orderedCategories, searchQuery, categoryLabel]);
|
||||||
|
|
||||||
const navIcon = (children: React.ReactNode) => (
|
const navIcon = (children: React.ReactNode) => (
|
||||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width={isCollapsed ? 22 : 20} height={isCollapsed ? 22 : 20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={isCollapsed ? 22 : 20}
|
||||||
|
height={isCollapsed ? 22 : 20}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -234,7 +251,12 @@ export function Sidebar() {
|
|||||||
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
|
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
|
||||||
<ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}>
|
<ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/" className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}>
|
<Link
|
||||||
|
href="/"
|
||||||
|
className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')}
|
||||||
|
aria-label={t('dashboard')}
|
||||||
|
title={isCollapsed ? t('dashboard') : undefined}
|
||||||
|
>
|
||||||
{isActive('/') && <SelectionPill />}
|
{isActive('/') && <SelectionPill />}
|
||||||
{navIcon(
|
{navIcon(
|
||||||
<>
|
<>
|
||||||
@@ -248,7 +270,16 @@ export function Sidebar() {
|
|||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/marketplace" className={isCollapsed ? collapsedItemClass(isActive('/marketplace')) : linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
|
<Link
|
||||||
|
href="/marketplace"
|
||||||
|
className={
|
||||||
|
isCollapsed
|
||||||
|
? collapsedItemClass(isActive('/marketplace'))
|
||||||
|
: linkClass('/marketplace')
|
||||||
|
}
|
||||||
|
aria-label={t('marketplace')}
|
||||||
|
title={isCollapsed ? t('marketplace') : undefined}
|
||||||
|
>
|
||||||
{isActive('/marketplace') && <SelectionPill />}
|
{isActive('/marketplace') && <SelectionPill />}
|
||||||
{navIcon(
|
{navIcon(
|
||||||
<>
|
<>
|
||||||
@@ -268,9 +299,18 @@ export function Sidebar() {
|
|||||||
const active = isEntryActive(entry);
|
const active = isEntryActive(entry);
|
||||||
return (
|
return (
|
||||||
<li key={entry.key}>
|
<li key={entry.key}>
|
||||||
<Link href={entry.href} className={collapsedItemClass(active)} aria-label={entry.name} title={entry.name}>
|
<Link
|
||||||
|
href={entry.href}
|
||||||
|
className={collapsedItemClass(active)}
|
||||||
|
aria-label={entry.name}
|
||||||
|
title={entry.name}
|
||||||
|
>
|
||||||
{active && <SelectionPill />}
|
{active && <SelectionPill />}
|
||||||
<ModuleTile slug={entry.tileSlug} size={24} tone={active ? 'accent' : 'neutral'} />
|
<ModuleTile
|
||||||
|
slug={entry.tileSlug}
|
||||||
|
size={24}
|
||||||
|
tone={active ? 'accent' : 'neutral'}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -290,9 +330,7 @@ export function Sidebar() {
|
|||||||
{t('categories.empty')}
|
{t('categories.empty')}
|
||||||
</div>
|
</div>
|
||||||
) : filteredCategories.length === 0 ? (
|
) : filteredCategories.length === 0 ? (
|
||||||
<div className="px-2.5 py-2 text-sm text-muted-foreground">
|
<div className="px-2.5 py-2 text-sm text-muted-foreground">{t('noResults')}</div>
|
||||||
{t('noResults')}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col gap-2">
|
<ul className="flex flex-col gap-2">
|
||||||
{filteredCategories.map(([category, categoryEntries]) => {
|
{filteredCategories.map(([category, categoryEntries]) => {
|
||||||
@@ -307,7 +345,19 @@ export function Sidebar() {
|
|||||||
className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<span className="truncate">{categoryLabel(category)}</span>
|
<span className="truncate">{categoryLabel(category)}</span>
|
||||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}>
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.25"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}
|
||||||
|
>
|
||||||
<polyline points="6 9 12 15 18 9" />
|
<polyline points="6 9 12 15 18 9" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -319,7 +369,11 @@ export function Sidebar() {
|
|||||||
<li key={entry.key}>
|
<li key={entry.key}>
|
||||||
<Link href={entry.href} className={moduleItemClass(active)}>
|
<Link href={entry.href} className={moduleItemClass(active)}>
|
||||||
{active && <SelectionPill />}
|
{active && <SelectionPill />}
|
||||||
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
|
<ModuleTile
|
||||||
|
slug={entry.tileSlug}
|
||||||
|
size={20}
|
||||||
|
tone={active ? 'accent' : 'neutral'}
|
||||||
|
/>
|
||||||
<span className="truncate">{entry.name}</span>
|
<span className="truncate">{entry.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -353,7 +407,19 @@ export function Sidebar() {
|
|||||||
aria-label={isCollapsed ? t('expand') : t('collapse')}
|
aria-label={isCollapsed ? t('expand') : t('collapse')}
|
||||||
title={isCollapsed ? t('expand') : undefined}
|
title={isCollapsed ? t('expand') : undefined}
|
||||||
>
|
>
|
||||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
|
||||||
|
>
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
<path d="M9 3v18" />
|
<path d="M9 3v18" />
|
||||||
<path d="m16 15-3-3 3-3" />
|
<path d="m16 15-3-3 3-3" />
|
||||||
@@ -377,9 +443,7 @@ export function Sidebar() {
|
|||||||
<aside
|
<aside
|
||||||
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block"
|
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block"
|
||||||
style={{
|
style={{
|
||||||
width: isCollapsed
|
width: isCollapsed ? 'var(--sidebar-width-collapsed)' : 'var(--sidebar-width)',
|
||||||
? 'var(--sidebar-width-collapsed)'
|
|
||||||
: 'var(--sidebar-width)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sidebarContent}
|
{sidebarContent}
|
||||||
@@ -408,7 +472,18 @@ export function Sidebar() {
|
|||||||
aria-label={tCommon('close')}
|
aria-label={tCommon('close')}
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground"
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground"
|
||||||
>
|
>
|
||||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
||||||
|
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
|
||||||
|
|
||||||
vi.mock('next-intl', () => ({
|
vi.mock('next-intl', () => ({
|
||||||
useTranslations: () => (key: string) => {
|
useTranslations: () => (key: string) => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
openInNewTab: 'In neuem Tab öffnen',
|
openInNewTab: 'In neuem Tab öffnen',
|
||||||
notFound: 'Dieses Modul gibt es nicht mehr.',
|
notFound: 'Dieses Modul gibt es nicht mehr.',
|
||||||
|
loadError: 'Dieses Modul konnte nicht geladen werden.',
|
||||||
invalidUrl: 'Ungültige Adresse',
|
invalidUrl: 'Ungültige Adresse',
|
||||||
loading: 'Wird geladen',
|
loading: 'Wird geladen',
|
||||||
};
|
};
|
||||||
@@ -106,4 +108,19 @@ describe('CustomModuleView', () => {
|
|||||||
});
|
});
|
||||||
expect(container.querySelector('iframe')).toBeNull();
|
expect(container.querySelector('iframe')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['500', new CustomModuleRequestError(500, 'Internal server error')],
|
||||||
|
['403', new CustomModuleRequestError(403, 'Forbidden')],
|
||||||
|
['Netzwerkfehler', new TypeError('Failed to fetch')],
|
||||||
|
])('zeigt bei %s „konnte nicht geladen werden“ statt „nicht gefunden“', async (_, err) => {
|
||||||
|
mockGetCustomModule.mockRejectedValue(err);
|
||||||
|
const { container } = render(<CustomModuleView id="abc" />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'Dieses Modul konnte nicht geladen werden.',
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Dieses Modul gibt es nicht mehr.')).toBeNull();
|
||||||
|
expect(container.querySelector('iframe')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
|
|
||||||
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
||||||
|
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
|
||||||
import { type CustomModule, checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api';
|
import { type CustomModule, checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api';
|
||||||
|
|
||||||
type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'ready'; mod: CustomModule };
|
type LoadState =
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'missing' }
|
||||||
|
| { status: 'failed' }
|
||||||
|
| { status: 'ready'; mod: CustomModule };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): der Rahmen
|
* Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): der Rahmen
|
||||||
@@ -19,6 +23,8 @@ type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'read
|
|||||||
* Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne
|
* Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne
|
||||||
* Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit
|
* Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit
|
||||||
* `javascript:` oder `http:` wird nie gerendert (T-9WC-03).
|
* `javascript:` oder `http:` wird nie gerendert (T-9WC-03).
|
||||||
|
* Nur ein 404 heisst „gibt es nicht mehr“; 403, 500 oder ein Netzwerkfehler
|
||||||
|
* zeigen „konnte nicht geladen werden“ — der Eintrag existiert vielleicht noch.
|
||||||
*/
|
*/
|
||||||
export function CustomModuleView({ id }: { id: string }) {
|
export function CustomModuleView({ id }: { id: string }) {
|
||||||
const t = useTranslations('customModules');
|
const t = useTranslations('customModules');
|
||||||
@@ -37,7 +43,7 @@ export function CustomModuleView({ id }: { id: string }) {
|
|||||||
setState(mod ? { status: 'ready', mod } : { status: 'missing' });
|
setState(mod ? { status: 'ready', mod } : { status: 'missing' });
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setState({ status: 'missing' });
|
if (!cancelled) setState({ status: 'failed' });
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -50,6 +56,13 @@ export function CustomModuleView({ id }: { id: string }) {
|
|||||||
if (state.status === 'missing') {
|
if (state.status === 'missing') {
|
||||||
return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>;
|
return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>;
|
||||||
}
|
}
|
||||||
|
if (state.status === 'failed') {
|
||||||
|
return (
|
||||||
|
<div role="alert" className="p-4 text-sm text-destructive">
|
||||||
|
{t('loadError')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { mod } = state;
|
const { mod } = state;
|
||||||
const urlOk = checkCustomModuleUrl(mod.url) === 'ok';
|
const urlOk = checkCustomModuleUrl(mod.url) === 'ok';
|
||||||
|
|||||||
Reference in New Issue
Block a user