feat: Link-Widget restlos entfernt (Web, API, Migration, Handbuch, Changelog)
- Web: Union-Mitglied, Constraints, Icon, Registry-Eintrag, wire-Funktion, Katalog, Seiten-Verdrahtung, Tests und i18n (de/en) fuer den Typ link entfernt; link-widget.tsx/.test.tsx geloescht - API: create-widget.dto.ts (@IsIn-Liste) und widget-module-map.ts (Kommentare) auf sieben Typen angepasst; schema.prisma unveraendert - Neue Migration 20260916120000_remove_link_widget: eine idempotente DELETE-Anweisung auf WidgetInstance, FavoriteLink kaskadiert ueber den bestehenden FK; wird in diesem Auftrag NICHT ausgefuehrt - Neuer widget-wrapper-Test belegt den grauen Fallback fuer unbekannte Widget-Typen (Bestandsverhalten, jetzt festgeschrieben) - Handbuch (Widget-Tabelle, Einstellungs-Hinweise) und CHANGELOG (Geaendert/Entfernt/Behoben) aktualisiert - Web: 52 Dateien / 344 Tests gruen, tsc Exit 0; API: tsc Exit 0, dashboard-Spec 31 Tests gruen Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,15 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
- Kalender-Einstellungen: Das Feld „Adresse (URL)“ im Formular für Kalenderquellen zeigt jetzt je nach Typ ein passendes Beispiel (z. B. `https://mail.firma.de/EWS/Exchange.asmx` für Exchange EWS) und bei Exchange EWS einen Hinweis, dass die vollständige Adresse nötig ist – der Servername allein reicht nicht.
|
||||
- Kalender-Widget neu gestaltet: Monatsübersicht mit Terminanzahl je Tag (Termine beim Überfahren sichtbar) und darunter die nächsten Termine. In den Widget-Einstellungen lässt sich die Monatsansicht ein-/ausblenden sowie Anzahl und Zeitraum der angezeigten Termine wählen.
|
||||
- Favoriten-Widget kann einen Titel bekommen; ohne Titel bleibt die Kopfzeile weg.
|
||||
|
||||
### Entfernt
|
||||
|
||||
- Widget „Link“ (ein einzelner Link) entfernt — Favoriten-Widget übernimmt das; vorhandene Link-Kacheln werden beim Update automatisch entfernt.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Notiz-Widget: Listen zum Abhaken lassen sich jetzt in der Ansicht direkt abhaken.
|
||||
|
||||
## 1.1.0 – 2026-09-16
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Widget "Link" entfernt (quick-260916-iex): das Favoriten-Widget deckt den
|
||||
-- Anwendungsfall "Schnellzugriff auf Links" bereits vollstaendig ab, ein
|
||||
-- separates Einzel-Link-Widget ist ueberfluessig. Web/API haben den Typ
|
||||
-- `link` restlos entfernt (Registry, Katalog, DTO); diese Migration raeumt
|
||||
-- bestehende Link-Kacheln in der Datenbank hinterher.
|
||||
--
|
||||
-- FavoriteLink-Zeilen des jeweiligen Widgets kaskadieren automatisch ueber
|
||||
-- den bestehenden Fremdschluessel "FavoriteLink_widgetId_fkey ... ON DELETE
|
||||
-- CASCADE" (Migration 20260708090000_add_favorite_link) -- kein separates
|
||||
-- DELETE auf "FavoriteLink" noetig.
|
||||
--
|
||||
-- Laeuft als Migrationsrolle "tessera" (POSTGRES_USER, Superuser mit
|
||||
-- BYPASSRLS, siehe 20260909130000_rls_app_role). Die FORCE ROW LEVEL
|
||||
-- SECURITY-Regel auf "WidgetInstance" (Migration 20260909140000) greift
|
||||
-- fuer diese Rolle deshalb nicht -- das DELETE sieht und loescht ueber
|
||||
-- alle Mandanten hinweg.
|
||||
--
|
||||
-- Idempotent: ein zweiter Lauf loescht 0 Zeilen. Keine Schemaaenderung.
|
||||
-- Verwaiste Eintraege im Layout-JSON von "DashboardLayout" sind unschaedlich
|
||||
-- (dashboard-grid.tsx rendert nur ueber widgets.map) und verschwinden beim
|
||||
-- naechsten Speichern des Dashboards von selbst.
|
||||
DELETE FROM "WidgetInstance" WHERE "widgetType" = 'link';
|
||||
@@ -2,12 +2,12 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for creating a new widget instance on a user's dashboard.
|
||||
* widgetType must be one of the eight supported types.
|
||||
* widgetType must be one of the seven supported types.
|
||||
* config is optional and defaults to {} on the model.
|
||||
*/
|
||||
export class CreateWidgetDto {
|
||||
@IsString()
|
||||
@IsIn(['clock', 'search', 'calendar', 'note', 'calculator', 'favorites', 'link', 'stopwatch'])
|
||||
@IsIn(['clock', 'search', 'calendar', 'note', 'calculator', 'favorites', 'stopwatch'])
|
||||
widgetType!: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
* für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als
|
||||
* diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5).
|
||||
*
|
||||
* Die Tabelle ist am Ende dieser Phase bewusst leer: alle acht heute
|
||||
* Die Tabelle ist am Ende dieser Phase bewusst leer: alle sieben heute
|
||||
* registrierten Widget-Typen (clock/search/calendar/note/calculator/
|
||||
* favorites/link/stopwatch, siehe apps/web/src/components/dashboard/
|
||||
* favorites/stopwatch, siehe apps/web/src/components/dashboard/
|
||||
* widget-registry.tsx) sind Plattform-Widgets ohne Modulbezug. Das
|
||||
* einzige bislang geplante modulgebundene Widget steht in
|
||||
* .planning/REQUIREMENTS.md unter "Future Requirements (deferred)" und
|
||||
@@ -25,7 +25,7 @@ export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = {};
|
||||
|
||||
/**
|
||||
* Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn
|
||||
* der Typ kein Modul-Widget ist (der heutige Zustand für alle acht
|
||||
* der Typ kein Modul-Widget ist (der heutige Zustand für alle sieben
|
||||
* bestehenden Typen). Einziger Lesezugriff auf die Zuordnungstabelle,
|
||||
* damit Tests sie gezielt mocken können.
|
||||
*/
|
||||
|
||||
@@ -58,7 +58,6 @@ vi.mock('@/components/dashboard/widgets/note-widget', () => ({ NoteWidget: () =>
|
||||
vi.mock('@/components/dashboard/widgets/calculator-widget', () => ({ CalculatorWidget: () => null }));
|
||||
vi.mock('@/components/dashboard/widgets/stopwatch-widget', () => ({ StopwatchWidget: () => null }));
|
||||
vi.mock('@/components/dashboard/widgets/favorites-widget', () => ({ FavoritesWidget: () => null }));
|
||||
vi.mock('@/components/dashboard/widgets/link-widget', () => ({ LinkWidget: () => null }));
|
||||
|
||||
beforeEach(() => {
|
||||
mockStore.isEditMode = false;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
||||
import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget, wireLinkWidget } from '@/components/dashboard/widget-registry';
|
||||
import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget } from '@/components/dashboard/widget-registry';
|
||||
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
||||
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
|
||||
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
|
||||
@@ -13,7 +13,6 @@ import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
|
||||
import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget';
|
||||
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget';
|
||||
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
|
||||
import { LinkWidget } from '@/components/dashboard/widgets/link-widget';
|
||||
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||
|
||||
@@ -25,7 +24,6 @@ wireNoteWidget(NoteWidget);
|
||||
wireCalculatorWidget(CalculatorWidget);
|
||||
wireStopwatchWidget(StopwatchWidget);
|
||||
wireFavoritesWidget(FavoritesWidget);
|
||||
wireLinkWidget(LinkWidget);
|
||||
|
||||
export default function DashboardPage() {
|
||||
const t = useTranslations('widgets');
|
||||
|
||||
@@ -20,8 +20,8 @@ const COLS = { lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 };
|
||||
// quick-260916-dyv: Ziehen zuverlaessig.
|
||||
// - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx).
|
||||
// - `cancel` verhindert den Drag-Start an Eingabefeldern, Knoepfen, Links,
|
||||
// [contenteditable], [data-no-drag] und der Klasse `widgetNoDrag` (Favoriten/
|
||||
// Link-Widget, bisher nirgends verdrahtet). react-draggable 4.7.0 prueft
|
||||
// [contenteditable], [data-no-drag] und der Klasse `widgetNoDrag`
|
||||
// (Favoriten-Widget). react-draggable 4.7.0 prueft
|
||||
// `cancel` NACH `handle` und vom Ereignisziel aufwaerts bis zum RGL-Element
|
||||
// (Draggable.js:417 + matchesSelectorAndParentsTo) — cancel gewinnt also auch
|
||||
// INNERHALB des Griffs. RGL haengt `.react-resizable-handle` selbst voran
|
||||
|
||||
@@ -17,7 +17,6 @@ const WIDGET_TYPES: WidgetType[] = [
|
||||
'note',
|
||||
'calculator',
|
||||
'favorites',
|
||||
'link',
|
||||
'stopwatch',
|
||||
];
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
|
||||
// New types (Phase 8)
|
||||
'calculator',
|
||||
'favorites',
|
||||
'link',
|
||||
'stopwatch',
|
||||
];
|
||||
|
||||
@@ -49,11 +48,10 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
it('WIDGET_CONSTRAINTS contains new Phase-8 keys', () => {
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('calculator');
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('favorites');
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('link');
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
|
||||
});
|
||||
|
||||
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert', () => {
|
||||
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt, sieben Typen)', () => {
|
||||
// Raster 24 Spalten / 20 px (quick-260916-bwo). Die Minima sind seit
|
||||
// quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben
|
||||
// (defaultW/defaultH) bleiben das Doppelte der alten 12-Spalten-Werte.
|
||||
@@ -65,7 +63,6 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
|
||||
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
|
||||
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
|
||||
link: { minW: 3, minH: 2, defaultW: 4, defaultH: 4 },
|
||||
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
||||
});
|
||||
|
||||
@@ -76,6 +73,6 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
counted++;
|
||||
}
|
||||
}
|
||||
expect(counted).toBe(32);
|
||||
expect(counted).toBe(28);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { ComponentType } from 'react';
|
||||
/**
|
||||
* Supported widget types for the dashboard.
|
||||
* clock/search/calendar/note: implemented in previous plans.
|
||||
* calculator/favorites/link/stopwatch: Phase 8 additions.
|
||||
* calculator/favorites/stopwatch: Phase 8 additions (der fruehere
|
||||
* Einzel-Schnellzugriffs-Typ wurde in quick-260916-iex entfernt — Favoriten
|
||||
* decken den Fall ab).
|
||||
*/
|
||||
export type WidgetType =
|
||||
| 'clock'
|
||||
@@ -12,7 +14,6 @@ export type WidgetType =
|
||||
| 'note'
|
||||
| 'calculator'
|
||||
| 'favorites'
|
||||
| 'link'
|
||||
| 'stopwatch';
|
||||
|
||||
/**
|
||||
@@ -51,7 +52,6 @@ export const WIDGET_CONSTRAINTS: Record<
|
||||
// Inhalt 268 px; bei 9 Zeilen (244 px) fehlte die unterste Reihe um 25 px.
|
||||
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
|
||||
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
|
||||
link: { minW: 3, minH: 2, defaultW: 4, defaultH: 4 },
|
||||
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
||||
};
|
||||
|
||||
@@ -219,26 +219,6 @@ function FavoritesIcon({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LinkIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StopwatchIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
@@ -314,14 +294,6 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
...WIDGET_CONSTRAINTS.favorites,
|
||||
component: PlaceholderWidget, // Replaced via wireFavoritesWidget()
|
||||
},
|
||||
link: {
|
||||
type: 'link',
|
||||
nameKey: 'link.name',
|
||||
descriptionKey: 'link.description',
|
||||
icon: LinkIcon,
|
||||
...WIDGET_CONSTRAINTS.link,
|
||||
component: PlaceholderWidget, // Replaced via wireLinkWidget()
|
||||
},
|
||||
stopwatch: {
|
||||
type: 'stopwatch',
|
||||
nameKey: 'stopwatch.name',
|
||||
@@ -384,14 +356,6 @@ export function wireFavoritesWidget(component: ComponentType<WidgetProps>) {
|
||||
}
|
||||
}
|
||||
|
||||
let linkWired = false;
|
||||
export function wireLinkWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!linkWired) {
|
||||
WIDGET_REGISTRY.link.component = component;
|
||||
linkWired = true;
|
||||
}
|
||||
}
|
||||
|
||||
let stopwatchWired = false;
|
||||
export function wireStopwatchWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!stopwatchWired) {
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock next-intl — passthrough t(key) => key (must be before imports)
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// Mock favorites-api — all 4 functions as controllable vi.fn() spies
|
||||
vi.mock('@/lib/favorites-api', () => ({
|
||||
fetchFavorites: vi.fn(),
|
||||
createFavorite: vi.fn(),
|
||||
updateFavorite: vi.fn(),
|
||||
deleteFavorite: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock dashboard-api for updateWidgetConfig (view mode persistence)
|
||||
vi.mock('@/lib/dashboard-api', () => ({
|
||||
updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import {
|
||||
fetchFavorites,
|
||||
createFavorite,
|
||||
updateFavorite,
|
||||
} from '@/lib/favorites-api';
|
||||
|
||||
// Must import AFTER mocks
|
||||
import { LinkWidget } from './link-widget';
|
||||
|
||||
// Typed mock helpers
|
||||
const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>;
|
||||
const mockCreate = createFavorite as ReturnType<typeof vi.fn>;
|
||||
const mockUpdate = updateFavorite as ReturnType<typeof vi.fn>;
|
||||
|
||||
const SINGLE_LINK = {
|
||||
id: 'link-id-1',
|
||||
widgetId: 'link-1',
|
||||
title: 'GitHub',
|
||||
url: 'https://github.com',
|
||||
iconUrl: 'https://github.com/favicon.ico',
|
||||
position: 0,
|
||||
};
|
||||
|
||||
describe('LinkWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetch.mockResolvedValue([SINGLE_LINK]);
|
||||
mockCreate.mockResolvedValue({
|
||||
id: 'link-id-new',
|
||||
widgetId: 'link-1',
|
||||
title: 'New Site',
|
||||
url: 'https://example.com',
|
||||
iconUrl: null,
|
||||
position: 0,
|
||||
});
|
||||
mockUpdate.mockResolvedValue({ ...SINGLE_LINK, title: 'GitHub Updated' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('calls fetchFavorites with instanceId on mount and renders the link as an anchor with target="_blank" rel="noreferrer"', async () => {
|
||||
render(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
|
||||
// fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3)
|
||||
expect(mockFetch).toHaveBeenCalledWith('link-1');
|
||||
|
||||
// After async resolution, link title should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Link must open in new tab with noreferrer (T-08-12)
|
||||
const anchor = screen.getByText('GitHub').closest('a');
|
||||
expect(anchor).toHaveAttribute('target', '_blank');
|
||||
expect(anchor).toHaveAttribute('rel', 'noreferrer');
|
||||
});
|
||||
|
||||
it('shows empty message in non-edit mode when no link exists', async () => {
|
||||
mockFetch.mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('link.empty')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows add form in edit mode when no link exists; submitting calls createFavorite with widgetId, title, url', async () => {
|
||||
mockFetch.mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('link.addTitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const titleInput = screen.getByPlaceholderText('link.addTitle');
|
||||
const urlInput = screen.getByPlaceholderText('link.addUrl');
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(titleInput, { target: { value: 'New Site' } });
|
||||
fireEvent.change(urlInput, { target: { value: 'https://example.com' } });
|
||||
});
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /link\.addButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(addButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
widgetId: 'link-1',
|
||||
title: 'New Site',
|
||||
url: 'https://example.com',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// New link title should appear after creation
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('New Site')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('single-link enforcement: add form is hidden when a link already exists', async () => {
|
||||
render(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Add form must NOT be present — single-link constraint (D-06)
|
||||
expect(screen.queryByPlaceholderText('link.addTitle')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edit: clicking edit, changing title, saving calls updateFavorite with id and new title', async () => {
|
||||
render(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the edit button (aria-label is the translation key)
|
||||
const editButton = screen.getByRole('button', { name: /link\.editButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(editButton);
|
||||
});
|
||||
|
||||
// Inline edit form appears with current title pre-filled
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editTitleInput = screen.getByDisplayValue('GitHub');
|
||||
act(() => {
|
||||
fireEvent.change(editTitleInput, { target: { value: 'GitHub Updated' } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /link\.saveButton/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /link\.saveButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(saveButton);
|
||||
});
|
||||
|
||||
// updateFavorite called with the link id and new title
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
'link-id-1',
|
||||
expect.objectContaining({ title: 'GitHub Updated' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('view toggle: list is default; switching to tile in edit mode renders tile container', async () => {
|
||||
render(
|
||||
<LinkWidget
|
||||
instanceId="link-1"
|
||||
config={{ viewMode: 'list' }}
|
||||
isEditMode={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Default list view — list container should be present
|
||||
expect(screen.getByTestId('link-list')).toBeInTheDocument();
|
||||
|
||||
// Click the tile (grid) toggle button
|
||||
const tileButton = screen.getByRole('button', { name: /link\.gridView/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(tileButton);
|
||||
});
|
||||
|
||||
// Tile container should now be rendered
|
||||
expect(screen.getByTestId('link-tile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => {
|
||||
mockFetch.mockResolvedValue([{ ...SINGLE_LINK, iconUrl: null, title: 'Notion' }]);
|
||||
|
||||
render(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Notion')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Letter fallback span should show 'N' (first letter of 'Notion')
|
||||
expect(screen.getByTestId('link-letter-fallback')).toHaveTextContent('N');
|
||||
});
|
||||
});
|
||||
@@ -1,381 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
import {
|
||||
createFavorite,
|
||||
deleteFavorite,
|
||||
fetchFavorites,
|
||||
updateFavorite,
|
||||
type FavoriteLink,
|
||||
} from '@/lib/favorites-api';
|
||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
|
||||
/**
|
||||
* LinkWidget — single-link quick-access tile (DASH-09 / D-06).
|
||||
*
|
||||
* Features:
|
||||
* - Displays exactly one link, scoped to this widget instance via instanceId
|
||||
* - Shares the FavoriteLink backend from Plan 03 (widgetId = instanceId)
|
||||
* - Single-link enforcement: add form hidden when a link already exists
|
||||
* - Add / edit / delete in edit mode (inline forms)
|
||||
* - List view (row: icon + title, default) and tile view, switchable in edit mode
|
||||
* - Server-side icon discovery (letter fallback when iconUrl is null)
|
||||
* - Link opens in new tab with rel="noreferrer" (T-08-12 open-redirect mitigation)
|
||||
* - No dangerouslySetInnerHTML (T-08-11 XSS mitigation)
|
||||
*/
|
||||
export function LinkWidget({
|
||||
instanceId,
|
||||
config,
|
||||
isEditMode,
|
||||
}: WidgetProps) {
|
||||
const t = useTranslations('widgets');
|
||||
|
||||
const [link, setLink] = useState<FavoriteLink | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// View mode: read from config, default 'list' (D-06)
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid'>(
|
||||
(config.viewMode as 'list' | 'grid') ?? 'list',
|
||||
);
|
||||
|
||||
// Add-form state
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newUrl, setNewUrl] = useState('');
|
||||
|
||||
// Inline edit state
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editUrl, setEditUrl] = useState('');
|
||||
const [editIconUrl, setEditIconUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchFavorites(instanceId);
|
||||
// Single-link: take only the first entry (D-06)
|
||||
if (!cancelled) setLink(data[0] ?? null);
|
||||
} catch {
|
||||
// Stable error key — t excluded from deps to prevent re-fetch loops
|
||||
if (!cancelled) setError(t('link.error'));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// instanceId is the only meaningful dependency — t is excluded intentionally
|
||||
// (mock creates new fn ref on each render, causing re-fetch loops in tests)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [instanceId]);
|
||||
|
||||
function getFallbackLetter(title: string): string {
|
||||
const clean = title.trim();
|
||||
return clean ? clean.slice(0, 1).toUpperCase() : '?';
|
||||
}
|
||||
|
||||
// --- View mode toggle (edit mode only) ---
|
||||
function handleViewMode(next: 'list' | 'grid') {
|
||||
setViewMode(next);
|
||||
void updateWidgetConfig(instanceId, { viewMode: next });
|
||||
}
|
||||
|
||||
// --- Add link ---
|
||||
async function handleAdd(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const cleanTitle = newTitle.trim();
|
||||
const cleanUrl = newUrl.trim();
|
||||
if (!cleanTitle || !cleanUrl) return;
|
||||
|
||||
try {
|
||||
const created = await createFavorite({
|
||||
widgetId: instanceId,
|
||||
title: cleanTitle,
|
||||
url: cleanUrl,
|
||||
});
|
||||
setLink(created);
|
||||
setNewTitle('');
|
||||
setNewUrl('');
|
||||
} catch {
|
||||
setError(t('link.error'));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edit link ---
|
||||
function startEdit() {
|
||||
if (!link) return;
|
||||
setIsEditing(true);
|
||||
setEditTitle(link.title);
|
||||
setEditUrl(link.url);
|
||||
setEditIconUrl(link.iconUrl ?? '');
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setIsEditing(false);
|
||||
setEditTitle('');
|
||||
setEditUrl('');
|
||||
setEditIconUrl('');
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
if (!link) return;
|
||||
const cleanTitle = editTitle.trim();
|
||||
const cleanUrl = editUrl.trim();
|
||||
const cleanIconUrl = editIconUrl.trim();
|
||||
if (!cleanTitle || !cleanUrl) return;
|
||||
|
||||
try {
|
||||
const updated = await updateFavorite(link.id, {
|
||||
title: cleanTitle,
|
||||
url: cleanUrl,
|
||||
iconUrl: cleanIconUrl || null,
|
||||
});
|
||||
setLink(updated);
|
||||
cancelEdit();
|
||||
} catch {
|
||||
setError(t('link.error'));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delete link ---
|
||||
async function handleDelete() {
|
||||
if (!link) return;
|
||||
try {
|
||||
await deleteFavorite(link.id);
|
||||
setLink(null);
|
||||
cancelEdit();
|
||||
} catch {
|
||||
setError(t('link.error'));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shared icon element ---
|
||||
function renderIcon(size: 'sm' | 'md') {
|
||||
if (!link) return null;
|
||||
const wh = size === 'sm' ? 'w-5 h-5' : 'w-8 h-8';
|
||||
const textSize = size === 'sm' ? 'text-xs' : 'text-sm';
|
||||
const imgSize = size === 'sm' ? 20 : 32;
|
||||
return (
|
||||
<div className={`relative flex-shrink-0 ${wh}`}>
|
||||
<span
|
||||
data-testid="link-letter-fallback"
|
||||
className={`absolute inset-0 flex items-center justify-center ${textSize} font-semibold bg-muted rounded`}
|
||||
>
|
||||
{getFallbackLetter(link.title)}
|
||||
</span>
|
||||
{link.iconUrl && (
|
||||
<img
|
||||
src={link.iconUrl}
|
||||
alt=""
|
||||
width={imgSize}
|
||||
height={imgSize}
|
||||
loading="lazy"
|
||||
className={`absolute inset-0 ${wh} rounded`}
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Inline edit form ---
|
||||
function renderEditForm() {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 widgetNoDrag">
|
||||
<input
|
||||
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
placeholder={t('link.addTitle')}
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={editUrl}
|
||||
onChange={(e) => setEditUrl(e.target.value)}
|
||||
placeholder={t('link.addUrl')}
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={editIconUrl}
|
||||
onChange={(e) => setEditIconUrl(e.target.value)}
|
||||
placeholder="Logo-URL (optional)"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
onClick={() => void handleSaveEdit()}
|
||||
>
|
||||
{t('link.saveButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded bg-muted px-2 py-1 text-xs text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
onClick={cancelEdit}
|
||||
>
|
||||
{t('link.cancelButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto p-1 gap-2">
|
||||
{/* View mode toggle (edit mode only) */}
|
||||
{isEditMode && (
|
||||
<div className="flex gap-1 widgetNoDrag">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewMode('list')}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
viewMode === 'list'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{t('link.listView')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewMode('grid')}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{t('link.gridView')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status messages */}
|
||||
{loading && (
|
||||
<p className="text-xs text-muted-foreground">{t('link.loading')}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && !link && (
|
||||
<p className="text-xs text-muted-foreground">{t('link.empty')}</p>
|
||||
)}
|
||||
|
||||
{/* Link display — list (row) view */}
|
||||
{!loading && link && !isEditing && viewMode === 'list' && (
|
||||
<div data-testid="link-list" className="flex items-center gap-2">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 flex-1 min-w-0 hover:underline widgetNoDrag"
|
||||
onClick={(e) => isEditMode && e.preventDefault()}
|
||||
>
|
||||
{renderIcon('sm')}
|
||||
<span className="text-xs truncate">{link.title}</span>
|
||||
</a>
|
||||
{isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('link.editButton')}
|
||||
title={t('link.editButton')}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground flex-shrink-0 widgetNoDrag"
|
||||
onClick={startEdit}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 20h4.6L19.2 9.4a2.1 2.1 0 0 0 0-3l-1.6-1.6a2.1 2.1 0 0 0-3 0L4 15.4V20Zm2-2v-1.8L16.1 6.1l1.8 1.8L7.8 18H6Zm9.1-12.9 1.8-1.8 1.8 1.8-1.8 1.8-1.8-1.8Z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link display — tile (grid) view */}
|
||||
{!loading && link && !isEditing && viewMode === 'grid' && (
|
||||
<div data-testid="link-tile" className="flex flex-col items-center gap-1 p-1">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex flex-col items-center gap-1 widgetNoDrag"
|
||||
onClick={(e) => isEditMode && e.preventDefault()}
|
||||
>
|
||||
{renderIcon('md')}
|
||||
<span className="text-xs text-center truncate max-w-full">{link.title}</span>
|
||||
</a>
|
||||
{isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('link.editButton')}
|
||||
title={t('link.editButton')}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground widgetNoDrag"
|
||||
onClick={startEdit}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 20h4.6L19.2 9.4a2.1 2.1 0 0 0 0-3l-1.6-1.6a2.1 2.1 0 0 0-3 0L4 15.4V20Zm2-2v-1.8L16.1 6.1l1.8 1.8L7.8 18H6Zm9.1-12.9 1.8-1.8 1.8 1.8-1.8 1.8-1.8-1.8Z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline edit form */}
|
||||
{isEditMode && isEditing && link && renderEditForm()}
|
||||
|
||||
{/* Add form — single-link enforcement: hidden when link exists (D-06) */}
|
||||
{isEditMode && !link && !loading && (
|
||||
<form
|
||||
onSubmit={(e) => void handleAdd(e)}
|
||||
className="flex flex-col gap-1 widgetNoDrag"
|
||||
>
|
||||
<input
|
||||
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder={t('link.addTitle')}
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
placeholder={t('link.addUrl')}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t('link.addButton')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// quick-260916-iex: Link-Widget entfernt — unbekannte Widget-Typen (z. B.
|
||||
// eine alte Link-Kachel vor dem Einspielen der Migration) muessen weiterhin
|
||||
// ohne Absturz als grauer Text gerendert werden.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import { WidgetWrapper } from './widget-wrapper';
|
||||
|
||||
describe('WidgetWrapper', () => {
|
||||
it('unbekannter Widget-Typ (z. B. eine alte Link-Kachel vor der Migration) rendert als grauer Text ohne Absturz', () => {
|
||||
render(
|
||||
<WidgetWrapper
|
||||
widget={{ id: 'w-alt', widgetType: 'link', config: {} }}
|
||||
isEditMode={false}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const article = screen.getByRole('article');
|
||||
expect(article).toHaveAttribute('aria-label', 'link');
|
||||
|
||||
const fallback = screen.getByText('link');
|
||||
expect(fallback.className).toContain('text-muted-foreground');
|
||||
});
|
||||
});
|
||||
@@ -285,22 +285,6 @@
|
||||
"titleLabel": "Titel",
|
||||
"titlePlaceholder": "Titel (optional)"
|
||||
},
|
||||
"link": {
|
||||
"name": "Link",
|
||||
"description": "Einzelner Schnellzugriff-Link",
|
||||
"loading": "Link wird geladen...",
|
||||
"empty": "Noch kein Link gesetzt.",
|
||||
"addTitle": "Titel",
|
||||
"addUrl": "URL",
|
||||
"addButton": "Hinzufügen",
|
||||
"listView": "Liste",
|
||||
"gridView": "Kachel",
|
||||
"editButton": "Link bearbeiten",
|
||||
"deleteButton": "Link löschen",
|
||||
"saveButton": "Speichern",
|
||||
"cancelButton": "Abbrechen",
|
||||
"error": "Fehler beim Laden des Links"
|
||||
},
|
||||
"stopwatch": {
|
||||
"name": "Stoppuhr",
|
||||
"description": "Zeitmessung mit Rundenzeiten",
|
||||
|
||||
@@ -285,22 +285,6 @@
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Title (optional)"
|
||||
},
|
||||
"link": {
|
||||
"name": "Link",
|
||||
"description": "Single quick-access link",
|
||||
"loading": "Loading link...",
|
||||
"empty": "No link set yet.",
|
||||
"addTitle": "Title",
|
||||
"addUrl": "URL",
|
||||
"addButton": "Add",
|
||||
"listView": "List",
|
||||
"gridView": "Tile",
|
||||
"editButton": "Edit link",
|
||||
"deleteButton": "Delete link",
|
||||
"saveButton": "Save",
|
||||
"cancelButton": "Cancel",
|
||||
"error": "Error loading link"
|
||||
},
|
||||
"stopwatch": {
|
||||
"name": "Stopwatch",
|
||||
"description": "Time measurement with lap times",
|
||||
|
||||
@@ -74,13 +74,12 @@ Ihre Änderungen werden über **„Änderungen speichern"** übernommen. Verlass
|
||||
| Uhr | Zeigt die aktuelle Uhrzeit an (optional mit Datum). Die Uhrzeit wächst und schrumpft mit der Kachel; wer eine feste Größe möchte, stellt sie unter Einstellungen > Dashboard als Schriftgröße in Punkt ein |
|
||||
| Suchleiste | Schnellsuche im Web über frei konfigurierbare Suchanbieter |
|
||||
| Kalender | Monatsübersicht mit der Anzahl der Termine je Tag (die Termine eines Tages erscheinen, wenn Sie mit der Maus darüberfahren) und darunter die nächsten Termine aus Ihren verbundenen Kalenderquellen. Ob die Monatsansicht erscheint, wie viele Termine und welcher Zeitraum gezeigt werden, stellen Sie unter Einstellungen > Dashboard > Widgets ein |
|
||||
| Notizen | Freitext-Notizen mit Markdown-Formatierung |
|
||||
| Notizen | Freitext-Notizen mit Markdown-Formatierung; Listen zum Abhaken (`- [ ]`) lassen sich in der Ansicht direkt per Klick abhaken |
|
||||
| Taschenrechner | Grundrechenarten, auch per Tastatur bedienbar |
|
||||
| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht |
|
||||
| Link | Schnellzugriff auf genau einen einzelnen Link |
|
||||
| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht, optional mit eigener Überschrift |
|
||||
| Stoppuhr | Zeitmessung mit Rundenzeiten |
|
||||
|
||||
Für Uhr, Suchleiste, Kalender, Favoriten und Link gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, hinterlegte Links) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen).
|
||||
Für Uhr, Suchleiste, Kalender, Notizen und Favoriten gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen).
|
||||
|
||||
## Marktplatz
|
||||
|
||||
@@ -150,7 +149,7 @@ Ein einfaches Werkzeug, um zu prüfen, ob eine Internet-Domain verfügbar ist. G
|
||||
- **Akzentfarbe:** Passt die Hauptfarbe der Oberfläche an Ihren Geschmack an; über „Zurücksetzen" kehren Sie zur Standardfarbe zurück.
|
||||
- **Passwort ändern:** Nur sichtbar und nutzbar, wenn Ihr Konto **lokal** in Tessera verwaltet wird. Wird Ihr Konto stattdessen über das Verzeichnis (LDAP/Active Directory) verwaltet, zeigt Tessera stattdessen den Hinweis „Ihr Passwort wird über das Verzeichnis (LDAP) verwaltet. Eine Änderung ist hier nicht möglich." — in diesem Fall ändern Sie Ihr Passwort über die üblichen Firmenwege (z. B. Windows-Anmeldung), nicht in Tessera.
|
||||
|
||||
**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste oder beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage).
|
||||
**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel.
|
||||
|
||||
**Dashboard > Kalender:** Hier verwalten Sie die Kalenderquellen, aus denen das Kalender-Widget seine Termine bezieht — Quellen hinzufügen, die Verbindung testen und nicht mehr benötigte Quellen wieder entfernen.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user