407 lines
13 KiB
TypeScript
407 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useTranslations } from "next-intl";
|
|
import { fetchContentList, fetchCollections } from "@/lib/api";
|
|
import type { FieldDefinition } from "@/lib/api";
|
|
import { getOptionLabel } from "@/lib/referenceOptionLabel";
|
|
import {
|
|
SearchableSelect,
|
|
type SearchableSelectOption,
|
|
} from "./SearchableSelect";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
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 t = useTranslations("ReferenceArrayField");
|
|
const schemaCollections = getCollections(def);
|
|
const singleCollection =
|
|
schemaCollections.length === 1 ? schemaCollections[0] : null;
|
|
const multipleCollections =
|
|
schemaCollections.length > 1 ? schemaCollections : [];
|
|
|
|
/** Normalise to string[]: API may return resolved refs as objects with _slug. */
|
|
function toSlugList(raw: unknown): string[] {
|
|
if (Array.isArray(raw)) {
|
|
return raw
|
|
.map((v) =>
|
|
typeof v === "object" && v !== null && "_slug" in v
|
|
? String((v as { _slug?: string })._slug ?? "")
|
|
: typeof v === "string"
|
|
? v.trim()
|
|
: "",
|
|
)
|
|
.filter(Boolean);
|
|
}
|
|
if (typeof raw === "string") {
|
|
return raw
|
|
.split(/[\n,]+/)
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
}
|
|
return [];
|
|
}
|
|
const valueList: string[] = toSlugList(value);
|
|
|
|
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);
|
|
};
|
|
|
|
/** Options for the "add existing" SearchableSelect: full label, exclude already selected. */
|
|
const addSelectOptions: SearchableSelectOption[] =
|
|
multipleCollections.length > 1 && multiQueries.data
|
|
? multiQueries.data.flatMap(({ collection: coll, items: list }) =>
|
|
(list as Record<string, unknown>[])
|
|
.filter((i) => !valueList.includes(String(i._slug ?? "")))
|
|
.map((i) => ({
|
|
value: `${coll}:${i._slug ?? ""}`,
|
|
label: getOptionLabel(i),
|
|
}))
|
|
)
|
|
: ((singleQuery.data?.items ?? []) as Record<string, unknown>[])
|
|
.filter((i) => !valueList.includes(String(i._slug ?? "")))
|
|
.map((i) => ({
|
|
value: String(i._slug ?? ""),
|
|
label: getOptionLabel(i),
|
|
}));
|
|
|
|
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">
|
|
{label}
|
|
{schemaCollections.length > 0 ? (
|
|
<p className="mt-0.5 text-xs text-gray-500">
|
|
{schemaCollections.length === 1
|
|
? t("typeLabel", { collection: schemaCollections[0] })
|
|
: t("typesLabel", { collections: schemaCollections.join(", ") })}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Selected entries */}
|
|
<ul className="mb-2 space-y-1.5">
|
|
{valueList.map((slug, index) => {
|
|
const collForSlug =
|
|
options.find((o) => o.slug === slug)?.collection ??
|
|
effectiveCollection ??
|
|
null;
|
|
return (
|
|
<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">
|
|
{collForSlug && (
|
|
<Link
|
|
href={`/content/${collForSlug}/${encodeURIComponent(slug)}${locale ? `?_locale=${locale}` : ""}`}
|
|
className="rounded p-1 text-accent-700 hover:bg-accent-50 hover:text-accent-900"
|
|
title="Open entry"
|
|
>
|
|
→
|
|
</Link>
|
|
)}
|
|
<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={t("moveUp")}
|
|
aria-label={t("moveUp")}
|
|
>
|
|
<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={t("moveDown")}
|
|
aria-label={t("moveDown")}
|
|
>
|
|
<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={t("remove")}
|
|
aria-label={t("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">
|
|
{t("componentType")}
|
|
</label>
|
|
<Select value={pickedCollection} onValueChange={setPickedCollection}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder={t("selectType")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableCollections.map((c) => (
|
|
<SelectItem key={c} value={c}>{c}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</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">
|
|
<div className="min-w-0 flex-1">
|
|
<SearchableSelect
|
|
value=""
|
|
onChange={(v) => v && addFromOption(v)}
|
|
options={addSelectOptions}
|
|
placeholder={t("selectFromExisting")}
|
|
clearable={false}
|
|
filterPlaceholder={t("filterPlaceholder")}
|
|
emptyLabel={t("emptyLabel")}
|
|
disabled={isLoading}
|
|
ariaLabel={t("selectExistingAriaLabel")}
|
|
/>
|
|
</div>
|
|
{collectionsForNew.length > 0 ? (
|
|
<span className="flex shrink-0 flex-col gap-0.5">
|
|
{collectionsForNew.length === 1 ? (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link
|
|
href={
|
|
collectionsForNew[0]
|
|
? `/content/${collectionsForNew[0]}/new${locale ? `?_locale=${locale}` : ""}`
|
|
: "#"
|
|
}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
{t("newComponent", { collection: collectionsForNew[0] })}
|
|
</Link>
|
|
</Button>
|
|
) : (
|
|
<Select
|
|
value=""
|
|
onValueChange={(c) => {
|
|
if (c) window.open(
|
|
`/content/${c}/new${locale ? `?_locale=${locale}` : ""}`,
|
|
"_blank",
|
|
);
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder={t("createNewComponent")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{collectionsForNew.map((c) => (
|
|
<SelectItem key={c} value={c}>{c}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
<span className="text-xs text-gray-500">
|
|
{t("openInNewTab")}
|
|
</span>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{schemaCollections.length === 0 && availableCollections.length === 0 ? (
|
|
<p className="text-xs text-amber-600">
|
|
{t("noCollection", {
|
|
collectionCode: "items.collection",
|
|
collectionsCode: "items.collections",
|
|
})}
|
|
</p>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<p className="mt-1 text-sm text-red-600">
|
|
{String((error as { message?: string })?.message ?? error)}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|