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