diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index ff5af9b..ebdc4e2 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -7,6 +7,7 @@ import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1e import { default as default_d9c22b951ac6762f436db59b889da382 } from '@/components/admin/awards/CommendationColorField' import { default as default_c3ea68702b5e5f8f2f98055e01c43c8d } from '@/components/admin/awards/RibbonDesigner' import { default as default_2f2033acccb3be245f1a85f7b4271dac } from '@/components/admin/awards/MedalDesigner' +import { default as default_4b88a624e9f0b376cffcc6814d459adb } from '@/components/admin/assignments/AssignmentMembersField' import { default as default_0edde40a6ccd30c893b1227360f69360 } from '@/components/static/TickDurationDescription' import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/components/static/WarningMessage' import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor' @@ -32,6 +33,7 @@ export const importMap = { "@/components/admin/awards/CommendationColorField#default": default_d9c22b951ac6762f436db59b889da382, "@/components/admin/awards/RibbonDesigner#default": default_c3ea68702b5e5f8f2f98055e01c43c8d, "@/components/admin/awards/MedalDesigner#default": default_2f2033acccb3be245f1a85f7b4271dac, + "@/components/admin/assignments/AssignmentMembersField#default": default_4b88a624e9f0b376cffcc6814d459adb, "@/components/static/TickDurationDescription#default": default_0edde40a6ccd30c893b1227360f69360, "@/components/static/WarningMessage#default": default_2d468cc5cf0b647ffca3201592c20825, "@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759, diff --git a/src/app/(payload)/custom.scss b/src/app/(payload)/custom.scss index ead5dda..03106d9 100644 --- a/src/app/(payload)/custom.scss +++ b/src/app/(payload)/custom.scss @@ -22,4 +22,4 @@ @layer utilities { @import 'tailwindcss/utilities.css'; -} \ No newline at end of file +} diff --git a/src/collections/users/Assignments.ts b/src/collections/users/Assignments.ts index e4d84c8..e76b463 100644 --- a/src/collections/users/Assignments.ts +++ b/src/collections/users/Assignments.ts @@ -88,6 +88,9 @@ export const Assignments: CollectionConfig = { relationTo: "users", hasMany: true, admin: { + components: { + Field: "@/components/admin/assignments/AssignmentMembersField", + }, description: "Add individuals as members of this team/assignment.", }, }, diff --git a/src/components/admin/assignments/AssignmentMembersField.tsx b/src/components/admin/assignments/AssignmentMembersField.tsx new file mode 100644 index 0000000..a75b99c --- /dev/null +++ b/src/components/admin/assignments/AssignmentMembersField.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { + ConfirmationModal, + RelationshipInput, + toast, + useDocumentInfo, + useField, + useModal, +} from "@payloadcms/ui"; +import type { RelationshipFieldClientComponent } from "payload"; +import { useCallback, useId, useMemo, useState } from "react"; + +type ExistingAssignment = { + id: number; + name: string; +}; + +type PendingSelection = { + value: Array<{ relationTo: string; value: number | string }>; + conflicts: ExistingAssignment[]; +}; + +const getId = (value: number | string | { value?: number | string }) => + typeof value === "object" ? value.value : value; + +/** + * Keeps Payload's relationship picker, but asks before putting an operator in + * more than one assignment. “Replace” updates existing assignments through + * the admin API; “Add as new” only changes this unsaved assignment form. + */ +const AssignmentMembersField: RelationshipFieldClientComponent = (props) => { + const { field, path: pathFromProps, readOnly, validate } = props; + const { id: documentId } = useDocumentInfo(); + const { openModal } = useModal(); + const modalSlug = useId().replaceAll(":", ""); + const { + customComponents: { AfterInput, BeforeInput, Description, Error: ErrorComponent, Label } = {}, + disabled, + filterOptions, + initialValue, + path, + setValue, + showError, + value: currentValue, + } = useField>({ + potentiallyStalePath: pathFromProps, + // RelationshipInput performs the field validation itself. Its validation + // type is intentionally broader than useField's internal callback type. + }); + const [pending, setPending] = useState(null); + + const { + admin: { + allowCreate = true, + allowEdit = true, + appearance = "select", + className, + description, + isSortable = true, + placeholder, + sortOptions, + } = {}, + hasMany, + label, + localized, + relationTo, + required, + } = field; + + const relation = typeof relationTo === "string" ? relationTo : relationTo[0]; + const selectedIds = useMemo( + () => + Array.isArray(currentValue) ? currentValue.map((item) => getId(item)).filter(Boolean) : [], + [currentValue], + ); + + const applyValue = useCallback( + (next: Array<{ relationTo: string; value: number | string }>) => { + setValue(next.map((item) => item.value)); + }, + [setValue], + ); + + const handleChange = useCallback( + async (next: Array<{ relationTo: string; value: number | string }>) => { + const added = next.filter((item) => !selectedIds.includes(item.value)); + if (added.length === 0) { + applyValue(next); + return; + } + + const responses = await Promise.all( + added.map(async (item) => { + const query = new URLSearchParams({ + depth: "0", + limit: "100", + "where[or][0][members][contains]": String(item.value), + "where[or][1][leader][equals]": String(item.value), + }); + const response = await fetch(`/api/assignments?${query.toString()}`); + if (!response.ok) throw new Error("Could not check existing assignments."); + return (await response.json()).docs as ExistingAssignment[]; + }), + ); + const conflicts = responses.flat().filter((assignment, index, assignments) => { + if (String(assignment.id) === String(documentId)) return false; + return assignments.findIndex((item) => item.id === assignment.id) === index; + }); + + if (conflicts.length > 0) { + setPending({ value: next, conflicts }); + openModal(modalSlug); + } else { + applyValue(next); + } + }, + [applyValue, documentId, modalSlug, openModal, selectedIds], + ); + + const replaceAssignments = async () => { + if (!pending) return; + try { + for (const assignment of pending.conflicts) { + const response = await fetch(`/api/assignments/${assignment.id}?depth=0`); + if (!response.ok) throw new Error("Could not load an existing assignment."); + const existing = await response.json(); + const members = (existing.members ?? []) + .map((member: number | { id?: number }) => + typeof member === "object" ? member.id : member, + ) + .filter( + (member: number | undefined) => + member !== undefined && + !pending.value.some((item) => String(item.value) === String(member)), + ); + const leaderId = + typeof existing.leader === "object" ? existing.leader?.id : existing.leader; + const update = await fetch(`/api/assignments/${assignment.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + members, + ...(pending.value.some((item) => String(item.value) === String(leaderId)) + ? { leader: null } + : {}), + }), + }); + if (!update.ok) throw new Error(`Could not remove the operator from ${assignment.name}.`); + } + applyValue(pending.value); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Could not replace the existing assignment.", + ); + } + setPending(null); + }; + + return ( + <> + ({ relationTo: relation, value })) + : undefined + } + value={ + Array.isArray(currentValue) + ? currentValue.map((value) => ({ relationTo: relation, value })) + : null + } + onChange={handleChange} + /> + + +

+ This operator is already assigned to{" "} + {pending?.conflicts.length === 1 ? "an assignment" : "these assignments"}: +

+
    + {pending?.conflicts.map((assignment) => ( +
  • {assignment.name}
  • + ))} +
+

+ Replace the existing assignment, or add this operator to the new assignment as well? +

+ + } + cancelLabel="Add as new" + confirmingLabel="Replacing..." + confirmLabel="Replace existing" + heading="Operator already assigned" + modalSlug={modalSlug} + onCancel={() => { + if (pending) applyValue(pending.value); + setPending(null); + }} + onConfirm={replaceAssignments} + /> + + ); +}; + +export default AssignmentMembersField;