diff --git a/apps/builder/app/builder/features/assets/assets.tsx b/apps/builder/app/builder/features/assets/assets.tsx index 4c6cec0011d0..bdd98edeeab4 100644 --- a/apps/builder/app/builder/features/assets/assets.tsx +++ b/apps/builder/app/builder/features/assets/assets.tsx @@ -1,10 +1,14 @@ import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, IconButton, PanelTitle, Separator, Tooltip, } from "@webstudio-is/design-system"; -import { BrushCleaningIcon, NewFolderIcon } from "@webstudio-is/icons"; +import { BrushCleaningIcon, PlusIcon } from "@webstudio-is/icons"; import { useRef, useState } from "react"; import { useStore } from "@nanostores/react"; import { isTextFileAsset } from "@webstudio-is/sdk"; @@ -17,6 +21,7 @@ import { $assets } from "~/shared/sync/data-stores"; import type { Publish } from "~/shared/pubsub"; import { useImageAssetCanvasDrag } from "./use-image-asset-canvas-drag"; import { TextFileEditor } from "~/builder/features/text-file-editor/text-file-editor"; +import { CreateTextFileDialog } from "~/builder/features/text-file-editor/create-text-file-dialog"; import { getAssetUrl } from "~/builder/shared/assets/asset-utils"; export const AssetsPanel = ({ @@ -27,9 +32,15 @@ export const AssetsPanel = ({ }) => { const [folderId, setFolderId] = useState(); const [createFolderOpen, setCreateFolderOpen] = useState(false); + const [createTextFileOpen, setCreateTextFileOpen] = useState(false); const [openedTextAssetId, setOpenedTextAssetId] = useState(); const uploadRef = useRef(null); const authPermit = useStore($authPermit); + const addActions = { + upload: () => uploadRef.current?.open(), + createFile: () => setCreateTextFileOpen(true), + createFolder: () => setCreateFolderOpen(true), + }; const openAsset = (assetId: string) => { const asset = $assets.get().get(assetId); if (asset === undefined) { @@ -48,24 +59,46 @@ export const AssetsPanel = ({ useImageAssetCanvasDrag(publish); return ( <> + - + setCreateFolderOpen(true)} + aria-label="Delete unused assets" + onClick={openDeleteUnusedAssetsDialog} > - - - - - - + + + + + + + + + + + Upload + + + Create text file + + + Create folder + + + } > @@ -78,12 +111,7 @@ export const AssetsPanel = ({ onOpen={openAsset} canManageFolders={authPermit !== "view"} panelActions={{ - ...(authPermit === "view" - ? {} - : { - createFolder: () => setCreateFolderOpen(true), - upload: () => uploadRef.current?.open(), - }), + ...(authPermit === "view" ? {} : addActions), deleteUnusedAssets: openDeleteUnusedAssetsDialog, }} /> @@ -92,6 +120,12 @@ export const AssetsPanel = ({ onOpenChange={setCreateFolderOpen} currentFolderId={folderId} /> + {openedTextAssetId !== undefined && ( { + expect( + getTextFileNameError({ name: "data.json", assets: [existing] }) + ).toBeUndefined(); + expect(getTextFileNameError({ name: "image.png", assets: [] })).toBe( + "Use a supported editable text extension." + ); + expect(getTextFileNameError({ name: ".md", assets: [] })).toBe( + "Use a supported editable text extension." + ); + expect(getTextFileNameError({ name: "bad/name.md", assets: [] })).toBe( + "Enter a valid file name." + ); + expect( + getTextFileNameError({ + name: "readme.md", + assets: [existing], + }) + ).toBe("A file with this name already exists."); +}); + +test("compares global complete display names after an asset is renamed", () => { + expect( + getTextFileNameError({ + name: "guide.md", + assets: [{ ...existing, filename: "guide", folderId: "other" }], + }) + ).toBe("A file with this name already exists."); +}); diff --git a/apps/builder/app/builder/features/text-file-editor/create-text-file-dialog.tsx b/apps/builder/app/builder/features/text-file-editor/create-text-file-dialog.tsx new file mode 100644 index 000000000000..a91868ca6ff3 --- /dev/null +++ b/apps/builder/app/builder/features/text-file-editor/create-text-file-dialog.tsx @@ -0,0 +1,169 @@ +import { useLayoutEffect, useState, type KeyboardEvent } from "react"; +import isValidFilename from "valid-filename"; +import { + Button, + Dialog, + DialogContent, + DialogTitle, + Flex, + Grid, + InputField, + Label, + Text, + theme, +} from "@webstudio-is/design-system"; +import { + formatAssetName, + getFileExtension, + getMimeTypeByExtension, + isTextFileAsset, + type Asset, +} from "@webstudio-is/sdk"; +import { $assets } from "~/shared/sync/data-stores"; +import { uploadSingleAsset } from "~/builder/shared/assets/upload-assets"; + +export const getTextFileNameError = ({ + name, + assets, +}: { + name: string; + assets: Iterable; +}) => { + if (isValidFilename(name) === false) { + return "Enter a valid file name."; + } + if ( + isTextFileAsset({ format: getFileExtension(name)?.toLowerCase() ?? "" }) === + false + ) { + return "Use a supported editable text extension."; + } + for (const asset of assets) { + if (formatAssetName(asset) === name) { + return "A file with this name already exists."; + } + } +}; + +const createTextFile = async ({ + name, + folderId, +}: { + name: string; + folderId?: string; +}): Promise => { + const format = getFileExtension(name)?.toLowerCase() ?? ""; + if (isTextFileAsset({ format }) === false) { + return; + } + const file = new File([""], name, { + type: getMimeTypeByExtension(format), + }); + return uploadSingleAsset("file", file, { folderId }); +}; + +const stopEscapePropagation = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.stopPropagation(); + } +}; + +export const CreateTextFileDialog = ({ + open, + folderId, + onOpenChange, + onCreated, +}: { + open: boolean; + folderId?: string; + onOpenChange: (open: boolean) => void; + onCreated: (assetId: string) => void; +}) => { + const [name, setName] = useState("untitled.md"); + const [error, setError] = useState(); + const [creating, setCreating] = useState(false); + + useLayoutEffect(() => { + if (open) { + setName("untitled.md"); + setError(undefined); + setCreating(false); + } + }, [open]); + + const normalizedName = name.trim(); + const submit = async () => { + if (creating) { + return; + } + const validationError = getTextFileNameError({ + name: normalizedName, + assets: $assets.get().values(), + }); + setError(validationError); + if (validationError !== undefined) { + return; + } + setCreating(true); + try { + const asset = await createTextFile({ name: normalizedName, folderId }); + if (asset === undefined) { + setError("The file could not be created."); + return; + } + onOpenChange(false); + onCreated(asset.id); + } catch (error) { + setError( + error instanceof Error + ? error.message + : "The file could not be created." + ); + } finally { + setCreating(false); + } + }; + + return ( + + + New text file + + + + { + setName(event.target.value); + setError(undefined); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + void submit(); + } + }} + /> + {error !== undefined && ( + + {error} + + )} + + + + + + + + ); +}; diff --git a/apps/builder/app/builder/shared/asset-manager/asset-manager-item-menu.test.ts b/apps/builder/app/builder/shared/asset-manager/asset-manager-item-menu.test.ts index 7e99d4509c53..93dfdb0f833f 100644 --- a/apps/builder/app/builder/shared/asset-manager/asset-manager-item-menu.test.ts +++ b/apps/builder/app/builder/shared/asset-manager/asset-manager-item-menu.test.ts @@ -7,6 +7,7 @@ test("uses one ordered command model for context and dropdown menus", () => { open: action, settings: action, createFolder: action, + createFile: action, upload: action, cut: action, copy: action, @@ -21,6 +22,7 @@ test("uses one ordered command model for context and dropdown menus", () => { expect(items.map(({ name }) => name)).toEqual([ "createFolder", + "createFile", "upload", "open", "settings", @@ -60,6 +62,7 @@ test("orders panel actions independently of unavailable item actions", () => { const action = vi.fn(); const items = getAssetManagerItemMenuItems({ createFolder: action, + createFile: action, upload: action, paste: action, deleteUnusedAssets: action, @@ -67,6 +70,7 @@ test("orders panel actions independently of unavailable item actions", () => { expect(items.map(({ name }) => name)).toEqual([ "createFolder", + "createFile", "upload", "paste", "deleteUnusedAssets", diff --git a/apps/builder/app/builder/shared/asset-manager/asset-manager-item-menu.tsx b/apps/builder/app/builder/shared/asset-manager/asset-manager-item-menu.tsx index b6ef0127e1b4..8682d73f74c2 100644 --- a/apps/builder/app/builder/shared/asset-manager/asset-manager-item-menu.tsx +++ b/apps/builder/app/builder/shared/asset-manager/asset-manager-item-menu.tsx @@ -28,6 +28,7 @@ export type AssetManagerItemActions = Partial< | "download" | "replace" | "createFolder" + | "createFile" | "upload" | "deleteUnusedAssets" | "delete", @@ -46,6 +47,7 @@ type ItemDefinition = { const itemDefinitions: readonly ItemDefinition[] = [ { name: "createFolder", label: "Create folder" }, + { name: "createFile", label: "Create text file" }, { name: "upload", label: "Upload asset" }, { name: "open", label: "Open" }, { name: "settings", label: "Settings" }, diff --git a/apps/builder/app/builder/shared/asset-manager/asset-manager.tsx b/apps/builder/app/builder/shared/asset-manager/asset-manager.tsx index 7f816bcbd858..0b9c5facc6cb 100644 --- a/apps/builder/app/builder/shared/asset-manager/asset-manager.tsx +++ b/apps/builder/app/builder/shared/asset-manager/asset-manager.tsx @@ -110,7 +110,7 @@ type AssetManagerProps = FolderNavigationProps & { panelActions?: Partial< Pick< AssetManagerItemActions, - "createFolder" | "upload" | "deleteUnusedAssets" + "createFolder" | "createFile" | "upload" | "deleteUnusedAssets" > >; }; diff --git a/apps/builder/app/builder/shared/asset-manager/asset-settings.tsx b/apps/builder/app/builder/shared/asset-manager/asset-settings.tsx index 460dfc9ba158..d50a33b63915 100644 --- a/apps/builder/app/builder/shared/asset-manager/asset-settings.tsx +++ b/apps/builder/app/builder/shared/asset-manager/asset-settings.tsx @@ -70,7 +70,7 @@ import { } from "~/shared/sync/data-stores"; import { formatAssetName, - parseAssetName, + getAssetDisplayNameParts, } from "@webstudio-is/project-build/runtime"; import { AssetFolderSelector } from "./asset-folder-selector"; import { moveAssetManagerItems } from "./asset-manager-operations"; @@ -313,38 +313,34 @@ const AssetSettingsContent = ({ focusName: boolean; }) => { const { canDownloadAssets } = useStore($permissions); - const { size, meta, id, name } = asset; - const { basename, ext } = parseAssetName(name); + const { size, meta, id } = asset; + const { basename, ext } = getAssetDisplayNameParts(asset); const [filenameError, setFilenameError] = useState(); - const [filename, setFilename] = useLocalValue( - asset.filename ?? basename, - (newFilename) => { - const assetId = asset.id; - // validate filename - if (!isValidFilename(newFilename)) { - setFilenameError("Invalid filename"); - return; - } - // validate duplicates - for (const asset of $assets.get().values()) { - if (asset.id !== assetId) { - const filename = - asset.filename ?? parseAssetName(asset.name).basename; - if (newFilename === filename) { - setFilenameError("Filename already used"); - return; - } + const [filename, setFilename] = useLocalValue(basename, (newFilename) => { + const assetId = asset.id; + // validate filename + if (!isValidFilename(newFilename)) { + setFilenameError("Invalid filename"); + return; + } + // validate duplicates + for (const asset of $assets.get().values()) { + if (asset.id !== assetId) { + const { basename: filename } = getAssetDisplayNameParts(asset); + if (newFilename === filename) { + setFilenameError("Filename already used"); + return; } } - executeRuntimeMutation({ - id: "assets.update", - input: { - assetId, - values: { filename: newFilename }, - }, - }); } - ); + executeRuntimeMutation({ + id: "assets.update", + input: { + assetId, + values: { filename: newFilename }, + }, + }); + }); const [description, setDescription] = useLocalValue( asset.description ?? "", (newDescription) => { @@ -456,10 +452,24 @@ const AssetSettingsContent = ({ > + .{ext} + + ) + } onChange={(event) => { setFilename(event.target.value); setFilenameError(undefined); diff --git a/apps/builder/app/builder/shared/asset-manager/asset-thumbnail.tsx b/apps/builder/app/builder/shared/asset-manager/asset-thumbnail.tsx index 5b9946caf238..df3d62934301 100644 --- a/apps/builder/app/builder/shared/asset-manager/asset-thumbnail.tsx +++ b/apps/builder/app/builder/shared/asset-manager/asset-thumbnail.tsx @@ -10,7 +10,7 @@ import { Image } from "./image"; import brokenImage from "~/shared/images/broken-image-placeholder.svg"; import { formatAssetName, - parseAssetName, + getAssetDisplayNameParts, } from "@webstudio-is/project-build/runtime"; import type { IconComponent } from "@webstudio-is/icons"; import type { AllowedFileExtension } from "@webstudio-is/sdk"; @@ -150,7 +150,7 @@ export const AssetThumbnail = ({ const { canDownloadAssets } = useStore($permissions); const { asset } = assetContainer; const getDragItems = interactions.getDragItems; - const { basename, ext } = parseAssetName(asset.name); + const { basename, ext } = getAssetDisplayNameParts(asset); const alt = asset.description ?? formatAssetName(asset); const isUploading = assetContainer.status === "uploading"; const assetType = detectAssetType(asset.name); @@ -269,8 +269,8 @@ export const AssetThumbnail = ({ ) } - label={asset.filename ?? basename} - labelSuffix={`.${ext}`} + label={basename} + labelSuffix={ext === "" ? undefined : `.${ext}`} path={folderPath} onPreviewClick={() => onChange?.(assetContainer)} onDoubleClick={onOpen} diff --git a/apps/builder/app/builder/shared/assets/asset-upload.test.tsx b/apps/builder/app/builder/shared/assets/asset-upload.test.tsx index 324131b1ef73..e8ec182afabd 100644 --- a/apps/builder/app/builder/shared/assets/asset-upload.test.tsx +++ b/apps/builder/app/builder/shared/assets/asset-upload.test.tsx @@ -15,17 +15,20 @@ afterEach(() => { vi.restoreAllMocks(); }); -test("opens the same file input from its imperative action", () => { +test("opens the hidden file input from its imperative action", () => { $authPermit.set("build"); const inputClick = vi .spyOn(HTMLInputElement.prototype, "click") .mockImplementation(() => {}); const ref = createRef(); - renderer.render(); + const container = renderer.render( + + ); ref.current?.open(); expect(inputClick).toHaveBeenCalledOnce(); + expect(container.querySelector("button")).toBeNull(); }); test("groups upload files by their detected asset type", () => { diff --git a/apps/builder/app/builder/shared/assets/asset-upload.tsx b/apps/builder/app/builder/shared/assets/asset-upload.tsx index c98c17b6e2b9..69e0d6617795 100644 --- a/apps/builder/app/builder/shared/assets/asset-upload.tsx +++ b/apps/builder/app/builder/shared/assets/asset-upload.tsx @@ -111,6 +111,7 @@ type AssetUploadProps = { type: AssetType; accept?: string; folderId?: string; + showTrigger?: boolean; }; export type AssetUploadHandle = { @@ -118,7 +119,7 @@ export type AssetUploadHandle = { }; const EnabledAssetUpload = forwardRef( - ({ accept, type, folderId }, forwardedRef) => { + ({ accept, type, folderId, showTrigger = true }, forwardedRef) => { const { inputRef, onChange } = useUpload(folderId); useImperativeHandle(forwardedRef, () => ({ open: () => inputRef.current?.click(), @@ -134,13 +135,15 @@ const EnabledAssetUpload = forwardRef( ref={inputRef} style={{ display: "none" }} /> - + {showTrigger && ( +