From ff5730d8a76aa8a8f6c65c5e08f674a0315ee1a8 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 19 Mar 2024 03:57:57 +0200 Subject: [PATCH] feat(webapp): import resource UI --- packages/webapp/package.json | 15 +- .../components/Dropzone/Dropzone.module.css | 12 + .../src/components/Dropzone/Dropzone.tsx | 266 ++++++++++++++++++ .../components/Dropzone/DropzoneProvider.tsx | 12 + .../components/Dropzone/DropzoneStatus.tsx | 36 +++ .../Dropzone/create-safe-context.tsx | 25 ++ .../webapp/src/components/Dropzone/index.ts | 1 + .../src/components/Dropzone/mine-types.ts | 39 +++ .../webapp/src/components/Stepper/Stepper.tsx | 111 ++++++++ .../components/Stepper/StepperCompleted.tsx | 9 + .../src/components/Stepper/StepperStep.tsx | 102 +++++++ .../webapp/src/components/Stepper/index.ts | 1 + .../webapp/src/components/Stepper/types.ts | 7 + .../Accounts/AccountsActionsBar.tsx | 11 +- .../Import/ImportDropzone.module.css | 32 +++ .../src/containers/Import/ImportDropzone.tsx | 49 ++++ .../Import/ImportFileMapping.module.scss | 21 ++ .../containers/Import/ImportFileMapping.tsx | 74 +++++ .../Import/ImportFileMappingForm.tsx | 70 +++++ .../containers/Import/ImportFilePreview.tsx | 121 ++++++++ .../Import/ImportFilePreviewBoot.tsx | 52 ++++ .../containers/Import/ImportFileProvider.tsx | 87 ++++++ .../Import/ImportFileUploadForm.tsx | 62 ++++ .../Import/ImportFileUploadStep.style.scss | 3 + .../Import/ImportFileUploadStep.tsx | 40 +++ .../containers/Import/ImportPage.module.scss | 11 + .../src/containers/Import/ImportPage.tsx | 19 ++ .../Import/ImportSampleDownload.module.scss | 23 ++ .../Import/ImportSampleDownload.tsx | 24 ++ .../Import/ImportStepper.module.scss | 3 + .../src/containers/Import/ImportStepper.tsx | 28 ++ packages/webapp/src/hooks/query/import.ts | 63 +++++ packages/webapp/src/routes/dashboard.tsx | 10 +- packages/webapp/src/static/json/icons.tsx | 12 + .../src/style/components/CloudSpinner.scss | 1 - packages/webapp/src/utils/is-element.ts | 17 ++ pnpm-lock.yaml | 13 + 37 files changed, 1470 insertions(+), 12 deletions(-) create mode 100644 packages/webapp/src/components/Dropzone/Dropzone.module.css create mode 100644 packages/webapp/src/components/Dropzone/Dropzone.tsx create mode 100644 packages/webapp/src/components/Dropzone/DropzoneProvider.tsx create mode 100644 packages/webapp/src/components/Dropzone/DropzoneStatus.tsx create mode 100644 packages/webapp/src/components/Dropzone/create-safe-context.tsx create mode 100644 packages/webapp/src/components/Dropzone/index.ts create mode 100644 packages/webapp/src/components/Dropzone/mine-types.ts create mode 100644 packages/webapp/src/components/Stepper/Stepper.tsx create mode 100644 packages/webapp/src/components/Stepper/StepperCompleted.tsx create mode 100644 packages/webapp/src/components/Stepper/StepperStep.tsx create mode 100644 packages/webapp/src/components/Stepper/index.ts create mode 100644 packages/webapp/src/components/Stepper/types.ts create mode 100644 packages/webapp/src/containers/Import/ImportDropzone.module.css create mode 100644 packages/webapp/src/containers/Import/ImportDropzone.tsx create mode 100644 packages/webapp/src/containers/Import/ImportFileMapping.module.scss create mode 100644 packages/webapp/src/containers/Import/ImportFileMapping.tsx create mode 100644 packages/webapp/src/containers/Import/ImportFileMappingForm.tsx create mode 100644 packages/webapp/src/containers/Import/ImportFilePreview.tsx create mode 100644 packages/webapp/src/containers/Import/ImportFilePreviewBoot.tsx create mode 100644 packages/webapp/src/containers/Import/ImportFileProvider.tsx create mode 100644 packages/webapp/src/containers/Import/ImportFileUploadForm.tsx create mode 100644 packages/webapp/src/containers/Import/ImportFileUploadStep.style.scss create mode 100644 packages/webapp/src/containers/Import/ImportFileUploadStep.tsx create mode 100644 packages/webapp/src/containers/Import/ImportPage.module.scss create mode 100644 packages/webapp/src/containers/Import/ImportPage.tsx create mode 100644 packages/webapp/src/containers/Import/ImportSampleDownload.module.scss create mode 100644 packages/webapp/src/containers/Import/ImportSampleDownload.tsx create mode 100644 packages/webapp/src/containers/Import/ImportStepper.module.scss create mode 100644 packages/webapp/src/containers/Import/ImportStepper.tsx create mode 100644 packages/webapp/src/hooks/query/import.ts create mode 100644 packages/webapp/src/utils/is-element.ts diff --git a/packages/webapp/package.json b/packages/webapp/package.json index 9d7ab57ae..5d8e33da8 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -20,11 +20,11 @@ "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.4.0", "@testing-library/user-event": "^7.2.1", - "@tiptap/extension-color": "latest", - "@tiptap/extension-text-style": "2.1.13", "@tiptap/core": "2.1.13", - "@tiptap/pm": "2.1.13", + "@tiptap/extension-color": "latest", "@tiptap/extension-list-item": "2.1.13", + "@tiptap/extension-text-style": "2.1.13", + "@tiptap/pm": "2.1.13", "@tiptap/react": "2.1.13", "@tiptap/starter-kit": "2.1.13", "@types/jest": "^26.0.15", @@ -38,9 +38,9 @@ "@types/react-redux": "^7.1.24", "@types/react-router-dom": "^5.3.3", "@types/react-transition-group": "^4.4.5", + "@types/socket.io-client": "^3.0.0", "@types/styled-components": "^5.1.25", "@types/yup": "^0.29.13", - "@types/socket.io-client": "^3.0.0", "@typescript-eslint/eslint-plugin": "^2.10.0", "@typescript-eslint/parser": "^2.10.0", "@welldone-software/why-did-you-render": "^6.0.0-rc.1", @@ -69,10 +69,9 @@ "moment": "^2.24.0", "moment-timezone": "^0.5.33", "path-browserify": "^1.0.1", - "prop-types": "15.8.1", "plaid": "^9.3.0", "plaid-threads": "^11.4.3", - "react-plaid-link": "^3.2.1", + "prop-types": "15.8.1", "query-string": "^7.1.1", "ramda": "^0.27.1", "react": "^18.2.0", @@ -82,11 +81,13 @@ "react-dev-utils": "^11.0.4", "react-dom": "^18.2.0", "react-dropzone": "^11.0.1", + "react-dropzone-esm": "^15.0.1", "react-error-boundary": "^3.0.2", "react-error-overlay": "^6.0.9", "react-hotkeys-hook": "^3.0.3", "react-intl-universal": "^2.4.7", "react-loadable": "^5.5.0", + "react-plaid-link": "^3.2.1", "react-query": "^3.6.0", "react-query-devtools": "^2.1.1", "react-redux": "^7.2.9", @@ -112,10 +113,10 @@ "rtl-detect": "^1.0.3", "sass": "^1.68.0", "semver": "6.3.0", + "socket.io-client": "^4.7.4", "style-loader": "0.23.1", "styled-components": "^5.3.1", "stylis-rtlcss": "^2.1.1", - "socket.io-client": "^4.7.4", "typescript": "^4.8.3", "yup": "^0.28.1" }, diff --git a/packages/webapp/src/components/Dropzone/Dropzone.module.css b/packages/webapp/src/components/Dropzone/Dropzone.module.css new file mode 100644 index 000000000..63a207805 --- /dev/null +++ b/packages/webapp/src/components/Dropzone/Dropzone.module.css @@ -0,0 +1,12 @@ + + +.root { + padding: 20px; + border: 2px dotted #c5cbd3; + border-radius: 6px; + min-height: 200px; + display: flex; + flex-direction: column; + background: #fff; + position: relative; +} \ No newline at end of file diff --git a/packages/webapp/src/components/Dropzone/Dropzone.tsx b/packages/webapp/src/components/Dropzone/Dropzone.tsx new file mode 100644 index 000000000..cf335d7ca --- /dev/null +++ b/packages/webapp/src/components/Dropzone/Dropzone.tsx @@ -0,0 +1,266 @@ +// @ts-nocheck +import React from 'react'; +import clsx from 'classnames'; +import { + Accept, + DropEvent, + FileError, + FileRejection, + FileWithPath, + useDropzone, +} from 'react-dropzone-esm'; +import { DropzoneProvider } from './DropzoneProvider'; +import { DropzoneAccept, DropzoneIdle, DropzoneReject } from './DropzoneStatus'; +import { Box } from '../Layout'; +import styles from './Dropzone.module.css'; +import { CloudLoadingIndicator } from '../Indicator'; + +export type DropzoneStylesNames = 'root' | 'inner'; +export type DropzoneVariant = 'filled' | 'light'; +export type DropzoneCssVariables = { + root: + | '--dropzone-radius' + | '--dropzone-accept-color' + | '--dropzone-accept-bg' + | '--dropzone-reject-color' + | '--dropzone-reject-bg'; +}; + +export interface DropzoneProps { + /** Key of `theme.colors` or any valid CSS color to set colors of `Dropzone.Accept`, `theme.primaryColor` by default */ + acceptColor?: MantineColor; + + /** Key of `theme.colors` or any valid CSS color to set colors of `Dropzone.Reject`, `'red'` by default */ + rejectColor?: MantineColor; + + /** Key of `theme.radius` or any valid CSS value to set `border-radius`, numbers are converted to rem, `theme.defaultRadius` by default */ + radius?: MantineRadius; + + /** Determines whether files capturing should be disabled, `false` by default */ + disabled?: boolean; + + /** Called when any files are dropped to the dropzone */ + onDropAny?: (files: FileWithPath[], fileRejections: FileRejection[]) => void; + + /** Called when valid files are dropped to the dropzone */ + onDrop: (files: FileWithPath[]) => void; + + /** Called when dropped files do not meet file restrictions */ + onReject?: (fileRejections: FileRejection[]) => void; + + /** Determines whether a loading overlay should be displayed over the dropzone, `false` by default */ + loading?: boolean; + + /** Mime types of the files that dropzone can accepts. By default, dropzone accepts all file types. */ + accept?: Accept | string[]; + + /** A ref function which when called opens the file system file picker */ + openRef?: React.ForwardedRef<() => void | undefined>; + + /** Determines whether multiple files can be dropped to the dropzone or selected from file system picker, `true` by default */ + multiple?: boolean; + + /** Maximum file size in bytes */ + maxSize?: number; + + /** Name of the form control. Submitted with the form as part of a name/value pair. */ + name?: string; + + /** Maximum number of files that can be picked at once */ + maxFiles?: number; + + /** Set to autofocus the root element */ + autoFocus?: boolean; + + /** If `false`, disables click to open the native file selection dialog */ + activateOnClick?: boolean; + + /** If `false`, disables drag 'n' drop */ + activateOnDrag?: boolean; + + /** If `false`, disables Space/Enter to open the native file selection dialog. Note that it also stops tracking the focus state. */ + activateOnKeyboard?: boolean; + + /** If `false`, stops drag event propagation to parents */ + dragEventsBubbling?: boolean; + + /** Called when the `dragenter` event occurs */ + onDragEnter?: (event: React.DragEvent) => void; + + /** Called when the `dragleave` event occurs */ + onDragLeave?: (event: React.DragEvent) => void; + + /** Called when the `dragover` event occurs */ + onDragOver?: (event: React.DragEvent) => void; + + /** Called when user closes the file selection dialog with no selection */ + onFileDialogCancel?: () => void; + + /** Called when user opens the file selection dialog */ + onFileDialogOpen?: () => void; + + /** If `false`, allow dropped items to take over the current browser window */ + preventDropOnDocument?: boolean; + + /** Set to true to use the File System Access API to open the file picker instead of using an click event, defaults to true */ + useFsAccessApi?: boolean; + + /** Use this to provide a custom file aggregator */ + getFilesFromEvent?: ( + event: DropEvent, + ) => Promise>; + + /** Custom validation function. It must return null if there's no errors. */ + validator?: (file: T) => FileError | FileError[] | null; + + /** Determines whether pointer events should be enabled on the inner element, `false` by default */ + enablePointerEvents?: boolean; + + /** Props passed down to the Loader component */ + loaderProps?: LoaderProps; + + /** Props passed down to the internal Input component */ + inputProps?: React.InputHTMLAttributes; +} + +export type DropzoneFactory = Factory<{ + props: DropzoneProps; + ref: HTMLDivElement; + stylesNames: DropzoneStylesNames; + vars: DropzoneCssVariables; + staticComponents: { + Accept: typeof DropzoneAccept; + Idle: typeof DropzoneIdle; + Reject: typeof DropzoneReject; + }; +}>; + +const defaultProps: Partial = { + loading: false, + multiple: true, + maxSize: Infinity, + autoFocus: false, + activateOnClick: true, + activateOnDrag: true, + dragEventsBubbling: true, + activateOnKeyboard: true, + useFsAccessApi: true, + variant: 'light', + rejectColor: 'red', +}; + +export const Dropzone = (_props: DropzoneProps) => { + const { + // classNames, + // className, + // style, + // styles, + // unstyled, + // vars, + radius, + disabled, + loading, + multiple, + maxSize, + accept, + children, + onDropAny, + onDrop, + onReject, + openRef, + name, + maxFiles, + autoFocus, + activateOnClick, + activateOnDrag, + dragEventsBubbling, + activateOnKeyboard, + onDragEnter, + onDragLeave, + onDragOver, + onFileDialogCancel, + onFileDialogOpen, + preventDropOnDocument, + useFsAccessApi, + getFilesFromEvent, + validator, + rejectColor, + acceptColor, + enablePointerEvents, + loaderProps, + inputProps, + // mod, + classNames, + ...others + } = { + ...defaultProps, + ..._props, + }; + + const { getRootProps, getInputProps, isDragAccept, isDragReject, open } = + useDropzone({ + onDrop: onDropAny, + onDropAccepted: onDrop, + onDropRejected: onReject, + disabled: disabled || loading, + accept: Array.isArray(accept) + ? accept.reduce((r, key) => ({ ...r, [key]: [] }), {}) + : accept, + multiple, + maxSize, + maxFiles, + autoFocus, + noClick: !activateOnClick, + noDrag: !activateOnDrag, + noDragEventsBubbling: !dragEventsBubbling, + noKeyboard: !activateOnKeyboard, + onDragEnter, + onDragLeave, + onDragOver, + onFileDialogCancel, + onFileDialogOpen, + preventDropOnDocument, + useFsAccessApi, + validator, + ...(getFilesFromEvent ? { getFilesFromEvent } : null), + }); + + const isIdle = !isDragAccept && !isDragReject; + + return ( + + + +
+ {children} +
+
+
+ ); +}; + +Dropzone.displayName = '@mantine/dropzone/Dropzone'; +Dropzone.Accept = DropzoneAccept; +Dropzone.Idle = DropzoneIdle; +Dropzone.Reject = DropzoneReject; diff --git a/packages/webapp/src/components/Dropzone/DropzoneProvider.tsx b/packages/webapp/src/components/Dropzone/DropzoneProvider.tsx new file mode 100644 index 000000000..085e51b47 --- /dev/null +++ b/packages/webapp/src/components/Dropzone/DropzoneProvider.tsx @@ -0,0 +1,12 @@ +import { createSafeContext } from './create-safe-context'; + +export interface DropzoneContextValue { + idle: boolean; + accept: boolean; + reject: boolean; +} + +export const [DropzoneProvider, useDropzoneContext] = + createSafeContext( + 'Dropzone component was not found in tree', + ); diff --git a/packages/webapp/src/components/Dropzone/DropzoneStatus.tsx b/packages/webapp/src/components/Dropzone/DropzoneStatus.tsx new file mode 100644 index 000000000..3daa99e36 --- /dev/null +++ b/packages/webapp/src/components/Dropzone/DropzoneStatus.tsx @@ -0,0 +1,36 @@ +import React, { cloneElement } from 'react'; +import { upperFirst } from 'lodash'; +import { DropzoneContextValue, useDropzoneContext } from './DropzoneProvider'; +import { isElement } from '@/utils/is-element'; + +export interface DropzoneStatusProps { + children: React.ReactNode; +} + +type DropzoneStatusComponent = React.FC; + +function createDropzoneStatus(status: keyof DropzoneContextValue) { + const Component: DropzoneStatusComponent = (props) => { + const { children, ...others } = props; + + const ctx = useDropzoneContext(); + const _children = isElement(children) ? children : {children}; + + if (ctx[status]) { + return cloneElement(_children as JSX.Element, others); + } + + return null; + }; + Component.displayName = `@bigcapital/core/dropzone/${upperFirst(status)}`; + + return Component; +} + +export const DropzoneAccept = createDropzoneStatus('accept'); +export const DropzoneReject = createDropzoneStatus('reject'); +export const DropzoneIdle = createDropzoneStatus('idle'); + +export type DropzoneAcceptProps = DropzoneStatusProps; +export type DropzoneRejectProps = DropzoneStatusProps; +export type DropzoneIdleProps = DropzoneStatusProps; diff --git a/packages/webapp/src/components/Dropzone/create-safe-context.tsx b/packages/webapp/src/components/Dropzone/create-safe-context.tsx new file mode 100644 index 000000000..f6e43a0df --- /dev/null +++ b/packages/webapp/src/components/Dropzone/create-safe-context.tsx @@ -0,0 +1,25 @@ +import React, { createContext, useContext } from 'react'; + +export function createSafeContext(errorMessage: string) { + const Context = createContext(null); + + const useSafeContext = () => { + const ctx = useContext(Context); + + if (ctx === null) { + throw new Error(errorMessage); + } + + return ctx; + }; + + const Provider = ({ + children, + value, + }: { + value: ContextValue; + children: React.ReactNode; + }) => {children}; + + return [Provider, useSafeContext] as const; +} diff --git a/packages/webapp/src/components/Dropzone/index.ts b/packages/webapp/src/components/Dropzone/index.ts new file mode 100644 index 000000000..1d815a4a3 --- /dev/null +++ b/packages/webapp/src/components/Dropzone/index.ts @@ -0,0 +1 @@ +export * from './Dropzone'; \ No newline at end of file diff --git a/packages/webapp/src/components/Dropzone/mine-types.ts b/packages/webapp/src/components/Dropzone/mine-types.ts new file mode 100644 index 000000000..01f0475b2 --- /dev/null +++ b/packages/webapp/src/components/Dropzone/mine-types.ts @@ -0,0 +1,39 @@ +export const MIME_TYPES = { + // Images + png: 'image/png', + gif: 'image/gif', + jpeg: 'image/jpeg', + svg: 'image/svg+xml', + webp: 'image/webp', + avif: 'image/avif', + heic: 'image/heic', + + // Documents + mp4: 'video/mp4', + zip: 'application/zip', + csv: 'text/csv', + pdf: 'application/pdf', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + exe: 'application/vnd.microsoft.portable-executable', +} as const; + +export const IMAGE_MIME_TYPE = [ + MIME_TYPES.png, + MIME_TYPES.gif, + MIME_TYPES.jpeg, + MIME_TYPES.svg, + MIME_TYPES.webp, + MIME_TYPES.avif, + MIME_TYPES.heic, +]; + +export const PDF_MIME_TYPE = [MIME_TYPES.pdf]; +export const MS_WORD_MIME_TYPE = [MIME_TYPES.doc, MIME_TYPES.docx]; +export const MS_EXCEL_MIME_TYPE = [MIME_TYPES.xls, MIME_TYPES.xlsx]; +export const MS_POWERPOINT_MIME_TYPE = [MIME_TYPES.ppt, MIME_TYPES.pptx]; +export const EXE_MIME_TYPE = [MIME_TYPES.exe]; diff --git a/packages/webapp/src/components/Stepper/Stepper.tsx b/packages/webapp/src/components/Stepper/Stepper.tsx new file mode 100644 index 000000000..9d58874e2 --- /dev/null +++ b/packages/webapp/src/components/Stepper/Stepper.tsx @@ -0,0 +1,111 @@ +// @ts-nocheck +import { cloneElement } from 'react'; +import styled from 'styled-components'; +import { toArray } from 'lodash'; +import { Box } from '../Layout'; +import { StepperCompleted } from './StepperCompleted'; +import { StepperStep } from './StepperStep'; +import { StepperStepState } from './types'; + +export interface StepperProps { + /** components */ + children: React.ReactNode; + + /** Index of the active step */ + active: number; + + /** Called when step is clicked */ + onStepClick?: (stepIndex: number) => void; + + /** Determines whether next steps can be selected, `true` by default **/ + allowNextStepsSelect?: boolean; + + classNames?: Record; +} + +export function Stepper({ + active, + onStepClick, + children, + classNames, +}: StepperProps) { + const convertedChildren = toArray(children) as React.ReactElement[]; + const _children = convertedChildren.filter( + (child) => child.type !== StepperCompleted, + ); + const completedStep = convertedChildren.find( + (item) => item.type === StepperCompleted, + ); + const items = _children.reduce((acc, item, index) => { + const state = + active === index + ? StepperStepState.Progress + : active > index + ? StepperStepState.Completed + : StepperStepState.Inactive; + + const shouldAllowSelect = () => { + if (typeof onStepClick !== 'function') { + return false; + } + if (typeof item.props.allowStepSelect === 'boolean') { + return item.props.allowStepSelect; + } + return state === 'stepCompleted' || allowNextStepsSelect; + }; + const isStepSelectionEnabled = shouldAllowSelect(); + + acc.push( + cloneElement(item, { + key: index, + step: index + 1, + state, + onClick: () => isStepSelectionEnabled && onStepClick?.(index), + allowStepClick: isStepSelectionEnabled, + }), + ); + if (index !== _children.length - 1) { + acc.push( + , + ); + } + return acc; + }, []); + + const stepContent = _children[active]?.props?.children; + const completedContent = completedStep?.props?.children; + const content = + active > _children.length - 1 ? completedContent : stepContent; + + return ( + + {items} + {content} + + ); +} + +Stepper.Step = StepperStep; +Stepper.Completed = StepperCompleted; +Stepper.displayName = '@bigcapital/core/stepper'; + +const StepsItems = styled(Box)` + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; +`; +const StepsContent = styled(Box)` + margin-top: 16px; + margin-bottom: 8px; +`; +const StepSeparator = styled.div` + flex: 1; + display: block; + border-color: #c5cbd3; + border-top-style: solid; + border-top-width: 1px; +`; diff --git a/packages/webapp/src/components/Stepper/StepperCompleted.tsx b/packages/webapp/src/components/Stepper/StepperCompleted.tsx new file mode 100644 index 000000000..e382a3668 --- /dev/null +++ b/packages/webapp/src/components/Stepper/StepperCompleted.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export interface StepperCompletedProps { + /** Label content */ + children: React.ReactNode; +} + +export const StepperCompleted: React.FC = () => null; +StepperCompleted.displayName = '@bigcapital/core/StepperCompleted'; diff --git a/packages/webapp/src/components/Stepper/StepperStep.tsx b/packages/webapp/src/components/Stepper/StepperStep.tsx new file mode 100644 index 000000000..2103e5b81 --- /dev/null +++ b/packages/webapp/src/components/Stepper/StepperStep.tsx @@ -0,0 +1,102 @@ +// @ts-nocheck +import { StepperStepState } from './types'; +import styled from 'styled-components'; +import { Icon } from '../Icon'; + +interface StepperStepProps { + label: string; + description?: string; + children: React.ReactNode; + step?: number; + active?: boolean; + state?: StepperStepState; + allowStepClick?: boolean; +} + +export function StepperStep({ + label, + description, + step, + active, + state, + children, +}: StepperStepProps) { + return ( + + + + {state === StepperStepState.Completed && ( + + )} + {step} + + + + + + {label} + + {description && ( + + {description} + + )} + + + ); +} + +const StepButton = styled.button` + background: transparent; + color: inherit; + border: 0; + align-items: center; + display: flex; + gap: 10px; + text-align: left; +`; + +const StepIcon = styled.span` + display: block; + height: 24px; + width: 24px; + display: block; + line-height: 24px; + border-radius: 24px; + text-align: center; + background-color: ${(props) => + props.isCompleted || props.isActive ? 'rgb(0, 82, 204)' : '#9e9e9e'}; + color: #fff; + margin: auto; + font-size: 12px; +`; + +const StepTitle = styled.div` + color: ${(props) => + props.isCompleted || props.isActive ? 'rgb(0, 82, 204)' : '#738091'}; +`; +const StepDescription = styled.div` + font-size: 12px; + margin-top: 10px; + color: ${(props) => + props.isCompleted || props.isActive ? 'rgb(0, 82, 204)' : '#738091'}; +`; + +const StepIconWrap = styled.div` + display: flex; +`; + +const StepTextWrap = styled.div` + text-align: left; +`; + +const StepIconText = styled.div``; diff --git a/packages/webapp/src/components/Stepper/index.ts b/packages/webapp/src/components/Stepper/index.ts new file mode 100644 index 000000000..4b2d6faf6 --- /dev/null +++ b/packages/webapp/src/components/Stepper/index.ts @@ -0,0 +1 @@ +export * from './Stepper'; \ No newline at end of file diff --git a/packages/webapp/src/components/Stepper/types.ts b/packages/webapp/src/components/Stepper/types.ts new file mode 100644 index 000000000..35334873b --- /dev/null +++ b/packages/webapp/src/components/Stepper/types.ts @@ -0,0 +1,7 @@ + + +export enum StepperStepState { + Progress = 'stepProgress', + Completed = 'stepCompleted', + Inactive = 'stepInactive', +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Accounts/AccountsActionsBar.tsx b/packages/webapp/src/containers/Accounts/AccountsActionsBar.tsx index 38d7a8214..dbffc59ec 100644 --- a/packages/webapp/src/containers/Accounts/AccountsActionsBar.tsx +++ b/packages/webapp/src/containers/Accounts/AccountsActionsBar.tsx @@ -20,7 +20,7 @@ import { DashboardActionViewsList, DashboardFilterButton, DashboardRowsHeightButton, - DashboardActionsBar + DashboardActionsBar, } from '@/components'; import { AccountAction, AbilitySubject } from '@/constants/abilityOption'; @@ -37,6 +37,7 @@ import withSettings from '@/containers/Settings/withSettings'; import withSettingsActions from '@/containers/Settings/withSettingsActions'; import { compose } from '@/utils'; +import { useHistory } from 'react-router-dom'; /** * Accounts actions bar. @@ -67,6 +68,8 @@ function AccountsActionsBar({ }) { const { resourceViews, fields } = useAccountsChartContext(); + const history = useHistory(); + const onClickNewAccount = () => { openDialog(DialogsName.AccountForm, {}); }; @@ -111,6 +114,11 @@ function AccountsActionsBar({ const handleTableRowSizeChange = (size) => { addSetting('accounts', 'tableSize', size); }; + // handle the import button click. + const handleImportBtnClick = () => { + history.push('/accounts/import'); + }; + return ( @@ -183,6 +191,7 @@ function AccountsActionsBar({ className={Classes.MINIMAL} icon={} text={} + onClick={handleImportBtnClick} /> + + {({ form: { setFieldValue } }) => ( + setFieldValue('file', files[0])} + onReject={(files) => console.log('rejected files', files)} + maxSize={5 * 1024 ** 2} + accept={[MIME_TYPES.csv, MIME_TYPES.xls, MIME_TYPES.xlsx]} + classNames={{ content: styles.dropzoneContent }} + > + + + + + +

+ Drag images here or click to select files +

+ + Drag and Drop file here or Choose file + +
+ + + +
+
+ )} +
+ + + Supperted Formats: CSV, XLSX + Maximum size: 25MB + + + ); +} diff --git a/packages/webapp/src/containers/Import/ImportFileMapping.module.scss b/packages/webapp/src/containers/Import/ImportFileMapping.module.scss new file mode 100644 index 000000000..ef05600f9 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileMapping.module.scss @@ -0,0 +1,21 @@ +.table { + width: 100%; + margin-top: 1.8rem; + + thead{ + th{ + border-top: 1px solid #d9d9da; + padding-top: 8px; + padding-bottom: 8px; + color: #738091; + } + th.label{ + width: 32%; + } + } + tbody{ + tr td { + vertical-align: middle; + } + } +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Import/ImportFileMapping.tsx b/packages/webapp/src/containers/Import/ImportFileMapping.tsx new file mode 100644 index 000000000..0e0e23e29 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileMapping.tsx @@ -0,0 +1,74 @@ +import { useMemo } from 'react'; +import clsx from 'classnames'; +import { FSelect, Group } from '@/components'; +import { ImportFileMappingForm } from './ImportFileMappingForm'; +import { useImportFileContext } from './ImportFileProvider'; +import styles from './ImportFileMapping.module.scss'; +import { CLASSES } from '@/constants'; +import { Button, Intent } from '@blueprintjs/core'; +import { useFormikContext } from 'formik'; + +export function ImportFileMapping() { + return ( + +

+ Review and map the column headers in your csv/xlsx file with the + Bigcapital fields. +

+ + + + + + + + + + + +
Bigcapital FieldsSheet Column Headers
+ + +
+ ); +} + +function ImportFileMappingFields() { + const { entityColumns, sheetColumns } = useImportFileContext(); + + const items = useMemo( + () => sheetColumns.map((column) => ({ value: column, text: column })), + [sheetColumns], + ); + + const columns = entityColumns.map((column, index) => ( + + {column.name} + + + + + )); + return <>{columns}; +} + +function ImportFileMappingFloatingActions() { + const { isSubmitting } = useFormikContext(); + + return ( +
+ + + + +
+ ); +} diff --git a/packages/webapp/src/containers/Import/ImportFileMappingForm.tsx b/packages/webapp/src/containers/Import/ImportFileMappingForm.tsx new file mode 100644 index 000000000..29e4a3c54 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileMappingForm.tsx @@ -0,0 +1,70 @@ +// @ts-nocheck +import { useImportFileMapping } from '@/hooks/query/import'; +import { Form, Formik, FormikHelpers } from 'formik'; +import { useImportFileContext } from './ImportFileProvider'; +import { useMemo } from 'react'; +import { isEmpty } from 'lodash'; + +const validationSchema = null; + +interface ImportFileMappingFormProps { + children: React.ReactNode; +} + +type ImportFileMappingFormValues = Record; + +export function ImportFileMappingForm({ + children, +}: ImportFileMappingFormProps) { + const { mutateAsync: submitImportFileMapping } = useImportFileMapping(); + const { importId, setStep } = useImportFileContext(); + + const initialValues = useImportFileMappingInitialValues(); + + const handleSubmit = ( + values: ImportFileMappingFormValues, + { setSubmitting }: FormikHelpers, + ) => { + setSubmitting(true); + const _values = transformValueToReq(values); + + submitImportFileMapping([importId, _values]) + .then(() => { + setSubmitting(false); + setStep(2); + }) + .catch((error) => { + setSubmitting(false); + }); + }; + + return ( + +
{children}
+
+ ); +} + +const transformValueToReq = (value: ImportFileMappingFormValues) => { + const mapping = Object.keys(value) + .filter((key) => !isEmpty(value[key])) + .map((key) => ({ from: value[key], to: key })); + return { mapping }; +}; + +const useImportFileMappingInitialValues = () => { + const { entityColumns } = useImportFileContext(); + + return useMemo( + () => + entityColumns.reduce((acc, { key, name }) => { + acc[key] = ''; + return acc; + }, {}), + [entityColumns], + ); +}; diff --git a/packages/webapp/src/containers/Import/ImportFilePreview.tsx b/packages/webapp/src/containers/Import/ImportFilePreview.tsx new file mode 100644 index 000000000..69ea80756 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFilePreview.tsx @@ -0,0 +1,121 @@ +// @ts-nocheck +import { Button, Callout, Intent, Text } from '@blueprintjs/core'; +import clsx from 'classnames'; +import { + ImportFilePreviewBootProvider, + useImportFilePreviewBootContext, +} from './ImportFilePreviewBoot'; +import { useImportFileContext } from './ImportFileProvider'; +import { AppToaster, Card, Group } from '@/components'; +import { useImportFileProcess } from '@/hooks/query/import'; +import { CLASSES } from '@/constants'; +import { useHistory } from 'react-router-dom'; + +export function ImportFilePreview() { + const { importId } = useImportFileContext(); + + return ( + + + + ); +} + +function ImportFilePreviewContent() { + const { importPreview } = useImportFilePreviewBootContext(); + + return ( +
+ + {importPreview.createdCount} of {importPreview.totalCount} Items in your + file are ready to be imported. + + + + + Items that are ready to be imported - {importPreview.createdCount} + +
    +
  • Items to be created: ({importPreview.createdCount})
  • +
  • Items to be skipped: ({importPreview.skippedCount})
  • +
  • Items have errors: ({importPreview.errorsCount})
  • +
+ + + + + + + + + + + {importPreview?.errors.map((error, key) => ( + + + + + + ))} + +
#NameError
{error.rowNumber}{error.rowNumber} + {error.errorMessage.map((message) => ( +
{message}
+ ))} +
+ + + Unmapped Sheet Columns - ({importPreview?.unmappedColumnsCount}) + + +
    + {importPreview.unmappedColumns?.map((column, key) => ( +
  • {column}
  • + ))} +
+
+ + +
+ ); +} + +function ImportFilePreviewFloatingActions() { + const { importId } = useImportFileContext(); + const { importPreview } = useImportFilePreviewBootContext(); + const { mutateAsync: importFile, isLoading: isImportFileLoading } = + useImportFileProcess(); + + const history = useHistory(); + const isValidToImport = importPreview?.createdCount > 0; + + const handleSubmitBtn = () => { + importFile(importId) + .then(() => { + AppToaster.show({ + intent: Intent.SUCCESS, + message: `The ${ + importPreview.createdCount + } of ${10} has imported successfully.`, + }); + history.push('/accounts'); + }) + .catch((error) => {}); + }; + return ( +
+ + + + +
+ ); +} diff --git a/packages/webapp/src/containers/Import/ImportFilePreviewBoot.tsx b/packages/webapp/src/containers/Import/ImportFilePreviewBoot.tsx new file mode 100644 index 000000000..ae2bee5f4 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFilePreviewBoot.tsx @@ -0,0 +1,52 @@ +import { useImportFilePreview } from '@/hooks/query/import'; +import { transformToCamelCase } from '@/utils'; +import React, { createContext, useContext } from 'react'; + +interface ImportFilePreviewBootContextValue {} + +const ImportFilePreviewBootContext = + createContext( + {} as ImportFilePreviewBootContextValue, + ); + +export const useImportFilePreviewBootContext = () => { + const context = useContext( + ImportFilePreviewBootContext, + ); + + if (!context) { + throw new Error( + 'useImportFilePreviewBootContext must be used within an ImportFilePreviewBootProvider', + ); + } + return context; +}; + +interface ImportFilePreviewBootProps { + importId: string; + children: React.ReactNode; +} + +export const ImportFilePreviewBootProvider = ({ + importId, + children, +}: ImportFilePreviewBootProps) => { + const { + data: importPreview, + isLoading: isImportPreviewLoading, + isFetching: isImportPreviewFetching, + } = useImportFilePreview(importId, { + enabled: Boolean(importId), + }); + + const value = { + importPreview, + isImportPreviewLoading, + isImportPreviewFetching, + }; + return ( + + {isImportPreviewLoading ? 'loading' : <>{children}} + + ); +}; diff --git a/packages/webapp/src/containers/Import/ImportFileProvider.tsx b/packages/webapp/src/containers/Import/ImportFileProvider.tsx new file mode 100644 index 000000000..a28ef5ac1 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileProvider.tsx @@ -0,0 +1,87 @@ +// @ts-nocheck +import React, { + Dispatch, + SetStateAction, + createContext, + useContext, + useState, +} from 'react'; + +type EntityColumn = { key: string; name: string }; +type SheetColumn = string; +type SheetMap = { from: string; to: string }; + +interface ImportFileContextValue { + sheetColumns: SheetColumn[]; + setSheetColumns: Dispatch>; + + entityColumns: EntityColumn[]; + setEntityColumns: Dispatch>; + + sheetMapping: SheetMap[]; + setSheetMapping: Dispatch>; + + step: number; + setStep: Dispatch>; + + importId: string; + setImportId: Dispatch>; + + resource: string; +} +interface ImportFileProviderProps { + resource: string; + children: React.ReactNode; +} + +const ImportFileContext = createContext( + {} as ImportFileContextValue, +); + +export const useImportFileContext = () => { + const context = useContext(ImportFileContext); + + if (!context) { + throw new Error( + 'useImportFileContext must be used within an ImportFileProvider', + ); + } + return context; +}; + +export const ImportFileProvider = ({ + resource, + children, +}: ImportFileProviderProps) => { + const [sheetColumns, setSheetColumns] = useState([]); + const [entityColumns, setEntityColumns] = useState([]); + const [sheetMapping, setSheetMapping] = useState([]); + const [importId, setImportId] = useState(''); + + const [step, setStep] = useState(0); + + const value = { + sheetColumns, + setSheetColumns, + + entityColumns, + setEntityColumns, + + sheetMapping, + setSheetMapping, + + step, + setStep, + + importId, + setImportId, + + resource, + }; + + return ( + + {children} + + ); +}; diff --git a/packages/webapp/src/containers/Import/ImportFileUploadForm.tsx b/packages/webapp/src/containers/Import/ImportFileUploadForm.tsx new file mode 100644 index 000000000..5dbbff2b3 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileUploadForm.tsx @@ -0,0 +1,62 @@ +// @ts-nocheck +import { AppToaster } from '@/components'; +import { useImportFileUpload } from '@/hooks/query/import'; +import { Intent } from '@blueprintjs/core'; +import { Formik, Form, FormikHelpers } from 'formik'; +import * as Yup from 'yup'; +import { useImportFileContext } from './ImportFileProvider'; + +const initialValues = { + file: null, +} as ImportFileUploadValues; + +interface ImportFileUploadFormProps { + children: React.ReactNode; +} + +const validationSchema = Yup.object().shape({ + file: Yup.mixed().required('File is required'), +}); + +interface ImportFileUploadValues { + file: File | null; +} + +export function ImportFileUploadForm({ children }: ImportFileUploadFormProps) { + const { mutateAsync: uploadImportFile } = useImportFileUpload(); + const { setStep, setSheetColumns, setEntityColumns, setImportId } = useImportFileContext(); + + const handleSubmit = ( + values: ImportFileUploadValues, + { setSubmitting }: FormikHelpers, + ) => { + if (!values.file) return; + + setSubmitting(true); + const formData = new FormData(); + formData.append('file', values.file); + formData.append('resource', 'Account'); + + uploadImportFile(formData) + .then(({ data }) => { + setImportId(data.import.import_id); + setSheetColumns(data.sheet_columns); + setEntityColumns(data.resource_columns); + setStep(1); + setSubmitting(false); + }) + .catch((error) => { + setSubmitting(false); + }); + }; + + return ( + +
{children}
+
+ ); +} diff --git a/packages/webapp/src/containers/Import/ImportFileUploadStep.style.scss b/packages/webapp/src/containers/Import/ImportFileUploadStep.style.scss new file mode 100644 index 000000000..059702469 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileUploadStep.style.scss @@ -0,0 +1,3 @@ +.root { + margin-top: 2.2rem +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Import/ImportFileUploadStep.tsx b/packages/webapp/src/containers/Import/ImportFileUploadStep.tsx new file mode 100644 index 000000000..9c6523823 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileUploadStep.tsx @@ -0,0 +1,40 @@ +// @ts-nocheck +import clsx from 'classnames'; +import { Group, Stack } from '@/components'; +import { ImportDropzone } from './ImportDropzone'; +import { ImportSampleDownload } from './ImportSampleDownload'; +import { CLASSES } from '@/constants'; +import { Button, Intent } from '@blueprintjs/core'; +import { ImportFileUploadForm } from './ImportFileUploadForm'; +import { useFormikContext } from 'formik'; + +export function ImportFileUploadStep() { + return ( + +

+ Download a sample file and compare it to your import file to ensure you + have the file perfect for the import. +

+ + + + + + +
+ ); +} + +function ImportFileUploadFooterActions() { + const { isSubmitting } = useFormikContext(); + return ( +
+ + + + +
+ ); +} diff --git a/packages/webapp/src/containers/Import/ImportPage.module.scss b/packages/webapp/src/containers/Import/ImportPage.module.scss new file mode 100644 index 000000000..201c1b9f2 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportPage.module.scss @@ -0,0 +1,11 @@ +.root{ + padding: 32px 40px; + min-width: 700px; + max-width: 800px; + width: 75%; + margin-left: auto; + margin-right: auto; +} +.rootWrap { + max-width: 1800px; +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Import/ImportPage.tsx b/packages/webapp/src/containers/Import/ImportPage.tsx new file mode 100644 index 000000000..7b01e0fb9 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportPage.tsx @@ -0,0 +1,19 @@ +// @ts-nocheck +import { ImportStepper } from './ImportStepper'; +import { Box, DashboardInsider } from '@/components'; +import styles from './ImportPage.module.scss'; +import { ImportFileProvider } from './ImportFileProvider'; + +export default function ImportPage() { + return ( + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/Import/ImportSampleDownload.module.scss b/packages/webapp/src/containers/Import/ImportSampleDownload.module.scss new file mode 100644 index 000000000..ee230449c --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportSampleDownload.module.scss @@ -0,0 +1,23 @@ + + +.root{ + background: #fff; + border: 1px solid #D3D8DE; + border-radius: 5px; + padding: 16px; +} +.description{ + margin: 0; + margin-top: 6px; + color: #8F99A8; +} +.title{ + color: #5F6B7C; + font-weight: 600; + font-size: 14px; +} + +.buttonWrap{ + flex: 25% 0; + text-align: right; +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Import/ImportSampleDownload.tsx b/packages/webapp/src/containers/Import/ImportSampleDownload.tsx new file mode 100644 index 000000000..7d999f7f7 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportSampleDownload.tsx @@ -0,0 +1,24 @@ +// @ts-nocheck +import { Box, Group } from '@/components'; +import { Button } from '@blueprintjs/core'; +import styles from './ImportSampleDownload.module.scss'; + +export function ImportSampleDownload() { + return ( + + +

Table Example

+

+ Download a sample file and compare it to your import file to ensure + you have the file perfect for the import. +

+
+ + + + +
+ ); +} diff --git a/packages/webapp/src/containers/Import/ImportStepper.module.scss b/packages/webapp/src/containers/Import/ImportStepper.module.scss new file mode 100644 index 000000000..6eacdc66a --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportStepper.module.scss @@ -0,0 +1,3 @@ +.content { + margin-top: 2.4rem; +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Import/ImportStepper.tsx b/packages/webapp/src/containers/Import/ImportStepper.tsx new file mode 100644 index 000000000..5ab7b9548 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportStepper.tsx @@ -0,0 +1,28 @@ +// @ts-nocheck + +import { Stepper } from '@/components/Stepper'; +import { ImportFileUploadStep } from './ImportFileUploadStep'; +import styles from './ImportStepper.module.scss'; +import { useImportFileContext } from './ImportFileProvider'; +import { ImportFileMapping } from './ImportFileMapping'; +import { ImportFilePreview } from './ImportFilePreview'; + +export function ImportStepper() { + const { step } = useImportFileContext(); + + return ( + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/hooks/query/import.ts b/packages/webapp/src/hooks/query/import.ts new file mode 100644 index 000000000..3b612e2ae --- /dev/null +++ b/packages/webapp/src/hooks/query/import.ts @@ -0,0 +1,63 @@ +// @ts-nocheck +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import useApiRequest from '../useRequest'; +import { transformToCamelCase } from '@/utils'; + +/** + * + */ +export function useImportFileUpload(props = {}) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation((values) => apiRequest.post(`import/file`, values), { + onSuccess: (res, id) => { + // Invalidate queries. + }, + ...props, + }); +} + +export function useImportFileMapping(props = {}) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ([importId, values]) => + apiRequest.post(`import/${importId}/mapping`, values), + { + onSuccess: (res, id) => { + // Invalidate queries. + }, + ...props, + }, + ); +} +export function useImportFilePreview(importId: string, props = {}) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useQuery(['importPreview', importId], () => + apiRequest + .get(`import/${importId}/preview`) + .then((res) => transformToCamelCase(res.data)), + ); +} + +/** + * + */ +export function useImportFileProcess(props = {}) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + (importId) => apiRequest.post(`import/${importId}/import`), + { + onSuccess: (res, id) => { + // Invalidate queries. + }, + ...props, + }, + ); +} diff --git a/packages/webapp/src/routes/dashboard.tsx b/packages/webapp/src/routes/dashboard.tsx index c1137bc5b..d2d69e450 100644 --- a/packages/webapp/src/routes/dashboard.tsx +++ b/packages/webapp/src/routes/dashboard.tsx @@ -10,6 +10,12 @@ const SUBSCRIPTION_TYPE = { export const getDashboardRoutes = () => [ // Accounts. + { + path: '/accounts/import', + component: lazy(() => import('@/containers/Import/ImportPage')), + breadcrumb: 'Import Accounts', + pageTitle: 'Import Accounts', + }, { path: `/accounts`, component: lazy(() => import('@/containers/Accounts/AccountsChart')), @@ -19,7 +25,6 @@ export const getDashboardRoutes = () => [ defaultSearchResource: RESOURCES_TYPES.ACCOUNT, subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], }, - // Accounting. { path: `/make-journal-entry`, @@ -1062,8 +1067,7 @@ export const getDashboardRoutes = () => [ { path: '/tax-rates', component: lazy( - () => - import('@/containers/TaxRates/pages/TaxRatesLanding'), + () => import('@/containers/TaxRates/pages/TaxRatesLanding'), ), pageTitle: 'Tax Rates', subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], diff --git a/packages/webapp/src/static/json/icons.tsx b/packages/webapp/src/static/json/icons.tsx index 5bfafa141..9e3a457cb 100644 --- a/packages/webapp/src/static/json/icons.tsx +++ b/packages/webapp/src/static/json/icons.tsx @@ -577,4 +577,16 @@ export default { ], viewBox: '0 0 20 20', }, + done: { + path: [ + 'M395-285 226-455l50-50 119 118 289-288 50 51-339 339Z', + ], + viewBox: '0 -960 960 960' + }, + download: { + path: [ + 'M480-336 288-528l51-51 105 105v-342h72v342l105-105 51 51-192 192ZM263.717-192Q234-192 213-213.15T192-264v-72h72v72h432v-72h72v72q0 29.7-21.162 50.85Q725.676-192 695.96-192H263.717Z' + ], + viewBox: '0 -960 960 960' + } }; diff --git a/packages/webapp/src/style/components/CloudSpinner.scss b/packages/webapp/src/style/components/CloudSpinner.scss index f4ab62a0c..f5f1fe4a8 100644 --- a/packages/webapp/src/style/components/CloudSpinner.scss +++ b/packages/webapp/src/style/components/CloudSpinner.scss @@ -1,7 +1,6 @@ .cloud-spinner{ - position: relative; &.is-loading:before{ content: ""; diff --git a/packages/webapp/src/utils/is-element.ts b/packages/webapp/src/utils/is-element.ts new file mode 100644 index 000000000..63ba1dcce --- /dev/null +++ b/packages/webapp/src/utils/is-element.ts @@ -0,0 +1,17 @@ +import React from 'react'; + +export function isElement(value: any): value is React.ReactElement { + if (Array.isArray(value) || value === null) { + return false; + } + + if (typeof value === 'object') { + if (value.type === React.Fragment) { + return false; + } + + return true; + } + + return false; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb8bc2771..f148a1365 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -660,6 +660,9 @@ importers: react-dropzone: specifier: ^11.0.1 version: 11.7.1(react@18.2.0) + react-dropzone-esm: + specifier: ^15.0.1 + version: 15.0.1(react@18.2.0) react-error-boundary: specifier: ^3.0.2 version: 3.1.4(react@18.2.0) @@ -21180,6 +21183,16 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /react-dropzone-esm@15.0.1(react@18.2.0): + resolution: {integrity: sha512-RdeGpqwHnoV/IlDFpQji7t7pTtlC2O1i/Br0LWkRZ9hYtLyce814S71h5NolnCZXsIN5wrZId6+8eQj2EBnEzg==} + engines: {node: '>= 10.13'} + peerDependencies: + react: '>= 16.8 || 18.0.0' + dependencies: + prop-types: 15.8.1 + react: 18.2.0 + dev: false + /react-dropzone@11.7.1(react@18.2.0): resolution: {integrity: sha512-zxCMwhfPy1olUEbw3FLNPLhAm/HnaYH5aELIEglRbqabizKAdHs0h+WuyOpmA+v1JXn0++fpQDdNfUagWt5hJQ==} engines: {node: '>= 10.13'}