diff --git a/e2e/nextjs-app/src/app/components/file-upload/editable.e2e.tsx b/e2e/nextjs-app/src/app/components/file-upload/editable.e2e.tsx index 014c8657bf..ccc36bd11f 100644 --- a/e2e/nextjs-app/src/app/components/file-upload/editable.e2e.tsx +++ b/e2e/nextjs-app/src/app/components/file-upload/editable.e2e.tsx @@ -2,7 +2,9 @@ import type { FileItemProps } from "@lifesg/react-design-system/file-upload"; import { FileUpload } from "@lifesg/react-design-system/file-upload"; -import { useState } from "react"; +import { useRef, useState } from "react"; + +const THUMBNAIL_URL = "/sample.jpg"; const INITIAL_ITEMS: FileItemProps[] = [ { @@ -10,41 +12,70 @@ const INITIAL_ITEMS: FileItemProps[] = [ name: "editable-image.jpg", size: 5120, type: "image/jpeg", - thumbnailImageDataUrl: "/sample.jpg", + thumbnailImageDataUrl: THUMBNAIL_URL, + }, + { + id: "described-image", + name: "described-image.jpg", + size: 3072, + type: "image/jpeg", + thumbnailImageDataUrl: THUMBNAIL_URL, + description: "Already has a description", + }, + { + id: "another-image", + name: "another-image.jpg", + size: 2048, + type: "image/jpeg", + thumbnailImageDataUrl: THUMBNAIL_URL, + description: "Another described image", }, ]; export default function Story() { + const nextId = useRef(0); const [fileItems, setFileItems] = useState(INITIAL_ITEMS); - const handleEdit = (updatedItem: FileItemProps) => { - setFileItems((prevItems) => { - return prevItems.map((item) => { - if (item.id === updatedItem.id) { - return updatedItem; - } - - return item; - }); + const handleChange = (files: File[]) => { + const newItems = files.map((file) => { + nextId.current += 1; + return { + id: `upload-file-${nextId.current}`, + name: file.name, + size: file.size, + type: file.type, + }; }); + setFileItems((prev) => prev.concat(newItems)); + }; + + const handleEdit = (updatedItem: FileItemProps) => { + setFileItems((prevItems) => + prevItems.map((item) => + item.id === updatedItem.id ? updatedItem : item + ) + ); }; const handleDelete = (deletedItem: FileItemProps) => { - setFileItems((prevItems) => { - return prevItems.filter((item) => item.id !== deletedItem.id); - }); + setFileItems((prevItems) => + prevItems.filter((item) => item.id !== deletedItem.id) + ); }; return ( ); } diff --git a/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Editable---mobile--mount.png b/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Editable---mobile--mount.png index 2d1a711d93..fad1f445db 100644 Binary files a/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Editable---mobile--mount.png and b/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Editable---mobile--mount.png differ diff --git a/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Editable--edited.png b/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Editable--edited.png index 4f0c7fe1f3..295bf97d66 100644 Binary files a/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Editable--edited.png and b/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Editable--edited.png differ diff --git a/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Editable--mount.png b/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Editable--mount.png index 2f0fe75bc9..ccb4f3bb8f 100644 Binary files a/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Editable--mount.png and b/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Editable--mount.png differ diff --git a/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Form-states-Dark-mode--mount.png b/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Form-states-Dark-mode--mount.png index 32d8fe7e7c..584b14614c 100644 Binary files a/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Form-states-Dark-mode--mount.png and b/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Form-states-Dark-mode--mount.png differ diff --git a/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Form-states-Mobile--mount.png b/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Form-states-Mobile--mount.png index 6878387a5d..437c0da140 100644 Binary files a/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Form-states-Mobile--mount.png and b/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Form-states-Mobile--mount.png differ diff --git a/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Form-states-Visual--mount.png b/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Form-states-Visual--mount.png index 4b1d170caf..e4ed54a33d 100644 Binary files a/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Form-states-Visual--mount.png and b/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Form-states-Visual--mount.png differ diff --git a/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Long-description-text--mount.png b/e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Long-description-text---mobile--mount.png similarity index 100% rename from e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Long-description-text--mount.png rename to e2e/tests/components/file-upload/__screenshots__/chromium/FileUpload-Long-description-text---mobile--mount.png diff --git a/e2e/tests/components/file-upload/file-upload.e2e.spec.ts b/e2e/tests/components/file-upload/file-upload.e2e.spec.ts index c36e46473e..f6ed360a2a 100644 --- a/e2e/tests/components/file-upload/file-upload.e2e.spec.ts +++ b/e2e/tests/components/file-upload/file-upload.e2e.spec.ts @@ -328,9 +328,21 @@ test.describe("FileUpload", () => { story.locators.internal.editDisplay("editable-image") ).toBeVisible(); + const dragHandle = story.locators.fileUpload.locator( + '[data-testid$="-drag-handle"]' + ); + await expect(dragHandle).toHaveCount(0); + await compareScreenshot(story, "mount"); }); + await test.step("Sort disabled while an item is in edit mode", async () => { + const dragHandle = story.locators.fileUpload.locator( + '[data-testid$="-drag-handle"]' + ); + await expect(dragHandle).toHaveCount(0); + }); + await test.step("Save description", async () => { await story.locators.internal .textarea("editable-image") @@ -350,10 +362,20 @@ test.describe("FileUpload", () => { }); }); + await test.step("Sort enabled after all items in display mode", async () => { + const dragHandle = story.locators.fileUpload.locator( + '[data-testid$="-drag-handle"]' + ); + await expect(dragHandle.first()).toBeVisible(); + }); + await test.step("Cancel edit keeps saved description", async () => { await story.locators.internal .editButton("editable-image") .click(); + await expect( + story.locators.internal.textarea("editable-image") + ).toBeVisible(); await story.locators.internal .textarea("editable-image") .fill("Temporary change"); @@ -367,6 +389,13 @@ test.describe("FileUpload", () => { ) ).toBeVisible(); }); + + await test.step("Sort restored after cancel", async () => { + const dragHandle = story.locators.fileUpload.locator( + '[data-testid$="-drag-handle"]' + ); + await expect(dragHandle.first()).toBeVisible(); + }); }); }); @@ -467,7 +496,7 @@ test.describe("FileUpload", () => { await story.init("long-description", { size: "mobile" }); }); - test("Long description text", async ({ story }) => { + test("Long description text - mobile", async ({ story }) => { await compareScreenshot(story, "mount", { locator: story.locators.fileUpload, }); diff --git a/src/file-upload/file-item-edit.styles.ts b/src/file-upload/file-item-edit.styles.ts deleted file mode 100644 index e95c2e6eaa..0000000000 --- a/src/file-upload/file-item-edit.styles.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { css } from "@linaria/core"; - -import { Border, Colour, MediaQuery, Radius, Spacing } from "../theme"; - -export const item = css` - display: flex; - flex-direction: column; - padding: ${Spacing["spacing-32"]}; - background: ${Colour["bg-primary-subtlest"]}; - border: ${Border["width-010"]} ${Border.solid} ${Colour.border}; - border-radius: ${Radius["sm"]}; - - &:not(:last-child) { - margin-bottom: ${Spacing["spacing-16"]}; - } -`; - -export const contentSection = css` - display: flex; - align-items: flex-start; - margin-bottom: ${Spacing["spacing-16"]}; - width: 100%; -`; - -export const detailsSection = css` - display: flex; - flex: 1; - flex-direction: column; -`; - -export const nameSection = css` - width: 100%; - display: flex; - justify-content: space-between; - margin-bottom: ${Spacing["spacing-16"]}; - - ${MediaQuery.MaxWidth.md} { - flex-direction: column; - justify-content: flex-start; - } -`; - -export const fileNameText = css` - display: flex; - flex: 1; - margin-right: ${Spacing["spacing-16"]}; - - ${MediaQuery.MaxWidth.md} { - margin-right: 0; - margin-bottom: ${Spacing["spacing-8"]}; - } -`; - -export const fileSizeText = css` - color: ${Colour["text-subtler"]}; -`; - -export const actionButtonsSection = css` - display: flex; - justify-content: flex-end; - - ${MediaQuery.MaxWidth.md} { - flex-direction: column; - } -`; - -export const actionButtonsSectionWithThumbnail = css` - margin-left: calc( - 96px + ${Spacing["spacing-32"]} - ); // thumbnail width + right margin - - ${MediaQuery.MaxWidth.md} { - margin-left: 0; - } -`; - -export const actionButton = css` - width: 7.5rem; - - &:not(:last-of-type) { - margin-right: ${Spacing["spacing-16"]}; - } - - ${MediaQuery.MaxWidth.md} { - width: 100%; - - &:not(:last-of-type) { - margin-bottom: ${Spacing["spacing-16"]}; - } - } -`; diff --git a/src/file-upload/file-item-edit.tsx b/src/file-upload/file-item-edit.tsx deleted file mode 100644 index 80713f5c2e..0000000000 --- a/src/file-upload/file-item-edit.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import clsx from "clsx"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import { Button } from "../button"; -import { Form } from "../form"; -import type { FormLabelProps } from "../form/form-label/types"; -import { Typography } from "../typography"; -import { StringHelper } from "../util"; -import * as styles from "./file-item-edit.styles"; -import { FileListItemThumbnail } from "./file-list-item/file-list-item-thumbnail"; -import { FileUploadHelper } from "./helper"; -import type { FileItemProps } from "./types"; - -interface Props { - fileItem: FileItemProps; - wrapperWidth: number; - fileDescriptionMaxLength?: number | undefined; - descriptionRequired?: boolean | undefined; - descriptionLabel?: FormLabelProps | undefined; - onSave: (description: string) => void; - onCancel: () => void; - onBlur: (value: string) => void; -} - -export const FileItemEdit = ({ - fileItem, - fileDescriptionMaxLength, - descriptionRequired = true, - descriptionLabel, - wrapperWidth, - onSave, - onCancel, - onBlur, -}: Props) => { - // ========================================================================= - // CONST, STATE, REFS - // ========================================================================= - const { - id, - name, - size, - type, - truncateText = true, - thumbnailImageDataUrl, - } = fileItem; - - const [formattedName, setFormattedName] = useState(); - const [currentDescription, setCurrentDescription] = useState(""); - - const textareaRef = useRef(null); - const nameSectionRef = useRef(null); - - // ========================================================================= - // HELPER FUNCTIONS - // ========================================================================= - const getTruncatedText = useCallback( - (value: string) => { - if (!truncateText) return value; - - const widthOfElement = - nameSectionRef && nameSectionRef.current - ? nameSectionRef.current.getBoundingClientRect().width - : 0; - - return StringHelper.truncateOneLine( - value, - widthOfElement, - widthOfElement / 2, - widthOfElement / 2 / 8, // Arbitrary - 16 // Font size - ); - }, - [truncateText] - ); - - // ========================================================================= - // EFFECTS - // ========================================================================= - useEffect(() => { - setFormattedName(getTruncatedText(name)); - }, [wrapperWidth, getTruncatedText, name]); - - useEffect(() => { - setCurrentDescription(fileItem.description || ""); - }, [fileItem]); - - // ========================================================================= - // EVENT HANDLERS - // ========================================================================= - const handleSave = () => { - if (textareaRef.current) { - onSave(textareaRef.current.value.trim()); - } - }; - - const handleChange = (event: React.ChangeEvent) => { - setCurrentDescription(event.target.value); - }; - - const handleBlur = (event: React.FocusEvent) => { - onBlur(event.target.value); - }; - - const shouldDisableSave = () => { - if (!descriptionRequired) return false; - const trimmedDescription = currentDescription.trim(); - return trimmedDescription.length === 0; - }; - - const shouldShowThumbnail = - !!thumbnailImageDataUrl || type === FileUploadHelper.PDF_MIME_TYPE; - - // ========================================================================= - // RENDER FUNCTIONS - // ========================================================================= - const renderFileNameAndSize = () => ( -
- - {formattedName} - - - {FileUploadHelper.formatFileSizeDisplay(size)} - -
- ); - - return ( -
  • -
    - {shouldShowThumbnail && ( - - )} -
    - {renderFileNameAndSize()} - -
    -
    -
    - - -
    -
  • - ); -}; diff --git a/src/file-upload/file-list-item/file-item-actions.styles.ts b/src/file-upload/file-list-item/file-item-actions.styles.ts new file mode 100644 index 0000000000..57b8435201 --- /dev/null +++ b/src/file-upload/file-list-item/file-item-actions.styles.ts @@ -0,0 +1,82 @@ +import { css } from "@linaria/core"; + +import { MediaQuery, Spacing } from "../../theme"; + +export const actionContainer = css` + width: 6rem; + margin-left: ${Spacing["spacing-32"]}; + display: flex; + justify-content: flex-end; + align-items: center; + + ${MediaQuery.MaxWidth.md} { + width: fit-content; + } + + &[data-mobile-layout="loading"] { + ${MediaQuery.MaxWidth.md} { + margin-left: 0; + margin-top: ${Spacing["spacing-16"]}; + width: 100%; + } + } + + &[data-mobile-layout="editable"] { + ${MediaQuery.MaxWidth.md} { + margin-left: 0; + margin-top: ${Spacing["spacing-16"]}; + align-self: flex-end; + } + } +`; + +export const inlineActionContainer = css` + width: 100%; + margin-top: ${Spacing["spacing-16"]}; + display: flex; + justify-content: flex-end; + align-items: center; +`; + +export const actionButtonsSection = css` + display: flex; + justify-content: flex-end; + + ${MediaQuery.MaxWidth.md} { + flex-direction: column; + } +`; + +export const actionButtonsSectionWithThumbnail = css` + margin-left: calc( + 96px + ${Spacing["spacing-32"]} + ); // thumbnail width + right margin + + ${MediaQuery.MaxWidth.md} { + margin-left: 0; + } +`; + +export const actionButton = css` + width: 7.5rem; + + &:not(:last-of-type) { + margin-right: ${Spacing["spacing-16"]}; + } + + ${MediaQuery.MaxWidth.md} { + width: 100%; + + &:not(:last-of-type) { + margin-bottom: ${Spacing["spacing-16"]}; + } + } +`; + +export const iconButton = css` + min-width: unset; + + &:not(:last-child) { + margin-right: ${Spacing["spacing-8"]}; + } +`; diff --git a/src/file-upload/file-list-item/file-item-actions.tsx b/src/file-upload/file-list-item/file-item-actions.tsx new file mode 100644 index 0000000000..8a17373c0c --- /dev/null +++ b/src/file-upload/file-list-item/file-item-actions.tsx @@ -0,0 +1,203 @@ +import { BinIcon } from "@lifesg/react-icons/bin"; +import { PencilIcon } from "@lifesg/react-icons/pencil"; +import clsx from "clsx"; + +import { Button } from "../../button"; +import { ProgressBar } from "../../shared/progress-bar"; +import * as styles from "./file-item-actions.styles"; + +// ============================================================================= +// TYPES +// ============================================================================= +interface BaseProps { + id: string; + name: string; +} + +interface ErrorActionProps extends BaseProps { + mode: "error"; + errorMessage?: string | React.ReactNode | undefined; + onDelete: () => void; +} + +interface DisplayActionProps extends BaseProps { + mode: "display"; + inline?: boolean | undefined; + editable?: boolean | undefined; + isLoading: boolean; + progress: number; + disabled: boolean; + onDelete: () => void; + onEdit?: (() => void) | undefined; + onKeyDown?: + | ((event: React.KeyboardEvent) => void) + | undefined; +} + +interface EditActionProps extends BaseProps { + mode: "edit"; + hasThumbnail: boolean; + disableSave: boolean; + onSave: () => void; + onCancel: () => void; +} + +type FileItemActionsProps = + | ErrorActionProps + | DisplayActionProps + | EditActionProps; + +// ============================================================================= +// COMPONENT +// ============================================================================= +export const FileItemActions = (props: FileItemActionsProps) => { + const { mode, id, name } = props; + + if (mode === "error") { + return ( +
    +
    + ); + } + + if (mode === "edit") { + return ( +
    + + +
    + ); + } + + // Display mode + if (props.inline) { + return ( +
    +
    + ); + } + + const getActionContainerLayout = () => { + if (props.isLoading) return "loading"; + if (props.editable) return "editable"; + return undefined; + }; + + let content: JSX.Element; + + if (props.isLoading) { + content = ( + + ); + } else { + content = ( + <> + {props.editable && ( +