From 5706dda4b6d602cfeac6542a2ccf38f350053156 Mon Sep 17 00:00:00 2001 From: DDK1127 Date: Thu, 28 May 2026 15:12:07 +0800 Subject: [PATCH] Reuse template detail actions in project scopes --- frontend/src/PrototypeUI.jsx | 3 + frontend/src/pages/Projects.jsx | 185 +++++++++++++++++++++++++++++++- 2 files changed, 184 insertions(+), 4 deletions(-) diff --git a/frontend/src/PrototypeUI.jsx b/frontend/src/PrototypeUI.jsx index 0c5feba..69cabb9 100644 --- a/frontend/src/PrototypeUI.jsx +++ b/frontend/src/PrototypeUI.jsx @@ -609,6 +609,9 @@ function PageRouter({ onUpdateProject={onUpdateProject} onDeleteProject={onDeleteProject} onUpdateProjectTemplates={onUpdateProjectTemplates} + onUpdateTemplate={onUpdateTemplate} + onDeleteTemplate={onDeleteTemplate} + onFetchTemplateImpact={fetchTemplateImpact} onAddProjectScope={onAddProjectScope} onRemoveProjectScope={onRemoveProjectScope} onCreateOverrideRequest={onCreateOverrideRequest} diff --git a/frontend/src/pages/Projects.jsx b/frontend/src/pages/Projects.jsx index 3837edc..d5e038b 100644 --- a/frontend/src/pages/Projects.jsx +++ b/frontend/src/pages/Projects.jsx @@ -24,6 +24,7 @@ import { fetchProjectScopeConfig } from '../api/projects.js'; import { configRows } from '../data/configs.js'; import { formatConfigContent, formatParsedContent } from '../lib/formatConfigContent.js'; import { overridesAtScope } from '../lib/scope.js'; +import { TemplateChangeRequestModal, TemplateDetailModal, TemplateFormModal } from './Templates.jsx'; import scrollbar from '../styles/scrollbar.module.css'; function inheritedAtScope(project) { @@ -52,6 +53,9 @@ export function Projects({ onUpdateProject, onDeleteProject, onUpdateProjectTemplates, + onUpdateTemplate, + onDeleteTemplate, + onFetchTemplateImpact, onAddProjectScope, onRemoveProjectScope, onCreateOverrideRequest, @@ -73,6 +77,14 @@ export function Projects({ const [scopeConfigError, setScopeConfigError] = useState(null); const [cloneDraft, setCloneDraft] = useState(null); const [viewingTemplate, setViewingTemplate] = useState(null); + const [templateActiveTab, setTemplateActiveTab] = useState('definition'); + const [editingTemplate, setEditingTemplate] = useState(null); + const [templateChangeRequestDraft, setTemplateChangeRequestDraft] = useState(null); + const [templateActionError, setTemplateActionError] = useState(null); + const [deletingTemplateId, setDeletingTemplateId] = useState(null); + const [impactByTemplateId, setImpactByTemplateId] = useState({}); + const [impactLoadingId, setImpactLoadingId] = useState(null); + const [impactErrorByTemplateId, setImpactErrorByTemplateId] = useState({}); const [editingProject, setEditingProject] = useState(null); const [deletingProject, setDeletingProject] = useState(null); const [editingTemplatesFor, setEditingTemplatesFor] = useState(null); @@ -102,6 +114,38 @@ export function Projects({ const selectedScope = factoryScopes.find( (item) => item.siteName === selectedSiteName && item.environmentName === selectedEnvName, ) ?? null; + const selectedProjectTemplate = viewingTemplate + ? normalizeProjectTemplateForModal(viewingTemplate, templates) + : null; + + useEffect(() => { + if (!selectedProjectTemplate?.id || templateActiveTab !== 'impact' || !onFetchTemplateImpact) return; + if (impactByTemplateId[selectedProjectTemplate.id]) return; + + let cancelled = false; + setImpactLoadingId(selectedProjectTemplate.id); + setImpactErrorByTemplateId((current) => ({ ...current, [selectedProjectTemplate.id]: null })); + + onFetchTemplateImpact(selectedProjectTemplate.id, token) + .then((impact) => { + if (cancelled) return; + setImpactByTemplateId((current) => ({ ...current, [selectedProjectTemplate.id]: impact })); + }) + .catch((error) => { + if (cancelled) return; + setImpactErrorByTemplateId((current) => ({ + ...current, + [selectedProjectTemplate.id]: error.message ?? 'Failed to load template impact.', + })); + }) + .finally(() => { + if (!cancelled) setImpactLoadingId(null); + }); + + return () => { + cancelled = true; + }; + }, [impactByTemplateId, onFetchTemplateImpact, selectedProjectTemplate?.id, templateActiveTab, token]); useEffect(() => { if (selectedProjectName && !projects.some((project) => project.name === selectedProjectName)) { @@ -287,6 +331,64 @@ export function Projects({ setShowClone(false); }; + const handleOpenProjectTemplate = (template) => { + setViewingTemplate(template); + setTemplateActiveTab('definition'); + setTemplateActionError(null); + }; + + const handleEditProjectTemplate = () => { + if (!selectedProjectTemplate) return; + setTemplateActionError(null); + setEditingTemplate(selectedProjectTemplate); + }; + + const handleDeleteProjectTemplate = () => { + if (!selectedProjectTemplate || !onDeleteTemplate || deletingTemplateId) return; + setTemplateActionError(null); + setTemplateChangeRequestDraft({ + action: 'delete', + template: selectedProjectTemplate, + payload: null, + }); + }; + + const handleSubmitTemplateEdit = async (templateUpdate) => { + setTemplateActionError(null); + setEditingTemplate(null); + setTemplateChangeRequestDraft({ + action: 'update', + template: selectedProjectTemplate, + payload: templateUpdate, + }); + return templateUpdate; + }; + + const submitTemplateChangeRequest = async ({ reason }) => { + if (!templateChangeRequestDraft) return null; + const { action, template, payload } = templateChangeRequestDraft; + setTemplateActionError(null); + if (action === 'delete') { + setDeletingTemplateId(template.id); + } + + try { + const result = action === 'delete' + ? await onDeleteTemplate(template, reason) + : await onUpdateTemplate({ ...payload, reason }); + setTemplateChangeRequestDraft(null); + setViewingTemplate(null); + return result; + } catch (error) { + setTemplateActionError(error.message ?? 'Failed to open template change request.'); + throw error; + } finally { + if (action === 'delete') { + setDeletingTemplateId(null); + } + } + }; + return (
@@ -456,10 +558,44 @@ export function Projects({
- {viewingTemplate ? ( - setViewingTemplate(null)} + onEdit={handleEditProjectTemplate} + onDelete={handleDeleteProjectTemplate} + onOpenImpact={onOpenImpact} + onProposeChange={onProposeChange} + canManageTemplate={canCreateProject && Boolean(onUpdateTemplate)} + actionError={templateActionError} + deleting={deletingTemplateId === selectedProjectTemplate.id} + exactImpact={impactByTemplateId[selectedProjectTemplate.id]} + impactLoading={impactLoadingId === selectedProjectTemplate.id} + impactError={impactErrorByTemplateId[selectedProjectTemplate.id]} + /> + ) : null} + + {editingTemplate && canCreateProject && onUpdateTemplate ? ( + { + setTemplateActionError(null); + setEditingTemplate(null); + }} + onSubmit={handleSubmitTemplateEdit} + templateTypes={templateTypes} + /> + ) : null} + + {templateChangeRequestDraft ? ( + setTemplateChangeRequestDraft(null)} + onSubmit={submitTemplateChangeRequest} /> ) : null} @@ -1488,6 +1624,47 @@ function parseConfigPath(path) { )); } +function normalizeProjectTemplateForModal(section, templates = []) { + const templateId = section.templateId ?? section.id; + const sourceTemplate = templates.find((template) => template.id === templateId) ?? {}; + const rawKeys = section.keys ?? sourceTemplate.keys ?? (section.keyPaths ?? sourceTemplate.keyPaths ?? []).map((key) => ({ + key, + defaultValue: getValueAtPath(section.parsedContent ?? sourceTemplate.parsedContent, key), + })); + const keys = rawKeys.map((item) => ({ + key: item.key, + defaultValue: item.effectiveValue ?? item.defaultValue ?? item.baseValue ?? '-', + effectiveValue: item.effectiveValue ?? item.defaultValue ?? item.baseValue ?? '-', + baseValue: item.baseValue ?? getValueAtPath(section.parsedContent ?? sourceTemplate.parsedContent, item.key), + overrideValue: item.overrideValue ?? null, + rawOverrideValue: item.rawOverrideValue, + overridden: Boolean(item.overridden), + overridable: item.overridable ?? true, + })); + + return { + ...sourceTemplate, + ...section, + id: templateId, + name: section.templateName ?? sourceTemplate.name ?? 'Selected template', + templateName: section.templateName ?? sourceTemplate.name, + format: section.format ?? sourceTemplate.format, + status: section.status ?? sourceTemplate.status ?? 'active', + version: section.version ?? sourceTemplate.version ?? 1, + updatedAt: section.updatedAt ?? sourceTemplate.updatedAt, + rawContent: section.rawContent ?? sourceTemplate.rawContent ?? '', + parsedContent: section.parsedContent ?? sourceTemplate.parsedContent ?? {}, + keyPaths: section.keyPaths ?? sourceTemplate.keyPaths ?? keys.map((item) => item.key), + keys, + keysCount: keys.length, + templateType: section.templateType ?? sourceTemplate.templateType, + description: sourceTemplate.description ?? [ + section.cloneSource ? `Source scope: ${section.cloneSource.siteName} / ${section.cloneSource.environmentName}` : null, + section.priority !== undefined ? `Priority ${section.priority}` : null, + ].filter(Boolean).join(' ยท '), + }; +} + function buildTemplateModalKeys(template) { const baseKeys = template.keys ?? (template.keyPaths ?? []).map((key) => ({ key,