Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions browser2/js/src/markup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -4556,7 +4564,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);
Expand Down
7 changes: 6 additions & 1 deletion browser2/js/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,9 @@ table td {
padding: 20px;
border-radius: 5px;
z-index: 100;
}
}
/* Field validation error styles */
.field-error {
color: red;
font-weight: bold;
}
3 changes: 2 additions & 1 deletion src/mspec/data/lingo/pages/builtin-mapp-model-instance.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@
"default": {
"state": "pending",
"error": "",
"data": {}
"data": {},
"field_errors": {}
}
},
"header": {
Expand Down
3 changes: 2 additions & 1 deletion src/mspec/data/lingo/pages/builtin-mapp-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@
"state": "initial",
"error": "",
"item_id": "",
"data": {}
"data": {},
"field_errors": {}
}
},
"model_list": {
Expand Down
68 changes: 68 additions & 0 deletions templates/mapp-py/tests/crud.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -498,3 +498,71 @@ 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 targetModule, 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;
targetModule = module;
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);
});
69 changes: 68 additions & 1 deletion templates/sosh-net/tests/crud.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -497,4 +497,71 @@ 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('::');
}
});
});
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 targetModule, 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;
targetModule = module;
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);
});