diff --git a/src/_fakeData/storyFakeData.tsx b/src/_fakeData/storyFakeData.tsx index 16bac139..32265975 100644 --- a/src/_fakeData/storyFakeData.tsx +++ b/src/_fakeData/storyFakeData.tsx @@ -66,43 +66,37 @@ export const RICH_COLUMNS = [ }, ] as Column[] -export const IMEX_COLUMNS = [ +export const IMEX_COLUMNS: IMEXColumn[] = [ { Header: 'Username', accessor: 'username', - meta: { - imex: { - identifier: true, - type: 'string' as const, - }, + imex: { + path: 'username', + identifier: true, }, }, { Header: 'Name', accessor: 'name', - meta: { - imex: { - type: 'string' as const, - note: 'This is a comment', - }, + imex: { + path: 'name', + header: 'Name', + note: 'This is a comment', }, }, { Header: 'Email', accessor: 'email', - meta: { - imex: { - type: 'string' as const, - }, + imex: { + path: 'email', + header: 'Email', }, }, { Header: 'City', - accessor: 'address.city', - meta: { - imex: { - type: 'string' as const, - }, + accessor: (row) => row.address.city, + imex: { + path: 'address.city', }, }, -] as IMEXColumn[] +] diff --git a/src/filterMethod/arrayFilter.ts b/src/filterMethod/arrayFilter.ts deleted file mode 100644 index c5dc1f2d..00000000 --- a/src/filterMethod/arrayFilter.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { FilterType, Row } from 'react-table' - -/** - * @deprecated use filters: 'includes' instead - */ -export const arrayFilter = ( - accessor: (row: Row) => any -): FilterType => (rows, columnId, filterValue) => { - return rows.filter((row) => { - return filterValue?.some((value: any) => `${accessor(row)}`.includes(value)) - }) -} diff --git a/src/filterMethod/booleanFilter.ts b/src/filterMethod/booleanFilter.ts deleted file mode 100644 index 7aa23313..00000000 --- a/src/filterMethod/booleanFilter.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { get, has } from 'lodash' -import { FilterType } from 'react-table' - -/** - * @deprecated use filters: 'equals' instead - */ -export const booleanFilter: FilterType = (rows, id, filterValue) => { - if (filterValue == null) { - return rows - } - - return rows.filter((row) => { - const rawValue = get(row.values, id) - const value = has(rawValue, 'value') ? !!rawValue.value : !!rawValue - - return (value && filterValue === true) || (!value && filterValue === false) - }) -} diff --git a/src/imex/excel.utils.ts b/src/imex/excel.utils.ts index f13be932..3835abcb 100644 --- a/src/imex/excel.utils.ts +++ b/src/imex/excel.utils.ts @@ -5,6 +5,7 @@ import { palette } from '@habx/ui-core' import { createWorkbook, getCellValueTypes } from './exceljs' import { IMEXColumn } from './imex.interface' +import { getHeader } from './imex.utils' export const parseExcelFileData = async (file: File): Promise => { const CellValueType = await getCellValueTypes() @@ -43,8 +44,8 @@ export const parseExcelFileData = async (file: File): Promise => { break case CellValueType.Hyperlink: // seems to be badly typed - const text = ((cell.value as Excel.CellHyperlinkValue) - .text as unknown) as Excel.CellRichTextValue + const text = (cell.value as Excel.CellHyperlinkValue) + .text as unknown as Excel.CellRichTextValue data[rowIndex][cellIndex] = text?.richText?.map((t) => t.text).join('') ?? text ?? null break @@ -94,9 +95,9 @@ export const applyValidationRulesAndStyle = ( for (const columnIndex in columns) { const column = columns[columnIndex] const columnNumber = Number(columnIndex) + 1 - const dataValidation = column.meta?.imex?.dataValidation - const isIdentifer = !!column.meta?.imex?.identifier - const note = column.meta?.imex?.note + const dataValidation = column?.imex?.dataValidation + const isIdentifer = !!column?.imex?.identifier + const note = column?.imex?.note if (dataValidation || isIdentifer || note) { const worksheetColumn = worksheet.getColumn(columnNumber) @@ -116,11 +117,10 @@ export const applyValidationRulesAndStyle = ( dataValidation.formulae.some((f) => f.length > 255) ) { const worksheetName = capitalize( - snakeCase(escape(`${column.Header}`)) - ) - const validationValuesWorksheet = worksheet.workbook.addWorksheet( - worksheetName + snakeCase(escape(getHeader(column))) ) + const validationValuesWorksheet = + worksheet.workbook.addWorksheet(worksheetName) dataValidation.formulae.forEach((f, listColumnIndex) => { const list: string[] = f.replace(/"/g, '').split(',') list.forEach((listEl, rowIndex) => { diff --git a/src/imex/export/useExportTable.ts b/src/imex/export/useExportTable.ts index 61b86be6..8616ca75 100644 --- a/src/imex/export/useExportTable.ts +++ b/src/imex/export/useExportTable.ts @@ -5,52 +5,50 @@ import { getImexColumns } from '../getImexColumns' import { IMEXColumn, IMEXFileExtensionTypes, - RowValueTypes, + IMEXColumnType, } from '../imex.interface' +import { getPath } from '../imex.utils' import { exportData, ExportDataOptions } from './useExportTable.utils' -export interface UseExportTableParams +export interface UseExportTableParams extends Omit { - data?: D[] + data: D[] columns: IMEXColumn[] + /** + * @default 'xls' + */ type?: IMEXFileExtensionTypes } -const ARRAY_TYPES = new Set(['string[]', 'number[]']) - -export const useExportTable = ( - params: UseExportTableParams -) => { - // Put params in ref to avoid useless changes of `onFiles` function - const paramsRef = React.useRef(params) - paramsRef.current = params +const ARRAY_TYPES = new Set([ + IMEXColumnType['string[]'], + IMEXColumnType['number[]'], +]) +export const useExportTable = () => { const downloadTableData = React.useCallback( - (title: string, options?: Partial>) => { - const { data, columns, ...exportOptions } = { - data: [], - type: 'xls', - ...paramsRef.current, - ...options, - } as const + (title: string, options: UseExportTableParams) => { + const { data = [], columns, type = 'xls' } = options const imexColumns = getImexColumns(columns) const imexData = data.map((row) => imexColumns.map((column) => { - const meta = column.meta?.imex - const valueType = meta?.type + const imexOptions = column.imex + const valueType = imexOptions?.type + + const path = getPath(column) - let value = get(row, column.accessor as string) + let value = get(row, path) - if (meta?.parse) { - value = meta.parse(value, Object.values(row)) + if (imexOptions?.format) { + value = imexOptions.format(value, Object.values(row)) } else if (ARRAY_TYPES.has(valueType!) && Array.isArray(value)) { value = value.join(',') } - return exportOptions.type === 'xls' && - valueType === 'number' && + return type === 'xls' && + valueType === IMEXColumnType.number && !isFinite(value) && value != null ? Number(value) @@ -58,7 +56,7 @@ export const useExportTable = ( }) ) - return exportData(title, imexColumns, imexData, exportOptions) + return exportData(title, imexColumns, imexData, { ...options, type }) }, [] ) diff --git a/src/imex/export/useExportTable.utils.ts b/src/imex/export/useExportTable.utils.ts index 26146a34..f8fd47ad 100644 --- a/src/imex/export/useExportTable.utils.ts +++ b/src/imex/export/useExportTable.utils.ts @@ -6,6 +6,7 @@ import { } from '../excel.utils' import { createWorkbook } from '../exceljs' import { IMEXColumn, IMEXFileExtensionTypes } from '../imex.interface' +import { getHeader } from '../imex.utils' const saveFile = ( type: IMEXFileExtensionTypes, @@ -63,10 +64,10 @@ export const exportData = async ( const worksheet = workbook.addWorksheet(filename) worksheet.columns = columns.map((column) => ({ - header: column.Header + (column.meta?.imex?.required ? '*' : ''), - key: column.id ?? column.Header, - width: column.meta?.imex?.width, - hidden: column.meta?.imex?.hidden, + header: `${getHeader(column)} ${column.imex?.required ? '*' : ''}`, + key: column.id ?? column.imex, + width: column.imex?.width, + hidden: column.imex?.hidden, })) as Excel.Column[] worksheet.addRows(data) diff --git a/src/imex/getImexColumns.ts b/src/imex/getImexColumns.ts index 208b5496..eb642756 100644 --- a/src/imex/getImexColumns.ts +++ b/src/imex/getImexColumns.ts @@ -20,16 +20,26 @@ export const getImexColumns = ( ) const imexColumns = flatColumns.filter( (column) => - !!column.meta?.imex && + !!column?.imex && COLUMN_ENABLED_CONDITION.includes(column.enabled ?? 'always') ) imexColumns.forEach((column) => { - if (typeof column.accessor !== 'string') { - throw new Error('Cannot include data with a non-string accessor') + if ( + typeof column.accessor !== 'string' && + typeof column.imex?.path !== 'string' + ) { + throw new Error( + 'Cannot include data without a column path or string accessor' + ) } - if (typeof column.Header !== 'string') { - throw new Error('Cannot include non string Header') + if ( + typeof column.Header !== 'string' && + typeof column.imex?.header !== 'string' + ) { + throw new Error( + 'Cannot include data without column header or imex.header' + ) } }) diff --git a/src/imex/imex.interface.ts b/src/imex/imex.interface.ts index d506b250..3ad51b57 100644 --- a/src/imex/imex.interface.ts +++ b/src/imex/imex.interface.ts @@ -1,5 +1,4 @@ import type * as Excel from 'exceljs' -import { DropEvent } from 'react-dropzone' import { Column } from '../types/Table' @@ -12,7 +11,7 @@ export interface ImportedRowMeta { export type ImportedRow = D & { _rowMeta: ImportedRowMeta } -export type UseImportTableOptions = { +export type UseImportTableOptions = { columns: IMEXColumn[] onUpsertRowError?: (error: Error) => void getOriginalData: () => D[] | Promise @@ -40,19 +39,9 @@ export interface _UseImportTableOptions { onFinish?: (rows: D[]) => void | Promise } -export type UseImportTableParams = { +export type UseImportTableParams = { disabled?: boolean accept?: string[] - /** - * @deprecated - */ - onBeforeDropAccepted?: ( - onFiles: ( - files: File[], - options?: Partial> - ) => Promise - ) => (files: File[], event?: DropEvent) => Promise - /** * Defines the number of upsertRow calls parallelized. * @default 1 @@ -61,25 +50,80 @@ export type UseImportTableParams = { concurrency?: number } & UseImportTableOptions -export type RowValueTypes = 'string' | 'number' | 'number[]' | 'string[]' +export enum IMEXColumnType { + 'string', + 'number', + 'number[]', + 'string[]', +} + +export interface IMEXOptions { + /** + * path passed to lodash get & set function to access & define data + * fallback to column accessor if provided as string + * + */ + path?: string + /** + * header name of the column in the sheet file + * fallback to column Header if provided as string + * + */ + header?: string + /** identify a uniq row **/ + identifier?: boolean + required?: boolean + /** @default IMEXColumnType.string **/ + type?: IMEXColumnType + format?: (value: any, row: any[]) => any + parse?: (value: any, row: any[]) => any + width?: number + validate?: (value: any, row: any[]) => string | boolean | null + dataValidation?: Excel.DataValidation + hidden?: boolean + note?: string | Excel.Comment + ignoreEmpty?: boolean +} -export type IMEXColumn = Column< - D & { [key: string]: any }, - { - imex?: { - identifier?: boolean - required?: boolean - type?: RowValueTypes - format?: (value: any, row: any[]) => any - parse?: (value: any, row: any[]) => any - width?: number - validate?: (value: any, row: any[]) => string | boolean | null - dataValidation?: Excel.DataValidation - hidden?: boolean - note?: string | Excel.Comment - ignoreEmpty?: boolean - } - } -> +export type IMEXColumn = Omit< + Column, + 'Header' | 'accessor' +> & { + columns?: IMEXColumn[] + imex?: IMEXOptions +} & ( + | { + Header: string + } + | { + Header?: Exclude['Header'], string> + imex?: { header: NonNullable } + } + ) & + ( + | { + accessor: string + } + | { + accessor?: Exclude['accessor'], string> + imex?: { path: NonNullable } + } + ) export type IMEXFileExtensionTypes = 'csv' | 'xls' + +// Type tests + +// @ts-expect-error +const a: IMEXColumn = { // eslint-disable-line + Header: '', + accessor: () => '', + imex: {}, +} + +// @ts-expect-error +const b: IMEXColumn = { // eslint-disable-line + Header: () => '', + accessor: '', + imex: {}, +} diff --git a/src/imex/imex.spec.ts b/src/imex/imex.spec.ts index ddea26a4..e58f12c8 100644 --- a/src/imex/imex.spec.ts +++ b/src/imex/imex.spec.ts @@ -14,17 +14,15 @@ describe('Import/Export (imex)', () => { global.navigator.msSaveBlob = jest.fn() }) it('should allow download data in CSV', async () => { - const { result } = renderHook(() => - useExportTable({ + const { result } = renderHook(() => useExportTable()) + + await act(() => { + const [downloadFile] = result.current + return downloadFile('test', { data: FAKE_DATA, columns: IMEX_COLUMNS, type: 'csv', }) - ) - - await act(() => { - const [downloadFile] = result.current - return downloadFile('test') }) // @ts-ignore expect(global.navigator.msSaveBlob).toHaveBeenCalledWith( @@ -33,17 +31,15 @@ describe('Import/Export (imex)', () => { ) }) it('should allow download data in XLS', async () => { - const { result } = renderHook(() => - useExportTable({ + const { result } = renderHook(() => useExportTable()) + + await act(() => { + const [downloadFile] = result.current + return downloadFile('test', { data: FAKE_DATA, columns: IMEX_COLUMNS, type: 'xls', }) - ) - - await act(() => { - const [downloadFile] = result.current - return downloadFile('test') }) // @ts-ignore expect(global.navigator.msSaveBlob).toHaveBeenCalledWith( @@ -54,25 +50,7 @@ describe('Import/Export (imex)', () => { }) describe('getImexColumns', () => { - it('it needs string accessors only', () => { - expect(() => - getImexColumns([ - { - Header: 'header', - accessor: (originalRow) => originalRow.id, - meta: { imex: {} }, - }, - ]) - ).toThrow() - }) - it('it needs string header only', () => { - expect(() => - getImexColumns([ - { Header: () => null, accessor: 'id', meta: { imex: {} } }, - ]) - ).toThrow() - }) - it('it ignore columns without meta.imex field', () => { + it('it ignore columns without imex field', () => { expect( getImexColumns([{ Header: () => null, accessor: 'id' }]) ).toHaveLength(0) diff --git a/src/imex/imex.utils.ts b/src/imex/imex.utils.ts new file mode 100644 index 00000000..e9cce04a --- /dev/null +++ b/src/imex/imex.utils.ts @@ -0,0 +1,10 @@ +import { IMEXColumn } from './imex.interface' + +export const getPath = (column: IMEXColumn) => + (typeof column.accessor === 'string' + ? column.accessor + : column.imex!.path) as string +export const getHeader = (column: IMEXColumn) => + (typeof column.Header === 'string' + ? column.Header + : column.imex!.header) as string diff --git a/src/imex/import/parse.spec.ts b/src/imex/import/parse.spec.ts index 1835ee23..81e9e24d 100644 --- a/src/imex/import/parse.spec.ts +++ b/src/imex/import/parse.spec.ts @@ -1,47 +1,49 @@ +import { IMEXColumnType } from '../imex.interface' + import { parseCell } from './useImportTable.utils' describe('Import parsing', () => { describe('parse cell', () => { describe('number', () => { it('should parse normal number', () => { - const result = parseCell(12, 'number', { - format: (value) => value, + const result = parseCell(12, IMEXColumnType.number, { + parse: (value) => value, ignoreEmpty: true, }) expect(result).toBe(12) }) it('should parse string number', () => { - const result = parseCell('10', 'number', { - format: (value) => value, + const result = parseCell('10', IMEXColumnType.number, { + parse: (value) => value, ignoreEmpty: true, }) expect(result).toBe(10) }) it('should parse 0', () => { - const result = parseCell(0, 'number', { - format: (value) => value, + const result = parseCell(0, IMEXColumnType.number, { + parse: (value) => value, ignoreEmpty: true, }) expect(result).toBe(0) }) it('should parse 0 in string', () => { - const result = parseCell('0', 'number', { - format: (value) => value, + const result = parseCell('0', IMEXColumnType.number, { + parse: (value) => value, ignoreEmpty: true, }) expect(result).toBe(0) }) it('should consider empty string as undefined', () => { - const result = parseCell('', 'number', { - format: (value) => value, + const result = parseCell('', IMEXColumnType.number, { + parse: (value) => value, ignoreEmpty: true, }) expect(result).toBe(undefined) }) it('should throw error if invalid number', () => { const parseInvalid = () => - parseCell('invalid', 'number', { - format: (value) => value, + parseCell('invalid', IMEXColumnType.number, { + parse: (value) => value, ignoreEmpty: true, }) expect(parseInvalid).toThrow() @@ -49,15 +51,15 @@ describe('Import parsing', () => { }) describe('string', () => { it('should return param value', () => { - const result = parseCell('value', 'string', { - format: (value) => value, + const result = parseCell('value', IMEXColumnType.string, { + parse: (value) => value, ignoreEmpty: true, }) expect(result).toBe('value') }) it('should consider empty string as undefined if `ignoreEmpty` is true', () => { - const result = parseCell('', 'number', { - format: (value) => value, + const result = parseCell('', IMEXColumnType.number, { + parse: (value) => value, ignoreEmpty: true, }) expect(result).toBe(undefined) @@ -65,38 +67,38 @@ describe('Import parsing', () => { }) describe('number[]', () => { it('should parse uniq number', () => { - const result = parseCell('1', 'number[]', { - format: (value) => value, + const result = parseCell('1', IMEXColumnType['number[]'], { + parse: (value) => value, ignoreEmpty: true, }) expect(result).toEqual([1]) }) it('should consider empty string as empty array', () => { - const result = parseCell('', 'number[]', { - format: (value) => value, + const result = parseCell('', IMEXColumnType['number[]'], { + parse: (value) => value, ignoreEmpty: false, }) expect(result).toEqual([]) }) it('should parse multiple number', () => { - const result = parseCell('1, 2, 3', 'number[]', { - format: (value) => value, + const result = parseCell('1, 2, 3', IMEXColumnType['number[]'], { + parse: (value) => value, ignoreEmpty: true, }) expect(result).toEqual([1, 2, 3]) }) it('should throw error if invalid value', () => { const parseInvalid = () => - parseCell('AA', 'number[]', { - format: (value) => value, + parseCell('AA', IMEXColumnType['number[]'], { + parse: (value) => value, ignoreEmpty: true, }) expect(parseInvalid).toThrow() }) it('should throw error if invalid number contained', () => { const parseInvalid = () => - parseCell('1,A,3', 'number[]', { - format: (value) => value, + parseCell('1,A,3', IMEXColumnType['number[]'], { + parse: (value) => value, ignoreEmpty: true, }) expect(parseInvalid).toThrow() @@ -104,22 +106,22 @@ describe('Import parsing', () => { }) describe('string[]', () => { it('should parse uniq value', () => { - const result = parseCell('a', 'string[]', { - format: (value) => value, + const result = parseCell('a', IMEXColumnType['string[]'], { + parse: (value) => value, ignoreEmpty: true, }) expect(result).toEqual(['a']) }) it('should consider empty string as empty array', () => { - const result = parseCell('', 'string[]', { - format: (value) => value, + const result = parseCell('', IMEXColumnType['string[]'], { + parse: (value) => value, ignoreEmpty: false, }) expect(result).toEqual([]) }) it('should parse multiple values', () => { - const result = parseCell('a,b,c', 'string[]', { - format: (value) => value, + const result = parseCell('a,b,c', IMEXColumnType['string[]'], { + parse: (value) => value, ignoreEmpty: true, }) expect(result).toEqual(['a', 'b', 'c']) diff --git a/src/imex/import/useImportTable.columns.tsx b/src/imex/import/useImportTable.columns.tsx index f25fb562..862f9afc 100644 --- a/src/imex/import/useImportTable.columns.tsx +++ b/src/imex/import/useImportTable.columns.tsx @@ -6,6 +6,7 @@ import { Tooltip } from '@habx/ui-core' import { CellProps, Column, FooterProps } from '../../types/Table' import { IMEXColumn, ImportedRow } from '../imex.interface' +import { getPath } from '../imex.utils' import { IconIndicator } from './DataIndicators' import { @@ -27,13 +28,18 @@ interface GetCompareColumnsFromImexColumnsOptions { */ statusColumn?: boolean } + export const getCompareColumnsFromImexColumns = >( columns: IMEXColumn[], options?: GetCompareColumnsFromImexColumnsOptions ) => { const { footer = true, statusColumn = true } = options ?? {} - const compareColumns = columns - .filter((column) => !column.meta?.imex?.hidden) + + // FIXME + + // @ts-ignore + const compareColumns: Column[] = columns + .filter((column) => !column.imex?.hidden) .map((column) => ({ ...column, Footer: footer @@ -63,27 +69,27 @@ export const getCompareColumnsFromImexColumns = >( }) as ReactTable.Renderer>>) : undefined, Cell: ((rawProps) => { - const props = (rawProps as unknown) as CellProps + const props = rawProps as unknown as CellProps const rowMeta = rawProps.row.original?._rowMeta - const Cell = (isFunction(column.Cell) - ? column.Cell - : ({ cell }) =>
{cell.value}
) as React.ComponentType< - CellProps - > + const Cell = ( + isFunction(column.Cell) + ? column.Cell + : ({ cell }) =>
{cell.value}
+ ) as React.ComponentType> // Do not add style on grouped cell if (rawProps.row.isGrouped) { return } - const cellPrevVal = get(rowMeta?.prevVal, column.accessor as string) + const cellPrevVal = get(rowMeta?.prevVal, getPath(column)) const CellContainer: React.FunctionComponent = ({ children }) => { if (!Object.values(rowMeta?.errors ?? {}).length) { return {children} } - const error = get(rowMeta!.errors, column.accessor as string) + const error = get(rowMeta!.errors, getPath(column)) return ( @@ -107,7 +113,7 @@ export const getCompareColumnsFromImexColumns = >( if (isNil(cellPrevVal)) { return ( - + @@ -164,5 +170,5 @@ export const getCompareColumnsFromImexColumns = >( }) } - return compareColumns as Column[] + return compareColumns } diff --git a/src/imex/import/useImportTable.tsx b/src/imex/import/useImportTable.tsx index 1b324f65..1603a5a7 100644 --- a/src/imex/import/useImportTable.tsx +++ b/src/imex/import/useImportTable.tsx @@ -1,7 +1,7 @@ import { groupBy as lodashGroupBy, omit } from 'lodash' import pLimit from 'p-limit' import * as React from 'react' -import { DropEvent, useDropzone } from 'react-dropzone' +import { useDropzone } from 'react-dropzone' import { useExpanded, useGroupBy } from 'react-table' import { Button, HeaderBar, notify, prompt, Title } from '@habx/ui-core' @@ -20,7 +20,6 @@ import { getImexColumns } from '../getImexColumns' import { IMEXFileExtensionTypes, ImportedRow, - UseImportTableOptions, UseImportTableParams, } from '../imex.interface' import { IMEXColumn } from '../imex.interface' @@ -52,300 +51,274 @@ export const useImportTable = ( const paramsRef = React.useRef(params) paramsRef.current = params - const onFiles = React.useCallback( - async (files: File[], options: Partial> = {}) => { - const mergedOptions = { - ...paramsRef.current, - ...options, - } as UseImportTableParams - validateOptions(mergedOptions) - - /** - * File - */ - const file = files[0] - const fileType: IMEXFileExtensionTypes = file.type.includes('text/') - ? 'csv' - : 'xls' - - /** - * Table params - */ - const initialColumns = mergedOptions.columns as IMEXColumn< - ImportedRow - >[] - const imexColumns = getImexColumns>(initialColumns) - const diffColumns = - getCompareColumnsFromImexColumns>(imexColumns) + const onFiles = React.useCallback(async (files: File[]) => { + const options = paramsRef.current + validateOptions(options) + + /** + * File + */ + const file = files[0] + const fileType: IMEXFileExtensionTypes = file.type.includes('text/') + ? 'csv' + : 'xls' + + /** + * Table params + */ + const initialColumns = options.columns as IMEXColumn>[] + const imexColumns = getImexColumns>(initialColumns) + const diffColumns = + getCompareColumnsFromImexColumns>(imexColumns) + + const plugins = options.groupBy + ? [useGroupBy, useExpanded, useExpandAll] + : [] + + const initialState: Partial>> = { + groupBy: options.groupBy ? [options.groupBy] : [], + } + + const parseFilePromise: Promise[]> = new Promise( + async (resolve, reject) => { + try { + let rawData + if (options.readFile) { + rawData = await options.readFile(file) + } else if (file.type.includes('text/')) { + rawData = await parseCsvFileData(file) + } else { + rawData = await parseExcelFileData(file) + } - const plugins = mergedOptions.groupBy - ? [useGroupBy, useExpanded, useExpandAll] - : [] + const originalData = options.getOriginalData + ? await options.getOriginalData() + : [] - const initialState: Partial>> = { - groupBy: mergedOptions.groupBy ? [mergedOptions.groupBy] : [], + const parsedData = await parseRawData( + { data: rawData, originalData, columns: imexColumns }, + options + ) + resolve(parsedData) + } catch (e) { + reject(e) + } } + ) + + const userInputs = await prompt<{ + message: string + ignoredRows: ImportedRow[] + } | null>(({ onResolve }) => ({ + fullscreen: true, + spacing: 'regular', + Component: () => { + // Parsing + const [parsedData, setParsedData] = React.useState[]>() + React.useEffect(() => { + const asyncParse = async () => { + const data = await parseFilePromise + if (data?.length === 0) { + onResolve({ + message: 'Aucune difference avec les données actuelles', + ignoredRows: [], + }) + } + setParsedData(data) + } + asyncParse() + }, []) + + // Table + const tableInstance = useTable>( + { + columns: diffColumns, + data: parsedData, + initialState, + }, + ...plugins + ) + + const [remainingActionsState, remainingActions] = + useRemainingActionsTime() - const parseFilePromise: Promise[]> = new Promise( - async (resolve, reject) => { + /** + * Prevent leaving while uploading + */ + usePreventLeave(remainingActionsState.loading) + + const handleConfirm = async () => { try { - let rawData - if (mergedOptions.readFile) { - rawData = await mergedOptions.readFile(file) - } else if (file.type.includes('text/')) { - rawData = await parseCsvFileData(file) - } else { - rawData = await parseExcelFileData(file) + const concurrency = options.concurrency ?? 1 + + const cleanData = + parsedData + ?.filter((row) => !row._rowMeta.isIgnored) // remove ignored rows + .map((row) => omit(row, ['_rowMeta']) as unknown as D) ?? [] // remove local meta + + const dataToUpsert = options.groupBy + ? (Object.values( + lodashGroupBy(cleanData, options.groupBy) + ) as D[][]) + : cleanData + + if (options.upsertRow) { + remainingActions.initLoading() + const limit = pLimit(concurrency) + remainingActions.setActionsCount(dataToUpsert.length) + const upsertRowFunctions = dataToUpsert.map((data: D | D[]) => + limit(async () => { + try { + // @ts-ignore + await options.upsertRow?.(data) + } catch (e) { + if (e instanceof Error) { + options.onUpsertRowError?.(e) + console.error(e) // eslint-disable-line + } + } + remainingActions.onActionDone() + }) + ) + await Promise.all(upsertRowFunctions) } - const originalData = mergedOptions.getOriginalData - ? await mergedOptions.getOriginalData() - : [] + // @ts-ignore + await options.onFinish?.(dataToUpsert) - const parsedData = await parseRawData( - { data: rawData, originalData, columns: imexColumns }, - mergedOptions - ) - resolve(parsedData) + onResolve({ + message: `Import terminé\n${dataToUpsert.length} ligne(s) importée(s)`, + ignoredRows: + parsedData?.filter((row) => row._rowMeta.isIgnored) ?? [], + }) } catch (e) { - reject(e) + if (e instanceof Error) { + console.error(e) // eslint-disable-line + notify(e.toString()) + } + remainingActions.onError() + onResolve(null) } } - ) - const userInputs = await prompt<{ - message: string - ignoredRows: ImportedRow[] - } | null>(({ onResolve }) => ({ + if (!parsedData) { + return ( + + analyse + Analyse en cours... + + ) + } + + return ( + + {options.confirmLightBoxTitle && ( + + {options.confirmLightBoxTitle} + + )} + {remainingActionsState.loading && ( + + )} + + + + + + + + + + ) + }, + })) + + if (userInputs?.message) { + notify({ message: userInputs.message, markdown: true }) + } + + /** + * Skipped rows export + */ + if (!options.skipIgnoredRowsExport && userInputs?.ignoredRows.length) { + const [errorFileName] = file.name.split('.') + const errorExportFileName = `${errorFileName}_erreurs` + const ignoredRowsColumns = getCompareColumnsFromImexColumns< + ImportedRow + >(imexColumns, { statusColumn: false, footer: false }) + await prompt(({ onResolve }) => ({ fullscreen: true, spacing: 'regular', Component: () => { - // Parsing - const [parsedData, setParsedData] = React.useState[]>() - React.useEffect(() => { - const asyncParse = async () => { - const data = await parseFilePromise - if (data?.length === 0) { - onResolve({ - message: 'Aucune difference avec les données actuelles', - ignoredRows: [], - }) - } - setParsedData(data) - } - asyncParse() - }, []) - - // Table + const [handleExport] = useExportTable>() const tableInstance = useTable>( { - columns: diffColumns, - data: parsedData, + data: userInputs.ignoredRows, + columns: ignoredRowsColumns, initialState, }, ...plugins ) - const [remainingActionsState, remainingActions] = - useRemainingActionsTime() - - /** - * Prevent leaving while uploading - */ - usePreventLeave(remainingActionsState.loading) - - const handleConfirm = async () => { - try { - const concurrency = mergedOptions.concurrency ?? 1 - - const cleanData = - parsedData - ?.filter((row) => !row._rowMeta.isIgnored) // remove ignored rows - .map((row) => omit(row, ['_rowMeta']) as unknown as D) ?? [] // remove local meta - - const dataToUpsert = mergedOptions.groupBy - ? (Object.values( - lodashGroupBy(cleanData, mergedOptions.groupBy) - ) as D[][]) - : cleanData - - if (mergedOptions.upsertRow) { - remainingActions.initLoading() - const limit = pLimit(concurrency) - remainingActions.setActionsCount(dataToUpsert.length) - const upsertRowFunctions = dataToUpsert.map((data: D | D[]) => - limit(async () => { - try { - // @ts-ignore - await mergedOptions.upsertRow?.(data) - } catch (e) { - if (e instanceof Error) { - mergedOptions.onUpsertRowError?.(e) - console.error(e) // eslint-disable-line - } - } - remainingActions.onActionDone() - }) - ) - await Promise.all(upsertRowFunctions) - } - - // @ts-ignore - await mergedOptions.onFinish?.(dataToUpsert) - - onResolve({ - message: `Import terminé\n${dataToUpsert.length} ligne(s) importée(s)`, - ignoredRows: - parsedData?.filter((row) => row._rowMeta.isIgnored) ?? [], - }) - } catch (e) { - if (e instanceof Error) { - console.error(e) // eslint-disable-line - notify(e.toString()) - } - remainingActions.onError() - onResolve(null) - } - } - - if (!parsedData) { - return ( - - analyse - Analyse en cours... - - ) + const handleDownloadClick = () => { + handleExport(errorExportFileName, { + columns: initialColumns, + data: userInputs.ignoredRows, + type: fileType, + }) + onResolve(true) } return ( - {mergedOptions.confirmLightBoxTitle && ( - - - {mergedOptions.confirmLightBoxTitle} - - - )} - {remainingActionsState.loading && ( - - )} - + + Les éléments suivant n'ont pas été importés. + +
+ + Téléchargez les {userInputs.ignoredRows.length} éléments ignorés + afin de corriger les données. + +
- - - + ) }, })) - - if (userInputs?.message) { - notify({ message: userInputs.message, markdown: true }) - } - - /** - * Skipped rows export - */ - if ( - !mergedOptions.skipIgnoredRowsExport && - userInputs?.ignoredRows.length - ) { - const [errorFileName] = file.name.split('.') - const errorExportFileName = `${errorFileName}_erreurs` - const ignoredRowsColumns = getCompareColumnsFromImexColumns< - ImportedRow - >(imexColumns, { statusColumn: false, footer: false }) - await prompt(({ onResolve }) => ({ - fullscreen: true, - spacing: 'regular', - Component: () => { - const [handleExport] = useExportTable({ - columns: initialColumns, - data: userInputs.ignoredRows, - type: fileType, - }) - const tableInstance = useTable>( - { - data: userInputs.ignoredRows, - columns: ignoredRowsColumns, - initialState, - }, - ...plugins - ) - - const handleDownloadClick = () => { - handleExport(errorExportFileName) - onResolve(true) - } - - return ( - - - Les éléments suivant n'ont pas été importés. - -
- - Téléchargez les {userInputs.ignoredRows.length} éléments - ignorés afin de corriger les données. - - -
- - - - - - - ) - }, - })) - } - }, - [] - ) + } + }, []) const onDropRejected = React.useCallback( () => notify('Type de fichier non supporté'), [] ) - const onDropAccepted = React.useCallback( - (files: File[], event?: DropEvent) => - // eslint-disable-next-line deprecation/deprecation - params.onBeforeDropAccepted - ? // eslint-disable-next-line deprecation/deprecation - params.onBeforeDropAccepted(onFiles)(files, event) - : onFiles(files), - [ - onFiles, // eslint-disable-line react-hooks/exhaustive-deps - params.onBeforeDropAccepted, // eslint-disable-line deprecation/deprecation - ] - ) - const dropzone = useDropzone({ accept: DEFAULT_ACCEPT, - onDropAccepted, + onDropAccepted: onFiles, onDropRejected, }) diff --git a/src/imex/import/useImportTable.utils.ts b/src/imex/import/useImportTable.utils.ts index b048240e..78912715 100644 --- a/src/imex/import/useImportTable.utils.ts +++ b/src/imex/import/useImportTable.utils.ts @@ -12,10 +12,11 @@ import { IMEXColumn, ImportedRow, ImportedRowMeta, - RowValueTypes, + IMEXColumnType, UseImportTableOptions, UseImportTableParams, } from '../imex.interface' +import { getHeader, getPath } from '../imex.utils' export enum ParseCellError { NOT_A_NUMBER, @@ -56,31 +57,31 @@ const isNotEmptyCell = (cell: any) => cell !== '' && cell != null export const parseCell = ( rawCell: any, - type: RowValueTypes, - options: { format: (value: any) => any; ignoreEmpty: boolean } + type: IMEXColumnType, + options: { parse: (value: any) => any; ignoreEmpty: boolean } ): string | number | string[] | number[] | undefined => { if (options.ignoreEmpty && !isNotEmptyCell(rawCell)) { return undefined } switch (type) { - case 'number': + case IMEXColumnType.number: if (typeof rawCell === 'number') { - return Number(options.format(rawCell)) + return Number(options.parse(rawCell)) } - const newCellValue = Number(options.format(rawCell?.replace(',', '.'))) + const newCellValue = Number(options.parse(rawCell?.replace(',', '.'))) if (Number.isNaN(newCellValue)) { throw new Error(ParsingErrors[ParseCellError.NOT_A_NUMBER]) } return newCellValue - case 'number[]': - let formattedNumberArrayCell = options.format(rawCell) - if (!Array.isArray(formattedNumberArrayCell)) { - if (typeof formattedNumberArrayCell !== 'string') { + case IMEXColumnType['number[]']: + let parsedNumberArrayCell = options.parse(rawCell) + if (!Array.isArray(parsedNumberArrayCell)) { + if (typeof parsedNumberArrayCell !== 'string') { throw new Error(ParsingErrors[ParseCellError.INVALID]) } - formattedNumberArrayCell = formattedNumberArrayCell.split(',') + parsedNumberArrayCell = parsedNumberArrayCell.split(',') } - return formattedNumberArrayCell + return parsedNumberArrayCell .filter(isNotEmptyCell) .map((value: string | number) => { const transformedValue = Number(value) @@ -90,17 +91,17 @@ export const parseCell = ( return transformedValue }) - case 'string[]': - let formattedStringArrayCell = options.format(rawCell) - if (!Array.isArray(formattedStringArrayCell)) { - if (typeof formattedStringArrayCell !== 'string') { + case IMEXColumnType['string[]']: + let parsedStringArrayCell = options.parse(rawCell) + if (!Array.isArray(parsedStringArrayCell)) { + if (typeof parsedStringArrayCell !== 'string') { throw new Error(ParsingErrors[ParseCellError.INVALID]) } - formattedStringArrayCell = formattedStringArrayCell.split(',') + parsedStringArrayCell = parsedStringArrayCell.split(',') } - return formattedStringArrayCell.filter(isNotEmptyCell) + return parsedStringArrayCell.filter(isNotEmptyCell) default: - return options.format(rawCell) + return options.parse(rawCell) } } @@ -112,6 +113,7 @@ interface ParseDataParams { originalData: D[] columns: IMEXColumn>[] } + export const parseRawData = async ( params: ParseDataParams, options: Pick< @@ -126,15 +128,16 @@ export const parseRawData = async ( if (!headers) { throw new Error('Missing headers row') } + const identifierColumn = params.columns.find( - (column) => column.meta?.imex?.identifier + (column) => column.imex?.identifier ) if (!identifierColumn) { throw new Error('Missing identifier column') } const requiredColumnHeaders = params.columns - .filter((column) => column.meta?.imex?.required) - .map((column) => cleanHeader(column.Header as string)) + .filter((column) => column?.imex?.required) + .map((column) => cleanHeader(getHeader(column))) const missingRequiredColumns = difference( requiredColumnHeaders as string[], @@ -147,7 +150,7 @@ export const parseRawData = async ( const ignoredColumns = [] const orderedColumns = headers.map((header) => { const column = params.columns.find((imexColumn) => { - const searchedHeader = cleanHeader(imexColumn.Header as string) + const searchedHeader = cleanHeader(getHeader(imexColumn)) const cleanedHeader = requiredColumnHeaders.includes(searchedHeader) ? header.replace(/\*$/, '') : header @@ -179,38 +182,40 @@ export const parseRawData = async ( const rawRowValues = Object.values(row) for (let index = 0; index < rawRowValues.length; index++) { const rawCell = rawRowValues[index] - if (!orderedColumns[index]) { + const currentColumn = orderedColumns[index] + if (!currentColumn) { continue } + const columnDataPath = getPath(currentColumn) + let cellError: string | null = null - const format = (value: any) => - orderedColumns[index]?.meta?.imex?.format?.(value, row) ?? value + const parse = (value: any) => + currentColumn.imex?.parse?.(value, row) ?? value let newCellValue: string | number | string[] | number[] | undefined = rawCell - const ignoreEmpty = orderedColumns[index]?.meta?.imex?.ignoreEmpty ?? true + const ignoreEmpty = currentColumn.imex?.ignoreEmpty ?? true try { newCellValue = parseCell( rawCell, - orderedColumns[index]!.meta!.imex!.type as RowValueTypes, - { format, ignoreEmpty } + currentColumn.imex?.type as IMEXColumnType, + { parse, ignoreEmpty } ) // If parsed value is null, throw if required and ignore if not. if (newCellValue == null) { - if (orderedColumns[index]?.meta?.imex?.required) { + if (currentColumn.imex?.required) { throw new Error(ParsingErrors[ParseCellError.REQUIRED]) } else if (ignoreEmpty) { continue } } - const validate = - orderedColumns[index]?.meta?.imex?.validate ?? (() => true) + const validate = currentColumn.imex?.validate ?? (() => true) const validateResponse = validate(newCellValue, row) const isValid = typeof validateResponse === 'string' @@ -234,18 +239,10 @@ export const parseRawData = async ( */ if (cellError) { importedRowMeta.isIgnored = true - set( - importedRowMeta.errors, - orderedColumns[index]?.accessor as string, - cellError - ) + set(importedRowMeta.errors, columnDataPath, cellError) } - set( - importedRowValue, - orderedColumns[index]?.accessor as string, - newCellValue - ) + set(importedRowValue, columnDataPath, newCellValue) } /** @@ -325,7 +322,9 @@ export const parseRawData = async ( }) } -export const validateOptions = (options: UseImportTableParams) => { +export const validateOptions = ( + options: UseImportTableParams +) => { if (options.concurrency && options.concurrency < 1) { throw new Error('concurrency should be greater than 1') } diff --git a/src/index.ts b/src/index.ts index 8e84ba0c..6387991c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,12 +3,6 @@ export { useTable } from './useTable' export { Table, TablePagination } from './Table' -/* - * Filter methods - */ -export { booleanFilter } from './filterMethod/booleanFilter' -export { arrayFilter } from './filterMethod/arrayFilter' - /* * Filter components */ @@ -52,7 +46,7 @@ export { export { selectFilterFactory } from './filterFactory/selectFilterFactory' // Import / Export -export { RowValueTypes, IMEXColumn } from './imex/imex.interface' +export { IMEXColumnType, IMEXColumn } from './imex/imex.interface' export { useExportTable, UseExportTableParams, diff --git a/src/types/Table.ts b/src/types/Table.ts index c31efa71..d8e0da13 100644 --- a/src/types/Table.ts +++ b/src/types/Table.ts @@ -59,13 +59,14 @@ export type FooterProps = TableInstance & { export type ColumnEnabledCondition = 'always' | 'never' | 'imex-only' -type CustomColumnFields = { +type CustomColumnFields = { + /** @default 'left' */ align?: 'left' | 'right' | 'center' + /** @default 'always' */ enabled?: ColumnEnabledCondition | null - meta?: Meta & { [key: string]: any } } -type ColumnCustom = Omit< +type ColumnCustom = Omit< ReactTable.UseFiltersColumnOptions, 'Filter' > & @@ -73,20 +74,20 @@ type ColumnCustom = Omit< ReactTable.UseGroupByColumnOptions & ReactTable.UseGlobalFiltersColumnOptions & ReactTable.UseSortByColumnOptions & - CustomColumnFields & { + CustomColumnFields & { HeaderIcon?: React.ReactNode Filter?: ReactTable.Renderer> Cell?: ReactTable.Renderer> Header?: ReactTable.Renderer> Footer?: ReactTable.Renderer> - columns?: Column[] + columns?: Column[] headerClassName?: string } -export type Column = - | (Omit, 'columns'> & ColumnCustom) - | (ColumnWithLooseAccessor & ColumnCustom) - | (ColumnWithStrictAccessor & ColumnCustom) +export type Column = + | (Omit, 'columns'> & ColumnCustom) + | (ColumnWithLooseAccessor & ColumnCustom) + | (ColumnWithStrictAccessor & ColumnCustom) export interface TableOptions extends Omit< @@ -109,7 +110,7 @@ export interface TableOptions columns: Array | IMEXColumn> defaultColumn?: Partial> initialState?: Partial> - data?: D[] | null + data: D[] | null | undefined } export interface TableState @@ -151,16 +152,15 @@ export interface TableInstance columns: ColumnInstance[] } -export type ColumnInstance< - D extends object = {} -> = ReactTable.ColumnInstance & - ReactTable.UseTableColumnProps & - ReactTable.UseFiltersColumnProps & - ReactTable.UseSortByColumnProps & - ReactTable.UseGroupByColumnProps & - UseDensityColumnProps & { - getToggleAllRowsSelectedProps: Function - } & CustomColumnFields +export type ColumnInstance = + ReactTable.ColumnInstance & + ReactTable.UseTableColumnProps & + ReactTable.UseFiltersColumnProps & + ReactTable.UseSortByColumnProps & + ReactTable.UseGroupByColumnProps & + UseDensityColumnProps & { + getToggleAllRowsSelectedProps: Function + } & CustomColumnFields export interface Cell extends ReactTable.Cell { canGroupBy?: boolean