This is an automated email from the ASF dual-hosted git repository. porcelli pushed a commit to branch KOGITO-8015-feature-preview in repository https://gitbox.apache.org/repos/asf/incubator-kie-tools-temporary-rnd-do-not-use.git
commit 15fcd99b0b8b64c72c3b1f1ba3eac9d0a3d6dbb3 Author: Fabrizio Antonangeli <[email protected]> AuthorDate: Tue May 16 13:33:43 2023 +0200 KOGITO-8153: Create an extensible Samples page (now Catalog) (#1635) Co-authored-by: Guilherme Caponetto <[email protected]> --- .../src/home/sample/SampleCard.tsx | 192 ++++++++++++------- .../src/home/sample/SampleCardSkeleton.css | 9 - .../src/home/sample/Showcase.tsx | 209 +++++++++++++-------- .../src/home/sample/hooks/SampleContext.tsx | 148 ++++++++++++++- .../src/home/sample/sampleApi.ts | 118 +++++++----- 5 files changed, 461 insertions(+), 215 deletions(-) diff --git a/packages/serverless-logic-web-tools/src/home/sample/SampleCard.tsx b/packages/serverless-logic-web-tools/src/home/sample/SampleCard.tsx index 31475546ff..aa81206b59 100644 --- a/packages/serverless-logic-web-tools/src/home/sample/SampleCard.tsx +++ b/packages/serverless-logic-web-tools/src/home/sample/SampleCard.tsx @@ -14,19 +14,19 @@ * limitations under the License. */ -import * as React from "react"; -import { useMemo, useRef, useEffect } from "react"; -import { Card, CardTitle, CardFooter, CardBody } from "@patternfly/react-core/dist/js/components/Card"; -import { Grid, GridItem } from "@patternfly/react-core/dist/js/layouts/Grid"; -import { Button, ButtonVariant } from "@patternfly/react-core/dist/js/components/Button"; -import { useRoutes } from "../../navigation/Hooks"; -import { Link } from "react-router-dom"; -import { Text } from "@patternfly/react-core/dist/js/components/Text"; +import { Button, Modal, ModalVariant, Skeleton } from "@patternfly/react-core/dist/js"; +import { Card, CardBody, CardTitle } from "@patternfly/react-core/dist/js/components/Card"; import { Label, LabelProps } from "@patternfly/react-core/dist/js/components/Label"; -import { FolderIcon, FileIcon, MonitoringIcon } from "@patternfly/react-icons/dist/js/icons"; -import { Sample, SampleCategory } from "./sampleApi"; +import { Text } from "@patternfly/react-core/dist/js/components/Text"; import { Tooltip } from "@patternfly/react-core/dist/js/components/Tooltip"; import { Bullseye } from "@patternfly/react-core/dist/js/layouts/Bullseye"; +import { Grid, GridItem } from "@patternfly/react-core/dist/js/layouts/Grid"; +import { FileIcon, FolderIcon, MonitoringIcon, SearchPlusIcon } from "@patternfly/react-icons/dist/js/icons"; +import * as React from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useHistory } from "react-router-dom"; +import { useRoutes } from "../../navigation/Hooks"; +import { Sample, SampleCategory } from "./sampleApi"; const tagMap: Record<SampleCategory, { label: string; icon: React.ComponentClass; color: LabelProps["color"] }> = { ["serverless-workflow"]: { @@ -46,72 +46,120 @@ const tagMap: Record<SampleCategory, { label: string; icon: React.ComponentClass }, }; -export function SampleCard(props: { sample: Sample }) { - const routes = useRoutes(); - const imgRef = useRef<HTMLImageElement>(null); - const tag = useMemo(() => tagMap[props.sample.definition.category], [props.sample.definition.category]); +function SampleSvgImg(props: { + sample: Sample; + svgBlob: string | undefined; + height?: string; + width?: string; + maxWidth?: string; + maxHeight?: string; +}) { + const [svgUrl, setSvgUrl] = useState(""); + const { height, width, maxWidth = "100%", maxHeight } = props; useEffect(() => { - const blob = new Blob([props.sample.svgContent], { type: "image/svg+xml" }); + const blob = new Blob([props.svgBlob || ""], { type: "image/svg+xml" }); const url = URL.createObjectURL(blob); - imgRef.current!.addEventListener("load", () => URL.revokeObjectURL(url), { once: true }); - imgRef.current!.src = url; - }, [props.sample.svgContent]); + setSvgUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + }, [props.svgBlob]); + + if (!svgUrl || !props.svgBlob) { + return <Skeleton height={height} width="100%" style={{ maxHeight: "80%" }} />; + } + + return ( + <img style={{ height, width, maxWidth, maxHeight }} src={svgUrl} alt={`SVG for sample ${props.sample.sampleId}`} /> + ); +} + +export function SampleCard(props: { sample: Sample; cover: string | undefined }) { + const [isModalOpen, setIsModalOpen] = useState(false); + const routes = useRoutes(); + const tag = useMemo(() => tagMap[props.sample.definition.category], [props.sample.definition.category]); + const history = useHistory(); + + const onCardClick = useCallback(() => { + history.push({ + pathname: routes.sampleShowcase.path({}), + search: routes.sampleShowcase.queryString({ sampleId: props.sample.sampleId }), + }); + }, [props.sample, history, routes]); + + const handleModalToggle = useCallback((e?) => { + e?.stopPropagation(); + setIsModalOpen((prevState) => !prevState); + }, []); return ( - <Card isCompact={true} isFullHeight={true}> - <Grid style={{ height: "100%" }}> - <GridItem - lg={6} - style={{ overflow: "hidden", textAlign: "center", verticalAlign: "middle", position: "relative" }} - > - <div style={{ position: "absolute", bottom: "16px", right: 0, left: 0, margin: "auto" }}> - <Label color={tag.color}> - <tag.icon /> - <b>{tag.label}</b> - </Label> - </div> - <Bullseye style={{ padding: "0px 8px 30px 8px" }}> - <img - style={{ height: "370px", maxWidth: "100%" }} - ref={imgRef} - alt={`SVG for sample ${props.sample.sampleId}`} - /> - </Bullseye> - </GridItem> - <GridItem lg={6} style={{ display: "flex", flexDirection: "column" }}> - <CardTitle data-ouia-component-type="sample-title">{props.sample.definition.title}</CardTitle> - <CardBody isFilled={true}> - <Tooltip content={<div>{props.sample.definition.description}</div>}> - <Text - component="p" - style={{ - display: "-webkit-box", - WebkitBoxOrient: "vertical", - WebkitLineClamp: 5, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "pre-wrap", - }} - > - {props.sample.definition.description} - </Text> - </Tooltip> - </CardBody> - <CardFooter style={{ alignItems: "baseline" }}> - <Link - to={{ - pathname: routes.sampleShowcase.path({}), - search: routes.sampleShowcase.queryString({ sampleId: props.sample.sampleId }), - }} - > - <Button variant={ButtonVariant.tertiary} ouiaId={props.sample.sampleId + `-try-swf-sample-button`}> - Try it out! - </Button> - </Link> - </CardFooter> - </GridItem> - </Grid> - </Card> + <> + <Card isCompact={true} isFullHeight={true} onClick={onCardClick} isSelectable> + <Grid style={{ height: "100%" }}> + <GridItem + lg={6} + style={{ overflow: "hidden", textAlign: "center", verticalAlign: "middle", position: "relative" }} + > + <div style={{ position: "absolute", bottom: "16px", right: 0, left: 0, margin: "auto" }}> + {props.cover && ( + <Button + type="button" + onClick={handleModalToggle} + isLarge + variant="plain" + style={ + { + "--pf-c-button--PaddingLeft": "0", + "--pf-c-button--PaddingRight": "0", + marginTop: "-16px", + marginLeft: "80%", + } as React.CSSProperties + } + > + <SearchPlusIcon size="sm" /> + </Button> + )} + <Label color={tag.color}> + <tag.icon /> + <b>{tag.label}</b> + </Label> + </div> + <Bullseye style={{ padding: "0px 8px 30px 8px" }}> + <SampleSvgImg sample={props.sample} svgBlob={props.cover} height="370px" /> + </Bullseye> + </GridItem> + <GridItem lg={6} style={{ display: "flex", flexDirection: "column" }}> + <CardTitle data-ouia-component-type="sample-title">{props.sample.definition.title}</CardTitle> + <CardBody isFilled={true}> + <Tooltip content={<div>{props.sample.definition.description}</div>}> + <Text + component="p" + style={{ + display: "-webkit-box", + WebkitBoxOrient: "vertical", + WebkitLineClamp: 5, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "pre-wrap", + }} + > + {props.sample.definition.description} + </Text> + </Tooltip> + </CardBody> + </GridItem> + </Grid> + </Card> + <Modal + title={props.sample.definition.title} + variant={ModalVariant.large} + isOpen={isModalOpen} + onClose={handleModalToggle} + style={{ textAlign: "center" }} + > + <SampleSvgImg sample={props.sample} svgBlob={props.cover} height="670px" /> + </Modal> + </> ); } diff --git a/packages/serverless-logic-web-tools/src/home/sample/SampleCardSkeleton.css b/packages/serverless-logic-web-tools/src/home/sample/SampleCardSkeleton.css index cc7433efff..3c9c1fa459 100644 --- a/packages/serverless-logic-web-tools/src/home/sample/SampleCardSkeleton.css +++ b/packages/serverless-logic-web-tools/src/home/sample/SampleCardSkeleton.css @@ -14,15 +14,6 @@ * limitations under the License. */ -.sample-card-skeleton--gallery { - overflow-x: auto; - grid-auto-flow: column; - grid-auto-columns: minmax(calc(100% / 3.1 - 16px), 1fr); - padding-bottom: 8px; - padding-right: var(--pf-c-page__main-section--xl--PaddingRight); - height: 430px; -} - .sample-card-skeleton--grid { height: 100%; } diff --git a/packages/serverless-logic-web-tools/src/home/sample/Showcase.tsx b/packages/serverless-logic-web-tools/src/home/sample/Showcase.tsx index e1839b9c36..52303c80b2 100644 --- a/packages/serverless-logic-web-tools/src/home/sample/Showcase.tsx +++ b/packages/serverless-logic-web-tools/src/home/sample/Showcase.tsx @@ -14,28 +14,29 @@ * limitations under the License. */ -import * as React from "react"; -import { useEffect, useState, useCallback, useMemo } from "react"; -import { TextContent, Text } from "@patternfly/react-core/dist/js/components/Text"; -import { SampleCard } from "./SampleCard"; -import { Gallery } from "@patternfly/react-core/dist/js/layouts/Gallery"; -import { Sample, SampleCategory } from "./sampleApi"; -import { SampleCardSkeleton } from "./SampleCardSkeleton"; -import { SamplesLoadError } from "./SamplesLoadError"; -import { useSampleDispatch } from "./hooks/SampleContext"; -import { SearchInput } from "@patternfly/react-core/dist/js/components/SearchInput"; -import { PageSection } from "@patternfly/react-core/dist/js/components/Page"; -import { EmptyState, EmptyStateIcon } from "@patternfly/react-core/dist/js/components/EmptyState"; -import { Title } from "@patternfly/react-core/dist/js/components/Title"; -import { CubesIcon } from "@patternfly/react-icons/dist/js/icons/cubes-icon"; +import { Pagination, PaginationVariant, PerPageOptions, Skeleton } from "@patternfly/react-core/dist/js"; import { Dropdown, DropdownItem, DropdownSeparator, DropdownToggle, } from "@patternfly/react-core/dist/js/components/Dropdown"; +import { EmptyState, EmptyStateIcon } from "@patternfly/react-core/dist/js/components/EmptyState"; +import { Page, PageSection } from "@patternfly/react-core/dist/js/components/Page"; +import { SearchInput } from "@patternfly/react-core/dist/js/components/SearchInput"; +import { Text, TextContent, TextVariants } from "@patternfly/react-core/dist/js/components/Text"; +import { Title } from "@patternfly/react-core/dist/js/components/Title"; +import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core/dist/js/components/Toolbar"; +import { Gallery } from "@patternfly/react-core/dist/js/layouts/Gallery"; +import { CubesIcon } from "@patternfly/react-icons/dist/js/icons/cubes-icon"; +import * as React from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { FileLabel } from "../../workspace/components/FileLabel"; -import { Flex, FlexItem } from "@patternfly/react-core/dist/js/layouts/Flex"; +import { useSampleDispatch } from "./hooks/SampleContext"; +import { Sample, SampleCategory, SampleCoversHashtable } from "./sampleApi"; +import { SampleCard } from "./SampleCard"; +import { SampleCardSkeleton } from "./SampleCardSkeleton"; +import { SamplesLoadError } from "./SamplesLoadError"; const SAMPLE_PRIORITY: Record<SampleCategory, number> = { ["serverless-workflow"]: 1, @@ -50,18 +51,49 @@ const LABEL_MAP: Record<SampleCategory, JSX.Element> = { }; const ALL_CATEGORIES_LABEL = "All categories"; - +const CARDS_PER_PAGE = 9; const CATEGORY_ARRAY = Object.keys(SAMPLE_PRIORITY) as SampleCategory[]; +export const SAMPLE_CARDS_PER_PAGE_OPTIONS: PerPageOptions[] = [ + { + title: `${CARDS_PER_PAGE}`, + value: CARDS_PER_PAGE, + }, +]; + export function Showcase() { const sampleDispatch = useSampleDispatch(); const [loading, setLoading] = useState<boolean>(true); const [samples, setSamples] = useState<Sample[]>([]); + const [sampleCovers, setSampleCovers] = useState<SampleCoversHashtable>({}); const [sampleLoadingError, setSampleLoadingError] = useState(""); const [searchFilter, setSearchFilter] = useState(""); const [categoryFilter, setCategoryFilter] = useState<SampleCategory | undefined>(); + const [page, setPage] = React.useState(1); const [isCategoryFilterDropdownOpen, setCategoryFilterDropdownOpen] = useState(false); + const visibleSamples = useMemo( + () => samples.slice((page - 1) * CARDS_PER_PAGE, page * CARDS_PER_PAGE), + [samples, page] + ); + + const samplesCount = useMemo(() => samples.length, [samples]); + + const filterResultMessage = useMemo(() => { + if (samplesCount === 0) { + return; + } + const isPlural = samplesCount > 1; + return `Showing ${samplesCount} sample${isPlural ? "s" : ""}`; + }, [samplesCount]); + + const selectedCategory = useMemo(() => { + if (categoryFilter) { + return LABEL_MAP[categoryFilter]; + } + return ALL_CATEGORIES_LABEL; + }, [categoryFilter]); + const onSearch = useCallback( async (args: { searchValue: string; category?: SampleCategory }) => { if (args.searchValue === searchFilter && args.category === categoryFilter) { @@ -91,20 +123,10 @@ export function Showcase() { }); }, [sampleDispatch]); - const filterResultMessage = useMemo(() => { - if (samples.length === 0) { - return; - } - const isPlural = samples.length > 1; - return `Showing ${samples.length} sample${isPlural ? "s" : ""}`; - }, [samples.length]); - - const selectedCategory = useMemo(() => { - if (categoryFilter) { - return LABEL_MAP[categoryFilter]; - } - return ALL_CATEGORIES_LABEL; - }, [categoryFilter]); + useEffect(() => { + sampleDispatch.getSampleCovers({ samples: visibleSamples, prevState: sampleCovers }).then(setSampleCovers); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visibleSamples, sampleDispatch]); const categoryFilterDropdownItems = useMemo( () => [ @@ -127,17 +149,20 @@ export function Showcase() { [onSearch, searchFilter] ); + const onSetPage = useCallback((_e, v) => { + setPage(v); + }, []); + return ( - <> - {sampleLoadingError && <SamplesLoadError errors={[sampleLoadingError]} />} - {!sampleLoadingError && ( - <> - <TextContent> - <Text component="h1">Samples Showcase</Text> - </TextContent> - <br /> - <Flex flexWrap={{ default: "wrap" }}> - <FlexItem style={{ marginRight: 0 }}> + <Page> + <PageSection variant={"light"}> + <TextContent> + <Text component={TextVariants.h1}>Samples Catalog</Text> + <Text component={TextVariants.p}>Try one of our samples to start defining your model.</Text> + </TextContent> + <Toolbar style={{ paddingBottom: "0" }}> + <ToolbarContent style={{ paddingLeft: "0", paddingRight: "0", paddingBottom: "0" }}> + <ToolbarItem variant="search-filter"> <SearchInput value={""} type={"search"} @@ -150,8 +175,8 @@ export function Showcase() { e.stopPropagation(); }} /> - </FlexItem> - <FlexItem> + </ToolbarItem> + <ToolbarItem> <Dropdown style={{ backgroundColor: "white" }} onSelect={() => setCategoryFilterDropdownOpen(false)} @@ -166,46 +191,74 @@ export function Showcase() { } isOpen={isCategoryFilterDropdownOpen} /> - </FlexItem> - <FlexItem> + </ToolbarItem> + <ToolbarItem> {filterResultMessage && ( <TextContent> <Text>{filterResultMessage}</Text> </TextContent> )} - </FlexItem> - </Flex> - <br /> - {loading && <SampleCardSkeleton numberOfCards={4} />} - {!loading && samples.length === 0 && ( - <PageSection variant={"light"} isFilled={true} style={{ marginRight: "25px" }}> - <EmptyState style={{ height: "350px" }}> - <EmptyStateIcon icon={CubesIcon} /> - <Title headingLevel="h4" size="lg"> - {"None of the available samples matched this search"} - </Title> - </EmptyState> - </PageSection> - )} - {!loading && samples.length > 0 && ( - <Gallery - hasGutter={true} - minWidths={{ sm: "calc(100%/3.1 - 16px)", default: "100%" }} - style={{ - overflowX: "auto", - gridAutoFlow: "column", - gridAutoColumns: "minmax(calc(100%/3.1 - 16px),1fr)", - paddingBottom: "8px", - paddingRight: "var(--pf-c-page__main-section--xl--PaddingRight)", - }} - > - {samples.map((sample) => ( - <SampleCard sample={sample} key={`sample-${sample.sampleId}`} /> - ))} - </Gallery> - )} - </> - )} - </> + </ToolbarItem> + <ToolbarItem variant="pagination"> + {loading && <Skeleton width="200px" />} + {!loading && ( + <Pagination + isCompact + itemCount={samplesCount} + onSetPage={onSetPage} + page={page} + perPage={CARDS_PER_PAGE} + perPageOptions={SAMPLE_CARDS_PER_PAGE_OPTIONS} + variant="top" + /> + )} + </ToolbarItem> + </ToolbarContent> + </Toolbar> + </PageSection> + + <PageSection isFilled> + {sampleLoadingError && <SamplesLoadError errors={[sampleLoadingError]} />} + {!sampleLoadingError && ( + <> + {loading && <SampleCardSkeleton numberOfCards={6} />} + {!loading && samplesCount === 0 && ( + <PageSection variant={"light"} isFilled={true} style={{ marginRight: "25px" }}> + <EmptyState style={{ height: "350px" }}> + <EmptyStateIcon icon={CubesIcon} /> + <Title headingLevel="h4" size="lg"> + {"None of the available samples matched this search"} + </Title> + </EmptyState> + </PageSection> + )} + {!loading && samplesCount > 0 && ( + <> + <Gallery hasGutter={true} minWidths={{ sm: "calc(100%/3.1 - 16px)", default: "100%" }}> + {visibleSamples.map((sample) => ( + <SampleCard + sample={sample} + key={`sample-${sample.sampleId}`} + cover={sampleCovers[sample.sampleId]} + /> + ))} + </Gallery> + <br /> + <Pagination + itemCount={samplesCount} + onSetPage={onSetPage} + page={page} + perPage={CARDS_PER_PAGE} + perPageComponent="button" + perPageOptions={SAMPLE_CARDS_PER_PAGE_OPTIONS} + variant={PaginationVariant.bottom} + widgetId="bottom-example" + /> + </> + )} + </> + )} + </PageSection> + </Page> ); } diff --git a/packages/serverless-logic-web-tools/src/home/sample/hooks/SampleContext.tsx b/packages/serverless-logic-web-tools/src/home/sample/hooks/SampleContext.tsx index f8fdbd7e4c..65204accd1 100644 --- a/packages/serverless-logic-web-tools/src/home/sample/hooks/SampleContext.tsx +++ b/packages/serverless-logic-web-tools/src/home/sample/hooks/SampleContext.tsx @@ -20,17 +20,42 @@ import { LocalFile } from "@kie-tools-core/workspaces-git-fs/dist/worker/api/Loc import * as React from "react"; import { useContext, useMemo, useCallback, useState } from "react"; import { useSettingsDispatch } from "../../../settings/SettingsContext"; -import { fetchSampleDefinitions, fetchSampleFiles, Sample, SampleCategory } from "../sampleApi"; +import { + fetchSampleCover, + fetchSampleDefinitions, + fetchSampleFiles, + Sample, + SampleCategory, + SampleCoversHashtable, +} from "../sampleApi"; import { decoder, encoder } from "@kie-tools-core/workspaces-git-fs/dist/encoderdecoder/EncoderDecoder"; import Fuse from "fuse.js"; const SAMPLE_DEFINITIONS_CACHE_FILE_PATH = "/definitions.json"; +const SAMPLE_COVERS_CACHE_FILE_PATH = "/covers.json"; const SAMPLES_FS_MOUNT_POINT = `lfs_v1__samples__${process.env.WEBPACK_REPLACE__version!}`; const SEARCH_KEYS = ["definition.category", "definition.title", "definition.description"]; export interface SampleDispatchContextType { getSamples(args: { categoryFilter?: SampleCategory; searchFilter?: string }): Promise<Sample[]>; getSampleFiles(sampleId: string): Promise<LocalFile[]>; + /** + * Gets the cover image for a sample from cache, or loads it from the API and caches it. + * @param args.sample The sample to get the cover image for. + * @param args.noCacheWriting A flag indicating whether to write the loaded cover image to cache. + * @returns The cover image as a base64-encoded string. + */ + getSampleCover(args: { sample: Sample; noCacheWriting?: boolean }): Promise<string | undefined>; + /** + * Gets the cover images for an array of samples from cache, or loads them from the API and caches them. + * @param args.samples An array of samples to get the cover images for. + * @param args.prevState The previous state of the entities being loaded + * @returns An object containing the sample IDs as keys and the cover images as base64-encoded strings. + */ + getSampleCovers(args: { + samples: Sample[]; + prevState: { [key: string]: string | undefined }; + }): Promise<SampleCoversHashtable>; } export const SampleDispatchContext = React.createContext<SampleDispatchContextType>({} as any); @@ -43,23 +68,81 @@ export function SampleContextProvider(props: React.PropsWithChildren<{}>) { const sampleStorageService = useMemo(() => new LfsStorageService(), []); const [allSampleDefinitions, setAllSampleDefinitions] = useState<Sample[]>(); - - const loadCache = useCallback( - async (args: { path: string; loadFn: () => Promise<any> }) => { + const [allSampleCovers, setAllSampleCovers] = useState<{ [sampleId: string]: string }>({}); + + /** + * Retrieves the contents of a cache file + * + * @param args.path The path of the cache file to retrieve. + * @returns The JSON-parsed contents of the cache file, or `null` if the file doesn't exist. + */ + const getCacheContent = useCallback( + async (args: { path: string }) => { const storageFile = await sampleStorageService.getFile(fs, args.path); if (storageFile) { const cacheContent = decoder.decode(await storageFile.getFileContents()); return JSON.parse(cacheContent); } - const content = await args.loadFn(); + return null; + }, + [sampleStorageService, fs] + ); + + /** + * Adds or updates the contents of a cache file + * + * @param args.path The path of the cache file to retrieve. + * @param args.content The content to add in the cache file. + * @returns The content stored in the cache file. + */ + const addCacheContent = useCallback( + async (args: { path: string; content: any }) => { const cacheFile = new LfsStorageFile({ path: args.path, - getFileContents: async () => encoder.encode(JSON.stringify(content)), + getFileContents: async () => encoder.encode(JSON.stringify(args.content)), }); - await sampleStorageService.createFiles(fs, [cacheFile]); - return content; + await sampleStorageService.createOrOverwriteFile(fs, cacheFile); + return args.content; }, - [fs, sampleStorageService] + [sampleStorageService, fs] + ); + + const loadCache = useCallback( + async (args: { path: string; loadFn: () => Promise<any> }) => { + const cacheContent = await getCacheContent(args); + + if (cacheContent) { + return cacheContent; + } + const content = await args.loadFn(); + + return await addCacheContent({ ...args, content }); + }, + [getCacheContent, addCacheContent] + ); + + /** + * Loads an entity from cache if available, otherwise loads it from the provided load function and saves it to cache. + * @param args.path The path of the cache file. + * @param args.id The unique identifier for the entity being loaded. + * @param args.noCacheWriting A flag indicating whether to write the loaded entity to cache. + * @param args.loadFn The function to use to load the entity if it is not found in cache. + * @returns The loaded entity. + */ + const loadCacheEntity = useCallback( + async (args: { path: string; id: string; noCacheWriting?: boolean; loadFn: () => Promise<any> }) => { + const cacheContent = (await getCacheContent(args)) || {}; + if (cacheContent[args.id]) { + return cacheContent[args.id]; + } + + cacheContent[args.id] = await args.loadFn(); + + return !args.noCacheWriting + ? (await addCacheContent({ ...args, content: cacheContent }))[args.id] + : cacheContent[args.id]; + }, + [getCacheContent, addCacheContent] ); const getSamples = useCallback( @@ -95,12 +178,57 @@ export function SampleContextProvider(props: React.PropsWithChildren<{}>) { [allSampleDefinitions, loadCache, settingsDispatch.github.octokit] ); + const getSampleCover = useCallback( + async (args: { sample: Sample; noCacheWriting?: boolean }) => { + const cachedCover = allSampleCovers[args.sample.sampleId]; + + if (!cachedCover) { + const cover = await loadCacheEntity({ + path: SAMPLE_COVERS_CACHE_FILE_PATH, + id: args.sample.sampleId, + loadFn: async () => fetchSampleCover({ octokit: settingsDispatch.github.octokit, sample: args.sample }), + noCacheWriting: args.noCacheWriting, + }); + + allSampleCovers[args.sample.sampleId] = cover; + setAllSampleCovers(allSampleCovers); + return cover; + } else { + return cachedCover; + } + }, + [settingsDispatch.github.octokit, loadCacheEntity, allSampleCovers] + ); + + const getSampleCovers = useCallback( + async (args: { samples: Sample[]; prevState: { [key: string]: string } }) => { + if (!args.samples.length) { + return {}; + } + + const covers = ( + await Promise.all( + args.samples.map(async (sample) => ({ + [sample.sampleId]: await getSampleCover({ ...args, sample, noCacheWriting: true }), + })) + ) + ).reduce((acc, curr) => ({ ...acc, ...curr }), args.prevState || {}); + + const cacheContent = await getCacheContent({ path: SAMPLE_COVERS_CACHE_FILE_PATH }); + return await addCacheContent({ path: SAMPLE_COVERS_CACHE_FILE_PATH, content: { ...covers, ...cacheContent } }); + }, + [getSampleCover, addCacheContent, getCacheContent] + ); + const getSampleFiles = useCallback( async (sampleId: string) => fetchSampleFiles({ octokit: settingsDispatch.github.octokit, sampleId }), [settingsDispatch.github.octokit] ); - const dispatch = useMemo(() => ({ getSamples, getSampleFiles }), [getSamples, getSampleFiles]); + const dispatch = useMemo( + () => ({ getSamples, getSampleFiles, getSampleCover, getSampleCovers }), + [getSamples, getSampleFiles, getSampleCover, getSampleCovers] + ); return <SampleDispatchContext.Provider value={dispatch}>{props.children}</SampleDispatchContext.Provider>; } diff --git a/packages/serverless-logic-web-tools/src/home/sample/sampleApi.ts b/packages/serverless-logic-web-tools/src/home/sample/sampleApi.ts index e4054706dc..7a8efb27ba 100644 --- a/packages/serverless-logic-web-tools/src/home/sample/sampleApi.ts +++ b/packages/serverless-logic-web-tools/src/home/sample/sampleApi.ts @@ -39,7 +39,10 @@ type GitHubFileInfo = GitHubRepoInfo & { path: string }; export type SampleCategory = "serverless-workflow" | "serverless-decision" | "dashbuilder"; +const SUPPORTING_FILES_FOLDER = join(".github", "supporting-files"); + const SAMPLE_DEFINITION_FILE = "definition.json"; +const SAMPLE_DEFINITIONS_FILE = join(SUPPORTING_FILES_FOLDER, "sample-definitions.json"); const SAMPLE_TEMPLATE_FOLDER = "template"; @@ -49,6 +52,18 @@ const SAMPLE_FOLDER = "samples"; type SampleStatus = "ok" | "out of date" | "deprecated"; +interface SampleSocial { + network: string; + id: string; +} + +interface SampleAuthor { + name: string; + email: string; + github: string; + social: SampleSocial[]; +} + interface SampleDefinition { category: SampleCategory; status: SampleStatus; @@ -56,6 +71,12 @@ interface SampleDefinition { description: string; cover: string; tags: string[]; + type: string; + dependencies: string[]; + related_to: string[]; + resources: string[]; + authors: SampleAuthor[]; + sample_path: string; } interface ContentData { @@ -73,14 +94,17 @@ interface ContentData { export type Sample = { sampleId: string; definition: SampleDefinition; - svgContent: string; +}; + +export type SampleCoversHashtable = { + [sampleId: string]: string; }; export const KIE_SAMPLES_REPO: GitHubFileInfo = { owner: "kiegroup", repo: "kie-samples", ref: process.env["WEBPACK_REPLACE__samplesRepositoryRef"]!, - path: "samples", + path: SAMPLE_FOLDER, }; async function fetchFileContent(args: { octokit: Octokit; fileInfo: GitHubFileInfo }): Promise<string | undefined> { @@ -143,56 +167,56 @@ async function listSampleDefinitionFiles(args: { .map((folder) => ({ sampleId: folder.name, definitionPath: join(folder.path, SAMPLE_DEFINITION_FILE) })); } +/** + * fetch the Sample Definitions file from the repository. + * @param args.octokit An instance of the Octokit GitHub API client. + * @returns an array of Samples + */ export async function fetchSampleDefinitions(octokit: Octokit): Promise<Sample[]> { - const sampleDefinitionFiles = await listSampleDefinitionFiles({ + const fileContent = await fetchFileContent({ octokit, - repoInfo: { ...KIE_SAMPLES_REPO }, + fileInfo: { + ...KIE_SAMPLES_REPO, + path: SAMPLE_DEFINITIONS_FILE, + }, }); - const samples = ( - await Promise.all( - sampleDefinitionFiles.map(async (definitionFile) => { - const fileContent = await fetchFileContent({ - octokit, - fileInfo: { - ...KIE_SAMPLES_REPO, - path: definitionFile.definitionPath, - }, - }); - - if (!fileContent) { - console.error(`Could not read sample definition for ${definitionFile.sampleId}`); - return null; - } - - const definition = JSON.parse(fileContent) as SampleDefinition; - const svgContent = await fetchFileContent({ - octokit, - fileInfo: { - ...KIE_SAMPLES_REPO, - path: join("samples", definitionFile.sampleId, definition.cover), - }, - }); - - if (!svgContent) { - console.error(`Could not read sample svg for ${definitionFile.sampleId}`); - return null; - } - - return { - sampleId: definitionFile.sampleId, - definition, - svgContent, - }; - }) - ) - ).filter((sample) => sample !== null) as Sample[]; + if (!fileContent) { + console.error(`Could not read sample definitions`); + return []; + } + + const definitions = JSON.parse(fileContent) as SampleDefinition[]; + + return definitions.map((definition) => ({ + sampleId: definition.sample_path.replace(new RegExp(`^${SAMPLE_FOLDER}/`), ""), + definition, + })); +} - if (samples.length === 0) { - throw new Error("No samples could be loaded."); +/** + * Fetches the cover of a given sample using the GitHub API. + * @param args.octokit An instance of the Octokit GitHub API client. + * @param args.sample The sample object for which the cover is being fetched. + * @returns The content of the SVG cover file for the sample, or undefined if it could not be fetched. + */ +export async function fetchSampleCover(args: { octokit: Octokit; sample: Sample }): Promise<string | undefined> { + const { sample } = args; + + const svgContent = await fetchFileContent({ + octokit: args.octokit, + fileInfo: { + ...KIE_SAMPLES_REPO, + path: join(SAMPLE_FOLDER, sample.sampleId, sample.definition.cover), + }, + }); + + if (!svgContent) { + console.error(`Could not read sample svg for ${sample.sampleId}`); + return; } - return samples; + return svgContent; } export async function fetchSampleFiles(args: { octokit: Octokit; sampleId: string }): Promise<LocalFile[]> { @@ -209,7 +233,9 @@ export async function fetchSampleFiles(args: { octokit: Octokit; sampleId: strin } const sampleFiles = sampleFolderFiles - .filter((file) => file.name !== SAMPLE_DEFINITION_FILE && extname(file.name) !== SVG_EXTENSION) + .filter( + (file) => file.name !== SAMPLE_DEFINITION_FILE && extname(file.name) !== SVG_EXTENSION && file.type === "file" + ) .map(async (file) => { const fileContent = await fetchFileContent({ octokit: args.octokit, --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
