@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.
- package/README.md +86 -0
- package/dist/backup.d.ts +47 -0
- package/dist/backup.d.ts.map +1 -0
- package/dist/backup.js +3 -0
- package/dist/export-status.d.ts +6 -0
- package/dist/export-status.d.ts.map +1 -0
- package/dist/export-status.js +28 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/messages.d.ts +308 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +202 -0
- package/dist/module.d.ts +3 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +23 -0
- package/dist/module.json +15 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +11 -0
- package/dist/server/create-backup-server.d.ts +19 -0
- package/dist/server/create-backup-server.d.ts.map +1 -0
- package/dist/server/create-backup-server.js +19 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +2 -0
- package/package.json +43 -0
- package/scaffold/README.md +69 -0
- package/scaffold/src/app/api/backup/exports/[jobId]/download/route.ts +87 -0
- package/scaffold/src/app/backup/exports/(create)/new/page.test.tsx +182 -0
- package/scaffold/src/app/backup/exports/(create)/new/page.tsx +168 -0
- package/scaffold/src/app/backup/exports/(list)/page.test.tsx +166 -0
- package/scaffold/src/app/backup/exports/(list)/page.tsx +286 -0
- package/scaffold/src/modules/backup/components/ExportStatusBadge.test.tsx +75 -0
- package/scaffold/src/modules/backup/components/ExportStatusBadge.tsx +54 -0
- package/scaffold/src/modules/backup/config.ts +27 -0
- package/scaffold/src/modules/backup/index.ts +19 -0
- package/scaffold/src/modules/backup/services/backup-api.test.ts +122 -0
- package/scaffold/src/modules/backup/services/backup-api.ts +205 -0
- package/scaffold/src/modules/backup/services/backup-service.test.ts +125 -0
- package/scaffold/src/modules/backup/services/backup-service.ts +34 -0
- package/scaffold/src/modules/backup/types.ts +17 -0
- package/scaffold/src/modules/backup/useBackupAppShellSections.test.tsx +61 -0
- package/scaffold/src/modules/backup/useBackupAppShellSections.tsx +50 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// seedgrid:managed
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ApiClientError,
|
|
5
|
+
createApiClient,
|
|
6
|
+
extractApiErrorMessage,
|
|
7
|
+
type ApiRequestOptions,
|
|
8
|
+
} from "@/modules/core";
|
|
9
|
+
import { getMessages, translate } from "@/i18n";
|
|
10
|
+
|
|
11
|
+
import { backupConfig } from "../config";
|
|
12
|
+
|
|
13
|
+
type AuthRuntime = {
|
|
14
|
+
getAccessToken: () => Promise<string | null>;
|
|
15
|
+
refreshAccessToken: () => Promise<string | null>;
|
|
16
|
+
onAuthFailure: () => void | Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const runtime: AuthRuntime = {
|
|
20
|
+
getAccessToken: async () => null,
|
|
21
|
+
refreshAccessToken: async () => null,
|
|
22
|
+
onAuthFailure: async () => {},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
let pendingRefresh: Promise<string | null> | null = null;
|
|
26
|
+
|
|
27
|
+
const privateClient = createApiClient({
|
|
28
|
+
baseUrl: backupConfig.apiBaseUrl,
|
|
29
|
+
getAccessToken: () => runtime.getAccessToken(),
|
|
30
|
+
refreshAccessToken: () => {
|
|
31
|
+
if (!pendingRefresh) {
|
|
32
|
+
pendingRefresh = runtime.refreshAccessToken().finally(() => {
|
|
33
|
+
pendingRefresh = null;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return pendingRefresh;
|
|
37
|
+
},
|
|
38
|
+
onAuthFailure: () => runtime.onAuthFailure(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export function configureBackupApi(nextRuntime: Partial<AuthRuntime>) {
|
|
42
|
+
if (typeof nextRuntime.getAccessToken === "function") {
|
|
43
|
+
runtime.getAccessToken = nextRuntime.getAccessToken;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (typeof nextRuntime.refreshAccessToken === "function") {
|
|
47
|
+
runtime.refreshAccessToken = nextRuntime.refreshAccessToken;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (typeof nextRuntime.onAuthFailure === "function") {
|
|
51
|
+
runtime.onAuthFailure = nextRuntime.onAuthFailure;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const backupApi = {
|
|
56
|
+
get: <T = unknown>(path: string, options?: ApiRequestOptions) =>
|
|
57
|
+
privateClient.get<T>(path, options),
|
|
58
|
+
post: <T = unknown>(path: string, options?: ApiRequestOptions) =>
|
|
59
|
+
privateClient.post<T>(path, options),
|
|
60
|
+
delete: <T = unknown>(path: string, options?: ApiRequestOptions) =>
|
|
61
|
+
privateClient.delete<T>(path, options),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function isApiClientError(error: unknown): error is ApiClientError {
|
|
65
|
+
return error instanceof ApiClientError;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function extractBackupApiMessage(error: unknown): string | null {
|
|
69
|
+
if (error instanceof ApiClientError) {
|
|
70
|
+
return normalizeReadableErrorMessage(
|
|
71
|
+
readApiMessage(error.responseBody) ?? extractApiErrorMessage(error)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return normalizeReadableErrorMessage(extractApiErrorMessage(error));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readApiMessage(body: unknown): string | null {
|
|
79
|
+
const normalizedBody = normalizeApiBody(body);
|
|
80
|
+
|
|
81
|
+
if (!normalizedBody || typeof normalizedBody !== "object") {
|
|
82
|
+
return pickString(normalizedBody);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const record = normalizedBody as Record<string, unknown>;
|
|
86
|
+
|
|
87
|
+
if (Array.isArray(record.violations)) {
|
|
88
|
+
for (const entry of record.violations) {
|
|
89
|
+
const violationMessage = readRecordMessage(entry);
|
|
90
|
+
|
|
91
|
+
if (violationMessage) {
|
|
92
|
+
return violationMessage;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (Array.isArray(record.errors)) {
|
|
98
|
+
for (const entry of record.errors) {
|
|
99
|
+
const nestedMessage = readRecordMessage(entry);
|
|
100
|
+
|
|
101
|
+
if (nestedMessage) {
|
|
102
|
+
return nestedMessage;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
pickString(record.userMessage) ??
|
|
109
|
+
pickString(record.detail) ??
|
|
110
|
+
pickString(record.message) ??
|
|
111
|
+
pickString(record.error) ??
|
|
112
|
+
pickString(record.title)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeApiBody(body: unknown) {
|
|
117
|
+
if (typeof body !== "string") {
|
|
118
|
+
return body;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const normalized = body.trim();
|
|
122
|
+
|
|
123
|
+
if (!normalized) {
|
|
124
|
+
return body;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
return JSON.parse(normalized) as unknown;
|
|
129
|
+
} catch {
|
|
130
|
+
return body;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function readRecordMessage(value: unknown) {
|
|
135
|
+
if (!value || typeof value !== "object") {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const record = value as Record<string, unknown>;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
pickString(record.userMessage) ??
|
|
143
|
+
pickString(record.message) ??
|
|
144
|
+
pickString(record.error) ??
|
|
145
|
+
pickString(record.description)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeReadableErrorMessage(message: string | null) {
|
|
150
|
+
const normalized = pickString(message);
|
|
151
|
+
|
|
152
|
+
if (!normalized) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (isServerConnectionError(normalized)) {
|
|
157
|
+
return translate(
|
|
158
|
+
getMessages(resolveRuntimeLocale()),
|
|
159
|
+
"backup.common.server_connection_error"
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (isHtmlContent(normalized)) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return normalized;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isHtmlContent(message: string) {
|
|
171
|
+
const trimmed = message.trimStart();
|
|
172
|
+
return trimmed.startsWith("<!") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isServerConnectionError(message: string) {
|
|
176
|
+
const normalized = message.trim().toLowerCase();
|
|
177
|
+
return (
|
|
178
|
+
normalized === "fetch failed" ||
|
|
179
|
+
normalized === "failed to fetch" ||
|
|
180
|
+
normalized.includes("networkerror") ||
|
|
181
|
+
normalized.includes("network request failed") ||
|
|
182
|
+
normalized.includes("load failed")
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function resolveRuntimeLocale() {
|
|
187
|
+
if (typeof document !== "undefined") {
|
|
188
|
+
const documentLocale = document.documentElement.lang?.trim();
|
|
189
|
+
if (documentLocale) return documentLocale;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (typeof navigator !== "undefined") {
|
|
193
|
+
const browserLocale =
|
|
194
|
+
navigator.language?.trim() || navigator.languages?.[0]?.trim();
|
|
195
|
+
if (browserLocale) return browserLocale;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function pickString(value: unknown) {
|
|
202
|
+
return typeof value === "string" && value.trim().length > 0
|
|
203
|
+
? value.trim()
|
|
204
|
+
: null;
|
|
205
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// seedgrid:managed
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { backupApi } from "./backup-api";
|
|
6
|
+
import { backupService } from "./backup-service";
|
|
7
|
+
import type { ExportRequest, ExportResponse } from "../types";
|
|
8
|
+
|
|
9
|
+
vi.mock("./backup-api", () => ({
|
|
10
|
+
backupApi: {
|
|
11
|
+
get: vi.fn(),
|
|
12
|
+
post: vi.fn(),
|
|
13
|
+
delete: vi.fn(),
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const mockGet = vi.mocked(backupApi.get);
|
|
18
|
+
const mockPost = vi.mocked(backupApi.post);
|
|
19
|
+
const mockDelete = vi.mocked(backupApi.delete);
|
|
20
|
+
|
|
21
|
+
const makeExportResponse = (
|
|
22
|
+
overrides: Partial<ExportResponse> = {}
|
|
23
|
+
): ExportResponse => ({
|
|
24
|
+
jobId: "job-abc-123",
|
|
25
|
+
status: "REQUESTED",
|
|
26
|
+
format: "CSV_ZIP",
|
|
27
|
+
scope: "DOMAIN",
|
|
28
|
+
progress: 0,
|
|
29
|
+
createdAt: "2024-01-15T10:30:00",
|
|
30
|
+
downloadable: false,
|
|
31
|
+
...overrides,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("backupService.createExportJob", () => {
|
|
35
|
+
beforeEach(() => vi.clearAllMocks());
|
|
36
|
+
|
|
37
|
+
it("posts to /exports with the given request body", async () => {
|
|
38
|
+
const request: ExportRequest = {
|
|
39
|
+
format: "CSV_ZIP",
|
|
40
|
+
scope: "DOMAIN",
|
|
41
|
+
includeAttachments: false,
|
|
42
|
+
};
|
|
43
|
+
const expected = makeExportResponse({ status: "REQUESTED" });
|
|
44
|
+
mockPost.mockResolvedValueOnce(expected);
|
|
45
|
+
|
|
46
|
+
const result = await backupService.createExportJob(request);
|
|
47
|
+
|
|
48
|
+
expect(mockPost).toHaveBeenCalledWith("/exports", { body: request });
|
|
49
|
+
expect(result).toEqual(expected);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("propagates API errors from createExportJob", async () => {
|
|
53
|
+
const request: ExportRequest = { format: "SQL_DUMP_ZIP", scope: "RAW" };
|
|
54
|
+
mockPost.mockRejectedValueOnce(new Error("Network error"));
|
|
55
|
+
|
|
56
|
+
await expect(backupService.createExportJob(request)).rejects.toThrow(
|
|
57
|
+
"Network error"
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("backupService.listExportJobs", () => {
|
|
63
|
+
beforeEach(() => vi.clearAllMocks());
|
|
64
|
+
|
|
65
|
+
it("calls GET /exports with page and size query params", async () => {
|
|
66
|
+
const jobs = [makeExportResponse(), makeExportResponse({ jobId: "job-def-456" })];
|
|
67
|
+
mockGet.mockResolvedValueOnce(jobs);
|
|
68
|
+
|
|
69
|
+
const result = await backupService.listExportJobs({ page: 0, size: 10 });
|
|
70
|
+
|
|
71
|
+
expect(mockGet).toHaveBeenCalledWith("/exports?page=0&size=10");
|
|
72
|
+
expect(result).toHaveLength(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("passes the correct page when paginating", async () => {
|
|
76
|
+
mockGet.mockResolvedValueOnce([]);
|
|
77
|
+
|
|
78
|
+
await backupService.listExportJobs({ page: 2, size: 20 });
|
|
79
|
+
|
|
80
|
+
expect(mockGet).toHaveBeenCalledWith("/exports?page=2&size=20");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("backupService.getExportStatus", () => {
|
|
85
|
+
beforeEach(() => vi.clearAllMocks());
|
|
86
|
+
|
|
87
|
+
it("fetches status for the given jobId", async () => {
|
|
88
|
+
const job = makeExportResponse({ status: "RUNNING", progress: 45 });
|
|
89
|
+
mockGet.mockResolvedValueOnce(job);
|
|
90
|
+
|
|
91
|
+
const result = await backupService.getExportStatus("job-abc-123");
|
|
92
|
+
|
|
93
|
+
expect(mockGet).toHaveBeenCalledWith("/exports/job-abc-123");
|
|
94
|
+
expect(result.status).toBe("RUNNING");
|
|
95
|
+
expect(result.progress).toBe(45);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("URL-encodes the jobId when calling the API", async () => {
|
|
99
|
+
mockGet.mockResolvedValueOnce(makeExportResponse());
|
|
100
|
+
|
|
101
|
+
await backupService.getExportStatus("job/with/slashes");
|
|
102
|
+
|
|
103
|
+
expect(mockGet).toHaveBeenCalledWith("/exports/job%2Fwith%2Fslashes");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("backupService.deleteExportJob", () => {
|
|
108
|
+
beforeEach(() => vi.clearAllMocks());
|
|
109
|
+
|
|
110
|
+
it("calls DELETE for the given jobId", async () => {
|
|
111
|
+
mockDelete.mockResolvedValueOnce(undefined);
|
|
112
|
+
|
|
113
|
+
await backupService.deleteExportJob("job-abc-123");
|
|
114
|
+
|
|
115
|
+
expect(mockDelete).toHaveBeenCalledWith("/exports/job-abc-123");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("propagates errors from the delete call", async () => {
|
|
119
|
+
mockDelete.mockRejectedValueOnce(new Error("Not found"));
|
|
120
|
+
|
|
121
|
+
await expect(backupService.deleteExportJob("missing-id")).rejects.toThrow(
|
|
122
|
+
"Not found"
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// seedgrid:managed
|
|
2
|
+
|
|
3
|
+
import { backupApi } from "./backup-api";
|
|
4
|
+
import type { ExportRequest, ExportResponse } from "../types";
|
|
5
|
+
|
|
6
|
+
const EXPORTS_PATH = "/exports";
|
|
7
|
+
|
|
8
|
+
export const backupService = {
|
|
9
|
+
async createExportJob(request: ExportRequest): Promise<ExportResponse> {
|
|
10
|
+
return backupApi.post<ExportResponse>(EXPORTS_PATH, {
|
|
11
|
+
body: request,
|
|
12
|
+
});
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
async listExportJobs(params: {
|
|
16
|
+
page: number;
|
|
17
|
+
size: number;
|
|
18
|
+
}): Promise<ExportResponse[]> {
|
|
19
|
+
const query = new URLSearchParams({
|
|
20
|
+
page: String(params.page),
|
|
21
|
+
size: String(params.size),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return backupApi.get<ExportResponse[]>(`${EXPORTS_PATH}?${query}`);
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
async getExportStatus(jobId: string): Promise<ExportResponse> {
|
|
28
|
+
return backupApi.get<ExportResponse>(`${EXPORTS_PATH}/${encodeURIComponent(jobId)}`);
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async deleteExportJob(jobId: string): Promise<void> {
|
|
32
|
+
await backupApi.delete(`${EXPORTS_PATH}/${encodeURIComponent(jobId)}`);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// seedgrid:managed
|
|
2
|
+
|
|
3
|
+
// Um agente anterior tinha redeclarado BackupFormat/ArtifactInfo aqui porque
|
|
4
|
+
// divergiam do que existia em @seedgrid/fe-backup (BackupFormat tinha 8
|
|
5
|
+
// valores fictícios; ArtifactInfo.id era string). Conferi contra o backend
|
|
6
|
+
// real (seedgrid-quarkus-ext-backup/.../domains/BackupFormat.java,
|
|
7
|
+
// dtos/ExportResponse.java) e corrigi @seedgrid/fe-backup pra bater com o
|
|
8
|
+
// contrato de verdade (BackupFormat só tem CSV_ZIP/SQL_DUMP_ZIP; ArtifactInfo.id
|
|
9
|
+
// é number). Com isso não há mais divergência — tudo reexportado da lib.
|
|
10
|
+
export type {
|
|
11
|
+
ArtifactInfo,
|
|
12
|
+
BackupFormat,
|
|
13
|
+
ExportRequest,
|
|
14
|
+
ExportResponse,
|
|
15
|
+
ExportScope,
|
|
16
|
+
ExportStatus,
|
|
17
|
+
} from "@seedgrid/fe-backup";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// seedgrid:managed
|
|
2
|
+
|
|
3
|
+
import { renderHook } from "@testing-library/react";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { useBackupAppShellSections } from "./useBackupAppShellSections";
|
|
7
|
+
|
|
8
|
+
vi.mock("@/i18n", () => ({
|
|
9
|
+
useI18n: () => ({ t: (key: string) => key }),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const mockUsePermission = vi.fn();
|
|
13
|
+
|
|
14
|
+
vi.mock("@/modules/security", () => ({
|
|
15
|
+
usePermission: (permission: string) => mockUsePermission(permission),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
describe("useBackupAppShellSections", () => {
|
|
19
|
+
it("returns an empty array when the user has no JOB_READ permission", () => {
|
|
20
|
+
mockUsePermission.mockReturnValue(false);
|
|
21
|
+
|
|
22
|
+
const { result } = renderHook(() => useBackupAppShellSections());
|
|
23
|
+
|
|
24
|
+
expect(result.current).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns a backup section when the user has JOB_READ permission", () => {
|
|
28
|
+
mockUsePermission.mockImplementation((permission: string) =>
|
|
29
|
+
permission === "JOB_READ"
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const { result } = renderHook(() => useBackupAppShellSections());
|
|
33
|
+
|
|
34
|
+
expect(result.current).toHaveLength(1);
|
|
35
|
+
expect(result.current[0].id).toBe("backup");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("includes a backup-exports nav item inside the section", () => {
|
|
39
|
+
mockUsePermission.mockImplementation((permission: string) =>
|
|
40
|
+
permission === "JOB_READ"
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const { result } = renderHook(() => useBackupAppShellSections());
|
|
44
|
+
|
|
45
|
+
const section = result.current[0];
|
|
46
|
+
const sectionItem = section.items[0];
|
|
47
|
+
const exportItem = sectionItem.children?.find(
|
|
48
|
+
(item) => item.id === "backup-exports"
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(exportItem).toBeDefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("passes the JOB_READ permission check to usePermission", () => {
|
|
55
|
+
mockUsePermission.mockReturnValue(false);
|
|
56
|
+
|
|
57
|
+
renderHook(() => useBackupAppShellSections());
|
|
58
|
+
|
|
59
|
+
expect(mockUsePermission).toHaveBeenCalledWith("JOB_READ");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Database } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
AppShellNavItem,
|
|
7
|
+
AppShellSection,
|
|
8
|
+
} from "@/components/layout/app-shell-navigation";
|
|
9
|
+
import { useI18n } from "@/i18n";
|
|
10
|
+
import { usePermission } from "@/modules/security";
|
|
11
|
+
|
|
12
|
+
import { backupConfig } from "./config";
|
|
13
|
+
|
|
14
|
+
export function useBackupAppShellSections(): AppShellSection[] {
|
|
15
|
+
const { t } = useI18n();
|
|
16
|
+
const canReadJobs = usePermission("JOB_READ");
|
|
17
|
+
|
|
18
|
+
const items: AppShellNavItem[] = [];
|
|
19
|
+
|
|
20
|
+
if (canReadJobs) {
|
|
21
|
+
items.push({
|
|
22
|
+
id: "backup-exports",
|
|
23
|
+
label: t("backup.exports.list.title"),
|
|
24
|
+
href: backupConfig.routes.exports,
|
|
25
|
+
icon: <Database size={16} />,
|
|
26
|
+
order: 10,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!items.length) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
id: "backup",
|
|
37
|
+
label: t("backup.common.module_name"),
|
|
38
|
+
order: 50,
|
|
39
|
+
items: [
|
|
40
|
+
{
|
|
41
|
+
id: "backup-management",
|
|
42
|
+
label: t("backup.common.module_name"),
|
|
43
|
+
icon: <Database size={18} />,
|
|
44
|
+
order: 10,
|
|
45
|
+
children: items,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
}
|