Files
rustycms/admin-ui/src/components/ReferenceArrayField.tsx

362 lines
12 KiB
TypeScript

"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { fetchContentList, fetchCollections } from "@/lib/api";
import type { FieldDefinition } from "@/lib/api";
type Props = {
name: string;
def: FieldDefinition;
value: string[] | string;
onChange: (value: string[]) => void;
required?: boolean;
error?: unknown;
locale?: string;
label: React.ReactNode;
};
/** Referenced collection(s) from schema: single collection or list for polymorphic. */
function getCollections(def: FieldDefinition): string[] {
const items = def.items as FieldDefinition | undefined;
if (!items) return [];
if (items.collection) return [items.collection];
if (Array.isArray(items.collections) && items.collections.length > 0)
return items.collections;
return [];
}
export function ReferenceArrayField({
name,
def,
value,
onChange,
required,
error,
locale,
label,
}: Props) {
const schemaCollections = getCollections(def);
const singleCollection =
schemaCollections.length === 1 ? schemaCollections[0] : null;
const multipleCollections =
schemaCollections.length > 1 ? schemaCollections : [];
const valueList: string[] = Array.isArray(value)
? value
: typeof value === "string"
? value
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean)
: [];
const [pickedCollection, setPickedCollection] = useState<string>(
singleCollection ?? multipleCollections[0] ?? "",
);
const { data: collectionsData } = useQuery({
queryKey: ["collections"],
queryFn: fetchCollections,
enabled: schemaCollections.length === 0,
});
const availableCollections =
schemaCollections.length > 0
? schemaCollections
: (collectionsData?.collections ?? [])
.map((c) => c.name)
.filter(
(n) =>
n !== "content_layout" && n !== "component_layout" && n !== "seo",
);
const effectiveCollection = singleCollection ?? pickedCollection;
const listParams = { _per_page: 200, ...(locale ? { _locale: locale } : {}) };
const singleQuery = useQuery({
queryKey: ["content", effectiveCollection, listParams],
queryFn: () => fetchContentList(effectiveCollection!, listParams),
enabled: !!effectiveCollection && schemaCollections.length <= 1,
});
const multiQueries = useQuery({
queryKey: ["content-multi", multipleCollections, listParams],
queryFn: async () => {
const results = await Promise.all(
multipleCollections.map((coll) => fetchContentList(coll, listParams)),
);
return multipleCollections.map((coll, i) => ({
collection: coll,
items: results[i]?.items ?? [],
}));
},
enabled: multipleCollections.length > 1,
});
type OptionItem = { slug: string; collection: string };
const options: OptionItem[] =
multipleCollections.length > 1 && multiQueries.data
? multiQueries.data.flatMap(({ collection: coll, items }) =>
(items as { _slug?: string }[])
.map((o) => ({
slug: String(o._slug ?? ""),
collection: coll,
}))
.filter((o) => o.slug),
)
: ((singleQuery.data?.items ?? []) as { _slug?: string }[])
.map((o) => ({
slug: String(o._slug ?? ""),
collection: effectiveCollection ?? "",
}))
.filter((o) => o.slug);
const isLoading =
schemaCollections.length <= 1
? singleQuery.isLoading
: multiQueries.isLoading;
const add = (slug: string) => {
if (!slug || valueList.includes(slug)) return;
onChange([...valueList, slug]);
};
const addFromOption = (optionValue: string) => {
const slug = optionValue.includes(":")
? optionValue.split(":")[1]!
: optionValue;
add(slug);
};
const remove = (index: number) => {
onChange(valueList.filter((_, i) => i !== index));
};
const move = (index: number, dir: number) => {
const next = index + dir;
if (next < 0 || next >= valueList.length) return;
const copy = [...valueList];
const tmp = copy[index];
copy[index] = copy[next];
copy[next] = tmp;
onChange(copy);
};
const collectionsForNew =
multipleCollections.length > 1
? multipleCollections
: effectiveCollection
? [effectiveCollection]
: [];
return (
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">{label}</span>
{schemaCollections.length > 0 ? (
<span className="text-xs text-gray-500">
{schemaCollections.length === 1
? `Typ: ${schemaCollections[0]}`
: `Typen: ${schemaCollections.join(", ")}`}
</span>
) : null}
</div>
{/* Selected entries */}
<ul className="mb-2 space-y-1.5">
{valueList.map((slug, index) => (
<li
key={`${slug}-${index}`}
className="flex items-center gap-2 rounded border border-gray-200 bg-gray-50 px-3 py-2 text-sm"
>
<span className="flex-1 font-mono text-gray-900">{slug}</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => move(index, -1)}
disabled={index === 0}
className="rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 disabled:opacity-40"
title="Move up"
aria-label="Move up"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 15l7-7 7 7"
/>
</svg>
</button>
<button
type="button"
onClick={() => move(index, 1)}
disabled={index === valueList.length - 1}
className="rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 disabled:opacity-40"
title="Move down"
aria-label="Move down"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<button
type="button"
onClick={() => remove(index)}
className="rounded p-1 text-red-600 hover:bg-red-50 hover:text-red-700"
title="Remove"
aria-label="Remove"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</li>
))}
</ul>
{/* Without schema collection: choose component type first */}
{!singleCollection &&
schemaCollections.length === 0 &&
availableCollections.length > 0 ? (
<div className="mb-2">
<label className="mb-1 block text-xs font-medium text-gray-500">
Component type
</label>
<select
value={pickedCollection}
onChange={(e) => setPickedCollection(e.target.value)}
className="block w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900"
>
<option value=""> Select type </option>
{availableCollections.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
) : null}
{/* Add: select from existing + create new component */}
{effectiveCollection || multipleCollections.length > 1 ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
<select
value=""
onChange={(e) => {
const v = e.target.value;
if (v) addFromOption(v);
e.target.value = "";
}}
className="block flex-1 rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900"
disabled={isLoading}
>
<option value=""> Select from existing </option>
{options.map((item) => {
const key =
multipleCollections.length > 1
? `${item.collection}:${item.slug}`
: item.slug;
const alreadySelected = valueList.includes(item.slug);
const label =
multipleCollections.length > 1
? `${item.slug} (${item.collection})`
: item.slug;
return (
<option key={key} value={key} disabled={alreadySelected}>
{alreadySelected ? `${label} (already selected)` : label}
</option>
);
})}
</select>
{collectionsForNew.length > 0 ? (
<span className="flex shrink-0 flex-col gap-0.5">
{collectionsForNew.length === 1 ? (
<Link
href={
collectionsForNew[0]
? `/content/${collectionsForNew[0]}/new${locale ? `?_locale=${locale}` : ""}`
: "#"
}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-1 rounded border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
<span aria-hidden>+</span> New {collectionsForNew[0]} component
</Link>
) : (
<select
className="rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900"
value=""
onChange={(e) => {
const c = e.target.value;
if (c)
window.open(
`/content/${c}/new${locale ? `?_locale=${locale}` : ""}`,
"_blank",
);
e.target.value = "";
}}
>
<option value="">+ Create new component</option>
{collectionsForNew.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
)}
<span className="text-xs text-gray-500">
Open in new tab; then reload this page.
</span>
</span>
) : null}
</div>
) : null}
{schemaCollections.length === 0 && availableCollections.length === 0 ? (
<p className="text-xs text-amber-600">
No reference collection in schema. Set{" "}
<code className="rounded bg-gray-100 px-1">items.collection</code> or{" "}
<code className="rounded bg-gray-100 px-1">items.collections</code>{" "}
in the type, or start the API and reload the page.
</p>
) : null}
{error ? (
<p className="mt-1 text-sm text-red-600">
{String((error as { message?: string })?.message ?? error)}
</p>
) : null}
</div>
);
}