@kyro-cms/admin 0.11.2 → 0.11.4

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.11.2",
3
+ "version": "0.11.4",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -933,6 +933,10 @@ export function AutoForm({
933
933
  onChange={onFieldChange}
934
934
  error={error}
935
935
  disabled={effectiveDisabled}
936
+ formData={formData}
937
+ siblingData={currentData}
938
+ collectionSlug={collectionSlug}
939
+ globalSlug={globalSlug}
936
940
  />
937
941
  );
938
942
  }
@@ -25,6 +25,10 @@ interface FieldRendererProps {
25
25
  onChange: (value: any) => void;
26
26
  error?: string;
27
27
  disabled?: boolean;
28
+ formData?: Record<string, unknown>;
29
+ siblingData?: Record<string, unknown>;
30
+ collectionSlug?: string;
31
+ globalSlug?: string;
28
32
  }
29
33
 
30
34
  export const FieldRenderer: React.FC<FieldRendererProps> = ({
@@ -33,6 +37,10 @@ export const FieldRenderer: React.FC<FieldRendererProps> = ({
33
37
  onChange,
34
38
  error,
35
39
  disabled,
40
+ formData,
41
+ siblingData,
42
+ collectionSlug,
43
+ globalSlug,
36
44
  }) => {
37
45
  if (field.hidden === true || field.admin?.hidden === true) return null;
38
46
 
@@ -109,9 +117,13 @@ export const FieldRenderer: React.FC<FieldRendererProps> = ({
109
117
  <SelectField
110
118
  field={field as any}
111
119
  value={value}
112
- onChange={onChange}
113
- disabled={disabled}
120
+ onChange={onChangeKeystroke}
114
121
  error={error}
122
+ disabled={disabled}
123
+ formData={formData}
124
+ siblingData={siblingData}
125
+ collectionSlug={collectionSlug}
126
+ globalSlug={globalSlug}
115
127
  />
116
128
  );
117
129
  case "date":
@@ -244,6 +256,8 @@ export const FieldRenderer: React.FC<FieldRendererProps> = ({
244
256
  }}
245
257
  disabled={disabled}
246
258
  error={error}
259
+ collectionSlug={collectionSlug}
260
+ globalSlug={globalSlug}
247
261
  />
248
262
  );
249
263
  }}
@@ -638,8 +638,8 @@ export function ListView({
638
638
  {displayFields.map((field) => {
639
639
  const rawValue = extractFieldValue(doc, field);
640
640
  const cellValue =
641
- field.type === "select" && rawValue
642
- ? field.options?.find((o: any) => o.value === rawValue)
641
+ field.type === "select" && rawValue && Array.isArray(field.options)
642
+ ? field.options.find((o: any) => o.value === rawValue)
643
643
  ?.label || rawValue
644
644
  : formatCellValue(rawValue, field.type);
645
645
  return (
@@ -17,7 +17,7 @@ import {
17
17
  } from "@dnd-kit/sortable";
18
18
  import { CSS } from "@dnd-kit/utilities";
19
19
 
20
- const SIMPLE_TYPES = new Set(["text", "textarea", "number", "checkbox", "select", "radio", "color", "email", "password", "code", "markdown", "upload"]);
20
+ const SIMPLE_TYPES = new Set(["text", "number", "checkbox", "select", "radio", "color", "email", "password"]);
21
21
 
22
22
  interface ArrayLayoutProps {
23
23
  field: Field;
@@ -102,7 +102,7 @@ function SortableArrayItem({
102
102
  <span className="text-[10px] font-bold text-[var(--kyro-text-muted)] pt-2.5 min-w-[18px] text-center">
103
103
  {index + 1}
104
104
  </span>
105
- <div className="flex-1 flex items-start gap-1.5 min-w-0">
105
+ <div className={`flex-1 min-w-0 ${((field as any).fields || []).length >= 3 ? "flex flex-col gap-1.5" : "flex items-start gap-1.5"}`}>
106
106
  {((field as any).fields || []).map((f: Field) => (
107
107
  <div key={f.name} className="flex-1 min-w-0">
108
108
  {renderField(f, item, onChangeItem)}
@@ -1,6 +1,9 @@
1
+ import React, { useState, useEffect } from "react";
1
2
  import type { SelectField as SelectFieldType } from "@kyro-cms/core/client";
2
3
  import FieldLayout from "./FieldLayout";
3
4
  import { collections } from "../../lib/config";
5
+ import { fetchWithAuth } from "../../lib/api";
6
+ import { apiPath } from "../../lib/paths";
4
7
 
5
8
  interface SelectFieldComponentProps {
6
9
  field: SelectFieldType;
@@ -8,6 +11,10 @@ interface SelectFieldComponentProps {
8
11
  onChange?: (value: string | string[] | undefined) => void;
9
12
  error?: string;
10
13
  disabled?: boolean;
14
+ formData?: Record<string, unknown>;
15
+ siblingData?: Record<string, unknown>;
16
+ collectionSlug?: string;
17
+ globalSlug?: string;
11
18
  }
12
19
 
13
20
  export default function SelectField({
@@ -16,14 +23,71 @@ export default function SelectField({
16
23
  onChange,
17
24
  error,
18
25
  disabled,
26
+ formData,
27
+ siblingData,
28
+ collectionSlug,
29
+ globalSlug,
19
30
  }: SelectFieldComponentProps) {
20
31
  const isReadOnly =
21
32
  typeof field.admin?.readOnly === "function"
22
33
  ? false
23
34
  : Boolean(field.admin?.readOnly);
24
35
 
36
+ const [dynamicOptions, setDynamicOptions] = useState<Array<{ label: string; value: string }> | null>(null);
37
+
38
+ // Debounce stringify for formData to prevent infinite loops or excessive refetches
39
+ const formDataStr = JSON.stringify(formData || {});
40
+ const siblingDataStr = JSON.stringify(siblingData || {});
41
+
42
+ useEffect(() => {
43
+ if (field.options !== "__KYRO_DYNAMIC_OPTIONS__") return;
44
+
45
+ const fetchOptions = async () => {
46
+ try {
47
+ let url = "";
48
+ if (collectionSlug) {
49
+ url = `${apiPath}/${collectionSlug}/dynamic-options/${field.name}`;
50
+ } else if (globalSlug) {
51
+ url = `${apiPath}/globals/${globalSlug}/dynamic-options/${field.name}`;
52
+ } else {
53
+ return;
54
+ }
55
+
56
+ const res = await fetchWithAuth(url, {
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/json" },
59
+ body: JSON.stringify({
60
+ data: JSON.parse(formDataStr),
61
+ siblingData: JSON.parse(siblingDataStr)
62
+ }),
63
+ });
64
+
65
+ if (res.ok) {
66
+ const json = await res.json();
67
+ if (json.options) {
68
+ setDynamicOptions(json.options);
69
+ }
70
+ }
71
+ } catch (err) {
72
+ console.error("Failed to fetch dynamic options:", err);
73
+ }
74
+ };
75
+
76
+ const timer = setTimeout(fetchOptions, 300);
77
+ return () => clearTimeout(timer);
78
+ }, [formDataStr, siblingDataStr, field.name, field.options, collectionSlug, globalSlug]);
79
+
25
80
  // Resolve dynamic options at runtime if configured
26
- let options = field.options || [];
81
+ let options: Array<{ label: string; value: string }> = dynamicOptions || [];
82
+
83
+ if (field.options !== "__KYRO_DYNAMIC_OPTIONS__") {
84
+ if (typeof field.options === "function") {
85
+ options = field.options({ data: formData || {}, siblingData: siblingData || {} });
86
+ } else if (Array.isArray(field.options)) {
87
+ options = field.options;
88
+ }
89
+ }
90
+
27
91
  if (field.dynamicOptions === "collections") {
28
92
  options = Object.keys(collections)
29
93
  .filter((slug) => slug !== "media")
@@ -347,46 +347,6 @@ export function UploadField({
347
347
  );
348
348
  };
349
349
 
350
- if (value) {
351
- return (
352
- <div className="space-y-2">
353
- {isMultiple ? (
354
- <div className="grid grid-cols-2 gap-2">
355
- {currentValue.map((img: any, i: number) =>
356
- renderImagePreview(img, i),
357
- )}
358
- {canAddMore && (
359
- <button
360
- type="button"
361
- onClick={() => {
362
- setSelectedItems([]);
363
- setShowPicker(true);
364
- }}
365
- disabled={disabled}
366
- className="flex items-center justify-center h-12 border-2 border-dashed border-[var(--kyro-border)] rounded-lg text-sm text-[var(--kyro-text-secondary)] hover:border-[var(--kyro-border-active)] cursor-pointer transition-colors"
367
- >
368
- + Add {fieldLabel}
369
- </button>
370
- )}
371
- </div>
372
- ) : (
373
- renderImagePreview(value)
374
- )}
375
- <input
376
- ref={inputRef}
377
- type="file"
378
- accept="image/*"
379
- onChange={(e) => {
380
- const file = e.target.files?.[0];
381
- if (file) uploadFile(file);
382
- }}
383
- disabled={disabled}
384
- className="hidden"
385
- />
386
- </div>
387
- );
388
- }
389
-
390
350
  return (
391
351
  <div className="space-y-2 relative">
392
352
  <input
@@ -400,35 +360,46 @@ export function UploadField({
400
360
  disabled={disabled}
401
361
  className="hidden"
402
362
  />
403
- <div className="flex gap-2 flex-wrap">
404
- <button
405
- type="button"
406
- onClick={() => inputRef.current?.click()}
407
- disabled={disabled}
408
- className="px-3 py-1.5 text-xs font-semibold rounded border border-dashed border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] cursor-pointer hover:border-[var(--kyro-border-active)] transition-colors"
409
- >
410
- + Upload {fieldLabel}
411
- </button>
412
- <button
413
- type="button"
414
- onClick={() => {
415
- setSelectedItems([]);
416
- setShowPicker(true);
417
- }}
418
- disabled={disabled}
419
- className="px-3 py-1.5 text-xs font-semibold rounded border border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] cursor-pointer hover:border-[var(--kyro-border-active)] transition-colors"
420
- >
421
- Library
422
- </button>
423
- <button
424
- type="button"
425
- onClick={() => setShowUrlInput(!showUrlInput)}
426
- disabled={disabled}
427
- className="px-3 py-1.5 text-xs font-semibold rounded border border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] cursor-pointer hover:border-[var(--kyro-border-active)] transition-colors"
428
- >
429
- URL
430
- </button>
431
- </div>
363
+
364
+ {currentValue.length > 0 && (
365
+ <div className={isMultiple ? "grid grid-cols-2 gap-2" : "space-y-2"}>
366
+ {currentValue.map((img: any, i: number) =>
367
+ renderImagePreview(img, i),
368
+ )}
369
+ </div>
370
+ )}
371
+
372
+ {(!currentValue.length || canAddMore) && (
373
+ <div className="flex gap-2 flex-wrap">
374
+ <button
375
+ type="button"
376
+ onClick={() => inputRef.current?.click()}
377
+ disabled={disabled}
378
+ className="px-3 py-1.5 text-xs font-semibold rounded border border-dashed border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] cursor-pointer hover:border-[var(--kyro-border-active)] transition-colors"
379
+ >
380
+ + Upload {fieldLabel}
381
+ </button>
382
+ <button
383
+ type="button"
384
+ onClick={() => {
385
+ setSelectedItems([]);
386
+ setShowPicker(true);
387
+ }}
388
+ disabled={disabled}
389
+ className="px-3 py-1.5 text-xs font-semibold rounded border border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] cursor-pointer hover:border-[var(--kyro-border-active)] transition-colors"
390
+ >
391
+ Library
392
+ </button>
393
+ <button
394
+ type="button"
395
+ onClick={() => setShowUrlInput(!showUrlInput)}
396
+ disabled={disabled}
397
+ className="px-3 py-1.5 text-xs font-semibold rounded border border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] cursor-pointer hover:border-[var(--kyro-border-active)] transition-colors"
398
+ >
399
+ URL
400
+ </button>
401
+ </div>
402
+ )}
432
403
 
433
404
  {showUrlInput && (
434
405
  <div className="flex gap-2 items-center">
@@ -79,14 +79,17 @@ export function kyroAdmin(options: KyroAdminOptions = {}): AstroIntegration {
79
79
  import { parentPort } from 'worker_threads';
80
80
  import('${pathToFileURL(tmpFile).href}').then(mod => {
81
81
  const cfg = mod.default || mod;
82
- const serialize = (obj) => {
82
+ const serialize = (obj, key) => {
83
83
  if (obj === null || obj === undefined) return obj;
84
- if (typeof obj === 'function') return undefined;
85
- if (Array.isArray(obj)) return obj.map(serialize);
84
+ if (typeof obj === 'function') {
85
+ if (key === 'options') return "__KYRO_DYNAMIC_OPTIONS__";
86
+ return undefined;
87
+ }
88
+ if (Array.isArray(obj)) return obj.map((v) => serialize(v));
86
89
  if (typeof obj === 'object') {
87
90
  const result = {};
88
91
  for (const [k, v] of Object.entries(obj)) {
89
- const sv = serialize(v);
92
+ const sv = serialize(v, k);
90
93
  if (sv !== undefined) result[k] = sv;
91
94
  }
92
95
  return result;
@@ -25,7 +25,7 @@ export function normalizeUploadFields(value: unknown): unknown {
25
25
  const hasId = "id" in obj && (typeof obj.id === "string" || obj.id === null);
26
26
  const hasMediaField = "url" in obj && ("filename" in obj || "mimeType" in obj);
27
27
 
28
- if (hasId && hasMediaField && keys.length <= 8) {
28
+ if (hasId && hasMediaField && keys.length <= 25) {
29
29
  return obj.id;
30
30
  }
31
31