Skip to content
Draft
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
6 changes: 6 additions & 0 deletions lms/models/application_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
),
}


Expand Down
20 changes: 20 additions & 0 deletions lms/resources/_js_config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { OptionButton } 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<AssignmentType, string> = {
reading: 'Social annotation',
hide_and_reveal: 'Guided Social annotation',
};

/** Short description shown under each option's label. */
const ASSIGNMENT_TYPE_DETAILS: Record<AssignmentType, string> = {
reading: 'Standard annotation',
hide_and_reveal: 'Hidden until revealed',
};

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[];

/**
* 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`. Rendered as
* clickable buttons (like the content selector) that advance on click.
*/
export default function AssignmentTypeSelector({
types,
onSelect,
}: AssignmentTypeSelectorProps) {
return (
<div className="grid grid-cols-1 gap-y-2 w-fit">
{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 (
<OptionButton
key={type}
data-testid={`assignment-type-${type}`}
// Extra left margin adds a bit of breathing room between the label
// and the right-aligned description.
details={details && <span className="ml-4">{details}</span>}
onClick={() => onSelect(type)}
>
{label}
</OptionButton>
);
})}
</div>
);
}
69 changes: 69 additions & 0 deletions lms/static/scripts/frontend_apps/components/CheckpointSelector.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-2">
<h3 id={headingId} className="uppercase font-medium text-slate-600">
Checkpoint
</h3>
<RadioGroup<CheckpointOption>
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);
}
}}
>
<RadioGroup.Radio value="manual">Manual</RadioGroup.Radio>
<RadioGroup.Radio value="more" disabled>
More coming soon
</RadioGroup.Radio>
</RadioGroup>
{showManualNote && (
// No color class: inherits the base text color (black) per design.
<p>
Students will see when the settings have changed from
&ldquo;Hide&rdquo; to &ldquo;Reveal&rdquo; in their notifications.
</p>
)}
</div>
);
}
87 changes: 87 additions & 0 deletions lms/static/scripts/frontend_apps/components/DueDateSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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 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<HTMLInputElement>;
};

/**
* 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,
min,
inputRef,
}: 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<HTMLButtonElement | null>(null);
const [infoPopoverOpen, setInfoPopoverOpen] = useState(false);

return (
<div className="space-y-2">
<div className="flex items-center gap-x-1">
<h3 id={headingId} className="uppercase font-medium text-slate-600">
Due Date
</h3>
<IconButton
icon={InfoIcon}
title="About due date"
onClick={() => setInfoPopoverOpen(open => !open)}
expanded={infoPopoverOpen}
elementRef={infoIconRef}
classes="text-[16px]"
/>
<Popover
open={infoPopoverOpen}
anchorElementRef={infoIconRef}
onClose={() => setInfoPopoverOpen(false)}
classes="p-2"
placement="above"
arrow
>
The point where annotations are no longer tallied in auto grading.
Optional — if set, it must be a future date and time.
</Popover>
</div>
<input
type="datetime-local"
data-testid="due-date-input"
ref={inputRef}
min={min}
aria-labelledby={headingId}
// The shared `Input` component does not support `type="datetime-local"`,
// so this mirrors its base classes (`inputStyles`) to stay visually
// consistent, including `touch:text-at-least-16px` which prevents iOS
// zoom-on-focus.
className="focus-visible:ring focus-visible:outline-none ring-inset border rounded w-full p-2 bg-grey-0 focus:bg-white disabled:bg-grey-1 placeholder:text-grey-6 disabled:placeholder:text-grey-7 touch:text-at-least-16px"
value={dueDate ?? ''}
onChange={e => {
const { value } = e.target as HTMLInputElement;
onChange(value === '' ? null : value);
}}
/>
</div>
);
}
Loading
Loading