From a09fd46371315b18f0bc7afc46753220cc6ce265 Mon Sep 17 00:00:00 2001 From: 5000user5000 Date: Thu, 28 May 2026 13:55:55 +0800 Subject: [PATCH 01/11] feat: add template type management --- .gitignore | 1 + .../ConfigurationRepository.java | 5 + .../configuration/TemplateType.java | 5 + .../configuration/TemplateTypeController.java | 49 ++- .../dto/TemplateTypeUpdateRequest.java | 13 + .../TemplateTypeControllerTest.java | 179 ++++++++++ frontend/src/PrototypeUI.jsx | 34 ++ frontend/src/api/templates.js | 18 + frontend/src/pages/Templates.jsx | 318 +++++++++++++++++- frontend/tests/api/templates.test.js | 43 +++ frontend/tests/pages/TemplateTypes.test.jsx | 81 +++++ 11 files changed, 737 insertions(+), 9 deletions(-) create mode 100644 backend/src/main/java/com/cloudnative/configuration/dto/TemplateTypeUpdateRequest.java create mode 100644 backend/src/test/java/com/cloudnative/configuration/TemplateTypeControllerTest.java create mode 100644 frontend/tests/pages/TemplateTypes.test.jsx diff --git a/.gitignore b/.gitignore index af4d85f..adbdaa2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ !/.nvmrc .vscode/ *.DS_Store +local_docs/ \ No newline at end of file diff --git a/backend/src/main/java/com/cloudnative/configuration/ConfigurationRepository.java b/backend/src/main/java/com/cloudnative/configuration/ConfigurationRepository.java index 94141b9..4b814a8 100644 --- a/backend/src/main/java/com/cloudnative/configuration/ConfigurationRepository.java +++ b/backend/src/main/java/com/cloudnative/configuration/ConfigurationRepository.java @@ -30,4 +30,9 @@ Optional findFirstByKindAndTemplateType_IdOrderByCreatedAtAsc( ConfigurationKind kind, UUID templateTypeId ); + + boolean existsByKindAndTemplateType_IdAndDeletedAtIsNull( + ConfigurationKind kind, + UUID templateTypeId + ); } diff --git a/backend/src/main/java/com/cloudnative/configuration/TemplateType.java b/backend/src/main/java/com/cloudnative/configuration/TemplateType.java index fe1c0e1..96ea37f 100644 --- a/backend/src/main/java/com/cloudnative/configuration/TemplateType.java +++ b/backend/src/main/java/com/cloudnative/configuration/TemplateType.java @@ -40,6 +40,11 @@ public TemplateType(String code, String name, String description) { this.description = description; } + public void update(String name, String description) { + this.name = name; + this.description = description; + } + public UUID getId() { return id; } diff --git a/backend/src/main/java/com/cloudnative/configuration/TemplateTypeController.java b/backend/src/main/java/com/cloudnative/configuration/TemplateTypeController.java index fd47a2a..4593b70 100644 --- a/backend/src/main/java/com/cloudnative/configuration/TemplateTypeController.java +++ b/backend/src/main/java/com/cloudnative/configuration/TemplateTypeController.java @@ -2,31 +2,43 @@ import com.cloudnative.configuration.dto.TemplateTypeCreateRequest; import com.cloudnative.configuration.dto.TemplateTypeResponse; +import com.cloudnative.configuration.dto.TemplateTypeUpdateRequest; import com.cloudnative.identity.security.AuthenticatedUser; import com.cloudnative.identity.security.RoleGuard; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import java.util.List; +import java.util.UUID; import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.NOT_FOUND; @RestController @RequestMapping("/api/template-types") public class TemplateTypeController { private final TemplateTypeRepository templateTypeRepository; + private final ConfigurationRepository configurationRepository; private final RoleGuard roleGuard; - public TemplateTypeController(TemplateTypeRepository templateTypeRepository, RoleGuard roleGuard) { + public TemplateTypeController( + TemplateTypeRepository templateTypeRepository, + ConfigurationRepository configurationRepository, + RoleGuard roleGuard + ) { this.templateTypeRepository = templateTypeRepository; + this.configurationRepository = configurationRepository; this.roleGuard = roleGuard; } @@ -52,4 +64,39 @@ public ResponseEntity create( ); return ResponseEntity.status(HttpStatus.CREATED).body(TemplateTypeResponse.from(created)); } + + @PutMapping("/{id}") + public TemplateTypeResponse update( + @AuthenticationPrincipal AuthenticatedUser authenticatedUser, + @PathVariable UUID id, + @Valid @RequestBody TemplateTypeUpdateRequest request + ) { + roleGuard.requireWriterOrAdmin(authenticatedUser); + TemplateType templateType = templateTypeRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Template type not found")); + + templateType.update(request.name(), request.description()); + return TemplateTypeResponse.from(templateTypeRepository.save(templateType)); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete( + @AuthenticationPrincipal AuthenticatedUser authenticatedUser, + @PathVariable UUID id + ) { + roleGuard.requireWriterOrAdmin(authenticatedUser); + TemplateType templateType = templateTypeRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Template type not found")); + + boolean usedByTemplate = configurationRepository.existsByKindAndTemplateType_IdAndDeletedAtIsNull( + ConfigurationKind.template, + templateType.getId() + ); + if (usedByTemplate) { + throw new ResponseStatusException(CONFLICT, "Template type is used by templates"); + } + + templateTypeRepository.delete(templateType); + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/com/cloudnative/configuration/dto/TemplateTypeUpdateRequest.java b/backend/src/main/java/com/cloudnative/configuration/dto/TemplateTypeUpdateRequest.java new file mode 100644 index 0000000..8f61776 --- /dev/null +++ b/backend/src/main/java/com/cloudnative/configuration/dto/TemplateTypeUpdateRequest.java @@ -0,0 +1,13 @@ +package com.cloudnative.configuration.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record TemplateTypeUpdateRequest( + @NotBlank(message = "name is required") + @Size(max = 100, message = "name must be at most 100 characters") + String name, + + String description +) { +} diff --git a/backend/src/test/java/com/cloudnative/configuration/TemplateTypeControllerTest.java b/backend/src/test/java/com/cloudnative/configuration/TemplateTypeControllerTest.java new file mode 100644 index 0000000..385e350 --- /dev/null +++ b/backend/src/test/java/com/cloudnative/configuration/TemplateTypeControllerTest.java @@ -0,0 +1,179 @@ +package com.cloudnative.configuration; + +import com.cloudnative.identity.security.AuthenticatedUser; +import com.cloudnative.identity.security.RoleGuard; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(TemplateTypeController.class) +@AutoConfigureMockMvc(addFilters = false) +class TemplateTypeControllerTest { + private static final UUID USER_ID = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static final UUID TYPE_ID = UUID.fromString("11111111-1111-1111-1111-111111111111"); + + @Autowired + private MockMvc mockMvc; + + @MockBean + private TemplateTypeRepository templateTypeRepository; + + @MockBean + private ConfigurationRepository configurationRepository; + + @MockBean + private RoleGuard roleGuard; + + @BeforeEach + void setUp() { + doReturn(USER_ID).when(roleGuard).requireWriterOrAdmin(nullable(AuthenticatedUser.class)); + } + + @Test + void listsTemplateTypes() throws Exception { + when(templateTypeRepository.findAllByOrderByNameAsc()) + .thenReturn(List.of(templateType(TYPE_ID, "db", "Database Config", "Database settings"))); + + mockMvc.perform(get("/api/template-types")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(TYPE_ID.toString())) + .andExpect(jsonPath("$[0].code").value("db")) + .andExpect(jsonPath("$[0].name").value("Database Config")); + } + + @Test + void createsTemplateType() throws Exception { + when(templateTypeRepository.existsByCode("cache")).thenReturn(false); + when(templateTypeRepository.save(any(TemplateType.class))).thenAnswer(invocation -> { + TemplateType saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", TYPE_ID); + ReflectionTestUtils.setField(saved, "createdAt", LocalDateTime.of(2026, 5, 28, 10, 0)); + return saved; + }); + + mockMvc.perform(post("/api/template-types") + .contentType(APPLICATION_JSON) + .content(""" + { + "code": "cache", + "name": "Cache Config", + "description": "Cache settings" + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(TYPE_ID.toString())) + .andExpect(jsonPath("$.code").value("cache")) + .andExpect(jsonPath("$.name").value("Cache Config")) + .andExpect(jsonPath("$.description").value("Cache settings")); + } + + @Test + void rejectsDuplicateTemplateTypeCode() throws Exception { + when(templateTypeRepository.existsByCode("db")).thenReturn(true); + + mockMvc.perform(post("/api/template-types") + .contentType(APPLICATION_JSON) + .content(""" + { + "code": "db", + "name": "Database Config", + "description": "Duplicate" + } + """)) + .andExpect(status().isConflict()); + } + + @Test + void updatesTemplateTypeNameAndDescriptionWithoutChangingCode() throws Exception { + TemplateType existing = templateType(TYPE_ID, "db", "Database Config", "Database settings"); + when(templateTypeRepository.findById(TYPE_ID)).thenReturn(Optional.of(existing)); + when(templateTypeRepository.save(any(TemplateType.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + mockMvc.perform(put("/api/template-types/{id}", TYPE_ID) + .contentType(APPLICATION_JSON) + .content(""" + { + "name": "Database Defaults", + "description": "Updated database settings" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("db")) + .andExpect(jsonPath("$.name").value("Database Defaults")) + .andExpect(jsonPath("$.description").value("Updated database settings")); + } + + @Test + void deletesUnusedTemplateType() throws Exception { + TemplateType existing = templateType(TYPE_ID, "cache", "Cache Config", null); + when(templateTypeRepository.findById(TYPE_ID)).thenReturn(Optional.of(existing)); + when(configurationRepository.existsByKindAndTemplateType_IdAndDeletedAtIsNull( + ConfigurationKind.template, TYPE_ID)).thenReturn(false); + + mockMvc.perform(delete("/api/template-types/{id}", TYPE_ID)) + .andExpect(status().isNoContent()); + + verify(templateTypeRepository).delete(existing); + } + + @Test + void rejectsDeleteWhenTemplateTypeIsUsedByTemplate() throws Exception { + TemplateType existing = templateType(TYPE_ID, "db", "Database Config", null); + when(templateTypeRepository.findById(TYPE_ID)).thenReturn(Optional.of(existing)); + when(configurationRepository.existsByKindAndTemplateType_IdAndDeletedAtIsNull( + ConfigurationKind.template, TYPE_ID)).thenReturn(true); + + mockMvc.perform(delete("/api/template-types/{id}", TYPE_ID)) + .andExpect(status().isConflict()); + } + + @Test + void appliesRoleGuardToMutatingRequests() throws Exception { + doThrow(new ResponseStatusException(FORBIDDEN, "writer or admin role is required")) + .when(roleGuard).requireWriterOrAdmin(nullable(AuthenticatedUser.class)); + + mockMvc.perform(post("/api/template-types") + .contentType(APPLICATION_JSON) + .content(""" + { + "code": "cache", + "name": "Cache Config", + "description": "Cache settings" + } + """)) + .andExpect(status().isForbidden()); + } + + private TemplateType templateType(UUID id, String code, String name, String description) { + TemplateType templateType = new TemplateType(code, name, description); + ReflectionTestUtils.setField(templateType, "id", id); + ReflectionTestUtils.setField(templateType, "createdAt", LocalDateTime.of(2026, 5, 28, 10, 0)); + return templateType; + } +} diff --git a/frontend/src/PrototypeUI.jsx b/frontend/src/PrototypeUI.jsx index 0c5feba..f04660c 100644 --- a/frontend/src/PrototypeUI.jsx +++ b/frontend/src/PrototypeUI.jsx @@ -7,9 +7,12 @@ import { useAutoDismiss } from './hooks/useAutoDismiss.js'; import { fetchConfigurations } from './api/configurations.js'; import { createTemplate, + createTemplateType, + deleteTemplateType, fetchTemplateImpact, fetchTemplateTypes, fetchTemplates, + updateTemplateType, } from './api/templates.js'; import { createChangeRequest } from './api/changeRequests.js'; import { @@ -243,6 +246,28 @@ export default function PrototypeUI() { return created; }; + const handleCreateTemplateType = async (payload) => { + const created = await createTemplateType(payload, token); + setTemplateTypes((current) => [...current, created].sort((left, right) => left.name.localeCompare(right.name))); + setNotification(`Template type "${created.name}" created.`); + return created; + }; + + const handleUpdateTemplateType = async (id, payload) => { + const updated = await updateTemplateType(id, payload, token); + setTemplateTypes((current) => current + .map((type) => (type.id === updated.id ? updated : type)) + .sort((left, right) => left.name.localeCompare(right.name))); + setNotification(`Template type "${updated.name}" updated.`); + return updated; + }; + + const handleDeleteTemplateType = async (templateType) => { + await deleteTemplateType(templateType.id, token); + setTemplateTypes((current) => current.filter((type) => type.id !== templateType.id)); + setNotification(`Template type "${templateType.name}" deleted.`); + }; + const handleUpdateTemplate = async (payload) => { const changeRequest = await createChangeRequest( { @@ -451,6 +476,9 @@ export default function PrototypeUI() { onCreateTemplate={handleCreateTemplate} onUpdateTemplate={handleUpdateTemplate} onDeleteTemplate={handleDeleteTemplate} + onCreateTemplateType={handleCreateTemplateType} + onUpdateTemplateType={handleUpdateTemplateType} + onDeleteTemplateType={handleDeleteTemplateType} onCreateProject={handleCreateProject} onCloneProject={handleCloneProject} onUpdateProject={handleUpdateProject} @@ -520,6 +548,9 @@ function PageRouter({ onCreateTemplate, onUpdateTemplate, onDeleteTemplate, + onCreateTemplateType, + onUpdateTemplateType, + onDeleteTemplateType, onCreateProject, onCloneProject, onUpdateProject, @@ -579,6 +610,9 @@ function PageRouter({ onCreateTemplate={onCreateTemplate} onUpdateTemplate={onUpdateTemplate} onDeleteTemplate={onDeleteTemplate} + onCreateTemplateType={onCreateTemplateType} + onUpdateTemplateType={onUpdateTemplateType} + onDeleteTemplateType={onDeleteTemplateType} templateTypes={templateTypes} templateTypesError={templateTypesError} projects={projects} diff --git a/frontend/src/api/templates.js b/frontend/src/api/templates.js index 042cf58..e0ccba2 100644 --- a/frontend/src/api/templates.js +++ b/frontend/src/api/templates.js @@ -70,6 +70,24 @@ export async function createTemplateType({ code, name, description }, token) { }); } +export async function updateTemplateType(id, { name, description }, token) { + return httpRequest(`/api/template-types/${id}`, { + method: 'PUT', + token, + headers: { + Accept: 'application/json', + }, + body: { name, description }, + }); +} + +export async function deleteTemplateType(id, token) { + return httpRequest(`/api/template-types/${id}`, { + method: 'DELETE', + token, + }); +} + function formatLabel(format) { return String(format ?? '').toUpperCase(); } diff --git a/frontend/src/pages/Templates.jsx b/frontend/src/pages/Templates.jsx index 3b8fd77..f848e55 100644 --- a/frontend/src/pages/Templates.jsx +++ b/frontend/src/pages/Templates.jsx @@ -39,6 +39,9 @@ export function Templates({ onCreateTemplate, onUpdateTemplate, onDeleteTemplate, + onCreateTemplateType, + onUpdateTemplateType, + onDeleteTemplateType, setNotification, onOpenImpact, onProposeChange, @@ -50,6 +53,7 @@ export function Templates({ const [activeTab, setActiveTab] = useState('definition'); const [showCreate, setShowCreate] = useState(false); const [showImport, setShowImport] = useState(false); + const [showManageTypes, setShowManageTypes] = useState(false); const [editingTemplate, setEditingTemplate] = useState(null); const [applyingTemplate, setApplyingTemplate] = useState(null); const [query, setQuery] = useState(''); @@ -146,6 +150,31 @@ export function Templates({ setShowCreate(false); }; + const handleCreateTemplateType = async (payload) => { + const created = await onCreateTemplateType(payload); + const createdId = getTemplateTypeId(created); + if (createdId) { + setTypeFilter(createdId); + } + return created; + }; + + const handleUpdateTemplateType = async (type, payload) => { + const updated = await onUpdateTemplateType(type.id, payload); + const updatedId = getTemplateTypeId(updated); + if (typeFilter === getTemplateTypeId(type) && updatedId) { + setTypeFilter(updatedId); + } + return updated; + }; + + const handleDeleteTemplateType = async (type) => { + await onDeleteTemplateType(type); + if (typeFilter === getTemplateTypeId(type)) { + setTypeFilter('all'); + } + }; + const handleUpdate = async (templateUpdate) => { setTemplateActionError(null); setEditingTemplate(null); @@ -256,14 +285,24 @@ export function Templates({ /> -
-

- Step 1 -

-

Choose Template Type

-

- Each type represents one config component. Pick a type to see the value variants that belong to it. -

+
+
+

+ Step 1 +

+

Choose Template Type

+

+ Each type represents one config component. Pick a type to see the value variants that belong to it. +

+
+
+ + {showManageTypes && canCreateTemplate ? ( + setShowManageTypes(false)} + onCreate={handleCreateTemplateType} + onUpdate={handleUpdateTemplateType} + onDelete={handleDeleteTemplateType} + /> + ) : null} + + {showCreate && canCreateTemplate ? ( { + const counts = new Map(); + templates.forEach((template) => { + const id = getTemplateTypeId(template.templateType); + if (!id) return; + counts.set(id, (counts.get(id) ?? 0) + 1); + }); + return counts; + }, [templates]); + + const updateCreateDraft = (field, value) => { + setCreateDraft((current) => ({ ...current, [field]: value })); + setError(''); + }; + + const submitCreate = async () => { + const payload = { + code: createDraft.code.trim(), + name: createDraft.name.trim(), + description: createDraft.description.trim(), + }; + if (!payload.code || !payload.name) { + setError('Code and name are required.'); + return; + } + + setPendingAction('create'); + setError(''); + try { + await onCreate(payload); + setCreateDraft({ code: '', name: '', description: '' }); + } catch (createError) { + setError(createError.message ?? 'Failed to create template type.'); + } finally { + setPendingAction(''); + } + }; + + const startEdit = (type) => { + setEditingId(type.id); + setEditDraft({ name: type.name ?? '', description: type.description ?? '' }); + setError(''); + }; + + const submitEdit = async (type) => { + const payload = { + name: editDraft.name.trim(), + description: editDraft.description.trim(), + }; + if (!payload.name) { + setError('Name is required.'); + return; + } + + setPendingAction(`edit:${type.id}`); + setError(''); + try { + await onUpdate(type, payload); + setEditingId(null); + } catch (updateError) { + setError(updateError.message ?? 'Failed to update template type.'); + } finally { + setPendingAction(''); + } + }; + + const submitDelete = async (type) => { + setPendingAction(`delete:${type.id}`); + setError(''); + try { + await onDelete(type); + } catch (deleteError) { + setError(deleteError.message ?? 'Failed to delete template type.'); + } finally { + setPendingAction(''); + } + }; + + return ( + <> + + +
+
+

Manage Template Types

+

+ Create categories, rename descriptions, and delete unused types. +

+
+ +
+ +
+
+
+

Create Type

+

Code is permanent after creation.

+
+
+ updateCreateDraft('code', event.target.value)} + placeholder="code" + className="px-3 py-2 rounded-lg border border-slate-200 text-sm focus:ring-2 focus:ring-indigo-500/20 focus:outline-none" + /> + updateCreateDraft('name', event.target.value)} + placeholder="Type name" + className="px-3 py-2 rounded-lg border border-slate-200 text-sm focus:ring-2 focus:ring-indigo-500/20 focus:outline-none" + /> +
+