Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
10 changes: 10 additions & 0 deletions localtypings/pxtpackage.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ 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 extends string> = T | T[];
type CodeCardActivityType = "tutorial" | "example" | "skillmap" | "video" | "project" | "community" | "hardware" | "extension" | "resource";
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<T> {
[index: string]: T;
Expand Down Expand Up @@ -161,6 +166,11 @@ 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
activityType?: CodeCardFilterValue<CodeCardActivityType>;
language?: CodeCardFilterValue<CodeCardEditorType>;
difficulty?: CodeCardFilterValue<CodeCardDifficulty>;
duration?: CodeCardFilterValue<CodeCardDuration>;
targetAge?: CodeCardFilterValue<CodeCardTargetAge>;
tabIndex?: number;
style?: string; // "card" | "item" | undefined;

Expand Down
52 changes: 39 additions & 13 deletions pxtlib/gallery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@

namespace pxt.gallery {
const codeCardFilterValues: pxt.Map<string[]> = {
activityType: ["tutorial", "example", "skillmap", "video", "project", "community", "hardware", "extension", "resource"],
language: ["blocks", "js", "py"],
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[];
Expand Down Expand Up @@ -113,13 +121,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.map(normalizeCodeCardStringArrays);
return normalizeCodeCards(cards);

// not json, try parsing as sequence of key,value pairs, with line splits
cards = md.split(/^---$/gm)
Expand All @@ -146,7 +182,7 @@ namespace pxt.gallery {
})
.filter(cc => !!cc);
if (cards?.length)
return cards.map(normalizeCodeCardStringArrays);
return normalizeCodeCards(cards);

return undefined;
}
Expand Down Expand Up @@ -190,17 +226,7 @@ namespace pxt.gallery {
cards = pxt.Util.jsonTryParse(el.textContent);
}

return !!cards?.length && cards.map(normalizeCodeCardStringArrays);
}

function normalizeCodeCardStringArrays(card: pxt.CodeCard): pxt.CodeCard {
["tags", "searchTerms"].forEach(field => {
const value = (<any>card)[field];
if (typeof value === "string") {
(<any>card)[field] = value.split(',').map(term => term.trim()).filter(term => !!term);
}
});
return card;
return !!cards?.length && normalizeCodeCards(cards);
}

export function parseGalleryMardown(md: string): Gallery[] {
Expand Down
3 changes: 3 additions & 0 deletions react-common/components/controls/MenuDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface MenuDropdownProps extends ControlProps {
label?: string | JSX.Element;
title: string;
icon?: string;
showChevron?: boolean;
tabIndex?: number;
disabled?: boolean;
}
Expand All @@ -52,6 +53,7 @@ export const MenuDropdown = (props: MenuDropdownProps) => {
label,
title,
icon,
showChevron,
tabIndex,
disabled
} = props;
Expand Down Expand Up @@ -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}
Expand Down
119 changes: 119 additions & 0 deletions tests/pxt-editor-test/editorrunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import {
isImplicitSimulatorThemePreference,
} from "../../react-common/components/theming/simulatorThemeDefaults";
import { resetEditorThemesAsync } from "../../react-common/components/theming/themeReset";
import {
filterHomeSearchCards,
getAvailableHomeSearchFilters,
getHomeSearchFilterOptionCounts,
} from "../../webapp/src/homeSearchFilters";

pxt.appTarget = {
versions: {
Expand Down Expand Up @@ -72,6 +77,120 @@ 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
* activityType: video, resource, movie
* language: blocks, js, typescript
* difficulty: beginner, expert, beginer
* duration: 30-minutes
* targetAge: 9-12, 13-18, teenager
`)[0];
const jsonCard = pxt.gallery.parseCodeCards(JSON.stringify({
name: "JSON activity",
activityType: "project",
language: ["py", "py", "python"],
difficulty: "intermediate",
duration: ["15-minutes", "short"],
targetAge: ["adult", "adult"],
}))[0];

chai.expect(lineCard.activityType).deep.equals(["video", "resource"]);
chai.expect(lineCard.language).deep.equals(["blocks", "js"]);
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.activityType).deep.equals(["project"]);
chai.expect(jsonCard.language).deep.equals(["py"]);
chai.expect(jsonCard.difficulty).deep.equals(["intermediate"]);
chai.expect(jsonCard.duration).deep.equals(["15-minutes"]);
chai.expect(jsonCard.targetAge).deep.equals(["adult"]);
});

it("uses explicit activity type and language values instead of inferred values", () => {
const card: pxt.CodeCard = {
name: "JavaScript video",
cardType: "tutorial",
editor: "blocks",
activityType: "video",
language: "js",
};
const filters = getAvailableHomeSearchFilters([card]);
const byId = pxt.Util.toDictionary(filters, filter => filter.id);

chai.expect(byId.activityType.options.map(option => option.id)).deep.equals(["video"]);
chai.expect(byId.language.options.map(option => option.id)).deep.equals(["js"]);
chai.expect(filterHomeSearchCards([card], {
activityType: ["tutorial"],
language: ["blocks"],
})).deep.equals([]);
chai.expect(filterHomeSearchCards([card], {
activityType: ["video"],
language: ["js"],
})).deep.equals([card]);
});

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"]);
});

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", () => {
const defaultSimulatorTheme = simulatorTheme;
const purpleSimulatorTheme = { ...simulatorTheme, "background-color": "#660066" };
Expand Down
77 changes: 75 additions & 2 deletions theme/home.less
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@

&.search-segment {
padding: 1rem @carouselArrowSize;
min-height: 28rem;

.search-results-grid {
display: grid;
Expand Down Expand Up @@ -335,6 +336,53 @@
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);
border-radius: 0.25rem;
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;
border-radius: 0.25rem;
}
}
.import-dialog-btn,
.home-search-btn,
.go-back-btn,
Expand Down Expand Up @@ -967,7 +1015,7 @@
padding-left: @carouselArrowSizeTablet !important;
}
}
.homescreen-search-box {
.projectsdialog .homescreen-search-box {
margin: 1em @carouselArrowSizeTablet;
}
.projectsdialog {
Expand Down Expand Up @@ -1090,9 +1138,34 @@
display: none;
}
}
.homescreen-search-box {
.projectsdialog .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: 0 1 auto;
max-width: 100%;

> .menu-button {
width: auto;
max-width: 100%;
}
}

.home-search-clear-filters {
margin-left: 0;
}
}
.projectsdialog {
.ui.segment.getting-started-segment {
height: 10rem;
Expand Down
Loading
Loading