diff --git a/TASKS.md b/TASKS.md index ebb98c0..a4f7885 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,20 +2,20 @@ ## Current -### `feat/frontend-wizard-modularization` +### `feat/frontend-build-pipeline` Status: in_progress DoD: -- `wizard.js` is split into smaller modules by responsibility -- global mutable wizard state is reduced to one explicit shared state object -- protocol-specific logic is separated from navigation/rendering plumbing -- the existing wizard flow keeps working after the split -- targeted wizard e2e coverage stays green +- UI assets are bundled through a lightweight pipeline suitable for the current stack +- local source files remain readable, but shipped JS is no longer served as dozens of unbundled scripts +- vendor dependencies move under package-managed installation where practical +- Docker UI image builds the frontend assets before packaging them into nginx +- existing UI e2e coverage stays green after the build change ## Next -- `feat/frontend-build-pipeline` +- `feat/frontend-css-state-cleanup` ## Backlog diff --git a/apps/ui/html/wizard/index.html b/apps/ui/html/wizard/index.html index 16bfac4..2917ee2 100644 --- a/apps/ui/html/wizard/index.html +++ b/apps/ui/html/wizard/index.html @@ -11,6 +11,9 @@ + + + diff --git a/apps/ui/js/wizard-shell.js b/apps/ui/js/wizard-shell.js new file mode 100644 index 0000000..92f0be7 --- /dev/null +++ b/apps/ui/js/wizard-shell.js @@ -0,0 +1,180 @@ +(function() { + var TOTAL_STEPS = 5; + var CHECK_SVG = ''; + var ARROW_SVG = ''; + + function tKey(key) { + return typeof t === 'function' ? t(key) : key; + } + + function tfKey(key, vars) { + return typeof tf === 'function' ? tf(key, vars) : key; + } + + function step3PanelId() { + if (window.wizardProtocol === 'graphql') return 'step-panel-3-graphql'; + if (window.wizardProtocol === 'grpc') return 'step-panel-3-grpc'; + if (window.wizardProtocol === 'websocket') return 'step-panel-3-websocket'; + if (window.wizardProtocol === 'soap') return 'step-panel-3-soap'; + return 'step-panel-3-rest'; + } + + function stepFile(step) { + return step === 3 ? 'step3-' + window.wizardProtocol + '.html' : 'step' + step + '.html'; + } + + function loadStep(step, callback) { + var panelId = step === 3 ? step3PanelId() : 'step-panel-' + step; + if (document.getElementById(panelId)) { + if (callback) callback(); + return; + } + fetch(stepFile(step)) + .then(function(response) { return response.text(); }) + .then(function(html) { + var container = document.getElementById('step-panel-container'); + if (container) container.insertAdjacentHTML('beforeend', html); + if (typeof applyLang === 'function') applyLang(); + if (callback) callback(); + }) + .catch(function() { + if (callback) callback(); + }); + } + + function doGoToStep(step) { + document.querySelectorAll('.step-number[data-step]').forEach(function(element) { + var stepNumber = Number(element.getAttribute('data-step')) || 0; + element.textContent = tfKey('wizard.step_short', { step: stepNumber }); + }); + + document.querySelectorAll('.step-pane').forEach(function(pane) { pane.style.display = 'none'; }); + var panelId = step === 3 ? step3PanelId() : 'step-panel-' + step; + var pane = document.getElementById(panelId); + if (pane) pane.style.display = ''; + + document.querySelectorAll('.step-item').forEach(function(item, index) { + var stepNumber = index + 1; + item.classList.remove('active', 'done', 'pending'); + var indicator = item.querySelector('.step-indicator'); + var status = item.querySelector('.step-status-text'); + if (stepNumber < step) { + item.classList.add('done'); + if (indicator) indicator.innerHTML = CHECK_SVG; + if (status) status.textContent = tKey('wizard.status.completed'); + } else if (stepNumber === step) { + item.classList.add('active'); + if (indicator) indicator.textContent = stepNumber; + if (status) status.textContent = tKey('wizard.status.in_progress'); + } else { + item.classList.add('pending'); + if (indicator) indicator.textContent = stepNumber; + if (status) status.textContent = tKey('wizard.status.not_started'); + } + }); + + var percent = Math.round((step / TOTAL_STEPS) * 100); + var fill = document.querySelector('.progress-bar-fill'); + if (fill) fill.style.width = percent + '%'; + var percentNode = document.querySelector('.progress-pct'); + if (percentNode) percentNode.textContent = percent + '%'; + + var counter = document.querySelector('[data-step-counter]'); + if (counter) counter.innerHTML = tfKey('wizard.step_of', { step: step, total: TOTAL_STEPS }); + + var backButton = document.querySelector('.btn-back'); + if (backButton) { + if (step === 1) { + backButton.disabled = true; + backButton.style.opacity = '0.35'; + backButton.style.cursor = 'not-allowed'; + } else { + backButton.disabled = false; + backButton.style.opacity = ''; + backButton.style.cursor = ''; + } + } + + var continueButton = document.querySelector('.btn-continue'); + if (continueButton) { + if (step === TOTAL_STEPS) { + var finalLabel = window.wizardMode === 'edit' ? tKey('wizard.button.save_changes') : tKey('wizard.button.create'); + continueButton.innerHTML = finalLabel + ' ' + ARROW_SVG; + continueButton.style.cssText = 'background:#3fb950;border-color:#2ea043;box-shadow:0 1px 4px rgba(63,185,80,0.4);'; + } else { + continueButton.innerHTML = tKey('wizard.button.continue') + ' ' + ARROW_SVG; + continueButton.style.cssText = ''; + } + } + + window.currentStep = step; + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + + function goToStep(step) { + if (step < 1 || step > TOTAL_STEPS) return; + loadStep(step, function() { doGoToStep(step); }); + } + + function loadWizardPanels(steps) { + return steps.reduce(function(chain, step) { + return chain.then(function() { + return new Promise(function(resolve) { + loadStep(step, resolve); + }); + }); + }, Promise.resolve()); + } + + function bindProtocolCards() { + document.querySelectorAll('.protocol-card').forEach(function(card) { + card.addEventListener('click', function() { + document.querySelectorAll('.protocol-card').forEach(function(item) { + item.classList.remove('selected'); + item.setAttribute('aria-checked', 'false'); + }); + card.classList.add('selected'); + card.setAttribute('aria-checked', 'true'); + + var protocol = card.dataset.protocol + || (card.classList.contains('rest') + ? 'rest' + : card.classList.contains('graphql') + ? 'graphql' + : card.classList.contains('grpc') + ? 'grpc' + : card.classList.contains('websocket') + ? 'websocket' + : card.classList.contains('soap') + ? 'soap' + : 'rest'); + + window.wizardProtocol = protocol; + var step3Name = document.getElementById('sidebar-step-3-name'); + var labels = window.CrankWizardState.step3Labels(); + if (step3Name) step3Name.textContent = labels[protocol] || tKey('wizard.step3.rest.label'); + + if (protocol === 'graphql') { + var pathInput = document.getElementById('endpoint-path'); + if (pathInput && pathInput.value === '/v1/leads') pathInput.value = '/graphql'; + } + }); + card.addEventListener('keydown', function(event) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + card.click(); + } + }); + }); + } + + window.CrankWizardShell = { + TOTAL_STEPS: TOTAL_STEPS, + step3PanelId: step3PanelId, + loadStep: loadStep, + doGoToStep: doGoToStep, + goToStep: goToStep, + loadWizardPanels: loadWizardPanels, + bindProtocolCards: bindProtocolCards, + }; +}()); diff --git a/apps/ui/js/wizard-state.js b/apps/ui/js/wizard-state.js new file mode 100644 index 0000000..c3e2158 --- /dev/null +++ b/apps/ui/js/wizard-state.js @@ -0,0 +1,131 @@ +(function() { + function tKey(key) { + return typeof t === 'function' ? t(key) : key; + } + + var state = { + currentStep: 1, + wizardProtocol: 'rest', + wizardMode: 'create', + wizardEditId: null, + wizardWorkspaceId: null, + wizardCurrentOperation: null, + wizardCurrentVersion: null, + wizardProtoUpload: null, + wizardDescriptorSetUpload: null, + wizardSoapWsdlUpload: null, + wizardSoapXsdUpload: null, + wizardTestResponsePreview: null, + grpcDescriptorServices: [], + soapServiceCatalog: [], + wizardProtocolCapabilities: null, + wizardSecrets: [], + wizardAuthProfiles: [], + selectedUpstreamId: null, + editingUpstreamId: null, + protoParsed: null, + selectedRpcMethod: null, + }; + + function defaultProtocolCapabilities() { + return { + rest: { + supports_execution_modes: ['unary', 'window', 'session', 'async_job'], + supports_transport_behaviors: ['request_response', 'server_stream', 'deferred_result'], + }, + graphql: { + supports_execution_modes: ['unary'], + supports_transport_behaviors: ['request_response'], + }, + grpc: { + supports_execution_modes: ['unary', 'window', 'session', 'async_job'], + supports_transport_behaviors: ['request_response', 'server_stream'], + }, + websocket: { + supports_execution_modes: ['window', 'session', 'async_job'], + supports_transport_behaviors: ['server_stream', 'stateful_session', 'deferred_result'], + }, + soap: { + supports_execution_modes: ['unary', 'async_job'], + supports_transport_behaviors: ['request_response', 'server_stream', 'deferred_result'], + }, + }; + } + + function currentProtocolCapabilities() { + var capabilities = state.wizardProtocolCapabilities || defaultProtocolCapabilities(); + return capabilities[state.wizardProtocol] || capabilities.rest; + } + + async function loadProtocolCapabilities() { + if (!state.wizardWorkspaceId || !window.CrankApi || typeof window.CrankApi.getProtocolCapabilities !== 'function') { + state.wizardProtocolCapabilities = defaultProtocolCapabilities(); + return state.wizardProtocolCapabilities; + } + + try { + var response = await window.CrankApi.getProtocolCapabilities(state.wizardWorkspaceId); + var mapped = {}; + (response.items || []).forEach(function(item) { + mapped[item.protocol] = item; + }); + state.wizardProtocolCapabilities = mapped; + } catch (_error) { + state.wizardProtocolCapabilities = defaultProtocolCapabilities(); + } + + return state.wizardProtocolCapabilities; + } + + function step3Labels() { + return { + rest: tKey('wizard.step3.rest.label'), + graphql: tKey('wizard.step3.graphql.label'), + grpc: tKey('wizard.step3.grpc.label'), + websocket: tKey('wizard.step3.websocket.label'), + soap: tKey('wizard.step3.soap.label'), + }; + } + + window.CrankWizardState = { + state: state, + defaultProtocolCapabilities: defaultProtocolCapabilities, + currentProtocolCapabilities: currentProtocolCapabilities, + loadProtocolCapabilities: loadProtocolCapabilities, + step3Labels: step3Labels, + }; + + [ + 'currentStep', + 'wizardProtocol', + 'wizardMode', + 'wizardEditId', + 'wizardWorkspaceId', + 'wizardCurrentOperation', + 'wizardCurrentVersion', + 'wizardProtoUpload', + 'wizardDescriptorSetUpload', + 'wizardSoapWsdlUpload', + 'wizardSoapXsdUpload', + 'wizardTestResponsePreview', + 'grpcDescriptorServices', + 'soapServiceCatalog', + 'wizardProtocolCapabilities', + 'wizardSecrets', + 'wizardAuthProfiles', + 'selectedUpstreamId', + 'editingUpstreamId', + 'protoParsed', + 'selectedRpcMethod' + ].forEach(function(key) { + Object.defineProperty(window, key, { + configurable: true, + get: function() { + return state[key]; + }, + set: function(value) { + state[key] = value; + }, + }); + }); +}()); diff --git a/apps/ui/js/wizard.js b/apps/ui/js/wizard.js index 169779f..8c73d7b 100644 --- a/apps/ui/js/wizard.js +++ b/apps/ui/js/wizard.js @@ -1,247 +1,18 @@ -var currentStep = 1; -var TOTAL_STEPS = 5; -var wizardProtocol = 'rest'; // 'rest' | 'graphql' | 'grpc' | 'websocket' | 'soap' -var wizardMode = 'create'; // 'create' | 'edit' -var wizardEditId = null; -var wizardWorkspaceId = null; -var wizardCurrentOperation = null; -var wizardCurrentVersion = null; -var wizardProtoUpload = null; -var wizardDescriptorSetUpload = null; -var wizardSoapWsdlUpload = null; -var wizardSoapXsdUpload = null; -var wizardTestResponsePreview = null; -var grpcDescriptorServices = []; -var soapServiceCatalog = []; -var wizardProtocolCapabilities = null; function tKey(key) { return typeof t === 'function' ? t(key) : key; } function tfKey(key, vars) { return typeof tf === 'function' ? tf(key, vars) : key; } - -function defaultProtocolCapabilities() { - return { - rest: { - supports_execution_modes: ['unary', 'window', 'session', 'async_job'], - supports_transport_behaviors: ['request_response', 'server_stream', 'deferred_result'], - }, - graphql: { - supports_execution_modes: ['unary'], - supports_transport_behaviors: ['request_response'], - }, - grpc: { - supports_execution_modes: ['unary', 'window', 'session', 'async_job'], - supports_transport_behaviors: ['request_response', 'server_stream'], - }, - websocket: { - supports_execution_modes: ['window', 'session', 'async_job'], - supports_transport_behaviors: ['server_stream', 'stateful_session', 'deferred_result'], - }, - soap: { - supports_execution_modes: ['unary', 'async_job'], - supports_transport_behaviors: ['request_response', 'server_stream', 'deferred_result'], - }, - }; -} - -function currentProtocolCapabilities() { - var capabilities = wizardProtocolCapabilities || defaultProtocolCapabilities(); - return capabilities[wizardProtocol] || capabilities.rest; -} - -async function loadProtocolCapabilities() { - if (!wizardWorkspaceId || !window.CrankApi || typeof window.CrankApi.getProtocolCapabilities !== 'function') { - wizardProtocolCapabilities = defaultProtocolCapabilities(); - return wizardProtocolCapabilities; - } - - try { - var response = await window.CrankApi.getProtocolCapabilities(wizardWorkspaceId); - var mapped = {}; - (response.items || []).forEach(function(item) { - mapped[item.protocol] = item; - }); - wizardProtocolCapabilities = mapped; - } catch (_error) { - wizardProtocolCapabilities = defaultProtocolCapabilities(); - } - - return wizardProtocolCapabilities; -} - -/* ── Dynamic step loading ── */ -function _stepFile(n) { - return (n === 3) ? 'step3-' + wizardProtocol + '.html' : 'step' + n + '.html'; -} - -function loadStep(n, callback) { - var panelId = (n === 3) ? step3PanelId() : 'step-panel-' + n; - if (document.getElementById(panelId)) { if (callback) callback(); return; } - fetch(_stepFile(n)) - .then(function(r) { return r.text(); }) - .then(function(html) { - var c = document.getElementById('step-panel-container'); - if (c) c.insertAdjacentHTML('beforeend', html); - if (typeof applyLang === 'function') applyLang(); - if (callback) callback(); - }) - .catch(function() { if (callback) callback(); }); -} - -/* Step 3 panel id by protocol */ -function step3PanelId() { - if (wizardProtocol === 'graphql') return 'step-panel-3-graphql'; - if (wizardProtocol === 'grpc') return 'step-panel-3-grpc'; - if (wizardProtocol === 'websocket') return 'step-panel-3-websocket'; - if (wizardProtocol === 'soap') return 'step-panel-3-soap'; - return 'step-panel-3-rest'; -} - -/* Sidebar step-3 label by protocol */ -var STEP3_LABELS = { - rest: tKey('wizard.step3.rest.label'), - graphql: tKey('wizard.step3.graphql.label'), - grpc: tKey('wizard.step3.grpc.label'), - websocket: tKey('wizard.step3.websocket.label'), - soap: tKey('wizard.step3.soap.label'), -}; - -var CHECK_SVG = ''; -var ARROW_SVG = ''; -var BACK_SVG = ''; - -function goToStep(n) { - if (n < 1 || n > TOTAL_STEPS) return; - loadStep(n, function() { _doGoToStep(n); }); -} - -function _doGoToStep(n) { - document.querySelectorAll('.step-number[data-step]').forEach(function(element) { - var step = Number(element.getAttribute('data-step')) || 0; - element.textContent = tfKey('wizard.step_short', { step: step }); - }); - - // 1. hide all panes, show target (step 4 is protocol-specific) - document.querySelectorAll('.step-pane').forEach(function(p) { p.style.display = 'none'; }); - var panelId = (n === 3) ? step3PanelId() : 'step-panel-' + n; - var pane = document.getElementById(panelId); - if (pane) pane.style.display = ''; - - // 2. update sidebar step items - document.querySelectorAll('.step-item').forEach(function(item, i) { - var num = i + 1; - item.classList.remove('active', 'done', 'pending'); - var ind = item.querySelector('.step-indicator'); - var statusEl = item.querySelector('.step-status-text'); - if (num < n) { - item.classList.add('done'); - if (ind) ind.innerHTML = CHECK_SVG; - if (statusEl) statusEl.textContent = tKey('wizard.status.completed'); - } else if (num === n) { - item.classList.add('active'); - if (ind) ind.textContent = num; - if (statusEl) statusEl.textContent = tKey('wizard.status.in_progress'); - } else { - item.classList.add('pending'); - if (ind) ind.textContent = num; - if (statusEl) statusEl.textContent = tKey('wizard.status.not_started'); - } - }); - - // 3. update progress bar - var pct = Math.round((n / TOTAL_STEPS) * 100); - var fill = document.querySelector('.progress-bar-fill'); - if (fill) fill.style.width = pct + '%'; - var pctEl = document.querySelector('.progress-pct'); - if (pctEl) pctEl.textContent = pct + '%'; - - // 4. update step counter - var counter = document.querySelector('[data-step-counter]'); - if (counter) counter.innerHTML = tfKey('wizard.step_of', { step: n, total: TOTAL_STEPS }); - - // 5. back button - var backBtn = document.querySelector('.btn-back'); - if (backBtn) { - if (n === 1) { - backBtn.disabled = true; - backBtn.style.opacity = '0.35'; - backBtn.style.cursor = 'not-allowed'; - } else { - backBtn.disabled = false; - backBtn.style.opacity = ''; - backBtn.style.cursor = ''; - } - } - - // 6. continue button - var contBtn = document.querySelector('.btn-continue'); - if (contBtn) { - if (n === TOTAL_STEPS) { - var finalLabel = wizardMode === 'edit' ? tKey('wizard.button.save_changes') : tKey('wizard.button.create'); - contBtn.innerHTML = finalLabel + ' ' + ARROW_SVG; - contBtn.style.cssText = 'background:#3fb950;border-color:#2ea043;box-shadow:0 1px 4px rgba(63,185,80,0.4);'; - } else { - contBtn.innerHTML = tKey('wizard.button.continue') + ' ' + ARROW_SVG; - contBtn.style.cssText = ''; - } - } - - currentStep = n; - window.scrollTo({ top: 0, behavior: 'smooth' }); -} - -function loadWizardPanels(steps) { - return steps.reduce(function(chain, step) { - return chain.then(function() { - return new Promise(function(resolve) { - loadStep(step, resolve); - }); - }); - }, Promise.resolve()); -} - -function bindProtocolCards() { - document.querySelectorAll('.protocol-card').forEach(function(card) { - card.addEventListener('click', function() { - document.querySelectorAll('.protocol-card').forEach(function(item) { - item.classList.remove('selected'); - item.setAttribute('aria-checked', 'false'); - }); - card.classList.add('selected'); - card.setAttribute('aria-checked', 'true'); - - var proto = card.dataset.protocol - || (card.classList.contains('rest') - ? 'rest' - : card.classList.contains('graphql') - ? 'graphql' - : card.classList.contains('grpc') - ? 'grpc' - : card.classList.contains('websocket') - ? 'websocket' - : card.classList.contains('soap') - ? 'soap' - : 'rest'); - - wizardProtocol = proto; - var s3name = document.getElementById('sidebar-step-3-name'); - if (s3name) s3name.textContent = STEP3_LABELS[proto] || tKey('wizard.step3.rest.label'); - - if (proto === 'graphql') { - var pathInput = document.getElementById('endpoint-path'); - if (pathInput && pathInput.value === '/v1/leads') pathInput.value = '/graphql'; - } - }); - card.addEventListener('keydown', function(event) { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - card.click(); - } - }); - }); -} +var TOTAL_STEPS = window.CrankWizardShell.TOTAL_STEPS; +var loadProtocolCapabilities = window.CrankWizardState.loadProtocolCapabilities; +var currentProtocolCapabilities = window.CrankWizardState.currentProtocolCapabilities; +var loadStep = window.CrankWizardShell.loadStep; +var goToStep = window.CrankWizardShell.goToStep; +var _doGoToStep = window.CrankWizardShell.doGoToStep; +var loadWizardPanels = window.CrankWizardShell.loadWizardPanels; +var bindProtocolCards = window.CrankWizardShell.bindProtocolCards; +var step3PanelId = window.CrankWizardShell.step3PanelId; document.addEventListener('DOMContentLoaded', async function() { document.querySelector('.btn-continue').addEventListener('click', function() { @@ -2066,7 +1837,10 @@ function selectProtocol(protocol) { card.setAttribute('aria-checked', selected ? 'true' : 'false'); }); var s3name = document.getElementById('sidebar-step-3-name'); - if (s3name) s3name.textContent = STEP3_LABELS[wizardProtocol] || tKey('wizard.step3.rest.label'); + if (s3name) { + var step3Labels = window.CrankWizardState.step3Labels(); + s3name.textContent = step3Labels[wizardProtocol] || tKey('wizard.step3.rest.label'); + } updateWizardProtocolVisibility(); }