Approval steps can now be added to approval tasks
This commit is contained in:
parent
f1da101e4e
commit
dca30cfca0
@ -4,6 +4,7 @@
|
|||||||
"Active": "Active",
|
"Active": "Active",
|
||||||
"ActivityNameTemplate": "Activity Name Template",
|
"ActivityNameTemplate": "Activity Name Template",
|
||||||
"Add": "Add",
|
"Add": "Add",
|
||||||
|
"AddWithValue": "Add {{value}}",
|
||||||
"AddDomain": "Add Domain",
|
"AddDomain": "Add Domain",
|
||||||
"AddTask": "Add Task",
|
"AddTask": "Add Task",
|
||||||
"AddUser": "Add User",
|
"AddUser": "Add User",
|
||||||
@ -210,4 +211,4 @@
|
|||||||
"e-print": "e-print",
|
"e-print": "e-print",
|
||||||
"e-suite": "e-suite",
|
"e-suite": "e-suite",
|
||||||
"e-suiteLogo": "e-suite logo"
|
"e-suiteLogo": "e-suite logo"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"AdhocApprovalTask": "Adhoc Approval",
|
"AdhocApprovalTask": "Adhoc Approval",
|
||||||
"ApprovalTask": "Approval",
|
"ApprovalTask": "Approval",
|
||||||
|
"ApprovalStep": "Approval Step",
|
||||||
"AssetUploadTask": "Asset Upload",
|
"AssetUploadTask": "Asset Upload",
|
||||||
"BasicTask": "Basic",
|
"BasicTask": "Basic",
|
||||||
"ContentCollationTask": "Content Collation",
|
"ContentCollationTask": "Content Collation",
|
||||||
@ -12,4 +13,4 @@
|
|||||||
"StageTask": "Stage",
|
"StageTask": "Stage",
|
||||||
"VisualBriefReviewTask": "Visual Brief Review",
|
"VisualBriefReviewTask": "Visual Brief Review",
|
||||||
"VisualBriefUploadTask": "Visual Brief Upload"
|
"VisualBriefUploadTask": "Visual Brief Upload"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import React from "react";
|
|||||||
import { TaskMetadata } from "../services/WorkflowTemplateService";
|
import { TaskMetadata } from "../services/WorkflowTemplateService";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Namespaces } from "../../../../i18n/i18n";
|
import { Namespaces } from "../../../../i18n/i18n";
|
||||||
|
import Button, { ButtonType } from "../../../../components/common/Button";
|
||||||
|
|
||||||
interface AddTaskButtonProps {
|
interface AddTaskButtonProps {
|
||||||
tasksMetadata: TaskMetadata[];
|
tasksMetadata: TaskMetadata[];
|
||||||
@ -22,11 +23,27 @@ const AddTaskButton: React.FC<AddTaskButtonProps> = ({
|
|||||||
setOpen(next);
|
setOpen(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (tasksMetadata.length === 1) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
buttonType={ButtonType.secondary}
|
||||||
|
onClick={() => {
|
||||||
|
onAdd(tasksMetadata[0]);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("AddWithValue", {
|
||||||
|
value: tTaskType(tTaskType(tasksMetadata[0].displayName)),
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="add-task-button">
|
<div className="add-task-button">
|
||||||
<button className="btn btn-secondary" type="button" onClick={toggle}>
|
<Button buttonType={ButtonType.secondary} onClick={toggle}>
|
||||||
{t("AddTask")}
|
{t("AddTask")}
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="dropdown-menu show">
|
<div className="dropdown-menu show">
|
||||||
|
|||||||
@ -0,0 +1,248 @@
|
|||||||
|
import React from "react";
|
||||||
|
import UserPicker from "../../../../../components/pickers/UserPicker";
|
||||||
|
import {
|
||||||
|
GeneralIdRef,
|
||||||
|
MakeGeneralIdRef,
|
||||||
|
} from "../../../../../utils/GeneralIdRef";
|
||||||
|
import { TaskDefinition } from "../../services/WorkflowTemplateService";
|
||||||
|
import {
|
||||||
|
CapabilityEditorProps,
|
||||||
|
capabilityEditorRegistryEntry,
|
||||||
|
defaultsContext,
|
||||||
|
} from "../useCapabilityDefaults";
|
||||||
|
import Button, { ButtonType } from "../../../../../components/common/Button";
|
||||||
|
import ValidationErrorIcon from "../../../../../components/validationErrorIcon";
|
||||||
|
import ErrorBlock from "../../../../../components/common/ErrorBlock";
|
||||||
|
import RolePicker from "../../../../../components/pickers/RolePicker";
|
||||||
|
import { getCurrentUser } from "../../../../frame/services/authenticationService";
|
||||||
|
import RaciPicker from "../../../../../components/pickers/RaciPicker";
|
||||||
|
import VerdictPicker from "../../../../../components/pickers/VerdictPicker";
|
||||||
|
import { renderInput } from "../../../../../components/common/formHelpers";
|
||||||
|
import { InputType } from "../../../../../components/common/Input";
|
||||||
|
import { error } from "console";
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Domain Interfaces
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
export interface IApprovalTaskAssignee {
|
||||||
|
role?: GeneralIdRef | null;
|
||||||
|
contact?: GeneralIdRef | null;
|
||||||
|
raci: string; // Raci enum as string
|
||||||
|
allowNoVerdict: boolean;
|
||||||
|
bypassable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IApprovalTaskAssigneesCapability {
|
||||||
|
assignees: IApprovalTaskAssignee[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Editor Component
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
export const AssigneesOfIApprovalTaskAssigneeEditor: React.FC<
|
||||||
|
CapabilityEditorProps
|
||||||
|
> = (props) => {
|
||||||
|
const { task, onChange, fieldErrors } = props;
|
||||||
|
const assignees = (task.config.assignees ?? []) as IApprovalTaskAssignee[];
|
||||||
|
|
||||||
|
function updateAssignee(index: number, updated: IApprovalTaskAssignee) {
|
||||||
|
const clone = structuredClone(task);
|
||||||
|
const list = clone.config.assignees ?? [];
|
||||||
|
list[index] = updated;
|
||||||
|
clone.config.assignees = list;
|
||||||
|
onChange(clone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAssignee() {
|
||||||
|
const clone = structuredClone(task);
|
||||||
|
const list = clone.config.assignees ?? [];
|
||||||
|
list.push({
|
||||||
|
role: null,
|
||||||
|
contact: null,
|
||||||
|
raci: "Responsible",
|
||||||
|
allowNoVerdict: false,
|
||||||
|
bypassable: false,
|
||||||
|
});
|
||||||
|
clone.config.assignees = list;
|
||||||
|
onChange(clone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAssignee(index: number) {
|
||||||
|
const clone = structuredClone(task);
|
||||||
|
const list = clone.config.assignees ?? [];
|
||||||
|
clone.config.assignees = list.filter((_, i) => i !== index);
|
||||||
|
onChange(clone);
|
||||||
|
}
|
||||||
|
|
||||||
|
const guid = task.config.guid as string;
|
||||||
|
|
||||||
|
const domain = MakeGeneralIdRef(getCurrentUser()?.domainid);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
Select assigness (you can select either a role or a contact)
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Contact</th>
|
||||||
|
<th>RACI</th>
|
||||||
|
<th>Allow No Verdict</th>
|
||||||
|
<th>Bypassable</th>
|
||||||
|
<th>
|
||||||
|
<Button onClick={addAssignee} buttonType={ButtonType.secondary}>
|
||||||
|
Add Assignee
|
||||||
|
</Button>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{assignees.map((assignee, index) => (
|
||||||
|
<tr key={index} className="align-top">
|
||||||
|
<td className="form-group">
|
||||||
|
<ValidationErrorIcon
|
||||||
|
visible={
|
||||||
|
!!fieldErrors?.[`${guid}.assignees[${index}].role`] ||
|
||||||
|
!!fieldErrors?.[`${guid}.assignees[${index}].contact`] ||
|
||||||
|
!!fieldErrors?.[`${guid}.assignees[${index}].raci`]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<RolePicker
|
||||||
|
includeLabel={false}
|
||||||
|
name="role"
|
||||||
|
label="Role"
|
||||||
|
value={assignee.role}
|
||||||
|
domain={domain}
|
||||||
|
error={fieldErrors?.[`${guid}.assignees[${index}].role`]}
|
||||||
|
onChange={(name: string, val: GeneralIdRef | null) =>
|
||||||
|
updateAssignee(index, { ...assignee, role: val })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<UserPicker
|
||||||
|
includeLabel={false}
|
||||||
|
name="contact"
|
||||||
|
label="Contact"
|
||||||
|
value={assignee.contact}
|
||||||
|
domain={domain}
|
||||||
|
error={fieldErrors?.[`${guid}.assignees[${index}].contact`]}
|
||||||
|
onChange={(name: string, val: GeneralIdRef | null) =>
|
||||||
|
updateAssignee(index, { ...assignee, contact: val })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<RaciPicker
|
||||||
|
includeLabel={false}
|
||||||
|
name="raci"
|
||||||
|
label="RACI"
|
||||||
|
value={assignee.raci}
|
||||||
|
error={fieldErrors?.[`${guid}.assignees[${index}].raci`]}
|
||||||
|
onChange={(name: string, val: string) =>
|
||||||
|
updateAssignee(index, { ...assignee, raci: val })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>allowNoVerdict goes here.</td>
|
||||||
|
<td>Bypass goes here.</td>
|
||||||
|
<td className="form-group">
|
||||||
|
<Button
|
||||||
|
onClick={() => removeAssignee(index)}
|
||||||
|
buttonType={ButtonType.secondary}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ErrorBlock
|
||||||
|
error={Object.values(fieldErrors ?? {})
|
||||||
|
.filter((_, i) =>
|
||||||
|
Object.keys(fieldErrors ?? {}).some(
|
||||||
|
(key) => key === `${guid}.assignees`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join("; ")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Validation
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
const runValidation = (
|
||||||
|
task: TaskDefinition,
|
||||||
|
tasks: TaskDefinition[],
|
||||||
|
): Promise<Record<string, string>> => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
const guid = task.config.guid as string;
|
||||||
|
const assignees = task.config.assignees as ITaskAssignee[] | undefined;
|
||||||
|
|
||||||
|
if (!assignees || assignees.length === 0) {
|
||||||
|
errors[`${guid}.assignees`] = "At least one assignee is required.";
|
||||||
|
return Promise.resolve(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
assignees.forEach((a, i) => {
|
||||||
|
const noContactSelected = !a.contact || a.contact?.id === BigInt(0);
|
||||||
|
const noRoleSelected = !a.role || a.role?.id === BigInt(0);
|
||||||
|
|
||||||
|
if (!noContactSelected && !noRoleSelected) {
|
||||||
|
errors[`${guid}.assignees[${i}].contact`] =
|
||||||
|
"Cannot select both a contact and a role.";
|
||||||
|
errors[`${guid}.assignees[${i}].role`] =
|
||||||
|
"Cannot select both a contact and a role.";
|
||||||
|
} else {
|
||||||
|
if (!(!noContactSelected || !noRoleSelected)) {
|
||||||
|
if (noContactSelected) {
|
||||||
|
errors[`${guid}.assignees[${i}].contact`] =
|
||||||
|
"A contact must be selected.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noRoleSelected) {
|
||||||
|
errors[`${guid}.assignees[${i}].role`] = "A role must be selected.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!a.raci) {
|
||||||
|
errors[`${guid}.assignees[${i}].raci`] = "RACI is required.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Defaults Assignment
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
export function defaultsAssignment(
|
||||||
|
task: TaskDefinition,
|
||||||
|
tasks: TaskDefinition[],
|
||||||
|
ctx: defaultsContext,
|
||||||
|
) {
|
||||||
|
task.config.assignees = [{ raci: "Responsible" } as ITaskAssignee];
|
||||||
|
//task.config.assignees = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Registry Entry
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
export const assigneesOfIApprovalTaskAssigneeRegistryEntry: capabilityEditorRegistryEntry =
|
||||||
|
{
|
||||||
|
Editor: AssigneesOfIApprovalTaskAssigneeEditor,
|
||||||
|
DefaultsAssignment: defaultsAssignment,
|
||||||
|
ValidationRunner: runValidation,
|
||||||
|
};
|
||||||
@ -199,7 +199,7 @@ export function defaultsAssignment(
|
|||||||
ctx: defaultsContext,
|
ctx: defaultsContext,
|
||||||
) {
|
) {
|
||||||
task.config.outcomeActions = [] as IOutcomeAction[];
|
task.config.outcomeActions = [] as IOutcomeAction[];
|
||||||
task.config.overrideDefaultTaskProgression = true;
|
task.config.overrideDefaultTaskProgression = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const outcomeOfApprovalVerdictRegistryEntry: capabilityEditorRegistryEntry =
|
export const outcomeOfApprovalVerdictRegistryEntry: capabilityEditorRegistryEntry =
|
||||||
|
|||||||
@ -0,0 +1,124 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import templateVersionsService, {
|
||||||
|
TaskDefinition,
|
||||||
|
TaskMetadata,
|
||||||
|
} from "../../services/WorkflowTemplateService";
|
||||||
|
import AddTaskButton from "../AddTaskButton";
|
||||||
|
import {
|
||||||
|
CapabilityEditorProps,
|
||||||
|
capabilityEditorRegistryEntry,
|
||||||
|
defaultsContext,
|
||||||
|
useCapabilityDefaults,
|
||||||
|
validateTask,
|
||||||
|
} from "../useCapabilityDefaults";
|
||||||
|
|
||||||
|
function createStageEditor(taskType: string): React.FC<CapabilityEditorProps> {
|
||||||
|
return (props) => {
|
||||||
|
const {
|
||||||
|
task,
|
||||||
|
onChange,
|
||||||
|
onValidate,
|
||||||
|
onValidateTask,
|
||||||
|
onTaskAdded,
|
||||||
|
fieldErrors,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [tasksMetadata, setTasksMetadata] = React.useState<TaskMetadata[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTaskMetadata = async () => {
|
||||||
|
const meta = await templateVersionsService.getTaskMetadata(taskType);
|
||||||
|
setTasksMetadata(meta);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTaskMetadata();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runDefaults = useCapabilityDefaults(tasksMetadata);
|
||||||
|
|
||||||
|
const handleAddTask = async (selectedType: TaskMetadata) => {
|
||||||
|
const newTask: TaskDefinition = {
|
||||||
|
type: selectedType.taskType,
|
||||||
|
|
||||||
|
config: {
|
||||||
|
guid: crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var childTasks = task.config.tasks as TaskDefinition[]; //Type assertion to satisfy the compiler, we know this will be the correct type.
|
||||||
|
|
||||||
|
//Assign the default values for the task here.
|
||||||
|
selectedType.capabilities.forEach((capability) => {
|
||||||
|
runDefaults(capability, newTask, childTasks);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedTasks = [...childTasks, newTask];
|
||||||
|
const errors = await validateTask(newTask, updatedTasks, tasksMetadata);
|
||||||
|
|
||||||
|
const isValid = Object.keys(errors).length === 0;
|
||||||
|
|
||||||
|
// Mark the child task as valid/invalid
|
||||||
|
onValidateTask?.(newTask.config.guid as string, isValid);
|
||||||
|
|
||||||
|
task.config.tasks = updatedTasks;
|
||||||
|
|
||||||
|
onChange(task);
|
||||||
|
onTaskAdded?.(newTask.config.guid as string);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AddTaskButton tasksMetadata={tasksMetadata} onAdd={handleAddTask} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createValidationRunner(
|
||||||
|
taskType: string,
|
||||||
|
): (
|
||||||
|
task: TaskDefinition,
|
||||||
|
tasks: TaskDefinition[],
|
||||||
|
) => Promise<Record<string, string>> {
|
||||||
|
return async (task: TaskDefinition, tasks: TaskDefinition[]) => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
const meta = await templateVersionsService.getTaskMetadata(taskType);
|
||||||
|
|
||||||
|
if (task.config.tasks) {
|
||||||
|
for (const childTask of task.config.tasks as TaskDefinition[]) {
|
||||||
|
const childErrors = await validateTask(
|
||||||
|
childTask,
|
||||||
|
task.config.tasks as TaskDefinition[],
|
||||||
|
meta,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (childErrors && Object.keys(childErrors).length > 0) {
|
||||||
|
errors[`${task.config.guid}.tasks`] =
|
||||||
|
`One or more child tasks are invalid.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultsAssignment(
|
||||||
|
task: TaskDefinition,
|
||||||
|
tasks: TaskDefinition[],
|
||||||
|
ctx: defaultsContext,
|
||||||
|
) {
|
||||||
|
task.config.tasks = [] as TaskDefinition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStageEditorRegistryEntry(
|
||||||
|
taskType: string,
|
||||||
|
): capabilityEditorRegistryEntry {
|
||||||
|
return {
|
||||||
|
Editor: createStageEditor(taskType),
|
||||||
|
DefaultsAssignment: defaultsAssignment,
|
||||||
|
ValidationRunner: createValidationRunner(taskType),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,108 +0,0 @@
|
|||||||
import React, { useEffect } from "react";
|
|
||||||
import templateVersionsService, {
|
|
||||||
TaskDefinition,
|
|
||||||
TaskMetadata,
|
|
||||||
} from "../../services/WorkflowTemplateService";
|
|
||||||
import AddTaskButton from "../AddTaskButton";
|
|
||||||
import {
|
|
||||||
CapabilityEditorProps,
|
|
||||||
capabilityEditorRegistryEntry,
|
|
||||||
defaultsContext,
|
|
||||||
TaskValidationResult,
|
|
||||||
useCapabilityDefaults,
|
|
||||||
validateTask,
|
|
||||||
} from "../useCapabilityDefaults";
|
|
||||||
|
|
||||||
export const StageOfGeneralTaskEditor: React.FC<CapabilityEditorProps> = (
|
|
||||||
props,
|
|
||||||
) => {
|
|
||||||
const { task, onChange, onValidate, onValidateTask, fieldErrors } = props;
|
|
||||||
|
|
||||||
const [tasksMetadata, setTasksMetadata] = React.useState<TaskMetadata[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchTaskMetadata = async () => {
|
|
||||||
const meta = await templateVersionsService.getTaskMetadata("GeneralTask");
|
|
||||||
setTasksMetadata(meta);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchTaskMetadata();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const runDefaults = useCapabilityDefaults(tasksMetadata);
|
|
||||||
|
|
||||||
const handleAddTask = async (selectedType: TaskMetadata) => {
|
|
||||||
const newTask: TaskDefinition = {
|
|
||||||
type: selectedType.taskType,
|
|
||||||
|
|
||||||
config: {
|
|
||||||
guid: crypto.randomUUID(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
var childTasks = task.config.tasks as TaskDefinition[]; //Type assertion to satisfy the compiler, we know this will be the correct type.
|
|
||||||
|
|
||||||
//Assign the default values for the task here.
|
|
||||||
selectedType.capabilities.forEach((capability) => {
|
|
||||||
runDefaults(capability, newTask, childTasks);
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatedTasks = [...childTasks, newTask];
|
|
||||||
const errors = await validateTask(newTask, updatedTasks, tasksMetadata);
|
|
||||||
|
|
||||||
const isValid = Object.keys(errors).length === 0;
|
|
||||||
|
|
||||||
// Mark the child task as valid/invalid
|
|
||||||
onValidateTask?.(newTask.config.guid as string, isValid);
|
|
||||||
|
|
||||||
task.config.tasks = updatedTasks;
|
|
||||||
|
|
||||||
onChange(task);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AddTaskButton tasksMetadata={tasksMetadata} onAdd={handleAddTask} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const runValidation = async (
|
|
||||||
task: TaskDefinition,
|
|
||||||
tasks: TaskDefinition[],
|
|
||||||
): Promise<Record<string, string>> => {
|
|
||||||
const errors: Record<string, string> = {};
|
|
||||||
|
|
||||||
const meta = await templateVersionsService.getTaskMetadata("GeneralTask");
|
|
||||||
|
|
||||||
if (task.config.tasks) {
|
|
||||||
for (const childTask of task.config.tasks as TaskDefinition[]) {
|
|
||||||
const childErrors = await validateTask(
|
|
||||||
childTask,
|
|
||||||
task.config.tasks as TaskDefinition[],
|
|
||||||
meta,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (childErrors && Object.keys(childErrors).length > 0) {
|
|
||||||
errors[`${task.config.guid}.tasks`] =
|
|
||||||
`One or more child tasks are invalid.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errors;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function defaultsAssignment(
|
|
||||||
task: TaskDefinition,
|
|
||||||
tasks: TaskDefinition[],
|
|
||||||
ctx: defaultsContext,
|
|
||||||
) {
|
|
||||||
task.config.tasks = [] as TaskDefinition[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const stageOfGeneralTaskEditorRegistryEntry: capabilityEditorRegistryEntry =
|
|
||||||
{
|
|
||||||
Editor: StageOfGeneralTaskEditor,
|
|
||||||
DefaultsAssignment: defaultsAssignment,
|
|
||||||
ValidationRunner: runValidation,
|
|
||||||
};
|
|
||||||
@ -12,24 +12,28 @@ interface TaskEditorProps {
|
|||||||
task: TaskDefinition;
|
task: TaskDefinition;
|
||||||
tasks: TaskDefinition[];
|
tasks: TaskDefinition[];
|
||||||
siblingTasks: TaskDefinition[];
|
siblingTasks: TaskDefinition[];
|
||||||
|
parentTask: TaskDefinition | null;
|
||||||
onChange: (updatedTask: TaskDefinition) => void;
|
onChange: (updatedTask: TaskDefinition) => void;
|
||||||
onValidate: (taskId: string, isValid: boolean) => void;
|
onValidate: (taskId: string, isValid: boolean) => void;
|
||||||
onDelete: (taskId: string) => void;
|
onDelete: (taskId: string) => void;
|
||||||
|
onTaskAdded?: (taskGuid: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskEditorComponent: React.FC<TaskEditorProps> = ({
|
const TaskEditorComponent: React.FC<TaskEditorProps> = ({
|
||||||
task,
|
task,
|
||||||
tasks,
|
tasks,
|
||||||
siblingTasks,
|
siblingTasks,
|
||||||
|
parentTask,
|
||||||
onChange,
|
onChange,
|
||||||
onValidate,
|
onValidate,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onTaskAdded,
|
||||||
}) => {
|
}) => {
|
||||||
const [tasksMetadata, setTasksMetadata] = React.useState<TaskMetadata[]>([]);
|
const [tasksMetadata, setTasksMetadata] = React.useState<TaskMetadata[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTaskMetadata = async () => {
|
const fetchTaskMetadata = async () => {
|
||||||
const meta = await templateVersionsService.getTaskMetadata("GeneralTask");
|
const meta = await templateVersionsService.getTaskMetadata("");
|
||||||
setTasksMetadata(meta);
|
setTasksMetadata(meta);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -52,12 +56,23 @@ const TaskEditorComponent: React.FC<TaskEditorProps> = ({
|
|||||||
tasksMetadataList,
|
tasksMetadataList,
|
||||||
);
|
);
|
||||||
setFieldErrors(errors);
|
setFieldErrors(errors);
|
||||||
onValidate(
|
const isValid = Object.keys(errors).length === 0;
|
||||||
taskToValidate.config.guid as string,
|
onValidate(taskToValidate.config.guid as string, isValid);
|
||||||
Object.keys(errors).length === 0,
|
|
||||||
);
|
// If this task has a parent, re-validate the parent
|
||||||
|
if (parentTask) {
|
||||||
|
const parentErrors = await validateTask(
|
||||||
|
parentTask,
|
||||||
|
tasks,
|
||||||
|
tasksMetadataList,
|
||||||
|
);
|
||||||
|
onValidate(
|
||||||
|
parentTask.config.guid as string,
|
||||||
|
Object.keys(parentErrors).length === 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[onValidate],
|
[onValidate, parentTask, tasks],
|
||||||
);
|
);
|
||||||
|
|
||||||
const tasksRef = React.useRef(tasks);
|
const tasksRef = React.useRef(tasks);
|
||||||
@ -108,14 +123,16 @@ const TaskEditorComponent: React.FC<TaskEditorProps> = ({
|
|||||||
onChange={handleTaskChange}
|
onChange={handleTaskChange}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
onValidateTask={onValidate}
|
onValidateTask={onValidate}
|
||||||
|
onTaskAdded={onTaskAdded}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
buttonType={ButtonType.primary}
|
buttonType={ButtonType.primary}
|
||||||
|
keyValue={task.config.guid as string}
|
||||||
onClick={() => onDelete(task.config.guid as string)}
|
onClick={() => onDelete(task.config.guid as string)}
|
||||||
>
|
>
|
||||||
Delete {task.config.name || "Task"}
|
Delete {(task.config.name as string) || "Task"}
|
||||||
</ConfirmButton>
|
</ConfirmButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -126,17 +143,18 @@ export const TaskEditor = React.memo(
|
|||||||
(prevProps, nextProps) => {
|
(prevProps, nextProps) => {
|
||||||
const taskIdChanged =
|
const taskIdChanged =
|
||||||
prevProps.task.config.guid !== nextProps.task.config.guid;
|
prevProps.task.config.guid !== nextProps.task.config.guid;
|
||||||
const metaChanged = prevProps.tasksMetadata !== nextProps.tasksMetadata;
|
|
||||||
const onChangeChanged = prevProps.onChange !== nextProps.onChange;
|
const onChangeChanged = prevProps.onChange !== nextProps.onChange;
|
||||||
const onValidateChanged = prevProps.onValidate !== nextProps.onValidate;
|
const onValidateChanged = prevProps.onValidate !== nextProps.onValidate;
|
||||||
|
const parentChanged = prevProps.parentTask !== nextProps.parentTask;
|
||||||
|
|
||||||
// Skip tasks comparison - we use a ref to access it
|
// Skip tasks comparison - we use a ref to access it
|
||||||
return !(
|
return !(
|
||||||
taskIdChanged ||
|
taskIdChanged ||
|
||||||
metaChanged ||
|
|
||||||
onChangeChanged ||
|
onChangeChanged ||
|
||||||
onValidateChanged ||
|
onValidateChanged ||
|
||||||
prevProps.onDelete !== nextProps.onDelete
|
parentChanged ||
|
||||||
|
prevProps.onDelete !== nextProps.onDelete ||
|
||||||
|
prevProps.onTaskAdded !== nextProps.onTaskAdded
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -26,8 +26,25 @@ const TasksTab: React.FC<TasksTabProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const tasks = data.tasks;
|
const tasks = data.tasks;
|
||||||
const [selectedTask, setSelectedTask] = useState<TaskDefinition | null>(null);
|
const [selectedTask, setSelectedTask] = useState<TaskDefinition | null>(null);
|
||||||
|
const [pendingSelectGuid, setPendingSelectGuid] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// If we have a pending task GUID to select, find it and select it
|
||||||
|
if (pendingSelectGuid) {
|
||||||
|
const { task: foundTask } = findTaskAndSiblings(
|
||||||
|
pendingSelectGuid,
|
||||||
|
tasks,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
if (foundTask) {
|
||||||
|
setSelectedTask(foundTask);
|
||||||
|
setPendingSelectGuid(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't override user selection
|
// Don't override user selection
|
||||||
if (selectedTask) return;
|
if (selectedTask) return;
|
||||||
|
|
||||||
@ -48,7 +65,7 @@ const TasksTab: React.FC<TasksTabProps> = ({
|
|||||||
|
|
||||||
// Otherwise select first task
|
// Otherwise select first task
|
||||||
setSelectedTask(tasks[0]);
|
setSelectedTask(tasks[0]);
|
||||||
}, [tasks, taskValidation, selectedTask]);
|
}, [tasks, taskValidation, selectedTask, pendingSelectGuid]);
|
||||||
|
|
||||||
const handleTasksChange = React.useCallback(
|
const handleTasksChange = React.useCallback(
|
||||||
(newTasks: TaskDefinition[]) => {
|
(newTasks: TaskDefinition[]) => {
|
||||||
@ -61,22 +78,27 @@ const TasksTab: React.FC<TasksTabProps> = ({
|
|||||||
const findTaskAndSiblings = (
|
const findTaskAndSiblings = (
|
||||||
targetGuid: string,
|
targetGuid: string,
|
||||||
source: TaskDefinition[],
|
source: TaskDefinition[],
|
||||||
): { task: TaskDefinition | null; siblings: TaskDefinition[] } => {
|
parent: TaskDefinition | null = null,
|
||||||
|
): {
|
||||||
|
task: TaskDefinition | null;
|
||||||
|
siblings: TaskDefinition[];
|
||||||
|
parent: TaskDefinition | null;
|
||||||
|
} => {
|
||||||
for (const task of source) {
|
for (const task of source) {
|
||||||
if (task.config.guid === targetGuid) {
|
if (task.config.guid === targetGuid) {
|
||||||
return { task, siblings: source };
|
return { task, siblings: source, parent };
|
||||||
}
|
}
|
||||||
|
|
||||||
const childTasks = (task.config.tasks as TaskDefinition[]) ?? [];
|
const childTasks = (task.config.tasks as TaskDefinition[]) ?? [];
|
||||||
if (childTasks.length === 0) continue;
|
if (childTasks.length === 0) continue;
|
||||||
|
|
||||||
const result = findTaskAndSiblings(targetGuid, childTasks);
|
const result = findTaskAndSiblings(targetGuid, childTasks, task);
|
||||||
if (result.task) {
|
if (result.task) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { task: null, siblings: [] };
|
return { task: null, siblings: [], parent: null };
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTaskEditorChange = React.useCallback(
|
const handleTaskEditorChange = React.useCallback(
|
||||||
@ -84,6 +106,7 @@ const TasksTab: React.FC<TasksTabProps> = ({
|
|||||||
const { siblings } = findTaskAndSiblings(
|
const { siblings } = findTaskAndSiblings(
|
||||||
updatedTask.config.guid as string,
|
updatedTask.config.guid as string,
|
||||||
tasks,
|
tasks,
|
||||||
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (siblings.length === 0) {
|
if (siblings.length === 0) {
|
||||||
@ -242,12 +265,25 @@ const TasksTab: React.FC<TasksTabProps> = ({
|
|||||||
task={selectedTask}
|
task={selectedTask}
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
siblingTasks={
|
siblingTasks={
|
||||||
findTaskAndSiblings(selectedTask.config.guid as string, tasks)
|
findTaskAndSiblings(
|
||||||
.siblings
|
selectedTask.config.guid as string,
|
||||||
|
tasks,
|
||||||
|
null,
|
||||||
|
).siblings
|
||||||
|
}
|
||||||
|
parentTask={
|
||||||
|
findTaskAndSiblings(
|
||||||
|
selectedTask.config.guid as string,
|
||||||
|
tasks,
|
||||||
|
null,
|
||||||
|
).parent
|
||||||
}
|
}
|
||||||
onChange={handleTaskEditorChange}
|
onChange={handleTaskEditorChange}
|
||||||
onValidate={onValidate}
|
onValidate={onValidate}
|
||||||
onDelete={handleTaskDelete}
|
onDelete={handleTaskDelete}
|
||||||
|
onTaskAdded={(taskGuid: string) => {
|
||||||
|
setPendingSelectGuid(taskGuid);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -5,7 +5,8 @@ import { capabilityEditorRegistryEntry } from "./useCapabilityDefaults";
|
|||||||
import { outcomeOfApprovalVerdictRegistryEntry } from "./CapabilityEditors/OutcomeOfApprovalVerdictRegistryEntry";
|
import { outcomeOfApprovalVerdictRegistryEntry } from "./CapabilityEditors/OutcomeOfApprovalVerdictRegistryEntry";
|
||||||
import { budgetEditorRegistryEntry } from "./CapabilityEditors/BudgetEditorRegistryEntry";
|
import { budgetEditorRegistryEntry } from "./CapabilityEditors/BudgetEditorRegistryEntry";
|
||||||
import { bypassableEditorRegistryEntry } from "./CapabilityEditors/BypassableEditor";
|
import { bypassableEditorRegistryEntry } from "./CapabilityEditors/BypassableEditor";
|
||||||
import { stageOfGeneralTaskEditorRegistryEntry } from "./CapabilityEditors/StageOfGeneralTaskEditor";
|
import { createStageEditorRegistryEntry } from "./CapabilityEditors/StageEditor";
|
||||||
|
import { assigneesOfIApprovalTaskAssigneeRegistryEntry } from "./CapabilityEditors/AssigneesOfIApprovalTaskAssigneeEditor";
|
||||||
|
|
||||||
export const capabilityEditorRegistry: Record<
|
export const capabilityEditorRegistry: Record<
|
||||||
string,
|
string,
|
||||||
@ -15,9 +16,12 @@ export const capabilityEditorRegistry: Record<
|
|||||||
ITags: tagsEditorRegistryEntry,
|
ITags: tagsEditorRegistryEntry,
|
||||||
IBudget: budgetEditorRegistryEntry,
|
IBudget: budgetEditorRegistryEntry,
|
||||||
"IAssignees<ITaskAssignee>": assigneesOfITaskAssigneeRegistryEntry,
|
"IAssignees<ITaskAssignee>": assigneesOfITaskAssigneeRegistryEntry,
|
||||||
|
"IAssignees<IApprovalTaskAssignee>":
|
||||||
|
assigneesOfIApprovalTaskAssigneeRegistryEntry,
|
||||||
"IOutcome<ApprovalVerdict>": outcomeOfApprovalVerdictRegistryEntry,
|
"IOutcome<ApprovalVerdict>": outcomeOfApprovalVerdictRegistryEntry,
|
||||||
// IFormTemplate: null, //ToDo implement this
|
// IFormTemplate: null, //ToDo implement this
|
||||||
IBypassable: bypassableEditorRegistryEntry,
|
IBypassable: bypassableEditorRegistryEntry,
|
||||||
"IStage<GeneralTaskAttribute>": stageOfGeneralTaskEditorRegistryEntry,
|
"IStage<GeneralTaskAttribute>": createStageEditorRegistryEntry("GeneralTask"),
|
||||||
// "IStage<ApprovalTaskAttribute>": null, //ToDo implement this
|
"IStage<ApprovalTaskAttribute>":
|
||||||
|
createStageEditorRegistryEntry("ApprovalTask"),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export interface CapabilityEditorProps {
|
|||||||
onChange: (updated: TaskDefinition) => void;
|
onChange: (updated: TaskDefinition) => void;
|
||||||
onValidate: (result: TaskValidationResult) => void;
|
onValidate: (result: TaskValidationResult) => void;
|
||||||
onValidateTask?: (taskId: string, isValid: boolean) => void;
|
onValidateTask?: (taskId: string, isValid: boolean) => void;
|
||||||
|
onTaskAdded?: (taskGuid: string) => void;
|
||||||
fieldErrors: Record<string, string>;
|
fieldErrors: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,7 +57,11 @@ export async function validateTask(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validationErrors = await entry.ValidationRunner(task, tasks);
|
const validationErrors = await entry.ValidationRunner(
|
||||||
|
task,
|
||||||
|
tasks,
|
||||||
|
tasksMetadata,
|
||||||
|
);
|
||||||
Object.assign(errors, validationErrors);
|
Object.assign(errors, validationErrors);
|
||||||
}
|
}
|
||||||
return errors;
|
return errors;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user