From 36d1078acea20e4b76fd43cae579bdde75158332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Jamro=C5=BCek?= Date: Mon, 13 May 2024 08:31:50 +0200 Subject: [PATCH 01/10] feat: TET-853 add FileItem component --- src/components/FileItem/FileItem.props.ts | 33 ++++ src/components/FileItem/FileItem.stories.tsx | 170 ++++++++++++++++++ src/components/FileItem/FileItem.styles.ts | 65 +++++++ src/components/FileItem/FileItem.test.tsx | 151 ++++++++++++++++ src/components/FileItem/FileItem.tsx | 59 ++++++ .../CompressedVariant.props.ts | 16 ++ .../CompressedVariant.styles.ts | 74 ++++++++ .../CompressedVariant/CompressedVariant.tsx | 99 ++++++++++ .../components/CompressedVariant/index.ts | 3 + .../CompressedVariant/stylesBuilder.ts | 47 +++++ .../ExtendedVariant/ExtendedVariant.props.ts | 18 ++ .../ExtendedVariant/ExtendedVariant.styles.ts | 111 ++++++++++++ .../ExtendedVariant/ExtendedVariant.tsx | 146 +++++++++++++++ .../components/ExtendedVariant/index.ts | 3 + .../ExtendedVariant/stylesBuilder.ts | 65 +++++++ .../ProgressBar/ProgressBar.props.ts | 7 + .../ProgressBar/ProgressBar.styles.ts | 34 ++++ .../components/ProgressBar/ProgressBar.tsx | 22 +++ .../FileItem/components/ProgressBar/index.ts | 3 + .../components/ProgressBar/stylesBuilder.ts | 33 ++++ src/components/FileItem/components/index.ts | 3 + src/components/FileItem/index.ts | 1 + src/components/FileItem/mocks.ts | 12 ++ src/components/FileItem/stylesBuilder.ts | 45 +++++ src/components/FileItem/types.ts | 3 + src/docs-components/FileItemDocs.tsx | 145 +++++++++++++++ src/index.ts | 1 + src/services/files.ts | 54 ++++++ src/services/index.ts | 3 +- 29 files changed, 1425 insertions(+), 1 deletion(-) create mode 100644 src/components/FileItem/FileItem.props.ts create mode 100644 src/components/FileItem/FileItem.stories.tsx create mode 100644 src/components/FileItem/FileItem.styles.ts create mode 100644 src/components/FileItem/FileItem.test.tsx create mode 100644 src/components/FileItem/FileItem.tsx create mode 100644 src/components/FileItem/components/CompressedVariant/CompressedVariant.props.ts create mode 100644 src/components/FileItem/components/CompressedVariant/CompressedVariant.styles.ts create mode 100644 src/components/FileItem/components/CompressedVariant/CompressedVariant.tsx create mode 100644 src/components/FileItem/components/CompressedVariant/index.ts create mode 100644 src/components/FileItem/components/CompressedVariant/stylesBuilder.ts create mode 100644 src/components/FileItem/components/ExtendedVariant/ExtendedVariant.props.ts create mode 100644 src/components/FileItem/components/ExtendedVariant/ExtendedVariant.styles.ts create mode 100644 src/components/FileItem/components/ExtendedVariant/ExtendedVariant.tsx create mode 100644 src/components/FileItem/components/ExtendedVariant/index.ts create mode 100644 src/components/FileItem/components/ExtendedVariant/stylesBuilder.ts create mode 100644 src/components/FileItem/components/ProgressBar/ProgressBar.props.ts create mode 100644 src/components/FileItem/components/ProgressBar/ProgressBar.styles.ts create mode 100644 src/components/FileItem/components/ProgressBar/ProgressBar.tsx create mode 100644 src/components/FileItem/components/ProgressBar/index.ts create mode 100644 src/components/FileItem/components/ProgressBar/stylesBuilder.ts create mode 100644 src/components/FileItem/components/index.ts create mode 100644 src/components/FileItem/index.ts create mode 100644 src/components/FileItem/mocks.ts create mode 100644 src/components/FileItem/stylesBuilder.ts create mode 100644 src/components/FileItem/types.ts create mode 100644 src/docs-components/FileItemDocs.tsx create mode 100644 src/services/files.ts diff --git a/src/components/FileItem/FileItem.props.ts b/src/components/FileItem/FileItem.props.ts new file mode 100644 index 00000000..bef759d7 --- /dev/null +++ b/src/components/FileItem/FileItem.props.ts @@ -0,0 +1,33 @@ +import { MouseEvent } from 'react'; + +import { FileItemConfig } from './FileItem.styles'; +import { FileItemState, FileItemThumbnail } from './types'; + +export type FileItemProps = { + custom?: FileItemConfig; + file: File; + state?: FileItemState; + isInverted?: boolean; + isExtended?: boolean; + thumbnail?: FileItemThumbnail; + uploadedPercentage?: number; + timeLeftText?: string; + alertText?: string; + onReplaceClick?: (e?: MouseEvent) => void; + onRetryClick?: (e?: MouseEvent) => void; + onCloseClick?: (e?: MouseEvent) => void; +}; + +export type Fallback = { + state: FileItemState; + isInverted: boolean; + isExtended: boolean; + thumbnail: FileItemThumbnail; +}; + +export const fallback: Fallback = { + state: 'uploading', + isInverted: false, + isExtended: false, + thumbnail: 'none', +}; diff --git a/src/components/FileItem/FileItem.stories.tsx b/src/components/FileItem/FileItem.stories.tsx new file mode 100644 index 00000000..0e075412 --- /dev/null +++ b/src/components/FileItem/FileItem.stories.tsx @@ -0,0 +1,170 @@ +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { FileItem } from './FileItem'; +import { mockTextFile, mockImageFile } from './mocks'; + +import { FileItemDocs } from '@/docs-components/FileItemDocs'; +import { TetDocs } from '@/docs-components/TetDocs'; + +const meta = { + title: 'FileItem', + component: FileItem, + tags: ['autodocs'], + argTypes: {}, + args: { + file: mockTextFile(), + state: 'uploaded', + isInverted: false, + isExtended: false, + thumbnail: 'none', + uploadedPercentage: 25, + timeLeftText: '7 seconds left', + alertText: 'Short alert text', + onReplaceClick: action('onReplaceClick'), + onRetryClick: action('onRetryClick'), + onCloseClick: action('onCloseClick'), + }, + parameters: { + docs: { + description: { + component: + 'Enable users to upload specific files, such as images, documents, or videos, to a particular location. The user can perform this action by dragging and dropping files into the designated area or browsing local storage.', + }, + page: () => ( + + + + ), + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + file: mockTextFile(), + state: 'uploaded', + isExtended: false, + thumbnail: 'none', + onCloseClick: action('onCloseClick'), + }, +}; + +export const Uploading: Story = { + args: { + file: mockTextFile(), + state: 'uploading', + uploadedPercentage: 25, + onCloseClick: action('onCloseClick'), + }, +}; + +export const Uploaded: Story = { + args: { + file: mockTextFile(), + state: 'uploaded', + onCloseClick: action('onCloseClick'), + }, +}; + +export const Replaceable: Story = { + args: { + file: mockTextFile(), + state: 'replaceable', + onReplaceClick: action('onReplaceClick'), + onCloseClick: action('onCloseClick'), + }, +}; + +export const Alert: Story = { + args: { + file: mockTextFile(), + state: 'alert', + alertText: 'Short alert text', + onRetryClick: action('onRetryClick'), + onCloseClick: action('onCloseClick'), + }, +}; + +export const ExtendedUploading: Story = { + args: { + file: mockTextFile(), + state: 'uploading', + isExtended: true, + uploadedPercentage: 25, + timeLeftText: '7 seconds left', + onCloseClick: action('onCloseClick'), + }, +}; + +export const ExtendedUploaded: Story = { + args: { + file: mockTextFile(), + state: 'uploaded', + isExtended: true, + onCloseClick: action('onCloseClick'), + }, +}; + +export const ExtendedReplaceable: Story = { + args: { + file: mockTextFile(), + state: 'replaceable', + isExtended: true, + onReplaceClick: action('onReplaceClick'), + onCloseClick: action('onCloseClick'), + }, +}; + +export const ExtendedAlert: Story = { + args: { + file: mockTextFile(), + state: 'alert', + isExtended: true, + alertText: 'Short alert text', + onRetryClick: action('onRetryClick'), + onCloseClick: action('onCloseClick'), + }, +}; + +export const ExtendedUploadingFile: Story = { + args: { + file: mockTextFile(), + state: 'uploading', + isExtended: true, + thumbnail: 'file', + uploadedPercentage: 25, + timeLeftText: '7 seconds left', + onCloseClick: action('onCloseClick'), + }, +}; + +export const ExtendedAlertImage: Story = { + args: { + file: mockImageFile(), + state: 'alert', + isExtended: true, + thumbnail: 'photo', + alertText: 'Short alert text', + onReplaceClick: action('onReplaceClick'), + onRetryClick: action('onRetryClick'), + onCloseClick: action('onCloseClick'), + }, +}; + +export const ExtendedInvertedAlertImage: Story = { + args: { + file: mockImageFile(), + state: 'alert', + isExtended: true, + isInverted: true, + thumbnail: 'photo', + alertText: 'Short alert text', + onReplaceClick: action('onReplaceClick'), + onRetryClick: action('onRetryClick'), + onCloseClick: action('onCloseClick'), + }, +}; diff --git a/src/components/FileItem/FileItem.styles.ts b/src/components/FileItem/FileItem.styles.ts new file mode 100644 index 00000000..9f43a388 --- /dev/null +++ b/src/components/FileItem/FileItem.styles.ts @@ -0,0 +1,65 @@ +import { compressedVariantStyles, extendedVariantStyles } from './components'; +import { FileItemThumbnail } from './types'; + +import type { BaseProps } from '@/types/BaseProps'; + +export type FileItemConfig = BaseProps & { + state?: { + uploading?: BaseProps; + uploaded?: BaseProps; + replaceable?: BaseProps; + alert?: BaseProps; + }; + inverted?: { + yes?: BaseProps; + no?: BaseProps; + }; + thumbnail?: Record; + invertedAlert?: BaseProps; + compressed?: BaseProps; + extended?: BaseProps; +}; + +export const defaultConfig = { + display: 'flex', + flexDirection: 'column', + gap: '$space-component-gap-small', + borderRadius: '$border-radius-large', + state: { + uploading: { + backgroundColor: '$color-interaction-neutral-subtle-normal', + }, + uploaded: { + backgroundColor: '$color-interaction-default-subtle-normal', + }, + replaceable: { + backgroundColor: '$color-interaction-default-subtle-normal', + }, + alert: { + backgroundColor: '$color-interaction-alert-subtle-normal', + }, + }, + inverted: { + yes: { + backgroundColor: '$color-interaction-background-formField', + borderWidth: '$border-width-100', + borderStyle: '$border-style-solid', + borderColor: '$color-interaction-border-neutral-normal', + }, + no: {}, + }, + thumbnail: { + none: {}, + file: {}, + photo: {}, + }, + invertedAlert: { + borderColor: '$color-interaction-border-alert', + }, + compressed: compressedVariantStyles.defaultConfig, + extended: extendedVariantStyles.defaultConfig, +} as const satisfies FileItemConfig; + +export const fileItemStyles = { + defaultConfig, +}; diff --git a/src/components/FileItem/FileItem.test.tsx b/src/components/FileItem/FileItem.test.tsx new file mode 100644 index 00000000..531945fa --- /dev/null +++ b/src/components/FileItem/FileItem.test.tsx @@ -0,0 +1,151 @@ +import { vi } from 'vitest'; + +import { FileItem } from './FileItem'; +import { mockImageFile, mockTextFile } from './mocks'; +import { render, screen, fireEvent } from '../../tests/render'; + +describe('FileItem', () => { + it('should render empty file item', () => { + render(); + const fileItem = screen.getByTestId('file-item'); + expect(fileItem).toBeInTheDocument(); + + const fileName = screen.getByTestId('file-name'); + expect(fileName).toBeInTheDocument(); + + const fileSize = screen.getByTestId('file-size'); + expect(fileSize).toBeInTheDocument(); + + const closeIcon = screen.getByTestId('close-icon'); + expect(closeIcon).toBeInTheDocument(); + }); + + it('should call onReplaceClick after click to Replace button', () => { + const onReplaceClick = vi.fn(); + + render( + , + ); + + const replaceableButton = screen.getByTestId('replaceable-button'); + expect(replaceableButton).toBeInTheDocument(); + + fireEvent.click(replaceableButton); + expect(onReplaceClick).toHaveBeenCalled(); + }); + + it('should call onRetryClick after click to Retry button', () => { + const onRetryClick = vi.fn(); + + render( + , + ); + + const retryButton = screen.getByTestId('retry-button'); + expect(retryButton).toBeInTheDocument(); + + fireEvent.click(retryButton); + expect(onRetryClick).toHaveBeenCalled(); + }); + + it('should call onCloseClick after click to close button', () => { + const onCloseClick = vi.fn(); + + render( + , + ); + + const closeButton = screen.getByTestId('close-icon'); + expect(closeButton).toBeInTheDocument(); + + fireEvent.click(closeButton); + expect(onCloseClick).toHaveBeenCalled(); + }); + + it('should render progress bar in uploading state', () => { + render(); + const progressBar = screen.getByTestId('progress-bar'); + expect(progressBar).toBeInTheDocument(); + }); + + it('should show uploaded percentage in uploading state', () => { + render( + , + ); + const uploadedPercentage = screen.getByTestId('uploaded-percentage'); + expect(uploadedPercentage).toBeInTheDocument(); + expect(uploadedPercentage.innerHTML).toEqual('76.25%'); + }); + + it('should show alert text in compressed alert state', () => { + render( + , + ); + const alertText = screen.getByTestId('alert-text'); + expect(alertText).toBeInTheDocument(); + expect(alertText.innerHTML).toEqual('Short alert text'); + }); + + it('should show alert text in extended alert state', () => { + render( + , + ); + const alertInfo = screen.getByTestId('alert-info'); + expect(alertInfo).toBeInTheDocument(); + expect(alertInfo.innerHTML).toContain('Short alert text'); + }); + + it('should show thumbnail file icon', () => { + render( + , + ); + const thumbnailFile = screen.getByTestId('thumbnail-file'); + expect(thumbnailFile).toBeInTheDocument(); + }); + + it('should show thumbnail photo image', () => { + Object.defineProperty(window.URL, 'createObjectURL', { value: () => '' }); + + render( + , + ); + const thumbnailPhoto = screen.getByTestId('thumbnail-photo'); + expect(thumbnailPhoto).toBeInTheDocument(); + }); +}); diff --git a/src/components/FileItem/FileItem.tsx b/src/components/FileItem/FileItem.tsx new file mode 100644 index 00000000..82952af2 --- /dev/null +++ b/src/components/FileItem/FileItem.tsx @@ -0,0 +1,59 @@ +import { FC } from 'react'; + +import { CompressedVariant, ExtendedVariant } from './components'; +import { FileItemProps, fallback } from './FileItem.props'; +import { stylesBuilder } from './stylesBuilder'; + +import { tet } from '@/tetrisly'; + +export const FileItem: FC = (props) => { + const { + file, + state = fallback.state, + isInverted = fallback.isInverted, + isExtended = fallback.isExtended, + thumbnail = fallback.thumbnail, + uploadedPercentage, + timeLeftText, + alertText, + onReplaceClick, + onRetryClick, + onCloseClick, + } = props; + + const styles = stylesBuilder(props); + + return ( + + {!isExtended && ( + + )} + + {isExtended && ( + + )} + + ); +}; diff --git a/src/components/FileItem/components/CompressedVariant/CompressedVariant.props.ts b/src/components/FileItem/components/CompressedVariant/CompressedVariant.props.ts new file mode 100644 index 00000000..e3f7da1e --- /dev/null +++ b/src/components/FileItem/components/CompressedVariant/CompressedVariant.props.ts @@ -0,0 +1,16 @@ +import { MouseEvent } from 'react'; + +import { CompressedVariantConfig } from './CompressedVariant.styles'; +import { FileItemState } from '../../types'; + +export type CompressedVariantProps = { + custom?: CompressedVariantConfig; + state: FileItemState; + file: File; + isInverted?: boolean; + uploadedPercentage?: number; + alertText?: string; + onReplaceClick?: (e?: MouseEvent) => void; + onRetryClick?: (e?: MouseEvent) => void; + onCloseClick?: (e?: MouseEvent) => void; +}; diff --git a/src/components/FileItem/components/CompressedVariant/CompressedVariant.styles.ts b/src/components/FileItem/components/CompressedVariant/CompressedVariant.styles.ts new file mode 100644 index 00000000..a4d745a2 --- /dev/null +++ b/src/components/FileItem/components/CompressedVariant/CompressedVariant.styles.ts @@ -0,0 +1,74 @@ +import { ProgressBarConfig, progressBarStyles } from '../ProgressBar'; + +import type { BaseProps } from '@/types/BaseProps'; + +export type CompressedVariantConfig = BaseProps & { + innerElements?: { + fileInfo?: BaseProps; + fileName?: BaseProps; + fileSize?: BaseProps; + alertIcon?: BaseProps; + content?: BaseProps; + uploadingContent?: BaseProps; + replaceableContent?: BaseProps; + alertContent?: BaseProps; + notExtendedAlert?: BaseProps; + closeIconButton?: BaseProps; + progressBar?: ProgressBarConfig; + }; +}; + +export const defaultConfig = { + display: 'flex', + flexDirection: 'column', + gap: '$space-component-gap-xSmall', + px: '$space-component-padding-large', + py: '$space-component-padding-small', + innerElements: { + fileInfo: { + display: 'flex', + alignItems: 'center', + gap: '$space-component-gap-small', + }, + fileName: { + text: '$typo-medium-175', + color: '$color-content-primary', + }, + fileSize: { + text: '$typo-medium-175', + color: '$color-content-secondary', + }, + alertIcon: { + color: '$color-content-negative-secondary', + }, + content: { + flexGrow: 1, + mx: '$space-component-padding-large', + }, + uploadingContent: {}, + replaceableContent: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', + }, + alertContent: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', + }, + notExtendedAlert: { + text: '$typo-body-small', + color: '$color-content-negative-secondary', + }, + closeIconButton: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + }, + progressBar: progressBarStyles.defaultConfig, + }, +} as const satisfies CompressedVariantConfig; + +export const compressedVariantStyles = { + defaultConfig, +}; diff --git a/src/components/FileItem/components/CompressedVariant/CompressedVariant.tsx b/src/components/FileItem/components/CompressedVariant/CompressedVariant.tsx new file mode 100644 index 00000000..b6de324c --- /dev/null +++ b/src/components/FileItem/components/CompressedVariant/CompressedVariant.tsx @@ -0,0 +1,99 @@ +import { FC } from 'react'; + +import { CompressedVariantProps } from './CompressedVariant.props'; +import { stylesBuilder } from './stylesBuilder'; +import { ProgressBar } from '../ProgressBar'; + +import { Button } from '@/components/Button'; +import { Icon } from '@/components/Icon'; +import { IconButton } from '@/components/IconButton'; +import { formatFileSize } from '@/services'; +import { tet } from '@/tetrisly'; + +export const CompressedVariant: FC = ({ + custom, + state, + file, + isInverted, + uploadedPercentage, + alertText, + onReplaceClick, + onRetryClick, + onCloseClick, +}) => { + const styles = stylesBuilder(custom); + const formattedFileSize = formatFileSize(file.size); + + return ( + + + {state === 'alert' && ( + + )} + + + {file.name} + + + {formattedFileSize} + + + + {state === 'uploading' && ( + + + + )} + + {state === 'replaceable' && onReplaceClick && ( + +