diff --git a/localtypings/pxteditor.d.ts b/localtypings/pxteditor.d.ts index e1824cb9d2..7884f10aff 100644 --- a/localtypings/pxteditor.d.ts +++ b/localtypings/pxteditor.d.ts @@ -938,6 +938,7 @@ declare namespace pxt.editor { createProjectAsync(options: pxt.editor.ProjectCreationOptions): Promise; importExampleAsync(options: ExampleImportOptions): Promise; showScriptManager(): void; + showGalleryViewer(galleryPath: string, galleryName: string): void; importProjectDialog(): void; removeProject(): void; editText(): void; diff --git a/package.json b/package.json index 473a33207a..d265c54d7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pxt-core", - "version": "11.4.7", + "version": "11.4.8", "description": "Microsoft MakeCode provides Blocks / JavaScript / Python tools and editors", "keywords": [ "TypeScript", diff --git a/tests/unit/galleryviewer.test.js b/tests/unit/galleryviewer.test.js new file mode 100644 index 0000000000..5c3d71d43e --- /dev/null +++ b/tests/unit/galleryviewer.test.js @@ -0,0 +1,29 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +describe('View All logic', () => { + it('calls showGalleryViewer when gallery View All is clicked', () => { + // Mock gallery structure + const galleryName = "Tutorials"; + const galleryUrl = "tutorials"; + + // Simulate a simplified Projects component with props + const parentMock = { + showGalleryViewer: sinon.spy() + }; + + // Simulate what the click handler does + function onClickViewAll(url, name) { + if (url && parentMock.showGalleryViewer) { + parentMock.showGalleryViewer(url, name); + } + } + + // Act: pretend user clicked + onClickViewAll(galleryUrl, galleryName); + + // Assert + expect(parentMock.showGalleryViewer.calledOnce).to.be.true; + expect(parentMock.showGalleryViewer.firstCall.args).to.deep.equal(["tutorials", "Tutorials"]); + }); +}); diff --git a/webapp/src/app.tsx b/webapp/src/app.tsx index 6eea319b59..917b1da1cb 100644 --- a/webapp/src/app.tsx +++ b/webapp/src/app.tsx @@ -32,6 +32,7 @@ import * as scriptsearch from "./scriptsearch"; import * as extensionsBrowser from "./extensionsBrowser"; import * as projects from "./projects"; import * as scriptmanager from "./scriptmanager"; +import * as galleryviewer from "./galleryviewer"; import * as extensions from "./extensions"; import * as sounds from "./sounds"; import * as make from "./make"; @@ -144,6 +145,7 @@ export class ProjectView shareEditor: share.ShareEditor; languagePicker: lang.LanguagePicker; scriptManagerDialog: scriptmanager.ScriptManagerDialog; + galleryViewerDialog: galleryviewer.GalleryViewerDialog; importDialog: projects.ImportDialog; loginDialog: identity.LoginDialog; profileDialog: user.ProfileDialog; @@ -4126,6 +4128,10 @@ export class ProjectView this.scriptManagerDialog.show(); } + showGalleryViewer(galleryPath: string, galleryName: string) { + this.galleryViewerDialog.show(galleryPath, galleryName); + } + importProjectDialog() { this.importDialog.show(); } @@ -5297,6 +5303,10 @@ export class ProjectView this.scriptManagerDialog = c; } + private handleGalleryViewerDialogRef = (c: galleryviewer.GalleryViewerDialog) => { + this.galleryViewerDialog = c; + } + private handleImportDialogRef = (c: projects.ImportDialog) => { this.importDialog = c; } @@ -5499,6 +5509,7 @@ export class ProjectView {hasIdentity ? : undefined} {hasIdentity ? : undefined} {inHome && targetTheme.scriptManager ? : undefined} + {sandbox ? undefined : } {sandbox ? undefined : } {hwDialog ? : undefined} diff --git a/webapp/src/galleryviewer.tsx b/webapp/src/galleryviewer.tsx new file mode 100644 index 0000000000..72996ec184 --- /dev/null +++ b/webapp/src/galleryviewer.tsx @@ -0,0 +1,139 @@ +import * as React from "react"; +import * as data from "./data"; +import * as sui from "./sui"; +import * as core from "./core"; +import * as codecard from "./codecard"; + +import { SearchInput } from "./components/searchInput"; +import { fireClickOnEnter } from "./util"; + +import ISettingsProps = pxt.editor.ISettingsProps; +import { applyCodeCardAction } from "./projects"; + +export interface GalleryViewerDialogProps extends ISettingsProps { + onClose?: () => void; +} + +export interface GalleryViewerDialogState { + visible?: boolean; + galleryCards?: pxt.CodeCard[]; + searchFor?: string; + galleryName?: string; + galleryPath?: string; +} + +export class GalleryViewerDialog extends data.Component { + constructor(props: GalleryViewerDialogProps) { + super(props); + this.state = { + visible: false, + galleryCards: [], + galleryName: "", + galleryPath: "" + }; + + this.close = this.close.bind(this); + this.handleCardClick = this.handleCardClick.bind(this); + this.handleSearch = this.handleSearch.bind(this); + } + + hide() { + this.setState({ visible: false, searchFor: undefined }); + } + + close() { + this.setState({ visible: false, searchFor: undefined }); + if (this.props.onClose) this.props.onClose(); + } + + show(galleryPath: string, galleryName: string) { + this.setState({ + visible: true, + galleryPath: galleryPath, + galleryName: galleryName, + galleryCards: [] // Clear previous gallery cards + }, () => { + this.fetchGalleryData(); + }); + } + + fetchGalleryData() { + const { galleryPath } = this.state; + if (!galleryPath) return; + + // Fetch gallery cards + let res = this.getData(`gallery:${encodeURIComponent(galleryPath)}`) as pxt.gallery.Gallery[]; + if (res && !(res instanceof Error)) { + this.setState({ + galleryCards: pxt.Util.concat(res.map(g => g.cards)) + }); + } else { + // Handle error + this.setState({ + galleryCards: [] + }); + } + } + + handleCardClick(e: any, scr: pxt.CodeCard) { + pxt.tickEvent("galleryviewer.card", { name: scr.name, cardType: scr.cardType }); + + // Use the same application method as the regular gallery + applyCodeCardAction(this.props.parent, "projects", scr); + + // Close the dialog after selection + this.close(); + } + + handleSearch(inputValue: string) { + const searchFor = (inputValue || '').trim().toLowerCase(); + this.setState({ searchFor }); + } + + renderCore() { + const { visible, galleryCards, searchFor, galleryName } = this.state; + if (!visible) return
; + + // Filter gallery cards based on search term if provided + const filteredCards = searchFor && galleryCards ? + galleryCards.filter(card => + (card.name || "").toLowerCase().indexOf(searchFor) !== -1 || + (card.description || "").toLowerCase().indexOf(searchFor) !== -1 + ) : + galleryCards || []; + + return ( + +
+
+ +
+ {filteredCards.length === 0 ? +
+ {searchFor ? lf("No matching tutorials found.") : lf("No tutorials found.")} +
: +
+ {filteredCards.map((card, index) => + this.handleCardClick(e, card)} + /> + )} +
+ } +
+
+ ); + } +} diff --git a/webapp/src/projects.tsx b/webapp/src/projects.tsx index 4e1f9c54a3..e2141eb981 100644 --- a/webapp/src/projects.tsx +++ b/webapp/src/projects.tsx @@ -197,7 +197,24 @@ export class Projects extends auth.Component { const url = typeof galProps === "string" ? galProps : galProps.url const shuffle: pxt.GalleryShuffle = typeof galProps === "string" ? undefined : galProps.shuffle; return
-

{pxt.Util.rlf(galleryName)}

+
+
url && this.props.parent.showGalleryViewer(url, galleryName)} + onKeyDown={fireClickOnEnter} + > +

+ {pxt.Util.rlf(galleryName)} + + {lf("View All")} + +

+
+
{ path={url} onClick={this.chgGallery} setSelected={this.setSelected} shuffle={shuffle} + showViewAll={false} selectedIndex={selectedCategory == galleryName ? selectedIndex : undefined} />
@@ -570,6 +588,7 @@ interface ProjectsCarouselProps extends ISettingsProps { selectedIndex?: number; setSelected?: (name: string, index: number) => void; shuffle?: pxt.GalleryShuffle; + showViewAll?: boolean; } interface ProjectsCarouselState { @@ -591,6 +610,7 @@ export class ProjectsCarousel extends data.Component) { + e.stopPropagation(); + e.preventDefault(); + + const { name, path } = this.props; + if (path) { + pxt.tickEvent("gallery.viewall", { gallery: name }); + this.props.parent.showGalleryViewer(path, name); + } + } + closeDetail() { const { name } = this.props; pxt.tickEvent("projects.detail.close"); @@ -705,7 +736,28 @@ export class ProjectsCarousel extends data.Component c.tags && c.tags.length != 0) - return
+ return
+ {this.props.showViewAll && ( +
+
+
+

+ {lf(name)} + + {lf("View All")} + +

+
+
+
+ )} {cards.map((scr, index) => , className: string /> } -function applyCodeCardAction(projectView: IProjectView, ticSrc: "projects" | "herobanner", scr: pxt.CodeCard, action?: pxt.CodeCardAction) { +export function applyCodeCardAction(projectView: IProjectView, ticSrc: "projects" | "herobanner", scr: pxt.CodeCard, action?: pxt.CodeCardAction) { let editor: string = (action && action.editor) || "blocks"; if (editor == "js") editor = "ts"; const url = action ? action.url : scr.url;