Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TypeScript] Improve List exporter type #9968

Merged
merged 6 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}>
Copy link
Collaborator

@djhi djhi Jul 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably go one step further and make exporter typed when passing a type to List:

<List<Comment> 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
Loading