Compare commits

...

2 Commits

11 changed files with 483 additions and 132 deletions

View File

@ -4,6 +4,7 @@
"Active": "Active",
"ActivityNameTemplate": "Activity Name Template",
"Add": "Add",
"AddWithValue": "Add {{value}}",
"AddDomain": "Add Domain",
"AddTask": "Add Task",
"AddUser": "Add User",
@ -210,4 +211,4 @@
"e-print": "e-print",
"e-suite": "e-suite",
"e-suiteLogo": "e-suite logo"
}
}

View File

@ -1,6 +1,7 @@
{
"AdhocApprovalTask": "Adhoc Approval",
"ApprovalTask": "Approval",
"ApprovalStep": "Approval Step",
"AssetUploadTask": "Asset Upload",
"BasicTask": "Basic",
"ContentCollationTask": "Content Collation",
@ -12,4 +13,4 @@
"StageTask": "Stage",
"VisualBriefReviewTask": "Visual Brief Review",
"VisualBriefUploadTask": "Visual Brief Upload"
}
}

View File

@ -2,6 +2,7 @@ import React from "react";
import { TaskMetadata } from "../services/WorkflowTemplateService";
import { useTranslation } from "react-i18next";
import { Namespaces } from "../../../../i18n/i18n";
import Button, { ButtonType } from "../../../../components/common/Button";
interface AddTaskButtonProps {
tasksMetadata: TaskMetadata[];
@ -22,11 +23,27 @@ const AddTaskButton: React.FC<AddTaskButtonProps> = ({
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 (
<div className="add-task-button">
<button className="btn btn-secondary" type="button" onClick={toggle}>
<Button buttonType={ButtonType.secondary} onClick={toggle}>
{t("AddTask")}
</button>
</Button>
{open && (
<div className="dropdown-menu show">

View File

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

View File

@ -199,7 +199,7 @@ export function defaultsAssignment(
ctx: defaultsContext,
) {
task.config.outcomeActions = [] as IOutcomeAction[];
task.config.overrideDefaultTaskProgression = true;
task.config.overrideDefaultTaskProgression = false;
}
export const outcomeOfApprovalVerdictRegistryEntry: capabilityEditorRegistryEntry =

View File

@ -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),
};
}

View File

@ -1,105 +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, 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;
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,
};

View File

@ -12,24 +12,28 @@ interface TaskEditorProps {
task: TaskDefinition;
tasks: TaskDefinition[];
siblingTasks: TaskDefinition[];
parentTask: TaskDefinition | null;
onChange: (updatedTask: TaskDefinition) => void;
onValidate: (taskId: string, isValid: boolean) => void;
onDelete: (taskId: string) => void;
onTaskAdded?: (taskGuid: string) => void;
}
const TaskEditorComponent: React.FC<TaskEditorProps> = ({
task,
tasks,
siblingTasks,
parentTask,
onChange,
onValidate,
onDelete,
onTaskAdded,
}) => {
const [tasksMetadata, setTasksMetadata] = React.useState<TaskMetadata[]>([]);
useEffect(() => {
const fetchTaskMetadata = async () => {
const meta = await templateVersionsService.getTaskMetadata("GeneralTask");
const meta = await templateVersionsService.getTaskMetadata("");
setTasksMetadata(meta);
};
@ -52,12 +56,23 @@ const TaskEditorComponent: React.FC<TaskEditorProps> = ({
tasksMetadataList,
);
setFieldErrors(errors);
onValidate(
taskToValidate.config.guid as string,
Object.keys(errors).length === 0,
);
const isValid = Object.keys(errors).length === 0;
onValidate(taskToValidate.config.guid as string, isValid);
// 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);
@ -106,15 +121,18 @@ const TaskEditorComponent: React.FC<TaskEditorProps> = ({
task={task}
tasks={tasks}
onChange={handleTaskChange}
fieldErrors={fieldErrors} // ← THIS IS THE MISSING PIECE
fieldErrors={fieldErrors}
onValidateTask={onValidate}
onTaskAdded={onTaskAdded}
/>
);
})}
<ConfirmButton
buttonType={ButtonType.primary}
keyValue={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>
</div>
);
@ -125,17 +143,18 @@ export const TaskEditor = React.memo(
(prevProps, nextProps) => {
const taskIdChanged =
prevProps.task.config.guid !== nextProps.task.config.guid;
const metaChanged = prevProps.tasksMetadata !== nextProps.tasksMetadata;
const onChangeChanged = prevProps.onChange !== nextProps.onChange;
const onValidateChanged = prevProps.onValidate !== nextProps.onValidate;
const parentChanged = prevProps.parentTask !== nextProps.parentTask;
// Skip tasks comparison - we use a ref to access it
return !(
taskIdChanged ||
metaChanged ||
onChangeChanged ||
onValidateChanged ||
prevProps.onDelete !== nextProps.onDelete
parentChanged ||
prevProps.onDelete !== nextProps.onDelete ||
prevProps.onTaskAdded !== nextProps.onTaskAdded
);
},
);

View File

@ -26,8 +26,25 @@ const TasksTab: React.FC<TasksTabProps> = ({
}) => {
const tasks = data.tasks;
const [selectedTask, setSelectedTask] = useState<TaskDefinition | null>(null);
const [pendingSelectGuid, setPendingSelectGuid] = useState<string | null>(
null,
);
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
if (selectedTask) return;
@ -48,7 +65,7 @@ const TasksTab: React.FC<TasksTabProps> = ({
// Otherwise select first task
setSelectedTask(tasks[0]);
}, [tasks, taskValidation, selectedTask]);
}, [tasks, taskValidation, selectedTask, pendingSelectGuid]);
const handleTasksChange = React.useCallback(
(newTasks: TaskDefinition[]) => {
@ -61,22 +78,27 @@ const TasksTab: React.FC<TasksTabProps> = ({
const findTaskAndSiblings = (
targetGuid: string,
source: TaskDefinition[],
): { task: TaskDefinition | null; siblings: TaskDefinition[] } => {
parent: TaskDefinition | null = null,
): {
task: TaskDefinition | null;
siblings: TaskDefinition[];
parent: TaskDefinition | null;
} => {
for (const task of source) {
if (task.config.guid === targetGuid) {
return { task, siblings: source };
return { task, siblings: source, parent };
}
const childTasks = (task.config.tasks as TaskDefinition[]) ?? [];
if (childTasks.length === 0) continue;
const result = findTaskAndSiblings(targetGuid, childTasks);
const result = findTaskAndSiblings(targetGuid, childTasks, task);
if (result.task) {
return result;
}
}
return { task: null, siblings: [] };
return { task: null, siblings: [], parent: null };
};
const handleTaskEditorChange = React.useCallback(
@ -84,6 +106,7 @@ const TasksTab: React.FC<TasksTabProps> = ({
const { siblings } = findTaskAndSiblings(
updatedTask.config.guid as string,
tasks,
null,
);
if (siblings.length === 0) {
@ -242,12 +265,25 @@ const TasksTab: React.FC<TasksTabProps> = ({
task={selectedTask}
tasks={tasks}
siblingTasks={
findTaskAndSiblings(selectedTask.config.guid as string, tasks)
.siblings
findTaskAndSiblings(
selectedTask.config.guid as string,
tasks,
null,
).siblings
}
parentTask={
findTaskAndSiblings(
selectedTask.config.guid as string,
tasks,
null,
).parent
}
onChange={handleTaskEditorChange}
onValidate={onValidate}
onDelete={handleTaskDelete}
onTaskAdded={(taskGuid: string) => {
setPendingSelectGuid(taskGuid);
}}
/>
</div>
)}

View File

@ -5,7 +5,8 @@ import { capabilityEditorRegistryEntry } from "./useCapabilityDefaults";
import { outcomeOfApprovalVerdictRegistryEntry } from "./CapabilityEditors/OutcomeOfApprovalVerdictRegistryEntry";
import { budgetEditorRegistryEntry } from "./CapabilityEditors/BudgetEditorRegistryEntry";
import { bypassableEditorRegistryEntry } from "./CapabilityEditors/BypassableEditor";
import { stageOfGeneralTaskEditorRegistryEntry } from "./CapabilityEditors/StageOfGeneralTaskEditor";
import { createStageEditorRegistryEntry } from "./CapabilityEditors/StageEditor";
import { assigneesOfIApprovalTaskAssigneeRegistryEntry } from "./CapabilityEditors/AssigneesOfIApprovalTaskAssigneeEditor";
export const capabilityEditorRegistry: Record<
string,
@ -15,9 +16,12 @@ export const capabilityEditorRegistry: Record<
ITags: tagsEditorRegistryEntry,
IBudget: budgetEditorRegistryEntry,
"IAssignees<ITaskAssignee>": assigneesOfITaskAssigneeRegistryEntry,
"IAssignees<IApprovalTaskAssignee>":
assigneesOfIApprovalTaskAssigneeRegistryEntry,
"IOutcome<ApprovalVerdict>": outcomeOfApprovalVerdictRegistryEntry,
// IFormTemplate: null, //ToDo implement this
IBypassable: bypassableEditorRegistryEntry,
"IStage<GeneralTaskAttribute>": stageOfGeneralTaskEditorRegistryEntry,
// "IStage<ApprovalTaskAttribute>": null, //ToDo implement this
"IStage<GeneralTaskAttribute>": createStageEditorRegistryEntry("GeneralTask"),
"IStage<ApprovalTaskAttribute>":
createStageEditorRegistryEntry("ApprovalTask"),
};

View File

@ -17,6 +17,8 @@ export interface CapabilityEditorProps {
tasks: TaskDefinition[];
onChange: (updated: TaskDefinition) => void;
onValidate: (result: TaskValidationResult) => void;
onValidateTask?: (taskId: string, isValid: boolean) => void;
onTaskAdded?: (taskGuid: string) => void;
fieldErrors: Record<string, string>;
}
@ -55,7 +57,11 @@ export async function validateTask(
continue;
}
const validationErrors = await entry.ValidationRunner(task, tasks);
const validationErrors = await entry.ValidationRunner(
task,
tasks,
tasksMetadata,
);
Object.assign(errors, validationErrors);
}
return errors;