Skip to content

Commit de5fc87

Browse files
committed
feat: restrict edit of archived versions to internal fields
1 parent 6375082 commit de5fc87

10 files changed

Lines changed: 397 additions & 162 deletions

File tree

apps/concept-catalog/app/actions/concept/actions.ts

Lines changed: 78 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
deleteConcept as deleteConceptApi,
55
createConcept as createConceptApi,
66
patchConcept as patchConceptApi,
7+
createConceptRevision,
78
getConcept,
89
removeImportResultConcept as removeImportResult,
910
cancelConceptImport,
@@ -16,9 +17,13 @@ import {
1617
redirectToSignIn,
1718
removeEmptyValues,
1819
} from "@catalog-frontend/utils";
20+
import { Operation } from "fast-json-patch";
1921
import _ from "lodash";
2022
import { updateTag } from "next/cache";
21-
import { conceptJsonPatchOperations } from "@concept-catalog/utils/json-patch";
23+
import {
24+
archivedConceptJsonPatchOperations,
25+
conceptJsonPatchOperations,
26+
} from "@concept-catalog/utils/json-patch";
2227

2328
const clearValues = (object: Concept, path: string): void => {
2429
const fields = path.split(".");
@@ -144,16 +149,11 @@ export async function deleteConcept(conceptId: string): Promise<void> {
144149
}
145150
}
146151

147-
export async function updateConcept(
148-
initialConcept: Concept,
152+
function sanitizeConceptValues(
149153
values: Concept,
150154
internalFields: InternalField[],
151-
): Promise<Concept> {
152-
if (!initialConcept.id) {
153-
throw new Error("Concept id cannot be null");
154-
}
155-
156-
let conceptId: string | undefined = initialConcept.id;
155+
): Concept {
156+
const cleaned = _.cloneDeep(values);
157157

158158
[
159159
"definisjon.kildebeskrivelse.kilde[].uri",
@@ -166,45 +166,57 @@ export async function updateConcept(
166166
"interneFelt.*.value",
167167
"omfang.*",
168168
].forEach((field) => {
169-
clearValues(values, field);
169+
clearValues(cleaned, field);
170170
});
171171

172172
internalFields.forEach((field) => {
173173
if (
174174
field.type === "boolean" &&
175-
values.interneFelt?.[field.id]?.value === undefined
175+
cleaned.interneFelt?.[field.id]?.value === undefined
176176
) {
177-
// Ensure interneFelt is defined before assignment
178-
values.interneFelt = values.interneFelt || {};
179-
values.interneFelt[field.id] = { value: "false" };
177+
cleaned.interneFelt = cleaned.interneFelt || {};
178+
cleaned.interneFelt[field.id] = { value: "false" };
180179
}
181180
});
182181

183-
const diff = conceptJsonPatchOperations(initialConcept, values);
182+
return cleaned;
183+
}
184+
185+
async function applyConceptChanges(
186+
initialConcept: Concept,
187+
diff: Operation[],
188+
mode: "patch" | "revision",
189+
): Promise<Concept | undefined> {
190+
if (!initialConcept.id) {
191+
throw new Error("Concept id cannot be null");
192+
}
184193

185194
if (diff.length === 0) {
186195
return initialConcept;
187196
}
188197

189-
let success = false;
190198
const session = await getValidSession();
191199
if (!session) {
192200
return redirectToSignIn();
193201
}
194202

203+
const initialId = initialConcept.id;
204+
let success = false;
205+
let resolvedConceptId: string | undefined = initialId;
206+
195207
try {
196-
const response = await patchConceptApi(
197-
initialConcept.id,
198-
diff,
199-
session.accessToken,
200-
);
208+
const response =
209+
mode === "revision"
210+
? await createConceptRevision(initialId, diff, session.accessToken)
211+
: await patchConceptApi(initialId, diff, session.accessToken);
201212
if (response.status !== 200 && response.status !== 201) {
202213
throw new Error(`${response.status} ${response.statusText}`);
203214
}
204215

205216
success = true;
206217
if (response.status === 201) {
207-
conceptId = response.headers.get("location")?.split("/").pop();
218+
resolvedConceptId =
219+
response.headers.get("location")?.split("/").pop() ?? initialId;
208220
}
209221
} catch (error) {
210222
console.error(`${localization.alert.fail} ${error}`);
@@ -216,11 +228,54 @@ export async function updateConcept(
216228
updateTag("concepts");
217229
}
218230

219-
return await getConcept(`${conceptId}`, session.accessToken).then(
231+
return await getConcept(`${resolvedConceptId}`, session.accessToken).then(
220232
(response) => (response.ok ? response.json() : undefined),
221233
);
222234
}
223235

236+
/**
237+
* Update a concept from the standard edit form.
238+
*
239+
* When editing an archived concept, a new revision is created server-side
240+
* (POST /revision). Otherwise a normal PATCH is performed against the concept.
241+
*/
242+
export async function updateConcept(
243+
initialConcept: Concept,
244+
values: Concept,
245+
internalFields: InternalField[],
246+
): Promise<Concept | undefined> {
247+
const diff = conceptJsonPatchOperations(
248+
initialConcept,
249+
sanitizeConceptValues(values, internalFields),
250+
);
251+
return applyConceptChanges(
252+
initialConcept,
253+
diff,
254+
initialConcept.isArchived ? "revision" : "patch",
255+
);
256+
}
257+
258+
/**
259+
* Update an archived concept from the restricted edit form. Only fields that
260+
* the backend allows to be mutated on an archived concept are diffed; all
261+
* other field changes are silently ignored. Throws if the concept isn't
262+
* actually archived.
263+
*/
264+
export async function updateArchivedConcept(
265+
initialConcept: Concept,
266+
values: Concept,
267+
internalFields: InternalField[],
268+
): Promise<Concept | undefined> {
269+
if (!initialConcept.isArchived) {
270+
throw new Error("Concept is not archived");
271+
}
272+
const diff = archivedConceptJsonPatchOperations(
273+
initialConcept,
274+
sanitizeConceptValues(values, internalFields),
275+
);
276+
return applyConceptChanges(initialConcept, diff, "patch");
277+
}
278+
224279
export async function deleteImportResult(
225280
catalogId: string,
226281
resultId: string,

apps/concept-catalog/app/catalogs/[catalogId]/concepts/[conceptId]/concept-page-client.tsx

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -405,18 +405,51 @@ export const ConceptPageClient = ({
405405
setLanguage(lang);
406406
};
407407

408+
const compareVersions = (a: Concept, b: Concept): number => {
409+
const va = a?.versjonsnr;
410+
const vb = b?.versjonsnr;
411+
if (!va && !vb) return 0;
412+
if (!va) return -1;
413+
if (!vb) return 1;
414+
return (
415+
(va.major ?? 0) - (vb.major ?? 0) ||
416+
(va.minor ?? 0) - (vb.minor ?? 0) ||
417+
(va.patch ?? 0) - (vb.patch ?? 0)
418+
);
419+
};
420+
421+
const pickLatest = (candidates: Concept[]): Concept | undefined =>
422+
candidates.reduce<Concept | undefined>(
423+
(latest, current) =>
424+
!latest || compareVersions(current, latest) > 0 ? current : latest,
425+
undefined,
426+
);
427+
428+
const safeRevisions = revisions ?? [];
429+
const latestRevision = pickLatest(safeRevisions);
430+
const latestNonArchivedRevision = pickLatest(
431+
safeRevisions.filter((r) => !r.isArchived),
432+
);
433+
408434
const handleEditConcept = () => {
409-
const revision = revisions?.find((revision) => !revision.isArchived);
410-
const id = revision ? revision.id : concept?.id;
435+
const id = latestRevision?.id ?? concept?.id;
411436
if (validOrganizationNumber(catalogId) && validUUID(id)) {
412437
router.push(`/catalogs/${catalogId}/concepts/${id}/edit`);
413438
}
414439
};
415440

441+
const handleEditArchivedConcept = () => {
442+
if (validOrganizationNumber(catalogId) && validUUID(concept?.id)) {
443+
router.push(
444+
`/catalogs/${catalogId}/concepts/${concept?.id}/edit-archived`,
445+
);
446+
}
447+
};
448+
416449
const handleDeleteConcept = () => {
417-
const revision = revisions?.find((revision) => !revision.isArchived);
418-
if (revision) {
419-
deleteConcept.mutate(revision.id as string);
450+
const target = latestNonArchivedRevision?.id ?? concept?.id;
451+
if (target && validUUID(target)) {
452+
deleteConcept.mutate(target);
420453
}
421454
};
422455

@@ -940,7 +973,11 @@ export const ConceptPageClient = ({
940973
<Button onClick={handleEditConcept}>
941974
{localization.button.edit}
942975
</Button>
943-
{!concept?.isArchived && (
976+
{concept?.isArchived ? (
977+
<Button onClick={handleEditArchivedConcept}>
978+
{localization.concept.editArchived}
979+
</Button>
980+
) : (
944981
<Button
945982
data-color="danger"
946983
variant="secondary"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { withWriteProtectedPage } from "@concept-catalog/utils/auth";
2+
import { renderConceptEditPage } from "@concept-catalog/components/concept-form/edit-page-loader";
3+
4+
export default withWriteProtectedPage(
5+
({ catalogId, conceptId }) =>
6+
`/catalogs/${catalogId}/concepts/${conceptId}/edit-archived`,
7+
async ({ catalogId, conceptId, session }) =>
8+
renderConceptEditPage({
9+
catalogId,
10+
conceptId: `${conceptId}`,
11+
session,
12+
mode: "archived",
13+
}),
14+
);
Lines changed: 7 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,117 +1,14 @@
1-
import {
2-
getAllCodeLists,
3-
getConcept,
4-
getConceptStatuses,
5-
getFields,
6-
getUsers,
7-
searchChangeRequest,
8-
} from "@catalog-frontend/data-access";
9-
import { redirect, RedirectType } from "next/navigation";
10-
import {
11-
Breadcrumbs,
12-
BreadcrumbType,
13-
DesignBanner,
14-
} from "@catalog-frontend/ui";
15-
import {
16-
getTranslateText,
17-
localization,
18-
prepareStatusList,
19-
} from "@catalog-frontend/utils";
20-
import {
21-
CodeListsResult,
22-
Concept,
23-
FieldsResult,
24-
UsersResult,
25-
} from "@catalog-frontend/types";
261
import { withWriteProtectedPage } from "@concept-catalog/utils/auth";
27-
import { EditPage } from "./edit-page.client";
2+
import { renderConceptEditPage } from "@concept-catalog/components/concept-form/edit-page-loader";
283

294
export default withWriteProtectedPage(
305
({ catalogId, conceptId }) =>
316
`/catalogs/${catalogId}/concepts/${conceptId}/edit`,
32-
async ({ catalogId, conceptId, session }) => {
33-
const concept: Concept = await getConcept(
34-
`${conceptId}`,
35-
session.accessToken,
36-
).then((response) => {
37-
if (response.ok) return response.json();
38-
});
39-
if (!concept || concept.ansvarligVirksomhet?.id !== catalogId) {
40-
return redirect("/notfound", RedirectType.replace);
41-
}
42-
43-
const changeRequests = await searchChangeRequest(
44-
catalogId,
45-
`${conceptId}`,
46-
session.accessToken,
47-
"OPEN",
48-
).then((response) => {
49-
if (response.ok) {
50-
return response.json();
51-
} else {
52-
console.error(
53-
`Failed to fetch change requests, status: ${response.status}`,
54-
);
55-
throw new Error("Failed to fetch change requests");
56-
}
57-
});
58-
59-
const conceptStatuses = await getConceptStatuses().then((body) =>
60-
prepareStatusList(body.conceptStatuses),
61-
);
62-
63-
const codeListsResult: CodeListsResult = await getAllCodeLists(
7+
async ({ catalogId, conceptId, session }) =>
8+
renderConceptEditPage({
649
catalogId,
65-
session.accessToken,
66-
).then((response) => response.json());
67-
const fieldsResult: FieldsResult = await getFields(
68-
catalogId,
69-
session.accessToken,
70-
).then((response) => response.json());
71-
const usersResult: UsersResult = await getUsers(
72-
catalogId,
73-
session.accessToken,
74-
).then((response) => response.json());
75-
76-
const getTitle = (text: string | string[]) =>
77-
text ? text : localization.concept.noName;
78-
const breadcrumbList = catalogId
79-
? ([
80-
{
81-
href: `/catalogs/${catalogId}`,
82-
text: localization.catalogType.concept,
83-
},
84-
{
85-
href: `/catalogs/${catalogId}/concepts/${concept?.id}`,
86-
text: getTitle(getTranslateText(concept?.anbefaltTerm?.navn)),
87-
},
88-
{
89-
href: `/catalogs/${catalogId}/concepts/${concept?.id}/edit`,
90-
text: localization.edit,
91-
},
92-
] as BreadcrumbType[])
93-
: [];
94-
95-
return (
96-
<>
97-
<Breadcrumbs
98-
breadcrumbList={breadcrumbList}
99-
catalogPortalUrl={`${process.env.CATALOG_PORTAL_BASE_URI}/catalogs`}
100-
/>
101-
<DesignBanner
102-
title={localization.catalogType.concept}
103-
catalogId={catalogId}
104-
/>
105-
<EditPage
106-
catalogId={catalogId}
107-
concept={concept}
108-
conceptStatuses={conceptStatuses}
109-
codeListsResult={codeListsResult}
110-
fieldsResult={fieldsResult}
111-
usersResult={usersResult}
112-
hasChangeRequests={changeRequests?.length}
113-
/>
114-
</>
115-
);
116-
},
10+
conceptId: `${conceptId}`,
11+
session,
12+
mode: "full",
13+
}),
11714
);

0 commit comments

Comments
 (0)