diff --git a/packages/ra-core/src/inference/assertions.ts b/packages/ra-core/src/inference/assertions.ts index fb66f18cabd..bbabb93263b 100644 --- a/packages/ra-core/src/inference/assertions.ts +++ b/packages/ra-core/src/inference/assertions.ts @@ -1,3 +1,4 @@ +import isValid from 'date-fns/is_valid'; import parseDate from 'date-fns/parse'; export const isNumeric = (value: any) => @@ -45,7 +46,7 @@ export const isDate = (value: any) => !value || value instanceof Date; export const valuesAreDate = (values: any[]) => values.every(isDate); export const isDateString = (value: any) => - !value || (typeof value === 'string' && !isNaN(parseDate(value).getDate())); + !value || (typeof value === 'string' && isValid(parseDate(value))); export const valuesAreDateString = (values: any[]) => values.every(isDateString); diff --git a/packages/ra-core/src/inference/inferTypeFromValues.ts b/packages/ra-core/src/inference/inferTypeFromValues.ts index 88107508946..b7a7c3d18ef 100644 --- a/packages/ra-core/src/inference/inferTypeFromValues.ts +++ b/packages/ra-core/src/inference/inferTypeFromValues.ts @@ -18,7 +18,7 @@ import { valuesAreEmail, } from './assertions'; -const types = [ +export const InferenceTypes = [ 'array', 'boolean', 'date', @@ -33,9 +33,10 @@ const types = [ 'richText', 'string', 'url', + 'object', ] as const; -export type PossibleInferredElementTypes = typeof types[number]; +export type PossibleInferredElementTypes = typeof InferenceTypes[number]; export interface InferredElementDescription { type: PossibleInferredElementTypes; @@ -169,8 +170,7 @@ export const inferTypeFromValues = ( return { type: 'number', props: { source: name } }; } if (valuesAreObject(values)) { - // we need to go deeper - // Arbitrarily, choose the first prop of the first object + /// Arbitrarily, choose the first prop of the first object const propName = Object.keys(values[0]).shift(); const leafValues = values.map(v => v[propName]); return inferTypeFromValues(`${name}.${propName}`, leafValues); diff --git a/packages/ra-no-code/src/Admin.spec.tsx b/packages/ra-no-code/src/Admin.spec.tsx index 2d2bc2311a2..b1d61deae84 100644 --- a/packages/ra-no-code/src/Admin.spec.tsx +++ b/packages/ra-no-code/src/Admin.spec.tsx @@ -83,7 +83,6 @@ describe('Admin', () => { getByText('Date', { selector: 'th *' }); getByText('Customer', { selector: 'th *' }); getByText('Basket.product', { selector: 'th *' }); - getByText('Basket.quantity', { selector: 'th *' }); getByText('Total ex taxes', { selector: 'th *' }); getByText('Delivery fees', { selector: 'th *' }); getByText('Tax rate', { selector: 'th *' }); diff --git a/packages/ra-no-code/src/Admin.tsx b/packages/ra-no-code/src/Admin.tsx index 803e33a0ff7..784094f51f3 100644 --- a/packages/ra-no-code/src/Admin.tsx +++ b/packages/ra-no-code/src/Admin.tsx @@ -5,15 +5,26 @@ import { Resource, } from 'react-admin'; import localStorageDataProvider from 'ra-data-local-storage'; -import { Create, Edit, List } from './builders'; +import { Create, Edit, List, Show } from './builders'; import { useResourcesConfiguration, + ResourceConfigurationPage, ResourceConfigurationProvider, } from './ResourceConfiguration'; import { Layout, Ready } from './ui'; +import { Route } from 'react-router'; const dataProvider = localStorageDataProvider(); +const customRoutes = [ + ( + + )} + />, +]; + export const Admin = (props: AdminProps) => ( @@ -28,6 +39,7 @@ const InnerAdmin = (props: AdminProps) => { dataProvider={dataProvider} ready={Ready} layout={Layout} + customRoutes={customRoutes} {...props} > {hasResources @@ -39,6 +51,7 @@ const InnerAdmin = (props: AdminProps) => { list={List} edit={Edit} create={Create} + show={Show} /> )) : undefined} diff --git a/packages/ra-no-code/src/ResourceConfiguration/FieldConfiguration/FieldTypeInput.tsx b/packages/ra-no-code/src/ResourceConfiguration/FieldConfiguration/FieldTypeInput.tsx new file mode 100644 index 00000000000..b09d5718005 --- /dev/null +++ b/packages/ra-no-code/src/ResourceConfiguration/FieldConfiguration/FieldTypeInput.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { InferenceTypes } from 'ra-core'; +import { FieldProps, SelectInput } from 'ra-ui-materialui'; + +export const FieldTypeInput = (props: FieldProps) => ( + +); + +const INFERENCE_TYPES = InferenceTypes.map(type => ({ + id: type, + name: type, +})); diff --git a/packages/ra-no-code/src/ResourceConfiguration/FieldConfiguration/FieldViewsInput.tsx b/packages/ra-no-code/src/ResourceConfiguration/FieldConfiguration/FieldViewsInput.tsx new file mode 100644 index 00000000000..66fee6625b0 --- /dev/null +++ b/packages/ra-no-code/src/ResourceConfiguration/FieldConfiguration/FieldViewsInput.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { CheckboxGroupInput, FieldProps } from 'ra-ui-materialui'; + +export const FieldViewsInput = (props: FieldProps) => ( + +); + +const VIEWS = [ + { + id: 'list', + name: 'List', + }, + { + id: 'edit', + name: 'Edit', + }, + { + id: 'create', + name: 'Create', + }, + { + id: 'show', + name: 'Show', + }, +]; + +const VIEWS_INITIAL_VALUE = ['list', 'edit', 'create', 'show']; diff --git a/packages/ra-no-code/src/ResourceConfiguration/FieldConfiguration/index.ts b/packages/ra-no-code/src/ResourceConfiguration/FieldConfiguration/index.ts new file mode 100644 index 00000000000..834dabd86f7 --- /dev/null +++ b/packages/ra-no-code/src/ResourceConfiguration/FieldConfiguration/index.ts @@ -0,0 +1,2 @@ +export * from './FieldTypeInput'; +export * from './FieldViewsInput'; diff --git a/packages/ra-no-code/src/ResourceConfiguration/FieldConfigurationFormSection.tsx b/packages/ra-no-code/src/ResourceConfiguration/FieldConfigurationFormSection.tsx new file mode 100644 index 00000000000..d23e25299f6 --- /dev/null +++ b/packages/ra-no-code/src/ResourceConfiguration/FieldConfigurationFormSection.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { getFieldLabelTranslationArgs, useTranslate } from 'ra-core'; +import { TextInput } from 'ra-ui-materialui'; +import { CardContent } from '@material-ui/core'; +import { FieldTypeInput } from './FieldConfiguration/FieldTypeInput'; +import { FieldViewsInput } from './FieldConfiguration/FieldViewsInput'; + +export const FieldConfigurationFormSection = props => { + const { sourcePrefix, field, resource } = props; + const translate = useTranslate(); + const labelArgs = getFieldLabelTranslationArgs({ + source: field.props.source, + resource, + label: field.props.label, + }); + + return ( + + + + + + + ); +}; diff --git a/packages/ra-no-code/src/ResourceConfiguration/FieldConfigurationTab.tsx b/packages/ra-no-code/src/ResourceConfiguration/FieldConfigurationTab.tsx new file mode 100644 index 00000000000..7a72eb7eb7c --- /dev/null +++ b/packages/ra-no-code/src/ResourceConfiguration/FieldConfigurationTab.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { Tab } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { getFieldLabelTranslationArgs, useTranslate } from 'ra-core'; + +export const FieldConfigurationTab = ({ field, resource, ...props }) => { + const classes = useStyles(); + const translate = useTranslate(); + const labelArgs = getFieldLabelTranslationArgs({ + source: field.props.source, + resource, + label: field.props.label, + }); + + return ( + + ); +}; + +const useStyles = makeStyles(theme => ({ + root: { + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + textTransform: 'none', + minHeight: 0, + fontWeight: 'normal', + }, + selected: { + fontWeight: 'bold', + }, +})); diff --git a/packages/ra-no-code/src/ResourceConfiguration/ResourceConfiguration.tsx b/packages/ra-no-code/src/ResourceConfiguration/ResourceConfiguration.tsx new file mode 100644 index 00000000000..9a18d2317f7 --- /dev/null +++ b/packages/ra-no-code/src/ResourceConfiguration/ResourceConfiguration.tsx @@ -0,0 +1,186 @@ +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { + Avatar, + Card, + CardActions, + CardContent, + CardHeader, + Divider, + IconButton, + Tabs, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import MoreVertIcon from '@material-ui/icons/MoreVert'; +import { + FormWithRedirect, + RecordContextProvider, + SaveContextProvider, +} from 'ra-core'; +import { SaveButton, TextInput } from 'ra-ui-materialui'; +import { + ResourceConfiguration, + FieldConfiguration, +} from './ResourceConfigurationContext'; +import { useResourceConfiguration } from './useResourceConfiguration'; +import { FieldConfigurationFormSection } from './FieldConfigurationFormSection'; +import { FieldConfigurationTab } from './FieldConfigurationTab'; + +export const ResourceConfigurationPage = ({ + resource, +}: { + resource: string; +}) => { + const [resourceConfiguration, actions] = useResourceConfiguration(resource); + const [activeField, setActiveField] = useState(); + const classes = useStyles(); + + const save = (values: ResourceConfiguration) => { + actions.update(values); + }; + const saveContext = { + save, + setOnFailure: () => {}, + setOnSuccess: () => {}, + }; + + const handleTabChange = (event, newValue) => { + const newField = resourceConfiguration.fields.find( + f => f.props.source === newValue + ); + setActiveField(newField); + }; + + useEffect(() => { + if (resourceConfiguration && resourceConfiguration.fields) { + setActiveField(resourceConfiguration.fields[0]); + } + }, [resourceConfiguration]); + + if (!resourceConfiguration || !activeField) { + return null; + } + + return ( + + + ( + + + { + // TODO: Add an icon selector + ( + resourceConfiguration.label || + resourceConfiguration.name + ).substr(0, 1) + } + + } + action={ + // TODO: Add a menu with resource related actions (delete, etc.) + + + + } + title={`Configuration of ${ + resourceConfiguration.label || + resourceConfiguration.name + }`} + /> + + + + + +
+ + {resourceConfiguration.fields.map(field => ( + + ))} + + {resourceConfiguration.fields.map( + (field, index) => ( + + ) + )} +
+ + + +
+ )} + /> +
+
+ ); +}; + +const useStyles = makeStyles(theme => ({ + fields: { + display: 'flex', + }, + fieldList: { + backgroundColor: theme.palette.background.default, + }, + fieldTitle: { + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + textTransform: 'none', + minHeight: 0, + }, + fieldPanel: { + flexGrow: 1, + }, + actions: { + backgroundColor: theme.palette.background.default, + }, +})); diff --git a/packages/ra-no-code/src/ResourceConfiguration/ResourceConfigurationContext.ts b/packages/ra-no-code/src/ResourceConfiguration/ResourceConfigurationContext.ts index 9517384c559..726430ba0f7 100644 --- a/packages/ra-no-code/src/ResourceConfiguration/ResourceConfigurationContext.ts +++ b/packages/ra-no-code/src/ResourceConfiguration/ResourceConfigurationContext.ts @@ -1,5 +1,5 @@ -import { createContext } from 'react'; import { InferredElementDescription } from 'ra-core'; +import { createContext } from 'react'; export const ResourceConfigurationContext = createContext< ResourceConfigurationContextValue @@ -29,9 +29,15 @@ export type ResourceConfigurationContextHelpers = { export type ResourceConfiguration = { name: string; label?: string; - fields?: InferredElementDescription[]; + fields?: FieldConfiguration[]; }; +export interface FieldConfiguration extends InferredElementDescription { + views: FieldView[]; +} + +export type FieldView = 'list' | 'create' | 'edit' | 'show'; + export type ResourceConfigurationMap = | { [key: string]: ResourceConfiguration; diff --git a/packages/ra-no-code/src/ResourceConfiguration/getFieldDefinitionsFromRecords.ts b/packages/ra-no-code/src/ResourceConfiguration/getFieldDefinitionsFromRecords.ts index aa4da5c7a20..99517c76699 100644 --- a/packages/ra-no-code/src/ResourceConfiguration/getFieldDefinitionsFromRecords.ts +++ b/packages/ra-no-code/src/ResourceConfiguration/getFieldDefinitionsFromRecords.ts @@ -1,16 +1,13 @@ -import { - getValuesFromRecords, - InferredElementDescription, - inferTypeFromValues, - Record, -} from 'ra-core'; +import { getValuesFromRecords, inferTypeFromValues, Record } from 'ra-core'; +import { FieldConfiguration } from './ResourceConfigurationContext'; export const getFieldDefinitionsFromRecords = ( records: Record[] -): InferredElementDescription[] => { +): FieldConfiguration[] => { const values = getValuesFromRecords(records); - return Object.keys(values).map(key => - inferTypeFromValues(key, values[key]) - ); + return Object.keys(values).map(key => ({ + ...inferTypeFromValues(key, values[key]), + views: ['list', 'create', 'edit', 'show'], + })); }; diff --git a/packages/ra-no-code/src/ResourceConfiguration/index.ts b/packages/ra-no-code/src/ResourceConfiguration/index.ts index 6145f4ee7f3..75fde27f687 100644 --- a/packages/ra-no-code/src/ResourceConfiguration/index.ts +++ b/packages/ra-no-code/src/ResourceConfiguration/index.ts @@ -1,4 +1,5 @@ export * from './getFieldDefinitionsFromRecords'; +export * from './ResourceConfiguration'; export * from './useResourceConfiguration'; export * from './useResourcesConfiguration'; export * from './ResourceConfigurationContext'; diff --git a/packages/ra-no-code/src/builders/Create.tsx b/packages/ra-no-code/src/builders/Create.tsx index 299ca2853fd..dd87f57dd84 100644 --- a/packages/ra-no-code/src/builders/Create.tsx +++ b/packages/ra-no-code/src/builders/Create.tsx @@ -21,9 +21,9 @@ export const CreateForm = (props: Omit) => { return ( - {resourceConfiguration.fields.map(definition => - getInputFromFieldDefinition(definition) - )} + {resourceConfiguration.fields + .filter(definition => definition.views.includes('create')) + .map(definition => getInputFromFieldDefinition(definition))} ); }; diff --git a/packages/ra-no-code/src/builders/Edit.tsx b/packages/ra-no-code/src/builders/Edit.tsx index 50a3bea6036..b3550c3ae65 100644 --- a/packages/ra-no-code/src/builders/Edit.tsx +++ b/packages/ra-no-code/src/builders/Edit.tsx @@ -21,9 +21,9 @@ export const EditForm = (props: Omit) => { return ( - {resourceConfiguration.fields.map(definition => - getInputFromFieldDefinition(definition) - )} + {resourceConfiguration.fields + .filter(definition => definition.views.includes('edit')) + .map(definition => getInputFromFieldDefinition(definition))} ); }; diff --git a/packages/ra-no-code/src/builders/List.tsx b/packages/ra-no-code/src/builders/List.tsx index d9dcacb0e2a..9d066c49d82 100644 --- a/packages/ra-no-code/src/builders/List.tsx +++ b/packages/ra-no-code/src/builders/List.tsx @@ -22,9 +22,9 @@ export const Datagrid = (props: Omit) => { return ( - {resourceConfiguration.fields.map(definition => - getFieldFromFieldDefinition(definition) - )} + {resourceConfiguration.fields + .filter(definition => definition.views.includes('list')) + .map(definition => getFieldFromFieldDefinition(definition))} ); }; diff --git a/packages/ra-no-code/src/builders/Show.tsx b/packages/ra-no-code/src/builders/Show.tsx new file mode 100644 index 00000000000..4f07246d063 --- /dev/null +++ b/packages/ra-no-code/src/builders/Show.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { useResourceContext } from 'ra-core'; +import { + Show as RaShow, + ShowProps, + SimpleShowLayout, + SimpleShowLayoutProps, +} from 'ra-ui-materialui'; +import { useResourceConfiguration } from '../ResourceConfiguration'; +import { getFieldFromFieldDefinition } from './getFieldFromFieldDefinition'; + +export const Show = (props: ShowProps) => ( + + + +); + +export const ShowForm = (props: Omit) => { + const resource = useResourceContext(props); + const [resourceConfiguration] = useResourceConfiguration(resource); + + return ( + + {resourceConfiguration.fields + .filter(definition => definition.views.includes('show')) + .map(definition => getFieldFromFieldDefinition(definition))} + + ); +}; diff --git a/packages/ra-no-code/src/builders/getInputFromFieldDefinition.tsx b/packages/ra-no-code/src/builders/getInputFromFieldDefinition.tsx index e23fedcd049..30eddd7b3da 100644 --- a/packages/ra-no-code/src/builders/getInputFromFieldDefinition.tsx +++ b/packages/ra-no-code/src/builders/getInputFromFieldDefinition.tsx @@ -9,20 +9,21 @@ import { } from 'ra-ui-materialui'; export const getInputFromFieldDefinition = ( - definition: InferredElementDescription + definition: InferredElementDescription, + keyPrefix?: string ) => { switch (definition.type) { case 'date': return ( ); case 'email': return ( @@ -30,37 +31,46 @@ export const getInputFromFieldDefinition = ( case 'boolean': return ( ); case 'number': return ( ); case 'image': return ( ); case 'url': return ( ); + case 'object': + if (Array.isArray(definition.children)) { + return definition.children.map((child, index) => + getInputFromFieldDefinition(child, index.toString()) + ); + } + return <>{getInputFromFieldDefinition(definition.children)}; default: return ( ); } }; + +const getKey = (prefix, source) => (prefix ? `${prefix}_${source}` : source); diff --git a/packages/ra-no-code/src/builders/index.ts b/packages/ra-no-code/src/builders/index.ts index aada1d13318..c9663ead7f4 100644 --- a/packages/ra-no-code/src/builders/index.ts +++ b/packages/ra-no-code/src/builders/index.ts @@ -3,3 +3,4 @@ export * from './Edit'; export * from './List'; export * from './getFieldFromFieldDefinition'; export * from './getInputFromFieldDefinition'; +export * from './Show'; diff --git a/packages/ra-no-code/src/ui/ImportResourceDialog.tsx b/packages/ra-no-code/src/ui/ImportResourceDialog.tsx index d8157e45aed..1707ef52ec3 100644 --- a/packages/ra-no-code/src/ui/ImportResourceDialog.tsx +++ b/packages/ra-no-code/src/ui/ImportResourceDialog.tsx @@ -12,7 +12,7 @@ import { } from '@material-ui/core'; import { useDropzone } from 'react-dropzone'; -import { useRefresh } from 'ra-core'; +import { useNotify, useRefresh } from 'ra-core'; import { useHistory } from 'react-router-dom'; import { useImportResourceFromCsv } from './useImportResourceFromCsv'; @@ -21,6 +21,7 @@ export const ImportResourceDialog = (props: ImportResourceDialogProps) => { const [resource, setResource] = useState(''); const history = useHistory(); const refresh = useRefresh(); + const notify = useNotify(); const handleClose = () => { if (props.onClose) { @@ -28,20 +29,7 @@ export const ImportResourceDialog = (props: ImportResourceDialogProps) => { } }; - const handleImportCompleted = ({ resourceAlreadyExists }) => { - handleClose(); - history.push(`/${resource}`); - - if (resourceAlreadyExists) { - // If we imported more records for an existing resource, - // we must refresh the list - refresh(); - } - }; - - const [parsing, importResource] = useImportResourceFromCsv( - handleImportCompleted - ); + const [parsing, importResource] = useImportResourceFromCsv(); const onDrop = (acceptedFiles: File[]) => { if (acceptedFiles.length > 0) { @@ -57,7 +45,20 @@ export const ImportResourceDialog = (props: ImportResourceDialogProps) => { event.preventDefault(); if (resource && file) { - importResource(resource, file); + importResource(resource, file) + .then(({ resource, resourceAlreadyExists }) => { + handleClose(); + history.push(`/${resource}`); + + if (resourceAlreadyExists) { + // If we imported more records for an existing resource, + // we must refresh the list + refresh(); + } + }) + .catch(() => { + notify('An error occured while handling this CSV file'); + }); } }; diff --git a/packages/ra-no-code/src/ui/Layout.tsx b/packages/ra-no-code/src/ui/Layout.tsx index ade1235d912..77851e22255 100644 --- a/packages/ra-no-code/src/ui/Layout.tsx +++ b/packages/ra-no-code/src/ui/Layout.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Layout as RaLayout, LayoutProps } from 'react-admin'; -import Menu from './Menu'; +import { Menu } from './Menu'; export const Layout = (props: LayoutProps) => ( diff --git a/packages/ra-no-code/src/ui/Menu.tsx b/packages/ra-no-code/src/ui/Menu.tsx index af30ae85bb8..5721b85bdd4 100644 --- a/packages/ra-no-code/src/ui/Menu.tsx +++ b/packages/ra-no-code/src/ui/Menu.tsx @@ -6,18 +6,18 @@ import lodashGet from 'lodash/get'; // @ts-ignore import { useMediaQuery, Theme } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; -import DefaultIcon from '@material-ui/icons/ViewList'; import classnames from 'classnames'; -import { useGetResourceLabel, ReduxState } from 'ra-core'; +import { ReduxState } from 'ra-core'; -import { DashboardMenuItem, MenuItemLink } from 'react-admin'; +import { DashboardMenuItem } from 'react-admin'; import { NewResourceMenuItem } from './NewResourceMenuItem'; import { useResourcesConfiguration } from '../ResourceConfiguration'; +import { ResourceMenuItem } from './ResourceMenuItem'; export const MENU_WIDTH = 240; export const CLOSED_MENU_WIDTH = 55; -const Menu = (props: MenuProps) => { +export const Menu = (props: MenuProps) => { const { classes: classesOverride, className, @@ -34,7 +34,6 @@ const Menu = (props: MenuProps) => { ); const open = useSelector((state: ReduxState) => state.admin.ui.sidebarOpen); const [resources] = useResourcesConfiguration(); - const getResourceLabel = useGetResourceLabel(); return ( <> @@ -57,14 +56,9 @@ const Menu = (props: MenuProps) => { /> )} {Object.keys(resources).map(resource => ( - } + resource={resources[resource]} onClick={onMenuClick} dense={dense} sidebarIsOpen={open} @@ -126,5 +120,3 @@ Menu.propTypes = { Menu.defaultProps = { onMenuClick: () => null, }; - -export default Menu; diff --git a/packages/ra-no-code/src/ui/ResourceMenuItem.tsx b/packages/ra-no-code/src/ui/ResourceMenuItem.tsx new file mode 100644 index 00000000000..d8816c9988b --- /dev/null +++ b/packages/ra-no-code/src/ui/ResourceMenuItem.tsx @@ -0,0 +1,57 @@ +import React, { forwardRef } from 'react'; +import { MenuItemLink, MenuItemLinkProps } from 'react-admin'; +import { IconButton } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import SettingsIcon from '@material-ui/icons/Settings'; +import DefaultIcon from '@material-ui/icons/ViewList'; +import { NavLink, NavLinkProps } from 'react-router-dom'; +import { ResourceConfiguration } from '../ResourceConfiguration'; + +export const ResourceMenuItem = ( + props: Omit & { + resource: ResourceConfiguration; + } +) => { + const { resource, ...rest } = props; + const classes = useStyles(props); + return ( +
+ } + {...rest} + /> + + + +
+ ); +}; + +const NavLinkRef = forwardRef((props, ref) => ( + +)); + +const useStyles = makeStyles(theme => ({ + root: { + display: 'flex', + }, + resource: { + flexGrow: 1, + }, + settings: { + marginLeft: 'auto', + }, +})); diff --git a/packages/ra-no-code/src/ui/useImportResourceFromCsv.tsx b/packages/ra-no-code/src/ui/useImportResourceFromCsv.tsx index 337327cf23b..f94733be4cf 100644 --- a/packages/ra-no-code/src/ui/useImportResourceFromCsv.tsx +++ b/packages/ra-no-code/src/ui/useImportResourceFromCsv.tsx @@ -1,6 +1,11 @@ import { useState } from 'react'; import { parse } from 'papaparse'; -import { Record, useDataProvider } from 'ra-core'; +import { + getValuesFromRecords, + Record, + useDataProvider, +} from 'ra-core'; +import set from 'lodash/set'; import { useResourcesConfiguration, @@ -13,48 +18,98 @@ import { * @param onImportCompleted A function called once the import is completed. Receive an object containing the resource imported and the resourceAlreadyExists boolean. * @returns {[boolean, ImportResource]} */ -export const useImportResourceFromCsv = ( - onImportCompleted: ImportCompletedHandler -): [boolean, ImportResource] => { +export const useImportResourceFromCsv = (): [boolean, ImportResource] => { const [parsing, setParsing] = useState(false); const dataProvider = useDataProvider(); const [resources, { addResource }] = useResourcesConfiguration(); - const importResource = (resource: string, file: File) => { + const importResource = async (resource: string, file: File) => { setParsing(true); - parse(file, { - header: true, - complete: async ({ data }) => { - const resourceAlreadyExists = !!resources[resource]; - - await Promise.all( - data.map(record => { - if (record.id) { - return dataProvider - .create(resource, { - data: record, - }) - .catch(error => console.error(error)); - } - return Promise.resolve(); + const resourceAlreadyExists = !!resources[resource]; + const { data, meta } = await parseCSV(file); + const records = sanitizeRecords( + data.filter(record => !!record.id), + meta + ); + await Promise.all( + records.map(record => { + return dataProvider + .create(resource, { + data: record, }) - ); - setParsing(false); - const fields = getFieldDefinitionsFromRecords(data); - addResource({ name: resource, fields }); - onImportCompleted({ resource, resourceAlreadyExists }); - }, - }); + .catch(error => { + // Ignore errors while adding a single record + console.error( + `Error while importing record ${JSON.stringify( + record, + null, + 4 + )}` + ); + }); + }) + ); + setParsing(false); + const fields = getFieldDefinitionsFromRecords(records); + addResource({ name: resource, fields }); + return { resource, resourceAlreadyExists }; }; return [parsing, importResource]; }; -type ImportResource = (resource: string, file: File) => void; -type ImportCompletedHandler = ({ - resourceAlreadyExists, - resource, -}: { +const parseCSV = (file: File): Promise<{ data: Record[]; meta: any }> => + new Promise((resolve, reject) => { + parse(file, { + header: true, + skipEmptyLines: true, + complete: async ({ data, meta }) => { + resolve({ data, meta }); + }, + error: error => { + reject(error); + }, + }); + }); + +type ImportResource = ( + resource: string, + file: File +) => Promise<{ resourceAlreadyExists: boolean; resource: string; -}) => void; +}>; + +const sanitizeRecords = ( + records: Record[], + { fields }: { fields: string[] } +): Record[] => { + const values = getValuesFromRecords(records); + return fields.reduce( + (newRecords, field) => sanitizeRecord(newRecords, values, field), + [...records] + ); +}; + +const sanitizeRecord = (records, values, field) => { + if (field.split('.').length > 1) { + return records.map(record => { + let { [field]: pathField, ...newRecord } = record; + return set(newRecord, field, record[field]); + }); + } + + const fieldValues = values[field]; + + if ( + fieldValues.some(value => + ['false', 'true'].includes(value.toString().toLowerCase()) + ) + ) { + return records.map(record => + set(record, field, Boolean(record[field])) + ); + } + + return records; +};