@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 +6 -3
- package/src/components/ChangesModal/ChangesModal.tsx +262 -0
- package/src/components/ChangesModal/ChangesTab.tsx +51 -0
- package/src/components/ChangesModal/ConflictsTab.tsx +176 -0
- package/src/components/ChangesModal/ErrorsTab.tsx +49 -0
- package/src/components/ChangesModal/NewRecordsTab.tsx +48 -0
- package/src/components/ChangesModal/ResultsTab.tsx +28 -0
- package/src/components/ChangesModal/index.ts +1 -0
- package/src/components/Inputs/RelationSelect.tsx +69 -30
- package/src/components/Table/Table.tsx +46 -6
- package/src/components/Table/tableTypes.ts +13 -4
- package/src/components/Table/useImportExport.tsx +280 -32
- package/src/components/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boarteam/boar-pack-common-frontend",
|
|
3
|
-
"version": "3.
|
|
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": "
|
|
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
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 = <
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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> & {
|
|
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
|
-
|
|
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
|
-
|
|
35
|
+
columns,
|
|
36
|
+
changedRecordsColumnsConfig,
|
|
37
|
+
relationalFields,
|
|
11
38
|
}: {
|
|
12
39
|
exportUrl?: string;
|
|
13
40
|
exportParams?: {
|
|
14
41
|
[key: string]: string | number
|
|
15
|
-
}
|
|
16
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
.
|
|
28
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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={
|
|
43
|
-
<Button icon={<
|
|
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
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
style={{ display:
|
|
57
|
-
accept=".
|
|
58
|
-
onChange={
|
|
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
|
}
|
package/src/components/index.ts
CHANGED