Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot]anderomano 1e3e8aee53 Restrict SonarCloud analysis to lib/**/src/** sources
Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>
2026-08-21 07:58:38 +00:00
Alex Molodyh 83b5769fbc AAE-46057 Stabilize form field status layout (#12165) 2026-08-20 09:55:29 -07:00
Bartosz Sekula d0368ba9c8 AAE-48569 Security bump for socket-io.parser (#12185)
* AAE-48569 Security bump for socket-io.parser

* cr
2026-08-20 16:36:12 +01:00
Shivangi Shree beebf737b9 [ACS-10230] Add role to search filter like logic, properties, date etc. (#12179)
* [ACS-10230] Add role to search filter like logic, properties, date etc.

* [ACS-10230] CR fixes
2026-08-20 16:15:29 +05:30
Ehsan Rezaei cc39b19eea AAE-50665 Handling dynamic component destroy (#12183)
* AAE-50665 Handling dynamic component destroy

* AAE-50665 Code improvements
2026-08-19 22:33:38 +02:00
Copilotanderomano 5e0c52afcb AAE-50678 SonarCloud scan on daily cron instead of every develop push (#12184)
* ci: change sonar-develop workflow to run on daily cron instead of every push to develop

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* ci: add pull-requests: read permission to sonar-develop workflow

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>
2026-08-19 20:10:52 +02:00
7b8d616a9f AAE-50678 Fix: report test coverage to SonarCloud (#12181),
* fix: enable LCOV coverage reporting for SonarCloud

Add lcov reporter to all karma configs, upload coverage artifacts from
unit test matrix jobs, and add a SonarCloud scan job that merges
coverage reports and runs the sonar-scanner with proper LCOV paths.

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: pass secrets to unit-test-workflow and set SONAR_HOST_URL for SonarCloud

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* fix: add test outputs to nx.json so NX caches and restores coverage reports

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* test: add unit tests for Chart model to verify coverage reporting

* fix: use find to locate lcov.info in downloaded artifacts for SonarCloud coverage

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* test: add fake file with unit test to verify coverage reporting

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* ci: add full SonarCloud scan workflow on develop push

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* test: remove fake coverage-canary file and its spec

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* fix: replace secrets inherit with explicit SONAR_TOKEN in workflow call

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>
Co-authored-by: Eugenio Romano <eromano@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-19 19:44:15 +02:00
Darren Thornton f915c813f0 AAE-49862 Fix Form Rule Does Not Update Hidden Status of Outcomes on First Run (#12168) 2026-08-19 10:17:52 -05:00
58 changed files with 1213 additions and 342 deletions
+2
View File
@@ -254,6 +254,8 @@ jobs:
name: "Unit Tests"
needs: [setup]
uses: ./.github/workflows/unit-test-workflow.yml
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
base_ref: ${{ github.base_ref || 'develop' }}
+22
View File
@@ -0,0 +1,22 @@
name: "SonarCloud Full Scan (develop)"
on:
schedule:
- cron: '0 5 * * *'
workflow_dispatch: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: read
jobs:
full-unit-tests-and-sonar-scan:
name: "Full Unit Tests + SonarCloud Scan"
uses: ./.github/workflows/unit-test-workflow.yml
secrets: inherit
with:
full: true
+65 -2
View File
@@ -2,12 +2,21 @@ name: "Unit Tests Workflow"
on:
workflow_call:
secrets:
SONAR_TOKEN:
description: 'Token for SonarCloud analysis'
required: false
inputs:
base_ref:
description: 'Base branch for affected calculation'
required: false
type: string
default: 'develop'
full:
description: 'Run the full (non-affected) test suite for every project instead of only affected ones'
required: false
type: boolean
default: false
jobs:
generate-affected-matrix:
@@ -30,9 +39,15 @@ jobs:
id: set-matrix
env:
BASE_REF: ${{ inputs.base_ref }}
FULL_RUN: ${{ inputs.full }}
run: |
echo "Base ref is $BASE_REF"
AFFECTED_UNIT=$(pnpm nx show projects --affected --target=test --base=origin/$BASE_REF --head=HEAD --select=projects --plain --exclude=cli,stories,eslint-angular)
if [ "$FULL_RUN" == "true" ]; then
echo "Running full (non-affected) test suite"
AFFECTED_UNIT=$(pnpm nx show projects --target=test --select=projects --plain --exclude=cli,stories,eslint-angular)
else
echo "Base ref is $BASE_REF"
AFFECTED_UNIT=$(pnpm nx show projects --affected --target=test --base=origin/$BASE_REF --head=HEAD --select=projects --plain --exclude=cli,stories,eslint-angular)
fi
echo "Affected projects for UNIT: $AFFECTED_UNIT"
if [ -z "$AFFECTED_UNIT" ]; then
@@ -74,8 +89,56 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=5120"
run: |
xvfb-run --auto-servernum pnpm nx run ${{ matrix.project }}:test
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: coverage-${{ matrix.project }}
path: coverage/${{ matrix.project }}/lcov.info
if-no-files-found: ignore
retention-days: 1
- name: Save nx cache
if: ${{ success() }}
uses: ./.github/actions/save-nx-cache
with:
cache-suffix: test-${{ matrix.project }}
sonarcloud:
name: "SonarCloud Scan"
runs-on: ubuntu-latest
needs: [generate-affected-matrix, unit-tests]
if: ${{ needs.generate-affected-matrix.outputs.hasProjects == 'true' && always() && needs.unit-tests.result != 'cancelled' }}
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Download all coverage artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: coverage-*
path: coverage-reports
- name: Merge coverage reports
run: |
mkdir -p coverage
echo "Artifact structure:"
find coverage-reports -type f -name 'lcov.info' 2>/dev/null || true
for dir in coverage-reports/coverage-*/; do
project_name=$(basename "$dir" | sed 's/^coverage-//')
lcov_file=$(find "$dir" -name 'lcov.info' -type f | head -1)
if [ -n "$lcov_file" ]; then
mkdir -p "coverage/${project_name}"
cp "$lcov_file" "coverage/${project_name}/lcov.info"
echo "Copied coverage for ${project_name}"
fi
done
echo "Coverage files found:"
find coverage -name 'lcov.info' -type f
- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97 # v5.1.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: https://sonarcloud.io
+1 -1
View File
@@ -56,7 +56,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/content-services'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
@@ -1,6 +1,8 @@
<div class="adf-search-filter-menu-card">
<div class="adf-search-filter-title">
<ng-content select="filter-title" />
<h2 class="adf-search-filter-title-heading">
<ng-content select="filter-title" />
</h2>
<button mat-icon-button
class="adf-search-filter-title-action"
aria-hidden="false"
@@ -7,6 +7,11 @@
height: 32px;
font: var(--mat-sys-body-medium);
&-heading {
margin: 0;
font: inherit;
}
&-action {
float: right;
}
@@ -38,4 +38,11 @@ describe('SearchFilterMenuComponent', () => {
closeButton.click();
expect(spyCloseEvent).toHaveBeenCalled();
});
it('should expose the title as a heading', () => {
const heading = fixture.debugElement.nativeElement.querySelector('.adf-search-filter-title-heading');
expect(heading).not.toBeNull();
expect(heading.tagName).toBe('H2');
});
});
+1 -1
View File
@@ -69,7 +69,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/core'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
@@ -20,6 +20,12 @@
.mat-mdc-form-field-infix {
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 {
@@ -40,7 +46,7 @@
.adf-container-widget {
.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 {
@@ -265,7 +271,7 @@
}
&-error-messages-container {
min-height: 35px;
height: 40px;
}
&-error-messages-container-visible {
@@ -5,7 +5,7 @@
&-single-column {
display: flex;
flex-wrap: inherit;
align-items: center;
align-items: flex-start;
gap: 1%;
@include flex.layout-bp(lt-md) {
@@ -16,10 +16,9 @@
<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span>
</mat-checkbox>
<div class="adf-error-messages-container">
<error-widget [error]="field.validationSummary" />
<error-widget
*ngIf="isInvalidFieldRequired() && isTouched()"
required="{{ 'FORM.FIELD.REQUIRED' | translate }}"
[error]="field.validationSummary"
[required]="isInvalidFieldRequired() && isTouched() ? ('FORM.FIELD.REQUIRED' | translate) : ''"
/>
</div>
</div>
@@ -17,7 +17,7 @@
/* eslint-disable @angular-eslint/component-selector */
import { NgClass, NgIf } from '@angular/common';
import { NgClass } from '@angular/common';
import { Component, ViewEncapsulation } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -47,7 +47,7 @@ import { WidgetComponent } from '../widget.component';
'(invalid)': 'event($event)',
'(select)': 'event($event)'
},
imports: [NgClass, MatCheckboxModule, FormsModule, TranslatePipe, ErrorWidgetComponent, NgIf],
imports: [NgClass, MatCheckboxModule, FormsModule, TranslatePipe, ErrorWidgetComponent],
encapsulation: ViewEncapsulation.None
})
export class CheckboxWidgetComponent extends WidgetComponent {}
@@ -10,12 +10,16 @@
}
}
error-widget {
display: block;
}
.adf-error {
display: flex;
align-items: center;
&-widget-container {
height: auto;
height: 40px;
}
&-animate {
@@ -1,6 +1,7 @@
.adf-hyperlink-widget {
padding: 0.4375em 0;
border-top: 0.8438em solid transparent;
margin-bottom: 20px;
a {
color: var(--mat-sys-primary);
@@ -43,6 +43,6 @@
@include mixins.adf-error-icon;
}
.adf-container-widget .adf-multiline-text-widget .adf-form-field-input.adf-has-counter {
margin-bottom: 44px;
.adf-container-widget .adf-multiline-text-widget mat-form-field.adf-form-field-input.adf-has-counter {
margin-bottom: 20px;
}
@@ -9,7 +9,8 @@
}
&-row-action {
margin-left: 10px;
margin-inline-start: 10px;
margin-block-end: 35px;
}
&-row-limit {
@@ -16,6 +16,7 @@
*/
import { Subject } from 'rxjs';
import { FormEvent } from '../events/form.event';
import { FormFieldEvent } from '../events/form-field.event';
import { FormRulesEvent } from '../events/form-rules.event';
import { ValidateFormFieldEvent } from '../events/validate-form-field.event';
@@ -26,4 +27,5 @@ export interface FormValidationService {
validateForm: Subject<ValidateFormEvent>;
validateFormField: Subject<ValidateFormFieldEvent>;
formRulesEvent?: Subject<FormRulesEvent>;
formVisibilityRefreshed?: Subject<FormEvent>;
}
@@ -62,6 +62,12 @@ export class FormService implements FormValidationService {
formRulesEvent = new Subject<FormRulesEvent>();
/**
* Emitted after form field/outcome visibility has been re-evaluated via WidgetVisibilityService.refreshVisibility.
* Internal ADF form-rendering event — not part of the FormValidationService contract.
*/
formVisibilityRefreshed = new Subject<FormEvent>();
constructor() {
const injectedFieldValidators = inject(FORM_SERVICE_FIELD_VALIDATORS_TOKEN, { optional: true });
@@ -19,6 +19,7 @@ import { TestBed } from '@angular/core/testing';
import { ContainerModel, FormFieldModel, FormFieldTypes, FormModel, TabModel } from '../components/widgets/core';
import { WidgetVisibilityModel } from '../models/widget-visibility.model';
import { WidgetVisibilityService } from './widget-visibility.service';
import { FormService } from './form.service';
import {
fakeFormJson,
formTest,
@@ -50,6 +51,30 @@ describe('WidgetVisibilityService', () => {
service = TestBed.inject(WidgetVisibilityService);
});
it('should emit formVisibilityRefreshed when visibility is refreshed', () => {
const formService = TestBed.inject(FormService);
let emittedForm: FormModel | undefined;
formService.formVisibilityRefreshed.subscribe((event) => {
emittedForm = event.form;
});
service.refreshVisibility(stubFormWithFields);
expect(emittedForm).toBe(stubFormWithFields);
});
it('should not emit formVisibilityRefreshed when form is null', () => {
const formService = TestBed.inject(FormService);
let emitCount = 0;
formService.formVisibilityRefreshed.subscribe(() => emitCount++);
service.refreshVisibility(null);
expect(emitCount).toBe(0);
});
describe('should be able to evaluate next condition operations', () => {
it('using == and return true', () => {
const resultsArray = evaluateConditions(
@@ -15,16 +15,20 @@
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { FormFieldModel, FormModel, TabModel, ContainerModel, FormOutcomeModel } from '../components/widgets/core';
import { FormEvent } from '../events/form.event';
import { TaskProcessVariableModel } from '../models/task-process-variable.model';
import { WidgetVisibilityModel, WidgetTypeEnum } from '../models/widget-visibility.model';
import { format, isValid, parse } from 'date-fns';
import { FormService } from './form.service';
@Injectable({
providedIn: 'root'
})
export class WidgetVisibilityService {
private readonly formService = inject(FormService);
private processVarList: TaskProcessVariableModel[];
private form: FormModel;
@@ -45,6 +49,8 @@ export class WidgetVisibilityService {
}
form.getFormFields().map((field) => this.refreshEntityVisibility(field));
this.formService.formVisibilityRefreshed.next(new FormEvent(form));
}
}
@@ -17,6 +17,7 @@ $mat-button: '.mat-mdc-button';
$mat-button-label: '.mdc-button__label';
$mat-form-field: '.mat-mdc-form-field';
$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-form-field-prefix: '.mat-mdc-form-field-text-prefix';
$mat-form-field-suffix: '.mat-mdc-form-field-text-suffix';
+1 -1
View File
@@ -23,7 +23,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/extensions'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
+1 -1
View File
@@ -44,7 +44,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/insights'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
@@ -0,0 +1,148 @@
/*!
* @license
* Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Chart } from './chart.model';
describe('Chart Model', () => {
describe('constructor', () => {
it('should create with default values when no argument is provided', () => {
const chart = new Chart();
expect(chart.labels).toEqual([]);
expect(chart.data).toEqual([]);
expect(chart.datasets).toEqual([]);
expect(chart.showDetails).toBe(false);
});
it('should populate properties from input object', () => {
const chart = new Chart({
id: '1',
title: 'Test Chart',
titleKey: 'KEY',
labels: ['a', 'b'],
data: [1, 2],
datasets: [{ data: [1] }],
showDetails: true,
detailsTable: { key: 'value' },
options: { responsive: true }
});
expect(chart.id).toBe('1');
expect(chart.title).toBe('Test Chart');
expect(chart.titleKey).toBe('KEY');
expect(chart.labels).toEqual(['a', 'b']);
expect(chart.data).toEqual([1, 2]);
expect(chart.datasets).toEqual([{ data: [1] }]);
expect(chart.showDetails).toBe(true);
expect(chart.detailsTable).toEqual({ key: 'value' });
expect(chart.options).toEqual({ responsive: true });
});
it('should convert type and set icon for pieChart', () => {
const chart = new Chart({ type: 'pieChart' });
expect(chart.type).toBe('pie');
expect(chart.icon).toBe('pie_chart');
});
it('should convert type and set icon for barChart', () => {
const chart = new Chart({ type: 'barChart' });
expect(chart.type).toBe('bar');
expect(chart.icon).toBe('equalizer');
});
it('should convert type and set icon for line', () => {
const chart = new Chart({ type: 'line' });
expect(chart.type).toBe('line');
expect(chart.icon).toBe('show_chart');
});
it('should convert type and set icon for table', () => {
const chart = new Chart({ type: 'table' });
expect(chart.type).toBe('table');
expect(chart.icon).toBe('web');
});
it('should convert type and set icon for multiBarChart', () => {
const chart = new Chart({ type: 'multiBarChart' });
expect(chart.type).toBe('multiBar');
expect(chart.icon).toBe('poll');
});
it('should convert type and set icon for processDefinitionHeatMap', () => {
const chart = new Chart({ type: 'processDefinitionHeatMap' });
expect(chart.type).toBe('HeatMap');
expect(chart.icon).toBe('share');
});
it('should convert type and set icon for masterDetailTable', () => {
const chart = new Chart({ type: 'masterDetailTable' });
expect(chart.type).toBe('masterDetailTable');
expect(chart.icon).toBe('subtitles');
});
it('should default to table type for unknown types', () => {
const chart = new Chart({ type: 'unknown' });
expect(chart.type).toBe('table');
expect(chart.icon).toBe('web');
});
});
describe('hasData', () => {
it('should return true when data is not empty', () => {
const chart = new Chart({ data: [1, 2, 3] });
expect(chart.hasData()).toBe(true);
});
it('should return false when data is empty', () => {
const chart = new Chart({ data: [] });
expect(chart.hasData()).toBe(false);
});
it('should return false when no data is provided', () => {
const chart = new Chart();
expect(chart.hasData()).toBe(false);
});
});
describe('hasDatasets', () => {
it('should return true when datasets is not empty', () => {
const chart = new Chart({ datasets: [{ data: [1] }] });
expect(chart.hasDatasets()).toBe(true);
});
it('should return false when datasets is empty', () => {
const chart = new Chart({ datasets: [] });
expect(chart.hasDatasets()).toBe(false);
});
});
describe('hasZeroValues', () => {
it('should return true when all data values are zero', () => {
const chart = new Chart({ data: [0, 0, 0] });
expect(chart.hasZeroValues()).toBe(true);
});
it('should return false when at least one value is non-zero', () => {
const chart = new Chart({ data: [0, 1, 0] });
expect(chart.hasZeroValues()).toBe(false);
});
it('should return false when data is empty', () => {
const chart = new Chart({ data: [] });
expect(chart.hasZeroValues()).toBe(false);
});
});
});
+1 -1
View File
@@ -48,7 +48,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/process-services-cloud'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
@@ -32,6 +32,8 @@ import {
provideTranslations,
AuthModule,
FormFieldEvent,
FormEvent,
FormRulesEvent,
NoopTranslateModule,
NoopAuthModule,
FORM_FIELD_VALIDATORS
@@ -1299,6 +1301,38 @@ describe('FormCloudComponent', () => {
expect(formComponent.visibleOutcomes).toEqual([]);
});
it('should recompute visibleOutcomes when form visibility is refreshed', () => {
formComponent.showCompleteButton = true;
const formModel = new FormModel(cloudFormMock);
formComponent.form = formModel;
expect(formComponent.visibleOutcomes.length).toBeGreaterThan(0);
formModel.outcomes.forEach((outcome) => {
outcome.isVisible = false;
});
TestBed.inject(FormService).formVisibilityRefreshed.next(new FormEvent(formModel));
expect(formComponent.visibleOutcomes).toEqual([]);
});
it('should recompute visibleOutcomes when fieldValueChanged rule event fires', () => {
formComponent.showCompleteButton = true;
const formModel = new FormModel(cloudFormMock);
formComponent.form = formModel;
expect(formComponent.visibleOutcomes.length).toBeGreaterThan(0);
formModel.outcomes.forEach((outcome) => {
outcome.isVisible = false;
});
TestBed.inject(FormService).formRulesEvent.next(new FormRulesEvent('fieldValueChanged', new FormEvent(formModel)));
expect(formComponent.visibleOutcomes).toEqual([]);
});
it('should raise [executeOutcome] event for formService', async () => {
spyOn(formComponent.executeOutcome, 'emit');
@@ -30,7 +30,7 @@ import {
SimpleChanges,
ViewChild
} from '@angular/core';
import { forkJoin, isObservable, Observable, of, Subscription } from 'rxjs';
import { forkJoin, isObservable, merge, Observable, of, Subscription } from 'rxjs';
import { filter, map, switchMap } from 'rxjs/operators';
import {
ConfirmDialogComponent,
@@ -306,11 +306,11 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
}
});
this.formService.formRulesEvent
.pipe(
filter((event) => event?.type === 'fieldValueChanged' && event.form?.id === this.form?.id),
takeUntilDestroyed()
)
merge(
this.formService.formVisibilityRefreshed.pipe(filter((event) => event.form?.id === this.form?.id)),
this.formService.formRulesEvent.pipe(filter((event) => event?.type === 'fieldValueChanged' && event.form?.id === this.form?.id))
)
.pipe(takeUntilDestroyed())
.subscribe(() => this.recomputeVisibleOutcomes());
}
@@ -595,7 +595,6 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
checkVisibility(field: FormFieldModel) {
if (field?.form) {
this.visibilityService.refreshVisibility(field.form);
this.recomputeVisibleOutcomes();
}
}
@@ -613,7 +612,6 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
this.setCheckParentVisibilityForValidationOnFields();
this.visibilityService.refreshVisibility(this.form);
this.form.validateForm();
this.recomputeVisibleOutcomes();
this.onFormLoaded(this.form);
this.formService.formRulesEvent.next(new FormRulesEvent('dataRefreshed', new FormEvent(this.form)));
this.onFormDataRefreshed(this.form);
@@ -1,21 +1,26 @@
<div class="adf-attach-file-widget-container">
<div class="adf-attach-widget {{field.className}}"
[class.adf-readonly]="field.readOnly">
<label class="adf-label" [attr.for]="field.id + '-label'">{{field.name}}
<span class="adf-asterisk" *ngIf="isRequired()">*</span>
<div class="adf-attach-widget {{ field.className }}" [class.adf-readonly]="field.readOnly">
<label class="adf-label" [attr.for]="field.id + '-label'"
>{{ field.name }}
@if (isRequired()) {
<span class="adf-asterisk">*</span>
}
</label>
<div class="adf-attach-widget-container" (focusout)="markAsTouched()">
<div class="adf-attach-widget__menu-upload" *ngIf="isUploadButtonVisible()">
<button
(click)="openSelectDialog()"
mat-raised-button
class="adf-attach-widget__menu-upload__button"
[id]="field.id"
[title]="field.tooltip">
@if (isUploadButtonVisible()) {
<div class="adf-attach-widget__menu-upload">
<button
(click)="openSelectDialog()"
mat-raised-button
class="adf-attach-widget__menu-upload__button"
[id]="field.id"
[title]="field.tooltip"
>
{{ 'FORM.FIELD.ATTACH' | translate }}
<mat-icon class="adf-attach-widget__menu-upload__button__icon" [adf-icon]="getWidgetIcon()" />
</button>
</div>
</button>
</div>
}
</div>
</div>
@@ -34,12 +39,15 @@
(contentModelFileHandler)="contentModelFormFileHandler($event)"
(removeAttachFile)="onRemoveAttachFile($event)"
/>
<div *ngIf="!hasFile && field.readOnly" id="{{'adf-attach-empty-list-'+field.id}}">
{{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }}
</div>
@if (!hasFile && field.readOnly) {
<div id="{{ 'adf-attach-empty-list-' + field.id }}">
{{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }}
</div>
}
</div>
<error-widget [error]="field.validationSummary" />
<error-widget *ngIf="!field.isValid && isTouched() && !isSelected()" required="{{ 'FORM.FIELD.REQUIRED' | translate }}" />
<error-widget
[error]="field.validationSummary"
[required]="!field.isValid && isTouched() && !isSelected() ? ('FORM.FIELD.REQUIRED' | translate) : ''"
/>
</div>
@@ -1,31 +1,23 @@
<div class="adf-data-table-widget-container">
<div class="adf-data-table-widget-label">
<label
class="adf-label"
[class.adf-left-label]="field.leftLabels"
[attr.for]="field.id">
{{field.name | translate }}
</label>
<label class="adf-label" [class.adf-left-label]="field.leftLabels" [attr.for]="field.id"> {{field.name | translate }} </label>
</div>
<ng-container *ngIf="!previewState; else previewTemplate">
@if (!previewState) {
<adf-datatable data-automation-id="adf-data-table-widget" [data]="dataSource">
<adf-no-content-template>
<ng-template>
<adf-empty-content
icon="border_all"
[title]="'FORM.FIELD.DATA_TABLE_EMPTY_CONTENT' | translate" />
<adf-empty-content icon="border_all" [title]="'FORM.FIELD.DATA_TABLE_EMPTY_CONTENT' | translate" />
</ng-template>
</adf-no-content-template>
</adf-datatable>
<error-widget *ngIf="dataTableLoadFailed"
<error-widget
class="adf-data-table-widget-failed-message"
[required]="'FORM.FIELD.DATA_TABLE_LOAD_FAILED' | translate" />
</ng-container>
<ng-template #previewTemplate>
[required]="dataTableLoadFailed ? ('FORM.FIELD.DATA_TABLE_LOAD_FAILED' | translate) : ''"
/>
} @else {
<adf-datatable data-automation-id="adf-data-table-widget-preview" />
<div class="adf-preview-placeholder"></div>
</ng-template>
}
</div>
@@ -1,4 +1,5 @@
.adf-data-table-widget-failed-message {
display: block;
margin: 10px;
}
@@ -283,7 +283,8 @@ describe('DataTableWidgetComponent', () => {
const failedErrorMsgElement = fixture.debugElement.query(By.css('.adf-data-table-widget-failed-message'));
assertData(mockCountryColumns, []);
expect(failedErrorMsgElement).toBeNull();
expect(failedErrorMsgElement).toBeTruthy();
expect(failedErrorMsgElement.nativeElement.textContent.trim()).toBe('');
});
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'));
assertData(mockCountryColumns, [mockEuropeCountriesRows[1]]);
expect(failedErrorMsgElement).toBeNull();
expect(failedErrorMsgElement).toBeTruthy();
expect(failedErrorMsgElement.nativeElement.textContent.trim()).toBe('');
});
});
@@ -27,7 +27,6 @@ import {
NoContentTemplateDirective,
EmptyContentComponent
} from '@alfresco/adf-core';
import { NgIf } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { FormCloudService } from '../../../services/form-cloud.service';
import { TaskVariableCloud } from '../../../models/task-variable-cloud.model';
@@ -36,7 +35,7 @@ import { DataTablePathParserHelper } from './helpers/data-table-path-parser.help
@Component({
standalone: true,
imports: [NgIf, TranslatePipe, FormBaseModule, DataTableComponent, NoContentTemplateDirective, EmptyContentComponent],
imports: [TranslatePipe, FormBaseModule, DataTableComponent, NoContentTemplateDirective, EmptyContentComponent],
selector: 'data-table',
templateUrl: './data-table.widget.html',
styleUrls: ['./data-table.widget.scss'],
@@ -4,11 +4,13 @@
[class.adf-readonly]="field.readOnly"
[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
>
</div>
@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>
<adf-cloud-group
[mode]="mode"
@@ -22,13 +24,7 @@
[preSelectGroups]="preSelectGroup"
(blur)="markAsTouched()"
[attr.title]="field.tooltip"
[label] = "field.name | translate"
/>
<error-widget [error]="field.validationSummary" />
<error-widget
class="adf-dropdown-required-message"
*ngIf="isInvalidFieldRequired() && isTouched()"
required="{{ 'FORM.FIELD.REQUIRED' | translate }}"
[label]="field.name | translate"
/>
</div>
</div>
@@ -141,8 +141,9 @@ describe('GroupCloudWidgetComponent', () => {
fixture.detectChanges();
await fixture.whenStable();
expect(element.querySelector('.adf-error-text')).toBeTruthy();
expect(element.querySelector('.adf-error-text').textContent).toContain('ADF_CLOUD_GROUPS.ERROR.NOT_FOUND');
const errorMessages = element.querySelectorAll('.adf-error-text');
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 { ErrorWidgetComponent, WidgetComponent } from '@alfresco/adf-core';
import { WidgetComponent } from '@alfresco/adf-core';
import { UntypedFormControl } from '@angular/forms';
import { filter } from 'rxjs/operators';
import { ComponentSelectionMode } from '../../../../types';
@@ -31,7 +31,7 @@ import { GroupCloudComponent } from '../../../../group/components/group-cloud.co
@Component({
selector: 'group-cloud-widget',
imports: [CommonModule, TranslatePipe, ErrorWidgetComponent, GroupCloudComponent],
imports: [CommonModule, TranslatePipe, GroupCloudComponent],
templateUrl: './group-cloud.widget.html',
host: {
'(click)': 'event($event)',
@@ -1,10 +1,16 @@
<div class="adf-dropdown-widget {{field.className}}"
[class.adf-invalid]="!field.isValid && isTouched()"
[class.adf-readonly]="field.readOnly"
[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>
</div>
<div
class="adf-dropdown-widget {{field.className}}"
[class.adf-invalid]="!field.isValid && isTouched()"
[class.adf-readonly]="field.readOnly"
[class.adf-left-label-input-container]="field.leftLabels"
>
@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>
<adf-cloud-people
[preSelectUsers]="preSelectUsers"
@@ -21,11 +27,5 @@
[attr.title]="field.tooltip"
[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>
@@ -171,8 +171,9 @@ describe('PeopleCloudWidgetComponent', () => {
fixture.detectChanges();
await fixture.whenStable();
expect(element.querySelector('.adf-error-text')).toBeTruthy();
expect(element.querySelector('.adf-error-text').textContent).toContain('ADF_CLOUD_USERS.ERROR.NOT_FOUND');
const errorMessages = element.querySelectorAll('.adf-error-text');
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 { ErrorWidgetComponent, WidgetComponent } from '@alfresco/adf-core';
import { WidgetComponent } from '@alfresco/adf-core';
import { UntypedFormControl } from '@angular/forms';
import { filter } from 'rxjs/operators';
import { ComponentSelectionMode } from '../../../../types';
@@ -27,13 +27,12 @@ import { ReactivePreselectionService } from '../reactive-preselection.service';
import { CommonModule } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { PeopleCloudComponent } from '../../../../people/components/people-cloud.component';
import { MatFormFieldModule } from '@angular/material/form-field';
/* eslint-disable @angular-eslint/component-selector */
@Component({
selector: 'people-cloud-widget',
imports: [CommonModule, TranslatePipe, ErrorWidgetComponent, PeopleCloudComponent, MatFormFieldModule],
imports: [CommonModule, TranslatePipe, PeopleCloudComponent],
templateUrl: './people-cloud.widget.html',
host: {
'(click)': 'event($event)',
@@ -11,7 +11,6 @@
}
&-radio-button-container-horizontal {
margin-bottom: 15px;
display: flex;
flex-flow: column wrap;
align-items: flex-start;
@@ -4,51 +4,50 @@
>
<div class="adf-cloud-upload-widget-container">
<div>
<mat-list *ngIf="hasFile">
<mat-list-item class="adf-upload-files-row" *ngFor="let file of uploadedFiles">
<img
matListItemLine
class="adf-upload-widget__icon"
[id]="'file-'+file.id+'-icon'"
[src]="getIcon(file.content.mimeType)"
[alt]="mimeTypeIcon"
(click)="fileClicked(file)"
(keyup.enter)="fileClicked(file)"
role="button"
tabindex="0"
/>
<span
class="adf-upload-widget__button adf-file"
matLine
id="{{'file-'+file.id}}"
(click)="fileClicked(file)"
(keyup.enter)="fileClicked(file)"
role="button"
tabindex="0"
>{{file.name}}</span
>
<button
*ngIf="!field.readOnly"
mat-icon-button
[id]="'file-'+file.id+'-remove'"
(click)="removeFile(file);"
(keyup.enter)="removeFile(file);"
>
<mat-icon class="mat-24" adf-icon="highlight_off" />
</button>
</mat-list-item>
</mat-list>
@if (hasFile) {
<mat-list>
<mat-list-item class="adf-upload-files-row" *ngFor="let file of uploadedFiles">
<img
matListItemLine
class="adf-upload-widget__icon"
[id]="'file-'+file.id+'-icon'"
[src]="getIcon(file.content.mimeType)"
[alt]="mimeTypeIcon"
(click)="fileClicked(file)"
(keyup.enter)="fileClicked(file)"
role="button"
tabindex="0"
/>
<span
class="adf-upload-widget__button adf-file"
matLine
id="{{'file-'+file.id}}"
(click)="fileClicked(file)"
(keyup.enter)="fileClicked(file)"
role="button"
tabindex="0"
>{{file.name}}</span
>
@if (!field.readOnly) {
<button mat-icon-button [id]="'file-'+file.id+'-remove'" (click)="removeFile(file);" (keyup.enter)="removeFile(file);">
<mat-icon class="mat-24" adf-icon="highlight_off" />
</button>
}
</mat-list-item>
</mat-list>
}
</div>
<div *ngIf="(!hasFile || multipleOption) && !field.readOnly">
<button mat-raised-button (click)="uploadFiles.click()" [title]="field.tooltip">
{{ 'FORM.FIELD.UPLOAD' | translate }}<mat-icon adf-icon="file_upload" />
<input #uploadFiles [multiple]="multipleOption" type="file" [id]="field.form.nodeId" (change)="onFileChanged($event)" />
</button>
</div>
<div *ngIf="!hasFile && field.readOnly">{{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }}</div>
@if ((!hasFile || multipleOption) && !field.readOnly) {
<div>
<button mat-raised-button (click)="uploadFiles.click()" [title]="field.tooltip">
{{ 'FORM.FIELD.UPLOAD' | translate }}<mat-icon adf-icon="file_upload" />
<input #uploadFiles [multiple]="multipleOption" type="file" [id]="field.form.nodeId" (change)="onFileChanged($event)" />
</button>
</div>
} @if (!hasFile && field.readOnly) {
<div>{{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }}</div>
}
</div>
<error-widget [error]="field.validationSummary" />
<error-widget *ngIf="isInvalidFieldRequired()" required="{{ 'FORM.FIELD.REQUIRED' | translate }}" />
<error-widget [error]="field.validationSummary" [required]="isInvalidFieldRequired() ? ('FORM.FIELD.REQUIRED' | translate) : ''" />
</div>
@@ -55,4 +55,12 @@ describe('UploadCloudWidgetComponent', () => {
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,27 +1,37 @@
<form>
<mat-form-field class="adf-cloud-group adf-form-field-input" [class.adf-invalid]="hasError() && isDirty()">
@if (label || required) { <mat-label><span>{{label}}</span></mat-label> }
<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
>
}
<mat-chip-grid [required]="required" [disabled]="isReadonly()" #groupChipList data-automation-id="adf-cloud-group-chip-list">
<mat-chip-row
*ngFor="let group of selectedGroups"
[removable]="!(group.readonly)"
[removable]="!group.readonly"
[attr.data-automation-id]="'adf-cloud-group-chip-' + group.name"
(removed)="onRemove(group)"
[disabled]="readOnly || isValidationLoading()"
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" />
title="{{ (group.readonly ? 'ADF_CLOUD_GROUPS.MANDATORY' : '') | translate }}"
>
{{ 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>
<input matInput
[formControl]="searchGroupsControl"
[matAutocomplete]="auto"
[matChipInputFor]="groupChipList"
[placeholder]="isReadonly() ? '' : (title | translate)"
[required]="required"
(focus)="setFocus(true)"
(blur)="setFocus(false); markAsTouched()"
class="adf-group-input"
data-automation-id="adf-cloud-group-search-input" #groupInput>
<input
matInput
[formControl]="searchGroupsControl"
[matAutocomplete]="auto"
[matChipInputFor]="groupChipList"
[placeholder]="isReadonly() ? '' : (title | translate)"
[required]="required"
(focus)="setFocus(true)"
(blur)="setFocus(false); markAsTouched()"
class="adf-group-input"
data-automation-id="adf-cloud-group-search-input"
#groupInput
/>
</mat-chip-grid>
<mat-autocomplete
@@ -30,57 +40,77 @@
class="adf-cloud-group-list"
(optionSelected)="onSelect($event.option.value)"
[displayWith]="getDisplayName"
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"
[attr.data-automation-id]="'adf-cloud-group-chip-' + group.name"
class="adf-cloud-group-option-active">
<div
class="adf-cloud-group-row"
id="adf-group-{{i}}"
data-automation-id="adf-cloud-group-row">
<button class="adf-group-short-name" mat-fab>{{getGroupNameInitials(group)}}</button>
<span>{{group.name}}</span>
data-automation-id="adf-cloud-group-autocomplete"
>
@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"
class="adf-cloud-group-option-active"
>
<div class="adf-cloud-group-row" id="adf-group-{{ i }}" data-automation-id="adf-cloud-group-row">
<button class="adf-group-short-name" mat-fab>{{ getGroupNameInitials(group) }}</button>
<span>{{ group.name }}</span>
</div>
</mat-option>
</ng-container>
} @else {
<ng-container [ngTemplateOutlet]="noResults" />
}
<ng-template #noResults>
<mat-option *ngIf="searchGroupsControl.hasError('searchTypingError') && !searchLoading" disabled
class="adf-cloud-group-option-not-active"
data-automation-id="adf-cloud-group-no-results">
<span> {{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</span>
</mat-option>
@if (searchGroupsControl.hasError('searchTypingError') && !searchLoading) {
<mat-option disabled class="adf-cloud-group-option-not-active" data-automation-id="adf-cloud-group-no-results">
<span> {{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</span>
</mat-option>
}
</ng-template>
</mat-autocomplete>
</mat-form-field>
<mat-progress-bar *ngIf="validationLoading" mode="indeterminate" />
<div class="adf-error-container adf-error-messages-container">
<mat-error *ngIf="hasPreselectError() && !isValidationLoading()" class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</div>
</mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('pattern')" class="adf-error">
<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>
@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" />
<div class="adf-error-text">{{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</div>
</mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('maxlength')" class="adf-error">
<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>
</mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('minlength')" class="adf-error">
<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>
<mat-error *ngIf="(searchGroupsControl.hasError('required') || groupChipsCtrl.hasError('required')) && isDirty()"
class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.REQUIRED' | translate }} </div>
</mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('searchTypingError') && !this.isFocused"
data-automation-id="invalid-groups-typing-error" class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</div>
</mat-error>
}
@if (searchGroupsControl.hasError('pattern')) {
<mat-error class="adf-error">
<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>
</mat-error>
}
@if (searchGroupsControl.hasError('maxlength')) {
<mat-error class="adf-error">
<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>
</mat-error>
}
@if (searchGroupsControl.hasError('minlength')) {
<mat-error class="adf-error">
<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 ((searchGroupsControl.hasError('required') || groupChipsCtrl.hasError('required')) && isDirty()) {
<mat-error class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.REQUIRED' | translate }}</div>
</mat-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" />
<div class="adf-error-text">{{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</div>
</mat-error>
}
</div>
</form>
@@ -57,10 +57,12 @@
}
}
.adf-error-messages-container .adf-error-icon {
@include mixins.adf-error-icon;
}
.adf-error-messages-container {
.adf-error-icon {
@include mixins.adf-error-icon;
}
.adf-error-messages-container .adf-error {
animation: slide-down-fade-in 300ms cubic-bezier(0.55, 0, 0.55, 0.2);
.adf-error {
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 { MatIconHarness } from '@angular/material/icon/testing';
import { MatInputHarness } from '@angular/material/input/testing';
import { MatFormField } from '@angular/material/form-field';
import { MatProgressBar } from '@angular/material/progress-bar';
describe('GroupCloudComponent', () => {
let loader: HarnessLoader;
@@ -98,6 +100,22 @@ describe('GroupCloudComponent', () => {
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', () => {
beforeEach(() => {
fixture.detectChanges();
@@ -4,10 +4,14 @@
class="adf-people-cloud adf-form-field-input"
[class.adf-invalid]="hasError() && isDirty()"
>
<mat-label *ngIf="!title">
<span>{{label}}</span>
</mat-label>
<mat-label *ngIf="title">{{ title | translate }}</mat-label>
@if (!title) {
<mat-label>
<span>{{ label }}</span>
</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-row
@@ -17,10 +21,12 @@
(removed)="onRemove(user)"
[disabled]="isReadonly() || isValidationLoading()"
title="{{ (user.readonly ? 'ADF_CLOUD_GROUPS.MANDATORY' : '') | translate }}"
[matTooltip]="showFullNameOnHover ? (user | fullName : true) : user.email"
[matTooltip]="showFullNameOnHover ? (user | fullName: true) : user.email"
>
{{ 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>
<input
matInput
@@ -44,64 +50,73 @@
(optionSelected)="onSelect($event.option.value)"
[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">
<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>
<span class="adf-people-label-name"> {{ user | fullName : true }}</span>
<div [outerHTML]="user | usernameInitials: 'adf-people-cloud-pic'"></div>
<span class="adf-people-label-name"> {{ user | fullName: true }}</span>
</div>
</mat-option>
</ng-container>
} @else {
<ng-container [ngTemplateOutlet]="noResults" />
}
<ng-template #noResults>
<mat-option
*ngIf="searchUserCtrl.hasError('searchTypingError') && !searchLoading"
disabled
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>
@if (searchUserCtrl.hasError('searchTypingError') && !searchLoading) {
<mat-option disabled 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>
}
</ng-template>
</mat-autocomplete>
</mat-form-field>
<mat-progress-bar *ngIf="validationLoading" mode="indeterminate" />
<div class="adf-error-container adf-error-messages-container" *ngIf="showErrors">
<mat-error *ngIf="hasPreselectError() && !isValidationLoading()" class="adf-error adf-error-animate">
<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>
</mat-error>
<mat-error *ngIf="searchUserCtrl.hasError('pattern')" 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_PATTERN' | translate : { pattern: getValidationPattern() } }}</div>
</mat-error>
<mat-error *ngIf="searchUserCtrl.hasError('maxlength')" 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_MAX_LENGTH' | translate : { requiredLength: getValidationMaxLength() } }}
</div>
</mat-error>
<mat-error *ngIf="searchUserCtrl.hasError('minlength')" 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>
<mat-error
*ngIf="(searchUserCtrl.hasError('required') || userChipsCtrl.hasError('required')) && isDirty()"
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.REQUIRED' | translate }}</div>
</mat-error>
<mat-error
*ngIf="searchUserCtrl.hasError('searchTypingError') && !this.isFocused"
data-automation-id="invalid-users-typing-error"
class="adf-error adf-error-animate"
>
<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>
</mat-error>
<div class="adf-error-container adf-error-messages-container">
@if (validationLoading) {
<mat-progress-bar mode="indeterminate" />
}
@if (showErrors) {
@if (hasPreselectError() && !isValidationLoading()) {
<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_USERS.ERROR.NOT_FOUND' | translate: { userName: validateUsersMessage } }}</div>
</mat-error>
}
@if (searchUserCtrl.hasError('pattern')) {
<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_PATTERN' | translate: { pattern: getValidationPattern() } }}
</div>
</mat-error>
}
@if (searchUserCtrl.hasError('maxlength')) {
<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_MAX_LENGTH' | translate: { requiredLength: getValidationMaxLength() } }}
</div>
</mat-error>
}
@if (searchUserCtrl.hasError('minlength')) {
<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" />
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.REQUIRED' | translate }}</div>
</mat-error>
}
@if (searchUserCtrl.hasError('searchTypingError') && !this.isFocused) {
<mat-error data-automation-id="invalid-users-typing-error" class="adf-error adf-error-animate">
<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>
</mat-error>
}
}
</div>
</form>
@@ -27,6 +27,7 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatChipHarness } from '@angular/material/chips/testing';
import { MatInputHarness } from '@angular/material/input/testing';
import { MatFormFieldHarness } from '@angular/material/form-field/testing';
import { MatProgressBar } from '@angular/material/progress-bar';
import { IdentityUserService } from '../services/identity-user.service';
describe('PeopleCloudComponent', () => {
@@ -99,6 +100,19 @@ describe('PeopleCloudComponent', () => {
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', () => {
beforeEach(() => {
fixture.detectChanges();
@@ -169,7 +169,7 @@ export class PeopleCloudComponent implements OnInit, OnChanges, AfterViewInit {
* Material form field subscript sizing (fixed / dynamic)
*/
@Input()
formFieldSubscriptSizing: SubscriptSizing = 'fixed';
formFieldSubscriptSizing: SubscriptSizing = 'dynamic';
/**
* Show errors under the form field
@@ -0,0 +1,198 @@
/*!
* @license
* Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, ComponentRef, OnDestroy } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { BaseScreenCloudComponent } from './base-screen-cloud.component';
import { provideScreen } from '../../../services/provide-screen';
import { ScreenRenderingService } from '../../../services/screen-rendering.service';
@Component({
selector: 'adf-cloud-test-dynamic-screen',
template: `<div class="adf-cloud-test-dynamic-screen">dynamic screen</div>`
})
class TestDynamicScreenComponent implements OnDestroy {
destroyed = false;
ngOnDestroy(): void {
this.destroyed = true;
}
}
@Component({
selector: 'adf-cloud-test-host-screen',
template: `<ng-container #container />`
})
class TestHostScreenComponent extends BaseScreenCloudComponent<TestDynamicScreenComponent> {
setInputsCalls: ComponentRef<TestDynamicScreenComponent>[] = [];
subscribeToOutputsCalls: ComponentRef<TestDynamicScreenComponent>[] = [];
get dynamicComponentRef(): ComponentRef<TestDynamicScreenComponent> | undefined {
return this.componentRef;
}
get dynamicComponentRefSignalValue(): ComponentRef<TestDynamicScreenComponent> | undefined {
return this.componentRefChanged();
}
protected override setInputsForDynamicComponent(componentRef: ComponentRef<TestDynamicScreenComponent>): void {
this.setInputsCalls.push(componentRef);
}
protected subscribeToOutputs(componentRef: ComponentRef<TestDynamicScreenComponent>): void {
this.subscribeToOutputsCalls.push(componentRef);
}
}
/** Same host component, but without the `#container` anchor in its template. */
@Component({
selector: 'adf-cloud-test-host-screen-without-container',
template: `<div class="adf-cloud-no-container"></div>`
})
class TestHostScreenWithoutContainerComponent extends TestHostScreenComponent {}
describe('BaseScreenCloudComponent', () => {
const screenId = 'test-screen';
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TestHostScreenComponent, TestHostScreenWithoutContainerComponent, TestDynamicScreenComponent],
providers: [provideScreen(screenId, TestDynamicScreenComponent)]
});
});
describe('when a screenId is provided', () => {
let fixture: ComponentFixture<TestHostScreenComponent>;
let component: TestHostScreenComponent;
beforeEach(() => {
fixture = TestBed.createComponent(TestHostScreenComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('screenId', screenId);
fixture.detectChanges();
});
it('should create the dynamic component and expose it through the signal', () => {
expect(component.dynamicComponentRef).toBeDefined();
expect(component.dynamicComponentRefSignalValue).toBe(component.dynamicComponentRef);
expect(fixture.debugElement.query(By.css('.adf-cloud-test-dynamic-screen'))).toBeTruthy();
});
it('should wire inputs and outputs once, passing the created component reference', () => {
expect(component.setInputsCalls).toEqual([component.dynamicComponentRef!]);
expect(component.subscribeToOutputsCalls).toEqual([component.dynamicComponentRef!]);
});
it('should destroy the dynamic component reference on destroy', () => {
const destroySpy = spyOn(component.dynamicComponentRef!, 'destroy').and.callThrough();
fixture.destroy();
expect(destroySpy).toHaveBeenCalledTimes(1);
});
it('should run the ngOnDestroy hook of the dynamic component on destroy', () => {
const dynamicComponentInstance = component.dynamicComponentRef?.instance;
expect(dynamicComponentInstance?.destroyed).toBeFalse();
fixture.destroy();
expect(dynamicComponentInstance?.destroyed).toBeTrue();
});
it('should clear the dynamic component reference and the signal on destroy', () => {
fixture.destroy();
expect(component.dynamicComponentRef).toBeUndefined();
expect(component.dynamicComponentRefSignalValue).toBeUndefined();
});
it('should destroy the dynamic component reference only once when ngOnDestroy runs again', () => {
const destroySpy = spyOn(component.dynamicComponentRef!, 'destroy');
component.ngOnDestroy();
component.ngOnDestroy();
expect(destroySpy).toHaveBeenCalledTimes(1);
});
});
describe('when no screenId is provided', () => {
let fixture: ComponentFixture<TestHostScreenComponent>;
let component: TestHostScreenComponent;
beforeEach(() => {
fixture = TestBed.createComponent(TestHostScreenComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should not create any dynamic component nor wire inputs and outputs', () => {
expect(component.dynamicComponentRef).toBeUndefined();
expect(component.dynamicComponentRefSignalValue).toBeUndefined();
expect(component.setInputsCalls).toEqual([]);
expect(component.subscribeToOutputsCalls).toEqual([]);
expect(fixture.debugElement.query(By.css('.adf-cloud-test-dynamic-screen'))).toBeNull();
});
it('should not throw on destroy', () => {
expect(() => fixture.destroy()).not.toThrow();
expect(component.dynamicComponentRef).toBeUndefined();
});
});
describe('when the container anchor is missing', () => {
let fixture: ComponentFixture<TestHostScreenWithoutContainerComponent>;
let component: TestHostScreenWithoutContainerComponent;
beforeEach(() => {
fixture = TestBed.createComponent(TestHostScreenWithoutContainerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('screenId', screenId);
});
it('should not throw and should not create any dynamic component', () => {
expect(() => fixture.detectChanges()).not.toThrow();
expect(component.container).toBeUndefined();
expect(component.dynamicComponentRef).toBeUndefined();
expect(component.dynamicComponentRefSignalValue).toBeUndefined();
});
it('should not wire inputs and outputs when no dynamic component was created', () => {
fixture.detectChanges();
expect(component.setInputsCalls).toEqual([]);
expect(component.subscribeToOutputsCalls).toEqual([]);
});
it('should not resolve any component type', () => {
const resolveComponentTypeSpy = spyOn(TestBed.inject(ScreenRenderingService), 'resolveComponentType').and.callThrough();
fixture.detectChanges();
expect(resolveComponentTypeSpy).not.toHaveBeenCalled();
});
it('should not throw on destroy', () => {
fixture.detectChanges();
expect(() => fixture.destroy()).not.toThrow();
});
});
});
@@ -15,20 +15,20 @@
* limitations under the License.
*/
import { Component, ComponentRef, inject, Input, OnInit, signal, ViewChild, ViewContainerRef } from '@angular/core';
import { Component, ComponentRef, inject, Input, OnDestroy, OnInit, signal, ViewChild, ViewContainerRef } from '@angular/core';
import { ScreenRenderingService } from '../../../services/screen-rendering.service';
@Component({
template: ''
})
export abstract class BaseScreenCloudComponent<TScreenComponent = unknown> implements OnInit {
export abstract class BaseScreenCloudComponent<TScreenComponent = unknown> implements OnInit, OnDestroy {
@Input()
screenId: string = '';
@ViewChild('container', { read: ViewContainerRef, static: true })
container: ViewContainerRef;
container: ViewContainerRef | undefined;
protected componentRef: ComponentRef<TScreenComponent>;
protected componentRef: ComponentRef<TScreenComponent> | undefined;
private readonly _componentRefChanged = signal<ComponentRef<TScreenComponent> | undefined>(undefined);
protected readonly componentRefChanged = this._componentRefChanged.asReadonly();
protected readonly screenRenderingService = inject(ScreenRenderingService);
@@ -37,17 +37,27 @@ export abstract class BaseScreenCloudComponent<TScreenComponent = unknown> imple
this.createDynamicComponent();
}
private createDynamicComponent(): void {
if (this.screenId) {
const componentType = this.screenRenderingService.resolveComponentType({ type: this.screenId });
this.componentRef = this.container.createComponent(componentType);
this._componentRefChanged.set(this.componentRef);
this.setInputsForDynamicComponent();
this.subscribeToOutputs();
}
ngOnDestroy(): void {
this.componentRef?.destroy();
this.componentRef = undefined;
this._componentRefChanged.set(undefined);
}
protected setInputsForDynamicComponent(): void {}
private createDynamicComponent(): void {
if (!this.screenId || !this.container) {
return;
}
protected abstract subscribeToOutputs(): void;
const componentType = this.screenRenderingService.resolveComponentType({ type: this.screenId });
const componentRef: ComponentRef<TScreenComponent> = this.container.createComponent(componentType);
this.componentRef = componentRef;
this._componentRefChanged.set(componentRef);
this.setInputsForDynamicComponent(componentRef);
this.subscribeToOutputs(componentRef);
}
protected setInputsForDynamicComponent(_componentRef: ComponentRef<TScreenComponent>): void {}
protected abstract subscribeToOutputs(componentRef: ComponentRef<TScreenComponent>): void;
}
@@ -16,6 +16,7 @@
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, Input, OnDestroy, input, output } from '@angular/core';
import { StartProcessScreenCloudComponent } from './start-process-screen-cloud.component';
import { MockedTaskScreenCloudComponent } from '../../../../testing/start-process-screen-mock.component';
import { provideScreen } from '../../../services/provide-screen';
@@ -66,11 +67,11 @@ describe('StartProcessScreenCloudComponent', () => {
it('should set appName', () => {
const screenInstance: StartProcessScreenCloud = fixture.debugElement.query(By.directive(MockedTaskScreenCloudComponent)).componentInstance;
expect(screenInstance.appName()).toEqual('');
expect(screenInstance.appName?.()).toEqual('');
const newValue = 'new-app-name';
fixture.componentRef.setInput('appName', newValue);
fixture.detectChanges();
expect(screenInstance.appName()).toEqual(newValue);
expect(screenInstance.appName?.()).toEqual(newValue);
});
it('should set process definition id', () => {
@@ -84,10 +85,125 @@ describe('StartProcessScreenCloudComponent', () => {
it('should set resolvedValues', () => {
const screenInstance: StartProcessScreenCloud = fixture.debugElement.query(By.directive(MockedTaskScreenCloudComponent)).componentInstance;
expect(screenInstance.resolvedValues()).toBeUndefined();
expect(screenInstance.resolvedValues?.()).toBeUndefined();
const newValues = [new TaskVariableCloud({ id: 'new-id', name: 'new-name' })];
fixture.componentRef.setInput('resolvedValues', newValues);
fixture.detectChanges();
expect(screenInstance.resolvedValues()).toEqual(newValues);
expect(screenInstance.resolvedValues?.()).toEqual(newValues);
});
});
@Component({
selector: 'adf-cloud-destroy-tracking-screen',
template: `<div class="adf-cloud-destroy-tracking-screen">screen</div>`
})
class DestroyTrackingScreenComponent implements StartProcessScreenCloud, OnDestroy {
readonly appName = input('');
processDefinitionId = input('');
readonly resolvedValues = input<TaskVariableCloud[] | undefined>();
defaultStartProcessButtonsConfigurationChange = output<StartProcessScreenDefaultButtons>();
startProcessPayloadChanged = output<unknown>();
destroyed = false;
ngOnDestroy(): void {
this.destroyed = true;
}
}
@Component({
selector: 'adf-cloud-test-start-process-wrapper',
template: `
@if (showScreen) {
<adf-cloud-start-process-screen-cloud [screenId]="screenId" [processDefinitionId]="'definition-id'" />
}
`,
imports: [StartProcessScreenCloudComponent]
})
class TestStartProcessWrapperComponent {
@Input() screenId = '';
showScreen = true;
}
describe('StartProcessScreenCloudComponent - destroy', () => {
let fixture: ComponentFixture<TestStartProcessWrapperComponent>;
let component: TestStartProcessWrapperComponent;
const screenId = 'screen-1234-5678-121212-123456';
const getScreenInstance = (): DestroyTrackingScreenComponent =>
fixture.debugElement.query(By.directive(DestroyTrackingScreenComponent)).componentInstance;
const destroyScreen = () => {
component.showScreen = false;
fixture.detectChanges();
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TestStartProcessWrapperComponent],
providers: [provideScreen(screenId, DestroyTrackingScreenComponent)]
});
fixture = TestBed.createComponent(TestStartProcessWrapperComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('screenId', screenId);
fixture.detectChanges();
});
it('should destroy the screen component when the host is destroyed', () => {
const screenInstance = getScreenInstance();
expect(screenInstance.destroyed).toBeFalse();
destroyScreen();
expect(screenInstance.destroyed).toBeTrue();
});
it('should remove the screen component from the DOM when the host is destroyed', () => {
expect(fixture.debugElement.query(By.css('.adf-cloud-destroy-tracking-screen'))).toBeTruthy();
destroyScreen();
expect(fixture.debugElement.query(By.css('.adf-cloud-destroy-tracking-screen'))).toBeNull();
});
it('should create a new screen component instance when the host is re-created', () => {
const firstInstance = getScreenInstance();
destroyScreen();
component.showScreen = true;
fixture.detectChanges();
const secondInstance = getScreenInstance();
expect(secondInstance).not.toBe(firstInstance);
expect(secondInstance.destroyed).toBeFalse();
expect(secondInstance.processDefinitionId()).toBe('definition-id');
});
});
describe('StartProcessScreenCloudComponent - without screenId', () => {
let fixture: ComponentFixture<StartProcessScreenCloudComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [StartProcessScreenCloudComponent]
});
fixture = TestBed.createComponent(StartProcessScreenCloudComponent);
});
it('should not create any screen component and should not throw', () => {
expect(() => fixture.detectChanges()).not.toThrow();
expect(fixture.debugElement.query(By.directive(DestroyTrackingScreenComponent))).toBeNull();
});
it('should not throw when inputs change or on destroy', () => {
fixture.detectChanges();
expect(() => {
fixture.componentRef.setInput('appName', 'new-app-name');
fixture.componentRef.setInput('resolvedValues', [new TaskVariableCloud({ id: 'id', name: 'name' })]);
fixture.detectChanges();
}).not.toThrow();
expect(() => fixture.destroy()).not.toThrow();
});
});
@@ -15,7 +15,7 @@
* limitations under the License.
*/
import { ChangeDetectionStrategy, Component, effect, input, output, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, ComponentRef, effect, input, output, signal } from '@angular/core';
import { BaseScreenCloudComponent } from '../base-screen/base-screen-cloud.component';
import { MatCardModule } from '@angular/material/card';
import { CommonModule } from '@angular/common';
@@ -43,7 +43,7 @@ export class StartProcessScreenCloudComponent extends BaseScreenCloudComponent<S
super();
effect(() => {
const componentRef = this.componentRefChanged();
if (componentRef.instance && 'appName' in componentRef.instance) {
if (componentRef?.instance && 'appName' in componentRef.instance) {
componentRef.setInput('appName', this.appName());
}
});
@@ -56,9 +56,9 @@ export class StartProcessScreenCloudComponent extends BaseScreenCloudComponent<S
});
}
protected subscribeToOutputs(): void {
this.componentRef.instance.startProcessPayloadChanged.subscribe((payload) => this.screenStartProcessPayloadChange.emit(payload));
this.componentRef.instance.defaultStartProcessButtonsConfigurationChange.subscribe((config) => {
protected subscribeToOutputs(componentRef: ComponentRef<StartProcessScreenCloud>): void {
componentRef.instance.startProcessPayloadChanged.subscribe((payload) => this.screenStartProcessPayloadChange.emit(payload));
componentRef.instance.defaultStartProcessButtonsConfigurationChange.subscribe((config) => {
this.showStartProcessButtons.set(config.show);
this.disableStartProcessButton.emit(config.disable);
});
@@ -16,7 +16,7 @@
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { Component, EventEmitter, Input, OnDestroy, Output, ViewChild } from '@angular/core';
import { By } from '@angular/platform-browser';
import { ScreenRenderingService } from '../../../services/screen-rendering.service';
import { TaskScreenCloudComponent } from './screen-cloud.component';
@@ -32,18 +32,22 @@ import { TaskScreenCloudComponent } from './screen-cloud.component';
</div>
`
})
class TestComponent {
class TestComponent implements OnDestroy {
@Input() taskId = '';
@Input() screenId = '';
@Input() rootProcessInstanceId = '';
@Output() taskCompleted = new EventEmitter();
displayMode: string;
displayMode: string | undefined;
destroyed = false;
onComplete() {
this.taskCompleted.emit();
}
switchToDisplayMode(newDisplayMode?: string) {
this.displayMode = newDisplayMode;
}
ngOnDestroy(): void {
this.destroyed = true;
}
}
@Component({
@@ -61,7 +65,7 @@ class TestComponent {
})
class TestWrapperComponent {
@Input() screenId = '';
@ViewChild('adfCloudTaskScreen') adfCloudTaskScreen: TaskScreenCloudComponent;
@ViewChild('adfCloudTaskScreen') adfCloudTaskScreen: TaskScreenCloudComponent | undefined;
onTaskCompleted() {}
switchToDisplayMode(newDisplayMode?: string): void {
if (this.adfCloudTaskScreen) {
@@ -118,6 +122,118 @@ describe('TaskScreenCloudComponent', () => {
component.switchToDisplayMode();
fixture.detectChanges();
expect(component.adfCloudTaskScreen.switchToDisplayMode).toHaveBeenCalled();
expect(component.adfCloudTaskScreen?.switchToDisplayMode).toHaveBeenCalled();
});
});
@Component({
selector: 'adf-cloud-test-conditional-component',
template: `
@if (showTaskScreen) {
<adf-cloud-task-screen [taskId]="'1'" [appName]="'app-name-test'" [screenId]="'test'" (taskCompleted)="onTaskCompleted()" />
}
`,
imports: [TaskScreenCloudComponent]
})
class TestConditionalWrapperComponent {
showTaskScreen = true;
onTaskCompleted() {}
}
describe('TaskScreenCloudComponent - destroy', () => {
let fixture: ComponentFixture<TestConditionalWrapperComponent>;
let component: TestConditionalWrapperComponent;
const getDynamicComponentInstance = (): TestComponent => fixture.debugElement.query(By.directive(TestComponent)).componentInstance;
const getTaskScreen = (): TaskScreenCloudComponent => fixture.debugElement.query(By.directive(TaskScreenCloudComponent)).componentInstance;
const destroyTaskScreen = () => {
component.showTaskScreen = false;
fixture.detectChanges();
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TaskScreenCloudComponent, TestComponent, TestConditionalWrapperComponent]
});
TestBed.inject(ScreenRenderingService).register({ ['test']: () => TestComponent });
fixture = TestBed.createComponent(TestConditionalWrapperComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should destroy the dynamic component when the task screen is destroyed', () => {
const dynamicComponentInstance = getDynamicComponentInstance();
expect(dynamicComponentInstance.destroyed).toBeFalse();
destroyTaskScreen();
expect(dynamicComponentInstance.destroyed).toBeTrue();
});
it('should remove the dynamic component from the DOM when the task screen is destroyed', () => {
expect(fixture.debugElement.query(By.css('.adf-cloud-test-container'))).toBeTruthy();
destroyTaskScreen();
expect(fixture.debugElement.query(By.css('.adf-cloud-test-container'))).toBeNull();
});
it('should not emit outputs of the dynamic component after the task screen is destroyed', () => {
const onTaskCompletedSpy = spyOn(component, 'onTaskCompleted');
const dynamicComponentInstance = getDynamicComponentInstance();
destroyTaskScreen();
dynamicComponentInstance.taskCompleted.emit();
expect(onTaskCompletedSpy).not.toHaveBeenCalled();
});
it('should not call the dynamic component when switching display mode after destroy', () => {
const taskScreen = getTaskScreen();
const switchToDisplayModeSpy = spyOn(getDynamicComponentInstance(), 'switchToDisplayMode');
destroyTaskScreen();
expect(() => taskScreen.switchToDisplayMode('mode')).not.toThrow();
expect(switchToDisplayModeSpy).not.toHaveBeenCalled();
});
it('should create a new dynamic component instance when the task screen is re-created', () => {
const firstInstance = getDynamicComponentInstance();
destroyTaskScreen();
component.showTaskScreen = true;
fixture.detectChanges();
const secondInstance = getDynamicComponentInstance();
expect(secondInstance).not.toBe(firstInstance);
expect(secondInstance.destroyed).toBeFalse();
expect(secondInstance.taskId).toBe('1');
});
});
describe('TaskScreenCloudComponent - without screenId', () => {
let fixture: ComponentFixture<TaskScreenCloudComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TaskScreenCloudComponent]
});
fixture = TestBed.createComponent(TaskScreenCloudComponent);
});
it('should not create any dynamic component and should not throw', () => {
expect(() => fixture.detectChanges()).not.toThrow();
expect(fixture.debugElement.query(By.directive(TestComponent))).toBeNull();
});
it('should not throw when switching display mode or destroying', () => {
fixture.detectChanges();
expect(() => fixture.componentInstance.switchToDisplayMode('mode')).not.toThrow();
expect(() => fixture.destroy()).not.toThrow();
});
});
@@ -16,7 +16,7 @@
*/
import { CommonModule } from '@angular/common';
import { Component, DestroyRef, EventEmitter, inject, Input, Output } from '@angular/core';
import { Component, ComponentRef, DestroyRef, EventEmitter, inject, Input, Output } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { UserTaskCustomUi } from './screen-cloud.model';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -102,65 +102,65 @@ export class TaskScreenCloudComponent extends BaseScreenCloudComponent<UserTaskC
private readonly destroyRef = inject(DestroyRef);
protected override setInputsForDynamicComponent(): void {
if (this.taskId && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'taskId')) {
this.componentRef.setInput('taskId', this.taskId);
protected override setInputsForDynamicComponent(componentRef: ComponentRef<UserTaskCustomUi>): void {
if (this.taskId && Object.prototype.hasOwnProperty.call(componentRef.instance, 'taskId')) {
componentRef.setInput('taskId', this.taskId);
}
if (this.appName && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'appName')) {
this.componentRef.setInput('appName', this.appName);
if (this.appName && Object.prototype.hasOwnProperty.call(componentRef.instance, 'appName')) {
componentRef.setInput('appName', this.appName);
}
if (this.screenId && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'screenId')) {
this.componentRef.setInput('screenId', this.screenId);
if (this.screenId && Object.prototype.hasOwnProperty.call(componentRef.instance, 'screenId')) {
componentRef.setInput('screenId', this.screenId);
}
if (this.processInstanceId && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'processInstanceId')) {
this.componentRef.setInput('processInstanceId', this.processInstanceId);
if (this.processInstanceId && Object.prototype.hasOwnProperty.call(componentRef.instance, 'processInstanceId')) {
componentRef.setInput('processInstanceId', this.processInstanceId);
}
if (this.taskName && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'taskName')) {
this.componentRef.setInput('taskName', this.taskName);
if (this.taskName && Object.prototype.hasOwnProperty.call(componentRef.instance, 'taskName')) {
componentRef.setInput('taskName', this.taskName);
}
if (this.canClaimTask && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'canClaimTask')) {
this.componentRef.setInput('canClaimTask', this.canClaimTask);
if (this.canClaimTask && Object.prototype.hasOwnProperty.call(componentRef.instance, 'canClaimTask')) {
componentRef.setInput('canClaimTask', this.canClaimTask);
}
if (this.canUnclaimTask && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'canUnclaimTask')) {
this.componentRef.setInput('canUnclaimTask', this.canUnclaimTask);
if (this.canUnclaimTask && Object.prototype.hasOwnProperty.call(componentRef.instance, 'canUnclaimTask')) {
componentRef.setInput('canUnclaimTask', this.canUnclaimTask);
}
if (this.showCancelButton && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'showCancelButton')) {
this.componentRef.setInput('showCancelButton', this.showCancelButton);
if (this.showCancelButton && Object.prototype.hasOwnProperty.call(componentRef.instance, 'showCancelButton')) {
componentRef.setInput('showCancelButton', this.showCancelButton);
}
if (this.rootProcessInstanceId && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'rootProcessInstanceId')) {
this.componentRef.setInput('rootProcessInstanceId', this.rootProcessInstanceId);
if (this.rootProcessInstanceId && Object.prototype.hasOwnProperty.call(componentRef.instance, 'rootProcessInstanceId')) {
componentRef.setInput('rootProcessInstanceId', this.rootProcessInstanceId);
}
if (this.showNextTaskCheckbox && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'showNextTaskCheckbox')) {
this.componentRef.setInput('showNextTaskCheckbox', this.showNextTaskCheckbox);
if (this.showNextTaskCheckbox && Object.prototype.hasOwnProperty.call(componentRef.instance, 'showNextTaskCheckbox')) {
componentRef.setInput('showNextTaskCheckbox', this.showNextTaskCheckbox);
}
if (this.isNextTaskCheckboxChecked && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'isNextTaskCheckboxChecked')) {
this.componentRef.setInput('isNextTaskCheckboxChecked', this.isNextTaskCheckboxChecked);
if (this.isNextTaskCheckboxChecked && Object.prototype.hasOwnProperty.call(componentRef.instance, 'isNextTaskCheckboxChecked')) {
componentRef.setInput('isNextTaskCheckboxChecked', this.isNextTaskCheckboxChecked);
}
}
protected override subscribeToOutputs(): void {
if (this.componentRef.instance?.taskSaved) {
this.componentRef.instance.taskSaved.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.taskSaved.emit());
protected override subscribeToOutputs(componentRef: ComponentRef<UserTaskCustomUi>): void {
if (componentRef.instance?.taskSaved) {
componentRef.instance.taskSaved.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.taskSaved.emit());
}
if (this.componentRef.instance?.taskCompleted) {
this.componentRef.instance.taskCompleted
if (componentRef.instance?.taskCompleted) {
componentRef.instance.taskCompleted
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((openNextTask) => this.taskCompleted.emit(openNextTask));
}
if (this.componentRef.instance?.error) {
this.componentRef.instance.error.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => this.error.emit(data));
if (componentRef.instance?.error) {
componentRef.instance.error.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => this.error.emit(data));
}
if (this.componentRef.instance?.claimTask) {
this.componentRef.instance.claimTask.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => this.claimTask.emit(data));
if (componentRef.instance?.claimTask) {
componentRef.instance.claimTask.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => this.claimTask.emit(data));
}
if (this.componentRef.instance?.unclaimTask) {
this.componentRef.instance.unclaimTask.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => this.unclaimTask.emit(data));
if (componentRef.instance?.unclaimTask) {
componentRef.instance.unclaimTask.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => this.unclaimTask.emit(data));
}
if (this.componentRef.instance?.cancelTask) {
this.componentRef.instance.cancelTask.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => this.cancelTask.emit(data));
if (componentRef.instance?.cancelTask) {
componentRef.instance.cancelTask.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => this.cancelTask.emit(data));
}
if (this.componentRef.instance?.nextTaskCheckboxCheckedChanged) {
this.componentRef.instance.nextTaskCheckboxCheckedChanged
if (componentRef.instance?.nextTaskCheckboxCheckedChanged) {
componentRef.instance.nextTaskCheckboxCheckedChanged
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => this.nextTaskCheckboxCheckedChanged.emit(data));
}
+1 -1
View File
@@ -43,7 +43,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/process-services'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
+2 -1
View File
@@ -10,7 +10,8 @@
"cache": true
},
"test": {
"cache": true
"cache": true,
"outputs": ["{workspaceRoot}/coverage/{projectName}"]
},
"stylelint": {
"cache": true
+5 -4
View File
@@ -9,6 +9,7 @@ overrides:
js-yaml@4.2.0: 4.3.1
brace-expansion@5.0.8: 5.0.9
brace-expansion@<1.1.18: 1.1.18
socket.io-parser@4.2.6: 4.2.7
svgo: 4.0.2
shell-quote: 1.9.0
adm-zip: 0.6.0
@@ -8462,8 +8463,8 @@ packages:
socket.io-adapter@2.5.8:
resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==}
socket.io-parser@4.2.6:
resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==}
socket.io-parser@4.2.7:
resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==}
engines: {node: '>=10.0.0'}
socket.io@4.8.3:
@@ -18870,7 +18871,7 @@ snapshots:
- supports-color
- utf-8-validate
socket.io-parser@4.2.6(supports-color@7.2.0):
socket.io-parser@4.2.7(supports-color@7.2.0):
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3(supports-color@7.2.0)
@@ -18885,7 +18886,7 @@ snapshots:
debug: 4.4.3(supports-color@7.2.0)
engine.io: 6.6.9(supports-color@7.2.0)
socket.io-adapter: 2.5.8(supports-color@7.2.0)
socket.io-parser: 4.2.6(supports-color@7.2.0)
socket.io-parser: 4.2.7(supports-color@7.2.0)
transitivePeerDependencies:
- bufferutil
- supports-color
+1
View File
@@ -11,6 +11,7 @@ overrides:
"js-yaml@4.2.0": "4.3.1"
"brace-expansion@5.0.8": "5.0.9"
"brace-expansion@<1.1.18": "1.1.18"
"socket.io-parser@4.2.6": 4.2.7
svgo: 4.0.2
shell-quote: 1.9.0
adm-zip: 0.6.0
+12
View File
@@ -0,0 +1,12 @@
sonar.organization=alfresco
sonar.projectKey=Alfresco_alfresco-ng2-components
sonar.sources=lib
sonar.tests=lib
sonar.inclusions=lib/**/src/**
sonar.test.inclusions=**/*.spec.ts
sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.ts,**/*.mock.ts,**/mock/**,**/mocks/**,**/testing/**,**/stories/**
sonar.javascript.lcov.reportPaths=coverage/core/lcov.info,coverage/content-services/lcov.info,coverage/extensions/lcov.info,coverage/insights/lcov.info,coverage/process-services/lcov.info,coverage/process-services-cloud/lcov.info
sonar.sourceEncoding=UTF-8