Skip to content
Merged
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
48 changes: 46 additions & 2 deletions e2e/hypermedia-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ const customer = {
name: 'changeAddress', title: 'Change address', method: 'PUT', href: `${api}/customers/42/address`, type: 'application/json',
fields: [{ name: 'address', type: 'application/json', class: [`${api}/schemas/address`] }],
},
{
name: 'moveTo', title: 'Move to', method: 'PUT', href: `${api}/customers/42/move`, type: 'application/json',
fields: [{
name: 'address', type: 'application/json', class: [`${api}/schemas/address`],
value: { street: '1 Default Lane', city: 'Paris', labels: [] },
}],
},
{
name: 'uploadAvatar', title: 'Upload avatar', method: 'POST', href: `${api}/customers/42/avatar`, type: 'multipart/form-data',
fields: [{ name: 'file', type: 'file', accept: 'text/plain' }],
Expand All @@ -82,7 +89,10 @@ async function installApi(page: import('@playwright/test').Page) {
case '/schemas/address': return route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
type: 'object', required: ['street'], properties: { street: { type: 'string' }, city: { type: 'string' } },
type: 'object', required: ['street', 'labels'], properties: {
street: { type: 'string' }, city: { type: 'string' },
tags: { type: ['array', 'null'], items: { type: 'string' } }, labels: { type: 'array', items: { type: 'string' } },
},
}),
});
case '/documents/data': return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ document: 'ordinary JSON' }) });
Expand Down Expand Up @@ -138,7 +148,11 @@ async function installApi(page: import('@playwright/test').Page) {
}
if (url.pathname === '/customers/42/address') {
expect(request.method()).toBe('PUT');
expect(request.postDataJSON()).toEqual([{ address: { street: '12 Analytical Engine Way', city: 'London' } }]);
expect(request.postDataJSON()).toEqual([{ address: { street: '12 Analytical Engine Way', city: 'London', tags: null, labels: [] } }]);
return json({}, 204);
}
if (url.pathname === '/customers/42/move') {
expect(request.postDataJSON()).toEqual([{ address: { street: '1 Default Lane', city: 'Paris', tags: null, labels: [] } }]);
return json({}, 204);
}
if (url.pathname === '/customers/42/avatar') {
Expand Down Expand Up @@ -184,6 +198,36 @@ test('executes an action with parameters', async ({ page }) => {
await page.locator('button', { hasText: 'Change address' }).click();
await page.getByLabel('street').fill('12 Analytical Engine Way');
await page.getByLabel('city').fill('London');

// a nullable array starts as null, a non-nullable one without default value is missing
const tags = page.locator('formly-array-type', { hasText: 'tags' });
const labels = page.locator('formly-array-type', { hasText: 'labels' });
await expect(tags.locator('legend')).toHaveText('tags');
await expect(tags).toContainText('null');
await expect(labels.locator('legend')).toHaveText('labels *');
await expect(labels).toContainText('not set');
await expect(page.getByRole('button', { name: 'Submit' })).toBeDisabled();

// removing the last item leaves an empty array
await labels.locator('.add-button').click();
await labels.locator('.delete-button').click();
await expect(labels).toContainText('empty');

// a nullable array can be set back to null
await tags.locator('.add-button', { hasText: 'add' }).click();
await tags.locator('.add-button', { hasText: 'backspace' }).click();
await expect(tags).toContainText('null');

await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.locator('app-parameter-action .success')).toBeVisible();
});

test('takes arrays from the default values', async ({ page }) => {
await openEntryPoint(page);
await page.getByRole('link', { name: 'customer' }).click();

await page.locator('button', { hasText: 'Move to' }).click();
await expect(page.locator('formly-array-type', { hasText: 'labels' })).toContainText('empty');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.locator('app-parameter-action .success')).toBeVisible();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@

<div class="button-row">
<!--place tooltip in parent because it does not work when button is disabled-->
<span matTooltip="Please select a file and complete all required fields." [matTooltipDisabled]="this.canSubmit()">
<span [matTooltip]="this.submitBlockedReason()" [matTooltipDisabled]="this.canSubmit()">
<button (click)="onSubmit()"
[disabled]="!this.canSubmit()"
mat-flat-button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {selectEffectiveGeneralSettings} from "../../../store/selectors";
import { FormGroup } from '@angular/forms';
import { FormlyFieldConfig } from '@ngx-formly/core';
import { FormlyJsonschema } from '@ngx-formly/core/json-schema';
import {createActionFormlyFields} from '../../formly-extensions';
import {actionParameterValue, createActionFormlyFields, describeInvalidControls} from '../../formly-extensions';

@Component({
selector: 'app-file-upload-action',
Expand Down Expand Up @@ -111,6 +111,11 @@ export class FileUploadActionComponent implements OnInit {

canSubmit = () => this.hasFiles() && this.form.valid;

submitBlockedReason = () => [
...(this.hasFiles() ? [] : ['Please select a file.']),
...(this.form.valid ? [] : [`Please fix: ${describeInvalidControls(this.form).join(', ')}`]),
].join(' ');

onSubmit() {
if (!this.canSubmit()) {
return;
Expand Down Expand Up @@ -147,7 +152,7 @@ export class FileUploadActionComponent implements OnInit {
private doSubmit() {
this.action.files = this.files;
if (this.action.waheActionParameterName) {
this.action.parameters = this.form.value;
this.action.parameters = actionParameterValue(this.form, this.formlyFields);
}
this.actionResult= ActionResults.pending;
this.executed = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,9 @@
mat-card {
border-top: 1px solid lightgray;
}

.state {
font-style: italic;
opacity: 0.6;
margin: 0 8px 8px 8px;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,22 @@ import {FieldArrayType, FormlyFieldConfig} from '@ngx-formly/core';
@Component({
selector: 'formly-array-type',
template: ` <div>
<mat-card style="margin: 10px 0 0 0" appearance="raised">
<mat-card style="margin: 0 0 20px 0" appearance="raised">
<div class="header-container">
<mat-card-header class="header-content">
@if (props.label) {
<legend class="title">
{{ props.label }}
{{ props.label }}@if (!props['nullable']) {<span> *</span>}
</legend>
}
@if (!field.fieldGroup?.length) {
<span class="state">{{ isSet() ? 'empty' : props['nullable'] ? 'null' : 'not set' }}</span>
}
<div style="margin-bottom: 3px; margin-left: 0px;">
<mat-icon class="add-button" (click)="add()">add</mat-icon>
@if (props['nullable'] && isSet()) {
<mat-icon class="add-button" matTooltip="Set to null" (click)="unset()">backspace</mat-icon>
}
</div>
</mat-card-header>
<mat-card-title class="description" style="margin-left: 18px;">
Expand Down Expand Up @@ -52,6 +58,21 @@ import {FieldArrayType, FormlyFieldConfig} from '@ngx-formly/core';
standalone: false
})
export class ArrayTypeComponent extends FieldArrayType {
// an unset array is sent as null, an empty one as []
isSet(): boolean {
return this.model !== null && this.model !== undefined;
}

unset() {
for (let i = this.field.fieldGroup!.length - 1; i >= 0; i--) {
this.remove(i);
}
const parentModel = this.field.parent!.model as Record<string, unknown>;
parentModel[String(this.key)] = null;
this.formControl.markAsDirty();
this.formControl.updateValueAndValidity();
}

getLabel(field: FormlyFieldConfig | undefined): string {
if (field?.parent?.type === 'array' && !isNaN(Number(field?.parent?.key))) {
return `[${field?.parent?.key}, ${field.key}]`;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Component, OnInit} from '@angular/core';
import {Component, OnInit, ViewEncapsulation} from '@angular/core';
import { FormlyFieldCheckbox } from '@ngx-formly/material/checkbox';

@Component({
Expand All @@ -13,6 +13,20 @@ import { FormlyFieldCheckbox } from '@ngx-formly/material/checkbox';
{{ props.label }}
</mat-checkbox>
`,
// the form field around the checkbox is outside this component
styles: `
/* beats the 16px of .mdc-text-field--no-label .mat-mdc-form-field-infix */
.mat-mdc-form-field .mat-mdc-text-field-wrapper .mat-mdc-form-field-infix:has(> boolean-type) {
padding-top: 8px;
padding-bottom: 8px;
}

/* aligns the box with the text of other fields, the box's touch target pads it */
boolean-type .mat-mdc-checkbox {
margin-left: calc((var(--mat-checkbox-state-layer-size, 40px) - 18px) / -2);
}
`,
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class BooleanTypeComponent extends FormlyFieldCheckbox implements OnInit {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<formly-form [fields]="formlyFields" [form]="form" [model]="model"></formly-form>
</div>
<div class="button-row">
<span matTooltip="Please complete all required fields." [matTooltipDisabled]="this.canSubmit()">
<span [matTooltip]="this.submitBlockedReason()" [matTooltipDisabled]="this.canSubmit()">
<button mat-flat-button type="submit" class="submit-button" color="primary" [disabled]="!this.canSubmit()">
<mat-icon>send</mat-icon>
Submit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {AppConfig} from 'src/app.config.service';
import {Store} from "@ngrx/store";
import {AppSettings, GeneralSettings} from "../../../settings/app-settings";
import {selectEffectiveGeneralSettings} from "../../../store/selectors";
import {createActionFormlyFields} from '../../formly-extensions';
import {actionParameterValue, createActionFormlyFields, describeInvalidControls} from '../../formly-extensions';

@Component({
selector: 'app-parameter-action',
Expand Down Expand Up @@ -75,6 +75,8 @@ export class ParameterActionComponent implements OnInit {

canSubmit = () => this.form.valid;

submitBlockedReason = () => `Please fix: ${describeInvalidControls(this.form).join(', ')}`;

public onActionSubmitted() {
if (!this.canSubmit()) {
console.log('not valid');
Expand All @@ -89,7 +91,7 @@ export class ParameterActionComponent implements OnInit {
}

private doActionSubmitted = () => {
this.action.parameters = this.form.value;
this.action.parameters = actionParameterValue(this.form, this.formlyFields);
this.actionResult = ActionResults.pending;
this.executed = true;

Expand Down
18 changes: 16 additions & 2 deletions src/app/hypermedia-view/formly-extensions.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {TestBed} from '@angular/core/testing';
import {FormGroup} from '@angular/forms';
import {FormArray, FormControl, FormGroup, Validators} from '@angular/forms';
import {FormlyFieldConfig, FormlyFormBuilder, FormlyModule} from '@ngx-formly/core';
import {FormlyJsonschema} from '@ngx-formly/core/json-schema';
import {FormlyMaterialModule} from '@ngx-formly/material';
import {JSONSchema7} from 'json-schema';
import {createActionFormlyFields} from './formly-extensions';
import {createActionFormlyFields, describeInvalidControls} from './formly-extensions';
import {SchemaSimplifier} from './siren-parser/schema-simplifier';

describe('createActionFormlyFields', () => {
Expand Down Expand Up @@ -83,3 +83,17 @@ describe('createActionFormlyFields', () => {
});
});
});

describe('describeInvalidControls', () => {
it('lists the path and failed validators of each invalid control', () => {
const form = new FormGroup({
name: new FormControl('set', Validators.required),
state: new FormControl(null, [Validators.required, Validators.minLength(2)]),
paging: new FormGroup({size: new FormControl(null, Validators.required)}),
tags: new FormArray([new FormControl('ok'), new FormControl(null, Validators.required)]),
ignored: new FormControl({value: null, disabled: true}, Validators.required),
});

expect(describeInvalidControls(form)).toEqual(['state (required)', 'paging.size (required)', 'tags.1 (required)']);
});
});
92 changes: 69 additions & 23 deletions src/app/hypermedia-view/formly-extensions.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,20 @@

import {AbstractControl} from '@angular/forms';
import {AbstractControl, FormArray, FormGroup} from '@angular/forms';
import {FormlyExtension, FormlyFieldConfig} from '@ngx-formly/core';
import {FormlyJsonschema} from '@ngx-formly/core/json-schema';
import {JSONSchema7} from 'json-schema';

// fix forms with arrays do not allow empty arrays to be submitted (which is not null)
export const allowEmptyArrayExtension: FormlyExtension = {
postPopulate(field: FormlyFieldConfig) {
const ctrl = field.formControl;

if (field.type === 'array' && ctrl) {
// Because the model is initially empty ([]) and the schema says it's required,
// the FormControl is born with an error.
// in that case remove to allow input
if (ctrl.hasError('required')) {
ctrl.clearValidators();

// Update UI so the label/asterisk updates
if (field.props) {
field.props.required = false;
}

// Refresh validity state
ctrl.updateValueAndValidity({emitEvent: false});

console.log(`Successfully bypassed 'required' for ${field.key} to allow empty arrays`);
}
// before formly evaluates expressions and adds validators, so the form is valid from the start
prePopulate(field: FormlyFieldConfig) {
if (field.type !== 'array') {
return;
}
// formly derives props.required from the schema's required list, and its required validator rejects []
delete field.expressions?.['props.required'];
if (field.props) {
field.props.required = false;
}
}
};
Expand All @@ -39,10 +28,22 @@ export function createActionFormlyFields(formlyJsonschema: FormlyJsonschema, jso
}
const types = schemaTypes(mapSource);
const allowsNull = isUntyped(mapSource) || isNullable(mapSource) || hasNullableBranch(mapSource);
const isArray = mappedField.type === 'array';
mappedField.validators ??= {};
mappedField.validators['required'] = (control: AbstractControl) => (allowsNull || (control.value !== null && control.value !== undefined));
// a FormArray's value is always an array, only the model tells an unset array from an empty one
mappedField.validators['required'] = (control: AbstractControl, field: FormlyFieldConfig) =>
allowsNull || isSet(isArray ? field.model : control.value);
if (isArray && mappedField.props) {
// the array type shows the asterisk and null state itself, see ArrayTypeComponent
mappedField.props['nullable'] = allowsNull;
}
if (isArray && mapSource.default === undefined) {
// formly defaults arrays in the required list to [], but without a default value from the server they are not set
delete mappedField.defaultValue;
}
// shows the asterisk, formly only sets it for properties in the schema's required list
if (!allowsNull && mappedField.key !== undefined && mappedField.props) {
// not for arrays: required would reject an empty array, which is a valid value
if (!allowsNull && !isArray && mappedField.key !== undefined && mappedField.props) {
mappedField.props.required = true;
}
if (types.includes('string') && mapSource.format === 'date') {
Expand All @@ -57,6 +58,51 @@ export function createActionFormlyFields(formlyJsonschema: FormlyJsonschema, jso
];
}

// the form value, except that arrays the user never set are null instead of the FormArray's empty array
export function actionParameterValue(form: FormGroup, fields: FormlyFieldConfig[]): Record<string, unknown> {
const value = structuredClone(form.value);
replaceUnsetArrays(fields, value);
return value;
}

function replaceUnsetArrays(fields: FormlyFieldConfig[] | undefined, value: unknown) {
if (value === null || typeof value !== 'object') {
return;
}
const values = value as Record<string, unknown>;
for (const field of fields ?? []) {
if (field.key === undefined || field.key === null) {
// e.g. the root object or a oneOf wrapper, which share the value of their parent
replaceUnsetArrays(field.fieldGroup, value);
continue;
}
const key = String(field.key);
if (!(key in values)) {
continue;
}
if (field.type === 'array' && !isSet(field.model)) {
values[key] = null;
} else {
replaceUnsetArrays(field.fieldGroup, values[key]);
}
}
}

function isSet(value: unknown): boolean {
return value !== null && value !== undefined;
}

// e.g. ["DeploymentState (enum)", "Paging.PageSize (required)"], to tell the user why a form cannot be submitted
export function describeInvalidControls(control: AbstractControl, path = ''): string[] {
const own = control.errors ? [`${path || 'form'} (${Object.keys(control.errors).join(', ')})`] : [];
const children = control instanceof FormGroup || control instanceof FormArray
? Object.entries<AbstractControl>(control.controls)
.filter(([, child]) => child.enabled)
.flatMap(([key, child]) => describeInvalidControls(child, path ? `${path}.${key}` : key))
: [];
return [...own, ...children];
}

function schemaTypes(schema: JSONSchema7): string[] {
return schema.type === undefined ? [] : schema.type instanceof Array ? schema.type : [schema.type];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ button[mat-icon-button] {
flex: 1;
}

::ng-deep .mat-mdc-checkbox {
:host ::ng-deep .mat-mdc-checkbox {
transform: scale(0.8);
}

Expand Down
Loading