@oneuptime/common 11.0.0 → 11.0.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/Tests/Utils/ModelImportExport.test.ts +366 -0
- package/UI/Components/ImportExport/ExportModelCard.tsx +90 -0
- package/UI/Components/ImportExport/ImportModelsModal.tsx +239 -0
- package/UI/Components/ModelTable/ModelTable.tsx +294 -143
- package/UI/Utils/ModelImportExport.ts +207 -0
- package/Utils/ModelImportExport.ts +369 -0
- package/build/dist/UI/Components/ImportExport/ExportModelCard.js +50 -0
- package/build/dist/UI/Components/ImportExport/ExportModelCard.js.map +1 -0
- package/build/dist/UI/Components/ImportExport/ImportModelsModal.js +115 -0
- package/build/dist/UI/Components/ImportExport/ImportModelsModal.js.map +1 -0
- package/build/dist/UI/Components/ModelTable/ModelTable.js +166 -74
- package/build/dist/UI/Components/ModelTable/ModelTable.js.map +1 -1
- package/build/dist/UI/Utils/ModelImportExport.js +142 -0
- package/build/dist/UI/Utils/ModelImportExport.js.map +1 -0
- package/build/dist/Utils/ModelImportExport.js +257 -0
- package/build/dist/Utils/ModelImportExport.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import Dashboard from "../../Models/DatabaseModels/Dashboard";
|
|
2
|
+
import Monitor from "../../Models/DatabaseModels/Monitor";
|
|
3
|
+
import ScheduledMaintenanceTemplate from "../../Models/DatabaseModels/ScheduledMaintenanceTemplate";
|
|
4
|
+
import StatusPage from "../../Models/DatabaseModels/StatusPage";
|
|
5
|
+
import EventInterval from "../../Types/Events/EventInterval";
|
|
6
|
+
import Recurring from "../../Types/Events/Recurring";
|
|
7
|
+
import BadDataException from "../../Types/Exception/BadDataException";
|
|
8
|
+
import { JSONObject } from "../../Types/JSON";
|
|
9
|
+
import ObjectID from "../../Types/ObjectID";
|
|
10
|
+
import PositiveNumber from "../../Types/PositiveNumber";
|
|
11
|
+
import ModelImportExport, {
|
|
12
|
+
MODEL_EXPORT_FILE_TYPE,
|
|
13
|
+
MODEL_EXPORT_SCHEMA_VERSION,
|
|
14
|
+
} from "../../Utils/ModelImportExport";
|
|
15
|
+
import { describe, expect, test } from "@jest/globals";
|
|
16
|
+
|
|
17
|
+
describe("ModelImportExport", () => {
|
|
18
|
+
describe("getImportExportableColumnNames", () => {
|
|
19
|
+
test("should include user-editable configuration columns for Dashboard", () => {
|
|
20
|
+
const columns: Array<string> =
|
|
21
|
+
ModelImportExport.getImportExportableColumnNames(Dashboard);
|
|
22
|
+
|
|
23
|
+
expect(columns).toContain("name");
|
|
24
|
+
expect(columns).toContain("description");
|
|
25
|
+
expect(columns).toContain("dashboardViewConfig");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("should exclude server-controlled and system columns", () => {
|
|
29
|
+
const columns: Array<string> =
|
|
30
|
+
ModelImportExport.getImportExportableColumnNames(Dashboard);
|
|
31
|
+
|
|
32
|
+
expect(columns).not.toContain("_id");
|
|
33
|
+
expect(columns).not.toContain("createdAt");
|
|
34
|
+
expect(columns).not.toContain("updatedAt");
|
|
35
|
+
expect(columns).not.toContain("deletedAt");
|
|
36
|
+
expect(columns).not.toContain("version");
|
|
37
|
+
expect(columns).not.toContain("slug");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("should exclude the tenant column", () => {
|
|
41
|
+
const columns: Array<string> =
|
|
42
|
+
ModelImportExport.getImportExportableColumnNames(Dashboard);
|
|
43
|
+
|
|
44
|
+
expect(columns).not.toContain("projectId");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("should exclude entity relations and their foreign key columns", () => {
|
|
48
|
+
const columns: Array<string> =
|
|
49
|
+
ModelImportExport.getImportExportableColumnNames(Dashboard);
|
|
50
|
+
|
|
51
|
+
expect(columns).not.toContain("labels");
|
|
52
|
+
expect(columns).not.toContain("createdByUser");
|
|
53
|
+
expect(columns).not.toContain("createdByUserId");
|
|
54
|
+
expect(columns).not.toContain("deletedByUser");
|
|
55
|
+
expect(columns).not.toContain("deletedByUserId");
|
|
56
|
+
expect(columns).not.toContain("logoFile");
|
|
57
|
+
expect(columns).not.toContain("logoFileId");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("should include monitor configuration columns", () => {
|
|
61
|
+
const columns: Array<string> =
|
|
62
|
+
ModelImportExport.getImportExportableColumnNames(Monitor);
|
|
63
|
+
|
|
64
|
+
expect(columns).toContain("name");
|
|
65
|
+
expect(columns).toContain("monitorType");
|
|
66
|
+
expect(columns).toContain("monitorSteps");
|
|
67
|
+
expect(columns).not.toContain("currentMonitorStatusId");
|
|
68
|
+
expect(columns).not.toContain("projectId");
|
|
69
|
+
expect(columns).not.toContain("slug");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("should exclude status page foreign keys and relations", () => {
|
|
73
|
+
const columns: Array<string> =
|
|
74
|
+
ModelImportExport.getImportExportableColumnNames(StatusPage);
|
|
75
|
+
|
|
76
|
+
expect(columns).toContain("name");
|
|
77
|
+
expect(columns).not.toContain("projectId");
|
|
78
|
+
expect(columns).not.toContain("labels");
|
|
79
|
+
expect(columns).not.toContain("faviconFileId");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("should exclude plain-text credentials via the per-table exclusion list", () => {
|
|
83
|
+
const columns: Array<string> =
|
|
84
|
+
ModelImportExport.getImportExportableColumnNames(StatusPage);
|
|
85
|
+
|
|
86
|
+
expect(columns).not.toContain("embeddedOverallStatusToken");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("should exclude monitor runtime-state columns", () => {
|
|
90
|
+
const columns: Array<string> =
|
|
91
|
+
ModelImportExport.getImportExportableColumnNames(Monitor);
|
|
92
|
+
|
|
93
|
+
expect(columns).not.toContain("incomingMonitorRequest");
|
|
94
|
+
expect(columns).not.toContain("serverMonitorResponse");
|
|
95
|
+
expect(columns).not.toContain("incomingRequestMonitorHeartbeatCheckedAt");
|
|
96
|
+
expect(columns).not.toContain("serverMonitorRequestReceivedAt");
|
|
97
|
+
expect(columns).not.toContain("telemetryMonitorNextMonitorAt");
|
|
98
|
+
expect(columns).not.toContain("telemetryMonitorLastMonitorAt");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("getImportExportSelect", () => {
|
|
103
|
+
test("should build a select object from exportable columns", () => {
|
|
104
|
+
const select: JSONObject = ModelImportExport.getImportExportSelect(
|
|
105
|
+
Dashboard,
|
|
106
|
+
) as JSONObject;
|
|
107
|
+
|
|
108
|
+
expect(select["name"]).toBe(true);
|
|
109
|
+
expect(select["dashboardViewConfig"]).toBe(true);
|
|
110
|
+
expect(select["_id"]).toBeUndefined();
|
|
111
|
+
expect(select["projectId"]).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("buildExportEnvelope and toExportJSON", () => {
|
|
116
|
+
test("should build a valid envelope and strip non-exportable columns", () => {
|
|
117
|
+
const dashboard: Dashboard = new Dashboard();
|
|
118
|
+
dashboard.name = "My Dashboard";
|
|
119
|
+
dashboard.description = "My Description";
|
|
120
|
+
dashboard._id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
|
121
|
+
dashboard.projectId = new ObjectID(
|
|
122
|
+
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const envelope: JSONObject = ModelImportExport.buildExportEnvelope({
|
|
126
|
+
modelType: Dashboard,
|
|
127
|
+
items: [dashboard],
|
|
128
|
+
exportedAt: new Date("2026-06-11T00:00:00.000Z"),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(envelope["fileType"]).toBe(MODEL_EXPORT_FILE_TYPE);
|
|
132
|
+
expect(envelope["schemaVersion"]).toBe(MODEL_EXPORT_SCHEMA_VERSION);
|
|
133
|
+
expect(envelope["resourceType"]).toBe(new Dashboard().tableName);
|
|
134
|
+
expect(envelope["exportedAt"]).toBe("2026-06-11T00:00:00.000Z");
|
|
135
|
+
|
|
136
|
+
const items: Array<JSONObject> = envelope["items"] as Array<JSONObject>;
|
|
137
|
+
expect(items).toHaveLength(1);
|
|
138
|
+
expect(items[0]!["name"]).toBe("My Dashboard");
|
|
139
|
+
expect(items[0]!["description"]).toBe("My Description");
|
|
140
|
+
expect(items[0]!["_id"]).toBeUndefined();
|
|
141
|
+
expect(items[0]!["projectId"]).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("parseImportPayload", () => {
|
|
146
|
+
test("should accept a valid envelope", () => {
|
|
147
|
+
const items: Array<JSONObject> = ModelImportExport.parseImportPayload({
|
|
148
|
+
modelType: Dashboard,
|
|
149
|
+
payload: {
|
|
150
|
+
fileType: MODEL_EXPORT_FILE_TYPE,
|
|
151
|
+
schemaVersion: MODEL_EXPORT_SCHEMA_VERSION,
|
|
152
|
+
resourceType: new Dashboard().tableName!,
|
|
153
|
+
items: [{ name: "Imported Dashboard" }],
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(items).toHaveLength(1);
|
|
158
|
+
expect(items[0]!["name"]).toBe("Imported Dashboard");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("should accept a plain array of items", () => {
|
|
162
|
+
const items: Array<JSONObject> = ModelImportExport.parseImportPayload({
|
|
163
|
+
modelType: Dashboard,
|
|
164
|
+
payload: [{ name: "One" }, { name: "Two" }],
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(items).toHaveLength(2);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("should accept a single bare item object", () => {
|
|
171
|
+
const items: Array<JSONObject> = ModelImportExport.parseImportPayload({
|
|
172
|
+
modelType: Dashboard,
|
|
173
|
+
payload: { name: "Only One" },
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(items).toHaveLength(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("should reject an envelope for a different resource type", () => {
|
|
180
|
+
expect(() => {
|
|
181
|
+
ModelImportExport.parseImportPayload({
|
|
182
|
+
modelType: Dashboard,
|
|
183
|
+
payload: {
|
|
184
|
+
fileType: MODEL_EXPORT_FILE_TYPE,
|
|
185
|
+
resourceType: new Monitor().tableName!,
|
|
186
|
+
items: [{ name: "A Monitor" }],
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}).toThrow(BadDataException);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("should reject an envelope with no items", () => {
|
|
193
|
+
expect(() => {
|
|
194
|
+
ModelImportExport.parseImportPayload({
|
|
195
|
+
modelType: Dashboard,
|
|
196
|
+
payload: {
|
|
197
|
+
fileType: MODEL_EXPORT_FILE_TYPE,
|
|
198
|
+
resourceType: new Dashboard().tableName!,
|
|
199
|
+
items: [],
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}).toThrow(BadDataException);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("should reject items that are not objects", () => {
|
|
206
|
+
expect(() => {
|
|
207
|
+
ModelImportExport.parseImportPayload({
|
|
208
|
+
modelType: Dashboard,
|
|
209
|
+
payload: ["not-an-object"] as any,
|
|
210
|
+
});
|
|
211
|
+
}).toThrow(BadDataException);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("fromImportJSON", () => {
|
|
216
|
+
test("should build a model with only importable columns", () => {
|
|
217
|
+
const item: Dashboard = ModelImportExport.fromImportJSON({
|
|
218
|
+
json: {
|
|
219
|
+
_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
220
|
+
id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
221
|
+
slug: "my-dashboard",
|
|
222
|
+
projectId: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
|
223
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
224
|
+
name: "My Dashboard",
|
|
225
|
+
description: "My Description",
|
|
226
|
+
dashboardViewConfig: { components: [], heightInDashboardUnits: 60 },
|
|
227
|
+
unknownColumn: "should be dropped",
|
|
228
|
+
},
|
|
229
|
+
modelType: Dashboard,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(item.name).toBe("My Dashboard");
|
|
233
|
+
expect(item.description).toBe("My Description");
|
|
234
|
+
expect(item.dashboardViewConfig).toBeDefined();
|
|
235
|
+
expect(item._id).toBeUndefined();
|
|
236
|
+
expect(item.projectId).toBeUndefined();
|
|
237
|
+
expect(item.slug).toBeUndefined();
|
|
238
|
+
expect(item.createdAt).toBeUndefined();
|
|
239
|
+
expect((item as any)["unknownColumn"]).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("should round-trip an exported dashboard back into a creatable model", () => {
|
|
243
|
+
const original: Dashboard = new Dashboard();
|
|
244
|
+
original.name = "Round Trip";
|
|
245
|
+
original.description = "Round trip description";
|
|
246
|
+
|
|
247
|
+
const envelope: JSONObject = ModelImportExport.buildExportEnvelope({
|
|
248
|
+
modelType: Dashboard,
|
|
249
|
+
items: [original],
|
|
250
|
+
exportedAt: new Date(),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const itemJsons: Array<JSONObject> = ModelImportExport.parseImportPayload(
|
|
254
|
+
{
|
|
255
|
+
modelType: Dashboard,
|
|
256
|
+
payload: envelope,
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const imported: Dashboard = ModelImportExport.fromImportJSON({
|
|
261
|
+
json: itemJsons[0]!,
|
|
262
|
+
modelType: Dashboard,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(imported.name).toBe("Round Trip");
|
|
266
|
+
expect(imported.description).toBe("Round trip description");
|
|
267
|
+
expect(imported._id).toBeUndefined();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("should advance past recurring dates for scheduled maintenance templates", () => {
|
|
271
|
+
const dayInMs: number = 24 * 60 * 60 * 1000;
|
|
272
|
+
|
|
273
|
+
const template: ScheduledMaintenanceTemplate =
|
|
274
|
+
new ScheduledMaintenanceTemplate();
|
|
275
|
+
template.templateName = "Weekly Maintenance";
|
|
276
|
+
template.isRecurringEvent = true;
|
|
277
|
+
|
|
278
|
+
const recurring: Recurring = new Recurring();
|
|
279
|
+
recurring.intervalType = EventInterval.Week;
|
|
280
|
+
recurring.intervalCount = new PositiveNumber(1);
|
|
281
|
+
template.recurringInterval = recurring;
|
|
282
|
+
|
|
283
|
+
const pastScheduledAt: Date = new Date(Date.now() - 30 * dayInMs);
|
|
284
|
+
const pastStartsAt: Date = new Date(Date.now() - 29 * dayInMs);
|
|
285
|
+
const pastEndsAt: Date = new Date(
|
|
286
|
+
pastStartsAt.getTime() + 2 * 60 * 60 * 1000,
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
template.firstEventScheduledAt = pastScheduledAt;
|
|
290
|
+
template.firstEventStartsAt = pastStartsAt;
|
|
291
|
+
template.firstEventEndsAt = pastEndsAt;
|
|
292
|
+
|
|
293
|
+
const envelope: JSONObject = ModelImportExport.buildExportEnvelope({
|
|
294
|
+
modelType: ScheduledMaintenanceTemplate,
|
|
295
|
+
items: [template],
|
|
296
|
+
exportedAt: new Date(),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const itemJsons: Array<JSONObject> = ModelImportExport.parseImportPayload(
|
|
300
|
+
{
|
|
301
|
+
modelType: ScheduledMaintenanceTemplate,
|
|
302
|
+
payload: envelope,
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const imported: ScheduledMaintenanceTemplate =
|
|
307
|
+
ModelImportExport.fromImportJSON({
|
|
308
|
+
json: itemJsons[0]!,
|
|
309
|
+
modelType: ScheduledMaintenanceTemplate,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(imported.firstEventScheduledAt!.getTime()).toBeGreaterThan(
|
|
313
|
+
Date.now(),
|
|
314
|
+
);
|
|
315
|
+
expect(imported.firstEventStartsAt!.getTime()).toBeGreaterThan(
|
|
316
|
+
Date.now(),
|
|
317
|
+
);
|
|
318
|
+
expect(imported.firstEventEndsAt!.getTime()).toBeGreaterThan(Date.now());
|
|
319
|
+
|
|
320
|
+
// offsets between the dates are preserved.
|
|
321
|
+
expect(
|
|
322
|
+
imported.firstEventStartsAt!.getTime() -
|
|
323
|
+
imported.firstEventScheduledAt!.getTime(),
|
|
324
|
+
).toBe(pastStartsAt.getTime() - pastScheduledAt.getTime());
|
|
325
|
+
expect(
|
|
326
|
+
imported.firstEventEndsAt!.getTime() -
|
|
327
|
+
imported.firstEventStartsAt!.getTime(),
|
|
328
|
+
).toBe(pastEndsAt.getTime() - pastStartsAt.getTime());
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("should leave future recurring dates unchanged", () => {
|
|
332
|
+
const dayInMs: number = 24 * 60 * 60 * 1000;
|
|
333
|
+
|
|
334
|
+
const template: ScheduledMaintenanceTemplate =
|
|
335
|
+
new ScheduledMaintenanceTemplate();
|
|
336
|
+
template.templateName = "Future Maintenance";
|
|
337
|
+
template.isRecurringEvent = true;
|
|
338
|
+
|
|
339
|
+
const recurring: Recurring = new Recurring();
|
|
340
|
+
recurring.intervalType = EventInterval.Day;
|
|
341
|
+
recurring.intervalCount = new PositiveNumber(1);
|
|
342
|
+
template.recurringInterval = recurring;
|
|
343
|
+
|
|
344
|
+
const futureScheduledAt: Date = new Date(Date.now() + 10 * dayInMs);
|
|
345
|
+
template.firstEventScheduledAt = futureScheduledAt;
|
|
346
|
+
template.firstEventStartsAt = new Date(Date.now() + 11 * dayInMs);
|
|
347
|
+
template.firstEventEndsAt = new Date(Date.now() + 12 * dayInMs);
|
|
348
|
+
|
|
349
|
+
const envelope: JSONObject = ModelImportExport.buildExportEnvelope({
|
|
350
|
+
modelType: ScheduledMaintenanceTemplate,
|
|
351
|
+
items: [template],
|
|
352
|
+
exportedAt: new Date(),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const imported: ScheduledMaintenanceTemplate =
|
|
356
|
+
ModelImportExport.fromImportJSON({
|
|
357
|
+
json: (envelope["items"] as Array<JSONObject>)[0]!,
|
|
358
|
+
modelType: ScheduledMaintenanceTemplate,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
expect(imported.firstEventScheduledAt!.getTime()).toBe(
|
|
362
|
+
futureScheduledAt.getTime(),
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
|
|
2
|
+
import IconProp from "../../../Types/Icon/IconProp";
|
|
3
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
4
|
+
import API from "../../Utils/API/API";
|
|
5
|
+
import ModelAPI from "../../Utils/ModelAPI/ModelAPI";
|
|
6
|
+
import ModelImportExportUtil from "../../Utils/ModelImportExport";
|
|
7
|
+
import { ButtonStyleType } from "../Button/Button";
|
|
8
|
+
import Card from "../Card/Card";
|
|
9
|
+
import ConfirmModal from "../Modal/ConfirmModal";
|
|
10
|
+
import React, { ReactElement, useState } from "react";
|
|
11
|
+
|
|
12
|
+
export interface ComponentProps<TBaseModel extends BaseModel> {
|
|
13
|
+
modelType: { new (): TBaseModel };
|
|
14
|
+
modelId: ObjectID;
|
|
15
|
+
modelAPI?: typeof ModelAPI | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ExportModelCard: <TBaseModel extends BaseModel>(
|
|
19
|
+
props: ComponentProps<TBaseModel>,
|
|
20
|
+
) => ReactElement = <TBaseModel extends BaseModel>(
|
|
21
|
+
props: ComponentProps<TBaseModel>,
|
|
22
|
+
): ReactElement => {
|
|
23
|
+
const model: TBaseModel = new props.modelType();
|
|
24
|
+
const singularName: string = model.singularName || "Resource";
|
|
25
|
+
|
|
26
|
+
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
27
|
+
const [error, setError] = useState<string>("");
|
|
28
|
+
|
|
29
|
+
type ExportItemFunction = () => Promise<void>;
|
|
30
|
+
|
|
31
|
+
const exportItem: ExportItemFunction = async (): Promise<void> => {
|
|
32
|
+
setIsLoading(true);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const item: TBaseModel =
|
|
36
|
+
await ModelImportExportUtil.fetchItemForExport<TBaseModel>({
|
|
37
|
+
modelType: props.modelType,
|
|
38
|
+
modelId: props.modelId,
|
|
39
|
+
modelAPI: props.modelAPI,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
ModelImportExportUtil.downloadExportFile({
|
|
43
|
+
modelType: props.modelType,
|
|
44
|
+
items: [item],
|
|
45
|
+
});
|
|
46
|
+
} catch (err) {
|
|
47
|
+
setError(API.getFriendlyMessage(err));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
setIsLoading(false);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
<Card
|
|
56
|
+
title={`Export ${singularName} as JSON`}
|
|
57
|
+
description={`Download this ${singularName.toLowerCase()} as a JSON file. You can import it later to re-create this ${singularName.toLowerCase()}. Only this ${singularName.toLowerCase()}'s own settings are included - related resources (like owners, labels, or other linked resources) are not exported, and references to resources from this project may need to be re-selected after importing into another project.`}
|
|
58
|
+
buttons={[
|
|
59
|
+
{
|
|
60
|
+
title: `Export ${singularName}`,
|
|
61
|
+
buttonStyle: ButtonStyleType.NORMAL,
|
|
62
|
+
onClick: () => {
|
|
63
|
+
exportItem().catch((err: Error) => {
|
|
64
|
+
setError(API.getFriendlyMessage(err));
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
isLoading: isLoading,
|
|
68
|
+
icon: IconProp.Download,
|
|
69
|
+
},
|
|
70
|
+
]}
|
|
71
|
+
/>
|
|
72
|
+
|
|
73
|
+
{error ? (
|
|
74
|
+
<ConfirmModal
|
|
75
|
+
description={error}
|
|
76
|
+
title={`Export Error`}
|
|
77
|
+
onSubmit={() => {
|
|
78
|
+
setError("");
|
|
79
|
+
}}
|
|
80
|
+
submitButtonText={`Close`}
|
|
81
|
+
submitButtonType={ButtonStyleType.NORMAL}
|
|
82
|
+
/>
|
|
83
|
+
) : (
|
|
84
|
+
<></>
|
|
85
|
+
)}
|
|
86
|
+
</>
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export default ExportModelCard;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
|
|
2
|
+
import CodeType from "../../../Types/Code/CodeType";
|
|
3
|
+
import { JSONObject } from "../../../Types/JSON";
|
|
4
|
+
import API from "../../Utils/API/API";
|
|
5
|
+
import ModelAPI from "../../Utils/ModelAPI/ModelAPI";
|
|
6
|
+
import ModelImportExportUtil, {
|
|
7
|
+
ImportFailure,
|
|
8
|
+
ImportResult,
|
|
9
|
+
} from "../../Utils/ModelImportExport";
|
|
10
|
+
import Alert, { AlertType } from "../Alerts/Alert";
|
|
11
|
+
import { ButtonStyleType } from "../Button/Button";
|
|
12
|
+
import CodeEditor from "../CodeEditor/CodeEditor";
|
|
13
|
+
import Modal, { ModalWidth } from "../Modal/Modal";
|
|
14
|
+
import ProgressBar from "../ProgressBar/ProgressBar";
|
|
15
|
+
import React, { ReactElement, useState } from "react";
|
|
16
|
+
|
|
17
|
+
export interface ComponentProps<TBaseModel extends BaseModel> {
|
|
18
|
+
modelType: { new (): TBaseModel };
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
onImportComplete: (result: ImportResult) => void;
|
|
21
|
+
modelAPI?: typeof ModelAPI | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
enum ImportPhase {
|
|
25
|
+
Edit = "Edit",
|
|
26
|
+
Importing = "Importing",
|
|
27
|
+
Done = "Done",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ImportModelsModal: <TBaseModel extends BaseModel>(
|
|
31
|
+
props: ComponentProps<TBaseModel>,
|
|
32
|
+
) => ReactElement = <TBaseModel extends BaseModel>(
|
|
33
|
+
props: ComponentProps<TBaseModel>,
|
|
34
|
+
): ReactElement => {
|
|
35
|
+
const model: TBaseModel = new props.modelType();
|
|
36
|
+
const singularName: string = model.singularName || "Resource";
|
|
37
|
+
const pluralName: string = model.pluralName || "Resources";
|
|
38
|
+
|
|
39
|
+
const [phase, setPhase] = useState<ImportPhase>(ImportPhase.Edit);
|
|
40
|
+
const [fileText, setFileText] = useState<string>("");
|
|
41
|
+
const [error, setError] = useState<string>("");
|
|
42
|
+
const [completedCount, setCompletedCount] = useState<number>(0);
|
|
43
|
+
const [totalCount, setTotalCount] = useState<number>(0);
|
|
44
|
+
const [result, setResult] = useState<ImportResult | null>(null);
|
|
45
|
+
|
|
46
|
+
type OnFileSelectedFunction = (
|
|
47
|
+
event: React.ChangeEvent<HTMLInputElement>,
|
|
48
|
+
) => void;
|
|
49
|
+
|
|
50
|
+
const onFileSelected: OnFileSelectedFunction = (
|
|
51
|
+
event: React.ChangeEvent<HTMLInputElement>,
|
|
52
|
+
): void => {
|
|
53
|
+
const file: File | undefined = event.target.files?.[0];
|
|
54
|
+
|
|
55
|
+
if (!file) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const reader: FileReader = new FileReader();
|
|
60
|
+
|
|
61
|
+
reader.onload = (): void => {
|
|
62
|
+
setFileText((reader.result as string) || "");
|
|
63
|
+
setError("");
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
reader.onerror = (): void => {
|
|
67
|
+
setError("Could not read the selected file. Please try again.");
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
reader.readAsText(file);
|
|
71
|
+
|
|
72
|
+
// allow re-selecting the same file after editing.
|
|
73
|
+
event.target.value = "";
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type StartImportFunction = () => Promise<void>;
|
|
77
|
+
|
|
78
|
+
const startImport: StartImportFunction = async (): Promise<void> => {
|
|
79
|
+
setError("");
|
|
80
|
+
|
|
81
|
+
let itemJsons: Array<JSONObject> = [];
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
itemJsons = ModelImportExportUtil.parseImportFileText({
|
|
85
|
+
modelType: props.modelType,
|
|
86
|
+
fileText: fileText,
|
|
87
|
+
});
|
|
88
|
+
} catch (err) {
|
|
89
|
+
setError(API.getFriendlyMessage(err));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
setPhase(ImportPhase.Importing);
|
|
94
|
+
setCompletedCount(0);
|
|
95
|
+
setTotalCount(itemJsons.length);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const importResult: ImportResult =
|
|
99
|
+
await ModelImportExportUtil.importItems({
|
|
100
|
+
modelType: props.modelType,
|
|
101
|
+
itemJsons: itemJsons,
|
|
102
|
+
modelAPI: props.modelAPI,
|
|
103
|
+
onProgress: (completed: number, total: number) => {
|
|
104
|
+
setCompletedCount(completed);
|
|
105
|
+
setTotalCount(total);
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
setResult(importResult);
|
|
110
|
+
setPhase(ImportPhase.Done);
|
|
111
|
+
props.onImportComplete(importResult);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// never trap the user in the importing phase - return to the editor.
|
|
114
|
+
setError(API.getFriendlyMessage(err));
|
|
115
|
+
setPhase(ImportPhase.Edit);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (phase === ImportPhase.Importing) {
|
|
120
|
+
return (
|
|
121
|
+
<Modal
|
|
122
|
+
title={`Importing ${pluralName}`}
|
|
123
|
+
description={`Please wait while your ${pluralName.toLowerCase()} are being imported.`}
|
|
124
|
+
isBodyLoading={false}
|
|
125
|
+
onSubmit={() => {}}
|
|
126
|
+
disableSubmitButton={true}
|
|
127
|
+
submitButtonText={"Importing..."}
|
|
128
|
+
>
|
|
129
|
+
<div className="mt-5 mb-5">
|
|
130
|
+
<ProgressBar
|
|
131
|
+
count={completedCount}
|
|
132
|
+
totalCount={totalCount}
|
|
133
|
+
suffix={pluralName}
|
|
134
|
+
/>
|
|
135
|
+
</div>
|
|
136
|
+
</Modal>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (phase === ImportPhase.Done && result) {
|
|
141
|
+
return (
|
|
142
|
+
<Modal
|
|
143
|
+
title={`Import Complete`}
|
|
144
|
+
onSubmit={() => {
|
|
145
|
+
props.onClose();
|
|
146
|
+
}}
|
|
147
|
+
submitButtonText={"Close"}
|
|
148
|
+
submitButtonStyleType={ButtonStyleType.NORMAL}
|
|
149
|
+
>
|
|
150
|
+
<div className="mt-5 mb-5 space-y-3">
|
|
151
|
+
{result.successCount > 0 ? (
|
|
152
|
+
<Alert
|
|
153
|
+
type={AlertType.SUCCESS}
|
|
154
|
+
strongTitle={`${result.successCount} ${
|
|
155
|
+
result.successCount === 1 ? singularName : pluralName
|
|
156
|
+
} imported successfully.`}
|
|
157
|
+
/>
|
|
158
|
+
) : (
|
|
159
|
+
<></>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{result.failures.length > 0 ? (
|
|
163
|
+
<div>
|
|
164
|
+
<Alert
|
|
165
|
+
type={AlertType.DANGER}
|
|
166
|
+
strongTitle={`${result.failures.length} ${
|
|
167
|
+
result.failures.length === 1 ? singularName : pluralName
|
|
168
|
+
} could not be imported.`}
|
|
169
|
+
/>
|
|
170
|
+
<ul className="mt-3 list-disc pl-5 text-sm text-gray-600">
|
|
171
|
+
{result.failures.map(
|
|
172
|
+
(failure: ImportFailure, index: number) => {
|
|
173
|
+
return (
|
|
174
|
+
<li key={index} className="mt-1">
|
|
175
|
+
<span className="font-medium">{failure.itemName}</span>:{" "}
|
|
176
|
+
{failure.errorMessage}
|
|
177
|
+
</li>
|
|
178
|
+
);
|
|
179
|
+
},
|
|
180
|
+
)}
|
|
181
|
+
</ul>
|
|
182
|
+
</div>
|
|
183
|
+
) : (
|
|
184
|
+
<></>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
</Modal>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<Modal
|
|
193
|
+
title={`Import ${pluralName}`}
|
|
194
|
+
description={`Upload a ${singularName.toLowerCase()} JSON export file, or paste its contents below. New ${pluralName.toLowerCase()} will be created in this project. Related resources (like owners, labels, or other linked resources) are not part of export files, and references to resources from another project may need to be re-selected after import.`}
|
|
195
|
+
modalWidth={ModalWidth.Large}
|
|
196
|
+
onClose={props.onClose}
|
|
197
|
+
onSubmit={async () => {
|
|
198
|
+
await startImport();
|
|
199
|
+
}}
|
|
200
|
+
disableSubmitButton={!fileText.trim()}
|
|
201
|
+
submitButtonText={`Import`}
|
|
202
|
+
error={error || undefined}
|
|
203
|
+
>
|
|
204
|
+
<div className="mt-5 mb-5">
|
|
205
|
+
<label
|
|
206
|
+
htmlFor="import-file-input"
|
|
207
|
+
className="block text-sm font-medium text-gray-700"
|
|
208
|
+
>
|
|
209
|
+
Select export file
|
|
210
|
+
</label>
|
|
211
|
+
<input
|
|
212
|
+
id="import-file-input"
|
|
213
|
+
data-testid="import-file-input"
|
|
214
|
+
type="file"
|
|
215
|
+
accept=".json,application/json"
|
|
216
|
+
onChange={onFileSelected}
|
|
217
|
+
className="mt-2 block w-full text-sm text-gray-600 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:py-2 file:px-4 file:text-sm file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100"
|
|
218
|
+
/>
|
|
219
|
+
|
|
220
|
+
<div className="mt-4">
|
|
221
|
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
222
|
+
Or paste the export JSON
|
|
223
|
+
</label>
|
|
224
|
+
<CodeEditor
|
|
225
|
+
type={CodeType.JSON}
|
|
226
|
+
value={fileText}
|
|
227
|
+
onChange={(value: string) => {
|
|
228
|
+
setFileText(value);
|
|
229
|
+
setError("");
|
|
230
|
+
}}
|
|
231
|
+
placeholder={`Paste your ${singularName.toLowerCase()} export JSON here.`}
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
</Modal>
|
|
236
|
+
);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
export default ImportModelsModal;
|