From fd6de9963c60ba8d3972a239cbb3918388ad235d Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Mon, 23 Mar 2026 07:31:10 +0700 Subject: [PATCH] Add Xcode Cloud workflow editing - Add WorkflowEditPage with form editing for workflow name, description, environment (Xcode/macOS versions), start conditions (branch, tag, PR, scheduled), and actions - Add GET/PATCH endpoints for single workflow with version resolution - Add tag start condition display on workflows list - Add CI constants (action types, platforms, schedule frequencies) - Handle manual start conditions passthrough to prevent data loss on save --- server/routes/xcode-cloud.js | 179 ++++- src/api/index.js | 28 + src/components/AppStoreManager.jsx | 52 ++ src/components/WorkflowEditPage.jsx | 1094 +++++++++++++++++++++++++++ src/components/XcodeCloudPage.jsx | 15 +- src/constants/index.js | 31 + 6 files changed, 1395 insertions(+), 4 deletions(-) create mode 100644 src/components/WorkflowEditPage.jsx diff --git a/server/routes/xcode-cloud.js b/server/routes/xcode-cloud.js index 8ec0d32..c91b7c2 100644 --- a/server/routes/xcode-cloud.js +++ b/server/routes/xcode-cloud.js @@ -54,19 +54,40 @@ function normalizeBuildRun(item) { }; } -function normalizeWorkflow(item) { +function normalizeWorkflow(item, included) { const a = item.attributes || {}; - return { + const workflow = { id: item.id, name: a.name, description: a.description || "", branchStartCondition: a.branchStartCondition || null, + tagStartCondition: a.tagStartCondition || null, pullRequestStartCondition: a.pullRequestStartCondition || null, scheduledStartCondition: a.scheduledStartCondition || null, + manualBranchStartCondition: a.manualBranchStartCondition || null, + manualTagStartCondition: a.manualTagStartCondition || null, + manualPullRequestStartCondition: a.manualPullRequestStartCondition || null, actions: a.actions || [], isEnabled: a.isEnabled ?? true, + clean: a.clean ?? false, + containerFilePath: a.containerFilePath || "", + isLockedForEditing: a.isLockedForEditing ?? false, lastModifiedDate: a.lastModifiedDate, }; + + if (included) { + const rels = item.relationships || {}; + if (rels.xcodeVersion?.data?.id) { + const xv = included.find((i) => i.type === "ciXcodeVersions" && i.id === rels.xcodeVersion.data.id); + if (xv) workflow.xcodeVersion = { id: xv.id, name: xv.attributes?.name, version: xv.attributes?.version }; + } + if (rels.macOsVersion?.data?.id) { + const mv = included.find((i) => i.type === "ciMacOsVersions" && i.id === rels.macOsVersion.data.id); + if (mv) workflow.macOsVersion = { id: mv.id, name: mv.attributes?.name, version: mv.attributes?.version }; + } + } + + return workflow; } // ── Build Runs ────────────────────────────────────────────────────────────── @@ -139,6 +160,160 @@ router.get("/:appId/xcode-cloud/workflows", async (req, res) => { } }); +// ── Single Workflow ────────────────────────────────────────────────────────── + +router.get("/:appId/xcode-cloud/workflows/:workflowId", async (req, res) => { + const { workflowId } = req.params; + const account = resolveAccount(req, res); + if (!account) return; + + const cacheKey = `ci:workflow:${workflowId}:${account.id}`; + const cached = apiCache.get(cacheKey); + if (cached) return res.json(cached); + + try { + const data = await ascFetch( + account, + `/v1/ciWorkflows/${workflowId}?include=xcodeVersion,macOsVersion` + ); + const result = normalizeWorkflow(data.data, data.included || []); + apiCache.set(cacheKey, result); + res.json(result); + } catch (err) { + console.error(`Failed to fetch workflow ${workflowId}:`, err.message); + res.status(502).json({ error: err.message }); + } +}); + +// ── Update Workflow ───────────────────────────────────────────────────────── + +router.patch("/:appId/xcode-cloud/workflows/:workflowId", async (req, res) => { + const { appId, workflowId } = req.params; + const account = resolveAccount(req, res); + if (!account) return; + + try { + const { + accountId: _a, name, description, isEnabled, clean, + isLockedForEditing, containerFilePath, actions, + branchStartCondition, tagStartCondition, + pullRequestStartCondition, scheduledStartCondition, + manualBranchStartCondition, manualTagStartCondition, + manualPullRequestStartCondition, + xcodeVersionId, macOsVersionId, + } = req.body; + + const attributes = {}; + if (name !== undefined) attributes.name = name; + if (description !== undefined) attributes.description = description; + if (isEnabled !== undefined) attributes.isEnabled = isEnabled; + if (clean !== undefined) attributes.clean = clean; + if (isLockedForEditing !== undefined) attributes.isLockedForEditing = isLockedForEditing; + if (containerFilePath !== undefined) attributes.containerFilePath = containerFilePath; + if (actions !== undefined) attributes.actions = actions; + if (branchStartCondition !== undefined) attributes.branchStartCondition = branchStartCondition; + if (tagStartCondition !== undefined) attributes.tagStartCondition = tagStartCondition; + if (pullRequestStartCondition !== undefined) attributes.pullRequestStartCondition = pullRequestStartCondition; + if (scheduledStartCondition !== undefined) attributes.scheduledStartCondition = scheduledStartCondition; + if (manualBranchStartCondition !== undefined) attributes.manualBranchStartCondition = manualBranchStartCondition; + if (manualTagStartCondition !== undefined) attributes.manualTagStartCondition = manualTagStartCondition; + if (manualPullRequestStartCondition !== undefined) attributes.manualPullRequestStartCondition = manualPullRequestStartCondition; + + const relationships = {}; + if (xcodeVersionId) { + relationships.xcodeVersion = { data: { type: "ciXcodeVersions", id: xcodeVersionId } }; + } + if (macOsVersionId) { + relationships.macOsVersion = { data: { type: "ciMacOsVersions", id: macOsVersionId } }; + } + + const body = { + data: { + type: "ciWorkflows", + id: workflowId, + attributes, + ...(Object.keys(relationships).length > 0 && { relationships }), + }, + }; + + const data = await ascFetch(account, `/v1/ciWorkflows/${workflowId}`, { + method: "PATCH", + body, + }); + + // Invalidate caches + apiCache.delete(`ci:workflows:${appId}:${account.id}`); + apiCache.delete(`ci:workflow:${workflowId}:${account.id}`); + + const result = normalizeWorkflow(data.data, data.included || []); + res.json(result); + } catch (err) { + console.error(`Failed to update workflow ${workflowId}:`, err.message); + res.status(502).json({ error: err.message }); + } +}); + +// ── Xcode & macOS Versions ────────────────────────────────────────────────── + +const VERSION_TTL = 60 * 60 * 1000; // 1 hour + +router.get("/:appId/xcode-cloud/xcode-versions", async (req, res) => { + const account = resolveAccount(req, res); + if (!account) return; + + const cacheKey = `ci:xcode-versions:${account.id}`; + const cached = apiCache.get(cacheKey); + if (cached) return res.json(cached); + + try { + const data = await ascFetch(account, `/v1/ciXcodeVersions?include=macOsVersions&limit=50`); + const result = (data.data || []).map((item) => { + const a = item.attributes || {}; + const macOsVersionIds = (item.relationships?.macOsVersions?.data || []).map((r) => r.id); + const macOsVersions = macOsVersionIds + .map((id) => { + const mv = (data.included || []).find((i) => i.type === "ciMacOsVersions" && i.id === id); + return mv ? { id: mv.id, name: mv.attributes?.name, version: mv.attributes?.version } : null; + }) + .filter(Boolean); + return { + id: item.id, + name: a.name, + version: a.version, + testDestinations: a.testDestinations || [], + macOsVersions, + }; + }); + apiCache.set(cacheKey, result, VERSION_TTL); + res.json(result); + } catch (err) { + console.error("Failed to fetch Xcode versions:", err.message); + res.status(502).json({ error: err.message }); + } +}); + +router.get("/:appId/xcode-cloud/macos-versions", async (req, res) => { + const account = resolveAccount(req, res); + if (!account) return; + + const cacheKey = `ci:macos-versions:${account.id}`; + const cached = apiCache.get(cacheKey); + if (cached) return res.json(cached); + + try { + const data = await ascFetch(account, `/v1/ciMacOsVersions?limit=50`); + const result = (data.data || []).map((item) => { + const a = item.attributes || {}; + return { id: item.id, name: a.name, version: a.version }; + }); + apiCache.set(cacheKey, result, VERSION_TTL); + res.json(result); + } catch (err) { + console.error("Failed to fetch macOS versions:", err.message); + res.status(502).json({ error: err.message }); + } +}); + // ── Build Actions ──────────────────────────────────────────────────────────── router.get("/:appId/xcode-cloud/builds/:buildId/actions", async (req, res) => { diff --git a/src/api/index.js b/src/api/index.js index 9d6aafb..8ad02d0 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -473,3 +473,31 @@ export async function fetchCiWorkflows(appId, accountId) { if (!res.ok) throw new Error(`Failed to fetch CI workflows: ${res.status}`); return res.json(); } + +export async function fetchCiWorkflowDetail(appId, workflowId, accountId) { + const params = new URLSearchParams({ accountId }); + const res = await fetch(`/api/apps/${appId}/xcode-cloud/workflows/${workflowId}?${params}`); + if (!res.ok) throw new Error(`Failed to fetch workflow detail: ${res.status}`); + return res.json(); +} + +export async function updateCiWorkflow(appId, workflowId, payload) { + const res = await fetch(`/api/apps/${appId}/xcode-cloud/workflows/${workflowId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Failed to update workflow: ${res.status}`); + } + return res.json(); +} + +export async function fetchCiXcodeVersions(appId, accountId) { + const params = new URLSearchParams({ accountId }); + const res = await fetch(`/api/apps/${appId}/xcode-cloud/xcode-versions?${params}`); + if (!res.ok) throw new Error(`Failed to fetch Xcode versions: ${res.status}`); + return res.json(); +} + diff --git a/src/components/AppStoreManager.jsx b/src/components/AppStoreManager.jsx index 381ce9e..f4b8ac9 100644 --- a/src/components/AppStoreManager.jsx +++ b/src/components/AppStoreManager.jsx @@ -11,6 +11,7 @@ import VersionDetailPage from "./VersionDetailPage.jsx"; import ProductsPage from "./ProductsPage.jsx"; import XcodeCloudPage from "./XcodeCloudPage.jsx"; import BuildDetailPage from "./BuildDetailPage.jsx"; +import WorkflowEditPage from "./WorkflowEditPage.jsx"; function buildGroups(apps, groupBy, accounts) { if (groupBy === "none") return [{ key: "all", label: null, apps }]; @@ -42,6 +43,8 @@ function getRouteFromPath() { if (versionMatch) return { appId: versionMatch[1], versionId: versionMatch[2], subPage: null }; const productsMatch = path.match(/^\/app\/([^/]+)\/products$/); if (productsMatch) return { appId: productsMatch[1], versionId: null, subPage: "products" }; + const xcodeCloudWorkflowMatch = path.match(/^\/app\/([^/]+)\/xcode-cloud\/workflow\/([^/]+)$/); + if (xcodeCloudWorkflowMatch) return { appId: xcodeCloudWorkflowMatch[1], versionId: null, subPage: "xcode-cloud-workflow", workflowId: xcodeCloudWorkflowMatch[2] }; const xcodeCloudBuildMatch = path.match(/^\/app\/([^/]+)\/xcode-cloud\/build\/([^/]+)$/); if (xcodeCloudBuildMatch) return { appId: xcodeCloudBuildMatch[1], versionId: null, subPage: "xcode-cloud-build", buildId: xcodeCloudBuildMatch[2] }; const xcodeCloudMatch = path.match(/^\/app\/([^/]+)\/xcode-cloud$/); @@ -110,6 +113,20 @@ export default function AppStoreManager() { }, []); const [selectedBuildRun, setSelectedBuildRun] = useState(null); + const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); + + const navigateToWorkflowEdit = useCallback((workflow, app) => { + setSelectedApp(app); + setSelectedVersion(null); + setSelectedBuildRun(null); + setSelectedWorkflowId(workflow.id); + setCurrentView("xcode-cloud-workflow"); + window.history.pushState( + { appId: app.id, workflowId: workflow.id, subPage: "xcode-cloud-workflow" }, + "", + `/app/${app.id}/xcode-cloud/workflow/${workflow.id}` + ); + }, []); const navigateToXcodeCloudBuild = useCallback((buildRun, app) => { setSelectedApp(app); @@ -150,6 +167,13 @@ export default function AppStoreManager() { setSelectedApp(appMatch); setCurrentView("products"); } + } else if (route.subPage === "xcode-cloud-workflow") { + const appMatch = appsList.find((a) => a.id === route.appId); + if (appMatch) { + setSelectedApp(appMatch); + setSelectedWorkflowId(route.workflowId); + setCurrentView("xcode-cloud-workflow"); + } } else if (route.subPage === "xcode-cloud-build") { const appMatch = appsList.find((a) => a.id === route.appId); if (appMatch) { @@ -221,6 +245,21 @@ export default function AppStoreManager() { return; } + if (route.subPage === "xcode-cloud-workflow") { + const appMatch = apps.find((a) => a.id === route.appId); + if (appMatch) { + setSelectedApp(appMatch); + setSelectedWorkflowId(route.workflowId); + setSelectedBuildRun(null); + setCurrentView("xcode-cloud-workflow"); + } else { + setSelectedApp(null); + setSelectedWorkflowId(null); + setCurrentView(null); + } + return; + } + if (route.subPage === "xcode-cloud-build") { const appMatch = apps.find((a) => a.id === route.appId); if (appMatch) { @@ -298,6 +337,18 @@ export default function AppStoreManager() { ); } + if (currentView === "xcode-cloud-workflow" && selectedApp && selectedWorkflowId) { + return ( +
+ +
+ ); + } + if (currentView === "xcode-cloud-build" && selectedApp && selectedBuildRun) { return (
@@ -319,6 +370,7 @@ export default function AppStoreManager() { accounts={accounts} isMobile={isMobile} onSelectBuild={(buildRun) => navigateToXcodeCloudBuild(buildRun, selectedApp)} + onSelectWorkflow={(workflow) => navigateToWorkflowEdit(workflow, selectedApp)} />
); diff --git a/src/components/WorkflowEditPage.jsx b/src/components/WorkflowEditPage.jsx new file mode 100644 index 0000000..2f9bebd --- /dev/null +++ b/src/components/WorkflowEditPage.jsx @@ -0,0 +1,1094 @@ +import { useState, useEffect, useCallback, useMemo } from "react"; +import { + fetchCiWorkflowDetail, + updateCiWorkflow, + fetchCiXcodeVersions, +} from "../api/index.js"; +import { + CI_ACTION_TYPES, + CI_PLATFORMS, + CI_SCHEDULE_FREQUENCIES, + CI_DAYS_OF_WEEK, +} from "../constants/index.js"; +import AppIcon from "./AppIcon.jsx"; + +function deepClone(obj) { + return JSON.parse(JSON.stringify(obj)); +} + +// ── Reusable form controls ────────────────────────────────────────────────── + +function FormField({ label, children }) { + return ( +
+ + {children} +
+ ); +} + +function TextInput({ value, onChange, placeholder, ...props }) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + className="w-full bg-dark-hover border border-dark-border rounded-lg px-3 py-2 text-[13px] text-dark-text placeholder:text-dark-ghost font-sans focus:outline-none focus:border-accent" + {...props} + /> + ); +} + +function TextArea({ value, onChange, placeholder, rows = 3 }) { + return ( +