Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ env:
# renovate: datasource=npm depName=npm
NPM_VERSION: "11"
# renovate: datasource=npm depName=@microbit-foundation/python-editor-v3-microbit registryUrl=https://npm.pkg.github.com
THEME_VERSION: 0.0.0-home.page.124
THEME_VERSION: 0.0.0-home.page.125

jobs:
stages:
Expand Down
3 changes: 2 additions & 1 deletion docs/analytics-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@ Projects page only. Fires when the user changes the sort field or direction.

### `idea_open`

User opened an idea from the documentation as a new project.
User opened an idea as a new project, from the home page idea dialog or the
editor's Ideas tab.

| Param | Values |
| ----- | -------------------------- |
Expand Down
6 changes: 5 additions & 1 deletion lang/ui.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@
},
"create-project-action": {
"defaultMessage": "Create",
"description": "Confirm button of the dialog naming a new project"
"description": "Button creating a new project: confirms the dialog naming a new project, and creates one from the home page idea dialog"
},
"create-python": {
"defaultMessage": "Create a new Python file in this project",
Expand Down Expand Up @@ -391,6 +391,10 @@
"defaultMessage": "For more support, see the <link>Python Editor guide</link>",
"description": "Text with an external link to support/help content"
},
"guide-tab": {
"defaultMessage": "Guide",
"description": "Sidebar tab button text. Tab shows the guide for the idea the project was opened from."
},
"help": {
"defaultMessage": "Help",
"description": "Help menu label"
Expand Down
4 changes: 2 additions & 2 deletions src/common/AreaHeading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ import { Box } from "styled-system/jsx";

interface AreaHeadingProps {
name: string;
description: ReactNode;
description?: ReactNode;
}

const AreaHeading = ({ name, description }: AreaHeadingProps) => (
<Box p="5" pt="3">
<Text as="h2" fontSize="3xl" fontWeight="semibold">
{name}
</Text>
<Text fontSize="md">{description}</Text>
{description !== undefined && <Text fontSize="md">{description}</Text>}
</Box>
);

Expand Down
58 changes: 58 additions & 0 deletions src/documentation/GuideArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* (c) 2026, Micro:bit Educational Foundation and contributors
*
* SPDX-License-Identifier: MIT
*/
import { Text } from "@microbit/ui";
import { FormattedMessage } from "react-intl";
import { Stack } from "styled-system/jsx";
import AreaHeading from "../common/AreaHeading";
import { docStylesClass } from "../common/documentation-styles";
import HeadedScrollablePanel from "../common/HeadedScrollablePanel";
import Spinner from "../common/Spinner";
import { useOpenIdeaSlug } from "../project/projects-hooks";
import { useDocumentation } from "./documentation-hooks";
import IdeaGuideContent from "./ideas/IdeaGuideContent";
import { Idea } from "./ideas/model";

/**
* The guide for the idea the open project came from: its content without
* the program, which is in the editor.
*/
const GuideArea = () => {
const slug = useOpenIdeaSlug();
const { ideas } = useDocumentation();
if (ideas.status === "loading") {
return <Spinner />;
}
const idea =
ideas.status === "ok"
? ideas.content.find((idea) => idea.slug.current === slug)
: undefined;
if (!idea) {
return (
<Text p="5" height="100%">
<FormattedMessage id="toolkit-error-loading" />
</Text>
);
}
return <Guide idea={idea} />;
};

const Guide = ({ idea }: { idea: Idea }) => (
<HeadedScrollablePanel heading={<AreaHeading name={idea.name} />}>
<Stack
gap="3"
fontSize="sm"
p="5"
pr="3"
mt="1"
mb="1"
className={docStylesClass}
>
<IdeaGuideContent idea={idea} />
</Stack>
</HeadedScrollablePanel>
);

export default GuideArea;
16 changes: 16 additions & 0 deletions src/documentation/common/DocumentationContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ interface DocumentationContextValue {
parentSlug?: string;
toolkitType?: string;
title?: string;
/**
* Links to Reference and API, which navigate the editor's sidebar. False
* outside the editor, where they show as plain text.
*/
editorLinks?: boolean;
}

const DocumentationContext = React.createContext<DocumentationContextValue>({});
Expand All @@ -67,10 +72,17 @@ const useCodeEmbedContext = () => {
return { parentSlug, toolkitType, title };
};

const useEditorLinks = () =>
useContext(DocumentationContext).editorLinks !== false;

const DocumentationApiLinkMark = (
props: SerializerMarkProps<ToolkitApiLink>
) => {
const [, setState] = useRouterState();
const editorLinks = useEditorLinks();
if (!editorLinks) {
return <>{props.children}</>;
}
return (
<Link
color="brand.600"
Expand All @@ -91,6 +103,10 @@ const DocumentationInternalLinkMark = (
props: SerializerMarkProps<ToolkitInternalLink>
) => {
const [state, setState] = useRouterState();
const editorLinks = useEditorLinks();
if (!editorLinks) {
return <>{props.children}</>;
}
return (
<Link
color="brand.600"
Expand Down
70 changes: 70 additions & 0 deletions src/documentation/ideas/IdeaGuideContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* (c) 2026, Micro:bit Educational Foundation and contributors
*
* SPDX-License-Identifier: MIT
*/
import { CSSProperties } from "react";
import { Box } from "styled-system/jsx";
import { runtimeAspectRatioClass } from "../../common/documentation-styles";
import {
getAspectRatioPadding,
imageUrlBuilder,
} from "../../common/imageUrlBuilder";
import DocumentationContent, {
DocumentationContextProvider,
} from "../common/DocumentationContent";
import ImageWithFallback from "../common/ImageWithFallback";
import OfflineImageFallback from "../OfflineImageFallback";
import { withoutCode } from "./idea-code";
import { Idea } from "./model";

interface IdeaGuideContentProps {
idea: Idea;
/**
* In the editor's guide panel: with the idea's image, and links to
* Reference and API. False in the home page's idea dialog.
*/
inEditor?: boolean;
}

/**
* An idea's content without its program, for the guide panel and the home
* page's idea dialog. Callers supply the surrounding stack and
* docStylesClass.
*/
const IdeaGuideContent = ({ idea, inEditor = true }: IdeaGuideContentProps) => {
// Runtime value derived from the image reference.
const imagePadding = idea.image
? getAspectRatioPadding(idea.image.asset._ref)
: undefined;
return (
<>
{inEditor && idea.image && (
<Box
width="600px"
maxWidth="100%"
className={imagePadding ? runtimeAspectRatioClass : undefined}
style={{ "--aspect-ratio-padding": imagePadding } as CSSProperties}
>
<ImageWithFallback
src={imageUrlBuilder.image(idea.image.asset).fit("max").url()}
ignoreFallback={navigator.onLine}
fallback={<OfflineImageFallback width={600} />}
alt=""
borderTopRadius="lg"
/>
</Box>
)}
<DocumentationContextProvider
parentSlug={idea.slug.current}
toolkitType="ideas"
title={idea.name}
editorLinks={inEditor}
>
<DocumentationContent blocks={withoutCode(idea.content)} />
</DocumentationContextProvider>
</>
);
};

export default IdeaGuideContent;
19 changes: 19 additions & 0 deletions src/documentation/ideas/idea-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* (c) 2026, Micro:bit Educational Foundation and contributors
*
* SPDX-License-Identifier: MIT
*/
import { PortableText } from "../../common/sanity";
import { Idea } from "./model";

const isCode = (
block: PortableText[number]
): block is { _type: "python"; main: string } => block._type === "python";

/** The idea's program: its content's first code block. */
export const ideaCode = (idea: Idea): string | undefined =>
idea.content?.find(isCode)?.main;

/** The idea's content without its code, for the guide beside the editor. */
export const withoutCode = (content: PortableText | undefined): PortableText =>
(content ?? []).filter((block) => !isCode(block));
2 changes: 1 addition & 1 deletion src/e2e/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ export class App {
}
}

async switchTab(tabName: "Files" | "API" | "Reference" | "Ideas") {
async switchTab(tabName: "Files" | "API" | "Reference" | "Ideas" | "Guide") {
await this.page.getByRole("tab", { name: tabName }).click();
}

Expand Down
23 changes: 23 additions & 0 deletions src/e2e/home.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,29 @@ test.describe("home page", () => {
await app.expectEditorContainText("from microbit import");
});

test("opens an idea from its dialog as a project with its guide", async ({
app,
homePage,
}) => {
const ideaName = "Emotion badge";
await homePage.goto();
await homePage.page.getByRole("button", { name: ideaName }).click();
const dialog = homePage.page.getByRole("dialog");
await expect(
dialog.getByRole("heading", { name: ideaName, exact: true })
).toBeVisible();

await dialog.getByRole("button", { name: "Create", exact: true }).click();

await expect(app.page).toHaveURL(appUrlPattern("project/guide"));
await app.expectProjectName(ideaName);
await expect(
app.page.getByRole("tab", { name: "Guide", selected: true })
).toBeVisible();
await app.expectDocumentationTopLevelHeading(ideaName);
await app.expectEditorContainText("from microbit import");
});

test("returns home from the editor with the project listed", async ({
app,
homePage,
Expand Down
2 changes: 2 additions & 0 deletions src/fs/projects-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface ProjectMeta {
name: string | undefined;
/** Last modified or opened, for ordering by recency. */
timestamp: number;
/** The idea the project was opened from, whose guide it shows. */
ideaSlug?: string;
}

/** A project as listed on the pages: its metadata plus what it contains. */
Expand Down
19 changes: 16 additions & 3 deletions src/pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ import { useSettings } from "../settings/settings";
import { createProjectsPageUrl } from "../urls";
import ActionCard from "./ActionCard";
import DefaultPageLayout from "./DefaultPageLayout";
import IdeaDialog from "./IdeaDialog";
import ProjectIcon from "./ProjectIcon";
import HomepageBanner from "./HomepageBanner";
import HomepageFooter from "./HomepageFooter";
import {
useCreateProject,
useImportProjectFiles,
usePageProjects,
useProjectPageActions,
Expand All @@ -55,14 +55,27 @@ const HomePage = () => {
const intl = useIntl();
const [{ languageId }] = useSettings();
const brand = useDeployment();
const createProject = useCreateProject("home");
const [selectedIdea, setSelectedIdea] = useState<{
slug: string;
title: string;
}>();
const selectIdea = useCallback(
(slug: string, title: string) => setSelectedIdea({ slug, title }),
[]
);
const importFiles = useImportProjectFiles();
const handleDrop = useCallback(
(files: File[]) => void importFiles(files, "drop"),
[importFiles]
);
return (
<DefaultPageLayout>
{selectedIdea && (
<IdeaDialog
{...selectedIdea}
onClose={() => setSelectedIdea(undefined)}
/>
)}
<FileDropTarget
data-testid="home-drop-target"
onFileDrop={handleDrop}
Expand All @@ -71,7 +84,7 @@ const HomePage = () => {
<HomepageBanner />
<ProjectsRow />
<CarouselRow
carouselItems={createIdeaCards(intl, createProject)}
carouselItems={createIdeaCards(intl, selectIdea)}
title={<FormattedMessage id="ideas-row-title" />}
navigation
/>
Expand Down
Loading
Loading