@cosmicdrift/kumiko-renderer-web 0.130.1 → 0.131.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/package.json +4 -4
- package/src/__tests__/dashboard-screen.test.tsx +86 -0
- package/src/__tests__/nav-tree.test.tsx +86 -0
- package/src/__tests__/projection-list-actions.test.tsx +89 -0
- package/src/app/create-app.tsx +18 -14
- package/src/app/dashboard-body.tsx +146 -0
- package/src/index.ts +28 -0
- package/src/layout/nav-tree.tsx +114 -25
- package/src/primitives/index.tsx +47 -2
- package/src/styles.css +14 -0
- package/src/widgets/__tests__/query-table.test.tsx +118 -0
- package/src/widgets/__tests__/widgets.test.tsx +229 -0
- package/src/widgets/charts.tsx +228 -0
- package/src/widgets/collapsible-section.tsx +42 -0
- package/src/widgets/detail-list.tsx +25 -0
- package/src/widgets/index.ts +21 -0
- package/src/widgets/mode-switch.tsx +40 -0
- package/src/widgets/progress-bar.tsx +27 -0
- package/src/widgets/query-table.tsx +103 -0
- package/src/widgets/section-card.tsx +30 -0
- package/src/widgets/stat.tsx +181 -0
- package/src/widgets/states.tsx +89 -0
- package/src/widgets/status-badge.tsx +51 -0
package/src/layout/nav-tree.tsx
CHANGED
|
@@ -33,9 +33,12 @@ import {
|
|
|
33
33
|
ChevronRight,
|
|
34
34
|
Coins,
|
|
35
35
|
CreditCard,
|
|
36
|
+
Download,
|
|
36
37
|
FileText,
|
|
37
38
|
Folder,
|
|
39
|
+
FolderOpen,
|
|
38
40
|
Gauge,
|
|
41
|
+
Hash,
|
|
39
42
|
Home,
|
|
40
43
|
KeyRound,
|
|
41
44
|
Layers,
|
|
@@ -43,11 +46,15 @@ import {
|
|
|
43
46
|
LineChart,
|
|
44
47
|
Link,
|
|
45
48
|
List,
|
|
49
|
+
Lock,
|
|
50
|
+
Mail,
|
|
46
51
|
Palette,
|
|
47
52
|
PiggyBank,
|
|
48
53
|
Plus,
|
|
49
54
|
Receipt,
|
|
55
|
+
Rocket,
|
|
50
56
|
Search,
|
|
57
|
+
Server,
|
|
51
58
|
Settings,
|
|
52
59
|
Share2,
|
|
53
60
|
Shield,
|
|
@@ -76,6 +83,7 @@ import {
|
|
|
76
83
|
SidebarGroup,
|
|
77
84
|
SidebarGroupContent,
|
|
78
85
|
SidebarGroupLabel,
|
|
86
|
+
SidebarInput,
|
|
79
87
|
SidebarMenu,
|
|
80
88
|
SidebarMenuAction,
|
|
81
89
|
SidebarMenuButton,
|
|
@@ -113,6 +121,7 @@ const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
|
|
|
113
121
|
calendar: CalendarDays,
|
|
114
122
|
file: FileText,
|
|
115
123
|
folder: Folder,
|
|
124
|
+
"folder-open": FolderOpen,
|
|
116
125
|
home: Home,
|
|
117
126
|
bell: Bell,
|
|
118
127
|
shield: Shield,
|
|
@@ -125,6 +134,12 @@ const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
|
|
|
125
134
|
link: Link,
|
|
126
135
|
palette: Palette,
|
|
127
136
|
share: Share2,
|
|
137
|
+
server: Server,
|
|
138
|
+
mail: Mail,
|
|
139
|
+
lock: Lock,
|
|
140
|
+
hash: Hash,
|
|
141
|
+
download: Download,
|
|
142
|
+
rocket: Rocket,
|
|
128
143
|
};
|
|
129
144
|
|
|
130
145
|
export type NavTreeProps = {
|
|
@@ -149,6 +164,22 @@ export type NavTreeProps = {
|
|
|
149
164
|
const EMPTY_BADGES: ReadonlyMap<string, ReactNode> = new Map();
|
|
150
165
|
const NavBadgesContext = createContext<ReadonlyMap<string, ReactNode>>(EMPTY_BADGES);
|
|
151
166
|
|
|
167
|
+
// Sidebar-Filter: `q` leer = inaktiv (matches akzeptiert alles). `matches`
|
|
168
|
+
// bekommt das ROHE Label (i18n-Key oder Klartext) und übersetzt intern — so
|
|
169
|
+
// filtert das Suchfeld auf den sichtbaren Text, nicht den Key.
|
|
170
|
+
type NavFilter = { readonly q: string; readonly matches: (rawLabel: string) => boolean };
|
|
171
|
+
const NavFilterContext = createContext<NavFilter>({ q: "", matches: () => true });
|
|
172
|
+
|
|
173
|
+
// Ein Knoten überlebt den Filter, wenn er selbst oder ein Nachfahre matcht.
|
|
174
|
+
// Provider-Kinder sind zur Filterzeit schon materialisiert (Provider-Knoten
|
|
175
|
+
// sind default-expanded → eager geladen), darum reicht die statische
|
|
176
|
+
// children-Rekursion. ponytail: ein zur Filterzeit noch NICHT geladener
|
|
177
|
+
// (user-zugeklappter) Provider wird über den force-expand in useNavNodeState
|
|
178
|
+
// aufgeklappt, lädt und filtert dann mit.
|
|
179
|
+
function subtreeMatches(node: NavNode, matches: (rawLabel: string) => boolean): boolean {
|
|
180
|
+
return matches(node.label) || node.children.some((c) => subtreeMatches(c, matches));
|
|
181
|
+
}
|
|
182
|
+
|
|
152
183
|
export function NavTree({
|
|
153
184
|
schema,
|
|
154
185
|
user,
|
|
@@ -175,25 +206,47 @@ export function NavTree({
|
|
|
175
206
|
});
|
|
176
207
|
}, []);
|
|
177
208
|
|
|
209
|
+
const t = useTranslation();
|
|
210
|
+
const [filter, setFilter] = useState("");
|
|
211
|
+
const q = filter.trim().toLowerCase();
|
|
212
|
+
const matches = useCallback(
|
|
213
|
+
(raw: string): boolean => {
|
|
214
|
+
const label = raw.includes(".") ? t(raw) : raw;
|
|
215
|
+
return label.toLowerCase().includes(q);
|
|
216
|
+
},
|
|
217
|
+
[t, q],
|
|
218
|
+
);
|
|
219
|
+
const navFilter = useMemo<NavFilter>(() => ({ q, matches }), [q, matches]);
|
|
220
|
+
|
|
178
221
|
return (
|
|
179
|
-
<
|
|
180
|
-
<
|
|
181
|
-
{tree
|
|
182
|
-
|
|
183
|
-
<
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
222
|
+
<NavFilterContext.Provider value={navFilter}>
|
|
223
|
+
<NavBadgesContext.Provider value={navBadges ?? EMPTY_BADGES}>
|
|
224
|
+
<div data-testid={testId} data-kumiko-layout="nav-tree" className="flex w-full flex-col">
|
|
225
|
+
<div className="px-2 pt-2 pb-1">
|
|
226
|
+
<SidebarInput
|
|
227
|
+
value={filter}
|
|
228
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
229
|
+
placeholder={t("kumiko.nav.search")}
|
|
230
|
+
aria-label={t("kumiko.nav.search")}
|
|
188
231
|
/>
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
232
|
+
</div>
|
|
233
|
+
{tree.map((node) =>
|
|
234
|
+
isPureSection(node) ? (
|
|
235
|
+
<NavSection
|
|
236
|
+
key={node.qualifiedName}
|
|
237
|
+
node={node}
|
|
238
|
+
collapsed={collapsed}
|
|
239
|
+
onToggle={onToggle}
|
|
240
|
+
/>
|
|
241
|
+
) : (
|
|
242
|
+
<SidebarMenu key={node.qualifiedName} className="px-2 py-1">
|
|
243
|
+
<NavMenuNode node={node} collapsed={collapsed} onToggle={onToggle} />
|
|
244
|
+
</SidebarMenu>
|
|
245
|
+
),
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
</NavBadgesContext.Provider>
|
|
249
|
+
</NavFilterContext.Provider>
|
|
197
250
|
);
|
|
198
251
|
}
|
|
199
252
|
|
|
@@ -245,8 +298,17 @@ function useLabel(node: NavNode): string {
|
|
|
245
298
|
|
|
246
299
|
// Icon-or-Dot: bekannter icon-Key → Lucide-Icon, sonst ein dezenter Dot.
|
|
247
300
|
// Aktiv = accent-foreground, inaktiv = gedimmt.
|
|
248
|
-
function NavLeadingIcon({
|
|
249
|
-
|
|
301
|
+
function NavLeadingIcon({
|
|
302
|
+
node,
|
|
303
|
+
active,
|
|
304
|
+
expanded = false,
|
|
305
|
+
}: {
|
|
306
|
+
node: NavNode;
|
|
307
|
+
active: boolean;
|
|
308
|
+
expanded?: boolean;
|
|
309
|
+
}): ReactNode {
|
|
310
|
+
const iconKey = expanded && node.icon === "folder" ? "folder-open" : node.icon;
|
|
311
|
+
const NavIcon = iconKey !== undefined ? NAV_ICONS[iconKey] : undefined;
|
|
250
312
|
if (NavIcon !== undefined) return <NavIcon aria-hidden="true" className="shrink-0" />;
|
|
251
313
|
return (
|
|
252
314
|
<span
|
|
@@ -366,6 +428,7 @@ type NavNodeState = {
|
|
|
366
428
|
readonly isExpanded: boolean;
|
|
367
429
|
readonly active: boolean;
|
|
368
430
|
readonly childNodes: readonly NavNode[];
|
|
431
|
+
readonly hidden: boolean;
|
|
369
432
|
readonly providerLoading: boolean;
|
|
370
433
|
readonly providerError: string | null;
|
|
371
434
|
readonly workspaceId: string | undefined;
|
|
@@ -377,10 +440,14 @@ type NavNodeState = {
|
|
|
377
440
|
// in den shadcn-Primitives, nicht in dieser Logik.
|
|
378
441
|
function useNavNodeState(node: NavNode, collapsed: ReadonlySet<string>): NavNodeState {
|
|
379
442
|
const nav = useNav();
|
|
443
|
+
const { q: filterQ, matches } = useContext(NavFilterContext);
|
|
444
|
+
const filterActive = filterQ !== "";
|
|
380
445
|
const displayLabel = useLabel(node);
|
|
381
446
|
const isCollapsed = collapsed.has(node.qualifiedName);
|
|
382
447
|
const expandable = node.children.length > 0 || node.provider === true;
|
|
383
|
-
|
|
448
|
+
// Aktiver Filter klappt jeden Container auf, damit Treffer unter zugeklappten
|
|
449
|
+
// Ordnern sichtbar werden (und Provider-Kinder nachladen).
|
|
450
|
+
const isExpanded = expandable && (!isCollapsed || filterActive);
|
|
384
451
|
const { nodes: providerChildren, error: providerError } = useNavProviderChildren(
|
|
385
452
|
node.qualifiedName,
|
|
386
453
|
node.provider === true && isExpanded,
|
|
@@ -393,13 +460,19 @@ function useNavNodeState(node: NavNode, collapsed: ReadonlySet<string>): NavNode
|
|
|
393
460
|
node.screen !== undefined && nav.route?.screenId === lastSegment(node.screen);
|
|
394
461
|
const targetActive = node.target !== undefined && targetsEqual(node.target, activeTarget);
|
|
395
462
|
const childNodes = node.provider === true ? (providerChildren ?? []) : node.children;
|
|
463
|
+
const visibleChildNodes = filterActive
|
|
464
|
+
? childNodes.filter((c) => subtreeMatches(c, matches))
|
|
465
|
+
: childNodes;
|
|
396
466
|
return {
|
|
397
467
|
displayLabel,
|
|
398
468
|
isCollapsed,
|
|
399
469
|
expandable,
|
|
400
470
|
isExpanded,
|
|
401
471
|
active: screenActive || targetActive,
|
|
402
|
-
childNodes,
|
|
472
|
+
childNodes: visibleChildNodes,
|
|
473
|
+
// Ein gefilterter Knoten verschwindet nur, wenn weder er selbst noch ein
|
|
474
|
+
// sichtbar gebliebenes Kind matcht — sonst bliebe ein leerer Ordner-Header.
|
|
475
|
+
hidden: filterActive && !matches(node.label) && visibleChildNodes.length === 0,
|
|
403
476
|
providerLoading: node.provider === true && isExpanded && providerChildren === null,
|
|
404
477
|
providerError,
|
|
405
478
|
workspaceId: nav.route?.workspaceId,
|
|
@@ -488,6 +561,16 @@ function ProviderStatus({
|
|
|
488
561
|
// gehört auf Items MIT children, nicht auf die Section selbst.
|
|
489
562
|
function NavSection({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
490
563
|
const displayLabel = useLabel(node);
|
|
564
|
+
const { q, matches } = useContext(NavFilterContext);
|
|
565
|
+
// Provider-Kinder (statisch leer bis geladen) halten die Section offen — ihr
|
|
566
|
+
// Match lässt sich hier nicht statisch prüfen; der Knoten selbst self-hidet.
|
|
567
|
+
if (
|
|
568
|
+
q !== "" &&
|
|
569
|
+
!matches(node.label) &&
|
|
570
|
+
!node.children.some((c) => c.provider === true || subtreeMatches(c, matches))
|
|
571
|
+
) {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
491
574
|
return (
|
|
492
575
|
<SidebarGroup className="py-1">
|
|
493
576
|
<SidebarGroupLabel>{displayLabel}</SidebarGroupLabel>
|
|
@@ -518,6 +601,7 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
518
601
|
const t = useTranslation();
|
|
519
602
|
const dispatch = useDispatchTarget();
|
|
520
603
|
const s = useNavNodeState(node, collapsed);
|
|
604
|
+
if (s.hidden) return null;
|
|
521
605
|
|
|
522
606
|
const sub = s.isExpanded ? (
|
|
523
607
|
<SidebarMenuSub>
|
|
@@ -593,7 +677,7 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
593
677
|
}}
|
|
594
678
|
{...(s.expandable && { "aria-expanded": !s.isCollapsed })}
|
|
595
679
|
>
|
|
596
|
-
<NavLeadingIcon node={node} active={false} />
|
|
680
|
+
<NavLeadingIcon node={node} active={false} expanded={s.isExpanded} />
|
|
597
681
|
<span className="truncate">{s.displayLabel}</span>
|
|
598
682
|
{s.expandable &&
|
|
599
683
|
(s.isCollapsed ? (
|
|
@@ -617,9 +701,14 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
617
701
|
const t = useTranslation();
|
|
618
702
|
const dispatch = useDispatchTarget();
|
|
619
703
|
const s = useNavNodeState(node, collapsed);
|
|
704
|
+
if (s.hidden) return null;
|
|
620
705
|
|
|
706
|
+
// Kinder in ein eigenes SidebarMenuSub (<ul>) — ein Sub-Item ist ein <li>,
|
|
707
|
+
// verschachtelte <li> ohne <ul> dazwischen sind invalides HTML (nested
|
|
708
|
+
// Content-Ordner: Marketing/ > Block). Der <ul> trägt zugleich die
|
|
709
|
+
// Einrück-/Border-Stufe pro Tiefe.
|
|
621
710
|
const deeper = s.isExpanded ? (
|
|
622
|
-
|
|
711
|
+
<SidebarMenuSub>
|
|
623
712
|
<ProviderStatus loading={s.providerLoading} error={s.providerError} />
|
|
624
713
|
{s.childNodes.map((child) => (
|
|
625
714
|
<NavSubNode
|
|
@@ -629,7 +718,7 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
629
718
|
onToggle={onToggle}
|
|
630
719
|
/>
|
|
631
720
|
))}
|
|
632
|
-
|
|
721
|
+
</SidebarMenuSub>
|
|
633
722
|
) : null;
|
|
634
723
|
|
|
635
724
|
const chevron = s.expandable ? (
|
|
@@ -695,7 +784,7 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
695
784
|
if (s.expandable) onToggle(node.qualifiedName);
|
|
696
785
|
}}
|
|
697
786
|
>
|
|
698
|
-
<NavLeadingIcon node={node} active={false} />
|
|
787
|
+
<NavLeadingIcon node={node} active={false} expanded={s.isExpanded} />
|
|
699
788
|
<span className="truncate">{s.displayLabel}</span>
|
|
700
789
|
</button>
|
|
701
790
|
</SidebarMenuSubButton>
|
package/src/primitives/index.tsx
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type GridProps,
|
|
30
30
|
type HeadingProps,
|
|
31
31
|
type InputProps,
|
|
32
|
+
type LinkProps,
|
|
32
33
|
type SectionProps,
|
|
33
34
|
type TextProps,
|
|
34
35
|
useColumnRenderer,
|
|
@@ -59,7 +60,7 @@ import {
|
|
|
59
60
|
} from "react";
|
|
60
61
|
import { cn } from "../lib/cn";
|
|
61
62
|
import { Badge } from "../ui/badge";
|
|
62
|
-
import { Button as UiButton } from "../ui/button";
|
|
63
|
+
import { buttonVariants, Button as UiButton } from "../ui/button";
|
|
63
64
|
import { Checkbox } from "../ui/checkbox";
|
|
64
65
|
import { Input as UiInput } from "../ui/input";
|
|
65
66
|
import { Label as UiLabel } from "../ui/label";
|
|
@@ -102,11 +103,12 @@ const cardFooterBorder = "border-t bg-muted/30";
|
|
|
102
103
|
|
|
103
104
|
// Contract-Variant → shadcn-Variant: secondary war schon immer der
|
|
104
105
|
// bordered-bg-background-Look = shadcns `outline`. primary→default,
|
|
105
|
-
// danger→destructive.
|
|
106
|
+
// danger→destructive, link→link (kein BG, underline on hover).
|
|
106
107
|
const BUTTON_VARIANT = {
|
|
107
108
|
primary: "default",
|
|
108
109
|
secondary: "outline",
|
|
109
110
|
danger: "destructive",
|
|
111
|
+
link: "link",
|
|
110
112
|
} as const;
|
|
111
113
|
|
|
112
114
|
function DefaultButton({
|
|
@@ -126,6 +128,9 @@ function DefaultButton({
|
|
|
126
128
|
data-testid={testId}
|
|
127
129
|
data-loading={loading === true ? "true" : undefined}
|
|
128
130
|
variant={BUTTON_VARIANT[variant]}
|
|
131
|
+
// link-Variant rendert text-artig (Inline-Link im Fließtext/Banner),
|
|
132
|
+
// nicht als gepolsterte Buttonfläche.
|
|
133
|
+
className={variant === "link" ? "h-auto px-0 py-0" : undefined}
|
|
129
134
|
>
|
|
130
135
|
{loading === true ? <Loader2 className="size-4 animate-spin" aria-hidden="true" /> : children}
|
|
131
136
|
</UiButton>
|
|
@@ -1590,11 +1595,50 @@ function DefaultText({ variant = "body", children, testId }: TextProps): ReactNo
|
|
|
1590
1595
|
{children}
|
|
1591
1596
|
</span>
|
|
1592
1597
|
);
|
|
1598
|
+
case "muted":
|
|
1599
|
+
return (
|
|
1600
|
+
<span data-testid={testId} className="text-sm text-muted-foreground">
|
|
1601
|
+
{children}
|
|
1602
|
+
</span>
|
|
1603
|
+
);
|
|
1593
1604
|
default:
|
|
1594
1605
|
return <span data-testid={testId}>{children}</span>;
|
|
1595
1606
|
}
|
|
1596
1607
|
}
|
|
1597
1608
|
|
|
1609
|
+
// ---- Link (anchor mit Button-/Muted-Optik) ----
|
|
1610
|
+
|
|
1611
|
+
// `button` nutzt die Primary-Buttonfläche auf einem semantischen <a> —
|
|
1612
|
+
// der Standard für „weiter zu"-Navigationen nach Success-States (ehem.
|
|
1613
|
+
// authButtonClass), `muted` der dezente Sekundär-Link (ehem.
|
|
1614
|
+
// authMutedLinkClass).
|
|
1615
|
+
function DefaultLink({
|
|
1616
|
+
href,
|
|
1617
|
+
variant = "default",
|
|
1618
|
+
target,
|
|
1619
|
+
className,
|
|
1620
|
+
children,
|
|
1621
|
+
testId,
|
|
1622
|
+
}: LinkProps): ReactNode {
|
|
1623
|
+
const variantClass =
|
|
1624
|
+
variant === "button"
|
|
1625
|
+
? buttonVariants({ variant: "default" })
|
|
1626
|
+
: variant === "muted"
|
|
1627
|
+
? "text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
|
1628
|
+
: "text-primary underline-offset-4 hover:underline";
|
|
1629
|
+
return (
|
|
1630
|
+
<a
|
|
1631
|
+
href={href}
|
|
1632
|
+
target={target}
|
|
1633
|
+
rel={target === "_blank" ? "noreferrer" : undefined}
|
|
1634
|
+
data-testid={testId}
|
|
1635
|
+
className={cn(variantClass, className)}
|
|
1636
|
+
>
|
|
1637
|
+
{children}
|
|
1638
|
+
</a>
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1598
1642
|
function DefaultHeading({ variant = "page", children, testId }: HeadingProps): ReactNode {
|
|
1599
1643
|
// Page-Heading = h1, sehr selten in einer App (max 1 pro Screen).
|
|
1600
1644
|
// Section-Heading = h2 mit uppercase + muted-foreground — derselbe
|
|
@@ -1685,4 +1729,5 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
1685
1729
|
Lightbox: DefaultLightbox,
|
|
1686
1730
|
ConfigSourceBadge: DefaultConfigSourceBadge,
|
|
1687
1731
|
ConfigCascadeView: DefaultConfigCascadeView,
|
|
1732
|
+
Link: DefaultLink,
|
|
1688
1733
|
};
|
package/src/styles.css
CHANGED
|
@@ -80,6 +80,14 @@
|
|
|
80
80
|
--radius-md: calc(var(--radius) - 2px);
|
|
81
81
|
--radius-lg: var(--radius);
|
|
82
82
|
--radius-xl: calc(var(--radius) + 4px);
|
|
83
|
+
|
|
84
|
+
/* Semantische Status-Farben (Widgets: StatusBadge, Charts, StatCard-Tones).
|
|
85
|
+
@theme generiert die Utilities (text-status-ok, bg-status-warn/10, …).
|
|
86
|
+
Dark-Werte; Light-Overrides unten. Apps überschreiben in ihrer styles.css. */
|
|
87
|
+
--color-status-ok: #22c55e;
|
|
88
|
+
--color-status-warn: #f59e0b;
|
|
89
|
+
--color-status-bad: #ef4444;
|
|
90
|
+
--color-status-critical: #f87171;
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
/* Card-Chrome-Maße — Framework-Defaults. Eine App überschreibt selektiv in
|
|
@@ -132,6 +140,12 @@
|
|
|
132
140
|
--color-sidebar-accent-foreground: hsl(0 0% 9%);
|
|
133
141
|
--color-sidebar-border: hsl(0 0% 89.8%);
|
|
134
142
|
--color-sidebar-ring: hsl(0 0% 63%);
|
|
143
|
+
|
|
144
|
+
/* Status-Farben Light — kräftiger/dunkler als die Dark-Werte. */
|
|
145
|
+
--color-status-ok: #16a34a;
|
|
146
|
+
--color-status-warn: #d97706;
|
|
147
|
+
--color-status-bad: #dc2626;
|
|
148
|
+
--color-status-critical: #991b1b;
|
|
135
149
|
}
|
|
136
150
|
|
|
137
151
|
* {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { DispatcherProvider } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import {
|
|
6
|
+
createMockDispatcher,
|
|
7
|
+
fireEvent,
|
|
8
|
+
render,
|
|
9
|
+
screen,
|
|
10
|
+
waitFor,
|
|
11
|
+
} from "../../__tests__/test-utils";
|
|
12
|
+
import { QueryTable } from "../query-table";
|
|
13
|
+
|
|
14
|
+
function renderWithDispatcher(ui: ReactNode, dispatcher: Dispatcher) {
|
|
15
|
+
return render(<DispatcherProvider dispatcher={dispatcher}>{ui}</DispatcherProvider>);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const COLUMNS = [
|
|
19
|
+
{ field: "name", label: "Name" },
|
|
20
|
+
{ field: "plan", label: "Plan" },
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
describe("QueryTable", () => {
|
|
24
|
+
test("rendert Query-Result als DataTable-Rows", async () => {
|
|
25
|
+
const dispatcher = createMockDispatcher({
|
|
26
|
+
query: (async () => ({
|
|
27
|
+
isSuccess: true,
|
|
28
|
+
data: [
|
|
29
|
+
{ id: "t1", name: "Acme", plan: "pro" },
|
|
30
|
+
{ id: "t2", name: "Beta GmbH", plan: "free" },
|
|
31
|
+
],
|
|
32
|
+
})) as unknown as Dispatcher["query"],
|
|
33
|
+
});
|
|
34
|
+
renderWithDispatcher(
|
|
35
|
+
<QueryTable query="tenant:query:tenant:list" columns={COLUMNS} testId="tbl" />,
|
|
36
|
+
dispatcher,
|
|
37
|
+
);
|
|
38
|
+
await waitFor(() => expect(screen.getByText("Acme")).toBeTruthy());
|
|
39
|
+
expect(screen.getByText("Beta GmbH")).toBeTruthy();
|
|
40
|
+
expect(screen.getByText("Name")).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("Row-Click liefert die geklickte Row", async () => {
|
|
44
|
+
const onRowClick = mock((_row: { id: string }) => {});
|
|
45
|
+
const dispatcher = createMockDispatcher({
|
|
46
|
+
query: (async () => ({
|
|
47
|
+
isSuccess: true,
|
|
48
|
+
data: [{ id: "t1", name: "Acme", plan: "pro" }],
|
|
49
|
+
})) as unknown as Dispatcher["query"],
|
|
50
|
+
});
|
|
51
|
+
renderWithDispatcher(
|
|
52
|
+
<QueryTable query="tenant:query:tenant:list" columns={COLUMNS} onRowClick={onRowClick} />,
|
|
53
|
+
dispatcher,
|
|
54
|
+
);
|
|
55
|
+
await waitFor(() => expect(screen.getByText("Acme")).toBeTruthy());
|
|
56
|
+
fireEvent.click(screen.getByText("Acme"));
|
|
57
|
+
expect(onRowClick).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(onRowClick.mock.calls[0]?.[0]?.id).toBe("t1");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("Fehler → ErrorState, Retry refetcht", async () => {
|
|
62
|
+
let calls = 0;
|
|
63
|
+
const dispatcher = createMockDispatcher({
|
|
64
|
+
query: (async () => {
|
|
65
|
+
calls += 1;
|
|
66
|
+
if (calls === 1) {
|
|
67
|
+
return {
|
|
68
|
+
isSuccess: false,
|
|
69
|
+
error: { code: "internal", message: "kaputt", i18nKey: "errors.internal" },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return { isSuccess: true, data: [{ id: "t1", name: "Acme", plan: "pro" }] };
|
|
73
|
+
}) as unknown as Dispatcher["query"],
|
|
74
|
+
});
|
|
75
|
+
renderWithDispatcher(
|
|
76
|
+
<QueryTable query="tenant:query:tenant:list" columns={COLUMNS} testId="tbl" />,
|
|
77
|
+
dispatcher,
|
|
78
|
+
);
|
|
79
|
+
await waitFor(() => expect(screen.getByRole("button")).toBeTruthy());
|
|
80
|
+
fireEvent.click(screen.getByRole("button"));
|
|
81
|
+
await waitFor(() => expect(screen.getByText("Acme")).toBeTruthy());
|
|
82
|
+
expect(calls).toBe(2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("leeres Result rendert Empty-State", async () => {
|
|
86
|
+
const dispatcher = createMockDispatcher({
|
|
87
|
+
query: (async () => ({ isSuccess: true, data: [] })) as unknown as Dispatcher["query"],
|
|
88
|
+
});
|
|
89
|
+
const { container } = renderWithDispatcher(
|
|
90
|
+
<QueryTable
|
|
91
|
+
query="tenant:query:tenant:list"
|
|
92
|
+
columns={COLUMNS}
|
|
93
|
+
emptyState={<span>Keine Tenants</span>}
|
|
94
|
+
/>,
|
|
95
|
+
dispatcher,
|
|
96
|
+
);
|
|
97
|
+
await waitFor(() => expect(screen.getByText("Keine Tenants")).toBeTruthy());
|
|
98
|
+
expect(container.querySelectorAll("tbody tr").length).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("rows-Selector zieht Rows aus verschachteltem Result", async () => {
|
|
102
|
+
const dispatcher = createMockDispatcher({
|
|
103
|
+
query: (async () => ({
|
|
104
|
+
isSuccess: true,
|
|
105
|
+
data: { items: [{ id: "x", name: "Nested", plan: "pro" }], total: 1 },
|
|
106
|
+
})) as unknown as Dispatcher["query"],
|
|
107
|
+
});
|
|
108
|
+
renderWithDispatcher(
|
|
109
|
+
<QueryTable<{ items: readonly Record<string, unknown>[]; total: number }>
|
|
110
|
+
query="tenant:query:tenant:list"
|
|
111
|
+
columns={COLUMNS}
|
|
112
|
+
rows={(data) => data.items}
|
|
113
|
+
/>,
|
|
114
|
+
dispatcher,
|
|
115
|
+
);
|
|
116
|
+
await waitFor(() => expect(screen.getByText("Nested")).toBeTruthy());
|
|
117
|
+
});
|
|
118
|
+
});
|