@boarteam/boar-pack-common-frontend 3.2.1 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boarteam/boar-pack-common-frontend",
3
- "version": "3.2.1",
3
+ "version": "3.3.1",
4
4
  "description": "Common frontend package for Boar Pack",
5
5
  "repository": "git@github.com:boarteam/boar-pack.git",
6
6
  "author": "Andrew Balakirev <balakirev.andrey@gmail.com>",
@@ -26,14 +26,17 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@nestjsx/crud-request": "^5.0.0-alpha.3",
29
+ "deep-diff": "^1.0.2",
29
30
  "lodash": "^4.17.21",
30
- "uuid": "^11.1.0"
31
+ "uuid": "^11.1.0",
32
+ "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
31
33
  },
32
34
  "devDependencies": {
33
35
  "@ant-design/icons": "^4.8.3",
34
36
  "@ant-design/pro-components": "^2.6.52",
35
37
  "@ant-design/pro-table": "^3.15.1",
36
38
  "@ant-design/pro-utils": "^2.15.5",
39
+ "@types/deep-diff": "^1.0.5",
37
40
  "@types/lodash": "^4.17.0",
38
41
  "@types/react-dom": "^18.2.22",
39
42
  "@umijs/plugin-locale": "^0.16.0",
@@ -47,5 +50,5 @@
47
50
  "scripts": {
48
51
  "yalc:push": "yalc push"
49
52
  },
50
- "gitHead": "84d2c3f084f48afc57cb93f7eb2639c971bba5d2"
53
+ "gitHead": "b0da143812410567f09640cbc6a6ecf95516bed2"
51
54
  }
@@ -0,0 +1,262 @@
1
+ import { Badge, Button, message, Modal, Tabs } from "antd";
2
+ import { ProColumns } from "@ant-design/pro-components";
3
+ import { createStyles } from "antd-style";
4
+ import { useEffect, useState } from "react";
5
+ import ConflictsTab from "./ConflictsTab";
6
+ import ErrorsTab from "./ErrorsTab";
7
+ import ResultsTab from "./ResultsTab";
8
+ import NewRecordsTab, { TCreatedRecordsColumnsConfig } from "./NewRecordsTab";
9
+ import ChangesTab from "./ChangesTab";
10
+ import { TDiffResult } from "../Table/useImportExport";
11
+ import { CancelablePromise } from "@boarteam/boar-pack-users-frontend/dist/src/tools/api-client";
12
+
13
+ enum ModalTabs {
14
+ changes = "changes",
15
+ newRecords = "newRecords",
16
+ errors = "errors",
17
+ results = "results",
18
+ conflicts = "conflicts",
19
+ }
20
+
21
+ export type TServerErrorItem = { field: string; message: string };
22
+
23
+ export type TRelationalFields = Map<string, {
24
+ key: string,
25
+ data: {
26
+ [key: string]: any,
27
+ }
28
+ }>
29
+
30
+ export type TImportConflict = {
31
+ id: number;
32
+ version: number;
33
+ fields: Array<{
34
+ field: string;
35
+ current_value: any;
36
+ imported_value: any;
37
+ }>;
38
+ }
39
+
40
+ export type TImportResponse = {
41
+ errors?: Array<TServerErrorItem>,
42
+ conflicts?: Array<TImportConflict>,
43
+ created_count: number,
44
+ updated_count: number
45
+ }
46
+
47
+ const useStyles = createStyles(() => {
48
+ return {
49
+ changesModal: {
50
+ ".ant-modal-content": {
51
+ width: 800,
52
+ },
53
+ ".ant-table-content": {
54
+ overflowX: "auto",
55
+ },
56
+ "ul": {
57
+ maxHeight: 500,
58
+ overflowY: "auto",
59
+ },
60
+ },
61
+ };
62
+ });
63
+
64
+ export function ChangesModal<
65
+ Entity,
66
+ ImportRequestParams,
67
+ >({
68
+ onClose,
69
+ onCommit,
70
+ changes,
71
+ relationalFields,
72
+ originRecordsColumnsConfig,
73
+ changedRecordsColumnsConfig,
74
+ createdRecordsColumnsConfig,
75
+ }: {
76
+ onCommit: (params: ImportRequestParams) => CancelablePromise<TImportResponse>,
77
+ onClose: () => void;
78
+ changes?: TDiffResult<Entity>,
79
+ relationalFields?: TRelationalFields,
80
+ originRecordsColumnsConfig: ProColumns<Entity>[],
81
+ changedRecordsColumnsConfig: ProColumns<Entity>[];
82
+ createdRecordsColumnsConfig: TCreatedRecordsColumnsConfig<Entity>;
83
+ }) {
84
+ const { styles } = useStyles();
85
+ const [activeTab, setActiveTab] = useState<string>(ModalTabs.changes);
86
+ const [isLoading, setIsLoading] = useState<boolean>(false);
87
+ const [importResponse, setImportResponse] = useState<TImportResponse>();
88
+ const [serverErrors, setServerErrors] = useState<TServerErrorItem[]>([]);
89
+ const [resolvedData, setResolvedData] = useState<Entity[]>();
90
+
91
+ useEffect(() => {
92
+ if (serverErrors.length > 0) {
93
+ setActiveTab(ModalTabs.errors);
94
+ }
95
+ }, [serverErrors.length]);
96
+
97
+ if (!changes) return null;
98
+
99
+ const { created, updated, tableData } = changes;
100
+
101
+ const onCancel = () => {
102
+ setActiveTab(ModalTabs.changes);
103
+ setImportResponse(undefined);
104
+ setServerErrors([]);
105
+ setResolvedData([])
106
+ onClose();
107
+ }
108
+
109
+ const handleCommitClick = async () => {
110
+ // TODO: Client validation
111
+ // ...
112
+ setServerErrors([]);
113
+ setIsLoading(true);
114
+
115
+ const modifiedSource = (resolvedData && resolvedData.length > 0)
116
+ ? resolvedData
117
+ : updated;
118
+
119
+ const payload: any = {
120
+ new: created,
121
+ modified: modifiedSource,
122
+ };
123
+
124
+ onCommit(payload).then((res) => {
125
+ setImportResponse(res);
126
+
127
+ // Check conflicts
128
+ if (res.conflicts?.length) {
129
+ setActiveTab(ModalTabs.conflicts);
130
+ message.error("There are conflicts in the import. Please resolve them.");
131
+ return;
132
+ }
133
+
134
+ setActiveTab(ModalTabs.results);
135
+ }).catch((err) => {
136
+ // TODO: Simplify
137
+ const status = err?.status || err?.statusCode || err?.response?.status;
138
+ const payload = err?.body || err?.response?.data || err?.data || err;
139
+
140
+ if ((status === 400 || payload?.statusCode === 400) && Array.isArray(payload?.errors)) {
141
+ setServerErrors(payload.errors);
142
+ message.error(payload.message || "Validation error");
143
+ return;
144
+ }
145
+
146
+ console.error("Commit failed:", err);
147
+ message.error("Unexpected error while committing changes");
148
+ }).finally(() => {
149
+ setIsLoading(false);
150
+ });
151
+ };
152
+
153
+ const tabList = [
154
+ {
155
+ key: ModalTabs.changes,
156
+ tab: "Changed values",
157
+ disabled: tableData.length === 0,
158
+ label: tableData.length ? (
159
+ <Badge
160
+ size="small"
161
+ color="blue"
162
+ count={tableData.length}
163
+ >
164
+ Changed Values
165
+ </Badge>
166
+ ) : "Changed Values",
167
+ children: <ChangesTab
168
+ changedRecordsColumnsConfig={changedRecordsColumnsConfig}
169
+ updated={tableData}
170
+ />,
171
+ },
172
+ {
173
+ key: ModalTabs.newRecords,
174
+ tab: "New records",
175
+ disabled: created.length === 0,
176
+ label: created.length ? (
177
+ <Badge
178
+ size="small"
179
+ color="blue"
180
+ count={created.length}
181
+ >
182
+ New Records
183
+ </Badge>
184
+ ) : "New Records",
185
+ children: <NewRecordsTab
186
+ createdRecordsColumnsConfig={createdRecordsColumnsConfig}
187
+ created={created}
188
+ />,
189
+ },
190
+ {
191
+ key: ModalTabs.errors,
192
+ tab: "Import errors",
193
+ disabled: serverErrors.length === 0,
194
+ label: serverErrors.length ? (
195
+ <Badge size="small" count={serverErrors.length}>
196
+ Errors
197
+ </Badge>
198
+ ) : "Errors",
199
+ children: <ErrorsTab serverErrors={serverErrors} />,
200
+ },
201
+ {
202
+ key: ModalTabs.conflicts,
203
+ tab: "Import conflicts",
204
+ disabled: !importResponse || importResponse.conflicts?.length === 0,
205
+ label: importResponse?.conflicts?.length ? (
206
+ <Badge size="small" count={importResponse.conflicts.length}>
207
+ Conflicts
208
+ </Badge>
209
+ ) : "Conflicts",
210
+ children: <ConflictsTab<Entity>
211
+ conflicts={importResponse?.conflicts}
212
+ setResolvedData={setResolvedData}
213
+ relationalFields={relationalFields}
214
+ originColumns={originRecordsColumnsConfig}
215
+ />,
216
+ },
217
+ {
218
+ key: ModalTabs.results,
219
+ tab: "Import results",
220
+ disabled: !importResponse || !!importResponse.conflicts?.length,
221
+ label: "Results",
222
+ children: <ResultsTab importStatistic={
223
+ {
224
+ created: importResponse?.created_count || 0,
225
+ updated: importResponse?.updated_count || 0,
226
+ }
227
+ } />,
228
+ },
229
+ ];
230
+
231
+ return (
232
+ <Modal
233
+ title="Preview changes"
234
+ open={true}
235
+ onCancel={onCancel}
236
+ footer={[
237
+ <Button key="close" onClick={onCancel}>Close</Button>,
238
+ <Button
239
+ loading={isLoading}
240
+ type="primary"
241
+ key="approve"
242
+ onClick={handleCommitClick}
243
+ disabled={
244
+ importResponse?.conflicts?.length === 0
245
+ || serverErrors.length > 0
246
+ || updated.length === 0 && created.length === 0
247
+ }
248
+ >
249
+ Commit
250
+ </Button>,
251
+ ]}
252
+ className={styles.changesModal}
253
+ >
254
+ <Tabs
255
+ activeKey={activeTab}
256
+ onChange={setActiveTab}
257
+ defaultActiveKey="1"
258
+ items={tabList}
259
+ />
260
+ </Modal>
261
+ )
262
+ }
@@ -0,0 +1,51 @@
1
+ import diff from "deep-diff";
2
+ import { Tag } from "antd";
3
+ import { ProColumns } from "@ant-design/pro-components";
4
+ import { TDiffResult, TUpdatedDiffResult } from "../Table/useImportExport";
5
+ import ProTable from "@ant-design/pro-table";
6
+
7
+ function ChangesTab<Entity> ({ updated, changedRecordsColumnsConfig }: {
8
+ updated: TDiffResult<Entity>['tableData'],
9
+ changedRecordsColumnsConfig: ProColumns<Entity>[]
10
+ }) {
11
+ if (!updated.length) {
12
+ return <>
13
+ No changes found.
14
+ </>
15
+ }
16
+
17
+ const updateColumns = [
18
+ ...changedRecordsColumnsConfig,
19
+ {
20
+ title: "Changes",
21
+ dataIndex: "diff",
22
+ key: "diff",
23
+ render: (diff: diff.Diff<any, any>[]) => (
24
+ <div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
25
+ {diff.map((change, index) => {
26
+ return (
27
+ <div key={index} style={{ display: "flex", alignItems: "center" }}>
28
+ <Tag color="blue">{change.path.join(".")}</Tag>
29
+ {change.lhs ? `${change.lhs.toString()} →` : "- →"} {change.rhs ? change.rhs : change.rhs === false ? 'false' : '-'}
30
+ </div>
31
+ );
32
+ })}
33
+ </div>
34
+ ),
35
+ },
36
+ ];
37
+
38
+ return [
39
+ <h3 key='changes-header'>Changed Values (Local Comparing)</h3>,
40
+ <ProTable<TUpdatedDiffResult>
41
+ key='changes-data'
42
+ dataSource={updated}
43
+ columns={updateColumns}
44
+ rowKey='id'
45
+ search={false}
46
+ toolBarRender={false}
47
+ />
48
+ ]
49
+ }
50
+
51
+ export default ChangesTab;
@@ -0,0 +1,176 @@
1
+ import { Descriptions, TImportConflict } from "@boarteam/boar-pack-common-frontend";
2
+ import { Tag, Tooltip, Button } from "antd";
3
+ import { Dispatch, SetStateAction, useEffect, useState } from "react";
4
+ import { TImportResponse, TRelationalFields } from "./ChangesModal";
5
+ import { keyBy } from "lodash";
6
+ import { createStyles } from "antd-style";
7
+ import { SwapOutlined } from "@ant-design/icons";
8
+ import { ProColumns } from "@ant-design/pro-components";
9
+
10
+ const useStyles = createStyles(() => {
11
+ return {
12
+ conflictsStyle: {
13
+ ".ant-descriptions-row > *:nth-child(1), .ant-descriptions-row > *:nth-child(2)": {
14
+
15
+ },
16
+ ".ant-descriptions-row > *:nth-child(3)": {
17
+
18
+ },
19
+ ".ant-descriptions-row > *:nth-child(4)": {
20
+ backgroundColor: "#f0fff0 !important",
21
+ },
22
+ },
23
+ };
24
+ });
25
+
26
+ const getCurrentKey = (field: string) => {
27
+ return `${field}-current`;
28
+ };
29
+
30
+ const getNormalizedKey = (field: string) => {
31
+ return field.replace('_id', '');
32
+ }
33
+
34
+ function ConflictsTab<Entity>({
35
+ conflicts,
36
+ relationalFields,
37
+ setResolvedData,
38
+ originColumns,
39
+ }: {
40
+ conflicts?: TImportResponse['conflicts'],
41
+ relationalFields?: TRelationalFields;
42
+ setResolvedData?: Dispatch<SetStateAction<Entity[]>>;
43
+ originColumns: ProColumns<Entity>[];
44
+ }) {
45
+
46
+ if (!conflicts || conflicts.length === 0) {
47
+ return <p>No conflicts found.</p>;
48
+ }
49
+
50
+ const getRelationalData = (key: string, value: string) => {
51
+ if (relationalFields?.has(key)) {
52
+ const relation = relationalFields.get(key);
53
+ return relation?.data[value] ?? null;
54
+ }
55
+
56
+ return value;
57
+ };
58
+
59
+ /*Initial value based on received conflicts
60
+ {
61
+ field: 'importing field value',
62
+ field-current: 'current field name from server',
63
+ ...
64
+ }*/
65
+ const [resolvedData, setLocalResolvedData] = useState<Record<string, any>[]>(conflicts.map(conflict => (
66
+ conflict.fields.reduce((acc: Record<string, any>, currentValue) => {
67
+ const key = getNormalizedKey(currentValue.field);
68
+ acc[key] = getRelationalData(key, currentValue.imported_value);
69
+ acc[getCurrentKey(key)] = getRelationalData(key, currentValue.current_value);
70
+
71
+ return acc;
72
+ }, {})
73
+ )));
74
+
75
+ useEffect(() => {
76
+ if (!setResolvedData) return;
77
+
78
+ const payload = resolvedData.map((obj, i) => {
79
+ const data = { ...obj };
80
+ // Remove "current" postfix key from the resolved data
81
+ Object.keys(obj).forEach(key => {
82
+ if (key.endsWith('-current')) {
83
+ delete data[key];
84
+ }
85
+ })
86
+
87
+ return {
88
+ id: conflicts[i].id,
89
+ ...data,
90
+ version: conflicts[i].version,
91
+ }
92
+ });
93
+
94
+ setResolvedData(payload);
95
+ }, [resolvedData, conflicts, setResolvedData]);
96
+
97
+ const { styles } = useStyles();
98
+
99
+ const useCurrentValue = (conflict: TImportConflict, field: string) => {
100
+ const key = getNormalizedKey(field);
101
+
102
+ // Update the resolved data for this conflict
103
+ setLocalResolvedData(prev => {
104
+ const newData = [...prev];
105
+ const index = conflicts.indexOf(conflict);
106
+ newData[index] = {
107
+ ...newData[index],
108
+ [key]: newData[index][getCurrentKey(key)],
109
+ };
110
+ return newData;
111
+ });
112
+ };
113
+
114
+ const keyedOriginColumns = keyBy(originColumns, "dataIndex");
115
+
116
+ return conflicts.map((conflict, idx) => {
117
+ const conflictColumns: ProColumns[] = [];
118
+
119
+ conflict.fields.forEach(field => {
120
+ const key = getNormalizedKey(field.field);
121
+ const originColumn = keyedOriginColumns[key];
122
+ conflictColumns.push({
123
+ ...originColumn,
124
+ dataIndex: getCurrentKey(key),
125
+ editable: false,
126
+ // Should override render to keep -current value
127
+ render: (node) => node
128
+ });
129
+ conflictColumns.push({
130
+ ...originColumn,
131
+ title: (
132
+ <div style={{ textAlign: 'center' }}>
133
+ <Tooltip title="Use value from server">
134
+ <Button
135
+ size="small"
136
+ type="link"
137
+ icon={<SwapOutlined />}
138
+ onClick={() => useCurrentValue(conflict, field.field)}
139
+ />
140
+ </Tooltip>
141
+ </div>
142
+ ),
143
+ });
144
+ });
145
+
146
+ return (
147
+ <div key={idx} style={{ display: "flex", flexDirection: "column", gap: 15, width: "100%" }}>
148
+ <div>
149
+ <Tag color="error">ID {conflict.id}</Tag>
150
+ <Tag color="blue">Server version: v{conflict.version}</Tag>
151
+ </div>
152
+ <table>
153
+ <thead>
154
+ <tr>
155
+ <th style={{ width: '50%' }}>Current (From Server)</th>
156
+ <th style={{ width: '50%' }}>New (Importing)</th>
157
+ </tr>
158
+ </thead>
159
+ </table>
160
+ <Descriptions
161
+ size="small"
162
+ bordered
163
+ entity={resolvedData[idx]}
164
+ columns={conflictColumns}
165
+ column={2}
166
+ canEdit={true}
167
+ mainTitle={null}
168
+ className={styles.conflictsStyle}
169
+ >
170
+ </Descriptions>
171
+ </div>
172
+ );
173
+ });
174
+ }
175
+
176
+ export default ConflictsTab;
@@ -0,0 +1,49 @@
1
+ import { List, Tag } from "antd";
2
+ import { TServerErrorItem } from "./ChangesModal";
3
+
4
+ const formatField = (field: string) => {
5
+ const parts = field.split(".");
6
+ if (parts[0] === "new" && parts.length >= 3) {
7
+ const idx = Number(parts[1]);
8
+ return `New #${isNaN(idx) ? parts[1] : idx + 1}: ${parts.slice(2).join(".")}`;
9
+ }
10
+ if (parts[0] === "updated" && parts.length >= 3) {
11
+ const id = parts[1];
12
+ return `Updated ${id}: ${parts.slice(2).join(".")}`;
13
+ }
14
+ return field;
15
+ };
16
+
17
+ const ErrorsTab = ({
18
+ serverErrors,
19
+ }: {
20
+ serverErrors: TServerErrorItem[],
21
+ }) => {
22
+ if (serverErrors.length === 0) {
23
+ return <div>No errors</div>;
24
+ }
25
+
26
+ return (
27
+ [
28
+ <p key='errors-header'>Please fix the following errors and repeat import</p>,
29
+ <List
30
+ key='errors-list'
31
+ size="small"
32
+ dataSource={serverErrors}
33
+ renderItem={(err, idx) => (
34
+ <List.Item key={idx}>
35
+ <div style={{ display: "flex", flexDirection: "column", gap: 4, width: "100%" }}>
36
+ {err.field && <div>
37
+ <Tag color="error">{formatField(err.field)}</Tag>
38
+ </div>}
39
+ <div>{err.message}</div>
40
+ {err.field && <div style={{ color: "#999" }}>({err.field})</div>}
41
+ </div>
42
+ </List.Item>
43
+ )}
44
+ />,
45
+ ]
46
+ );
47
+ };
48
+
49
+ export default ErrorsTab;
@@ -0,0 +1,48 @@
1
+ import { ProColumns } from "@ant-design/pro-components";
2
+ import { TDiffResult } from "../Table/useImportExport";
3
+ import ProTable from "@ant-design/pro-table";
4
+ import useColumnsSets, { TColumnsSet } from "../Table/useColumnsSets";
5
+
6
+ export type TCreatedRecordsColumnsConfig<Entity> = {
7
+ columnsSets?: TColumnsSet<Entity>[],
8
+ columns: ProColumns<Entity>[],
9
+ }
10
+
11
+ function NewRecordsTab<Entity>({
12
+ created,
13
+ createdRecordsColumnsConfig,
14
+ }: {
15
+ created: TDiffResult<Entity>['created'],
16
+ createdRecordsColumnsConfig: TCreatedRecordsColumnsConfig<Entity>,
17
+ }) {
18
+ const { columns, columnsSets } = createdRecordsColumnsConfig;
19
+
20
+ const {
21
+ columnsSetSelect,
22
+ columnsState,
23
+ } = useColumnsSets<Entity>({
24
+ columns,
25
+ columnsSets,
26
+ });
27
+
28
+ if (!created.length) {
29
+ return <p>No new records found.</p>
30
+ }
31
+
32
+ return [
33
+ <h3 key='new-records-header'>New Records (Local Comparing)</h3>,
34
+ <ProTable<Entity>
35
+ key='new-records-data'
36
+ dataSource={created}
37
+ columns={columns}
38
+ columnsState={columnsState}
39
+ toolBarRender={(...args) => [
40
+ columnsSetSelect?.() || null,
41
+ ]}
42
+ rowKey={(record, index) => index}
43
+ search={false}
44
+ />,
45
+ ];
46
+ }
47
+
48
+ export default NewRecordsTab;
@@ -0,0 +1,28 @@
1
+ import { CheckCircleOutlined, SyncOutlined } from "@ant-design/icons";
2
+
3
+ type TImportStatistic = {
4
+ created: number;
5
+ updated: number;
6
+ }
7
+
8
+ const ResultsTab = ({
9
+ importStatistic
10
+ }: {
11
+ importStatistic: TImportStatistic
12
+ }) => {
13
+ return [
14
+ <h3 key='results-header'>Import Results</h3>,
15
+ <div key='results-data' style={{ display: "flex", gap: "16px", alignItems: "center" }}>
16
+ <div style={{ display: "flex", alignItems: "center" }}>
17
+ <CheckCircleOutlined style={{ color: "#52c41a", marginRight: "8px" }} />
18
+ <span>Created: {importStatistic.created}</span>
19
+ </div>
20
+ <div style={{ display: "flex", alignItems: "center" }}>
21
+ <SyncOutlined style={{ color: "#1890ff", marginRight: "8px" }} />
22
+ <span>Updated: {importStatistic.updated}</span>
23
+ </div>
24
+ </div>
25
+ ]
26
+ }
27
+
28
+ export default ResultsTab;
@@ -0,0 +1 @@
1
+ export * from './ChangesModal';
@@ -1,19 +1,30 @@
1
1
  import { ProFormSelect, ProFormSelectProps } from "@ant-design/pro-components";
2
2
  import { useState } from "react";
3
+ import { useCreation } from "../Table";
4
+ import { Space } from "antd";
3
5
 
4
- type RelationSelectProps<T> = ProFormSelectProps & {
6
+ type RelationSelectProps<T, CreateDto = T> = ProFormSelectProps & {
5
7
  selectedItem: T | null | undefined,
6
8
  onChange?: (type: T | null) => void,
7
9
  filter?: string[],
8
- fetchItems: (filter: string[], keyword?: string) => Promise<{data: T[]}>,
10
+ fetchItems: (filter: string[], keyword?: string) => Promise<{ data: T[] }>,
9
11
  fieldNames?: {
10
12
  value: string,
11
13
  label: string,
12
14
  },
15
+ onCreate?: ({}: { requestBody: CreateDto }) => Promise<T>,
16
+ creationColumns?: any[], // TODO: any specified in the createEntityModal. Need to fix it in the both places
17
+ idColumnName?: string & keyof T | (string & keyof T)[],
18
+ createPopupTitle?: string,
13
19
  };
14
20
 
15
- export const RelationSelect = function<T>({
21
+ export const RelationSelect = function <T, CreateDto = T>({
16
22
  selectedItem,
23
+ onCreate,
24
+ creationColumns,
25
+ // @ts-ignore
26
+ idColumnName = 'id',
27
+ createPopupTitle = 'Add new record',
17
28
  onChange,
18
29
  filter = [],
19
30
  fetchItems,
@@ -22,13 +33,28 @@ export const RelationSelect = function<T>({
22
33
  label: 'name',
23
34
  },
24
35
  ...rest
25
- }: RelationSelectProps<T>) {
36
+ }: RelationSelectProps<T, CreateDto>) {
26
37
  const { value: valueKey, label: labelKey } = fieldNames;
27
38
  const [value, setValue] = useState(selectedItem ? {
28
39
  label: selectedItem[labelKey as keyof T],
29
40
  value: selectedItem[valueKey as keyof T],
30
41
  } : undefined);
31
42
 
43
+ const {
44
+ creationModal,
45
+ createButton,
46
+ } = useCreation<T, CreateDto>({
47
+ onCreate,
48
+ columns: creationColumns,
49
+ popupCreation: !!onCreate,
50
+ createNewDefaultParams: {},
51
+ createButtonSize: 'small',
52
+ pathParams: {},
53
+ entityToCreateDto: (entity: T) => entity as unknown as CreateDto,
54
+ title: createPopupTitle,
55
+ idColumnName
56
+ });
57
+
32
58
  const request = async ({ keyWords: keyword }: { keyWords: string }) => {
33
59
  const reqFilter = [...filter];
34
60
  if (keyword) {
@@ -39,31 +65,44 @@ export const RelationSelect = function<T>({
39
65
  }
40
66
 
41
67
  return (
42
- <ProFormSelect.SearchSelect
43
- showSearch
44
- mode={'single'}
45
- request={request}
46
- className='relational-select'
47
- formItemProps={{
48
- style: {
49
- margin: 0,
50
- display: 'inline-block',
51
- }
52
- }}
53
- style={{ minWidth: 160 }}
54
- placeholder='Please choose'
55
- fieldProps={{
56
- fieldNames: {
57
- value: valueKey,
58
- label: labelKey,
59
- },
60
- value,
61
- onChange(value, row) {
62
- setValue(value);
63
- onChange?.(row ? value : null);
64
- },
65
- }}
66
- {...rest}
67
- />
68
+ <div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
69
+ <ProFormSelect.SearchSelect
70
+ showSearch
71
+ mode={'single'}
72
+ request={request}
73
+ className='relational-select'
74
+ formItemProps={{
75
+ style: {
76
+ margin: 0,
77
+ display: 'inline-block',
78
+ }
79
+ }}
80
+ style={{ minWidth: 160 }}
81
+ placeholder='Please choose'
82
+ fieldProps={{
83
+ fieldNames: {
84
+ value: valueKey,
85
+ label: labelKey,
86
+ },
87
+ value,
88
+ onChange(value, row) {
89
+ setValue(value);
90
+ onChange?.(row ? value : null);
91
+ },
92
+ dropdownRender: (menu) => (
93
+ <>
94
+ {menu}
95
+ {onCreate && (
96
+ <Space style={{ padding: '0 8px 4px', display: 'flex', justifyContent: 'center' }}>
97
+ {createButton}
98
+ </Space>
99
+ )}
100
+ </>
101
+ ),
102
+ }}
103
+ {...rest}
104
+ />
105
+ {creationModal}
106
+ </div>
68
107
  );
69
108
  }
@@ -10,6 +10,8 @@ import { getTableDataQueryParams } from "./getTableDataQueryParams";
10
10
  import { useEditableTable } from "./useEditableTable";
11
11
  import { useBulkEditing } from "./useBulkEditing";
12
12
  import { useImportExport } from "./useImportExport";
13
+ import { ChangesModal } from "../ChangesModal";
14
+ import { ProColumns } from "@ant-design/pro-components";
13
15
 
14
16
  const useStyles = createStyles(() => {
15
17
  return {
@@ -21,12 +23,16 @@ const useStyles = createStyles(() => {
21
23
  }
22
24
  })
23
25
 
24
- const Table = <Entity extends Record<string | symbol, any>,
26
+ const Table = <
27
+ Entity extends Record<string | symbol, any>,
25
28
  CreateDto = Entity,
26
29
  UpdateDto = Entity,
27
30
  TEntityParams = {},
28
31
  TPathParams extends Record<string, string | number> = {},
29
- TKey = string,
32
+ TImportRequest = { // TODO: Add to Table types
33
+ new?: Array<Entity>,
34
+ modified?: Array<Entity>,
35
+ },
30
36
  >(
31
37
  {
32
38
  getAll,
@@ -37,7 +43,6 @@ const Table = <Entity extends Record<string | symbol, any>,
37
43
  onDeleteMany,
38
44
  exportUrl,
39
45
  exportParams,
40
- onImport,
41
46
  pathParams,
42
47
  idColumnName = 'id',
43
48
  entityToCreateDto,
@@ -60,6 +65,7 @@ const Table = <Entity extends Record<string | symbol, any>,
60
65
  editPopupTitle,
61
66
  createPopupTitle,
62
67
  descriptionsMainTitle,
68
+ importConfig,
63
69
  ...rest
64
70
  }: TTableProps<Entity,
65
71
  CreateDto,
@@ -71,6 +77,14 @@ const Table = <Entity extends Record<string | symbol, any>,
71
77
  const actionRef = actionRefProp || actionRefComponent;
72
78
  const [updatePopupData, setUpdatePopupData] = useState<Partial<Entity> | undefined>();
73
79
  const { styles } = useStyles();
80
+ const flatColumns: ProColumns<Entity>[] = [];
81
+ columns.forEach((column) => {
82
+ if (column.children && column.children.length > 0) {
83
+ flatColumns.push(...column.children);
84
+ } else {
85
+ flatColumns.push(column);
86
+ }
87
+ });
74
88
 
75
89
  const {
76
90
  editableConfig,
@@ -122,10 +136,18 @@ const Table = <Entity extends Record<string | symbol, any>,
122
136
  createNewDefaultParams,
123
137
  });
124
138
 
125
- const { exportButton, importButton, setLastQueryParams } = useImportExport<TPathParams>({
139
+ const {
140
+ exportButton,
141
+ importButton,
142
+ setLastQueryParams,
143
+ diffResult,
144
+ setDiffResult,
145
+ } = useImportExport<Entity, TPathParams>({
146
+ columns: flatColumns,
126
147
  exportUrl,
127
148
  exportParams,
128
- onImport,
149
+ changedRecordsColumnsConfig: importConfig?.changedRecordsColumnsConfig,
150
+ relationalFields: importConfig?.relationalFields,
129
151
  })
130
152
 
131
153
  useEffect(() => {
@@ -207,7 +229,7 @@ const Table = <Entity extends Record<string | symbol, any>,
207
229
  ? bulkDeleteButton
208
230
  : null,
209
231
  !viewOnly && createButton || null,
210
- !viewOnly && onImport && importButton || null,
232
+ !viewOnly && importConfig?.onImport && importButton || null,
211
233
  exportUrl && exportButton || null,
212
234
  ...toolBarRender && toolBarRender(...args) || [],
213
235
  ]}
@@ -247,6 +269,24 @@ const Table = <Entity extends Record<string | symbol, any>,
247
269
  entityToUpdateDto={entityToUpdateDto}
248
270
  />
249
271
  </Modal>
272
+ <ChangesModal<
273
+ Entity,
274
+ TImportRequest
275
+ >
276
+ {...(diffResult && { changes: diffResult })}
277
+ onCommit={importConfig?.onImport}
278
+ onClose={() => {
279
+ actionRef.current?.reload();
280
+ setDiffResult(undefined);
281
+ }}
282
+ originRecordsColumnsConfig={flatColumns}
283
+ changedRecordsColumnsConfig={importConfig?.changedRecordsColumnsConfig}
284
+ createdRecordsColumnsConfig={{
285
+ columnsSets,
286
+ columns: importConfig?.createdRecordsColumnsConfig
287
+ }}
288
+ relationalFields={importConfig?.relationalFields}
289
+ />
250
290
  {messagesContext}
251
291
  </>);
252
292
  };
@@ -6,6 +6,8 @@ import { TColumnsSet } from "./useColumnsSets";
6
6
  import { ColumnStateType } from "@ant-design/pro-table/es/typing";
7
7
  import { RowEditableConfig } from "@ant-design/pro-utils";
8
8
  import { ProColumns } from "@ant-design/pro-components";
9
+ import { TImportResponse, TRelationalFields } from "../ChangesModal";
10
+ import { CancelablePromise } from "@boarteam/boar-pack-users-frontend/dist/src/tools/api-client";
9
11
 
10
12
  export type IWithId = {
11
13
  id: string | number,
@@ -103,7 +105,7 @@ interface BaseProps<Entity,
103
105
  descriptionsMainTitle?: ProColumns<Entity>['title'] | null;
104
106
  }
105
107
 
106
- export interface EditableProps<Entity, CreateDto, UpdateDto, TPathParams = {}> {
108
+ export interface EditableProps<Entity, CreateDto, UpdateDto, TPathParams = {}, ImportRequestParams = {}> {
107
109
  actionRef?: MutableRefObject<ActionType | undefined>;
108
110
  editable?: RowEditableConfig<Entity>;
109
111
  afterSave?: (record: Entity) => Promise<void>;
@@ -112,7 +114,6 @@ export interface EditableProps<Entity, CreateDto, UpdateDto, TPathParams = {}> {
112
114
  exportParams?: {
113
115
  [key: string]: string | number
114
116
  };
115
- onImport?: (event: React.ChangeEvent<HTMLInputElement>) => Promise<any>;
116
117
  onUpdate: ({}: Partial<Entity> & {
117
118
  requestBody: UpdateDto,
118
119
  index?: number,
@@ -120,8 +121,16 @@ export interface EditableProps<Entity, CreateDto, UpdateDto, TPathParams = {}> {
120
121
  onDelete: ({}: Partial<Entity> & TPathParams) => Promise<void>;
121
122
  entityToCreateDto: (entity: Entity) => CreateDto;
122
123
  entityToUpdateDto: (entity: Entity) => UpdateDto;
123
- onUpdateMany: ({}: Partial<Entity> & { requestBody: { updateValues: Partial<UpdateDto>[], records: Entity[] } } & TPathParams) => Promise<void>,
124
+ onUpdateMany: ({}: Partial<Entity> & {
125
+ requestBody: { updateValues: Partial<UpdateDto>[], records: Entity[] }
126
+ } & TPathParams) => Promise<void>,
124
127
  onDeleteMany: ({}: Partial<Entity> & { requestBody: { records: Entity[] } } & TPathParams) => Promise<void>,
128
+ importConfig?: {
129
+ onImport?: (params: ImportRequestParams) => CancelablePromise<TImportResponse>;
130
+ relationalFields?: TRelationalFields,
131
+ changedRecordsColumnsConfig: ProColumns<Entity>[],
132
+ createdRecordsColumnsConfig: ProColumns<Entity>[],
133
+ }
125
134
  }
126
135
 
127
136
  // Conditional type to merge base and editable props conditionally
@@ -138,4 +147,4 @@ export type TTableProps<Entity,
138
147
  CreateDto,
139
148
  UpdateDto,
140
149
  TEntityParams = {},
141
- TPathParams = {}> = ConditionalProps<Entity, CreateDto, UpdateDto, TEntityParams, TPathParams>;
150
+ TPathParams = {}> = ConditionalProps<Entity, CreateDto, UpdateDto, TEntityParams, TPathParams>
@@ -1,67 +1,315 @@
1
1
  import { Button, Tooltip } from 'antd';
2
2
  import { DownloadOutlined, UploadOutlined } from "@ant-design/icons";
3
- import { useState } from "react";
3
+ import { ChangeEvent, useCallback, useRef, useState } from "react";
4
4
  import { TGetAllParams } from "./tableTypes";
5
5
  import { Link } from "react-router-dom";
6
+ import * as XLSX from "xlsx";
7
+ import diff from "deep-diff";
8
+ import { ProColumns } from "@ant-design/pro-components";
9
+ import { keyBy } from "lodash";
10
+ import { TRelationalFields } from "../ChangesModal";
6
11
 
7
- export function useImportExport<TPathParams = {}>({
12
+ type TImportedJSON<Entity> = (Entity & { id: string })[]
13
+
14
+ export type TUpdatedDiffResult = {
15
+ id: string | number,
16
+ diff: diff.Diff<any, any>[],
17
+ [key: string]: any,
18
+ }
19
+
20
+ type TUpdatedResult = ({
21
+ id: string | number,
22
+ version: string | number,
23
+ [key: string]: any,
24
+ })
25
+
26
+ export type TDiffResult<Entity> = {
27
+ created: Entity[],
28
+ updated: TUpdatedResult[],
29
+ tableData: TUpdatedDiffResult[],
30
+ }
31
+
32
+ export function useImportExport<Entity, TPathParams = {}>({
8
33
  exportUrl,
9
34
  exportParams,
10
- onImport
35
+ columns,
36
+ changedRecordsColumnsConfig,
37
+ relationalFields,
11
38
  }: {
12
39
  exportUrl?: string;
13
40
  exportParams?: {
14
41
  [key: string]: string | number
15
- }
16
- onImport?: (event: React.ChangeEvent<HTMLInputElement>) => Promise<any>;
42
+ },
43
+ columns: ProColumns<Entity>[],
44
+ changedRecordsColumnsConfig?: ProColumns<Entity>[],
45
+ relationalFields?: TRelationalFields,
17
46
  }) {
18
47
  const [isLoadingImport, setIsLoadingImport] = useState(false);
19
48
  const [lastQueryParams, setLastQueryParams] = useState<TGetAllParams & TPathParams>();
49
+ const keyedColumns = keyBy(columns, 'dataIndex');
20
50
 
21
- const onImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
22
- setIsLoadingImport(true);
23
- await onImport?.(event)
24
- .then((response) => {
25
- console.log(response);
26
- })
27
- .finally(() => {
28
- setIsLoadingImport(false);
29
- });
51
+ const [diffResult, setDiffResult] = useState<TDiffResult<Entity>>();
52
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
53
+
54
+ const openFileDialog = useCallback(() => {
55
+ if (fileInputRef.current) {
56
+ fileInputRef.current.value = '';
57
+ fileInputRef.current.click();
58
+ }
59
+ }, []);
60
+
61
+ const trueBooleanValues = [true, "TRUE", "True", "true", "1", "yes", "on"];
62
+
63
+ const buildExportUrl = useCallback(() => {
64
+ const params = {
65
+ ...(lastQueryParams && {
66
+ s: (lastQueryParams as any).s,
67
+ sort: (lastQueryParams as any).sort?.[0],
68
+ }),
69
+ ...exportParams,
70
+ } as Record<string, string | number | undefined>;
71
+
72
+ const qp = Object.entries(params)
73
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
74
+ .reduce((acc, [k, v]) => {
75
+ acc.append(k, String(v));
76
+ return acc;
77
+ }, new URLSearchParams());
78
+
79
+ return exportUrl
80
+ ? exportUrl + (qp.toString() ? `?${qp.toString()}` : '')
81
+ : undefined;
82
+ }, [exportUrl, exportParams, lastQueryParams]);
83
+
84
+ const fetchExportCsvArrayBuffer = useCallback(async (): Promise<ArrayBuffer> => {
85
+ const url = buildExportUrl();
86
+ if (!url) throw new Error('Specify exportUrl!');
87
+
88
+ const res = await fetch(url, {
89
+ method: 'GET',
90
+ });
91
+
92
+ if (!res.ok) {
93
+ throw new Error(`Can't get actual CSV data for comparing. HTTP ${res.status}`);
94
+ }
95
+ return await res.arrayBuffer();
96
+ }, [buildExportUrl]);
97
+
98
+ const normalizeRow = (row: any) => {
99
+ const normalizedRow = { ...row };
100
+ for (const key in normalizedRow) {
101
+ if (normalizedRow[key] === null) {
102
+ continue;
103
+ }
104
+
105
+ // Relational fields handling
106
+ const relationalKey = key.replace('_id', '');
107
+ if (relationalFields?.has(relationalKey)) {
108
+ const relation = relationalFields.get(relationalKey);
109
+ const id = Number(normalizedRow[key]);
110
+ normalizedRow[relationalKey] = relation.data[id] || null;
111
+ continue;
112
+ }
113
+
114
+ // Boolean values handling
115
+ if (keyedColumns[key]?.valueType === "switch") {
116
+ normalizedRow[key] = trueBooleanValues.includes(normalizedRow[key]);
117
+ continue;
118
+ }
119
+
120
+ // Numeric values handling
121
+ if (keyedColumns[key]?.valueType === "digit") {
122
+ normalizedRow[key] = Number(normalizedRow[key]);
123
+ continue;
124
+ }
125
+
126
+ // Empty values handling
127
+ if (['', 'null'].includes(normalizedRow[key])) {
128
+ normalizedRow[key] = null;
129
+ continue;
130
+ }
131
+
132
+ // Text values handling
133
+ // Text by default if not specified
134
+ if (keyedColumns[key] && keyedColumns[key].valueType === undefined || 'text') {
135
+ normalizedRow[key] = String(normalizedRow[key]);
136
+ }
137
+ }
138
+
139
+ return normalizedRow;
30
140
  }
31
141
 
32
- const params = {
33
- ...(lastQueryParams && {
34
- s: lastQueryParams.s,
35
- sort: lastQueryParams.sort?.[0],
36
- }),
37
- ...exportParams
142
+ const getRelationalValue = (relationField: {
143
+ id: any;
144
+ name?: string;
145
+ description?: string;
146
+ }) => {
147
+ return relationField?.name || relationField?.description || relationField?.id;
38
148
  }
39
149
 
40
- const url = exportUrl + (Object.keys(params).length ? '?' + new URLSearchParams(params).toString() : '');
150
+ const handleFileAsync = async (e: ChangeEvent<HTMLInputElement>) => {
151
+ setIsLoadingImport(true);
152
+
153
+ const fileAfter = e.target.files[0];
154
+
155
+ if (!fileAfter) {
156
+ throw new Error('Choose CSV with changes.');
157
+ }
158
+
159
+ console.time('fetch actual export from api');
160
+
161
+ // File before now should be fetched from the same endpoint as the export button use
162
+ const dataBefore = await fetchExportCsvArrayBuffer();
163
+
164
+ console.timeEnd('fetch actual export from api');
165
+
166
+ const dataAfter = await fileAfter.arrayBuffer();
167
+
168
+ // Data is an ArrayBuffer
169
+ const workbookBefore = XLSX.read(dataBefore);
170
+ const workbookAfter = XLSX.read(dataAfter, {
171
+ type: 'array',
172
+ raw: true,
173
+ });
174
+
175
+ const jsonBefore: TImportedJSON<Entity> = XLSX.utils.sheet_to_json(workbookBefore.Sheets[workbookBefore.SheetNames[0]], {
176
+ defval: null
177
+ });
178
+
179
+ const jsonAfter: TImportedJSON<Entity> = XLSX.utils.sheet_to_json(workbookAfter.Sheets[workbookAfter.SheetNames[0]], {
180
+ defval: null
181
+ });
182
+
183
+ // TODO: Check JSON structure
184
+ // ...
185
+
186
+ const oldMap = Object.fromEntries(
187
+ jsonBefore.map(
188
+ (row) => {
189
+ return [row.id, normalizeRow(row)]
190
+ }
191
+ )
192
+ );
193
+ const newMap: { [key: string]: any } = {};
194
+
195
+ const diffResult: TDiffResult<Entity> = {
196
+ created: [],
197
+ updated: [],
198
+ tableData: [],
199
+ };
200
+
201
+ jsonAfter.map((row) => {
202
+ const normalizedRow = normalizeRow(row);
203
+
204
+ // New record
205
+ if (!normalizedRow.id) {
206
+ diffResult.created.push(normalizedRow);
207
+ return;
208
+ }
209
+
210
+ // Existing record
211
+ newMap[normalizedRow.id] = normalizedRow;
212
+ });
213
+
214
+ // Recognize added and changed records
215
+ for (const id in newMap) {
216
+ if (!oldMap[id]) {
217
+ continue;
218
+ }
219
+
220
+ const differences = diff<any, any>(oldMap[id], newMap[id], {
221
+ normalize: (currentPath, key, lhs, rhs) => {
222
+ // We don't need to compare versions
223
+ if (key === 'version') {
224
+ return [true, true];
225
+ }
226
+
227
+ // We don't care about relational ids. Skip them
228
+ if (key.endsWith('_id')) {
229
+ return [true, true];
230
+ }
231
+
232
+ // If the key is a relational field (dictionary value), we need to compare only value fields
233
+ if (relationalFields && relationalFields.has(key)) {
234
+ return [
235
+ getRelationalValue(lhs),
236
+ getRelationalValue(rhs),
237
+ ]
238
+ }
239
+
240
+ return [lhs, rhs];
241
+ }
242
+ });
243
+
244
+ if (differences) {
245
+ // console.log(differences);
246
+ const changedFields = differences.map((diff) => diff.path?.[0]).filter((field: string | undefined) => field);
247
+ const displayFields = changedRecordsColumnsConfig.map(column => column.dataIndex);
248
+
249
+ const payload: TUpdatedResult = {
250
+ id,
251
+ version: newMap[id].version,
252
+ };
253
+
254
+ const tableData: TUpdatedDiffResult = {
255
+ id,
256
+ diff: differences,
257
+ };
258
+
259
+ columns.forEach((column) => {
260
+ const key = String(column.dataIndex);
261
+ const value = newMap[id][key] === undefined ? newMap[id][key + "_id"] : newMap[id][key];
262
+ if (changedFields.includes(key)) {
263
+ payload[key] = value;
264
+ }
265
+
266
+ if (displayFields.includes(key)) {
267
+ tableData[key] = value;
268
+ }
269
+ })
270
+
271
+ // Will be sent to the server
272
+ diffResult.updated.push(payload);
273
+
274
+ // Will be shown in the "changed values" tab
275
+ diffResult.tableData.push(tableData);
276
+ }
277
+ }
278
+
279
+ // Store this value in the state
280
+ setDiffResult(diffResult);
281
+ setIsLoadingImport(false);
282
+ };
283
+
41
284
  const exportButton = <Tooltip title="Export">
42
- <Link to={url} target={'_blank'}>
43
- <Button icon={<DownloadOutlined />}/>
285
+ <Link to={buildExportUrl() ?? '#'} target={'_blank'}>
286
+ <Button icon={<UploadOutlined />} />
44
287
  </Link>
45
288
  </Tooltip>;
46
289
 
47
290
  const importButton = <>
48
- <Tooltip title="Import">
49
- <label htmlFor="import-input">
50
- <Button loading={isLoadingImport} icon={<UploadOutlined />} />
51
- </label>
291
+ <Tooltip title="Import changes CSV file">
292
+ <Button
293
+ loading={isLoadingImport}
294
+ icon={<DownloadOutlined />}
295
+ onClick={openFileDialog}
296
+ />
52
297
  </Tooltip>
298
+
53
299
  <input
54
300
  type="file"
55
- id="import-input"
56
- style={{ display: "none" }}
57
- accept=".xlsx, .xls"
58
- onChange={onImportChange}
301
+ ref={fileInputRef}
302
+ style={{ display: 'none' }}
303
+ accept=".csv"
304
+ onChange={handleFileAsync}
59
305
  />
60
306
  </>
61
307
 
62
308
  return {
63
309
  exportButton,
64
310
  importButton,
65
- setLastQueryParams
311
+ setLastQueryParams,
312
+ diffResult,
313
+ setDiffResult,
66
314
  };
67
315
  }
@@ -3,3 +3,4 @@ export * from './Inputs';
3
3
  export * from './QuestionMarkHint';
4
4
  export * from './Table';
5
5
  export * from './List';
6
+ export * from './ChangesModal';