khotan-data 0.1.0 → 0.1.1

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.
@@ -0,0 +1,761 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Label } from "@/components/ui/label";
8
+ import {
9
+ Table,
10
+ TableBody,
11
+ TableCell,
12
+ TableHead,
13
+ TableHeader,
14
+ TableRow,
15
+ } from "@/components/ui/table";
16
+
17
+ interface ResourceDefinition {
18
+ uniqueIdentifier: string;
19
+ }
20
+
21
+ interface ResourceRecord {
22
+ id: string;
23
+ name: string;
24
+ description?: string | null;
25
+ mapping: {
26
+ connectField: string | string[];
27
+ plugs?: Record<string, ResourceDefinition>;
28
+ };
29
+ }
30
+
31
+ interface MappingRecord {
32
+ id: string;
33
+ resourceId: string;
34
+ connectValue: string;
35
+ refs: Record<string, string>;
36
+ metadata?: Record<string, unknown> | null;
37
+ }
38
+
39
+ interface MappingPage {
40
+ items: MappingRecord[];
41
+ page: {
42
+ limit: number;
43
+ offset: number;
44
+ hasMore: boolean;
45
+ prevOffset: number;
46
+ nextOffset: number;
47
+ total: number;
48
+ };
49
+ }
50
+
51
+ interface RefEntry {
52
+ plugName: string;
53
+ ref: string;
54
+ }
55
+
56
+ type FormMode = "create" | "edit";
57
+
58
+ function readErrorMessage(error: unknown): string {
59
+ if (error instanceof Error) return error.message;
60
+ return "Unknown error";
61
+ }
62
+
63
+ function toPrettyJson(value: Record<string, unknown> | null | undefined): string {
64
+ return value ? JSON.stringify(value, null, 2) : "";
65
+ }
66
+
67
+ function parseMetadata(text: string): Record<string, unknown> | null {
68
+ const trimmed = text.trim();
69
+ if (!trimmed) return null;
70
+ const parsed = JSON.parse(trimmed) as unknown;
71
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
72
+ throw new Error("Metadata must be a JSON object.");
73
+ }
74
+ return parsed as Record<string, unknown>;
75
+ }
76
+
77
+ function toRefEntries(refs: Record<string, string>): RefEntry[] {
78
+ return Object.entries(refs).map(([plugName, ref]) => ({ plugName, ref }));
79
+ }
80
+
81
+ function parseConnectValueInput(
82
+ resource: ResourceRecord | null,
83
+ rawValue: string,
84
+ ): string | string[] {
85
+ if (!resource || !Array.isArray(resource.mapping.connectField)) {
86
+ return rawValue;
87
+ }
88
+
89
+ const trimmed = rawValue.trim();
90
+ if (!trimmed.startsWith("[")) {
91
+ return rawValue;
92
+ }
93
+
94
+ const parsed = JSON.parse(trimmed) as unknown;
95
+ if (!Array.isArray(parsed) || parsed.some((value) => typeof value !== "string")) {
96
+ throw new Error(
97
+ "Composite connect values must be provided as a JSON string array in declared field order.",
98
+ );
99
+ }
100
+ return parsed;
101
+ }
102
+
103
+ function formatConnectField(connectField: string | string[]): string {
104
+ return Array.isArray(connectField)
105
+ ? connectField.join(" -> ")
106
+ : String(connectField);
107
+ }
108
+
109
+ export function KhotanMappingBrowser({
110
+ pageSize = 20,
111
+ }: {
112
+ pageSize?: number;
113
+ } = {}) {
114
+ const [resources, setResources] = useState<ResourceRecord[]>([]);
115
+ const [resourcesLoading, setResourcesLoading] = useState(true);
116
+ const [selectedResourceId, setSelectedResourceId] = useState<string>("");
117
+ const [mappings, setMappings] = useState<MappingRecord[]>([]);
118
+ const [page, setPage] = useState<MappingPage["page"] | null>(null);
119
+ const [mappingsLoading, setMappingsLoading] = useState(false);
120
+ const [search, setSearch] = useState("");
121
+ const [offset, setOffset] = useState(0);
122
+ const [error, setError] = useState<string | null>(null);
123
+ const [actionError, setActionError] = useState<string | null>(null);
124
+ const [submitting, setSubmitting] = useState(false);
125
+ const [formMode, setFormMode] = useState<FormMode | null>(null);
126
+ const [editingMappingId, setEditingMappingId] = useState<string | null>(null);
127
+ const [connectValueInput, setConnectValueInput] = useState("");
128
+ const [metadataInput, setMetadataInput] = useState("");
129
+ const [dynamicRefs, setDynamicRefs] = useState<RefEntry[]>([
130
+ { plugName: "", ref: "" },
131
+ ]);
132
+ const [declaredRefs, setDeclaredRefs] = useState<Record<string, string>>({});
133
+
134
+ const selectedResource =
135
+ resources.find((resource) => resource.id === selectedResourceId) ?? null;
136
+ const declaredPlugNames = useMemo(
137
+ () => Object.keys(selectedResource?.mapping.plugs ?? {}),
138
+ [selectedResource],
139
+ );
140
+
141
+ async function fetchResources() {
142
+ setResourcesLoading(true);
143
+ setError(null);
144
+ try {
145
+ const res = await fetch("/api/khotan/resources");
146
+ if (!res.ok) {
147
+ throw new Error("Failed to fetch resources from /api/khotan/resources");
148
+ }
149
+ const data = (await res.json()) as ResourceRecord[];
150
+ setResources(data);
151
+
152
+ setSelectedResourceId((current) => {
153
+ if (data.length === 0) return "";
154
+ if (data.some((resource) => resource.id === current)) return current;
155
+ if (data.length === 1) return data[0]!.id;
156
+ return current || data[0]!.id;
157
+ });
158
+ } catch (error) {
159
+ setError(readErrorMessage(error));
160
+ } finally {
161
+ setResourcesLoading(false);
162
+ }
163
+ }
164
+
165
+ async function fetchMappings(resourceId: string, nextOffset: number, term: string) {
166
+ setMappingsLoading(true);
167
+ setError(null);
168
+ try {
169
+ const url = new URL(
170
+ `/api/khotan/resources/${resourceId}/mappings`,
171
+ window.location.origin,
172
+ );
173
+ url.searchParams.set("limit", String(pageSize));
174
+ url.searchParams.set("offset", String(nextOffset));
175
+ if (term.trim()) {
176
+ url.searchParams.set("search", term.trim());
177
+ }
178
+ const res = await fetch(url.toString());
179
+ if (!res.ok) {
180
+ throw new Error("Failed to fetch mappings for the selected resource");
181
+ }
182
+ const data = (await res.json()) as MappingPage;
183
+ setMappings(data.items);
184
+ setPage(data.page);
185
+ } catch (error) {
186
+ setError(readErrorMessage(error));
187
+ setMappings([]);
188
+ setPage(null);
189
+ } finally {
190
+ setMappingsLoading(false);
191
+ }
192
+ }
193
+
194
+ useEffect(() => {
195
+ void fetchResources();
196
+ }, []);
197
+
198
+ useEffect(() => {
199
+ if (!selectedResourceId) {
200
+ setMappings([]);
201
+ setPage(null);
202
+ return;
203
+ }
204
+ void fetchMappings(selectedResourceId, offset, search);
205
+ }, [selectedResourceId, offset, pageSize, search]);
206
+
207
+ useEffect(() => {
208
+ setOffset(0);
209
+ }, [selectedResourceId]);
210
+
211
+ function resetForm() {
212
+ setFormMode(null);
213
+ setEditingMappingId(null);
214
+ setConnectValueInput("");
215
+ setMetadataInput("");
216
+ setDynamicRefs([{ plugName: "", ref: "" }]);
217
+ setDeclaredRefs({});
218
+ setActionError(null);
219
+ }
220
+
221
+ function openCreateForm() {
222
+ resetForm();
223
+ setFormMode("create");
224
+ if (declaredPlugNames.length > 0) {
225
+ setDeclaredRefs(
226
+ Object.fromEntries(declaredPlugNames.map((plugName) => [plugName, ""])),
227
+ );
228
+ setDynamicRefs([]);
229
+ }
230
+ }
231
+
232
+ function openEditForm(mapping: MappingRecord) {
233
+ setFormMode("edit");
234
+ setEditingMappingId(mapping.id);
235
+ setConnectValueInput(mapping.connectValue);
236
+ setMetadataInput(toPrettyJson(mapping.metadata));
237
+ setActionError(null);
238
+
239
+ if (declaredPlugNames.length > 0) {
240
+ const nextDeclaredRefs = Object.fromEntries(
241
+ declaredPlugNames.map((plugName) => [plugName, mapping.refs[plugName] ?? ""]),
242
+ );
243
+ setDeclaredRefs(nextDeclaredRefs);
244
+ setDynamicRefs([]);
245
+ return;
246
+ }
247
+
248
+ setDeclaredRefs({});
249
+ setDynamicRefs(
250
+ toRefEntries(mapping.refs).length > 0
251
+ ? toRefEntries(mapping.refs)
252
+ : [{ plugName: "", ref: "" }],
253
+ );
254
+ }
255
+
256
+ function buildRefsPayload(): Record<string, string> {
257
+ if (declaredPlugNames.length > 0) {
258
+ return Object.fromEntries(
259
+ Object.entries(declaredRefs)
260
+ .map(([plugName, ref]) => [plugName, ref.trim()] as const)
261
+ .filter(([, ref]) => ref.length > 0),
262
+ );
263
+ }
264
+
265
+ return Object.fromEntries(
266
+ dynamicRefs
267
+ .map((entry) => ({
268
+ plugName: entry.plugName.trim(),
269
+ ref: entry.ref.trim(),
270
+ }))
271
+ .filter((entry) => entry.plugName && entry.ref)
272
+ .map((entry) => [entry.plugName, entry.ref] as const),
273
+ );
274
+ }
275
+
276
+ async function submitForm() {
277
+ if (!selectedResource) {
278
+ setActionError("Select a resource before saving a mapping.");
279
+ return;
280
+ }
281
+
282
+ setSubmitting(true);
283
+ setActionError(null);
284
+
285
+ try {
286
+ const metadata = parseMetadata(metadataInput);
287
+ const refs = buildRefsPayload();
288
+ const connectValue = parseConnectValueInput(
289
+ selectedResource,
290
+ connectValueInput,
291
+ );
292
+
293
+ if (
294
+ (typeof connectValue === "string" && !connectValue.trim()) ||
295
+ (Array.isArray(connectValue) && connectValue.length === 0)
296
+ ) {
297
+ throw new Error("Connect value is required.");
298
+ }
299
+
300
+ if (Object.keys(refs).length === 0) {
301
+ throw new Error("At least one ref is required.");
302
+ }
303
+
304
+ const body = {
305
+ resourceId: selectedResource.id,
306
+ connectValue,
307
+ refs,
308
+ metadata,
309
+ };
310
+
311
+ const url =
312
+ formMode === "edit" && editingMappingId
313
+ ? `/api/khotan/mappings/${editingMappingId}`
314
+ : "/api/khotan/mappings";
315
+ const method = formMode === "edit" ? "PUT" : "POST";
316
+
317
+ const res = await fetch(url, {
318
+ method,
319
+ headers: { "Content-Type": "application/json" },
320
+ body: JSON.stringify(body),
321
+ });
322
+
323
+ if (!res.ok) {
324
+ const payload = (await res.json().catch(() => ({}))) as {
325
+ error?: string;
326
+ };
327
+ throw new Error(payload.error ?? "Failed to save mapping.");
328
+ }
329
+
330
+ resetForm();
331
+ await fetchMappings(selectedResource.id, offset, search);
332
+ } catch (error) {
333
+ setActionError(readErrorMessage(error));
334
+ } finally {
335
+ setSubmitting(false);
336
+ }
337
+ }
338
+
339
+ async function handleDelete(mapping: MappingRecord) {
340
+ const confirmed = window.confirm(
341
+ `Delete mapping "${mapping.connectValue}"? This removes the shared identity row for the selected resource.`,
342
+ );
343
+ if (!confirmed) return;
344
+
345
+ setActionError(null);
346
+ try {
347
+ const res = await fetch(`/api/khotan/mappings/${mapping.id}`, {
348
+ method: "DELETE",
349
+ });
350
+ if (!res.ok) {
351
+ const payload = (await res.json().catch(() => ({}))) as {
352
+ error?: string;
353
+ };
354
+ throw new Error(payload.error ?? "Failed to delete mapping.");
355
+ }
356
+
357
+ const nextOffset =
358
+ mappings.length === 1 && offset > 0 ? Math.max(offset - pageSize, 0) : offset;
359
+ setOffset(nextOffset);
360
+ await fetchMappings(mapping.resourceId, nextOffset, search);
361
+ } catch (error) {
362
+ setActionError(readErrorMessage(error));
363
+ }
364
+ }
365
+
366
+ function renderRefsSummary(mapping: MappingRecord) {
367
+ const entries = Object.entries(mapping.refs);
368
+ if (entries.length === 0) {
369
+ return <span className="text-muted-foreground text-sm">No refs</span>;
370
+ }
371
+ return (
372
+ <div className="space-y-1">
373
+ {entries.map(([plugName, ref]) => (
374
+ <div key={plugName} className="text-sm">
375
+ <span className="font-medium">{plugName}:</span> {ref}
376
+ </div>
377
+ ))}
378
+ </div>
379
+ );
380
+ }
381
+
382
+ function renderMetadataSummary(mapping: MappingRecord) {
383
+ const entries = Object.entries(mapping.metadata ?? {});
384
+ if (entries.length === 0) {
385
+ return <span className="text-muted-foreground text-sm">No metadata</span>;
386
+ }
387
+ return (
388
+ <div className="space-y-1">
389
+ {entries.map(([key, value]) => (
390
+ <div key={key} className="text-sm">
391
+ <span className="font-medium">{key}:</span> {String(value)}
392
+ </div>
393
+ ))}
394
+ </div>
395
+ );
396
+ }
397
+
398
+ return (
399
+ <div className="space-y-6">
400
+ <Card>
401
+ <CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
402
+ <div className="space-y-2">
403
+ <CardTitle>Mappings Browser</CardTitle>
404
+ <p className="text-muted-foreground text-sm">
405
+ Browse shared identities by resource, search by connect value, and
406
+ maintain per-plug refs without mixing them into metadata.
407
+ </p>
408
+ </div>
409
+ <Button onClick={openCreateForm} disabled={!selectedResourceId}>
410
+ Create Mapping
411
+ </Button>
412
+ </CardHeader>
413
+ <CardContent className="space-y-4">
414
+ <div className="grid gap-4 md:grid-cols-[minmax(0,240px)_1fr]">
415
+ <div className="space-y-2">
416
+ <Label htmlFor="resource-select">Resource</Label>
417
+ <select
418
+ id="resource-select"
419
+ className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm"
420
+ value={selectedResourceId}
421
+ onChange={(event) => {
422
+ setSelectedResourceId(event.target.value);
423
+ setSearch("");
424
+ setOffset(0);
425
+ resetForm();
426
+ }}
427
+ disabled={resourcesLoading || resources.length === 0}
428
+ >
429
+ {resources.length === 0 ? (
430
+ <option value="">No resources</option>
431
+ ) : (
432
+ resources.map((resource) => (
433
+ <option key={resource.id} value={resource.id}>
434
+ {resource.name}
435
+ </option>
436
+ ))
437
+ )}
438
+ </select>
439
+ {selectedResource ? (
440
+ <p className="text-muted-foreground text-xs">
441
+ mapping.connectField:{" "}
442
+ {formatConnectField(selectedResource.mapping.connectField)}
443
+ </p>
444
+ ) : null}
445
+ </div>
446
+
447
+ <div className="space-y-2">
448
+ <Label htmlFor="mapping-search">Search</Label>
449
+ <Input
450
+ id="mapping-search"
451
+ placeholder="Search connect values, refs, or metadata"
452
+ value={search}
453
+ onChange={(event) => {
454
+ setSearch(event.target.value);
455
+ setOffset(0);
456
+ }}
457
+ disabled={!selectedResourceId}
458
+ />
459
+ </div>
460
+ </div>
461
+
462
+ {resourcesLoading ? (
463
+ <div className="text-muted-foreground text-sm">Loading resources...</div>
464
+ ) : null}
465
+
466
+ {!resourcesLoading && resources.length === 0 ? (
467
+ <div className="text-muted-foreground text-sm">
468
+ No resources are registered yet. Mappings require registered resources
469
+ in your `khotan()` config.
470
+ </div>
471
+ ) : null}
472
+
473
+ {error ? (
474
+ <div className="space-y-2 rounded-md border border-destructive/30 bg-destructive/5 p-4">
475
+ <p className="text-sm text-destructive">{error}</p>
476
+ <Button
477
+ variant="outline"
478
+ size="sm"
479
+ onClick={() => {
480
+ if (selectedResourceId) {
481
+ void fetchMappings(selectedResourceId, offset, search);
482
+ } else {
483
+ void fetchResources();
484
+ }
485
+ }}
486
+ >
487
+ Retry
488
+ </Button>
489
+ </div>
490
+ ) : null}
491
+ </CardContent>
492
+ </Card>
493
+
494
+ {formMode ? (
495
+ <Card>
496
+ <CardHeader>
497
+ <CardTitle>
498
+ {formMode === "create" ? "Create Mapping" : "Edit Mapping"}
499
+ </CardTitle>
500
+ </CardHeader>
501
+ <CardContent className="space-y-4">
502
+ <div className="space-y-2">
503
+ <Label htmlFor="connect-value">Connect Value</Label>
504
+ <Input
505
+ id="connect-value"
506
+ value={connectValueInput}
507
+ onChange={(event) => setConnectValueInput(event.target.value)}
508
+ placeholder={
509
+ Array.isArray(selectedResource?.mapping.connectField)
510
+ ? 'Use the canonical string or JSON array, e.g. ["tenant-a","alice@example.com"]'
511
+ : "alice@example.com"
512
+ }
513
+ />
514
+ {Array.isArray(selectedResource?.mapping.connectField) ? (
515
+ <p className="text-muted-foreground text-xs">
516
+ Composite resources can accept a JSON array in declared field
517
+ order: {selectedResource.mapping.connectField.join(" -> ")}.
518
+ </p>
519
+ ) : null}
520
+ </div>
521
+
522
+ <div className="space-y-3">
523
+ <div>
524
+ <Label>Refs</Label>
525
+ <p className="text-muted-foreground text-xs">
526
+ Refs are the external per-plug identifiers for this shared
527
+ entity.
528
+ </p>
529
+ </div>
530
+
531
+ {declaredPlugNames.length > 0 ? (
532
+ <div className="grid gap-3 md:grid-cols-2">
533
+ {declaredPlugNames.map((plugName) => (
534
+ <div key={plugName} className="space-y-2">
535
+ <Label htmlFor={`ref-${plugName}`}>{plugName}</Label>
536
+ <Input
537
+ id={`ref-${plugName}`}
538
+ value={declaredRefs[plugName] ?? ""}
539
+ placeholder={
540
+ selectedResource?.mapping.plugs?.[plugName]?.uniqueIdentifier ??
541
+ "External ID"
542
+ }
543
+ onChange={(event) =>
544
+ setDeclaredRefs((current) => ({
545
+ ...current,
546
+ [plugName]: event.target.value,
547
+ }))
548
+ }
549
+ />
550
+ </div>
551
+ ))}
552
+ </div>
553
+ ) : (
554
+ <div className="space-y-3">
555
+ {dynamicRefs.map((entry, index) => (
556
+ <div
557
+ key={`${entry.plugName}-${index}`}
558
+ className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]"
559
+ >
560
+ <Input
561
+ placeholder="Plug name"
562
+ value={entry.plugName}
563
+ onChange={(event) =>
564
+ setDynamicRefs((current) =>
565
+ current.map((item, itemIndex) =>
566
+ itemIndex === index
567
+ ? { ...item, plugName: event.target.value }
568
+ : item,
569
+ ),
570
+ )
571
+ }
572
+ />
573
+ <Input
574
+ placeholder="External ref"
575
+ value={entry.ref}
576
+ onChange={(event) =>
577
+ setDynamicRefs((current) =>
578
+ current.map((item, itemIndex) =>
579
+ itemIndex === index
580
+ ? { ...item, ref: event.target.value }
581
+ : item,
582
+ ),
583
+ )
584
+ }
585
+ />
586
+ <Button
587
+ type="button"
588
+ variant="outline"
589
+ onClick={() =>
590
+ setDynamicRefs((current) =>
591
+ current.length === 1
592
+ ? [{ plugName: "", ref: "" }]
593
+ : current.filter((_, itemIndex) => itemIndex !== index),
594
+ )
595
+ }
596
+ >
597
+ Remove
598
+ </Button>
599
+ </div>
600
+ ))}
601
+ <Button
602
+ type="button"
603
+ variant="outline"
604
+ onClick={() =>
605
+ setDynamicRefs((current) => [
606
+ ...current,
607
+ { plugName: "", ref: "" },
608
+ ])
609
+ }
610
+ >
611
+ Add Ref
612
+ </Button>
613
+ </div>
614
+ )}
615
+ </div>
616
+
617
+ <div className="space-y-2">
618
+ <Label htmlFor="metadata-json">Metadata</Label>
619
+ <p className="text-muted-foreground text-xs">
620
+ Metadata is for contextual display fields only, separate from
621
+ mapping identity refs.
622
+ </p>
623
+ <textarea
624
+ id="metadata-json"
625
+ className="border-input bg-background min-h-32 w-full rounded-md border px-3 py-2 text-sm font-mono"
626
+ value={metadataInput}
627
+ onChange={(event) => setMetadataInput(event.target.value)}
628
+ placeholder='{"firstName":"Alice","company":"Example Co"}'
629
+ />
630
+ </div>
631
+
632
+ {actionError ? (
633
+ <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
634
+ {actionError}
635
+ </div>
636
+ ) : null}
637
+
638
+ <div className="flex flex-wrap gap-3">
639
+ <Button onClick={() => void submitForm()} disabled={submitting}>
640
+ {submitting
641
+ ? "Saving..."
642
+ : formMode === "create"
643
+ ? "Create Mapping"
644
+ : "Save Changes"}
645
+ </Button>
646
+ <Button variant="outline" onClick={resetForm} disabled={submitting}>
647
+ Cancel
648
+ </Button>
649
+ </div>
650
+ </CardContent>
651
+ </Card>
652
+ ) : null}
653
+
654
+ <Card>
655
+ <CardHeader className="flex flex-row items-center justify-between">
656
+ <CardTitle>Mappings</CardTitle>
657
+ {page ? (
658
+ <p className="text-muted-foreground text-sm">
659
+ {page.total} total mapping{page.total === 1 ? "" : "s"}
660
+ </p>
661
+ ) : null}
662
+ </CardHeader>
663
+ <CardContent>
664
+ {mappingsLoading ? (
665
+ <div className="text-muted-foreground text-sm">Loading mappings...</div>
666
+ ) : null}
667
+
668
+ {!mappingsLoading &&
669
+ selectedResource &&
670
+ mappings.length === 0 &&
671
+ search.trim() ? (
672
+ <div className="text-muted-foreground text-sm">
673
+ No mappings match this search for the selected resource.
674
+ </div>
675
+ ) : null}
676
+
677
+ {!mappingsLoading &&
678
+ selectedResource &&
679
+ mappings.length === 0 &&
680
+ !search.trim() ? (
681
+ <div className="text-muted-foreground text-sm">
682
+ This resource has no mappings yet. Create the first one to start
683
+ connecting identities across plugs.
684
+ </div>
685
+ ) : null}
686
+
687
+ {!mappingsLoading && mappings.length > 0 ? (
688
+ <div className="space-y-4">
689
+ <Table>
690
+ <TableHeader>
691
+ <TableRow>
692
+ <TableHead>Connect Value</TableHead>
693
+ <TableHead>Refs</TableHead>
694
+ <TableHead>Metadata</TableHead>
695
+ <TableHead className="text-right">Actions</TableHead>
696
+ </TableRow>
697
+ </TableHeader>
698
+ <TableBody>
699
+ {mappings.map((mapping) => (
700
+ <TableRow key={mapping.id}>
701
+ <TableCell className="font-medium">
702
+ {mapping.connectValue}
703
+ </TableCell>
704
+ <TableCell>{renderRefsSummary(mapping)}</TableCell>
705
+ <TableCell>{renderMetadataSummary(mapping)}</TableCell>
706
+ <TableCell className="text-right">
707
+ <div className="flex justify-end gap-2">
708
+ <Button
709
+ variant="outline"
710
+ size="sm"
711
+ onClick={() => openEditForm(mapping)}
712
+ >
713
+ Edit
714
+ </Button>
715
+ <Button
716
+ variant="outline"
717
+ size="sm"
718
+ onClick={() => void handleDelete(mapping)}
719
+ >
720
+ Delete
721
+ </Button>
722
+ </div>
723
+ </TableCell>
724
+ </TableRow>
725
+ ))}
726
+ </TableBody>
727
+ </Table>
728
+
729
+ {page ? (
730
+ <div className="flex flex-wrap items-center justify-between gap-3">
731
+ <p className="text-muted-foreground text-sm">
732
+ Showing {page.offset + 1}-
733
+ {page.offset + mappings.length} of {page.total}
734
+ </p>
735
+ <div className="flex gap-2">
736
+ <Button
737
+ variant="outline"
738
+ size="sm"
739
+ disabled={page.offset === 0}
740
+ onClick={() => setOffset(page.prevOffset)}
741
+ >
742
+ Previous
743
+ </Button>
744
+ <Button
745
+ variant="outline"
746
+ size="sm"
747
+ disabled={!page.hasMore}
748
+ onClick={() => setOffset(page.nextOffset)}
749
+ >
750
+ Next
751
+ </Button>
752
+ </div>
753
+ </div>
754
+ ) : null}
755
+ </div>
756
+ ) : null}
757
+ </CardContent>
758
+ </Card>
759
+ </div>
760
+ );
761
+ }