Skip to content

Commit

Permalink
Merge pull request #9968 from marmelab/typescript-exporter-type
Browse files Browse the repository at this point in the history
[TypeScript] Improve List exporter type
  • Loading branch information
fzaninotto authored Jul 2, 2024
2 parents 7a79484 + f4c4bee commit 631e2ff
Show file tree
Hide file tree
Showing 16 changed files with 163 additions and 128 deletions.
15 changes: 8 additions & 7 deletions docs/Demos.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,13 +280,14 @@ A complete CRM app allowing to manage contacts, companies, deals, notes, tasks,

The source shows how to implement the following features:

- [Horizontal navigation](https://github.com/marmelab/react-admin/blob/7c60db09aea34a90607a4e7560e9e4b51bd7b9a3/examples/crm/src/Layout.tsx)
- [Trello-like Kanban board for the deals pipeline](https://github.com/marmelab/react-admin/blob/7c60db09aea34a90607a4e7560e9e4b51bd7b9a3/examples/crm/src/deals/DealListContent.tsx)
- [Custom d3.js / Nivo Chart in the dashboard](https://github.com/marmelab/react-admin/blob/7c60db09aea34a90607a4e7560e9e4b51bd7b9a3/examples/crm/src/dashboard/DealsChart.tsx)
- [Add or remove tags to a contact](https://github.com/marmelab/react-admin/blob/7c60db09aea34a90607a4e7560e9e4b51bd7b9a3/examples/crm/src/contacts/TagsListEdit.tsx)
- [Use dataProvider hooks to update notes](https://github.com/marmelab/react-admin/blob/7c60db09aea34a90607a4e7560e9e4b51bd7b9a3/examples/crm/src/notes/Note.tsx)
- [Custom grid layout for companies](https://github.com/marmelab/react-admin/blob/7c60db09aea34a90607a4e7560e9e4b51bd7b9a3/examples/crm/src/companies/GridList.tsx)
- [Filter by "my favorites" in the company list](https://github.com/marmelab/react-admin/blob/7c60db09aea34a90607a4e7560e9e4b51bd7b9a3/examples/crm/src/deals/OnlyMineInput.tsx)
- [Horizontal navigation](https://github.com/marmelab/react-admin/blob/master/examples/crm/src/Layout.tsx)
- [Custom exporter](https://github.com/marmelab/react-admin/blob/master/examples/crm/src/contacts/ContactList.tsx)
- [Trello-like Kanban board for the deals pipeline](https://github.com/marmelab/react-admin/blob/master/examples/crm/src/deals/DealListContent.tsx)
- [Custom d3.js / Nivo Chart in the dashboard](https://github.com/marmelab/react-admin/blob/master/examples/crm/src/dashboard/DealsChart.tsx)
- [Add or remove tags to a contact](https://github.com/marmelab/react-admin/blob/master/examples/crm/src/contacts/TagsListEdit.tsx)
- [Use dataProvider hooks to update notes](https://github.com/marmelab/react-admin/blob/master/examples/crm/src/notes/Note.tsx)
- [Custom grid layout for companies](https://github.com/marmelab/react-admin/blob/master/examples/crm/src/companies/GridList.tsx)
- [Filter by "my favorites" in the company list](https://github.com/marmelab/react-admin/blob/master/examples/crm/src/deals/OnlyMineInput.tsx)

## Help Desk

Expand Down
29 changes: 15 additions & 14 deletions docs/List.md
Original file line number Diff line number Diff line change
Expand Up @@ -607,31 +607,32 @@ In many cases, you'll need more than simple object manipulation. You'll need to

Here is an example for a Comments exporter, fetching related Posts:

```jsx
```tsx
// in CommentList.js
import { List, downloadCSV } from 'react-admin';
import type { FetchRelatedRecords } from 'react-admin';
import jsonExport from 'jsonexport/dist';

const exporter = (records, fetchRelatedRecords) => {
// will call dataProvider.getMany('posts', { ids: records.map(record => record.post_id) }), ignoring duplicate and empty post_id
fetchRelatedRecords(records, 'post_id', 'posts').then(posts => {
const data = records.map(record => ({
...record,
post_title: posts[record.post_id].title,
}));
return jsonExport(data, {
headers: ['id', 'post_id', 'post_title', 'body'],
}, (err, csv) => {
downloadCSV(csv, 'comments');
});
const exporter = async (comments: Comments[], fetchRelatedRecords: FetchRelatedRecords) => {
// will call dataProvider.getMany('posts', { ids: records.map(record => record.post_id) }),
// ignoring duplicate and empty post_id
const posts = await fetchRelatedRecords<Post>(comments, 'post_id', 'posts')
const commentsWithPostTitle = comments.map(comment => ({
...comment,
post_title: posts[comment.post_id].title,
}));
return jsonExport(commentsWithPostTitle, {
headers: ['id', 'post_id', 'post_title', 'body'],
}, (err, csv) => {
downloadCSV(csv, 'comments');
});
};

const CommentList = () => (
<List exporter={exporter}>
...
</List>
)
);
```

**Tip**: If you need to call another verb in the exporter, take advantage of the third parameter passed to the function: it's the `dataProvider` function.
Expand Down
1 change: 1 addition & 0 deletions examples/crm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@testing-library/user-event": "^14.5.2",
"@types/faker": "^5.1.7",
"@types/jest": "^29.5.2",
"@types/jsonexport": "^3.0.5",
"@types/lodash": "~4.14.168",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
Expand Down
48 changes: 37 additions & 11 deletions examples/crm/src/contacts/ContactList.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
/* eslint-disable import/no-anonymous-default-export */
import * as React from 'react';
import {
BulkActionsToolbar,
BulkDeleteButton,
CreateButton,
downloadCSV,
ExportButton,
List as RaList,
SimpleListLoading,
Pagination,
RecordContextProvider,
ReferenceField,
TextField,
useListContext,
ExportButton,
SimpleListLoading,
SortButton,
TextField,
TopToolbar,
CreateButton,
Pagination,
useGetIdentity,
BulkActionsToolbar,
BulkDeleteButton,
RecordContextProvider,
useListContext,
} from 'react-admin';
import type { Exporter } from 'react-admin';
import {
List,
ListItem,
Expand All @@ -28,12 +30,13 @@ import {
} from '@mui/material';
import { Link } from 'react-router-dom';
import { formatDistance } from 'date-fns';
import jsonExport from 'jsonexport/dist';

import { Avatar } from './Avatar';
import { Status } from '../misc/Status';
import { TagsList } from './TagsList';
import { ContactListFilter } from './ContactListFilter';
import { Contact } from '../types';
import { Contact, Company, Sale, Tag } from '../types';

const ContactListContent = () => {
const {
Expand Down Expand Up @@ -140,16 +143,39 @@ const ContactListActions = () => (
</TopToolbar>
);

const exporter: Exporter<Contact> = async (records, fetchRelatedRecords) => {
const companies = await fetchRelatedRecords<Company>(
records,
'company_id',
'companies'
);
const sales = await fetchRelatedRecords<Sale>(records, 'sales_id', 'sales');
const tags = await fetchRelatedRecords<Tag>(records, 'tags', 'tags');

const contacts = records.map(contact => ({
...contact,
company: companies[contact.company_id].name,
sales: `${sales[contact.sales_id].first_name} ${
sales[contact.sales_id].last_name
}`,
tags: contact.tags.map(tagId => tags[tagId].name).join(', '),
}));
return jsonExport(contacts, {}, (_err: any, csv: string) => {
downloadCSV(csv, 'contacts');
});
};

export const ContactList = () => {
const { identity } = useGetIdentity();
return identity ? (
<RaList
<RaList<Contact>
actions={<ContactListActions />}
aside={<ContactListFilter />}
perPage={25}
pagination={<Pagination rowsPerPageOptions={[10, 25, 50, 100]} />}
filterDefaultValues={{ sales_id: identity?.id }}
sort={{ field: 'last_seen', order: 'DESC' }}
exporter={exporter}
>
<ContactListContent />
</RaList>
Expand Down
3 changes: 2 additions & 1 deletion examples/simple/src/comments/CommentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@ import {
downloadCSV,
useListContext,
useTranslate,
Exporter,
} from 'react-admin'; // eslint-disable-line import/no-unresolved

const commentFilters = [
<SearchInput source="q" alwaysOn />,
<ReferenceInput source="post_id" reference="posts" />,
];

const exporter = (records, fetchRelatedRecords) =>
const exporter: Exporter = (records, fetchRelatedRecords) =>
fetchRelatedRecords(records, 'post_id', 'posts').then(posts => {
const data = records.map(record => {
const { author, ...recordForExport } = record; // omit author
Expand Down
2 changes: 1 addition & 1 deletion packages/ra-core/src/controller/list/useListController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ export interface ListControllerProps<RecordType extends RaRecord = any> {
* </List>
* )
*/
exporter?: Exporter | false;
exporter?: Exporter<RecordType> | false;

/**
* Permanent filter applied to all getList queries, regardless of the user selected filters.
Expand Down
6 changes: 2 additions & 4 deletions packages/ra-core/src/export/ExporterContext.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { createContext } from 'react';

import { Exporter } from '../types';
import defaultExporter from './defaultExporter';
import { defaultExporter } from './defaultExporter';

const ExporterContext = createContext<Exporter | false>(defaultExporter);
export const ExporterContext = createContext<Exporter | false>(defaultExporter);

ExporterContext.displayName = 'ExporterContext';

export default ExporterContext;
6 changes: 2 additions & 4 deletions packages/ra-core/src/export/defaultExporter.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import jsonExport from 'jsonexport/dist';

import downloadCSV from './downloadCSV';
import { downloadCSV } from './downloadCSV';
import { Exporter } from '../types';

const defaultExporter: Exporter = (data, _, __, resource) =>
export const defaultExporter: Exporter = (data, _, __, resource) =>
jsonExport(data, (err, csv) => downloadCSV(csv, resource));

export default defaultExporter;
2 changes: 1 addition & 1 deletion packages/ra-core/src/export/downloadCSV.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export default (csv: string, filename: string = 'export'): void => {
export const downloadCSV = (csv: string, filename: string = 'export'): void => {
const fakeLink = document.createElement('a');
fakeLink.style.display = 'none';
document.body.appendChild(fakeLink);
Expand Down
32 changes: 0 additions & 32 deletions packages/ra-core/src/export/fetchRelatedRecords.spec.ts

This file was deleted.

45 changes: 5 additions & 40 deletions packages/ra-core/src/export/fetchRelatedRecords.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { RaRecord, Identifier, DataProvider } from '../types';
import { DataProvider, FetchRelatedRecords } from '../types';
import { getRelatedIds } from './getRelatedIds';

/**
* Helper function for calling the dataProvider.getMany() method,
Expand All @@ -12,8 +13,9 @@ import { RaRecord, Identifier, DataProvider } from '../types';
* }))
* );
*/
const fetchRelatedRecords =
(dataProvider: DataProvider) => (data, field, resource) =>
export const fetchRelatedRecords =
(dataProvider: DataProvider): FetchRelatedRecords =>
(data, field, resource) =>
dataProvider
.getMany(resource, { ids: getRelatedIds(data, field) })
.then(({ data }) =>
Expand All @@ -22,40 +24,3 @@ const fetchRelatedRecords =
return acc;
}, {})
);

/**
* Extracts, aggregates and deduplicates the ids of related records
*
* @example
* const books = [
* { id: 1, author_id: 123, title: 'Pride and Prejudice' },
* { id: 2, author_id: 123, title: 'Sense and Sensibility' },
* { id: 3, author_id: 456, title: 'War and Peace' },
* ];
* getRelatedIds(books, 'author_id'); => [123, 456]
*
* @example
* const books = [
* { id: 1, tag_ids: [1, 2], title: 'Pride and Prejudice' },
* { id: 2, tag_ids: [2, 3], title: 'Sense and Sensibility' },
* { id: 3, tag_ids: [4], title: 'War and Peace' },
* ];
* getRelatedIds(records, 'tag_ids'); => [1, 2, 3, 4]
*
* @param {Object[]} records An array of records
* @param {string} field the identifier of the record field to use
*/
export const getRelatedIds = (
records: RaRecord[],
field: string
): Identifier[] =>
Array.from(
new Set(
records
.filter(record => record[field] != null)
.map(record => record[field])
.reduce((ids, value) => ids.concat(value), [])
)
);

export default fetchRelatedRecords;
30 changes: 30 additions & 0 deletions packages/ra-core/src/export/getRelatedIds.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import expect from 'expect';

import { getRelatedIds } from './getRelatedIds';

describe('getRelatedIds', () => {
it('should ignore null or undefined values', () => {
const books = [
{ id: 1, author_id: 123, title: 'Pride and Prejudice' },
{ id: 2, author_id: null },
{ id: 3 },
];
expect(getRelatedIds(books, 'author_id')).toEqual([123]);
});
it('should aggregate scalar related ids', () => {
const books = [
{ id: 1, author_id: 123, title: 'Pride and Prejudice' },
{ id: 2, author_id: 123, title: 'Sense and Sensibility' },
{ id: 3, author_id: 456, title: 'War and Peace' },
];
expect(getRelatedIds(books, 'author_id')).toEqual([123, 456]);
});
it('should aggregate arrays of related ids', () => {
const books = [
{ id: 1, tag_ids: [1, 2], title: 'Pride and Prejudice' },
{ id: 2, tag_ids: [2, 3], title: 'Sense and Sensibility' },
{ id: 3, tag_ids: [4], title: 'War and Peace' },
];
expect(getRelatedIds(books, 'tag_ids')).toEqual([1, 2, 3, 4]);
});
});
36 changes: 36 additions & 0 deletions packages/ra-core/src/export/getRelatedIds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { RaRecord, Identifier } from '../types';

/**
* Extracts, aggregates and deduplicates the ids of related records
*
* @example
* const books = [
* { id: 1, author_id: 123, title: 'Pride and Prejudice' },
* { id: 2, author_id: 123, title: 'Sense and Sensibility' },
* { id: 3, author_id: 456, title: 'War and Peace' },
* ];
* getRelatedIds(books, 'author_id'); => [123, 456]
*
* @example
* const books = [
* { id: 1, tag_ids: [1, 2], title: 'Pride and Prejudice' },
* { id: 2, tag_ids: [2, 3], title: 'Sense and Sensibility' },
* { id: 3, tag_ids: [4], title: 'War and Peace' },
* ];
* getRelatedIds(records, 'tag_ids'); => [1, 2, 3, 4]
*
* @param {Object[]} records An array of records
* @param {string} field the identifier of the record field to use
*/
export const getRelatedIds = (
records: RaRecord[],
field: string
): Identifier[] =>
Array.from(
new Set(
records
.filter(record => record[field] != null)
.map(record => record[field])
.reduce((ids, value) => ids.concat(value), [])
)
);
10 changes: 4 additions & 6 deletions packages/ra-core/src/export/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import defaultExporter from './defaultExporter';
import downloadCSV from './downloadCSV';
import ExporterContext from './ExporterContext';
import fetchRelatedRecords from './fetchRelatedRecords';

export { defaultExporter, downloadCSV, ExporterContext, fetchRelatedRecords };
export * from './defaultExporter';
export * from './downloadCSV';
export * from './ExporterContext';
export * from './fetchRelatedRecords';
Loading

0 comments on commit 631e2ff

Please sign in to comment.