Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FS-238]: Add File Upload Component #127

Merged
merged 2 commits into from
Sep 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@ import { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../app/**/*.stories.tsx', '../libs/design/**/*.stories.tsx'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
'storybook-addon-apollo-client',
],
framework: '@storybook/react-vite',
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ The project contains multiple modules which have their specific responsibilities

### Run

To start the app first set the envs as described in the (envs section)[#Envs] then run:
To start the app first set the envs as described in the [envs section](#envs) then run:

```bash
npm install
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useMemberListQuery } from '@camp/data-layer';
import { debug } from '@camp/debug';
import { FullPageLoader, showNotification } from '@camp/design';
import { DashboardTitle, FullPageLoader, showNotification } from '@camp/design';
import { errorMessages, messages } from '@camp/messages';
import { Center, Group, Stack, Title } from '@mantine/core';

Check warning on line 5 in app/Dashboard/Households/HouseholdDetail/_components/MemberList/MemberList.tsx

View workflow job for this annotation

GitHub Actions / verify

'Title' is defined but never used. Allowed unused vars must match /^([iI]gnore(d)?)|(_+)/u
import { useState } from 'react';

import { CreateMemberButton, MemberForm } from '../MemberForm';
Expand Down Expand Up @@ -46,9 +46,7 @@
return (
<Stack spacing={25} sx={{ position: 'relative' }}>
<Group position="apart">
<Title order={4} color="fgMuted" weight="bold">
{t.title}
</Title>
<DashboardTitle>{t.title}</DashboardTitle>
<CreateMemberButton onClick={addNewMemberHandler} />
</Group>
{isMemberEmpty ? (
Expand Down
6 changes: 6 additions & 0 deletions app/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,12 @@ export const messages = {
title: 'توضیحات',
},
},
addDocument: {
title: 'ایجاد سند جدید',
description: 'توضیحات',
addDocBtn: 'بارگذاری سند',
note: 'سند را اینجا قرار دهید',
},
},
logout: {
link: 'خروج',
Expand Down
2 changes: 1 addition & 1 deletion libs/design/Breadcrumbs/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const styles: Styles<
Record<string, any>
> = theme => ({
breadcrumb: {
'color': theme.colors.fgMuted[6],
'color': theme.colors.fgDefault[6],
'&:hover': {
color: theme.colors.indigo,
},
Expand Down
2 changes: 1 addition & 1 deletion libs/design/DashboardTitle/DashboardTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ interface Props {

export const DashboardTitle = ({ children }: Props) => {
return (
<Title order={4} color="fgMuted" weight="bold">
<Title order={4} color="fgDefault" weight="bold">
{children}
</Title>
);
Expand Down
2 changes: 1 addition & 1 deletion libs/design/EmptyState/EmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const EmptyState = ({
<Space h="md" />
<Text weight={700}>{title}</Text>
<Space h="xs" />
<Text color="fgMuted">{message}</Text>
<Text color="fgDefault">{message}</Text>
<Box p="xl">{children}</Box>
</Box>
</Center>
Expand Down
46 changes: 46 additions & 0 deletions libs/design/FileUpload/File.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { TrashIcon } from '@camp/icons';
import { ActionIcon, Box, Group, Text } from '@mantine/core';

import type { FileState } from './FileState';
import { isSuccess } from './FileState';

interface Props {
file: FileState;
onRemove?: () => Promise<void> | void;
}

export const File = ({ file, onRemove }: Props) => (
<Group
py="10px"
spacing="15px"
position="apart"
sx={theme => ({
'borderColor': theme.colors.bgMuted[6],
'borderWidth': 1,
'&:not(:last-child)': {
borderBottomStyle: 'solid',
},
})}
>
<ActionIcon>
<TrashIcon size="18px" onClick={onRemove} />
</ActionIcon>
<Group spacing="8px" align="center" noWrap>
<Box sx={{ maxWidth: '320px' }}>
<Text dir="ltr" lh="1" truncate>
{file.file.name}
</Text>
</Box>
<Box
sx={theme => ({
width: '8px',
height: '8px',
borderRadius: '100%',
backgroundColor: isSuccess(file)
? theme.colors.successDefault[6]
: theme.colors.primaryDefault[6],
})}
/>
</Group>
</Group>
);
28 changes: 28 additions & 0 deletions libs/design/FileUpload/FileList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { isNullOrEmpty } from '@fullstacksjs/toolbox';
import { Box } from '@mantine/core';

import { File } from './File';
import type { FileState } from './FileState';

interface Props {
files?: FileState[];
onRemove: (file: FileState, index: number) => void;
}

export const FileList = ({ files, onRemove }: Props) => {
if (isNullOrEmpty(files)) return null;

return (
<Box>
{files.map((file, index) => (
<File
key={file.id}
file={file}
onRemove={() => {
onRemove(file, index);
}}
/>
))}
</Box>
);
};
34 changes: 34 additions & 0 deletions libs/design/FileUpload/FileSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { messages } from '@camp/messages';
import { Button, Group, Text } from '@mantine/core';
import { forwardRef } from 'react';
import { Upload } from 'react-feather';

interface Props extends Omit<React.HTMLProps<HTMLInputElement>, 'onClick'> {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
}

const t = messages.projectDetail.addDocument;

export const FileSelect = forwardRef<HTMLInputElement, Props>(
({ onClick, ...props }, ref) => {
return (
<Group position="apart">
<input {...props} ref={ref} />
<Button disabled={props.disabled} onClick={onClick} variant="outline">
{t.addDocBtn}
</Button>
<Group
spacing="8px"
sx={theme => ({
color: props.disabled
? theme.colors.fgSubtle[6]
: theme.colors.fgMuted[6],
})}
>
<Upload />
<Text size="sm">{t.note}</Text>
</Group>
</Group>
);
},
);
21 changes: 21 additions & 0 deletions libs/design/FileUpload/FileState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
interface BaseFile {
id: number;
file: File;
}

interface SuccessFile extends BaseFile {
status: 'Success';
}

interface FailedFile extends BaseFile {
status: 'Failed';
error?: string;
}

export type FileState = FailedFile | SuccessFile;

export const isFailed = (fileState: FileState): fileState is FailedFile =>
fileState.status === 'Failed';

export const isSuccess = (fileState: FileState): fileState is SuccessFile =>
fileState.status === 'Success';
52 changes: 52 additions & 0 deletions libs/design/FileUpload/FileUpload.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/react';

import type { FileUploadProps } from './FileUpload';
import { FileUpload } from './FileUpload';

export default {
argTypes: {
onDrop: { control: false },
defaultFiles: { control: false },
},
component: FileUpload,
args: {
helper: 'فایل باید کمتر از ۲۰ مگابایت باشد',
label: 'اسناد',
},
} as Meta<FileUploadProps>;

type Story = StoryObj<FileUploadProps>;

export const Default: Story = {};

export const Disabled: Story = {
args: {
disabled: true,
},
};

export const WithFiles: Story = {
args: {
defaultFiles: [
{ name: 'Some Random Name' } as File,
{ name: 'Some Random Name Which is long' } as File,
{ name: 'Some Random Name Which is very very long' } as File,
{
name: 'Some Random Name Which is even longer than what we had before',
} as File,
],
},
};

export const Error: Story = {
args: {
variant: 'error',
required: true,
defaultFiles: [
{ name: 'Some Random Name' } as File,
{ name: 'Some Random Name Which is long' } as File,
{ name: 'Some Random Name Which is long' } as File,
{ name: 'Some Random Name Which is long' } as File,
],
},
};
127 changes: 127 additions & 0 deletions libs/design/FileUpload/FileUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { randomInt } from '@fullstacksjs/toolbox';
import type { InputWrapperProps } from '@mantine/core';
import { Input, Stack, Text } from '@mantine/core';
import { useReducer } from 'react';
import type { DropEvent, FileRejection } from 'react-dropzone';
import { useDropzone } from 'react-dropzone';

import { FileList } from './FileList';
import { FileSelect } from './FileSelect';
import type { FileState } from './FileState';

type Action =
| { type: 'Add'; files: FileState[] }
| { type: 'Remove'; id: number };

const toSuccessFile = (file: File): FileState => ({
id: randomInt(),
status: 'Success',
file,
});

const toFileState = (file: File): FileState => ({
id: randomInt(),
file,
status: 'Success',
});

const fileReducer = (state: FileState[], action: Action): FileState[] => {
switch (action.type) {
case 'Add':
return [...state, ...action.files];

case 'Remove':
return state.filter(({ id }) => id !== action.id);

default:
return state;
}
};

type FileHandler = (
acceptedFiles: File[],
fileRejections: FileRejection[],
event: DropEvent,
) => void;

type FileUploadVariant = 'default' | 'error';

export interface FileUploadProps extends Omit<InputWrapperProps, 'onDrop'> {
disabled?: boolean;
onDrop?: FileHandler;
defaultFiles?: File[];
helper?: string;
onDelete?: (index: number) => Promise<any>;
className?: string;
dropText?: string;
variant?: FileUploadVariant;
}

const empty: File[] = [];

export const FileUpload = ({
disabled,
onDrop,
onDelete,
helper,
defaultFiles = empty,
variant = 'default',
...props
}: FileUploadProps) => {
const [files, dispatch] = useReducer(
fileReducer,
defaultFiles.map(toSuccessFile),
);

const handleDrop: FileHandler = (acceptedFiles, ...args) => {
const fileStates = acceptedFiles.map(toFileState);
dispatch({ type: 'Add', files: fileStates });
onDrop?.(acceptedFiles, ...args);
};

const { getRootProps, getInputProps } = useDropzone({
onDrop: handleDrop,
disabled,
});

const { onClick, ...rootProps } = getRootProps();

const handleRemove = async (file: FileState, index: number) => {
await onDelete?.(index);
dispatch({ type: 'Remove', id: file.id });
};

return (
<Input.Wrapper {...props}>
<Stack spacing="8px" mt="8px">
<Stack
{...rootProps}
spacing="16px"
p="20px"
w="430px"
sx={theme => ({
borderWidth: 1,
borderStyle: 'dashed',
borderColor:
variant === 'error'
? theme.colors.errorDefault[6]
: theme.colors.bgDisabled[6],
borderRadius: '8px',
})}
>
<FileSelect {...getInputProps({ disabled })} onClick={onClick} />
<FileList files={files} onRemove={handleRemove} />
</Stack>
{helper ? (
<Text
size="sm"
display={variant === 'error' ? 'none' : 'block'}
color="fgMuted"
>
{helper}
</Text>
) : null}
</Stack>
</Input.Wrapper>
);
};
1 change: 1 addition & 0 deletions libs/design/FileUpload/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './FileUpload';
Loading