@seedgrid/fe-backup 2026.7.0

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.
Files changed (44) hide show
  1. package/README.md +86 -0
  2. package/dist/backup.d.ts +47 -0
  3. package/dist/backup.d.ts.map +1 -0
  4. package/dist/backup.js +3 -0
  5. package/dist/export-status.d.ts +6 -0
  6. package/dist/export-status.d.ts.map +1 -0
  7. package/dist/export-status.js +28 -0
  8. package/dist/index.d.ts +5 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +6 -0
  11. package/dist/messages.d.ts +308 -0
  12. package/dist/messages.d.ts.map +1 -0
  13. package/dist/messages.js +202 -0
  14. package/dist/module.d.ts +3 -0
  15. package/dist/module.d.ts.map +1 -0
  16. package/dist/module.js +23 -0
  17. package/dist/module.json +15 -0
  18. package/dist/paths.d.ts +9 -0
  19. package/dist/paths.d.ts.map +1 -0
  20. package/dist/paths.js +11 -0
  21. package/dist/server/create-backup-server.d.ts +19 -0
  22. package/dist/server/create-backup-server.d.ts.map +1 -0
  23. package/dist/server/create-backup-server.js +19 -0
  24. package/dist/server/index.d.ts +2 -0
  25. package/dist/server/index.d.ts.map +1 -0
  26. package/dist/server/index.js +2 -0
  27. package/package.json +43 -0
  28. package/scaffold/README.md +69 -0
  29. package/scaffold/src/app/api/backup/exports/[jobId]/download/route.ts +87 -0
  30. package/scaffold/src/app/backup/exports/(create)/new/page.test.tsx +182 -0
  31. package/scaffold/src/app/backup/exports/(create)/new/page.tsx +168 -0
  32. package/scaffold/src/app/backup/exports/(list)/page.test.tsx +166 -0
  33. package/scaffold/src/app/backup/exports/(list)/page.tsx +286 -0
  34. package/scaffold/src/modules/backup/components/ExportStatusBadge.test.tsx +75 -0
  35. package/scaffold/src/modules/backup/components/ExportStatusBadge.tsx +54 -0
  36. package/scaffold/src/modules/backup/config.ts +27 -0
  37. package/scaffold/src/modules/backup/index.ts +19 -0
  38. package/scaffold/src/modules/backup/services/backup-api.test.ts +122 -0
  39. package/scaffold/src/modules/backup/services/backup-api.ts +205 -0
  40. package/scaffold/src/modules/backup/services/backup-service.test.ts +125 -0
  41. package/scaffold/src/modules/backup/services/backup-service.ts +34 -0
  42. package/scaffold/src/modules/backup/types.ts +17 -0
  43. package/scaffold/src/modules/backup/useBackupAppShellSections.test.tsx +61 -0
  44. package/scaffold/src/modules/backup/useBackupAppShellSections.tsx +50 -0
@@ -0,0 +1,182 @@
1
+ // seedgrid:managed
2
+
3
+ import { render, screen, waitFor } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import { backupService } from "@/modules/backup/services/backup-service";
8
+
9
+ vi.mock("next/navigation", () => ({
10
+ useRouter: () => ({ push: vi.fn() }),
11
+ }));
12
+
13
+ vi.mock("@/modules/backup/services/backup-service", () => ({
14
+ backupService: {
15
+ createExportJob: vi.fn(),
16
+ },
17
+ }));
18
+
19
+ vi.mock("@/modules/security", () => ({
20
+ RouteGuard: ({ children }: { children: React.ReactNode }) => <>{children}</>,
21
+ PageFrame: ({ children, actions }: { children: React.ReactNode; actions?: React.ReactNode }) => (
22
+ <div>
23
+ {actions}
24
+ {children}
25
+ </div>
26
+ ),
27
+ }));
28
+
29
+ vi.mock("@/i18n", () => ({
30
+ useI18n: () => ({ t: (key: string) => key }),
31
+ }));
32
+
33
+ vi.mock("@seedgrid/fe-components", () => ({
34
+ SgButton: ({ label, onClick, loading, disabled }: any) => (
35
+ <button onClick={onClick} disabled={disabled || loading}>
36
+ {label}
37
+ </button>
38
+ ),
39
+ SgRadioGroup: ({ title, source, value, onChange }: any) => (
40
+ <fieldset>
41
+ <legend>{title}</legend>
42
+ {source.map((opt: any) => (
43
+ <label key={opt.value}>
44
+ <input
45
+ type="radio"
46
+ name={title}
47
+ value={opt.value}
48
+ checked={value === opt.value}
49
+ onChange={() => onChange(opt.value)}
50
+ />
51
+ {opt.label}
52
+ </label>
53
+ ))}
54
+ </fieldset>
55
+ ),
56
+ SgStack: ({ children }: any) => <div>{children}</div>,
57
+ SgToggleSwitch: ({ id, label, checked, onChange }: any) => (
58
+ <label htmlFor={id}>
59
+ <input
60
+ id={id}
61
+ type="checkbox"
62
+ checked={checked}
63
+ onChange={(e) => onChange(e.target.checked)}
64
+ />
65
+ {label}
66
+ </label>
67
+ ),
68
+ sgWhistle: { success: vi.fn(), error: vi.fn() },
69
+ }));
70
+
71
+ const mockCreateExportJob = vi.mocked(backupService.createExportJob);
72
+
73
+ describe("NewExportPage", () => {
74
+ beforeEach(() => vi.clearAllMocks());
75
+
76
+ it("renders format and scope radio groups", async () => {
77
+ const { default: Page } = await import("./page");
78
+ render(<Page />);
79
+
80
+ expect(
81
+ screen.getByText("backup.exports.create.format_label")
82
+ ).toBeInTheDocument();
83
+ expect(
84
+ screen.getByText("backup.exports.create.scope_label")
85
+ ).toBeInTheDocument();
86
+ });
87
+
88
+ it("submits with CSV_ZIP and DOMAIN by default", async () => {
89
+ mockCreateExportJob.mockResolvedValue({
90
+ jobId: "new-job",
91
+ status: "REQUESTED",
92
+ format: "CSV_ZIP",
93
+ scope: "DOMAIN",
94
+ createdAt: "2026-01-01T00:00:00Z",
95
+ });
96
+
97
+ const { default: Page } = await import("./page");
98
+ render(<Page />);
99
+
100
+ const submitBtn = screen.getByText("backup.exports.create.submit");
101
+ await userEvent.click(submitBtn);
102
+
103
+ await waitFor(() =>
104
+ expect(mockCreateExportJob).toHaveBeenCalledWith(
105
+ expect.objectContaining({ format: "CSV_ZIP", scope: "DOMAIN" })
106
+ )
107
+ );
108
+ });
109
+
110
+ it("submits with SQL_DUMP_ZIP when that format is selected", async () => {
111
+ mockCreateExportJob.mockResolvedValue({
112
+ jobId: "new-job",
113
+ status: "REQUESTED",
114
+ format: "SQL_DUMP_ZIP",
115
+ scope: "DOMAIN",
116
+ createdAt: "2026-01-01T00:00:00Z",
117
+ });
118
+
119
+ const { default: Page } = await import("./page");
120
+ render(<Page />);
121
+
122
+ const sqlDumpRadio = screen.getByDisplayValue("SQL_DUMP_ZIP");
123
+ await userEvent.click(sqlDumpRadio);
124
+
125
+ const submitBtn = screen.getByText("backup.exports.create.submit");
126
+ await userEvent.click(submitBtn);
127
+
128
+ await waitFor(() =>
129
+ expect(mockCreateExportJob).toHaveBeenCalledWith(
130
+ expect.objectContaining({ format: "SQL_DUMP_ZIP" })
131
+ )
132
+ );
133
+ });
134
+
135
+ it("includes attachments when the toggle is checked", async () => {
136
+ mockCreateExportJob.mockResolvedValue({
137
+ jobId: "new-job",
138
+ status: "REQUESTED",
139
+ format: "CSV_ZIP",
140
+ scope: "DOMAIN",
141
+ createdAt: "2026-01-01T00:00:00Z",
142
+ });
143
+
144
+ const { default: Page } = await import("./page");
145
+ render(<Page />);
146
+
147
+ const toggle = screen.getByRole("checkbox");
148
+ await userEvent.click(toggle);
149
+
150
+ const submitBtn = screen.getByText("backup.exports.create.submit");
151
+ await userEvent.click(submitBtn);
152
+
153
+ await waitFor(() =>
154
+ expect(mockCreateExportJob).toHaveBeenCalledWith(
155
+ expect.objectContaining({ includeAttachments: true })
156
+ )
157
+ );
158
+ });
159
+
160
+ it("disables the submit button while submitting", async () => {
161
+ let resolveCreate!: () => void;
162
+ mockCreateExportJob.mockReturnValue(
163
+ new Promise((res) => {
164
+ resolveCreate = () => res({
165
+ jobId: "x", status: "REQUESTED", format: "CSV_ZIP", scope: "DOMAIN",
166
+ createdAt: "2026-01-01T00:00:00Z",
167
+ });
168
+ })
169
+ );
170
+
171
+ const { default: Page } = await import("./page");
172
+ render(<Page />);
173
+
174
+ const submitBtn = screen.getByText("backup.exports.create.submit");
175
+ await userEvent.click(submitBtn);
176
+
177
+ expect(submitBtn).toBeDisabled();
178
+
179
+ resolveCreate();
180
+ await waitFor(() => expect(submitBtn).not.toBeDisabled());
181
+ });
182
+ });
@@ -0,0 +1,168 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+
5
+ import {
6
+ SgButton,
7
+ SgRadioGroup,
8
+ SgStack,
9
+ SgToggleSwitch,
10
+ sgWhistle,
11
+ } from "@seedgrid/fe-components";
12
+ import { useRouter } from "next/navigation";
13
+
14
+ import { useI18n } from "@/i18n";
15
+ import { PageFrame, RouteGuard } from "@/modules/security";
16
+
17
+ import { backupConfig } from "@/modules/backup/config";
18
+ import { extractBackupApiMessage } from "@/modules/backup/services/backup-api";
19
+ import { backupService } from "@/modules/backup/services/backup-service";
20
+ import type { BackupFormat, ExportRequest, ExportScope } from "@/modules/backup/types";
21
+
22
+ // `as const` (em vez de `: Array<{ labelKey: string }>`): mantem labelKey/hintKey
23
+ // como literais, que sao chaves validas de i18n — t() so aceita a uniao de
24
+ // chaves conhecidas, entao um `string` alargado seria recusado (TS2345).
25
+ const FORMAT_OPTIONS = [
26
+ {
27
+ value: "CSV_ZIP",
28
+ labelKey: "backup.exports.create.format_csv_zip",
29
+ hintKey: "backup.exports.create.format_csv_zip_hint",
30
+ },
31
+ {
32
+ value: "SQL_DUMP_ZIP",
33
+ labelKey: "backup.exports.create.format_sql_dump_zip",
34
+ hintKey: "backup.exports.create.format_sql_dump_zip_hint",
35
+ },
36
+ ] as const;
37
+
38
+ const SCOPE_OPTIONS = [
39
+ {
40
+ value: "DOMAIN",
41
+ labelKey: "backup.exports.create.scope_domain",
42
+ hintKey: "backup.exports.create.scope_domain_hint",
43
+ },
44
+ {
45
+ value: "RAW",
46
+ labelKey: "backup.exports.create.scope_raw",
47
+ hintKey: "backup.exports.create.scope_raw_hint",
48
+ },
49
+ {
50
+ value: "CUSTOM",
51
+ labelKey: "backup.exports.create.scope_custom",
52
+ hintKey: "backup.exports.create.scope_custom_hint",
53
+ },
54
+ ] as const;
55
+
56
+ export default function NewExportPage() {
57
+ return (
58
+ <RouteGuard permissions={["JOB_CREATE"]}>
59
+ <NewExportScreen />
60
+ </RouteGuard>
61
+ );
62
+ }
63
+
64
+ function NewExportScreen() {
65
+ const router = useRouter();
66
+ const { t } = useI18n();
67
+
68
+ const [format, setFormat] = useState<BackupFormat>("CSV_ZIP");
69
+ const [scope, setScope] = useState<ExportScope>("DOMAIN");
70
+ const [includeAttachments, setIncludeAttachments] = useState(false);
71
+ const [submitting, setSubmitting] = useState(false);
72
+
73
+ const formatRadioSource = FORMAT_OPTIONS.map((opt) => ({
74
+ label: t(opt.labelKey),
75
+ value: opt.value,
76
+ description: t(opt.hintKey),
77
+ }));
78
+
79
+ const scopeRadioSource = SCOPE_OPTIONS.map((opt) => ({
80
+ label: t(opt.labelKey),
81
+ value: opt.value,
82
+ description: t(opt.hintKey),
83
+ }));
84
+
85
+ async function handleSubmit() {
86
+ setSubmitting(true);
87
+
88
+ const request: ExportRequest = {
89
+ format,
90
+ scope,
91
+ includeAttachments,
92
+ };
93
+
94
+ try {
95
+ await backupService.createExportJob(request);
96
+ sgWhistle.success({ message: t("backup.exports.create.success") });
97
+ router.push(backupConfig.routes.exports);
98
+ } catch (error) {
99
+ sgWhistle.error({
100
+ message:
101
+ extractBackupApiMessage(error) ?? t("backup.exports.create.error"),
102
+ });
103
+ } finally {
104
+ setSubmitting(false);
105
+ }
106
+ }
107
+
108
+ return (
109
+ <PageFrame
110
+ title={t("backup.exports.create.title")}
111
+ description={t("backup.exports.create.description")}
112
+ actions={
113
+ <SgStack direction="row" gap={12}>
114
+ <SgButton
115
+ appearance="ghost"
116
+ severity="secondary"
117
+ label={t("backup.common.cancel")}
118
+ onClick={() => router.push(backupConfig.routes.exports)}
119
+ disabled={submitting}
120
+ />
121
+ <SgButton
122
+ appearance="solid"
123
+ severity="primary"
124
+ shape="rounded"
125
+ label={t("backup.exports.create.submit")}
126
+ loading={submitting}
127
+ onClick={() => void handleSubmit()}
128
+ />
129
+ </SgStack>
130
+ }
131
+ >
132
+ <div className="max-w-2xl">
133
+ <div className="rounded-lg border border-slate-200 bg-white p-6">
134
+ <SgStack gap={24}>
135
+ <SgRadioGroup
136
+ title={t("backup.exports.create.format_label")}
137
+ source={formatRadioSource}
138
+ value={format}
139
+ orientation="vertical"
140
+ selectionStyle="highlight"
141
+ onChange={(val) => setFormat(val as BackupFormat)}
142
+ />
143
+
144
+ <hr className="border-slate-100" />
145
+
146
+ <SgRadioGroup
147
+ title={t("backup.exports.create.scope_label")}
148
+ source={scopeRadioSource}
149
+ value={scope}
150
+ orientation="vertical"
151
+ selectionStyle="highlight"
152
+ onChange={(val) => setScope(val as ExportScope)}
153
+ />
154
+
155
+ <hr className="border-slate-100" />
156
+
157
+ <SgToggleSwitch
158
+ id="backup-include-attachments"
159
+ label={t("backup.exports.create.attachments_label")}
160
+ checked={includeAttachments}
161
+ onChange={setIncludeAttachments}
162
+ />
163
+ </SgStack>
164
+ </div>
165
+ </div>
166
+ </PageFrame>
167
+ );
168
+ }
@@ -0,0 +1,166 @@
1
+ // seedgrid:managed
2
+
3
+ import { render, screen, waitFor } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import { backupService } from "@/modules/backup/services/backup-service";
8
+ import type { ExportResponse } from "@/modules/backup/types";
9
+
10
+ vi.mock("next/navigation", () => ({
11
+ useRouter: () => ({ push: vi.fn() }),
12
+ }));
13
+
14
+ vi.mock("@/modules/backup/services/backup-service", () => ({
15
+ backupService: {
16
+ listExportJobs: vi.fn(),
17
+ deleteExportJob: vi.fn(),
18
+ },
19
+ }));
20
+
21
+ vi.mock("@/modules/security", () => ({
22
+ RouteGuard: ({ children }: { children: React.ReactNode }) => <>{children}</>,
23
+ PageFrame: ({ children, actions }: { children: React.ReactNode; actions?: React.ReactNode }) => (
24
+ <div>
25
+ {actions}
26
+ {children}
27
+ </div>
28
+ ),
29
+ }));
30
+
31
+ vi.mock("@/i18n", () => ({
32
+ useI18n: () => ({ t: (key: string) => key }),
33
+ }));
34
+
35
+ vi.mock("@seedgrid/fe-components", () => ({
36
+ SgButton: ({ label, onClick, children, "aria-label": ariaLabel }: any) => (
37
+ <button onClick={onClick} aria-label={ariaLabel}>
38
+ {label ?? children}
39
+ </button>
40
+ ),
41
+ // ExportStatusBadge (renderizado numa coluna do datatable) usa SgBadge; sem
42
+ // ele no mock, <SgBadge> fica undefined e o componente quebra no render.
43
+ SgBadge: ({ value }: any) => <span>{value}</span>,
44
+ SgConfirmationDialog: ({ open, onConfirm, onCancel }: any) =>
45
+ open ? (
46
+ <div>
47
+ <button onClick={onConfirm}>confirm</button>
48
+ <button onClick={onCancel}>cancel</button>
49
+ </div>
50
+ ) : null,
51
+ SgDatatable: ({ value, columns, emptyMessage }: any) =>
52
+ value.length === 0 ? (
53
+ <div>{emptyMessage}</div>
54
+ ) : (
55
+ <table>
56
+ <tbody>
57
+ {value.map((row: any, i: number) => (
58
+ <tr key={i}>
59
+ {columns.map((col: any, j: number) => (
60
+ <td key={j}>{col.body ? col.body(row) : row[col.field]}</td>
61
+ ))}
62
+ </tr>
63
+ ))}
64
+ </tbody>
65
+ </table>
66
+ ),
67
+ SgStack: ({ children }: any) => <div>{children}</div>,
68
+ sgWhistle: { success: vi.fn(), error: vi.fn() },
69
+ }));
70
+
71
+ const mockListExportJobs = vi.mocked(backupService.listExportJobs);
72
+ const mockDeleteExportJob = vi.mocked(backupService.deleteExportJob);
73
+
74
+ function makeJob(overrides: Partial<ExportResponse> = {}): ExportResponse {
75
+ return {
76
+ jobId: "job-abc-123",
77
+ status: "DONE",
78
+ format: "CSV_ZIP",
79
+ scope: "DOMAIN",
80
+ progress: 100,
81
+ downloadable: true,
82
+ createdAt: "2024-01-15T10:30:00",
83
+ ...overrides,
84
+ };
85
+ }
86
+
87
+ describe("ExportsListPage", () => {
88
+ beforeEach(() => {
89
+ vi.clearAllMocks();
90
+ mockListExportJobs.mockResolvedValue([]);
91
+ });
92
+
93
+ it("shows the empty message when there are no export jobs", async () => {
94
+ const { default: Page } = await import("./page");
95
+ render(<Page />);
96
+
97
+ await waitFor(() => {
98
+ expect(
99
+ screen.getByText("backup.exports.list.empty")
100
+ ).toBeInTheDocument();
101
+ });
102
+ });
103
+
104
+ it("renders export rows when the service returns data", async () => {
105
+ const jobs = [
106
+ makeJob({ jobId: "job-abc-123", status: "DONE" }),
107
+ makeJob({ jobId: "job-def-456", status: "RUNNING" }),
108
+ ];
109
+ mockListExportJobs.mockResolvedValue(jobs);
110
+
111
+ const { default: Page } = await import("./page");
112
+ render(<Page />);
113
+
114
+ await waitFor(() => {
115
+ expect(screen.getByText(/job-abc/)).toBeInTheDocument();
116
+ });
117
+ });
118
+
119
+ it("calls deleteExportJob when the confirmation dialog is confirmed", async () => {
120
+ const job = makeJob({ jobId: "job-to-delete" });
121
+ mockListExportJobs.mockResolvedValue([job]);
122
+ mockDeleteExportJob.mockResolvedValue(undefined);
123
+
124
+ const { default: Page } = await import("./page");
125
+ render(<Page />);
126
+
127
+ await waitFor(() =>
128
+ expect(screen.getByText(/job-to-d/)).toBeInTheDocument()
129
+ );
130
+
131
+ const deleteBtn = screen.getByRole("button", { name: "backup.exports.list.action.delete" });
132
+ await userEvent.click(deleteBtn);
133
+
134
+ const confirmBtn = screen.getByText("confirm");
135
+ await userEvent.click(confirmBtn);
136
+
137
+ await waitFor(() =>
138
+ expect(mockDeleteExportJob).toHaveBeenCalledWith("job-to-delete")
139
+ );
140
+ });
141
+
142
+ it("closes the confirmation dialog when cancelled", async () => {
143
+ const job = makeJob({ jobId: "job-cancel-test" });
144
+ mockListExportJobs.mockResolvedValue([job]);
145
+
146
+ const { default: Page } = await import("./page");
147
+ render(<Page />);
148
+
149
+ // A coluna do jobId exibe row.jobId.slice(0, 8) — "job-cancel-test" vira
150
+ // "job-canc" (8 chars). O regex tem que casar a forma truncada.
151
+ await waitFor(() =>
152
+ expect(screen.getByText(/job-canc/)).toBeInTheDocument()
153
+ );
154
+
155
+ const deleteBtn = screen.getByRole("button", { name: "backup.exports.list.action.delete" });
156
+ await userEvent.click(deleteBtn);
157
+
158
+ expect(screen.getByText("confirm")).toBeInTheDocument();
159
+
160
+ const cancelBtn = screen.getByText("cancel");
161
+ await userEvent.click(cancelBtn);
162
+
163
+ expect(screen.queryByText("confirm")).not.toBeInTheDocument();
164
+ expect(mockDeleteExportJob).not.toHaveBeenCalled();
165
+ });
166
+ });