From 98eec1b3d87a328ac0e6842d92c4b8015a5e5cd3 Mon Sep 17 00:00:00 2001 From: Charles Nykamp Date: Wed, 22 Apr 2026 11:15:41 -0500 Subject: [PATCH 1/4] refactor: move category types and validation logic to shared package - Move CategoryGroup and Category types to packages/shared/src/types/categories.ts - Move isActivityFullyCategorized() logic to packages/shared/src/logic/browsable.ts - Update all imports across app and api workspaces to use the shared package - Export new types and logic from packages/shared/src/index.ts This allows category-related code to be reused across the platform without duplication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/api/src/query/classification.ts | 2 +- apps/api/src/types.ts | 13 --------- .../src/drawers/ExploreFilterDrawer.cy.tsx | 7 ++--- apps/app/src/drawers/ExploreFilterDrawer.tsx | 7 ++--- apps/app/src/paths/Explore.tsx | 8 ++--- apps/app/src/paths/editor/EditorHeader.tsx | 14 ++++----- .../paths/editor/EditorSettingsMode.cy.tsx | 3 +- .../src/paths/editor/EditorSettingsMode.tsx | 3 +- apps/app/src/popups/ShareMyContentModal.tsx | 2 +- apps/app/src/types.ts | 13 --------- apps/app/src/utils/classification.tsx | 29 +------------------ apps/app/src/widgets/FilterPanel.cy.tsx | 3 +- apps/app/src/widgets/FilterPanel.tsx | 7 ++--- .../src/widgets/editor/EditCategories.cy.tsx | 2 +- .../app/src/widgets/editor/EditCategories.tsx | 2 +- packages/shared/src/index.ts | 2 ++ packages/shared/src/logic/browsable.ts | 28 ++++++++++++++++++ packages/shared/src/types/categories.ts | 12 ++++++++ 18 files changed, 65 insertions(+), 92 deletions(-) create mode 100644 packages/shared/src/logic/browsable.ts create mode 100644 packages/shared/src/types/categories.ts diff --git a/apps/api/src/query/classification.ts b/apps/api/src/query/classification.ts index d2645ea14b..3a61a2ef18 100644 --- a/apps/api/src/query/classification.ts +++ b/apps/api/src/query/classification.ts @@ -1,7 +1,6 @@ import { Prisma } from "@prisma/client"; import { prisma } from "../model"; import { - CategoryGroup, ClassificationCategoryTree, ContentClassification, PartialContentClassification, @@ -20,6 +19,7 @@ import { getIsEditor, } from "../utils/permissions"; import { InvalidRequestError } from "../utils/error"; +import { CategoryGroup } from "@doenet-tools/shared"; export async function getClassificationCategories() { const results = await prisma.classificationSystems.findMany({ diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts index fe753475a7..21cfc44eba 100644 --- a/apps/api/src/types.ts +++ b/apps/api/src/types.ts @@ -103,19 +103,6 @@ export type UserInfoWithEmail = UserInfo & { isEditor?: boolean; }; -export type CategoryGroup = { - name: string; - isRequired: boolean; - isExclusive: boolean; - categories: Category[]; -}; - -export type Category = { - code: string; - description: string; - term: string; -}; - export type ContentClassification = { id: number; code: string; diff --git a/apps/app/src/drawers/ExploreFilterDrawer.cy.tsx b/apps/app/src/drawers/ExploreFilterDrawer.cy.tsx index c13879c55a..f4563cd63b 100644 --- a/apps/app/src/drawers/ExploreFilterDrawer.cy.tsx +++ b/apps/app/src/drawers/ExploreFilterDrawer.cy.tsx @@ -1,9 +1,6 @@ import { ExploreFilterDrawer } from "./ExploreFilterDrawer"; -import { - CategoryGroup, - PartialContentClassification, - UserInfo, -} from "../types"; +import { PartialContentClassification, UserInfo } from "../types"; +import { CategoryGroup } from "@doenet-tools/shared"; describe("ExploreFilterDrawer", { tags: ["@group1"] }, () => { const mockAuthors: UserInfo[] = [ diff --git a/apps/app/src/drawers/ExploreFilterDrawer.tsx b/apps/app/src/drawers/ExploreFilterDrawer.tsx index dd7eed32a3..ea304d9808 100644 --- a/apps/app/src/drawers/ExploreFilterDrawer.tsx +++ b/apps/app/src/drawers/ExploreFilterDrawer.tsx @@ -6,11 +6,8 @@ import { DrawerContent, DrawerOverlay, } from "@chakra-ui/react"; -import { - CategoryGroup, - PartialContentClassification, - UserInfo, -} from "../types"; +import { PartialContentClassification, UserInfo } from "../types"; +import { CategoryGroup } from "@doenet-tools/shared"; import { NavigateFunction } from "react-router"; import { FilterPanel } from "../widgets/FilterPanel"; diff --git a/apps/app/src/paths/Explore.tsx b/apps/app/src/paths/Explore.tsx index 6cdb44c5b2..15982d1881 100644 --- a/apps/app/src/paths/Explore.tsx +++ b/apps/app/src/paths/Explore.tsx @@ -36,12 +36,8 @@ import Searchbar from "../widgets/SearchBar"; import { Form, useFetcher } from "react-router"; import { CardContent } from "../widgets/Card"; import { createNameNoTag, createNameCheckCurateTag } from "../utils/names"; -import { - Content, - UserInfo, - PartialContentClassification, - CategoryGroup, -} from "../types"; +import { Content, UserInfo, PartialContentClassification } from "../types"; +import { CategoryGroup } from "@doenet-tools/shared"; import CardList from "../widgets/CardList"; import { intWithCommas } from "../utils/formatting"; import { MdFilterAlt, MdFilterAltOff } from "react-icons/md"; diff --git a/apps/app/src/paths/editor/EditorHeader.tsx b/apps/app/src/paths/editor/EditorHeader.tsx index c2d3f4b955..22235c3ff0 100644 --- a/apps/app/src/paths/editor/EditorHeader.tsx +++ b/apps/app/src/paths/editor/EditorHeader.tsx @@ -44,13 +44,7 @@ import { IoGitBranch } from "react-icons/io5"; import { LuCircleHelp, LuLibraryBig } from "react-icons/lu"; import axios from "axios"; -import { - AssignmentStatus, - ContentType, - ContentDescription, - CategoryGroup, - Category, -} from "../../types"; +import { AssignmentStatus, ContentType, ContentDescription } from "../../types"; import { contentTypeToName, getIconInfo } from "../../utils/activity"; import { SiteContext } from "../SiteHeader"; import { getDiscourseUrl } from "../../utils/discourse"; @@ -63,12 +57,16 @@ import { LibraryEditorControls } from "../../widgets/editor/LibraryEditorControl import { editorUrl } from "../../utils/url"; import { NameBar } from "../../widgets/NameBar"; import { loader as settingsLoader } from "./EditorSettingsMode"; -import { isActivityFullyCategorized } from "../../utils/classification"; import { useIframeMenuDismissOverlay } from "../../utils/useIframeMenuDismissOverlay"; import { MenuDismissOverlay } from "../../components/MenuDismissOverlay"; import { IFRAME_MENU_IDS } from "../../utils/iframeMenuIds"; import { useControlledMenu } from "../../utils/useControlledMenu"; import { useMenuTooltipSuppression } from "../../utils/useMenuTooltipSuppression"; +import { + Category, + CategoryGroup, + isActivityFullyCategorized, +} from "@doenet-tools/shared"; export async function loader({ params, diff --git a/apps/app/src/paths/editor/EditorSettingsMode.cy.tsx b/apps/app/src/paths/editor/EditorSettingsMode.cy.tsx index 609d43e397..738d44a36d 100644 --- a/apps/app/src/paths/editor/EditorSettingsMode.cy.tsx +++ b/apps/app/src/paths/editor/EditorSettingsMode.cy.tsx @@ -1,12 +1,11 @@ import { EditorSettingsModeComponent } from "./EditorSettingsMode"; import { AssignmentMode, - Category, - CategoryGroup, ContentClassification, DoenetmlVersion, LicenseCode, } from "../../types"; +import { Category, CategoryGroup } from "@doenet-tools/shared"; import { FetcherWithComponents } from "react-router"; describe("EditorSettingsModeComponent", { tags: ["@group1"] }, () => { diff --git a/apps/app/src/paths/editor/EditorSettingsMode.tsx b/apps/app/src/paths/editor/EditorSettingsMode.tsx index 5778f11a9b..fafcd14c33 100644 --- a/apps/app/src/paths/editor/EditorSettingsMode.tsx +++ b/apps/app/src/paths/editor/EditorSettingsMode.tsx @@ -7,11 +7,10 @@ import { import { AssignmentMode, ContentClassification, - Category, DoenetmlVersion, LicenseCode, - CategoryGroup, } from "../../types"; +import { Category, CategoryGroup } from "@doenet-tools/shared"; import axios from "axios"; import { Box, Heading, VStack } from "@chakra-ui/react"; import { BlueBanner } from "../../widgets/BlueBanner"; diff --git a/apps/app/src/popups/ShareMyContentModal.tsx b/apps/app/src/popups/ShareMyContentModal.tsx index c1329d0d41..98fa65e0b4 100644 --- a/apps/app/src/popups/ShareMyContentModal.tsx +++ b/apps/app/src/popups/ShareMyContentModal.tsx @@ -34,7 +34,7 @@ import { FiCode } from "react-icons/fi"; import { loader as settingsLoader } from "../paths/editor/EditorSettingsMode"; import { editorUrl } from "../utils/url"; -import { isActivityFullyCategorized } from "../utils/classification"; +import { isActivityFullyCategorized } from "@doenet-tools/shared"; export async function loadShareStatus({ params }: { params: any }) { const { data } = await axios.get( diff --git a/apps/app/src/types.ts b/apps/app/src/types.ts index 6dee1f845b..b89274a152 100644 --- a/apps/app/src/types.ts +++ b/apps/app/src/types.ts @@ -87,19 +87,6 @@ export type UserInfoWithEmail = UserInfo & { isEditor?: boolean; }; -export type CategoryGroup = { - name: string; - isRequired: boolean; - isExclusive: boolean; - categories: Category[]; -}; - -export type Category = { - code: string; - description: string; - term: string; -}; - export type ContentClassification = { id: number; code: string; diff --git a/apps/app/src/utils/classification.tsx b/apps/app/src/utils/classification.tsx index 7ea476f477..46016cf963 100644 --- a/apps/app/src/utils/classification.tsx +++ b/apps/app/src/utils/classification.tsx @@ -1,5 +1,5 @@ import { ReactElement } from "react"; -import { Category, CategoryGroup, ContentClassification } from "../types"; +import { ContentClassification } from "../types"; import { Text, Accordion, @@ -73,30 +73,3 @@ export function returnClassificationsAccordionPanel( ); } - -/** - * Detect whether or not this activity has the required categories filled out. - * For each group that is required, make sure this activity has at least 1 category in that group. - * Returns true if all required groups have at least one category filled out, false otherwise. - */ -export function isActivityFullyCategorized({ - allCategories, - categories, -}: { - allCategories: CategoryGroup[]; - categories: Category[]; -}) { - const existingCodes = categories.map((c) => c.code); - - for (const group of allCategories.filter((g) => g.isRequired)) { - const groupCategoryCodes = group.categories.map((c) => c.code); - const intersection = existingCodes.filter((code) => - groupCategoryCodes.includes(code), - ); - if (intersection.length === 0) { - return false; - } - } - - return true; -} diff --git a/apps/app/src/widgets/FilterPanel.cy.tsx b/apps/app/src/widgets/FilterPanel.cy.tsx index b8da2d1631..3adc40de24 100644 --- a/apps/app/src/widgets/FilterPanel.cy.tsx +++ b/apps/app/src/widgets/FilterPanel.cy.tsx @@ -1,5 +1,6 @@ import { FilterPanel } from "./FilterPanel"; -import { CategoryGroup, UserInfo } from "../types"; +import { UserInfo } from "../types"; +import { CategoryGroup } from "@doenet-tools/shared"; type FilterPanelProps = React.ComponentProps; diff --git a/apps/app/src/widgets/FilterPanel.tsx b/apps/app/src/widgets/FilterPanel.tsx index 03a783394a..11efe303f3 100644 --- a/apps/app/src/widgets/FilterPanel.tsx +++ b/apps/app/src/widgets/FilterPanel.tsx @@ -25,11 +25,8 @@ import { ReactElement } from "react"; import { createNameNoTag } from "../utils/names"; import { CloseIcon } from "@chakra-ui/icons"; import { activityCategoryIcons } from "../utils/activity"; -import { - CategoryGroup, - PartialContentClassification, - UserInfo, -} from "../types"; +import { PartialContentClassification, UserInfo } from "../types"; +import { CategoryGroup } from "@doenet-tools/shared"; import { intWithCommas } from "../utils/formatting"; import { Link as ReactRouterLink, NavigateFunction } from "react-router"; import { clearQueryParameter } from "../utils/explore"; diff --git a/apps/app/src/widgets/editor/EditCategories.cy.tsx b/apps/app/src/widgets/editor/EditCategories.cy.tsx index 088797fc85..f76a3d0e29 100644 --- a/apps/app/src/widgets/editor/EditCategories.cy.tsx +++ b/apps/app/src/widgets/editor/EditCategories.cy.tsx @@ -1,5 +1,5 @@ import { EditCategories } from "./EditCategories"; -import { CategoryGroup } from "../../types"; +import { CategoryGroup } from "@doenet-tools/shared"; describe("EditCategories Component", { tags: ["@group3"] }, () => { const mockAllCategories: CategoryGroup[] = [ diff --git a/apps/app/src/widgets/editor/EditCategories.tsx b/apps/app/src/widgets/editor/EditCategories.tsx index ecd9074a3e..c5328be617 100644 --- a/apps/app/src/widgets/editor/EditCategories.tsx +++ b/apps/app/src/widgets/editor/EditCategories.tsx @@ -16,8 +16,8 @@ import { } from "@chakra-ui/react"; import { useFetcher } from "react-router"; import { optimistic } from "../../utils/optimistic_ui"; -import { Category, CategoryGroup } from "../../types"; import { activityCategoryIcons } from "../../utils/activity"; +import { Category, CategoryGroup } from "@doenet-tools/shared"; export function EditCategories({ contentId, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 32bfd36404..2fdedfa549 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,6 @@ export * from "./types/activityViewer.js"; +export * from "./types/categories.js"; export * from "./utils/ipfs.js"; export * from "./utils/test.js"; export * from "./apiTypes.js"; +export * from "./logic/browsable.js"; diff --git a/packages/shared/src/logic/browsable.ts b/packages/shared/src/logic/browsable.ts new file mode 100644 index 0000000000..8a78a235c1 --- /dev/null +++ b/packages/shared/src/logic/browsable.ts @@ -0,0 +1,28 @@ +import { CategoryGroup, Category } from "../types/categories.js"; + +/** + * Detect whether or not this activity has the required categories filled out. + * For each group that is required, make sure this activity has at least 1 category in that group. + * Returns true if all required groups have at least one category filled out, false otherwise. + */ +export function isActivityFullyCategorized({ + allCategories, + categories, +}: { + allCategories: CategoryGroup[]; + categories: Category[]; +}) { + const existingCodes = categories.map((c) => c.code); + + for (const group of allCategories.filter((g) => g.isRequired)) { + const groupCategoryCodes = group.categories.map((c) => c.code); + const intersection = existingCodes.filter((code) => + groupCategoryCodes.includes(code), + ); + if (intersection.length === 0) { + return false; + } + } + + return true; +} diff --git a/packages/shared/src/types/categories.ts b/packages/shared/src/types/categories.ts new file mode 100644 index 0000000000..102afa3495 --- /dev/null +++ b/packages/shared/src/types/categories.ts @@ -0,0 +1,12 @@ +export type CategoryGroup = { + name: string; + isRequired: boolean; + isExclusive: boolean; + categories: Category[]; +}; + +export type Category = { + code: string; + description: string; + term: string; +}; From 346a6e5cbceb53def23c50911d13b8963bfeb5cc Mon Sep 17 00:00:00 2001 From: Charles Nykamp Date: Thu, 23 Apr 2026 04:05:33 -0500 Subject: [PATCH 2/4] fix: address shared browsable review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/shared/src/logic/browsable.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/logic/browsable.ts b/packages/shared/src/logic/browsable.ts index 8a78a235c1..57b7bf88ad 100644 --- a/packages/shared/src/logic/browsable.ts +++ b/packages/shared/src/logic/browsable.ts @@ -1,4 +1,4 @@ -import { CategoryGroup, Category } from "../types/categories.js"; +import type { Category, CategoryGroup } from "../types/categories.js"; /** * Detect whether or not this activity has the required categories filled out. @@ -12,14 +12,13 @@ export function isActivityFullyCategorized({ allCategories: CategoryGroup[]; categories: Category[]; }) { - const existingCodes = categories.map((c) => c.code); + const existingCodes = new Set(categories.map((c) => c.code)); for (const group of allCategories.filter((g) => g.isRequired)) { - const groupCategoryCodes = group.categories.map((c) => c.code); - const intersection = existingCodes.filter((code) => - groupCategoryCodes.includes(code), + const hasMatch = group.categories.some((category) => + existingCodes.has(category.code), ); - if (intersection.length === 0) { + if (!hasMatch) { return false; } } From ff39d1eeb33de006610caac27545fed0383dd84e Mon Sep 17 00:00:00 2001 From: Charles Nykamp Date: Thu, 23 Apr 2026 04:20:42 -0500 Subject: [PATCH 3/4] refactor: add shared isBrowsable wrapper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/app/src/paths/editor/EditorHeader.tsx | 8 ++------ apps/app/src/popups/ShareMyContentModal.tsx | 6 +++--- packages/shared/src/logic/browsable.ts | 20 ++++++++++++++------ 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/app/src/paths/editor/EditorHeader.tsx b/apps/app/src/paths/editor/EditorHeader.tsx index 22235c3ff0..94ede95e83 100644 --- a/apps/app/src/paths/editor/EditorHeader.tsx +++ b/apps/app/src/paths/editor/EditorHeader.tsx @@ -62,11 +62,7 @@ import { MenuDismissOverlay } from "../../components/MenuDismissOverlay"; import { IFRAME_MENU_IDS } from "../../utils/iframeMenuIds"; import { useControlledMenu } from "../../utils/useControlledMenu"; import { useMenuTooltipSuppression } from "../../utils/useMenuTooltipSuppression"; -import { - Category, - CategoryGroup, - isActivityFullyCategorized, -} from "@doenet-tools/shared"; +import { Category, CategoryGroup, isBrowsable } from "@doenet-tools/shared"; export async function loader({ params, @@ -163,7 +159,7 @@ export function EditorHeader() { const notBrowsable = isPublic && categoryCheckFetcher.data && - !isActivityFullyCategorized({ + !isBrowsable({ allCategories: categoryCheckFetcher.data.allCategories as CategoryGroup[], categories: categoryCheckFetcher.data.categories as Category[], }); diff --git a/apps/app/src/popups/ShareMyContentModal.tsx b/apps/app/src/popups/ShareMyContentModal.tsx index 98fa65e0b4..7edd2ad3cd 100644 --- a/apps/app/src/popups/ShareMyContentModal.tsx +++ b/apps/app/src/popups/ShareMyContentModal.tsx @@ -34,7 +34,7 @@ import { FiCode } from "react-icons/fi"; import { loader as settingsLoader } from "../paths/editor/EditorSettingsMode"; import { editorUrl } from "../utils/url"; -import { isActivityFullyCategorized } from "@doenet-tools/shared"; +import { isBrowsable } from "@doenet-tools/shared"; export async function loadShareStatus({ params }: { params: any }) { const { data } = await axios.get( @@ -238,12 +238,12 @@ function SharePublicly({ const [copiedShareLink, setCopiedShareLink] = useState(false); const [copiedEmbedCode, setCopiedEmbedCode] = useState(false); - const unspecifiedCategories = !isActivityFullyCategorized({ + const notBrowsable = !isBrowsable({ allCategories: settings.allCategories, categories: settings.categories, }); - const browseWarning = unspecifiedCategories && ( + const browseWarning = notBrowsable && ( Not browsable diff --git a/packages/shared/src/logic/browsable.ts b/packages/shared/src/logic/browsable.ts index 57b7bf88ad..e1cb0d1898 100644 --- a/packages/shared/src/logic/browsable.ts +++ b/packages/shared/src/logic/browsable.ts @@ -1,17 +1,25 @@ import type { Category, CategoryGroup } from "../types/categories.js"; +type BrowsableData = { + allCategories: CategoryGroup[]; + categories: Category[]; +}; + +export function isBrowsable(data: BrowsableData) { + const fullyCategorized = isActivityFullyCategorized(data); + // TODO: check for no errors + // TODO: check for no accessibility violations + return fullyCategorized; +} + /** * Detect whether or not this activity has the required categories filled out. * For each group that is required, make sure this activity has at least 1 category in that group. - * Returns true if all required groups have at least one category filled out, false otherwise. */ -export function isActivityFullyCategorized({ +function isActivityFullyCategorized({ allCategories, categories, -}: { - allCategories: CategoryGroup[]; - categories: Category[]; -}) { +}: BrowsableData) { const existingCodes = new Set(categories.map((c) => c.code)); for (const group of allCategories.filter((g) => g.isRequired)) { From 3c6dd2cfa583a93012b11a3bfd6302930ae0c551 Mon Sep 17 00:00:00 2001 From: Charles Nykamp Date: Wed, 29 Apr 2026 12:40:37 -0500 Subject: [PATCH 4/4] fix: address browsable helper feedback Keep isBrowsable as the public interface, clarify its current behavior, and export the precise category helper for reuse. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/app/src/paths/editor/EditorHeader.tsx | 6 +++++- packages/shared/src/logic/browsable.ts | 11 +++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/app/src/paths/editor/EditorHeader.tsx b/apps/app/src/paths/editor/EditorHeader.tsx index 94ede95e83..bdb5c0847f 100644 --- a/apps/app/src/paths/editor/EditorHeader.tsx +++ b/apps/app/src/paths/editor/EditorHeader.tsx @@ -62,7 +62,11 @@ import { MenuDismissOverlay } from "../../components/MenuDismissOverlay"; import { IFRAME_MENU_IDS } from "../../utils/iframeMenuIds"; import { useControlledMenu } from "../../utils/useControlledMenu"; import { useMenuTooltipSuppression } from "../../utils/useMenuTooltipSuppression"; -import { Category, CategoryGroup, isBrowsable } from "@doenet-tools/shared"; +import { + isBrowsable, + type Category, + type CategoryGroup, +} from "@doenet-tools/shared"; export async function loader({ params, diff --git a/packages/shared/src/logic/browsable.ts b/packages/shared/src/logic/browsable.ts index e1cb0d1898..62d352c4d9 100644 --- a/packages/shared/src/logic/browsable.ts +++ b/packages/shared/src/logic/browsable.ts @@ -5,11 +5,14 @@ type BrowsableData = { categories: Category[]; }; +/** + * Determine whether this activity should be discoverable. + * Currently this checks category completeness; additional browsable criteria may be added here later. + * TODO: check for no errors + * TODO: check for no accessibility violations + */ export function isBrowsable(data: BrowsableData) { - const fullyCategorized = isActivityFullyCategorized(data); - // TODO: check for no errors - // TODO: check for no accessibility violations - return fullyCategorized; + return isActivityFullyCategorized(data); } /**