feat(assignments): add AssignmentMembersField component for managing assignment members
This commit is contained in:
parent
a0c9fe9e8e
commit
66181ff51c
4 changed files with 237 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -22,4 +22,4 @@
|
|||
|
||||
@layer utilities {
|
||||
@import 'tailwindcss/utilities.css';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
231
src/components/admin/assignments/AssignmentMembersField.tsx
Normal file
231
src/components/admin/assignments/AssignmentMembersField.tsx
Normal 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;
|
||||
Loading…
Reference in a new issue