@kyro-cms/admin 0.12.16 → 0.12.18

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyro-cms/admin",
3
- "version": "0.12.16",
3
+ "version": "0.12.18",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -73,6 +73,8 @@
73
73
  "@dnd-kit/sortable": "^10.0.0",
74
74
  "@dnd-kit/utilities": "^3.2.2",
75
75
  "@graphiql/react": "^0.37.3",
76
+ "@kyro-cms/astro": "0.12.18",
77
+ "@kyro-cms/core": "0.12.18",
76
78
  "@tailwindcss/vite": "^4.0.0",
77
79
  "@tiptap/extension-color": "^3.23.6",
78
80
  "@tiptap/extension-highlight": "^3.23.6",
@@ -109,8 +111,7 @@
109
111
  "tailwindcss": "^4.0.0",
110
112
  "unstorage": "^1.17.5",
111
113
  "use-sync-external-store": "1.5.0",
112
- "zustand": "^5.0.3",
113
- "@kyro-cms/core": "0.12.16"
114
+ "zustand": "^5.0.3"
114
115
  },
115
116
  "devDependencies": {
116
117
  "@astrojs/check": "^0.9.9",
@@ -124,9 +125,9 @@
124
125
  "vitest": "^4.1.4"
125
126
  },
126
127
  "peerDependencies": {
128
+ "@kyro-cms/core": "0.12.18",
127
129
  "react": "^19.0.0",
128
- "react-dom": "^19.0.0",
129
- "@kyro-cms/core": "0.12.16"
130
+ "react-dom": "^19.0.0"
130
131
  },
131
132
  "repository": {
132
133
  "type": "git",
@@ -864,11 +864,13 @@ export function AutoForm({
864
864
  case "tabs":
865
865
  return (
866
866
  <TabsLayout
867
- key={field.name || `tabs-${Math.random()}`}
867
+ key={field.name || `tabs-layout`}
868
868
  field={field}
869
869
  formData={formData}
870
870
  onTabDataChange={(tabData) => {
871
- setField(field.name!, tabData);
871
+ if (field.name) {
872
+ setField(field.name, tabData);
873
+ }
872
874
  }}
873
875
  renderField={(f, parentData, onChange) => renderField(f, parentData, onChange)}
874
876
  />
@@ -431,32 +431,34 @@ export const DashboardMetrics: React.FC<DashboardMetricsProps> = ({ isEcommerce
431
431
 
432
432
  const currency = analyticsData?.currencyCode || "USD";
433
433
 
434
+ const productCount =
435
+ (data?.collectionCounts?.["products"] || 0) +
436
+ (data?.collectionCounts?.["food-menu"] || 0) +
437
+ (data?.collectionCounts?.["menu"] || 0);
438
+
434
439
  const ecommerceCards = [
435
440
  {
436
441
  icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>,
437
442
  gradient: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
438
- value: analyticsData?.totalRevenue
443
+ value: analyticsData?.totalRevenue !== undefined
439
444
  ? new Intl.NumberFormat("en-US", { style: "currency", currency }).format(analyticsData.totalRevenue)
440
445
  : "$0",
441
446
  label: "Total Revenue",
442
- subtext: "From paid orders",
443
- trend: { value: "+12.4%", up: true },
447
+ subtext: "From system orders",
444
448
  },
445
449
  {
446
450
  icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>,
447
451
  gradient: "linear-gradient(135deg, #ec4899 0%, #f43f5e 100%)",
448
- value: data?.collectionCounts?.["products"] || 0,
449
- label: "Products",
450
- subtext: "Active inventory items",
451
- trend: { value: "+3 new", up: true },
452
+ value: productCount,
453
+ label: "Products / Menu Items",
454
+ subtext: "Active inventory & menu items",
452
455
  },
453
456
  {
454
457
  icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" /></svg>,
455
458
  gradient: "linear-gradient(135deg, #10b981 0%, #06b6d4 100%)",
456
- value: data?.collectionCounts?.["orders"] || 0,
459
+ value: analyticsData?.totalOrders !== undefined ? analyticsData.totalOrders : (data?.collectionCounts?.["orders"] || 0),
457
460
  label: "Orders",
458
461
  subtext: "Total orders placed",
459
- trend: { value: "+8 today", up: true },
460
462
  },
461
463
  {
462
464
  icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg>,
@@ -464,7 +466,6 @@ export const DashboardMetrics: React.FC<DashboardMetricsProps> = ({ isEcommerce
464
466
  value: data?.collectionCounts?.["customers"] || 0,
465
467
  label: "Customers",
466
468
  subtext: "Registered shoppers",
467
- trend: { value: "+5 this week", up: true },
468
469
  },
469
470
  {
470
471
  icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" /></svg>,
@@ -475,7 +476,26 @@ export const DashboardMetrics: React.FC<DashboardMetricsProps> = ({ isEcommerce
475
476
  },
476
477
  ];
477
478
 
478
- const cmsCards = [
479
+ // Dynamically extract top active custom collections if present
480
+ const activeCustomCollections = data?.collectionCounts
481
+ ? Object.entries(data.collectionCounts)
482
+ .filter(([slug, count]) => count > 0 && !["users", "audit_logs", "media"].includes(slug))
483
+ .sort((a, b) => b[1] - a[1])
484
+ .slice(0, 3)
485
+ : [];
486
+
487
+ const dynamicCollectionCards = activeCustomCollections.map(([slug, count]) => {
488
+ const formattedLabel = slug.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
489
+ return {
490
+ icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>,
491
+ gradient: "linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)",
492
+ value: count,
493
+ label: formattedLabel,
494
+ subtext: "Active entries",
495
+ };
496
+ });
497
+
498
+ const baseCmsCards = [
479
499
  {
480
500
  icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>,
481
501
  gradient: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
@@ -490,6 +510,9 @@ export const DashboardMetrics: React.FC<DashboardMetricsProps> = ({ isEcommerce
490
510
  label: "Media Files",
491
511
  subtext: "Images, videos & docs",
492
512
  },
513
+ ];
514
+
515
+ const trailingCmsCards = [
493
516
  {
494
517
  icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>,
495
518
  gradient: "linear-gradient(135deg, #0ea5e9 0%, #06b6d4 100%)",
@@ -497,22 +520,14 @@ export const DashboardMetrics: React.FC<DashboardMetricsProps> = ({ isEcommerce
497
520
  label: "Team Members",
498
521
  subtext: "Active user accounts",
499
522
  },
500
- {
501
- icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>,
502
- gradient: "linear-gradient(135deg, #10b981 0%, #06b6d4 100%)",
503
- value: data?.totalWebhooks || 0,
504
- label: "Webhooks",
505
- subtext: "Active integrations",
506
- },
507
- {
508
- icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>,
509
- gradient: "linear-gradient(135deg, #f59e0b 0%, #f97316 100%)",
510
- value: data?.totalApiKeys || 0,
511
- label: "API Keys",
512
- subtext: "Developer access tokens",
513
- },
514
523
  ];
515
524
 
525
+ const cmsCards = [
526
+ ...baseCmsCards,
527
+ ...dynamicCollectionCards,
528
+ ...trailingCmsCards,
529
+ ].slice(0, 5);
530
+
516
531
  const cards = isEcommerce ? ecommerceCards : cmsCards;
517
532
 
518
533
  return (
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import type { Field } from "@kyro-cms/core/client";
3
3
  import RelationshipField from "./RelationshipField";
4
4
  import { ChevronDown, ChevronUp, GripVertical } from "../ui/icons";
5
+ import { apiGet } from "../../lib/api";
5
6
  import {
6
7
  DndContext,
7
8
  closestCenter,
@@ -20,6 +21,32 @@ import { useTranslation } from "react-i18next";
20
21
 
21
22
  const SIMPLE_TYPES = new Set(["text", "number", "checkbox", "select", "radio", "color", "email", "password"]);
22
23
 
24
+ function extractLabelFromObj(obj: unknown): string | null {
25
+ if (!obj || typeof obj !== "object") return null;
26
+ const o = obj as Record<string, unknown>;
27
+ const tabs = o.tabs as Record<string, unknown> | undefined;
28
+ const label =
29
+ (o.title as string) ||
30
+ (tabs?.title as string) ||
31
+ (o.name as string) ||
32
+ (o.label as string) ||
33
+ (o.email as string) ||
34
+ (o.filename as string) ||
35
+ (o.slug as string);
36
+ if (label && typeof label === "string") return label;
37
+ if (o.value && typeof o.value === "object") return extractLabelFromObj(o.value);
38
+ if (o.doc && typeof o.doc === "object") return extractLabelFromObj(o.doc);
39
+ return null;
40
+ }
41
+
42
+ function getFallbackLabel(field: Field, index: number): string {
43
+ const rawLabel = field.label || field.name || "Item";
44
+ if (/item$/i.test(rawLabel.trim())) {
45
+ return `${rawLabel} ${index + 1}`;
46
+ }
47
+ return `${rawLabel} Item ${index + 1}`;
48
+ }
49
+
23
50
  interface ArrayLayoutProps {
24
51
  field: Field;
25
52
  value: unknown[];
@@ -148,7 +175,7 @@ function SortableArrayItem({
148
175
 
149
176
  <div className="flex-1 min-w-0">
150
177
  <span className="text-xs font-medium text-[var(--kyro-text-primary)] truncate block">
151
- {getItemLabel(item) || `${field.label || field.name} Item`}
178
+ {getItemLabel(item) || getFallbackLabel(field, index)}
152
179
  </span>
153
180
  </div>
154
181
 
@@ -197,11 +224,13 @@ export function ArrayLayout({
197
224
  disabled,
198
225
  }: ArrayLayoutProps) {
199
226
  const items = (Array.isArray(value) ? value : []) as Record<string, unknown>[];
200
- const fields = (field as Field & { fields?: { name?: string; type?: string; relationTo?: string; label?: string }[] }).fields || [];
227
+ const fields = (field as Field & { fields?: { name?: string; type?: string; relationTo?: string | string[]; label?: string }[] }).fields || [];
201
228
  const firstField = fields[0];
202
229
  const labelField = firstField?.name || "user";
203
230
  const isRelationship = firstField?.type === "relationship";
204
231
  const [openIndex, setOpenIndex] = React.useState<number | null>(0);
232
+ const [relLabels, setRelLabels] = React.useState<Record<string, string>>({});
233
+ const fetchedRelIds = React.useRef<Set<string>>(new Set());
205
234
 
206
235
  // Sync stable IDs and heal bad data
207
236
  React.useEffect(() => {
@@ -229,27 +258,117 @@ export function ArrayLayout({
229
258
  }
230
259
  }, [value, onChange]);
231
260
 
261
+ React.useEffect(() => {
262
+ const fieldsTyped = (field as Field & { fields?: Field[] }).fields || [];
263
+ items.forEach((item) => {
264
+ if (!item || typeof item !== "object") return;
265
+ fieldsTyped.forEach((f) => {
266
+ if (f.type === "relationship" && f.name) {
267
+ const val = item[f.name];
268
+ if (!val) return;
269
+
270
+ let id = "";
271
+ let rel = Array.isArray(f.relationTo) ? f.relationTo[0] : f.relationTo;
272
+
273
+ if (typeof val === "string") {
274
+ id = val;
275
+ } else if (typeof val === "object" && val !== null) {
276
+ const vObj = val as Record<string, unknown>;
277
+ if (vObj.relationTo && typeof vObj.relationTo === "string") {
278
+ rel = vObj.relationTo;
279
+ }
280
+ if (vObj.id && typeof vObj.id === "string") {
281
+ id = vObj.id;
282
+ } else if (vObj.value && typeof vObj.value === "string") {
283
+ id = vObj.value;
284
+ }
285
+ }
286
+
287
+ if (id && rel && !fetchedRelIds.current.has(id)) {
288
+ fetchedRelIds.current.add(id);
289
+ apiGet<Record<string, unknown>>(`/api/${rel}/${id}`)
290
+ .then((res) => {
291
+ const doc = (res as any)?.data || res;
292
+ const label = extractLabelFromObj(doc);
293
+ if (label) {
294
+ setRelLabels((prev) => ({ ...prev, [id]: label }));
295
+ }
296
+ })
297
+ .catch(() => {});
298
+ }
299
+ }
300
+ });
301
+ });
302
+ }, [items, field]);
303
+
232
304
  function getItemLabel(item: Record<string, unknown>): string {
233
- for (const key of ["label", "title", "name"]) {
305
+ for (const key of ["label", "title", "name", "externalUrl", "url"]) {
234
306
  const val = item[key];
235
- if (val && typeof val === "string") return val;
307
+ if (val && typeof val === "string" && val.trim() !== "") return val;
236
308
  }
237
- const fieldsTyped = fields as Field[];
309
+
310
+ const fieldsTyped = (field as Field & { fields?: Field[] }).fields || [];
311
+ const DISCRIMINATOR_NAMES = new Set(["linkType", "type", "blockType", "kind", "mode"]);
312
+
238
313
  for (const f of fieldsTyped) {
314
+ if (!f.name) continue;
315
+ const val = item[f.name];
316
+ if (!val) continue;
317
+
239
318
  if (f.type === "text" || f.type === "textarea") {
240
- const val = item[f.name!];
241
- if (val && typeof val === "string") return val;
319
+ if (typeof val === "string" && val.trim() !== "") return val;
242
320
  }
321
+
322
+ if (f.type === "select" && !DISCRIMINATOR_NAMES.has(f.name)) {
323
+ if (typeof val === "string" && val.trim() !== "") return val;
324
+ }
325
+
326
+ if (f.type === "relationship") {
327
+ const directLabel = extractLabelFromObj(val);
328
+ if (directLabel) return directLabel;
329
+
330
+ let id = "";
331
+ if (typeof val === "string") {
332
+ id = val;
333
+ } else if (typeof val === "object" && val !== null) {
334
+ const vObj = val as Record<string, unknown>;
335
+ if (vObj.id && typeof vObj.id === "string") id = vObj.id;
336
+ else if (vObj.value && typeof vObj.value === "string") id = vObj.value;
337
+ }
338
+ if (id && relLabels[id]) {
339
+ return relLabels[id];
340
+ }
341
+ }
342
+
243
343
  if (f.type === "group" && (f as Field & { fields?: Field[] }).fields) {
244
- for (const inner of (f as Field & { fields?: Field[] }).fields || []) {
245
- if (inner.type === "text" || inner.type === "textarea") {
246
- const group = item[f.name!] as Record<string, unknown> | undefined;
247
- const val = group?.[inner.name!];
248
- if (val && typeof val === "string") return val;
344
+ const groupObj = val as Record<string, unknown> | undefined;
345
+ if (groupObj && typeof groupObj === "object") {
346
+ for (const inner of (f as Field & { fields?: Field[] }).fields || []) {
347
+ if (!inner.name) continue;
348
+ const innerVal = groupObj[inner.name];
349
+ if (inner.type === "text" || inner.type === "textarea") {
350
+ if (typeof innerVal === "string" && innerVal.trim() !== "") return innerVal;
351
+ }
352
+ if (inner.type === "select" && !DISCRIMINATOR_NAMES.has(inner.name)) {
353
+ if (typeof innerVal === "string" && innerVal.trim() !== "") return innerVal;
354
+ }
355
+ if (inner.type === "relationship") {
356
+ const directLabel = extractLabelFromObj(innerVal);
357
+ if (directLabel) return directLabel;
358
+ let id = "";
359
+ if (typeof innerVal === "string") id = innerVal;
360
+ else if (typeof innerVal === "object" && innerVal !== null) {
361
+ const vObj = innerVal as Record<string, unknown>;
362
+ if (vObj.id && typeof vObj.id === "string") id = vObj.id;
363
+ else if (vObj.value && typeof vObj.value === "string") id = vObj.value;
364
+ }
365
+ if (id && relLabels[id]) return relLabels[id];
366
+ }
249
367
  }
250
368
  }
251
369
  }
252
370
  }
371
+
253
372
  return "";
254
373
  }
255
374
 
@@ -326,14 +326,14 @@ export function RelationshipField({
326
326
  items={tagIds}
327
327
  strategy={horizontalListSortingStrategy}
328
328
  >
329
- {items.map((item) => {
329
+ {items.map((item, idx) => {
330
330
  const rawId = getValueId(item);
331
331
  const doc = selectedDocs.find((d) => d.id === rawId);
332
- const label = doc ? getLabel(doc) : rawId.slice(0, 12);
332
+ const label = doc ? getLabel(doc) : (rawId ? rawId.slice(0, 12) : "Item");
333
333
  const rel = isPolymorphic && doc ? doc.relationTo : null;
334
334
  return (
335
335
  <SortableTag
336
- key={rawId}
336
+ key={`${rawId}-${idx}`}
337
337
  id={rawId}
338
338
  label={label}
339
339
  relation={rel}
@@ -353,14 +353,14 @@ export function RelationshipField({
353
353
 
354
354
  return (
355
355
  <div className="flex flex-wrap gap-1.5 mt-2">
356
- {items.map((item) => {
356
+ {items.map((item, idx) => {
357
357
  const rawId = getValueId(item);
358
358
  const doc = selectedDocs.find((d) => d.id === rawId);
359
- const label = doc ? getLabel(doc) : rawId.slice(0, 12);
359
+ const label = doc ? getLabel(doc) : (rawId ? rawId.slice(0, 12) : "Item");
360
360
  const rel = isPolymorphic && doc ? doc.relationTo : null;
361
361
  return (
362
362
  <span
363
- key={rawId}
363
+ key={`${rawId}-${idx}`}
364
364
  className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-md bg-[var(--kyro-sidebar-active)]/10 text-[var(--kyro-sidebar-active)]"
365
365
  >
366
366
  {rel && <span className="opacity-60 mr-0.5">{rel}:</span>}
@@ -53,7 +53,11 @@ export function TabsLayout({
53
53
  </div>
54
54
  <div className="space-y-6">
55
55
  {currentTab?.fields.map((f: Field) =>
56
- renderField(f, tabData, onTabDataChange),
56
+ renderField(
57
+ f,
58
+ field.name ? tabData : formData,
59
+ field.name ? onTabDataChange : undefined as any,
60
+ ),
57
61
  )}
58
62
  </div>
59
63
 
@@ -447,9 +447,17 @@ const persistBrowserDraft = useCallback(
447
447
  const titleStr = titleValue ? String(titleValue) : "";
448
448
 
449
449
  if (titleStr && (!formData.metaTitle || formData.metaTitle === formData._lastMetaTitle)) {
450
- setField("metaTitle", titleStr);
450
+ if (formData.metaTitle !== titleStr) {
451
+ useAutoFormStore.setState((state) => ({
452
+ formData: {
453
+ ...state.formData,
454
+ metaTitle: titleStr,
455
+ _lastMetaTitle: titleStr,
456
+ },
457
+ }));
458
+ }
451
459
  }
452
- }, [formData, config?.fields, setField]);
460
+ }, [formData.title, config?.fields]);
453
461
 
454
462
  interface FieldConfig {
455
463
  name?: string;
@@ -471,13 +479,18 @@ const persistBrowserDraft = useCallback(
471
479
 
472
480
  const sourceValue = resolveFieldValue(fields, formData, sourceField);
473
481
 
474
- if (isSlugLocked && typeof sourceValue === "string") {
482
+ if (isSlugLocked && typeof sourceValue === "string" && sourceValue) {
475
483
  const newSlug = slugifyText(sourceValue);
476
- if (newSlug !== formData.slug) {
477
- setField("slug", newSlug);
484
+ if (newSlug && newSlug !== formData.slug) {
485
+ useAutoFormStore.setState((state) => ({
486
+ formData: {
487
+ ...state.formData,
488
+ slug: newSlug,
489
+ },
490
+ }));
478
491
  }
479
492
  }
480
- }, [formData, isSlugLocked, config?.fields, setField]);
493
+ }, [formData.title, isSlugLocked, config?.fields]);
481
494
 
482
495
  // Auto-save effect — only starts timers on keystroke-originated changes.
483
496
  // Local save fires after 1.5s of inactivity, server save after 8s.
@@ -150,7 +150,7 @@ export function kyroAdmin(options: KyroAdminOptions = {}): AstroIntegration {
150
150
  ];
151
151
  for (const col of (configResult.collections || [])) {
152
152
  if (col.seo) {
153
- const tabsField = col.fields?.find((f: any) => f.type === 'tabs' && f.name === 'tabs');
153
+ const tabsField = col.fields?.find((f: any) => f.type === 'tabs');
154
154
  if (tabsField?.tabs) {
155
155
  // Only add if not already injected (avoid duplicates on hot-reload)
156
156
  if (!tabsField.tabs.find((t: any) => t.label === 'SEO Settings')) {
@@ -258,6 +258,7 @@ export default debug;
258
258
  alias: {
259
259
  "kyro:config": resolvedConfig,
260
260
  },
261
+ dedupe: ["react", "react-dom"],
261
262
  },
262
263
  optimizeDeps: {
263
264
  include: [
@@ -211,6 +211,7 @@ export const useAutoFormStore = create<AutoFormStore>()(
211
211
 
212
212
  // Field update actions
213
213
  setField: (field: string, value: unknown) => {
214
+ if (!field || field === "undefined") return;
214
215
  const state = get();
215
216
  const newDirty = new Set(state.dirtyFields);
216
217
  // Mark dirty if value differs from last saved baseline (normalized to strip full media details)
@@ -223,18 +224,22 @@ export const useAutoFormStore = create<AutoFormStore>()(
223
224
  } else {
224
225
  newDirty.delete(field);
225
226
  }
227
+ const nextFormData = { ...state.formData };
228
+ delete nextFormData.undefined;
229
+ delete nextFormData["undefined"];
230
+ nextFormData[field] = value;
226
231
  set({
227
- formData: {
228
- ...state.formData,
229
- [field]: value,
230
- },
232
+ formData: nextFormData,
231
233
  dirtyFields: newDirty,
232
234
  hasUnsavedChanges: newDirty.size > 0,
233
235
  });
234
236
  },
235
237
 
236
238
  setFormData: (data: Record<string, unknown>) => {
237
- set({ formData: data });
239
+ const cleanData = { ...data };
240
+ delete cleanData.undefined;
241
+ delete cleanData["undefined"];
242
+ set({ formData: cleanData });
238
243
  },
239
244
 
240
245
  setNestedField: (path: string, value: unknown) => {
@@ -253,6 +258,8 @@ export const useAutoFormStore = create<AutoFormStore>()(
253
258
  }
254
259
 
255
260
  current[keys[keys.length - 1]] = value;
261
+ delete newFormData.undefined;
262
+ delete newFormData["undefined"];
256
263
 
257
264
  return { formData: newFormData };
258
265
  });
@@ -363,17 +370,24 @@ export const useAutoFormStore = create<AutoFormStore>()(
363
370
  data: Record<string, unknown>,
364
371
  lastSaved?: Record<string, unknown>,
365
372
  ) => {
373
+ const cleanData = { ...data };
374
+ delete cleanData.undefined;
375
+ delete cleanData["undefined"];
376
+ const cleanLastSaved = lastSaved ? { ...lastSaved } : cleanData;
377
+ delete cleanLastSaved.undefined;
378
+ delete cleanLastSaved["undefined"];
379
+
366
380
  const state = get();
367
381
  if (
368
- state.formData === data &&
369
- state.lastSavedData === (lastSaved || data) &&
382
+ state.formData === cleanData &&
383
+ state.lastSavedData === cleanLastSaved &&
370
384
  !state.hasUnsavedChanges
371
385
  ) {
372
386
  return;
373
387
  }
374
388
  set({
375
- formData: data,
376
- lastSavedData: lastSaved || data,
389
+ formData: cleanData,
390
+ lastSavedData: cleanLastSaved,
377
391
  hasUnsavedChanges: false,
378
392
  dirtyFields: new Set<string>(),
379
393
  });
package/src/lib/config.ts CHANGED
@@ -98,7 +98,7 @@ function updateFieldByPath(
98
98
  if (field.fields && Array.isArray(field.fields)) {
99
99
  return updateFieldByPath(field.fields, remainingPath, updates);
100
100
  }
101
-
101
+
102
102
  // For tabs fields, traverse into all tabs
103
103
  if (field.type === "tabs" && field.tabs && Array.isArray(field.tabs)) {
104
104
  for (const tab of field.tabs) {
@@ -116,7 +116,7 @@ function updateFieldByPath(
116
116
  const blockSlug = remainingPath.split(".")[0];
117
117
  const restOfPath = remainingPath.split(".").slice(1).join(".");
118
118
  if (!restOfPath) return false;
119
-
119
+
120
120
  for (const block of field.blocks) {
121
121
  if (block.slug === blockSlug && block.fields && Array.isArray(block.fields)) {
122
122
  return updateFieldByPath(block.fields, restOfPath, updates);
@@ -124,7 +124,7 @@ function updateFieldByPath(
124
124
  }
125
125
  return false;
126
126
  }
127
-
127
+
128
128
  // For array fields, look in the nested fields
129
129
  if (field.type === "array" && field.fields && Array.isArray(field.fields)) {
130
130
  return updateFieldByPath(field.fields, remainingPath, updates);
@@ -136,9 +136,10 @@ function updateFieldByPath(
136
136
  return true;
137
137
  }
138
138
  }
139
-
140
- // If it's a structural field (like tabs, row, or collapsible) that we should pass through implicitly,
141
- // we must traverse it even if it has a name, because these fields do not create nested data objects.
139
+ }
140
+
141
+ // Pass through structural flat wrappers (unnamed tabs, rows, collapsibles)
142
+ for (const field of fields) {
142
143
  const isFlatStructuralField =
143
144
  !field.name ||
144
145
  field.type === "tabs" ||
@@ -158,6 +159,17 @@ function updateFieldByPath(
158
159
  }
159
160
  }
160
161
  }
162
+
163
+ // Target field does not exist in this container.
164
+ // If there is no remaining path, this is the terminal target container — append the new field here!
165
+ if (!remainingPath) {
166
+ fields.push({
167
+ name: currentPart,
168
+ ...updates,
169
+ });
170
+ return true;
171
+ }
172
+
161
173
  return false;
162
174
  }
163
175
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  import AdminLayout from "../layouts/AdminLayout.astro";
3
- import i18next from '../lib/i18n';
3
+ import i18next from "../lib/i18n";
4
4
  import { collections } from "../lib/config";
5
5
  const authCollections = ["users", "audit_logs"];
6
6
  import { adminPath, apiPath } from "../lib/paths";
@@ -13,7 +13,7 @@ const authItems = authCollections.map((slug) => ({
13
13
  }));
14
14
  ---
15
15
 
16
- <AdminLayout title={i18next.t("pages." + "Dashboard".replace(/ /g, ""), { defaultValue: "Dashboard" })}>
16
+ <AdminLayout title={i18next.t("pages." + "Dashboard".replace(/ /g, ""), { defaultValue: "Dashboard" })}>
17
17
  <div class="flex-1 overflow-y-auto space-y-8">
18
18
  <!-- Header -->
19
19
  <div class="surface-tile p-6 flex items-center justify-between gap-8">
@@ -31,7 +31,7 @@ const authItems = authCollections.map((slug) => ({
31
31
  type="text"
32
32
  id="header-search-input"
33
33
  placeholder="Search anything..."
34
- class="w-full bg-[var(--kyro-surface-accent)] border border-transparent rounded-2xl py-3 pl-16 pr-8 text-lg font-medium focus:outline-none focus:bg-[var(--kyro-surface)] focus:border-[var(--kyro-border)] transition-all shadow-inner text-[var(--kyro-text-primary)] placeholder-[var(--kyro-text-muted)]"
34
+ class="w-full bg-[var(--kyro-surface-accent)] border border-transparent rounded-2xl py-3 pl-16 pr-8 text-md font-medium focus:outline-none focus:bg-[var(--kyro-surface)] focus:border-[var(--kyro-border)] transition-all shadow-inner text-[var(--kyro-text-primary)] placeholder-[var(--kyro-text-muted)]"
35
35
  autocomplete="off"
36
36
  />
37
37
  <div
@@ -51,8 +51,8 @@ const authItems = authCollections.map((slug) => ({
51
51
  </div>
52
52
 
53
53
  <!-- CMS Metrics -->
54
- <DashboardMetrics client:load isEcommerce={!!collections['orders'] || !!collections['products']} />
55
- {(!!collections['orders'] || !!collections['products']) && <RevenueChart client:load />}
54
+ <DashboardMetrics client:load isEcommerce={!!collections["orders"] || !!collections["products"]} />
55
+ {(!!collections["orders"] || !!collections["products"]) && <RevenueChart client:load />}
56
56
 
57
57
  <!-- Quick Links Section -->
58
58
  <div class="surface-tile overflow-hidden mt-8">