From 3ee6d51c2018ac74739e569b61b4ed5beda35ed6 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Mon, 30 Mar 2026 11:07:37 +0100 Subject: [PATCH 01/25] Restructure builder's question search modal body --- .../elements/modals/QuestionSearchModal.tsx | 260 +++++++++--------- src/app/services/gameboardBuilder.ts | 6 +- src/scss/common/modals.scss | 4 - 3 files changed, 138 insertions(+), 132 deletions(-) diff --git a/src/app/components/elements/modals/QuestionSearchModal.tsx b/src/app/components/elements/modals/QuestionSearchModal.tsx index 5f2c8fbdab..19fe97caab 100644 --- a/src/app/components/elements/modals/QuestionSearchModal.tsx +++ b/src/app/components/elements/modals/QuestionSearchModal.tsx @@ -69,7 +69,6 @@ export const QuestionSearchModal = ( {currentQuestions, undoStack, redoStack, eventLog}: QuestionSearchModalProps) => { const dispatch = useAppDispatch(); const userContext = useUserViewingContext(); - const deviceSize = useDeviceSize(); const sublistDelimiter = " >>> "; const [searchParams, setSearchParams] = useState(skipToken); @@ -149,7 +148,7 @@ export const QuestionSearchModal = ( searchDebounce(searchQuestionName, searchTopics, searchExamBoards, searchBook, searchStages, searchDifficulties, searchFastTrack, 0); },[searchDebounce, searchQuestionName, searchTopics, searchExamBoards, searchBook, searchFastTrack, searchStages, searchDifficulties]); - const sortAndFilterBySearch = (questions: ContentSummaryDTO[]) => questions && sortQuestions(questionsSort, creationContext)( + const sortAndFilterBySearch = (questions: ContentSummaryDTO[]) => questions && sortQuestions({...questionsSort, ...(isBookSearch ? {book: SortOrder.ASC} : {})}, creationContext)( questions.filter(question => { const qIsPublic = searchResultIsPublic(question, user); if (isBookSearch) return qIsPublic; @@ -161,7 +160,7 @@ export const QuestionSearchModal = ( }) ); - const addSelectionsRow =
+ const addSelectionsRow =
QUESTIONS_PER_GAMEBOARD})}> {`${selectedQuestions.size} question${selectedQuestions.size !== 1 ? "s" : ""} selected`} @@ -193,133 +192,140 @@ export const QuestionSearchModal = ( setSearchTopics(getChoiceTreeLeaves(topicSelections).map((s) => s.value)); }, [topicSelections]); - return - - - - {isAda && Topic} - expanded={listState.topics.state} - className="mb-3" - toggle={() => listStateDispatch({type: "toggle", id: "topics", focus: below["md"](deviceSize)})} - > - {groupBaseTagOptions.map((tag, index) => ( - listStateDispatch({type: "toggle", id: `topics ${sublistDelimiter} ${tag.label}`, focus: true})} - key={index} tag={"li"} - > - {tag.options.map((topic, index) => ( -
  • - setSearchTopics(s => s.includes(topic.value) ? s.filter(v => v !== topic.value) : [...s, topic.value])} - label={{topic.label}} className="ps-3"/> -
  • ))} -
    - ))} -
    } - {isPhy &&
    - - !b.hidden).map(book => ({value: book.tag, label: book.shortTitle}))} - /> -
    } -
    - - -
    - {isPhy && !isBookSearch && deviceSize !== "lg" &&
    - - -
    } -
    - - - {isAda && <> - - searchExamBoards.includes(o.value))} - options={getFilteredExamBoardOptions({byStages: searchStages})} - onChange={(s: MultiValue>) => selectOnChange(setSearchExamBoards, true)(s)} - /> - } -
    - - ) => { - setSearchQuestionName(e.target.value); - }} + return <> + + + + ) => { + setSearchQuestionName(e.target.value); + }} + /> + + + {isPhy &&
    + + !b.hidden).map(book => ({value: book.tag, label: book.shortTitle}))} /> - {isPhy && isStaff(user) && -
    - -
    } - - {isPhy && !isBookSearch && deviceSize === "lg" && +
    } + +
    + + + + + + + + + + + {isAda && + + searchExamBoards.includes(o.value))} + options={getFilteredExamBoardOptions({byStages: searchStages})} + onChange={(s: MultiValue>) => selectOnChange(setSearchExamBoards, true)(s)} + /> + } + + + + +
    + {} +
    + +
    + + {isAda &&
      + {groupBaseTagOptions.map((tag, index) => ( + listStateDispatch({type: "toggle", id: `topics ${sublistDelimiter} ${tag.label}`, focus: true})} + key={index} tag={"li"} + > + {tag.options.map((topic, index) => ( +
    • + setSearchTopics(s => s.includes(topic.value) ? s.filter(v => v !== topic.value) : [...s, topic.value])} + label={{topic.label}} className="ps-3"/> +
    • + ))} +
      + ))} +
    } + {isPhy && !isBookSearch &&
    - } - - {addSelectionsRow} - - - - - - - - - className={siteSpecific("w-40", "w-30")} - setOrder={sortableTableHeaderUpdateState(questionsSort, setQuestionsSort, "title")} - defaultOrder={SortOrder.ASC} - reverseOrder={SortOrder.DESC} - currentOrder={questionsSort['title']} - alignment="start" - >Question title - - - - {isAda && } - - - - } - defaultErrorTitle="Failed to load questions." - thenRender={({results: questions}) => { - if (!questions) return <>; - const sortedQuestions = sortAndFilterBySearch(questions); - return sortedQuestions?.map(question => - - ); - }} - /> - -
    TopicStageDifficultyExam boards
    -
    - - ; +
    } + + {/* required for sticky */} +
    + {addSelectionsRow} + + + + + + + + + + + + className={siteSpecific("w-40", "w-30")} + setOrder={sortableTableHeaderUpdateState(questionsSort, setQuestionsSort, "title")} + defaultOrder={SortOrder.ASC} + reverseOrder={SortOrder.DESC} + currentOrder={questionsSort['title']} + alignment="start" + >Question title + + + + {isAda && } + + + + } + defaultErrorTitle="Failed to load questions." + thenRender={({results: questions}) => { + if (!questions) return <>; + const sortedQuestions = sortAndFilterBySearch(questions); + console.log(sortedQuestions); + return sortedQuestions?.map(question => + + ); + }} + /> + +
    TopicStageDifficultyExam boards
    +
    + + + ; }; diff --git a/src/app/services/gameboardBuilder.ts b/src/app/services/gameboardBuilder.ts index fb53f1c4a9..f87ed01a0d 100644 --- a/src/app/services/gameboardBuilder.ts +++ b/src/app/services/gameboardBuilder.ts @@ -1,4 +1,4 @@ -import {determineAudienceViews, difficultiesOrdered, SortOrder, sortStringsNumerically, tags} from "./"; +import {determineAudienceViews, difficultiesOrdered, sortByStringValue, SortOrder, sortStringsNumerically, tags} from "./"; import orderBy from "lodash/orderBy"; import {AudienceContext, ContentSummaryDTO, Difficulty, GameboardDTO, GameboardItem} from "../../IsaacApiTypes"; import {ContentSummary, Tag} from "../../IsaacAppTypes"; @@ -19,6 +19,10 @@ export const sortQuestions = (sortState: {[s: string]: string}, creationContext? }); return questions; } + if (sortState.book && sortState.book != SortOrder.NONE) { + const sortedQuestions = questions.sort(sortByStringValue("subtitle")); + return sortState.book == SortOrder.ASC ? sortedQuestions : sortedQuestions.reverse(); + } const keys: string[] = []; const order: ("asc" | "desc")[] = []; for (const key of Object.keys(sortState)) { diff --git a/src/scss/common/modals.scss b/src/scss/common/modals.scss index 702e30ab6e..27fb5ad507 100644 --- a/src/scss/common/modals.scss +++ b/src/scss/common/modals.scss @@ -33,10 +33,6 @@ } } - .modal-body { - overflow: auto; - } - .modal-footer { @include respond-below(sm) { button, a { From cf4b10a58bcc317aaaac2da452302dd0c72cf397 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Tue, 5 May 2026 16:08:17 +0100 Subject: [PATCH 02/25] Replace builder board view with custom ALVIs --- .../list-groups/AbstractListViewItem.tsx | 38 ++++-- .../elements/list-groups/ListView.tsx | 84 +++++++++++++- src/app/components/pages/GameboardBuilder.tsx | 109 ++++++++++-------- src/scss/common/list-groups.scss | 7 +- src/scss/phy/list-groups.scss | 5 +- 5 files changed, 178 insertions(+), 65 deletions(-) diff --git a/src/app/components/elements/list-groups/AbstractListViewItem.tsx b/src/app/components/elements/list-groups/AbstractListViewItem.tsx index 0dd3716fd9..e7ee695c57 100644 --- a/src/app/components/elements/list-groups/AbstractListViewItem.tsx +++ b/src/app/components/elements/list-groups/AbstractListViewItem.tsx @@ -207,7 +207,7 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb const cardBody = <>
    -
    +
    {icon &&
    {icon.label && isAda && above['sm'](deviceSize) &&
    {icon.label}
    } @@ -221,14 +221,14 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb
    {url && !isDisabled ? (url.startsWith("http") - ? + ? {title} - : + : {title} ) - : + : {title} } @@ -242,12 +242,14 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb /> }
    - {subtitle &&
    - {subtitle} -
    } - {breadcrumb && !(flatLayout && subtitle) && - - } + {!flatLayout && <> + {subtitle &&
    + {subtitle} +
    } + {breadcrumb && !subtitle && + + } + } {(isItem || isBuilder) && stackedLayout && typedProps.audienceViews &&
    } @@ -272,6 +274,14 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb {!stackedLayout && <> {isPhy && isItem && typedProps.status && typedProps.status !== CompletionState.ALL_CORRECT && } + {flatLayout &&
    + {subtitle &&
    + {subtitle} +
    } + {breadcrumb && !subtitle && + + } +
    } {(isItem || isBuilder) && typedProps.audienceViews &&
    0})}>
    } @@ -295,7 +305,13 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb ; return !isAda || i !== 0).map(tag => tag.title); @@ -327,7 +328,63 @@ export const BookDetailListViewItem = ({item, ...rest}: BookDetailListViewItemPr />; }; -export type CustomListViewItemProps = ListViewItemBaseProps<"item", "list" | "card"> & { +type BuilderListViewItemProps = ListViewItemBaseProps<"builder", "list" | "card"> & { + item: ContentSummaryDTO; + index?: number; + onDelete?: (id: string) => void; +} + +export const BuilderListViewItem = (props: BuilderListViewItemProps) => { + const { item, index, onDelete, ...rest } = props; + // const breadcrumb = getBreadcrumb(item.tags as TAG_ID[]); + const audienceViews: ViewingContext[] = determineAudienceViews(item.audience); + const pageSubject = useAppSelector(selectors.pageContext.subject); + const itemSubject = getThemeFromContextAndTags(pageSubject, tags.getSubjectTags((item.tags || []) as TAG_ID[]).map(t => t.id)); + const state = item.state ?? CompletionState.NOT_ATTEMPTED; + + const topic = tags.getSpecifiedTag(TAG_LEVEL.topic, item.tags as TAG_ID[])?.title; + + const icon: TitleIconProps = { type: "icon", label: "Question", + icon: isPhy + ? {name: "icon-question", size: "md"} + : {name: QUESTION_STATUS_TO_ICON[CompletionState.NOT_ATTEMPTED], size: "md", altText: classNames(HUMAN_STATUS[state], "question icon"), color: "tertiary", raw: true} + }; + + return + {(providedDrag) => { + return
  • +
    + Drag to reorder +
    + + +
  • ; + }} +
    ; +}; + +export type CustomListViewItemProps = ListViewItemBaseProps<"item", "list" | "list" | "card"> & { item: Omit, "alviType"> & { type?: string; } @@ -356,6 +413,7 @@ type ListViewItemProps = | ShortcutListViewItemProps | BookIndexListViewItemProps | BookDetailListViewItemProps + | BuilderListViewItemProps | CustomListViewItemProps ; @@ -459,6 +517,17 @@ export const ListView = (props: } }); } + case "builder": { + const lviProps = {...rest, alviType: "builder" as const, alviLayout: "list" as const}; + return items.map((item, index) => { + switch (item.type) { + case (DOCUMENT_TYPE.QUESTION): + return ; + default: + return failedToRender(item); + } + }); + } default: return null; } @@ -524,6 +593,17 @@ export const ListViewCards = (pr } }); } + case "builder": { + const lviProps = {...rest, alviType: "builder" as const, alviLayout: "card" as const}; + return items.map((item, index) => { + switch (item.type) { + case (DOCUMENT_TYPE.QUESTION): + return ; + default: + return failedToRender(item); + } + }); + } default: return null; } diff --git a/src/app/components/pages/GameboardBuilder.tsx b/src/app/components/pages/GameboardBuilder.tsx index e2403622c2..f9173c56ca 100644 --- a/src/app/components/pages/GameboardBuilder.tsx +++ b/src/app/components/pages/GameboardBuilder.tsx @@ -61,8 +61,7 @@ import {StyledSelect} from "../elements/inputs/StyledSelect"; import {ExigentAlert} from "../elements/ExigentAlert"; import { PageMetadata } from '../elements/PageMetadata'; import {IconButton} from "../elements/AffixButton"; - -const GameboardBuilderRow = lazy(() => import("../elements/GameboardBuilderRow")); +import { ListView } from '../elements/list-groups/ListView'; class GameboardBuilderQuestionsStack { questionOrderStack: string[][]; @@ -449,54 +448,64 @@ const GameboardBuilder = ({user}: {user: RegisteredUserDTO}) => { {(providedDrop) => { - return ( - - - - - - - - - {isAda && } - - - {questionOrder.map((questionId, index: number) => { - const question = selectedQuestions.get(questionId); - return question && question.id && - - {(providedDrag, snapshot) => { - return - - ; - }} - ; - })} - - {providedDrop.placeholder} - {selectedQuestions?.size === 0 && - - } - -
    {isAda && selectedQuestions.size > 0 && "Remove"}Question titleTopicStageDifficultyExam boards
    -
    - - ); + return <> +
    + selectedQuestions.get(questionId)).filter(isDefined)} + onDelete={(id) => console.log("delete", id)} + /> + {providedDrop.placeholder} +
    + {/* // + // + // + // + // + // + // + // + // {isAda && } + // + // + // {questionOrder.map((questionId, index: number) => { + // const question = selectedQuestions.get(questionId); + // return question && question.id && + // + // {(providedDrag, snapshot) => { + // return + // + // ; + // }} + // ; + // })} + // + // {providedDrop.placeholder} + // {selectedQuestions?.size === 0 && + // + // } + // + //
    {isAda && selectedQuestions.size > 0 && "Remove"}Question titleTopicStageDifficultyExam boards
    + //
    */} + + ; }}
    diff --git a/src/scss/common/list-groups.scss b/src/scss/common/list-groups.scss index 3892b0d1c0..7d56f515d9 100644 --- a/src/scss/common/list-groups.scss +++ b/src/scss/common/list-groups.scss @@ -1,7 +1,12 @@ .list-group-links { border: 1px solid $gray-107; .question-link-title { - font-size: 1.25rem; + &.title-small { + font-size: 1.1rem; + } + &:not(.title-small) { + font-size: 1.25rem; + } } li { diff --git a/src/scss/phy/list-groups.scss b/src/scss/phy/list-groups.scss index 0175a04fd1..b510cca9e4 100644 --- a/src/scss/phy/list-groups.scss +++ b/src/scss/phy/list-groups.scss @@ -2,9 +2,12 @@ @import "../common/list-groups.scss"; .content-summary-item { - min-height: 88px; padding: 1rem; + &:not(.thin) { + min-height: 88px; + } + &:has(a.alvi-title) { button { z-index: 2; From b45bf2f202065c986613c432948a032b00e54b91 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Tue, 5 May 2026 17:28:14 +0100 Subject: [PATCH 03/25] Re-enable deletion logic in new-style rows --- .../elements/GameboardBuilderRow.tsx | 48 +++++++++---------- .../elements/modals/QuestionSearchModal.tsx | 4 -- src/app/components/pages/GameboardBuilder.tsx | 10 +++- 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/app/components/elements/GameboardBuilderRow.tsx b/src/app/components/elements/GameboardBuilderRow.tsx index e260becabf..b781119e1a 100644 --- a/src/app/components/elements/GameboardBuilderRow.tsx +++ b/src/app/components/elements/GameboardBuilderRow.tsx @@ -15,7 +15,7 @@ import { import React from "react"; import {AudienceContext} from "../../../IsaacApiTypes"; import {closeActiveModal, openActiveModal, useAppDispatch} from "../../state"; -import {DraggableProvided, DraggableStateSnapshot} from "@hello-pangea/dnd"; +import {DraggableProvided, DraggableStateSnapshot, DroppableProvided} from "@hello-pangea/dnd"; import {Question} from "../pages/Question"; import {ContentSummary, GameboardBuilderQuestions, GameboardBuilderQuestionsStackProps} from "../../../IsaacAppTypes"; import {DifficultyIcons} from "./svg/DifficultyIcons"; @@ -29,7 +29,7 @@ import { IconButton } from "./AffixButton"; import { CrossTopicQuestionIndicator } from "./CrossTopicQuestionIndicator"; interface GameboardBuilderRowInterface { - provided?: DraggableProvided; + provided?: DraggableProvided | DroppableProvided; snapshot?: DraggableStateSnapshot; question: ContentSummary; currentQuestions: GameboardBuilderQuestions; @@ -38,6 +38,26 @@ interface GameboardBuilderRowInterface { creationContext?: AudienceContext; } +export const handleBuilderRowChange = ({ provided, question, currentQuestions, undoStack, redoStack, creationContext }: GameboardBuilderRowInterface) => { + 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(); + } + } +}; + const GameboardBuilderRow = ( {provided, snapshot: _snapshot, question, undoStack, currentQuestions, redoStack, creationContext}: GameboardBuilderRowInterface ) => { @@ -60,39 +80,19 @@ const GameboardBuilderRow = ( 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) => {i === 0 && <>
    {isAda && provided - ? + ? handleBuilderRowChange({ provided, question, currentQuestions, undoStack, redoStack, creationContext })}/> : handleBuilderRowChange({ provided, question, currentQuestions, undoStack, redoStack, creationContext })} />}
    diff --git a/src/app/components/elements/modals/QuestionSearchModal.tsx b/src/app/components/elements/modals/QuestionSearchModal.tsx index 19fe97caab..ea3859ee65 100644 --- a/src/app/components/elements/modals/QuestionSearchModal.tsx +++ b/src/app/components/elements/modals/QuestionSearchModal.tsx @@ -18,7 +18,6 @@ import { groupTagSelectionsByParent, isAda, isPhy, - isStaff, Item, logEvent, searchResultIsPublic, @@ -30,8 +29,6 @@ import { useUserViewingContext, ISAAC_BOOKS, TAG_LEVEL, - below, - useDeviceSize, EXAM_BOARD, QUESTIONS_PER_GAMEBOARD } from "../../../services"; import {ContentSummary, GameboardBuilderQuestions, GameboardBuilderQuestionsStackProps, QuestionSearchQuery} from "../../../../IsaacAppTypes"; @@ -309,7 +306,6 @@ export const QuestionSearchModal = ( thenRender={({results: questions}) => { if (!questions) return <>; const sortedQuestions = sortAndFilterBySearch(questions); - console.log(sortedQuestions); return sortedQuestions?.map(question => { id="gameboard-builder-questions" type="builder" style="flat" - items={questionOrder.map((questionId) => selectedQuestions.get(questionId)).filter(isDefined)} - onDelete={(id) => console.log("delete", id)} + items={currentQuestions.questionOrder.map((questionId) => selectedQuestions.get(questionId)).filter(isDefined)} + onDelete={(id) => { + const question= selectedQuestions.get(id); + if (question) { + handleBuilderRowChange({ provided: providedDrop, question, currentQuestions, undoStack, redoStack, creationContext: question.creationContext }); + } + }} /> {providedDrop.placeholder}
    From c78582269a91901ee9b12d0a7fe9dd1b08df1004 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Wed, 6 May 2026 16:07:49 +0100 Subject: [PATCH 04/25] Improve button/icon logic slightly Tidies up some interactions between `.btn` and `.icon`, allowing for much more variation in how icon colours respond to hover. Also introduces a blank button type, with relevant icon support. --- src/scss/phy/button.scss | 26 ++++++++++++++++++++- src/scss/phy/icons.scss | 49 ++++++++++++++++------------------------ 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/src/scss/phy/button.scss b/src/scss/phy/button.scss index 276d585c81..cbca2ca7c0 100644 --- a/src/scss/phy/button.scss +++ b/src/scss/phy/button.scss @@ -40,7 +40,7 @@ &:hover, &.btn-dropdown.active { background: var(--buttons-dark-hover); - color: var(--buttons-dark-hover-icon); + color: var(--buttons-dark-text); text-decoration: none; } @@ -143,6 +143,30 @@ } } +.btn-blank { + background: none; + border: none; + color: var(--buttons-light-text); + + &:hover { + text-decoration: underline; + color: var(--buttons-dark-text); + } + + &:disabled, &.disabled { + color: var(--buttons-light-disabled-text); + } + + i.icon { + --icon-foreground: var(--buttons-light-icon); + } +} + +.btn:hover > i.icon:not(.icon-raw), .btn-blank:hover > i.icon:not(.icon-raw) { + --icon-foreground: var(--icon-hover); + --icon-background: var(--icon-hover); +} + .icon-button-sm { // Makes the size of consistent with the default padding on .btn buttons. Not the same as any existing .p-*, or even the .btn default. padding: 0.75rem !important; diff --git a/src/scss/phy/icons.scss b/src/scss/phy/icons.scss index c83501e6fc..4c19f7f92e 100644 --- a/src/scss/phy/icons.scss +++ b/src/scss/phy/icons.scss @@ -324,12 +324,6 @@ svg { } } -.btn.btn-keyline:hover > i.icon:not(.icon-raw), .btn.btn-secondary:hover > i.icon:not(.icon-raw), -.btn.btn-tint:hover > i.icon:not(.icon-raw), .btn.btn-tertiary:hover > i.icon:not(.icon-raw) { - --icon-foreground: var(--icon-hover); - --icon-background: var(--icon-hover); -} - .phy-hex-icon { // this is essentially an .icon, but needs different colouring; override size as needed with icon-lg, icon-xl, etc. @include svg-color(url("/assets/phy/icons/redesign/hexagon.svg"), var(--subject-color-200), var(--icon-size, 75px), var(--icon-size, 75px), 100%); @@ -343,30 +337,27 @@ svg { // the default icon colour for `neutral` is $color-theme-500 (green), but we use black icons in a few places across e.g. navigation -i.icon.icon-color-black { - @include icon-color-override($color-neutral-900); -} - -i.icon.icon-color-grey { - @include icon-color-override($color-neutral-500); -} - -i.icon.icon-color-muted { - @include icon-color-override($color-neutral-300); -} - -i.icon.icon-color-white { - @include icon-color-override($color-neutral-white); -} +$icon-color-themes: ( + "black": $color-neutral-900, + "grey": $color-neutral-500, + "muted": $color-neutral-300, + "white": $color-neutral-white, + "theme": var(--subject-color-500), + "alert": var(--bs-alert-color), +); -i.icon.icon-color-alert { - @include icon-color-override(var(--bs-alert-color)); -} +@each $name, $color in $icon-color-themes { + .icon-color-#{$name} { + @include icon-color-override($color); + } -i.icon.icon-color-black-hoverable { - --buttons-light-icon: #{$color-neutral-900}; -} + .icon-color-#{$name}-hoverable { + // replaces the color when *not* hovering; resets to white (or any -on-hover override) when hovering + --buttons-light-icon: #{$color}; + } -i.icon.icon-color-theme-hoverable { - --buttons-light-icon: var(--subject-color-500); + .icon-color-#{$name}-on-hover { + // replaces the color when hovering + --icon-hover: #{$color}; + } } From d28e8007a652bf9a341d65f400a5bbdd3ead280f Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Wed, 6 May 2026 16:08:18 +0100 Subject: [PATCH 05/25] Add reorder buttons to meet WCAG 2.2 in builder --- .../list-groups/AbstractListViewItem.tsx | 2 +- .../elements/list-groups/ListView.tsx | 15 ++- src/app/components/pages/GameboardBuilder.tsx | 92 ++++++------------- 3 files changed, 39 insertions(+), 70 deletions(-) diff --git a/src/app/components/elements/list-groups/AbstractListViewItem.tsx b/src/app/components/elements/list-groups/AbstractListViewItem.tsx index e7ee695c57..bc752dbe1f 100644 --- a/src/app/components/elements/list-groups/AbstractListViewItem.tsx +++ b/src/app/components/elements/list-groups/AbstractListViewItem.tsx @@ -274,7 +274,7 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb {!stackedLayout && <> {isPhy && isItem && typedProps.status && typedProps.status !== CompletionState.ALL_CORRECT && } - {flatLayout &&
    + {flatLayout && (subtitle || breadcrumb) &&
    {subtitle &&
    {subtitle}
    } diff --git a/src/app/components/elements/list-groups/ListView.tsx b/src/app/components/elements/list-groups/ListView.tsx index e82a15cff6..e843e9219a 100644 --- a/src/app/components/elements/list-groups/ListView.tsx +++ b/src/app/components/elements/list-groups/ListView.tsx @@ -331,11 +331,12 @@ export const BookDetailListViewItem = ({item, ...rest}: BookDetailListViewItemPr type BuilderListViewItemProps = ListViewItemBaseProps<"builder", "list" | "card"> & { item: ContentSummaryDTO; index?: number; + onMove?: (id: string, adjustment: number) => void; onDelete?: (id: string) => void; } export const BuilderListViewItem = (props: BuilderListViewItemProps) => { - const { item, index, onDelete, ...rest } = props; + const { item, index, onDelete, onMove, ...rest } = props; // const breadcrumb = getBreadcrumb(item.tags as TAG_ID[]); const audienceViews: ViewingContext[] = determineAudienceViews(item.audience); const pageSubject = useAppSelector(selectors.pageContext.subject); @@ -358,7 +359,15 @@ export const BuilderListViewItem = (props: BuilderListViewItemProps) => { {...providedDrag.draggableProps} {...providedDrag.dragHandleProps} >
    - Drag to reorder +
    + + Drag to reorder + +
    { // subtitle={item.subtitle} // breadcrumb={breadcrumb} audienceViews={audienceViews} - className="flex-grow-1" + className="flex-grow-1 align-content-center" /> Drag to reorder -
    diff --git a/src/scss/phy/button.scss b/src/scss/phy/button.scss index cbca2ca7c0..55191910ba 100644 --- a/src/scss/phy/button.scss +++ b/src/scss/phy/button.scss @@ -11,6 +11,10 @@ line-height: normal; opacity: 100%; + // WCAG 2.2: click targets must be at least 24x24px + min-width: 24px; + min-height: 24px; + &:focus:not(:focus-visible) { outline: 1px solid var(--buttons-focus-ring) !important; outline-offset: 3px; From 12674e8c9fae0f36ddae6d821d277dc595b9550d Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Fri, 8 May 2026 12:49:51 +0100 Subject: [PATCH 07/25] Display bookmark status on builder ALVIs --- .../components/elements/list-groups/AbstractListViewItem.tsx | 4 +++- src/app/components/elements/list-groups/ListView.tsx | 1 + src/app/components/pages/GameboardBuilder.tsx | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/components/elements/list-groups/AbstractListViewItem.tsx b/src/app/components/elements/list-groups/AbstractListViewItem.tsx index bc752dbe1f..dfa3fcc176 100644 --- a/src/app/components/elements/list-groups/AbstractListViewItem.tsx +++ b/src/app/components/elements/list-groups/AbstractListViewItem.tsx @@ -154,6 +154,7 @@ type ALVIType = { deprecated?: boolean; supersededByPath?: string; audienceViews?: ViewingContext[]; + allowBookmarking?: boolean; // as in item type }; type ALVILayout = { @@ -291,9 +292,10 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb {isQuiz && } - {isItem && contentId && typedProps.allowBookmarking && isLoggedIn(user) && bookmarksFeatureFlag &&
    ; From 4836734518feb08c3dbe9470a86e4d03333cfb82 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Mon, 11 May 2026 12:05:22 +0100 Subject: [PATCH 08/25] Rename old `` builder row to `..BuilderTableRow` Allows better distinguishing from the ALVI-based row, and will enable correct dep splitting when complete --- ...erRow.tsx => GameboardBuilderTableRow.tsx} | 41 +++---------------- .../elements/modals/QuestionSearchModal.tsx | 8 ++-- src/app/components/pages/GameboardBuilder.tsx | 2 +- src/app/services/gameboardBuilder.ts | 33 ++++++++++++++- 4 files changed, 42 insertions(+), 42 deletions(-) rename src/app/components/elements/{GameboardBuilderRow.tsx => GameboardBuilderTableRow.tsx} (78%) diff --git a/src/app/components/elements/GameboardBuilderRow.tsx b/src/app/components/elements/GameboardBuilderTableRow.tsx similarity index 78% rename from src/app/components/elements/GameboardBuilderRow.tsx rename to src/app/components/elements/GameboardBuilderTableRow.tsx index b781119e1a..f25182ce7b 100644 --- a/src/app/components/elements/GameboardBuilderRow.tsx +++ b/src/app/components/elements/GameboardBuilderTableRow.tsx @@ -10,14 +10,13 @@ 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, DroppableProvided} 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"; @@ -28,37 +27,7 @@ import { ContentPropertyTags } from "./ContentPropertyTags"; import { IconButton } from "./AffixButton"; import { CrossTopicQuestionIndicator } from "./CrossTopicQuestionIndicator"; -interface GameboardBuilderRowInterface { - provided?: DraggableProvided | DroppableProvided; - snapshot?: DraggableStateSnapshot; - question: ContentSummary; - currentQuestions: GameboardBuilderQuestions; - undoStack: GameboardBuilderQuestionsStackProps; - redoStack: GameboardBuilderQuestionsStackProps; - creationContext?: AudienceContext; -} - -export const handleBuilderRowChange = ({ provided, question, currentQuestions, undoStack, redoStack, creationContext }: GameboardBuilderRowInterface) => { - 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(); - } - } -}; - -const GameboardBuilderRow = ( +const GameboardBuilderTableRow = ( {provided, snapshot: _snapshot, question, undoStack, currentQuestions, redoStack, creationContext}: GameboardBuilderRowInterface ) => { const dispatch = useAppDispatch(); @@ -143,4 +112,4 @@ const GameboardBuilderRow = ( ); }; -export default GameboardBuilderRow; +export default GameboardBuilderTableRow; diff --git a/src/app/components/elements/modals/QuestionSearchModal.tsx b/src/app/components/elements/modals/QuestionSearchModal.tsx index ea3859ee65..04b36d7033 100644 --- a/src/app/components/elements/modals/QuestionSearchModal.tsx +++ b/src/app/components/elements/modals/QuestionSearchModal.tsx @@ -47,9 +47,9 @@ import { HorizontalScroller } from "../inputs/HorizontalScroller"; import { skipToken } from "@reduxjs/toolkit/query"; import { ShowLoadingQuery } from "../../handlers/ShowLoadingQuery"; -// Immediately load GameboardBuilderRow, but allow splitting -const importGameboardBuilderRow = import("../GameboardBuilderRow"); -const GameboardBuilderRow = lazy(() => importGameboardBuilderRow); +// Immediately load GameboardBuilderTableRow, but allow splitting +const importGameboardBuilderTableRow = import("../GameboardBuilderTableRow"); +const GameboardBuilderTableRow = lazy(() => importGameboardBuilderTableRow); const selectStyle = { className: "basic-multi-select", classNamePrefix: "select", @@ -307,7 +307,7 @@ export const QuestionSearchModal = ( if (!questions) return <>; const sortedQuestions = sortAndFilterBySearch(questions); return sortedQuestions?.map(question => - (questions: ContentSummaryDTO[]) => { if (sortState["title"] && sortState["title"] != SortOrder.NONE) { @@ -101,3 +102,33 @@ export const logEvent = (eventsLog: any[], event: string, params: any) => { ...params }); }; + +export interface GameboardBuilderRowInterface { + provided?: DraggableProvided | DroppableProvided; + snapshot?: DraggableStateSnapshot; + question: ContentSummary; + currentQuestions: GameboardBuilderQuestions; + undoStack: GameboardBuilderQuestionsStackProps; + redoStack: GameboardBuilderQuestionsStackProps; + creationContext?: AudienceContext; +} + +export const handleBuilderRowChange = ({ provided, question, currentQuestions, undoStack, redoStack, creationContext }: GameboardBuilderRowInterface) => { + 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(); + } + } +}; From 328b270013124cf6ca000ae2a0f456ffc1482927 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Mon, 11 May 2026 12:33:20 +0100 Subject: [PATCH 09/25] Refactor Draggable components into lazily-importable files --- .../elements/DraggableListViewContainer.tsx | 21 +++++ .../elements/DraggableListViewItemWrapper.tsx | 25 ++++++ .../elements/GameboardBuilderTableRow.tsx | 12 +-- .../elements/list-groups/ListView.tsx | 79 +++++++++---------- src/app/components/pages/GameboardBuilder.tsx | 59 +++++++------- src/app/services/gameboardBuilder.ts | 6 +- 6 files changed, 118 insertions(+), 84 deletions(-) create mode 100644 src/app/components/elements/DraggableListViewContainer.tsx create mode 100644 src/app/components/elements/DraggableListViewItemWrapper.tsx diff --git a/src/app/components/elements/DraggableListViewContainer.tsx b/src/app/components/elements/DraggableListViewContainer.tsx new file mode 100644 index 0000000000..35ee2802af --- /dev/null +++ b/src/app/components/elements/DraggableListViewContainer.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { DragDropContext, Droppable, DropResult } from "@hello-pangea/dnd"; + +interface DraggableListViewContainerProps extends React.HTMLAttributes { + reorder: (result: DropResult) => void; +} + +const DraggableListViewContainer = ({ children, reorder, ...rest }: DraggableListViewContainerProps) => { + return + + {(providedDrop) => { + return
    + {children} + {providedDrop.placeholder} +
    ; + }} +
    +
    ; +}; + +export default DraggableListViewContainer; diff --git a/src/app/components/elements/DraggableListViewItemWrapper.tsx b/src/app/components/elements/DraggableListViewItemWrapper.tsx new file mode 100644 index 0000000000..66eb812991 --- /dev/null +++ b/src/app/components/elements/DraggableListViewItemWrapper.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { Draggable } from "@hello-pangea/dnd"; +import classNames from "classnames"; + +interface DraggableListViewWrapperProps extends React.HTMLAttributes { + id: string; + index: number; +} + +const DraggableListViewWrapper = ({ id, index, className, children, ...rest }: DraggableListViewWrapperProps) => { + return + {(providedDrag) => { + return
  • + {children} +
  • ; + }} +
    ; +}; + +export default DraggableListViewWrapper; diff --git a/src/app/components/elements/GameboardBuilderTableRow.tsx b/src/app/components/elements/GameboardBuilderTableRow.tsx index f25182ce7b..4bedb369a0 100644 --- a/src/app/components/elements/GameboardBuilderTableRow.tsx +++ b/src/app/components/elements/GameboardBuilderTableRow.tsx @@ -28,7 +28,7 @@ import { IconButton } from "./AffixButton"; import { CrossTopicQuestionIndicator } from "./CrossTopicQuestionIndicator"; const GameboardBuilderTableRow = ( - {provided, snapshot: _snapshot, question, undoStack, currentQuestions, redoStack, creationContext}: GameboardBuilderRowInterface + {isDnd, snapshot: _snapshot, question, undoStack, currentQuestions, redoStack, creationContext}: GameboardBuilderRowInterface ) => { const dispatch = useAppDispatch(); @@ -53,21 +53,21 @@ const GameboardBuilderTableRow = ( {i === 0 && <>
    - {isAda && provided - ? handleBuilderRowChange({ provided, question, currentQuestions, undoStack, redoStack, creationContext })}/> + {isAda && isDnd + ? handleBuilderRowChange({ isDnd, question, currentQuestions, undoStack, redoStack, creationContext })}/> : handleBuilderRowChange({ provided, question, currentQuestions, undoStack, redoStack, creationContext })} + onChange={() => handleBuilderRowChange({ isDnd, question, currentQuestions, undoStack, redoStack, creationContext })} />}
    - {provided && Drag to reorder} + {isDnd && Drag to reorder}
    diff --git a/src/app/components/elements/list-groups/ListView.tsx b/src/app/components/elements/list-groups/ListView.tsx index 462b08fa48..45a62fca20 100644 --- a/src/app/components/elements/list-groups/ListView.tsx +++ b/src/app/components/elements/list-groups/ListView.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { lazy } from "react"; import { AbstractListViewItem, AbstractListViewItemProps, AbstractListViewProps } from "./AbstractListViewItem"; import { ShortcutResponse, ViewingContext } from "../../../../IsaacAppTypes"; import { determineAudienceViews } from "../../../services/userViewingContext"; @@ -13,7 +13,8 @@ import classNames from "classnames"; import { TitleIconProps } from "../PageTitle"; import { IconProps } from "../svg/HexIcon"; import { SetQuizzesModal } from "../modals/SetQuizzesModal"; -import { Draggable } from "@hello-pangea/dnd"; + +const DraggableListViewWrapper = lazy(() => import("../DraggableListViewItemWrapper")); function getBreadcrumb(tagIds: TAG_ID[] = []): string[] { return tags.getByIdsAsHierarchy(tagIds).filter((_t, i) => !isAda || i !== 0).map(tag => tag.title); @@ -351,47 +352,39 @@ export const BuilderListViewItem = (props: BuilderListViewItemProps) => { : {name: QUESTION_STATUS_TO_ICON[CompletionState.NOT_ATTEMPTED], size: "md", altText: classNames(HUMAN_STATUS[state], "question icon"), color: "tertiary", raw: true} }; - return - {(providedDrag) => { - return
  • -
    -
    - - Drag to reorder - -
    -
    - - -
  • ; - }} -
    ; + return +
    +
    + + Drag to reorder + +
    +
    + + +
    ; }; export type CustomListViewItemProps = ListViewItemBaseProps<"item", "list" | "list" | "card"> & { diff --git a/src/app/components/pages/GameboardBuilder.tsx b/src/app/components/pages/GameboardBuilder.tsx index c32152cfb9..354024bfa6 100644 --- a/src/app/components/pages/GameboardBuilder.tsx +++ b/src/app/components/pages/GameboardBuilder.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {lazy, useCallback, useEffect, useRef, useState} from 'react'; import { closeActiveModal, logAction, @@ -27,7 +27,7 @@ import { import {TitleAndBreadcrumb} from "../elements/TitleAndBreadcrumb"; import {GameboardDTO, GameboardItem, RegisteredUserDTO} from "../../../IsaacApiTypes"; import {QuestionSearchModal} from "../elements/modals/QuestionSearchModal"; -import {DragDropContext, Droppable, DropResult} from "@hello-pangea/dnd"; +import {DropResult} from "@hello-pangea/dnd"; import {GameboardCreatedModal} from "../elements/modals/GameboardCreatedModal"; import { convertContentSummaryToGameboardItem, @@ -63,6 +63,8 @@ import { PageMetadata } from '../elements/PageMetadata'; import {IconButton} from "../elements/AffixButton"; import { ListView } from '../elements/list-groups/ListView'; +const DraggableListViewContainer = lazy(() => import('../elements/DraggableListViewContainer')); + class GameboardBuilderQuestionsStack { questionOrderStack: string[][]; setQuestionOrderStack: React.Dispatch>; @@ -445,37 +447,30 @@ const GameboardBuilder = ({user}: {user: RegisteredUserDTO}) => {
    - - - {(providedDrop) => { - return
    - selectedQuestions.get(questionId)).filter(isDefined)} - onMove={(id, adjustment) => { - const index = currentQuestions.questionOrder.findIndex(qId => qId === id); - if (index === -1) return; - if (index + adjustment < 0 || index + adjustment >= currentQuestions.questionOrder.length) return; - const newQuestionOrder = [...currentQuestions.questionOrder]; - const [removed] = newQuestionOrder.splice(index, 1); - newQuestionOrder.splice(index + adjustment, 0, removed); - currentQuestions.setQuestionOrder(newQuestionOrder); - }} - onDelete={(id) => { - const question= selectedQuestions.get(id); - if (question) { - handleBuilderRowChange({ provided: providedDrop, question, currentQuestions, undoStack, redoStack, creationContext: question.creationContext }); - } - }} - allowBookmarking - /> - {providedDrop.placeholder} -
    ; + + selectedQuestions.get(questionId)).filter(isDefined)} + onMove={(id, adjustment) => { + const index = currentQuestions.questionOrder.findIndex(qId => qId === id); + if (index === -1) return; + if (index + adjustment < 0 || index + adjustment >= currentQuestions.questionOrder.length) return; + const newQuestionOrder = [...currentQuestions.questionOrder]; + const [removed] = newQuestionOrder.splice(index, 1); + newQuestionOrder.splice(index + adjustment, 0, removed); + currentQuestions.setQuestionOrder(newQuestionOrder); + }} + onDelete={(id) => { + const question= selectedQuestions.get(id); + if (question) { + handleBuilderRowChange({ isDnd: true, question, currentQuestions, undoStack, redoStack, creationContext: question.creationContext }); + } }} -
    -
    + allowBookmarking + />; +
    {`${tooManyQuestions ? `Only ${QUESTIONS_PER_GAMEBOARD} questions can be added, please remove some.` : "Please add some questions."}`} diff --git a/src/app/services/gameboardBuilder.ts b/src/app/services/gameboardBuilder.ts index 908a9f8159..4ed3b91721 100644 --- a/src/app/services/gameboardBuilder.ts +++ b/src/app/services/gameboardBuilder.ts @@ -104,7 +104,7 @@ export const logEvent = (eventsLog: any[], event: string, params: any) => { }; export interface GameboardBuilderRowInterface { - provided?: DraggableProvided | DroppableProvided; + isDnd?: boolean; snapshot?: DraggableStateSnapshot; question: ContentSummary; currentQuestions: GameboardBuilderQuestions; @@ -113,7 +113,7 @@ export interface GameboardBuilderRowInterface { creationContext?: AudienceContext; } -export const handleBuilderRowChange = ({ provided, question, currentQuestions, undoStack, redoStack, creationContext }: GameboardBuilderRowInterface) => { +export const handleBuilderRowChange = ({ isDnd, question, currentQuestions, undoStack, redoStack, creationContext }: GameboardBuilderRowInterface) => { if (question.id) { const newSelectedQuestions = new Map(currentQuestions.selectedQuestions); const newQuestionOrder = [...currentQuestions.questionOrder]; @@ -126,7 +126,7 @@ export const handleBuilderRowChange = ({ provided, question, currentQuestions, u } currentQuestions.setSelectedQuestions(newSelectedQuestions); currentQuestions.setQuestionOrder(newQuestionOrder); - if (provided) { + if (isDnd) { undoStack.push({questionOrder: currentQuestions.questionOrder, selectedQuestions: currentQuestions.selectedQuestions}); redoStack.clear(); } From fcd8f947f3d16f49bf41532c0799f0cd4072b037 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Mon, 11 May 2026 16:04:18 +0100 Subject: [PATCH 10/25] Fix bug with changes to order not saving --- src/app/components/pages/GameboardBuilder.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/components/pages/GameboardBuilder.tsx b/src/app/components/pages/GameboardBuilder.tsx index 354024bfa6..ab76a0dcb9 100644 --- a/src/app/components/pages/GameboardBuilder.tsx +++ b/src/app/components/pages/GameboardBuilder.tsx @@ -238,8 +238,10 @@ const GameboardBuilder = ({user}: {user: RegisteredUserDTO}) => { const reorder = (result: DropResult) => { if (result.destination) { - const [removed] = questionOrder.splice(result.source.index, 1); - questionOrder.splice(result.destination.index, 0, removed); + const newQuestionOrder = [...questionOrder]; + const [removed] = newQuestionOrder.splice(result.source.index, 1); + newQuestionOrder.splice(result.destination.index, 0, removed); + setQuestionOrder(newQuestionOrder); } }; @@ -448,11 +450,12 @@ const GameboardBuilder = ({user}: {user: RegisteredUserDTO}) => {
    + {/* dragging here can be a little choppy on local development if browser cache is disabled! */} selectedQuestions.get(questionId)).filter(isDefined)} + items={questionOrder.map((questionId) => selectedQuestions.get(questionId)).filter(isDefined)} onMove={(id, adjustment) => { const index = currentQuestions.questionOrder.findIndex(qId => qId === id); if (index === -1) return; @@ -469,7 +472,7 @@ const GameboardBuilder = ({user}: {user: RegisteredUserDTO}) => { } }} allowBookmarking - />; + />
    From 884f4f22b1ef4c2a2158fcc11d9fd645cbf1ae5b Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Mon, 11 May 2026 16:04:29 +0100 Subject: [PATCH 11/25] Improve bookmark alignment on thin ALVIs --- .../list-groups/AbstractListViewItem.tsx | 17 +++++++++++------ src/scss/common/list-groups.scss | 11 +++++++++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/app/components/elements/list-groups/AbstractListViewItem.tsx b/src/app/components/elements/list-groups/AbstractListViewItem.tsx index dfa3fcc176..20cd9f9f68 100644 --- a/src/app/components/elements/list-groups/AbstractListViewItem.tsx +++ b/src/app/components/elements/list-groups/AbstractListViewItem.tsx @@ -201,7 +201,7 @@ 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); @@ -292,11 +292,16 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb {isQuiz && } - {(isItem || isBuilder) && contentId && typedProps.allowBookmarking && isLoggedIn(user) && bookmarksFeatureFlag &&
    + } } {isItem && typedProps.hasCaret &&
    - {url && !isDisabled + {url && !isDisabled && !("disableRedirect" in typedProps && typedProps.disableRedirect) ? (url.startsWith("http") ? {title} diff --git a/src/app/components/elements/list-groups/ListView.tsx b/src/app/components/elements/list-groups/ListView.tsx index 45a62fca20..2e0d2659bc 100644 --- a/src/app/components/elements/list-groups/ListView.tsx +++ b/src/app/components/elements/list-groups/ListView.tsx @@ -380,6 +380,7 @@ export const BuilderListViewItem = (props: BuilderListViewItemProps) => { // breadcrumb={breadcrumb} audienceViews={audienceViews} className="flex-grow-1 align-content-center" + disableRedirect /> Drag to reorder -
    diff --git a/src/app/components/pages/GameboardBuilder.tsx b/src/app/components/pages/GameboardBuilder.tsx index ab76a0dcb9..8917af0dde 100644 --- a/src/app/components/pages/GameboardBuilder.tsx +++ b/src/app/components/pages/GameboardBuilder.tsx @@ -456,6 +456,7 @@ const GameboardBuilder = ({user}: {user: RegisteredUserDTO}) => { type="builder" style="flat" items={questionOrder.map((questionId) => selectedQuestions.get(questionId)).filter(isDefined)} + totalItems={questionOrder.length} onMove={(id, adjustment) => { const index = currentQuestions.questionOrder.findIndex(qId => qId === id); if (index === -1) return; diff --git a/src/scss/phy/icons.scss b/src/scss/phy/icons.scss index 3d3d923693..2819caa0ae 100644 --- a/src/scss/phy/icons.scss +++ b/src/scss/phy/icons.scss @@ -341,6 +341,7 @@ $icon-color-themes: ( "black": var(--color-neutral-900), "grey": var(--color-neutral-500), "muted": var(--color-neutral-300), + "disabled": var(--color-neutral-200), "white": var(--color-neutral-white), "theme": var(--subject-color-500), "alert": var(--bs-alert-color), From 301ec48b29a1795939156a15fe7afacb9f9ace03 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Thu, 14 May 2026 16:35:55 +0100 Subject: [PATCH 17/25] Improve focus-outlines on draggable builder This is a little ugly, but the `
  • `s *require* a white background (so as to appear as such while dragging), which necessarily overlaps any outline I can give it. --- .../elements/DraggableListViewItemWrapper.tsx | 2 +- src/app/components/pages/GameboardBuilder.tsx | 2 +- src/scss/common/accessibility.scss | 7 +++++ src/scss/common/gameboard.scss | 26 ------------------- src/scss/common/list-groups.scss | 8 ++++++ src/scss/phy/gameboard.scss | 13 +--------- 6 files changed, 18 insertions(+), 40 deletions(-) diff --git a/src/app/components/elements/DraggableListViewItemWrapper.tsx b/src/app/components/elements/DraggableListViewItemWrapper.tsx index 66eb812991..adf79b56d4 100644 --- a/src/app/components/elements/DraggableListViewItemWrapper.tsx +++ b/src/app/components/elements/DraggableListViewItemWrapper.tsx @@ -13,7 +13,7 @@ const DraggableListViewWrapper = ({ id, index, className, children, ...rest }: D return
  • {children} diff --git a/src/app/components/pages/GameboardBuilder.tsx b/src/app/components/pages/GameboardBuilder.tsx index 8917af0dde..d0ef9441b8 100644 --- a/src/app/components/pages/GameboardBuilder.tsx +++ b/src/app/components/pages/GameboardBuilder.tsx @@ -448,7 +448,7 @@ const GameboardBuilder = ({user}: {user: RegisteredUserDTO}) => {
  • -
    +
    {/* dragging here can be a little choppy on local development if browser cache is disabled! */} Date: Thu, 14 May 2026 17:02:27 +0100 Subject: [PATCH 18/25] Reduce builder footprint on mobile I don't love having to remove the drag handle, but we're really limited on space. A typical mobile app would use left/right swiping for certain actions (e.g. removal) which would help, but that's a significant amount of extra work. --- src/app/components/elements/list-groups/ListView.tsx | 10 ++++++---- src/scss/phy/gameboard.scss | 1 + 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/components/elements/list-groups/ListView.tsx b/src/app/components/elements/list-groups/ListView.tsx index 611ec46294..b7404c6e2a 100644 --- a/src/app/components/elements/list-groups/ListView.tsx +++ b/src/app/components/elements/list-groups/ListView.tsx @@ -2,7 +2,7 @@ import React, { lazy } from "react"; import { AbstractListViewItem, AbstractListViewItemProps, AbstractListViewProps } from "./AbstractListViewItem"; import { ShortcutResponse, ViewingContext } from "../../../../IsaacAppTypes"; import { determineAudienceViews } from "../../../services/userViewingContext"; -import { BOOK_DETAIL_ID_SEPARATOR, DOCUMENT_TYPE, documentTypePathPrefix, getThemeFromContextAndTags, HUMAN_STATUS, ISAAC_BOOKS, isAda, isPhy, PATHS, QUESTION_STATUS_TO_ICON, SEARCH_RESULT_TYPE, Subject, TAG_ID, TAG_LEVEL, tags } from "../../../services"; +import { BOOK_DETAIL_ID_SEPARATOR, DOCUMENT_TYPE, documentTypePathPrefix, getThemeFromContextAndTags, HUMAN_STATUS, ISAAC_BOOKS, isAda, isPhy, PATHS, QUESTION_STATUS_TO_ICON, SEARCH_RESULT_TYPE, Subject, TAG_ID, TAG_LEVEL, tags, useDeviceSize } from "../../../services"; import { Button, ListGroup } from "reactstrap"; import { AffixButton } from "../AffixButton"; import { CompletionState, ContentSummaryDTO, GameboardDTO, IsaacWildcard, QuizSummaryDTO } from "../../../../IsaacApiTypes"; @@ -353,8 +353,10 @@ export const BuilderListViewItem = (props: BuilderListViewItemProps) => { : {name: QUESTION_STATUS_TO_ICON[CompletionState.NOT_ATTEMPTED], size: "md", altText: classNames(HUMAN_STATUS[state], "question icon"), color: "tertiary", raw: true} }; + const deviceSize = useDeviceSize(); + return -
    + {deviceSize !== "xs" &&
    -
    +
    } img { filter: invert(1); } From 6806d9f7e78beadcb0e1f66e661ae32f2b4ba41f Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Thu, 14 May 2026 17:34:00 +0100 Subject: [PATCH 19/25] Improve builder on Ada --- .../components/elements/DraggableListViewItemWrapper.tsx | 3 ++- .../elements/list-groups/AbstractListViewItem.tsx | 4 ++-- src/app/components/elements/list-groups/ListView.tsx | 4 ++-- src/scss/common/list-groups.scss | 8 ++++++++ src/scss/cs/color-theme.scss | 2 ++ src/scss/phy/gameboard.scss | 1 + src/scss/phy/list-groups.scss | 8 -------- 7 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/app/components/elements/DraggableListViewItemWrapper.tsx b/src/app/components/elements/DraggableListViewItemWrapper.tsx index adf79b56d4..cccd81e4fd 100644 --- a/src/app/components/elements/DraggableListViewItemWrapper.tsx +++ b/src/app/components/elements/DraggableListViewItemWrapper.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Draggable } from "@hello-pangea/dnd"; import classNames from "classnames"; +import { isAda } from "../../services"; interface DraggableListViewWrapperProps extends React.HTMLAttributes { id: string; @@ -13,7 +14,7 @@ const DraggableListViewWrapper = ({ id, index, className, children, ...rest }: D return
  • {children} diff --git a/src/app/components/elements/list-groups/AbstractListViewItem.tsx b/src/app/components/elements/list-groups/AbstractListViewItem.tsx index a2e7927875..9bba32c310 100644 --- a/src/app/components/elements/list-groups/AbstractListViewItem.tsx +++ b/src/app/components/elements/list-groups/AbstractListViewItem.tsx @@ -277,7 +277,7 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb {!stackedLayout && <> {isPhy && isItem && typedProps.status && typedProps.status !== CompletionState.ALL_CORRECT && } - {flatLayout && (subtitle || breadcrumb) &&
    + {flatLayout && (subtitle || breadcrumb) &&
    {subtitle &&
    {subtitle}
    } @@ -286,7 +286,7 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb }
    } - {(isItem || isBuilder) && typedProps.audienceViews &&
    0})}> + {(isItem || isBuilder) && typedProps.audienceViews &&
    0})}>
    } {isGameboard && isTeacherOrAbove(user) && diff --git a/src/app/components/elements/list-groups/ListView.tsx b/src/app/components/elements/list-groups/ListView.tsx index b7404c6e2a..a266166215 100644 --- a/src/app/components/elements/list-groups/ListView.tsx +++ b/src/app/components/elements/list-groups/ListView.tsx @@ -2,7 +2,7 @@ import React, { lazy } from "react"; import { AbstractListViewItem, AbstractListViewItemProps, AbstractListViewProps } from "./AbstractListViewItem"; import { ShortcutResponse, ViewingContext } from "../../../../IsaacAppTypes"; import { determineAudienceViews } from "../../../services/userViewingContext"; -import { BOOK_DETAIL_ID_SEPARATOR, DOCUMENT_TYPE, documentTypePathPrefix, getThemeFromContextAndTags, HUMAN_STATUS, ISAAC_BOOKS, isAda, isPhy, PATHS, QUESTION_STATUS_TO_ICON, SEARCH_RESULT_TYPE, Subject, TAG_ID, TAG_LEVEL, tags, useDeviceSize } from "../../../services"; +import { BOOK_DETAIL_ID_SEPARATOR, DOCUMENT_TYPE, documentTypePathPrefix, getThemeFromContextAndTags, HUMAN_STATUS, ISAAC_BOOKS, isAda, isPhy, PATHS, QUESTION_STATUS_TO_ICON, SEARCH_RESULT_TYPE, siteSpecific, Subject, TAG_ID, TAG_LEVEL, tags, useDeviceSize } from "../../../services"; import { Button, ListGroup } from "reactstrap"; import { AffixButton } from "../AffixButton"; import { CompletionState, ContentSummaryDTO, GameboardDTO, IsaacWildcard, QuizSummaryDTO } from "../../../../IsaacApiTypes"; @@ -385,7 +385,7 @@ export const BuilderListViewItem = (props: BuilderListViewItemProps) => { className="flex-grow-1 align-content-center" disableRedirect /> - ; diff --git a/src/scss/common/list-groups.scss b/src/scss/common/list-groups.scss index a59cd2c4a8..698d1a1d98 100644 --- a/src/scss/common/list-groups.scss +++ b/src/scss/common/list-groups.scss @@ -59,6 +59,14 @@ } } +.list-view-border { + border-left: 1px solid var(--color-neutral-200, $gray-107); + padding-left: 1rem; + &.flex-column { + min-width: 220px; + } +} + .draggable-list-view-accessible { @extend .focus-visible-z-index; diff --git a/src/scss/cs/color-theme.scss b/src/scss/cs/color-theme.scss index adaf651120..b7fbe524d4 100644 --- a/src/scss/cs/color-theme.scss +++ b/src/scss/cs/color-theme.scss @@ -22,4 +22,6 @@ --subject-color-700: #{$dark-pink-200}; --subject-color-800: #{$dark-pink-200}; --subject-color-900: #{$dark-pink-100}; + + --bs-btn-disabled-border-color: transparent; } diff --git a/src/scss/phy/gameboard.scss b/src/scss/phy/gameboard.scss index 3e56f54ade..c8f93525e9 100644 --- a/src/scss/phy/gameboard.scss +++ b/src/scss/phy/gameboard.scss @@ -40,6 +40,7 @@ .gameboard-builder-container { max-height: 60vh; + overflow-y: auto; } button.btn.delete-button { diff --git a/src/scss/phy/list-groups.scss b/src/scss/phy/list-groups.scss index d136ef5975..9067742102 100644 --- a/src/scss/phy/list-groups.scss +++ b/src/scss/phy/list-groups.scss @@ -19,14 +19,6 @@ } } -.list-view-border { - border-left: 1px solid var(--color-neutral-200); - padding-left: 1rem; - &.flex-column { - min-width: 220px; - } -} - .list-view-card-container { border: none; background: transparent; From 1c3ced68d6a9fc536f3d17bd7318babd6f661fea Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Fri, 15 May 2026 10:32:57 +0100 Subject: [PATCH 20/25] Restore scroll shadows + align Ada + Sci --- .../elements/DraggableListViewItemWrapper.tsx | 9 +++++++-- .../elements/list-groups/ListView.tsx | 4 ++-- src/app/components/pages/GameboardBuilder.tsx | 2 +- src/scss/common/gameboard.scss | 5 +++++ src/scss/cs/isaac.scss | 1 + src/scss/cs/scroll.scss | 17 +++++++++++++++++ src/scss/phy/gameboard.scss | 5 ----- src/scss/phy/scroll.scss | 8 ++++++++ 8 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 src/scss/cs/scroll.scss diff --git a/src/app/components/elements/DraggableListViewItemWrapper.tsx b/src/app/components/elements/DraggableListViewItemWrapper.tsx index cccd81e4fd..d149f80029 100644 --- a/src/app/components/elements/DraggableListViewItemWrapper.tsx +++ b/src/app/components/elements/DraggableListViewItemWrapper.tsx @@ -10,11 +10,16 @@ interface DraggableListViewWrapperProps extends React.HTMLAttributes { return - {(providedDrag) => { + {(providedDrag, snapshot) => { return
  • {children} diff --git a/src/app/components/elements/list-groups/ListView.tsx b/src/app/components/elements/list-groups/ListView.tsx index a266166215..a45aa7f3e3 100644 --- a/src/app/components/elements/list-groups/ListView.tsx +++ b/src/app/components/elements/list-groups/ListView.tsx @@ -356,7 +356,7 @@ export const BuilderListViewItem = (props: BuilderListViewItemProps) => { const deviceSize = useDeviceSize(); return - {deviceSize !== "xs" &&
    + {deviceSize !== "xs" &&
    -
    +
    {/* dragging here can be a little choppy on local development if browser cache is disabled! */} Date: Fri, 15 May 2026 11:23:11 +0100 Subject: [PATCH 21/25] Improve modal on Ada --- .../elements/modals/QuestionSearchModal.tsx | 31 ++++++++++--------- src/scss/common/bootstrap-additions.scss | 10 ++++++ src/scss/cs/isaac.scss | 1 + src/scss/phy/isaac.scss | 1 + 4 files changed, 28 insertions(+), 15 deletions(-) create mode 100644 src/scss/common/bootstrap-additions.scss diff --git a/src/app/components/elements/modals/QuestionSearchModal.tsx b/src/app/components/elements/modals/QuestionSearchModal.tsx index 0c0cc37111..c62d619303 100644 --- a/src/app/components/elements/modals/QuestionSearchModal.tsx +++ b/src/app/components/elements/modals/QuestionSearchModal.tsx @@ -195,7 +195,7 @@ export const QuestionSearchModal = ( return <> - + - - {isPhy &&
    + {isPhy && +
    !b.hidden).map(book => ({value: book.tag, label: book.shortTitle}))} /> -
    } - +
    + }
    - + - + - {isAda && + {isAda && - Show FastTrack questions} onChange={e => { - startTransition(() => { - setSearchFastTrack(e.target.checked); - }); - }} /> - -
    + {isPhy && <> + Show FastTrack questions} onChange={e => { + startTransition(() => { + setSearchFastTrack(e.target.checked); + }); + }} /> +
    + } {isAda &&
      {groupBaseTagOptions.map((tag, index) => ( diff --git a/src/scss/common/bootstrap-additions.scss b/src/scss/common/bootstrap-additions.scss new file mode 100644 index 0000000000..1c04c2ec19 --- /dev/null +++ b/src/scss/common/bootstrap-additions.scss @@ -0,0 +1,10 @@ +// This file contains additions to Bootstrap that would undesirably override certain BS styles if they were placed after the main BS import. + +// this isn't in bootstrap for some reason. sm and lg+ are? +@include media-breakpoint-up(md) { + .modal-md, + .modal-lg, + .modal-xl { + --bs-modal-width: 680px; + } +} diff --git a/src/scss/cs/isaac.scss b/src/scss/cs/isaac.scss index a7ac74b019..8188110ef2 100644 --- a/src/scss/cs/isaac.scss +++ b/src/scss/cs/isaac.scss @@ -365,6 +365,7 @@ $theme-colors-bg-subtle: map-merge($theme-colors-bg-subtle, $custom-colors-bg-su $theme-colors-border-subtle: map-merge($theme-colors-border-subtle, $custom-colors-border-subtle); @import "bootstrap/scss/mixins"; +@import "../common/bootstrap-additions"; @import "bootstrap/scss/utilities"; @import "../common/bootstrap-contents"; // See https://getbootstrap.com/docs/5.3/customize/sass/#importing; using Option A ("@import "bootstrap/scss/bootstrap";") is not feasible as we need the default overrides above. diff --git a/src/scss/phy/isaac.scss b/src/scss/phy/isaac.scss index 9f82847b22..110dd5a1d5 100644 --- a/src/scss/phy/isaac.scss +++ b/src/scss/phy/isaac.scss @@ -215,6 +215,7 @@ $theme-colors-border-subtle: map-merge($theme-colors-border-subtle, $custom-colo // all Bootstrap overrides must appear before this @import "bootstrap/scss/mixins"; @import "../common/bootstrap-theme-override"; +@import "../common/bootstrap-additions"; @import "bootstrap/scss/utilities"; @import "../common/bootstrap-contents"; @import "dark-mode-overrides"; From 2213b00645b10fb9ab7d96157627486c26e7e421 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Fri, 15 May 2026 14:11:45 +0100 Subject: [PATCH 22/25] Enable filtering while book set; add filter list for clarity --- .../elements/list-groups/ListView.tsx | 4 +- .../elements/modals/QuestionSearchModal.tsx | 65 ++++++++++++++++--- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/app/components/elements/list-groups/ListView.tsx b/src/app/components/elements/list-groups/ListView.tsx index a45aa7f3e3..2b9e18af9b 100644 --- a/src/app/components/elements/list-groups/ListView.tsx +++ b/src/app/components/elements/list-groups/ListView.tsx @@ -358,11 +358,11 @@ export const BuilderListViewItem = (props: BuilderListViewItemProps) => { return {deviceSize !== "xs" &&
      - Drag to reorder -
      diff --git a/src/app/components/elements/modals/QuestionSearchModal.tsx b/src/app/components/elements/modals/QuestionSearchModal.tsx index c62d619303..a56a13e2a5 100644 --- a/src/app/components/elements/modals/QuestionSearchModal.tsx +++ b/src/app/components/elements/modals/QuestionSearchModal.tsx @@ -30,7 +30,12 @@ import { useUserViewingContext, ISAAC_BOOKS, TAG_LEVEL, - EXAM_BOARD, QUESTIONS_PER_GAMEBOARD + EXAM_BOARD, QUESTIONS_PER_GAMEBOARD, + simpleDifficultyLabelMap, + stageLabelMap, + TAG_ID, + itemise, + difficultyLabelMap } from "../../../services"; import {ContentSummary, GameboardBuilderQuestions, GameboardBuilderQuestionsStackProps, QuestionSearchQuery} from "../../../../IsaacAppTypes"; import {AudienceContext, ContentSummaryDTO, Difficulty, ExamBoard} from "../../../../IsaacApiTypes"; @@ -48,6 +53,8 @@ import { HorizontalScroller } from "../inputs/HorizontalScroller"; import { skipToken } from "@reduxjs/toolkit/query"; import { ShowLoadingQuery } from "../../handlers/ShowLoadingQuery"; import { FeatureFlag, FeatureFlagWrapper } from "../../../services/featureFlag"; +import { FilterSummary } from "../../pages/QuestionFinder"; +import { pruneTreeNode } from "../../../services/questionHierarchy"; // Immediately load GameboardBuilderTableRow, but allow splitting const importGameboardBuilderTableRow = import("../GameboardBuilderTableRow"); @@ -111,18 +118,19 @@ export const QuestionSearchModal = ( // Clear front-end sorting so as not to override ElasticSearch's match ranking setQuestionsSort({}); - const isBookSearch = book.length > 0; // Tasty. if ([searchString, topics, book, stages, difficulties, examBoards].every(v => v.length === 0) && !fasttrack) { // Nothing to search for return setSearchParams(skipToken); } - const tags = (isBookSearch ? book : [...([topics].map((tags) => tags.join(" ")))].filter((query) => query != "")).join(","); + const topicTags = [...([topics].map((tags) => tags.join(" ")))].filter((query) => query != "").join(","); + const bookTags = book.join(","); setSearchParams({ querySource: "gameboardBuilder", searchString: searchString || undefined, - tags: tags || undefined, + tags: topicTags, + books: bookTags, stages: stages.join(",") || undefined, difficulties: difficulties.join(",") || undefined, examBoards: examBoards.join(",") || undefined, @@ -193,6 +201,41 @@ export const QuestionSearchModal = ( setSearchTopics(getChoiceTreeLeaves(topicSelections).map((s) => s.value)); }, [topicSelections]); + const selectionList: Item[] = getChoiceTreeLeaves(topicSelections); + + const filterTags = useMemo(() => [ + searchDifficulties.map(d => {return {value: d, label: simpleDifficultyLabelMap[d]};}), + searchStages.map(s => {return {value: s, label: stageLabelMap[s]};}), + searchBook.map(b => {const book = ISAAC_BOOKS.find(book => book.tag === b); return {value: b, label: book ? book.shortTitle : b};}), + selectionList, + ].flat(), [searchBook, searchDifficulties, searchStages, selectionList]); + + const removeFilterTag = (filter: string) => { + if (searchStages.includes(filter as STAGE)) { + setSearchStages(searchStages.filter(f => f !== filter)); + } else if (getChoiceTreeLeaves(topicSelections).some(leaf => leaf.value === filter)) { + setTopicSelections(pruneTreeNode(topicSelections, filter, true)); + } else if (searchDifficulties.includes(filter as Difficulty)) { + setSearchDifficulties(searchDifficulties.filter(f => f !== filter)); + } else if (searchExamBoards.includes(filter as ExamBoard)) { + setSearchExamBoards(searchExamBoards.filter(f => f !== filter)); + } else if (searchBook.includes(filter)) { + setSearchBook(sb => sb.filter(f => f !== filter)); + } + }; + + const clearFilters = () => { + setSearchDifficulties([]); + setSearchTopics([]); + setSearchExamBoards([]); + setSearchStages([]); + setSearchBook([]); + setTopicSelections([{}, {}, {}]); + }; + + // only allowing search for a single book makes sense in this context, but we track it as an array to align with the other filters + const selectedBook = searchBook.length > 0 ? ISAAC_BOOKS.find(b => b.tag === searchBook[0]) : undefined; + return <> @@ -209,6 +252,7 @@ export const QuestionSearchModal = (
      !b.hidden).map(book => ({value: book.tag, label: book.shortTitle}))} @@ -218,21 +262,23 @@ export const QuestionSearchModal = ( - + itemise(s, stageLabelMap[s]))} inputId="question-search-stage" isClearable isMulti placeholder="Any" {...selectStyle} options={getFilteredStageOptions()} onChange={selectOnChange(setSearchStages, true)} /> - + itemise(d, difficultyLabelMap[d]))} inputId="question-search-difficulty" isClearable isMulti placeholder="Any" {...selectStyle} options={DIFFICULTY_ICON_ITEM_OPTIONS} onChange={selectOnChange(setSearchDifficulties, true)} /> - {isAda && + {isAda && ))}
    } - {isPhy && !isBookSearch &&
    + {isPhy &&
    + + + From e98b98afb5ed5f8da31c5a85b3aef7f6b3354780 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Fri, 15 May 2026 14:28:49 +0100 Subject: [PATCH 23/25] Add preview button to ALVI --- .../elements/GameboardBuilderTableRow.tsx | 21 ++------------ src/app/components/elements/PreviewButton.tsx | 22 ++++++++++++++ .../list-groups/AbstractListViewItem.tsx | 29 +++++++++++-------- .../elements/list-groups/ListView.tsx | 1 + 4 files changed, 43 insertions(+), 30 deletions(-) create mode 100644 src/app/components/elements/PreviewButton.tsx diff --git a/src/app/components/elements/GameboardBuilderTableRow.tsx b/src/app/components/elements/GameboardBuilderTableRow.tsx index 4bedb369a0..6fc2513bac 100644 --- a/src/app/components/elements/GameboardBuilderTableRow.tsx +++ b/src/app/components/elements/GameboardBuilderTableRow.tsx @@ -15,8 +15,6 @@ import { handleBuilderRowChange } from "../../services"; import React from "react"; -import {closeActiveModal, openActiveModal, useAppDispatch} from "../../state"; -import {Question} from "../pages/Question"; import {DifficultyIcons} from "./svg/DifficultyIcons"; import classNames from "classnames"; import { Spacer } from "./Spacer"; @@ -26,23 +24,15 @@ import { Markup } from "./markup"; import { ContentPropertyTags } from "./ContentPropertyTags"; import { IconButton } from "./AffixButton"; import { CrossTopicQuestionIndicator } from "./CrossTopicQuestionIndicator"; +import { PreviewQuestionButton } from "./PreviewButton"; const GameboardBuilderTableRow = ( {isDnd, snapshot: _snapshot, question, undoStack, currentQuestions, redoStack, creationContext}: GameboardBuilderRowInterface ) => { - const dispatch = useAppDispatch(); - const tagIcon = (tag: string) => { return {tag}; }; - const openQuestionModal = (urlQuestionId: string) => { - dispatch(openActiveModal({ - closeAction: () => {dispatch(closeActiveModal());}, size: "xl", - title: "Question preview", body: - })); - }; - const audienceViews = determineAudienceViews(question.audience, creationContext); const filteredAudienceViews = audienceViews.length === 0 ? [{stage: undefined, difficulty: undefined}] : filterAudienceViewsByProperties(audienceViews, AUDIENCE_DISPLAY_FIELDS); @@ -70,15 +60,10 @@ const GameboardBuilderTableRow = ( {isDnd && Drag to reorder}
    - + {generateQuestionTitle(question)} - +
    diff --git a/src/app/components/elements/PreviewButton.tsx b/src/app/components/elements/PreviewButton.tsx new file mode 100644 index 0000000000..37a8356545 --- /dev/null +++ b/src/app/components/elements/PreviewButton.tsx @@ -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: + })); + }; + + if (!id) return null; + + return ; +}; diff --git a/src/app/components/elements/list-groups/AbstractListViewItem.tsx b/src/app/components/elements/list-groups/AbstractListViewItem.tsx index 9bba32c310..62fe2bce8e 100644 --- a/src/app/components/elements/list-groups/AbstractListViewItem.tsx +++ b/src/app/components/elements/list-groups/AbstractListViewItem.tsx @@ -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 <> @@ -157,6 +158,7 @@ type ALVIType = { 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 = { @@ -222,19 +224,22 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb
    - {url && !isDisabled && !("disableRedirect" in typedProps && typedProps.disableRedirect) - ? (url.startsWith("http") - ? + <> + {url && !isDisabled && !("disableRedirect" in typedProps && typedProps.disableRedirect) + ? (url.startsWith("http") + ? + {title} + + : + {title} + + ) + : {title} - - : - {title} - - ) - : - {title} - - } + + } + {isBuilder && typedProps.questionPreviewId && } + {isItem && <> {typedProps.quizTag && {typedProps.quizTag}} { // breadcrumb={breadcrumb} audienceViews={audienceViews} className="flex-grow-1 align-content-center bg-transparent" + questionPreviewId={item.id} disableRedirect />