Skip to content

Commit

Permalink
Pages Editor: implement Branching Controls (#6991)
Browse files Browse the repository at this point in the history
* pages-editor-pt13 - prepare branching controls

* Move canStepBranch into its own function

* WIP. Add BranchingControls. Refactor how StepItem gets steps

* BranchingControls: list 'next' options for each answer

* BranchingControls: style up

* BranchingControls: style next arrows

* BracnhingControls: Implement branching choices

* TasksPage: add (hardcoded) Preview Workflow button

* StepItem: style update. Add outline when dragging

* TasksPage: fix missing rel=noopener noreferrer

* PagesEditor: replace anonymous functions with DEFAULT_HANDLER const

* SingleQuestionTask: fix accessibility. Fix Form Submit (Enter key) bug

* TextTask: fix accessibility
  • Loading branch information
shaunanoordin authored Feb 13, 2024
1 parent 0e611cf commit 38d95c3
Show file tree
Hide file tree
Showing 12 changed files with 259 additions and 66 deletions.
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
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]}`}
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;
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({
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

0 comments on commit 38d95c3

Please sign in to comment.