AAE-46057 Stabilize form field status layout (#12165)

This commit is contained in:
Alex Molodyh
2026-08-20 09:55:29 -07:00
committed by GitHub
parent d0368ba9c8
commit 83b5769fbc
29 changed files with 348 additions and 252 deletions
@@ -20,6 +20,12 @@
.mat-mdc-form-field-infix { .mat-mdc-form-field-infix {
width: auto; width: auto;
} }
.adf-form-field-input:not(.adf-inplace-input-mat-form-field, .adf-people-cloud, .adf-cloud-group) {
#{ms.$mat-form-field-subscript-wrapper} {
height: 40px;
}
}
} }
.alfresco-tabs-widget { .alfresco-tabs-widget {
@@ -40,7 +46,7 @@
.adf-container-widget { .adf-container-widget {
.adf-form-field-input:not(.adf-inplace-input-mat-form-field, .adf-people-cloud, .adf-cloud-group) { .adf-form-field-input:not(.adf-inplace-input-mat-form-field, .adf-people-cloud, .adf-cloud-group) {
margin-bottom: 35px; margin-bottom: 0;
} }
.adf-grid-list { .adf-grid-list {
@@ -265,7 +271,7 @@
} }
&-error-messages-container { &-error-messages-container {
min-height: 35px; height: 40px;
} }
&-error-messages-container-visible { &-error-messages-container-visible {
@@ -5,7 +5,7 @@
&-single-column { &-single-column {
display: flex; display: flex;
flex-wrap: inherit; flex-wrap: inherit;
align-items: center; align-items: flex-start;
gap: 1%; gap: 1%;
@include flex.layout-bp(lt-md) { @include flex.layout-bp(lt-md) {
@@ -16,10 +16,9 @@
<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span> <span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span>
</mat-checkbox> </mat-checkbox>
<div class="adf-error-messages-container"> <div class="adf-error-messages-container">
<error-widget [error]="field.validationSummary" />
<error-widget <error-widget
*ngIf="isInvalidFieldRequired() && isTouched()" [error]="field.validationSummary"
required="{{ 'FORM.FIELD.REQUIRED' | translate }}" [required]="isInvalidFieldRequired() && isTouched() ? ('FORM.FIELD.REQUIRED' | translate) : ''"
/> />
</div> </div>
</div> </div>
@@ -17,7 +17,7 @@
/* eslint-disable @angular-eslint/component-selector */ /* eslint-disable @angular-eslint/component-selector */
import { NgClass, NgIf } from '@angular/common'; import { NgClass } from '@angular/common';
import { Component, ViewEncapsulation } from '@angular/core'; import { Component, ViewEncapsulation } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -47,7 +47,7 @@ import { WidgetComponent } from '../widget.component';
'(invalid)': 'event($event)', '(invalid)': 'event($event)',
'(select)': 'event($event)' '(select)': 'event($event)'
}, },
imports: [NgClass, MatCheckboxModule, FormsModule, TranslatePipe, ErrorWidgetComponent, NgIf], imports: [NgClass, MatCheckboxModule, FormsModule, TranslatePipe, ErrorWidgetComponent],
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None
}) })
export class CheckboxWidgetComponent extends WidgetComponent {} export class CheckboxWidgetComponent extends WidgetComponent {}
@@ -10,12 +10,16 @@
} }
} }
error-widget {
display: block;
}
.adf-error { .adf-error {
display: flex; display: flex;
align-items: center; align-items: center;
&-widget-container { &-widget-container {
height: auto; height: 40px;
} }
&-animate { &-animate {
@@ -1,6 +1,7 @@
.adf-hyperlink-widget { .adf-hyperlink-widget {
padding: 0.4375em 0; padding: 0.4375em 0;
border-top: 0.8438em solid transparent; border-top: 0.8438em solid transparent;
margin-bottom: 20px;
a { a {
color: var(--mat-sys-primary); color: var(--mat-sys-primary);
@@ -43,6 +43,6 @@
@include mixins.adf-error-icon; @include mixins.adf-error-icon;
} }
.adf-container-widget .adf-multiline-text-widget .adf-form-field-input.adf-has-counter { .adf-container-widget .adf-multiline-text-widget mat-form-field.adf-form-field-input.adf-has-counter {
margin-bottom: 44px; margin-bottom: 20px;
} }
@@ -9,7 +9,8 @@
} }
&-row-action { &-row-action {
margin-left: 10px; margin-inline-start: 10px;
margin-block-end: 35px;
} }
&-row-limit { &-row-limit {
@@ -17,6 +17,7 @@ $mat-button: '.mat-mdc-button';
$mat-button-label: '.mdc-button__label'; $mat-button-label: '.mdc-button__label';
$mat-form-field: '.mat-mdc-form-field'; $mat-form-field: '.mat-mdc-form-field';
$mat-form-field-wrapper: '.mat-mdc-text-field-wrapper'; $mat-form-field-wrapper: '.mat-mdc-text-field-wrapper';
$mat-form-field-subscript-wrapper: '.mat-mdc-form-field-subscript-wrapper';
$mat-line-ripple: '.mdc-line-ripple'; $mat-line-ripple: '.mdc-line-ripple';
$mat-form-field-prefix: '.mat-mdc-form-field-text-prefix'; $mat-form-field-prefix: '.mat-mdc-form-field-text-prefix';
$mat-form-field-suffix: '.mat-mdc-form-field-text-suffix'; $mat-form-field-suffix: '.mat-mdc-form-field-text-suffix';
@@ -1,21 +1,26 @@
<div class="adf-attach-file-widget-container"> <div class="adf-attach-file-widget-container">
<div class="adf-attach-widget {{field.className}}" <div class="adf-attach-widget {{ field.className }}" [class.adf-readonly]="field.readOnly">
[class.adf-readonly]="field.readOnly"> <label class="adf-label" [attr.for]="field.id + '-label'"
<label class="adf-label" [attr.for]="field.id + '-label'">{{field.name}} >{{ field.name }}
<span class="adf-asterisk" *ngIf="isRequired()">*</span> @if (isRequired()) {
<span class="adf-asterisk">*</span>
}
</label> </label>
<div class="adf-attach-widget-container" (focusout)="markAsTouched()"> <div class="adf-attach-widget-container" (focusout)="markAsTouched()">
<div class="adf-attach-widget__menu-upload" *ngIf="isUploadButtonVisible()"> @if (isUploadButtonVisible()) {
<div class="adf-attach-widget__menu-upload">
<button <button
(click)="openSelectDialog()" (click)="openSelectDialog()"
mat-raised-button mat-raised-button
class="adf-attach-widget__menu-upload__button" class="adf-attach-widget__menu-upload__button"
[id]="field.id" [id]="field.id"
[title]="field.tooltip"> [title]="field.tooltip"
>
{{ 'FORM.FIELD.ATTACH' | translate }} {{ 'FORM.FIELD.ATTACH' | translate }}
<mat-icon class="adf-attach-widget__menu-upload__button__icon" [adf-icon]="getWidgetIcon()" /> <mat-icon class="adf-attach-widget__menu-upload__button__icon" [adf-icon]="getWidgetIcon()" />
</button> </button>
</div> </div>
}
</div> </div>
</div> </div>
@@ -34,12 +39,15 @@
(contentModelFileHandler)="contentModelFormFileHandler($event)" (contentModelFileHandler)="contentModelFormFileHandler($event)"
(removeAttachFile)="onRemoveAttachFile($event)" (removeAttachFile)="onRemoveAttachFile($event)"
/> />
<div *ngIf="!hasFile && field.readOnly" id="{{'adf-attach-empty-list-'+field.id}}"> @if (!hasFile && field.readOnly) {
<div id="{{ 'adf-attach-empty-list-' + field.id }}">
{{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }} {{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }}
</div> </div>
}
</div> </div>
<error-widget [error]="field.validationSummary" /> <error-widget
<error-widget *ngIf="!field.isValid && isTouched() && !isSelected()" required="{{ 'FORM.FIELD.REQUIRED' | translate }}" /> [error]="field.validationSummary"
[required]="!field.isValid && isTouched() && !isSelected() ? ('FORM.FIELD.REQUIRED' | translate) : ''"
/>
</div> </div>
@@ -1,31 +1,23 @@
<div class="adf-data-table-widget-container"> <div class="adf-data-table-widget-container">
<div class="adf-data-table-widget-label"> <div class="adf-data-table-widget-label">
<label <label class="adf-label" [class.adf-left-label]="field.leftLabels" [attr.for]="field.id"> {{field.name | translate }} </label>
class="adf-label"
[class.adf-left-label]="field.leftLabels"
[attr.for]="field.id">
{{field.name | translate }}
</label>
</div> </div>
<ng-container *ngIf="!previewState; else previewTemplate"> @if (!previewState) {
<adf-datatable data-automation-id="adf-data-table-widget" [data]="dataSource"> <adf-datatable data-automation-id="adf-data-table-widget" [data]="dataSource">
<adf-no-content-template> <adf-no-content-template>
<ng-template> <ng-template>
<adf-empty-content <adf-empty-content icon="border_all" [title]="'FORM.FIELD.DATA_TABLE_EMPTY_CONTENT' | translate" />
icon="border_all"
[title]="'FORM.FIELD.DATA_TABLE_EMPTY_CONTENT' | translate" />
</ng-template> </ng-template>
</adf-no-content-template> </adf-no-content-template>
</adf-datatable> </adf-datatable>
<error-widget *ngIf="dataTableLoadFailed" <error-widget
class="adf-data-table-widget-failed-message" class="adf-data-table-widget-failed-message"
[required]="'FORM.FIELD.DATA_TABLE_LOAD_FAILED' | translate" /> [required]="dataTableLoadFailed ? ('FORM.FIELD.DATA_TABLE_LOAD_FAILED' | translate) : ''"
</ng-container> />
} @else {
<ng-template #previewTemplate>
<adf-datatable data-automation-id="adf-data-table-widget-preview" /> <adf-datatable data-automation-id="adf-data-table-widget-preview" />
<div class="adf-preview-placeholder"></div> <div class="adf-preview-placeholder"></div>
</ng-template> }
</div> </div>
@@ -1,4 +1,5 @@
.adf-data-table-widget-failed-message { .adf-data-table-widget-failed-message {
display: block;
margin: 10px; margin: 10px;
} }
@@ -283,7 +283,8 @@ describe('DataTableWidgetComponent', () => {
const failedErrorMsgElement = fixture.debugElement.query(By.css('.adf-data-table-widget-failed-message')); const failedErrorMsgElement = fixture.debugElement.query(By.css('.adf-data-table-widget-failed-message'));
assertData(mockCountryColumns, []); assertData(mockCountryColumns, []);
expect(failedErrorMsgElement).toBeNull(); expect(failedErrorMsgElement).toBeTruthy();
expect(failedErrorMsgElement.nativeElement.textContent.trim()).toBe('');
}); });
it('path points to single object with appropriate schema definition', () => { it('path points to single object with appropriate schema definition', () => {
@@ -294,7 +295,8 @@ describe('DataTableWidgetComponent', () => {
const failedErrorMsgElement = fixture.debugElement.query(By.css('.adf-data-table-widget-failed-message')); const failedErrorMsgElement = fixture.debugElement.query(By.css('.adf-data-table-widget-failed-message'));
assertData(mockCountryColumns, [mockEuropeCountriesRows[1]]); assertData(mockCountryColumns, [mockEuropeCountriesRows[1]]);
expect(failedErrorMsgElement).toBeNull(); expect(failedErrorMsgElement).toBeTruthy();
expect(failedErrorMsgElement.nativeElement.textContent.trim()).toBe('');
}); });
}); });
@@ -27,7 +27,6 @@ import {
NoContentTemplateDirective, NoContentTemplateDirective,
EmptyContentComponent EmptyContentComponent
} from '@alfresco/adf-core'; } from '@alfresco/adf-core';
import { NgIf } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { FormCloudService } from '../../../services/form-cloud.service'; import { FormCloudService } from '../../../services/form-cloud.service';
import { TaskVariableCloud } from '../../../models/task-variable-cloud.model'; import { TaskVariableCloud } from '../../../models/task-variable-cloud.model';
@@ -36,7 +35,7 @@ import { DataTablePathParserHelper } from './helpers/data-table-path-parser.help
@Component({ @Component({
standalone: true, standalone: true,
imports: [NgIf, TranslatePipe, FormBaseModule, DataTableComponent, NoContentTemplateDirective, EmptyContentComponent], imports: [TranslatePipe, FormBaseModule, DataTableComponent, NoContentTemplateDirective, EmptyContentComponent],
selector: 'data-table', selector: 'data-table',
templateUrl: './data-table.widget.html', templateUrl: './data-table.widget.html',
styleUrls: ['./data-table.widget.scss'], styleUrls: ['./data-table.widget.scss'],
@@ -4,11 +4,13 @@
[class.adf-readonly]="field.readOnly" [class.adf-readonly]="field.readOnly"
[class.adf-left-label-input-container]="field.leftLabels" [class.adf-left-label-input-container]="field.leftLabels"
> >
<div *ngIf="field.leftLabels"> @if (field.leftLabels) {
<div>
<label class="adf-label adf-left-label" [attr.for]="field.id" <label class="adf-label adf-left-label" [attr.for]="field.id"
>{{field.name | translate }}<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span></label >{{field.name | translate }}<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span></label
> >
</div> </div>
}
<div> <div>
<adf-cloud-group <adf-cloud-group
[mode]="mode" [mode]="mode"
@@ -22,13 +24,7 @@
[preSelectGroups]="preSelectGroup" [preSelectGroups]="preSelectGroup"
(blur)="markAsTouched()" (blur)="markAsTouched()"
[attr.title]="field.tooltip" [attr.title]="field.tooltip"
[label] = "field.name | translate" [label]="field.name | translate"
/>
<error-widget [error]="field.validationSummary" />
<error-widget
class="adf-dropdown-required-message"
*ngIf="isInvalidFieldRequired() && isTouched()"
required="{{ 'FORM.FIELD.REQUIRED' | translate }}"
/> />
</div> </div>
</div> </div>
@@ -141,8 +141,9 @@ describe('GroupCloudWidgetComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
expect(element.querySelector('.adf-error-text')).toBeTruthy(); const errorMessages = element.querySelectorAll('.adf-error-text');
expect(element.querySelector('.adf-error-text').textContent).toContain('ADF_CLOUD_GROUPS.ERROR.NOT_FOUND'); expect(errorMessages.length).toBe(1);
expect(errorMessages[0].textContent).toContain('ADF_CLOUD_GROUPS.ERROR.NOT_FOUND');
}); });
}); });
@@ -16,7 +16,7 @@
*/ */
import { Component, DestroyRef, inject, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { Component, DestroyRef, inject, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { ErrorWidgetComponent, WidgetComponent } from '@alfresco/adf-core'; import { WidgetComponent } from '@alfresco/adf-core';
import { UntypedFormControl } from '@angular/forms'; import { UntypedFormControl } from '@angular/forms';
import { filter } from 'rxjs/operators'; import { filter } from 'rxjs/operators';
import { ComponentSelectionMode } from '../../../../types'; import { ComponentSelectionMode } from '../../../../types';
@@ -31,7 +31,7 @@ import { GroupCloudComponent } from '../../../../group/components/group-cloud.co
@Component({ @Component({
selector: 'group-cloud-widget', selector: 'group-cloud-widget',
imports: [CommonModule, TranslatePipe, ErrorWidgetComponent, GroupCloudComponent], imports: [CommonModule, TranslatePipe, GroupCloudComponent],
templateUrl: './group-cloud.widget.html', templateUrl: './group-cloud.widget.html',
host: { host: {
'(click)': 'event($event)', '(click)': 'event($event)',
@@ -1,10 +1,16 @@
<div class="adf-dropdown-widget {{field.className}}" <div
class="adf-dropdown-widget {{field.className}}"
[class.adf-invalid]="!field.isValid && isTouched()" [class.adf-invalid]="!field.isValid && isTouched()"
[class.adf-readonly]="field.readOnly" [class.adf-readonly]="field.readOnly"
[class.adf-left-label-input-container]="field.leftLabels"> [class.adf-left-label-input-container]="field.leftLabels"
<div *ngIf="field.leftLabels"> >
<label class="adf-label adf-left-label" [attr.for]="field.id">{{field.name | translate }}<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span></label> @if (field.leftLabels) {
<div>
<label class="adf-label adf-left-label" [attr.for]="field.id"
>{{field.name | translate }}<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span></label
>
</div> </div>
}
<div> <div>
<adf-cloud-people <adf-cloud-people
[preSelectUsers]="preSelectUsers" [preSelectUsers]="preSelectUsers"
@@ -21,11 +27,5 @@
[attr.title]="field.tooltip" [attr.title]="field.tooltip"
[label]="field.name | translate" [label]="field.name | translate"
/> />
<error-widget [error]="field.validationSummary" />
<error-widget
class="adf-dropdown-required-message"
*ngIf="isInvalidFieldRequired() && isTouched()"
required="{{ 'FORM.FIELD.REQUIRED' | translate }}" />
</div> </div>
</div> </div>
@@ -171,8 +171,9 @@ describe('PeopleCloudWidgetComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
expect(element.querySelector('.adf-error-text')).toBeTruthy(); const errorMessages = element.querySelectorAll('.adf-error-text');
expect(element.querySelector('.adf-error-text').textContent).toContain('ADF_CLOUD_USERS.ERROR.NOT_FOUND'); expect(errorMessages.length).toBe(1);
expect(errorMessages[0].textContent).toContain('ADF_CLOUD_USERS.ERROR.NOT_FOUND');
}); });
}); });
@@ -16,7 +16,7 @@
*/ */
import { Component, DestroyRef, inject, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { Component, DestroyRef, inject, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { ErrorWidgetComponent, WidgetComponent } from '@alfresco/adf-core'; import { WidgetComponent } from '@alfresco/adf-core';
import { UntypedFormControl } from '@angular/forms'; import { UntypedFormControl } from '@angular/forms';
import { filter } from 'rxjs/operators'; import { filter } from 'rxjs/operators';
import { ComponentSelectionMode } from '../../../../types'; import { ComponentSelectionMode } from '../../../../types';
@@ -27,13 +27,12 @@ import { ReactivePreselectionService } from '../reactive-preselection.service';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { PeopleCloudComponent } from '../../../../people/components/people-cloud.component'; import { PeopleCloudComponent } from '../../../../people/components/people-cloud.component';
import { MatFormFieldModule } from '@angular/material/form-field';
/* eslint-disable @angular-eslint/component-selector */ /* eslint-disable @angular-eslint/component-selector */
@Component({ @Component({
selector: 'people-cloud-widget', selector: 'people-cloud-widget',
imports: [CommonModule, TranslatePipe, ErrorWidgetComponent, PeopleCloudComponent, MatFormFieldModule], imports: [CommonModule, TranslatePipe, PeopleCloudComponent],
templateUrl: './people-cloud.widget.html', templateUrl: './people-cloud.widget.html',
host: { host: {
'(click)': 'event($event)', '(click)': 'event($event)',
@@ -11,7 +11,6 @@
} }
&-radio-button-container-horizontal { &-radio-button-container-horizontal {
margin-bottom: 15px;
display: flex; display: flex;
flex-flow: column wrap; flex-flow: column wrap;
align-items: flex-start; align-items: flex-start;
@@ -4,7 +4,8 @@
> >
<div class="adf-cloud-upload-widget-container"> <div class="adf-cloud-upload-widget-container">
<div> <div>
<mat-list *ngIf="hasFile"> @if (hasFile) {
<mat-list>
<mat-list-item class="adf-upload-files-row" *ngFor="let file of uploadedFiles"> <mat-list-item class="adf-upload-files-row" *ngFor="let file of uploadedFiles">
<img <img
matListItemLine matListItemLine
@@ -27,28 +28,26 @@
tabindex="0" tabindex="0"
>{{file.name}}</span >{{file.name}}</span
> >
<button @if (!field.readOnly) {
*ngIf="!field.readOnly" <button mat-icon-button [id]="'file-'+file.id+'-remove'" (click)="removeFile(file);" (keyup.enter)="removeFile(file);">
mat-icon-button
[id]="'file-'+file.id+'-remove'"
(click)="removeFile(file);"
(keyup.enter)="removeFile(file);"
>
<mat-icon class="mat-24" adf-icon="highlight_off" /> <mat-icon class="mat-24" adf-icon="highlight_off" />
</button> </button>
}
</mat-list-item> </mat-list-item>
</mat-list> </mat-list>
}
</div> </div>
<div *ngIf="(!hasFile || multipleOption) && !field.readOnly"> @if ((!hasFile || multipleOption) && !field.readOnly) {
<div>
<button mat-raised-button (click)="uploadFiles.click()" [title]="field.tooltip"> <button mat-raised-button (click)="uploadFiles.click()" [title]="field.tooltip">
{{ 'FORM.FIELD.UPLOAD' | translate }}<mat-icon adf-icon="file_upload" /> {{ 'FORM.FIELD.UPLOAD' | translate }}<mat-icon adf-icon="file_upload" />
<input #uploadFiles [multiple]="multipleOption" type="file" [id]="field.form.nodeId" (change)="onFileChanged($event)" /> <input #uploadFiles [multiple]="multipleOption" type="file" [id]="field.form.nodeId" (change)="onFileChanged($event)" />
</button> </button>
</div> </div>
} @if (!hasFile && field.readOnly) {
<div *ngIf="!hasFile && field.readOnly">{{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }}</div> <div>{{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }}</div>
}
</div> </div>
<error-widget [error]="field.validationSummary" /> <error-widget [error]="field.validationSummary" [required]="isInvalidFieldRequired() ? ('FORM.FIELD.REQUIRED' | translate) : ''" />
<error-widget *ngIf="isInvalidFieldRequired()" required="{{ 'FORM.FIELD.REQUIRED' | translate }}" />
</div> </div>
@@ -55,4 +55,12 @@ describe('UploadCloudWidgetComponent', () => {
expect(eventSpy).toHaveBeenCalledWith(clickEvent); expect(eventSpy).toHaveBeenCalledWith(clickEvent);
}); });
}); });
it('should render one reserved form field status area', () => {
widget.field = new FormFieldModel(new FormModel(), {});
fixture.detectChanges();
const statusAreas = fixture.nativeElement.querySelectorAll('error-widget');
expect(statusAreas.length).toBe(1);
});
}); });
@@ -1,18 +1,26 @@
<form> <form>
<mat-form-field class="adf-cloud-group adf-form-field-input" [class.adf-invalid]="hasError() && isDirty()"> <mat-form-field subscriptSizing="dynamic" class="adf-cloud-group adf-form-field-input" [class.adf-invalid]="hasError() && isDirty()">
@if (label || required) { <mat-label><span>{{label}}</span></mat-label> } @if (label || required) {
<mat-label
><span>{{ label }}</span></mat-label
>
}
<mat-chip-grid [required]="required" [disabled]="isReadonly()" #groupChipList data-automation-id="adf-cloud-group-chip-list"> <mat-chip-grid [required]="required" [disabled]="isReadonly()" #groupChipList data-automation-id="adf-cloud-group-chip-list">
<mat-chip-row <mat-chip-row
*ngFor="let group of selectedGroups" *ngFor="let group of selectedGroups"
[removable]="!(group.readonly)" [removable]="!group.readonly"
[attr.data-automation-id]="'adf-cloud-group-chip-' + group.name" [attr.data-automation-id]="'adf-cloud-group-chip-' + group.name"
(removed)="onRemove(group)" (removed)="onRemove(group)"
[disabled]="readOnly || isValidationLoading()" [disabled]="readOnly || isValidationLoading()"
title="{{ (group.readonly ? 'ADF_CLOUD_GROUPS.MANDATORY' : '') | translate }}"> title="{{ (group.readonly ? 'ADF_CLOUD_GROUPS.MANDATORY' : '') | translate }}"
{{group.name}} >
<mat-icon *ngIf="!(group.readonly || readOnly)" matChipRemove [attr.data-automation-id]="'adf-cloud-group-chip-remove-icon-' + group.name" adf-icon="cancel" /> {{ group.name }}
@if (!(group.readonly || readOnly)) {
<mat-icon matChipRemove [attr.data-automation-id]="'adf-cloud-group-chip-remove-icon-' + group.name" adf-icon="cancel" />
}
</mat-chip-row> </mat-chip-row>
<input matInput <input
matInput
[formControl]="searchGroupsControl" [formControl]="searchGroupsControl"
[matAutocomplete]="auto" [matAutocomplete]="auto"
[matChipInputFor]="groupChipList" [matChipInputFor]="groupChipList"
@@ -21,7 +29,9 @@
(focus)="setFocus(true)" (focus)="setFocus(true)"
(blur)="setFocus(false); markAsTouched()" (blur)="setFocus(false); markAsTouched()"
class="adf-group-input" class="adf-group-input"
data-automation-id="adf-cloud-group-search-input" #groupInput> data-automation-id="adf-cloud-group-search-input"
#groupInput
/>
</mat-chip-grid> </mat-chip-grid>
<mat-autocomplete <mat-autocomplete
@@ -30,57 +40,77 @@
class="adf-cloud-group-list" class="adf-cloud-group-list"
(optionSelected)="onSelect($event.option.value)" (optionSelected)="onSelect($event.option.value)"
[displayWith]="getDisplayName" [displayWith]="getDisplayName"
data-automation-id="adf-cloud-group-autocomplete"> data-automation-id="adf-cloud-group-autocomplete"
<ng-container *ngIf="(searchGroups$ | async)?.length else noResults"> >
<mat-option *ngFor="let group of searchGroups$ | async; let i = index" [value]="group" @if ((searchGroups$ | async)?.length) {
<mat-option
*ngFor="let group of searchGroups$ | async; let i = index"
[value]="group"
[attr.data-automation-id]="'adf-cloud-group-chip-' + group.name" [attr.data-automation-id]="'adf-cloud-group-chip-' + group.name"
class="adf-cloud-group-option-active"> class="adf-cloud-group-option-active"
<div >
class="adf-cloud-group-row" <div class="adf-cloud-group-row" id="adf-group-{{ i }}" data-automation-id="adf-cloud-group-row">
id="adf-group-{{i}}" <button class="adf-group-short-name" mat-fab>{{ getGroupNameInitials(group) }}</button>
data-automation-id="adf-cloud-group-row"> <span>{{ group.name }}</span>
<button class="adf-group-short-name" mat-fab>{{getGroupNameInitials(group)}}</button>
<span>{{group.name}}</span>
</div> </div>
</mat-option> </mat-option>
</ng-container> } @else {
<ng-container [ngTemplateOutlet]="noResults" />
}
<ng-template #noResults> <ng-template #noResults>
<mat-option *ngIf="searchGroupsControl.hasError('searchTypingError') && !searchLoading" disabled @if (searchGroupsControl.hasError('searchTypingError') && !searchLoading) {
class="adf-cloud-group-option-not-active" <mat-option disabled class="adf-cloud-group-option-not-active" data-automation-id="adf-cloud-group-no-results">
data-automation-id="adf-cloud-group-no-results">
<span> {{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</span> <span> {{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</span>
</mat-option> </mat-option>
}
</ng-template> </ng-template>
</mat-autocomplete> </mat-autocomplete>
</mat-form-field> </mat-form-field>
<mat-progress-bar *ngIf="validationLoading" mode="indeterminate" />
<div class="adf-error-container adf-error-messages-container"> <div class="adf-error-container adf-error-messages-container">
<mat-error *ngIf="hasPreselectError() && !isValidationLoading()" class="adf-error"> @if (validationLoading) {
<mat-progress-bar mode="indeterminate" />
}
@if (hasPreselectError() && !isValidationLoading()) {
<mat-error class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> <mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</div> <div class="adf-error-text">{{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</div>
</mat-error> </mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('pattern')" class="adf-error"> }
@if (searchGroupsControl.hasError('pattern')) {
<mat-error class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> <mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_PATTERN' | translate: { pattern: getValidationPattern() } }}</div> <div class="adf-error-text">
{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_PATTERN' | translate: { pattern: getValidationPattern() } }}
</div>
</mat-error> </mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('maxlength')" class="adf-error"> }
@if (searchGroupsControl.hasError('maxlength')) {
<mat-error class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> <mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MAX_LENGTH' | translate: { requiredLength: getValidationMaxLength() } }}</div> <div class="adf-error-text">
{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MAX_LENGTH' | translate: { requiredLength: getValidationMaxLength() } }}
</div>
</mat-error> </mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('minlength')" class="adf-error"> }
@if (searchGroupsControl.hasError('minlength')) {
<mat-error class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> <mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MIN_LENGTH' | translate: { requiredLength: getValidationMinLength() } }}</div> <div class="adf-error-text">
{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MIN_LENGTH' | translate: { requiredLength: getValidationMinLength() } }}
</div>
</mat-error> </mat-error>
<mat-error *ngIf="(searchGroupsControl.hasError('required') || groupChipsCtrl.hasError('required')) && isDirty()" }
class="adf-error"> @if ((searchGroupsControl.hasError('required') || groupChipsCtrl.hasError('required')) && isDirty()) {
<mat-error class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> <mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.REQUIRED' | translate }} </div> <div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.REQUIRED' | translate }}</div>
</mat-error> </mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('searchTypingError') && !this.isFocused" }
data-automation-id="invalid-groups-typing-error" class="adf-error"> @if (searchGroupsControl.hasError('searchTypingError') && !this.isFocused) {
<mat-error data-automation-id="invalid-groups-typing-error" class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> <mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</div> <div class="adf-error-text">{{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</div>
</mat-error> </mat-error>
}
</div> </div>
</form> </form>
@@ -57,10 +57,12 @@
} }
} }
.adf-error-messages-container .adf-error-icon { .adf-error-messages-container {
.adf-error-icon {
@include mixins.adf-error-icon; @include mixins.adf-error-icon;
} }
.adf-error-messages-container .adf-error { .adf-error {
animation: slide-down-fade-in 300ms cubic-bezier(0.55, 0, 0.55, 0.2); animation: slide-down-fade-in 300ms cubic-bezier(0.55, 0, 0.55, 0.2);
}
} }
@@ -27,6 +27,8 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatChipHarness } from '@angular/material/chips/testing'; import { MatChipHarness } from '@angular/material/chips/testing';
import { MatIconHarness } from '@angular/material/icon/testing'; import { MatIconHarness } from '@angular/material/icon/testing';
import { MatInputHarness } from '@angular/material/input/testing'; import { MatInputHarness } from '@angular/material/input/testing';
import { MatFormField } from '@angular/material/form-field';
import { MatProgressBar } from '@angular/material/progress-bar';
describe('GroupCloudComponent', () => { describe('GroupCloudComponent', () => {
let loader: HarnessLoader; let loader: HarnessLoader;
@@ -98,6 +100,22 @@ describe('GroupCloudComponent', () => {
expect(await inputElement.getPlaceholder()).toEqual(''); expect(await inputElement.getPlaceholder()).toEqual('');
}); });
it('should use dynamic form field subscript sizing', () => {
fixture.detectChanges();
const formField = fixture.debugElement.query(By.directive(MatFormField)).componentInstance as MatFormField;
expect(formField.subscriptSizing).toBe('dynamic');
});
it('should render validation progress inside the reserved status area', () => {
component.validationLoading = true;
fixture.detectChanges();
const progressBar = fixture.debugElement.query(By.directive(MatProgressBar));
expect(progressBar.parent.classes['adf-error-messages-container']).toBeTrue();
});
describe('Search group', () => { describe('Search group', () => {
beforeEach(() => { beforeEach(() => {
fixture.detectChanges(); fixture.detectChanges();
@@ -4,10 +4,14 @@
class="adf-people-cloud adf-form-field-input" class="adf-people-cloud adf-form-field-input"
[class.adf-invalid]="hasError() && isDirty()" [class.adf-invalid]="hasError() && isDirty()"
> >
<mat-label *ngIf="!title"> @if (!title) {
<span>{{label}}</span> <mat-label>
<span>{{ label }}</span>
</mat-label> </mat-label>
<mat-label *ngIf="title">{{ title | translate }}</mat-label> }
@if (title) {
<mat-label>{{ title | translate }}</mat-label>
}
<mat-chip-grid [required]="required" [disabled]="isReadonly()" #userMultipleChipList data-automation-id="adf-cloud-people-chip-list"> <mat-chip-grid [required]="required" [disabled]="isReadonly()" #userMultipleChipList data-automation-id="adf-cloud-people-chip-list">
<mat-chip-row <mat-chip-row
@@ -17,10 +21,12 @@
(removed)="onRemove(user)" (removed)="onRemove(user)"
[disabled]="isReadonly() || isValidationLoading()" [disabled]="isReadonly() || isValidationLoading()"
title="{{ (user.readonly ? 'ADF_CLOUD_GROUPS.MANDATORY' : '') | translate }}" title="{{ (user.readonly ? 'ADF_CLOUD_GROUPS.MANDATORY' : '') | translate }}"
[matTooltip]="showFullNameOnHover ? (user | fullName : true) : user.email" [matTooltip]="showFullNameOnHover ? (user | fullName: true) : user.email"
> >
{{ user | fullName }} {{ user | fullName }}
<mat-icon matChipRemove *ngIf="!(user.readonly || readOnly)" [attr.data-automation-id]="'adf-people-cloud-chip-remove-icon-' + user.username" adf-icon="cancel" /> @if (!(user.readonly || readOnly)) {
<mat-icon matChipRemove [attr.data-automation-id]="'adf-people-cloud-chip-remove-icon-' + user.username" adf-icon="cancel" />
}
</mat-chip-row> </mat-chip-row>
<input <input
matInput matInput
@@ -44,64 +50,73 @@
(optionSelected)="onSelect($event.option.value)" (optionSelected)="onSelect($event.option.value)"
[displayWith]="getDisplayName" [displayWith]="getDisplayName"
> >
<ng-container *ngIf="(searchUsers$ | async)?.length; else noResults"> @if ((searchUsers$ | async)?.length) {
<mat-option *ngFor="let user of searchUsers$ | async; let i = index" [value]="user" class="adf-people-cloud-option-active"> <mat-option *ngFor="let user of searchUsers$ | async; let i = index" [value]="user" class="adf-people-cloud-option-active">
<div class="adf-people-cloud-row" id="adf-people-cloud-user-{{ user.username }}" data-automation-id="adf-people-cloud-row"> <div class="adf-people-cloud-row" id="adf-people-cloud-user-{{ user.username }}" data-automation-id="adf-people-cloud-row">
<div [outerHTML]="user | usernameInitials : 'adf-people-cloud-pic'"></div> <div [outerHTML]="user | usernameInitials: 'adf-people-cloud-pic'"></div>
<span class="adf-people-label-name"> {{ user | fullName : true }}</span> <span class="adf-people-label-name"> {{ user | fullName: true }}</span>
</div> </div>
</mat-option> </mat-option>
</ng-container> } @else {
<ng-container [ngTemplateOutlet]="noResults" />
}
<ng-template #noResults> <ng-template #noResults>
<mat-option @if (searchUserCtrl.hasError('searchTypingError') && !searchLoading) {
*ngIf="searchUserCtrl.hasError('searchTypingError') && !searchLoading" <mat-option disabled class="adf-people-cloud-option-not-active" data-automation-id="adf-people-cloud-no-results">
disabled <span> {{ 'ADF_CLOUD_USERS.ERROR.NOT_FOUND' | translate: { userName: searchedValue } }}</span>
class="adf-people-cloud-option-not-active"
data-automation-id="adf-people-cloud-no-results"
>
<span> {{ 'ADF_CLOUD_USERS.ERROR.NOT_FOUND' | translate : { userName: searchedValue } }}</span>
</mat-option> </mat-option>
}
</ng-template> </ng-template>
</mat-autocomplete> </mat-autocomplete>
</mat-form-field> </mat-form-field>
<mat-progress-bar *ngIf="validationLoading" mode="indeterminate" /> <div class="adf-error-container adf-error-messages-container">
@if (validationLoading) {
<div class="adf-error-container adf-error-messages-container" *ngIf="showErrors"> <mat-progress-bar mode="indeterminate" />
<mat-error *ngIf="hasPreselectError() && !isValidationLoading()" class="adf-error adf-error-animate"> }
@if (showErrors) {
@if (hasPreselectError() && !isValidationLoading()) {
<mat-error class="adf-error adf-error-animate">
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> <mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_USERS.ERROR.NOT_FOUND' | translate : { userName: validateUsersMessage } }}</div> <div class="adf-error-text">{{ 'ADF_CLOUD_USERS.ERROR.NOT_FOUND' | translate: { userName: validateUsersMessage } }}</div>
</mat-error> </mat-error>
<mat-error *ngIf="searchUserCtrl.hasError('pattern')" class="adf-error adf-error-animate"> }
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> @if (searchUserCtrl.hasError('pattern')) {
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_PATTERN' | translate : { pattern: getValidationPattern() } }}</div> <mat-error class="adf-error adf-error-animate">
</mat-error>
<mat-error *ngIf="searchUserCtrl.hasError('maxlength')" class="adf-error adf-error-animate">
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> <mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text"> <div class="adf-error-text">
{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MAX_LENGTH' | translate : { requiredLength: getValidationMaxLength() } }} {{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_PATTERN' | translate: { pattern: getValidationPattern() } }}
</div> </div>
</mat-error> </mat-error>
<mat-error *ngIf="searchUserCtrl.hasError('minlength')" class="adf-error adf-error-animate"> }
@if (searchUserCtrl.hasError('maxlength')) {
<mat-error class="adf-error adf-error-animate">
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> <mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text"> <div class="adf-error-text">
{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MIN_LENGTH' | translate : { requiredLength: getValidationMinLength() } }} {{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MAX_LENGTH' | translate: { requiredLength: getValidationMaxLength() } }}
</div> </div>
</mat-error> </mat-error>
<mat-error }
*ngIf="(searchUserCtrl.hasError('required') || userChipsCtrl.hasError('required')) && isDirty()" @if (searchUserCtrl.hasError('minlength')) {
class="adf-error adf-error-animate" <mat-error class="adf-error adf-error-animate">
> <mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">
{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MIN_LENGTH' | translate: { requiredLength: getValidationMinLength() } }}
</div>
</mat-error>
}
@if ((searchUserCtrl.hasError('required') || userChipsCtrl.hasError('required')) && isDirty()) {
<mat-error class="adf-error adf-error-animate">
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> <mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.REQUIRED' | translate }}</div> <div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.REQUIRED' | translate }}</div>
</mat-error> </mat-error>
<mat-error }
*ngIf="searchUserCtrl.hasError('searchTypingError') && !this.isFocused" @if (searchUserCtrl.hasError('searchTypingError') && !this.isFocused) {
data-automation-id="invalid-users-typing-error" <mat-error data-automation-id="invalid-users-typing-error" class="adf-error adf-error-animate">
class="adf-error adf-error-animate"
>
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> <mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_USERS.ERROR.NOT_FOUND' | translate : { userName: searchedValue } }}</div> <div class="adf-error-text">{{ 'ADF_CLOUD_USERS.ERROR.NOT_FOUND' | translate: { userName: searchedValue } }}</div>
</mat-error> </mat-error>
}
}
</div> </div>
</form> </form>
@@ -27,6 +27,7 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatChipHarness } from '@angular/material/chips/testing'; import { MatChipHarness } from '@angular/material/chips/testing';
import { MatInputHarness } from '@angular/material/input/testing'; import { MatInputHarness } from '@angular/material/input/testing';
import { MatFormFieldHarness } from '@angular/material/form-field/testing'; import { MatFormFieldHarness } from '@angular/material/form-field/testing';
import { MatProgressBar } from '@angular/material/progress-bar';
import { IdentityUserService } from '../services/identity-user.service'; import { IdentityUserService } from '../services/identity-user.service';
describe('PeopleCloudComponent', () => { describe('PeopleCloudComponent', () => {
@@ -99,6 +100,19 @@ describe('PeopleCloudComponent', () => {
expect(await inputField.getLabel()).toEqual('TITLE_KEY'); expect(await inputField.getLabel()).toEqual('TITLE_KEY');
}); });
it('should use dynamic form field subscript sizing by default', () => {
expect(component.formFieldSubscriptSizing).toBe('dynamic');
});
it('should render validation progress inside the reserved status area', () => {
component.validationLoading = true;
fixture.detectChanges();
const progressBar = fixture.debugElement.query(By.directive(MatProgressBar));
expect(progressBar.parent.classes['adf-error-messages-container']).toBeTrue();
});
describe('Search user', () => { describe('Search user', () => {
beforeEach(() => { beforeEach(() => {
fixture.detectChanges(); fixture.detectChanges();
@@ -169,7 +169,7 @@ export class PeopleCloudComponent implements OnInit, OnChanges, AfterViewInit {
* Material form field subscript sizing (fixed / dynamic) * Material form field subscript sizing (fixed / dynamic)
*/ */
@Input() @Input()
formFieldSubscriptSizing: SubscriptSizing = 'fixed'; formFieldSubscriptSizing: SubscriptSizing = 'dynamic';
/** /**
* Show errors under the form field * Show errors under the form field