From b17fe5d51404c728017b135cdb4ec33bf9858411 Mon Sep 17 00:00:00 2001 From: Joey Wunderlich Date: Tue, 22 Sep 2026 20:11:14 -0700 Subject: [PATCH 1/6] add homescreen search filters categorizable rather than searchable terms --- localtypings/pxtpackage.d.ts | 7 + pxtlib/gallery.ts | 40 +++++- tests/pxt-editor-test/editorrunner.ts | 67 +++++++++ theme/home.less | 68 +++++++++ webapp/src/homeSearchFilters.ts | 189 ++++++++++++++++++++++++++ webapp/src/projects.tsx | 100 ++++++++++++-- 6 files changed, 460 insertions(+), 11 deletions(-) create mode 100644 webapp/src/homeSearchFilters.ts diff --git a/localtypings/pxtpackage.d.ts b/localtypings/pxtpackage.d.ts index 9ecbee6db250..479bacf9ec0d 100644 --- a/localtypings/pxtpackage.d.ts +++ b/localtypings/pxtpackage.d.ts @@ -2,6 +2,10 @@ declare namespace pxt { type CodeCardType = "file" | "example" | "codeExample" | "tutorial" | "side" | "template" | "package" | "hw" | "forumUrl" | "forumExample" | "sharedExample" | "link"; type CodeCardEditorType = "blocks" | "js" | "py"; + type CodeCardFilterValue = T | T[]; + type CodeCardDifficulty = "beginner" | "intermediate" | "expert"; + type CodeCardDuration = "15-minutes" | "30-minutes" | "60-minutes" | "one-day" | "longer"; + type CodeCardTargetAge = "up-to-8" | "9-12" | "13-18" | "adult"; interface Map { [index: string]: T; @@ -161,6 +165,9 @@ declare namespace pxt { tags?: string[]; // tags shown in home screen, colors specified in theme searchTerms?: string[]; // extra terms used to improve home screen search matching hideFromSearch?: boolean; // hide card from home screen search results + difficulty?: CodeCardFilterValue; + duration?: CodeCardFilterValue; + targetAge?: CodeCardFilterValue; tabIndex?: number; style?: string; // "card" | "item" | undefined; diff --git a/pxtlib/gallery.ts b/pxtlib/gallery.ts index d09df0405a26..86eb57530d19 100644 --- a/pxtlib/gallery.ts +++ b/pxtlib/gallery.ts @@ -1,5 +1,11 @@ namespace pxt.gallery { + const codeCardFilterValues: pxt.Map = { + difficulty: ["beginner", "intermediate", "expert"], + duration: ["15-minutes", "30-minutes", "60-minutes", "one-day", "longer"], + targetAge: ["up-to-8", "9-12", "13-18", "adult"] + }; + export interface Gallery { name: string; cards: pxt.CodeCard[]; @@ -113,13 +119,41 @@ namespace pxt.gallery { return prj; } + function normalizeCodeCardStringArray(card: pxt.CodeCard, property: keyof pxt.CodeCard, allowedValues?: string[]) { + const value = card[property] as string | string[]; + if (value === undefined || value === null) return; + + const values = (Array.isArray(value) ? value : value.split(",")) + .map(entry => typeof entry === "string" ? entry.trim() : "") + .filter(entry => !!entry && (!allowedValues || allowedValues.indexOf(entry) !== -1)) + .filter((entry, index, entries) => entries.indexOf(entry) === index); + + if (values.length) + (card as any)[property] = values; + else + delete (card as any)[property]; + } + + export function normalizeCodeCardMetadata(card: pxt.CodeCard): pxt.CodeCard { + normalizeCodeCardStringArray(card, "tags"); + normalizeCodeCardStringArray(card, "searchTerms"); + Object.keys(codeCardFilterValues).forEach(property => + normalizeCodeCardStringArray(card, property as keyof pxt.CodeCard, codeCardFilterValues[property])); + return card; + } + + function normalizeCodeCards(cards: pxt.CodeCard[]): pxt.CodeCard[] { + cards?.forEach(normalizeCodeCardMetadata); + return cards; + } + export function parseCodeCards(md: string): pxt.CodeCard[] { // try to parse code cards as JSON let cards = Util.jsonTryParse(md) as pxt.CodeCard[]; if (cards && !Array.isArray(cards)) cards = [cards]; if (cards?.length) - return cards; + return normalizeCodeCards(cards); // not json, try parsing as sequence of key,value pairs, with line splits cards = md.split(/^---$/gm) @@ -146,7 +180,7 @@ namespace pxt.gallery { }) .filter(cc => !!cc); if (cards?.length) - return cards; + return normalizeCodeCards(cards); return undefined; } @@ -190,7 +224,7 @@ namespace pxt.gallery { cards = pxt.Util.jsonTryParse(el.textContent); } - return !!cards?.length && cards; + return !!cards?.length && normalizeCodeCards(cards); } export function parseGalleryMardown(md: string): Gallery[] { diff --git a/tests/pxt-editor-test/editorrunner.ts b/tests/pxt-editor-test/editorrunner.ts index d738df120680..bad690e5c416 100644 --- a/tests/pxt-editor-test/editorrunner.ts +++ b/tests/pxt-editor-test/editorrunner.ts @@ -21,6 +21,10 @@ import { isImplicitSimulatorThemePreference, } from "../../react-common/components/theming/simulatorThemeDefaults"; import { resetEditorThemesAsync } from "../../react-common/components/theming/themeReset"; +import { + filterHomeSearchCards, + getAvailableHomeSearchFilters, +} from "../../webapp/src/homeSearchFilters"; pxt.appTarget = { versions: { @@ -53,6 +57,69 @@ const simulatorTheme: pxt.SimulatorTheme = { layout: "default", }; +describe("home search filters", () => { + it("normalizes canonical filter metadata and drops unknown IDs", () => { + const lineCard = pxt.gallery.parseCodeCards(` +* name: Filtered activity +* difficulty: beginner, expert, beginer +* duration: 30-minutes +* targetAge: 9-12, 13-18, teenager +`)[0]; + const jsonCard = pxt.gallery.parseCodeCards(JSON.stringify({ + name: "JSON activity", + difficulty: "intermediate", + duration: ["15-minutes", "short"], + targetAge: ["adult", "adult"], + }))[0]; + + chai.expect(lineCard.difficulty).deep.equals(["beginner", "expert"]); + chai.expect(lineCard.duration).deep.equals(["30-minutes"]); + chai.expect(lineCard.targetAge).deep.equals(["9-12", "13-18"]); + chai.expect(jsonCard.difficulty).deep.equals(["intermediate"]); + chai.expect(jsonCard.duration).deep.equals(["15-minutes"]); + chai.expect(jsonCard.targetAge).deep.equals(["adult"]); + }); + + it("only exposes filter options represented by searchable cards", () => { + const filters = getAvailableHomeSearchFilters([ + { + name: "Blocks tutorial", + cardType: "tutorial", + difficulty: ["beginner"], + otherActions: [{ url: "/tutorial", cardType: "tutorial", editor: "py" }], + }, + { + name: "Skillmap", + cardType: "link", + url: "https://example.com/--skillmap#intro", + }, + ]); + const byId = pxt.Util.toDictionary(filters, filter => filter.id); + + chai.expect(byId.activityType.options.map(option => option.id)).deep.equals(["tutorial", "skillmap"]); + chai.expect(byId.language.options.map(option => option.id)).deep.equals(["blocks", "py"]); + chai.expect(byId.difficulty.options.map(option => option.id)).deep.equals(["beginner"]); + chai.expect(byId.duration).equals(undefined); + chai.expect(byId.targetAge).equals(undefined); + chai.expect(byId.activityType.options.some(option => option.id === "extension")).equals(false); + }); + + it("matches any selected value within a filter and every selected filter", () => { + const cards: pxt.CodeCard[] = [ + { name: "Beginner blocks", cardType: "tutorial", difficulty: "beginner", targetAge: ["9-12"] }, + { name: "Beginner Python", cardType: "tutorial", editor: "py", difficulty: "beginner", targetAge: ["13-18"] }, + { name: "Expert Python", cardType: "tutorial", editor: "py", difficulty: "expert", targetAge: ["9-12"] }, + ]; + const matches = filterHomeSearchCards(cards, { + language: ["blocks", "py"], + difficulty: ["beginner"], + targetAge: ["9-12"], + }); + + chai.expect(matches.map(card => card.name)).deep.equals(["Beginner blocks"]); + }); +}); + describe("simulator themes", () => { const defaultSimulatorTheme = simulatorTheme; const purpleSimulatorTheme = { ...simulatorTheme, "background-color": "#660066" }; diff --git a/theme/home.less b/theme/home.less index 4c7ef706c0d5..5c55f70b9684 100644 --- a/theme/home.less +++ b/theme/home.less @@ -335,6 +335,51 @@ padding-top: 1em; margin: 1em @carouselArrowSize; } + .home-search-filters { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + min-height: 2.75rem; + margin-top: 0.75rem; + color: var(--pxt-neutral-foreground1); + + .home-search-filters-label { + flex: 0 0 auto; + font-weight: 600; + } + + .home-search-filter-menus { + display: flex; + flex: 0 1 auto; + flex-wrap: wrap; + gap: 0.5rem; + min-width: 0; + } + + .home-search-filter { + height: auto; + + > .menu-button { + min-height: 2.75rem; + padding: 0.625rem 0.875rem; + border: 1px solid var(--pxt-neutral-stencil1); + background: var(--pxt-neutral-background1); + color: var(--pxt-neutral-foreground1); + } + + > .common-menu-dropdown-pane { + left: 0; + right: auto; + z-index: @homeDetailViewSelectedCardZIndex + 1; + } + } + + .home-search-clear-filters { + min-height: 2.75rem; + margin-left: auto; + } + } .import-dialog-btn, .home-search-btn, .go-back-btn, @@ -1093,6 +1138,29 @@ .homescreen-search-box { margin: 1em @carouselArrowSizeMobile; } + .home-search-filters { + align-items: stretch; + + .home-search-filters-label { + width: 100%; + } + + .home-search-filter-menus { + flex: 1 1 100%; + } + + .home-search-filter { + flex: 1 1 auto; + + > .menu-button { + width: 100%; + } + } + + .home-search-clear-filters { + margin-left: 0; + } + } .projectsdialog { .ui.segment.getting-started-segment { height: 10rem; diff --git a/webapp/src/homeSearchFilters.ts b/webapp/src/homeSearchFilters.ts new file mode 100644 index 000000000000..f7823e4418dc --- /dev/null +++ b/webapp/src/homeSearchFilters.ts @@ -0,0 +1,189 @@ +export interface HomeSearchFilterSelection { + [filterId: string]: string[]; +} + +export interface HomeSearchFilterOption { + id: string; + label: string; +} + +export interface HomeSearchFilterDefinition { + id: string; + label: string; + options: HomeSearchFilterOption[]; +} + +interface HomeSearchFilterRegistryEntry extends HomeSearchFilterDefinition { + getValues: (card: pxt.CodeCard) => string[]; +} + +const activityTypeOptions: HomeSearchFilterOption[] = [ + { id: "tutorial", label: lf("Tutorials") }, + { id: "example", label: lf("Examples") }, + { id: "skillmap", label: lf("Skillmaps") }, + { id: "video", label: lf("Videos") }, + { id: "project", label: lf("Projects") }, + { id: "community", label: lf("Community") }, + { id: "hardware", label: lf("Hardware") }, + { id: "extension", label: lf("Extensions") }, + { id: "resource", label: lf("Resources") }, +]; + +const languageOptions: HomeSearchFilterOption[] = [ + { id: "blocks", label: lf("Blocks") }, + { id: "js", label: lf("JavaScript") }, + { id: "py", label: lf("Python") }, +]; + +const explicitFilterOptions: pxt.Map = { + difficulty: [ + { id: "beginner", label: lf("Beginner") }, + { id: "intermediate", label: lf("Intermediate") }, + { id: "expert", label: lf("Expert") }, + ], + duration: [ + { id: "15-minutes", label: lf("15 minutes") }, + { id: "30-minutes", label: lf("30 minutes") }, + { id: "60-minutes", label: lf("60 minutes") }, + { id: "one-day", label: lf("One day") }, + { id: "longer", label: lf("Longer") }, + ], + targetAge: [ + { id: "up-to-8", label: lf("Up to age 8") }, + { id: "9-12", label: lf("Ages 9-12") }, + { id: "13-18", label: lf("Ages 13-18") }, + { id: "adult", label: lf("Adult") }, + ], +}; + +function addUnique(values: string[], value: string) { + if (value && values.indexOf(value) === -1) values.push(value); +} + +function getActivityTypes(card: pxt.CodeCard): string[] { + const values: string[] = []; + const url = card.url || ""; + + if (card.cardType === "file") addUnique(values, "project"); + if (/--skillmap(?:#|$)/i.test(url)) addUnique(values, "skillmap"); + if (card.youTubeId || card.youTubePlaylistId) addUnique(values, "video"); + if (card.variant) addUnique(values, "hardware"); + + switch (card.cardType) { + case "tutorial": + addUnique(values, "tutorial"); + break; + case "example": + case "codeExample": + case "sharedExample": + case "forumExample": + addUnique(values, "example"); + break; + case "forumUrl": + addUnique(values, "community"); + break; + case "hw": + addUnique(values, "hardware"); + break; + case "package": + addUnique(values, "extension"); + break; + case "link": + if (!values.length) addUnique(values, "resource"); + break; + } + + if (!values.length && url) addUnique(values, "resource"); + + return values; +} + +function getActionEditor(cardType: pxt.CodeCardType, editor?: pxt.CodeCardEditorType): pxt.CodeCardEditorType { + if (editor) return editor; + if (cardType === "tutorial" || cardType === "example") return "blocks"; + if (cardType === "codeExample") return "js"; + return undefined; +} + +function getLanguages(card: pxt.CodeCard): string[] { + const values: string[] = []; + const addAction = (cardType: pxt.CodeCardType, editor?: pxt.CodeCardEditorType) => + addUnique(values, getActionEditor(cardType, editor)); + + addAction(card.cardType, card.editor); + card.otherActions?.forEach(action => addAction(action.cardType || card.cardType, action.editor)); + + if (!values.length && /--skillmap(?:#|$)/i.test(card.url || "")) + addUnique(values, "blocks"); + + return values; +} + +function getExplicitValues(card: pxt.CodeCard, property: "difficulty" | "duration" | "targetAge"): string[] { + const value = card[property]; + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + +const filterRegistry: HomeSearchFilterRegistryEntry[] = [ + { + id: "activityType", + label: lf("Type"), + options: activityTypeOptions, + getValues: getActivityTypes, + }, + { + id: "language", + label: lf("Language"), + options: languageOptions, + getValues: getLanguages, + }, + { + id: "difficulty", + label: lf("Difficulty"), + options: explicitFilterOptions.difficulty, + getValues: card => getExplicitValues(card, "difficulty"), + }, + { + id: "duration", + label: lf("Duration"), + options: explicitFilterOptions.duration, + getValues: card => getExplicitValues(card, "duration"), + }, + { + id: "targetAge", + label: lf("Target age"), + options: explicitFilterOptions.targetAge, + getValues: card => getExplicitValues(card, "targetAge"), + }, +]; + +function getValidSelectedValues(definition: HomeSearchFilterRegistryEntry, selection: HomeSearchFilterSelection): string[] { + const validOptions = definition.options.map(option => option.id); + return (selection[definition.id] || []).filter(value => validOptions.indexOf(value) !== -1); +} + +export function getAvailableHomeSearchFilters(cards: pxt.CodeCard[]): HomeSearchFilterDefinition[] { + return filterRegistry.map(definition => { + const availableValues: string[] = []; + cards.forEach(card => definition.getValues(card).forEach(value => addUnique(availableValues, value))); + const options = definition.options.filter(option => availableValues.indexOf(option.id) !== -1); + return options.length ? { id: definition.id, label: definition.label, options } : undefined; + }).filter(definition => !!definition); +} + +export function hasActiveHomeSearchFilters(selection: HomeSearchFilterSelection): boolean { + return filterRegistry.some(definition => getValidSelectedValues(definition, selection || {}).length > 0); +} + +export function filterHomeSearchCards(cards: T[], selection: HomeSearchFilterSelection): T[] { + if (!hasActiveHomeSearchFilters(selection)) return cards; + + return cards.filter(card => filterRegistry.every(definition => { + const selectedValues = getValidSelectedValues(definition, selection); + if (!selectedValues.length) return true; + + const cardValues = definition.getValues(card); + return selectedValues.some(value => cardValues.indexOf(value) !== -1); + })); +} \ No newline at end of file diff --git a/webapp/src/projects.tsx b/webapp/src/projects.tsx index f66cf0996150..50a4b51d402a 100644 --- a/webapp/src/projects.tsx +++ b/webapp/src/projects.tsx @@ -24,6 +24,13 @@ import ISettingsProps = pxt.editor.ISettingsProps; import UserInfo = pxt.editor.UserInfo; import { Dropdown, DropdownItem } from "../../react-common/components/controls/Dropdown"; import { MenuDropdown, MenuItem } from "../../react-common/components/controls/MenuDropdown"; +import { + filterHomeSearchCards, + getAvailableHomeSearchFilters, + hasActiveHomeSearchFilters, + HomeSearchFilterDefinition, + HomeSearchFilterSelection, +} from "./homeSearchFilters"; // This Component overrides shouldComponentUpdate, be sure to update that if the state is updated @@ -34,6 +41,7 @@ interface ProjectsState { searchMode?: boolean; searchQuery?: string; searchResults?: SearchCard[]; + searchFilters?: HomeSearchFilterSelection; } const SEARCH_CATEGORY = "__search__"; @@ -92,7 +100,8 @@ export class Projects extends auth.Component { || this.state.selectedIndex != nextState.selectedIndex || this.state.searchMode != nextState.searchMode || this.state.searchQuery != nextState.searchQuery - || this.state.searchResults != nextState.searchResults; + || this.state.searchResults != nextState.searchResults + || this.state.searchFilters != nextState.searchFilters; } setSelected(category: string, index?: number) { @@ -190,6 +199,10 @@ export class Projects extends auth.Component { return { projectHeader: header, cardType: "file", + editor: header.editor === pxt.BLOCKS_PROJECT_NAME ? "blocks" + : header.editor === pxt.PYTHON_PROJECT_NAME ? "py" + : header.editor === pxt.JAVASCRIPT_PROJECT_NAME ? "js" + : undefined, name: (ghid && pxt.github.join(ghid.project, ghid.fileName)) || header.name, time: header.modificationTime, tutorialStep, @@ -231,12 +244,13 @@ export class Projects extends auth.Component { return { cards, entries, cardMap }; } - private runSearch(query: string) { + private runSearch(query: string, filters = this.state.searchFilters || {}) { const normalized = (query || "").trim(); const galleries = this.getSearchGalleries(); const requestId = ++this.searchRequestId; + const hasFilters = hasActiveHomeSearchFilters(filters); - if (!normalized) { + if (!normalized && !hasFilters) { const resetCategory = this.state.selectedCategory === SEARCH_CATEGORY ? undefined : this.state.selectedCategory; const resetIndex = this.state.selectedCategory === SEARCH_CATEGORY ? undefined : this.state.selectedIndex; compiler.homeSearchClear(); @@ -244,19 +258,28 @@ export class Projects extends auth.Component { return; } - const { entries, cardMap } = this.collectSearchEntries(galleries); + const { cards, entries, cardMap } = this.collectSearchEntries(galleries); if (!entries.length) { this.setState({ searchResults: [] }); return; } + if (!normalized) { + this.setState({ + searchResults: filterHomeSearchCards(cards, filters), + selectedCategory: SEARCH_CATEGORY, + selectedIndex: undefined + }); + return; + } + compiler.homeSearchAsync({ term: normalized, entries }) .then(results => { if (requestId !== this.searchRequestId) return; - const matches = results + const matches = filterHomeSearchCards(results .map(result => cardMap[result.id]) - .filter(card => !!card); + .filter(card => !!card), filters); this.setState({ searchResults: matches, @@ -280,6 +303,49 @@ export class Projects extends auth.Component { this.runSearch(query); } + private setSearchFilter(filterId: string, optionId: string, selected: boolean) { + const searchFilters = { ...(this.state.searchFilters || {}) }; + const values = (searchFilters[filterId] || []).filter(value => value !== optionId); + if (selected) values.push(optionId); + + if (values.length) searchFilters[filterId] = values; + else delete searchFilters[filterId]; + + this.setState({ searchFilters }); + this.runSearch(this.state.searchQuery || "", searchFilters); + } + + private clearSearchFilters = () => { + const searchFilters = {}; + this.setState({ searchFilters }); + this.runSearch(this.state.searchQuery || "", searchFilters); + } + + private renderSearchFilter(definition: HomeSearchFilterDefinition) { + const selectedValues = this.state.searchFilters?.[definition.id] || []; + const selectedCount = selectedValues.length; + const label = selectedCount ? lf("{0} ({1})", definition.label, selectedCount) : definition.label; + const ariaLabel = selectedCount + ? lf("{0} filter, {1} selected", definition.label, selectedCount) + : lf("{0} filter", definition.label); + const items: MenuItem[] = definition.options.map(option => ({ + role: "menuitemcheckbox", + label: option.label, + isChecked: selectedValues.indexOf(option.id) !== -1, + onChange: selected => this.setSearchFilter(definition.id, option.id, selected), + })); + + return ; + } + public getSearchQuery() { return this.state.searchQuery || ""; } @@ -298,6 +364,7 @@ export class Projects extends auth.Component { this.warmSearchIndex(); this.setState({ searchMode: true, + searchFilters: {}, selectedCategory: this.state.selectedCategory === SEARCH_CATEGORY ? undefined : this.state.selectedCategory, selectedIndex: this.state.selectedCategory === SEARCH_CATEGORY ? undefined : this.state.selectedIndex, }); @@ -314,6 +381,7 @@ export class Projects extends auth.Component { searchMode: false, searchQuery: "", searchResults: undefined, + searchFilters: undefined, selectedCategory: undefined, selectedIndex: undefined, }, () => { @@ -423,8 +491,12 @@ export class Projects extends auth.Component { const galleries = this.getHomeGalleries(); const searchMode = !!this.state.searchMode; const searchQuery = this.state.searchQuery || ""; - const hasSearchQuery = !!searchQuery.trim(); + const hasSearchFilters = hasActiveHomeSearchFilters(this.state.searchFilters || {}); + const hasSearchCriteria = !!searchQuery.trim() || hasSearchFilters; const searchResults = this.state.searchResults || []; + const availableSearchFilters = searchMode + ? getAvailableHomeSearchFilters(this.collectSearchEntries(this.getSearchGalleries()).cards) + : []; const searchSelectedIndex = this.state.selectedCategory === SEARCH_CATEGORY ? this.state.selectedIndex : undefined; const selectedSearchCard = searchSelectedIndex !== undefined ? searchResults[searchSelectedIndex] : undefined; const selectedSearchProjectHeader = selectedSearchCard?.projectHeader; @@ -432,7 +504,7 @@ export class Projects extends auth.Component { ? this.getLocalProjectDescription(selectedSearchProjectHeader) || selectedSearchCard?.description : selectedSearchCard?.description; const canImport = !!(pxt.appTarget.compile || (pxt.appTarget.cloud && pxt.appTarget.cloud.sharing && pxt.appTarget.cloud.importing)) && !searchMode; - const searchResultsContent = hasSearchQuery + const searchResultsContent = hasSearchCriteria ? searchResults.length ? searchResults.map((scr, index) => @@ -535,6 +607,18 @@ export class Projects extends auth.Component { icon="search icon" ariaLabel={lf("Search tutorials, examples, and projects")} /> + {!!availableSearchFilters.length &&
+ {lf("Filter by")} +
+ {availableSearchFilters.map(definition => this.renderSearchFilter(definition))} +
+ {hasSearchFilters &&
} } {!searchMode &&
From a6037b1242f0dfe229c01222999867209d38c90d Mon Sep 17 00:00:00 2001 From: Joey Wunderlich Date: Tue, 22 Sep 2026 21:09:23 -0700 Subject: [PATCH 2/6] clean up passes --- cli/cli.ts | 4 +- .../components/controls/MenuDropdown.tsx | 3 ++ tests/pxt-editor-test/editorrunner.ts | 21 ++++++++ theme/home.less | 12 +++-- webapp/src/homeSearchFilters.ts | 23 ++++++++ webapp/src/projects.tsx | 52 ++++++++++++++----- 6 files changed, 97 insertions(+), 18 deletions(-) diff --git a/cli/cli.ts b/cli/cli.ts index 8e8dba2a45b9..e08960b164d3 100644 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -2098,8 +2098,8 @@ function saveThemeJson(cfg: pxt.TargetBundle, localDir?: boolean, packaged?: boo const url = card.url || card.learnMoreUrl || card.buyUrl || (card.youTubeId && `https://youtu.be/${card.youTubeId}`); tocmd += ` * [${card.name || card.title}](${url}) `; - if (card.tags) - card.tags.forEach(tag => targetStrings[tag] = tag); + (card.tags || []).concat(card.searchTerms || []) + .forEach(term => targetStrings[term] = term); })) }); diff --git a/react-common/components/controls/MenuDropdown.tsx b/react-common/components/controls/MenuDropdown.tsx index 1aee6f0bb575..88668b3cb164 100644 --- a/react-common/components/controls/MenuDropdown.tsx +++ b/react-common/components/controls/MenuDropdown.tsx @@ -37,6 +37,7 @@ export interface MenuDropdownProps extends ControlProps { label?: string | JSX.Element; title: string; icon?: string; + showChevron?: boolean; tabIndex?: number; disabled?: boolean; } @@ -52,6 +53,7 @@ export const MenuDropdown = (props: MenuDropdownProps) => { label, title, icon, + showChevron, tabIndex, disabled } = props; @@ -133,6 +135,7 @@ export const MenuDropdown = (props: MenuDropdownProps) => { buttonRef={handleButtonRef} title={title} leftIcon={icon} + rightIcon={showChevron ? (expanded ? "fas fa-chevron-up" : "fas fa-chevron-down") : undefined} role={role || "button"} className={classList("menu-button", expanded && "expanded")} onClick={null} diff --git a/tests/pxt-editor-test/editorrunner.ts b/tests/pxt-editor-test/editorrunner.ts index bad690e5c416..248d28e29e05 100644 --- a/tests/pxt-editor-test/editorrunner.ts +++ b/tests/pxt-editor-test/editorrunner.ts @@ -24,6 +24,7 @@ import { resetEditorThemesAsync } from "../../react-common/components/theming/th import { filterHomeSearchCards, getAvailableHomeSearchFilters, + getHomeSearchFilterOptionCounts, } from "../../webapp/src/homeSearchFilters"; pxt.appTarget = { @@ -118,6 +119,26 @@ describe("home search filters", () => { chai.expect(matches.map(card => card.name)).deep.equals(["Beginner blocks"]); }); + + it("counts each option against the query candidates and other active filters", () => { + const queryMatches: pxt.CodeCard[] = [ + { name: "Beginner blocks", cardType: "tutorial", difficulty: "beginner", targetAge: ["9-12"] }, + { name: "Beginner Python", cardType: "tutorial", editor: "py", difficulty: "beginner", targetAge: ["13-18"] }, + { name: "Expert Python", cardType: "tutorial", editor: "py", difficulty: "expert", targetAge: ["9-12"] }, + ]; + const counts = getHomeSearchFilterOptionCounts(queryMatches, { + language: ["blocks", "py"], + difficulty: ["beginner"], + targetAge: ["9-12"], + }); + + chai.expect(counts.language.blocks).equals(1); + chai.expect(counts.language.py).equals(0); + chai.expect(counts.difficulty.beginner).equals(1); + chai.expect(counts.difficulty.expert).equals(1); + chai.expect(counts.targetAge["9-12"]).equals(1); + chai.expect(counts.targetAge["13-18"]).equals(1); + }); }); describe("simulator themes", () => { diff --git a/theme/home.less b/theme/home.less index 5c55f70b9684..c15d93c2233b 100644 --- a/theme/home.less +++ b/theme/home.less @@ -364,6 +364,7 @@ min-height: 2.75rem; padding: 0.625rem 0.875rem; border: 1px solid var(--pxt-neutral-stencil1); + border-radius: 0.25rem; background: var(--pxt-neutral-background1); color: var(--pxt-neutral-foreground1); } @@ -378,6 +379,7 @@ .home-search-clear-filters { min-height: 2.75rem; margin-left: auto; + border-radius: 0.25rem; } } .import-dialog-btn, @@ -1012,7 +1014,7 @@ padding-left: @carouselArrowSizeTablet !important; } } - .homescreen-search-box { + .projectsdialog .homescreen-search-box { margin: 1em @carouselArrowSizeTablet; } .projectsdialog { @@ -1135,7 +1137,7 @@ display: none; } } - .homescreen-search-box { + .projectsdialog .homescreen-search-box { margin: 1em @carouselArrowSizeMobile; } .home-search-filters { @@ -1150,10 +1152,12 @@ } .home-search-filter { - flex: 1 1 auto; + flex: 0 1 auto; + max-width: 100%; > .menu-button { - width: 100%; + width: auto; + max-width: 100%; } } diff --git a/webapp/src/homeSearchFilters.ts b/webapp/src/homeSearchFilters.ts index f7823e4418dc..7271b4fb8aa7 100644 --- a/webapp/src/homeSearchFilters.ts +++ b/webapp/src/homeSearchFilters.ts @@ -13,6 +13,10 @@ export interface HomeSearchFilterDefinition { options: HomeSearchFilterOption[]; } +export interface HomeSearchFilterOptionCounts { + [filterId: string]: pxt.Map; +} + interface HomeSearchFilterRegistryEntry extends HomeSearchFilterDefinition { getValues: (card: pxt.CodeCard) => string[]; } @@ -176,6 +180,25 @@ export function hasActiveHomeSearchFilters(selection: HomeSearchFilterSelection) return filterRegistry.some(definition => getValidSelectedValues(definition, selection || {}).length > 0); } +export function getHomeSearchFilterOptionCounts(cards: pxt.CodeCard[], selection: HomeSearchFilterSelection): HomeSearchFilterOptionCounts { + const counts: HomeSearchFilterOptionCounts = {}; + + filterRegistry.forEach(definition => { + const otherSelections = { ...(selection || {}) }; + delete otherSelections[definition.id]; + const candidates = filterHomeSearchCards(cards, otherSelections); + const optionCounts: pxt.Map = {}; + + definition.options.forEach(option => { + optionCounts[option.id] = candidates.filter(card => + definition.getValues(card).indexOf(option.id) !== -1).length; + }); + counts[definition.id] = optionCounts; + }); + + return counts; +} + export function filterHomeSearchCards(cards: T[], selection: HomeSearchFilterSelection): T[] { if (!hasActiveHomeSearchFilters(selection)) return cards; diff --git a/webapp/src/projects.tsx b/webapp/src/projects.tsx index 50a4b51d402a..4400e6581243 100644 --- a/webapp/src/projects.tsx +++ b/webapp/src/projects.tsx @@ -27,6 +27,7 @@ import { MenuDropdown, MenuItem } from "../../react-common/components/controls/M import { filterHomeSearchCards, getAvailableHomeSearchFilters, + getHomeSearchFilterOptionCounts, hasActiveHomeSearchFilters, HomeSearchFilterDefinition, HomeSearchFilterSelection, @@ -40,6 +41,7 @@ interface ProjectsState { selectedIndex?: number; searchMode?: boolean; searchQuery?: string; + searchCandidates?: SearchCard[]; searchResults?: SearchCard[]; searchFilters?: HomeSearchFilterSelection; } @@ -67,6 +69,16 @@ function getProjectDescriptionFromConfig(configText: string): string { return description || undefined; } +function localizedSearchTerms(terms?: string[]): string { + const result: string[] = []; + (terms || []).forEach(term => { + if (result.indexOf(term) === -1) result.push(term); + const localized = pxt.Util.rlf(term); + if (localized && result.indexOf(localized) === -1) result.push(localized); + }); + return result.join(" "); +} + export class Projects extends auth.Component { protected searchRequestId = 0; protected searchButton: HTMLElement; @@ -100,6 +112,7 @@ export class Projects extends auth.Component { || this.state.selectedIndex != nextState.selectedIndex || this.state.searchMode != nextState.searchMode || this.state.searchQuery != nextState.searchQuery + || this.state.searchCandidates != nextState.searchCandidates || this.state.searchResults != nextState.searchResults || this.state.searchFilters != nextState.searchFilters; } @@ -171,8 +184,8 @@ export class Projects extends auth.Component { id: key, name: card.name || "", description: card.description || "", - tags: Array.isArray(card.tags) ? card.tags.join(" ") : "", - searchTerms: Array.isArray(card.searchTerms) ? card.searchTerms.join(" ") : "" + tags: localizedSearchTerms(card.tags), + searchTerms: localizedSearchTerms(card.searchTerms) }); })); }); @@ -254,18 +267,19 @@ export class Projects extends auth.Component { const resetCategory = this.state.selectedCategory === SEARCH_CATEGORY ? undefined : this.state.selectedCategory; const resetIndex = this.state.selectedCategory === SEARCH_CATEGORY ? undefined : this.state.selectedIndex; compiler.homeSearchClear(); - this.setState({ searchResults: undefined, selectedCategory: resetCategory, selectedIndex: resetIndex }); + this.setState({ searchCandidates: undefined, searchResults: undefined, selectedCategory: resetCategory, selectedIndex: resetIndex }); return; } const { cards, entries, cardMap } = this.collectSearchEntries(galleries); if (!entries.length) { - this.setState({ searchResults: [] }); + this.setState({ searchCandidates: [], searchResults: [] }); return; } if (!normalized) { this.setState({ + searchCandidates: undefined, searchResults: filterHomeSearchCards(cards, filters), selectedCategory: SEARCH_CATEGORY, selectedIndex: undefined @@ -277,11 +291,13 @@ export class Projects extends auth.Component { .then(results => { if (requestId !== this.searchRequestId) return; - const matches = filterHomeSearchCards(results + const candidates = results .map(result => cardMap[result.id]) - .filter(card => !!card), filters); + .filter(card => !!card); + const matches = filterHomeSearchCards(candidates, filters); this.setState({ + searchCandidates: candidates, searchResults: matches, selectedCategory: SEARCH_CATEGORY, selectedIndex: undefined @@ -291,6 +307,7 @@ export class Projects extends auth.Component { if (requestId !== this.searchRequestId) return; pxt.reportException(e); this.setState({ + searchCandidates: [], searchResults: [], selectedCategory: SEARCH_CATEGORY, selectedIndex: undefined @@ -299,7 +316,7 @@ export class Projects extends auth.Component { } public setSearchQuery(query: string) { - this.setState({ searchQuery: query }); + this.setState({ searchQuery: query, searchCandidates: undefined }); this.runSearch(query); } @@ -321,7 +338,7 @@ export class Projects extends auth.Component { this.runSearch(this.state.searchQuery || "", searchFilters); } - private renderSearchFilter(definition: HomeSearchFilterDefinition) { + private renderSearchFilter(definition: HomeSearchFilterDefinition, optionCounts: pxt.Map) { const selectedValues = this.state.searchFilters?.[definition.id] || []; const selectedCount = selectedValues.length; const label = selectedCount ? lf("{0} ({1})", definition.label, selectedCount) : definition.label; @@ -330,7 +347,7 @@ export class Projects extends auth.Component { : lf("{0} filter", definition.label); const items: MenuItem[] = definition.options.map(option => ({ role: "menuitemcheckbox", - label: option.label, + label: lf("{0} ({1})", option.label, optionCounts[option.id] || 0), isChecked: selectedValues.indexOf(option.id) !== -1, onChange: selected => this.setSearchFilter(definition.id, option.id, selected), })); @@ -342,6 +359,7 @@ export class Projects extends auth.Component { label={label} title={ariaLabel} ariaLabel={ariaLabel} + showChevron={true} items={items} />; } @@ -380,6 +398,7 @@ export class Projects extends auth.Component { this.setState({ searchMode: false, searchQuery: "", + searchCandidates: undefined, searchResults: undefined, searchFilters: undefined, selectedCategory: undefined, @@ -494,9 +513,17 @@ export class Projects extends auth.Component { const hasSearchFilters = hasActiveHomeSearchFilters(this.state.searchFilters || {}); const hasSearchCriteria = !!searchQuery.trim() || hasSearchFilters; const searchResults = this.state.searchResults || []; - const availableSearchFilters = searchMode - ? getAvailableHomeSearchFilters(this.collectSearchEntries(this.getSearchGalleries()).cards) + const allSearchCards = searchMode + ? this.collectSearchEntries(this.getSearchGalleries()).cards : []; + const availableSearchFilters = getAvailableHomeSearchFilters(allSearchCards); + const searchFilterCandidates = searchQuery.trim() + ? this.state.searchCandidates || [] + : allSearchCards; + const searchFilterOptionCounts = getHomeSearchFilterOptionCounts( + searchFilterCandidates, + this.state.searchFilters || {} + ); const searchSelectedIndex = this.state.selectedCategory === SEARCH_CATEGORY ? this.state.selectedIndex : undefined; const selectedSearchCard = searchSelectedIndex !== undefined ? searchResults[searchSelectedIndex] : undefined; const selectedSearchProjectHeader = selectedSearchCard?.projectHeader; @@ -610,7 +637,8 @@ export class Projects extends auth.Component { {!!availableSearchFilters.length &&
{lf("Filter by")}
- {availableSearchFilters.map(definition => this.renderSearchFilter(definition))} + {availableSearchFilters.map(definition => + this.renderSearchFilter(definition, searchFilterOptionCounts[definition.id]))}
{hasSearchFilters &&