diff --git a/packages/manager/src/constants/API_ENDPOINTS.ts b/packages/manager/src/constants/API_ENDPOINTS.ts index a1a6edf0fd..1a3a81e6be 100644 --- a/packages/manager/src/constants/API_ENDPOINTS.ts +++ b/packages/manager/src/constants/API_ENDPOINTS.ts @@ -13,6 +13,7 @@ export type APIEndpoints = { RepositoryService: string; LocaleService: string; CustomTypeService: string; + GitService: string; }; export const API_ENDPOINTS: APIEndpoints = (() => { @@ -47,6 +48,9 @@ export const API_ENDPOINTS: APIEndpoints = (() => { process.env.custom_type_api ?? "https://api.internal.wroom.io/custom-type/", ), + GitService: addTrailingSlash( + process.env.git_service_api ?? "https://api.internal.wroom.io/git/", + ), }; const missingAPIEndpoints = Object.keys(apiEndpoints).filter((key) => { @@ -96,6 +100,7 @@ If you didn't intend to run Slice Machine this way, stop it immediately and unse RepositoryService: "https://api.internal.wroom.io/repository/", LocaleService: "https://api.internal.wroom.io/locale/", CustomTypeService: "https://api.internal.wroom.io/custom-type/", + GitService: "https://api.internal.wroom.io/git/", }; } @@ -114,6 +119,7 @@ If you didn't intend to run Slice Machine this way, stop it immediately and unse RepositoryService: `https://api.internal.${process.env.SM_ENV}-wroom.com/repository/`, LocaleService: `https://api.internal.${process.env.SM_ENV}-wroom.com/locale/`, CustomTypeService: `https://api.internal.${process.env.SM_ENV}-wroom.com/custom-type/`, + GitService: `https://api.internal.${process.env.SM_ENV}-wroom.com/git/`, }; } @@ -131,6 +137,7 @@ If you didn't intend to run Slice Machine this way, stop it immediately and unse RepositoryService: "https://api.internal.prismic.io/repository/", LocaleService: "https://api.internal.prismic.io/locale/", CustomTypeService: "https://api.internal.prismic.io/custom-type/", + GitService: "https://api.internal.prismic.io/git/", }; } } diff --git a/packages/manager/src/managers/prismicRepository/PrismicRepositoryManager.ts b/packages/manager/src/managers/prismicRepository/PrismicRepositoryManager.ts index 38c19ec3f6..7117bb2105 100644 --- a/packages/manager/src/managers/prismicRepository/PrismicRepositoryManager.ts +++ b/packages/manager/src/managers/prismicRepository/PrismicRepositoryManager.ts @@ -83,6 +83,34 @@ type PrismicRepositoryManagerFetchEnvironmentsReturnType = { environments?: Environment[]; }; +const GitIntegrationsSchema = z.object({ + integrations: z.array( + z.discriminatedUnion("status", [ + z.object({ + id: z.string(), + status: z.literal("connected"), + owner: z.string(), + repositories: z.array( + z.object({ + name: z.string(), + fullName: z.string(), + }), + ), + }), + z.object({ + id: z.string(), + status: z.literal("broken"), + owner: z.string().optional(), + repositories: z.tuple([]), + }), + ]), + ), +}); + +const GitIntegrationTokenSchema = z.object({ + token: z.string(), +}); + export class PrismicRepositoryManager extends BaseManager { // TODO: Add methods for repository-specific actions. E.g. creating a // new repository. @@ -705,6 +733,68 @@ export class PrismicRepositoryManager extends BaseManager { } } + async fetchGitIntegrations(): Promise> { + const repositoryName = await this.project.getRepositoryName(); + + const url = new URL("integrations", API_ENDPOINTS.GitService); + url.searchParams.set("repository", repositoryName); + + const res = await this._fetch({ url }); + + if (!res.ok) { + const text = await res.text(); + throw new Error( + `Failed to fetch integrations for repository ${repositoryName}`, + { cause: text }, + ); + } + + const json = await res.json(); + const { value, error } = decode(GitIntegrationsSchema, json); + + if (error) { + throw new UnexpectedDataError( + `Failed to decode integrations: ${error.errors.join(", ")}`, + ); + } + + return value; + } + + async fetchGitIntegrationToken(args: { + integrationId: string; + }): Promise> { + const { integrationId } = args; + const repositoryName = await this.project.getRepositoryName(); + + const url = new URL( + `integrations/${integrationId}/token`, + API_ENDPOINTS.GitService, + ); + url.searchParams.set("repository", repositoryName); + + const res = await this._fetch({ url, method: "POST" }); + + if (!res.ok) { + const text = await res.text(); + throw new Error( + `Failed to fetch token for integration ${integrationId}`, + { cause: text }, + ); + } + + const json = await res.json(); + const { value, error } = decode(GitIntegrationTokenSchema, json); + + if (error) { + throw new UnexpectedDataError( + `Failed to decode integration token: ${error.errors.join(", ")}`, + ); + } + + return value; + } + private _decodeLimitOrThrow( potentialLimit: unknown, statusCode: number, diff --git a/packages/manager/src/managers/slices/SlicesManager.ts b/packages/manager/src/managers/slices/SlicesManager.ts index 914fd7defc..80575375e6 100644 --- a/packages/manager/src/managers/slices/SlicesManager.ts +++ b/packages/manager/src/managers/slices/SlicesManager.ts @@ -20,6 +20,8 @@ import { SliceRenameHookData, SliceUpdateHook, } from "@slicemachine/plugin-kit"; +import { writeSliceFile } from "@slicemachine/plugin-kit/fs"; +import pLimit from "p-limit"; import { DecodeError } from "../../lib/DecodeError"; import { assertPluginsInitialized } from "../../lib/assertPluginsInitialized"; @@ -98,6 +100,22 @@ type SliceMachineManagerReadSliceScreenshotArgs = { variationID: string; }; +type SliceFile = { + path: string; + contents: string | Buffer; // String for plain text files, Buffer for binary files + isBinary: boolean; +}; + +type SliceMachineManagerWriteSliceFilesArgs = { + libraryID: string; + sliceID: string; + files: SliceFile[]; +}; + +type SliceMachineManagerWriteSliceFilesReturnType = { + errors: (HookError | DecodeError)[]; +}; + type SliceMachineManagerReadSliceScreenshotReturnType = { data: Buffer | undefined; errors: (DecodeError | HookError)[]; @@ -1126,4 +1144,78 @@ export class SlicesManager extends BaseManager { return { errors: customTypeReadErrors }; } + + async writeSliceFiles( + args: SliceMachineManagerWriteSliceFilesArgs, + ): Promise { + assertPluginsInitialized(this.sliceMachinePluginRunner); + + const { libraryID, sliceID, files } = args; + + // Read the slice model to get helpers + const { model, errors: readSliceErrors } = await this.readSlice({ + libraryID, + sliceID, + }); + + if (!model) { + return { + errors: readSliceErrors, + }; + } + + // Write each file using writeSliceFile from plugin-kit with bounded concurrency + const errors: HookError[] = []; + const helpers = this.sliceMachinePluginRunner.rawHelpers; + const limit = pLimit(8); + await Promise.all( + files.map((file) => + limit(async () => { + try { + const writtenPath = await writeSliceFile({ + libraryID, + model, + filename: file.path, + contents: file.contents, + helpers, + }); + console.info( + `Successfully wrote file: ${file.path} -> ${writtenPath}`, + ); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : `Failed to write file ${file.path}`; + + const errorStack = error instanceof Error ? error.stack : undefined; + console.error(`Error writing file ${file.path}:`, errorMessage); + if (errorStack) { + console.error(`Stack trace:`, errorStack); + } + + const owner = + this.sliceMachinePluginRunner?.hooksForType( + "slice:asset:update", + )[0]?.meta.owner ?? "SlicesManager"; + + errors.push( + new HookError( + { + id: "writeSliceFile", + type: "slice:asset:update", + owner, + }, + error, + ), + ); + } + }), + ), + ); + + return { + errors, + }; + } } diff --git a/packages/manager/src/managers/telemetry/types.ts b/packages/manager/src/managers/telemetry/types.ts index f1ffcca058..a44ca4979c 100644 --- a/packages/manager/src/managers/telemetry/types.ts +++ b/packages/manager/src/managers/telemetry/types.ts @@ -55,6 +55,13 @@ export const SegmentEventType = { mcp_promo_link_clicked: "mcp:promo-link-clicked", info_banner_dismissed: "info-banner:dismissed", info_banner_button_clicked: "info-banner:button-clicked", + slice_library_opened: "slice-library:opened", + slice_library_projects_listed: "slice-library:projects-listed", + slice_library_slice_selected: "slice-library:slice-selected", + slice_library_fetching_started: "slice-library:fetching-started", + slice_library_fetching_ended: "slice-library:fetching-ended", + slice_library_import_started: "slice-library:import-started", + slice_library_import_ended: "slice-library:import-ended", } as const; type SegmentEventTypes = (typeof SegmentEventType)[keyof typeof SegmentEventType]; @@ -127,6 +134,20 @@ export const HumanSegmentEventType = { "SliceMachine Info Banner Dismissed", [SegmentEventType.info_banner_button_clicked]: "SliceMachine Info Banner Button Clicked", + [SegmentEventType.slice_library_opened]: + "SliceMachine Slice Library - Opened", + [SegmentEventType.slice_library_projects_listed]: + "SliceMachine Slice Library - Projects Listed", + [SegmentEventType.slice_library_slice_selected]: + "SliceMachine Slice Library - Slice Selected", + [SegmentEventType.slice_library_fetching_started]: + "SliceMachine Slice Library - Slice Fetching Started", + [SegmentEventType.slice_library_fetching_ended]: + "SliceMachine Slice Library - Slice Fetching Ended", + [SegmentEventType.slice_library_import_started]: + "SliceMachine Slice Library - Slice Import Started", + [SegmentEventType.slice_library_import_ended]: + "SliceMachine Slice Library - Slice Import Ended", } as const; export type HumanSegmentEventTypes = @@ -301,6 +322,7 @@ type SliceCreatedSegmentEvent = SegmentEvent< | { mode: "figma-to-slice" } | { mode: "manual" } | { mode: "template"; sliceTemplate: string } + | { mode: "import" } ) >; @@ -505,6 +527,64 @@ type InfoBannerButtonClicked = SegmentEvent< } >; +type SliceLibraryOpened = SegmentEvent< + typeof SegmentEventType.slice_library_opened +>; +type SliceLibraryProjectsListed = SegmentEvent< + typeof SegmentEventType.slice_library_projects_listed, + { + repositories_count: number; + } +>; +type SliceLibrarySliceSelected = SegmentEvent< + typeof SegmentEventType.slice_library_slice_selected, + { + slices_count: number; + source_project_id: string; + destination_project_id: string; + } +>; +type SliceLibraryFetchingStarted = SegmentEvent< + typeof SegmentEventType.slice_library_fetching_started, + { + source_project_id: string; + } +>; +type SliceLibraryFetchingEnded = SegmentEvent< + typeof SegmentEventType.slice_library_fetching_ended, + | { + error: false; + slices_count: number; + source_project_id: string; + } + | { + error: true; + slices_count?: never; + source_project_id: string; + } +>; +type SliceLibraryImportStarted = SegmentEvent< + typeof SegmentEventType.slice_library_import_started, + { + source_project_id: string; + } +>; +type SliceLibraryImportEnded = SegmentEvent< + typeof SegmentEventType.slice_library_import_ended, + | { + error: false; + slices_count: number; + source_project_id: string; + destination_project_id: string; + } + | { + error: true; + slices_count?: never; + source_project_id: string; + destination_project_id: string; + } +>; + export type SegmentEvents = | CommandInitStartSegmentEvent | CommandInitIdentifySegmentEvent @@ -550,4 +630,11 @@ export type SegmentEvents = | SidebarLinkClicked | McpPromoLinkClicked | InfoBannerDismissed - | InfoBannerButtonClicked; + | InfoBannerButtonClicked + | SliceLibraryOpened + | SliceLibraryProjectsListed + | SliceLibrarySliceSelected + | SliceLibraryFetchingStarted + | SliceLibraryFetchingEnded + | SliceLibraryImportStarted + | SliceLibraryImportEnded; diff --git a/packages/manager/test/SliceMachineManager-getState.test.ts b/packages/manager/test/SliceMachineManager-getState.test.ts index d3a313a820..5dad123dad 100644 --- a/packages/manager/test/SliceMachineManager-getState.test.ts +++ b/packages/manager/test/SliceMachineManager-getState.test.ts @@ -28,6 +28,7 @@ it("returns global Slice Machine state", async () => { RepositoryService: "https://api.internal.prismic.io/repository/", LocaleService: "https://api.internal.prismic.io/locale/", CustomTypeService: "https://api.internal.prismic.io/custom-type/", + GitService: "https://api.internal.prismic.io/git/", }); expect(result.clientError).toStrictEqual({ name: new UnauthenticatedError().name, diff --git a/packages/slice-machine/package.json b/packages/slice-machine/package.json index 9b0908cd1d..bd2081f7b2 100644 --- a/packages/slice-machine/package.json +++ b/packages/slice-machine/package.json @@ -42,9 +42,9 @@ "@emotion/react": "11.11.1", "@extractus/oembed-extractor": "3.1.8", "@prismicio/client": "7.17.0", - "@prismicio/editor-fields": "0.4.88", - "@prismicio/editor-support": "0.4.88", - "@prismicio/editor-ui": "0.4.88", + "@prismicio/editor-fields": "0.4.90", + "@prismicio/editor-support": "0.4.90", + "@prismicio/editor-ui": "0.4.90", "@prismicio/mock": "0.7.1", "@prismicio/mocks": "2.14.0", "@prismicio/simulator": "0.1.4", diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/DialogButtons.tsx b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/DialogButtons.tsx new file mode 100644 index 0000000000..558cf9ebcf --- /dev/null +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/DialogButtons.tsx @@ -0,0 +1,42 @@ +import { + DialogActionButton, + DialogActions, + DialogCancelButton, + Skeleton, +} from "@prismicio/editor-ui"; + +import { CommonDialogProps } from "./types"; + +type DialogButtonsProps = { + totalSelected: number; + isSubmitting?: boolean; + onSubmit: () => void; + typeName: CommonDialogProps["typeName"]; +}; + +export function DialogButtonsSkeleton() { + return ( + + + + + ); +} + +export function DialogButtons(props: DialogButtonsProps) { + const { totalSelected, onSubmit, isSubmitting, typeName } = props; + + return ( + + + + Add to {typeName} ({totalSelected}) + + + ); +} diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/DialogContent.tsx b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/DialogContent.tsx new file mode 100644 index 0000000000..1e75e6422c --- /dev/null +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/DialogContent.tsx @@ -0,0 +1,25 @@ +import { Box } from "@prismicio/editor-ui"; +import { ReactNode } from "react"; + +interface DialogContentProps { + children: ReactNode; + selected: boolean; +} + +export function DialogContent(args: DialogContentProps) { + const { children, selected } = args; + + if (!selected) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/DialogTabs.tsx b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/DialogTabs.tsx new file mode 100644 index 0000000000..694da303c4 --- /dev/null +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/DialogTabs.tsx @@ -0,0 +1,39 @@ +import { Box, Tab } from "@prismicio/editor-ui"; +import { ReactNode } from "react"; + +import { DialogTab } from "./types"; + +interface DialogTabsProps { + selectedTab: DialogTab; + onSelectTab: (tab: DialogTab) => void; + rightContent?: ReactNode; +} + +export function DialogTabs(props: DialogTabsProps) { + const { selectedTab, onSelectTab, rightContent } = props; + + return ( + + + onSelectTab("local")} + > + Local Slices + + onSelectTab("library")} + > + Library Slices + + + {rightContent} + + ); +} diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/EmptyView.tsx b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/EmptyView.tsx new file mode 100644 index 0000000000..cd13c43b56 --- /dev/null +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/EmptyView.tsx @@ -0,0 +1,61 @@ +import { + BlankSlate, + BlankSlateActions, + BlankSlateDescription, + BlankSlateIcon, + BlankSlateTitle, + Box, + ThemeKeys, +} from "@prismicio/editor-ui"; +import { ReactNode } from "react"; + +type EmptyViewProps = { + title: string; + description?: string; + icon: "github" | "alert" | "logout" | "viewDay"; + color?: "purple" | "tomato"; + actions?: ReactNode; +}; + +export function EmptyView(props: EmptyViewProps) { + const { title, description, icon, actions, color = "purple" } = props; + + let iconColor: ThemeKeys<"color">; + let iconBackgroundColor: ThemeKeys<"color">; + + switch (color) { + case "purple": + iconColor = "purple11"; + iconBackgroundColor = "purple3"; + break; + case "tomato": + iconColor = "tomato11"; + iconBackgroundColor = "tomato3"; + break; + } + + return ( + + + + {title} + {description !== undefined && ( + {description} + )} + {actions !== undefined && ( + {actions} + )} + + + ); +} diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/ImportSlicesFromLibraryModal.tsx b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/ImportSlicesFromLibraryModal.tsx new file mode 100644 index 0000000000..5f127572da --- /dev/null +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/ImportSlicesFromLibraryModal.tsx @@ -0,0 +1,72 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, +} from "@prismicio/editor-ui"; +import { SharedSlice } from "@prismicio/types-internal/lib/customtypes"; +import { useEffect, useState } from "react"; + +import { LibrarySlicesDialogContent } from "./LibrarySlicesDialogContent"; +import { LocalSlicesDialogContent } from "./LocalSlicesDialogContent"; +import { CommonDialogProps, DialogTab } from "./types"; + +type ImportSlicesFromLibraryModalProps = CommonDialogProps & { + open: boolean; + localSlices: (SharedSlice & { thumbnailUrl?: string })[]; + isEveryLocalSliceAdded: boolean; + onSuccess: (args: { + slices: { model: SharedSlice; langSmithUrl?: string }[]; + library?: string; + }) => void; +}; + +export function ImportSlicesFromLibraryModal( + props: ImportSlicesFromLibraryModalProps, +) { + const { + open, + localSlices, + isEveryLocalSliceAdded, + onClose, + ...contentProps + } = props; + + const [selectedTab, setSelectedTab] = useState("local"); + + useEffect(() => { + if (!open) { + // wait for the modal fade animation + const timeout = setTimeout(() => { + setSelectedTab("local"); + }, 250); + + return () => clearTimeout(timeout); + } + }, [open]); + + return ( + !open && onClose()}> + + + + + + + + ); +} diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/LibrarySlicesDialogContent.tsx b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/LibrarySlicesDialogContent.tsx new file mode 100644 index 0000000000..11a1d44be2 --- /dev/null +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/LibrarySlicesDialogContent.tsx @@ -0,0 +1,589 @@ +import { useDebounce } from "@prismicio/editor-support/React"; +import { + Box, + Button, + Checkbox, + ComboBox, + ComboboxAction, + ComboBoxContent, + ComboBoxInput, + ComboBoxItem, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + ErrorBoundary, + Icon, + InlineLabel, + ScrollArea, + Skeleton, + Text, +} from "@prismicio/editor-ui"; +import { SharedSlice } from "@prismicio/types-internal/lib/customtypes"; +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { ReactNode, Suspense, useEffect, useMemo, useState } from "react"; +import { toast } from "react-toastify"; + +import { getState, telemetry } from "@/apiClient"; +import { useOnboarding } from "@/features/onboarding/useOnboarding"; +import { useAutoSync } from "@/features/sync/AutoSyncProvider"; +import { useRepositoryInformation } from "@/hooks/useRepositoryInformation"; +import { managerClient } from "@/managerClient"; +import useSliceMachineActions from "@/modules/useSliceMachineActions"; + +import { DialogButtons, DialogButtonsSkeleton } from "./DialogButtons"; +import { DialogContent } from "./DialogContent"; +import { DialogTabs } from "./DialogTabs"; +import { EmptyView } from "./EmptyView"; +import { useGitIntegration } from "./hooks/useGitIntegration"; +import { SliceCard } from "./SliceCard"; +import { + CommonDialogContentProps, + GitIntegration, + NewSlice, + RepositorySelection, + SliceImport, +} from "./types"; +import { addSlices } from "./utils/addSlices"; +import { sliceWithoutConflicts } from "./utils/sliceWithoutConflicts"; + +interface LibrarySlicesDialogContentProps extends CommonDialogContentProps { + onSuccess: (args: { + slices: { model: SharedSlice; langSmithUrl?: string }[]; + library?: string; + }) => void; +} + +function LibrarySlicesDialogSuspenseContent( + props: LibrarySlicesDialogContentProps, +) { + const { + location, + typeName, + onSelectTab, + onSuccess, + selected: isTabSelected, + } = props; + + const [isSubmitting, setIsSubmitting] = useState(false); + const [selectedSlices, setSelectedSlices] = useState([]); + const [selectedRepository, setSelectedRepository] = + useState(); + + const { + integrations, + isImportingSlices, + fetchSlicesFromGithub, + importedSlices, + resetImportedSlices, + } = useGitIntegration(); + + const smActions = useSliceMachineActions(); + const { syncChanges } = useAutoSync(); + const { completeStep: completeOnboardingStep } = useOnboarding(); + const prismicRepositoryInformation = useRepositoryInformation(); + + useEffect(() => { + if (isTabSelected) { + void telemetry.track({ event: "slice-library:opened" }); + } + }, [isTabSelected]); + + useDebounce(selectedSlices, 1000, () => { + if (selectedRepository && selectedSlices.length > 0) { + void telemetry.track({ + event: "slice-library:slice-selected", + slices_count: selectedSlices.length, + source_project_id: selectedRepository.fullName, + destination_project_id: prismicRepositoryInformation.repositoryName, + }); + } + }); + + const onSelectRepository = (repository: RepositorySelection) => { + setSelectedRepository(repository); + setSelectedSlices([]); + void fetchSlicesFromGithub({ repository }); + }; + + const onSelectAll = (checked: boolean) => { + setSelectedSlices(checked ? importedSlices : []); + }; + + const onSelect = (slice: SliceImport) => { + setSelectedSlices((prev) => { + const isSelected = prev.some((s) => s.model.id === slice.model.id); + if (isSelected) return prev.filter((s) => s.model.id !== slice.model.id); + return [...prev, slice]; + }); + }; + + const importSelectedSlices = async () => { + if (selectedSlices.length === 0) { + toast.error("Please select at least one slice"); + return; + } + if (!selectedRepository) { + toast.error("Please select a repository"); + return; + } + + try { + setIsSubmitting(true); + void telemetry.track({ + event: "slice-library:import-started", + source_project_id: selectedRepository.fullName, + }); + + // Prepare library slices for import + const librarySlicesToImport: NewSlice[] = selectedSlices.map((slice) => ({ + image: slice.image, + model: slice.model, + files: slice.files, + componentContents: slice.componentContents, + mocks: slice.mocks, + screenshots: slice.screenshots, + })); + + // Ensure ids and names are conflict-free against existing and newly-added slices + const conflictFreeSlices: NewSlice[] = []; + + const existingSlices = await managerClient.slices + .readAllSlices() + .then((slices) => slices.models.map(({ model }) => model)); + + for (const sliceToImport of librarySlicesToImport) { + const adjustedModel = sliceWithoutConflicts({ + existingSlices: existingSlices, + newSlices: conflictFreeSlices, + slice: sliceToImport.model, + }); + + conflictFreeSlices.push({ ...sliceToImport, model: adjustedModel }); + } + + const { slices: createdSlices, library } = + await addSlices(conflictFreeSlices); + + // Wait a moment to ensure all file writes are complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + const serverState = await getState(); + smActions.createSliceSuccess(serverState.libraries); + + // Also update mocks individually to ensure they're in the store + for (const slice of conflictFreeSlices) { + if ( + slice.mocks && + Array.isArray(slice.mocks) && + slice.mocks.length > 0 + ) { + smActions.updateSliceMockSuccess({ + libraryID: library, + sliceID: slice.model.id, + mocks: slice.mocks, + }); + } + } + + syncChanges(); + + setIsSubmitting(false); + resetImportedSlices(); + + void completeOnboardingStep("createSlice"); + + for (const { model } of createdSlices) { + void telemetry.track({ + event: "slice:created", + id: model.id, + name: model.name, + library, + location, + mode: "import", + }); + } + + void telemetry.track({ + event: "slice-library:import-ended", + error: false, + slices_count: createdSlices.length, + source_project_id: selectedRepository.fullName, + destination_project_id: prismicRepositoryInformation.repositoryName, + }); + + onSuccess({ slices: createdSlices, library }); + } catch (error) { + setIsSubmitting(false); + toast.error("An unexpected error happened while adding slices."); + + void telemetry.track({ + event: "slice-library:import-ended", + error: true, + source_project_id: selectedRepository.fullName, + destination_project_id: prismicRepositoryInformation.repositoryName, + }); + } + }; + + const configureUrl = new URL( + "builder/settings/git-integration", + prismicRepositoryInformation.repositoryUrl, + ).toString(); + + let renderedContent: ReactNode; + + if (isImportingSlices) { + renderedContent = ; + } else if (integrations.length === 0) { + renderedContent = ( + + + Connect GitHub + + + } + /> + ); + } else if (!selectedRepository) { + renderedContent = ( + + ); + } else if (importedSlices.length === 0) { + renderedContent = ( + + ); + } else { + const allSelected = importedSlices.every((slice) => + selectedSlices.some((s) => s.model.id === slice.model.id), + ); + const someSelected = importedSlices.some((slice) => + selectedSlices.some((s) => s.model.id === slice.model.id), + ); + + let selectAllLabel = "Select all slices"; + if (allSelected) { + selectAllLabel = `Selected all slices (${selectedSlices.length})`; + } else if (someSelected) { + selectAllLabel = `${selectedSlices.length} of ${importedSlices.length} selected`; + } + + renderedContent = ( + <> + + + + + + + + + {importedSlices.map((slice) => ( + s.model.id === slice.model.id, + )} + onSelectedChange={() => onSelect(slice)} + /> + ))} + + + + void importSelectedSlices()} + isSubmitting={isSubmitting} + typeName={typeName} + /> + + ); + } + + return ( + + + } + /> + + {renderedContent} + + + ); +} + +type RepositorySelectorProps = { + integrations: GitIntegration[]; + selectedRepository: RepositorySelection | undefined; + onSelectRepository: (repository: RepositorySelection) => void; + configureUrl: string; + isTabSelected: boolean; +}; + +function RepositorySelector(props: RepositorySelectorProps) { + const { + integrations, + selectedRepository, + onSelectRepository, + configureUrl, + isTabSelected, + } = props; + + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(""); + + const onSelect = (repository: RepositorySelection) => { + onSelectRepository(repository); + setOpen(false); + }; + + const repositories = useMemo(() => { + return integrations.flatMap((integration) => + integration.repositories.map((repository) => ({ + ...repository, + integrationId: integration.id, + })), + ); + }, [integrations]); + + useEffect(() => { + if (isTabSelected && repositories.length > 0) { + void telemetry.track({ + event: "slice-library:projects-listed", + repositories_count: repositories.length, + }); + } + }, [isTabSelected, repositories]); + + const filteredRepositories = repositories.filter((repository) => + repository.fullName.toLowerCase().includes(filter.toLowerCase()), + ); + + return ( + + + + + + + + + {filteredRepositories.length > 0 ? ( + <> + {/* TODO: (DT-3163) Scroll to the selected repository */} + {filteredRepositories.map((repository) => ( + onSelect(repository)} + checked={ + selectedRepository?.fullName === repository.fullName + } + > + + {repository.fullName} + + + ))} + + ) : ( + + + No repositories found + + + )} + + + + + + + + ); +} + +function ComboBoxItemContent(props: { + children: ReactNode; + disabled?: boolean; +}) { + const { children, disabled } = props; + return ( + + + {children} + + + ); +} + +function SlicesLoadingSkeleton() { + return ( + <> + + + + {Array.from({ length: 9 }).map((_, index) => ( + + ))} + + + + + ); +} + +function LibrarySlicesLoggedInContent(props: LibrarySlicesDialogContentProps) { + const { openLoginModal } = useSliceMachineActions(); + const queryClient = useQueryClient(); + const { data: isLoggedIn } = useSuspenseQuery({ + queryKey: ["checkIsLoggedIn"], + queryFn: () => managerClient.user.checkIsLoggedIn(), + gcTime: 0, + staleTime: 0, + }); + + if (!isLoggedIn) { + const onLogin = () => { + props.onClose(); + openLoginModal(); + void queryClient.invalidateQueries({ queryKey: ["checkIsLoggedIn"] }); + }; + + return ( + <> + + + Log in + + } + /> + + ); + } + + return ; +} + +export function LibrarySlicesDialogContent( + props: LibrarySlicesDialogContentProps, +) { + return ( + + ( + <> + + + + )} + > + + } + /> + + + } + > + + + + + ); +} diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/LocalSlicesDialogContent.tsx b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/LocalSlicesDialogContent.tsx new file mode 100644 index 0000000000..169374b1d3 --- /dev/null +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/LocalSlicesDialogContent.tsx @@ -0,0 +1,103 @@ +import { Box, ScrollArea } from "@prismicio/editor-ui"; +import { SharedSlice } from "@prismicio/types-internal/lib/customtypes"; +import { useState } from "react"; +import { toast } from "react-toastify"; + +import { EmptyView } from "@/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/EmptyView"; + +import { DialogButtons } from "./DialogButtons"; +import { DialogContent } from "./DialogContent"; +import { DialogTabs } from "./DialogTabs"; +import { SliceCard } from "./SliceCard"; +import { CommonDialogContentProps } from "./types"; + +interface LocalSlicesDialogContentProps extends CommonDialogContentProps { + slices: (SharedSlice & { thumbnailUrl?: string })[]; + isEveryLocalSliceAdded: boolean; + onSuccess: (args: { slices: { model: SharedSlice }[] }) => void; +} + +export function LocalSlicesDialogContent(props: LocalSlicesDialogContentProps) { + const { + typeName, + onSelectTab, + onSuccess, + slices, + isEveryLocalSliceAdded, + selected, + } = props; + + const [selectedSlices, setSelectedSlices] = useState([]); + + const onSubmit = () => { + if (selectedSlices.length === 0) { + toast.error("Please select at least one slice"); + return; + } + + onSuccess({ slices: selectedSlices.map((s) => ({ model: s })) }); + }; + + const onSelect = (slice: SharedSlice) => { + setSelectedSlices((prev) => { + const isSelected = prev.some((s) => s.id === slice.id); + if (isSelected) return prev.filter((s) => s.id !== slice.id); + return [...prev, slice]; + }); + }; + + return ( + + + + + {slices.length > 0 ? ( + <> + + + {slices.map((slice) => { + const isSelected = selectedSlices.some( + (s) => s.id === slice.id, + ); + + return ( + onSelect(slice)} + /> + ); + })} + + + + + + ) : ( + + )} + + + ); +} diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/SliceCard.tsx b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/SliceCard.tsx new file mode 100644 index 0000000000..6f4da8d3cf --- /dev/null +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/SliceCard.tsx @@ -0,0 +1,67 @@ +import { Box, Checkbox, Text } from "@prismicio/editor-ui"; +import { SharedSlice } from "@prismicio/types-internal/lib/customtypes"; +import { useState } from "react"; + +import { Card, CardFooter, CardMedia } from "@/components/Card"; + +interface SliceCardProps { + thumbnailUrl?: string; + model: SharedSlice; + selected: boolean; + onSelectedChange: (selected: boolean) => void; +} + +export function SliceCard(props: SliceCardProps) { + const { thumbnailUrl, model, selected = true, onSelectedChange } = props; + const [thumbnailError, setThumbnailError] = useState(false); + + const handleClick = () => { + onSelectedChange(!selected); + }; + + const cardContent = ( + <> + {thumbnailUrl !== undefined && thumbnailUrl && !thumbnailError ? ( + setThumbnailError(true)} /> + ) : ( + + + + No screenshot available + + + + )} + 1 + ? `${model.variations.length} variations` + : `1 variation` + } + action={ +
event.stopPropagation()}> + +
+ } + /> + + ); + + return ( + + {cardContent} + + ); +} diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/hooks/useGitIntegration.ts b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/hooks/useGitIntegration.ts new file mode 100644 index 0000000000..82b4fca3ad --- /dev/null +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/hooks/useGitIntegration.ts @@ -0,0 +1,142 @@ +import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "react-toastify"; + +import { telemetry } from "@/apiClient"; +import { managerClient } from "@/managerClient"; + +import { RepositorySelection, SliceImport } from "../types"; +import { + fetchSlicesFromLibraries, + getDefaultBranch, + getSliceLibraries, +} from "../utils/github"; + +export function useGitIntegration() { + const [isImportingSlices, setIsImportingSlices] = useState(false); + const [importedSlices, setImportedSlices] = useState([]); + + const { data: githubIntegrations } = useSuspenseQuery({ + queryKey: ["getIntegrations"], + queryFn: () => managerClient.prismicRepository.fetchGitIntegrations(), + }); + const { mutateAsync: fetchGitHubToken } = useMutation({ + mutationFn: (args: { integrationId: string }) => { + return managerClient.prismicRepository.fetchGitIntegrationToken({ + integrationId: args.integrationId, + }); + }, + }); + + const resetImportedSlices = () => { + setImportedSlices([]); + setIsImportingSlices(false); + }; + + const fetchSlicesFromGithub = async (args: { + repository: RepositorySelection; + }) => { + const { repository } = args; + + try { + resetImportedSlices(); + setIsImportingSlices(true); + + void telemetry.track({ + event: "slice-library:fetching-started", + source_project_id: repository.fullName, + }); + + const { token } = await fetchGitHubToken({ + integrationId: repository.integrationId, + }); + + const [owner, repo] = args.repository.fullName.split("/"); + + if (!owner || !repo) { + throw new GitHubImportError("Invalid GitHub URL format"); + } + + const branch = await getDefaultBranch({ owner, repo, token }); + + let libraries: string[] | undefined; + + try { + libraries = await getSliceLibraries({ owner, repo, branch, token }); + } catch (error) { + throw new GitHubImportError(` + Failed to fetch slicemachine.config.json: ${ + error instanceof Error ? error.message : "Unknown error" + } + `); + } + + if (libraries.length === 0) { + throw new GitHubImportError( + "No libraries were found in the SM config.", + ); + } + + const fetchedSlices = await fetchSlicesFromLibraries({ + owner, + repo, + branch, + libraries, + token, + }); + + if (fetchedSlices.length === 0) { + throw new GitHubImportError("No slices were found in the libraries."); + } + + setImportedSlices(fetchedSlices); + toast.success( + `Found ${fetchedSlices.length} slice(s) from ${libraries.length} library/libraries`, + ); + + void telemetry.track({ + event: "slice-library:fetching-ended", + error: false, + slices_count: fetchedSlices.length, + source_project_id: repository.fullName, + }); + + return fetchedSlices; + } catch (error) { + if (error instanceof GitHubImportError) { + toast.error(error.message); + } else { + toast.error( + `Failed to import from GitHub: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + } + + void telemetry.track({ + event: "slice-library:fetching-ended", + error: true, + source_project_id: repository.fullName, + }); + + return []; + } finally { + setIsImportingSlices(false); + } + }; + + return { + integrations: githubIntegrations.integrations ?? [], + isImportingSlices, + importedSlices, + resetImportedSlices, + fetchSlicesFromGithub, + }; +} + +class GitHubImportError extends Error { + constructor(message: string) { + super(message); + this.name = "GitHubImportError"; + } +} diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/index.tsx b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/index.tsx new file mode 100644 index 0000000000..f5bf87e665 --- /dev/null +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/index.tsx @@ -0,0 +1 @@ +export { ImportSlicesFromLibraryModal } from "./ImportSlicesFromLibraryModal"; diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/types.ts b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/types.ts new file mode 100644 index 0000000000..61ec8475b3 --- /dev/null +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/types.ts @@ -0,0 +1,55 @@ +import { SharedSliceContent } from "@prismicio/types-internal/lib/content"; +import { SharedSlice } from "@prismicio/types-internal/lib/customtypes"; + +export type SliceImport = { + image: File; + model: SharedSlice; + thumbnailUrl?: string; + files?: SliceFile[]; + componentContents?: string; + mocks?: SharedSliceContent[]; + screenshots?: Record; +}; + +export type SliceFile = { + path: string; + contents: string | File; + isBinary: boolean; +}; + +export type NewSlice = { + image: File; + model: SharedSlice; + langSmithUrl?: string; + files?: SliceFile[]; + componentContents?: string; + mocks?: SharedSliceContent[]; + screenshots?: Record; +}; + +export type DialogTab = "local" | "library"; + +export type CommonDialogProps = { + location: "custom_type" | "page_type"; + typeName: string; + onClose: () => void; +}; + +export type CommonDialogContentProps = CommonDialogProps & { + onSelectTab: (tab: DialogTab) => void; + selected: boolean; +}; + +export type GitIntegration = { + id: string; + repositories: GitHubRepository[]; +}; + +export type GitHubRepository = { + name: string; + fullName: string; +}; + +export type RepositorySelection = { + integrationId: string; +} & GitHubRepository; diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/utils/addSlices.ts b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/utils/addSlices.ts new file mode 100644 index 0000000000..833c103bcf --- /dev/null +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/utils/addSlices.ts @@ -0,0 +1,186 @@ +import { managerClient } from "@/managerClient"; + +import { NewSlice, SliceFile } from "../types"; +import { mapWithConcurrency } from "./mapWithConcurrency"; + +export async function addSlices(newSlices: NewSlice[]) { + // use the first library + const { libraries = [] } = + await managerClient.project.getSliceMachineConfig(); + const library = libraries[0]; + if (!library) { + throw new Error("No library found in the config."); + } + + // Create slices with bounded concurrency + await mapWithConcurrency(newSlices, 3, async (slice: NewSlice) => { + const { errors } = await managerClient.slices.createSlice({ + libraryID: library, + model: slice.model, + componentContents: slice.componentContents, + }); + if (errors.length) { + throw new Error(`Failed to create slice ${slice.model.id}.`); + } + }); + + // Update mocks and screenshots, and write additional files + const slices = await mapWithConcurrency( + newSlices, + 3, + async (slice: NewSlice) => { + const { model, image, langSmithUrl, mocks, files, screenshots } = slice; + + // Update mocks if available + if (mocks && Array.isArray(mocks) && mocks.length > 0) { + const { errors: mocksErrors } = + await managerClient.slices.updateSliceMocks({ + libraryID: library, + sliceID: model.id, + mocks, + }); + if (mocksErrors.length) { + console.warn( + `Failed to update mocks for slice ${model.id}:`, + mocksErrors, + ); + } + } + + // Update screenshots for all variations + if (screenshots && Object.keys(screenshots).length > 0) { + await Promise.all( + Object.entries(screenshots).map( + async ([variationID, screenshotFile]) => { + if (screenshotFile.size > 0) { + await managerClient.slices.updateSliceScreenshot({ + libraryID: library, + sliceID: model.id, + variationID, + data: screenshotFile, + }); + } + }, + ), + ); + } else if ( + image.size > 0 && + model.variations !== undefined && + model.variations.length > 0 + ) { + // Fallback to using the first image if no screenshots were provided + await managerClient.slices.updateSliceScreenshot({ + libraryID: library, + sliceID: model.id, + variationID: model.variations[0].id, + data: image, + }); + } + + // Write additional files (CSS, other assets, etc.) + console.log( + `About to write files for slice ${model.id}:`, + files + ? `${files.length} file(s) - ${files + .map((f: SliceFile) => f.path) + .join(", ")}` + : "no files", + ); + if (files && files.length > 0) { + await writeSliceFiles({ + libraryID: library, + sliceID: model.id, + files, + }); + } else { + console.warn(`No files to write for slice ${model.id}. Files:`, files); + } + + return { model, langSmithUrl }; + }, + ); + + return { library, slices }; +} + +/** + * Writes additional slice files to the filesystem using the manager's plugin runner + * Uses the manager client's RPC interface to write files + */ +async function writeSliceFiles(args: { + libraryID: string; + sliceID: string; + files: SliceFile[]; +}): Promise { + const { libraryID, sliceID, files } = args; + + if (files.length === 0) { + return; + } + + // Filter out files that are already handled by other methods + // Note: We still write ALL files, but skip ones that are handled by createSlice/updateSliceMocks/updateSliceScreenshot + // This includes component files in subdirectories, CSS files, assets, etc. + const filesToWrite = files.filter( + (file) => + // Skip mocks.json (handled by updateSliceMocks) + file.path !== "mocks.json" && + // Skip screenshots (handled by updateSliceScreenshot) + !file.path.startsWith("screenshot-") && + // Skip the main index component file (handled by createSlice with componentContents) + // But allow component files in subdirectories (e.g., components/Button.tsx) + !file.path.match(/^index\.(tsx?|jsx?|vue|svelte)$/), + ); + + console.log( + `Writing ${filesToWrite.length} additional file(s) for slice ${sliceID}:`, + filesToWrite.map((f) => f.path), + ); + + if (filesToWrite.length === 0) { + return; + } + + try { + const filesForRPC = filesToWrite.map((file) => { + if (file.isBinary) { + return { + path: file.path, + contents: file.contents, + isBinary: true, + }; + } else if (typeof file.contents === "string") { + return { + path: file.path, + contents: file.contents, + isBinary: false, + }; + } else { + throw new Error(`Unexpected file contents type for ${file.path}`); + } + }); + + const result = await managerClient.slices.writeSliceFiles({ + libraryID, + sliceID, + files: filesForRPC, + }); + + if (result.errors.length > 0) { + console.error( + `Errors writing files for slice ${sliceID}:`, + result.errors.map((e: { message?: string }) => e.message ?? String(e)), + ); + } else { + console.log( + `Successfully wrote ${filesToWrite.length} file(s) for slice ${sliceID}`, + ); + } + } catch (error) { + console.error( + `Error writing files for slice ${sliceID}:`, + error instanceof Error ? error.message : String(error), + ); + // Don't throw - allow slice creation to succeed even if some files fail + } +} diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/utils/github.ts b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/utils/github.ts new file mode 100644 index 0000000000..83ca56e926 --- /dev/null +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/utils/github.ts @@ -0,0 +1,666 @@ +import { SharedSliceContent } from "@prismicio/types-internal/lib/content"; +import { SharedSlice } from "@prismicio/types-internal/lib/customtypes"; +import { z } from "zod"; + +import { SliceFile, SliceImport } from "../types"; +import { mapWithConcurrency } from "./mapWithConcurrency"; + +class GitHubRepositoryAPI { + private readonly owner: string; + private readonly repo: string; + private readonly token?: string; + private readonly baseUrl = "https://api.github.com"; + + constructor(args: { owner: string; repo: string; token?: string }) { + this.owner = args.owner; + this.repo = args.repo; + this.token = args.token; + } + + private getHeaders(): HeadersInit { + const headers: HeadersInit = { + Accept: "application/vnd.github.v3+json", + }; + if (this.token !== undefined) { + headers.Authorization = `Bearer ${this.token}`; + } + return headers; + } + + private async request( + endpoint: string, + options?: RequestInit, + ): Promise { + const url = `${this.baseUrl}${endpoint}`; + const response = await fetch(url, { + ...options, + headers: { ...this.getHeaders(), ...options?.headers }, + }); + + if (!response.ok) { + throw new Error( + `GitHub API request failed: ${response.status} ${response.statusText}`, + ); + } + + return response.json() as Promise; + } + + async getDefaultBranch() { + const data = await this.request(`/repos/${this.owner}/${this.repo}`); + return z.object({ default_branch: z.string() }).parse(data).default_branch; + } + + async getFileContents( + path: string, + branch: string, + isBinary = false, + ): Promise { + const data = await this.request( + `/repos/${this.owner}/${this.repo}/contents/${path}?ref=${branch}`, + ); + const parsed = z + .object({ content: z.string(), encoding: z.string() }) + .parse(data); + + if (parsed.encoding !== "base64") { + throw new Error(`Unexpected encoding for ${path}: ${parsed.encoding}`); + } + + // Decode base64 content + const base64Content = parsed.content.replace(/\s/g, ""); + const binaryString = atob(base64Content); + + if (isBinary) { + // Convert to ArrayBuffer + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; + } else { + // Return as string + return binaryString; + } + } + + async getDirectoryContents(path: string) { + const contents = await this.request( + `/repos/${this.owner}/${this.repo}/contents/${path}`, + ); + return z + .array(z.object({ name: z.string(), type: z.string(), path: z.string() })) + .parse(contents); + } + + async searchCode(args: { path?: string; filename?: string }) { + const { path, filename } = args; + + const query: string[] = []; + if (path !== undefined) query.push(`path:${path}`); + if (filename !== undefined) query.push(`filename:${filename}`); + query.push(`repo:${this.owner}/${this.repo}`); + + const searchUrl = `/search/code?q=${encodeURIComponent(query.join(" "))}`; + const data = await this.request(searchUrl); + + return z + .object({ + items: z + .array(z.object({ path: z.string(), name: z.string() })) + .optional(), + total_count: z.number().optional(), + }) + .parse(data); + } + + async getSliceLibraries(branch: string) { + const data = await this.request( + `/repos/${this.owner}/${this.repo}/contents/slicemachine.config.json?ref=${branch}`, + ); + const parsed = z + .object({ + content: z.string().optional(), + encoding: z.string().optional(), + }) + .parse(data); + + if (typeof parsed.content === "string") { + // GitHub API returns base64-encoded content + const decodedContent = atob(parsed.content.replace(/\s/g, "")); + + return z + .object({ libraries: z.array(z.string()) }) + .parse(JSON.parse(decodedContent)).libraries; + } else { + throw new Error("No content found in slicemachine.config.json"); + } + } +} + +export const getDefaultBranch = async ({ + owner, + repo, + token, +}: { + owner: string; + repo: string; + token: string; +}): Promise => { + const github = new GitHubRepositoryAPI({ owner, repo, token }); + return github.getDefaultBranch(); +}; + +export const getSliceLibraries = async ({ + owner, + repo, + branch, + token, +}: { + owner: string; + repo: string; + branch: string; + token: string; +}): Promise => { + const github = new GitHubRepositoryAPI({ owner, repo, token }); + return github.getSliceLibraries(branch); +}; + +const mocksSchema = z.array( + z.unknown().transform((content, ctx) => { + const result = SharedSliceContent.decode(content); + if (result._tag === "Left") { + for (const error of result.left) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: error.message, + }); + } + + return z.NEVER; + } + + return result.right; + }), +); + +export const fetchSlicesFromLibraries = async ({ + owner, + repo, + branch, + libraries, + token, +}: { + owner: string; + repo: string; + branch: string; + libraries: string[]; + token: string; +}) => { + const github = new GitHubRepositoryAPI({ owner, repo, token }); + const fetchedSlices: SliceImport[] = []; + + console.log( + `Fetching slices from ${libraries.length} library/libraries:`, + libraries, + ); + + for (const libraryPath of libraries) { + // Normalize library path (remove leading ./ if present) + const normalizedPath = libraryPath.replace(/^\.\//, ""); + + let sliceDirectories: Array<{ + name: string; + path: string; + }> = []; + + // Try GitHub API first + let apiFailed = false; + + try { + const libraryContents = await github.getDirectoryContents(normalizedPath); + sliceDirectories = libraryContents + .filter((item) => item.type === "dir") + .map((item) => ({ + name: item.name, + path: item.path, + })); + } catch (error) { + apiFailed = true; + const errorMessage = + error instanceof Error ? error.message : String(error); + if (errorMessage.includes("403")) { + console.warn( + `GitHub API returned 403 for ${libraryPath}, trying direct discovery...`, + ); + } else { + console.warn( + `GitHub API error for ${libraryPath}, trying direct discovery...`, + errorMessage, + ); + } + } + + // If API failed, use GitHub Search API to find all model.json files in this library path + if (apiFailed && sliceDirectories.length === 0) { + console.log( + `Attempting to discover slices using GitHub Search API for ${libraryPath}...`, + ); + + try { + // Use GitHub Search API to find all model.json files in the library path + const searchData = await github.searchCode({ + path: normalizedPath, + filename: "model.json", + }); + + if (searchData.items && searchData.items.length > 0) { + // Extract slice directory names from the paths + // Path format: slices/marketing/slice-name/model.json + const foundSlices = new Set(); + for (const item of searchData.items) { + // Extract the slice directory name from the path + // e.g., "slices/marketing/hero/model.json" -> "hero" + const pathParts = item.path.split("/"); + // The slice name should be the second-to-last part (before "model.json") + if (pathParts.length >= 2) { + const sliceName = pathParts[pathParts.length - 2]; + if (sliceName && !foundSlices.has(sliceName)) { + foundSlices.add(sliceName); + } + } + } + + // Convert to slice directories format + sliceDirectories = Array.from(foundSlices).map((sliceName) => ({ + name: sliceName, + path: `${normalizedPath}/${sliceName}`, + })); + + console.log( + `Discovered ${sliceDirectories.length} slice(s) via GitHub Search API for library ${libraryPath}`, + ); + } else { + console.warn( + `GitHub Search API found no model.json files in ${libraryPath}`, + ); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (errorMessage.includes("403")) { + console.warn( + `GitHub Search API also returned 403. Cannot discover slices without API access.`, + ); + } else { + console.warn( + `Error using GitHub Search API for ${libraryPath}:`, + errorMessage, + ); + } + } + } + + if (sliceDirectories.length === 0) { + console.warn( + `No slices found in library ${libraryPath}. The repository may be private or require authentication.`, + ); + continue; + } + + console.log( + `Processing ${sliceDirectories.length} slice(s) in library ${libraryPath}`, + ); + + // Fetch each slice's model.json, screenshot, and all other files with bounded concurrency + const perSlice = async (sliceDir: { name: string; path: string }) => { + try { + const modelContent = await github.getFileContents( + `${sliceDir.path}/model.json`, + branch, + false, + ); + + if (typeof modelContent !== "string") { + console.warn( + `Failed to fetch model.json for slice: ${sliceDir.name} - unexpected content type`, + ); + return; + } + + const modelResult = SharedSlice.decode(JSON.parse(modelContent)); + if (modelResult._tag === "Left") { + console.warn( + `Failed to decode model.json for slice: ${sliceDir.name}`, + ); + return; + } + const model = modelResult.right; + + // Fetch all files from the slice directory + // Wrap in try-catch to prevent failures from blocking other slices + let sliceFiles: SliceFile[] = []; + try { + sliceFiles = await fetchAllFilesFromDirectory({ + api: github, + branch, + directoryPath: sliceDir.path, + }); + console.log( + `Fetched ${sliceFiles.length} file(s) for slice ${sliceDir.name}:`, + sliceFiles.map((f) => `${f.path}${f.isBinary ? " (binary)" : ""}`), + ); + } catch (error) { + console.warn( + `Failed to fetch files for slice ${sliceDir.name}:`, + error instanceof Error ? error.message : String(error), + ); + // Continue with empty sliceFiles array + } + + // Extract component contents and mocks + let componentContents: string | undefined; + let mocks: SharedSliceContent[] | undefined; + + for (const file of sliceFiles) { + if ( + componentContents === undefined && + file.path.match(/^index\.(tsx?|jsx?|vue|svelte)$/) + ) { + if (typeof file.contents === "string") { + componentContents = file.contents; + } + } + + if (file.path === "mocks.json" && typeof file.contents === "string") { + try { + const parsedMocksResult = mocksSchema.safeParse( + JSON.parse(file.contents), + ); + if (!parsedMocksResult.success) { + console.warn( + `Failed to decode mocks.json for slice: ${sliceDir.name}`, + ); + } else { + const parsedMocks = parsedMocksResult.data; + if (Array.isArray(parsedMocks) && parsedMocks.length > 0) { + mocks = parsedMocks; + } + } + } catch { + console.warn( + `Failed to decode mocks.json for slice: ${sliceDir.name}`, + ); + } + } + } + + // Fetch screenshots for all variations (usually few); keep unbounded or lightly bounded + let thumbnailUrl: string | undefined; + let screenshotFile: File | undefined; + const screenshots: Record = {}; + + if (model.variations !== undefined && model.variations.length > 0) { + const screenshotResults = await Promise.allSettled( + model.variations.map(async (variation) => { + try { + const screenshotPath = `${sliceDir.path}/screenshot-${variation.id}.png`; + const screenshotContent = await github.getFileContents( + screenshotPath, + branch, + true, + ); + + if (screenshotContent instanceof ArrayBuffer) { + const blob = new Blob([screenshotContent], { + type: "image/png", + }); + const file = new File( + [blob], + `screenshot-${variation.id}.png`, + { + type: "image/png", + }, + ); + screenshots[variation.id] = file; + + if ( + thumbnailUrl === undefined && + model.variations[0] !== undefined && + variation.id === model.variations[0].id + ) { + thumbnailUrl = URL.createObjectURL(blob); + screenshotFile = file; + } + } + } catch (error) { + // Screenshot might not exist for this variation, that's okay + console.warn( + `Failed to fetch screenshot for variation ${variation.id}:`, + error instanceof Error ? error.message : String(error), + ); + } + }), + ); + + screenshotResults.forEach((result, index) => { + if (result.status === "rejected") { + console.warn( + `Failed to fetch screenshot for variation ${model.variations[index]?.id}:`, + result.reason instanceof Error + ? result.reason.message + : String(result.reason), + ); + } + }); + } + + const backupImageFile = + screenshotFile ?? + new File([], `${model.name}.json`, { + type: "application/json", + }); + + const sliceData: SliceImport = { + image: backupImageFile, + thumbnailUrl: thumbnailUrl ?? URL.createObjectURL(backupImageFile), + model, + files: sliceFiles, + componentContents, + mocks, + screenshots: + Object.keys(screenshots).length > 0 ? screenshots : undefined, + }; + fetchedSlices.push(sliceData); + } catch (error) { + console.warn( + `Error fetching slice ${sliceDir.name}:`, + error instanceof Error ? error.message : String(error), + ); + } + }; + + // Process slice directories with a concurrency cap to avoid API throttling + await mapWithConcurrency(sliceDirectories, 6, perSlice); + } + + return fetchedSlices; +}; + +/** + * Recursively fetches all files from a GitHub directory + */ +const fetchAllFilesFromDirectory = async (args: { + api: GitHubRepositoryAPI; + branch: string; + directoryPath: string; +}): Promise => { + const { api, branch, directoryPath } = args; + const files: SliceFile[] = []; + + // Try GitHub API first + let apiWorked = false; + try { + const contents = await api.getDirectoryContents(directoryPath); + apiWorked = true; + + const fileItems = contents.filter((i) => i.type === "file"); + const dirItems = contents.filter((i) => i.type === "dir"); + + // Process files with bounded concurrency + const fileResults = await mapWithConcurrency(fileItems, 8, async (item) => { + if (item.name === "model.json") return null; + try { + const binaryExtensions = [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".svg", + ".ico", + ".webp", + ]; + const isBinaryFile = binaryExtensions.some((ext) => + item.name.toLowerCase().endsWith(ext), + ); + + const fileContents = await api.getFileContents( + item.path, + branch, + isBinaryFile, + ); + + return { + path: item.name, + contents: fileContents, + isBinary: isBinaryFile, + } as SliceFile; + } catch (error) { + console.warn( + `Failed to fetch file ${item.path}:`, + error instanceof Error ? error.message : String(error), + ); + return null; + } + }); + for (const r of fileResults) { + if (r) files.push(r); + } + + // Recursively process directories sequentially (counts are usually low) + for (const item of dirItems) { + const subFiles = await fetchAllFilesFromDirectory({ + api, + branch, + directoryPath: item.path, + }); + for (const subFile of subFiles) { + files.push({ + ...subFile, + path: `${item.name}/${subFile.path}`, + }); + } + } + } catch (error) { + console.warn( + `GitHub API failed for directory ${directoryPath}, trying Search API...`, + error instanceof Error ? error.message : String(error), + ); + } + + // If API failed, use GitHub Search API to find all files recursively + if (!apiWorked) { + try { + console.log( + `Using GitHub Search API to find all files in ${directoryPath}...`, + ); + + // Use GitHub Search API to find all files in this directory (recursively) + const searchData = await api.searchCode({ path: directoryPath }); + + if (searchData.items && searchData.items.length > 0) { + console.log( + `Found ${searchData.items.length} file(s) via Search API for ${directoryPath}`, + ); + + // Fetch all discovered files + // Note: Search API returns up to 100 results per page, but we should get all files + const fetched = await mapWithConcurrency( + searchData.items, + 8, + async (item) => { + try { + if (item.name === "model.json") return null; + const relativePath = item.path.startsWith(directoryPath + "/") + ? item.path.slice(directoryPath.length + 1) + : item.name; + + const binaryExtensions = [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".svg", + ".ico", + ".webp", + ".woff", + ".woff2", + ".ttf", + ".eot", + ".otf", + ".pdf", + ".zip", + ".gz", + ]; + const isBinaryFile = binaryExtensions.some((ext) => + item.name.toLowerCase().endsWith(ext), + ); + + const fileContents = await api.getFileContents( + item.path, + branch, + isBinaryFile, + ); + + return { + path: relativePath, + contents: fileContents, + isBinary: isBinaryFile, + } as SliceFile | null; + } catch (error) { + console.warn( + `Error fetching file ${item.path}:`, + error instanceof Error ? error.message : String(error), + ); + return null; + } + }, + ); + for (const item of fetched) { + if (item) files.push(item); + } + + console.log( + `Fetched ${files.length} file(s) from ${directoryPath} via Search API`, + ); + } else { + console.warn(`GitHub Search API found no files in ${directoryPath}`); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (errorMessage.includes("403")) { + console.warn( + `GitHub Search API returned 403 for ${directoryPath}. Cannot fetch files without API access.`, + ); + } else { + console.warn( + `Error using GitHub Search API for ${directoryPath}:`, + errorMessage, + ); + } + } + } + + return files; +}; diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/utils/mapWithConcurrency.ts b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/utils/mapWithConcurrency.ts new file mode 100644 index 0000000000..ff62eea9f3 --- /dev/null +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/utils/mapWithConcurrency.ts @@ -0,0 +1,28 @@ +/** + * Concurrency helper to control parallel network/IO without external deps + * @param items - The items to map over + * @param limit - The maximum number of concurrent operations + * @param mapper - The function to map over the items + * @returns The results of the mapped items + */ +export async function mapWithConcurrency( + items: readonly T[], + limit: number, + mapper: (item: T, index: number) => Promise, +): Promise { + const results = new Array(items.length); + let nextIndex = 0; + + const workers = new Array(Math.min(limit, items.length)) + .fill(null) + .map(async () => { + while (true) { + const currentIndex = nextIndex++; + if (currentIndex >= items.length) break; + results[currentIndex] = await mapper(items[currentIndex], currentIndex); + } + }); + + await Promise.all(workers); + return results; +} diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/utils/sliceWithoutConflicts.ts b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/utils/sliceWithoutConflicts.ts new file mode 100644 index 0000000000..51b8a3ece1 --- /dev/null +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/ImportSlicesFromLibraryModal/utils/sliceWithoutConflicts.ts @@ -0,0 +1,51 @@ +import { SharedSlice } from "@prismicio/types-internal/lib/customtypes"; + +import { NewSlice } from "../types"; + +/** + * If needed, assigns new ids and names to avoid conflicts with existing slices. + * Names are compared case-insensitively to avoid conflicts + * between folder names with different casing. + */ +export function sliceWithoutConflicts({ + existingSlices, + newSlices, + slice, +}: { + existingSlices: SharedSlice[]; + newSlices: NewSlice[]; + slice: SharedSlice; +}): SharedSlice { + const existingIds = new Set(); + const existingNames = new Set(); + + for (const { id, name } of existingSlices) { + existingIds.add(id); + existingNames.add(name.toLowerCase()); + } + + for (const s of newSlices) { + existingIds.add(s.model.id); + existingNames.add(s.model.name.toLowerCase()); + } + + let id = slice.id; + let counter = 2; + while (existingIds.has(id)) { + id = `${slice.id}_${counter}`; + counter++; + } + + let name = slice.name; + counter = 2; + while (existingNames.has(name.toLowerCase())) { + name = `${slice.name}${counter}`; + counter++; + } + + return { + ...slice, + id, + name, + }; +} diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/SliceZoneBlankSlate.tsx b/packages/slice-machine/src/features/customTypes/customTypesBuilder/SliceZoneBlankSlate.tsx index 9f4e766f2b..8836833fa6 100644 --- a/packages/slice-machine/src/features/customTypes/customTypesBuilder/SliceZoneBlankSlate.tsx +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/SliceZoneBlankSlate.tsx @@ -25,7 +25,6 @@ export const SliceZoneBlankSlate: FC = ({ openCreateSliceFromImageModal, openUpdateSliceZoneModal, openSlicesTemplatesModal, - projectHasAvailableSlices, isSlicesTemplatesSupported, }) => { const sliceCreationOptions = getSliceCreationOptions({ @@ -77,17 +76,15 @@ export const SliceZoneBlankSlate: FC = ({ {sliceCreationOptions.fromTemplate.title} )} - {projectHasAvailableSlices && ( - - sliceCreationOptions.fromExisting.BackgroundIcon - } - onClick={openUpdateSliceZoneModal} - description={sliceCreationOptions.fromExisting.description} - > - {sliceCreationOptions.fromExisting.title} - - )} + + sliceCreationOptions.fromExisting.BackgroundIcon + } + onClick={openUpdateSliceZoneModal} + description={sliceCreationOptions.fromExisting.description} + > + {sliceCreationOptions.fromExisting.title} + diff --git a/packages/slice-machine/src/legacy/lib/builders/CustomTypeBuilder/SliceZone/UpdateSliceZoneModal.tsx b/packages/slice-machine/src/legacy/lib/builders/CustomTypeBuilder/SliceZone/UpdateSliceZoneModal.tsx deleted file mode 100644 index d26470d495..0000000000 --- a/packages/slice-machine/src/legacy/lib/builders/CustomTypeBuilder/SliceZone/UpdateSliceZoneModal.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { SharedSlice } from "@prismicio/types-internal/lib/customtypes"; -import { Text } from "theme-ui"; - -import ModalFormCard from "@/legacy/components/ModalFormCard"; -import { ComponentUI } from "@/legacy/lib/models/common/ComponentUI"; - -import UpdateSliceZoneModalList from "./UpdateSliceZoneModalList"; - -interface UpdateSliceModalProps { - formId: string; - close: () => void; - onSubmit: (slices: SharedSlice[]) => void; - availableSlices: ReadonlyArray; -} - -export type SliceZoneFormValues = { - sliceKeys: string[]; -}; - -const UpdateSliceZoneModal: React.FC = ({ - formId, - close, - onSubmit, - availableSlices, -}) => { - return ( - { - const { sliceKeys } = values; - const slices = sliceKeys - .map( - (sliceKey) => - availableSlices.find((s) => s.model.id === sliceKey)?.model, - ) - .filter((slice) => slice !== undefined) as SharedSlice[]; - onSubmit(slices); - }} - initialValues={{ - sliceKeys: [], - }} - content={{ - title: "Select existing slices", - }} - testId="update-slices-modal" - validate={(values) => { - if (values.sliceKeys.length === 0) { - return { - sliceKeys: "Select at least one slice to add", - }; - } - }} - actionMessage={({ errors }) => - errors.sliceKeys !== undefined ? ( - {errors.sliceKeys} - ) : undefined - } - > - {({ values }) => ( - - )} - - ); -}; - -export default UpdateSliceZoneModal; diff --git a/packages/slice-machine/src/legacy/lib/builders/CustomTypeBuilder/SliceZone/UpdateSliceZoneModalList.tsx b/packages/slice-machine/src/legacy/lib/builders/CustomTypeBuilder/SliceZone/UpdateSliceZoneModalList.tsx index 94f01b4cef..7a879fdc4f 100644 --- a/packages/slice-machine/src/legacy/lib/builders/CustomTypeBuilder/SliceZone/UpdateSliceZoneModalList.tsx +++ b/packages/slice-machine/src/legacy/lib/builders/CustomTypeBuilder/SliceZone/UpdateSliceZoneModalList.tsx @@ -4,11 +4,9 @@ import { SharedSliceCard } from "@/features/slices/sliceCards/SharedSliceCard"; import Grid from "@/legacy/components/Grid"; import { ComponentUI } from "@/legacy/lib/models/common/ComponentUI"; -import { SliceZoneFormValues } from "./UpdateSliceZoneModal"; - const UpdateSliceZoneModalList: React.FC<{ availableSlices: ReadonlyArray; - values: SliceZoneFormValues; + values: { sliceKeys: string[] }; }> = ({ availableSlices, values }) => ( = ({ : { availableSlices: [], slicesInSliceZone: [], notFound: [] }, [sliceZone, libraries], ); + const [isDeleteSliceZoneModalOpen, setIsDeleteSliceZoneModalOpen] = useState(false); @@ -261,17 +263,15 @@ const SliceZone: React.FC = ({ ) : undefined} - {availableSlicesToAdd.length > 0 ? ( - - sliceCreationOptions.fromExisting.BackgroundIcon - } - description={sliceCreationOptions.fromExisting.description} - > - {sliceCreationOptions.fromExisting.title} - - ) : undefined} + + sliceCreationOptions.fromExisting.BackgroundIcon + } + description={sliceCreationOptions.fromExisting.description} + > + {sliceCreationOptions.fromExisting.title} + ) : undefined @@ -331,28 +331,34 @@ const SliceZone: React.FC = ({ ) ) : undefined} - {isUpdateSliceZoneModalOpen && ( - { - const newCustomType = addSlicesToSliceZone({ - customType, - tabId, - slices, - }); - setCustomType({ - customType: CustomTypes.fromSM(newCustomType), - onSaveCallback: () => { - toast.success("Slice(s) added to slice zone"); - }, - }); - void completeStep("createSlice"); - closeUpdateSliceZoneModal(); - }} - close={closeUpdateSliceZoneModal} - /> - )} + ({ + ...Slices.fromSM(slice.model), + thumbnailUrl: getFirstVariationScreenshot(slice), + }))} + isEveryLocalSliceAdded={ + availableSlices.length > 0 && availableSlicesToAdd.length === 0 + } + onSuccess={({ slices }) => { + const newCustomType = addSlicesToSliceZone({ + customType, + tabId, + slices: slices.map((s) => s.model), + }); + setCustomType({ + customType: CustomTypes.fromSM(newCustomType), + onSaveCallback: () => { + toast.success("Slices successfully added"); + }, + }); + void completeStep("createSlice"); + closeUpdateSliceZoneModal(); + }} + onClose={closeUpdateSliceZoneModal} + /> {isSlicesTemplatesModalOpen && ( = ({ ); }; +function getFirstVariationScreenshot(slice: ComponentUI): string | undefined { + if ("default" in slice.screenshots) return slice.screenshots.default.url; + return Object.values(slice.screenshots)[0]?.url; +} + export default SliceZone; diff --git a/packages/slice-machine/test/src/modules/__fixtures__/serverState.ts b/packages/slice-machine/test/src/modules/__fixtures__/serverState.ts index cd8a73b5c8..414ff9772e 100644 --- a/packages/slice-machine/test/src/modules/__fixtures__/serverState.ts +++ b/packages/slice-machine/test/src/modules/__fixtures__/serverState.ts @@ -26,6 +26,7 @@ export const dummyServerState: Pick< RepositoryService: "https://api.internal.prismic.io/repository/", LocaleService: "https://api.internal.prismic.io/locale/", CustomTypeService: "https://api.internal.prismic.io/custom-type/", + GitService: "https://api.internal.prismic.io/git/", }, shortId: "shortId", }, diff --git a/playwright/pages/components/Dialog.ts b/playwright/pages/components/Dialog.ts index cf0ad88f01..1e63dff8d6 100644 --- a/playwright/pages/components/Dialog.ts +++ b/playwright/pages/components/Dialog.ts @@ -12,7 +12,7 @@ export class Dialog { page: Page, options: { title: string | RegExp | Locator; - submitName?: string; + submitName?: string | RegExp; }, ) { const { title, submitName = "Submit" } = options; diff --git a/playwright/pages/components/SelectExistingSlicesDialog.ts b/playwright/pages/components/SelectExistingSlicesDialog.ts index e439842425..a617942753 100644 --- a/playwright/pages/components/SelectExistingSlicesDialog.ts +++ b/playwright/pages/components/SelectExistingSlicesDialog.ts @@ -8,15 +8,15 @@ export class SelectExistingSlicesDialog extends Dialog { constructor(page: Page) { super(page, { - title: `Select existing slices`, - submitName: "Add", + title: "Reuse an existing slice", + submitName: new RegExp(`^Add to .+ \\(\\d+\\)$`), }); /** * Static locators */ - this.sharedSliceCard = this.dialog.getByTestId("shared-slice-card"); - this.addedMessage = page.getByText("Slice(s) added to slice zone", { + this.sharedSliceCard = this.dialog.getByTestId("slice-card"); + this.addedMessage = page.getByText("Slices successfully added", { exact: true, }); } diff --git a/yarn.lock b/yarn.lock index ece22c1c1b..4623528946 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7406,13 +7406,13 @@ __metadata: languageName: node linkType: hard -"@prismicio/editor-fields@npm:0.4.88": - version: 0.4.88 - resolution: "@prismicio/editor-fields@npm:0.4.88" +"@prismicio/editor-fields@npm:0.4.90": + version: 0.4.90 + resolution: "@prismicio/editor-fields@npm:0.4.90" dependencies: "@floating-ui/react-dom-interactions": 0.9.3 "@prismicio/client": 7.15.1 - "@prismicio/editor-support": 0.4.88 + "@prismicio/editor-support": 0.4.90 "@prismicio/richtext": 2.1.1 "@prismicio/types-internal": 3.16.1 "@tiptap/core": 2.11.5 @@ -7438,6 +7438,7 @@ __metadata: "@tiptap/react": 2.11.5 "@tiptap/suggestion": 2.11.5 clsx: 1.2.1 + deep-diff: 1.0.2 fp-ts: 2.12.3 io-ts: 2.2.18 io-ts-types: 0.5.16 @@ -7447,17 +7448,17 @@ __metadata: tslib: 2.4.0 zod: 3.23.8 peerDependencies: - "@prismicio/editor-ui": ^0.4.88 + "@prismicio/editor-ui": ^0.4.90 "@tanstack/react-query": 5.55.4 react: 18 react-dom: 18 - checksum: 4f3c00971dff7b4c04a8c74a5d2e074903b864a5b2942979fe7e75bc8575a6c1e79231bf137c4efa80b138b9ec5c12ca37de29ee5eb2fa21bd522dd03a467bbd + checksum: be034b2ef0aeeaff07a97d1ee6c391fbb4d051d664212f864829d1b6ad37fae9f051ec8a704e0e2a355708e556dc8524d50b4143eefd81bb058497c9ebc3f9ea languageName: node linkType: hard -"@prismicio/editor-support@npm:0.4.88": - version: 0.4.88 - resolution: "@prismicio/editor-support@npm:0.4.88" +"@prismicio/editor-support@npm:0.4.90": + version: 0.4.90 + resolution: "@prismicio/editor-support@npm:0.4.90" dependencies: dom-confetti: 0.2.2 is-hotkey-esm: 1.0.0 @@ -7470,19 +7471,19 @@ __metadata: optional: true zod: optional: true - checksum: 926761d13492bcd486bc738cc519c67701238dde97df76bb209b238a8736f6ae34df2073f185957c0282d777f4916a3d1bc2a64ca619a5e0faa931958185dc6c + checksum: c91f0fee0b8359ce3fd433cd5431dc9a8ce5168ee6f7900c95362a61a1e6fa7c364f1f51ef9153a247c4e61a95e393f877877fc7b7edd7364b3d29af619bfad7 languageName: node linkType: hard -"@prismicio/editor-ui@npm:0.4.88": - version: 0.4.88 - resolution: "@prismicio/editor-ui@npm:0.4.88" +"@prismicio/editor-ui@npm:0.4.90": + version: 0.4.90 + resolution: "@prismicio/editor-ui@npm:0.4.90" dependencies: "@internationalized/date": 3.8.1 "@nivo/bar": 0.96.0 "@nivo/core": 0.96.0 "@nivo/tooltip": 0.96.0 - "@prismicio/editor-support": 0.4.88 + "@prismicio/editor-support": 0.4.90 "@radix-ui/react-focus-scope": 1.1.7 "@react-aria/calendar": 3.8.1 "@react-aria/color": 3.0.0-rc.0 @@ -7505,6 +7506,7 @@ __metadata: "@tiptap/react": 2.11.5 clsx: 1.2.1 csstype: 3.1.2 + motion: 12.23.24 radix-ui: 1.4.2 react-easy-crop: 4.6.1 react-remove-scroll: 2.5.6 @@ -7513,7 +7515,7 @@ __metadata: peerDependencies: react: 17 || 18 react-dom: 17 || 18 - checksum: 92e4adc9d1b2b05078b37d24bcda70c0e8f35b99187f3196c85cd335d20c4efcf376633e896fbb9b04fbe6d092058b3be9a6b43caa5cb2eec8900e9686c9ae18 + checksum: 3a2db4d9588797a392e29ad3442bfc5467872da559346a031b4ab1a06c4e1c316bb5d778c19a9d4fe6a2213d075543cbd01e034815c9442837e483a8b8b240ef languageName: node linkType: hard @@ -19053,6 +19055,13 @@ __metadata: languageName: node linkType: hard +"deep-diff@npm:1.0.2": + version: 1.0.2 + resolution: "deep-diff@npm:1.0.2" + checksum: 9de8b5eedc1957116e1b47e4c3c4e3dbe23cb741abefc5ec8829a12e77958c689ac46888a3c35320f976cf42fb6de2b016e158facdb24d894ab5b5fdabad9b34 + languageName: node + linkType: hard + "deep-eql@npm:^4.1.2": version: 4.1.3 resolution: "deep-eql@npm:4.1.3" @@ -21978,6 +21987,28 @@ __metadata: languageName: node linkType: hard +"framer-motion@npm:^12.23.24": + version: 12.23.26 + resolution: "framer-motion@npm:12.23.26" + dependencies: + motion-dom: ^12.23.23 + motion-utils: ^12.23.6 + tslib: ^2.4.0 + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 28215c54ced0aae60fa239a064069689538749c5c74807d3e820e598d68d8a15e62ccf21610fc6567c8c2deeb973fd8394f82e0ac6a30448653c0b48bd85c530 + languageName: node + linkType: hard + "fresh@npm:0.5.2, fresh@npm:^0.5.2": version: 0.5.2 resolution: "fresh@npm:0.5.2" @@ -27856,6 +27887,43 @@ __metadata: languageName: node linkType: hard +"motion-dom@npm:^12.23.23": + version: 12.23.23 + resolution: "motion-dom@npm:12.23.23" + dependencies: + motion-utils: ^12.23.6 + checksum: c065c85eae9424c3e44a76da975784b17e2eb0554dc155452dc6622b62df5b7fffa8a963f4a049a4095f209a68016d3f24b011a3d51417bb23db14398fe208eb + languageName: node + linkType: hard + +"motion-utils@npm:^12.23.6": + version: 12.23.6 + resolution: "motion-utils@npm:12.23.6" + checksum: e7c0b1d7a893c5979eab529f2bd269031f71f8d23cbf8be2ec8785558544ee45b9ba7b23390f9a46f2f0a493903b6adc0666229de3b05e977a0bfc3c5fb13d8b + languageName: node + linkType: hard + +"motion@npm:12.23.24": + version: 12.23.24 + resolution: "motion@npm:12.23.24" + dependencies: + framer-motion: ^12.23.24 + tslib: ^2.4.0 + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 4e7046f312b85adf96f64f91d67f6f6880bab2ff6e9cdd62cd3e888da8c2f52e14202eb4c095e246cf760a2080fef449115174f8f4f24b1618520501b53b8485 + languageName: node + linkType: hard + "move-concurrently@npm:^1.0.1": version: 1.0.1 resolution: "move-concurrently@npm:1.0.1" @@ -35079,9 +35147,9 @@ __metadata: "@emotion/react": 11.11.1 "@extractus/oembed-extractor": 3.1.8 "@prismicio/client": 7.17.0 - "@prismicio/editor-fields": 0.4.88 - "@prismicio/editor-support": 0.4.88 - "@prismicio/editor-ui": 0.4.88 + "@prismicio/editor-fields": 0.4.90 + "@prismicio/editor-support": 0.4.90 + "@prismicio/editor-ui": 0.4.90 "@prismicio/mock": 0.7.1 "@prismicio/mocks": 2.14.0 "@prismicio/simulator": 0.1.4