diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 39862c83e..0acc0c81f 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -28,8 +28,14 @@ jobs: run: npm run build-angular-sdk-components - name: Public API report is up to date run: npm run api:check + - name: Component catalogue is up to date + run: npm run docs:components:check + - name: noImplicitAny ratchet + run: npm run check:any - name: Build overrides package run: npm run build-overrides + - name: Overrides package type-checks against the built library + run: npm run check:overrides - name: Verify package tarballs run: npm run smoke:pack diff --git a/AGENTS.md b/AGENTS.md index 71870aaf5..95ace5258 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,12 @@ angular-sdk-components/ | `npm run fix` | ESLint + Prettier auto-fix | | `npm run build-overrides` | Generate override templates package | | `npm run create_and_install_sdk_packages` | Build, pack, and install into angular-sdk repo | +| `npm run test:unit` | Headless Karma/Jasmine unit tests (no Pega server) — see docs/testing.md | +| `npm run new:component -- ` | Scaffold a component and register it in `public-api.ts` + component map | +| `npm run check:any` / `check:any:update` | `noImplicitAny` per-file ratchet (do not add new implicit-any errors) | +| `npm run api:check` / `api:update` | Public API report guard (`etc/angular-sdk-components.api.md`) | +| `npm run check:overrides` | Type-check the generated overrides package against the built library | +| `npm run docs:components` | Regenerate `docs/components.md` from the component map | ### Prerequisites diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index c75b70bc4..ccf293cda 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -32,7 +32,9 @@ Be sure to reference the original issue in the pull request. ## Run Tests -Since the Angular SDK requires interaction with a running Pega Infinity™ server, there is not an automated set of tests provided at this time. +Unit tests (`npm run test:unit`) and static checks run without a Pega server; see [testing.md](testing.md). To add a component use `npm run new:component` (it also registers the component in `public-api.ts` and the component map). + +End-to-end verification still requires interaction with a running Pega Infinity™ server. We suggest you begin by using the **MediaCo** application provided in the [Angular SDK download](https://community.pega.com/marketplace/components/angular-sdk). Test your change by using the MediaCo application and completing a complete case flow using the **New Service** casetype provided in that sample application. diff --git a/docs/adr/0002-phase-b-c-outcomes.md b/docs/adr/0002-phase-b-c-outcomes.md new file mode 100644 index 000000000..c6ce68529 --- /dev/null +++ b/docs/adr/0002-phase-b-c-outcomes.md @@ -0,0 +1,26 @@ +# ADR 0002: Phase B and C outcomes + +Status: Accepted + +## Delivered +**Phase B (additive)** +- `noImplicitAny` per-file ratchet (`npm run check:any`); `_bridge` is clean. Strict null checks were already enabled through `strict: true`. +- `FieldBase` generic (non-breaking default) and a `markForCheck()` hook. +- Bridge characterization tests (`AngularPConnectService`, component map) so the bridge can be refactored safely. +- axe-core accessibility checks for 12 field components. +- `npm run new:component` scaffolds and registers components; `npm run check:overrides` type-checks the generated overrides package. This found and fixed a real defect: `build-overrides` did not rewrite `import type` paths, so the shipped `field.base.ts` in `@pega/angular-sdk-overrides` pointed at a non-existent relative module. +- Generated component catalogue (`docs/components.md`), testing and theming guides. + +**Phase C (applied where it is verifiable without a Pega server)** +- `OnPush` for 17 synchronous field components and 6 presentational design-system components. The bridge calls `markForCheck()` after store callbacks so store-driven updates re-render. +- Material 3 token theming is documented (`docs/theming.md`). + +## Deferred, with reasons +- **Signal inputs (`input()`/`model()`)**: the `ComponentMapper` already uses `setInput`, so it is compatible, but every `$`-suffixed property is also an override contract (consumers copy and subclass components). Converting ~99 components is a breaking change for them and needs a major release plus a codemod and migration guide. +- **OnPush for the remaining components** (templates, infra, widgets, async fields such as AutoComplete/Dropdown/Location): these mutate state in promise/subscription callbacks and in the containers' rendering pipeline. Zoneless + OnPush there needs each mutation site to call `markForCheck()` or move to signals, and must be validated end-to-end against Infinity (Playwright MediaCo), which was not available here. +- **Secondary entry points** (`/fields`, `/templates`, `/widgets`): components import each other and the bridge by relative path; ng-packagr entry points require package-name imports across entries and would change public import paths. Needs its own design and a major release. +- **`@defer` for heavy widgets** (rich text/maps): changes loading behavior; validate with E2E first. +- **Bridge split**: the characterization tests are in place; splitting `angular-pconnect.ts` into registry/subscription/diff services is the next step. + +## Verification required before release +The OnPush changes are covered by unit tests but **must be exercised end-to-end** (MediaCo portal + embedded) before merging, since runtime store-driven rendering cannot be fully reproduced with mocks. diff --git a/docs/components.md b/docs/components.md new file mode 100644 index 000000000..4b02c0ea4 --- /dev/null +++ b/docs/components.md @@ -0,0 +1,148 @@ +# Component catalogue + + + +117 Pega component names are mapped to Angular implementations. Override any of them through `sdk-local-component-map.ts` (see the overrides package). + +## designSystemExtension + +| Pega component | Angular class | Source | +| --- | --- | --- | +| `Alert` | `AlertComponent` | `_components/designSystemExtension/alert/alert.component.ts` | +| `AlertBanner` | `AlertBannerComponent` | `_components/designSystemExtension/alert-banner/alert-banner.component.ts` | +| `Banner` | `BannerComponent` | `_components/designSystemExtension/banner/banner.component.ts` | +| `CaseCreateStage` | `CaseCreateStageComponent` | `_components/designSystemExtension/case-create-stage/case-create-stage.component.ts` | +| `CaseSummaryFields` | `MaterialCaseSummaryComponent` | `_components/designSystemExtension/material-case-summary/material-case-summary.component.ts` | +| `FieldGroup` | `FieldGroupComponent` | `_components/designSystemExtension/field-group/field-group.component.ts` | +| `MaterialUtility` | `MaterialUtilityComponent` | `_components/designSystemExtension/material-utility/material-utility.component.ts` | +| `Operator` | `OperatorComponent` | `_components/designSystemExtension/operator/operator.component.ts` | +| `Pulse` | `PulseComponent` | `_components/designSystemExtension/pulse/pulse.component.ts` | +| `RichTextEditor` | `RichTextEditorComponent` | `_components/designSystemExtension/rich-text-editor/rich-text-editor.component.ts` | +| `SummaryItem` | `MaterialSummaryItemComponent` | `_components/designSystemExtension/material-summary-item/material-summary-item.component.ts` | +| `SummaryList` | `MaterialSummaryListComponent` | `_components/designSystemExtension/material-summary-list/material-summary-list.component.ts` | +| `VerticalTabs` | `MaterialVerticalTabsComponent` | `_components/designSystemExtension/material-vertical-tabs/material-vertical-tabs.component.ts` | +| `WssQuickCreate` | `WssQuickCreateComponent` | `_components/designSystemExtension/wss-quick-create/wss-quick-create.component.ts` | + +## field + +| Pega component | Angular class | Source | +| --- | --- | --- | +| `AutoComplete` | `AutoCompleteComponent` | `_components/field/auto-complete/auto-complete.component.ts` | +| `CancelAlert` | `CancelAlertComponent` | `_components/field/cancel-alert/cancel-alert.component.ts` | +| `Checkbox` | `CheckBoxComponent` | `_components/field/check-box/check-box.component.ts` | +| `Currency` | `CurrencyComponent` | `_components/field/currency/currency.component.ts` | +| `Date` | `DateComponent` | `_components/field/date/date.component.ts` | +| `DateTime` | `DateTimeComponent` | `_components/field/date-time/date-time.component.ts` | +| `Decimal` | `DecimalComponent` | `_components/field/decimal/decimal.component.ts` | +| `Dropdown` | `DropdownComponent` | `_components/field/dropdown/dropdown.component.ts` | +| `Email` | `EmailComponent` | `_components/field/email/email.component.ts` | +| `EmbeddedDataMulti` | `EmbeddedDataMultiComponent` | `_components/field/embedded-data-multi/embedded-data-multi.component.ts` | +| `Group` | `GroupComponent` | `_components/field/group/group.component.ts` | +| `Integer` | `IntegerComponent` | `_components/field/integer/integer.component.ts` | +| `ListViewActionButtons` | `ListViewActionButtonsComponent` | `_components/field/list-view-action-buttons/list-view-action-buttons.component.ts` | +| `Location` | `LocationComponent` | `_components/field/location/location.component.ts` | +| `Multiselect` | `MultiselectComponent` | `_components/field/multiselect/multiselect.component.ts` | +| `ObjectReference` | `ObjectReferenceComponent` | `_components/field/object-reference/object-reference.component.ts` | +| `Percentage` | `PercentageComponent` | `_components/field/percentage/percentage.component.ts` | +| `Phone` | `PhoneComponent` | `_components/field/phone/phone.component.ts` | +| `RadioButtons` | `RadioButtonsComponent` | `_components/field/radio-buttons/radio-buttons.component.ts` | +| `RichText` | `RichTextComponent` | `_components/field/rich-text/rich-text.component.ts` | +| `ScalarList` | `ScalarListComponent` | `_components/field/scalar-list/scalar-list.component.ts` | +| `SelectableCard` | `SelectableCardComponent` | `_components/field/selectable-card/selectable-card.component.ts` | +| `SemanticLink` | `SemanticLinkComponent` | `_components/field/semantic-link/semantic-link.component.ts` | +| `Text` | `TextComponent` | `_components/field/text/text.component.ts` | +| `TextArea` | `TextAreaComponent` | `_components/field/text-area/text-area.component.ts` | +| `TextContent` | `TextContentComponent` | `_components/field/text-content/text-content.component.ts` | +| `TextInput` | `TextInputComponent` | `_components/field/text-input/text-input.component.ts` | +| `Time` | `TimeComponent` | `_components/field/time/time.component.ts` | +| `URL` | `UrlComponent` | `_components/field/url/url.component.ts` | +| `UserReference` | `UserReferenceComponent` | `_components/field/user-reference/user-reference.component.ts` | + +## infra + +| Pega component | Angular class | Source | +| --- | --- | --- | +| `ActionButtons` | `ActionButtonsComponent` | `_components/infra/action-buttons/action-buttons.component.ts` | +| `Assignment` | `AssignmentComponent` | `_components/infra/assignment/assignment.component.ts` | +| `AssignmentCard` | `AssignmentCardComponent` | `_components/infra/assignment-card/assignment-card.component.ts` | +| `DashboardFilter` | `DashboardFilterComponent` | `_components/infra/dashboard-filter/dashboard-filter.component.ts` | +| `DataViewActionButtons` | `DataViewActionButtonsComponent` | `_components/infra/Containers/modal-view-container/data-view-action-buttons/data-view-action-buttons.component.ts` | +| `DeferLoad` | `DeferLoadComponent` | `_components/infra/defer-load/defer-load.component.ts` | +| `ErrorBoundary` | `ErrorBoundaryComponent` | `_components/infra/error-boundary/error-boundary.component.ts` | +| `FlowContainer` | `FlowContainerComponent` | `_components/infra/Containers/flow-container/flow-container.component.ts` | +| `ModalViewContainer` | `ModalViewContainerComponent` | `_components/infra/Containers/modal-view-container/modal-view-container.component.ts` | +| `MultiStep` | `MultiStepComponent` | `_components/infra/multi-step/multi-step.component.ts` | +| `NavBar` | `NavbarComponent` | `_components/infra/navbar/navbar.component.ts` | +| `reference` | `ReferenceComponent` | `_components/infra/reference/reference.component.ts` | +| `Region` | `RegionComponent` | `_components/infra/region/region.component.ts` | +| `RootContainer` | `RootContainerComponent` | `_components/infra/root-container/root-container.component.ts` | +| `Stages` | `StagesComponent` | `_components/infra/stages/stages.component.ts` | +| `View` | `ViewComponent` | `_components/infra/view/view.component.ts` | +| `ViewContainer` | `ViewContainerComponent` | `_components/infra/Containers/view-container/view-container.component.ts` | + +## template + +| Pega component | Angular class | Source | +| --- | --- | --- | +| `AdvancedSearch` | `AdvancedSearchComponent` | `_components/template/advanced-search/advanced-search.component.ts` | +| `AppShell` | `AppShellComponent` | `_components/template/app-shell/app-shell.component.ts` | +| `BannerPage` | `BannerPageComponent` | `_components/template/banner-page/banner-page.component.ts` | +| `CaseSummary` | `CaseSummaryComponent` | `_components/template/case-summary/case-summary.component.ts` | +| `CaseView` | `CaseViewComponent` | `_components/template/case-view/case-view.component.ts` | +| `Confirmation` | `ConfirmationComponent` | `_components/template/confirmation/confirmation.component.ts` | +| `DataReference` | `DataReferenceComponent` | `_components/template/data-reference/data-reference.component.ts` | +| `DefaultForm` | `DefaultFormComponent` | `_components/template/default-form/default-form.component.ts` | +| `DefaultPage` | `DefaultPageComponent` | `_components/template/default-page/default-page.component.ts` | +| `Details` | `DetailsComponent` | `_components/template/details/details.component.ts` | +| `DetailsOneColumn` | `DetailsOneColumnComponent` | `_components/template/details-one-column/details-one-column.component.ts` | +| `DetailsSubTabs` | `DetailsSubTabsComponent` | `_components/template/details-sub-tabs/details-sub-tabs.component.ts` | +| `DetailsThreeColumn` | `DetailsThreeColumnComponent` | `_components/template/details-three-column/details-three-column.component.ts` | +| `DetailsTwoColumn` | `DetailsTwoColumnComponent` | `_components/template/details-two-column/details-two-column.component.ts` | +| `DynamicTabs` | `DynamicTabsComponent` | `_components/template/dynamic-tabs/dynamic-tabs.component.ts` | +| `FieldGroupList` | `FieldGroupListComponent` | `_components/template/field-group-list/field-group-list.component.ts` | +| `FieldGroupTemplate` | `FieldGroupTemplateComponent` | `_components/template/field-group-template/field-group-template.component.ts` | +| `FieldValueList` | `FieldValueListComponent` | `_components/template/field-value-list/field-value-list.component.ts` | +| `InlineDashboard` | `InlineDashboardComponent` | `_components/template/inline-dashboard/inline-dashboard.component.ts` | +| `InlineDashboardPage` | `InlineDashboardPageComponent` | `_components/template/inline-dashboard-page/inline-dashboard-page.component.ts` | +| `ListPage` | `ListPageComponent` | `_components/template/list-page/list-page.component.ts` | +| `ListView` | `ListViewComponent` | `_components/template/list-view/list-view.component.ts` | +| `MultiReferenceReadOnly` | `MultiReferenceReadonlyComponent` | `_components/template/multi-reference-readonly/multi-reference-readonly.component.ts` | +| `NarrowWideDetails` | `DetailsNarrowWideComponent` | `_components/template/details-narrow-wide/details-narrow-wide.component.ts` | +| `NarrowWideForm` | `NarrowWideFormComponent` | `_components/template/narrow-wide-form/narrow-wide-form.component.ts` | +| `ObjectPage` | `ObjectPageComponent` | `_components/template/object-page/object-page.component.ts` | +| `OneColumn` | `OneColumnComponent` | `_components/template/one-column/one-column.component.ts` | +| `OneColumnPage` | `OneColumnPageComponent` | `_components/template/one-column-page/one-column-page.component.ts` | +| `OneColumnTab` | `OneColumnTabComponent` | `_components/template/one-column-tab/one-column-tab.component.ts` | +| `Page` | `PageComponent` | `_components/template/page/page.component.ts` | +| `PromotedFilters` | `PromotedFiltersComponent` | `_components/template/promoted-filters/promoted-filters.component.ts` | +| `SearchForm` | `SearchFormComponent` | `_components/template/data-reference/search-form/search-form.component.ts` | +| `SearchGroups` | `SearchGroupsComponent` | `_components/template/advanced-search/search-groups/search-groups.component.ts` | +| `SelfServiceCaseView` | `SelfServiceCaseViewComponent` | `_components/template/self-service-case-view/self-service-case-view.component.ts` | +| `SimpleTable` | `SimpleTableComponent` | `_components/template/simple-table/simple-table.component.ts` | +| `SimpleTableManual` | `SimpleTableManualComponent` | `_components/template/simple-table-manual/simple-table-manual.component.ts` | +| `SimpleTableSelect` | `SimpleTableSelectComponent` | `_components/template/simple-table-select/simple-table-select.component.ts` | +| `SingleReferenceReadOnly` | `SingleReferenceReadonlyComponent` | `_components/template/single-reference-readonly/single-reference-readonly.component.ts` | +| `SubTabs` | `SubTabsComponent` | `_components/template/sub-tabs/sub-tabs.component.ts` | +| `ThreeColumn` | `ThreeColumnComponent` | `_components/template/three-column/three-column.component.ts` | +| `ThreeColumnPage` | `ThreeColumnPageComponent` | `_components/template/three-column-page/three-column-page.component.ts` | +| `TwoColumn` | `TwoColumnComponent` | `_components/template/two-column/two-column.component.ts` | +| `TwoColumnPage` | `TwoColumnPageComponent` | `_components/template/two-column-page/two-column-page.component.ts` | +| `TwoColumnTab` | `TwoColumnTabComponent` | `_components/template/two-column-tab/two-column-tab.component.ts` | +| `WideNarrowDetails` | `DetailsWideNarrowComponent` | `_components/template/details-wide-narrow/details-wide-narrow.component.ts` | +| `WideNarrowForm` | `WideNarrowFormComponent` | `_components/template/wide-narrow-form/wide-narrow-form.component.ts` | +| `WideNarrowPage` | `WideNarrowPageComponent` | `_components/template/wide-narrow-page/wide-narrow-page.component.ts` | +| `WssNavBar` | `WssNavBarComponent` | `_components/template/wss-nav-bar/wss-nav-bar.component.ts` | + +## widget + +| Pega component | Angular class | Source | +| --- | --- | --- | +| `AppAnnouncement` | `AppAnnouncementComponent` | `_components/widget/app-announcement/app-announcement.component.ts` | +| `Attachment` | `AttachmentComponent` | `_components/widget/attachment/attachment.component.ts` | +| `CaseHistory` | `CaseHistoryComponent` | `_components/widget/case-history/case-history.component.ts` | +| `FeedContainer` | `FeedContainerComponent` | `_components/widget/feed-container/feed-container.component.ts` | +| `FileUtility` | `FileUtilityComponent` | `_components/widget/file-utility/file-utility.component.ts` | +| `ListUtility` | `ListUtilityComponent` | `_components/widget/list-utility/list-utility.component.ts` | +| `QuickCreate` | `QuickCreateComponent` | `_components/widget/quick-create/quick-create.component.ts` | +| `Todo` | `TodoComponent` | `_components/widget/todo/todo.component.ts` | + diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 000000000..24f78b507 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,36 @@ +# Testing + +## Unit tests (no Pega server needed) + +``` +npm run test:unit +``` + +Runs Karma/Jasmine in headless Chrome against the library. The harness lives in `packages/angular-sdk-components/src`: + +| File | Purpose | +| --- | --- | +| `test-setup.ts` | Registers a global `PCore` stand-in, `createMockPConn()` (lenient PConnect double) and `getA11yViolations()` (axe-core). Loaded as a polyfill via `angular.json`. | +| `test-hooks.spec.ts` | Global hooks: loads the component map, resets `PCore` before each spec, stubs `ServerConfigService`. | + +Guidelines: + +- Prefer `createMockPConn()` and override only the methods the test cares about (`pConn.getConfigProps = () => ({ label: 'x' })`, `pConn.resolveConfigProps = p => p`). +- Field components need `formGroup$` (`new FormGroup({})`) to render the editable branch. +- The bridge caches the store on first use; call `TestBed.resetTestingModule()` before swapping `PCore.getStore`. +- 43 legacy `xdescribe`d specs need richer engine fixtures; un-skip them as fixtures become available. +- Bridge behavior is pinned by characterization tests in `_bridge/angular-pconnect.service.spec.ts`; extend them before changing the bridge. + +## Static ratchets + +| Command | Guards | +| --- | --- | +| `npm run check:any` | Per-file `noImplicitAny` baseline (`scripts/implicit-any-baseline.json`). Fixing errors? run `npm run check:any:update` to lower it. | +| `npm run api:check` | Public API report (`etc/angular-sdk-components.api.md`). Intentional change? `npm run api:update`. | +| `npm run check:overrides` | The generated overrides package type-checks against the built library. | +| `npm run docs:components:check` | `docs/components.md` matches the component map. | +| `npm run smoke:pack` | Published tarballs contain the expected files. | + +## End-to-end + +Playwright tests (`npm test`) need a running Pega Infinity server with the MediaCo app; see `projects/angular-test-app/tests`. diff --git a/docs/theming.md b/docs/theming.md new file mode 100644 index 000000000..7ca790d46 --- /dev/null +++ b/docs/theming.md @@ -0,0 +1,24 @@ +# Theming + +SDK components are built on Angular Material 3 and read **system tokens** (`--mat-sys-*`) from CSS custom properties, so a theme is just a set of variables on a root element. + +## How the test app does it + +`projects/angular-test-app/src/themes.scss` defines a `.dark` class that overrides the palette tokens (`--mat-sys-primary`, `--mat-sys-surface`, `--mat-sys-on-surface`, `--mat-sys-error`, ...). Toggling the class on `` switches themes at runtime without rebuilding. + +## Rules for components + +- Use Material tokens (`var(--mat-sys-primary)`, `var(--mat-sys-on-surface)`) instead of hard-coded colors so every theme works. +- App-specific tokens use the `--app-sys-*` prefix and must default to a Material token (for example `--app-sys-secondary-button-border: var(--mat-sys-primary)`). +- Never rely on color alone to convey state (errors, required fields); pair it with text or an icon. + +## Creating a theme + +1. Generate a Material 3 palette (Material Theme Builder or `mat.theme`). +2. Create a class (for example `.high-contrast`) that sets the full token set shown in `themes.scss`. +3. Verify contrast (WCAG 2.2 AA: 4.5:1 for text, 3:1 for UI components) in both display and edit modes. +4. Apply the class to ``. + +## Accessibility checks + +Field components are covered by automated axe-core checks (`field-a11y.spec.ts`, helper `getA11yViolations` in `src/test-setup.ts`). The unit-test environment does not load the Material theme stylesheet, so contrast results there are not representative; review contrast for new themes in the browser. diff --git a/etc/angular-sdk-components.api.md b/etc/angular-sdk-components.api.md index f24d11838..b6da8df03 100644 --- a/etc/angular-sdk-components.api.md +++ b/etc/angular-sdk-components.api.md @@ -135,7 +135,7 @@ export class AngularPConnectService { getComponentID(inComp: any): string; getComponentProp(inComp?: any, inProp?: string): any; // (undocumented) - getCurrentCompleteProps(inComp?: any): object; + getCurrentCompleteProps(inComp?: any): any; // (undocumented) getState(bLogMsg?: boolean, inComp?: any): object; // (undocumented) @@ -3094,7 +3094,7 @@ export class FeedContainerComponent implements OnInit, OnDestroy { } // @public (undocumented) -export class FieldBase implements OnInit, OnDestroy { +export class FieldBase implements OnInit, OnDestroy { // (undocumented) actionsApi: object; // (undocumented) @@ -3132,6 +3132,7 @@ export class FieldBase implements OnInit, OnDestroy { hideLabel: boolean; // (undocumented) label$: string; + markForCheck(): void; ngOnDestroy(): void; ngOnInit(): void; // (undocumented) @@ -3151,11 +3152,11 @@ export class FieldBase implements OnInit, OnDestroy { // (undocumented) protected utils: Utils; // (undocumented) - value$: any; + value$: TValue; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration, never, never, { "pConn$": { "alias": "pConn$"; "required": false; }; "formGroup$": { "alias": "formGroup$"; "required": false; }; }, {}, never, never, true, never>; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration, never>; } // @public (undocumented) @@ -6860,9 +6861,9 @@ export class WideNarrowPageComponent implements OnInit, OnDestroy { // Warnings were encountered during analysis: // -// dist/angular-sdk-components/types/pega-angular-sdk-components.d.ts:1789:9 - (ae-forgotten-export) The symbol "SearchCategory" needs to be exported by the entry point pega-angular-sdk-components.d.ts -// dist/angular-sdk-components/types/pega-angular-sdk-components.d.ts:1789:9 - (ae-forgotten-export) The symbol "SearchGroup" needs to be exported by the entry point pega-angular-sdk-components.d.ts -// dist/angular-sdk-components/types/pega-angular-sdk-components.d.ts:1790:9 - (ae-forgotten-export) The symbol "getComponentStateOptions" needs to be exported by the entry point pega-angular-sdk-components.d.ts +// dist/angular-sdk-components/types/pega-angular-sdk-components.d.ts:1794:9 - (ae-forgotten-export) The symbol "SearchCategory" needs to be exported by the entry point pega-angular-sdk-components.d.ts +// dist/angular-sdk-components/types/pega-angular-sdk-components.d.ts:1794:9 - (ae-forgotten-export) The symbol "SearchGroup" needs to be exported by the entry point pega-angular-sdk-components.d.ts +// dist/angular-sdk-components/types/pega-angular-sdk-components.d.ts:1795:9 - (ae-forgotten-export) The symbol "getComponentStateOptions" needs to be exported by the entry point pega-angular-sdk-components.d.ts // (No @packageDocumentation comment for this package) diff --git a/package-lock.json b/package-lock.json index fea6ca1bf..45d9db7a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "@types/jasmine": "~6.0.0", "@types/jasminewd2": "~2.0.13", "@types/node": "^24.0.0", + "axe-core": "^4.13.0", "brotli": "^1.3.3", "compressing": "^2.1.3", "copy-webpack-plugin": "^14.0.0", @@ -9650,6 +9651,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", diff --git a/package.json b/package.json index 9a3157859..154597a16 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,13 @@ "test:headed": "playwright test --headed --project=chromium MediaCo/portal MediaCo/embedded", "test-report": "playwright show-report", "test:unit": "ng test angular-sdk-components --watch=false --browsers=ChromeHeadless", - "smoke:pack": "node scripts/smoke-pack.js" + "smoke:pack": "node scripts/smoke-pack.js", + "check:any": "node scripts/check-implicit-any.js", + "check:any:update": "node scripts/check-implicit-any.js --update", + "new:component": "node scripts/new-component.js", + "check:overrides": "ngc -p tsconfig.overrides-check.json", + "docs:components": "node scripts/generate-component-catalog.js && prettier -w docs/components.md", + "docs:components:check": "node scripts/generate-component-catalog.js --check" }, "dependencies": { "@angular/animations": "^21.2.4", @@ -116,6 +122,7 @@ "@types/jasmine": "~6.0.0", "@types/jasminewd2": "~2.0.13", "@types/node": "^24.0.0", + "axe-core": "^4.13.0", "brotli": "^1.3.3", "compressing": "^2.1.3", "copy-webpack-plugin": "^14.0.0", diff --git a/packages/angular-sdk-components/src/lib/_bridge/angular-pconnect.service.spec.ts b/packages/angular-sdk-components/src/lib/_bridge/angular-pconnect.service.spec.ts index 434acfd04..ab2620da1 100644 --- a/packages/angular-sdk-components/src/lib/_bridge/angular-pconnect.service.spec.ts +++ b/packages/angular-sdk-components/src/lib/_bridge/angular-pconnect.service.spec.ts @@ -2,10 +2,51 @@ import { TestBed } from '@angular/core/testing'; import { AngularPConnectService } from './angular-pconnect'; +type StoreListener = () => void; + +/** Builds a component double that satisfies the bridge's registration contract. */ +function createComp(configProps: Record = { label: 'A' }) { + const state = { props: configProps }; + const pConn: any = { + meta: { config: {} }, + isEditable: () => true, + setAction: jasmine.createSpy('setAction'), + getActions: () => ({}), + addFormField: jasmine.createSpy('addFormField'), + removeFormField: jasmine.createSpy('removeFormField'), + getContextName: () => 'app/primary_1', + getPageReference: () => 'caseInfo.content', + getConfigProps: () => state.props, + populateAdditionalProps: () => undefined, + resolveConfigProps: (p: any) => ({ ...p }), + getStateProps: () => ({}), + _rawConfig: {}, + _type: 'Text', + _getPropertyName: () => 'Name' + }; + return { comp: { pConn$: pConn, angularPConnectData: {} } as any, pConn, state }; +} + describe('AngularPConnectService', () => { let service: AngularPConnectService; + let listeners: StoreListener[]; + let storeUnsubscribe: jasmine.Spy; beforeEach(() => { + listeners = []; + storeUnsubscribe = jasmine.createSpy('storeUnsubscribe'); + (globalThis as any).PCore = { + setBehaviorOverride: () => undefined, + getEnvironmentInfo: () => ({ getTimeZone: () => 'UTC' }), + getStore: () => ({ + getState: () => ({}), + subscribe: (cb: StoreListener) => { + listeners.push(cb); + return storeUnsubscribe; + } + }), + getContextTreeManager: () => ({ removeFieldNode: () => undefined, removeViewNode: () => undefined }) + }; TestBed.configureTestingModule({}); service = TestBed.inject(AngularPConnectService); }); @@ -13,4 +54,103 @@ describe('AngularPConnectService', () => { it('should be created', () => { expect(service).toBeTruthy(); }); + + describe('registerAndSubscribeComponent', () => { + it('returns an empty registration when the callback is missing', () => { + const { comp } = createComp(); + const data = service.registerAndSubscribeComponent(comp, null); + expect(data.compID).toBe(''); + expect(listeners.length).toBe(0); + }); + + it('assigns unique component ids and subscribes to the store', () => { + const a = createComp(); + const b = createComp(); + a.comp.angularPConnectData = service.registerAndSubscribeComponent(a.comp, () => undefined); + b.comp.angularPConnectData = service.registerAndSubscribeComponent(b.comp, () => undefined); + + expect(a.comp.angularPConnectData.compID).toBeTruthy(); + expect(a.comp.angularPConnectData.compID).not.toBe(b.comp.angularPConnectData.compID); + expect(service.getComponentID(a.comp)).toBe(a.comp.angularPConnectData.compID); + expect(listeners.length).toBe(2); + }); + + it('registers form field and onChange/onBlur actions for editable components', () => { + const { comp, pConn } = createComp(); + service.registerAndSubscribeComponent(comp, () => undefined); + expect(pConn.addFormField).toHaveBeenCalled(); + expect(pConn.setAction).toHaveBeenCalledWith('onChange', jasmine.any(Function)); + expect(pConn.setAction).toHaveBeenCalledWith('onBlur', jasmine.any(Function)); + }); + + it('invokes the callback bound to the component on store changes until unsubscribed', () => { + const { comp, pConn } = createComp(); + const callback = jasmine.createSpy('callback'); + comp.angularPConnectData = service.registerAndSubscribeComponent(comp, callback); + + listeners[0](); + expect(callback).toHaveBeenCalledTimes(1); + + comp.angularPConnectData.unsubscribeFn(); + expect(storeUnsubscribe).toHaveBeenCalled(); + expect(pConn.removeFormField).toHaveBeenCalled(); + + listeners[0](); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe('shouldComponentUpdate', () => { + it('returns false for an empty component', () => { + spyOn(console, 'error'); + expect(service.shouldComponentUpdate({})).toBeFalse(); + }); + + it('reports a change on first evaluation and no change when props are identical', () => { + const { comp } = createComp({ label: 'A' }); + comp.angularPConnectData = service.registerAndSubscribeComponent(comp, () => undefined); + + expect(service.shouldComponentUpdate(comp)).toBeTrue(); + expect(service.shouldComponentUpdate(comp)).toBeFalse(); + }); + + it('reports a change when a config prop changes', () => { + const { comp, state } = createComp({ label: 'A' }); + comp.angularPConnectData = service.registerAndSubscribeComponent(comp, () => undefined); + service.shouldComponentUpdate(comp); + + state.props = { label: 'B' }; + expect(service.shouldComponentUpdate(comp)).toBeTrue(); + expect(service.getComponentProp(comp, 'label')).toBe('B'); + }); + + it('ignores blank page messages when deciding whether to update', () => { + const { comp, state } = createComp({ label: 'A', pageMessages: [] }); + comp.angularPConnectData = service.registerAndSubscribeComponent(comp, () => undefined); + service.shouldComponentUpdate(comp); + + state.props = { label: 'A', pageMessages: [] }; + expect(service.shouldComponentUpdate(comp)).toBeFalse(); + }); + + it('stores the decoded validation message on the component bridge data', () => { + const { comp, state } = createComp({ label: 'A' }); + comp.angularPConnectData = service.registerAndSubscribeComponent(comp, () => undefined); + service.shouldComponentUpdate(comp); + + state.props = { label: 'A', validatemessage: 'Required' }; + expect(service.shouldComponentUpdate(comp)).toBeTrue(); + expect(comp.angularPConnectData.validateMessage).toBe('Required'); + }); + + it('always re-renders contextual components nested below the case content', () => { + const { comp, pConn } = createComp({ label: 'A' }); + comp.angularPConnectData = service.registerAndSubscribeComponent(comp, () => undefined); + service.shouldComponentUpdate(comp); + + pConn.meta.config.context = '.Pages'; + pConn.getPageReference = () => 'caseInfo.content.Pages'; + expect(service.shouldComponentUpdate(comp)).toBeTrue(); + }); + }); }); diff --git a/packages/angular-sdk-components/src/lib/_bridge/angular-pconnect.ts b/packages/angular-sdk-components/src/lib/_bridge/angular-pconnect.ts index 14a4e88c0..089141bb3 100644 --- a/packages/angular-sdk-components/src/lib/_bridge/angular-pconnect.ts +++ b/packages/angular-sdk-components/src/lib/_bridge/angular-pconnect.ts @@ -35,7 +35,7 @@ export class AngularPConnectService { * anything added by populateAdditionalProps. * Each entry is: { __componentID__: _the component's most recent props_ } */ - private componentPropsArr: object[] = []; + private componentPropsArr: Record = {}; /* Used to toggle some class-wide logging */ private static bLogging = false; @@ -83,15 +83,17 @@ export class AngularPConnectService { * @returns The **unsubscribe** function that should be called when the component needs * to unsubscribe from the store. (Typically during ngOnDestroy) */ - private subscribeToStore(inComp: any = null, inCallback: Function | null = null): Function { + private subscribeToStore(inComp: any = null, inCallback: Function | null = null): Function | undefined { // const theCompName: string = inComp ? `${inComp.constructor.name}` : 'no component provided'; - let fnUnsubscribe; + let fnUnsubscribe: (() => void) | undefined; // console.log( `Bridge subscribing: ${theCompName} `); if (inComp) { let bSubscribed = true; const wrappedCallback = () => { if (bSubscribed && inCallback) { inCallback(); + // Store callbacks mutate component state outside Angular's event system; flag the view for OnPush components. + inComp.markForCheck?.(); } }; const storeUnsubscribe = this.getStore().subscribe(wrappedCallback); @@ -163,7 +165,7 @@ export class AngularPConnectService { * Otherwise, return undefined. * @param inComp The component whose property is being requested. */ - public getComponentID(inComp): string { + public getComponentID(inComp: any): string { return inComp.bridgeComponentID || inComp.angularPConnectData.compID; } @@ -213,7 +215,7 @@ export class AngularPConnectService { * validateMessage: any validation/error message that gets generated for this object, * actions: any actions that are defined for this object */ - registerAndSubscribeComponent(inComp, inCallback: Function | null = null): AngularPConnectData { + registerAndSubscribeComponent(inComp: any, inCallback: Function | null = null): AngularPConnectData { // Create an initial object to be returned. const returnObject: AngularPConnectData = { compID: '', @@ -271,7 +273,7 @@ export class AngularPConnectService { // Now proceed to register and subscribe... const theCompID: string = this.getNextComponentId(); - const theUnsub: Function | null = this.subscribeToStore(inComp, inCallback); + const theUnsub: Function | undefined = this.subscribeToStore(inComp, inCallback); if (undefined === inComp.angularPConnectData) { inComp.bridgeComponentID = theCompID; @@ -279,7 +281,7 @@ export class AngularPConnectService { returnObject.compID = theCompID; returnObject.unsubscribeFn = () => { this.removeFormField(inComp); - theUnsub(); + theUnsub?.(); }; } @@ -292,11 +294,11 @@ export class AngularPConnectService { return returnObject; } - addFormField(inComp) { + addFormField(inComp: any) { inComp.pConn$?.addFormField(); } - removeFormField(inComp) { + removeFormField(inComp: any) { if (inComp.pConn$?.removeFormField) { inComp.pConn$?.removeFormField(); } @@ -346,7 +348,7 @@ export class AngularPConnectService { * Return **false**: means the component props are the same and the component doesn't need to update (re-render). * If the ***inComp*** input is bad, false is also returned. */ - shouldComponentUpdate(inComp): boolean { + shouldComponentUpdate(inComp: any): boolean { // const bShowLogging = false; let bRet = false; // check for reasonable input @@ -438,7 +440,7 @@ export class AngularPConnectService { return bRet; } - isPageMessagesEmpty(incomingProps) { + isPageMessagesEmpty(incomingProps: any) { return incomingProps.pageMessages && incomingProps.pageMessages.length === 0; } @@ -447,7 +449,7 @@ export class AngularPConnectService { * @param inComp The component calling the change event * @param event The event */ - changeHandler(inComp, event) { + changeHandler(inComp: any, event: any) { const bLogging = false; if (bLogging) { // console.log(`AngularPConnect.changeHandler`); @@ -475,7 +477,7 @@ export class AngularPConnectService { * @param inComp The component calling the event * @param event The event */ - eventHandler(inComp, event) { + eventHandler(inComp: any, event: any) { const bLogging = false; if (bLogging) { // console.log(`AngularPConnect.eventHandler`); @@ -524,7 +526,7 @@ export class AngularPConnectService { * processActions exposes all actions in the metadata. * Attaches common handler (eventHandler) for all actions. */ - private processActions(inComp) { + private processActions(inComp: any) { const pConnect = inComp.pConn$; if (undefined === pConnect) { console.error(`AngularPConnect: bad call to processActions: pConn$: ${pConnect} from component: ${inComp.constructor.name}`); diff --git a/packages/angular-sdk-components/src/lib/_bridge/component-mapper/component-mapper.component.ts b/packages/angular-sdk-components/src/lib/_bridge/component-mapper/component-mapper.component.ts index 3266c8a1e..c47534594 100644 --- a/packages/angular-sdk-components/src/lib/_bridge/component-mapper/component-mapper.component.ts +++ b/packages/angular-sdk-components/src/lib/_bridge/component-mapper/component-mapper.component.ts @@ -90,7 +90,7 @@ export class ComponentMapperComponent implements OnInit, OnDestroy, OnChanges { const propsKeys = Object.keys(this.outputEvents); const propsValues: any = Object.values(this.outputEvents); for (let i = 0; i < propsKeys.length; i++) { - this.componentRef?.instance[propsKeys[i]].subscribe(value => { + this.componentRef?.instance[propsKeys[i]].subscribe((value: any) => { const callbackFn = propsValues[i].bind(this.parent); callbackFn(value); }); diff --git a/packages/angular-sdk-components/src/lib/_bridge/helpers/sdk_component_map.spec.ts b/packages/angular-sdk-components/src/lib/_bridge/helpers/sdk_component_map.spec.ts new file mode 100644 index 000000000..eb8829c90 --- /dev/null +++ b/packages/angular-sdk-components/src/lib/_bridge/helpers/sdk_component_map.spec.ts @@ -0,0 +1,39 @@ +// Component and map modules are imported lazily: they form an import cycle that only initializes +// correctly when entered through the shared test hooks (see src/test-hooks.spec.ts). +const loadMap = () => import('./sdk_component_map'); + +describe('getComponentFromMap', () => { + let originalLocalMap: any; + + beforeEach(async () => { + const { SdkComponentMap } = await loadMap(); + originalLocalMap = SdkComponentMap.getLocalComponentMap(); + spyOn(console, 'log'); + }); + + afterEach(async () => { + const { SdkComponentMap } = await loadMap(); + SdkComponentMap.setLocalComponentMap(originalLocalMap); + }); + + it('resolves SDK-provided components by name', async () => { + const { getComponentFromMap } = await loadMap(); + const { TextComponent } = await import('../../_components/field/text/text.component'); + expect(getComponentFromMap('Text')).toBe(TextComponent); + }); + + it('prefers a local override over the SDK-provided component', async () => { + const { getComponentFromMap, SdkComponentMap } = await loadMap(); + class LocalText {} + SdkComponentMap.setLocalComponentMap({ Text: LocalText }); + expect(getComponentFromMap('Text')).toBe(LocalText); + }); + + it('falls back to the error boundary and logs for unmapped components', async () => { + const { getComponentFromMap } = await loadMap(); + const { ErrorBoundaryComponent } = await import('../../_components/infra/error-boundary/error-boundary.component'); + const errorSpy = spyOn(console, 'error'); + expect(getComponentFromMap('DoesNotExist')).toBe(ErrorBoundaryComponent); + expect(errorSpy).toHaveBeenCalledWith(jasmine.stringContaining('DoesNotExist')); + }); +}); diff --git a/packages/angular-sdk-components/src/lib/_bridge/helpers/sdk_component_map.ts b/packages/angular-sdk-components/src/lib/_bridge/helpers/sdk_component_map.ts index a6fc0b2f5..aa40cb635 100644 --- a/packages/angular-sdk-components/src/lib/_bridge/helpers/sdk_component_map.ts +++ b/packages/angular-sdk-components/src/lib/_bridge/helpers/sdk_component_map.ts @@ -9,7 +9,7 @@ import pegaSdkComponentMap from './sdk-pega-component-map'; // Note: Initializing SdkComponentMap to null seems to cause lots of compile issues with references // within other components and the value potentially being null (so try to leave it undefined) -export let SdkComponentMap; +export let SdkComponentMap: any; let SdkComponentMapCreateInProgress = false; interface ISdkComponentMap { @@ -84,7 +84,7 @@ class ComponentMap { return this.sdkComponentMap.localComponentMap; }; - setLocalComponentMap(inLocalSdkComponentMap) { + setLocalComponentMap(inLocalSdkComponentMap: any) { this.sdkComponentMap.localComponentMap = inLocalSdkComponentMap; return this.sdkComponentMap.localComponentMap; } @@ -93,7 +93,7 @@ class ComponentMap { return this.sdkComponentMap.pegaProvidedComponentMap; }; - setPegaProvidedComponentMap = inPegaProvidedComponentMap => { + setPegaProvidedComponentMap = (inPegaProvidedComponentMap: any) => { this.sdkComponentMap.pegaProvidedComponentMap = inPegaProvidedComponentMap; return this.sdkComponentMap.pegaProvidedComponentMap; }; @@ -111,7 +111,7 @@ async function createSdkComponentMap(inLocalComponentMap = {}) { // Initialize exported SdkComponentMap structure export async function getSdkComponentMap(inLocalComponentMap = {}) { return new Promise(resolve => { - let idNextCheck; + let idNextCheck: any; if (!SdkComponentMap && !SdkComponentMapCreateInProgress) { SdkComponentMapCreateInProgress = true; createSdkComponentMap(inLocalComponentMap).then(theComponentMap => { diff --git a/packages/angular-sdk-components/src/lib/_components/designSystemExtension/alert-banner/alert-banner.component.ts b/packages/angular-sdk-components/src/lib/_components/designSystemExtension/alert-banner/alert-banner.component.ts index 99bbede6f..08eb343ad 100644 --- a/packages/angular-sdk-components/src/lib/_components/designSystemExtension/alert-banner/alert-banner.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/designSystemExtension/alert-banner/alert-banner.component.ts @@ -1,8 +1,9 @@ -import { Component, Input, forwardRef } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, forwardRef } from '@angular/core'; import { ComponentMapperComponent } from '../../../_bridge/component-mapper/component-mapper.component'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-alert-banner', templateUrl: './alert-banner.component.html', styleUrls: ['./alert-banner.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/designSystemExtension/alert/alert.component.ts b/packages/angular-sdk-components/src/lib/_components/designSystemExtension/alert/alert.component.ts index dd798ee98..70a6ca0c8 100644 --- a/packages/angular-sdk-components/src/lib/_components/designSystemExtension/alert/alert.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/designSystemExtension/alert/alert.component.ts @@ -1,8 +1,9 @@ -import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, Output, EventEmitter } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatIconModule } from '@angular/material/icon'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-alert', templateUrl: './alert.component.html', styleUrls: ['./alert.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/designSystemExtension/banner/banner.component.ts b/packages/angular-sdk-components/src/lib/_components/designSystemExtension/banner/banner.component.ts index 7ffc1519f..ec9fe0212 100644 --- a/packages/angular-sdk-components/src/lib/_components/designSystemExtension/banner/banner.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/designSystemExtension/banner/banner.component.ts @@ -1,9 +1,10 @@ import { CommonModule } from '@angular/common'; import { FormGroup } from '@angular/forms'; -import { Component, Input, forwardRef } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, forwardRef } from '@angular/core'; import { ComponentMapperComponent } from '../../../_bridge/component-mapper/component-mapper.component'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-banner', templateUrl: './banner.component.html', styleUrls: ['./banner.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/designSystemExtension/material-utility/material-utility.component.ts b/packages/angular-sdk-components/src/lib/_components/designSystemExtension/material-utility/material-utility.component.ts index a66783a4f..9d96ec15c 100644 --- a/packages/angular-sdk-components/src/lib/_components/designSystemExtension/material-utility/material-utility.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/designSystemExtension/material-utility/material-utility.component.ts @@ -1,7 +1,8 @@ -import { Component, OnInit, Input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, Input } from '@angular/core'; import { Utils } from '../../../_helpers/utils'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-material-utility', templateUrl: './material-utility.component.html', styleUrls: ['./material-utility.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/designSystemExtension/pulse/pulse.component.ts b/packages/angular-sdk-components/src/lib/_components/designSystemExtension/pulse/pulse.component.ts index 18e03df07..b47e7c185 100644 --- a/packages/angular-sdk-components/src/lib/_components/designSystemExtension/pulse/pulse.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/designSystemExtension/pulse/pulse.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, Input, forwardRef } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, Input, forwardRef } from '@angular/core'; import { ComponentMapperComponent } from '../../../_bridge/component-mapper/component-mapper.component'; interface PulseProps { @@ -7,6 +7,7 @@ interface PulseProps { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-pulse', templateUrl: './pulse.component.html', styleUrls: ['./pulse.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/designSystemExtension/wss-quick-create/wss-quick-create.component.ts b/packages/angular-sdk-components/src/lib/_components/designSystemExtension/wss-quick-create/wss-quick-create.component.ts index bb16b13b6..b2e12f1a1 100644 --- a/packages/angular-sdk-components/src/lib/_components/designSystemExtension/wss-quick-create/wss-quick-create.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/designSystemExtension/wss-quick-create/wss-quick-create.component.ts @@ -1,7 +1,8 @@ -import { Component, Input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; // import { Button } from '@angular/material' @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'wss-quick-create', templateUrl: './wss-quick-create.component.html', styleUrls: ['./wss-quick-create.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/field/check-box/check-box.component.ts b/packages/angular-sdk-components/src/lib/_components/field/check-box/check-box.component.ts index 96a3be893..be6645fb9 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/check-box/check-box.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/check-box/check-box.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, forwardRef, OnDestroy } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, forwardRef, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ReactiveFormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -30,6 +30,7 @@ interface CheckboxProps extends Omit { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-check-box', templateUrl: './check-box.component.html', styleUrls: ['./check-box.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/field/currency/currency.component.ts b/packages/angular-sdk-components/src/lib/_components/field/currency/currency.component.ts index 45c5c4c23..e85230af0 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/currency/currency.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/currency/currency.component.ts @@ -1,4 +1,4 @@ -import { Component, forwardRef } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; @@ -21,6 +21,7 @@ interface CurrrencyProps extends PConnFieldProps { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-currency', templateUrl: './currency.component.html', styleUrls: ['./currency.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/field/date-time/date-time.component.ts b/packages/angular-sdk-components/src/lib/_components/field/date-time/date-time.component.ts index 086c7935a..7f3f7d1a8 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/date-time/date-time.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/date-time/date-time.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, forwardRef, OnDestroy, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, forwardRef, OnDestroy, inject } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { MatDatepickerModule } from '@angular/material/datepicker'; @@ -21,6 +21,7 @@ interface DateTimeProps extends PConnFieldProps { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-date-time', templateUrl: './date-time.component.html', styleUrls: ['./date-time.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/field/date/date.component.ts b/packages/angular-sdk-components/src/lib/_components/field/date/date.component.ts index 2d3ca9c7e..b554ec069 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/date/date.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/date/date.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, forwardRef, OnDestroy } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, forwardRef, OnDestroy } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { MatDatepickerModule } from '@angular/material/datepicker'; @@ -40,6 +40,7 @@ class MyFormat { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-date', templateUrl: './date.component.html', styleUrls: ['./date.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/field/decimal/decimal.component.ts b/packages/angular-sdk-components/src/lib/_components/field/decimal/decimal.component.ts index 719071f10..4287c316c 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/decimal/decimal.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/decimal/decimal.component.ts @@ -1,4 +1,4 @@ -import { Component, forwardRef } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; @@ -22,6 +22,7 @@ interface DecimalProps extends PConnFieldProps { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-decimal', templateUrl: './decimal.component.html', styleUrls: ['./decimal.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/field/email/email.component.ts b/packages/angular-sdk-components/src/lib/_components/field/email/email.component.ts index 73d782a91..319f910a2 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/email/email.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/email/email.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, forwardRef, OnDestroy } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, forwardRef, OnDestroy } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; @@ -15,6 +15,7 @@ interface EmailProps extends PConnFieldProps { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-email', templateUrl: './email.component.html', styleUrls: ['./email.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/field/field-a11y.spec.ts b/packages/angular-sdk-components/src/lib/_components/field/field-a11y.spec.ts new file mode 100644 index 000000000..b6bdedf3d --- /dev/null +++ b/packages/angular-sdk-components/src/lib/_components/field/field-a11y.spec.ts @@ -0,0 +1,61 @@ +import { Component } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { TestBed } from '@angular/core/testing'; + +import { createMockPConn, getA11yViolations } from '../../../test-setup'; +import { TextInputComponent } from './text-input/text-input.component'; +import { TextAreaComponent } from './text-area/text-area.component'; +import { EmailComponent } from './email/email.component'; +import { IntegerComponent } from './integer/integer.component'; +import { CheckBoxComponent } from './check-box/check-box.component'; +import { UrlComponent } from './url/url.component'; +import { TimeComponent } from './time/time.component'; +import { DecimalComponent } from './decimal/decimal.component'; +import { PercentageComponent } from './percentage/percentage.component'; +import { CurrencyComponent } from './currency/currency.component'; +import { DateComponent } from './date/date.component'; +import { RadioButtonsComponent } from './radio-buttons/radio-buttons.component'; + +function createHost(selector: string, component: any, configProps: Record) { + const pConn = createMockPConn(); + pConn.getConfigProps = () => configProps; + pConn.resolveConfigProps = (p: any) => p; + + const template = `<${selector} [pConn$]="pConn" [formGroup$]="formGroup">`; + + @Component({ imports: [component], template }) + class HostComponent { + pConn = pConn; + formGroup = new FormGroup({}); + } + return HostComponent; +} + +describe('Field accessibility (axe-core, WCAG 2.1 A/AA)', () => { + const cases: [string, string, any][] = [ + ['TextInput', 'app-text-input', TextInputComponent], + ['TextArea', 'app-text-area', TextAreaComponent], + ['Email', 'app-email', EmailComponent], + ['Integer', 'app-integer', IntegerComponent], + ['CheckBox', 'app-check-box', CheckBoxComponent], + ['Url', 'app-url', UrlComponent], + ['Time', 'app-time', TimeComponent], + ['Decimal', 'app-decimal', DecimalComponent], + ['Percentage', 'app-percentage', PercentageComponent], + ['Currency', 'app-currency', CurrencyComponent], + ['Date', 'app-date', DateComponent], + ['RadioButtons', 'app-radio-buttons', RadioButtonsComponent] + ]; + + cases.forEach(([name, selector, component]) => { + it(`${name} has no detectable violations when editable`, async () => { + const Host = createHost(selector, component, { label: `${name} label`, caption: `${name} caption`, testId: 'f1', required: true }); + TestBed.configureTestingModule({ imports: [Host] }); + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + await fx.whenStable(); + + expect(await getA11yViolations(fx.nativeElement)).toEqual([]); + }); + }); +}); diff --git a/packages/angular-sdk-components/src/lib/_components/field/field.base.ts b/packages/angular-sdk-components/src/lib/_components/field/field.base.ts index 53cc1a7d9..9b531254f 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/field.base.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/field.base.ts @@ -1,4 +1,4 @@ -import { Directive, inject, Input, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Directive, inject, Input, OnDestroy, OnInit } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { AngularPConnectData, AngularPConnectService } from '../../_bridge/angular-pconnect'; @@ -6,12 +6,16 @@ import { Utils } from '../../_helpers/utils'; import type { PConnFieldProps } from '../../_types/PConnProps.interface'; @Directive() -export class FieldBase implements OnInit, OnDestroy { +/** + * @typeParam TValue type of the field value held in `value$`; defaults to `any` so existing subclasses are unaffected. + */ +export class FieldBase implements OnInit, OnDestroy { @Input() pConn$: typeof PConnect; @Input() formGroup$: FormGroup; protected angularPConnect = inject(AngularPConnectService); protected utils = inject(Utils); + private readonly fieldCdr = inject(ChangeDetectorRef); protected angularPConnectData: AngularPConnectData = {}; @@ -24,7 +28,7 @@ export class FieldBase implements OnInit, OnDestroy { testId: string; helperText: string; placeholder: string; - value$: any = ''; + value$: TValue = '' as TValue; label$ = ''; hideLabel = false; bRequired$ = false; @@ -74,6 +78,13 @@ export class FieldBase implements OnInit, OnDestroy { } } + /** + * Called by the bridge after a store-driven update so OnPush subclasses re-render. + */ + markForCheck(): void { + this.fieldCdr.markForCheck(); + } + // Callback passed when subscribing to store change onStateChange() { this.checkAndUpdate(); diff --git a/packages/angular-sdk-components/src/lib/_components/field/integer/integer.component.ts b/packages/angular-sdk-components/src/lib/_components/field/integer/integer.component.ts index e7a8dc26f..15492fd78 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/integer/integer.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/integer/integer.component.ts @@ -1,4 +1,4 @@ -import { Component, forwardRef } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; @@ -15,6 +15,7 @@ interface IntegerProps extends PConnFieldProps { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-integer', templateUrl: './integer.component.html', styleUrls: ['./integer.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/field/percentage/percentage.component.ts b/packages/angular-sdk-components/src/lib/_components/field/percentage/percentage.component.ts index d6aa6a482..83d12a2e9 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/percentage/percentage.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/percentage/percentage.component.ts @@ -1,4 +1,4 @@ -import { Component, forwardRef } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; @@ -22,6 +22,7 @@ interface PercentageProps extends Omit { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-percentage', templateUrl: './percentage.component.html', styleUrls: ['./percentage.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/field/phone/phone.component.ts b/packages/angular-sdk-components/src/lib/_components/field/phone/phone.component.ts index 901b9c4c0..d0eac7d46 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/phone/phone.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/phone/phone.component.ts @@ -1,4 +1,4 @@ -import { Component, forwardRef } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -16,6 +16,7 @@ interface PhoneProps extends PConnFieldProps { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-phone', templateUrl: './phone.component.html', styleUrls: ['./phone.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/field/radio-buttons/radio-buttons.component.ts b/packages/angular-sdk-components/src/lib/_components/field/radio-buttons/radio-buttons.component.ts index f938831a6..1c5a9634b 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/radio-buttons/radio-buttons.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/radio-buttons/radio-buttons.component.ts @@ -1,4 +1,4 @@ -import { Component, forwardRef } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { MatRadioModule } from '@angular/material/radio'; @@ -25,6 +25,7 @@ interface RadioButtonsProps extends PConnFieldProps { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-radio-buttons', templateUrl: './radio-buttons.component.html', styleUrls: ['./radio-buttons.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/field/rich-text/rich-text.component.ts b/packages/angular-sdk-components/src/lib/_components/field/rich-text/rich-text.component.ts index d42da5304..0466a7386 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/rich-text/rich-text.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/rich-text/rich-text.component.ts @@ -1,4 +1,4 @@ -import { Component, forwardRef } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; @@ -12,6 +12,7 @@ interface RichTextProps extends PConnFieldProps { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-rich-text', templateUrl: './rich-text.component.html', styleUrls: ['./rich-text.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/field/scalar-list/scalar-list.component.ts b/packages/angular-sdk-components/src/lib/_components/field/scalar-list/scalar-list.component.ts index cee2634a9..b1afebd60 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/scalar-list/scalar-list.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/scalar-list/scalar-list.component.ts @@ -1,4 +1,4 @@ -import { Component, forwardRef } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; import { FieldBase } from '../field.base'; import { ComponentMapperComponent } from '../../../_bridge/component-mapper/component-mapper.component'; @@ -13,6 +13,7 @@ interface ScalarListProps extends Omit { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-scalar-list', templateUrl: './scalar-list.component.html', styleUrls: ['./scalar-list.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/field/selectable-card/selectable-card.component.ts b/packages/angular-sdk-components/src/lib/_components/field/selectable-card/selectable-card.component.ts index 95aafcbef..fae95891f 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/selectable-card/selectable-card.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/selectable-card/selectable-card.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit, EventEmitter, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, OnInit, EventEmitter, Output } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatCardModule } from '@angular/material/card'; import { MatRadioModule } from '@angular/material/radio'; @@ -24,6 +24,7 @@ interface SelectableCardProps extends PConnFieldProps { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'lib-selectable-card', imports: [MatCardModule, CommonModule, MatRadioModule, MatCheckboxModule], templateUrl: './selectable-card.component.html', diff --git a/packages/angular-sdk-components/src/lib/_components/field/text-area/text-area.component.ts b/packages/angular-sdk-components/src/lib/_components/field/text-area/text-area.component.ts index 9827a9aac..4953c729e 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/text-area/text-area.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/text-area/text-area.component.ts @@ -1,4 +1,4 @@ -import { Component, forwardRef } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; @@ -16,6 +16,7 @@ interface TextAreaProps extends PConnFieldProps { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-text-area', templateUrl: './text-area.component.html', styleUrls: ['./text-area.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/field/text-input/text-input.component.spec.ts b/packages/angular-sdk-components/src/lib/_components/field/text-input/text-input.component.spec.ts index 217d9324c..8c88f21dd 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/text-input/text-input.component.spec.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/text-input/text-input.component.spec.ts @@ -1,3 +1,4 @@ +import { Component } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { createMockPConn } from '../../../../test-setup'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -25,4 +26,41 @@ describe('TextInputComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('reflects store-driven updates while using OnPush', () => { + // The bridge caches the store on first use, so start from a fresh injector before swapping PCore.getStore. + TestBed.resetTestingModule(); + let storeListener: () => void = () => undefined; + (globalThis as any).PCore.getStore = () => ({ getState: () => ({}), subscribe: (cb: () => void) => ((storeListener = cb), () => undefined) }); + + const pConn = createMockPConn(); + let label = 'First'; + pConn.getConfigProps = () => ({ label }); + pConn.resolveConfigProps = (p: any) => p; + + // A default-strategy host is needed: ComponentFixture.detectChanges() force-refreshes the OnPush component itself. + @Component({ + imports: [TextInputComponent], + template: '' + }) + class HostComponent { + pConn = pConn; + formGroup = new FormGroup({}); + } + + TestBed.configureTestingModule({ imports: [HostComponent] }); + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + expect(fx.nativeElement.textContent).toContain('First'); + + label = 'Second'; + storeListener(); + fx.detectChanges(); + expect(fx.nativeElement.textContent).toContain('Second'); + }); + + it('uses OnPush change detection', () => { + const def = (TextInputComponent as any).ɵcmp; + expect(def.onPush).toBeTrue(); + }); }); diff --git a/packages/angular-sdk-components/src/lib/_components/field/text-input/text-input.component.ts b/packages/angular-sdk-components/src/lib/_components/field/text-input/text-input.component.ts index efd6c07ca..2e691a601 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/text-input/text-input.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/text-input/text-input.component.ts @@ -1,4 +1,4 @@ -import { Component, forwardRef } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; @@ -16,6 +16,7 @@ interface TextInputProps extends PConnFieldProps { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-text-input', templateUrl: './text-input.component.html', styleUrls: ['./text-input.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/field/time/time.component.ts b/packages/angular-sdk-components/src/lib/_components/field/time/time.component.ts index bdb79d1f9..2fc47f7bc 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/time/time.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/time/time.component.ts @@ -1,4 +1,4 @@ -import { Component, forwardRef } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; @@ -16,6 +16,7 @@ interface TimeProps extends PConnFieldProps { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-time', templateUrl: './time.component.html', styleUrls: ['./time.component.scss'], diff --git a/packages/angular-sdk-components/src/lib/_components/field/url/url.component.ts b/packages/angular-sdk-components/src/lib/_components/field/url/url.component.ts index c50c9cd84..8c4ca985b 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/url/url.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/url/url.component.ts @@ -1,4 +1,4 @@ -import { Component, forwardRef } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; @@ -15,6 +15,7 @@ interface URLProps extends PConnFieldProps { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-url', templateUrl: './url.component.html', styleUrls: ['./url.component.scss'], diff --git a/packages/angular-sdk-components/src/test-setup.ts b/packages/angular-sdk-components/src/test-setup.ts index 0d22a4646..6277f2e60 100644 --- a/packages/angular-sdk-components/src/test-setup.ts +++ b/packages/angular-sdk-components/src/test-setup.ts @@ -70,3 +70,15 @@ export function createMockPConn(): any { } (globalThis as any).PCore = createPCoreStub(); + +/** Runs axe-core (WCAG 2.x A/AA rules) against a rendered element and returns a readable list of violations. */ +export async function getA11yViolations(element: HTMLElement): Promise { + const axe = (await import('axe-core')).default; + document.body.appendChild(element); + try { + const results = await axe.run(element, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] } }); + return results.violations.map(v => `${v.id}: ${v.help} (${v.nodes.map(n => n.target.join(' ')).join(', ')})`); + } finally { + element.remove(); + } +} diff --git a/scripts/build-overrides.js b/scripts/build-overrides.js index 986ae7f8c..e31f7bd12 100644 --- a/scripts/build-overrides.js +++ b/scripts/build-overrides.js @@ -41,7 +41,7 @@ function processOverrideFile(filePath) { } // The Regex pattern to match import statements - const importPattern = /import\s+(?:(?:{[^}]+})|(?:[\w\d*]+))\s+from\s+['"]([^'"]+)['"]/g; + const importPattern = /import\s+(?:type\s+)?(?:(?:{[^}]+})|(?:[\w\d*]+))\s+from\s+['"]([^'"]+)['"]/g; const newData = data.replace(importPattern, (match, importPath) => { if (importPath.includes('../')) { diff --git a/scripts/check-implicit-any.js b/scripts/check-implicit-any.js new file mode 100644 index 000000000..30011a53d --- /dev/null +++ b/scripts/check-implicit-any.js @@ -0,0 +1,51 @@ +/* + * Ratchet for `noImplicitAny`: the library is not yet clean, so we record the per-file compiler error count (with the flag + * on) in a baseline and fail whenever a file gets worse or a new file starts failing. Run with --update after + * fixing errors to lower the baseline. + */ +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..'); +const baselinePath = path.join(root, 'scripts', 'implicit-any-baseline.json'); +const update = process.argv.includes('--update'); + +const tsc = path.join(root, 'node_modules', '.bin', 'tsc'); +const result = spawnSync(tsc, ['-p', 'packages/angular-sdk-components/tsconfig.lib.json', '--noEmit', '--noImplicitAny', '--pretty', 'false'], { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024 +}); + +const counts = {}; +for (const line of (result.stdout || '').split('\n')) { + const m = /^(.+?)\(\d+,\d+\): error TS(\d+):/.exec(line); + if (!m) continue; + counts[m[1]] = (counts[m[1]] || 0) + 1; +} + +const sorted = Object.fromEntries(Object.entries(counts).sort(([a], [b]) => a.localeCompare(b))); + +if (update) { + fs.writeFileSync(baselinePath, `${JSON.stringify(sorted, null, 2)}\n`); + console.log(`Baseline updated: ${Object.values(sorted).reduce((a, b) => a + b, 0)} errors in ${Object.keys(sorted).length} files`); + process.exit(0); +} + +const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); +let failed = false; +let improved = 0; +for (const [file, n] of Object.entries(sorted)) { + const allowed = baseline[file] ?? 0; + if (n > allowed) { + console.error(`${file}: ${n} implicit-any errors (baseline ${allowed})`); + failed = true; + } +} +for (const [file, allowed] of Object.entries(baseline)) { + if ((sorted[file] ?? 0) < allowed) improved += 1; +} +if (improved) console.log(`${improved} file(s) improved; run "npm run check:any:update" to lower the baseline.`); +if (failed) process.exit(1); +console.log('noImplicitAny ratchet OK'); diff --git a/scripts/generate-component-catalog.js b/scripts/generate-component-catalog.js new file mode 100644 index 000000000..a65684677 --- /dev/null +++ b/scripts/generate-component-catalog.js @@ -0,0 +1,54 @@ +/* + * Generates docs/components.md from the SDK component map so the catalogue cannot drift from the code. + * Usage: node scripts/generate-component-catalog.js [--check] + */ +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..'); +const mapFile = path.join(root, 'packages/angular-sdk-components/src/lib/_bridge/helpers/sdk-pega-component-map.ts'); +const outFile = path.join(root, 'docs', 'components.md'); + +const source = fs.readFileSync(mapFile, 'utf8'); +const imports = new Map(); +for (const m of source.matchAll(/import \{\s*([A-Za-z0-9_]+)\s*\} from '\.\.\/\.\.\/_components\/([^']+)';/g)) { + imports.set(m[1], m[2]); +} + +const body = source.slice(source.indexOf('const pegaSdkComponentMap')); +const entries = [...body.matchAll(/^\s+([A-Za-z0-9_]+):\s*([A-Za-z0-9_]+),?\s*$/gm)].map(([, key, cls]) => ({ key, cls, file: imports.get(cls) })); + +const byKind = {}; +for (const e of entries) { + const kind = e.file ? e.file.split('/')[0] : 'unknown'; + (byKind[kind] ||= []).push(e); +} + +const lines = [ + '# Component catalogue', + '', + '', + '', + `${entries.length} Pega component names are mapped to Angular implementations. Override any of them through \`sdk-local-component-map.ts\` (see the overrides package).`, + '' +]; +for (const kind of Object.keys(byKind).sort()) { + lines.push(`## ${kind}`, '', '| Pega component | Angular class | Source |', '| --- | --- | --- |'); + for (const e of byKind[kind].sort((a, b) => a.key.localeCompare(b.key))) { + lines.push(`| \`${e.key}\` | \`${e.cls}\` | \`_components/${e.file}.ts\` |`); + } + lines.push(''); +} +const output = `${lines.join('\n')}\n`.replace(/\n{3,}/g, '\n\n'); + +if (process.argv.includes('--check')) { + const current = fs.existsSync(outFile) ? fs.readFileSync(outFile, 'utf8') : ''; + if (current !== output) { + console.error('docs/components.md is out of date. Run: npm run docs:components'); + process.exit(1); + } + console.log('Component catalogue is up to date'); +} else { + fs.writeFileSync(outFile, output); + console.log(`Wrote ${path.relative(root, outFile)} (${entries.length} components)`); +} diff --git a/scripts/implicit-any-baseline.json b/scripts/implicit-any-baseline.json new file mode 100644 index 000000000..233cebdbd --- /dev/null +++ b/scripts/implicit-any-baseline.json @@ -0,0 +1,93 @@ +{ + "packages/angular-sdk-components/src/lib/_components/designSystemExtension/alert-banner/alert-banner.component.ts": 1, + "packages/angular-sdk-components/src/lib/_components/designSystemExtension/alert/alert.component.ts": 3, + "packages/angular-sdk-components/src/lib/_components/designSystemExtension/operator/operator.component.ts": 1, + "packages/angular-sdk-components/src/lib/_components/designSystemExtension/rich-text-editor/rich-text-editor.component.ts": 9, + "packages/angular-sdk-components/src/lib/_components/field/auto-complete/auto-complete.component.ts": 12, + "packages/angular-sdk-components/src/lib/_components/field/cancel-alert/cancel-alert.component.ts": 1, + "packages/angular-sdk-components/src/lib/_components/field/check-box/check-box.component.ts": 4, + "packages/angular-sdk-components/src/lib/_components/field/currency/currency.component.ts": 2, + "packages/angular-sdk-components/src/lib/_components/field/date-time/date-time.component.ts": 1, + "packages/angular-sdk-components/src/lib/_components/field/decimal/decimal.component.ts": 2, + "packages/angular-sdk-components/src/lib/_components/field/dropdown/dropdown.component.ts": 15, + "packages/angular-sdk-components/src/lib/_components/field/field.base.ts": 1, + "packages/angular-sdk-components/src/lib/_components/field/multiselect/multiselect.component.ts": 10, + "packages/angular-sdk-components/src/lib/_components/field/multiselect/utils.ts": 55, + "packages/angular-sdk-components/src/lib/_components/field/object-reference/object-reference.component.ts": 1, + "packages/angular-sdk-components/src/lib/_components/field/percentage/percentage.component.ts": 1, + "packages/angular-sdk-components/src/lib/_components/field/radio-buttons/radio-buttons.component.ts": 1, + "packages/angular-sdk-components/src/lib/_components/field/selectable-card/selectable-card.component.ts": 8, + "packages/angular-sdk-components/src/lib/_components/field/semantic-link/semantic-link.component.ts": 1, + "packages/angular-sdk-components/src/lib/_components/field/text/text.component.ts": 3, + "packages/angular-sdk-components/src/lib/_components/field/user-reference/user-reference.component.ts": 10, + "packages/angular-sdk-components/src/lib/_components/infra/action-buttons/action-buttons.component.ts": 2, + "packages/angular-sdk-components/src/lib/_components/infra/assignment-card/assignment-card.component.ts": 1, + "packages/angular-sdk-components/src/lib/_components/infra/assignment/assignment.component.ts": 11, + "packages/angular-sdk-components/src/lib/_components/infra/Containers/base-components/flow-container-base.component.ts": 1, + "packages/angular-sdk-components/src/lib/_components/infra/Containers/base-components/helper.ts": 5, + "packages/angular-sdk-components/src/lib/_components/infra/Containers/flow-container/flow-container.component.ts": 6, + "packages/angular-sdk-components/src/lib/_components/infra/Containers/flow-container/helpers.ts": 13, + "packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.ts": 28, + "packages/angular-sdk-components/src/lib/_components/infra/Containers/view-container/helper.ts": 2, + "packages/angular-sdk-components/src/lib/_components/infra/dashboard-filter/dashboard-filter.component.ts": 4, + "packages/angular-sdk-components/src/lib/_components/infra/defer-load/defer-load.component.ts": 4, + "packages/angular-sdk-components/src/lib/_components/infra/multi-step/multi-step.component.ts": 4, + "packages/angular-sdk-components/src/lib/_components/infra/view/view.component.ts": 4, + "packages/angular-sdk-components/src/lib/_components/template/advanced-search/advanced-search.component.ts": 1, + "packages/angular-sdk-components/src/lib/_components/template/advanced-search/search-group/persist-utils.ts": 3, + "packages/angular-sdk-components/src/lib/_components/template/advanced-search/search-groups/search-groups.component.ts": 14, + "packages/angular-sdk-components/src/lib/_components/template/base/details-template-base.ts": 1, + "packages/angular-sdk-components/src/lib/_components/template/case-summary/case-summary.component.ts": 6, + "packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.ts": 4, + "packages/angular-sdk-components/src/lib/_components/template/data-reference/data-reference.component.ts": 15, + "packages/angular-sdk-components/src/lib/_components/template/data-reference/search-form/search-form.component.ts": 9, + "packages/angular-sdk-components/src/lib/_components/template/data-reference/search-form/tabsData.ts": 31, + "packages/angular-sdk-components/src/lib/_components/template/data-reference/utils.ts": 14, + "packages/angular-sdk-components/src/lib/_components/template/default-form/default-form.component.ts": 1, + "packages/angular-sdk-components/src/lib/_components/template/details-sub-tabs/details-sub-tabs.component.ts": 1, + "packages/angular-sdk-components/src/lib/_components/template/dynamic-tabs/dynamic-tabs.component.ts": 2, + "packages/angular-sdk-components/src/lib/_components/template/field-group-list/field-group-list.component.ts": 3, + "packages/angular-sdk-components/src/lib/_components/template/field-group-template/field-group-template.component.ts": 4, + "packages/angular-sdk-components/src/lib/_components/template/inline-dashboard-page/inline-dashboard-page.component.ts": 1, + "packages/angular-sdk-components/src/lib/_components/template/list-view/DefaultViewMeta.ts": 6, + "packages/angular-sdk-components/src/lib/_components/template/list-view/list-view.component.ts": 106, + "packages/angular-sdk-components/src/lib/_components/template/list-view/listViewHelpers.ts": 4, + "packages/angular-sdk-components/src/lib/_components/template/list-view/utils.ts": 95, + "packages/angular-sdk-components/src/lib/_components/template/promoted-filters/promoted-filters.component.ts": 15, + "packages/angular-sdk-components/src/lib/_components/template/repeating-structures/repeating-structures.component.ts": 6, + "packages/angular-sdk-components/src/lib/_components/template/self-service-case-view/self-service-case-view.component.ts": 3, + "packages/angular-sdk-components/src/lib/_components/template/simple-table-manual/helpers.ts": 61, + "packages/angular-sdk-components/src/lib/_components/template/simple-table-manual/simple-table-manual.component.ts": 53, + "packages/angular-sdk-components/src/lib/_components/template/simple-table-select/simple-table-select.component.ts": 5, + "packages/angular-sdk-components/src/lib/_components/template/sub-tabs/sub-tabs.component.ts": 1, + "packages/angular-sdk-components/src/lib/_components/template/utils.ts": 13, + "packages/angular-sdk-components/src/lib/_components/widget/app-announcement/app-announcement.component.ts": 1, + "packages/angular-sdk-components/src/lib/_components/widget/attachment/attachment.component.ts": 23, + "packages/angular-sdk-components/src/lib/_components/widget/attachment/AttachmentUtils.ts": 5, + "packages/angular-sdk-components/src/lib/_components/widget/feed-container/feed-container.component.ts": 14, + "packages/angular-sdk-components/src/lib/_components/widget/file-utility/file-utility.component.ts": 32, + "packages/angular-sdk-components/src/lib/_components/widget/quick-create/quick-create.component.ts": 5, + "packages/angular-sdk-components/src/lib/_components/widget/todo/todo.component.ts": 14, + "packages/angular-sdk-components/src/lib/_directives/thousand-seperator.directive.ts": 1, + "packages/angular-sdk-components/src/lib/_helpers/case-utils.ts": 6, + "packages/angular-sdk-components/src/lib/_helpers/common.ts": 1, + "packages/angular-sdk-components/src/lib/_helpers/createstage-utils.ts": 3, + "packages/angular-sdk-components/src/lib/_helpers/currency-utils.ts": 2, + "packages/angular-sdk-components/src/lib/_helpers/event-util.ts": 4, + "packages/angular-sdk-components/src/lib/_helpers/field-group-utils.ts": 4, + "packages/angular-sdk-components/src/lib/_helpers/filter-utils.ts": 16, + "packages/angular-sdk-components/src/lib/_helpers/formatters/boolean.ts": 3, + "packages/angular-sdk-components/src/lib/_helpers/formatters/currency.ts": 16, + "packages/angular-sdk-components/src/lib/_helpers/formatters/date.ts": 15, + "packages/angular-sdk-components/src/lib/_helpers/formatters/format-utils.ts": 17, + "packages/angular-sdk-components/src/lib/_helpers/formatters/index.ts": 5, + "packages/angular-sdk-components/src/lib/_helpers/instructions-utils.ts": 14, + "packages/angular-sdk-components/src/lib/_helpers/objectReference-utils.ts": 28, + "packages/angular-sdk-components/src/lib/_helpers/semanticLink-utils.ts": 12, + "packages/angular-sdk-components/src/lib/_helpers/tab-utils.ts": 30, + "packages/angular-sdk-components/src/lib/_helpers/template-utils.ts": 2, + "packages/angular-sdk-components/src/lib/_helpers/utils.ts": 17, + "packages/angular-sdk-components/src/lib/_services/banner.service.ts": 2, + "packages/angular-sdk-components/src/lib/_services/data-reference-advanced-search.service.ts": 1, + "packages/angular-sdk-components/src/lib/_services/datapage.service.ts": 8 +} diff --git a/scripts/new-component.js b/scripts/new-component.js new file mode 100644 index 000000000..6ca96d6b7 --- /dev/null +++ b/scripts/new-component.js @@ -0,0 +1,194 @@ +/* + * Scaffolds an SDK component and performs the two registrations that are easy to forget: + * 1. export from packages/angular-sdk-components/src/public-api.ts + * 2. mapping in src/lib/_bridge/helpers/sdk-pega-component-map.ts + * + * Usage: npm run new:component -- + * Example: npm run new:component -- field star-rating StarRating + */ +const fs = require('node:fs'); +const path = require('node:path'); + +const KINDS = ['field', 'template', 'widget', 'infra', 'designSystemExtension']; +const [kind, kebab, pegaName] = process.argv.slice(2); + +if (!KINDS.includes(kind) || !/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(kebab || '') || !/^[A-Z][A-Za-z0-9]*$/.test(pegaName || '')) { + console.error('Usage: npm run new:component -- '); + process.exit(1); +} + +const srcRoot = path.resolve(__dirname, '..', 'packages', 'angular-sdk-components', 'src'); +const dir = path.join(srcRoot, 'lib', '_components', kind, kebab); +if (fs.existsSync(dir)) { + console.error(`${path.relative(process.cwd(), dir)} already exists`); + process.exit(1); +} + +const className = `${kebab + .split('-') + .map(p => p[0].toUpperCase() + p.slice(1)) + .join('')}Component`; +const selector = `app-${kebab}`; +const depth = '../../../'; + +const fieldTs = `import { ChangeDetectionStrategy, Component, forwardRef } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; + +import { FieldBase } from '../field.base'; +import { ComponentMapperComponent } from '${depth}_bridge/component-mapper/component-mapper.component'; +import { handleEvent } from '${depth}_helpers/event-util'; +import { PConnFieldProps } from '${depth}_types/PConnProps.interface'; + +interface ${className.replace('Component', '')}Props extends PConnFieldProps { + // additional props that only exist on this component +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: '${selector}', + templateUrl: './${kebab}.component.html', + styleUrls: ['./${kebab}.component.scss'], + imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, forwardRef(() => ComponentMapperComponent)] +}) +export class ${className} extends FieldBase { + configProps$: ${className.replace('Component', '')}Props; + + override updateSelf(): void { + this.configProps$ = this.pConn$.resolveConfigProps(this.pConn$.getConfigProps()) as ${className.replace('Component', '')}Props; + this.updateComponentCommonProperties(this.configProps$); + this.value$ = this.configProps$.value; + } + + fieldOnChange() { + this.pConn$.clearErrorMessages({ property: this.propName }); + } + + fieldOnBlur(event: any) { + handleEvent(this.actionsApi, 'changeNblur', this.propName, event?.target?.value); + } +} +`; + +const fieldHtml = `@if (displayMode$) { + @if (bVisible$ !== false) { + + } +} @else { + @if (bVisible$ && !bReadonly$ && bHasForm$) { +
+ + {{ label$ }} + + @if (fieldControl.invalid) { + {{ getErrorMessage() }} + } + +
+ } +} +`; + +const genericTs = `import { Component, Input, OnDestroy, OnInit, inject } from '@angular/core'; + +import { AngularPConnectData, AngularPConnectService } from '${depth}_bridge/angular-pconnect'; + +@Component({ + selector: '${selector}', + templateUrl: './${kebab}.component.html', + styleUrls: ['./${kebab}.component.scss'] +}) +export class ${className} implements OnInit, OnDestroy { + @Input() pConn$: typeof PConnect; + + private angularPConnect = inject(AngularPConnectService); + angularPConnectData: AngularPConnectData = {}; + configProps$: any; + + ngOnInit(): void { + this.angularPConnectData = this.angularPConnect.registerAndSubscribeComponent(this, this.onStateChange); + this.updateSelf(); + } + + ngOnDestroy(): void { + this.angularPConnectData.unsubscribeFn?.(); + } + + onStateChange() { + if (this.angularPConnect.shouldComponentUpdate(this)) { + this.updateSelf(); + } + } + + updateSelf() { + this.configProps$ = this.pConn$.resolveConfigProps(this.pConn$.getConfigProps()); + } +} +`; + +const genericHtml = `
{{ configProps$?.label }}
+`; + +const specTs = `import { ComponentFixture, TestBed } from '@angular/core/testing'; +${kind === 'field' ? "import { FormGroup } from '@angular/forms';\n" : ''} +import { createMockPConn } from '${'../'.repeat(4)}test-setup'; +import { ${className} } from './${kebab}.component'; + +describe('${className}', () => { + let fixture: ComponentFixture<${className}>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [${className}] }).compileComponents(); + fixture = TestBed.createComponent(${className}); + (fixture.componentInstance as any).pConn$ = createMockPConn(); + ${kind === 'field' ? '(fixture.componentInstance as any).formGroup$ = new FormGroup({});' : ''} + fixture.detectChanges(); + }); + + it('should create', () => { + expect(fixture.componentInstance).toBeTruthy(); + }); +}); +`.replace(/\n\s*\n\s*fixture\.detectChanges/, '\n fixture.detectChanges'); + +fs.mkdirSync(dir, { recursive: true }); +const isField = kind === 'field'; +fs.writeFileSync(path.join(dir, `${kebab}.component.ts`), isField ? fieldTs : genericTs); +fs.writeFileSync(path.join(dir, `${kebab}.component.html`), isField ? fieldHtml : genericHtml); +fs.writeFileSync(path.join(dir, `${kebab}.component.scss`), ''); +fs.writeFileSync(path.join(dir, `${kebab}.component.spec.ts`), specTs); + +// 1) public-api.ts: insert after the last export from the same kind folder (or append) +const apiPath = path.join(srcRoot, 'public-api.ts'); +const exportLine = `export * from './lib/_components/${kind}/${kebab}/${kebab}.component';`; +const apiLines = fs.readFileSync(apiPath, 'utf8').split('\n'); +let at = -1; +apiLines.forEach((l, i) => { + if (l.includes(`/_components/${kind}/`)) at = i; +}); +apiLines.splice(at >= 0 ? at + 1 : apiLines.length, 0, exportLine); +fs.writeFileSync(apiPath, apiLines.join('\n')); + +// 2) component map: import + entry +const mapPath = path.join(srcRoot, 'lib', '_bridge', 'helpers', 'sdk-pega-component-map.ts'); +let map = fs.readFileSync(mapPath, 'utf8'); +const importLine = `import { ${className} } from '../../_components/${kind}/${kebab}/${kebab}.component';\n`; +const firstEntry = map.indexOf('const pegaSdkComponentMap'); +const lastImportEnd = map.lastIndexOf('\nimport ', firstEntry); +const insertAt = map.indexOf('\n', lastImportEnd + 1) + 1; +map = map.slice(0, insertAt) + importLine + map.slice(insertAt); +map = map.replace(/\n};\n\nexport default pegaSdkComponentMap;/, `,\n ${pegaName}: ${className}\n};\n\nexport default pegaSdkComponentMap;`); +map = map.replace(/,,\n/g, ',\n'); +fs.writeFileSync(mapPath, map); + +console.log(`Created ${path.relative(process.cwd(), dir)} and registered ${className} as "${pegaName}".`); +console.log('Next: npm run fix, then build-angular-sdk-components && npm run api:update'); diff --git a/tsconfig.overrides-check.json b/tsconfig.overrides-check.json new file mode 100644 index 000000000..188093cbf --- /dev/null +++ b/tsconfig.overrides-check.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "paths": { + "@pega/angular-sdk-components": ["dist/angular-sdk-components"] + } + }, + "include": ["packages/angular-sdk-overrides/lib/**/*.ts"], + "exclude": ["packages/angular-sdk-overrides/lib/**/*.spec.ts"] +}