mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-18 09:02:11 +03:00
Fix/csv import (#1397)
* feat: add ability to enable or disable header selection * feat: limit to max of 200 records for now * fix: bigger modal * feat: add missing standard fields for company * fix: person fields * feat: add hotkeys on dialog * feat: mobile device * fix: company import error * fix: csv import crash * fix: use scoped hotkey
This commit is contained in:
parent
f29d843db9
commit
c0cb3a47f3
@ -3,12 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import { useSpreadsheetImport } from '@/spreadsheet-import/hooks/useSpreadsheetImport';
|
import { useSpreadsheetImport } from '@/spreadsheet-import/hooks/useSpreadsheetImport';
|
||||||
import { SpreadsheetOptions } from '@/spreadsheet-import/types';
|
import { SpreadsheetOptions } from '@/spreadsheet-import/types';
|
||||||
import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar';
|
import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar';
|
||||||
import { useUpsertEntityTableItems } from '@/ui/table/hooks/useUpsertEntityTableItems';
|
import { useInsertManyCompanyMutation } from '~/generated/graphql';
|
||||||
import { useUpsertTableRowIds } from '@/ui/table/hooks/useUpsertTableRowIds';
|
|
||||||
import {
|
|
||||||
GetPeopleDocument,
|
|
||||||
useInsertManyCompanyMutation,
|
|
||||||
} from '~/generated/graphql';
|
|
||||||
|
|
||||||
import { fieldsForCompany } from '../utils/fieldsForCompany';
|
import { fieldsForCompany } from '../utils/fieldsForCompany';
|
||||||
|
|
||||||
@ -16,8 +11,6 @@ export type FieldCompanyMapping = (typeof fieldsForCompany)[number]['key'];
|
|||||||
|
|
||||||
export function useSpreadsheetCompanyImport() {
|
export function useSpreadsheetCompanyImport() {
|
||||||
const { openSpreadsheetImport } = useSpreadsheetImport<FieldCompanyMapping>();
|
const { openSpreadsheetImport } = useSpreadsheetImport<FieldCompanyMapping>();
|
||||||
const upsertEntityTableItems = useUpsertEntityTableItems();
|
|
||||||
const upsertTableRowIds = useUpsertTableRowIds();
|
|
||||||
const { enqueueSnackBar } = useSnackBar();
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
|
|
||||||
const [createManyCompany] = useInsertManyCompanyMutation();
|
const [createManyCompany] = useInsertManyCompanyMutation();
|
||||||
@ -34,11 +27,11 @@ export function useSpreadsheetCompanyImport() {
|
|||||||
// TODO: Add better type checking in spreadsheet import later
|
// TODO: Add better type checking in spreadsheet import later
|
||||||
const createInputs = data.validData.map((company) => ({
|
const createInputs = data.validData.map((company) => ({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
name: company.name as string,
|
name: (company.name ?? '') as string,
|
||||||
domainName: company.domainName as string,
|
domainName: (company.domainName ?? '') as string,
|
||||||
address: company.address as string,
|
address: (company.address ?? '') as string,
|
||||||
employees: parseInt(company.employees as string, 10),
|
employees: parseInt((company.employees ?? '') as string, 10),
|
||||||
linkedinUrl: company.linkedinUrl as string | undefined,
|
linkedinUrl: (company.linkedinUrl ?? '') as string | undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -46,15 +39,12 @@ export function useSpreadsheetCompanyImport() {
|
|||||||
variables: {
|
variables: {
|
||||||
data: createInputs,
|
data: createInputs,
|
||||||
},
|
},
|
||||||
refetchQueries: [GetPeopleDocument],
|
refetchQueries: 'active',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.errors) {
|
if (result.errors) {
|
||||||
throw result.errors;
|
throw result.errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
upsertTableRowIds(createInputs.map((company) => company.id));
|
|
||||||
upsertEntityTableItems(createInputs);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
enqueueSnackBar(error?.message || 'Something went wrong', {
|
enqueueSnackBar(error?.message || 'Something went wrong', {
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
IconBrandLinkedin,
|
IconBrandLinkedin,
|
||||||
|
IconBrandX,
|
||||||
IconBuildingSkyscraper,
|
IconBuildingSkyscraper,
|
||||||
IconMail,
|
IconMail,
|
||||||
IconMap,
|
IconMap,
|
||||||
|
IconMoneybag,
|
||||||
|
IconTarget,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
} from '@/ui/icon';
|
} from '@/ui/icon';
|
||||||
|
|
||||||
@ -16,13 +19,6 @@ export const fieldsForCompany = [
|
|||||||
type: 'input',
|
type: 'input',
|
||||||
},
|
},
|
||||||
example: 'Tim',
|
example: 'Tim',
|
||||||
validations: [
|
|
||||||
{
|
|
||||||
rule: 'required',
|
|
||||||
errorMessage: 'Name is required',
|
|
||||||
level: 'error',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <IconMail />,
|
icon: <IconMail />,
|
||||||
@ -33,13 +29,6 @@ export const fieldsForCompany = [
|
|||||||
type: 'input',
|
type: 'input',
|
||||||
},
|
},
|
||||||
example: 'apple.dev',
|
example: 'apple.dev',
|
||||||
validations: [
|
|
||||||
{
|
|
||||||
rule: 'required',
|
|
||||||
errorMessage: 'Domain name is required',
|
|
||||||
level: 'error',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <IconBrandLinkedin />,
|
icon: <IconBrandLinkedin />,
|
||||||
@ -51,6 +40,61 @@ export const fieldsForCompany = [
|
|||||||
},
|
},
|
||||||
example: 'https://www.linkedin.com/in/apple',
|
example: 'https://www.linkedin.com/in/apple',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <IconMoneybag />,
|
||||||
|
label: 'ARR',
|
||||||
|
key: 'annualRecurringRevenue',
|
||||||
|
alternateMatches: [
|
||||||
|
'arr',
|
||||||
|
'annual revenue',
|
||||||
|
'revenue',
|
||||||
|
'recurring revenue',
|
||||||
|
'annual recurring revenue',
|
||||||
|
],
|
||||||
|
fieldType: {
|
||||||
|
type: 'input',
|
||||||
|
},
|
||||||
|
validation: [
|
||||||
|
{
|
||||||
|
regex: /^(\d+)?$/,
|
||||||
|
errorMessage: 'Annual recurring revenue must be a number',
|
||||||
|
level: 'error',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
example: '1000000',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <IconTarget />,
|
||||||
|
label: 'ICP',
|
||||||
|
key: 'idealCustomerProfile',
|
||||||
|
alternateMatches: [
|
||||||
|
'icp',
|
||||||
|
'ideal profile',
|
||||||
|
'ideal customer profile',
|
||||||
|
'ideal customer',
|
||||||
|
],
|
||||||
|
fieldType: {
|
||||||
|
type: 'input',
|
||||||
|
},
|
||||||
|
validation: [
|
||||||
|
{
|
||||||
|
regex: /^(true|false)?$/,
|
||||||
|
errorMessage: 'Ideal custoner profile must be a boolean',
|
||||||
|
level: 'error',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
example: 'true/false',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <IconBrandX />,
|
||||||
|
label: 'x URL',
|
||||||
|
key: 'xUrl',
|
||||||
|
alternateMatches: ['x', 'twitter', 'twitter url', 'x url'],
|
||||||
|
fieldType: {
|
||||||
|
type: 'input',
|
||||||
|
},
|
||||||
|
example: 'https://x.com/tim_cook',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: <IconMap />,
|
icon: <IconMap />,
|
||||||
label: 'Address',
|
label: 'Address',
|
||||||
@ -59,13 +103,6 @@ export const fieldsForCompany = [
|
|||||||
type: 'input',
|
type: 'input',
|
||||||
},
|
},
|
||||||
example: 'Maple street',
|
example: 'Maple street',
|
||||||
validations: [
|
|
||||||
{
|
|
||||||
rule: 'required',
|
|
||||||
errorMessage: 'Address is required',
|
|
||||||
level: 'error',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <IconUsers />,
|
icon: <IconUsers />,
|
||||||
@ -75,6 +112,13 @@ export const fieldsForCompany = [
|
|||||||
fieldType: {
|
fieldType: {
|
||||||
type: 'input',
|
type: 'input',
|
||||||
},
|
},
|
||||||
|
validation: [
|
||||||
|
{
|
||||||
|
regex: /^\d+$/,
|
||||||
|
errorMessage: 'Employees must be a number',
|
||||||
|
level: 'error',
|
||||||
|
},
|
||||||
|
],
|
||||||
example: '150',
|
example: '150',
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
@ -3,12 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import { useSpreadsheetImport } from '@/spreadsheet-import/hooks/useSpreadsheetImport';
|
import { useSpreadsheetImport } from '@/spreadsheet-import/hooks/useSpreadsheetImport';
|
||||||
import { SpreadsheetOptions } from '@/spreadsheet-import/types';
|
import { SpreadsheetOptions } from '@/spreadsheet-import/types';
|
||||||
import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar';
|
import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar';
|
||||||
import { useUpsertEntityTableItems } from '@/ui/table/hooks/useUpsertEntityTableItems';
|
import { useInsertManyPersonMutation } from '~/generated/graphql';
|
||||||
import { useUpsertTableRowIds } from '@/ui/table/hooks/useUpsertTableRowIds';
|
|
||||||
import {
|
|
||||||
GetPeopleDocument,
|
|
||||||
useInsertManyPersonMutation,
|
|
||||||
} from '~/generated/graphql';
|
|
||||||
|
|
||||||
import { fieldsForPerson } from '../utils/fieldsForPerson';
|
import { fieldsForPerson } from '../utils/fieldsForPerson';
|
||||||
|
|
||||||
@ -16,8 +11,6 @@ export type FieldPersonMapping = (typeof fieldsForPerson)[number]['key'];
|
|||||||
|
|
||||||
export function useSpreadsheetPersonImport() {
|
export function useSpreadsheetPersonImport() {
|
||||||
const { openSpreadsheetImport } = useSpreadsheetImport<FieldPersonMapping>();
|
const { openSpreadsheetImport } = useSpreadsheetImport<FieldPersonMapping>();
|
||||||
const upsertEntityTableItems = useUpsertEntityTableItems();
|
|
||||||
const upsertTableRowIds = useUpsertTableRowIds();
|
|
||||||
const { enqueueSnackBar } = useSnackBar();
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
|
|
||||||
const [createManyPerson] = useInsertManyPersonMutation();
|
const [createManyPerson] = useInsertManyPersonMutation();
|
||||||
@ -49,15 +42,12 @@ export function useSpreadsheetPersonImport() {
|
|||||||
variables: {
|
variables: {
|
||||||
data: createInputs,
|
data: createInputs,
|
||||||
},
|
},
|
||||||
refetchQueries: [GetPeopleDocument],
|
refetchQueries: 'active',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.errors) {
|
if (result.errors) {
|
||||||
throw result.errors;
|
throw result.errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
upsertTableRowIds(createInputs.map((person) => person.id));
|
|
||||||
upsertEntityTableItems(createInputs);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
enqueueSnackBar(error?.message || 'Something went wrong', {
|
enqueueSnackBar(error?.message || 'Something went wrong', {
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
|
@ -2,7 +2,7 @@ import { isValidPhoneNumber } from 'libphonenumber-js';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
IconBrandLinkedin,
|
IconBrandLinkedin,
|
||||||
IconBrandTwitter,
|
IconBrandX,
|
||||||
IconBriefcase,
|
IconBriefcase,
|
||||||
IconMail,
|
IconMail,
|
||||||
IconMap,
|
IconMap,
|
||||||
@ -19,13 +19,6 @@ export const fieldsForPerson = [
|
|||||||
type: 'input',
|
type: 'input',
|
||||||
},
|
},
|
||||||
example: 'Tim',
|
example: 'Tim',
|
||||||
validations: [
|
|
||||||
{
|
|
||||||
rule: 'required',
|
|
||||||
errorMessage: 'Firstname is required',
|
|
||||||
level: 'error',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <IconUser />,
|
icon: <IconUser />,
|
||||||
@ -36,13 +29,6 @@ export const fieldsForPerson = [
|
|||||||
type: 'input',
|
type: 'input',
|
||||||
},
|
},
|
||||||
example: 'Cook',
|
example: 'Cook',
|
||||||
validations: [
|
|
||||||
{
|
|
||||||
rule: 'required',
|
|
||||||
errorMessage: 'Lastname is required',
|
|
||||||
level: 'error',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <IconMail />,
|
icon: <IconMail />,
|
||||||
@ -53,13 +39,6 @@ export const fieldsForPerson = [
|
|||||||
type: 'input',
|
type: 'input',
|
||||||
},
|
},
|
||||||
example: 'tim@apple.dev',
|
example: 'tim@apple.dev',
|
||||||
validations: [
|
|
||||||
{
|
|
||||||
rule: 'required',
|
|
||||||
errorMessage: 'email is required',
|
|
||||||
level: 'error',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <IconBrandLinkedin />,
|
icon: <IconBrandLinkedin />,
|
||||||
@ -72,7 +51,7 @@ export const fieldsForPerson = [
|
|||||||
example: 'https://www.linkedin.com/in/timcook',
|
example: 'https://www.linkedin.com/in/timcook',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <IconBrandTwitter />,
|
icon: <IconBrandX />,
|
||||||
label: 'X URL',
|
label: 'X URL',
|
||||||
key: 'xUrl',
|
key: 'xUrl',
|
||||||
alternateMatches: ['x', 'x url'],
|
alternateMatches: ['x', 'x url'],
|
||||||
|
@ -45,7 +45,7 @@ export const ModalCloseButton = ({ onClose }: ModalCloseButtonProps) => {
|
|||||||
message: 'Are you sure? Your current information will not be saved.',
|
message: 'Are you sure? Your current information will not be saved.',
|
||||||
buttons: [
|
buttons: [
|
||||||
{ title: 'Cancel' },
|
{ title: 'Cancel' },
|
||||||
{ title: 'Exit', onClick: onClose, accent: 'danger' },
|
{ title: 'Exit', onClick: onClose, accent: 'danger', role: 'confirm' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -3,15 +3,22 @@ import styled from '@emotion/styled';
|
|||||||
|
|
||||||
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||||
import { Modal } from '@/ui/modal/components/Modal';
|
import { Modal } from '@/ui/modal/components/Modal';
|
||||||
|
import { MOBILE_VIEWPORT } from '@/ui/theme/constants/theme';
|
||||||
|
|
||||||
import { ModalCloseButton } from './ModalCloseButton';
|
import { ModalCloseButton } from './ModalCloseButton';
|
||||||
|
|
||||||
const StyledModal = styled(Modal)`
|
const StyledModal = styled(Modal)`
|
||||||
height: 61%;
|
height: 61%;
|
||||||
min-height: 500px;
|
min-height: 600px;
|
||||||
min-width: 600px;
|
min-width: 800px;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 53%;
|
width: 63%;
|
||||||
|
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
||||||
|
min-width: auto;
|
||||||
|
min-height: auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledRtlLtr = styled.div`
|
const StyledRtlLtr = styled.div`
|
||||||
|
@ -12,6 +12,8 @@ export const defaultSpreadsheetImportProps: Partial<SpreadsheetOptions<any>> = {
|
|||||||
matchColumnsStepHook: async (table) => table,
|
matchColumnsStepHook: async (table) => table,
|
||||||
dateFormat: 'yyyy-mm-dd', // ISO 8601,
|
dateFormat: 'yyyy-mm-dd', // ISO 8601,
|
||||||
parseRaw: true,
|
parseRaw: true,
|
||||||
|
selectHeader: false,
|
||||||
|
maxRecords: 200,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const SpreadsheetImport = <T extends string>(
|
export const SpreadsheetImport = <T extends string>(
|
||||||
|
@ -21,6 +21,8 @@ import { UserTableColumn } from './components/UserTableColumn';
|
|||||||
|
|
||||||
const StyledContent = styled(Modal.Content)`
|
const StyledContent = styled(Modal.Content)`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding-left: ${({ theme }) => theme.spacing(6)};
|
||||||
|
padding-right: ${({ theme }) => theme.spacing(6)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledColumnsContainer = styled.div`
|
const StyledColumnsContainer = styled.div`
|
||||||
@ -224,6 +226,7 @@ export const MatchColumnsStep = <T extends string>({
|
|||||||
title: 'Continue',
|
title: 'Continue',
|
||||||
onClick: handleAlertOnContinue,
|
onClick: handleAlertOnContinue,
|
||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
|
role: 'confirm',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
@ -19,7 +19,7 @@ const StyledGrid = styled.div`
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-top: ${({ theme }) => theme.spacing(8)};
|
margin-top: ${({ theme }) => theme.spacing(8)};
|
||||||
width: 75%;
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type HeightProps = {
|
type HeightProps = {
|
||||||
|
@ -9,6 +9,8 @@ import { Modal } from '@/ui/modal/components/Modal';
|
|||||||
|
|
||||||
const StyledContent = styled(Modal.Content)`
|
const StyledContent = styled(Modal.Content)`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding-left: ${({ theme }) => theme.spacing(6)};
|
||||||
|
padding-right: ${({ theme }) => theme.spacing(6)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledHeading = styled(Heading)`
|
const StyledHeading = styled(Heading)`
|
||||||
|
@ -5,6 +5,7 @@ import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpre
|
|||||||
import { Modal } from '@/ui/modal/components/Modal';
|
import { Modal } from '@/ui/modal/components/Modal';
|
||||||
import { StepBar } from '@/ui/step-bar/components/StepBar';
|
import { StepBar } from '@/ui/step-bar/components/StepBar';
|
||||||
import { useStepBar } from '@/ui/step-bar/hooks/useStepBar';
|
import { useStepBar } from '@/ui/step-bar/hooks/useStepBar';
|
||||||
|
import { MOBILE_VIEWPORT } from '@/ui/theme/constants/theme';
|
||||||
|
|
||||||
import { UploadFlow } from './UploadFlow';
|
import { UploadFlow } from './UploadFlow';
|
||||||
|
|
||||||
@ -15,6 +16,10 @@ const StyledHeader = styled(Modal.Header)`
|
|||||||
padding: 0px;
|
padding: 0px;
|
||||||
padding-left: ${({ theme }) => theme.spacing(30)};
|
padding-left: ${({ theme }) => theme.spacing(30)};
|
||||||
padding-right: ${({ theme }) => theme.spacing(30)};
|
padding-right: ${({ theme }) => theme.spacing(30)};
|
||||||
|
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
||||||
|
padding-left: ${({ theme }) => theme.spacing(4)};
|
||||||
|
padding-right: ${({ theme }) => theme.spacing(4)};
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const stepTitles = {
|
const stepTitles = {
|
||||||
|
@ -72,6 +72,7 @@ export const UploadFlow = ({ nextStep }: Props) => {
|
|||||||
uploadStepHook,
|
uploadStepHook,
|
||||||
selectHeaderStepHook,
|
selectHeaderStepHook,
|
||||||
matchColumnsStepHook,
|
matchColumnsStepHook,
|
||||||
|
selectHeader,
|
||||||
} = useSpreadsheetImportInternal();
|
} = useSpreadsheetImportInternal();
|
||||||
const { enqueueSnackBar } = useSnackBar();
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
|
|
||||||
@ -109,10 +110,27 @@ export const UploadFlow = ({ nextStep }: Props) => {
|
|||||||
const mappedWorkbook = await uploadStepHook(
|
const mappedWorkbook = await uploadStepHook(
|
||||||
mapWorkbook(workbook),
|
mapWorkbook(workbook),
|
||||||
);
|
);
|
||||||
setState({
|
|
||||||
type: StepType.selectHeader,
|
if (selectHeader) {
|
||||||
data: mappedWorkbook,
|
setState({
|
||||||
});
|
type: StepType.selectHeader,
|
||||||
|
data: mappedWorkbook,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Automatically select first row as header
|
||||||
|
const trimmedData = mappedWorkbook.slice(1);
|
||||||
|
|
||||||
|
const { data, headerValues } = await selectHeaderStepHook(
|
||||||
|
mappedWorkbook[0],
|
||||||
|
trimmedData,
|
||||||
|
);
|
||||||
|
|
||||||
|
setState({
|
||||||
|
type: StepType.matchColumns,
|
||||||
|
data,
|
||||||
|
headerValues,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast((e as Error).message);
|
errorToast((e as Error).message);
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,11 @@ import { Modal } from '@/ui/modal/components/Modal';
|
|||||||
import { generateColumns } from './components/columns';
|
import { generateColumns } from './components/columns';
|
||||||
import type { Meta } from './types';
|
import type { Meta } from './types';
|
||||||
|
|
||||||
|
const StyledContent = styled(Modal.Content)`
|
||||||
|
padding-left: ${({ theme }) => theme.spacing(6)};
|
||||||
|
padding-right: ${({ theme }) => theme.spacing(6)};
|
||||||
|
`;
|
||||||
|
|
||||||
const StyledToolbar = styled.div`
|
const StyledToolbar = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@ -175,6 +180,7 @@ export const ValidationStep = <T extends string>({
|
|||||||
title: 'Submit',
|
title: 'Submit',
|
||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
onClick: submitData,
|
onClick: submitData,
|
||||||
|
role: 'confirm',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@ -183,7 +189,7 @@ export const ValidationStep = <T extends string>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal.Content>
|
<StyledContent>
|
||||||
<Heading
|
<Heading
|
||||||
title="Review your import"
|
title="Review your import"
|
||||||
description="Correct the issues and fill the missing data."
|
description="Correct the issues and fill the missing data."
|
||||||
@ -225,7 +231,7 @@ export const ValidationStep = <T extends string>({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</StyledScrollContainer>
|
</StyledScrollContainer>
|
||||||
</Modal.Content>
|
</StyledContent>
|
||||||
<ContinueButton onContinue={onContinue} title="Confirm" />
|
<ContinueButton onContinue={onContinue} title="Confirm" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -50,6 +50,8 @@ export type SpreadsheetOptions<Keys extends string> = {
|
|||||||
parseRaw?: boolean;
|
parseRaw?: boolean;
|
||||||
// Use for right-to-left (RTL) support
|
// Use for right-to-left (RTL) support
|
||||||
rtl?: boolean;
|
rtl?: boolean;
|
||||||
|
// Allow header selection
|
||||||
|
selectHeader?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RawData = Array<string | undefined>;
|
export type RawData = Array<string | undefined>;
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { Key } from 'ts-key-enum';
|
||||||
|
|
||||||
import { Button } from '@/ui/button/components/Button';
|
import { Button } from '@/ui/button/components/Button';
|
||||||
|
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||||
|
|
||||||
|
import { DialogHotkeyScope } from '../types/DialogHotkeyScope';
|
||||||
|
|
||||||
const StyledDialogOverlay = styled(motion.div)`
|
const StyledDialogOverlay = styled(motion.div)`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -52,7 +56,12 @@ const StyledDialogButton = styled(Button)`
|
|||||||
export type DialogButtonOptions = Omit<
|
export type DialogButtonOptions = Omit<
|
||||||
React.ComponentProps<typeof Button>,
|
React.ComponentProps<typeof Button>,
|
||||||
'fullWidth'
|
'fullWidth'
|
||||||
>;
|
> & {
|
||||||
|
onClick?: (
|
||||||
|
event: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
|
||||||
|
) => void;
|
||||||
|
role?: 'confirm';
|
||||||
|
};
|
||||||
|
|
||||||
export type DialogProps = React.ComponentPropsWithoutRef<typeof motion.div> & {
|
export type DialogProps = React.ComponentPropsWithoutRef<typeof motion.div> & {
|
||||||
title?: string;
|
title?: string;
|
||||||
@ -86,6 +95,32 @@ export function Dialog({
|
|||||||
closed: { y: '50vh' },
|
closed: { y: '50vh' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useScopedHotkeys(
|
||||||
|
Key.Enter,
|
||||||
|
(event: KeyboardEvent) => {
|
||||||
|
const confirmButton = buttons.find((button) => button.role === 'confirm');
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (confirmButton) {
|
||||||
|
confirmButton?.onClick?.(event);
|
||||||
|
closeSnackbar();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DialogHotkeyScope.Dialog,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useScopedHotkeys(
|
||||||
|
Key.Escape,
|
||||||
|
(event: KeyboardEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
closeSnackbar();
|
||||||
|
},
|
||||||
|
DialogHotkeyScope.Dialog,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledDialogOverlay
|
<StyledDialogOverlay
|
||||||
variants={dialogVariants}
|
variants={dialogVariants}
|
||||||
|
@ -1,20 +1,38 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilState } from 'recoil';
|
||||||
|
|
||||||
|
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||||
|
|
||||||
import { dialogInternalState } from '../states/dialogState';
|
import { dialogInternalState } from '../states/dialogState';
|
||||||
|
import { DialogHotkeyScope } from '../types/DialogHotkeyScope';
|
||||||
|
|
||||||
import { Dialog } from './Dialog';
|
import { Dialog } from './Dialog';
|
||||||
|
|
||||||
export function DialogProvider({ children }: React.PropsWithChildren) {
|
export function DialogProvider({ children }: React.PropsWithChildren) {
|
||||||
const [dialogState, setDialogState] = useRecoilState(dialogInternalState);
|
const [dialogState, setDialogState] = useRecoilState(dialogInternalState);
|
||||||
|
|
||||||
|
const {
|
||||||
|
setHotkeyScopeAndMemorizePreviousScope,
|
||||||
|
goBackToPreviousHotkeyScope,
|
||||||
|
} = usePreviousHotkeyScope();
|
||||||
|
|
||||||
// Handle dialog close event
|
// Handle dialog close event
|
||||||
const handleDialogClose = (id: string) => {
|
const handleDialogClose = (id: string) => {
|
||||||
setDialogState((prevState) => ({
|
setDialogState((prevState) => ({
|
||||||
...prevState,
|
...prevState,
|
||||||
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
|
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
|
||||||
}));
|
}));
|
||||||
|
goBackToPreviousHotkeyScope();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dialogState.queue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHotkeyScopeAndMemorizePreviousScope(DialogHotkeyScope.Dialog);
|
||||||
|
}, [dialogState.queue, setHotkeyScopeAndMemorizePreviousScope]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
|
3
front/src/modules/ui/dialog/types/DialogHotkeyScope.ts
Normal file
3
front/src/modules/ui/dialog/types/DialogHotkeyScope.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export enum DialogHotkeyScope {
|
||||||
|
Dialog = 'dialog',
|
||||||
|
}
|
@ -3,11 +3,16 @@ import styled from '@emotion/styled';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
import { AnimatedCheckmark } from '@/ui/checkmark/components/AnimatedCheckmark';
|
import { AnimatedCheckmark } from '@/ui/checkmark/components/AnimatedCheckmark';
|
||||||
|
import { MOBILE_VIEWPORT } from '@/ui/theme/constants/theme';
|
||||||
|
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||||
|
|
||||||
const StyledContainer = styled.div<{ isLast: boolean }>`
|
const StyledContainer = styled.div<{ isLast: boolean }>`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-grow: ${({ isLast }) => (isLast ? '0' : '1')};
|
flex-grow: ${({ isLast }) => (isLast ? '0' : '1')};
|
||||||
|
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
||||||
|
flex-grow: 0;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledStepCircle = styled(motion.div)`
|
const StyledStepCircle = styled(motion.div)`
|
||||||
@ -64,6 +69,7 @@ export const Step = ({
|
|||||||
children,
|
children,
|
||||||
}: StepProps) => {
|
}: StepProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const variantsCircle = {
|
const variantsCircle = {
|
||||||
active: {
|
active: {
|
||||||
@ -104,7 +110,7 @@ export const Step = ({
|
|||||||
{!isActive && <StyledStepIndex>{index + 1}</StyledStepIndex>}
|
{!isActive && <StyledStepIndex>{index + 1}</StyledStepIndex>}
|
||||||
</StyledStepCircle>
|
</StyledStepCircle>
|
||||||
<StyledStepLabel isActive={isActive}>{label}</StyledStepLabel>
|
<StyledStepLabel isActive={isActive}>{label}</StyledStepLabel>
|
||||||
{!isLast && (
|
{!isLast && !isMobile && (
|
||||||
<StyledStepLine
|
<StyledStepLine
|
||||||
variants={variantsLine}
|
variants={variantsLine}
|
||||||
animate={isActive ? 'active' : 'inactive'}
|
animate={isActive ? 'active' : 'inactive'}
|
||||||
|
@ -1,12 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
|
import { MOBILE_VIEWPORT } from '@/ui/theme/constants/theme';
|
||||||
|
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||||
|
|
||||||
import { Step, StepProps } from './Step';
|
import { Step, StepProps } from './Step';
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export type StepsProps = React.PropsWithChildren &
|
export type StepsProps = React.PropsWithChildren &
|
||||||
@ -15,6 +22,8 @@ export type StepsProps = React.PropsWithChildren &
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const StepBar = ({ children, activeStep, ...restProps }: StepsProps) => {
|
export const StepBar = ({ children, activeStep, ...restProps }: StepsProps) => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledContainer {...restProps}>
|
<StyledContainer {...restProps}>
|
||||||
{React.Children.map(children, (child, index) => {
|
{React.Children.map(children, (child, index) => {
|
||||||
@ -29,6 +38,14 @@ export const StepBar = ({ children, activeStep, ...restProps }: StepsProps) => {
|
|||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We should only render the active step, and if activeStep is -1, we should only render the first step only when it's mobile device
|
||||||
|
if (
|
||||||
|
isMobile &&
|
||||||
|
(activeStep === -1 ? index !== 0 : index !== activeStep)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return React.cloneElement<StepProps>(child as any, {
|
return React.cloneElement<StepProps>(child as any, {
|
||||||
index,
|
index,
|
||||||
isActive: index <= activeStep,
|
isActive: index <= activeStep,
|
||||||
|
Loading…
Reference in New Issue
Block a user