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: implement Branching Controls #6991

Merged
merged 13 commits into from
Feb 13, 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
40 changes: 31 additions & 9 deletions app/pages/lab-pages-editor/components/TasksPage/TasksPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import moveItemInArray from '../../helpers/moveItemInArray.js';
import EditStepDialog from './components/EditStepDialog';
import NewTaskDialog from './components/NewTaskDialog.jsx';
import StepItem from './components/StepItem';
import ExternalLinkIcon from '../../icons/ExternalLinkIcon.jsx';

export default function TasksPage() {
const { workflow, update } = useWorkflowContext();
Expand Down Expand Up @@ -83,6 +84,24 @@ export default function TasksPage() {
update({tasks});
}

// Changes the optional "next page" of a branching answer/choice
function updateAnswerNext(taskKey, answerIndex, next = undefined) {
// Check if input is valid
const task = workflow?.tasks?.[taskKey];
const answer = task?.answers[answerIndex];
if (!task || !answer) return;

const newTasks = workflow.tasks ? { ...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,
answers: newAnswers
}

update({ tasks: newTasks });
}

const previewUrl = 'https://frontend.preview.zooniverse.org/projects/darkeshard/example-1982/classify/workflow/3711?env=staging';
if (!workflow) return null;

return (
Expand All @@ -102,26 +121,29 @@ export default function TasksPage() {
>
Add a new Task
</button>
{/* Dev observation: the <select> should have some label to indicate it's for choosing the starting task. */}
<select
aria-label="Choose starting page"
className="flex-item"
<a
Fixed Show fixed Hide fixed
className="flex-item button-link"
href={previewUrl}
rel="noopener noreferrer"
target='_blank'
>
<option disabled>Choose starting Page</option>
</select>
Preview Workflow <ExternalLinkIcon />
</a>
</div>
<ul className="steps-list" aria-label="Pages/Steps">
{workflow.steps.map(([stepKey, step], index) => (
{workflow.steps.map((step, index) => (
<StepItem
key={`stepItem-${stepKey}`}
key={`stepItem-${step[0]}`}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

camelCase vs kebab-case

activeDragItem={activeDragItem}
allSteps={workflow.steps}
allTasks={workflow.tasks}
editStep={editStep}
moveStep={moveStep}
setActiveDragItem={setActiveDragItem}
step={step}
stepKey={stepKey}
stepKey={step[0]}
stepIndex={index}
updateAnswerNext={updateAnswerNext}
/>
))}
</ul>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { useEffect, useState } from 'react';
import MinusIcon from '../../../../../icons/MinusIcon.jsx';
import PlusIcon from '../../../../../icons/PlusIcon.jsx';

const DEFAULT_HANDLER = () => {};

export default function SingleQuestionTask({
task,
taskKey,
updateTask = () => {}
updateTask = DEFAULT_HANDLER
}) {
const [ answers, setAnswers ] = useState(task?.answers || []);
const [ help, setHelp ] = useState(task?.help || '');
Expand Down Expand Up @@ -47,6 +49,7 @@ export default function SingleQuestionTask({
}

function deleteAnswer(e) {
console.log('+++ deleteAnswer', e?.target)
const index = e?.target?.dataset?.index;
if (index === undefined || index < 0 || index >= answers.length) return;

Expand Down Expand Up @@ -76,7 +79,7 @@ export default function SingleQuestionTask({
<span className="task-key">{taskKey}</span>
<input
className="flex-item"
id={`task-${taskKey}-question`}
id={`task-${taskKey}-instruction`}
type="text"
value={question}
onBlur={update}
Expand All @@ -86,7 +89,7 @@ export default function SingleQuestionTask({
{/* <button>Delete</button> */}
</div>
<div className="input-row">
<label className="big">Choices</label>
<span className="big">Choices</span>
<div className="flex-row">
<button
aria-label="Add choice"
Expand All @@ -96,29 +99,30 @@ export default function SingleQuestionTask({
>
<PlusIcon />
</button>
<label className="narrow">
<span className="narrow">
<input
id={`task-${taskKey}-required`}
type="checkbox"
checked={required}
onChange={(e) => {
setRequired(!!e?.target?.checked);
}}
/>
<span>
<label htmlFor={`task-${taskKey}-required`}>
Required
</span>
</label>
</label>
</span>
</div>
</div>
<div className="input-row">
<ul>
{answers.map(({ label, next }, index) => (
<li
aria-label={`Choice ${index}`}
className="flex-row"
key={`single-question-task-answer-${index}`}
>
<input
aria-label={`Choice ${index}`}
className="flex-item"
onChange={editAnswer}
onBlur={update}
Expand All @@ -131,6 +135,7 @@ export default function SingleQuestionTask({
onClick={deleteAnswer}
className="big"
data-index={index}
type="button"
>
<MinusIcon data-index={index} />
</button>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react';

const DEFAULT_HANDLER = () => {};

export default function TextTask({
task,
taskKey,
updateTask = () => {}
updateTask = DEFAULT_HANDLER
}) {
const [ help, setHelp ] = useState(task?.help || '');
const [ instruction, setInstruction ] = useState(task?.instruction || '');
Expand Down Expand Up @@ -47,18 +49,19 @@ export default function TextTask({
{/* <button>Delete</button> */}
</div>
<div className="input-row">
<label className="narrow">
<span className="narrow">
<input
id={`task-${taskKey}-required`}
type="checkbox"
checked={required}
onChange={(e) => {
setRequired(!!e?.target?.checked);
}}
/>
<span>
<label htmlFor={`task-${taskKey}-required`}>
Required
</span>
</label>
</label>
</span>
</div>
<div className="input-row">
<label
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import CloseIcon from '../../../icons/CloseIcon.jsx';
import TaskIcon from '../../../icons/TaskIcon.jsx';
// import strings from '../../../strings.json'; // TODO: move all text into strings

const DEFAULT_HANDLER = () => {};

function NewTaskDialog({
addTaskWithStep = () => {},
editStep = () => {}
addTaskWithStep = DEFAULT_HANDLER,
editStep = DEFAULT_HANDLER
}, forwardedRef) {
const newTaskDialog = useRef(null);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
const DEFAULT_HANDLER = () => {};

export default function BranchingControls({
allSteps = [],
task,
taskKey,
updateAnswerNext = DEFAULT_HANDLER
}) {
if (!task || !taskKey) return null;

const answers = task.answers || []

function onChange(e) {
const next = e.target?.value;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be e?. or e. between this line and the below line.

const index = e?.target?.dataset.index;
updateAnswerNext(taskKey, index, next);
}

return (
<ul className="branching-controls">
{answers.map((answer, index) => (
<li key={`branching-controls-answer-${index}`}>
<div className="fake-button">{answer.label}</div>
<NextStepArrow className="next-arrow" />
<select
className={(!answer?.next) ? 'next-is-submit' : ''}
data-index={index}
onChange={onChange}
value={answer?.next || ''}
>
<option
value={''}
>
Submit
</option>
{allSteps.map(([stepKey, stepBody]) => {
const taskKeys = stepBody?.taskKeys?.toString() || '(none)';
return (
<option
key={`branching-controls-answer-${index}-option-${stepKey}`}
value={stepKey}
>
{taskKeys}
</option>
);
})}
</select>
</li>
))}
</ul>
);
}

function NextStepArrow({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be pulled out as an Icon / it's own file so it's usable in other parts of the codebase (if needed)

alt,
className = 'icon',
color = 'currentColor',
height = 48,
pad = 4,
strokeWidth = 2,
width = 16
}) {
const xA = 0 + pad;
const xB = width * 0.5;
const xC = width - pad;
const yA = 0 + pad;
const yB = height - (width / 2);
const yC = height - pad;

return (
<svg aria-label={alt} width={width} height={height} className={className}>
<g stroke={color} strokeWidth={strokeWidth}>
<line x1={xB} y1={yA} x2={xB} y2={yC} />
<line x1={xA} y1={yB} x2={xB} y2={yC} />
<line x1={xC} y1={yB} x2={xB} y2={yC} />
</g>
</svg>
);
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useState } from 'react';
import PropTypes from 'prop-types';

const DEFAULT_HANDLER = () => {};

function DropTarget({
activeDragItem = -1,
moveStep = () => {},
setActiveDragItem = () => {},
moveStep = DEFAULT_HANDLER,
setActiveDragItem = DEFAULT_HANDLER,
targetIndex = 0
}) {
const [active, setActive] = useState(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,33 @@ import PropTypes from 'prop-types';
import DropTarget from './DropTarget.jsx';
import TaskItem from './TaskItem.jsx';

import canStepBranch from '../../../../helpers/canStepBranch.js';

import BranchingControls from './BranchingControls.jsx';
import CopyIcon from '../../../../icons/CopyIcon.jsx';
import DeleteIcon from '../../../../icons/DeleteIcon.jsx';
import EditIcon from '../../../../icons/EditIcon.jsx';
import GripIcon from '../../../../icons/GripIcon.jsx';
import MoveDownIcon from '../../../../icons/MoveDownIcon.jsx';
import MoveUpIcon from '../../../../icons/MoveUpIcon.jsx';

const DEFAULT_HANDLER = () => {};

function StepItem({
activeDragItem = -1,
allSteps,
allTasks,
editStep = () => {},
moveStep = () => {},
setActiveDragItem = () => {},
editStep = DEFAULT_HANDLER,
moveStep = DEFAULT_HANDLER,
setActiveDragItem = DEFAULT_HANDLER,
step,
stepKey,
stepIndex
stepIndex,
updateAnswerNext = DEFAULT_HANDLER
}) {
if (!step || !stepKey || !allTasks) return <li className="step-item">ERROR: could not render Step</li>;
const [stepKey, stepBody] = step || [];
if (!stepKey || !stepBody || !allSteps || !allTasks) return <li className="step-item">ERROR: could not render Step</li>;

const taskKeys = step.taskKeys || [];
const taskKeys = stepBody.taskKeys || [];

function edit() {
editStep(stepIndex);
Expand All @@ -43,6 +50,9 @@ function StepItem({
setActiveDragItem(stepIndex); // Use state because DropTarget's onDragEnter CAN'T read dragEvent.dataTransfer.getData()
}

const branchingTaskKey = canStepBranch(step, allTasks);
const branchingTask = allTasks?.[branchingTaskKey];

return (
<li className="step-item">
{(stepIndex === 0)
Expand Down Expand Up @@ -114,6 +124,14 @@ function StepItem({
);
})}
</ul>
{branchingTask && (
<BranchingControls
allSteps={allSteps}
task={branchingTask}
taskKey={branchingTaskKey}
updateAnswerNext={updateAnswerNext}
/>
)}
</div>
<DropTarget
activeDragItem={activeDragItem}
Expand All @@ -127,13 +145,14 @@ function StepItem({

StepItem.propTypes = {
activeDragItem: PropTypes.number,
allSteps: PropTypes.array,
allTasks: PropTypes.object,
editStep: PropTypes.func,
moveStep: PropTypes.func,
setActiveDragItem: PropTypes.func,
step: PropTypes.object,
stepKey: PropTypes.string,
stepIndex: PropTypes.number
step: PropTypes.array,
stepIndex: PropTypes.number,
updateAnswerNext: PropTypes.func
};

export default StepItem;
Loading
Loading