Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions public/assets/common/bookmark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions public/assets/common/icons/bookmark-fill.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions public/assets/common/icons/bookmark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions public/assets/phy/icons/redesign/page-bookmarks-bg.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions public/assets/phy/icons/redesign/page-bookmarks-fg.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/IsaacApiTypes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ export interface ContentSummaryDTO {
tags?: string[];
url?: string;
state?: CompletionState;
bookmarked?: Date;
supersededBy?: string;
deprecated?: boolean;
difficulty?: string;
Expand Down
14 changes: 14 additions & 0 deletions src/IsaacAppTypes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,20 @@ export interface ActiveModalProps {
bodyContainerClassName?: string;
}

export enum BookmarksOrder {
"date" = "date",
"-date" = "-date",
"title" = "title",
"-title" = "-title"
}

export const BOOKMARKS_ORDER_NAMES: Record<BookmarksOrder, string> = {
[BookmarksOrder.date]: "Date added (newest first)",
[BookmarksOrder["-date"]]: "Date added (oldest first)",
[BookmarksOrder.title]: "Title (A-Z)",
[BookmarksOrder["-title"]]: "Title (Z-A)"
};

export type ProgressSortOrder = number | "name" | "totalPartPercentage" | "totalAttemptedPartPercentage" | "totalQuestionPercentage" | "totalAttemptedQuestionPercentage";

export enum QuizzesBoardOrder {
Expand Down
38 changes: 38 additions & 0 deletions src/app/components/elements/BookmarkButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React, { useState } from "react";
import { IconButton } from "./AffixButton";
import { useBookmarks } from "../../services/bookmarks";
import classNames from "classnames";
import { ContentDTO } from "../../../IsaacApiTypes";
import { Tooltip } from "reactstrap";

export const BookmarkButton = ({ doc }: { doc?: ContentDTO }) => {
const { isBookmarked, bookmarkItem } = useBookmarks();
const isQuestionBookmarked = doc?.type === "isaacQuestionPage" && doc.id ? isBookmarked(doc.id) : false;
const [showBookmarkTooltip, setShowBookmarkTooltip] = useState(false);

return <>
<IconButton
id="bookmark-button"
icon={classNames("icon-bookmark", "icon-color-black-hoverable", {"fill": isQuestionBookmarked})}
color="tint"
data-bs-theme="neutral"
onClick={() => {
if (!isQuestionBookmarked) {
setShowBookmarkTooltip(true);
setTimeout(() => setShowBookmarkTooltip(false), 1000);
}

bookmarkItem(doc?.id);
}}
/>
<Tooltip
id="bookmark-popover"
target="bookmark-button"
isOpen={showBookmarkTooltip}
toggle={() => setShowBookmarkTooltip(!showBookmarkTooltip)}
trigger="manual"
>
<div className="popover-header">Saved!</div>
</Tooltip>
</>;
};
6 changes: 6 additions & 0 deletions src/app/components/elements/PageMetadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { UserContextPicker } from './inputs/UserContextPicker';
import { LLMFreeTextQuestionIndicator } from './LLMFreeTextQuestionIndicator';
import { CrossTopicQuestionIndicator } from './CrossTopicQuestionIndicator';
import { selectors, useAppSelector } from '../../state';
import { BookmarkButton } from './BookmarkButton';
import { FeatureFlag, FeatureFlagWrapper } from '../../services/featureFlag';

type PageMetadataProps = {
doc?: SeguePageDTO;
Expand Down Expand Up @@ -51,6 +53,10 @@ export const ActionButtons = ({location, isQuestion, helpModalId, doc, ...rest}:
const anyActionButtonShown = isPhy && helpModalId || above['sm'](deviceSize) || doc?.id;

return anyActionButtonShown && <div {...rest} className={classNames("d-flex no-print gap-2", rest.className)}>
{isPhy && isQuestion && <FeatureFlagWrapper flag={FeatureFlag.ENABLE_SCI_BOOKMARKS}>
<BookmarkButton doc={doc} />
</FeatureFlagWrapper>
}
{isPhy && helpModalId && <HelpButton modalId={helpModalId} />}
{above['sm'](deviceSize) && <>
<ShareLink linkUrl={location.pathname + location.hash} clickAwayClose />
Expand Down
14 changes: 13 additions & 1 deletion src/app/components/elements/list-groups/AbstractListViewItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ViewingContext} from "../../../../IsaacAppTypes";
import classNames from "classnames";
import { Badge, Button, Col, ListGroupItem, ListGroupItemProps } from "reactstrap";
import { CompletionState, GameboardDTO } from "../../../../IsaacApiTypes";
import { above, below, isAda, isDefined, isPhy, isStaff, isTeacherOrAbove, siteSpecific, Subject, useDeviceSize } from "../../../services";
import { above, below, isAda, isDefined, isLoggedIn, isPhy, isStaff, isTeacherOrAbove, siteSpecific, Subject, useDeviceSize } from "../../../services";
import { TitleIcon, TitleIconProps } from "../PageTitle";
import { Markup } from "../markup";
import { closeActiveModal, openActiveModal, selectors, useAppDispatch, useAppSelector, useLazyGetGroupsQuery, useLazyGetMySetAssignmentsQuery, useUnassignGameboardMutation } from "../../../state";
Expand All @@ -16,6 +16,8 @@ import { ContentPropertyTags } from "../ContentPropertyTags";
import { LLMFreeTextQuestionIndicator } from "../LLMFreeTextQuestionIndicator";
import { CrossTopicQuestionIndicator } from "../CrossTopicQuestionIndicator";
import { SupersededDeprecatedBoardContentWarning } from "../../navigation/SupersededDeprecatedWarning";
import { useBookmarks } from "../../../services/bookmarks";
import { FeatureFlag, useFeatureFlag } from "../../../services/featureFlag";

const Breadcrumb = ({breadcrumb}: {breadcrumb: string[]}) => {
return <>
Expand Down Expand Up @@ -134,6 +136,7 @@ type ALVIType = {
quizTag?: string; // this is for quick quizzes only, which are currently just gameboards; may change in future
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
} | {
// quizzes – have exclusive "preview" and "view test" buttons
alviType: "quiz";
Expand Down Expand Up @@ -180,8 +183,13 @@ export type AbstractListViewProps = ALVILayout & {

export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb, tags, style, url, state, className, componentTag, ...typedProps}: AbstractListViewItemProps & AbstractListViewProps) => {
const deviceSize = useDeviceSize();
const { isBookmarked, bookmarkItem } = useBookmarks();
const user = useAppSelector(selectors.user.orNull);

const bookmarksFeatureFlag = useFeatureFlag(FeatureFlag.ENABLE_SCI_BOOKMARKS);

const contentId = (url?.includes("/questions/") || url?.includes("/concepts/")) && url.split("/").slice(-1)[0];

const isItem = typedProps.alviType === "item";
const isGameboard = typedProps.alviType === "gameboard";
const isQuiz = typedProps.alviType === "quiz";
Expand Down Expand Up @@ -273,6 +281,10 @@ 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 && typedProps.hasCaret && <div className="list-caret align-content-center" aria-hidden="true">
Expand Down
120 changes: 120 additions & 0 deletions src/app/components/elements/sidebar/MyBookmarksSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import React, { useState, ChangeEvent, useMemo } from "react";
import { Input } from "reactstrap";
import { getFilteredStageOptions, getSearchPlaceholder, TAG_ID, tags } from "../../../services";
import { ContentSidebarProps, ContentSidebar } from "../layout/SidebarLayout";
import { CheckboxWrapper, StyledCheckbox } from "../inputs/StyledCheckbox";
import { CollapsibleList } from "../CollapsibleList";
import { ContentSummaryDTO } from "../../../../IsaacApiTypes";
import { BOOKMARKS_ORDER_NAMES, BookmarksOrder } from "../../../../IsaacAppTypes";

interface MyBookmarksSidebarProps extends ContentSidebarProps {
bookmarks: ContentSummaryDTO[];
searchText: string;
setSearchText: React.Dispatch<React.SetStateAction<string>>;
searchSubjects: string[];
setSearchSubjects: React.Dispatch<React.SetStateAction<string[]>>;
searchStages: string[];
setSearchStages: React.Dispatch<React.SetStateAction<string[]>>;
sortOrder: BookmarksOrder;
setSortOrder: React.Dispatch<React.SetStateAction<BookmarksOrder>>;
}

export const MyBookmarksSidebar = (props: MyBookmarksSidebarProps) => {
const { bookmarks, searchText, setSearchText, searchSubjects, setSearchSubjects, searchStages, setSearchStages, sortOrder, setSortOrder, ...rest } = props;

const [subjectExpanded, toggleSubjectExpanded] = useState(true);
const [stageExpanded, toggleStageExpanded] = useState(false);

const tagCounts = useMemo<Record<string, number>>(() => {
return bookmarks.reduce((counts, bookmark) => {
bookmark.tags?.forEach(tag => {
// only track tags we care about
if (tags.allSubjectTags.includes(tags.getById(tag as TAG_ID))) {
counts[tag] = (counts[tag] || 0) + 1;
}
});
return counts;
}, {} as Record<string, number>);
}, [bookmarks]);

const stageCounts = useMemo<Record<string, number>>(() => {
return bookmarks.reduce((counts, bookmark) => {
bookmark.audience?.forEach(audience => {
audience.stage?.forEach(stage => {
counts[stage] = (counts[stage] || 0) + 1;
});
});
return counts;
}, {} as Record<string, number>);
}, [bookmarks]);

return <ContentSidebar {...rest}>
<search>
<div className="section-divider"/>
<h5>Search bookmarks</h5>
<Input
className='search--filter-input my-4'
type="search" value={searchText || ""}
placeholder={`e.g. ${getSearchPlaceholder()}`}
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchText(e.target.value)}
/>

<div className="section-divider"/>

<h5>Filter bookmarks by...</h5>

<div className="d-flex flex-column my-4">
<CollapsibleList
title={"Subject"} expanded={subjectExpanded}
toggle={() => toggleSubjectExpanded(e => !e)}
numberSelected={searchSubjects.length}
className="ps-0"
>
{tags.allSubjectTags.map((subject, index) => (
<li key={index}>
<CheckboxWrapper active={searchSubjects.includes(subject.id)}>
<StyledCheckbox
color="primary"
checked={searchSubjects.includes(subject.id)}
onChange={() => setSearchSubjects(s => s.includes(subject.id) ? s.filter(v => v !== subject.id) : [...s, subject.id])}
label={<>
{subject.title} {" "}
<span className="text-muted">({tagCounts[subject.id] || 0})</span>
</>}
/>
</CheckboxWrapper>
</li>
))}
</CollapsibleList>

<CollapsibleList
title={"Learning stage"} expanded={stageExpanded}
toggle={() => toggleStageExpanded(e => !e)}
numberSelected={searchStages.length}
>
{getFilteredStageOptions().map((stage, index) => (
<li key={index}>
<CheckboxWrapper active={searchStages.includes(stage.value)}>
<StyledCheckbox
color="primary"
checked={searchStages.includes(stage.value)}
onChange={() => setSearchStages(s => s.includes(stage.value) ? s.filter(v => v !== stage.value) : [...s, stage.value])}
label={<>
{stage.label} {" "}
<span className="text-muted">({stageCounts[stage.value] || 0})</span>
</>}
/>
</CheckboxWrapper>
</li>
))}
</CollapsibleList>
</div>

<h5>Sort bookmarks by...</h5>

<Input type="select" className="ps-3 my-3" value={sortOrder} onChange={e => setSortOrder(e.target.value as BookmarksOrder)}>
{Object.values(BookmarksOrder).map(order => <option key={order} value={order}>{BOOKMARKS_ORDER_NAMES[order]}</option>)}
</Input>
</search>
</ContentSidebar>;
};
78 changes: 78 additions & 0 deletions src/app/components/pages/MyBookmarks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React, { useState } from "react";
import { useGetBookmarksQuery } from "../../state";
import { ListView } from "../elements/list-groups/ListView";
import { PageContainer } from "../elements/layout/PageContainer";
import { TitleAndBreadcrumb } from "../elements/TitleAndBreadcrumb";
import { PageMetadata } from "../elements/PageMetadata";
import { ShowLoadingQuery } from "../handlers/ShowLoadingQuery";
import { MyBookmarksSidebar } from "../elements/sidebar/MyBookmarksSidebar";
import { BookmarksOrder } from "../../../IsaacAppTypes";
import { PageFragment } from "../elements/PageFragment";

export const MyBookmarks = () => {
const bookmarksQuery = useGetBookmarksQuery();

const [searchText, setSearchText] = useState("");
const [searchSubjects, setSearchSubjects] = useState<string[]>([]);
const [searchStages, setSearchStages] = useState<string[]>([]);
const [sortOrder, setSortOrder] = useState<BookmarksOrder>(BookmarksOrder.date);

return <PageContainer
pageTitle={<TitleAndBreadcrumb currentPageTitle="My bookmarks" icon={{type: "icon", icon: "icon-my-bookmarks"}} />}
sidebar={<MyBookmarksSidebar
searchText={searchText}
setSearchText={setSearchText}
searchSubjects={searchSubjects}
setSearchSubjects={setSearchSubjects}
searchStages={searchStages}
setSearchStages={setSearchStages}
bookmarks={bookmarksQuery.data || []}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
/>}
>

<PageMetadata noTitle>
<PageFragment fragmentId="help_toptext_bookmarks" />
</PageMetadata>

<ShowLoadingQuery
query={bookmarksQuery}
defaultErrorTitle="Could not load your bookmarks. Please try again later."
thenRender={(bookmarks) => {
if (bookmarks.length === 0) {
return <span>You have no bookmarks yet.</span>;
}

const filteredBookmarks = bookmarks.filter(bookmark => {
if (!bookmark) return false;
const matchesSearchText = bookmark.title?.toLowerCase().includes(searchText.toLowerCase());
const matchesSubject = searchSubjects.length === 0 || bookmark.tags?.some(tag => searchSubjects.includes(tag));
const matchesStage = searchStages.length === 0 || bookmark.audience?.some(audience => audience.stage?.some(stage => searchStages.includes(stage)));
return matchesSearchText && matchesSubject && matchesStage;
});

const sortedBookmarks = [...filteredBookmarks].sort((a, b) => {
if (sortOrder === BookmarksOrder.date) {
if (!a.bookmarked || !b.bookmarked) return 0;
return new Date(b.bookmarked).getTime() - new Date(a.bookmarked).getTime();
} else if (sortOrder === BookmarksOrder["-date"]) {
if (!a.bookmarked || !b.bookmarked) return 0;
return new Date(a.bookmarked).getTime() - new Date(b.bookmarked).getTime();
} else if (sortOrder === BookmarksOrder.title) {
return (a.title || "").localeCompare(b.title || "");
} else if (sortOrder === BookmarksOrder["-title"]) {
return (b.title || "").localeCompare(a.title || "");
}
return 0;
});

return <ListView
type="item"
items={sortedBookmarks}
allowBookmarking
/>;
}}
/>
</PageContainer>;
};
2 changes: 1 addition & 1 deletion src/app/components/pages/QuestionFinder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ export const QuestionFinder = () => {
</ResultsListHeader>
<CardBody className={classNames({"border-0": isPhy, "p-0": questions?.length, "m-0": isAda && questions?.length})}>
{questions?.length
? <ListView type="item" items={questions} hideIconLabel/>
? <ListView type="item" items={questions} allowBookmarking hideIconLabel/>
: isAda && (filteringByStatus
? <span>Could not load any results matching the requested filters.</span>
: <span>No results match the requested filters.</span>
Expand Down
Loading
Loading