@efa-one/sdk 1.25.0 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -1
- package/frontend/ui/ConfirmDeleteDialog.d.ts +17 -0
- package/frontend/ui/ConfirmDeleteDialog.js +11 -0
- package/frontend/ui/RecordDialog.d.ts +3 -3
- package/frontend/ui/RecordDialog.js +4 -4
- package/frontend/ui/Switch.d.ts +22 -0
- package/frontend/ui/Switch.js +14 -0
- package/frontend/ui/index.d.ts +5 -1
- package/frontend/ui/index.js +4 -0
- package/frontend/ui/records.d.ts +54 -0
- package/frontend/ui/records.js +248 -0
- package/frontend/ui/styles.css +60 -1
- package/frontend/ui/useRecords.d.ts +22 -0
- package/frontend/ui/useRecords.js +53 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ sub-paths — this keeps `express` out of the frontend and `react` out of the ba
|
|
|
14
14
|
|---|---|---|
|
|
15
15
|
| `@efa-one/sdk/backend` | Node/Express | Auth + token exchange (`requireAuth`, `createExchangeRouter`, `requireInternalOrAuth` …), health router, service discovery + gateway client (`serviceClient`, `resolveService`), clients for audit/reporting/mail/notifications, permission resolution/registration (`getUserPermissions`, `registerPermissions`, `checkPermission`), platform-admin check (`isPlatformAdmin` — top permission `efa-all`, legacy `converge-admin` accepted until Q1-2027; `requireAdmin`/`requireAdminOrPermission` use it), capability registry (`registerApiMetadata`), backend i18n |
|
|
16
16
|
| `@efa-one/sdk/frontend` | Browser/React | postMessage IPC (`registerAppInfo`, `sendAtStart`, `navigateToApp`, `notifyRouteChange` …), react-i18next factory (`initI18n`), platform-admin check (`isPlatformAdmin`, `PLATFORM_ADMIN_PERMISSIONS`), `DevHeader` |
|
|
17
|
-
| `@efa-one/sdk/frontend/ui` | Browser/React | The efa-one design-system kit: `Button`, `Input`, `Badge`, `Alert`, `Dialog`, `DropdownMenu`, `Tooltip`, `EmptyState`, `Skeleton`, `RecordDialog`, and the **`DataTable`** (sortable/filterable/groupable list with draggable, resizable columns, a per-user persisted view and CSV/Excel export of the current view; reflows to stacked cards below 640px). Also exports `useIsMobile` — the platform-wide 640px breakpoint hook. Ship its companion CSS once: `import '@efa-one/sdk/frontend/ui/styles.css'` |
|
|
17
|
+
| `@efa-one/sdk/frontend/ui` | Browser/React | The efa-one design-system kit: `Button`, `Input`, `Switch` (on/off settings — checkboxes only for row selection and confirmations), `Badge`, `Alert`, `Dialog`, `DropdownMenu`, `Tooltip`, `EmptyState`, `Skeleton`, `RecordDialog` with `useRecords` (ready-made list/create/edit/delete flow) and `ConfirmDeleteDialog`, and the **`DataTable`** (sortable/filterable/groupable list with draggable, resizable columns, a per-user persisted view and CSV/Excel export of the current view; reflows to stacked cards below 640px). Also exports `useIsMobile` — the platform-wide 640px breakpoint hook. Ship its companion CSS once: `import '@efa-one/sdk/frontend/ui/styles.css'` |
|
|
18
18
|
| `@efa-one/sdk/frontend/format` | Browser | Platform-wide date/time display — `formatDate` (`01.09.2026`), `formatDateTime` (`01.09.2026, 11:05`), `formatTime`, `formatFileStamp`, `localeForLanguage`. Always use these instead of `toLocaleDateString`/`toLocaleString`: without an options object those render `1.9.2026`, and without a locale argument they follow the browser language (`9/1/2026`) |
|
|
19
19
|
| `@efa-one/sdk/frontend/viewPreferences` | Browser/React | Persistence seam for `DataTable` — `createViewPreferencesClient()` (standard `/api/view-preferences/:listId` adapter) + `useViewPreferences()` |
|
|
20
20
|
|
|
@@ -281,6 +281,29 @@ Excel export for such data. No backend or kernel change.
|
|
|
281
281
|
scrollbar sits above the table and scrolls in sync with the bottom one. Not on
|
|
282
282
|
mobile. The border moved to an outer wrapper.
|
|
283
283
|
|
|
284
|
+
**1.27.0 — `useRecords`: edit first, read-only without permission.** No kernel or backend change.
|
|
285
|
+
- `editable` (default `true`): `false` opens records read-only, no pencil, no PUT
|
|
286
|
+
(`editable: can(user, 'write')`).
|
|
287
|
+
- `openIn: 'edit'`: a click on a record opens the form directly (with `editable`); default
|
|
288
|
+
`'read'` keeps read → pencil → edit.
|
|
289
|
+
- `dialogProps` now carries `mode`, `onModeChange` and `readOnly`; do not pass them yourself.
|
|
290
|
+
|
|
291
|
+
**1.26.0 — `useRecords`, `ConfirmDeleteDialog`, `Switch`.** No kernel or backend change.
|
|
292
|
+
- `useRecords({ endpoint, apiBase, emptyForm, toForm, toPayload?, required?, validate? })`
|
|
293
|
+
runs the whole records flow: load the list, open for create (`openCreate`) or read
|
|
294
|
+
(`openRecord`), save (`POST <endpoint>` or `PUT <endpoint>/:id`, `updateMethod: 'PATCH'`
|
|
295
|
+
optional), delete after confirmation (`DELETE <endpoint>/:id`), reload after each write,
|
|
296
|
+
server messages `{ "error" }` as banner. Spread `dialogProps` into `RecordDialog` and
|
|
297
|
+
`deleteDialogProps` into `ConfirmDeleteDialog`. The logic itself is
|
|
298
|
+
`createRecordsController` (no React, for tests or other frameworks).
|
|
299
|
+
- Use it instead of hand-written `save()` functions: one found in a generated app returned
|
|
300
|
+
early for `{ id: 'new' }`, so „Erstellen" never sent anything and no type check noticed.
|
|
301
|
+
- `ConfirmDeleteDialog`: delete confirmation, `size="sm"`, error banner.
|
|
302
|
+
- `RecordDialog` takes `readOnly` (no pencil, no „Bearbeiten“, e.g. without write
|
|
303
|
+
permission); `RecordDialogProps` is exported.
|
|
304
|
+
- `Switch`/`SwitchControl` for on/off values (checkboxes only for row selection and
|
|
305
|
+
confirmations).
|
|
306
|
+
|
|
284
307
|
**Next minor (planned 1.23.0, not yet released) — exchange, cookie and S2S hardening.**
|
|
285
308
|
Needs a kernel with efaware/efa_kernel#106 and #107 first (gateway exchange token
|
|
286
309
|
`gw: 'app'`, provenance `jti`, cookie filter). Then:
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export interface ConfirmDeleteDialogProps {
|
|
3
|
+
open: boolean;
|
|
4
|
+
itemName?: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
deleting?: boolean;
|
|
8
|
+
error?: string;
|
|
9
|
+
onErrorDismiss?: () => void;
|
|
10
|
+
onConfirm(): void | Promise<void>;
|
|
11
|
+
onCancel(): void;
|
|
12
|
+
labels?: {
|
|
13
|
+
cancel?: string;
|
|
14
|
+
confirm?: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export declare function ConfirmDeleteDialog({ open, itemName, title, description, deleting, error, onErrorDismiss, onConfirm, onCancel, labels, }: ConfirmDeleteDialogProps): React.JSX.Element;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Dialog } from './Dialog.js';
|
|
3
|
+
import { Button } from './Button.js';
|
|
4
|
+
import { Alert } from './Alert.js';
|
|
5
|
+
export function ConfirmDeleteDialog({ open, itemName, title, description, deleting, error, onErrorDismiss, onConfirm, onCancel, labels, }) {
|
|
6
|
+
const text = description ?? (itemName ? `„${itemName}“ wird endgültig gelöscht.` : 'Der Eintrag wird endgültig gelöscht.');
|
|
7
|
+
return (_jsx(Dialog, { open: open, onOpenChange: (o) => {
|
|
8
|
+
if (!o)
|
|
9
|
+
onCancel();
|
|
10
|
+
}, title: title ?? 'Eintrag löschen?', size: "sm", banner: error ? _jsx(Alert, { variant: "error", onDismiss: onErrorDismiss, children: error }) : undefined, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onCancel, disabled: !!deleting, children: labels?.cancel ?? 'Abbrechen' }), _jsx(Button, { variant: "danger", onClick: () => void onConfirm(), loading: !!deleting, children: labels?.confirm ?? 'Löschen' })] }), children: _jsx("p", { className: "text-sm text-[var(--color-text-secondary)]", children: text }) }));
|
|
11
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { type DialogSize } from './Dialog.js';
|
|
3
3
|
export type RecordDialogMode = 'read' | 'edit' | 'create';
|
|
4
|
-
interface RecordDialogProps {
|
|
4
|
+
export interface RecordDialogProps {
|
|
5
5
|
open: {
|
|
6
6
|
id: string;
|
|
7
7
|
item?: any;
|
|
@@ -20,6 +20,7 @@ interface RecordDialogProps {
|
|
|
20
20
|
saveDisabled?: boolean;
|
|
21
21
|
error?: string;
|
|
22
22
|
onErrorDismiss?: () => void;
|
|
23
|
+
readOnly?: boolean;
|
|
23
24
|
size?: DialogSize;
|
|
24
25
|
labels?: {
|
|
25
26
|
close?: string;
|
|
@@ -29,5 +30,4 @@ interface RecordDialogProps {
|
|
|
29
30
|
edit?: string;
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
|
-
export declare function RecordDialog({ open, onOpenChange, mode: controlledMode, onModeChange, title, readContent, editContent, onSave, saving, saveDisabled, error, onErrorDismiss, size, labels, }: RecordDialogProps): React.JSX.Element;
|
|
33
|
-
export {};
|
|
33
|
+
export declare function RecordDialog({ open, onOpenChange, mode: controlledMode, onModeChange, title, readContent, editContent, onSave, saving, saveDisabled, error, onErrorDismiss, readOnly, size, labels, }: RecordDialogProps): React.JSX.Element;
|
|
@@ -4,8 +4,8 @@ import { Pencil } from 'lucide-react';
|
|
|
4
4
|
import { Dialog, DialogClose } from './Dialog.js';
|
|
5
5
|
import { Button } from './Button.js';
|
|
6
6
|
import { Alert } from './Alert.js';
|
|
7
|
-
export function RecordDialog({ open, onOpenChange, mode: controlledMode, onModeChange, title, readContent, editContent, onSave, saving, saveDisabled, error, onErrorDismiss, size, labels, }) {
|
|
8
|
-
const [internalMode, setInternalMode] = useState('read');
|
|
7
|
+
export function RecordDialog({ open, onOpenChange, mode: controlledMode, onModeChange, title, readContent, editContent, onSave, saving, saveDisabled, error, onErrorDismiss, readOnly, size, labels, }) {
|
|
8
|
+
const [internalMode, setInternalMode] = useState(() => (open?.id === 'new' ? 'create' : 'read'));
|
|
9
9
|
const mode = controlledMode ?? internalMode;
|
|
10
10
|
useEffect(() => {
|
|
11
11
|
if (!open)
|
|
@@ -24,7 +24,7 @@ export function RecordDialog({ open, onOpenChange, mode: controlledMode, onModeC
|
|
|
24
24
|
onOpenChange(null);
|
|
25
25
|
}
|
|
26
26
|
async function handleSave() {
|
|
27
|
-
if (mode === 'read')
|
|
27
|
+
if (mode === 'read' || (readOnly && mode === 'edit'))
|
|
28
28
|
return;
|
|
29
29
|
await onSave(mode);
|
|
30
30
|
}
|
|
@@ -36,5 +36,5 @@ export function RecordDialog({ open, onOpenChange, mode: controlledMode, onModeC
|
|
|
36
36
|
return (_jsx(Dialog, { open: open !== null, onOpenChange: (o) => { if (!o)
|
|
37
37
|
handleClose(); }, title: title, size: size, banner: mode !== 'read' && error
|
|
38
38
|
? _jsx(Alert, { variant: "error", onDismiss: onErrorDismiss, children: error })
|
|
39
|
-
: undefined, footer: mode === 'read' ? (_jsxs(_Fragment, { children: [_jsx(DialogClose, { asChild: true, children: _jsx(Button, { variant: "secondary", children: closeLabel }) }), _jsxs(Button, { onClick: () => setMode('edit'), children: [_jsx(Pencil, { className: "w-4 h-4" }), editLabel] })] })) : (_jsxs(_Fragment, { children: [_jsx(DialogClose, { asChild: true, children: _jsx(Button, { variant: "secondary", children: cancelLabel }) }), _jsx(Button, { onClick: handleSave, loading: !!saving, disabled: !!saveDisabled, children: mode === 'create' ? createLabel : saveLabel })] })), children: mode === 'read' ? (_jsxs("div", { children: [_jsx("div", { className: "flex justify-end mb-2 -mt-1", children: _jsx("button", { onClick: () => setMode('edit'), className: "p-1.5 rounded-md text-[var(--color-text-muted)] hover:text-[var(--color-primary-text)] hover:bg-[var(--color-surface-raised)] transition-colors", title: editLabel, "aria-label": editLabel, children: _jsx(Pencil, { className: "w-4 h-4" }) }) }), readContent] })) : (_jsx("div", { className: "flex flex-col gap-4", children: editContent })) }));
|
|
39
|
+
: undefined, footer: mode === 'read' ? (_jsxs(_Fragment, { children: [_jsx(DialogClose, { asChild: true, children: _jsx(Button, { variant: "secondary", children: closeLabel }) }), !readOnly && (_jsxs(Button, { onClick: () => setMode('edit'), children: [_jsx(Pencil, { className: "w-4 h-4" }), editLabel] }))] })) : (_jsxs(_Fragment, { children: [_jsx(DialogClose, { asChild: true, children: _jsx(Button, { variant: "secondary", children: cancelLabel }) }), _jsx(Button, { onClick: handleSave, loading: !!saving, disabled: !!saveDisabled, children: mode === 'create' ? createLabel : saveLabel })] })), children: mode === 'read' ? (_jsxs("div", { children: [!readOnly && (_jsx("div", { className: "flex justify-end mb-2 -mt-1", children: _jsx("button", { onClick: () => setMode('edit'), className: "p-1.5 rounded-md text-[var(--color-text-muted)] hover:text-[var(--color-primary-text)] hover:bg-[var(--color-surface-raised)] transition-colors", title: editLabel, "aria-label": editLabel, children: _jsx(Pencil, { className: "w-4 h-4" }) }) })), readContent] })) : (_jsx("div", { className: "flex flex-col gap-4", children: editContent })) }));
|
|
40
40
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export type SwitchTone = 'primary' | 'danger';
|
|
3
|
+
export interface SwitchControlProps {
|
|
4
|
+
id?: string;
|
|
5
|
+
checked: boolean;
|
|
6
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
tone?: SwitchTone;
|
|
9
|
+
className?: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
'aria-label'?: string;
|
|
12
|
+
'aria-labelledby'?: string;
|
|
13
|
+
'aria-describedby'?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function SwitchControl({ id, checked, onCheckedChange, disabled, tone, className, name, ...aria }: SwitchControlProps): React.JSX.Element;
|
|
16
|
+
export interface SwitchProps extends Omit<SwitchControlProps, 'aria-label' | 'aria-labelledby' | 'aria-describedby' | 'className'> {
|
|
17
|
+
label: React.ReactNode;
|
|
18
|
+
description?: React.ReactNode;
|
|
19
|
+
title?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function Switch({ label, description, title, className, id, ...control }: SwitchProps): React.JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from 'react';
|
|
3
|
+
export function SwitchControl({ id, checked, onCheckedChange, disabled = false, tone = 'primary', className = '', name, ...aria }) {
|
|
4
|
+
const button = (_jsx("button", { type: "button", role: "switch", id: id, "aria-checked": checked, "data-state": checked ? 'checked' : 'unchecked', disabled: disabled, onClick: () => onCheckedChange?.(!checked), className: `switch switch-${tone} ${className}`, ...aria, children: _jsx("span", { className: "switch-thumb", "aria-hidden": "true" }) }));
|
|
5
|
+
if (!name)
|
|
6
|
+
return button;
|
|
7
|
+
return (_jsxs(_Fragment, { children: [button, _jsx("input", { type: "hidden", name: name, value: checked ? 'true' : 'false', disabled: disabled })] }));
|
|
8
|
+
}
|
|
9
|
+
export function Switch({ label, description, title, className = '', id, ...control }) {
|
|
10
|
+
const autoId = useId();
|
|
11
|
+
const switchId = id ?? `switch-${autoId}`;
|
|
12
|
+
const descId = description ? `${switchId}-desc` : undefined;
|
|
13
|
+
return (_jsxs("div", { className: `flex items-start gap-3 ${className}`, title: title, children: [_jsx("span", { className: "pt-0.5 flex", children: _jsx(SwitchControl, { id: switchId, "aria-describedby": descId, ...control }) }), _jsxs("span", { className: "min-w-0", children: [_jsx("label", { htmlFor: switchId, className: `block text-sm text-[var(--color-text-primary)] ${control.disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`, children: label }), description && (_jsx("span", { id: descId, className: "block text-xs text-[var(--color-text-muted)] mt-0.5", children: description }))] })] }));
|
|
14
|
+
}
|
package/frontend/ui/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { Button } from './Button.js';
|
|
2
2
|
export { Input } from './Input.js';
|
|
3
|
+
export { Switch, SwitchControl, type SwitchProps, type SwitchControlProps, type SwitchTone } from './Switch.js';
|
|
3
4
|
export { Badge } from './Badge.js';
|
|
4
5
|
export { Alert, type AlertVariant } from './Alert.js';
|
|
5
6
|
export { Dialog, DialogClose, DEFAULT_DIALOG_SIZE, type DialogSize } from './Dialog.js';
|
|
@@ -7,7 +8,10 @@ export * as DropdownMenu from './DropdownMenu.js';
|
|
|
7
8
|
export { Tooltip, TooltipProvider } from './Tooltip.js';
|
|
8
9
|
export { EmptyState } from './EmptyState.js';
|
|
9
10
|
export { Skeleton, SkeletonRow } from './Skeleton.js';
|
|
10
|
-
export { RecordDialog, type RecordDialogMode } from './RecordDialog.js';
|
|
11
|
+
export { RecordDialog, type RecordDialogMode, type RecordDialogProps } from './RecordDialog.js';
|
|
12
|
+
export { ConfirmDeleteDialog, type ConfirmDeleteDialogProps } from './ConfirmDeleteDialog.js';
|
|
13
|
+
export { useRecords, type UseRecordsResult } from './useRecords.js';
|
|
14
|
+
export { createRecordsController, type RecordsController, type RecordsOptions, type RecordsState, type RecordsSaveMode, type RecordsDialogMode, } from './records.js';
|
|
11
15
|
export { useIsMobile, MOBILE_QUERY } from '../useIsMobile.js';
|
|
12
16
|
export { DataTable, DEFAULT_VIEW_VERSION, type ColumnDef, type FilterDef, type DataTableSelectionProps, } from './DataTable.js';
|
|
13
17
|
export { toCsv, toXlsx, downloadFile, type ExportCell, type ExportTable } from './tableExport.js';
|
package/frontend/ui/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { Button } from './Button.js';
|
|
2
2
|
export { Input } from './Input.js';
|
|
3
|
+
export { Switch, SwitchControl } from './Switch.js';
|
|
3
4
|
export { Badge } from './Badge.js';
|
|
4
5
|
export { Alert } from './Alert.js';
|
|
5
6
|
export { Dialog, DialogClose, DEFAULT_DIALOG_SIZE } from './Dialog.js';
|
|
@@ -8,6 +9,9 @@ export { Tooltip, TooltipProvider } from './Tooltip.js';
|
|
|
8
9
|
export { EmptyState } from './EmptyState.js';
|
|
9
10
|
export { Skeleton, SkeletonRow } from './Skeleton.js';
|
|
10
11
|
export { RecordDialog } from './RecordDialog.js';
|
|
12
|
+
export { ConfirmDeleteDialog } from './ConfirmDeleteDialog.js';
|
|
13
|
+
export { useRecords } from './useRecords.js';
|
|
14
|
+
export { createRecordsController, } from './records.js';
|
|
11
15
|
export { useIsMobile, MOBILE_QUERY } from '../useIsMobile.js';
|
|
12
16
|
export { DataTable, DEFAULT_VIEW_VERSION, } from './DataTable.js';
|
|
13
17
|
export { toCsv, toXlsx, downloadFile } from './tableExport.js';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type RecordsSaveMode = 'create' | 'edit';
|
|
2
|
+
export type RecordsDialogMode = 'read' | 'edit' | 'create';
|
|
3
|
+
export interface RecordsOptions<T, F> {
|
|
4
|
+
endpoint: string;
|
|
5
|
+
apiBase: string | (() => string);
|
|
6
|
+
emptyForm: () => F;
|
|
7
|
+
toForm: (row: T) => F;
|
|
8
|
+
toPayload?: (form: F) => unknown;
|
|
9
|
+
required?: ReadonlyArray<keyof F>;
|
|
10
|
+
validate?: (form: F, mode: RecordsSaveMode) => string | null | undefined;
|
|
11
|
+
getId?: (row: T) => string;
|
|
12
|
+
editable?: boolean;
|
|
13
|
+
openIn?: 'read' | 'edit';
|
|
14
|
+
updateMethod?: 'PUT' | 'PATCH';
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
export interface RecordsState<T, F> {
|
|
18
|
+
rows: T[];
|
|
19
|
+
loading: boolean;
|
|
20
|
+
loadError: string;
|
|
21
|
+
open: {
|
|
22
|
+
id: string;
|
|
23
|
+
item?: T;
|
|
24
|
+
} | null;
|
|
25
|
+
mode: RecordsDialogMode;
|
|
26
|
+
form: F;
|
|
27
|
+
saving: boolean;
|
|
28
|
+
saveError: string;
|
|
29
|
+
deleteTarget: T | null;
|
|
30
|
+
deleting: boolean;
|
|
31
|
+
deleteError: string;
|
|
32
|
+
}
|
|
33
|
+
export interface RecordsController<T, F> {
|
|
34
|
+
getState(): RecordsState<T, F>;
|
|
35
|
+
subscribe(listener: () => void): () => void;
|
|
36
|
+
setOptions(options: RecordsOptions<T, F>): void;
|
|
37
|
+
load(): Promise<void>;
|
|
38
|
+
dismissLoadError(): void;
|
|
39
|
+
openCreate(): void;
|
|
40
|
+
openRecord(row: T): void;
|
|
41
|
+
close(): void;
|
|
42
|
+
setMode(mode: RecordsDialogMode): void;
|
|
43
|
+
setForm(next: F | ((prev: F) => F)): void;
|
|
44
|
+
setField<K extends keyof F>(key: K, value: F[K]): void;
|
|
45
|
+
isComplete(): boolean;
|
|
46
|
+
save(mode: RecordsSaveMode): Promise<void>;
|
|
47
|
+
dismissSaveError(): void;
|
|
48
|
+
requestDelete(row: T): void;
|
|
49
|
+
cancelDelete(): void;
|
|
50
|
+
confirmDelete(): Promise<void>;
|
|
51
|
+
dismissDeleteError(): void;
|
|
52
|
+
}
|
|
53
|
+
export declare function requiredFilled<F>(form: F, required: ReadonlyArray<keyof F> | undefined): boolean;
|
|
54
|
+
export declare function createRecordsController<T, F>(initial: RecordsOptions<T, F>): RecordsController<T, F>;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
const NEW_ID = 'new';
|
|
2
|
+
export function requiredFilled(form, required) {
|
|
3
|
+
return (required ?? []).every((k) => !isEmpty(form[k]));
|
|
4
|
+
}
|
|
5
|
+
function isEmpty(value) {
|
|
6
|
+
if (value === null || value === undefined)
|
|
7
|
+
return true;
|
|
8
|
+
if (typeof value === 'string')
|
|
9
|
+
return value.trim() === '';
|
|
10
|
+
if (Array.isArray(value))
|
|
11
|
+
return value.length === 0;
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
async function errorMessage(res, fallback) {
|
|
15
|
+
try {
|
|
16
|
+
const body = (await res.json());
|
|
17
|
+
if (typeof body?.error === 'string' && body.error.trim())
|
|
18
|
+
return body.error;
|
|
19
|
+
if (typeof body?.message === 'string' && body.message.trim())
|
|
20
|
+
return body.message;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
}
|
|
24
|
+
return `${fallback} (HTTP ${res.status})`;
|
|
25
|
+
}
|
|
26
|
+
export function createRecordsController(initial) {
|
|
27
|
+
let options = initial;
|
|
28
|
+
let state = {
|
|
29
|
+
rows: [],
|
|
30
|
+
loading: true,
|
|
31
|
+
loadError: '',
|
|
32
|
+
open: null,
|
|
33
|
+
mode: 'read',
|
|
34
|
+
form: initial.emptyForm(),
|
|
35
|
+
saving: false,
|
|
36
|
+
saveError: '',
|
|
37
|
+
deleteTarget: null,
|
|
38
|
+
deleting: false,
|
|
39
|
+
deleteError: '',
|
|
40
|
+
};
|
|
41
|
+
const listeners = new Set();
|
|
42
|
+
let loadSeq = 0;
|
|
43
|
+
let loadedOnce = false;
|
|
44
|
+
function set(patch) {
|
|
45
|
+
state = { ...state, ...patch };
|
|
46
|
+
for (const l of listeners)
|
|
47
|
+
l();
|
|
48
|
+
}
|
|
49
|
+
function base() {
|
|
50
|
+
const b = options.apiBase;
|
|
51
|
+
return typeof b === 'function' ? b() : (b ?? '');
|
|
52
|
+
}
|
|
53
|
+
function tryId(row) {
|
|
54
|
+
const id = options.getId ? options.getId(row) : row.id;
|
|
55
|
+
return id === undefined || id === null || String(id) === '' ? null : String(id);
|
|
56
|
+
}
|
|
57
|
+
const NO_ID = 'Eintrag ohne ID: Die Liste liefert kein Feld „id“ (in useRecords getId angeben).';
|
|
58
|
+
function url(id) {
|
|
59
|
+
const root = `${base()}${options.endpoint}`;
|
|
60
|
+
return id === undefined ? root : `${root.replace(/\/$/, '')}/${encodeURIComponent(id)}`;
|
|
61
|
+
}
|
|
62
|
+
function request(target, method, body) {
|
|
63
|
+
const f = options.fetchImpl ?? fetch;
|
|
64
|
+
return f(target, {
|
|
65
|
+
method,
|
|
66
|
+
credentials: 'include',
|
|
67
|
+
headers: body === undefined ? { Accept: 'application/json' } : { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
68
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function isComplete() {
|
|
72
|
+
return requiredFilled(state.form, options.required);
|
|
73
|
+
}
|
|
74
|
+
async function load() {
|
|
75
|
+
const seq = ++loadSeq;
|
|
76
|
+
set({ loading: !loadedOnce, loadError: '' });
|
|
77
|
+
try {
|
|
78
|
+
const res = await request(url(), 'GET');
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
const msg = await errorMessage(res, 'Liste konnte nicht geladen werden');
|
|
81
|
+
if (seq === loadSeq)
|
|
82
|
+
set({ loading: false, loadError: msg });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
let data;
|
|
86
|
+
try {
|
|
87
|
+
data = await res.json();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
if (seq === loadSeq)
|
|
91
|
+
set({ loading: false, loadError: 'Liste konnte nicht geladen werden (Antwort ist kein JSON, stimmen apiBase und endpoint?)' });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (seq !== loadSeq)
|
|
95
|
+
return;
|
|
96
|
+
if (!Array.isArray(data)) {
|
|
97
|
+
set({ loading: false, loadError: 'Liste konnte nicht geladen werden (Antwort ist keine Liste)' });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
loadedOnce = true;
|
|
101
|
+
set({ rows: data, loading: false });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
if (seq === loadSeq)
|
|
105
|
+
set({ loading: false, loadError: 'Server nicht erreichbar' });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function save(mode) {
|
|
109
|
+
if (state.saving)
|
|
110
|
+
return;
|
|
111
|
+
const open = state.open;
|
|
112
|
+
if (!open)
|
|
113
|
+
return;
|
|
114
|
+
if (mode === 'edit' && options.editable === false) {
|
|
115
|
+
set({ saveError: 'Keine Berechtigung zum Ändern.' });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!isComplete()) {
|
|
119
|
+
set({ saveError: 'Bitte alle Pflichtfelder ausfüllen.' });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const invalid = options.validate?.(state.form, mode);
|
|
123
|
+
if (invalid) {
|
|
124
|
+
set({ saveError: invalid });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
let target;
|
|
128
|
+
let method;
|
|
129
|
+
if (mode === 'create') {
|
|
130
|
+
target = url();
|
|
131
|
+
method = 'POST';
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
const id = open.item ? tryId(open.item) : null;
|
|
135
|
+
if (id === null) {
|
|
136
|
+
set({ saveError: open.item ? NO_ID : 'Kein Eintrag zum Speichern geöffnet.' });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
target = url(id);
|
|
140
|
+
method = options.updateMethod ?? 'PUT';
|
|
141
|
+
}
|
|
142
|
+
const payload = options.toPayload ? options.toPayload(state.form) : state.form;
|
|
143
|
+
set({ saving: true, saveError: '' });
|
|
144
|
+
try {
|
|
145
|
+
const res = await request(target, method, payload);
|
|
146
|
+
if (!res.ok) {
|
|
147
|
+
set({ saving: false, saveError: await errorMessage(res, 'Speichern fehlgeschlagen') });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
set({ saving: false, saveError: 'Server nicht erreichbar' });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (state.open === open)
|
|
156
|
+
set({ saving: false, open: null, form: options.emptyForm() });
|
|
157
|
+
else
|
|
158
|
+
set({ saving: false });
|
|
159
|
+
await load();
|
|
160
|
+
}
|
|
161
|
+
async function confirmDelete() {
|
|
162
|
+
const row = state.deleteTarget;
|
|
163
|
+
if (!row || state.deleting)
|
|
164
|
+
return;
|
|
165
|
+
const id = tryId(row);
|
|
166
|
+
if (id === null) {
|
|
167
|
+
set({ deleteError: NO_ID });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
set({ deleting: true, deleteError: '' });
|
|
171
|
+
try {
|
|
172
|
+
const res = await request(url(id), 'DELETE');
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
set({ deleting: false, deleteError: await errorMessage(res, 'Löschen fehlgeschlagen') });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
set({ deleting: false, deleteError: 'Server nicht erreichbar' });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const openItem = state.open?.item;
|
|
183
|
+
const closeDialog = openItem !== undefined && tryId(openItem) === id;
|
|
184
|
+
set({ deleting: false, deleteTarget: null, ...(closeDialog ? { open: null } : {}) });
|
|
185
|
+
await load();
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
getState: () => state,
|
|
189
|
+
subscribe(listener) {
|
|
190
|
+
listeners.add(listener);
|
|
191
|
+
return () => {
|
|
192
|
+
listeners.delete(listener);
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
setOptions(next) {
|
|
196
|
+
options = next;
|
|
197
|
+
},
|
|
198
|
+
load,
|
|
199
|
+
dismissLoadError() {
|
|
200
|
+
set({ loadError: '' });
|
|
201
|
+
},
|
|
202
|
+
openCreate() {
|
|
203
|
+
set({ open: { id: NEW_ID }, mode: 'create', form: options.emptyForm(), saveError: '' });
|
|
204
|
+
},
|
|
205
|
+
openRecord(row) {
|
|
206
|
+
const id = tryId(row);
|
|
207
|
+
if (id === null) {
|
|
208
|
+
console.error('useRecords: Eintrag ohne ID (getId angeben)', row);
|
|
209
|
+
set({ loadError: NO_ID });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const mode = options.openIn === 'edit' && options.editable !== false ? 'edit' : 'read';
|
|
213
|
+
set({ open: { id, item: row }, mode, form: options.toForm(row), saveError: '' });
|
|
214
|
+
},
|
|
215
|
+
close() {
|
|
216
|
+
set({ open: null, saveError: '' });
|
|
217
|
+
},
|
|
218
|
+
setMode(mode) {
|
|
219
|
+
if (mode !== 'edit' || options.editable === false || !state.open?.item || state.mode === 'edit')
|
|
220
|
+
return;
|
|
221
|
+
set({ mode: 'edit' });
|
|
222
|
+
},
|
|
223
|
+
setForm(next) {
|
|
224
|
+
const form = typeof next === 'function' ? next(state.form) : next;
|
|
225
|
+
set({ form });
|
|
226
|
+
},
|
|
227
|
+
setField(key, value) {
|
|
228
|
+
set({ form: { ...state.form, [key]: value } });
|
|
229
|
+
},
|
|
230
|
+
isComplete,
|
|
231
|
+
save,
|
|
232
|
+
dismissSaveError() {
|
|
233
|
+
set({ saveError: '' });
|
|
234
|
+
},
|
|
235
|
+
requestDelete(row) {
|
|
236
|
+
set({ deleteTarget: row, deleteError: '' });
|
|
237
|
+
},
|
|
238
|
+
cancelDelete() {
|
|
239
|
+
if (state.deleting)
|
|
240
|
+
return;
|
|
241
|
+
set({ deleteTarget: null, deleteError: '' });
|
|
242
|
+
},
|
|
243
|
+
confirmDelete,
|
|
244
|
+
dismissDeleteError() {
|
|
245
|
+
set({ deleteError: '' });
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
package/frontend/ui/styles.css
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @efa-one/sdk/frontend/ui/styles.css
|
|
3
3
|
*
|
|
4
|
-
* Globale Hilfsklassen, die `Badge` und `
|
|
4
|
+
* Globale Hilfsklassen, die `Badge`, `Skeleton`, `Alert` und `Switch` aus dem SDK-ui-Kit benötigen
|
|
5
5
|
* (sie sind CSS-Klassen-basiert, nicht Tailwind-Arbitrary). Einmal pro App
|
|
6
6
|
* importieren: import '@efa-one/sdk/frontend/ui/styles.css';
|
|
7
7
|
*
|
|
@@ -110,3 +110,62 @@
|
|
|
110
110
|
from { background-position: 200% 0; }
|
|
111
111
|
to { background-position: -200% 0; }
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
/* ── Switch (Schalter für Ein/Aus-Werte) ─────────────────────────
|
|
115
|
+
* Spur 36 × 20 px, Knopf 16 px. Aus-Zustand bewusst dunkler als
|
|
116
|
+
* `--color-border`: auf weißer Fläche wäre eine reine Rahmenfarbe kaum zu
|
|
117
|
+
* erkennen (WCAG 1.4.11 verlangt 3 : 1 für Bedienelemente). Die Mischung
|
|
118
|
+
* Richtung `--color-text-primary` wird im dunklen Theme automatisch heller.
|
|
119
|
+
* Gegen alle Built-in-Themes nachgerechnet (Spur auf surface/surface-raised,
|
|
120
|
+
* Knopf auf Spur): test/switch.test.ts. An-Zustand: Signalfarbe des Tons
|
|
121
|
+
* (`primary` bzw. `danger`) mit weißem Knopf — dieselbe Paarung wie der
|
|
122
|
+
* Primär-Button; der Zustand ist zusätzlich an der Knopf-Position erkennbar. */
|
|
123
|
+
.switch {
|
|
124
|
+
position: relative;
|
|
125
|
+
display: inline-flex;
|
|
126
|
+
flex-shrink: 0;
|
|
127
|
+
align-items: center;
|
|
128
|
+
width: 36px;
|
|
129
|
+
height: 20px;
|
|
130
|
+
padding: 0;
|
|
131
|
+
border: 2px solid transparent;
|
|
132
|
+
border-radius: 9999px;
|
|
133
|
+
background: color-mix(in srgb, var(--color-text-primary) 45%, var(--color-border));
|
|
134
|
+
cursor: pointer;
|
|
135
|
+
transition: background-color 150ms ease;
|
|
136
|
+
}
|
|
137
|
+
.switch-primary[data-state='checked'] { background: var(--color-primary); }
|
|
138
|
+
.switch-danger[data-state='checked'] { background: var(--color-danger); }
|
|
139
|
+
.switch:disabled {
|
|
140
|
+
opacity: 0.5;
|
|
141
|
+
cursor: not-allowed;
|
|
142
|
+
}
|
|
143
|
+
.switch:focus-visible {
|
|
144
|
+
outline: none;
|
|
145
|
+
box-shadow: 0 0 0 2px var(--color-surface), 0 0 0 4px var(--color-primary);
|
|
146
|
+
}
|
|
147
|
+
.switch-thumb {
|
|
148
|
+
display: block;
|
|
149
|
+
width: 16px;
|
|
150
|
+
height: 16px;
|
|
151
|
+
border-radius: 9999px;
|
|
152
|
+
background: #fff;
|
|
153
|
+
box-shadow: 0 1px 2px rgb(0 0 0 / 0.25);
|
|
154
|
+
transform: translateX(0);
|
|
155
|
+
transition: transform 150ms ease;
|
|
156
|
+
pointer-events: none;
|
|
157
|
+
}
|
|
158
|
+
.switch[data-state='checked'] .switch-thumb { transform: translateX(16px); }
|
|
159
|
+
@media (prefers-reduced-motion: reduce) {
|
|
160
|
+
.switch, .switch-thumb { transition: none; }
|
|
161
|
+
}
|
|
162
|
+
/* Windows-Kontrastmodus ersetzt Hintergrundfarben durch Systemfarben — ohne
|
|
163
|
+
* diesen Block wären Spur und Knopf gleich und der Zustand unsichtbar. */
|
|
164
|
+
@media (forced-colors: active) {
|
|
165
|
+
.switch { border-color: ButtonText; background: Canvas; }
|
|
166
|
+
.switch-thumb { background: ButtonText; box-shadow: none; }
|
|
167
|
+
.switch[data-state='checked'] { background: Highlight; }
|
|
168
|
+
.switch[data-state='checked'] .switch-thumb { background: HighlightText; }
|
|
169
|
+
.switch:disabled { border-color: GrayText; }
|
|
170
|
+
.switch:disabled .switch-thumb { background: GrayText; }
|
|
171
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RecordDialogProps } from './RecordDialog.js';
|
|
2
|
+
import type { ConfirmDeleteDialogProps } from './ConfirmDeleteDialog.js';
|
|
3
|
+
import { type RecordsOptions } from './records.js';
|
|
4
|
+
export interface UseRecordsResult<T, F> {
|
|
5
|
+
rows: T[];
|
|
6
|
+
loading: boolean;
|
|
7
|
+
loadError: string;
|
|
8
|
+
dismissLoadError(): void;
|
|
9
|
+
reload(): Promise<void>;
|
|
10
|
+
form: F;
|
|
11
|
+
setForm(next: F | ((prev: F) => F)): void;
|
|
12
|
+
setField<K extends keyof F>(key: K, value: F[K]): void;
|
|
13
|
+
current: T | null;
|
|
14
|
+
openCreate(): void;
|
|
15
|
+
openRecord(row: T): void;
|
|
16
|
+
close(): void;
|
|
17
|
+
dialogProps: Pick<RecordDialogProps, 'open' | 'onOpenChange' | 'mode' | 'onModeChange' | 'readOnly' | 'onSave' | 'saving' | 'saveDisabled' | 'error' | 'onErrorDismiss'>;
|
|
18
|
+
requestDelete(row: T): void;
|
|
19
|
+
deleteTarget: T | null;
|
|
20
|
+
deleteDialogProps: Pick<ConfirmDeleteDialogProps, 'open' | 'deleting' | 'error' | 'onConfirm' | 'onCancel' | 'onErrorDismiss'>;
|
|
21
|
+
}
|
|
22
|
+
export declare function useRecords<T, F>(options: RecordsOptions<T, F>): UseRecordsResult<T, F>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import { createRecordsController, requiredFilled, } from './records.js';
|
|
3
|
+
export function useRecords(options) {
|
|
4
|
+
const [controller] = useState(() => createRecordsController(options));
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
controller.setOptions(options);
|
|
7
|
+
});
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
void controller.load();
|
|
10
|
+
}, [controller, options.endpoint]);
|
|
11
|
+
const state = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
|
|
12
|
+
const complete = requiredFilled(state.form, options.required);
|
|
13
|
+
const readOnly = options.editable === false;
|
|
14
|
+
return useMemo(() => ({
|
|
15
|
+
rows: state.rows,
|
|
16
|
+
loading: state.loading,
|
|
17
|
+
loadError: state.loadError,
|
|
18
|
+
dismissLoadError: controller.dismissLoadError,
|
|
19
|
+
reload: controller.load,
|
|
20
|
+
form: state.form,
|
|
21
|
+
setForm: controller.setForm,
|
|
22
|
+
setField: controller.setField,
|
|
23
|
+
current: state.open?.item ?? null,
|
|
24
|
+
openCreate: controller.openCreate,
|
|
25
|
+
openRecord: controller.openRecord,
|
|
26
|
+
close: controller.close,
|
|
27
|
+
dialogProps: {
|
|
28
|
+
open: state.open,
|
|
29
|
+
onOpenChange: (next) => {
|
|
30
|
+
if (!next)
|
|
31
|
+
controller.close();
|
|
32
|
+
},
|
|
33
|
+
mode: readOnly && state.mode === 'edit' ? 'read' : state.mode,
|
|
34
|
+
onModeChange: controller.setMode,
|
|
35
|
+
readOnly,
|
|
36
|
+
onSave: controller.save,
|
|
37
|
+
saving: state.saving,
|
|
38
|
+
saveDisabled: !complete,
|
|
39
|
+
error: state.saveError,
|
|
40
|
+
onErrorDismiss: controller.dismissSaveError,
|
|
41
|
+
},
|
|
42
|
+
requestDelete: controller.requestDelete,
|
|
43
|
+
deleteTarget: state.deleteTarget,
|
|
44
|
+
deleteDialogProps: {
|
|
45
|
+
open: state.deleteTarget !== null,
|
|
46
|
+
deleting: state.deleting,
|
|
47
|
+
error: state.deleteError,
|
|
48
|
+
onConfirm: controller.confirmDelete,
|
|
49
|
+
onCancel: controller.cancelDelete,
|
|
50
|
+
onErrorDismiss: controller.dismissDeleteError,
|
|
51
|
+
},
|
|
52
|
+
}), [controller, state, complete, readOnly]);
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@efa-one/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0",
|
|
4
4
|
"description": "Offizielles SDK für efa-one-Apps: Auth/Session, Service-Discovery, Gateway-Client, Audit/Reporting/Mail/Notifications, Permission- und Capability-Registrierung sowie das Frontend-IPC-/i18n-Toolkit.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "commonjs",
|