1
0
Fork 0

feat(assignments): add AssignmentMembersField component for managing assignment members

This commit is contained in:
Jason Fraley 2026-08-29 23:53:13 -04:00
parent a0c9fe9e8e
commit 66181ff51c
4 changed files with 237 additions and 1 deletions

View file

@ -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,

View file

@ -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.",
},
},

View file

@ -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<Array<number | string>>({
potentiallyStalePath: pathFromProps,
// RelationshipInput performs the field validation itself. Its validation
// type is intentionally broader than useField's internal callback type.
});
const [pending, setPending] = useState<PendingSelection | null>(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 (
<>
<RelationshipInput
AfterInput={AfterInput}
allowCreate={allowCreate}
allowEdit={allowEdit}
appearance={appearance}
BeforeInput={BeforeInput}
className={className}
Description={Description}
description={description}
Error={ErrorComponent}
filterOptions={filterOptions}
Label={Label}
label={label}
localized={localized}
maxResultsPerRequest={10}
isSortable={isSortable}
path={path}
placeholder={placeholder}
readOnly={readOnly || disabled}
relationTo={[relation]}
required={required}
showError={showError}
sortOptions={sortOptions}
hasMany={true}
initialValue={
Array.isArray(initialValue)
? initialValue.map((value) => ({ relationTo: relation, value }))
: undefined
}
value={
Array.isArray(currentValue)
? currentValue.map((value) => ({ relationTo: relation, value }))
: null
}
onChange={handleChange}
/>
<ConfirmationModal
body={
<>
<p>
This operator is already assigned to{" "}
{pending?.conflicts.length === 1 ? "an assignment" : "these assignments"}:
</p>
<ul>
{pending?.conflicts.map((assignment) => (
<li key={assignment.id}>{assignment.name}</li>
))}
</ul>
<p>
Replace the existing assignment, or add this operator to the new assignment as well?
</p>
</>
}
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;