Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pages Editor: add rules to "Add Task to Page" functionality #7079

Merged
merged 13 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 34 additions & 14 deletions app/pages/lab-pages-editor/components/TasksPage/TasksPage.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useRef, useState } from 'react'
import { useWorkflowContext } from '../../context.js';
import canStepBranch from '../../helpers/canStepBranch.js';
import createStep from '../../helpers/createStep.js';
import createTask from '../../helpers/createTask.js';
import getNewStepKey from '../../helpers/getNewStepKey.js';
Expand Down Expand Up @@ -29,20 +30,21 @@ export default function TasksPage() {
Returns the newly created step index.
*/
async function addTask(taskType, stepIndex = -1) {
const newTaskKey = getNewTaskKey(workflow?.tasks);
if (!workflow) return;
const newTaskKey = getNewTaskKey(workflow.tasks);
const newTask = createTask(taskType);
const steps = workflow?.steps?.slice() || [];
const steps = workflow.steps?.slice() || [];

let step
if (stepIndex < 0) {
// If no step is specified, we create a new one.
const newStepKey = getNewStepKey(workflow?.steps);
const newStepKey = getNewStepKey(workflow.steps);
step = createStep(newStepKey, [newTaskKey]);
steps.push(step);

} else {
// If a step is specified, we'll add the Task to that one.
step = workflow?.steps?.[stepIndex];
step = workflow.steps?.[stepIndex];
if (step) {
const [stepKey, stepBody] = step;
const stepBodyTaskKeys = stepBody?.taskKeys?.slice() || [];
Expand All @@ -69,8 +71,8 @@ export default function TasksPage() {
Updates (or adds) a Task
*/
function updateTask(taskKey, task) {
if (!taskKey) return;
const newTasks = structuredClone(workflow?.tasks || {}); // Copy tasks
if (!workflow || !taskKey) return;
const newTasks = structuredClone(workflow.tasks); // Copy tasks
newTasks[taskKey] = task;
update({ tasks: newTasks });
}
Expand All @@ -90,11 +92,11 @@ export default function TasksPage() {
if (!confirmed) return;

// Delete the task.
const newTasks = structuredClone(workflow.tasks || {});
const newTasks = structuredClone(workflow.tasks) || {};
delete newTasks[taskKey];

// Delete the task reference in steps.
const newSteps = structuredClone(workflow.steps || {});
const newSteps = structuredClone(workflow.steps) || [];
newSteps.forEach(step => {
const stepBody = step[1] || {};
stepBody.taskKeys = (stepBody?.taskKeys || []).filter(key => key !== taskKey);
Expand All @@ -112,7 +114,8 @@ export default function TasksPage() {
}

function moveStep(from, to) {
const oldSteps = workflow?.steps || [];
if (!workflow) return;
const oldSteps = workflow.steps || [];
if (from < 0 || to < 0 || from >= oldSteps.length || to >= oldSteps.length) return;

const steps = moveItemInArray(oldSteps, from, to);
Expand Down Expand Up @@ -155,9 +158,11 @@ export default function TasksPage() {

// Changes the optional "next page" of a step/page
function updateNextStepForStep(stepKey, next = undefined) {
if (!workflow || !workflow.steps) return;

// Check if input is valid
const stepIndex = workflow?.steps?.findIndex(step => step[0] === stepKey);
const stepBody = workflow?.steps?.[stepIndex]?.[1];
const stepIndex = workflow.steps.findIndex(step => step[0] === stepKey);
const stepBody = workflow.steps[stepIndex]?.[1];
if (!stepBody) return;

const newSteps = workflow.steps.slice();
Expand All @@ -168,12 +173,14 @@ export default function TasksPage() {

// Changes the optional "next page" of a branching answer/choice
function updateNextStepForTaskAnswer(taskKey, answerIndex, next = undefined) {
if (!workflow || !workflow?.tasks) return;

// Check if input is valid
const task = workflow?.tasks?.[taskKey];
const task = workflow.tasks[taskKey];
const answer = task?.answers[answerIndex];
if (!task || !answer) return;

const newTasks = workflow.tasks ? { ...workflow.tasks } : {}; // Copy tasks
const newTasks = structuredClone(workflow.tasks); // Copy tasks
const newAnswers = task.answers.with(answerIndex, { ...answer, next }) // Copy, then modify, answers
newTasks[taskKey] = { // Insert modified answers into the task inside the copied tasks. Phew!
...task,
Expand All @@ -182,7 +189,18 @@ export default function TasksPage() {

update({ tasks: newTasks });
}


// Limited Branching Rule:
// 0. a Step can only have 1 branching task (single answer question task)
// 1. if a Step has a branching task, it can't have any other tasks.
// 2. if a Step already has at least one task, any added question task must be a multiple answer question task.
// 3. if a Step already has many tasks, any multiple answer question task can't be transformed into a single answer question task.
const activeStep = workflow?.steps?.[activeStepIndex]
const enforceLimitedBranchingRule = {
stepHasBranch: !!canStepBranch(activeStep, workflow?.tasks),
stepHasOneTask: activeStep?.[1]?.taskKeys?.length > 0,
stepHasManyTasks: activeStep?.[1]?.taskKeys?.length > 1
}
const previewEnv = getPreviewEnv();
const previewUrl = `https://frontend.preview.zooniverse.org/projects/${project?.slug}/classify/workflow/${workflow?.id}${previewEnv}`;
if (!workflow) return null;
Expand Down Expand Up @@ -235,12 +253,14 @@ export default function TasksPage() {
<NewTaskDialog
ref={newTaskDialog}
addTask={addTask}
enforceLimitedBranchingRule={enforceLimitedBranchingRule}
openEditStepDialog={openEditStepDialog}
stepIndex={activeStepIndex}
/>
<EditStepDialog
ref={editStepDialog}
allTasks={workflow.tasks}
enforceLimitedBranchingRule={enforceLimitedBranchingRule}
onClose={handleCloseEditStepDialog}
openNewTaskDialog={openNewTaskDialog}
step={workflow.steps[activeStepIndex]}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import CloseIcon from '../../../../icons/CloseIcon.jsx';

const taskNames = {
'drawing': 'Drawing',
'single': 'Single Question',
'multiple': 'Multiple Answer Question',
'single': 'Single Answer Question',
'text': 'Text',
}

Expand All @@ -15,6 +16,7 @@ const DEFAULT_HANDLER = () => {};
function EditStepDialog({
allTasks = {},
deleteTask,
enforceLimitedBranchingRule,
onClose = DEFAULT_HANDLER,
openNewTaskDialog = DEFAULT_HANDLER,
step = [],
Expand Down Expand Up @@ -48,7 +50,9 @@ function EditStepDialog({

const firstTask = allTasks?.[taskKeys?.[0]]
const taskName = taskNames[firstTask?.type] || '???';
const title = `Edit ${taskName} Task`;
const title = taskKeys?.length > 1
? 'Edit A Multi-Task Page'
: `Edit ${taskName} Task`;

return (
<dialog
Expand Down Expand Up @@ -81,6 +85,7 @@ function EditStepDialog({
<EditTaskForm
key={`editTaskForm-${taskKey}`}
deleteTask={deleteTask}
enforceLimitedBranchingRule={enforceLimitedBranchingRule}
task={task}
taskKey={taskKey}
updateTask={updateTask}
Expand All @@ -91,6 +96,7 @@ function EditStepDialog({
<div className="dialog-footer flex-row">
<button
className="big flex-item"
disabled={!!enforceLimitedBranchingRule?.stepHasBranch}
onClick={handleClickAddTaskButton}
type="button"
>
Expand All @@ -111,6 +117,11 @@ function EditStepDialog({
EditStepDialog.propTypes = {
allTasks: PropTypes.object,
deleteTask: PropTypes.func,
enforceLimitedBranchingRule: PropTypes.shape({
stepHasBranch: PropTypes.bool,
stepHasOneTask: PropTypes.bool,
stepHasManyTasks: PropTypes.bool
}),
onClose: PropTypes.func,
step: PropTypes.object,
stepIndex: PropTypes.number,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import PropTypes from 'prop-types';

import SingleQuestionTask from './types/SingleQuestionTask.jsx';
import TextTask from './types/TextTask.jsx';

const taskTypes = {
'multiple': SingleQuestionTask, // Shared with single answer question task
'single': SingleQuestionTask,
'text': TextTask
};

export default function EditTaskForm({ // It's not actually a form, but a fieldset that's part of a form.
function EditTaskForm({ // It's not actually a form, but a fieldset that's part of a form.
deleteTask,
enforceLimitedBranchingRule,
task,
taskKey,
updateTask
Expand All @@ -24,6 +28,7 @@ export default function EditTaskForm({ // It's not actually a form, but a field
{(TaskForm)
? <TaskForm
deleteTask={deleteTask}
enforceLimitedBranchingRule={enforceLimitedBranchingRule}
task={task}
taskKey={taskKey}
updateTask={updateTask}
Expand All @@ -34,4 +39,16 @@ export default function EditTaskForm({ // It's not actually a form, but a field
);
}

EditTaskForm.propTypes = {
deleteTask: PropTypes.func,
enforceLimitedBranchingRule: PropTypes.shape({
stepHasBranch: PropTypes.bool,
stepHasOneTask: PropTypes.bool,
stepHasManyTasks: PropTypes.bool
}),
task: PropTypes.object,
taskKey: PropTypes.string,
updateTask: PropTypes.func
}

export default EditTaskForm;
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@ import PlusIcon from '../../../../../icons/PlusIcon.jsx';
const DEFAULT_HANDLER = () => {};

export default function SingleQuestionTask({
enforceLimitedBranchingRule,
deleteTask = DEFAULT_HANDLER,
task,
taskKey,
deleteTask = DEFAULT_HANDLER,
updateTask = DEFAULT_HANDLER
updateTask = DEFAULT_HANDLER
}) {
const [ answers, setAnswers ] = useState(task?.answers || []);
const [ help, setHelp ] = useState(task?.help || '');
const [ question, setQuestion ] = useState(task?.question || ''); // TODO: figure out if FEM is standardising Question vs Instructions
const [ required, setRequired ] = useState(!!task?.required);
const [ isMultiple, setIsMultiple ] = useState(task?.type === 'multiple');

// Update is usually called manually onBlur, after user input is complete.
function update(optionalStateOverrides) {
Expand All @@ -24,6 +26,7 @@ export default function SingleQuestionTask({

const newTask = {
...task,
type: (!isMultiple) ? 'single' : 'multiple',
answers: nonEmptyAnswers,
help,
question,
Expand Down Expand Up @@ -69,7 +72,8 @@ export default function SingleQuestionTask({

// For inputs that don't have onBlur, update triggers automagically.
// (You can't call update() in the onChange() right after setStateValue().)
useEffect(update, [required]);
// TODO: useEffect() means update() is called on the first render, which is unnecessary. Clean this up.
useEffect(update, [required, isMultiple]);

return (
<div className="single-question-task">
Expand Down Expand Up @@ -124,6 +128,20 @@ export default function SingleQuestionTask({
Required
</label>
</span>
<span className="narrow">
<input
id={`task-${taskKey}-multiple`}
type="checkbox"
checked={isMultiple}
disabled={enforceLimitedBranchingRule?.stepHasManyTasks && isMultiple /* If rule is enforced, you can't switch a Multi Question Task to a Single Question Task. */}
onChange={(e) => {
setIsMultiple(!!e?.target?.checked);
}}
/>
<label htmlFor={`task-${taskKey}-multiple`}>
Allow multiple
</label>
</span>
</div>
</div>
<div className="input-row">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default function TextTask({

// For inputs that don't have onBlur, update triggers automagically.
// (You can't call update() in the onChange() right after setStateValue().)
// TODO: useEffect() means update() is called on the first render, which is unnecessary. Clean this up.
useEffect(update, [required]);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const DEFAULT_HANDLER = () => {};

function NewTaskDialog({
addTask = DEFAULT_HANDLER,
enforceLimitedBranchingRule,
openEditStepDialog = DEFAULT_HANDLER,
stepIndex = -1
}, forwardedRef) {
Expand Down Expand Up @@ -41,15 +42,21 @@ function NewTaskDialog({

closeDialog();

if (stepIndex < 0) {
const addingTaskToNewStep = stepIndex < 0
if (addingTaskToNewStep) {
const newStepIndex = await addTask(tasktype);
openEditStepDialog(newStepIndex);
} else {

} else { // Adding task to existing Step
await addTask(tasktype, stepIndex);
openEditStepDialog(stepIndex);
}
}

// The Question Task is either a Single Answer Question Task, or a Multiple Answer Question Task.
// By default, this is 'single', but under certain conditions, a new Question Task will be created as a Multiple Answer Question Task.
const questionTaskType = (!enforceLimitedBranchingRule?.stepHasOneTask) ? 'single' : 'multiple'

return (
<dialog
aria-labelledby="dialog-title"
Expand Down Expand Up @@ -92,11 +99,11 @@ function NewTaskDialog({
<button
aria-label="Add new Question Task"
className="new-task-button"
data-tasktype="single"
data-tasktype={questionTaskType}
onClick={handleClickAddTask}
type="button"
>
<TaskIcon type='single' />
<TaskIcon type={questionTaskType} />
<span>Question</span>
</button>
<button
Expand All @@ -117,6 +124,11 @@ function NewTaskDialog({

NewTaskDialog.propTypes = {
addTask: PropTypes.func,
enforceLimitedBranchingRule: PropTypes.shape({
stepHasBranch: PropTypes.bool,
stepHasOneTask: PropTypes.bool,
stepHasManyTasks: PropTypes.bool
}),
openEditStepDialog: PropTypes.func,
stepIndex: PropTypes.number
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function SimpleNextControls({

return (
<div className="next-controls vertical-layout">
<NextStepArrow className="next-arrow" />
<NextStepArrow className="next-arrow" height="10" />
<select
className={(!stepBody?.next) ? 'next-is-submit' : ''}
onChange={onChange}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,14 @@ function StepItem({
);
})}
</ul>
{!branchingTaskKey && (
<SimpleNextControls
allSteps={allSteps}
step={step}
updateNextStepForStep={updateNextStepForStep}
/>
)}
</div>
{!branchingTaskKey && (
<SimpleNextControls
allSteps={allSteps}
step={step}
updateNextStepForStep={updateNextStepForStep}
/>
)}
<DropTarget
activeDragItem={activeDragItem}
moveStep={moveStep}
Expand Down
Loading
Loading