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_d9c22b951ac6762f436db59b889da382 } from '@/components/admin/awards/CommendationColorField'
|
||||||
import { default as default_c3ea68702b5e5f8f2f98055e01c43c8d } from '@/components/admin/awards/RibbonDesigner'
|
import { default as default_c3ea68702b5e5f8f2f98055e01c43c8d } from '@/components/admin/awards/RibbonDesigner'
|
||||||
import { default as default_2f2033acccb3be245f1a85f7b4271dac } from '@/components/admin/awards/MedalDesigner'
|
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_0edde40a6ccd30c893b1227360f69360 } from '@/components/static/TickDurationDescription'
|
||||||
import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/components/static/WarningMessage'
|
import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/components/static/WarningMessage'
|
||||||
import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor'
|
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/CommendationColorField#default": default_d9c22b951ac6762f436db59b889da382,
|
||||||
"@/components/admin/awards/RibbonDesigner#default": default_c3ea68702b5e5f8f2f98055e01c43c8d,
|
"@/components/admin/awards/RibbonDesigner#default": default_c3ea68702b5e5f8f2f98055e01c43c8d,
|
||||||
"@/components/admin/awards/MedalDesigner#default": default_2f2033acccb3be245f1a85f7b4271dac,
|
"@/components/admin/awards/MedalDesigner#default": default_2f2033acccb3be245f1a85f7b4271dac,
|
||||||
|
"@/components/admin/assignments/AssignmentMembersField#default": default_4b88a624e9f0b376cffcc6814d459adb,
|
||||||
"@/components/static/TickDurationDescription#default": default_0edde40a6ccd30c893b1227360f69360,
|
"@/components/static/TickDurationDescription#default": default_0edde40a6ccd30c893b1227360f69360,
|
||||||
"@/components/static/WarningMessage#default": default_2d468cc5cf0b647ffca3201592c20825,
|
"@/components/static/WarningMessage#default": default_2d468cc5cf0b647ffca3201592c20825,
|
||||||
"@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759,
|
"@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759,
|
||||||
|
|
|
||||||
|
|
@ -22,4 +22,4 @@
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
@import 'tailwindcss/utilities.css';
|
@import 'tailwindcss/utilities.css';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,9 @@ export const Assignments: CollectionConfig = {
|
||||||
relationTo: "users",
|
relationTo: "users",
|
||||||
hasMany: true,
|
hasMany: true,
|
||||||
admin: {
|
admin: {
|
||||||
|
components: {
|
||||||
|
Field: "@/components/admin/assignments/AssignmentMembersField",
|
||||||
|
},
|
||||||
description: "Add individuals as members of this team/assignment.",
|
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