Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3ee6d51
Restructure builder's question search modal body
jacbn Mar 30, 2026
098c72d
Merge branch 'main' into feature/gameboard-builder-rework
jacbn May 5, 2026
cf4b10a
Replace builder board view with custom ALVIs
jacbn May 5, 2026
b45bf2f
Re-enable deletion logic in new-style rows
jacbn May 5, 2026
c785822
Improve button/icon logic slightly
jacbn May 6, 2026
d28e800
Add reorder buttons to meet WCAG 2.2 in builder
jacbn May 6, 2026
33363ef
Add min dimensions to reorder buttons; adjust edges
jacbn May 6, 2026
12674e8
Display bookmark status on builder ALVIs
jacbn May 8, 2026
4836734
Rename old `<tr>` builder row to `..BuilderTableRow`
jacbn May 11, 2026
328b270
Refactor Draggable components into lazily-importable files
jacbn May 11, 2026
fcd8f94
Fix bug with changes to order not saving
jacbn May 11, 2026
884f4f2
Improve bookmark alignment on thin ALVIs
jacbn May 11, 2026
bdc3271
Include `disableRedirect` field to prevent auto-URL redirects
jacbn May 11, 2026
3800f5c
Add ability to search by bookmarks in builder
jacbn May 11, 2026
3789f8b
Remove unused imports
jacbn May 11, 2026
e244aa9
Merge branch 'main' into feature/gameboard-builder-rework
jacbn May 11, 2026
8de208d
Merge branch 'improvement/inequality-bundle-split-restoration' into f…
jacbn May 12, 2026
982f165
Restore breadcrumb to non-flat ALVIs
jacbn May 12, 2026
e9f8183
Disable top/bottom builder item move buttons
jacbn May 14, 2026
301ec48
Improve focus-outlines on draggable builder
jacbn May 14, 2026
7bba2bc
Reduce builder footprint on mobile
jacbn May 14, 2026
6806d9f
Improve builder on Ada
jacbn May 14, 2026
1c3ced6
Restore scroll shadows + align Ada + Sci
jacbn May 15, 2026
cc1fc7b
Improve modal on Ada
jacbn May 15, 2026
2213b00
Enable filtering while book set; add filter list for clarity
jacbn May 15, 2026
e98b98a
Add preview button to ALVI
jacbn May 15, 2026
ce53d38
Improved compressed ALVI structure on Ada
jacbn May 15, 2026
1691641
Reduce padding on ALVI type icons
jacbn May 15, 2026
f6c5ea7
Merge branch 'main' into feature/gameboard-builder-rework
jacbn May 15, 2026
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
21 changes: 21 additions & 0 deletions src/app/components/elements/DraggableListViewContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from "react";
import { DragDropContext, Droppable, DropResult } from "@hello-pangea/dnd";

interface DraggableListViewContainerProps extends React.HTMLAttributes<HTMLDivElement> {
reorder: (result: DropResult<string>) => void;
}

const DraggableListViewContainer = ({ children, reorder, ...rest }: DraggableListViewContainerProps) => {
return <DragDropContext onDragEnd={reorder}>
<Droppable droppableId="droppable">
{(providedDrop) => {
return <div ref={providedDrop.innerRef} {...rest}>
{children}
{providedDrop.placeholder}
</div>;
}}
</Droppable>
</DragDropContext>;
};

export default DraggableListViewContainer;
31 changes: 31 additions & 0 deletions src/app/components/elements/DraggableListViewItemWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from "react";
import { Draggable } from "@hello-pangea/dnd";
import classNames from "classnames";
import { isAda } from "../../services";

interface DraggableListViewWrapperProps extends React.HTMLAttributes<HTMLLIElement> {
id: string;
index: number;
}

const DraggableListViewWrapper = ({ id, index, className, children, ...rest }: DraggableListViewWrapperProps) => {
return <Draggable key={id} draggableId={id ?? ""} index={index ?? -1}>
{(providedDrag, snapshot) => {
return <li
{...rest}
ref={providedDrag.innerRef}
className={classNames(
"d-flex draggable-list-view-accessible",
className,
{"list-group-item align-items-center": isAda},
snapshot.isDragging ? "bg-white" : "bg-transparent"
)}
{...providedDrag.draggableProps} {...providedDrag.dragHandleProps}
>
{children}
</li>;
}}
</Draggable>;
};

export default DraggableListViewWrapper;
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,11 @@ import {
stageLabelMap,
TAG_ID,
TAG_LEVEL,
isPhy
isPhy,
GameboardBuilderRowInterface,
handleBuilderRowChange
} from "../../services";
import React from "react";
import {AudienceContext} from "../../../IsaacApiTypes";
import {closeActiveModal, openActiveModal, useAppDispatch} from "../../state";
import {DraggableProvided, DraggableStateSnapshot} from "@hello-pangea/dnd";
import {Question} from "../pages/Question";
import {ContentSummary, GameboardBuilderQuestions, GameboardBuilderQuestionsStackProps} from "../../../IsaacAppTypes";
import {DifficultyIcons} from "./svg/DifficultyIcons";
import classNames from "classnames";
import { Spacer } from "./Spacer";
Expand All @@ -27,89 +24,46 @@ import { Markup } from "./markup";
import { ContentPropertyTags } from "./ContentPropertyTags";
import { IconButton } from "./AffixButton";
import { CrossTopicQuestionIndicator } from "./CrossTopicQuestionIndicator";
import { PreviewQuestionButton } from "./PreviewButton";

interface GameboardBuilderRowInterface {
provided?: DraggableProvided;
snapshot?: DraggableStateSnapshot;
question: ContentSummary;
currentQuestions: GameboardBuilderQuestions;
undoStack: GameboardBuilderQuestionsStackProps;
redoStack: GameboardBuilderQuestionsStackProps;
creationContext?: AudienceContext;
}

const GameboardBuilderRow = (
{provided, snapshot: _snapshot, question, undoStack, currentQuestions, redoStack, creationContext}: GameboardBuilderRowInterface
const GameboardBuilderTableRow = (
{isDnd, snapshot: _snapshot, question, undoStack, currentQuestions, redoStack, creationContext}: GameboardBuilderRowInterface
) => {
const dispatch = useAppDispatch();

const tagIcon = (tag: string) => {
return <span key={tag} className={classNames("badge rounded-pill mx-1", siteSpecific("text-bg-warning", "text-bg-primary"))}>{tag}</span>;
};

const openQuestionModal = (urlQuestionId: string) => {
dispatch(openActiveModal({
closeAction: () => {dispatch(closeActiveModal());}, size: "xl",
title: "Question preview", body: <Question questionIdOverride={urlQuestionId} preview />
}));
};

const audienceViews = determineAudienceViews(question.audience, creationContext);
const filteredAudienceViews = audienceViews.length === 0 ? [{stage: undefined, difficulty: undefined}] : filterAudienceViewsByProperties(audienceViews, AUDIENCE_DISPLAY_FIELDS);

const cellClasses = "text-start align-middle";
const isSelected = question.id !== undefined && currentQuestions.selectedQuestions.has(question.id);

const handleCheckboxChange = () => {
if (question.id) {
const newSelectedQuestions = new Map(currentQuestions.selectedQuestions);
const newQuestionOrder = [...currentQuestions.questionOrder];
if (newSelectedQuestions.has(question.id)) {
newSelectedQuestions.delete(question.id);
newQuestionOrder.splice(newQuestionOrder.indexOf(question.id), 1);
} else {
newSelectedQuestions.set(question.id, {...question, creationContext});
newQuestionOrder.push(question.id);
}
currentQuestions.setSelectedQuestions(newSelectedQuestions);
currentQuestions.setQuestionOrder(newQuestionOrder);
if (provided) {
undoStack.push({questionOrder: currentQuestions.questionOrder, selectedQuestions: currentQuestions.selectedQuestions});
redoStack.clear();
}
}
};

return filteredAudienceViews.map((view, i, arr) => <tr key={`${question.id} ${i}`}>
{i === 0 && <>
<td rowSpan={arr.length} className="w-5 text-center align-middle">
<div className="d-flex justify-content-center">
{isAda && provided
? <IconButton icon="icon-bin action-button-small" color="keyline" className="action-button" aria-label="Delete quiz" title="Delete quiz" onClick={handleCheckboxChange}/>
{isAda && isDnd
? <IconButton icon="icon-bin action-button-small" color="keyline" className="action-button" aria-label="Delete quiz" title="Delete quiz" onClick={() => handleBuilderRowChange({ isDnd, question, currentQuestions, undoStack, redoStack, creationContext })}/>
: <StyledCheckbox
id={`${provided ? "gameboard-builder" : "question-search-modal"}-include-${question.id}`}
id={`${isDnd ? "gameboard-builder" : "question-search-modal"}-include-${question.id}`}
aria-label={!isSelected ? "Select question" : "Deselect question"}
title={!isSelected ? "Select question" : "Deselect question"}
color="primary"
checked={isSelected}
onChange={handleCheckboxChange}
onChange={() => handleBuilderRowChange({ isDnd, question, currentQuestions, undoStack, redoStack, creationContext })}
/>}
</div>
</td>
<td rowSpan={arr.length} className={classNames(cellClasses, siteSpecific("w-40", "w-30"))}>
<div className="d-flex">
{provided && <img src="/assets/common/icons/drag_indicator.svg" alt="Drag to reorder" className="me-1 grab-cursor" />}
{isDnd && <img src="/assets/common/icons/drag_indicator.svg" alt="Drag to reorder" className="me-1 grab-cursor" />}
<div>
<div className="d-flex">
<a className="me-2 text-wrap" href={`/questions/${question.id}`} target="_blank" rel="noopener noreferrer" title="Preview question in new tab">
<a className="text-wrap" href={`/questions/${question.id}`} target="_blank" rel="noopener noreferrer" title="Preview question in new tab">
<Markup encoding="latex">{generateQuestionTitle(question)}</Markup>
</a>
<button
type="button" title="Preview question in modal" className="pointer-cursor align-middle new-tab p-0"
onClick={() => question.id && openQuestionModal(question.id)}
>
<img src="/assets/common/icons/new-tab.svg" alt="Preview question" />
</button>
<PreviewQuestionButton id={question.id} />
<Spacer />
</div>
<ContentPropertyTags className="my-1" deprecated={question.deprecated} supersededByPath={question.supersededBy ? `/questions/${question.supersededBy}` : undefined} tags={question.tags} />
Expand Down Expand Up @@ -143,4 +97,4 @@ const GameboardBuilderRow = (
</tr>);
};

export default GameboardBuilderRow;
export default GameboardBuilderTableRow;
22 changes: 22 additions & 0 deletions src/app/components/elements/PreviewButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from "react";
import { closeActiveModal, openActiveModal, useAppDispatch } from "../../state";
import { Question } from "../pages/Question";

export const PreviewQuestionButton = ({id}: {id?: string}) => {
const dispatch = useAppDispatch();
const openQuestionModal = (urlQuestionId: string) => {
dispatch(openActiveModal({
closeAction: () => {dispatch(closeActiveModal());}, size: "xl",
title: "Question preview", body: <Question questionIdOverride={urlQuestionId} preview />
}));
};

if (!id) return null;

return <button
type="button" title="Preview question in modal" className="pointer-cursor align-middle new-tab p-0 ms-2"
onClick={() => id && openQuestionModal(id)}
>
<img src="/assets/common/icons/new-tab.svg" alt="Preview question" />
</button>;
};
83 changes: 57 additions & 26 deletions src/app/components/elements/list-groups/AbstractListViewItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { CrossTopicQuestionIndicator } from "../CrossTopicQuestionIndicator";
import { SupersededDeprecatedBoardContentWarning } from "../../navigation/SupersededDeprecatedWarning";
import { useBookmarks } from "../../../services/bookmarks";
import { FeatureFlag, useFeatureFlag } from "../../../services/featureFlag";
import { PreviewQuestionButton } from "../PreviewButton";

const Breadcrumb = ({breadcrumb}: {breadcrumb: string[]}) => {
return <>
Expand Down Expand Up @@ -137,6 +138,7 @@ type ALVIType = {
hasCaret?: boolean;
linkTags?: ListViewTagProps[];
allowBookmarking?: boolean; // if set, displays a bookmark for logged-in users that will save the alvi to the user's bookmarks on click
disableRedirect?: boolean; // the URL is required for bookmarks to show; if we do not want the item to redirect on click, enable
} | {
// quizzes – have exclusive "preview" and "view test" buttons
alviType: "quiz";
Expand All @@ -154,6 +156,9 @@ type ALVIType = {
deprecated?: boolean;
supersededByPath?: string;
audienceViews?: ViewingContext[];
allowBookmarking?: boolean; // as in item type
disableRedirect?: boolean; // as in item type
questionPreviewId?: string; // shows a preview button next to the title; preview opens in modal
};

type ALVILayout = {
Expand Down Expand Up @@ -200,14 +205,14 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb
const isCrossTopic = isAda && tags?.includes("cross_topic");
const isLLM = tags?.includes("llm_question_page");

const flatLayout = style === "flat" && above['md'](deviceSize);
const flatLayout = style === "flat" && above['lg'](deviceSize);
const stackedLayout = style === "stacked" || below["sm"](deviceSize);
const wrapTitleTags = below["xs"](deviceSize);

const cardBody = <>
<div className="w-100 d-flex flex-row">
<Col className={classNames("d-flex flex-grow-1", {"mt-3": isCard})}>
<div className={classNames("position-relative", {"question-progress-icon": isAda})}>
<div className={classNames("position-relative", {"question-progress-icon": isAda, "d-flex vertical-center": flatLayout})}>
{icon && <div className="inner-progress-icon">
<TitleIcon icon={icon} />
{icon.label && isAda && above['sm'](deviceSize) && <div className="icon-title mt-1">{icon.label}</div>}
Expand All @@ -219,19 +224,22 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb
</div>
<div className={classNames("align-content-center text-overflow-ellipsis", siteSpecific("pe-2", "py-3"))}>
<div className={classNames("text-wrap mt-n1", {"d-flex": !wrapTitleTags})}>
{url && !isDisabled
? (url.startsWith("http")
? <ExternalLink href={url} className={classNames("alvi-title", {"question-link-title": isPhy || !isQuiz})}>
<>
{url && !isDisabled && !("disableRedirect" in typedProps && typedProps.disableRedirect)
? (url.startsWith("http")
? <ExternalLink href={url} className={classNames("alvi-title", {"question-link-title": isPhy || !isQuiz, "title-small": flatLayout})}>
<Markup encoding="latex">{title}</Markup>
</ExternalLink>
: <Link to={url} className={classNames("alvi-title", {"question-link-title": isPhy || !isQuiz, "title-small": flatLayout})}>
<Markup encoding="latex">{title}</Markup>
</Link>
)
: <span className={classNames("alvi-title", {"question-link-title": isPhy || !isQuiz, "title-small": flatLayout})}>
<Markup encoding="latex">{title}</Markup>
</ExternalLink>
: <Link to={url} className={classNames("alvi-title", {"question-link-title": isPhy || !isQuiz})}>
<Markup encoding="latex">{title}</Markup>
</Link>
)
: <span className={classNames("alvi-title", {"question-link-title": isPhy || !isQuiz})}>
<Markup encoding="latex">{title}</Markup>
</span>
}
</span>
}
{isBuilder && typedProps.questionPreviewId && <PreviewQuestionButton id={typedProps.questionPreviewId} />}
</>
{isItem && <>
{typedProps.quizTag && <span className="quiz-level-1-tag ms-sm-2">{typedProps.quizTag}</span>}
<ContentPropertyTags
Expand All @@ -242,12 +250,14 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb
/>
</>}
</div>
{subtitle && <div className="small text-muted text-wrap">
<Markup encoding="latex">{subtitle}</Markup>
</div>}
{breadcrumb && !(flatLayout && subtitle) && <span className="hierarchy-tags d-flex flex-wrap mw-auto">
<Breadcrumb breadcrumb={breadcrumb}/>
</span>}
{!flatLayout && <>
{subtitle && <div className="small text-muted text-wrap">
<Markup encoding="latex">{subtitle}</Markup>
</div>}
{breadcrumb && <span className="hierarchy-tags d-flex flex-wrap mw-auto">
<Breadcrumb breadcrumb={breadcrumb}/>
</span>}
</>}
{(isItem || isBuilder) && stackedLayout && typedProps.audienceViews && <div className="d-flex mt-1">
<StageAndDifficultySummaryIcons audienceViews={typedProps.audienceViews} stack/>
</div>}
Expand All @@ -272,7 +282,16 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb
{!stackedLayout &&
<>
{isPhy && isItem && typedProps.status && typedProps.status !== CompletionState.ALL_CORRECT && <StatusDisplay status={typedProps.status} showText className="ms-2 me-3" />}
{(isItem || isBuilder) && typedProps.audienceViews && <div className={classNames("d-none d-md-flex justify-content-end wf-13", {"list-view-border": typedProps.audienceViews.length > 0})}>
{flatLayout && (subtitle || breadcrumb) && <div className="list-view-border wf-10 pe-3 d-flex align-items-center">
{subtitle && <div className="small text-muted text-wrap">
<Markup encoding="latex">{subtitle}</Markup>
</div>}
{/* additional `&& !subtitle` as compared to stacked, as flat layout only has room for one */}
{breadcrumb && !subtitle && <span className="hierarchy-tags d-flex flex-wrap mw-auto">
<Breadcrumb breadcrumb={breadcrumb}/>
</span>}
</div>}
{(isItem || isBuilder) && typedProps.audienceViews && <div className={classNames("d-none d-md-flex justify-content-end", siteSpecific("wf-13", "wf-16"), {"list-view-border": typedProps.audienceViews.length > 0})}>
<StageAndDifficultySummaryIcons audienceViews={typedProps.audienceViews} stack className={siteSpecific("w-100", "py-3 pe-3")}/>
</div>}
{isGameboard && isTeacherOrAbove(user) && <Col md={6} className="d-none d-md-flex align-items-center justify-content-end">
Expand All @@ -281,10 +300,16 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb
{isQuiz && <Col md={6} className="d-none d-md-flex align-items-center justify-content-end">
<QuizLinks previewQuizUrl={typedProps.previewQuizUrl} quizButton={typedProps.quizButton}/>
</Col>}
{isItem && contentId && typedProps.allowBookmarking && isLoggedIn(user) && bookmarksFeatureFlag && <button
className={classNames("alvi-bookmark", {"saved": isBookmarked(contentId)})}
onClick={() => bookmarkItem(contentId)}
/> }
{(isItem || isBuilder) && contentId && typedProps.allowBookmarking && isLoggedIn(user) && bookmarksFeatureFlag && <div
className="alvi-bookmark-container"
>
<button
className={classNames("alvi-bookmark", {"saved": isBookmarked(contentId)})}
onClick={() => bookmarkItem(contentId)}
type="button"
/>
</div>
}
</>
}
{isItem && typedProps.hasCaret && <div className="list-caret align-content-center" aria-hidden="true">
Expand All @@ -295,7 +320,13 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb
</>;

return <ListGroupItem
className={classNames("content-summary-item", {"correct": isItem && typedProps.status === CompletionState.ALL_CORRECT}, className, state)}
className={classNames(
"content-summary-item",
{"thin": flatLayout},
{"correct": isItem && typedProps.status === CompletionState.ALL_CORRECT},
className,
state
)}
data-bs-theme={subject && !isDisabled ? subject : "neutral"}
data-testid={"list-view-item"}
tag={componentTag}
Expand Down
Loading
Loading