From 7106301e7ed85c34785010d9fba678171830b192 Mon Sep 17 00:00:00 2001 From: Gabriel Morador Date: Tue, 23 Jun 2026 15:26:34 -0300 Subject: [PATCH 1/7] hide and reveal frontend --- .../components/AssignmentTypeSelector.tsx | 63 +++ .../components/CheckpointSelector.tsx | 69 +++ .../components/DueDateSelector.tsx | 66 +++ .../components/FilePickerApp.tsx | 450 ++++++++++++------ .../test/AssignmentTypeSelector-test.js | 65 +++ .../test/CheckpointSelector-test.js | 76 +++ .../components/test/DueDateSelector-test.js | 87 ++++ .../components/test/FilePickerApp-test.js | 164 +++++++ lms/static/scripts/frontend_apps/config.ts | 10 + 9 files changed, 907 insertions(+), 143 deletions(-) create mode 100644 lms/static/scripts/frontend_apps/components/AssignmentTypeSelector.tsx create mode 100644 lms/static/scripts/frontend_apps/components/CheckpointSelector.tsx create mode 100644 lms/static/scripts/frontend_apps/components/DueDateSelector.tsx create mode 100644 lms/static/scripts/frontend_apps/components/test/AssignmentTypeSelector-test.js create mode 100644 lms/static/scripts/frontend_apps/components/test/CheckpointSelector-test.js create mode 100644 lms/static/scripts/frontend_apps/components/test/DueDateSelector-test.js diff --git a/lms/static/scripts/frontend_apps/components/AssignmentTypeSelector.tsx b/lms/static/scripts/frontend_apps/components/AssignmentTypeSelector.tsx new file mode 100644 index 0000000000..c7ea7a8726 --- /dev/null +++ b/lms/static/scripts/frontend_apps/components/AssignmentTypeSelector.tsx @@ -0,0 +1,63 @@ +import { RadioGroup } from '@hypothesis/frontend-shared'; + +/** + * The kind of assignment being created. + * + * - `reading`: a standard "Social annotation" reading assignment. + * - `hide_and_reveal`: "Guided Social annotation" — students' annotations are + * hidden from each other until an instructor reveals them (internally also + * referred to as "Hide & Reveal" / checkpoints). + */ +export type AssignmentType = 'reading' | 'hide_and_reveal'; + +/** Human-readable label shown in the selector for each assignment type. */ +const ASSIGNMENT_TYPE_LABELS: Record = { + reading: 'Social annotation', + hide_and_reveal: 'Guided Social annotation', +}; + +export type AssignmentTypeSelectorProps = { + /** + * Assignment types the instructor can choose from, in display order. Decided + * by the backend. A new type also needs an entry in the `AssignmentType` + * union and in `ASSIGNMENT_TYPE_LABELS` to render with a proper label. + */ + types: AssignmentType[]; + selected: AssignmentType; + onChange: (type: AssignmentType) => void; +}; + +/** + * First step of the assignment-type workflow: lets instructors choose which + * kind of assignment they are creating among the available `types`. + */ +export default function AssignmentTypeSelector({ + types, + selected, + onChange, +}: AssignmentTypeSelectorProps) { + return ( +
+ + {types.map(type => { + // Fall back to the raw key if the backend sends a type this frontend + // build doesn't know yet (deploy ordering), so the radio is never + // blank. Statically unreachable, but `type` is backend JSON at runtime. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const label = ASSIGNMENT_TYPE_LABELS[type] ?? type; + return ( + + {label} + + ); + })} + +
+ ); +} diff --git a/lms/static/scripts/frontend_apps/components/CheckpointSelector.tsx b/lms/static/scripts/frontend_apps/components/CheckpointSelector.tsx new file mode 100644 index 0000000000..7a6f70c47e --- /dev/null +++ b/lms/static/scripts/frontend_apps/components/CheckpointSelector.tsx @@ -0,0 +1,69 @@ +import { RadioGroup } from '@hypothesis/frontend-shared'; +import { useId } from 'preact/hooks'; + +/** + * The kind of checkpoint that controls when hidden annotations are revealed. + * + * For now only `manual` is functional (the instructor reveals annotations + * themselves); more options (e.g. a calendar/date-driven checkpoint) are + * planned. + */ +export type CheckpointType = 'manual'; + +/** Values rendered as radios, including not-yet-available options. */ +type CheckpointOption = CheckpointType | 'more'; + +export type CheckpointSelectorProps = { + selected: CheckpointType; + onChange: (type: CheckpointType) => void; +}; + +/** + * Second step of the "Hide & Reveal" workflow: lets instructors choose how the + * checkpoint (the point at which hidden annotations are revealed) works. + */ +export default function CheckpointSelector({ + selected, + onChange, +}: CheckpointSelectorProps) { + const headingId = useId(); + + // The note below is specific to the "manual" reveal, so it only shows for that + // option. `selected` is currently always 'manual' (the only enabled option), + // but this keeps the association explicit for when more types are added. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const showManualNote = selected === 'manual'; + + return ( +
+

+ Checkpoint +

+ + data-testid="checkpoint-radio-group" + aria-labelledby={headingId} + direction="vertical" + selected={selected} + onChange={option => { + // Ignore the disabled "coming soon" placeholder; anything else is a + // real CheckpointType. Adding a new type needs no change here. + if (option !== 'more') { + onChange(option); + } + }} + > + Manual + + More coming soon + + + {showManualNote && ( + // No color class: inherits the base text color (black) per design. +

+ Students will see when the settings have changed from + “Hide” to “Reveal” in their notifications. +

+ )} +
+ ); +} diff --git a/lms/static/scripts/frontend_apps/components/DueDateSelector.tsx b/lms/static/scripts/frontend_apps/components/DueDateSelector.tsx new file mode 100644 index 0000000000..759b13e000 --- /dev/null +++ b/lms/static/scripts/frontend_apps/components/DueDateSelector.tsx @@ -0,0 +1,66 @@ +import { IconButton, InfoIcon, Popover } from '@hypothesis/frontend-shared'; +import { useId, useRef, useState } from 'preact/hooks'; + +export type DueDateSelectorProps = { + /** Currently selected due date as an ISO date string (`YYYY-MM-DD`), or null. */ + dueDate: string | null; + onChange: (dueDate: string | null) => void; +}; + +/** + * Third step of the "Hide & Reveal" workflow: lets instructors pick the due + * date, the point at which annotations are no longer tallied in auto grading. + */ +export default function DueDateSelector({ + dueDate, + onChange, +}: DueDateSelectorProps) { + const headingId = useId(); + + // Explanation of the due date, shown in a tooltip (anchored to the info icon) + // rather than inline. Mirrors the "Max points" popover in FilePickerApp. + const infoIconRef = useRef(null); + const [infoPopoverOpen, setInfoPopoverOpen] = useState(false); + + return ( +
+
+

+ Due Date +

+ setInfoPopoverOpen(open => !open)} + expanded={infoPopoverOpen} + elementRef={infoIconRef} + classes="text-[16px]" + /> + setInfoPopoverOpen(false)} + classes="p-2" + placement="above" + arrow + > + The point where annotations are no longer tallied in auto grading. + +
+ { + const { value } = e.target as HTMLInputElement; + onChange(value === '' ? null : value); + }} + /> +
+ ); +} diff --git a/lms/static/scripts/frontend_apps/components/FilePickerApp.tsx b/lms/static/scripts/frontend_apps/components/FilePickerApp.tsx index 642afaa698..5ee2a280ab 100644 --- a/lms/static/scripts/frontend_apps/components/FilePickerApp.tsx +++ b/lms/static/scripts/frontend_apps/components/FilePickerApp.tsx @@ -32,9 +32,14 @@ import { apiCall } from '../utils/api'; import type { Content, URLContent } from '../utils/content-item'; import { truncateURL } from '../utils/format'; import { useUniqueId } from '../utils/hooks'; +import type { AssignmentType } from './AssignmentTypeSelector'; +import AssignmentTypeSelector from './AssignmentTypeSelector'; import type { AutoGradingConfig } from './AutoGradingConfigurator'; import AutoGradingConfigurator from './AutoGradingConfigurator'; +import type { CheckpointType } from './CheckpointSelector'; +import CheckpointSelector from './CheckpointSelector'; import ContentSelector from './ContentSelector'; +import DueDateSelector from './DueDateSelector'; import ErrorModal from './ErrorModal'; import FilePickerFormFields from './FilePickerFormFields'; import GroupConfigSelector from './GroupConfigSelector'; @@ -53,11 +58,27 @@ export type FilePickerAppProps = { /* A step or 'screen' of the assignment configuration */ type PickerStep = + // First screen (only shown when more than one assignment type is available) + // where the instructor picks the type of assignment they are creating. + | 'assignment-type' + // "Hide & Reveal" screens, only shown when that assignment type is chosen. + | 'checkpoint' + | 'due-date' | 'content-selection' // Final screen where the settings for the assignment are shown, and also // additional settings which don't need a whole screen. | 'details'; +/** + * Sub-steps of the assignment-type workflow shown before the regular file + * picker flow. These are the `PickerStep`s that precede content selection, plus + * `done`, which means the workflow has been completed (or skipped) and the + * regular flow takes over. Derived from `PickerStep` so the two stay in sync. + */ +type WorkflowStep = + | Exclude + | 'done'; + /** * For URL content, show the most meaningful explanation of the content we can * to the user. In cases where we have a filename (name), show that. For @@ -188,6 +209,7 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { assignment, filePicker: { autoGradingEnabled, + assignmentTypes, deepLinkingAPI, formAction, formFields, @@ -196,6 +218,19 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { }, } = useConfig(['api', 'filePicker']); + // Assignment types the instructor can choose from. `reading` is always + // available; other types (e.g. `hide_and_reveal`) are gated by the backend + // via a per-install feature flag. Until the backend sends `assignmentTypes`, + // we fall back to `reading` only, which keeps the type workflow dormant. + const availableAssignmentTypes = assignmentTypes ?? ['reading']; + + // The multi-step type workflow (type selection + any type-specific sub-steps) + // is only worth showing when there is more than one type to pick from. With a + // single type there is nothing to choose, so we skip straight to the regular + // flow. This intentionally does *not* depend on any single type being enabled, + // so adding a new type keeps the workflow working without changes here. + const enableTypeWorkflow = availableAssignmentTypes.length > 1; + // Currently selected content for assignment. const [content, setContent] = useState( assignment ? contentFromURL(assignment.document.url) : null, @@ -231,6 +266,58 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { // True if we are editing an existing assignment configuration. const isEditing = !!assignment; + // Type of assignment being created, chosen in the first ("assignment-type") + // step of the workflow. Defaults to the first available type so it is always + // one the backend actually offers. Only relevant when `enableTypeWorkflow`. + const [assignmentType, setAssignmentType] = useState( + availableAssignmentTypes[0] ?? 'reading', + ); + // Checkpoint configuration for "Hide & Reveal" assignments. + const [checkpointType, setCheckpointType] = + useState('manual'); + const [dueDate, setDueDate] = useState(null); + + // A checkpoint ("Hide & Reveal") assignment is being created when the + // instructor picked that type in the workflow. This drives the + // `checkpoint_enabled` field the backend persists. + const checkpointEnabled = assignmentType === 'hide_and_reveal'; + // Current sub-step of the assignment-type workflow. When the workflow isn't + // enabled we start as `done` so it is skipped entirely. + const [workflowStep, setWorkflowStep] = useState( + enableTypeWorkflow ? 'assignment-type' : 'done', + ); + + // Advance to the next sub-step of the workflow. Only "hide_and_reveal" has + // further steps (checkpoint, due-date); other types go straight to the + // regular flow. + const goToNextWorkflowStep = () => { + setWorkflowStep(step => { + switch (step) { + case 'assignment-type': + return assignmentType === 'hide_and_reveal' ? 'checkpoint' : 'done'; + case 'checkpoint': + return 'due-date'; + default: + return 'done'; + } + }); + }; + + // Go back to the previous sub-step of the workflow. Only ever invoked from + // the 'checkpoint' and 'due-date' steps (the "Back" button is + // hidden on the first step). Selections made in later steps are kept in state, + // so they survive going back and forth. + const goToPreviousWorkflowStep = () => { + setWorkflowStep(step => + step === 'due-date' ? 'checkpoint' : 'assignment-type', + ); + }; + + // Jump straight back to the assignment-mode selection from anywhere in the + // Guided ("Hide & Reveal") sub-steps, via the close button in the card header. + // Selections made so far are kept in state. + const returnToModeSelection = () => setWorkflowStep('assignment-type'); + // Whether there are additional configuration options to present after the // user has selected the content for the assignment. const showDetailsScreen = @@ -240,7 +327,11 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { autoGradingEnabled; let currentStep: PickerStep; - if (editingContent) { + if (enableTypeWorkflow && workflowStep !== 'done' && !isEditing) { + // While the assignment-type workflow is in progress, its current sub-step + // (which is a subset of PickerStep) is the active step. + currentStep = workflowStep; + } else if (editingContent) { currentStep = 'content-selection'; } else if (isEditing) { currentStep = 'details'; @@ -249,6 +340,28 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { content && showDetailsScreen ? 'details' : 'content-selection'; } + // Whether the current step belongs to the assignment-type workflow shown + // before the regular file picker flow. + const inTypeWorkflow = + currentStep === 'assignment-type' || + currentStep === 'checkpoint' || + currentStep === 'due-date'; + + // The first workflow step has nothing before it, so "Back" is only offered on + // later steps. + const canGoBackInWorkflow = + currentStep === 'checkpoint' || currentStep === 'due-date'; + + // Title shown in the card header, which changes depending on the current step. + const stepTitles: Record = { + 'assignment-type': 'Assignment mode', + checkpoint: 'Guided Social Annotation', + 'due-date': 'Guided Social Annotation', + 'content-selection': 'Assignment details', + details: 'Assignment details', + }; + const cardTitle = stepTitles[currentStep]; + const [groupConfig, setGroupConfig] = useState({ useGroupSet: !!assignment?.group_set_id, groupSet: assignment?.group_set_id ?? null, @@ -421,158 +534,209 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { )} {/* Card constrains overflow-scroll children to height constraints */} - + - {/* 1-col grid for very narrow screens; 2-col for everyone else */} -
- Select content for your assignment

} - isCurrentStep={currentStep === 'content-selection'} - > - Assignment content -
- -
- {content && currentStep !== 'content-selection' ? ( -
- - {contentDescription(content)} - - setEditingContent(true)} - data-testid="edit-content" - title="Change assignment content" - underline="always" - > - Change - -
- ) : ( - + {currentStep === 'assignment-type' && ( + )} + {currentStep === 'checkpoint' && ( + + )} + {currentStep === 'due-date' && ( + + )}
- {currentStep === 'details' && ( - <> - {typeof title === 'string' && ( - <> -
- - Title - - - setTitle((e.target as HTMLInputElement).value) - } - required - value={title} - /> - - )} - {promptForGradable && ( - <> -
- -
- Max points - - setMaxPointsPopoverOpen(open => !open) - } - expanded={maxPointsPopoverOpen} - elementRef={iconRef} - // Align right side of the icon with the right - // edge of the text labels above and below. - // Do it by setting negative margin that - // compensates for the button's padding. - classes="text-[16px] -mr-2 touch:-mr-[12px]" - /> -
-
- - setAssignmentGradableMaxPoints( - (e.target as HTMLInputElement).value, - ) - } - /> - setMaxPointsPopoverOpen(false)} - classes="p-2" - placement="above" - arrow + ) : ( + /* 1-col grid for very narrow screens; 2-col for everyone else */ +
+ Select content for your assignment

} + isCurrentStep={currentStep === 'content-selection'} + > + Assignment content +
+ +
+ {content && currentStep !== 'content-selection' ? ( +
+ -
- Optionally add a max points value here instead of - using your LMS grading settings. - - Learn more about our max points feature - -
- - - )} - - {autoGradingEnabled && ( - <> -
- Auto grading - - - )} - {enableGroupConfig && ( - <> -
- Group assignment -
element room when it renders (avoid - // changing the height of the Card later) - 'h-28', - )} + {contentDescription(content)} + + setEditingContent(true)} + data-testid="edit-content" + title="Change assignment content" + underline="always" > - -
- + Change + +
+ ) : ( + )} - - )} -
+
+ {currentStep === 'details' && ( + <> + {typeof title === 'string' && ( + <> +
+ + Title + + + setTitle((e.target as HTMLInputElement).value) + } + required + value={title} + /> + + )} + {promptForGradable && ( + <> +
+ +
+ Max points + + setMaxPointsPopoverOpen(open => !open) + } + expanded={maxPointsPopoverOpen} + elementRef={iconRef} + // Align right side of the icon with the right + // edge of the text labels above and below. + // Do it by setting negative margin that + // compensates for the button's padding. + classes="text-[16px] -mr-2 touch:-mr-[12px]" + /> +
+
+ + setAssignmentGradableMaxPoints( + (e.target as HTMLInputElement).value, + ) + } + /> + setMaxPointsPopoverOpen(false)} + classes="p-2" + placement="above" + arrow + > +
+ Optionally add a max points value here instead of + using your LMS grading settings. + + Learn more about our max points feature + +
+
+ + )} + + {autoGradingEnabled && ( + <> +
+ Auto grading + + + )} + {enableGroupConfig && ( + <> +
+ + Group assignment + +
element room when it renders (avoid + // changing the height of the Card later) + 'h-28', + )} + > + +
+ + )} + + )} +
+ )} + {inTypeWorkflow && ( + + + {canGoBackInWorkflow && ( + + )} + + + + )} { // See comments in `selectContent` about auto-submitting form. (editingContent || currentStep === 'details') && ( diff --git a/lms/static/scripts/frontend_apps/components/test/AssignmentTypeSelector-test.js b/lms/static/scripts/frontend_apps/components/test/AssignmentTypeSelector-test.js new file mode 100644 index 0000000000..8a0f21806b --- /dev/null +++ b/lms/static/scripts/frontend_apps/components/test/AssignmentTypeSelector-test.js @@ -0,0 +1,65 @@ +import { RadioGroup } from '@hypothesis/frontend-shared'; +import { checkAccessibility, mount } from '@hypothesis/frontend-testing'; +import { act } from 'preact/test-utils'; + +import AssignmentTypeSelector from '../AssignmentTypeSelector'; + +describe('AssignmentTypeSelector', () => { + let fakeOnChange; + + beforeEach(() => { + fakeOnChange = sinon.stub(); + }); + + function createComponent( + selected = 'reading', + types = ['reading', 'hide_and_reveal'], + ) { + return mount( + , + ); + } + + it('reflects the selected assignment type', () => { + const wrapper = createComponent('hide_and_reveal'); + assert.equal( + wrapper.find('RadioGroup').prop('selected'), + 'hide_and_reveal', + ); + }); + + it('renders a radio option for each available type', () => { + const wrapper = createComponent('reading', ['reading', 'hide_and_reveal']); + const values = wrapper + .find(RadioGroup.Radio) + .map(radio => radio.prop('value')); + assert.deepEqual(values, ['reading', 'hide_and_reveal']); + }); + + it('only renders the available types', () => { + const wrapper = createComponent('reading', ['reading']); + const values = wrapper + .find(RadioGroup.Radio) + .map(radio => radio.prop('value')); + assert.deepEqual(values, ['reading']); + }); + + it('invokes onChange when a different type is selected', () => { + const wrapper = createComponent('reading'); + + act(() => wrapper.find('RadioGroup').props().onChange('hide_and_reveal')); + + assert.calledWith(fakeOnChange, 'hide_and_reveal'); + }); + + it( + 'should pass a11y checks', + checkAccessibility({ + content: () => createComponent(), + }), + ); +}); diff --git a/lms/static/scripts/frontend_apps/components/test/CheckpointSelector-test.js b/lms/static/scripts/frontend_apps/components/test/CheckpointSelector-test.js new file mode 100644 index 0000000000..51a12eb35c --- /dev/null +++ b/lms/static/scripts/frontend_apps/components/test/CheckpointSelector-test.js @@ -0,0 +1,76 @@ +import { checkAccessibility, mount } from '@hypothesis/frontend-testing'; +import { act } from 'preact/test-utils'; + +import CheckpointSelector from '../CheckpointSelector'; + +describe('CheckpointSelector', () => { + let fakeOnChange; + + beforeEach(() => { + fakeOnChange = sinon.stub(); + }); + + function createComponent(selected = 'manual') { + return mount( + , + ); + } + + it('reflects the selected checkpoint type', () => { + const wrapper = createComponent('manual'); + assert.equal(wrapper.find('RadioGroup').prop('selected'), 'manual'); + }); + + it('invokes onChange when "manual" is selected', () => { + const wrapper = createComponent(); + + act(() => wrapper.find('RadioGroup').props().onChange('manual')); + + assert.calledWith(fakeOnChange, 'manual'); + }); + + it('ignores selection of not-yet-available options', () => { + const wrapper = createComponent(); + + act(() => wrapper.find('RadioGroup').props().onChange('more')); + + assert.notCalled(fakeOnChange); + }); + + it('passes through any real (non-placeholder) checkpoint type', () => { + const wrapper = createComponent(); + + // A future CheckpointType (anything other than the "more" placeholder) + // should propagate without changing the guard. + act(() => wrapper.find('RadioGroup').props().onChange('automatic')); + + assert.calledWith(fakeOnChange, 'automatic'); + }); + + it('shows the reveal note when "manual" is selected', () => { + const wrapper = createComponent('manual'); + + assert.include( + wrapper.text(), + 'Students will see when the settings have changed', + ); + }); + + it('hides the reveal note when a non-manual option is selected', () => { + // `selected` is typed `'manual'` today (the only option), so this exercises + // the conditional that will matter once more checkpoint types exist. + const wrapper = createComponent('more'); + + assert.notInclude( + wrapper.text(), + 'Students will see when the settings have changed', + ); + }); + + it( + 'should pass a11y checks', + checkAccessibility({ + content: () => createComponent(), + }), + ); +}); diff --git a/lms/static/scripts/frontend_apps/components/test/DueDateSelector-test.js b/lms/static/scripts/frontend_apps/components/test/DueDateSelector-test.js new file mode 100644 index 0000000000..ab399ee09f --- /dev/null +++ b/lms/static/scripts/frontend_apps/components/test/DueDateSelector-test.js @@ -0,0 +1,87 @@ +import { checkAccessibility, mount } from '@hypothesis/frontend-testing'; +import { act } from 'preact/test-utils'; + +import DueDateSelector from '../DueDateSelector'; + +describe('DueDateSelector', () => { + let fakeOnChange; + + beforeEach(() => { + fakeOnChange = sinon.stub(); + }); + + function createComponent(dueDate = null) { + return mount( + , + // Connect to the DOM so the info `Popover` (which uses the native popover + // API) can toggle. + { connected: true }, + ); + } + + const dateInput = wrapper => + wrapper.find('input[data-testid="due-date-input"]'); + + it('reflects the selected due date', () => { + const wrapper = createComponent('2026-06-11'); + assert.equal(dateInput(wrapper).prop('value'), '2026-06-11'); + }); + + it('renders an empty value when no due date is set', () => { + const wrapper = createComponent(null); + assert.equal(dateInput(wrapper).prop('value'), ''); + }); + + it('invokes onChange with the selected date', () => { + const wrapper = createComponent(); + + act(() => + dateInput(wrapper) + .props() + .onChange({ target: { value: '2026-06-11' } }), + ); + + assert.calledWith(fakeOnChange, '2026-06-11'); + }); + + it('invokes onChange with null when the date is cleared', () => { + const wrapper = createComponent('2026-06-11'); + + act(() => + dateInput(wrapper) + .props() + .onChange({ target: { value: '' } }), + ); + + assert.calledWith(fakeOnChange, null); + }); + + it('shows the due date explanation in a popover instead of inline', () => { + const wrapper = createComponent(); + const popover = () => wrapper.find('Popover'); + const explanation = + 'The point where annotations are no longer tallied in auto grading.'; + + // The explanation is not rendered inline, only inside the (closed) popover. + assert.isFalse(popover().prop('open')); + + // Clicking the info icon opens the popover with the explanation. + act(() => wrapper.find('IconButton').props().onClick()); + wrapper.update(); + + assert.isTrue(popover().prop('open')); + assert.include(popover().text(), explanation); + + // The popover can be dismissed. + act(() => popover().props().onClose()); + wrapper.update(); + assert.isFalse(popover().prop('open')); + }); + + it( + 'should pass a11y checks', + checkAccessibility({ + content: () => createComponent(), + }), + ); +}); diff --git a/lms/static/scripts/frontend_apps/components/test/FilePickerApp-test.js b/lms/static/scripts/frontend_apps/components/test/FilePickerApp-test.js index b55b0a630d..eebca5b554 100644 --- a/lms/static/scripts/frontend_apps/components/test/FilePickerApp-test.js +++ b/lms/static/scripts/frontend_apps/components/test/FilePickerApp-test.js @@ -53,6 +53,7 @@ describe('FilePickerApp', () => { formFields: { hidden_field: 'hidden_value' }, promptForTitle: false, promptForGradable: false, + assignmentTypes: ['reading'], }, }; @@ -112,6 +113,169 @@ describe('FilePickerApp', () => { assert.isTrue(wrapper.exists('ContentSelector')); }); + describe('assignment-type workflow', () => { + function clickNext(wrapper) { + interact(wrapper, () => { + wrapper + .find('Button[data-testid="workflow-next-button"]') + .props() + .onClick(); + }); + } + + function clickBack(wrapper) { + interact(wrapper, () => { + wrapper + .find('Button[data-testid="workflow-back-button"]') + .props() + .onClick(); + }); + } + + function selectAssignmentType(wrapper, type) { + interact(wrapper, () => { + wrapper.find('AssignmentTypeSelector').props().onChange(type); + }); + } + + it('does not show the workflow when only one type is available', () => { + fakeConfig.filePicker.assignmentTypes = ['reading']; + const wrapper = renderFilePicker(); + + assert.isFalse(wrapper.exists('AssignmentTypeSelector')); + assert.isTrue(wrapper.exists('ContentSelector')); + }); + + it('shows the assignment-type step first when several types are available', () => { + fakeConfig.filePicker.assignmentTypes = ['reading', 'hide_and_reveal']; + const wrapper = renderFilePicker(); + + assert.isTrue(wrapper.exists('AssignmentTypeSelector')); + // The content selector is not shown until the workflow is complete. + assert.isFalse(wrapper.exists('ContentSelector')); + }); + + it('skips to content selection for a "reading" assignment', () => { + fakeConfig.filePicker.assignmentTypes = ['reading', 'hide_and_reveal']; + const wrapper = renderFilePicker(); + + // The default assignment type is "reading". + clickNext(wrapper); + + assert.isFalse(wrapper.exists('AssignmentTypeSelector')); + assert.isFalse(wrapper.exists('CheckpointSelector')); + assert.isTrue(wrapper.exists('ContentSelector')); + }); + + it('walks through checkpoint and due-date steps for "Hide & Reveal"', () => { + fakeConfig.filePicker.assignmentTypes = ['reading', 'hide_and_reveal']; + const wrapper = renderFilePicker(); + + selectAssignmentType(wrapper, 'hide_and_reveal'); + clickNext(wrapper); + + // Checkpoint step. + assert.isTrue(wrapper.exists('CheckpointSelector')); + assert.isFalse(wrapper.exists('ContentSelector')); + clickNext(wrapper); + + // Due-date step. + assert.isTrue(wrapper.exists('DueDateSelector')); + assert.isFalse(wrapper.exists('ContentSelector')); + clickNext(wrapper); + + // Regular flow takes over. + assert.isFalse(wrapper.exists('DueDateSelector')); + assert.isTrue(wrapper.exists('ContentSelector')); + }); + + it('does not offer a "Back" button on the first step', () => { + fakeConfig.filePicker.assignmentTypes = ['reading', 'hide_and_reveal']; + const wrapper = renderFilePicker(); + + assert.isTrue(wrapper.exists('AssignmentTypeSelector')); + assert.isFalse( + wrapper.exists('Button[data-testid="workflow-back-button"]'), + ); + }); + + it('goes back through the "Hide & Reveal" steps', () => { + fakeConfig.filePicker.assignmentTypes = ['reading', 'hide_and_reveal']; + const wrapper = renderFilePicker(); + + selectAssignmentType(wrapper, 'hide_and_reveal'); + clickNext(wrapper); // -> checkpoint + clickNext(wrapper); // -> due-date + assert.isTrue(wrapper.exists('DueDateSelector')); + + clickBack(wrapper); // -> checkpoint + assert.isTrue(wrapper.exists('CheckpointSelector')); + assert.isFalse(wrapper.exists('DueDateSelector')); + + clickBack(wrapper); // -> assignment-type + assert.isTrue(wrapper.exists('AssignmentTypeSelector')); + assert.isFalse(wrapper.exists('CheckpointSelector')); + }); + + it('shows a step-specific card title', () => { + fakeConfig.filePicker.assignmentTypes = ['reading', 'hide_and_reveal']; + const wrapper = renderFilePicker(); + const cardTitle = () => wrapper.find('CardHeader').prop('title'); + + // Assignment-type step. + assert.equal(cardTitle(), 'Assignment mode'); + + selectAssignmentType(wrapper, 'hide_and_reveal'); + clickNext(wrapper); // -> checkpoint + assert.equal(cardTitle(), 'Guided Social Annotation'); + + clickNext(wrapper); // -> due-date + assert.equal(cardTitle(), 'Guided Social Annotation'); + + clickNext(wrapper); // -> regular flow + assert.equal(cardTitle(), 'Assignment details'); + }); + + it('returns to the assignment-type step via the header close button', () => { + fakeConfig.filePicker.assignmentTypes = ['reading', 'hide_and_reveal']; + const wrapper = renderFilePicker(); + + // The mode-selection step itself offers no close button. + assert.isNotOk(wrapper.find('CardHeader').prop('onClose')); + + selectAssignmentType(wrapper, 'hide_and_reveal'); + clickNext(wrapper); // -> checkpoint + clickNext(wrapper); // -> due-date + assert.isTrue(wrapper.exists('DueDateSelector')); + + // The header exposes a close handler during the Guided sub-steps. + const onClose = wrapper.find('CardHeader').prop('onClose'); + assert.isFunction(onClose); + interact(wrapper, () => onClose()); + + assert.isTrue(wrapper.exists('AssignmentTypeSelector')); + assert.isFalse(wrapper.exists('DueDateSelector')); + }); + + it('recomputes the branch when the type is changed after going back', () => { + fakeConfig.filePicker.assignmentTypes = ['reading', 'hide_and_reveal']; + const wrapper = renderFilePicker(); + + // Enter the Hide & Reveal branch... + selectAssignmentType(wrapper, 'hide_and_reveal'); + clickNext(wrapper); // -> checkpoint + clickBack(wrapper); // -> assignment-type + + // ...then switch to a regular reading assignment. + selectAssignmentType(wrapper, 'reading'); + clickNext(wrapper); // -> done (skips checkpoint/due-date) + + assert.isFalse(wrapper.exists('CheckpointSelector')); + assert.isFalse(wrapper.exists('DueDateSelector')); + assert.isTrue(wrapper.exists('ContentSelector')); + }); + }); + function selectContent(wrapper, content) { const picker = wrapper.find('ContentSelector'); interact(wrapper, () => { diff --git a/lms/static/scripts/frontend_apps/config.ts b/lms/static/scripts/frontend_apps/config.ts index 3926b72b59..149e611e32 100644 --- a/lms/static/scripts/frontend_apps/config.ts +++ b/lms/static/scripts/frontend_apps/config.ts @@ -2,6 +2,7 @@ import { createContext } from 'preact'; import { useContext } from 'preact/hooks'; import type { AutoGradingConfig } from './api-types'; +import type { AssignmentType } from './components/AssignmentTypeSelector'; import type { AppLaunchServerErrorCode, OAuthServerErrorCode } from './errors'; /** @@ -87,6 +88,15 @@ export type FilePickerConfig = { promptForTitle: boolean; promptForGradable: boolean; autoGradingEnabled: boolean; + /** + * The assignment types the instructor can choose from when configuring this + * assignment. The backend decides availability (e.g. via feature flags); + * `reading` is always present. When more than one type is offered, the file + * picker shows an initial "assignment type" selection step before the regular + * flow; with a single type that step is skipped. The backend that sets this + * is still pending, so it is optional for now. + */ + assignmentTypes?: AssignmentType[]; deepLinkingAPI?: APICallInfo; ltiLaunchUrl: string; blackboard: { From e89ea0a3cb325e484c2c21de091d7e0cddc28e91 Mon Sep 17 00:00:00 2001 From: Gabriel Morador Date: Fri, 26 Jun 2026 12:13:36 -0300 Subject: [PATCH 2/7] feeat: feature flag, application instance --- lms/models/application_instance.py | 6 +++++ lms/resources/_js_config/__init__.py | 20 +++++++++++++++ .../application_instance/show.html.jinja2 | 1 + .../lms/models/application_instance_test.py | 6 +++++ .../lms/resources/_js_config/__init___test.py | 25 +++++++++++++++++++ 5 files changed, 58 insertions(+) diff --git a/lms/models/application_instance.py b/lms/models/application_instance.py index 8920365236..bc83d4590b 100644 --- a/lms/models/application_instance.py +++ b/lms/models/application_instance.py @@ -70,6 +70,7 @@ class Settings(StrEnum): HYPOTHESIS_MENTIONS = "hypothesis.mentions" HYPOTHESIS_PDF_IMAGE_ANNOTATION = "hypothesis.pdf_image_annotation" HYPOTHESIS_PROMPT_FOR_GRADABLE = "hypothesis.prompt_for_gradable" + HYPOTHESIS_HIDE_AND_REVEAL = "hypothesis.hide_and_reveal" fields: Mapping[Settings, JSONSetting] = { Settings.BLACKBOARD_FILES_ENABLED: JSONSetting( @@ -182,6 +183,11 @@ class Settings(StrEnum): SettingFormat.TRI_STATE, default=True, ), + Settings.HYPOTHESIS_HIDE_AND_REVEAL: JSONSetting( + Settings.HYPOTHESIS_HIDE_AND_REVEAL, + SettingFormat.TRI_STATE, + default=False, + ), } diff --git a/lms/resources/_js_config/__init__.py b/lms/resources/_js_config/__init__.py index d2d70932d5..0e46cf1814 100644 --- a/lms/resources/_js_config/__init__.py +++ b/lms/resources/_js_config/__init__.py @@ -402,6 +402,9 @@ def enable_file_picker_mode( # noqa: PLR0913 "formFields": form_fields, "promptForTitle": prompt_for_title, "promptForGradable": prompt_for_gradable, + # Assignment types the instructor can choose from. Gated by + # the per-install "hide_and_reveal" feature flag. + "assignmentTypes": self._get_assignment_types(), # Enable auto grading everywhere except in Sakai "autoGradingEnabled": self._application_instance.tool_consumer_info_product_family_code != "sakai", @@ -430,6 +433,23 @@ def enable_file_picker_mode( # noqa: PLR0913 ) return self._config + def _get_assignment_types(self) -> list[str]: + """Return the assignment types the instructor can choose from. + + `reading` is always available. The "Hide & Reveal" (Guided Social + annotation) type is gated by the per-install `hypothesis.hide_and_reveal` + feature flag. + """ + settings = self._application_instance.settings + types = ["reading"] + + if settings.get_setting( + settings.fields[settings.Settings.HYPOTHESIS_HIDE_AND_REVEAL] + ): + types.append("hide_and_reveal") + + return types + def add_deep_linking_api(self): """ Add the details of the "DeepLinking API" in LMS where we support deep linking. diff --git a/lms/templates/admin/application_instance/show.html.jinja2 b/lms/templates/admin/application_instance/show.html.jinja2 index 1ce1de2847..182dd1dcfd 100644 --- a/lms/templates/admin/application_instance/show.html.jinja2 +++ b/lms/templates/admin/application_instance/show.html.jinja2 @@ -130,6 +130,7 @@ {{ tri_state_settings_checkbox("Collect student emails", fields[Settings.HYPOTHESIS_COLLECT_STUDENT_EMAILS]) }} {{ tri_state_settings_checkbox("Mentions", fields[Settings.HYPOTHESIS_MENTIONS]) }} {{ tri_state_settings_checkbox("PDF image annotation", fields[Settings.HYPOTHESIS_PDF_IMAGE_ANNOTATION]) }} + {{ tri_state_settings_checkbox("Hide & Reveal (Guided Social annotation)", fields[Settings.HYPOTHESIS_HIDE_AND_REVEAL]) }}
Grading settings diff --git a/tests/unit/lms/models/application_instance_test.py b/tests/unit/lms/models/application_instance_test.py index d5d78b09d7..04e9690e90 100644 --- a/tests/unit/lms/models/application_instance_test.py +++ b/tests/unit/lms/models/application_instance_test.py @@ -194,6 +194,12 @@ def test_it(self): "hypothesis.pdf_image_annotation", SettingFormat.TRI_STATE, ), + ( + "hypothesis", + "hide_and_reveal", + "hypothesis.hide_and_reveal", + SettingFormat.TRI_STATE, + ), ] diff --git a/tests/unit/lms/resources/_js_config/__init___test.py b/tests/unit/lms/resources/_js_config/__init___test.py index 9aa927f0c0..33584cffee 100644 --- a/tests/unit/lms/resources/_js_config/__init___test.py +++ b/tests/unit/lms/resources/_js_config/__init___test.py @@ -66,6 +66,31 @@ def test_it( } ) + @pytest.mark.parametrize( + "hide_and_reveal,expected_types", + [ + # Flag explicitly on. + (True, ["reading", "hide_and_reveal"]), + # Flag explicitly off. + (False, ["reading"]), + # Flag unset: defaults to off. + (None, ["reading"]), + ], + ) + def test_it_sets_assignment_types( + self, js_config, course, application_instance, hide_and_reveal, expected_types + ): + if hide_and_reveal is not None: + application_instance.settings.set( + "hypothesis", "hide_and_reveal", hide_and_reveal + ) + + js_config.enable_file_picker_mode( + sentinel.form_action, sentinel.form_fields, course + ) + + assert js_config.asdict()["filePicker"]["assignmentTypes"] == expected_types + @pytest.mark.parametrize( "config_function,key", ( From bb7031422eb2c70df6d181a93388be680fe94896 Mon Sep 17 00:00:00 2001 From: Gabriel Morador Date: Fri, 26 Jun 2026 12:22:39 -0300 Subject: [PATCH 3/7] fix: test --- tests/functional/views/lti/deep_linking_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/functional/views/lti/deep_linking_test.py b/tests/functional/views/lti/deep_linking_test.py index 35d2133de8..a28733b175 100644 --- a/tests/functional/views/lti/deep_linking_test.py +++ b/tests/functional/views/lti/deep_linking_test.py @@ -28,6 +28,7 @@ def test_basic_lti_launch_canvas_deep_linking_url( js_config = get_client_config(response) assert js_config["mode"] == JSConfig.Mode.FILE_PICKER assert js_config["filePicker"] == { + "assignmentTypes": ["reading"], "autoGradingEnabled": True, "blackboard": {"enabled": None}, "canvas": { From cbcf2e35accea21b1995b89cacc02209ccd4fd1c Mon Sep 17 00:00:00 2001 From: Gabriel Morador Date: Tue, 30 Jun 2026 16:47:40 -0300 Subject: [PATCH 4/7] Wire due_date (UTC) + button-based assignment-type workflow - Send due_date (datetime-local -> UTC ISO) and checkpoint_enabled in deep-linking payload - Assignment-type step uses OptionButtons that advance on click (no Next) - Due date optional, must be future; validated before leaving the step - Skip the workflow when editing (test added) --- .../components/AssignmentTypeSelector.tsx | 65 +++--- .../components/DueDateSelector.tsx | 31 ++- .../components/FilePickerApp.tsx | 84 ++++++-- .../test/AssignmentTypeSelector-test.js | 63 +++--- .../components/test/FilePickerApp-test.js | 202 ++++++++++++++++-- 5 files changed, 342 insertions(+), 103 deletions(-) diff --git a/lms/static/scripts/frontend_apps/components/AssignmentTypeSelector.tsx b/lms/static/scripts/frontend_apps/components/AssignmentTypeSelector.tsx index c7ea7a8726..29119eb015 100644 --- a/lms/static/scripts/frontend_apps/components/AssignmentTypeSelector.tsx +++ b/lms/static/scripts/frontend_apps/components/AssignmentTypeSelector.tsx @@ -1,4 +1,4 @@ -import { RadioGroup } from '@hypothesis/frontend-shared'; +import { OptionButton } from '@hypothesis/frontend-shared'; /** * The kind of assignment being created. @@ -16,6 +16,12 @@ const ASSIGNMENT_TYPE_LABELS: Record = { hide_and_reveal: 'Guided Social annotation', }; +/** Short description shown under each option's label. */ +const ASSIGNMENT_TYPE_DETAILS: Record = { + reading: 'Standard annotation', + hide_and_reveal: 'Hidden until revealed', +}; + export type AssignmentTypeSelectorProps = { /** * Assignment types the instructor can choose from, in display order. Decided @@ -23,41 +29,46 @@ export type AssignmentTypeSelectorProps = { * union and in `ASSIGNMENT_TYPE_LABELS` to render with a proper label. */ types: AssignmentType[]; - selected: AssignmentType; - onChange: (type: AssignmentType) => void; + + /** + * Called when the instructor picks a type. Selecting a type advances the + * workflow immediately, so this step has no separate "Next" button (it + * mirrors the content-selection buttons). + */ + onSelect: (type: AssignmentType) => void; }; /** * First step of the assignment-type workflow: lets instructors choose which - * kind of assignment they are creating among the available `types`. + * kind of assignment they are creating among the available `types`. Rendered as + * clickable buttons (like the content selector) that advance on click. */ export default function AssignmentTypeSelector({ types, - selected, - onChange, + onSelect, }: AssignmentTypeSelectorProps) { return ( -
- - {types.map(type => { - // Fall back to the raw key if the backend sends a type this frontend - // build doesn't know yet (deploy ordering), so the radio is never - // blank. Statically unreachable, but `type` is backend JSON at runtime. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - const label = ASSIGNMENT_TYPE_LABELS[type] ?? type; - return ( - - {label} - - ); - })} - +
+ {types.map(type => { + // Fall back to the raw key if the backend sends a type this frontend + // build doesn't know yet (deploy ordering), so the button is never + // blank. Statically unreachable, but `type` is backend JSON at runtime. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const label = ASSIGNMENT_TYPE_LABELS[type] ?? type; + const details = ASSIGNMENT_TYPE_DETAILS[type]; + return ( + {details}} + onClick={() => onSelect(type)} + > + {label} + + ); + })}
); } diff --git a/lms/static/scripts/frontend_apps/components/DueDateSelector.tsx b/lms/static/scripts/frontend_apps/components/DueDateSelector.tsx index 759b13e000..1808508bb5 100644 --- a/lms/static/scripts/frontend_apps/components/DueDateSelector.tsx +++ b/lms/static/scripts/frontend_apps/components/DueDateSelector.tsx @@ -1,10 +1,25 @@ import { IconButton, InfoIcon, Popover } from '@hypothesis/frontend-shared'; +import type { Ref } from 'preact'; import { useId, useRef, useState } from 'preact/hooks'; export type DueDateSelectorProps = { - /** Currently selected due date as an ISO date string (`YYYY-MM-DD`), or null. */ + /** + * Currently selected due date as a local `datetime-local` string + * (`YYYY-MM-DDTHH:MM`), or null. The parent converts this to UTC before + * sending it to the backend. + */ dueDate: string | null; onChange: (dueDate: string | null) => void; + + /** + * Earliest selectable value as a `datetime-local` string + * (`YYYY-MM-DDTHH:MM`). Used to enforce that the due date, when set, is in + * the future. + */ + min?: string; + + /** Ref to the underlying input, used by the parent to validate it. */ + inputRef?: Ref; }; /** @@ -14,6 +29,8 @@ export type DueDateSelectorProps = { export default function DueDateSelector({ dueDate, onChange, + min, + inputRef, }: DueDateSelectorProps) { const headingId = useId(); @@ -45,15 +62,19 @@ export default function DueDateSelector({ arrow > The point where annotations are no longer tallied in auto grading. + Optional — if set, it must be a future date and time.
{ diff --git a/lms/static/scripts/frontend_apps/components/FilePickerApp.tsx b/lms/static/scripts/frontend_apps/components/FilePickerApp.tsx index 5ee2a280ab..fa0fe1547c 100644 --- a/lms/static/scripts/frontend_apps/components/FilePickerApp.tsx +++ b/lms/static/scripts/frontend_apps/components/FilePickerApp.tsx @@ -118,8 +118,8 @@ function contentDescription(content: Content) { switch (content.type) { case 'url': return formatContentURL(content); + /* istanbul ignore next: defensive — content type is always 'url' here */ default: - /* istanbul ignore next */ throw new Error('Unknown content type'); } } @@ -276,6 +276,21 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { const [checkpointType, setCheckpointType] = useState('manual'); const [dueDate, setDueDate] = useState(null); + // The due date is optional, but when set it must be in the future. We enforce + // this with the input's native `min` (the current local date-time) plus a + // `reportValidity()` check before leaving the due-date step. The value is a + // local `datetime-local` string (`YYYY-MM-DDTHH:MM`); it's converted to UTC + // on submit. + const dueDateInputRef = useRef(null); + const minDueDate = useMemo(() => { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; + }, []); // A checkpoint ("Hide & Reveal") assignment is being created when the // instructor picked that type in the workflow. This drives the @@ -291,16 +306,28 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { // further steps (checkpoint, due-date); other types go straight to the // regular flow. const goToNextWorkflowStep = () => { - setWorkflowStep(step => { - switch (step) { - case 'assignment-type': - return assignmentType === 'hide_and_reveal' ? 'checkpoint' : 'done'; - case 'checkpoint': - return 'due-date'; - default: - return 'done'; - } - }); + // Block leaving the due-date step while a date has been entered but isn't in + // the future. An empty value is allowed since the due date is optional. + // `datetime-local` strings (`YYYY-MM-DDTHH:MM`) compare lexicographically, + // so a plain string comparison against the minimum (now) is correct. + if (workflowStep === 'due-date' && dueDate && dueDate < minDueDate) { + // Surface the input's native validation message (driven by its `min`). + dueDateInputRef.current?.reportValidity(); + return; + } + // From 'checkpoint' the next step is 'due-date'; from 'due-date' (the last + // step) the workflow is done. The 'assignment-type' step has no "Next" — it + // advances directly on selection (see `selectAssignmentType`). + setWorkflowStep(step => (step === 'checkpoint' ? 'due-date' : 'done')); + }; + + // Pick an assignment type in the first workflow step. Unlike the later steps, + // this advances immediately (the step has no "Next" button, mirroring the + // content-selection buttons): "hide_and_reveal" continues to the checkpoint + // sub-steps; other types go straight to the regular flow. + const selectAssignmentType = (type: AssignmentType) => { + setAssignmentType(type); + setWorkflowStep(type === 'hide_and_reveal' ? 'checkpoint' : 'done'); }; // Go back to the previous sub-step of the workflow. Only ever invoked from @@ -414,6 +441,11 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { const data: DeepLinkingAPIData = { ...deepLinkingAPI.data, auto_grading_config: autoGradingConfigToSave, + checkpoint_enabled: checkpointEnabled, + // Optional due date for "Hide & Reveal" assignments. The picker holds + // a local `datetime-local` value; convert it to a UTC ISO string for + // the backend. `null` when left blank or not a checkpoint assignment. + due_date: dueDate ? new Date(dueDate).toISOString() : null, content, group_set: groupConfig.useGroupSet ? groupConfig.groupSet : null, title, @@ -441,6 +473,8 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { }, [ authToken, + checkpointEnabled, + dueDate, deepLinkingFields, deepLinkingAPI, groupConfig.groupSet, @@ -549,8 +583,7 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { {currentStep === 'assignment-type' && ( )} {currentStep === 'checkpoint' && ( @@ -560,7 +593,12 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { /> )} {currentStep === 'due-date' && ( - + )}
) : ( @@ -716,17 +754,17 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { )} - {inTypeWorkflow && ( + {/* The assignment-type step advances on selection, so navigation + buttons only appear on the later workflow steps. */} + {canGoBackInWorkflow && ( - {canGoBackInWorkflow && ( - - )} +
) : ( /* 1-col grid for very narrow screens; 2-col for everyone else */ From 39e782d7763118d75ff235ed1e86aa18842c3522 Mon Sep 17 00:00:00 2001 From: Karen Rasmussen Date: Thu, 2 Jul 2026 17:54:00 -0300 Subject: [PATCH 6/7] Fix tests --- .../frontend_apps/components/test/FilePickerApp-test.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lms/static/scripts/frontend_apps/components/test/FilePickerApp-test.js b/lms/static/scripts/frontend_apps/components/test/FilePickerApp-test.js index 13accf2e50..4d8cd352f0 100644 --- a/lms/static/scripts/frontend_apps/components/test/FilePickerApp-test.js +++ b/lms/static/scripts/frontend_apps/components/test/FilePickerApp-test.js @@ -75,7 +75,6 @@ describe('FilePickerApp', () => { formFields = {}, title = null, autoGradingConfig = null, - checkpointEnabled = false, }, ) { const fieldsComponent = wrapper.find('FilePickerFormFields'); @@ -86,7 +85,6 @@ describe('FilePickerApp', () => { groupSet, title, autoGradingConfig, - checkpointEnabled, }); } @@ -144,7 +142,7 @@ describe('FilePickerApp', () => { function setDueDate(wrapper, date) { interact(wrapper, () => { - wrapper.find('DueDateSelector').props().onChange(date); + wrapper.find('DueDateSelector').first().props().onChange(date); }); } @@ -498,7 +496,7 @@ describe('FilePickerApp', () => { clickNext(); // -> due-date const localDueDate = '2035-01-15T10:30'; interact(wrapper, () => { - wrapper.find('DueDateSelector').props().onChange(localDueDate); + wrapper.find('DueDateSelector').first().props().onChange(localDueDate); }); clickNext(); // -> content selection From 17d8243982c9d48901ecb2c1119384fd3305ce13 Mon Sep 17 00:00:00 2001 From: Karen Rasmussen Date: Thu, 2 Jul 2026 18:50:44 -0300 Subject: [PATCH 7/7] Remove duplicate --- .../scripts/frontend_apps/components/FilePickerApp.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/lms/static/scripts/frontend_apps/components/FilePickerApp.tsx b/lms/static/scripts/frontend_apps/components/FilePickerApp.tsx index 254129afa1..fa0fe1547c 100644 --- a/lms/static/scripts/frontend_apps/components/FilePickerApp.tsx +++ b/lms/static/scripts/frontend_apps/components/FilePickerApp.tsx @@ -600,15 +600,6 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { inputRef={dueDateInputRef} /> )} - {currentStep === 'checkpoint' && ( - - )} - {currentStep === 'due-date' && ( - - )}
) : ( /* 1-col grid for very narrow screens; 2-col for everyone else */