From ff5730d8a76aa8a8f6c65c5e08f674a0315ee1a8 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 19 Mar 2024 03:57:57 +0200 Subject: [PATCH 1/3] feat(webapp): import resource UI --- packages/webapp/package.json | 13 +- .../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, 1469 insertions(+), 11 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/core": "2.1.13", "@tiptap/extension-color": "latest", + "@tiptap/extension-list-item": "2.1.13", "@tiptap/extension-text-style": "2.1.13", - "@tiptap/core": "2.1.13", "@tiptap/pm": "2.1.13", - "@tiptap/extension-list-item": "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'} From 1d8cec5069bc7f1cfe638b7d275b122c7ca03a71 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Wed, 20 Mar 2024 04:55:35 +0200 Subject: [PATCH 2/3] feat: wip import resource UI --- .../controllers/Import/ImportController.ts | 2 +- .../src/components/Dropzone/Dropzone.tsx | 25 ++++++ .../src/components/Layout/Stack/Stack.tsx | 2 +- .../webapp/src/components/Stepper/Stepper.tsx | 4 +- .../src/containers/Import/ImportDropzone.tsx | 40 +++------- .../containers/Import/ImportDropzoneFile.tsx | 80 +++++++++++++++++++ .../Import/ImportFileActions.module.scss | 5 ++ .../containers/Import/ImportFileContainer.tsx | 9 +++ .../Import/ImportFileFooterActions.tsx | 26 ++++++ .../Import/ImportFileMapping.module.scss | 18 ++++- .../containers/Import/ImportFileMapping.tsx | 59 ++++++++------ .../containers/Import/ImportFilePreview.tsx | 13 ++- .../Import/ImportFileUploadForm.tsx | 15 +++- .../Import/ImportFileUploadStep.module.scss | 16 ++++ .../Import/ImportFileUploadStep.style.scss | 3 - .../Import/ImportFileUploadStep.tsx | 40 +++------- .../containers/Import/ImportPage.module.scss | 7 +- .../src/containers/Import/ImportPage.tsx | 13 ++- .../Import/ImportStepper.module.scss | 17 +++- .../src/containers/Import/ImportStepper.tsx | 10 ++- .../webapp/src/containers/Import/_types.ts | 5 ++ 21 files changed, 291 insertions(+), 118 deletions(-) create mode 100644 packages/webapp/src/containers/Import/ImportDropzoneFile.tsx create mode 100644 packages/webapp/src/containers/Import/ImportFileActions.module.scss create mode 100644 packages/webapp/src/containers/Import/ImportFileContainer.tsx create mode 100644 packages/webapp/src/containers/Import/ImportFileFooterActions.tsx create mode 100644 packages/webapp/src/containers/Import/ImportFileUploadStep.module.scss delete mode 100644 packages/webapp/src/containers/Import/ImportFileUploadStep.style.scss create mode 100644 packages/webapp/src/containers/Import/_types.ts diff --git a/packages/server/src/api/controllers/Import/ImportController.ts b/packages/server/src/api/controllers/Import/ImportController.ts index c2d90cbe2..d8c44586c 100644 --- a/packages/server/src/api/controllers/Import/ImportController.ts +++ b/packages/server/src/api/controllers/Import/ImportController.ts @@ -42,7 +42,7 @@ export class ImportController extends BaseController { this.asyncMiddleware(this.mapping.bind(this)), this.catchServiceErrors ); - router.post( + router.get( '/:import_id/preview', this.asyncMiddleware(this.preview.bind(this)), this.catchServiceErrors diff --git a/packages/webapp/src/components/Dropzone/Dropzone.tsx b/packages/webapp/src/components/Dropzone/Dropzone.tsx index cf335d7ca..8682a499b 100644 --- a/packages/webapp/src/components/Dropzone/Dropzone.tsx +++ b/packages/webapp/src/components/Dropzone/Dropzone.tsx @@ -1,5 +1,6 @@ // @ts-nocheck import React from 'react'; +import { Ref, useCallback } from 'react'; import clsx from 'classnames'; import { Accept, @@ -226,6 +227,7 @@ export const Dropzone = (_props: DropzoneProps) => { }); const isIdle = !isDragAccept && !isDragReject; + assignRef(openRef, open); return ( = Ref | undefined; + +export function assignRef(ref: PossibleRef, value: T) { + if (typeof ref === 'function') { + ref(value); + } else if (typeof ref === 'object' && ref !== null && 'current' in ref) { + (ref as React.MutableRefObject).current = value; + } +} + +export function mergeRefs(...refs: PossibleRef[]) { + return (node: T | null) => { + refs.forEach((ref) => assignRef(ref, node)); + }; +} + +export function useMergedRef(...refs: PossibleRef[]) { + return useCallback(mergeRefs(...refs), refs); +} \ No newline at end of file diff --git a/packages/webapp/src/components/Layout/Stack/Stack.tsx b/packages/webapp/src/components/Layout/Stack/Stack.tsx index da1a9af6c..d5960ac1f 100644 --- a/packages/webapp/src/components/Layout/Stack/Stack.tsx +++ b/packages/webapp/src/components/Layout/Stack/Stack.tsx @@ -30,7 +30,7 @@ export function Stack(props: StackProps) { const StackStyled = styled(Box)` display: flex; flex-direction: column; - align-items: align; + align-items: ${(props: StackProps) => props.align}; justify-content: justify; gap: ${(props: StackProps) => props.spacing}px; `; diff --git a/packages/webapp/src/components/Stepper/Stepper.tsx b/packages/webapp/src/components/Stepper/Stepper.tsx index 9d58874e2..448398628 100644 --- a/packages/webapp/src/components/Stepper/Stepper.tsx +++ b/packages/webapp/src/components/Stepper/Stepper.tsx @@ -81,8 +81,8 @@ export function Stepper({ active > _children.length - 1 ? completedContent : stepContent; return ( - - {items} + + {items} {content} ); diff --git a/packages/webapp/src/containers/Import/ImportDropzone.tsx b/packages/webapp/src/containers/Import/ImportDropzone.tsx index 95e1532b5..cae4f6d65 100644 --- a/packages/webapp/src/containers/Import/ImportDropzone.tsx +++ b/packages/webapp/src/containers/Import/ImportDropzone.tsx @@ -1,42 +1,20 @@ // @ts-nocheck -import { Button } from '@blueprintjs/core'; import { Field } from 'formik'; -import { Box, Group, Icon, Stack } from '@/components'; -import { Dropzone } from '@/components/Dropzone'; -import { MIME_TYPES } from '@/components/Dropzone/mine-types'; +import { Box, Group, Stack } from '@/components'; import styles from './ImportDropzone.module.css'; +import { ImportDropzoneField } from './ImportDropzoneFile'; export function ImportDropzone() { return ( - {({ 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 - -
- - - -
-
+ {({ form }) => ( + { + form.setFieldValue('file', file); + }} + /> )}
diff --git a/packages/webapp/src/containers/Import/ImportDropzoneFile.tsx b/packages/webapp/src/containers/Import/ImportDropzoneFile.tsx new file mode 100644 index 000000000..2cec9242f --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportDropzoneFile.tsx @@ -0,0 +1,80 @@ +// @ts-nocheck +import { useRef } from 'react'; +import { Button, Intent } from '@blueprintjs/core'; +import { Box, Icon, Stack } from '@/components'; +import { Dropzone, DropzoneProps } from '@/components/Dropzone'; +import { MIME_TYPES } from '@/components/Dropzone/mine-types'; +import styles from './ImportDropzone.module.css'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; + +interface ImportDropzoneFieldProps { + initialValue?: File; + value?: File; + onChange?: (file: File) => void; + dropzoneProps?: DropzoneProps; +} + +export function ImportDropzoneField({ + initialValue, + value, + onChange, + dropzoneProps, +}: ImportDropzoneFieldProps) { + const [localValue, handleChange] = useUncontrolled({ + value, + initialValue, + finalValue: null, + onChange, + }); + const openRef = useRef<() => void>(null); + + const handleRemove = () => { + handleChange(null); + }; + + return ( + handleChange(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 }} + activateOnClick={false} + openRef={openRef} + {...dropzoneProps} + > + + + + + {localValue ? ( + +

{localValue.name}

+ +
+ ) : ( + +

+ Drag images here or click to select files +

+ + Drag and Drop file here or Choose file + +
+ )} + + +
+
+ ); +} diff --git a/packages/webapp/src/containers/Import/ImportFileActions.module.scss b/packages/webapp/src/containers/Import/ImportFileActions.module.scss new file mode 100644 index 000000000..fa4c9d19a --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileActions.module.scss @@ -0,0 +1,5 @@ + + +.root{ + +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Import/ImportFileContainer.tsx b/packages/webapp/src/containers/Import/ImportFileContainer.tsx new file mode 100644 index 000000000..a2b32f84b --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileContainer.tsx @@ -0,0 +1,9 @@ +import styles from './ImportFileUploadStep.module.scss'; + +interface ImportFileContainerProps { + children: React.ReactNode; +} + +export function ImportFileContainer({ children }: ImportFileContainerProps) { + return
{children}
; +} diff --git a/packages/webapp/src/containers/Import/ImportFileFooterActions.tsx b/packages/webapp/src/containers/Import/ImportFileFooterActions.tsx new file mode 100644 index 000000000..b8ae21f2e --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileFooterActions.tsx @@ -0,0 +1,26 @@ +// @ts-nocheck +import clsx from 'classnames'; +import { Group } from '@/components'; +import { CLASSES } from '@/constants'; +import { Button, Intent } from '@blueprintjs/core'; +import { useFormikContext } from 'formik'; +import styles from './ImportFileActions.module.scss'; +import { useImportFileContext } from './ImportFileProvider'; + +export function ImportFileUploadFooterActions() { + const { isSubmitting } = useFormikContext(); + const { setStep } = useImportFileContext(); + + const handleCancelBtnClick = () => {}; + + return ( +
+ + + + +
+ ); +} diff --git a/packages/webapp/src/containers/Import/ImportFileMapping.module.scss b/packages/webapp/src/containers/Import/ImportFileMapping.module.scss index ef05600f9..873dcf043 100644 --- a/packages/webapp/src/containers/Import/ImportFileMapping.module.scss +++ b/packages/webapp/src/containers/Import/ImportFileMapping.module.scss @@ -1,6 +1,11 @@ .table { width: 100%; - margin-top: 1.8rem; + margin-top: 1.4rem; + + th.label, + td.label{ + width: 32% !important; + } thead{ th{ @@ -8,14 +13,19 @@ padding-top: 8px; padding-bottom: 8px; color: #738091; - } - th.label{ - width: 32%; + font-weight: 500; } } + tbody{ tr td { vertical-align: middle; } + + tr td{ + :global(.bp4-popover-target .bp4-button){ + max-width: 250px; + } + } } } \ No newline at end of file diff --git a/packages/webapp/src/containers/Import/ImportFileMapping.tsx b/packages/webapp/src/containers/Import/ImportFileMapping.tsx index 0e0e23e29..b0ccb24ca 100644 --- a/packages/webapp/src/containers/Import/ImportFileMapping.tsx +++ b/packages/webapp/src/containers/Import/ImportFileMapping.tsx @@ -1,32 +1,36 @@ import { useMemo } from 'react'; import clsx from 'classnames'; +import { Button, Intent } from '@blueprintjs/core'; +import { useFormikContext } from 'formik'; 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'; +import { ImportFileContainer } from './ImportFileContainer'; +import styles from './ImportFileMapping.module.scss'; +import { ImportStepperStep } from './_types'; export function ImportFileMapping() { return ( -

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

+ +

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

- - - - - - - - - - -
Bigcapital FieldsSheet Column Headers
+ + + + + + + + + + +
Bigcapital FieldsSheet Column Headers
+
@@ -43,14 +47,14 @@ function ImportFileMappingFields() { const columns = entityColumns.map((column, index) => ( - {column.name} - + {column.name} + @@ -60,14 +64,19 @@ function ImportFileMappingFields() { function ImportFileMappingFloatingActions() { const { isSubmitting } = useFormikContext(); + const { setStep } = useImportFileContext(); + + const handleCancelBtnClick = () => { + setStep(ImportStepperStep.Upload); + }; return (
- + + -
); diff --git a/packages/webapp/src/containers/Import/ImportFilePreview.tsx b/packages/webapp/src/containers/Import/ImportFilePreview.tsx index 69ea80756..17aa8476d 100644 --- a/packages/webapp/src/containers/Import/ImportFilePreview.tsx +++ b/packages/webapp/src/containers/Import/ImportFilePreview.tsx @@ -1,6 +1,7 @@ // @ts-nocheck import { Button, Callout, Intent, Text } from '@blueprintjs/core'; import clsx from 'classnames'; +import { useHistory } from 'react-router-dom'; import { ImportFilePreviewBootProvider, useImportFilePreviewBootContext, @@ -9,7 +10,7 @@ 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'; +import { ImportStepperStep } from './_types'; export function ImportFilePreview() { const { importId } = useImportFileContext(); @@ -81,7 +82,7 @@ function ImportFilePreviewContent() { } function ImportFilePreviewFloatingActions() { - const { importId } = useImportFileContext(); + const { importId, setStep } = useImportFileContext(); const { importPreview } = useImportFilePreviewBootContext(); const { mutateAsync: importFile, isLoading: isImportFileLoading } = useImportFileProcess(); @@ -102,9 +103,14 @@ function ImportFilePreviewFloatingActions() { }) .catch((error) => {}); }; + const handleCancelBtnClick = () => { + setStep(ImportStepperStep.Mapping); + }; + return (
- + + -
); diff --git a/packages/webapp/src/containers/Import/ImportFileUploadForm.tsx b/packages/webapp/src/containers/Import/ImportFileUploadForm.tsx index 5dbbff2b3..5eeafaa26 100644 --- a/packages/webapp/src/containers/Import/ImportFileUploadForm.tsx +++ b/packages/webapp/src/containers/Import/ImportFileUploadForm.tsx @@ -5,6 +5,7 @@ import { Intent } from '@blueprintjs/core'; import { Formik, Form, FormikHelpers } from 'formik'; import * as Yup from 'yup'; import { useImportFileContext } from './ImportFileProvider'; +import { ImportStepperStep } from './_types'; const initialValues = { file: null, @@ -22,9 +23,14 @@ interface ImportFileUploadValues { file: File | null; } -export function ImportFileUploadForm({ children }: ImportFileUploadFormProps) { +export function ImportFileUploadForm({ + children, + formikProps, + formProps, +}: ImportFileUploadFormProps) { const { mutateAsync: uploadImportFile } = useImportFileUpload(); - const { setStep, setSheetColumns, setEntityColumns, setImportId } = useImportFileContext(); + const { setStep, setSheetColumns, setEntityColumns, setImportId } = + useImportFileContext(); const handleSubmit = ( values: ImportFileUploadValues, @@ -42,7 +48,7 @@ export function ImportFileUploadForm({ children }: ImportFileUploadFormProps) { setImportId(data.import.import_id); setSheetColumns(data.sheet_columns); setEntityColumns(data.resource_columns); - setStep(1); + setStep(ImportStepperStep.Mapping); setSubmitting(false); }) .catch((error) => { @@ -55,8 +61,9 @@ export function ImportFileUploadForm({ children }: ImportFileUploadFormProps) { initialValues={initialValues} onSubmit={handleSubmit} validationSchema={validationSchema} + {...formikProps} > -
{children}
+
{children}
); } diff --git a/packages/webapp/src/containers/Import/ImportFileUploadStep.module.scss b/packages/webapp/src/containers/Import/ImportFileUploadStep.module.scss new file mode 100644 index 000000000..61fc5c3fd --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileUploadStep.module.scss @@ -0,0 +1,16 @@ +.root { + margin-top: 2.2rem +} + +.content { + flex: 1; + padding: 32px 20px; + min-width: 660px; + max-width: 760px; + width: 75%; + margin-left: auto; + margin-right: auto; +} + +.form { +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Import/ImportFileUploadStep.style.scss b/packages/webapp/src/containers/Import/ImportFileUploadStep.style.scss deleted file mode 100644 index 059702469..000000000 --- a/packages/webapp/src/containers/Import/ImportFileUploadStep.style.scss +++ /dev/null @@ -1,3 +0,0 @@ -.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 index 9c6523823..04d126a78 100644 --- a/packages/webapp/src/containers/Import/ImportFileUploadStep.tsx +++ b/packages/webapp/src/containers/Import/ImportFileUploadStep.tsx @@ -1,40 +1,26 @@ // @ts-nocheck -import clsx from 'classnames'; -import { Group, Stack } from '@/components'; +import { 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'; +import { ImportFileUploadFooterActions } from './ImportFileFooterActions'; +import { ImportFileContainer } from './ImportFileContainer'; 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. -

+ +

+ 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 index 201c1b9f2..867462f16 100644 --- a/packages/webapp/src/containers/Import/ImportPage.module.scss +++ b/packages/webapp/src/containers/Import/ImportPage.module.scss @@ -1,10 +1,5 @@ .root{ - padding: 32px 40px; - min-width: 700px; - max-width: 800px; - width: 75%; - margin-left: auto; - margin-right: auto; + } .rootWrap { max-width: 1800px; diff --git a/packages/webapp/src/containers/Import/ImportPage.tsx b/packages/webapp/src/containers/Import/ImportPage.tsx index 7b01e0fb9..e12732b61 100644 --- a/packages/webapp/src/containers/Import/ImportPage.tsx +++ b/packages/webapp/src/containers/Import/ImportPage.tsx @@ -1,18 +1,17 @@ // @ts-nocheck import { ImportStepper } from './ImportStepper'; import { Box, DashboardInsider } from '@/components'; -import styles from './ImportPage.module.scss'; import { ImportFileProvider } from './ImportFileProvider'; +import styles from './ImportPage.module.scss'; + export default function ImportPage() { return ( - - - - - - + + + + ); diff --git a/packages/webapp/src/containers/Import/ImportStepper.module.scss b/packages/webapp/src/containers/Import/ImportStepper.module.scss index 6eacdc66a..2971a3632 100644 --- a/packages/webapp/src/containers/Import/ImportStepper.module.scss +++ b/packages/webapp/src/containers/Import/ImportStepper.module.scss @@ -1,3 +1,18 @@ .content { - margin-top: 2.4rem; + margin-top: 0; + margin-bottom: 0; + border-top: 1px solid #DCE0E5; +} + +.root { + +} + +.items { + padding: 32px 20px; + min-width: 660px; + max-width: 760px; + width: 75%; + margin-left: auto; + margin-right: auto; } \ No newline at end of file diff --git a/packages/webapp/src/containers/Import/ImportStepper.tsx b/packages/webapp/src/containers/Import/ImportStepper.tsx index 5ab7b9548..404f715d3 100644 --- a/packages/webapp/src/containers/Import/ImportStepper.tsx +++ b/packages/webapp/src/containers/Import/ImportStepper.tsx @@ -2,16 +2,22 @@ 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'; +import styles from './ImportStepper.module.scss'; export function ImportStepper() { const { step } = useImportFileContext(); return ( - + diff --git a/packages/webapp/src/containers/Import/_types.ts b/packages/webapp/src/containers/Import/_types.ts new file mode 100644 index 000000000..0d7856819 --- /dev/null +++ b/packages/webapp/src/containers/Import/_types.ts @@ -0,0 +1,5 @@ +export enum ImportStepperStep { + Upload = 0, + Mapping = 1, + Preview = 2, +} From a5ab535d3bc541f3150bb4fc18d2078f3a60f784 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 22 Mar 2024 00:05:10 +0200 Subject: [PATCH 3/3] feat(webapp): import preview page --- packages/webapp/src/components/Icon/index.tsx | 13 +- .../webapp/src/components/Section/Section.tsx | 248 ++++++++++++++++++ .../src/components/Section/SectionCard.tsx | 41 +++ .../webapp/src/components/Section/index.ts | 2 + packages/webapp/src/constants/classes.tsx | 77 ++++-- .../Import/ImportFileMappingForm.tsx | 13 +- .../Import/ImportFilePreview.module.scss | 42 +++ .../containers/Import/ImportFilePreview.tsx | 96 +++++-- .../Import/ImportFileUploadStep.module.scss | 1 + packages/webapp/src/static/json/icons.tsx | 14 +- packages/webapp/src/style/App.scss | 1 + .../webapp/src/style/objects/typography.scss | 2 +- packages/webapp/src/style/section.scss | 117 +++++++++ 13 files changed, 619 insertions(+), 48 deletions(-) create mode 100644 packages/webapp/src/components/Section/Section.tsx create mode 100644 packages/webapp/src/components/Section/SectionCard.tsx create mode 100644 packages/webapp/src/components/Section/index.ts create mode 100644 packages/webapp/src/containers/Import/ImportFilePreview.module.scss create mode 100644 packages/webapp/src/style/section.scss diff --git a/packages/webapp/src/components/Icon/index.tsx b/packages/webapp/src/components/Icon/index.tsx index 83ff5b2ae..beb5c9ba2 100644 --- a/packages/webapp/src/components/Icon/index.tsx +++ b/packages/webapp/src/components/Icon/index.tsx @@ -17,11 +17,20 @@ import classNames from 'classnames'; import * as React from 'react'; -import { Classes } from '@blueprintjs/core'; +import { Classes, Props } from '@blueprintjs/core'; import IconSvgPaths from '@/static/json/icons'; import PropTypes from 'prop-types'; +export interface IconProps extends Props { + color?: string; + htmlTitle?: string; + icon: IconName | MaybeElement; + iconSize?: number; + style?: object; + tagName?: keyof JSX.IntrinsicElements; + title?: string; +} -export class Icon extends React.Component { +export class Icon extends React.Component { static displayName = `af.Icon`; static SIZE_STANDARD = 16; diff --git a/packages/webapp/src/components/Section/Section.tsx b/packages/webapp/src/components/Section/Section.tsx new file mode 100644 index 000000000..e46578a3d --- /dev/null +++ b/packages/webapp/src/components/Section/Section.tsx @@ -0,0 +1,248 @@ +import classNames from 'classnames'; +import React from 'react'; +import { + Card, + Collapse, + type CollapseProps, + Elevation, + Utils, + DISPLAYNAME_PREFIX, + type HTMLDivProps, + type MaybeElement, + type Props, + IconName, +} from '@blueprintjs/core'; +import { H6 } from '@blueprintjs/core'; +import { CLASSES } from '@/constants'; +import { Icon } from '../Icon'; + +/** + * Subset of {@link Elevation} options which are visually supported by the {@link Section} component. + * + * Note that an elevation greater than 1 creates too much visual clutter/noise in the UI, especially when + * multiple Sections are shown on a single page. + */ +export type SectionElevation = typeof Elevation.ZERO | typeof Elevation.ONE; + +export interface SectionCollapseProps + extends Pick< + CollapseProps, + 'className' | 'isOpen' | 'keepChildrenMounted' | 'transitionDuration' + > { + /** + * Whether the component is initially open or closed. + * + * This prop has no effect if `collapsible={false}` or the component is in controlled mode, + * i.e. when `isOpen` is **not** `undefined`. + * + * @default true + */ + defaultIsOpen?: boolean; + + /** + * Whether the component is open or closed. + * + * Passing a boolean value to `isOpen` will enabled controlled mode for the component. + */ + isOpen?: boolean; + + /** + * Callback invoked in controlled mode when the collapse toggle element is clicked. + */ + onToggle?: () => void; +} + +export interface SectionProps extends Props, Omit { + /** + * Whether this section's contents should be collapsible. + * + * @default false + */ + collapsible?: boolean; + + /** + * Subset of props to forward to the underlying {@link Collapse} component, with the addition of a + * `defaultIsOpen` option which sets the default open state of the component when in uncontrolled mode. + */ + collapseProps?: SectionCollapseProps; + + /** + * Whether this section should use compact styles. + * + * @default false + */ + compact?: boolean; + + /** + * Visual elevation of this container element. + * + * @default Elevation.ZERO + */ + elevation?: SectionElevation; + + /** + * Name of a Blueprint UI icon (or an icon element) to render in the section's header. + * Note that the header will only be rendered if `title` is provided. + */ + icon?: IconName | MaybeElement; + + /** + * Element to render on the right side of the section header. + * Note that the header will only be rendered if `title` is provided. + */ + rightElement?: JSX.Element; + + /** + * Sub-title of the section. + * Note that the header will only be rendered if `title` is provided. + */ + subtitle?: JSX.Element | string; + + /** + * Title of the section. + * Note that the header will only be rendered if `title` is provided. + */ + title?: JSX.Element | string; + + /** + * Optional title renderer function. If provided, it is recommended to include a Blueprint `
` element + * as part of the title. The render function is supplied with `className` and `id` attributes which you must + * forward to the DOM. The `title` prop is also passed along to this renderer via `props.children`. + * + * @default H6 + */ + titleRenderer?: React.FC>; +} + +/** + * Section component. + * + * @see https://blueprintjs.com/docs/#core/components/section + */ +export const Section: React.FC = React.forwardRef( + (props, ref) => { + const { + children, + className, + collapseProps, + collapsible, + compact, + elevation, + icon, + rightElement, + subtitle, + title, + titleRenderer = H6, + ...htmlProps + } = props; + // Determine whether to use controlled or uncontrolled state. + const isControlled = collapseProps?.isOpen != null; + + // The initial useState value is negated in order to conform to the `isCollapsed` expectation. + const [isCollapsedUncontrolled, setIsCollapsed] = React.useState( + !(collapseProps?.defaultIsOpen ?? true), + ); + + const isCollapsed = isControlled + ? !collapseProps?.isOpen + : isCollapsedUncontrolled; + + const toggleIsCollapsed = React.useCallback(() => { + if (isControlled) { + collapseProps?.onToggle?.(); + } else { + setIsCollapsed(!isCollapsed); + } + }, [collapseProps, isCollapsed, isControlled]); + + const isHeaderRightContainerVisible = rightElement != null || collapsible; + + const sectionId = Utils.uniqueId('section'); + const sectionTitleId = title ? Utils.uniqueId('section-title') : undefined; + + return ( + + {title && ( +
+
+ {/* {icon && ( + + )} */} +
+ {React.createElement( + titleRenderer, + { + className: CLASSES.SECTION_HEADER_TITLE, + id: sectionTitleId, + }, + title, + )} + {subtitle && ( +
+ {subtitle} +
+ )} +
+
+ {isHeaderRightContainerVisible && ( +
+ {rightElement} + {collapsible && + (isCollapsed ? ( + + ) : ( + + ))} +
+ )} +
+ )} + {collapsible ? ( + // @ts-ignore + + {children} + + ) : ( + children + )} +
+ ); + }, +); +Section.defaultProps = { + compact: false, + elevation: Elevation.ZERO, +}; +Section.displayName = `${DISPLAYNAME_PREFIX}.Section`; diff --git a/packages/webapp/src/components/Section/SectionCard.tsx b/packages/webapp/src/components/Section/SectionCard.tsx new file mode 100644 index 000000000..9ff52e480 --- /dev/null +++ b/packages/webapp/src/components/Section/SectionCard.tsx @@ -0,0 +1,41 @@ +import classNames from 'classnames'; +import * as React from 'react'; +import { DISPLAYNAME_PREFIX, HTMLDivProps, Props } from '@blueprintjs/core'; +import { CLASSES } from '@/constants'; + +export interface SectionCardProps + extends Props, + HTMLDivProps, + React.RefAttributes { + /** + * Whether to apply visual padding inside the content container element. + * + * @default true + */ + padded?: boolean; +} + +/** + * Section card component. + * + * @see https://blueprintjs.com/docs/#core/components/section.section-card + */ +export const SectionCard: React.FC = React.forwardRef( + (props, ref) => { + const { className, children, padded, ...htmlProps } = props; + const classes = classNames( + CLASSES.SECTION_CARD, + { [CLASSES.PADDED]: padded }, + className, + ); + return ( +
+ {children} +
+ ); + }, +); +SectionCard.defaultProps = { + padded: true, +}; +SectionCard.displayName = `${DISPLAYNAME_PREFIX}.SectionCard`; diff --git a/packages/webapp/src/components/Section/index.ts b/packages/webapp/src/components/Section/index.ts new file mode 100644 index 000000000..ca7c2dece --- /dev/null +++ b/packages/webapp/src/components/Section/index.ts @@ -0,0 +1,2 @@ +export * from './Section'; +export * from './SectionCard'; \ No newline at end of file diff --git a/packages/webapp/src/constants/classes.tsx b/packages/webapp/src/constants/classes.tsx index 778829263..c708cd453 100644 --- a/packages/webapp/src/constants/classes.tsx +++ b/packages/webapp/src/constants/classes.tsx @@ -1,6 +1,21 @@ // @ts-nocheck import { Classes } from '@blueprintjs/core'; +export const NS = 'bp4'; + +export const SECTION = `${NS}-section`; +export const SECTION_COLLAPSED = `${SECTION}-collapsed`; +export const SECTION_HEADER = `${SECTION}-header`; +export const SECTION_HEADER_LEFT = `${SECTION_HEADER}-left`; +export const SECTION_HEADER_TITLE = `${SECTION_HEADER}-title`; +export const SECTION_HEADER_SUB_TITLE = `${SECTION_HEADER}-sub-title`; +export const SECTION_HEADER_DIVIDER = `${SECTION_HEADER}-divider`; +export const SECTION_HEADER_TABS = `${SECTION_HEADER}-tabs`; +export const SECTION_HEADER_RIGHT = `${SECTION_HEADER}-right`; +export const SECTION_CARD = `${SECTION}-card`; + +export const PADDED = `${NS}-padded`; + const CLASSES = { DASHBOARD_PAGE: 'dashboard__page', DASHBOARD_DATATABLE: 'dashboard__datatable', @@ -16,7 +31,7 @@ const CLASSES = { DASHBOARD_CONTENT_PREFERENCES: 'dashboard-content--preferences', DASHBOARD_CONTENT_PANE: 'Pane2', DASHBOARD_CENTERED_EMPTY_STATUS: 'dashboard__centered-empty-status', - + PAGE_FORM: 'page-form', PAGE_FORM_HEADER: 'page-form__header', PAGE_FORM_HEADER_PRIMARY: 'page-form__primary-section', @@ -40,9 +55,9 @@ const CLASSES = { PAGE_FORM_ITEM: 'page-form--item', PAGE_FORM_MAKE_JOURNAL: 'page-form--make-journal-entries', PAGE_FORM_EXPENSE: 'page-form--expense', - PAGE_FORM_CREDIT_NOTE:'page-form--credit-note', - PAGE_FORM_VENDOR_CREDIT_NOTE:'page-form--vendor-credit-note', - PAGE_FORM_WAREHOUSE_TRANSFER:'page-form--warehouse-transfer', + PAGE_FORM_CREDIT_NOTE: 'page-form--credit-note', + PAGE_FORM_VENDOR_CREDIT_NOTE: 'page-form--vendor-credit-note', + PAGE_FORM_WAREHOUSE_TRANSFER: 'page-form--warehouse-transfer', FORM_GROUP_LIST_SELECT: 'form-group--select-list', @@ -66,31 +81,42 @@ const CLASSES = { PREFERENCES_TOPBAR: 'preferences-topbar', PREFERENCES_PAGE_INSIDE_CONTENT: 'preferences-page__inside-content', - PREFERENCES_PAGE_INSIDE_CONTENT_GENERAL: 'preferences-page__inside-content--general', - PREFERENCES_PAGE_INSIDE_CONTENT_USERS: 'preferences-page__inside-content--users', - PREFERENCES_PAGE_INSIDE_CONTENT_CURRENCIES: 'preferences-page__inside-content--currencies', - PREFERENCES_PAGE_INSIDE_CONTENT_ACCOUNTANT: 'preferences-page__inside-content--accountant', - PREFERENCES_PAGE_INSIDE_CONTENT_SMS_INTEGRATION: 'preferences-page__inside-content--sms-integration', - PREFERENCES_PAGE_INSIDE_CONTENT_ROLES_FORM: 'preferences-page__inside-content--roles-form', - PREFERENCES_PAGE_INSIDE_CONTENT_BRANCHES: 'preferences-page__inside-content--branches', - PREFERENCES_PAGE_INSIDE_CONTENT_WAREHOUSES: 'preferences-page__inside-content--warehouses', + PREFERENCES_PAGE_INSIDE_CONTENT_GENERAL: + 'preferences-page__inside-content--general', + PREFERENCES_PAGE_INSIDE_CONTENT_USERS: + 'preferences-page__inside-content--users', + PREFERENCES_PAGE_INSIDE_CONTENT_CURRENCIES: + 'preferences-page__inside-content--currencies', + PREFERENCES_PAGE_INSIDE_CONTENT_ACCOUNTANT: + 'preferences-page__inside-content--accountant', + PREFERENCES_PAGE_INSIDE_CONTENT_SMS_INTEGRATION: + 'preferences-page__inside-content--sms-integration', + PREFERENCES_PAGE_INSIDE_CONTENT_ROLES_FORM: + 'preferences-page__inside-content--roles-form', + PREFERENCES_PAGE_INSIDE_CONTENT_BRANCHES: + 'preferences-page__inside-content--branches', + PREFERENCES_PAGE_INSIDE_CONTENT_WAREHOUSES: + 'preferences-page__inside-content--warehouses', FINANCIAL_REPORT_INSIDER: 'dashboard__insider--financial-report', - UNIVERSAL_SEARCH: 'universal-search', UNIVERSAL_SEARCH_OMNIBAR: 'universal-search__omnibar', UNIVERSAL_SEARCH_OVERLAY: 'universal-search-overlay', UNIVERSAL_SEARCH_INPUT: 'universal-search__input', - UNIVERSAL_SEARCH_INPUT_RIGHT_ELEMENTS: 'universal-search-input-right-elements', + UNIVERSAL_SEARCH_INPUT_RIGHT_ELEMENTS: + 'universal-search-input-right-elements', UNIVERSAL_SEARCH_TYPE_SELECT_OVERLAY: 'universal-search__type-select-overlay', UNIVERSAL_SEARCH_TYPE_SELECT_BTN: 'universal-search__type-select-btn', UNIVERSAL_SEARCH_FOOTER: 'universal-search__footer', UNIVERSAL_SEARCH_ACTIONS: 'universal-search__actions', - UNIVERSAL_SEARCH_ACTION_SELECT: 'universal-search__action universal-search__action--select', - UNIVERSAL_SEARCH_ACTION_CLOSE: 'universal-search__action universal-search__action--close', - UNIVERSAL_SEARCH_ACTION_ARROWS: 'universal-search__action universal-search__action--arrows', + UNIVERSAL_SEARCH_ACTION_SELECT: + 'universal-search__action universal-search__action--select', + UNIVERSAL_SEARCH_ACTION_CLOSE: + 'universal-search__action universal-search__action--close', + UNIVERSAL_SEARCH_ACTION_ARROWS: + 'universal-search__action universal-search__action--arrows', DIALOG_PDF_PREVIEW: 'dialog--pdf-preview-dialog', @@ -98,8 +124,19 @@ const CLASSES = { CARD: 'card', ALIGN_RIGHT: 'align-right', FONT_BOLD: 'font-bold', + + NS, + PADDED, + SECTION, + SECTION_COLLAPSED, + SECTION_HEADER, + SECTION_HEADER_LEFT, + SECTION_HEADER_TITLE, + SECTION_HEADER_SUB_TITLE, + SECTION_HEADER_DIVIDER, + SECTION_HEADER_TABS, + SECTION_HEADER_RIGHT, + SECTION_CARD, }; -export { - CLASSES, -} +export { CLASSES }; diff --git a/packages/webapp/src/containers/Import/ImportFileMappingForm.tsx b/packages/webapp/src/containers/Import/ImportFileMappingForm.tsx index 29e4a3c54..ebe8e2f16 100644 --- a/packages/webapp/src/containers/Import/ImportFileMappingForm.tsx +++ b/packages/webapp/src/containers/Import/ImportFileMappingForm.tsx @@ -1,11 +1,11 @@ // @ts-nocheck +import { Intent } from '@blueprintjs/core'; 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; +import { AppToaster } from '@/components'; interface ImportFileMappingFormProps { children: React.ReactNode; @@ -33,7 +33,13 @@ export function ImportFileMappingForm({ setSubmitting(false); setStep(2); }) - .catch((error) => { + .catch(({ response: { data } }) => { + if (data.errors.find(e => e.type === "DUPLICATED_FROM_MAP_ATTR")) { + AppToaster.show({ + message: 'Selected the same sheet columns to multiple fields.', + intent: Intent.DANGER + }) + } setSubmitting(false); }); }; @@ -42,7 +48,6 @@ export function ImportFileMappingForm({
{children}
diff --git a/packages/webapp/src/containers/Import/ImportFilePreview.module.scss b/packages/webapp/src/containers/Import/ImportFilePreview.module.scss new file mode 100644 index 000000000..f24ce4472 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFilePreview.module.scss @@ -0,0 +1,42 @@ + +.previewList { + list-style: none; + margin-top: 14px; + + :global(li) { + border-top: 1px solid #d9d9da; + padding: 6px 0; + + &:last-child{ + padding-bottom: 0; + } + } +} + +.unmappedList { + padding-left: 2rem; +} + +.skippedTable { + width: 100%; + + thead{ + th{ + padding-top: 0; + padding-bottom: 8px; + color: #738091; + font-weight: 500; + } + } + + tbody{ + tr td { + vertical-align: middle; + padding: 7px; + } + + tr:hover td{ + background: #F6F7F9; + } + } +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Import/ImportFilePreview.tsx b/packages/webapp/src/containers/Import/ImportFilePreview.tsx index 17aa8476d..326d72f73 100644 --- a/packages/webapp/src/containers/Import/ImportFilePreview.tsx +++ b/packages/webapp/src/containers/Import/ImportFilePreview.tsx @@ -7,10 +7,13 @@ import { useImportFilePreviewBootContext, } from './ImportFilePreviewBoot'; import { useImportFileContext } from './ImportFileProvider'; -import { AppToaster, Card, Group } from '@/components'; import { useImportFileProcess } from '@/hooks/query/import'; +import { AppToaster, Box, Group, Stack } from '@/components'; import { CLASSES } from '@/constants'; import { ImportStepperStep } from './_types'; +import { ImportFileContainer } from './ImportFileContainer'; +import { SectionCard, Section } from '@/components/Section'; +import styles from './ImportFilePreview.module.scss'; export function ImportFilePreview() { const { importId } = useImportFileContext(); @@ -26,23 +29,68 @@ function ImportFilePreviewContent() { const { importPreview } = useImportFilePreviewBootContext(); return ( -
- - {importPreview.createdCount} of {importPreview.totalCount} Items in your - file are ready to be imported. - + + + + + {importPreview.createdCount} of {importPreview.totalCount} Items in + your file are ready to be imported. + - + + + + + + + + ); +} + +function ImportFilePreviewImported() { + const { importPreview } = useImportFilePreviewBootContext(); + + return ( +
+ 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})
  • +
      +
    • + Items to be created: ({importPreview.createdCount}) +
    • +
    • + Items to be skipped: ({importPreview.skippedCount}) +
    • +
    • + Items have errors: ({importPreview.errorsCount}) +
    + +
+ ); +} + +function ImportFilePreviewSkipped() { + const { importPreview } = useImportFilePreviewBootContext(); - + return ( +
+ +
@@ -64,20 +112,28 @@ function ImportFilePreviewContent() { ))}
#
+ + + ); +} - - Unmapped Sheet Columns - ({importPreview?.unmappedColumnsCount}) - +function ImportFilePreviewUnmapped() { + const { importPreview } = useImportFilePreviewBootContext(); -
    + return ( +
    + +
      {importPreview.unmappedColumns?.map((column, key) => (
    • {column}
    • ))}
    - - - -
+ + ); } diff --git a/packages/webapp/src/containers/Import/ImportFileUploadStep.module.scss b/packages/webapp/src/containers/Import/ImportFileUploadStep.module.scss index 61fc5c3fd..95bfd50fd 100644 --- a/packages/webapp/src/containers/Import/ImportFileUploadStep.module.scss +++ b/packages/webapp/src/containers/Import/ImportFileUploadStep.module.scss @@ -5,6 +5,7 @@ .content { flex: 1; padding: 32px 20px; + padding-bottom: 80px; min-width: 660px; max-width: 760px; width: 75%; diff --git a/packages/webapp/src/static/json/icons.tsx b/packages/webapp/src/static/json/icons.tsx index 9e3a457cb..95cdbd46a 100644 --- a/packages/webapp/src/static/json/icons.tsx +++ b/packages/webapp/src/static/json/icons.tsx @@ -588,5 +588,17 @@ export default { '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' - } + }, + 'chevron-up': { + path: [ + 'M12.71,9.29l-4-4C8.53,5.11,8.28,5,8,5S7.47,5.11,7.29,5.29l-4,4C3.11,9.47,3,9.72,3,10c0,0.55,0.45,1,1,1c0.28,0,0.53-0.11,0.71-0.29L8,7.41l3.29,3.29C11.47,10.89,11.72,11,12,11c0.55,0,1-0.45,1-1C13,9.72,12.89,9.47,12.71,9.29' + ], + viewBox: '0 0 16 16', + }, + 'chevron-down': { + path: [ + 'M12,5c-0.28,0-0.53,0.11-0.71,0.29L8,8.59L4.71,5.29C4.53,5.11,4.28,5,4,5C3.45,5,3,5.45,3,6c0,0.28,0.11,0.53,0.29,0.71l4,4C7.47,10.89,7.72,11,8,11s0.53-0.11,0.71-0.29l4-4C12.89,6.53,13,6.28,13,6C13,5.45,12.55,5,12,5z' + ], + viewBox: '0 0 16 16', + }, }; diff --git a/packages/webapp/src/style/App.scss b/packages/webapp/src/style/App.scss index f18606673..b03ce5d27 100644 --- a/packages/webapp/src/style/App.scss +++ b/packages/webapp/src/style/App.scss @@ -36,6 +36,7 @@ // fonts @import 'pages/fonts'; +@import "section"; .App { min-width: 960px; diff --git a/packages/webapp/src/style/objects/typography.scss b/packages/webapp/src/style/objects/typography.scss index 9bd78c5f2..b4c97ce76 100644 --- a/packages/webapp/src/style/objects/typography.scss +++ b/packages/webapp/src/style/objects/typography.scss @@ -9,7 +9,7 @@ body{ } .#{$ns}-heading{ - font-weight: 400; + font-weight: 600; } .divider{ diff --git a/packages/webapp/src/style/section.scss b/packages/webapp/src/style/section.scss new file mode 100644 index 000000000..eab732844 --- /dev/null +++ b/packages/webapp/src/style/section.scss @@ -0,0 +1,117 @@ +@use "sass:math"; +@import './_base.scss'; + +$section-min-height: $pt-grid-size * 5 !default; +$section-padding-vertical: $pt-grid-size !default; +$section-padding-horizontal: $pt-grid-size * 2 !default; +$section-card-padding: $pt-grid-size * 2 !default; + +$section-min-height-compact: $pt-grid-size * 4 !default; +$section-padding-compact-vertical: 7px !default; +$section-padding-compact-horizontal: 15px !default; +$section-card-padding-compact: $pt-grid-size * 1.5 !default; + +.#{$ns}-section { + overflow: hidden; + width: 100%; + + &, + &.#{$ns}-compact { + // override Card compact styles here + padding: 0; + } + + &-header { + align-items: center; + border-bottom: 1px solid $pt-divider-black; + display: flex; + gap: $pt-grid-size * 2; + justify-content: space-between; + min-height: $section-min-height; + padding: 0 $section-padding-horizontal; + position: relative; + width: 100%; + + &.#{$ns}-dark, + .#{$ns}-dark & { + border-color: $pt-dark-divider-white; + } + + &-left { + align-items: center; + display: flex; + gap: $pt-grid-size; + padding: $section-padding-vertical 0; + } + + &-title { + margin-bottom: 0; + } + + &-sub-title { + margin-top: 2px; + } + + &-right { + align-items: center; + display: flex; + gap: $pt-grid-size; + margin-left: auto; + } + + &-divider { + align-self: stretch; + margin: $pt-grid-size * 1.5 0; + } + + &.#{$ns}-interactive { + cursor: pointer; + + &:hover, + &:active { + background: $light-gray5; + + &.#{$ns}-dark, + .#{$ns}-dark & { + background: $dark-gray4; + } + } + } + } + + &-card { + &.#{$ns}-padded { + padding: $section-card-padding; + } + + &:not(:last-child) { + border-bottom: 1px solid $pt-divider-black; + + &.#{$ns}-dark, + .#{$ns}-dark & { + border-color: $pt-dark-divider-white; + } + } + } + + &.#{$ns}-section-collapsed { + .#{$ns}-section-header { + border: none; + } + } + + &.#{$ns}-compact { + .#{$ns}-section-header { + min-height: $section-min-height-compact; + padding: 0 $section-padding-compact-horizontal; + + &-left { + padding: $section-padding-compact-vertical 0; + } + } + + .#{$ns}-section-card.#{$ns}-padded { + padding: $section-card-padding-compact; + } + } +} \ No newline at end of file