From cc0ea50be048598e965ed5c144e6f0c6dc575b51 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 26 Mar 2026 14:10:10 +0200 Subject: [PATCH] feat: better --- tests/wallets/index.html | 1110 +++++++++++++++++++++++++++++++++----- 1 file changed, 990 insertions(+), 120 deletions(-) diff --git a/tests/wallets/index.html b/tests/wallets/index.html index e929ae526..953c436c8 100644 --- a/tests/wallets/index.html +++ b/tests/wallets/index.html @@ -278,7 +278,7 @@ appearance: none; -webkit-appearance: none; border: 1px solid rgba(25, 54, 61, 0.14); - border-radius: 18px; + border-radius: 10px; background: rgba(255, 255, 255, 0.92); color: var(--studio-ink); padding: 14px 42px 14px 14px; @@ -293,6 +293,43 @@ border-color: rgba(25, 54, 61, 0.22); } + .native-input, + .native-textarea { + width: 100%; + border: 1px solid rgba(25, 54, 61, 0.14); + border-radius: 10px; + background: rgba(255, 255, 255, 0.92); + color: var(--studio-ink); + padding: 14px; + font: inherit; + font-size: 0.95rem; + line-height: 1.35; + box-shadow: inset 0 1px 1px rgba(20, 30, 36, 0.04); + } + + .native-textarea { + min-height: 92px; + resize: vertical; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; + font-size: 0.84rem; + } + + .native-input:focus, + .native-textarea:focus { + outline: 2px solid rgba(25, 54, 61, 0.18); + border-color: rgba(25, 54, 61, 0.22); + } + + .field-label { + display: block; + margin-bottom: 8px; + color: var(--studio-muted); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 700; + } + .pill-row { display: flex; gap: 8px; @@ -414,7 +451,6 @@ .test-detail-grid { display: grid; gap: 12px; - grid-template-columns: repeat(3, minmax(0, 1fr)); } .detail-panel { @@ -438,6 +474,149 @@ gap: 14px; } + .settings-editor { + display: grid; + gap: 12px; + } + + .settings-table { + border: 1px solid rgba(25, 54, 61, 0.08); + border-radius: 20px; + background: rgba(255, 255, 255, 0.78); + overflow: hidden; + } + + .settings-table-grid { + display: grid; + gap: 12px; + grid-template-columns: minmax(180px, 1fr) 140px minmax(220px, 1.3fr) auto; + align-items: center; + } + + .settings-table-header { + padding: 12px 14px; + border-bottom: 1px solid rgba(25, 54, 61, 0.08); + background: rgba(25, 54, 61, 0.05); + } + + .settings-table-heading { + color: var(--studio-muted); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 700; + } + + .settings-table-row { + padding: 14px; + border-bottom: 1px solid rgba(25, 54, 61, 0.08); + } + + .settings-table-row:last-child { + border-bottom: 0; + } + + .settings-table-actions { + display: flex; + justify-content: flex-end; + } + + .settings-table-note { + margin-top: 10px; + } + + .settings-row { + padding: 14px; + border-radius: 18px; + border: 1px solid rgba(25, 54, 61, 0.08); + background: rgba(255, 255, 255, 0.78); + } + + .settings-row-grid { + display: grid; + gap: 12px; + grid-template-columns: minmax(180px, 1fr) 140px minmax(220px, 1.3fr) auto; + align-items: end; + } + + .settings-row-actions { + display: flex; + justify-content: flex-end; + } + + .function-mocks-table-wrap { + overflow-x: auto; + } + + .function-mocks-table { + min-width: 1320px; + border: 1px solid rgba(25, 54, 61, 0.08); + border-radius: 20px; + background: rgba(255, 255, 255, 0.78); + overflow: hidden; + } + + .function-mocks-table-grid { + display: grid; + gap: 12px; + grid-template-columns: + minmax(150px, 1fr) + minmax(180px, 1.1fr) + 120px + minmax(220px, 1.2fr) + minmax(180px, 1fr) + 120px + 120px + minmax(220px, 1.2fr); + align-items: start; + } + + .function-mocks-header { + padding: 12px 14px; + border-bottom: 1px solid rgba(25, 54, 61, 0.08); + background: rgba(25, 54, 61, 0.05); + } + + .function-mocks-heading { + color: var(--studio-muted); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 700; + } + + .function-mocks-row { + padding: 14px; + border-bottom: 1px solid rgba(25, 54, 61, 0.08); + } + + .function-mocks-row:last-child { + border-bottom: 0; + } + + .function-mock-primary-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 10px; + } + + .function-mocks-note { + margin-top: 10px; + } + + .settings-hint { + color: var(--studio-muted); + font-size: 0.82rem; + line-height: 1.45; + } + + .settings-error { + color: var(--q-negative); + font-size: 0.82rem; + line-height: 1.45; + } + .wallet-expanded-card { border-radius: 22px; border: 1px solid rgba(25, 54, 61, 0.1); @@ -462,7 +641,7 @@ padding: 16px 18px; display: flex; gap: 12px; - align-items: flex-start; + align-items: center; justify-content: space-between; background: rgba(25, 54, 61, 0.05); } @@ -480,7 +659,7 @@ display: flex; gap: 12px; min-width: 0; - align-items: flex-start; + align-items: center; } .test-summary-arrow { @@ -493,6 +672,7 @@ background: rgba(25, 54, 61, 0.08); color: var(--studio-muted); font-size: 1rem; + line-height: 1; flex: 0 0 auto; } @@ -665,7 +845,7 @@ min-height: 68vh; resize: vertical; padding: 16px; - border-radius: 20px; + border-radius: 12px; border: 1px solid rgba(25, 54, 61, 0.12); background: #fffdf9; color: #172127; @@ -717,10 +897,20 @@ grid-template-columns: 1fr; } - .test-detail-grid, - .mock-source-grid { + .mock-source-grid, + .settings-row-grid, + .settings-table-grid { grid-template-columns: 1fr; } + + .settings-table-header { + display: none; + } + + .settings-row-actions, + .settings-table-actions { + justify-content: flex-start; + } } @media (max-width: 640px) { @@ -958,24 +1148,135 @@ -
- Settings to configure for the selected funding source. -
+
+ +
+ +
+

+ Settings to configure for the selected funding source. +

+
+
-
-
-
{{ setting.key }}
-
{{ prettyValue(setting.value) }}
+
+ + {{ currentFundingSourceSettingRows.length }} rows + +
+
+ +
+
+ Edit, add, or remove key-value settings. Changes update the active fixture JSON immediately. +
+ +
+
+
+
Key
+
Type
+
Value
+
Actions
+
+ +
+
+
+ +
+ +
+ +
+ +
+
+ +
+ + +
+ +
+ +
+
+ +
+ {{ row.error }} +
+
+
+
+ +
+ No settings are defined yet for this funding source. +
+ +
+ +
- - -
- No settings are defined for this funding source. -
+
@@ -1011,58 +1312,185 @@ -
- - {{ currentFunction.name }} - - - {{ currentFundingSource.name }} - - - {{ currentFunctionBaseMockPath }} - -
+
+ +
+ +
+

+ Function Mocks +

+
+
-
-
-

Function Mocks

+
+ + {{ currentFunctionMockRows.length }} mocks + +
+
+ +
Reusable mocks for this function and the selected funding source.
-
- - -
-
-
-
-
{{ mock.name }}
-
{{ mockSummary(mock) }}
-
- - {{ mock.response_type || mock.request_type || 'mock' }} - +
+ Edit the mock fields directly. Changes update + {{ currentFunctionBaseMockPath }} + immediately.
- -
-
+
+
+
+
+
Mock Name
+
URI
+
Method
+
Headers JSON
+
Query Params JSON
+
Request Type
+
Response Type
+
Response JSON
+
-
- No function-level mocks are defined for - {{ currentFundingSource.name }} - in - {{ currentFunction.name }}. -
+
+
+
+ +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ {{ mockRow.error }} +
+
+ Leave unused fields blank. JSON columns must contain valid JSON objects or values. +
+
+
+
+
+ +
+ No function-level mocks are defined yet for + {{ currentFundingSource.name }} + in + {{ currentFunction.name }}. +
+ +
+ +
+
+
@@ -1135,13 +1563,6 @@ max-height="220px" > -
- -
@@ -1702,6 +2123,38 @@ } } + function settingTypeFromValue(value) { + if (value === null) { + return 'null' + } + if (Array.isArray(value) || isPlainObject(value)) { + return 'json' + } + if (typeof value === 'boolean') { + return 'boolean' + } + if (typeof value === 'number') { + return 'number' + } + return 'string' + } + + function settingValueToText(value, type = settingTypeFromValue(value)) { + if (type === 'null') { + return 'null' + } + if (type === 'boolean') { + return value === true ? 'true' : 'false' + } + if (type === 'number') { + return value === undefined || value === null ? '' : String(value) + } + if (type === 'json') { + return safeStringify(value) + } + return value === undefined || value === null ? '' : String(value) + } + const { createApp } = Vue createApp({ @@ -1710,8 +2163,10 @@ }, data() { const datasets = {} + const functionMockDrafts = {} const selectedFunctionByDataset = {} const selectedFundingSourceByDataset = {} + const settingsDrafts = {} DATASET_DEFS.forEach(def => { datasets[def.key] = createDatasetState(def) @@ -1723,6 +2178,10 @@ datasetDefs: DATASET_DEFS, datasets, editorDialog: false, + functionMockDraftRowId: 0, + functionMockDrafts, + settingsDraftRowId: 0, + settingsDrafts, selectedDatasetKey: 'rest', selectedFunctionByDataset, selectedFundingSourceByDataset @@ -1741,6 +2200,15 @@ value: fs.name })) }, + settingTypeOptions() { + return [ + { label: 'String', value: 'string' }, + { label: 'Boolean', value: 'boolean' }, + { label: 'Number', value: 'number' }, + { label: 'Null', value: 'null' }, + { label: 'JSON', value: 'json' } + ] + }, currentFundingSourceName() { return this.selectedFundingSourceByDataset[this.selectedDatasetKey] || '' }, @@ -1751,16 +2219,14 @@ ) || null ) }, - currentFundingSourceSettings() { - if (!this.currentFundingSource?.settings) { + currentFundingSourceSettingRows() { + if (!this.currentFundingSourceName) { return [] } - return Object.entries(this.currentFundingSource.settings).map( - ([key, value]) => ({ - key, - value - }) + return this.getSettingsDraftRows( + this.selectedDatasetKey, + this.currentFundingSourceName ) }, functionOptions() { @@ -1790,20 +2256,6 @@ editorDirty() { return this.currentDataset.rawText !== this.currentDataset.snapshotText }, - currentFunctionBaseMockEntry() { - if (!this.currentFunction || !this.currentFundingSource) { - return null - } - - return ( - this.currentFunction.baseMockWallets.find( - entry => entry.fundingSource.name === this.currentFundingSource.name - ) || null - ) - }, - currentFunctionBaseMockEntries() { - return this.currentFunctionBaseMockEntry?.mockEntries || [] - }, currentFunctionBaseMockPath() { if (!this.currentFunction || !this.currentFundingSource) { return '' @@ -1814,6 +2266,17 @@ this.currentFundingSource.name ) }, + currentFunctionMockRows() { + if (!this.currentFunctionName || !this.currentFundingSourceName) { + return [] + } + + return this.getFunctionMockDraftRows( + this.selectedDatasetKey, + this.currentFunctionName, + this.currentFundingSourceName + ) + }, currentFunctionTestsForFundingSource() { if (!this.currentFunction || !this.currentFundingSource) { return [] @@ -1912,6 +2375,15 @@ watch: { selectedDatasetKey() { this.ensureSelections(this.selectedDatasetKey) + this.ensureSettingsDraftForCurrent() + this.ensureFunctionMockDraftForCurrent() + }, + currentFundingSourceName() { + this.ensureSettingsDraftForCurrent() + this.ensureFunctionMockDraftForCurrent() + }, + currentFunctionName() { + this.ensureFunctionMockDraftForCurrent() } }, mounted() { @@ -1938,27 +2410,6 @@ this.saveDataset(this.currentDataset.key) } }, - mockSummary(mock) { - const pieces = [] - - if (mock.method && mock.uri) { - pieces.push(`${mock.method} ${mock.uri}`) - } else if (mock.method) { - pieces.push(mock.method) - } else if (mock.uri) { - pieces.push(mock.uri) - } - - if (mock.request_type) { - pieces.push(`request=${mock.request_type}`) - } - - if (mock.response_type) { - pieces.push(`response=${mock.response_type}`) - } - - return pieces.join(' ยท ') || 'No mock metadata' - }, prettyValue(value) { if (typeof value === 'string') { return value @@ -1973,6 +2424,386 @@ return safeStringify(value) }, + makeSettingsDraftKey(datasetKey, fundingSourceName) { + return `${datasetKey}:${fundingSourceName}` + }, + makeFunctionMockDraftKey(datasetKey, functionName, fundingSourceName) { + return `${datasetKey}:${functionName}:${fundingSourceName}` + }, + createSettingsDraftRow(key = '', value = '') { + const type = settingTypeFromValue(value) + + return { + id: ++this.settingsDraftRowId, + key, + type, + valueText: settingValueToText(value, type), + error: '' + } + }, + createFunctionMockDraftRow(name = '', value = {}) { + const mock = isPlainObject(value) ? value : {} + + return { + id: ++this.functionMockDraftRowId, + name, + uri: mock.uri ?? '', + method: mock.method ?? '', + headersText: + mock.headers !== undefined ? safeStringify(mock.headers) : '', + queryParamsText: + mock.query_params !== undefined + ? safeStringify(mock.query_params) + : '', + requestType: mock.request_type ?? '', + responseType: mock.response_type ?? '', + responseText: + mock.response !== undefined ? safeStringify(mock.response) : '', + error: '' + } + }, + getSettingsDraftRows(datasetKey, fundingSourceName) { + const draftKey = this.makeSettingsDraftKey(datasetKey, fundingSourceName) + return this.settingsDrafts[draftKey] || [] + }, + getFunctionMockDraftRows(datasetKey, functionName, fundingSourceName) { + const draftKey = this.makeFunctionMockDraftKey( + datasetKey, + functionName, + fundingSourceName + ) + return this.functionMockDrafts[draftKey] || [] + }, + ensureSettingsDraft(datasetKey, fundingSourceName, force = false) { + if (!datasetKey || !fundingSourceName) { + return [] + } + + const draftKey = this.makeSettingsDraftKey(datasetKey, fundingSourceName) + + if (!force && this.settingsDrafts[draftKey]) { + return this.settingsDrafts[draftKey] + } + + const settings = + this.datasets[datasetKey].data?.funding_sources?.[fundingSourceName] + ?.settings || {} + + this.settingsDrafts[draftKey] = Object.entries(settings).map( + ([key, value]) => this.createSettingsDraftRow(key, value) + ) + + return this.settingsDrafts[draftKey] + }, + ensureFunctionMockDraft( + datasetKey, + functionName, + fundingSourceName, + force = false + ) { + if (!datasetKey || !functionName || !fundingSourceName) { + return [] + } + + const draftKey = this.makeFunctionMockDraftKey( + datasetKey, + functionName, + fundingSourceName + ) + + if (!force && this.functionMockDrafts[draftKey]) { + return this.functionMockDrafts[draftKey] + } + + const mocks = + this.datasets[datasetKey].data?.functions?.[functionName]?.mocks?.[ + fundingSourceName + ] || {} + + this.functionMockDrafts[draftKey] = Object.entries(mocks).map( + ([name, value]) => this.createFunctionMockDraftRow(name, value) + ) + + return this.functionMockDrafts[draftKey] + }, + ensureSettingsDraftForCurrent(force = false) { + if (!this.currentFundingSourceName) { + return + } + + this.ensureSettingsDraft( + this.selectedDatasetKey, + this.currentFundingSourceName, + force + ) + }, + ensureFunctionMockDraftForCurrent(force = false) { + if (!this.currentFunctionName || !this.currentFundingSourceName) { + return + } + + this.ensureFunctionMockDraft( + this.selectedDatasetKey, + this.currentFunctionName, + this.currentFundingSourceName, + force + ) + }, + clearSettingsDrafts(datasetKey) { + Object.keys(this.settingsDrafts).forEach(key => { + if (key.startsWith(`${datasetKey}:`)) { + delete this.settingsDrafts[key] + } + }) + }, + clearFunctionMockDrafts(datasetKey) { + Object.keys(this.functionMockDrafts).forEach(key => { + if (key.startsWith(`${datasetKey}:`)) { + delete this.functionMockDrafts[key] + } + }) + }, + parseSettingRowValue(row) { + if (row.type === 'boolean') { + return row.valueText === 'true' + } + + if (row.type === 'number') { + if (row.valueText === '') { + throw new Error('Number values cannot be empty.') + } + + const value = Number(row.valueText) + if (Number.isNaN(value)) { + throw new Error('Enter a valid number.') + } + + return value + } + + if (row.type === 'null') { + return null + } + + if (row.type === 'json') { + if (row.valueText.trim() === '') { + throw new Error('Enter valid JSON for this value.') + } + + return JSON.parse(row.valueText) + } + + return row.valueText + }, + parseFunctionMockRowBody(row) { + const mock = {} + + const name = row.name.trim() + if (!name) { + throw new Error('Mock name cannot be empty.') + } + + if (row.uri.trim() !== '') { + mock.uri = row.uri + } + + if (row.method.trim() !== '') { + mock.method = row.method + } + + if (row.headersText.trim() !== '') { + const parsedHeaders = JSON.parse(row.headersText) + if (!isPlainObject(parsedHeaders)) { + throw new Error('Headers must be a JSON object.') + } + mock.headers = parsedHeaders + } + + if (row.queryParamsText.trim() !== '') { + const parsedQueryParams = JSON.parse(row.queryParamsText) + if (!isPlainObject(parsedQueryParams)) { + throw new Error('Query params must be a JSON object.') + } + mock.query_params = parsedQueryParams + } + + if (row.requestType.trim() !== '') { + mock.request_type = row.requestType + } + + if (row.responseType.trim() !== '') { + mock.response_type = row.responseType + } + + if (row.responseText.trim() !== '') { + mock.response = JSON.parse(row.responseText) + } + + return mock + }, + applyDatasetMutation(datasetKey, mutator, options = {}) { + const dataset = this.datasets[datasetKey] + if (!dataset.data) { + return + } + + const nextData = deepClone(dataset.data) + mutator(nextData) + + const rawText = JSON.stringify(nextData, null, 2) + this.commitDataset(datasetKey, nextData, { + rawText, + snapshotText: dataset.snapshotText, + sourceLabel: dataset.sourceLabel, + fileHandle: dataset.fileHandle, + loadError: dataset.loadError, + preserveSettingsDrafts: options.preserveSettingsDrafts === true, + preserveFunctionMockDrafts: + options.preserveFunctionMockDrafts === true + }) + }, + addFundingSourceSettingRow() { + if (!this.currentFundingSourceName) { + return + } + + const rows = this.ensureSettingsDraft( + this.selectedDatasetKey, + this.currentFundingSourceName + ) + rows.push(this.createSettingsDraftRow('', '')) + }, + handleFundingSourceSettingTypeChange(row) { + if (row.type === 'boolean') { + row.valueText = + row.valueText === 'true' || row.valueText === 'false' + ? row.valueText + : 'false' + } else if (row.type === 'null') { + row.valueText = 'null' + } else if (row.type === 'number') { + row.valueText = row.valueText.trim() === '' ? '0' : row.valueText + } else if (row.type === 'json') { + row.valueText = + row.valueText.trim() === '' ? '{}' : row.valueText + } + + this.persistFundingSourceSettings() + }, + removeFundingSourceSettingRow(rowIndex) { + const rows = this.currentFundingSourceSettingRows + rows.splice(rowIndex, 1) + this.persistFundingSourceSettings() + }, + persistFundingSourceSettings() { + if (!this.currentFundingSourceName) { + return + } + + const rows = this.currentFundingSourceSettingRows + const nextSettings = {} + + for (const row of rows) { + row.error = '' + } + + for (const row of rows) { + const key = row.key.trim() + + if (!key) { + continue + } + + try { + nextSettings[key] = this.parseSettingRowValue(row) + } catch (error) { + row.error = + error instanceof Error ? error.message : String(error) + this.notify( + `Could not save setting "${key || 'new setting'}".`, + 'negative', + row.error + ) + return + } + } + + this.applyDatasetMutation( + this.selectedDatasetKey, + data => { + data.funding_sources[this.currentFundingSourceName].settings = + nextSettings + }, + { + preserveSettingsDrafts: true + } + ) + }, + addFunctionMockRow() { + if (!this.currentFunctionName || !this.currentFundingSourceName) { + return + } + + const rows = this.ensureFunctionMockDraft( + this.selectedDatasetKey, + this.currentFunctionName, + this.currentFundingSourceName + ) + rows.push(this.createFunctionMockDraftRow('', {})) + }, + removeFunctionMockRow(rowIndex) { + const rows = this.currentFunctionMockRows + rows.splice(rowIndex, 1) + this.persistFunctionMocks() + }, + persistFunctionMocks() { + if (!this.currentFunctionName || !this.currentFundingSourceName) { + return + } + + const rows = this.currentFunctionMockRows + const nextMocks = {} + + for (const row of rows) { + row.error = '' + } + + for (const row of rows) { + const name = row.name.trim() + + if (!name) { + continue + } + + try { + nextMocks[name] = this.parseFunctionMockRowBody(row) + } catch (error) { + row.error = + error instanceof Error ? error.message : String(error) + this.notify( + `Could not save mock "${name || 'new mock'}".`, + 'negative', + row.error + ) + return + } + } + + this.applyDatasetMutation( + this.selectedDatasetKey, + data => { + if (!data.functions[this.currentFunctionName].mocks) { + data.functions[this.currentFunctionName].mocks = {} + } + data.functions[this.currentFunctionName].mocks[ + this.currentFundingSourceName + ] = nextMocks + }, + { + preserveFunctionMockDrafts: true + } + ) + }, baseWalletPath(functionName, fundingSourceName) { return `functions.${functionName}.mocks.${fundingSourceName}` }, @@ -2040,7 +2871,33 @@ dataset.loadError = options.loadError } + if (options.preserveSettingsDrafts !== true) { + this.clearSettingsDrafts(datasetKey) + } + if (options.preserveFunctionMockDrafts !== true) { + this.clearFunctionMockDrafts(datasetKey) + } + this.ensureSelections(datasetKey) + + if ( + datasetKey === this.selectedDatasetKey && + this.selectedFundingSourceByDataset[datasetKey] + ) { + this.ensureSettingsDraft( + datasetKey, + this.selectedFundingSourceByDataset[datasetKey], + options.preserveSettingsDrafts !== true + ) + if (this.selectedFunctionByDataset[datasetKey]) { + this.ensureFunctionMockDraft( + datasetKey, + this.selectedFunctionByDataset[datasetKey], + this.selectedFundingSourceByDataset[datasetKey], + options.preserveFunctionMockDrafts !== true + ) + } + } }, validateEditor(datasetKey) { const dataset = this.datasets[datasetKey] @@ -2087,9 +2944,22 @@ }, resetEditor(datasetKey) { const dataset = this.datasets[datasetKey] - dataset.rawText = dataset.snapshotText - dataset.parseError = '' - this.notify(`Reset editor for ${dataset.fileName}.`, 'info') + const parsed = parseJsonText(dataset.snapshotText) + + if (!parsed.ok) { + dataset.parseError = `Invalid JSON in ${dataset.fileName}: ${parsed.error}` + this.notify(`Could not reset ${dataset.fileName}.`, 'negative') + return + } + + this.commitDataset(datasetKey, parsed.data, { + rawText: dataset.snapshotText, + snapshotText: dataset.snapshotText, + fileHandle: dataset.fileHandle, + sourceLabel: dataset.sourceLabel, + loadError: dataset.loadError + }) + this.notify(`Reset ${dataset.fileName} to the last saved snapshot.`, 'info') }, async loadRelative(datasetKey) { const dataset = this.datasets[datasetKey]