From 052b9356ec53b4e3d31402138d466eec1146258b Mon Sep 17 00:00:00 2001 From: github-ops Date: Tue, 23 Jun 2026 21:03:14 +0000 Subject: [PATCH] Add OpenAPI import UI --- apps/ui/css/openapi-import.css | 449 ++++++++++++++++++++++ apps/ui/index.html | 84 +++- apps/ui/js/api.js | 8 + apps/ui/js/catalog.js | 9 + apps/ui/js/openapi-import.js | 554 +++++++++++++++++++++++++++ apps/ui/tests/e2e/operations.spec.js | 108 +++++- 6 files changed, 1207 insertions(+), 5 deletions(-) create mode 100644 apps/ui/css/openapi-import.css create mode 100644 apps/ui/js/openapi-import.js diff --git a/apps/ui/css/openapi-import.css b/apps/ui/css/openapi-import.css new file mode 100644 index 0000000..b1da85a --- /dev/null +++ b/apps/ui/css/openapi-import.css @@ -0,0 +1,449 @@ +.page-header-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.openapi-import-modal[hidden] { + display: none; +} + +.openapi-import-modal { + position: fixed; + inset: 0; + z-index: 1200; +} + +.openapi-import-backdrop { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.52); + backdrop-filter: blur(5px); +} + +.openapi-import-dialog { + position: relative; + width: min(1040px, calc(100vw - 32px)); + max-height: min(860px, calc(100vh - 32px)); + margin: 16px auto; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 22px; + background: var(--surface); + box-shadow: 0 24px 80px rgba(15, 23, 42, 0.28); +} + +.openapi-import-header { + display: flex; + justify-content: space-between; + gap: 18px; + padding: 22px 24px; + border-bottom: 1px solid var(--border); +} + +.openapi-import-header h2 { + margin: 0 0 6px; + font-size: 22px; +} + +.openapi-import-header p { + margin: 0; + color: var(--text-secondary); + font-size: 14px; +} + +.openapi-import-body { + overflow: auto; + padding: 20px 24px 24px; +} + +.openapi-import-upload { + display: grid; + gap: 12px; +} + +.openapi-file-label { + display: grid; + gap: 8px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 600; +} + +#openapi-import-document { + min-height: 180px; + resize: vertical; + padding: 14px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface-muted); + color: var(--text-primary); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.55; +} + +.openapi-import-actions, +.openapi-import-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.openapi-import-status { + margin-top: 14px; + color: var(--text-secondary); + font-size: 13px; +} + +.openapi-import-status.error { + color: var(--red); +} + +.openapi-import-preview { + display: grid; + gap: 16px; + margin-top: 18px; +} + +.openapi-import-source, +.openapi-import-group { + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface-muted); +} + +.openapi-import-source { + padding: 14px 16px; + color: var(--text-secondary); + font-size: 13px; +} + +.openapi-import-server-row { + display: grid; + gap: 8px; +} + +.openapi-import-server-row label { + color: var(--text-secondary); + font-size: 13px; + font-weight: 700; +} + +#openapi-import-server, +#openapi-import-conflict-mode, +#openapi-import-search, +#openapi-import-method-filter, +.openapi-import-server-custom { + max-width: 520px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + color: var(--text-primary); +} + +.openapi-import-toolbar { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(160px, 220px) auto; + gap: 12px; + align-items: end; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface-muted); +} + +.openapi-import-filter { + display: grid; + gap: 8px; +} + +.openapi-import-filter label { + color: var(--text-secondary); + font-size: 12px; + font-weight: 800; +} + +#openapi-import-search, +#openapi-import-method-filter { + width: 100%; + max-width: none; + background: var(--surface); +} + +.openapi-import-bulk-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.openapi-import-groups { + display: grid; + gap: 14px; +} + +.openapi-import-group-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + font-weight: 800; +} + +.openapi-import-group-count { + margin-left: auto; + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.openapi-import-group-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; +} + +.openapi-import-operation { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 12px; + align-items: start; + padding: 14px 16px; + border-bottom: 1px solid var(--border); +} + +.openapi-import-operation:last-child { + border-bottom: 0; +} + +.openapi-import-operation-title { + font-weight: 800; +} + +.openapi-import-operation-meta { + margin-top: 4px; + color: var(--text-secondary); + font-size: 12px; +} + +.openapi-import-method { + padding: 4px 8px; + border-radius: 999px; + background: var(--accent-muted); + color: var(--accent); + font-size: 11px; + font-weight: 800; +} + +.openapi-import-findings { + grid-column: 2 / -1; + display: grid; + gap: 4px; + font-size: 12px; +} + +.openapi-import-finding { + display: inline-flex; + align-items: baseline; + gap: 6px; + color: var(--amber); + line-height: 1.45; +} + +.openapi-import-finding-info { + color: var(--blue); +} + +.openapi-import-finding-error { + color: var(--red); +} + +.openapi-import-finding strong { + font-weight: 900; +} + +.openapi-import-mapping-preview { + grid-column: 2 / -1; + display: grid; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); +} + +.openapi-import-mapping-group { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.openapi-import-mapping-label { + min-width: 52px; + color: var(--text-secondary); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.openapi-import-mapping-chip { + padding: 3px 7px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface-muted); + color: var(--text-primary); + font-size: 11px; +} + +.openapi-import-mapping-more { + color: var(--text-secondary); + font-size: 11px; + font-weight: 800; +} + +.openapi-import-document-findings, +.openapi-import-result { + display: grid; + gap: 8px; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface-muted); + color: var(--text-secondary); + font-size: 13px; +} + +.openapi-import-result strong { + color: var(--text-primary); +} + +.openapi-import-primary-result { + display: flex; + justify-content: flex-start; + margin: 4px 0; +} + +.openapi-import-primary-result .btn-primary { + text-decoration: none; +} + +.openapi-import-result-table { + display: grid; + gap: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); +} + +.openapi-import-result-row { + display: grid; + grid-template-columns: minmax(180px, 0.9fr) minmax(260px, 1.5fr) auto; + gap: 12px; + align-items: start; + padding: 12px 14px; + border-bottom: 1px solid var(--border); +} + +.openapi-import-result-row:last-child { + border-bottom: 0; +} + +.openapi-import-result-head { + color: var(--text-secondary); + font-size: 11px; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.04em; + background: var(--surface-muted); +} + +.openapi-import-result-name { + color: var(--text-primary); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + font-weight: 800; +} + +.openapi-import-result-meta { + margin-top: 4px; + color: var(--text-secondary); + font-size: 12px; +} + +.openapi-import-result-findings { + display: grid; + gap: 5px; +} + +.openapi-import-result-ok { + color: var(--green); + font-size: 12px; + font-weight: 800; +} + +.openapi-import-result-more { + color: var(--text-secondary); + font-size: 12px; + font-weight: 800; +} + +.openapi-import-result-action { + white-space: nowrap; + text-decoration: none; +} + +.openapi-import-result-skipped { + color: var(--text-secondary); + font-size: 13px; +} + +.openapi-import-created-list { + display: grid; + gap: 6px; + margin: 4px 0 0; + padding: 0; + list-style: none; +} + +.openapi-import-created-list a { + color: var(--accent); + font-weight: 700; + text-decoration: none; +} + +@media (max-width: 720px) { + .openapi-import-toolbar { + grid-template-columns: 1fr; + } + + .openapi-import-bulk-actions { + justify-content: flex-start; + } + + .openapi-import-operation { + grid-template-columns: auto 1fr; + } + + .openapi-import-result-row { + grid-template-columns: 1fr; + } + + .openapi-import-method { + justify-self: start; + } +} diff --git a/apps/ui/index.html b/apps/ui/index.html index 5e0d71a..2d46d87 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -9,6 +9,7 @@ + @@ -88,10 +89,16 @@

Operations

Catalog of tools. List of created MCP tools for API endpoints.

- +
+ + +
@@ -367,6 +374,75 @@ + + diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index 6332141..d9466cc 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -244,6 +244,14 @@ } ); }, + previewOpenApiImport: function(workspaceId, documentText) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/preview', { + document: documentText, + }); + }, + createOpenApiImport: function(workspaceId, jobId, payload) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/' + encodeURIComponent(jobId) + '/create', payload); + }, listAgents: function(workspaceId) { return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents'); }, diff --git a/apps/ui/js/catalog.js b/apps/ui/js/catalog.js index fe6fc34..8c43e8d 100644 --- a/apps/ui/js/catalog.js +++ b/apps/ui/js/catalog.js @@ -506,6 +506,15 @@ document.addEventListener('alpine:init', function() { window.location.href = (window.CrankRoutes && window.CrankRoutes.wizard) || '/wizard/'; }, + handleOpenApiImport() { + if (window.CrankOpenApiImport) { + window.CrankOpenApiImport.open({ + workspaceId: this.workspaceId, + onImported: this.reload.bind(this), + }); + } + }, + handleLogout() { window.CrankAuth.logout(); }, diff --git a/apps/ui/js/openapi-import.js b/apps/ui/js/openapi-import.js new file mode 100644 index 0000000..2267e55 --- /dev/null +++ b/apps/ui/js/openapi-import.js @@ -0,0 +1,554 @@ +(function() { + var state = { + workspaceId: null, + onImported: null, + jobId: null, + preview: null, + filterQuery: '', + filterMethod: '', + }; + + function qs(id) { + return document.getElementById(id); + } + + function setStatus(message, isError) { + var node = qs('openapi-import-status'); + if (!node) return; + node.textContent = message || ''; + node.classList.toggle('error', !!isError); + } + + function selectedKeys() { + return Array.from(document.querySelectorAll('[data-openapi-operation]:checked')) + .map(function(input) { return input.value; }); + } + + function operationRows() { + return Array.from(document.querySelectorAll('.openapi-import-operation')); + } + + function visibleOperationRows() { + return operationRows().filter(function(row) { return !row.hidden; }); + } + + function updateSelection() { + var node = qs('openapi-import-selection'); + var selectedCount = selectedKeys().length; + var totalCount = document.querySelectorAll('[data-openapi-operation]').length; + var visibleCount = visibleOperationRows().length; + if (node) node.textContent = 'Выбрано: ' + selectedCount + ' из ' + totalCount + ', показано: ' + visibleCount; + updateGroupSelectionState(); + } + + function updateGroupSelectionState() { + document.querySelectorAll('.openapi-import-group').forEach(function(groupNode) { + var rows = Array.from(groupNode.querySelectorAll('.openapi-import-operation')); + var visibleRows = rows.filter(function(row) { return !row.hidden; }); + var visibleInputs = visibleRows + .map(function(row) { return row.querySelector('[data-openapi-operation]'); }) + .filter(Boolean); + var selectedVisible = visibleInputs.filter(function(input) { return input.checked; }).length; + var selectedTotal = rows + .map(function(row) { return row.querySelector('[data-openapi-operation]'); }) + .filter(function(input) { return input && input.checked; }).length; + var checkbox = groupNode.querySelector('[data-openapi-group]'); + if (checkbox) { + checkbox.indeterminate = selectedVisible > 0 && selectedVisible < visibleInputs.length; + checkbox.checked = visibleInputs.length > 0 && selectedVisible === visibleInputs.length; + } + var counter = groupNode.querySelector('[data-openapi-group-count]'); + if (counter) { + counter.textContent = 'выбрано ' + selectedTotal + ' из ' + rows.length + ', показано ' + visibleRows.length; + } + }); + } + + function applyFilters() { + var query = state.filterQuery.toLowerCase(); + var method = state.filterMethod.toLowerCase(); + document.querySelectorAll('.openapi-import-group').forEach(function(groupNode) { + var visibleInGroup = 0; + groupNode.querySelectorAll('.openapi-import-operation').forEach(function(row) { + var rowMethod = String(row.dataset.openapiMethod || '').toLowerCase(); + var searchText = String(row.dataset.openapiSearch || '').toLowerCase(); + var visible = (!method || rowMethod === method) && (!query || searchText.indexOf(query) >= 0); + row.hidden = !visible; + if (visible) visibleInGroup += 1; + }); + groupNode.hidden = visibleInGroup === 0; + }); + updateSelection(); + } + + function setVisibleSelection(checked) { + visibleOperationRows().forEach(function(row) { + var input = row.querySelector('[data-openapi-operation]'); + if (input) input.checked = checked; + }); + updateSelection(); + } + + function renderPreview(response) { + state.jobId = response.job_id; + state.preview = response.preview; + state.filterQuery = ''; + state.filterMethod = ''; + var preview = response.preview; + qs('openapi-import-preview-panel').hidden = false; + qs('openapi-import-result').hidden = true; + qs('openapi-import-result').innerHTML = ''; + qs('openapi-import-source').textContent = [ + preview.source.title || 'Imported API', + preview.source.format + (preview.source.version ? ' ' + preview.source.version : ''), + 'методов: ' + preview.groups.reduce(function(sum, group) { return sum + group.operations.length; }, 0), + ].join(' · '); + + var serverSelect = qs('openapi-import-server'); + serverSelect.innerHTML = ''; + var servers = preview.source.servers && preview.source.servers.length ? preview.source.servers : ['']; + servers.forEach(function(server) { + var option = document.createElement('option'); + option.value = server; + option.textContent = server || 'Указать позже'; + serverSelect.appendChild(option); + }); + + var searchInput = qs('openapi-import-search'); + var methodFilter = qs('openapi-import-method-filter'); + if (searchInput) searchInput.value = ''; + if (methodFilter) { + methodFilter.innerHTML = ''; + Array.from(new Set(preview.groups.flatMap(function(group) { + return group.operations.map(function(operation) { return operation.method; }); + }))).sort().forEach(function(method) { + var option = document.createElement('option'); + option.value = method; + option.textContent = method; + methodFilter.appendChild(option); + }); + } + + var groupsNode = qs('openapi-import-groups'); + groupsNode.innerHTML = ''; + if (preview.findings && preview.findings.length) { + var documentFindings = document.createElement('div'); + documentFindings.className = 'openapi-import-document-findings'; + documentFindings.innerHTML = preview.findings.map(function(finding) { + return renderFinding(finding); + }).join(''); + groupsNode.appendChild(documentFindings); + } + preview.groups.forEach(function(group) { + var groupNode = document.createElement('section'); + groupNode.className = 'openapi-import-group'; + var header = document.createElement('div'); + header.className = 'openapi-import-group-header'; + header.innerHTML = '' + escapeHtml(group.title) + ' · ' + group.operations.length + '' + + '' + + ''; + groupNode.appendChild(header); + + group.operations.forEach(function(operation) { + var row = document.createElement('label'); + row.className = 'openapi-import-operation'; + row.dataset.openapiMethod = operation.method; + row.dataset.openapiSearch = [ + group.title, + operation.key, + operation.suggested_name, + operation.suggested_display_name, + operation.path, + operation.method, + ].join(' '); + row.innerHTML = [ + '', + '
' + escapeHtml(operation.suggested_display_name) + '
', + '
' + escapeHtml(operation.suggested_name) + ' · ' + escapeHtml(operation.path) + ' · входов: ' + operation.input_fields + ' · выходов: ' + operation.output_fields + '
', + '' + escapeHtml(operation.method) + '', + ].join(''); + row.appendChild(renderOperationMappingPreview(operation)); + if (operation.findings && operation.findings.length) { + var findings = document.createElement('div'); + findings.className = 'openapi-import-findings'; + findings.innerHTML = operation.findings.map(function(finding) { + return renderFinding(finding); + }).join(''); + row.appendChild(findings); + } + groupNode.appendChild(row); + }); + var groupCheckbox = groupNode.querySelector('[data-openapi-group]'); + groupCheckbox.addEventListener('change', function() { + groupNode.querySelectorAll('.openapi-import-operation').forEach(function(row) { + if (row.hidden) return; + var input = row.querySelector('[data-openapi-operation]'); + if (input) input.checked = groupCheckbox.checked; + }); + updateSelection(); + }); + groupsNode.appendChild(groupNode); + }); + groupsNode.querySelectorAll('[data-openapi-operation]').forEach(function(input) { + input.addEventListener('change', updateSelection); + }); + applyFilters(); + } + + function renderOperationMappingPreview(operation) { + var details = document.createElement('div'); + details.className = 'openapi-import-mapping-preview'; + + var request = summarizeRequestMapping(operation); + [ + ['Path', request.path], + ['Query', request.query], + ['Header', request.headers], + ['Body', request.body], + ].forEach(function(entry) { + appendMappingGroup(details, entry[0], entry[1]); + }); + appendMappingGroup(details, 'Ответ', summarizeResponseMapping(operation)); + return details; + } + + function summarizeRequestMapping(operation) { + var result = { + path: [], + query: [], + headers: [], + body: [], + }; + var rules = operation + && operation.draft + && operation.draft.input_mapping + && Array.isArray(operation.draft.input_mapping.rules) + ? operation.draft.input_mapping.rules + : []; + + rules.forEach(function(rule) { + var target = String(rule.target || ''); + if (target.indexOf('$.request.path.') === 0) { + result.path.push(target.replace('$.request.path.', '')); + } else if (target.indexOf('$.request.query.') === 0) { + result.query.push(target.replace('$.request.query.', '')); + } else if (target.indexOf('$.request.headers.') === 0) { + result.headers.push(target.replace('$.request.headers.', '')); + } else if (target.indexOf('$.request.body.') === 0) { + result.body.push(target.replace('$.request.body.', '')); + } else if (target === '$.request.body') { + result.body.push('body'); + } + }); + return result; + } + + function summarizeResponseMapping(operation) { + var rules = operation + && operation.draft + && operation.draft.output_mapping + && Array.isArray(operation.draft.output_mapping.rules) + ? operation.draft.output_mapping.rules + : []; + + return rules.map(function(rule) { + var target = String(rule.target || '').replace('$.output.', '').replace('$.output', 'result'); + var source = String(rule.source || '').replace('$.response.body.', '').replace('$.response.body', 'body'); + return target + ' ← ' + source; + }); + } + + function appendMappingGroup(root, label, values) { + if (!values || !values.length) return; + var group = document.createElement('div'); + group.className = 'openapi-import-mapping-group'; + + var title = document.createElement('span'); + title.className = 'openapi-import-mapping-label'; + title.textContent = label; + group.appendChild(title); + + values.slice(0, 8).forEach(function(value) { + var chip = document.createElement('code'); + chip.className = 'openapi-import-mapping-chip'; + chip.textContent = value; + group.appendChild(chip); + }); + if (values.length > 8) { + var more = document.createElement('span'); + more.className = 'openapi-import-mapping-more'; + more.textContent = '+' + (values.length - 8); + group.appendChild(more); + } + root.appendChild(group); + } + + function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function renderFinding(finding) { + var severity = String(finding && finding.severity || 'warning').toLowerCase(); + var label = severity === 'info' ? 'i' : severity === 'error' ? '×' : '!'; + return '' + + '' + label + ' ' + + escapeHtml(finding.message) + + ''; + } + + function previewOperationByName(name) { + if (!state.preview || !state.preview.groups) return null; + for (var groupIndex = 0; groupIndex < state.preview.groups.length; groupIndex += 1) { + var operations = state.preview.groups[groupIndex].operations || []; + for (var operationIndex = 0; operationIndex < operations.length; operationIndex += 1) { + if (operations[operationIndex].suggested_name === name) { + return operations[operationIndex]; + } + } + } + return null; + } + + function wizardHref(operationId) { + return ((window.CrankRoutes && window.CrankRoutes.wizard) || '/wizard/') + + '?mode=edit&operationId=' + encodeURIComponent(operationId); + } + + function appendFindingNodes(root, findings) { + if (!findings || !findings.length) { + var empty = document.createElement('span'); + empty.className = 'openapi-import-result-ok'; + empty.textContent = 'Критичных замечаний нет'; + root.appendChild(empty); + return; + } + findings.slice(0, 4).forEach(function(finding) { + var wrapper = document.createElement('div'); + wrapper.innerHTML = renderFinding(finding); + root.appendChild(wrapper.firstElementChild); + }); + if (findings.length > 4) { + var more = document.createElement('span'); + more.className = 'openapi-import-result-more'; + more.textContent = '+' + (findings.length - 4) + ' еще'; + root.appendChild(more); + } + } + + async function preview() { + if (!state.workspaceId) { + setStatus('Не выбран workspace.', true); + return; + } + var documentText = qs('openapi-import-document').value.trim(); + if (!documentText) { + setStatus('Вставьте документ OpenAPI/Swagger или выберите файл.', true); + return; + } + setStatus('Разбираю документ...'); + qs('openapi-import-preview').disabled = true; + try { + var response = await window.CrankApi.previewOpenApiImport(state.workspaceId, documentText); + renderPreview(response); + setStatus('Документ разобран. Выберите методы для импорта.'); + } catch (error) { + setStatus(error.message || 'Не удалось разобрать документ.', true); + } finally { + qs('openapi-import-preview').disabled = false; + } + } + + async function createDrafts() { + var keys = selectedKeys(); + if (!keys.length) { + setStatus('Выберите хотя бы один метод.', true); + return; + } + if (!state.jobId) { + setStatus('Сначала разберите OpenAPI документ.', true); + return; + } + var serverUrl = (qs('openapi-import-server-custom').value || qs('openapi-import-server').value || '').trim(); + if (!serverUrl) { + setStatus('Укажите base URL для создаваемых операций.', true); + return; + } + setStatus('Создаю черновики...'); + qs('openapi-import-create').disabled = true; + try { + var response = await window.CrankApi.createOpenApiImport(state.workspaceId, state.jobId, { + selected_operation_keys: keys, + server_url: serverUrl, + conflict_mode: qs('openapi-import-conflict-mode').value || 'rename', + }); + var message = 'Создано: ' + response.created.length; + if (response.skipped.length) message += ', пропущено: ' + response.skipped.length; + setStatus(message); + renderResult(response); + if (typeof state.onImported === 'function') { + await state.onImported(); + } + } catch (error) { + setStatus(error.message || 'Не удалось создать черновики.', true); + } finally { + qs('openapi-import-create').disabled = false; + } + } + + function renderResult(response) { + var node = qs('openapi-import-result'); + if (!node) return; + node.replaceChildren(); + + var title = document.createElement('strong'); + title.textContent = 'Результат импорта'; + node.appendChild(title); + + if (response.created && response.created.length) { + var firstHref = wizardHref(response.created[0].operation_id); + var primary = document.createElement('div'); + primary.className = 'openapi-import-primary-result'; + primary.innerHTML = 'Открыть первый черновик в мастере'; + node.appendChild(primary); + + var table = document.createElement('div'); + table.className = 'openapi-import-result-table'; + table.setAttribute('role', 'table'); + table.innerHTML = [ + '
', + '
Черновик
', + '
Замечания
', + '
Действие
', + '
', + ].join(''); + + response.created.forEach(function(operation) { + var previewOperation = previewOperationByName(operation.name); + var row = document.createElement('div'); + row.className = 'openapi-import-result-row'; + row.setAttribute('role', 'row'); + + var nameCell = document.createElement('div'); + nameCell.setAttribute('role', 'cell'); + var name = document.createElement('div'); + name.className = 'openapi-import-result-name'; + name.textContent = operation.name; + var meta = document.createElement('div'); + meta.className = 'openapi-import-result-meta'; + meta.textContent = previewOperation + ? previewOperation.method + ' ' + previewOperation.path + ' · v' + operation.version + : 'v' + operation.version; + nameCell.appendChild(name); + nameCell.appendChild(meta); + + var findingsCell = document.createElement('div'); + findingsCell.className = 'openapi-import-result-findings'; + findingsCell.setAttribute('role', 'cell'); + appendFindingNodes(findingsCell, previewOperation ? previewOperation.findings : []); + + var actionCell = document.createElement('div'); + actionCell.setAttribute('role', 'cell'); + var action = document.createElement('a'); + action.className = 'btn-secondary openapi-import-result-action'; + action.href = wizardHref(operation.operation_id); + action.textContent = previewOperation && previewOperation.findings && previewOperation.findings.length + ? 'Исправить в мастере' + : 'Открыть в мастере'; + actionCell.appendChild(action); + + row.appendChild(nameCell); + row.appendChild(findingsCell); + row.appendChild(actionCell); + table.appendChild(row); + }); + node.appendChild(table); + } + if (response.skipped && response.skipped.length) { + var skipped = document.createElement('div'); + skipped.className = 'openapi-import-result-skipped'; + skipped.textContent = 'Пропущено: ' + response.skipped.map(function(item) { + return (item.name || item.operation_key) + ' — ' + item.reason; + }).join('; '); + node.appendChild(skipped); + } + if (response.findings && response.findings.length) { + var responseFindings = document.createElement('div'); + responseFindings.className = 'openapi-import-result-findings'; + response.findings.forEach(function(finding) { + var wrapper = document.createElement('div'); + wrapper.innerHTML = renderFinding(finding); + responseFindings.appendChild(wrapper.firstElementChild); + }); + node.appendChild(responseFindings); + } + node.hidden = false; + } + + function reset() { + state.jobId = null; + state.preview = null; + state.filterQuery = ''; + state.filterMethod = ''; + qs('openapi-import-document').value = ''; + qs('openapi-import-file').value = ''; + qs('openapi-import-server-custom').value = ''; + qs('openapi-import-conflict-mode').value = 'rename'; + if (qs('openapi-import-search')) qs('openapi-import-search').value = ''; + if (qs('openapi-import-method-filter')) qs('openapi-import-method-filter').innerHTML = ''; + qs('openapi-import-preview-panel').hidden = true; + qs('openapi-import-groups').innerHTML = ''; + qs('openapi-import-result').hidden = true; + qs('openapi-import-result').innerHTML = ''; + setStatus(''); + updateSelection(); + } + + function open(options) { + state.workspaceId = options.workspaceId; + state.onImported = options.onImported; + qs('openapi-import-modal').hidden = false; + } + + function close() { + qs('openapi-import-modal').hidden = true; + } + + document.addEventListener('DOMContentLoaded', function() { + if (!qs('openapi-import-modal')) return; + qs('openapi-import-preview').addEventListener('click', preview); + qs('openapi-import-create').addEventListener('click', createDrafts); + qs('openapi-import-reset').addEventListener('click', reset); + qs('openapi-import-search').addEventListener('input', function(event) { + state.filterQuery = event.target.value.trim(); + applyFilters(); + }); + qs('openapi-import-method-filter').addEventListener('change', function(event) { + state.filterMethod = event.target.value; + applyFilters(); + }); + qs('openapi-import-select-visible').addEventListener('click', function() { + setVisibleSelection(true); + }); + qs('openapi-import-clear-visible').addEventListener('click', function() { + setVisibleSelection(false); + }); + qs('openapi-import-file').addEventListener('change', async function(event) { + var file = event.target.files && event.target.files[0]; + if (!file) return; + qs('openapi-import-document').value = await file.text(); + }); + document.querySelectorAll('[data-openapi-close]').forEach(function(node) { + node.addEventListener('click', close); + }); + }); + + window.CrankOpenApiImport = { + open: open, + close: close, + reset: reset, + }; +}()); diff --git a/apps/ui/tests/e2e/operations.spec.js b/apps/ui/tests/e2e/operations.spec.js index 628ddd5..0a56bda 100644 --- a/apps/ui/tests/e2e/operations.spec.js +++ b/apps/ui/tests/e2e/operations.spec.js @@ -1,5 +1,5 @@ const { test, expect } = require('@playwright/test'); -const { login, localized } = require('./helpers'); +const { login, localized, uniqueName } = require('./helpers'); test('operations page shows demo catalog and filter works', async ({ page }) => { await login(page); @@ -11,3 +11,109 @@ test('operations page shows demo catalog and filter works', async ({ page }) => await expect(page.locator('tbody tr')).toHaveCount(1); await expect(page.locator('tbody tr').first()).toContainText(/frankfurter_latest_rate/i); }); + +test('operations page imports OpenAPI methods as drafts', async ({ page }) => { + await login(page); + const operationId = uniqueName('get_rates'); + const statusOperationId = `${operationId}_status`; + await page.getByRole('button', { name: /Импорт OpenAPI/i }).click(); + await expect(page.locator('#openapi-import-modal')).toBeVisible(); + await page.locator('#openapi-import-document').fill(` +openapi: 3.0.3 +info: + title: Demo Import API +servers: +paths: + /v1/rates/{date}: + post: + operationId: ${operationId} + summary: Получить курсы валют + description: Курсы. + tags: [currency] + parameters: + - name: date + in: path + required: true + schema: { type: string } + - name: base + in: query + required: true + schema: { type: string } + - name: X-API-Version + in: header + required: false + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [symbols] + properties: + symbols: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + base: { type: string } + /v1/status: + get: + operationId: ${statusOperationId} + summary: Проверить статус + tags: [service] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: { type: string } +`); + await page.locator('#openapi-import-preview').click(); + await expect(page.locator('#openapi-import-preview-panel')).toBeVisible(); + await expect(page.locator('.openapi-import-document-findings')).toContainText(/base URL/i); + await expect(page.locator('.openapi-import-group').filter({ hasText: 'currency' })).toBeVisible(); + const importedOperation = page.locator('.openapi-import-operation').filter({ hasText: 'Получить курсы валют' }); + await expect(importedOperation).toBeVisible(); + await expect(page.locator('.openapi-import-operation').filter({ hasText: 'Проверить статус' })).toBeVisible(); + await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Path'); + await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('date'); + await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Query'); + await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('base'); + await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Header'); + await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('X-API-Version'); + await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Body'); + await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('symbols'); + await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Ответ'); + await expect(page.locator('#openapi-import-selection')).toContainText('Выбрано: 2'); + await page.locator('#openapi-import-search').fill('status'); + await expect(page.locator('.openapi-import-operation:visible')).toHaveCount(1); + await expect(page.locator('.openapi-import-operation:visible').filter({ hasText: 'Проверить статус' })).toBeVisible(); + await page.locator('#openapi-import-clear-visible').click(); + await expect(page.locator('#openapi-import-selection')).toContainText('Выбрано: 1'); + await page.locator('#openapi-import-search').fill(''); + await page.locator('#openapi-import-method-filter').selectOption('POST'); + await expect(page.locator('.openapi-import-operation:visible')).toHaveCount(1); + await expect(page.locator('.openapi-import-operation:visible').filter({ hasText: 'Получить курсы валют' })).toBeVisible(); + await page.locator('#openapi-import-method-filter').selectOption(''); + await page.locator('#openapi-import-server-custom').fill('https://api.example.test'); + await page.locator('#openapi-import-create').click(); + await expect(page.locator('#openapi-import-result')).toContainText('Результат импорта'); + await expect(page.locator('.openapi-import-result-table')).toBeVisible(); + await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText('POST /v1/rates/{date}'); + await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText(/Описание инструмента слишком короткое/); + await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText('Исправить в мастере'); + await expect(page.locator('.openapi-import-primary-result a')).toHaveAttribute( + 'href', + /\/wizard\/\?mode=edit&operationId=op_/ + ); + await expect(page.locator('tbody')).toContainText(new RegExp(operationId, 'i')); + await expect(page.locator('tbody')).not.toContainText(new RegExp(statusOperationId, 'i')); +});