Merge pull request #386 from bigcapitalhq/frontend-import-resource

feat(webapp): import resource UI
This commit is contained in:
Ahmed Bouhuolia
2024-03-22 01:45:19 +02:00
committed by GitHub
53 changed files with 2239 additions and 37 deletions

View File

@@ -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

View File

@@ -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"
},

View File

@@ -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;
}

View File

@@ -0,0 +1,291 @@
// @ts-nocheck
import React from 'react';
import { Ref, useCallback } 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<HTMLElement>) => void;
/** Called when the `dragleave` event occurs */
onDragLeave?: (event: React.DragEvent<HTMLElement>) => void;
/** Called when the `dragover` event occurs */
onDragOver?: (event: React.DragEvent<HTMLElement>) => 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 <input type="file"> click event, defaults to true */
useFsAccessApi?: boolean;
/** Use this to provide a custom file aggregator */
getFilesFromEvent?: (
event: DropEvent,
) => Promise<Array<File | DataTransferItem>>;
/** Custom validation function. It must return null if there's no errors. */
validator?: <T extends File>(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<HTMLInputElement>;
}
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<DropzoneProps> = {
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;
assignRef(openRef, open);
return (
<DropzoneProvider
value={{ accept: isDragAccept, reject: isDragReject, idle: isIdle }}
>
<Box
{...getRootProps({
className: clsx(styles.root, classNames?.root),
})}
// {...getStyles('root', { focusable: true })}
{...others}
mod={[
{
accept: isDragAccept,
reject: isDragReject,
idle: isIdle,
loading,
'activate-on-click': activateOnClick,
},
// mod,
]}
>
<input {...getInputProps(inputProps)} name={name} />
<div
data-enable-pointer-events={enablePointerEvents || undefined}
className={classNames?.content}
>
{children}
</div>
</Box>
</DropzoneProvider>
);
};
Dropzone.displayName = '@mantine/dropzone/Dropzone';
Dropzone.Accept = DropzoneAccept;
Dropzone.Idle = DropzoneIdle;
Dropzone.Reject = DropzoneReject;
type PossibleRef<T> = Ref<T> | undefined;
export function assignRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === 'function') {
ref(value);
} else if (typeof ref === 'object' && ref !== null && 'current' in ref) {
(ref as React.MutableRefObject<T>).current = value;
}
}
export function mergeRefs<T>(...refs: PossibleRef<T>[]) {
return (node: T | null) => {
refs.forEach((ref) => assignRef(ref, node));
};
}
export function useMergedRef<T>(...refs: PossibleRef<T>[]) {
return useCallback(mergeRefs(...refs), refs);
}

View File

@@ -0,0 +1,12 @@
import { createSafeContext } from './create-safe-context';
export interface DropzoneContextValue {
idle: boolean;
accept: boolean;
reject: boolean;
}
export const [DropzoneProvider, useDropzoneContext] =
createSafeContext<DropzoneContextValue>(
'Dropzone component was not found in tree',
);

View File

@@ -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<DropzoneStatusProps>;
function createDropzoneStatus(status: keyof DropzoneContextValue) {
const Component: DropzoneStatusComponent = (props) => {
const { children, ...others } = props;
const ctx = useDropzoneContext();
const _children = isElement(children) ? children : <span>{children}</span>;
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;

View File

@@ -0,0 +1,25 @@
import React, { createContext, useContext } from 'react';
export function createSafeContext<ContextValue>(errorMessage: string) {
const Context = createContext<ContextValue | null>(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;
}) => <Context.Provider value={value}>{children}</Context.Provider>;
return [Provider, useSafeContext] as const;
}

View File

@@ -0,0 +1 @@
export * from './Dropzone';

View File

@@ -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];

View File

@@ -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<IconProps> {
static displayName = `af.Icon`;
static SIZE_STANDARD = 16;

View File

@@ -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;
`;

View File

@@ -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<HTMLDivProps, 'title'> {
/**
* 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 `<H6>` 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<React.HTMLAttributes<HTMLElement>>;
}
/**
* Section component.
*
* @see https://blueprintjs.com/docs/#core/components/section
*/
export const Section: React.FC<SectionProps> = 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<boolean>(
!(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 (
<Card
className={classNames(className, CLASSES.SECTION, {
[CLASSES.COMPACT]: compact,
[CLASSES.SECTION_COLLAPSED]:
(collapsible && isCollapsed) || Utils.isReactNodeEmpty(children),
})}
elevation={elevation}
aria-labelledby={sectionTitleId}
{...htmlProps}
id={sectionId}
>
{title && (
<div
role={collapsible ? 'button' : undefined}
aria-pressed={collapsible ? isCollapsed : undefined}
aria-expanded={collapsible ? isCollapsed : undefined}
aria-controls={collapsible ? sectionId : undefined}
className={classNames(CLASSES.SECTION_HEADER, {
[CLASSES.INTERACTIVE]: collapsible,
})}
onClick={collapsible ? toggleIsCollapsed : undefined}
>
<div className={CLASSES.SECTION_HEADER_LEFT}>
{/* {icon && (
<Icon
icon={icon}
aria-hidden={true}
tabIndex={-1}
className={CLASSES.TEXT_MUTED}
/>
)} */}
<div>
{React.createElement(
titleRenderer,
{
className: CLASSES.SECTION_HEADER_TITLE,
id: sectionTitleId,
},
title,
)}
{subtitle && (
<div
className={classNames(
CLASSES.TEXT_MUTED,
CLASSES.SECTION_HEADER_SUB_TITLE,
)}
>
{subtitle}
</div>
)}
</div>
</div>
{isHeaderRightContainerVisible && (
<div className={CLASSES.SECTION_HEADER_RIGHT}>
{rightElement}
{collapsible &&
(isCollapsed ? (
<Icon
icon={'chevron-down'}
className={CLASSES.TEXT_MUTED}
/>
) : (
<Icon icon={'chevron-up'} className={CLASSES.TEXT_MUTED} />
))}
</div>
)}
</div>
)}
{collapsible ? (
// @ts-ignore
<Collapse isOpen={!isCollapsed}>
{children}
</Collapse>
) : (
children
)}
</Card>
);
},
);
Section.defaultProps = {
compact: false,
elevation: Elevation.ZERO,
};
Section.displayName = `${DISPLAYNAME_PREFIX}.Section`;

View File

@@ -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<HTMLDivElement> {
/**
* 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<SectionCardProps> = React.forwardRef(
(props, ref) => {
const { className, children, padded, ...htmlProps } = props;
const classes = classNames(
CLASSES.SECTION_CARD,
{ [CLASSES.PADDED]: padded },
className,
);
return (
<div className={classes} ref={ref} {...htmlProps}>
{children}
</div>
);
},
);
SectionCard.defaultProps = {
padded: true,
};
SectionCard.displayName = `${DISPLAYNAME_PREFIX}.SectionCard`;

View File

@@ -0,0 +1,2 @@
export * from './Section';
export * from './SectionCard';

View File

@@ -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 {
/** <Stepper.Step /> 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<string, string>;
}
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<React.ReactElement[]>((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(
<StepSeparator
data-active={index < active || undefined}
key={`separator-${index}`}
/>,
);
}
return acc;
}, []);
const stepContent = _children[active]?.props?.children;
const completedContent = completedStep?.props?.children;
const content =
active > _children.length - 1 ? completedContent : stepContent;
return (
<Box className={classNames?.root}>
<StepsItems className={classNames?.items}>{items}</StepsItems>
<StepsContent className={classNames?.content}>{content} </StepsContent>
</Box>
);
}
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;
`;

View File

@@ -0,0 +1,9 @@
import React from 'react';
export interface StepperCompletedProps {
/** Label content */
children: React.ReactNode;
}
export const StepperCompleted: React.FC<StepperCompletedProps> = () => null;
StepperCompleted.displayName = '@bigcapital/core/StepperCompleted';

View File

@@ -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 (
<StepButton>
<StepIconWrap>
<StepIcon
isCompleted={state === StepperStepState.Completed}
isActive={state === StepperStepState.Progress}
>
{state === StepperStepState.Completed && (
<Icon icon={'done'} iconSize={24} />
)}
<StepIconText>{step}</StepIconText>
</StepIcon>
</StepIconWrap>
<StepTextWrap>
<StepTitle
isCompleted={state === StepperStepState.Completed}
isActive={state === StepperStepState.Progress}
>
{label}
</StepTitle>
{description && (
<StepDescription
isCompleted={state === StepperStepState.Completed}
isActive={state === StepperStepState.Progress}
>
{description}
</StepDescription>
)}
</StepTextWrap>
</StepButton>
);
}
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``;

View File

@@ -0,0 +1 @@
export * from './Stepper';

View File

@@ -0,0 +1,7 @@
export enum StepperStepState {
Progress = 'stepProgress',
Completed = 'stepCompleted',
Inactive = 'stepInactive',
}

View File

@@ -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 };

View File

@@ -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 (
<DashboardActionsBar>
<NavbarGroup>
@@ -183,6 +191,7 @@ function AccountsActionsBar({
className={Classes.MINIMAL}
icon={<Icon icon="file-import-16" iconSize={16} />}
text={<T id={'import'} />}
onClick={handleImportBtnClick}
/>
<NavbarDivider />
<DashboardRowsHeightButton

View File

@@ -0,0 +1,32 @@
.root {
}
.title{
color: #5F6B7C;
font-weight: 600;
}
.iconWrap{
color: #8F99A8;
}
.subtitle {
color: #8F99A8;
}
.dropzoneContent{
text-align: center;
margin: auto;
}
.buttons-wrap {
margin-top: 12px;
}
.dropzoneHint{
display: flex;
font-size: 12px;
margin-top: 6px;
color: #8F99A8;
justify-content: space-between;
}

View File

@@ -0,0 +1,27 @@
// @ts-nocheck
import { Field } from 'formik';
import { Box, Group, Stack } from '@/components';
import styles from './ImportDropzone.module.css';
import { ImportDropzoneField } from './ImportDropzoneFile';
export function ImportDropzone() {
return (
<Stack spacing={0}>
<Field id={'file'} name={'file'} type="file">
{({ form }) => (
<ImportDropzoneField
value={form.file}
onChange={(file) => {
form.setFieldValue('file', file);
}}
/>
)}
</Field>
<Group className={styles.dropzoneHint}>
<Box>Supperted Formats: CSV, XLSX</Box>
<Box>Maximum size: 25MB</Box>
</Group>
</Stack>
);
}

View File

@@ -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 (
<Dropzone
onDrop={(files) => 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}
>
<Stack spacing={12} align="center" className={styles.content}>
<Box className={styles.iconWrap}>
<Icon icon="download" iconSize={26} />
</Box>
{localValue ? (
<Stack spacing={6} justify="center" align="center">
<h4 className={styles.title}>{localValue.name}</h4>
<Button small minimal intent={Intent.DANGER} onClick={handleRemove}>
Remove
</Button>
</Stack>
) : (
<Stack spacing={4} align="center">
<h4 className={styles.title}>
Drag images here or click to select files
</h4>
<span className={styles.subtitle}>
Drag and Drop file here or Choose file
</span>
</Stack>
)}
<Button
intent="none"
onClick={() => openRef.current?.()}
style={{ pointerEvents: 'all' }}
minimal
outlined
>
{localValue ? 'Replace File' : 'Upload File'}
</Button>
</Stack>
</Dropzone>
);
}

View File

@@ -0,0 +1,5 @@
.root{
}

View File

@@ -0,0 +1,9 @@
import styles from './ImportFileUploadStep.module.scss';
interface ImportFileContainerProps {
children: React.ReactNode;
}
export function ImportFileContainer({ children }: ImportFileContainerProps) {
return <div className={styles.content}>{children}</div>;
}

View File

@@ -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 (
<div className={clsx(CLASSES.PAGE_FORM_FLOATING_ACTIONS, styles.root)}>
<Group spacing={10}>
<Button onClick={handleCancelBtnClick}>Cancel</Button>
<Button type="submit" intent={Intent.PRIMARY} loading={isSubmitting}>
Next
</Button>
</Group>
</div>
);
}

View File

@@ -0,0 +1,31 @@
.table {
width: 100%;
margin-top: 1.4rem;
th.label,
td.label{
width: 32% !important;
}
thead{
th{
border-top: 1px solid #d9d9da;
padding-top: 8px;
padding-bottom: 8px;
color: #738091;
font-weight: 500;
}
}
tbody{
tr td {
vertical-align: middle;
}
tr td{
:global(.bp4-popover-target .bp4-button){
max-width: 250px;
}
}
}
}

View File

@@ -0,0 +1,83 @@
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 { CLASSES } from '@/constants';
import { ImportFileContainer } from './ImportFileContainer';
import styles from './ImportFileMapping.module.scss';
import { ImportStepperStep } from './_types';
export function ImportFileMapping() {
return (
<ImportFileMappingForm>
<ImportFileContainer>
<p>
Review and map the column headers in your csv/xlsx file with the
Bigcapital fields.
</p>
<table className={clsx('bp4-html-table', styles.table)}>
<thead>
<tr>
<th className={styles.label}>Bigcapital Fields</th>
<th className={styles.field}>Sheet Column Headers</th>
</tr>
</thead>
<tbody>
<ImportFileMappingFields />
</tbody>
</table>
</ImportFileContainer>
<ImportFileMappingFloatingActions />
</ImportFileMappingForm>
);
}
function ImportFileMappingFields() {
const { entityColumns, sheetColumns } = useImportFileContext();
const items = useMemo(
() => sheetColumns.map((column) => ({ value: column, text: column })),
[sheetColumns],
);
const columns = entityColumns.map((column, index) => (
<tr key={index}>
<td className={styles.label}>{column.name}</td>
<td className={styles.field}>
<FSelect
name={column.key}
items={items}
popoverProps={{ minimal: true }}
minimal={true}
fill={true}
/>
</td>
</tr>
));
return <>{columns}</>;
}
function ImportFileMappingFloatingActions() {
const { isSubmitting } = useFormikContext();
const { setStep } = useImportFileContext();
const handleCancelBtnClick = () => {
setStep(ImportStepperStep.Upload);
};
return (
<div className={clsx(CLASSES.PAGE_FORM_FLOATING_ACTIONS)}>
<Group spacing={10}>
<Button onClick={handleCancelBtnClick}>Cancel</Button>
<Button type="submit" intent={Intent.PRIMARY} loading={isSubmitting}>
Next
</Button>
</Group>
</div>
);
}

View File

@@ -0,0 +1,75 @@
// @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';
import { AppToaster } from '@/components';
interface ImportFileMappingFormProps {
children: React.ReactNode;
}
type ImportFileMappingFormValues = Record<string, string | null>;
export function ImportFileMappingForm({
children,
}: ImportFileMappingFormProps) {
const { mutateAsync: submitImportFileMapping } = useImportFileMapping();
const { importId, setStep } = useImportFileContext();
const initialValues = useImportFileMappingInitialValues();
const handleSubmit = (
values: ImportFileMappingFormValues,
{ setSubmitting }: FormikHelpers<ImportFileMappingFormValues>,
) => {
setSubmitting(true);
const _values = transformValueToReq(values);
submitImportFileMapping([importId, _values])
.then(() => {
setSubmitting(false);
setStep(2);
})
.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);
});
};
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
>
<Form>{children}</Form>
</Formik>
);
}
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],
);
};

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,182 @@
// @ts-nocheck
import { Button, Callout, Intent, Text } from '@blueprintjs/core';
import clsx from 'classnames';
import { useHistory } from 'react-router-dom';
import {
ImportFilePreviewBootProvider,
useImportFilePreviewBootContext,
} from './ImportFilePreviewBoot';
import { useImportFileContext } from './ImportFileProvider';
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();
return (
<ImportFilePreviewBootProvider importId={importId}>
<ImportFilePreviewContent />
</ImportFilePreviewBootProvider>
);
}
function ImportFilePreviewContent() {
const { importPreview } = useImportFilePreviewBootContext();
return (
<Box>
<ImportFileContainer>
<Stack spacing={20}>
<Callout
intent={
importPreview.createdCount <= 0 ? Intent.DANGER : Intent.NONE
}
>
{importPreview.createdCount} of {importPreview.totalCount} Items in
your file are ready to be imported.
</Callout>
<ImportFilePreviewImported />
<ImportFilePreviewSkipped />
<ImportFilePreviewUnmapped />
</Stack>
</ImportFileContainer>
<ImportFilePreviewFloatingActions />
</Box>
);
}
function ImportFilePreviewImported() {
const { importPreview } = useImportFilePreviewBootContext();
return (
<Section
collapseProps={{ defaultIsOpen: false }}
defaultIsOpen={true}
title={`(${importPreview.createdCount}) Items are ready to import`}
>
<SectionCard padded={true}>
<Text>
Items that are ready to be imported - {importPreview.createdCount}
</Text>
<ul className={styles.previewList}>
<li>
Items to be created: <span>({importPreview.createdCount})</span>
</li>
<li>
Items to be skipped: <span>({importPreview.skippedCount})</span>
</li>
<li>
Items have errors: <span>({importPreview.errorsCount})</span>
</li>
</ul>
</SectionCard>
</Section>
);
}
function ImportFilePreviewSkipped() {
const { importPreview } = useImportFilePreviewBootContext();
return (
<Section
collapseProps={{ defaultIsOpen: false }}
collapsible={true}
title={`(${importPreview.skippedCount}) Items are skipped`}
>
<SectionCard padded={true}>
<table className={clsx('bp4-html-table', styles.skippedTable)}>
<thead>
<tr>
<th className={'number'}>#</th>
<th className={'name'}>Name</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{importPreview?.errors.map((error, key) => (
<tr key={key}>
<td>{error.rowNumber}</td>
<td>{error.rowNumber}</td>
<td>
{error.errorMessage.map((message) => (
<div>{message}</div>
))}
</td>
</tr>
))}
</tbody>
</table>
</SectionCard>
</Section>
);
}
function ImportFilePreviewUnmapped() {
const { importPreview } = useImportFilePreviewBootContext();
return (
<Section
collapseProps={{ defaultIsOpen: false }}
collapsible={true}
title={`(${importPreview?.unmappedColumnsCount}) Unmapped Columns`}
>
<SectionCard padded={true}>
<ul className={styles.unmappedList}>
{importPreview.unmappedColumns?.map((column, key) => (
<li key={key}>{column}</li>
))}
</ul>
</SectionCard>
</Section>
);
}
function ImportFilePreviewFloatingActions() {
const { importId, setStep } = 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) => {});
};
const handleCancelBtnClick = () => {
setStep(ImportStepperStep.Mapping);
};
return (
<div className={clsx(CLASSES.PAGE_FORM_FLOATING_ACTIONS)}>
<Group spacing={10}>
<Button onClick={handleCancelBtnClick}>Cancel</Button>
<Button
type="submit"
intent={Intent.PRIMARY}
loading={isImportFileLoading}
onClick={handleSubmitBtn}
disabled={!isValidToImport}
>
Import
</Button>
</Group>
</div>
);
}

View File

@@ -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<ImportFilePreviewBootContextValue>(
{} as ImportFilePreviewBootContextValue,
);
export const useImportFilePreviewBootContext = () => {
const context = useContext<ImportFilePreviewBootContextValue>(
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 (
<ImportFilePreviewBootContext.Provider value={value}>
{isImportPreviewLoading ? 'loading' : <>{children}</>}
</ImportFilePreviewBootContext.Provider>
);
};

View File

@@ -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<SetStateAction<SheetColumn[]>>;
entityColumns: EntityColumn[];
setEntityColumns: Dispatch<SetStateAction<EntityColumn[]>>;
sheetMapping: SheetMap[];
setSheetMapping: Dispatch<SetStateAction<SheetMap[]>>;
step: number;
setStep: Dispatch<SetStateAction<number>>;
importId: string;
setImportId: Dispatch<SetStateAction<string>>;
resource: string;
}
interface ImportFileProviderProps {
resource: string;
children: React.ReactNode;
}
const ImportFileContext = createContext<ImportFileContextValue>(
{} as ImportFileContextValue,
);
export const useImportFileContext = () => {
const context = useContext<ImportFileContextValue>(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<SheetColumn[]>([]);
const [entityColumns, setEntityColumns] = useState<SheetColumn[]>([]);
const [sheetMapping, setSheetMapping] = useState<SheetMap[]>([]);
const [importId, setImportId] = useState<string>('');
const [step, setStep] = useState<number>(0);
const value = {
sheetColumns,
setSheetColumns,
entityColumns,
setEntityColumns,
sheetMapping,
setSheetMapping,
step,
setStep,
importId,
setImportId,
resource,
};
return (
<ImportFileContext.Provider value={value}>
{children}
</ImportFileContext.Provider>
);
};

View File

@@ -0,0 +1,69 @@
// @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';
import { ImportStepperStep } from './_types';
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,
formikProps,
formProps,
}: ImportFileUploadFormProps) {
const { mutateAsync: uploadImportFile } = useImportFileUpload();
const { setStep, setSheetColumns, setEntityColumns, setImportId } =
useImportFileContext();
const handleSubmit = (
values: ImportFileUploadValues,
{ setSubmitting }: FormikHelpers<ImportFileUploadValues>,
) => {
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(ImportStepperStep.Mapping);
setSubmitting(false);
})
.catch((error) => {
setSubmitting(false);
});
};
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validationSchema}
{...formikProps}
>
<Form {...formProps}>{children}</Form>
</Formik>
);
}

View File

@@ -0,0 +1,17 @@
.root {
margin-top: 2.2rem
}
.content {
flex: 1;
padding: 32px 20px;
padding-bottom: 80px;
min-width: 660px;
max-width: 760px;
width: 75%;
margin-left: auto;
margin-right: auto;
}
.form {
}

View File

@@ -0,0 +1,26 @@
// @ts-nocheck
import { Stack } from '@/components';
import { ImportDropzone } from './ImportDropzone';
import { ImportSampleDownload } from './ImportSampleDownload';
import { ImportFileUploadForm } from './ImportFileUploadForm';
import { ImportFileUploadFooterActions } from './ImportFileFooterActions';
import { ImportFileContainer } from './ImportFileContainer';
export function ImportFileUploadStep() {
return (
<ImportFileUploadForm>
<ImportFileContainer>
<p style={{ marginBottom: 18 }}>
Download a sample file and compare it to your import file to ensure
you have the file perfect for the import.
</p>
<Stack spacing={40}>
<ImportDropzone />
<ImportSampleDownload />
</Stack>
</ImportFileContainer>
<ImportFileUploadFooterActions />
</ImportFileUploadForm>
);
}

View File

@@ -0,0 +1,6 @@
.root{
}
.rootWrap {
max-width: 1800px;
}

View File

@@ -0,0 +1,18 @@
// @ts-nocheck
import { ImportStepper } from './ImportStepper';
import { Box, DashboardInsider } from '@/components';
import { ImportFileProvider } from './ImportFileProvider';
import styles from './ImportPage.module.scss';
export default function ImportPage() {
return (
<DashboardInsider>
<Box className={styles.root}>
<ImportFileProvider resource="account">
<ImportStepper />
</ImportFileProvider>
</Box>
</DashboardInsider>
);
}

View File

@@ -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;
}

View File

@@ -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 (
<Group className={styles.root} noWrap>
<Box>
<h3 className={styles.title}>Table Example</h3>
<p className={styles.description}>
Download a sample file and compare it to your import file to ensure
you have the file perfect for the import.
</p>
</Box>
<Box className={styles.buttonWrap}>
<Button minimal outlined>
Download File
</Button>
</Box>
</Group>
);
}

View File

@@ -0,0 +1,18 @@
.content {
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;
}

View File

@@ -0,0 +1,34 @@
// @ts-nocheck
import { Stepper } from '@/components/Stepper';
import { ImportFileUploadStep } from './ImportFileUploadStep';
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 (
<Stepper
active={step}
classNames={{
content: styles.content,
items: styles.items,
}}
>
<Stepper.Step label={'File Upload'}>
<ImportFileUploadStep />
</Stepper.Step>
<Stepper.Step label={'Mapping'}>
<ImportFileMapping />
</Stepper.Step>
<Stepper.Step label={'Results'}>
<ImportFilePreview />
</Stepper.Step>
</Stepper>
);
}

View File

@@ -0,0 +1,5 @@
export enum ImportStepperStep {
Upload = 0,
Mapping = 1,
Preview = 2,
}

View File

@@ -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,
},
);
}

View File

@@ -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],

View File

@@ -577,4 +577,28 @@ 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'
},
'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',
},
};

View File

@@ -36,6 +36,7 @@
// fonts
@import 'pages/fonts';
@import "section";
.App {
min-width: 960px;

View File

@@ -1,7 +1,6 @@
.cloud-spinner{
position: relative;
&.is-loading:before{
content: "";

View File

@@ -9,7 +9,7 @@ body{
}
.#{$ns}-heading{
font-weight: 400;
font-weight: 600;
}
.divider{

View File

@@ -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;
}
}
}

View File

@@ -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;
}

13
pnpm-lock.yaml generated
View File

@@ -666,6 +666,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)
@@ -21196,6 +21199,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'}