diff --git a/browser2/js/src/markup.js b/browser2/js/src/markup.js index 7012e31..e5300a2 100644 --- a/browser2/js/src/markup.js +++ b/browser2/js/src/markup.js @@ -924,19 +924,23 @@ const lingoFunctionLookup = { if (response.ok) { const responseData = await response.json(); // console.log('crud.create - responseData:', responseData); - return {state: 'success', item_id: responseData.id}; + return {state: 'success', item_id: responseData.id, field_errors: {}}; } else { const errorData = await response.json(); let errorMessage = `${response.status} ${response.statusText}`; + let fieldErrors = {}; if (errorData.hasOwnProperty('error') && errorData.error.hasOwnProperty('message')) { errorMessage = errorData.error.message; } + if (errorData.hasOwnProperty('error') && errorData.error.code === 'VALIDATION_ERROR' && errorData.error.hasOwnProperty('field_errors')) { + fieldErrors = errorData.error.field_errors; + } console.error('crud.create - HTTP error:', response.status, response.statusText); - return {state: 'error', error: errorMessage}; + return {state: 'error', error: errorMessage, field_errors: fieldErrors}; } } catch (error) { console.error('crud.create - network error:', error); - return {state: 'error', error: `Network error: ${error.message}`}; + return {state: 'error', error: `Network error: ${error.message}`, field_errors: {}}; } }, createArgs: _crudCreateArgs @@ -980,19 +984,23 @@ const lingoFunctionLookup = { if (response.ok) { const responseData = await response.json(); // console.log('crud.update - responseData:', responseData); - return {state: 'edited', data: responseData}; + return {state: 'edited', data: responseData, field_errors: {}}; } else { const errorData = await response.json(); let errorMessage = `${response.status} ${response.statusText}`; + let fieldErrors = {}; if (errorData.hasOwnProperty('error') && errorData.error.hasOwnProperty('message')) { errorMessage = errorData.error.message; } + if (errorData.hasOwnProperty('error') && errorData.error.code === 'VALIDATION_ERROR' && errorData.error.hasOwnProperty('field_errors')) { + fieldErrors = errorData.error.field_errors; + } console.error('crud.update - HTTP error:', response.status, response.statusText); - return {state: 'error', error: errorMessage}; + return {state: 'error', error: errorMessage, field_errors: fieldErrors}; } } catch (error) { console.error('crud.update - network error:', error); - return {state: 'error', error: `Network error: ${error.message}`}; + return {state: 'error', error: `Network error: ${error.message}`, field_errors: {}}; } }, createArgs: _crudUpdateArgs @@ -2545,6 +2553,10 @@ function _renderModelRead(app, element, ctx = null) { if (!state.hasOwnProperty('data')) state.data = null; if (!state.hasOwnProperty('state')) state.state = 'pending'; if (!state.hasOwnProperty('error')) state.error = ''; + if (!state.hasOwnProperty('field_errors')) state.field_errors = {}; + + // true when a validation error was returned during an edit operation + const isEditError = state.state === 'error' && state.field_errors && Object.keys(state.field_errors).length > 0; // // buttons @@ -2569,7 +2581,7 @@ function _renderModelRead(app, element, ctx = null) { elements.push({ button: loadScript, text: 'load', - disabled: state.state === 'editing' || state.state === 'loading' + disabled: state.state === 'editing' || state.state === 'loading' || isEditError }); // edit // @@ -2588,14 +2600,14 @@ function _renderModelRead(app, element, ctx = null) { // cancel // const cancelScript = { - set: {state: {[stateField]: {state: {}}}}, - to: 'loaded' + set: {state: {[stateField]: {}}}, + to: {state: 'loaded', field_errors: {}} }; elements.push({ button: cancelScript, text: 'cancel', - disabled: state.state !== 'editing' + disabled: state.state !== 'editing' && !isEditError }); // status // @@ -2649,7 +2661,7 @@ function _renderModelRead(app, element, ctx = null) { // view item // - if (state.state === 'editing') { + if (state.state === 'editing' || isEditError) { // view editable form elements.push({ form: { @@ -4556,7 +4568,18 @@ function createFormElement(app, element, ctx = null) { } else { // Description for non-list fields thirdCell.className = 'form-description'; - thirdCell.textContent = fieldSpec.description || ''; + if (fieldSpec.description) { + thirdCell.appendChild(document.createTextNode(fieldSpec.description)); + } + } + + // Show field validation error if present + const fieldErrors = currentState.field_errors; + if (fieldErrors && fieldErrors[fieldKey]) { + const errorSpan = document.createElement('span'); + errorSpan.textContent = fieldErrors[fieldKey]; + errorSpan.className = 'field-error'; + thirdCell.appendChild(errorSpan); } row.appendChild(thirdCell); diff --git a/browser2/js/src/style.css b/browser2/js/src/style.css index d2daf46..ab4ada4 100644 --- a/browser2/js/src/style.css +++ b/browser2/js/src/style.css @@ -182,4 +182,9 @@ table td { padding: 20px; border-radius: 5px; z-index: 100; -} \ No newline at end of file +} +/* Field validation error styles */ +.field-error { + color: red; + font-weight: bold; +} diff --git a/src/mspec/data/lingo/pages/builtin-mapp-model-instance.json b/src/mspec/data/lingo/pages/builtin-mapp-model-instance.json index 1d8c1ee..1e2e5de 100644 --- a/src/mspec/data/lingo/pages/builtin-mapp-model-instance.json +++ b/src/mspec/data/lingo/pages/builtin-mapp-model-instance.json @@ -107,7 +107,8 @@ "default": { "state": "pending", "error": "", - "data": {} + "data": {}, + "field_errors": {} } }, "header": { diff --git a/src/mspec/data/lingo/pages/builtin-mapp-model.json b/src/mspec/data/lingo/pages/builtin-mapp-model.json index 0540b48..07f119b 100644 --- a/src/mspec/data/lingo/pages/builtin-mapp-model.json +++ b/src/mspec/data/lingo/pages/builtin-mapp-model.json @@ -82,7 +82,8 @@ "state": "initial", "error": "", "item_id": "", - "data": {} + "data": {}, + "field_errors": {} } }, "model_list": { diff --git a/templates/mapp-py/tests/crud.spec.js b/templates/mapp-py/tests/crud.spec.js index eae612e..59059cc 100644 --- a/templates/mapp-py/tests/crud.spec.js +++ b/templates/mapp-py/tests/crud.spec.js @@ -498,3 +498,170 @@ test('test crud and list for all models', async ({ browser, crudEnv, crudSession await expect(page.locator('h1')).toContainText('::'); } }); + +test('test validation errors are displayed in form', async ({ browser, crudEnv, crudSession }) => { + const context = await browser.newContext({ storageState: crudSession.storageState }); + const page = await context.newPage(); + + await context.addCookies([{ name: 'protocol_mode', value: 'true', domain: new URL(crudEnv.host).hostname, path: '/' }]); + + // Find the first module and model that we can navigate to + const modules = crudEnv.spec.modules; + let targetModel, targetModuleKebab, targetModelKebab; + for (const [moduleName, module] of Object.entries(modules)) { + if (['auth', 'file-system', 'media'].includes(module.name.kebab_case)) continue; + for (const [modelName, model] of Object.entries(module.models || {})) { + if (model.hidden === true) continue; + if (model.auth && model.auth.max_models_per_user === 0) continue; + + targetModel = model; + targetModuleKebab = module.name.kebab_case; + targetModelKebab = model.name.kebab_case; + break; + } + if (targetModel) break; + } + + expect(targetModel).toBeDefined(); + + // Navigate to the model create page + await page.goto(crudEnv.host); + await page.getByRole('link', { name: targetModuleKebab, exact: true }).click(); + await page.getByRole('link', { name: targetModelKebab, exact: true }).click(); + await expect(page.locator('h1')).toContainText(`:: ${targetModelKebab}`); + + // Mock the API to return a VALIDATION_ERROR for the create call + const apiUrl = `${crudEnv.host}/api/${targetModuleKebab}/${targetModelKebab}`; + const fieldErrorMessage = 'This field failed validation'; + const firstFieldName = Object.keys(targetModel.fields)[0]; + + await page.route(apiUrl, async route => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + error: { + code: 'VALIDATION_ERROR', + message: 'This model has failed validation', + field_errors: { + [firstFieldName]: fieldErrorMessage + } + } + }) + }); + } else { + await route.continue(); + } + }); + + // Click submit to trigger the API call + await page.getByRole('button', { name: 'Submit' }).click(); + + // Verify the validation error message is shown in the status area + await expect(page.locator('#lingo-app')).toContainText('This model has failed validation'); + + // Verify the field error message is shown in the form's third column + const fieldError = page.locator('.field-error'); + await expect(fieldError).toBeVisible(); + await expect(fieldError).toContainText(fieldErrorMessage); +}); + +test('test validation errors are displayed in edit form', async ({ browser, crudEnv, crudSession }) => { + const context = await browser.newContext({ storageState: crudSession.storageState }); + const page = await context.newPage(); + + await context.addCookies([{ name: 'protocol_mode', value: 'true', domain: new URL(crudEnv.host).hostname, path: '/' }]); + + // Find the first module and model that we can navigate to + const modules = crudEnv.spec.modules; + let targetModel, targetModuleKebab, targetModelKebab; + for (const [moduleName, module] of Object.entries(modules)) { + if (['auth', 'file-system', 'media'].includes(module.name.kebab_case)) continue; + for (const [modelName, model] of Object.entries(module.models || {})) { + if (model.hidden === true) continue; + if (model.auth && model.auth.max_models_per_user === 0) continue; + + targetModel = model; + targetModuleKebab = module.name.kebab_case; + targetModelKebab = model.name.kebab_case; + break; + } + if (targetModel) break; + } + + expect(targetModel).toBeDefined(); + + // Create a real item first via the API so we have an item to edit + const createExample = getExampleFromModel(targetModel, 0); + const apiUrl = `${crudEnv.host}/api/${targetModuleKebab}/${targetModelKebab}`; + const createResponse = await fetch(apiUrl, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'Cookie': `session=${crudSession.storageState.cookies.find(c => c.name === 'session')?.value || ''}`}, + body: JSON.stringify(createExample) + }); + const createdItem = await createResponse.json(); + const itemId = createdItem.id; + expect(itemId).toBeDefined(); + + // Navigate to the item instance page + await page.goto(`${crudEnv.host}/${targetModuleKebab}/${targetModelKebab}/${itemId}`); + await expect(page.locator('h1')).toContainText(`:: ${targetModelKebab}`); + + // Wait for the item to load then click edit + await page.getByRole('button', { name: 'load', exact: true }).click(); + await expect(page.locator('#lingo-app')).toContainText('loaded'); + await page.getByRole('button', { name: 'edit', exact: true }).click(); + + // Verify the form is now visible + await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); + + // Mock the PUT endpoint to return a VALIDATION_ERROR + const itemApiUrl = `${crudEnv.host}/api/${targetModuleKebab}/${targetModelKebab}/${itemId}`; + const fieldErrorMessage = 'This field failed validation during edit'; + const firstFieldName = Object.keys(targetModel.fields)[0]; + + await page.route(itemApiUrl, async route => { + if (route.request().method() === 'PUT') { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + error: { + code: 'VALIDATION_ERROR', + message: 'This model has failed validation', + field_errors: { + [firstFieldName]: fieldErrorMessage + } + } + }) + }); + } else { + await route.continue(); + } + }); + + // Click submit to trigger the PUT with mocked VALIDATION_ERROR + await page.getByRole('button', { name: 'Submit' }).click(); + + // Verify the validation error message is shown in the status area + await expect(page.locator('#lingo-app')).toContainText('This model has failed validation'); + + // Verify the edit form is still visible (not reverted to view mode) + await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); + + // Verify the field error message is shown in the form's third column + const fieldError = page.locator('.field-error'); + await expect(fieldError).toBeVisible(); + await expect(fieldError).toContainText(fieldErrorMessage); + + // Verify the cancel button is enabled so user can exit edit mode + await expect(page.getByRole('button', { name: 'cancel', exact: true })).toBeEnabled(); + + // Click cancel to exit edit mode - field errors should clear + await page.getByRole('button', { name: 'cancel', exact: true }).click(); + + // Verify the form is gone and we are back in view mode with no field errors + await expect(page.getByRole('button', { name: 'Submit' })).not.toBeVisible(); + await expect(page.locator('.field-error')).not.toBeVisible(); +}); diff --git a/templates/sosh-net/tests/crud.spec.js b/templates/sosh-net/tests/crud.spec.js index a214b12..bcec480 100644 --- a/templates/sosh-net/tests/crud.spec.js +++ b/templates/sosh-net/tests/crud.spec.js @@ -497,4 +497,170 @@ test('test crud and list for all models', async ({ browser, crudEnv, crudSession await page.getByRole('link', { name: crudEnv.spec.project.name.lower_case, exact: true }).click(); await expect(page.locator('h1')).toContainText('::'); } -}); \ No newline at end of file +}); +test('test validation errors are displayed in form', async ({ browser, crudEnv, crudSession }) => { + const context = await browser.newContext({ storageState: crudSession.storageState }); + const page = await context.newPage(); + + await context.addCookies([{ name: 'protocol_mode', value: 'true', domain: new URL(crudEnv.host).hostname, path: '/' }]); + + // Find the first module and model that we can navigate to + const modules = crudEnv.spec.modules; + let targetModel, targetModuleKebab, targetModelKebab; + for (const [moduleName, module] of Object.entries(modules)) { + if (['auth', 'file-system', 'media'].includes(module.name.kebab_case)) continue; + for (const [modelName, model] of Object.entries(module.models || {})) { + if (model.hidden === true) continue; + if (model.auth && model.auth.max_models_per_user === 0) continue; + + targetModel = model; + targetModuleKebab = module.name.kebab_case; + targetModelKebab = model.name.kebab_case; + break; + } + if (targetModel) break; + } + + expect(targetModel).toBeDefined(); + + // Navigate to the model create page + await page.goto(crudEnv.host); + await page.getByRole('link', { name: targetModuleKebab, exact: true }).click(); + await page.getByRole('link', { name: targetModelKebab, exact: true }).click(); + await expect(page.locator('h1')).toContainText(`:: ${targetModelKebab}`); + + // Mock the API to return a VALIDATION_ERROR for the create call + const apiUrl = `${crudEnv.host}/api/${targetModuleKebab}/${targetModelKebab}`; + const fieldErrorMessage = 'This field failed validation'; + const firstFieldName = Object.keys(targetModel.fields)[0]; + + await page.route(apiUrl, async route => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + error: { + code: 'VALIDATION_ERROR', + message: 'This model has failed validation', + field_errors: { + [firstFieldName]: fieldErrorMessage + } + } + }) + }); + } else { + await route.continue(); + } + }); + + // Click submit to trigger the API call + await page.getByRole('button', { name: 'Submit' }).click(); + + // Verify the validation error message is shown in the status area + await expect(page.locator('#lingo-app')).toContainText('This model has failed validation'); + + // Verify the field error message is shown in the form's third column + const fieldError = page.locator('.field-error'); + await expect(fieldError).toBeVisible(); + await expect(fieldError).toContainText(fieldErrorMessage); +}); + +test('test validation errors are displayed in edit form', async ({ browser, crudEnv, crudSession }) => { + const context = await browser.newContext({ storageState: crudSession.storageState }); + const page = await context.newPage(); + + await context.addCookies([{ name: 'protocol_mode', value: 'true', domain: new URL(crudEnv.host).hostname, path: '/' }]); + + // Find the first module and model that we can navigate to + const modules = crudEnv.spec.modules; + let targetModel, targetModuleKebab, targetModelKebab; + for (const [moduleName, module] of Object.entries(modules)) { + if (['auth', 'file-system', 'media'].includes(module.name.kebab_case)) continue; + for (const [modelName, model] of Object.entries(module.models || {})) { + if (model.hidden === true) continue; + if (model.auth && model.auth.max_models_per_user === 0) continue; + + targetModel = model; + targetModuleKebab = module.name.kebab_case; + targetModelKebab = model.name.kebab_case; + break; + } + if (targetModel) break; + } + + expect(targetModel).toBeDefined(); + + // Create a real item first via the API so we have an item to edit + const createExample = getExampleFromModel(targetModel, 0); + const apiUrl = `${crudEnv.host}/api/${targetModuleKebab}/${targetModelKebab}`; + const createResponse = await fetch(apiUrl, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'Cookie': `session=${crudSession.storageState.cookies.find(c => c.name === 'session')?.value || ''}`}, + body: JSON.stringify(createExample) + }); + const createdItem = await createResponse.json(); + const itemId = createdItem.id; + expect(itemId).toBeDefined(); + + // Navigate to the item instance page + await page.goto(`${crudEnv.host}/${targetModuleKebab}/${targetModelKebab}/${itemId}`); + await expect(page.locator('h1')).toContainText(`:: ${targetModelKebab}`); + + // Wait for the item to load then click edit + await page.getByRole('button', { name: 'load', exact: true }).click(); + await expect(page.locator('#lingo-app')).toContainText('loaded'); + await page.getByRole('button', { name: 'edit', exact: true }).click(); + + // Verify the form is now visible + await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); + + // Mock the PUT endpoint to return a VALIDATION_ERROR + const itemApiUrl = `${crudEnv.host}/api/${targetModuleKebab}/${targetModelKebab}/${itemId}`; + const fieldErrorMessage = 'This field failed validation during edit'; + const firstFieldName = Object.keys(targetModel.fields)[0]; + + await page.route(itemApiUrl, async route => { + if (route.request().method() === 'PUT') { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + error: { + code: 'VALIDATION_ERROR', + message: 'This model has failed validation', + field_errors: { + [firstFieldName]: fieldErrorMessage + } + } + }) + }); + } else { + await route.continue(); + } + }); + + // Click submit to trigger the PUT with mocked VALIDATION_ERROR + await page.getByRole('button', { name: 'Submit' }).click(); + + // Verify the validation error message is shown in the status area + await expect(page.locator('#lingo-app')).toContainText('This model has failed validation'); + + // Verify the edit form is still visible (not reverted to view mode) + await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); + + // Verify the field error message is shown in the form's third column + const fieldError = page.locator('.field-error'); + await expect(fieldError).toBeVisible(); + await expect(fieldError).toContainText(fieldErrorMessage); + + // Verify the cancel button is enabled so user can exit edit mode + await expect(page.getByRole('button', { name: 'cancel', exact: true })).toBeEnabled(); + + // Click cancel to exit edit mode - field errors should clear + await page.getByRole('button', { name: 'cancel', exact: true }).click(); + + // Verify the form is gone and we are back in view mode with no field errors + await expect(page.getByRole('button', { name: 'Submit' })).not.toBeVisible(); + await expect(page.locator('.field-error')).not.toBeVisible(); +});