diff --git a/core-web/CLAUDE.md b/core-web/CLAUDE.md index d72e48a9a7c9..7a686e314521 100644 --- a/core-web/CLAUDE.md +++ b/core-web/CLAUDE.md @@ -20,7 +20,7 @@ Configured in `/.mcp.json`. Use these instead of guessing: pnpm nx serve dotcms-ui # Dev server (proxies /api/* to port 8080) pnpm nx build dotcms-ui # Build pnpm nx test {project} # Test specific project -pnpm nx test {project} --testPathPattern= # Test specific file +pnpm nx test {project} --testPathPatterns= # Test specific file (note the plural — Jest renamed it) pnpm nx lint {project} # Lint pnpm nx affected:test # Test only changed projects pnpm run test:dotcms # Test all @@ -98,6 +98,82 @@ Always wrap form fields with this structure for consistent styling: ``` +## TypeScript Strict Mode + +Strict mode is being rolled out **one project at a time** (epic #35932), bottom-up through the dependency graph. `tsconfig.base.json` stays at `"strict": false` — never flip it globally. + +To make a project strict: + +1. Add the flags to the **project's own** `tsconfig.json` (not `tsconfig.spec.json`, not the base): + + ```json + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + ``` + +2. Fix every error. No new `any` — use explicit types. To silence something unavoidable, use `@ts-expect-error` with a `// TODO(#issue):` note, never a blanket `@ts-ignore`. + +**What enforces this:** for Rollup libs that emit declarations (`"declaration": true`), `@rollup/plugin-typescript` is in the build chain and reports type errors, so the `build` target is the gate — CI runs `nx run-many -t build` (the `build-test` execution in `core-web/pom.xml`). Do **not** add a separate `typecheck` target to those projects; it is redundant. `lint` does not catch type errors — ESLint reports lint rules, not TS diagnostics. + +Vite-based projects are the exception: their builds use esbuild and skip type checking, which is why the Nx Vite plugin infers a separate `typecheck` target for them. + +Verify locally: + +```bash +pnpm exec tsc -p /tsconfig.lib.json --noEmit +pnpm exec nx run :build +pnpm exec nx affected -t build,lint --base=origin/main # check you didn't break consumers +``` + +`` is the path from `project.json`, which is often nested — e.g. `libs/sdk/create-app`, not `libs/create-app`. Two caveats on the tsconfig name: + +- **Apps** use `tsconfig.app.json`. +- **Some projects have no `tsconfig.lib.json`** (`libs/sdk/create-app` is one); use their `tsconfig.json` instead. + +Also check `tsconfig.spec.json` — the flags live in `tsconfig.json`, which the spec config extends, so specs go strict too and their errors are yours to fix. + +> **Watch out for masked results.** If a tsconfig declares a `types` entry that is not installed, `tsc` reports `TS2688: Cannot find type definition file for ''` and **stops before semantic checking** — you get one error and no type checking at all. A stable error count across a change proves nothing in that case. `libs/dotcms-js/tsconfig.spec.json` is affected today (`"types": ["jasmine"]`, and `@types/jasmine` is not installed in the workspace); check it with `--types node` to see real diagnostics. `apps/dotcms-block-editor` had the same defect in **three** of its configs — worth checking `tsconfig.editor.json` too, not just `spec`. +> +> A **`files` entry pointing at a file that does not exist** masks results the same way: `tsc` reports `TS6053: File '' not found` and aborts before semantic checking. Unlike a non-matching `include` glob — which is harmless — a missing `files` entry is fatal. This is what hid `libs/sdk/angular/tsconfig.spec.json` (it listed a `next/test-setup.ts` left over from a deleted directory), so that config had never completed a single semantic pass. Before trusting any error count, confirm `tsc` actually reached the code: a config-level error means it did not. +> +> **Deprecated options abort too.** The workspace runs TypeScript 6.x, which raises `TS5101` for `baseUrl` and `TS5107` for `moduleResolution: "node"` / `"node10"` unless `ignoreDeprecations: "6.0"` is set — and those are config-level errors, so they abort before semantic checking just like the two above. `libs/dotcms-webcomponents` reports **2** errors without the flag and **279** with it. It cannot set the option in its tsconfig, because Stencil bundles TypeScript 5.8.3, which only accepts `"5.0"`; pass `--ignoreDeprecations 6.0` on the CLI instead. +> +> **The general rule:** any error whose code starts `TS5` or `TS6`, or `TS2688`, is a *configuration* error. `tsc` never reached your code, so the count that follows means nothing. Read the first error before trusting the last number. +> +> **A silent fake zero: `include: []`.** All the aborts above at least *report* something. This one does not. Many project tsconfigs hold only `references` and delegate the real work to `tsconfig.lib.json` / `tsconfig.app.json` — `apps/dotcms-binary-field-builder/tsconfig.json` is one. Pointing `tsc -p` at that file compiles **nothing** and prints nothing, which reads exactly like a clean project. That app had *none* of the six flags while appearing to be at zero. **Measure `tsconfig.lib.json` for libraries and `tsconfig.app.json` for apps — never the project tsconfig that only holds `references`.** +> +> **A project with no `build` target has never had its templates checked.** `libs/edit-content` and `libs/block-editor` have only a `test` target. Both declare `strictTemplates` in `angularCompilerOptions`, and in both it is inert: nothing ever compiles their templates. Since `tsc -p` does not check templates either (see above), a library like this can be at 0 errors on both its configs and still have template type errors — a manual `nx run :build` of a consuming app found a real one in `block-editor`. Treat "0 errors" on a build-less library as covering its TypeScript only. +> +> **The repo's TypeScript is not always the strictest compiler in the build.** `libs/dotcms-webcomponents` type-checks twice: once by the workspace's `tsc` (6.0.3) and once by Stencil, which bundles its own 5.8.3. TypeScript 6 re-declared `Node.textContent` as an asymmetric accessor — `get(): string`, `set(value: string | null)` — so `element.textContent.replace(...)` is clean under 6 and `Object is possibly 'null'` under 5.8. The project reached **0** on `tsc -p` and the Stencil build still failed. Where two compilers check the same sources, the build is the gate; `tsc -p` at zero is a necessary condition, not a sufficient one. +> +> Related, and the reason that took two attempts to find: **verify a build by its exit status, never by grepping its output.** Stencil prints `transpile finished` and `build finished` for the phases that did succeed, so a grep for `finished` matches on a run that ends in `build failed` and exits non-zero. Four commits went in claiming a passing build on that basis. +> +> **`moduleResolution: node10` breaks Angular too, not just `@dotcms/*`.** A blast-radius sweep reported `libs/image-editor` at **996** spec errors; 335 of them were `TS2307: Cannot find module '@angular/common/http'`. Its `tsconfig.spec.json` carried `module: commonjs` + `moduleResolution: node10`, which cannot resolve subpath exports from *any* package. The real count was **8**. Unlike the aborts above this one produces a plausible-looking flood of code errors, so the tell is the first error, not the count: `TS2307` on a package that is obviously installed means the resolver, not the code. `libs/portlets/dot-agents` has the same config today (991 spec / 2 lib, unmeasured). +> +> **A Vite virtual module reads as one error per consumer.** `libs/sdk/client` imports `virtual:sdk-version`, which its Vite build injects and `tsc -p` cannot resolve. `libs/sdk/angular`, `libs/sdk/react` and `libs/sdk/vue` therefore each report exactly one `TS2307` that their real `build` target does not. Subtract it before comparing counts. +> +> **`SpyObject` cannot re-implement a union-returning method.** `@openng/spectator`'s mapped type routes every member through `T[P] extends (...args: any[]) => infer R ? ... : ...`. When `R` is a union — `string | string[] | undefined`, say — that conditional *distributes*, so the member's type becomes an **intersection** of `jest.Mock`s whose `mockImplementation` overloads demand `=> undefined`, `=> string` and `=> string[]` simultaneously. No implementation satisfies all three, and the error names an intersection the source never wrote. Reach the spy through one explicit signature (`store.m as unknown as jest.Mock`) rather than trying to satisfy it. +> +> **Flags interact across projects.** `libs/portlets/dot-query-tool` has `noImplicitReturns` *without* `strict`, and that combination caught a `TS7030` that `libs/edit-content` — which has `noImplicitReturns` too — did not, because its `strict` changes how a mixed `void`/teardown return is inferred. A clean `tsc -p` on the project you changed is not sufficient: re-measure the strict consumers as well. +> +> **A duplicate key in a tsconfig silently wins, and `tsc` does not warn.** `"strict": true` followed later in the same object by `"strict": false` leaves the project non-strict, with no diagnostic of any kind. Two projects on this branch (`libs/new-block-editor`, `libs/edit-content-bridge`) were closed as strict while being nothing of the kind, because the flags were added above a pre-existing `"strict": false`. The only thing that reported it was the **Angular compiler**, as a `Duplicate key "strict" in object literal` warning in an app build's output. After adding flags, read the whole `compilerOptions` block — do not just append. +> +> **The app build is the template gate even when `strictTemplates` is off.** It is `false` in all four apps (#35930), but a library's own `strictNullChecks` still applies to the expressions in its templates, and the Angular compiler is the only thing that evaluates them. `dotcms-binary-field-builder:build:production` found eight real errors in `libs/edit-content` and `libs/portlets/edit-ema/ui` after both measured 0 on `tsc -p`. For any library whose types feed a template, run a consuming app's production build before calling it done. +> +> **An ambient `.d.ts` only protects the project that includes it.** `libs/portlets/dot-experiments` declared the untyped `jstat` module in `src/jstat.d.ts`, reached through its own `include`. A strict consumer compiling those sources through a path mapping pulls in the import graph but *not* that sibling declaration, so `edit-ema/portlet` inherited a `TS7016` from a dependency measuring 0. Put such declarations in `core-web/types/` and wire them with a `paths` entry in `tsconfig.base.json`, which every project inherits (`htmldiff-js` and `jstat` are the precedents). A triple-slash reference also works but `@typescript-eslint/triple-slash-reference` forbids it. +> +> **An unresolved type name becomes `any` and inflates the count downstream.** Annotating a parameter with a type you forgot to import gives you one `TS2304` *and* a cascade of `TS7006`/`TS7031` on everything that reads it, because the annotation itself is `any`. Check `grep -cE 'TS2304|TS2552'` after every batch of annotations; a count that went *up* is usually this. Related: **an intersection with `any` is `any`** — one `declare global { interface Window { x: any } }` defeated every annotation downstream of it. +> +> **Removing an `any` raises the count before it lowers it.** Typing two parameters in `edit-ema/portlet` took it 213 → 239 → 194. Do not judge a fix by the immediate delta. +> +> **Nx silently runs a subset when a project name is wrong.** `nx run-many -t test -p edit-ema-portlet edit-ema-ui` ran only `edit-ema-ui` and exited **0** — the real name is `portlets-edit-ema-portlet`. There is no warning about the name that matched nothing. Confirm the summary names every project you asked for; `nx show projects | grep ` gets the real names. +> +> **`nx run :test` does not type-check — anywhere.** `jest-preset-angular` runs on ts-jest, and ts-jest copies TypeScript's `isolatedModules` into its own transpile-only switch (`config-set.js:229`), which stops it from building the language-service host it needs for diagnostics (`ts-compiler.js:74`). Since the Jest guidance below requires `isolatedModules: true` in every `tsconfig.spec.json`, **passing tests are never evidence that specs type-check.** `libs/data-access` proved it: 84 `tsc` errors alongside 754 green tests. Always verify specs with `tsc -p /tsconfig.spec.json --noEmit`. + ## Portlet Development New portlets go in `libs/portlets/`. For full patterns, architecture, testing, and Nx generator setup: @@ -110,8 +186,8 @@ New portlets go in `libs/portlets/`. For full patterns, architecture, testing, a - Use `dot-content-drive` portlet as reference for test config - `tsconfig.spec.json` tsconfig.spec.json must have "isolatedModules": true in compilerOptions -- `tsconfig.json` — do NOT add `"strict": true` or `"module": "preserve"` -- `tsconfig.spec.json` — keep minimal (only `module`, `target`, `types`) +- `tsconfig.json` — do NOT add `"module": "preserve"` +- `tsconfig.spec.json` — keep minimal (only `module`, `target`, `types`); do NOT add `"strict": true` here, it belongs in the project's `tsconfig.json` (see [TypeScript Strict Mode](#typescript-strict-mode)) - Import `mockProvider` from `@openng/spectator/jest` (not `@openng/spectator`) ### SignalStore Tests diff --git a/core-web/apps/dotcdn/src/app/app.component.ts b/core-web/apps/dotcdn/src/app/app.component.ts index 1af8828445b4..938a078d5ec6 100644 --- a/core-web/apps/dotcdn/src/app/app.component.ts +++ b/core-web/apps/dotcdn/src/app/app.component.ts @@ -25,8 +25,8 @@ export class AppComponent implements OnInit { private fb = inject(UntypedFormBuilder); private dotCdnStore = inject(DotCDNStore); - @ViewChild('chart', { static: true }) chart: UIChart; - purgeZoneForm: UntypedFormGroup; + @ViewChild('chart', { static: true }) chart!: UIChart; + purgeZoneForm!: UntypedFormGroup; periodValues: SelectItem[] = [ { label: 'Last 15 days', value: ChartPeriod.Last15Days }, { label: 'Last 30 days', value: ChartPeriod.Last30Days }, @@ -38,7 +38,7 @@ export class AppComponent implements OnInit { vmPurgeLoaders$: Observable> = this.dotCdnStore.vmPurgeLoaders$; chartHeight = '25rem'; - options: CdnChartOptions; + options!: CdnChartOptions; ngOnInit(): void { this.setChartOptions(); @@ -74,9 +74,9 @@ export class AppComponent implements OnInit { */ purgeUrls(): void { const urls: string[] = this.purgeZoneForm - .get('purgeUrlsTextArea') + .get('purgeUrlsTextArea')! .value.split('\n') - .map((url) => url.trim()); + .map((url: string) => url.trim()); this.dotCdnStore .purgeCDNCache(urls) @@ -109,7 +109,7 @@ export class AppComponent implements OnInit { display: true, position: 'left', ticks: { - callback: function (value: number): string { + callback: function (value: string | number): string { return value.toString() + 'MB'; } } @@ -131,10 +131,10 @@ export class AppComponent implements OnInit { scales: { ...defaultOptions.scales, x: { - ...defaultOptions.scales.x, + ...defaultOptions.scales?.['x'], ticks: { - callback: (value: number): string => { - return Math.round(value).toString(); + callback: (value: string | number): string => { + return Math.round(Number(value)).toString(); } } } diff --git a/core-web/apps/dotcdn/src/app/dotcdn.component.store.ts b/core-web/apps/dotcdn/src/app/dotcdn.component.store.ts index ef18f13f400a..354489303e46 100644 --- a/core-web/apps/dotcdn/src/app/dotcdn.component.store.ts +++ b/core-web/apps/dotcdn/src/app/dotcdn.component.store.ts @@ -145,6 +145,9 @@ export class DotCDNStore extends ComponentStore { ...state, isPurgeZoneLoading: action.loadingState === LoadingState.LOADING }; + + default: + return state; } } ); @@ -247,7 +250,7 @@ export class DotCDNStore extends ComponentStore { return { chartData, statsData, cdnDomain: stats.cdnDomain }; } - private formatDate(date) { + private formatDate(date: string) { return new Date(date).toLocaleDateString('en-GB', { month: '2-digit', day: '2-digit' diff --git a/core-web/apps/dotcdn/tsconfig.json b/core-web/apps/dotcdn/tsconfig.json index fd38bd926ae2..03e58a0181f9 100644 --- a/core-web/apps/dotcdn/tsconfig.json +++ b/core-web/apps/dotcdn/tsconfig.json @@ -3,6 +3,12 @@ "files": [], "include": [], "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, "types": ["jest", "node"], "target": "es2020", "module": "preserve", diff --git a/core-web/apps/dotcms-binary-field-builder/tsconfig.json b/core-web/apps/dotcms-binary-field-builder/tsconfig.json index 52f742f40c47..7a6df5bf3aa3 100644 --- a/core-web/apps/dotcms-binary-field-builder/tsconfig.json +++ b/core-web/apps/dotcms-binary-field-builder/tsconfig.json @@ -15,6 +15,12 @@ ], "compilerOptions": { "target": "es2020", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, "module": "preserve", "moduleResolution": "bundler", "lib": ["dom", "dom.iterable", "es2022"] diff --git a/core-web/apps/dotcms-block-editor/tsconfig.editor.json b/core-web/apps/dotcms-block-editor/tsconfig.editor.json index f0a09e817773..82c33482cdac 100644 --- a/core-web/apps/dotcms-block-editor/tsconfig.editor.json +++ b/core-web/apps/dotcms-block-editor/tsconfig.editor.json @@ -2,6 +2,8 @@ "extends": "./tsconfig.json", "include": ["**/*.ts"], "compilerOptions": { - "types": ["jasmine", "node"] + // See tsconfig.spec.json: @types/jasmine is not installed, so listing it here aborted + // this config with TS2688 before any semantic checking. + "types": ["node"] } } diff --git a/core-web/apps/dotcms-block-editor/tsconfig.json b/core-web/apps/dotcms-block-editor/tsconfig.json index 52f742f40c47..f0a84be293b6 100644 --- a/core-web/apps/dotcms-block-editor/tsconfig.json +++ b/core-web/apps/dotcms-block-editor/tsconfig.json @@ -17,6 +17,12 @@ "target": "es2020", "module": "preserve", "moduleResolution": "bundler", - "lib": ["dom", "dom.iterable", "es2022"] + "lib": ["dom", "dom.iterable", "es2022"], + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true } } diff --git a/core-web/apps/dotcms-block-editor/tsconfig.spec.json b/core-web/apps/dotcms-block-editor/tsconfig.spec.json index 0fa33287695a..83ed6a3f9524 100644 --- a/core-web/apps/dotcms-block-editor/tsconfig.spec.json +++ b/core-web/apps/dotcms-block-editor/tsconfig.spec.json @@ -2,7 +2,11 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "types": ["jasmine", "node"], + // `jasmine` was listed here, but @types/jasmine is not installed — nor are + // karma-jasmine or jasmine-core — so this config aborted with TS2688 before any + // semantic checking and had never actually type-checked anything. The app has no + // spec files, so `node` alone is the truthful set. + "types": ["node"], "target": "ES2022", "useDefineForClassFields": false, "moduleResolution": "bundler", diff --git a/core-web/apps/dotcms-ui/src/app/api/services/add-to-menu/add-to-menu.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/add-to-menu/add-to-menu.service.spec.ts index d3ae06b17a05..ec028a3a4da4 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/add-to-menu/add-to-menu.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/add-to-menu/add-to-menu.service.spec.ts @@ -51,7 +51,7 @@ describe('DotAddToMenuService', () => { }); it('should create a custom tool portlet', () => { - dotAddToMenuService.createCustomTool(customToolData).subscribe((response: string) => { + dotAddToMenuService.createCustomTool(customToolData).subscribe((response) => { expect(response).toEqual('ok'); }); @@ -69,7 +69,7 @@ describe('DotAddToMenuService', () => { it('should throw null on create custom tool error 400', () => { jest.spyOn(dotHttpErrorManagerService, 'handle'); - dotAddToMenuService.createCustomTool(customToolData).subscribe((response: string) => { + dotAddToMenuService.createCustomTool(customToolData).subscribe((response) => { expect(response).toEqual(null); }); @@ -82,7 +82,7 @@ describe('DotAddToMenuService', () => { it('should throw error 500 on create custom tool error', () => { jest.spyOn(dotHttpErrorManagerService, 'handle'); - dotAddToMenuService.createCustomTool(customToolData).subscribe((response: string) => { + dotAddToMenuService.createCustomTool(customToolData).subscribe((response) => { expect(response).toEqual(null); }); @@ -99,7 +99,7 @@ describe('DotAddToMenuService', () => { layoutId: '123' }; - dotAddToMenuService.addToLayout(layoutData).subscribe((response: string) => { + dotAddToMenuService.addToLayout(layoutData).subscribe((response) => { expect(response).toEqual('ok'); }); @@ -121,7 +121,7 @@ describe('DotAddToMenuService', () => { layoutId: '123' }; - dotAddToMenuService.addToLayout(layoutData).subscribe((response: string) => { + dotAddToMenuService.addToLayout(layoutData).subscribe((response) => { expect(response).toEqual(null); }); diff --git a/core-web/apps/dotcms-ui/src/app/api/services/add-to-menu/add-to-menu.service.ts b/core-web/apps/dotcms-ui/src/app/api/services/add-to-menu/add-to-menu.service.ts index f27c20a38b1a..94c40e01a893 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/add-to-menu/add-to-menu.service.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/add-to-menu/add-to-menu.service.ts @@ -47,10 +47,10 @@ export class DotAddToMenuService { * Creates a Custom tool portlet and returns the name of the portlet created * * @param {DotCreateCustomTool} params - * @returns Observable + * @returns Observable — null when the request failed * @memberof DotAddToMenuService */ - createCustomTool(params: DotCreateCustomTool): Observable { + createCustomTool(params: DotCreateCustomTool): Observable { return this.http .post>(`${addToMenuUrl}/custom`, { ...params, @@ -75,10 +75,10 @@ export class DotAddToMenuService { * Assigns a Custom tool portlet to a layout Id (menu) * * @param {DotCustomToolToLayout} params - * @returns Observable + * @returns Observable — null when the request failed * @memberof DotAddToMenuService */ - addToLayout(params: DotCustomToolToLayout): Observable { + addToLayout(params: DotCustomToolToLayout): Observable { const portletId = `${this.cleanUpPorletId(params.portletName)}_${params.dataViewMode}`; return this.http diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-account-service.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-account-service.ts index f85ff2c6fcbc..c1c7aba9f7b5 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/dot-account-service.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-account-service.ts @@ -44,10 +44,10 @@ export class DotAccountService { /** * Put request to add the getting starter portlet to menu * - * @returns {Observable} + * @returns {Observable} — null when the request failed * @memberof DotAccountService */ - addStarterPage(): Observable { + addStarterPage(): Observable { return this.http .put>('/api/v1/toolgroups/gettingstarted/_addtouser', {}) .pipe( @@ -64,10 +64,10 @@ export class DotAccountService { /** * put request to remove the getting starter portlet to menu * - * @returns {Observable} + * @returns {Observable} — null when the request failed * @memberof DotAccountService */ - removeStarterPage(): Observable { + removeStarterPage(): Observable { return this.http .put>('/api/v1/toolgroups/gettingstarted/_removefromuser', {}) .pipe( diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-containers/dot-containers.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-containers/dot-containers.service.spec.ts index 3c36484c7d60..17cb37de4563 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/dot-containers/dot-containers.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-containers/dot-containers.service.spec.ts @@ -65,7 +65,7 @@ describe('DotContainersService', () => { }); it('should get a list of containers', () => { - service.get().subscribe((container: DotContainerEntity[]) => { + service.get().subscribe((container) => { expect(container).toEqual([mockContainer]); }); @@ -79,7 +79,7 @@ describe('DotContainersService', () => { }); it('should get a container by id', () => { - service.getById('123').subscribe((containerEntity: DotContainerEntity) => { + service.getById('123').subscribe((containerEntity) => { expect(containerEntity).toEqual(mockContainer); }); @@ -95,7 +95,7 @@ describe('DotContainersService', () => { }); it('should get a containers by filter', () => { - service.getFiltered('123').subscribe((container: DotContainerEntity[]) => { + service.getFiltered('123').subscribe((container) => { expect(container).toEqual([mockContainer]); }); @@ -114,7 +114,7 @@ describe('DotContainersService', () => { title: '', friendlyName: '' } as DotContainerPayload) - .subscribe((container: DotContainerEntity) => { + .subscribe((container) => { expect(container).toEqual(mockContainer); }); @@ -154,8 +154,9 @@ describe('DotContainersService', () => { .saveAndPublish({ container: { name: '', friendlyName: '' }, contentTypes: [] - } as DotContainerEntity) - .subscribe((container: DotContainerEntity) => { + // Deliberately partial: this test asserts the request body, not the payload shape. + } as unknown as DotContainerEntity) + .subscribe((container) => { expect(container).toEqual(mockContainer); }); diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.spec.ts index 504338514899..14985e826eb6 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.spec.ts @@ -135,8 +135,8 @@ describe('DotCustomEventHandlerService', () => { router = TestBed.inject(Router); }; - const metadata = {}; - const metadata2 = {}; + const metadata: Record = {}; + const metadata2: Record = {}; metadata[FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED] = true; metadata2[FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED] = false; @@ -273,8 +273,6 @@ describe('DotCustomEventHandlerService', () => { it('should set colors in the ui', () => { jest.spyOn(dotUiColorsService, 'setColors'); - const fakeHtmlEl = { hello: 'html' }; - jest.spyOn(document, 'querySelector').mockReturnValue(fakeHtmlEl); service.handle( new CustomEvent('ng-event', { @@ -290,7 +288,9 @@ describe('DotCustomEventHandlerService', () => { } }) ); - expect(dotUiColorsService.setColors).toHaveBeenCalledWith(fakeHtmlEl, { + // The service reads `document.documentElement` rather than querying for it, so there is + // nothing to stub — the root element jsdom already provides is the one it colours. + expect(dotUiColorsService.setColors).toHaveBeenCalledWith(document.documentElement, { primary: '#fff', secondary: '#000', background: '#ccc' diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.ts index 0d3b472b6ab6..66d6343a1614 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.ts @@ -166,7 +166,7 @@ export class DotCustomEventHandlerService { this.dotNavLogoService.setLogo($event.detail.payload.navBarLogo); this.dotUiColorsService.setColors( - document.querySelector('html'), + document.documentElement, $event.detail.payload.colors ); this.dotIframeService.reloadColors(); diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-menu.service.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-menu.service.ts index 450b26aea39a..abe09dbcb0ab 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/dot-menu.service.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-menu.service.ts @@ -9,7 +9,8 @@ import { DotCMSResponse, DotMenu, DotMenuItem } from '@dotcms/dotcms-models'; @Injectable() export class DotMenuService { - menu$: Observable; + /** Null until the menu has been fetched, and again after `reloadMenu` invalidates it. */ + menu$: Observable | null = null; private urlMenus = '/api/v1/menu'; private readonly http = inject(HttpClient); @@ -88,8 +89,9 @@ export class DotMenuService { getDotMenuId(portletId: string): Observable { return this.loadMenu().pipe( mergeMap((menus: DotMenu[]) => menus), + // `find` emits `undefined` for a portlet id the loaded menu does not carry. find((menu: DotMenu) => menu.menuItems.some((menuItem) => menuItem.id === portletId)), - map((menu: DotMenu) => menu.id) + map((menu) => menu?.id ?? '') ); } diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-nav-logo/dot-nav-logo.service.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-nav-logo/dot-nav-logo.service.ts index 3b1ef433eea8..2090441ce2ea 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/dot-nav-logo/dot-nav-logo.service.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-nav-logo/dot-nav-logo.service.ts @@ -5,7 +5,11 @@ import { Injectable } from '@angular/core'; providedIn: 'root' }) export class DotNavLogoService { - navBarLogo$: BehaviorSubject = new BehaviorSubject(''); + /** + * `| null` because `setLogo` publishes null for anything that is not a `/dA` asset path — that + * is how "no custom logo" is spelled, and the nav header's template branches on it. + */ + navBarLogo$: BehaviorSubject = new BehaviorSubject(''); /** * Sets a logo for the nav bar @@ -14,7 +18,7 @@ export class DotNavLogoService { * @return {*} {void} * @memberof DotNavLogoService */ - setLogo(navLogo: string): void { + setLogo(navLogo: string | null): void { if (navLogo?.startsWith('/dA')) { this.navBarLogo$.next(this.setUrlProperty(navLogo)); } else { diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-parse-html/dot-parse-html.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-parse-html/dot-parse-html.service.spec.ts index 85382b673f4c..f6e778920256 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/dot-parse-html/dot-parse-html.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-parse-html/dot-parse-html.service.spec.ts @@ -13,7 +13,7 @@ class TestHostComponent {} describe('DotParseHtmlService', () => { let dotParseHtmlService: DotParseHtmlService; let fixture: ComponentFixture; - let target; + let target: HTMLDivElement; beforeEach(() => { TestBed.configureTestingModule({ diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-parse-html/dot-parse-html.service.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-parse-html/dot-parse-html.service.ts index 4772af0ab821..3eb22e9fdc2c 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/dot-parse-html/dot-parse-html.service.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-parse-html/dot-parse-html.service.ts @@ -31,7 +31,10 @@ export class DotParseHtmlService { const placeholder = document.createElement('div'); placeholder.innerHTML = code; - Array.from(placeholder.childNodes).forEach((el: HTMLElement) => { + Array.from(placeholder.childNodes).forEach((node) => { + // `childNodes` yields `ChildNode`, which covers the text nodes between tags — those + // have neither `tagName` nor `innerHTML`, and fall through to being appended as-is. + const el = node as HTMLElement; const parsedEl = this.isScriptElement(el.tagName) ? this.createScriptEl(el.innerHTML) : el; @@ -53,7 +56,7 @@ export class DotParseHtmlService { } private clearElement(element: HTMLElement): void { - Array.from(element.childNodes).forEach((child: HTMLElement) => { + Array.from(element.childNodes).forEach((child) => { this.renderer.removeChild(element, child); }); } diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-template-containers-cache/dot-template-containers-cache.service.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-template-containers-cache/dot-template-containers-cache.service.ts index c23d9fda637c..c6d1280777c9 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/dot-template-containers-cache/dot-template-containers-cache.service.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-template-containers-cache/dot-template-containers-cache.service.ts @@ -9,7 +9,7 @@ import { CONTAINER_SOURCE, DotContainer, DotContainerMap } from '@dotcms/dotcms- providedIn: 'root' }) export class DotTemplateContainersCacheService { - private containers: DotContainerMap; + private containers!: DotContainerMap; set(containers: DotContainerMap): void { this.containers = containers; @@ -27,8 +27,10 @@ export class DotTemplateContainersCacheService { * @memberof DotTemplateContainersCacheService */ getContainerReference(dotContainer: DotContainer): string { - return dotContainer.source === CONTAINER_SOURCE.FILE - ? dotContainer.path - : dotContainer.identifier; + return ( + (dotContainer.source === CONTAINER_SOURCE.FILE + ? dotContainer.path + : dotContainer.identifier) ?? '' + ); } } diff --git a/core-web/apps/dotcms-ui/src/app/api/services/guards/auth-guard.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/guards/auth-guard.service.spec.ts index a3376824f1e1..90c74cd8b82e 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/guards/auth-guard.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/guards/auth-guard.service.spec.ts @@ -13,7 +13,7 @@ import { DOTTestBed } from '../../../test/dot-test-bed'; @Injectable() class MockLoginService { - private _isLogin$: Observable; + private _isLogin$!: Observable; get isLogin$() { return this._isLogin$; } @@ -39,7 +39,7 @@ describe('ValidAuthGuardService', () => { }); it('should allow access to the requested route, User is logged in', () => { - let result: boolean; + let result: boolean | undefined; Object.defineProperty(loginService, 'isLogin$', { value: observableOf(true), writable: true @@ -51,7 +51,7 @@ describe('ValidAuthGuardService', () => { }); it('should denied access to the requested route, User is NOT logged in', () => { - let result: boolean; + let result: boolean | undefined; Object.defineProperty(loginService, 'isLogin$', { value: observableOf(false), writable: true diff --git a/core-web/apps/dotcms-ui/src/app/api/services/guards/contentlet-guard.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/guards/contentlet-guard.service.spec.ts index f97541694f32..87bf1aef0ff7 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/guards/contentlet-guard.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/guards/contentlet-guard.service.spec.ts @@ -47,14 +47,16 @@ describe('ValidContentletGuardService', () => { contentletGuardService = TestBed.inject(ContentletGuardService); dotContentletService = TestBed.inject(DotContentTypeService); dotNavigationService = TestBed.inject(DotNavigationService); - mockRouterStateSnapshot = jest.fn('RouterStateSnapshot', ['toString']); - mockActivatedRouteSnapshot = jest.fn('ActivatedRouteSnapshot', [ - 'toString' - ]); + // Minimal snapshots rather than `jest.fn(name, methods)`: that shape is + // `jasmine.createSpyObj` migrated mechanically, and `jest.fn` takes neither argument — it + // produced a `jest.Mock` standing in for a router snapshot, which is why these two + // declarations reported ~30 missing properties. The specs only ever read `url` and `params`. + mockRouterStateSnapshot = { url: '' } as RouterStateSnapshot; + mockActivatedRouteSnapshot = { params: {} } as ActivatedRouteSnapshot; }); it('should allow children access to Content Types Portlets', () => { - let result: boolean; + let result: boolean | undefined; mockActivatedRouteSnapshot.params = { id: 'banner' }; jest.spyOn(dotContentletService, 'isContentTypeInMenu').mockReturnValue(of(true)); contentletGuardService @@ -66,7 +68,7 @@ describe('ValidContentletGuardService', () => { }); it('should prevent children access to Content Types Portlets', () => { - let result: boolean; + let result: boolean | undefined; mockActivatedRouteSnapshot.params = { id: 'banner' }; jest.spyOn(dotContentletService, 'isContentTypeInMenu').mockReturnValue(of(false)); contentletGuardService diff --git a/core-web/apps/dotcms-ui/src/app/api/services/guards/contentlet-guard.service.ts b/core-web/apps/dotcms-ui/src/app/api/services/guards/contentlet-guard.service.ts index 0bf91ab5e6c6..ea5acc84dff2 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/guards/contentlet-guard.service.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/guards/contentlet-guard.service.ts @@ -21,7 +21,7 @@ export class ContentletGuardService implements CanActivateChild { route: ActivatedRouteSnapshot, _state: RouterStateSnapshot ): Observable { - return this.canAccessContentType(route.params.id); + return this.canAccessContentType(route.params['id']); } /** diff --git a/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.spec.ts index a9a3e996b52a..011aa937148f 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.spec.ts @@ -58,14 +58,16 @@ describe('ValidMenuGuardService', () => { dotMenuService = TestBed.inject(DotMenuService); dotRouterService = TestBed.inject(DotRouterService); dotNavigationService = TestBed.inject(DotNavigationService); - mockRouterStateSnapshot = jest.fn('RouterStateSnapshot', ['toString']); - mockActivatedRouteSnapshot = jest.fn('ActivatedRouteSnapshot', [ - 'toString' - ]); + // Minimal snapshots rather than `jest.fn(name, methods)`: that shape is + // `jasmine.createSpyObj` migrated mechanically, and `jest.fn` takes neither argument — it + // produced a `jest.Mock` standing in for a router snapshot, which is why these two + // declarations reported ~30 missing properties. The specs only ever read `url` and `params`. + mockRouterStateSnapshot = { url: '' } as RouterStateSnapshot; + mockActivatedRouteSnapshot = { params: {} } as ActivatedRouteSnapshot; }); it('should allow access to Menu Portlets', () => { - let result: boolean; + let result: boolean | undefined; mockRouterStateSnapshot.url = '/test'; jest.spyOn(dotMenuService, 'isPortletInMenu').mockReturnValue(observableOf(true)); menuGuardService @@ -77,7 +79,7 @@ describe('ValidMenuGuardService', () => { }); it('should prevent access to Menu Portlets', () => { - let result: boolean; + let result: boolean | undefined; mockRouterStateSnapshot.url = '/test'; jest.spyOn(dotMenuService, 'isPortletInMenu').mockReturnValue(observableOf(false)); menuGuardService @@ -90,7 +92,7 @@ describe('ValidMenuGuardService', () => { }); it('should allow children access to Menu Portlets', () => { - let result: boolean; + let result: boolean | undefined; mockRouterStateSnapshot.url = '/test'; jest.spyOn(dotMenuService, 'isPortletInMenu').mockReturnValue(observableOf(true)); menuGuardService @@ -102,7 +104,7 @@ describe('ValidMenuGuardService', () => { }); it('should prevent children access to Menu Portlets', () => { - let result: boolean; + let result: boolean | undefined; mockRouterStateSnapshot.url = '/test'; jest.spyOn(dotMenuService, 'isPortletInMenu').mockReturnValue(observableOf(false)); menuGuardService diff --git a/core-web/apps/dotcms-ui/src/app/api/services/guards/pages-guard.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/guards/pages-guard.service.spec.ts index 73dde72fe624..ae30d62dfca2 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/guards/pages-guard.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/guards/pages-guard.service.spec.ts @@ -31,7 +31,7 @@ describe('PagesGuardService', () => { }); it('should allow access to Pages Portlets', () => { - let result: boolean; + let result: boolean | undefined; jest.spyOn(dotPropertiesService, 'getFeatureFlag').mockReturnValue(of(true)); pagesGuardService.canActivate().subscribe((res) => (result = res)); expect(dotPropertiesService.getFeatureFlag).toHaveBeenCalledWith( @@ -41,7 +41,7 @@ describe('PagesGuardService', () => { }); it('should deny access to Pages Portlets', () => { - let result: boolean; + let result: boolean | undefined; jest.spyOn(dotPropertiesService, 'getFeatureFlag').mockReturnValue(of(false)); pagesGuardService.canActivate().subscribe((res) => (result = res)); expect(dotPropertiesService.getFeatureFlag).toHaveBeenCalledWith( diff --git a/core-web/apps/dotcms-ui/src/app/api/services/guards/public-auth-guard.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/guards/public-auth-guard.service.spec.ts index 5f1f7faf544c..e2f08c1031ae 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/guards/public-auth-guard.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/guards/public-auth-guard.service.spec.ts @@ -13,7 +13,7 @@ import { DOTTestBed } from '../../../test/dot-test-bed'; @Injectable() class MockLoginService { - private _isLogin$: Observable; + private _isLogin$!: Observable; get isLogin$() { return this._isLogin$; } @@ -37,14 +37,16 @@ describe('ValidPublicAuthGuardService', () => { publicAuthGuardService = TestBed.inject(PublicAuthGuardService); dotRouterService = TestBed.inject(DotRouterService); loginService = TestBed.inject(LoginService); - mockRouterStateSnapshot = jest.fn('RouterStateSnapshot', ['toString']); - mockActivatedRouteSnapshot = jest.fn('ActivatedRouteSnapshot', [ - 'toString' - ]); + // Minimal snapshots rather than `jest.fn(name, methods)`: that shape is + // `jasmine.createSpyObj` migrated mechanically, and `jest.fn` takes neither argument — it + // produced a `jest.Mock` standing in for a router snapshot, which is why these two + // declarations reported ~30 missing properties. The specs only ever read `url` and `params`. + mockRouterStateSnapshot = { url: '' } as RouterStateSnapshot; + mockActivatedRouteSnapshot = { params: {} } as ActivatedRouteSnapshot; }); it('should redirect to to Main Portlet if User is logged in', () => { - let result: boolean; + let result: boolean | undefined; Object.defineProperty(loginService, 'isLogin$', { value: observableOf(true), writable: true @@ -57,7 +59,7 @@ describe('ValidPublicAuthGuardService', () => { }); it('should allow access to the requested route if User is NOT logged in', () => { - let result: boolean; + let result: boolean | undefined; Object.defineProperty(loginService, 'isLogin$', { value: observableOf(false), writable: true diff --git a/core-web/apps/dotcms-ui/src/app/api/util/ColorUtil.ts b/core-web/apps/dotcms-ui/src/app/api/util/ColorUtil.ts index cb03d78aef1a..a7b72b8d2e87 100644 --- a/core-web/apps/dotcms-ui/src/app/api/util/ColorUtil.ts +++ b/core-web/apps/dotcms-ui/src/app/api/util/ColorUtil.ts @@ -12,7 +12,7 @@ export class ColorUtil { * @return brightness value from 0 to 255, where 255 is brigthest. * @see http://www.webmasterworld.com/forum88/9769.htm */ - public getBrightness(color): number { + public getBrightness(color: string): number { const isHexCode = color.indexOf('#') !== -1; if (isHexCode) { @@ -34,7 +34,7 @@ export class ColorUtil { * @param color color to check, it could be in hex or rgb format */ - public isBrightness(color): boolean { + public isBrightness(color: string): boolean { return this.getBrightness(color) > 138; } @@ -42,14 +42,22 @@ export class ColorUtil { * Convert RGB color format to hex color format, for example, if you have rgb(0,0,0) return #000 * @see http://stackoverflow.com/questions/1740700/get-hex-value-rather-than-rgb-value-using-jquery */ - public rgb2hex(rgb): string { + public rgb2hex(rgb: string): string { if (rgb.search('rgb') === -1) { return rgb; - } else { - rgb = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(\.\d+)?))?\)$/); + } + + // Held in a local rather than reassigned over the parameter, which is how a + // `RegExpMatchArray` came to be sitting in a `string`. A value containing "rgb" that is not + // a well-formed `rgb()`/`rgba()` does not match, and used to throw on `parts[1]`; it now + // comes back unchanged, like anything else this cannot convert. + const parts = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(\.\d+)?))?\)$/); - return '#' + this.hex(rgb[1]) + this.hex(rgb[2]) + this.hex(rgb[3]); + if (!parts) { + return rgb; } + + return '#' + this.hex(parts[1]) + this.hex(parts[2]) + this.hex(parts[3]); } private hex(x: string): string { diff --git a/core-web/apps/dotcms-ui/src/app/api/util/stringFormat.ts b/core-web/apps/dotcms-ui/src/app/api/util/stringFormat.ts index 7c2351bf2bc3..0641bce1f166 100644 --- a/core-web/apps/dotcms-ui/src/app/api/util/stringFormat.ts +++ b/core-web/apps/dotcms-ui/src/app/api/util/stringFormat.ts @@ -2,13 +2,18 @@ import { Injectable } from '@angular/core'; @Injectable() export class StringFormat { public formatMessage(s: string, ...args: string[]): string { - if (s) { - for (let i = 0; i < args.length - 1; i++) { - const reg = new RegExp('\\{' + i + '\\}', 'gm'); - s = s.replace(reg, args[i]); - } + if (!s) { + return ''; + } - return s; + // NOTE: `args.length - 1` leaves the last argument unsubstituted — with a single argument + // the loop never runs at all. Left as it stands: changing which placeholders get filled is + // behaviour, not types. + for (let i = 0; i < args.length - 1; i++) { + const reg = new RegExp('\\{' + i + '\\}', 'gm'); + s = s.replace(reg, args[i]); } + + return s; } } diff --git a/core-web/apps/dotcms-ui/src/app/app.component.ts b/core-web/apps/dotcms-ui/src/app/app.component.ts index f6c46bbfd9d1..495719c2531e 100644 --- a/core-web/apps/dotcms-ui/src/app/app.component.ts +++ b/core-web/apps/dotcms-ui/src/app/app.component.ts @@ -11,8 +11,7 @@ import { DotMessageService, DotUiColorsService } from '@dotcms/data-access'; -import { ConfigParams, DotcmsConfigService, DotUiColors } from '@dotcms/dotcms-js'; -import { DotLicense } from '@dotcms/dotcms-models'; +import { ConfigParams, DotcmsConfigService } from '@dotcms/dotcms-js'; import { DotNavLogoService } from './api/services/dot-nav-logo/dot-nav-logo.service'; import { DotAlertConfirmComponent } from './view/components/_common/dot-alert-confirm/dot-alert-confirm'; @@ -58,17 +57,9 @@ export class AppComponent implements OnInit { }) ) .subscribe( - ({ - buildDate, - colors, - navBar, - license - }: { - buildDate: string | null; - colors: DotUiColors; - navBar: string | null; - license: DotLicense | null; - }) => { + // Not annotated: the two branches of the pipe above emit different literal shapes, + // and a parameter wider than either is what strictFunctionTypes rejects. + ({ buildDate, colors, navBar, license }) => { // Initialize services with loaded or default values if (buildDate) { this.dotMessageService.init({ buildDate }); diff --git a/core-web/apps/dotcms-ui/src/app/app.routes.ts b/core-web/apps/dotcms-ui/src/app/app.routes.ts index 3a9bbf571073..b0f4a053df56 100644 --- a/core-web/apps/dotcms-ui/src/app/app.routes.ts +++ b/core-web/apps/dotcms-ui/src/app/app.routes.ts @@ -121,7 +121,7 @@ const PORTLETS_ANGULAR: Route[] = [ }, resolve: { uveConfig: (route: ActivatedRouteSnapshot) => { - return inject(EmaAppConfigurationService).get(route.queryParams.url); + return inject(EmaAppConfigurationService).get(route.queryParams['url']); } }, loadChildren: () => import('@dotcms/portlets/dot-ema').then((m) => m.dotEmaRoutes) diff --git a/core-web/apps/dotcms-ui/src/app/components.ts b/core-web/apps/dotcms-ui/src/app/components.ts deleted file mode 100644 index 984de70fa7ee..000000000000 --- a/core-web/apps/dotcms-ui/src/app/components.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { DotContentCompareComponent } from '@dotcms/portlets/dot-ema/ui'; -import { DotIconComponent } from '@dotcms/ui'; - -import { AppComponent } from './app.component'; -import { DotActionButtonComponent } from './view/components/_common/dot-action-button/dot-action-button.component'; -import { DotAlertConfirmComponent } from './view/components/_common/dot-alert-confirm/dot-alert-confirm'; -import { DotDownloadBundleDialogComponent } from './view/components/_common/dot-download-bundle-dialog/dot-download-bundle-dialog.component'; -import { DotGenerateSecurePasswordComponent } from './view/components/_common/dot-generate-secure-password/dot-generate-secure-password.component'; -import { DotPushPublishDialogComponent } from './view/components/_common/dot-push-publish-dialog/dot-push-publish-dialog.component'; -import { DotSiteSelectorComponent } from './view/components/_common/dot-site-selector/dot-site-selector.component'; -import { DotTextareaContentComponent } from './view/components/_common/dot-textarea-content/dot-textarea-content.component'; -import { DotWizardComponent } from './view/components/_common/dot-wizard/dot-wizard.component'; -import { IframeComponent } from './view/components/_common/iframe/iframe-component/iframe.component'; -import { IframePortletLegacyComponent } from './view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component'; -import { SearchableDropdownComponent } from './view/components/_common/searchable-dropdown/component/searchable-dropdown.component'; -import { DotEditContentletComponent } from './view/components/dot-contentlet-editor/components/dot-edit-contentlet/dot-edit-contentlet.component'; -import { DotCrumbtrailComponent } from './view/components/dot-crumbtrail/dot-crumbtrail.component'; -import { DotLargeMessageDisplayComponent } from './view/components/dot-large-message-display/dot-large-message-display.component'; -import { DotListingDataTableComponent } from './view/components/dot-listing-data-table/dot-listing-data-table.component'; -import { DotMessageDisplayComponent } from './view/components/dot-message-display/dot-message-display.component'; -import { DotToolbarComponent } from './view/components/dot-toolbar/dot-toolbar.component'; -import { DotWorkflowTaskDetailComponent } from './view/components/dot-workflow-task-detail/dot-workflow-task-detail.component'; -import { GlobalSearchComponent } from './view/components/global-search/global-search'; -import { DotLogOutContainerComponent } from './view/components/login/dot-logout-container-component/dot-log-out-container'; -import { DotLoginPageComponent } from './view/components/login/main/dot-login-page.component'; -import { MainCoreLegacyComponent } from './view/components/main-core-legacy/main-core-legacy-component'; -import { MainComponentLegacyComponent } from './view/components/main-legacy/main-legacy.component'; - -// Non-standalone components (traditional NgModule components) -export const COMPONENTS = [DotLogOutContainerComponent, GlobalSearchComponent]; - -// Standalone components (migrated to standalone) -export const STANDALONE_COMPONENTS = [ - AppComponent, - MainComponentLegacyComponent, - MainCoreLegacyComponent, - DotAlertConfirmComponent, - DotLoginPageComponent, - DotToolbarComponent, - DotActionButtonComponent, - DotEditContentletComponent, - DotIconComponent, - DotTextareaContentComponent, - DotWorkflowTaskDetailComponent, - DotMessageDisplayComponent, - IframeComponent, - IframePortletLegacyComponent, - DotListingDataTableComponent, - SearchableDropdownComponent, - DotSiteSelectorComponent, - DotLargeMessageDisplayComponent, - DotPushPublishDialogComponent, - DotContentCompareComponent, - DotDownloadBundleDialogComponent, - DotWizardComponent, - DotGenerateSecurePasswordComponent, - DotCrumbtrailComponent -]; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail-resolver.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail-resolver.service.ts index 60feeb236d7a..1d98f1dc7a97 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail-resolver.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail-resolver.service.ts @@ -8,8 +8,12 @@ import { DotApp } from '@dotcms/dotcms-models'; const DOT_AI_APP_KEY = 'dotAI'; -export const dotAiConfigDetailResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { +export const dotAiConfigDetailResolver: ResolveFn = ( + route: ActivatedRouteSnapshot +) => { const id = route.paramMap.get('id'); - return inject(DotAppsService).getConfiguration(DOT_AI_APP_KEY, id).pipe(take(1)); + return inject(DotAppsService) + .getConfiguration(DOT_AI_APP_KEY, id ?? '') + .pipe(take(1)); }; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts index 1be836f36531..f584105643a1 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts @@ -89,7 +89,7 @@ export class DotAiConfigDetailComponent implements OnInit { ngOnInit(): void { this.route.data .pipe( - map((x) => x?.data), + map((x) => x?.['data']), takeUntilDestroyed(this.destroyRef) ) .subscribe((app: DotApp) => { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.spec.ts index 5b3b080536a9..5bf251edad08 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.spec.ts @@ -12,10 +12,12 @@ import { Select, SelectModule } from 'primeng/select'; import { TextareaModule } from 'primeng/textarea'; import { TooltipModule } from 'primeng/tooltip'; +import { DotAppsSecret } from '@dotcms/dotcms-models'; import { DotFieldRequiredDirective } from '@dotcms/ui'; import { DotAppsConfigurationDetailFormComponent } from './dot-apps-configuration-detail-form.component'; +import { aliasedProps } from '../../../../../../test/spectator-aliased-props'; import { DotAppsConfigurationDetailGeneratedStringFieldComponent } from '../dot-apps-configuration-detail-generated-string-field/dot-apps-configuration-detail-generated-string-field.component'; const headingSecret = { @@ -143,11 +145,15 @@ const formState = { name: secrets[0].value, password: secrets[1].value, enabled: JSON.parse(secrets[2].value), - select: secrets[3].options[0].value, + select: secrets[3].options![0].value, integration: secrets[4].value, generatedString: secrets[5].value }; +/** `formFields` is the alias of `$formFields` — see {@link aliasedProps}. */ +const aliasedFormFields = (formFields: DotAppsSecret[]) => + aliasedProps({ formFields }); + describe('DotAppsConfigurationDetailFormComponent', () => { let spectator: Spectator; const createComponent = createComponentFactory({ @@ -172,9 +178,7 @@ describe('DotAppsConfigurationDetailFormComponent', () => { describe('Without warnings', () => { beforeEach(() => { spectator = createComponent({ - props: { - formFields: secrets - } as unknown + props: aliasedFormFields(secrets) }); spectator.detectChanges(); }); @@ -192,9 +196,7 @@ describe('DotAppsConfigurationDetailFormComponent', () => { it('should focus the first form field when form fields are available', async () => { // Create component with formFields const spectatorWithFields = createComponent({ - props: { - formFields: secrets - } as unknown + props: aliasedFormFields(secrets) }); spectatorWithFields.detectChanges(); await spectatorWithFields.fixture.whenStable(); @@ -213,27 +215,27 @@ describe('DotAppsConfigurationDetailFormComponent', () => { }); it('should load Label, Textarea & Hint with right attributes', () => { - const row = spectator.query(byTestId('name')); + const row = spectator.query(byTestId('name'))!; const markdownElement = row.querySelector('markdown'); expect(markdownElement).toBeTruthy(); const field = secrets[0]; - const labelElement = row.querySelector('label'); + const labelElement = row.querySelector('label')!; expect(labelElement.textContent.trim()).toBe(field.label); expect(labelElement.classList).toContain('p-label-input-required'); - const textareaElement = row.querySelector('textarea'); + const textareaElement = row.querySelector('textarea')!; expect(textareaElement.getAttribute('id')).toBe(field.name); expect(textareaElement.value).toBe(field.value); - const hintElement = row.querySelector('.p-field-hint'); + const hintElement = row.querySelector('.p-field-hint')!; expect(hintElement.textContent).toBe(field.hint); }); it('should load Checkbox & Hint with right attributes', () => { - const row = spectator.query(byTestId('enabled')); + const row = spectator.query(byTestId('enabled'))!; const markdownElement = row.querySelector('markdown'); expect(markdownElement).toBeTruthy(); @@ -243,52 +245,52 @@ describe('DotAppsConfigurationDetailFormComponent', () => { const checkboxElement = row.querySelector('p-checkbox'); expect(checkboxElement).toBeTruthy(); - const labelElement = row.querySelector('label'); + const labelElement = row.querySelector('label')!; expect(labelElement.textContent).toContain(field.label); - const inputElement = row.querySelector('input'); + const inputElement = row.querySelector('input')!; expect(inputElement.id).toBe(field.name); - const hintElement = row.querySelector('.p-field-hint'); + const hintElement = row.querySelector('.p-field-hint')!; expect(hintElement.textContent).toBe(field.hint); }); it('should load Label, Select & Hint with right attributes', () => { - const row = spectator.query(byTestId('select')); + const row = spectator.query(byTestId('select'))!; const markdownElement = row.querySelector('markdown'); expect(markdownElement).toBeTruthy(); const field = secrets[3]; - const labelElement = row.querySelector('label'); + const labelElement = row.querySelector('label')!; expect(labelElement.textContent.trim()).toBe(field.label); - const selectComponent = spectator.query(Select); + const selectComponent = spectator.query(Select)!; expect(selectComponent.id).toBe(field.name); expect(selectComponent.options).toBe(field.options); - const hintElement = row.querySelector('.p-field-hint'); + const hintElement = row.querySelector('.p-field-hint')!; expect(hintElement.textContent).toBe(field.hint); }); it('should load Label, Button & Hint with right attributes', () => { - const row = spectator.query(byTestId('integration')); + const row = spectator.query(byTestId('integration'))!; const field = secrets[4]; - const labelElement = row.querySelector('label'); + const labelElement = row.querySelector('label')!; expect(labelElement.textContent.trim()).toBe(field.label); - const buttonElement = row.querySelector('button'); + const buttonElement = row.querySelector('button')!; expect(buttonElement.id).toBe(field.name); - const hintElement = row.querySelector('.form__group-hint'); + const hintElement = row.querySelector('.form__group-hint')!; expect(hintElement.textContent).toBe(field.hint); }); it('should load Generated String Field component with right attributes', () => { - const row = spectator.query(byTestId('generated-string-field')); + const row = spectator.query(byTestId('generated-string-field'))!; expect(row).toBeTruthy(); expect( @@ -297,8 +299,8 @@ describe('DotAppsConfigurationDetailFormComponent', () => { }); it('should Button be disabled when no configured app', () => { - const row = spectator.query(byTestId('integration')); - const buttonElement = row.querySelector('button'); + const row = spectator.query(byTestId('integration'))!; + const buttonElement = row.querySelector('button')!; expect(buttonElement.disabled).toBe(true); }); @@ -310,8 +312,8 @@ describe('DotAppsConfigurationDetailFormComponent', () => { const openMock = jest.fn(); window.open = openMock; - const row = spectator.query(byTestId('integration')); - const buttonElement = row.querySelector('button'); + const row = spectator.query(byTestId('integration'))!; + const buttonElement = row.querySelector('button')!; buttonElement.click(); expect(openMock).toHaveBeenCalledWith(field.value, '_blank'); @@ -335,9 +337,9 @@ describe('DotAppsConfigurationDetailFormComponent', () => { const spyDataOutput = jest.spyOn(spectator.component.data, 'emit'); const spyValidOutput = jest.spyOn(spectator.component.valid, 'emit'); - spectator.component.myFormGroup.get('name').setValue('Test2'); - spectator.component.myFormGroup.get('password').setValue('Password2'); - spectator.component.myFormGroup.get('enabled').setValue('false'); + spectator.component.myFormGroup.get('name')!.setValue('Test2'); + spectator.component.myFormGroup.get('password')!.setValue('Password2'); + spectator.component.myFormGroup.get('enabled')!.setValue('false'); expect(spyDataOutput).toHaveBeenCalledTimes(3); expect(spyValidOutput).toHaveBeenCalledTimes(3); @@ -345,23 +347,23 @@ describe('DotAppsConfigurationDetailFormComponent', () => { it('should render HEADING field as section header with label text', () => { const spectatorWithHeading = createComponent({ - props: { formFields: [headingSecret, ...secrets] } as unknown + props: aliasedFormFields([headingSecret, ...secrets]) }); spectatorWithHeading.detectChanges(); - const header = spectatorWithHeading.query('[data-testid="sectionHeader"]'); + const header = spectatorWithHeading.query('[data-testid="sectionHeader"]')!; expect(header).toBeTruthy(); expect(header.classList).toContain('dot-apps-configuration-detail__section-header'); - expect(header.querySelector('h3').textContent.trim()).toBe(headingSecret.label); + expect(header.querySelector('h3')!.textContent.trim()).toBe(headingSecret.label); }); it('should render INFO field as info box with hint text', () => { const spectatorWithInfo = createComponent({ - props: { formFields: [infoSecret, ...secrets] } as unknown + props: aliasedFormFields([infoSecret, ...secrets]) }); spectatorWithInfo.detectChanges(); - const infoBox = spectatorWithInfo.query('[data-testid="infoBox"]'); + const infoBox = spectatorWithInfo.query('[data-testid="infoBox"]')!; expect(infoBox).toBeTruthy(); expect(infoBox.classList).toContain('dot-apps-configuration-detail__info-box'); expect(infoBox.querySelector('markdown')).toBeTruthy(); @@ -369,9 +371,7 @@ describe('DotAppsConfigurationDetailFormComponent', () => { it('should not add HEADING or INFO fields to the form group', () => { const spectatorWithExtra = createComponent({ - props: { - formFields: [headingSecret, infoSecret, ...secrets] - } as unknown + props: aliasedFormFields([headingSecret, infoSecret, ...secrets]) }); spectatorWithExtra.detectChanges(); @@ -382,7 +382,7 @@ describe('DotAppsConfigurationDetailFormComponent', () => { it('should emit form state disabled when required field empty', () => { const spyValidOutput = jest.spyOn(spectator.component.valid, 'emit'); - spectator.component.myFormGroup.get('name').setValue(''); + spectator.component.myFormGroup.get('name')!.setValue(''); expect(spyValidOutput).toHaveBeenCalledWith(false); expect(spyValidOutput).toHaveBeenCalledTimes(1); }); @@ -391,18 +391,9 @@ describe('DotAppsConfigurationDetailFormComponent', () => { describe('With warnings', () => { beforeEach(() => { spectator = createComponent({ - props: { - formFields: secrets.map((item, i) => { - if (i < 3) { - return { - ...item, - warnings: [`error ${i}`] - }; - } - - return item; - }) - } as unknown + props: aliasedFormFields( + secrets.map((item, i) => (i < 3 ? { ...item, warnings: [`error ${i}`] } : item)) + ) }); spectator.detectChanges(); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.ts index 1f1f6d00cb4f..f6b26a9b6451 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.ts @@ -41,6 +41,13 @@ enum FieldStatus { DISABLED_WITH_MESSAGE } +/** + * What a secret contributes to its form control: a plain value, or the + * `{ value, disabled }` form a reactive control also accepts — which is what the `STRING` + * transform below returns so the control can be disabled at construction. + */ +type DotAppsFieldValue = string | boolean | { value: string; disabled: boolean }; + @Component({ selector: 'dot-apps-configuration-detail-form', templateUrl: './dot-apps-configuration-detail-form.component.html', @@ -95,7 +102,7 @@ export class DotAppsConfigurationDetailFormComponent implements OnInit, OnDestro } ngOnInit() { - const group = {}; + const group: Record = {}; this.$formFields() .filter((field: DotAppsSecret) => field.type !== 'HEADING' && field.type !== 'INFO') @@ -125,12 +132,15 @@ export class DotAppsConfigurationDetailFormComponent implements OnInit, OnDestro window.open(url, '_blank'); } - private getFieldValueFn = { + private getFieldValueFn: Record< + string, + ((field: DotAppsSecret, status: FieldStatus) => DotAppsFieldValue) | undefined + > = { BOOL: (field: DotAppsSecret) => { return field.value ? JSON.parse(field.value) : field.value; }, SELECT: (field: DotAppsSecret) => { - return field.value === '' ? field.options[0].value : field.value; + return field.value === '' ? (field.options?.[0]?.value ?? '') : field.value; }, STRING: (field: DotAppsSecret, status: FieldStatus) => { const fieldValue = @@ -146,10 +156,11 @@ export class DotAppsConfigurationDetailFormComponent implements OnInit, OnDestro } }; - private getFieldValue(field: DotAppsSecret, status: FieldStatus): string | boolean { - return this.getFieldValueFn[field.type] - ? this.getFieldValueFn[field.type](field, status) - : field.value; + private getFieldValue(field: DotAppsSecret, status: FieldStatus): DotAppsFieldValue { + // Keyed by the server's field type, of which only the three above have a transform. + const transform = this.getFieldValueFn[field.type]; + + return transform ? transform(field, status) : field.value; } private emitValues(): void { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-generated-string-field/dot-apps-configuration-detail-generated-string-field.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-generated-string-field/dot-apps-configuration-detail-generated-string-field.component.spec.ts index bb76741786f1..cdf4f8899a03 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-generated-string-field/dot-apps-configuration-detail-generated-string-field.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-generated-string-field/dot-apps-configuration-detail-generated-string-field.component.spec.ts @@ -40,6 +40,17 @@ describe('DotAppsConfigurationDetailGeneratedStringFieldComponent', () => { detectChanges: false }); + /** + * The generate button, asserted present. Every test that clicks it has already rendered the + * component, so a missing button is a spec failure rather than a value each caller re-checks. + */ + const generateButton = (): Element => { + const button = spectator.query(byTestId('generate-button')); + expect(button).toBeTruthy(); + + return button as Element; + }; + beforeEach(() => { spectator = createComponent(); confirmationService = spectator.inject(ConfirmationService); @@ -73,7 +84,7 @@ describe('DotAppsConfigurationDetailGeneratedStringFieldComponent', () => { spectator.component.$value.set(''); spectator.detectChanges(); - const button = spectator.query(byTestId('generate-button')); + const button = generateButton(); spectator.click(button); expect(httpClient.get).toHaveBeenCalledWith(mockField.buttonEndpoint, { @@ -93,7 +104,7 @@ describe('DotAppsConfigurationDetailGeneratedStringFieldComponent', () => { spectator.detectChanges(); spectator.component.$value.set('existing-value'); - const button = spectator.query(byTestId('generate-button')); + const button = generateButton(); // Act - Click the generate button to show dialog spectator.click(button); @@ -109,7 +120,7 @@ describe('DotAppsConfigurationDetailGeneratedStringFieldComponent', () => { expect(yesButton).toBeTruthy(); // Real click on the Yes button - spectator.click(yesButton); + spectator.click(yesButton!); spectator.detectChanges(); // Assert @@ -128,7 +139,7 @@ describe('DotAppsConfigurationDetailGeneratedStringFieldComponent', () => { // Create spy for httpClient.get to verify it's not called const httpGetSpy = jest.spyOn(httpClient, 'get'); - const button = spectator.query(byTestId('generate-button')); + const button = generateButton(); // Act - Click the generate button to show dialog spectator.click(button); @@ -144,7 +155,7 @@ describe('DotAppsConfigurationDetailGeneratedStringFieldComponent', () => { expect(noButton).toBeTruthy(); // Real click on the No button - spectator.click(noButton); + spectator.click(noButton!); spectator.detectChanges(); // Assert @@ -160,7 +171,7 @@ describe('DotAppsConfigurationDetailGeneratedStringFieldComponent', () => { spectator.detectChanges(); spectator.component.$value.set(''); // Empty input - const button = spectator.query(byTestId('generate-button')); + const button = generateButton(); // Act spectator.click(button); @@ -200,7 +211,7 @@ describe('DotAppsConfigurationDetailGeneratedStringFieldComponent', () => { return confirmationService; }); - const button = spectator.query(byTestId('generate-button')); + const button = generateButton(); // Act spectator.click(button); @@ -232,7 +243,7 @@ describe('DotAppsConfigurationDetailGeneratedStringFieldComponent', () => { return confirmationService; }); - const button = spectator.query(byTestId('generate-button')); + const button = generateButton(); // Act spectator.click(button); @@ -258,7 +269,7 @@ describe('DotAppsConfigurationDetailGeneratedStringFieldComponent', () => { spectator.detectChanges(); spectator.component.$value.set(''); // Empty input to bypass confirmation - const button = spectator.query(byTestId('generate-button')); + const button = generateButton(); // Act spectator.click(button); @@ -282,7 +293,7 @@ describe('DotAppsConfigurationDetailGeneratedStringFieldComponent', () => { spectator.detectChanges(); spectator.component.$value.set(''); // Empty input to bypass confirmation - const button = spectator.query(byTestId('generate-button')); + const button = generateButton(); // Act spectator.click(button); @@ -351,7 +362,7 @@ describe('DotAppsConfigurationDetailGeneratedStringFieldComponent', () => { spectator.detectChanges(); spectator.component.$value.set(''); // Empty input to bypass confirmation - const button = spectator.query(byTestId('generate-button')); + const button = generateButton(); // Act spectator.click(button); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-header/dot-apps-configuration-header.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-header/dot-apps-configuration-header.component.spec.ts index 10dd4a83ecf9..34cbcfb116c7 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-header/dot-apps-configuration-header.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-header/dot-apps-configuration-header.component.spec.ts @@ -37,7 +37,7 @@ class MockMarkdownComponent {} standalone: false }) class TestHostComponent { - app: DotApp; + app!: DotApp; } const messages = { @@ -126,8 +126,8 @@ describe('DotAppsConfigurationHeaderComponent', () => { expect( de.query(By.css('.dot-apps-configuration__configurations')).nativeElement.textContent ).toContain(`${appData.configurationsCount} ${messages['apps.configurations']}`); - const description = component.app.description - .replace(/\n/gi, '') + const description = component.app + .description!.replace(/\n/gi, '') .replace(/\r/gi, '') .replace(/ {3}/gi, ''); expect( diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/dot-apps-configuration-detail.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/dot-apps-configuration-detail.component.html index 9aee466d6cc7..d850bfe5ceca 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/dot-apps-configuration-detail.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/dot-apps-configuration-detail.component.html @@ -2,7 +2,7 @@
- {{ apps.sites[0].name }} + {{ apps.sites?.[0]?.name }}
@@ -36,7 +36,7 @@ (edit)="gotoConfiguration($event)" (export)="openExportDialog($event)" (delete)="deleteConfiguration($event)" - [siteConfigurations]="app.sites" + [siteConfigurations]="app?.sites" [hideLoadDataButton]="!$showMoreData()" [itemsPerPage]="paginationPerPage" />
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration/dot-apps-configuration.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration/dot-apps-configuration.component.spec.ts index 98fd8ee04a19..1da9c0d10bb2 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration/dot-apps-configuration.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration/dot-apps-configuration.component.spec.ts @@ -168,12 +168,12 @@ describe('DotAppsConfigurationComponent', () => { }); it('should set App from resolver', () => { - expect(component.$app().key).toBe(appData.key); - expect(component.$app().name).toBe(appData.name); + expect(component.$app()!.key).toBe(appData.key); + expect(component.$app()!.name).toBe(appData.name); }); it('should set onInit Pagination Service with right values', () => { - expect(paginationService.url).toBe(`v1/apps/${component.$app().key}`); + expect(paginationService.url).toBe(`v1/apps/${component.$app()!.key}`); expect(paginationService.paginationPerPage).toBe(component.$paginationPerPage()); expect(paginationService.sortField).toBe('name'); expect(paginationService.sortOrder).toBe(1); @@ -210,7 +210,7 @@ describe('DotAppsConfigurationComponent', () => { By.css('dot-apps-configuration-list') ).componentInstance; fixture.detectChanges(); - expect(listComp.siteConfigurations()).toEqual(component.$app().sites); + expect(listComp.siteConfigurations()).toEqual(component.$app()!.sites); expect(listComp.itemsPerPage()).toBe(component.$paginationPerPage()); }); @@ -232,7 +232,7 @@ describe('DotAppsConfigurationComponent', () => { ).componentInstance; listComp.edit.emit(sites[0]); expect(routerService.goToUpdateAppsConfiguration).toHaveBeenCalledWith( - component.$app().key, + component.$app()!.key, sites[0] ); }); @@ -252,14 +252,16 @@ describe('DotAppsConfigurationComponent', () => { )[1]; jest.spyOn(dialogService, 'confirm').mockImplementation((conf) => { - conf.accept(); + conf.accept!(); }); jest.spyOn(appsServices, 'deleteAllConfigurations').mockReturnValue(of(null)); deleteAllBtn.triggerEventHandler('click', null); expect(dialogService.confirm).toHaveBeenCalledTimes(1); - expect(appsServices.deleteAllConfigurations).toHaveBeenCalledWith(component.$app().key); + expect(appsServices.deleteAllConfigurations).toHaveBeenCalledWith( + component.$app()!.key + ); expect(appsServices.deleteAllConfigurations).toHaveBeenCalledTimes(1); }); @@ -280,7 +282,7 @@ describe('DotAppsConfigurationComponent', () => { listComp.delete.emit(sites[0]); expect(appsServices.deleteConfiguration).toHaveBeenCalledWith( - component.$app().key, + component.$app()!.key, sites[0].id ); }); @@ -289,8 +291,8 @@ describe('DotAppsConfigurationComponent', () => { // Clear the spy to only count calls from this specific test setExtraParamsSpy.mockClear(); - component.$searchInputElement().nativeElement.value = 'test'; - component.$searchInputElement().nativeElement.dispatchEvent(new Event('keyup')); + component.$searchInputElement()!.nativeElement.value = 'test'; + component.$searchInputElement()!.nativeElement.dispatchEvent(new Event('keyup')); tick(550); expect(setExtraParamsSpy).toHaveBeenCalledWith('filter', 'test'); expect(setExtraParamsSpy).toHaveBeenCalledTimes(1); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration/dot-apps-configuration.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration/dot-apps-configuration.component.ts index b2e8a745be03..d1c538ceef38 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration/dot-apps-configuration.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration/dot-apps-configuration.component.ts @@ -63,7 +63,11 @@ export class DotAppsConfigurationComponent implements OnInit, AfterViewInit { $searchInputElement = viewChild>('searchInput'); - $state = signalState({ + $state = signalState<{ + app: DotApp | null; + paginationPerPage: number; + totalRecords: number; + }>({ app: null, paginationPerPage: 40, totalRecords: 0 @@ -85,7 +89,7 @@ export class DotAppsConfigurationComponent implements OnInit, AfterViewInit { ngOnInit() { this.#route.data .pipe( - map((x) => x?.data), + map((x) => x?.['data']), take(1) ) .subscribe((app: DotApp) => { @@ -124,13 +128,15 @@ export class DotAppsConfigurationComponent implements OnInit, AfterViewInit { */ loadData(event?: LazyLoadEvent): void { this.paginationService - .getWithOffset((event && event.first) || 0) + .getWithOffset((event && event.first) || 0) .pipe(take(1)) .subscribe((app: DotApp) => { patchState(this.$state, { app: { ...app, - sites: event ? this.$state().app.sites.concat(app.sites) : app.sites, + sites: event + ? [...(this.$app()?.sites ?? []), ...(app.sites ?? [])] + : app.sites, configurationsCount: app.configurationsCount }, totalRecords: this.paginationService.totalRecords @@ -142,7 +148,13 @@ export class DotAppsConfigurationComponent implements OnInit, AfterViewInit { * Redirects to create/edit configuration site page */ gotoConfiguration(site: DotAppsSite): void { - this.#dotRouterService.goToUpdateAppsConfiguration(this.$app().key, site); + const app = this.$app(); + + if (!app) { + return; + } + + this.#dotRouterService.goToUpdateAppsConfiguration(app.key, site); } /** @@ -163,13 +175,19 @@ export class DotAppsConfigurationComponent implements OnInit, AfterViewInit { * Delete a specific configuration */ deleteConfiguration(site: DotAppsSite): void { + const app = this.$app(); + + if (!app) { + return; + } + this.#dotAppsService - .deleteConfiguration(this.$app().key, site.id) + .deleteConfiguration(app.key, site.id) .pipe(take(1)) .subscribe(() => { patchState(this.$state, { app: { - ...this.$app(), + ...app, sites: [] } }); @@ -181,15 +199,21 @@ export class DotAppsConfigurationComponent implements OnInit, AfterViewInit { * Display confirmation dialog to delete all configurations */ deleteAllConfigurations(): void { + const app = this.$app(); + + if (!app) { + return; + } + this.#dotAlertConfirmService.confirm({ accept: () => { this.#dotAppsService - .deleteAllConfigurations(this.$app().key) + .deleteAllConfigurations(app.key) .pipe(take(1)) .subscribe(() => { patchState(this.$state, { app: { - ...this.$app(), + ...app, sites: [] } }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/dot-apps-import-export-dialog.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/dot-apps-import-export-dialog.component.spec.ts index a74d45904f5c..6d6253e8774f 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/dot-apps-import-export-dialog.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/dot-apps-import-export-dialog.component.spec.ts @@ -34,7 +34,9 @@ describe('DotAppsImportExportDialogComponent', () => { errorMessage: signal(null), dialogHeaderKey: signal(''), isLoading: signal(false), - status: signal(ComponentStatus.INIT), + // Annotated for the same reason as `statusSignal` below: bare, this infers the literal + // `'INIT'` because `ComponentStatus` is an `as const` object. + status: signal(ComponentStatus.INIT), close: jest.fn(), exportConfiguration: jest.fn(), importConfiguration: jest.fn() @@ -78,7 +80,9 @@ describe('DotAppsImportExportDialogComponent', () => { errorMessageSignal = signal(null); dialogHeaderKeySignal = signal(''); isLoadingSignal = signal(false); - statusSignal = signal(ComponentStatus.INIT); + // Annotated: `ComponentStatus` is an `as const` object, so a bare `signal(ComponentStatus.INIT)` + // infers `WritableSignal<'INIT'>` and cannot stand in for the store's signal. + statusSignal = signal(ComponentStatus.INIT); mockStore.visible = visibleSignal; mockStore.action = actionSignal; @@ -126,21 +130,21 @@ describe('DotAppsImportExportDialogComponent', () => { }); it('should have accept button disabled when form is invalid', () => { - expect(spectator.component.dialogActions.accept.disabled).toBe(true); + expect(spectator.component.dialogActions.accept!.disabled).toBe(true); }); it('should enable accept button when form is valid', () => { spectator.component.form.setValue({ password: 'test123' }); spectator.detectChanges(); - expect(spectator.component.dialogActions.accept.disabled).toBe(false); + expect(spectator.component.dialogActions.accept!.disabled).toBe(false); }); it('should call store.exportConfiguration when accept action is triggered', () => { spectator.component.form.setValue({ password: 'test123' }); spectator.detectChanges(); - spectator.component.dialogActions.accept.action(); + spectator.component.dialogActions.accept!.action!(); expect(mockStore.exportConfiguration).toHaveBeenCalledWith({ password: 'test123' }); }); @@ -148,14 +152,14 @@ describe('DotAppsImportExportDialogComponent', () => { it('should call closeDialog when cancel action is triggered', () => { jest.spyOn(spectator.component, 'closeDialog'); - spectator.component.dialogActions.cancel.action(); + spectator.component.dialogActions.cancel!.action!(); expect(spectator.component.closeDialog).toHaveBeenCalled(); }); it('should have correct dialog action labels', () => { - expect(spectator.component.dialogActions.accept.label).toBe('Accept'); - expect(spectator.component.dialogActions.cancel.label).toBe('Cancel'); + expect(spectator.component.dialogActions.accept!.label).toBe('Accept'); + expect(spectator.component.dialogActions.cancel!.label).toBe('Cancel'); }); }); @@ -179,7 +183,7 @@ describe('DotAppsImportExportDialogComponent', () => { }); it('should have accept button disabled when form is invalid', () => { - expect(spectator.component.dialogActions.accept.disabled).toBe(true); + expect(spectator.component.dialogActions.accept!.disabled).toBe(true); }); it('should render file upload component', () => { @@ -220,7 +224,7 @@ describe('DotAppsImportExportDialogComponent', () => { spectator.component.form.controls['password'].setValue('test123'); spectator.detectChanges(); - spectator.component.dialogActions.accept.action(); + spectator.component.dialogActions.accept!.action!(); expect(mockStore.importConfiguration).toHaveBeenCalledWith({ file: mockFile, @@ -232,7 +236,7 @@ describe('DotAppsImportExportDialogComponent', () => { spectator.component.form.controls['password'].setValue('test123'); spectator.detectChanges(); - spectator.component.dialogActions.accept.action(); + spectator.component.dialogActions.accept!.action!(); expect(mockStore.importConfiguration).not.toHaveBeenCalled(); }); @@ -296,7 +300,7 @@ describe('DotAppsImportExportDialogComponent', () => { // Trigger form value change to update disabled state spectator.component.form.updateValueAndValidity(); - expect(spectator.component.dialogActions.accept.disabled).toBe(true); + expect(spectator.component.dialogActions.accept!.disabled).toBe(true); }); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/dot-apps-import-export-dialog.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/dot-apps-import-export-dialog.component.ts index 89e8c5ed456e..05002e15ebbb 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/dot-apps-import-export-dialog.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/dot-apps-import-export-dialog.component.ts @@ -50,7 +50,11 @@ export class DotAppsImportExportDialogComponent { readonly isLoading = this.#store.isLoading; form: UntypedFormGroup = this.#fb.group({}); - dialogActions: DotDialogActions; + /** + * `accept` is required here even though `DotDialogActions` declares it optional: this component + * always builds one with a label, and updates its `disabled` flag by spreading it. + */ + dialogActions!: DotDialogActions & Required>; #selectedFile: File | null = null; // Effect to react to action changes to setup the form diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/store/dot-apps-import-export-dialog.store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/store/dot-apps-import-export-dialog.store.ts index 8b7c6fe76eab..7b3ff5dff3ea 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/store/dot-apps-import-export-dialog.store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/store/dot-apps-import-export-dialog.store.ts @@ -77,7 +77,12 @@ export const DotAppsImportExportDialogStore = signalStore( /** * Open the export dialog */ - openExport: (app: DotApp, site?: DotAppsSite) => { + /** + * `app` is nullable because "export all" is expressed by passing none — the export + * effect below reads it as `exportAll: app ? false : true`. Declaring it non-null is + * what forced the export-all caller into a `null as unknown as DotApp` cast. + */ + openExport: (app: DotApp | null, site?: DotAppsSite) => { patchState(store, { visible: true, action: dialogAction.EXPORT, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-list/dot-apps-list.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-list/dot-apps-list.component.ts index 70bfb1f30f78..0a2f743f1da6 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-list/dot-apps-list.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-list/dot-apps-list.component.ts @@ -74,8 +74,11 @@ export class DotAppsListComponent implements AfterViewInit { map((data) => data['dotAppsListResolverData']), takeUntilDestroyed(this.#destroyRef) ) - .subscribe((apps: DotApp[]) => { - this.initAppsState(apps); + .subscribe((apps: DotApp[] | null) => { + // `null` arrives when the request failed; the list keeps what it had. + if (apps) { + this.initAppsState(apps); + } }); } @@ -98,7 +101,7 @@ export class DotAppsListComponent implements AfterViewInit { */ openExportDialog(): void { // For export all, we don't pass an app - the store handles this - this.#dialogStore.openExport(null as unknown as DotApp); + this.#dialogStore.openExport(null); } /** @@ -115,8 +118,11 @@ export class DotAppsListComponent implements AfterViewInit { this.#dotAppsService .get() .pipe(take(1)) - .subscribe((apps: DotApp[]) => { - this.initAppsState(apps); + .subscribe((apps: DotApp[] | null) => { + // `null` arrives when the request failed; the list keeps what it had. + if (apps) { + this.initAppsState(apps); + } }); } @@ -143,9 +149,9 @@ export class DotAppsListComponent implements AfterViewInit { } private filterApps(searchCriteria?: string): void { - this.#dotAppsService.get(searchCriteria).subscribe((apps: DotApp[]) => { + this.#dotAppsService.get(searchCriteria).subscribe((apps: DotApp[] | null) => { patchState(this.state, { - displayedApps: apps + displayedApps: apps ?? [] }); }); } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/services/dot-apps-configuration-detail-resolver/dot-apps-configuration-detail-resolver.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/services/dot-apps-configuration-detail-resolver/dot-apps-configuration-detail-resolver.service.ts index bc0b98c86296..e19f9e1f203b 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/services/dot-apps-configuration-detail-resolver/dot-apps-configuration-detail-resolver.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/services/dot-apps-configuration-detail-resolver/dot-apps-configuration-detail-resolver.service.ts @@ -16,13 +16,13 @@ import { DotApp } from '@dotcms/dotcms-models'; * @implements {Resolve} */ @Injectable() -export class DotAppsConfigurationDetailResolver implements Resolve { +export class DotAppsConfigurationDetailResolver implements Resolve { private dotAppsService = inject(DotAppsService); - resolve(route: ActivatedRouteSnapshot): Observable { + resolve(route: ActivatedRouteSnapshot): Observable { const appKey = route.paramMap.get('appKey'); const id = route.paramMap.get('id'); - return this.dotAppsService.getConfiguration(appKey, id).pipe(take(1)); + return this.dotAppsService.getConfiguration(appKey ?? '', id ?? '').pipe(take(1)); } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/services/dot-apps-configuration-resolver/dot-apps-configuration-resolver.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/services/dot-apps-configuration-resolver/dot-apps-configuration-resolver.service.ts index 7db1159123b9..b34eb47fd23d 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/services/dot-apps-configuration-resolver/dot-apps-configuration-resolver.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/services/dot-apps-configuration-resolver/dot-apps-configuration-resolver.service.ts @@ -17,16 +17,20 @@ import { GlobalStore } from '@dotcms/store'; * @implements {Resolve>} */ @Injectable() -export class DotAppsConfigurationResolver implements Resolve> { +export class DotAppsConfigurationResolver implements Resolve { private dotAppsService = inject(DotAppsService); readonly #globalStore = inject(GlobalStore); - resolve(route: ActivatedRouteSnapshot): Observable { + resolve(route: ActivatedRouteSnapshot): Observable { const appsKey = route.paramMap.get('appKey'); - return this.dotAppsService.getConfigurationList(appsKey).pipe( + return this.dotAppsService.getConfigurationList(appsKey ?? '').pipe( take(1), tap((apps) => { + if (!apps) { + return; + } + this.#globalStore.addNewBreadcrumb({ label: apps.name, target: '_self', diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/services/dot-apps-list-resolver/dot-apps-list-resolver.service.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/services/dot-apps-list-resolver/dot-apps-list-resolver.service.spec.ts index bc140635a856..648d5e2c4f1a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/services/dot-apps-list-resolver/dot-apps-list-resolver.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/services/dot-apps-list-resolver/dot-apps-list-resolver.service.spec.ts @@ -39,9 +39,11 @@ describe('DotAppsListResolver', () => { it('should get and return apps list', () => { jest.spyOn(dotAppsService, 'get').mockReturnValue(of(appsResponse)); - dotAppsListResolver.resolve(activatedRouteSnapshotMock).subscribe((apps: DotApp[]) => { - expect(apps).toEqual(appsResponse); - }); + dotAppsListResolver + .resolve(activatedRouteSnapshotMock) + .subscribe((apps: DotApp[] | null) => { + expect(apps).toEqual(appsResponse); + }); expect(dotAppsService.get).toHaveBeenCalledTimes(1); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/services/dot-apps-list-resolver/dot-apps-list-resolver.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/services/dot-apps-list-resolver/dot-apps-list-resolver.service.ts index 4697b0c78a9c..80b841e99f5a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/services/dot-apps-list-resolver/dot-apps-list-resolver.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/services/dot-apps-list-resolver/dot-apps-list-resolver.service.ts @@ -16,10 +16,10 @@ import { DotApp } from '@dotcms/dotcms-models'; * @implements {Resolve} */ @Injectable() -export class DotAppsListResolver implements Resolve { +export class DotAppsListResolver implements Resolve { private dotAppsService = inject(DotAppsService); - resolve(_route: ActivatedRouteSnapshot): Observable { + resolve(_route: ActivatedRouteSnapshot): Observable { return this.dotAppsService.get().pipe(take(1)); } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.spec.ts index d368267f50ab..b8dae1db366a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.spec.ts @@ -46,6 +46,7 @@ import { import { CONTAINER_SOURCE, DotActionBulkResult, + DotCMSResponse, DotContainer, DotSite } from '@dotcms/dotcms-models'; @@ -223,7 +224,7 @@ class ActivatedRouteMock { template: '' }) class MockDotContentTypeSelectorComponent { - @Input() value: SelectItem; + @Input() value!: SelectItem; @Output() selected = new EventEmitter(); } @@ -357,7 +358,7 @@ describe('ContainerListComponent', () => { }); it('should set actions to publish template', () => { - const publishedContainer = containersMock.find((c) => c.identifier === '123Published'); + const publishedContainer = containersMock.find((c) => c.identifier === '123Published')!; const actions = setBasicOptions(); actions.push({ menuItem: { label: 'Unpublish', command: expect.any(Function) } @@ -381,11 +382,11 @@ describe('ContainerListComponent', () => { menuItem: { label: 'Duplicate', command: expect.any(Function) } }); - expect(comp.setContainerActions(unpublishedContainer)).toEqual(actions); + expect(comp.setContainerActions(unpublishedContainer!)).toEqual(actions); }); it('should set actions to archived template', () => { - const archivedContainer = containersMock.find((c) => c.identifier === '123Archived'); + const archivedContainer = containersMock.find((c) => c.identifier === '123Archived')!; const actions = [ { menuItem: { label: 'Unarchive', command: expect.any(Function) } }, @@ -407,7 +408,7 @@ describe('ContainerListComponent', () => { comp.handleActionMenuOpen({} as MouseEvent); - menu.model[0].command({ + menu.model![0].command!({ originalEvent: createFakeEvent('click') }); expect(store['dotContainersService'].publish).toHaveBeenCalledWith([ @@ -431,10 +432,11 @@ describe('ContainerListComponent', () => { }); it('should click on file container and move on Browser Screen', () => { - const fileContainer = containersMock.find((c) => c.identifier === 'FILE_CONTAINER'); + const fileContainer = containersMock.find((c) => c.identifier === 'FILE_CONTAINER')!; // Spy on the store's methods since it's now using component-level providers jest.spyOn(store['dotSiteBrowserService'], 'setSelectedFolder').mockReturnValue( - of(null) + // Only the call is asserted below, so an empty response stands in for the body. + of({ entity: {} } as DotCMSResponse>) ); jest.spyOn(store['dotRouterService'], 'goToSiteBrowser'); @@ -443,7 +445,7 @@ describe('ContainerListComponent', () => { fixture.detectChanges(); expect(store['dotSiteBrowserService'].setSelectedFolder).toHaveBeenCalledWith( - fileContainer.pathName + fileContainer!.pathName ); expect(store['dotSiteBrowserService'].setSelectedFolder).toHaveBeenCalledTimes(1); expect(store['dotRouterService'].goToSiteBrowser).toHaveBeenCalledTimes(1); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.ts index ede2d41ffdd7..3e010db1a332 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.ts @@ -135,7 +135,7 @@ export class ContainerListComponent implements OnDestroy { * @memberof ContainerListComponent */ getContainerState({ live, working, deleted }: DotContainer): DotContentState { - return { live, working, deleted, hasLiveVersion: live }; + return { live: live ?? false, working: working ?? false, deleted, hasLiveVersion: live }; } /** @@ -250,11 +250,11 @@ export class ContainerListComponent implements OnDestroy { } private notifyResult( - response: DotActionBulkResult | DotContainer, - failsInfo: DotBulkFailItem[], + response: DotActionBulkResult | DotContainer | undefined, + failsInfo: DotBulkFailItem[] | undefined, message: string ): void { - if ('fails' in response && failsInfo?.length) { + if (response && 'fails' in response && failsInfo?.length) { this.showErrorDialog({ ...response, fails: failsInfo, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/store/dot-container-list.store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/store/dot-container-list.store.ts index 4b4322cd031a..8d496de9ad3c 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/store/dot-container-list.store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/store/dot-container-list.store.ts @@ -31,7 +31,7 @@ import { DotListingDataTableComponent } from '../../../../view/components/dot-li export interface DotContainerListState { containerBulkActions: MenuItem[]; selectedContainers: DotContainer[]; - addToBundleIdentifier: string; + addToBundleIdentifier: string | null; actionHeaderOptions: ActionHeaderOptions; tableColumns: DataTableColumn[]; isEnterprise: boolean; @@ -45,7 +45,8 @@ export interface DotContainerListState { } export interface DotNotifyMessages { - payload: DotActionBulkResult | DotContainer; + /** Absent in the seeded state — see the comment on `notifyMessages` in `loadContainers`. */ + payload?: DotActionBulkResult | DotContainer; message: string; failsInfo?: DotBulkFailItem[]; } @@ -66,7 +67,7 @@ export class DotContainerListStore extends ComponentStore private dotSiteService = inject(SiteService); constructor() { - super(null); + super(); this.paginatorService.url = CONTAINERS_URL; this.paginatorService.paginationPerPage = 40; @@ -80,7 +81,7 @@ export class DotContainerListStore extends ComponentStore this.paginatorService.setExtraParams('host', identifier); return this.route.data.pipe( - map((x) => x?.dotContainerListResolverData), + map((x) => x?.['dotContainerListResolverData']), take(1) ); }) @@ -99,11 +100,13 @@ export class DotContainerListStore extends ComponentStore selectedContainers: [], actionHeaderOptions: this.getActionHeaderOptions(), listing: {} as DotListingDataTableComponent, + // Seeded rather than left empty: the component's `notify$` subscriber is what + // triggers the first page load, so this emission has to happen. `payload` is + // absent and `message` empty, which is what "nothing has been done yet" means. notifyMessages: { - payload: {}, - message: null, + message: '', failsInfo: [] - } as DotNotifyMessages, + }, containers: [], maxPageLinks: DEFAULT_MAX_PAGE_LINKS, totalRecords: 0 @@ -141,8 +144,8 @@ export class DotContainerListStore extends ComponentStore return notifyMessages; }); - readonly updateBundleIdentifier = this.updater( - (state: DotContainerListState, addToBundleIdentifier: string) => { + readonly updateBundleIdentifier = this.updater( + (state: DotContainerListState, addToBundleIdentifier: string | null) => { return { ...state, addToBundleIdentifier @@ -179,7 +182,7 @@ export class DotContainerListStore extends ComponentStore (state: DotContainerListState, notifyMessages: DotNotifyMessages) => { const { payload } = notifyMessages; - if ('fails' in payload && payload.fails.length) { + if (payload && 'fails' in payload && payload.fails.length) { notifyMessages.failsInfo = this.getFailsInfo(payload.fails); } @@ -195,7 +198,7 @@ export class DotContainerListStore extends ComponentStore switchMap((identifier) => { this.paginatorService.setExtraParams('host', identifier); - return this.paginatorService.getFirstPage(); + return this.paginatorService.getFirstPage(); }), tap((containers: DotContainer[]) => { this.patchContainers(containers); @@ -210,7 +213,7 @@ export class DotContainerListStore extends ComponentStore ? this.paginatorService.setExtraParams('content_type', contentType) : this.paginatorService.deleteExtraParams('content_type'); - return this.paginatorService.get(); + return this.paginatorService.get(); }), tap((containers: DotContainer[]) => { this.patchContainers(containers); @@ -225,7 +228,7 @@ export class DotContainerListStore extends ComponentStore ? this.paginatorService.setExtraParams('archive', archive) : this.paginatorService.deleteExtraParams('archive'); - return this.paginatorService.get(); + return this.paginatorService.get(); }), tap((containers: DotContainer[]) => { this.patchContainers(containers); @@ -240,7 +243,7 @@ export class DotContainerListStore extends ComponentStore ? this.paginatorService.setExtraParams('filter', query) : this.paginatorService.deleteExtraParams('filter'); - return this.paginatorService.get(); + return this.paginatorService.get(); }), tap((containers: DotContainer[]) => { this.patchContainers(containers); @@ -251,7 +254,7 @@ export class DotContainerListStore extends ComponentStore readonly getContainersWithOffset = this.effect((offset$) => { return offset$.pipe( switchMap((offset) => { - return this.paginatorService.getWithOffset(offset); + return this.paginatorService.getWithOffset(offset); }), tap((containers: DotContainer[]) => { this.patchContainers(containers); @@ -262,7 +265,7 @@ export class DotContainerListStore extends ComponentStore readonly loadCurrentContainersPage = this.effect((origin$) => { return origin$.pipe( switchMap(() => { - return this.paginatorService.getCurrentPage(); + return this.paginatorService.getCurrentPage(); }), tap((containers: DotContainer[]) => { this.patchContainers(containers); @@ -570,7 +573,7 @@ export class DotContainerListStore extends ComponentStore editContainer(container: DotContainer): void { this.isContainerAsFile(container) ? this.dotSiteBrowserService - .setSelectedFolder(container.pathName) + .setSelectedFolder(container.pathName ?? '') .pipe(take(1)) .subscribe(() => { this.dotRouterService.goToSiteBrowser(); @@ -584,7 +587,12 @@ export class DotContainerListStore extends ComponentStore this.dotContainersService .delete(identifiers) .pipe(take(1)) - .subscribe((payload: DotActionBulkResult) => { + .subscribe((payload: DotActionBulkResult | null) => { + // `null` arrives when the request failed — see the other bulk handlers. + if (!payload) { + return; + } + this.updateNotifyMessages({ payload, message: this.dotMessageService.get('message.containers.full_delete') @@ -603,7 +611,13 @@ export class DotContainerListStore extends ComponentStore this.dotContainersService .publish(identifiers) .pipe(take(1)) - .subscribe((payload: DotActionBulkResult) => { + .subscribe((payload: DotActionBulkResult | null) => { + // `null` arrives when the request failed — `DotContainersService.handleError` + // sends it down the stream — and the updater reads `'fails' in payload`. + if (!payload) { + return; + } + this.updateNotifyMessages({ payload, message: this.dotMessageService.get('message.container_list.published') @@ -615,7 +629,13 @@ export class DotContainerListStore extends ComponentStore this.dotContainersService .copy(identifier) .pipe(take(1)) - .subscribe((payload: DotContainer) => { + .subscribe((payload: DotContainer | null) => { + // `null` arrives when the request failed — `DotContainersService.handleError` + // sends it down the stream — and the updater reads `'fails' in payload`. + if (!payload) { + return; + } + this.updateNotifyMessages({ payload, message: this.dotMessageService.get('message.container_list.published') @@ -627,7 +647,13 @@ export class DotContainerListStore extends ComponentStore this.dotContainersService .unPublish(identifiers) .pipe(take(1)) - .subscribe((payload: DotActionBulkResult) => { + .subscribe((payload: DotActionBulkResult | null) => { + // `null` arrives when the request failed — `DotContainersService.handleError` + // sends it down the stream — and the updater reads `'fails' in payload`. + if (!payload) { + return; + } + this.updateNotifyMessages({ payload, message: this.dotMessageService.get('message.containers.unpublished') @@ -639,7 +665,13 @@ export class DotContainerListStore extends ComponentStore this.dotContainersService .unArchive(identifiers) .pipe(take(1)) - .subscribe((payload: DotActionBulkResult) => { + .subscribe((payload: DotActionBulkResult | null) => { + // `null` arrives when the request failed — `DotContainersService.handleError` + // sends it down the stream — and the updater reads `'fails' in payload`. + if (!payload) { + return; + } + this.updateNotifyMessages({ payload, message: this.dotMessageService.get('message.containers.undelete') @@ -651,7 +683,13 @@ export class DotContainerListStore extends ComponentStore this.dotContainersService .archive(identifiers) .pipe(take(1)) - .subscribe((payload: DotActionBulkResult) => { + .subscribe((payload: DotActionBulkResult | null) => { + // `null` arrives when the request failed — `DotContainersService.handleError` + // sends it down the stream — and the updater reads `'fails' in payload`. + if (!payload) { + return; + } + this.updateNotifyMessages({ payload, message: this.dotMessageService.get('message.containers.delete') @@ -661,16 +699,18 @@ export class DotContainerListStore extends ComponentStore private getFailsInfo(items: DotBulkFailItem[]): DotBulkFailItem[] { return items.map((item: DotBulkFailItem) => { - return { ...item, description: this.getContainerName(item.element) }; + return { ...item, description: this.getContainerName(item.element ?? '') }; }); } private getContainerName(identifier: string): string { const { selectedContainers } = this.get(); - return selectedContainers.find((container: DotContainer) => { - return container.identifier === identifier; - }).name; + return ( + selectedContainers.find((container: DotContainer) => { + return container.identifier === identifier; + })?.name ?? '' + ); } /** @@ -683,7 +723,7 @@ export class DotContainerListStore extends ComponentStore return containers.map((container) => { const copyContainer = structuredClone(container); copyContainer.disableInteraction = - copyContainer.identifier.includes('/') || + !!copyContainer.identifier?.includes('/') || copyContainer.identifier === 'SYSTEM_CONTAINER' || copyContainer.source === CONTAINER_SOURCE.FILE; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/dot-add-variable.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/dot-add-variable.component.spec.ts index 3356a65d3e14..b237dd695fda 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/dot-add-variable.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/dot-add-variable.component.spec.ts @@ -275,7 +275,7 @@ describe('DotAddVariableComponent', () => { it('should be a field list without FielteredTypes', () => { const fieldTypes = fixture.nativeElement.querySelectorAll('small'); - fieldTypes.forEach((field) => { + fieldTypes.forEach((field: Element) => { const content = field.textContent.trim(); expect(content).not.toEqual(FilteredFieldTypes.Column); expect(content).not.toEqual(FilteredFieldTypes.Row); @@ -291,11 +291,11 @@ describe('DotAddVariableComponent', () => { }); it('should contain 6 fields with the text label as "Image"', () => { - const fieldTypes = Array.from(fixture.nativeElement.querySelectorAll('small')).filter( - (fieldElement: HTMLElement) => { - return fieldElement.textContent.trim() === 'Image'; - } - ); + const fieldTypes = ( + Array.from(fixture.nativeElement.querySelectorAll('small')) as HTMLElement[] + ).filter((fieldElement) => { + return fieldElement.textContent?.trim() === 'Image'; + }); expect(fieldTypes.length).toEqual(6); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/services/dot-fields.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/services/dot-fields.service.ts index 22557ae1724b..eb8792209ed0 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/services/dot-fields.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/services/dot-fields.service.ts @@ -11,7 +11,13 @@ export class DotFieldsService { private dotMessage = inject(DotMessageService); // You can add here a new variable and add the custom code that it has - private readonly getCodeTemplate: Record string> = { + /** + * `satisfies` rather than a `Record` annotation: the annotation is an index signature, + * so every named read below — `.default`, `.binary`, `.blockEditor` — became a bracket access + * under `noPropertyAccessFromIndexSignature`. This keeps the shape constraint and the literal + * keys, which is what the eighteen call sites actually rely on. + */ + private readonly getCodeTemplate = { image: (variable) => `#if ($!{${DOT_CONTENT_MAP}.${variable}.rawUri})\n $!{${DOT_CONTENT_MAP}.${variable}.title}\n#elseif($!{${DOT_CONTENT_MAP}.${variable}.identifier})\n $!{${DOT_CONTENT_MAP}.${variable}.title}\n#end`, file: (variable) => @@ -30,7 +36,7 @@ export class DotFieldsService { `$date.format("M-dd-yyyy H:m:s", $${DOT_CONTENT_MAP}.${variable})`, time: (variable) => `$date.format("H:m:s", $${DOT_CONTENT_MAP}.${variable})`, default: (variable) => `$!{${DOT_CONTENT_MAP}.${variable}}` - }; + } satisfies Record string>; readonly contentIdentifierField: DotFieldContent = { name: this.dotMessage.get('Content-Identifier-value'), diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/store/dot-add-variable.store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/store/dot-add-variable.store.ts index 2b361c9b7bbd..70b64a729873 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/store/dot-add-variable.store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/store/dot-add-variable.store.ts @@ -13,7 +13,7 @@ import { } from '@dotcms/data-access'; import { DotCMSContentType, DotCMSContentTypeField } from '@dotcms/dotcms-models'; -import { DotFieldContent, FilteredFieldTypes } from '../dot-add-variable.models'; +import { DotFieldContent, FieldTypes, FilteredFieldTypes } from '../dot-add-variable.models'; import { DotFieldsService } from '../services/dot-fields.service'; export interface DotAddVariableState { @@ -86,7 +86,7 @@ export class DotAddVariableStore extends ComponentStore { fields.push( // This will try to find the fields by field type, if it doesn't exist it will use the default one - ...(this.dotFieldsService.fields[fieldType]?.(currentField) ?? + ...(this.dotFieldsService.fields[fieldType as FieldTypes]?.(currentField) ?? this.dotFieldsService.fields.default(currentField)) ); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-container-code.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-container-code.component.spec.ts index 74fe72ed407b..5d260914294d 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-container-code.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-container-code.component.spec.ts @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { MonacoStandaloneCodeEditor } from '@materia-ui/ngx-monaco-editor'; + import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { @@ -125,31 +127,31 @@ class HostTestComponent { }) export class DotTextareaContentMockComponent implements ControlValueAccessor { @Input() - code; + code!: { mode: string; options: Record }; @Input() - height; + height!: string; @Input() - show; + show!: string[]; @Input() - value; + value!: string; @Input() - width; + width!: string; @Input() - customStyles; + customStyles!: Record; @Input() - editorName; + editorName!: string; @Output() monacoInit = new EventEmitter(); @Input() - language; + language!: string; writeValue() { // @@ -272,12 +274,12 @@ describe('DotContentEditorComponent', () => { }); it('should have add content type', fakeAsync(() => { - menu.model[0].command({ originalEvent: createFakeEvent('click') }); + menu.model![0].command!({ originalEvent: createFakeEvent('click') }); hostFixture.detectChanges(); const contentTypes = de.queryAll(By.css('p-tabpanel')); const code = de.query(By.css(`[data-testid="${mockContentTypes[0].id}"]`)); code.triggerEventHandler('monacoInit', { - name: menu.model[0].label, + name: menu.model![0].label, editor: { focus: jest.fn() } @@ -285,8 +287,8 @@ describe('DotContentEditorComponent', () => { hostFixture.detectChanges(); tick(100); expect(code).not.toBeNull(); - expect(code.attributes.formControlName).toBe('code'); - expect(code.attributes.language).toBe('html'); + expect(code.attributes['formControlName']).toBe('code'); + expect(code.attributes['language']).toBe('html'); // In Angular 20, ng-reflect-* attributes are not available // Verify the show property directly on the component instance const codeComponent = code.componentInstance; @@ -297,7 +299,7 @@ describe('DotContentEditorComponent', () => { ); expect( (hostComponent.form.get('containerStructures') as FormArray).controls[0] - .get('code') + .get('code')! .hasValidator(Validators.required) ).toEqual(false); expect(hostComponent.form.valid).toEqual(true); @@ -318,7 +320,7 @@ describe('DotContentEditorComponent', () => { it('should have select content type and focus on field', fakeAsync(() => { // Add first content type - menu.model[0].command({ originalEvent: createFakeEvent('click') }); + menu.model![0].command!({ originalEvent: createFakeEvent('click') }); flush(); hostFixture.detectChanges(false); @@ -335,8 +337,8 @@ describe('DotContentEditorComponent', () => { hostFixture.detectChanges(false); // Verify first content type was added correctly - expect(code.attributes.formControlName).toBe('code'); - expect(code.attributes.language).toBe('html'); + expect(code.attributes['formControlName']).toBe('code'); + expect(code.attributes['language']).toBe('html'); const codeComponent = code.componentInstance; expect(codeComponent?.show).toEqual(['code']); @@ -353,7 +355,7 @@ describe('DotContentEditorComponent', () => { it('shoud not have required code field on default content type', () => { expect( (hostComponent.form.get('containerStructures') as FormArray).controls[0] - .get('code') + .get('code')! .hasValidator(Validators.required) ).toEqual(false); expect(hostComponent.form.valid).toEqual(true); @@ -438,10 +440,12 @@ describe('DotContentEditorComponent', () => { it('should initialize monaco editor correctly', fakeAsync(() => { const mockEditor = { focus: jest.fn(), updateOptions: jest.fn() }; + // Stubbed to the two methods `monacoInit` touches, out of the 111 on + // `MonacoStandaloneCodeEditor`. const monacoInstance = { name: 'testEditor', editor: mockEditor - }; + } as unknown as { name: string; editor: MonacoStandaloneCodeEditor }; comp.monacoInit(monacoInstance); // Trigger requestAnimationFrame @@ -453,10 +457,12 @@ describe('DotContentEditorComponent', () => { it('should set monaco editor to readonly when no content types', fakeAsync(() => { const mockEditor = { focus: jest.fn(), updateOptions: jest.fn() }; + // Stubbed to the two methods `monacoInit` touches, out of the 111 on + // `MonacoStandaloneCodeEditor`. const monacoInstance = { name: 'testEditor', editor: mockEditor - }; + } as unknown as { name: string; editor: MonacoStandaloneCodeEditor }; comp.contentTypes = []; comp.monacoInit(monacoInstance); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-container-code.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-container-code.component.ts index f1c962fbc6a9..d6b24997d089 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-container-code.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-container-code.component.ts @@ -62,13 +62,14 @@ export class DotContentEditorComponent implements OnInit, OnChanges { private dialogService = inject(DialogService); private dotMessageService = inject(DotMessageService); - @Input() fg: FormGroup; - @Input() contentTypes: DotCMSContentType[]; + @Input() fg!: FormGroup; + @Input() contentTypes!: DotCMSContentType[]; - menuItems: MenuItem[]; + menuItems: MenuItem[] = []; activeTabIndex = 0; monacoEditors: Record = {}; - contentTypeNamesById = {}; + /** Content type names keyed by id, built from whichever types the container allows. */ + contentTypeNamesById: Record = {}; ngOnInit() { if (this.contentTypes && this.contentTypes.length > 0) { @@ -82,8 +83,8 @@ export class DotContentEditorComponent implements OnInit, OnChanges { } ngOnChanges(changes: SimpleChanges) { - if (changes.contentTypes?.currentValue?.length > 0) { - changes.contentTypes.currentValue.forEach(({ id, name }: DotCMSContentType) => { + if (changes['contentTypes']?.currentValue?.length > 0) { + changes['contentTypes'].currentValue.forEach(({ id, name }: DotCMSContentType) => { this.contentTypeNamesById[id] = name; }); @@ -125,7 +126,7 @@ export class DotContentEditorComponent implements OnInit, OnChanges { * @param {number} [index=null] - number = null * @returns false */ - public handleTabClick(event: MouseEvent, index: number = null): boolean { + public handleTabClick(event: MouseEvent, index: number | null = null): boolean { if (index === 0) { event.preventDefault(); event.stopPropagation(); @@ -142,8 +143,9 @@ export class DotContentEditorComponent implements OnInit, OnChanges { * @param {number} [index=null] - number = null * @memberof DotContentEditorComponent */ - removeItem(index: number = null): void { - if (this.contentTypes.length > 0) { + removeItem(index: number | null = null): void { + // The default is `null`, which names no tab to remove. + if (index !== null && this.contentTypes.length > 0) { this.getcontainerStructures.removeAt(index - 1); const currentTabIndex = this.findCurrentTabIndex(index); this.updateActiveTabIndex(currentTabIndex); @@ -158,8 +160,16 @@ export class DotContentEditorComponent implements OnInit, OnChanges { */ focusCurrentEditor(tabIdx: number) { if (tabIdx > 0) { + // `.get(...)`, not `.controls[...]`: a `FormArray` element is an `AbstractControl`, which + // has no `controls` map — only `FormGroup` does. The id also gates the lookup below, + // where an absent one would index `monacoEditors` by `undefined`. const contentTypeId = - this.getcontainerStructures.controls[tabIdx - 1].get('structureId').value; + this.getcontainerStructures.controls[tabIdx - 1].get('structureId')?.value; + + if (!contentTypeId) { + return; + } + // Tab Panel does not trigger any event after completely rendered. // Tab Panel and Monaco-Editor take sometime to render it completely. requestAnimationFrame(() => { @@ -174,7 +184,7 @@ export class DotContentEditorComponent implements OnInit, OnChanges { * @return {*} {number} * @memberof DotContentEditorComponent */ - findCurrentTabIndex(index): number { + findCurrentTabIndex(index: number): number { // -1 in condition because if it is first tab then no need to minus return index - 1 > 0 ? index - 1 : this.getcontainerStructures.length > 0 ? index : 0; } @@ -198,7 +208,12 @@ export class DotContentEditorComponent implements OnInit, OnChanges { onSave: (codeTemplate: string) => { const editor = this.monacoEditors[contentType.structureId]; - const selections = editor.getSelections(); + const selections = editor?.getSelections(); + const model = editor?.getModel(); + + if (!selections || !model) { + return; + } const editOperation = selections.map((selection) => { return { @@ -212,7 +227,7 @@ export class DotContentEditorComponent implements OnInit, OnChanges { }; }); - editor.getModel().pushEditOperations(selections, editOperation, () => { + model.pushEditOperations(selections, editOperation, () => { return null; }); } @@ -225,7 +240,7 @@ export class DotContentEditorComponent implements OnInit, OnChanges { * @param monacoInstance - The monaco instance that is created by the component. * @memberof DotContentEditorComponent */ - monacoInit(monacoEditor) { + monacoInit(monacoEditor: { name: string; editor: MonacoStandaloneCodeEditor }) { this.monacoEditors[monacoEditor.name] = monacoEditor.editor; if (this.contentTypes.length === 0) { this.monacoEditors[monacoEditor.name].updateOptions({ readOnly: true }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-create.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-create.component.ts index ff5be6830e66..97932c0780db 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-create.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-create.component.ts @@ -40,7 +40,7 @@ export class DotContainerCreateComponent implements OnInit { ngOnInit() { this.activatedRoute.data .pipe( - map((x) => x?.container), + map((x) => x?.['container']), take(1) ) .subscribe((container: DotContainerEntity) => { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-history/dot-container-history.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-history/dot-container-history.component.spec.ts index bdb955e8e52f..ed43e6312b4f 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-history/dot-container-history.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-history/dot-container-history.component.spec.ts @@ -23,9 +23,9 @@ import { DotPortletBoxComponent } from '../../../../view/components/dot-portlet- template: '' }) export class IframeMockComponent { - @Input() src: string; + @Input() src!: string; @Output() custom: EventEmitter = new EventEmitter(); - @ViewChild('iframeElement') iframeElement: ElementRef; + @ViewChild('iframeElement') iframeElement!: ElementRef; } @Component({ diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-history/dot-container-history.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-history/dot-container-history.component.ts index 15a45635b08c..75350560aeab 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-history/dot-container-history.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-history/dot-container-history.component.ts @@ -20,8 +20,8 @@ import { DotPortletBoxComponent } from '../../../../view/components/dot-portlet- imports: [DotPortletBoxComponent, IframeComponent] }) export class DotContainerHistoryComponent implements OnChanges { - @Input() containerId: string; - @ViewChild('historyIframe') historyIframe: IframeComponent; + @Input() containerId!: string; + @ViewChild('historyIframe') historyIframe!: IframeComponent; protected historyUrl = '/html/containers/push_history.jsp'; private readonly dotRouterService = inject(DotRouterService); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-permissions/dot-container-permissions.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-permissions/dot-container-permissions.component.spec.ts index 816ddb3a1573..fd35a6f38cf2 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-permissions/dot-container-permissions.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-permissions/dot-container-permissions.component.spec.ts @@ -12,8 +12,8 @@ import { DotPortletBoxComponent } from '../../../../view/components/dot-portlet- template: '' }) export class IframeMockComponent { - @Input() src: string; - @ViewChild('iframeElement') iframeElement: ElementRef; + @Input() src!: string; + @ViewChild('iframeElement') iframeElement!: ElementRef; } @Component({ diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-permissions/dot-container-permissions.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-permissions/dot-container-permissions.component.ts index a75cfd6743a3..53310c160828 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-permissions/dot-container-permissions.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-permissions/dot-container-permissions.component.ts @@ -11,7 +11,7 @@ import { DotPortletBoxComponent } from '../../../../view/components/dot-portlet- imports: [DotPortletBoxComponent, IframeComponent] }) export class DotContainerPermissionsComponent implements OnInit { - @Input() containerId: string; + @Input() containerId!: string; permissionsUrl = '/html/containers/permissions.jsp'; ngOnInit() { this.permissionsUrl = `/html/containers/permissions.jsp?containerId=${this.containerId}&popup=true`; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/dot-container-properties.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/dot-container-properties.component.spec.ts index 134e27227e48..bca95122ef41 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/dot-container-properties.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/dot-container-properties.component.spec.ts @@ -107,13 +107,13 @@ export class DotLoopEditorComponent { ] }) export class DotTextareaContentMockComponent implements ControlValueAccessor { - @Input() code; - @Input() height; - @Input() show; - @Input() value; - @Input() width; + @Input() code!: { mode: string; options: Record }; + @Input() height!: string; + @Input() show!: string[]; + @Input() value!: string; + @Input() width!: string; @Output() monacoInit = new EventEmitter(); - @Input() language; + @Input() language!: string; writeValue(): void { /* mock ControlValueAccessor */ } @@ -319,12 +319,12 @@ describe('DotContainerPropertiesComponent', () => { it('should render content types when max-content greater then zero', fakeAsync(() => { const comp = spectator.component; jest.spyOn(comp, 'showContentTypeAndCode'); - comp.form.get('maxContentlets').setValue(0); - comp.form.get('maxContentlets').valueChanges.subscribe((value) => { + comp.form.get('maxContentlets')!.setValue(0); + comp.form.get('maxContentlets')!.valueChanges.subscribe((value) => { expect(value).toBe(5); }); - expect(comp.form.get('maxContentlets').updateOn).toBe('change'); - comp.form.get('maxContentlets').setValue(5); + expect(comp.form.get('maxContentlets')!.updateOn).toBe('change'); + comp.form.get('maxContentlets')!.setValue(5); tick(150); spectator.detectChanges(); tick(50); @@ -335,11 +335,11 @@ describe('DotContainerPropertiesComponent', () => { it('should clear the field', fakeAsync(() => { jest.spyOn(dotDialogService, 'confirm').mockImplementation((conf) => { - conf.accept(); + conf.accept!(); }); const comp = spectator.component; jest.spyOn(comp, 'clearContentConfirmationModal'); - comp.form.get('maxContentlets').setValue(0); + comp.form.get('maxContentlets')!.setValue(0); tick(150); spectator.detectChanges(); expect(comp.form.value).toEqual({ @@ -357,12 +357,12 @@ describe('DotContainerPropertiesComponent', () => { it('should clear the field when user click on clear button', () => { const comp = spectator.component; - comp.form.get('maxContentlets').setValue(0); - comp.form.get('maxContentlets').setValue(5); + comp.form.get('maxContentlets')!.setValue(0); + comp.form.get('maxContentlets')!.setValue(5); spectator.detectChanges(); jest.spyOn(comp, 'clearContentConfirmationModal'); jest.spyOn(dotDialogService, 'confirm').mockImplementation((conf) => { - conf.accept(); + conf.accept!(); }); spectator.click(byTestId('clearContent')); expect(comp.form.value).toEqual({ @@ -385,19 +385,19 @@ describe('DotContainerPropertiesComponent', () => { }); it('should save button enable when data change', fakeAsync(() => { - spectator.component.form.get('title').setValue('Hello'); + spectator.component.form.get('title')!.setValue('Hello'); tick(150); spectator.detectChanges(); const saveBtn = spectator.query(byTestId('saveBtn')) as HTMLButtonElement; expect(saveBtn.disabled).toBe(false); - spectator.component.form.get('title').setValue('FAQ'); + spectator.component.form.get('title')!.setValue('FAQ'); tick(150); spectator.detectChanges(); expect((spectator.query(byTestId('saveBtn')) as HTMLButtonElement).disabled).toBe(true); })); it('should save button disable after save', fakeAsync(() => { - spectator.component.form.get('title').setValue('Hello'); + spectator.component.form.get('title')!.setValue('Hello'); tick(150); spectator.detectChanges(); spectator.click(byTestId('saveBtn')); @@ -418,8 +418,8 @@ describe('DotContainerPropertiesComponent', () => { it('should save button disable but code field is not required', fakeAsync(() => { const comp = spectator.component; - comp.form.get('maxContentlets').setValue(0); - comp.form.get('maxContentlets').setValue(5); + comp.form.get('maxContentlets')!.setValue(0); + comp.form.get('maxContentlets')!.setValue(5); tick(200); spectator.detectChanges(); spectator.click(byTestId('saveBtn')); @@ -427,14 +427,14 @@ describe('DotContainerPropertiesComponent', () => { expect((spectator.query(byTestId('saveBtn')) as HTMLButtonElement).disabled).toBe(true); expect( (comp.form.get('containerStructures') as FormArray).controls[0] - .get('code') + .get('code')! .hasValidator(Validators.required) ).toBe(false); })); it('should redirect to containers list after save', fakeAsync(() => { (dotRouterService.goToURL as jest.Mock).mockClear(); - spectator.component.form.get('title').setValue('Hello'); + spectator.component.form.get('title')!.setValue('Hello'); tick(150); spectator.detectChanges(); spectator.click(byTestId('saveBtn')); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/dot-container-properties.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/dot-container-properties.component.ts index 113650177fd0..441453ae933c 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/dot-container-properties.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/dot-container-properties.component.ts @@ -31,10 +31,7 @@ import { DotMessagePipe } from '@dotcms/ui'; -import { - DotContainerPropertiesState, - DotContainerPropertiesStore -} from './store/dot-container-properties.store'; +import { DotContainerPropertiesStore } from './store/dot-container-properties.store'; import { DotContainersService } from '../../../../api/services/dot-containers/dot-containers.service'; import { MonacoEditor } from '../../../../shared/models/monaco-editor/monaco-editor.model'; @@ -80,15 +77,16 @@ export class DotContainerPropertiesComponent implements OnInit, AfterViewInit { readonly #dotRouterService = inject(DotRouterService); vm$ = this.#store.vm$; - editor: MonacoEditor; - form: FormGroup; + editor!: MonacoEditor; + form!: FormGroup; private destroy$: Subject = new Subject(); ngOnInit(): void { this.#store.containerAndStructure$ .pipe(take(1)) - .subscribe((state: DotContainerPropertiesState) => { - const { container, containerStructures } = state; + // Not annotated with the whole state: `containerAndStructure$` selects two fields, and + // a wider parameter than the selector emits is what strictFunctionTypes rejects. + .subscribe(({ container, containerStructures }) => { this.form = this.fb.group({ identifier: new FormControl(container?.identifier ?? ''), title: new FormControl(container?.title ?? '', [Validators.required]), @@ -213,12 +211,12 @@ export class DotContainerPropertiesComponent implements OnInit, AfterViewInit { Validators.required, Validators.minLength(1) ]); - this.form.get('code').clearValidators(); - this.form.get('code').reset(''); + this.form.controls['code'].clearValidators(); + this.form.controls['code'].reset(''); this.#store.updateContentTypeVisibility(true); } else { - this.form.get('code').setValidators(Validators.required); - this.form.get('containerStructures').clearValidators(); + this.form.controls['code'].setValidators(Validators.required); + this.form.controls['containerStructures'].clearValidators(); } this.form.updateValueAndValidity(); @@ -262,7 +260,7 @@ export class DotContainerPropertiesComponent implements OnInit, AfterViewInit { }, reject: () => { if (this.form.value.maxContentlets === 0 || !this.form.value.maxContentlets) { - this.form.get('maxContentlets').setValue(lastValue); + this.form.controls['maxContentlets'].setValue(lastValue); } }, header: this.dotMessageService.get( @@ -280,14 +278,14 @@ export class DotContainerPropertiesComponent implements OnInit, AfterViewInit { * @memberof DotContainerPropertiesComponent */ private clearContentTypesAndCode(): void { - this.form.get('containerStructures').clearValidators(); - this.form.get('containerStructures').reset(); - this.form.get('preLoop').reset(); - this.form.get('postLoop').reset(); + this.form.controls['containerStructures'].clearValidators(); + this.form.controls['containerStructures'].reset(); + this.form.controls['preLoop'].reset(); + this.form.controls['postLoop'].reset(); // clear containerStructures array (this.form.get('containerStructures') as FormArray).clear(); - this.form.get('code').addValidators(Validators.required); - this.form.get('maxContentlets').setValue(0); + this.form.controls['code'].addValidators(Validators.required); + this.form.controls['maxContentlets'].setValue(0); this.form.updateValueAndValidity(); this.#store.updateContentTypeAndPrePostLoopVisibility({ diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/store/dot-container-properties.store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/store/dot-container-properties.store.ts index 92435a808d79..6bbaf460a93d 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/store/dot-container-properties.store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/store/dot-container-properties.store.ts @@ -29,10 +29,12 @@ export interface DotContainerPropertiesState { showPrePostLoopInput: boolean; isContentTypeVisible: boolean; isContentTypeButtonEnabled: boolean; - container: DotContainer; + /** Null until the resolver's container lands, or on the create path until it is saved. */ + container: DotContainer | null; containerStructures: DotContainerStructure[]; contentTypes: DotCMSContentType[]; - originalForm: DotContainerPayload; + /** Null until the form has been built from the loaded container. */ + originalForm: DotContainerPayload | null; apiLink: string; invalidForm: boolean; } @@ -61,7 +63,7 @@ export class DotContainerPropertiesStore extends ComponentStore x?.container), + map((x) => x?.['container']), take(1), filter((containerEntity) => !!containerEntity) ) @@ -108,7 +110,13 @@ export class DotContainerPropertiesStore extends ComponentStore ) => { return { ...state, @@ -177,7 +185,10 @@ export class DotContainerPropertiesStore extends ComponentStore( ( state: DotContainerPropertiesState, - { isContentTypeVisible, showPrePostLoopInput }: DotContainerPropertiesState + { + isContentTypeVisible, + showPrePostLoopInput + }: Pick ) => { return { ...state, @@ -238,6 +249,7 @@ export class DotContainerPropertiesStore extends ComponentStore !!container), tap((container: DotContainerEntity) => { this.dotGlobalMessageService.success( this.dotMessageService.get('message.container.published') @@ -261,6 +273,7 @@ export class DotContainerPropertiesStore extends ComponentStore !!container), tap((container: DotContainerEntity) => { this.dotGlobalMessageService.success( this.dotMessageService.get('message.container.updated') diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-loop-editor/dot-loop-editor.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-loop-editor/dot-loop-editor.component.spec.ts index 1305e7351fff..dc925b03ea8d 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-loop-editor/dot-loop-editor.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-loop-editor/dot-loop-editor.component.spec.ts @@ -51,14 +51,14 @@ class DotTestHostComponent { standalone: false }) export class DotTextareaContentMockComponent implements ControlValueAccessor { - @Input() show; - @Input() height; + @Input() show!: string[]; + @Input() height!: string; propagateChange = (_: unknown) => { // }; - registerOnChange(fn): void { + registerOnChange(fn: (value: unknown) => void): void { this.propagateChange = fn; } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-loop-editor/dot-loop-editor.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-loop-editor/dot-loop-editor.component.ts index 688110fde0e7..b69068011cf4 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-loop-editor/dot-loop-editor.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-loop-editor/dot-loop-editor.component.ts @@ -66,13 +66,17 @@ export class DotLoopEditorComponent implements ControlValueAccessor, OnInit { } } - private _onChange = (_value: string | null) => undefined; + private _onChange: (value: string | null) => void = () => { + /* */ + }; public registerOnChange(fn: (value: string | null) => void): void { this._onChange = fn; } - public onTouched = () => undefined; + public onTouched: () => void = () => { + /* */ + }; public registerOnTouched(fn: () => void): void { this.onTouched = fn; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/resolvers/dot-container-edit.resolver.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/resolvers/dot-container-edit.resolver.spec.ts index ea2ed5923b0c..18759816843c 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/resolvers/dot-container-edit.resolver.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/resolvers/dot-container-edit.resolver.spec.ts @@ -5,6 +5,7 @@ import { of } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; +import { RouterStateSnapshot } from '@angular/router'; import { DotRouterService, DotSystemConfigService } from '@dotcms/data-access'; import { GlobalStore } from '@dotcms/store'; @@ -14,6 +15,9 @@ import { DotContainerEditResolver } from './dot-container-edit.resolver'; import { DotContainersService } from '../../../../api/services/dot-containers/dot-containers.service'; +/** Both resolvers declare this parameter `_state` and never read it. */ +const UNUSED_STATE = null as unknown as RouterStateSnapshot; + describe('DotContainerService', () => { let service: DotContainerEditResolver; let containersService: DotContainersService; @@ -63,12 +67,12 @@ describe('DotContainerService', () => { .resolve( { paramMap: { - get(param) { + get(param: string) { return param === 'inode' ? null : 'ID'; } } } as any, - null + UNUSED_STATE ) .subscribe( (_res) => { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/resolvers/dot-container-edit.resolver.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/resolvers/dot-container-edit.resolver.ts index cd4172b99020..595e8a8db4e8 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/resolvers/dot-container-edit.resolver.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/resolvers/dot-container-edit.resolver.ts @@ -11,16 +11,20 @@ import { GlobalStore } from '@dotcms/store'; import { DotContainersService } from '../../../../api/services/dot-containers/dot-containers.service'; @Injectable() -export class DotContainerEditResolver implements Resolve { +export class DotContainerEditResolver implements Resolve { private service = inject(DotContainersService); private globalStore = inject(GlobalStore); resolve( route: ActivatedRouteSnapshot, _state: RouterStateSnapshot - ): Observable { - return this.service.getById(route.paramMap.get('id'), 'working', true).pipe( + ): Observable { + return this.service.getById(route.paramMap.get('id') ?? '', 'working', true).pipe( tap((container) => { + if (!container) { + return; + } + const { identifier, title } = container.container; this.globalStore.addNewBreadcrumb({ label: title, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-form-builder/dot-form-builder.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-form-builder/dot-form-builder.component.ts index 9cb33d9a18d0..7c365d62ec46 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-form-builder/dot-form-builder.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-form-builder/dot-form-builder.component.ts @@ -20,9 +20,9 @@ import { DotContentTypesPortletComponent } from '../shared/dot-content-types-lis export class DotFormBuilderComponent implements OnInit { private route = inject(ActivatedRoute); - haveLicense$: Observable; + haveLicense$!: Observable; ngOnInit() { - this.haveLicense$ = this.route.data.pipe(map((x) => x?.haveLicense)); + this.haveLicense$ = this.route.data.pipe(map((x) => x?.['haveLicense'])); } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-create-page-dialog/dot-create-page-dialog.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-create-page-dialog/dot-create-page-dialog.component.spec.ts index 758b24466931..193a479e2ea1 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-create-page-dialog/dot-create-page-dialog.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-create-page-dialog/dot-create-page-dialog.component.spec.ts @@ -13,8 +13,10 @@ import { DotAutofocusDirective, DotMessagePipe } from '@dotcms/ui'; import { DotCreatePageDialogComponent } from './dot-create-page-dialog.component'; -const createMockContentType = (partial: Partial): DotCMSContentType => - partial as DotCMSContentType; +/** Nullable per field: two tests build a page type whose name or variable is missing. */ +const createMockContentType = ( + partial: Partial<{ [K in keyof DotCMSContentType]: DotCMSContentType[K] | null }> +): DotCMSContentType => partial as DotCMSContentType; const MOCK_PAGE_TYPES: DotCMSContentType[] = [ createMockContentType({ diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-page-favorites-panel/dot-page-favorites-panel.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-page-favorites-panel/dot-page-favorites-panel.component.spec.ts index aa5ccaede11e..2e1035b69169 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-page-favorites-panel/dot-page-favorites-panel.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-page-favorites-panel/dot-page-favorites-panel.component.spec.ts @@ -228,7 +228,7 @@ describe('DotPageFavoritesPanelComponent', () => { spectator.detectChanges(); const timestamp = spectator.component.$timeStamp(); - const expected = `${page.screenshot}?language_id=${page.languageId}&${timestamp}`; + const expected = `${page['screenshot']}?language_id=${page.languageId}&${timestamp}`; const card = spectator.query(DotPagesCardComponent); expect(card).toBeTruthy(); @@ -318,9 +318,9 @@ describe('DotPageFavoritesPanelComponent', () => { describe('Output Events', () => { it('should emit openMenu event with correct data', () => { - let emittedEvent: DotActionsMenuEventParams | null = null; + const emitted: { event?: DotActionsMenuEventParams } = {}; spectator.output('openMenu').subscribe((event) => { - emittedEvent = event; + emitted.event = event; }); spectator.setInput('favoritePages', MOCK_FAVORITE_PAGES); @@ -331,9 +331,9 @@ describe('DotPageFavoritesPanelComponent', () => { // Emit from the first card; panel template binds (openMenu)="handleOpenMenu($event, favoritePage)" spectator.triggerEventHandler('dot-pages-card', 'openMenu', mockEvent); - expect(emittedEvent).toBeTruthy(); - expect(emittedEvent?.originalEvent).toBe(mockEvent); - expect(emittedEvent?.data).toBe(MOCK_FAVORITE_PAGES[0]); + expect(emitted.event).toBeTruthy(); + expect(emitted.event?.originalEvent).toBe(mockEvent); + expect(emitted.event?.data).toBe(MOCK_FAVORITE_PAGES[0]); }); it('should stop event propagation when opening menu', () => { @@ -374,15 +374,15 @@ describe('DotPageFavoritesPanelComponent', () => { expect(cards).toHaveLength(3); // Step 5: User opens menu on a card - let emittedEvent: DotActionsMenuEventParams | null = null; + const emitted: { event?: DotActionsMenuEventParams } = {}; spectator.output('openMenu').subscribe((event) => { - emittedEvent = event; + emitted.event = event; }); const mockEvent = new MouseEvent('click'); spectator.triggerEventHandler('dot-pages-card', 'openMenu', mockEvent); - expect(emittedEvent?.data).toBe(MOCK_FAVORITE_PAGES[0]); + expect(emitted.event?.data).toBe(MOCK_FAVORITE_PAGES[0]); // Step 6: User collapses panel again spectator.triggerEventHandler('p-panel', 'collapsedChange', true); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-page-favorites-panel/dot-page-favorites-panel.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-page-favorites-panel/dot-page-favorites-panel.component.ts index 8fa948061d5d..19f339f62e1d 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-page-favorites-panel/dot-page-favorites-panel.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-page-favorites-panel/dot-page-favorites-panel.component.ts @@ -52,11 +52,11 @@ export class DotPageFavoritesPanelComponent { * @returns {string} The screenshot URL with cache-busting params, or empty string if missing. */ protected getScreenshotUri(favoritePage: DotCMSContentlet): string { - if (!favoritePage?.screenshot) { + if (!favoritePage?.['screenshot']) { return ''; } - return `${favoritePage.screenshot}?language_id=${favoritePage.languageId}&${this.$timeStamp()}`; + return `${favoritePage['screenshot']}?language_id=${favoritePage.languageId}&${this.$timeStamp()}`; } /** diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts index 3fe120d99546..53b38f552da4 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts @@ -226,7 +226,7 @@ describe('DotPageStore', () => { }); it('should load null Favorite Pages data when error on initial data fetch', () => { - const error500 = mockResponseView(500, '/test', null, { message: 'error' }); + const error500 = mockResponseView(500, '/test', undefined, { message: 'error' }); jest.spyOn(dotESContentService, 'get').mockReturnValue(throwError(() => error500)); // Mock sessionStorage.getItem (sessionStorage.getItem as jest.Mock).mockReturnValue(null); @@ -547,7 +547,7 @@ describe('DotPageStore', () => { }); it('should handle error when get Pages value fails', () => { - const error500 = mockResponseView(500, '/test', null, { message: 'error' }); + const error500 = mockResponseView(500, '/test', undefined, { message: 'error' }); jest.spyOn(dotESContentService, 'get').mockReturnValue(throwError(() => error500)); dotPageStore.getPages({ offset: 0, sortField: 'title', sortOrder: 1 }); @@ -618,17 +618,17 @@ describe('DotPageStore', () => { dotPageStore.state$.subscribe((data) => { const menuActions = data.pages.menuActions; - expect(menuActions.length).toEqual(9); + expect(menuActions!.length).toEqual(9); - expect(menuActions[0].label).toEqual('favoritePage.contextMenu.action.edit'); - expect(menuActions[1].label).toEqual('favoritePage.dialog.delete.button'); - expect(menuActions[2].label).toEqual(undefined); - expect(menuActions[3].label).toEqual('Edit'); - expect(menuActions[4].label).toEqual(mockWorkflowsActions[0].name); - expect(menuActions[5].label).toEqual(mockWorkflowsActions[1].name); - expect(menuActions[6].label).toEqual(mockWorkflowsActions[2].name); - expect(menuActions[7].label).toEqual('contenttypes.content.push_publish'); - expect(menuActions[8].label).toEqual('contenttypes.content.add_to_bundle'); + expect(menuActions![0].label).toEqual('favoritePage.contextMenu.action.edit'); + expect(menuActions![1].label).toEqual('favoritePage.dialog.delete.button'); + expect(menuActions![2].label).toEqual(undefined); + expect(menuActions![3].label).toEqual('Edit'); + expect(menuActions![4].label).toEqual(mockWorkflowsActions[0].name); + expect(menuActions![5].label).toEqual(mockWorkflowsActions[1].name); + expect(menuActions![6].label).toEqual(mockWorkflowsActions[2].name); + expect(menuActions![7].label).toEqual('contenttypes.content.push_publish'); + expect(menuActions![8].label).toEqual('contenttypes.content.add_to_bundle'); expect(data.pages.actionMenuDomId).toEqual('test1'); }); @@ -687,7 +687,7 @@ describe('DotPageStore', () => { expect(menuActions[7].label).toEqual('contenttypes.content.push_publish'); - menuActions[7].command({ originalEvent: createFakeEvent('click') }); + menuActions[7].command!({ originalEvent: createFakeEvent('click') }); expect(dotPushPublishDialogService.open).toHaveBeenCalledWith({ assetIdentifier: item.identifier, @@ -739,9 +739,11 @@ describe('DotPageStore', () => { }); dotPageStore.state$.subscribe((data) => { - expect(data.pages.menuActions.length).toEqual(8); - expect(data.pages.menuActions[0].label).toEqual('favoritePage.contextMenu.action.edit'); - expect(data.pages.menuActions[1].label).toEqual('favoritePage.dialog.delete.button'); + expect(data.pages.menuActions!.length).toEqual(8); + expect(data.pages.menuActions![0].label).toEqual( + 'favoritePage.contextMenu.action.edit' + ); + expect(data.pages.menuActions![1].label).toEqual('favoritePage.dialog.delete.button'); }); }); @@ -766,14 +768,16 @@ describe('DotPageStore', () => { }); dotPageStore.state$.subscribe((data) => { - expect(data.pages.menuActions[0].label).toEqual('favoritePage.contextMenu.action.edit'); - expect(data.pages.menuActions[1].label).toEqual('favoritePage.dialog.delete.button'); - expect(data.pages.menuActions[2]).toEqual({ separator: true }); - expect(data.pages.menuActions[3].label).toEqual('Assign Workflow'); - expect(data.pages.menuActions[4].label).toEqual('Save'); - expect(data.pages.menuActions[5].label).toEqual('Save / Publish'); - expect(data.pages.menuActions[6].label).toEqual('contenttypes.content.push_publish'); - expect(data.pages.menuActions[7].label).toEqual('contenttypes.content.add_to_bundle'); + expect(data.pages.menuActions![0].label).toEqual( + 'favoritePage.contextMenu.action.edit' + ); + expect(data.pages.menuActions![1].label).toEqual('favoritePage.dialog.delete.button'); + expect(data.pages.menuActions![2]).toEqual({ separator: true }); + expect(data.pages.menuActions![3].label).toEqual('Assign Workflow'); + expect(data.pages.menuActions![4].label).toEqual('Save'); + expect(data.pages.menuActions![5].label).toEqual('Save / Publish'); + expect(data.pages.menuActions![6].label).toEqual('contenttypes.content.push_publish'); + expect(data.pages.menuActions![7].label).toEqual('contenttypes.content.add_to_bundle'); }); }); @@ -853,10 +857,10 @@ describe('DotPageStore', () => { dotPageStore.state$.subscribe(({ pages }) => { const menuAction = pages.menuActions; - const publishAction = menuAction.find( + const publishAction = menuAction!.find( (action) => action.label === mockPublishAction.name ); - publishAction.command({ originalEvent: createFakeEvent('click') }); + publishAction!.command!({ originalEvent: createFakeEvent('click') }); expect(dotHttpErrorManagerService.handle).toHaveBeenCalledWith(error, true); expect(dotHttpErrorManagerService.handle).toHaveBeenCalledTimes(1); done(); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts index 368676458e61..214fef98b0dd 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts @@ -53,10 +53,15 @@ import { generateDotFavoritePageUrl } from '@dotcms/utils'; import { DotCreatePageDialogComponent } from '../dot-create-page-dialog/dot-create-page-dialog.component'; export interface DotPagesInfo { - actionMenuDomId?: string; + /** Null once the row menu is dismissed — `actionMenuDomId$` filters that out. */ + actionMenuDomId?: string | null; addToBundleCTId?: string; archived?: boolean; - items: DotCMSContentlet[]; + /** + * Holds `undefined` where a page was removed: `getPages` pads the array to keep its length so + * the endless scroll keeps working, and every reader goes through `page?.`. + */ + items: (DotCMSContentlet | undefined)[]; keyword?: string; languageId?: string; menuActions?: MenuItem[]; @@ -81,7 +86,13 @@ export interface DotPagesState { canWrite: { contentlets: boolean; htmlPages: boolean }; id: string; }; - pages?: DotPagesInfo; + /** + * Required, not optional: the initial state seeds it and `patchState` never removes a key, so it + * is present for the whole life of the store. Declared optional, every read of `pages.items` / + * `pages.keyword` / `pages.status` across this store's spec needed narrowing for a state that + * cannot occur — 54 of them. + */ + pages: DotPagesInfo; pageTypes?: DotCMSContentType[]; portletStatus: ComponentStatus; } @@ -99,6 +110,34 @@ interface UserPagePermission { canUserWriteContent: boolean; } +/** + * What the portlet's init pipeline gathers before seeding the store, in order. + * + * Named so the operators that build these and the callbacks that read them agree: an array literal + * infers as a union array, not a tuple, so the two ends never matched. + */ +type DotPagesSourceData = [ + ESContent, + DotCurrentUser, + DotLanguage[], + boolean, + boolean, + DotSessionStorageFilter | null, + boolean +]; + +/** {@link DotPagesSourceData} with the user's resolved permissions spliced in. */ +type DotPagesInitData = [ + ESContent, + DotCurrentUser, + DotLanguage[], + boolean, + boolean, + DotPermissionsType, + DotSessionStorageFilter | null, + boolean +]; + export const FAVORITE_PAGE_LIMIT = 500; export const LOCAL_STORAGE_FAVORITES_PANEL_KEY = 'FavoritesPanelCollapsed'; @@ -164,7 +203,7 @@ export class DotPageStore extends ComponentStore { }); readonly isFavoritePanelCollaped$: Observable = this.select((state) => { - return state?.favoritePages?.collapsed; + return state?.favoritePages?.collapsed ?? false; }); readonly isPagesLoading$: Observable = this.select( @@ -181,7 +220,7 @@ export class DotPageStore extends ComponentStore { readonly actionMenuDomId$: Observable = this.select( ({ pages }) => pages?.actionMenuDomId - ).pipe(filter((i) => i !== null)); + ).pipe(filter((id): id is string => !!id)); readonly languageOptions$: Observable = this.select( ({ languages }: DotPagesState) => { @@ -193,9 +232,7 @@ export class DotPageStore extends ComponentStore { if (languages?.length) { languages.forEach((language) => { - const countryCode = language.countryCode.length - ? ` (${language.countryCode})` - : ''; + const countryCode = language.countryCode ? ` (${language.countryCode})` : ''; languageOptions.push({ label: `${language.language}${countryCode}`, @@ -220,12 +257,10 @@ export class DotPageStore extends ComponentStore { readonly languageLabels$: Observable<{ [id: string]: string }> = this.select( ({ languages }: DotPagesState) => { - const langLabels = {}; + const langLabels: Record = {}; if (languages?.length) { languages.forEach((language) => { - const countryCode = language.countryCode.length - ? `-${language.countryCode}` - : ''; + const countryCode = language.countryCode ? `-${language.countryCode}` : ''; langLabels[language.id] = `${language.languageCode}${countryCode}`; }); @@ -238,7 +273,7 @@ export class DotPageStore extends ComponentStore { readonly pageTypes$ = this.select(({ pageTypes }) => pageTypes); readonly setFavoritePages = this.updater>( - (state: DotPagesState, favoritePages: DotFavoritePagesInfo) => { + (state: DotPagesState, favoritePages: Partial) => { return { ...state, favoritePages: { @@ -250,7 +285,7 @@ export class DotPageStore extends ComponentStore { ); readonly setPages = this.updater>( - (state: DotPagesState, pagesInfo: DotPagesInfo) => { + (state: DotPagesState, pagesInfo: Partial) => { return { ...state, pages: { @@ -518,15 +553,7 @@ export class DotPageStore extends ComponentStore { environments, filterParams, collapsedParam - ]: [ - ESContent, - DotCurrentUser, - DotLanguage[], - boolean, - boolean, - DotSessionStorageFilter, - boolean - ]) => { + ]: DotPagesSourceData) => { return this.dotCurrentUser .getUserPermissions( currentUser.userId, @@ -535,7 +562,7 @@ export class DotPageStore extends ComponentStore { ) .pipe( take(1), - map((permissionsType: DotPermissionsType) => { + map((permissionsType: DotPermissionsType): DotPagesInitData => { return [ favoritePages, currentUser, @@ -561,16 +588,7 @@ export class DotPageStore extends ComponentStore { permissions, filterParams, collapsedParam - ]: [ - ESContent, - DotCurrentUser, - DotLanguage[], - boolean, - boolean, - DotPermissionsType, - DotSessionStorageFilter, - boolean - ]): void => { + ]: DotPagesInitData): void => { this.setState({ favoritePages: { collapsed: collapsedParam, @@ -586,12 +604,12 @@ export class DotPageStore extends ComponentStore { loggedUser: { id: currentUser.userId, canRead: { - contentlets: permissions.CONTENTLETS.canRead, - htmlPages: permissions.HTMLPAGES.canRead + contentlets: permissions['CONTENTLETS'].canRead ?? false, + htmlPages: permissions['HTMLPAGES'].canRead ?? false }, canWrite: { - contentlets: permissions.CONTENTLETS.canWrite, - htmlPages: permissions.HTMLPAGES.canWrite + contentlets: permissions['CONTENTLETS'].canWrite ?? false, + htmlPages: permissions['HTMLPAGES'].canWrite ?? false } }, pages: { @@ -614,16 +632,16 @@ export class DotPageStore extends ComponentStore { }, isEnterprise: false, environments: false, - languages: null, + languages: [], loggedUser: { - id: null, + id: '', canRead: { - contentlets: null, - htmlPages: null + contentlets: false, + htmlPages: false }, canWrite: { - contentlets: null, - htmlPages: null + contentlets: false, + htmlPages: false } }, pages: { @@ -874,7 +892,7 @@ export class DotPageStore extends ComponentStore { item?.contentType === 'dotFavoritePage' ? item.url : generateDotFavoritePageUrl({ - pageURI: item.urlMap || item.url.split('?')[0], + pageURI: item['urlMap'] || item.url.split('?')[0], languageId: item.languageId, siteId: item.host }) @@ -903,10 +921,10 @@ export class DotPageStore extends ComponentStore { } ); - private getSessionStorageFilterParams(): Observable { - const params = JSON.parse(sessionStorage.getItem(SESSION_STORAGE_FAVORITES_KEY)); + private getSessionStorageFilterParams(): Observable { + const stored = sessionStorage.getItem(SESSION_STORAGE_FAVORITES_KEY); - return of(params); + return of(stored ? (JSON.parse(stored) as DotSessionStorageFilter) : null); } private getLocalStorageFavoritePanelParams(): Observable { @@ -927,7 +945,7 @@ export class DotPageStore extends ComponentStore { const favoritePageUrl = favoritePage ? favoritePage.url : generateDotFavoritePageUrl({ - pageURI: item.urlMap || item.url, + pageURI: item['urlMap'] || item.url, languageId: item.languageId, siteId: item.host }); @@ -1015,7 +1033,7 @@ export class DotPageStore extends ComponentStore { workflow: action, callback: 'ngWorkflowEventCallback', inode: item.inode, - selectedInodes: null + selectedInodes: undefined }; this.dotWorkflowEventHandlerService.open(wfActionEvent); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-table/dot-pages-table.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-table/dot-pages-table.component.ts index f73cd7c5e028..0291c2ecffbe 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-table/dot-pages-table.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-table/dot-pages-table.component.ts @@ -45,7 +45,8 @@ import { DotPageActionsService } from '../services/dot-page-actions.service'; type LanguageOption = { label: string; - value: string | number; + /** Null on the "All" row, which is how the table spells "no language filter". */ + value: string | number | null; }; type TableRowSelectEvent = { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.component.spec.ts index 04d8421f9b89..7a6abd3cf954 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.component.spec.ts @@ -231,7 +231,7 @@ describe('DotPagesComponent', () => { }); it('toggleMenu should close when already visible (triggered by dot-pages-table openMenu)', () => { - const menu = spectator.component.menu() as unknown as MenuStubComponent; + const menu = spectator.component.menu() as unknown as TieredMenuStubComponent; menu.visible = true; const closeSpy = jest.spyOn(spectator.component, 'closeMenu'); @@ -244,7 +244,7 @@ describe('DotPagesComponent', () => { }); it('toggleMenu should load items and show menu anchored to the click target (triggered by favorites panel openMenu)', () => { - const menu = spectator.component.menu() as unknown as MenuStubComponent; + const menu = spectator.component.menu() as unknown as TieredMenuStubComponent; menu.visible = false; const showSpy = jest.spyOn(menu, 'show'); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.component.ts index e6ca7d6ab3e0..bdd41bdf96a0 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.component.ts @@ -36,7 +36,6 @@ import { } from '@dotcms/data-access'; import { DotCMSContentlet, - DotEvent, DotMessageSeverity, DotMessageType, DotSystemLanguage @@ -139,7 +138,7 @@ export class DotPagesComponent { */ protected navigateToPage(url: string): void { const splittedUrl = url.split('?'); - const urlParams = { url: splittedUrl[0] }; + const urlParams: Record = { url: splittedUrl[0] }; const searchParams = new URLSearchParams(splittedUrl[1]); for (const entry of searchParams) { @@ -243,16 +242,21 @@ export class DotPagesComponent { */ private listenSavePageEvent(): void { this.#dotEventsService - .listen('save-page') + .listen('save-page') .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe((event: DotEvent) => { - const { data } = event; - const { value, payload } = data; + .subscribe((event) => { + const { value, payload } = event.data ?? {}; const { contentletIdentifier, identifier, contentletType, contentType } = payload ?? {}; const baseType = contentType ?? contentletType; const baseIdentifier = identifier ?? contentletIdentifier; + // Every key on the payload is optional; without an identifier there is no node to + // refresh, so the event is not about a page this portlet shows. + if (!baseIdentifier) { + return; + } + if (baseType === 'dotFavoritePage') { this.#dotCMSPagesStore.updateFavoritePageNode(baseIdentifier); } else { @@ -261,7 +265,7 @@ export class DotPagesComponent { this.#dotMessageDisplayService.push({ life: 3000, - message: value, + message: value ?? '', severity: DotMessageSeverity.SUCCESS, type: DotMessageType.SIMPLE_MESSAGE }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/guards/dot-pages.guard.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/guards/dot-pages.guard.ts index 9e2c98828f12..abdec8a2bb08 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/guards/dot-pages.guard.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/guards/dot-pages.guard.ts @@ -22,11 +22,11 @@ export const newEditContentForContentletGuard: CanActivateFn = ( const dotContentTypeService = inject(DotContentTypeService); const dotRouterService = inject(DotRouterService); // Inject the Router service - return dotContentletService.getContentletByInode(inode).pipe( + return dotContentletService.getContentletByInode(inode ?? '').pipe( switchMap((contentlet) => { return dotContentTypeService.getContentType(contentlet.contentType).pipe( map(({ metadata }) => { - const newEditorEnabled = metadata?.CONTENT_EDITOR2_ENABLED; + const newEditorEnabled = metadata?.['CONTENT_EDITOR2_ENABLED']; if (!newEditorEnabled) { return true; } @@ -54,9 +54,9 @@ export const newEditContentForContentTypeGuard: CanActivateFn = ( const dotContentTypeService = inject(DotContentTypeService); const dotRouterService = inject(DotRouterService); // Inject the Router service - return dotContentTypeService.getContentType(contentType).pipe( + return dotContentTypeService.getContentType(contentType ?? '').pipe( map(({ metadata }) => { - const newEditorEnabled = metadata?.CONTENT_EDITOR2_ENABLED; + const newEditorEnabled = metadata?.['CONTENT_EDITOR2_ENABLED']; if (!newEditorEnabled) { return true; } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/services/dot-page-actions.service.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/services/dot-page-actions.service.spec.ts index 1d52ba5cb975..18421d898819 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/services/dot-page-actions.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/services/dot-page-actions.service.spec.ts @@ -3,6 +3,7 @@ import { of, throwError } from 'rxjs'; import { fakeAsync, tick } from '@angular/core/testing'; +import { MenuItemCommandEvent } from 'primeng/api'; import { DialogService } from 'primeng/dynamicdialog'; import { @@ -100,7 +101,7 @@ const MOCK_WORKFLOW_ACTION_NO_INPUTS: DotCMSWorkflowAction = { nextAssign: 'user1', nextStep: 'step1', schemeId: 'scheme1' -} as DotCMSWorkflowAction; +} as unknown as DotCMSWorkflowAction; const MOCK_WORKFLOW_ACTION_WITH_INPUTS: DotCMSWorkflowAction = { id: 'workflow-2', @@ -115,7 +116,7 @@ const MOCK_WORKFLOW_ACTION_WITH_INPUTS: DotCMSWorkflowAction = { nextAssign: 'user2', nextStep: 'step2', schemeId: 'scheme1' -} as DotCMSWorkflowAction; +} as unknown as DotCMSWorkflowAction; const MOCK_PERMISSIONS = { CONTENTLETS: { @@ -424,7 +425,7 @@ describe('DotPageActionsService', () => { item.label?.includes('favoritePage.contextMenu.action.add') ); - favoriteAction?.command?.({} as unknown); + favoriteAction?.command?.({} as MenuItemCommandEvent); expect(mockDialogService.open).toHaveBeenCalled(); done(); @@ -437,7 +438,7 @@ describe('DotPageActionsService', () => { item.label?.includes('favoritePage.dialog.delete.button') ); - deleteAction?.command?.({} as unknown); + deleteAction?.command?.({} as MenuItemCommandEvent); tick(); expect(mockWorkflowActionsFireService.deleteContentlet).toHaveBeenCalledWith({ @@ -458,7 +459,7 @@ describe('DotPageActionsService', () => { item.label?.includes('favoritePage.dialog.delete.button') ); - deleteAction?.command?.({} as unknown); + deleteAction?.command?.({} as MenuItemCommandEvent); tick(); // Check that error handler was called with an error and true flag @@ -474,7 +475,7 @@ describe('DotPageActionsService', () => { spectator.service.getItems(MOCK_HTMLPAGE_CONTENTLET).subscribe((items) => { const editAction = items.find((item) => item.label === 'Edit'); - editAction?.command?.({} as unknown); + editAction?.command?.({} as MenuItemCommandEvent); expect(mockRouterService.goToEditContentlet).toHaveBeenCalledWith( MOCK_HTMLPAGE_CONTENTLET.inode @@ -491,7 +492,7 @@ describe('DotPageActionsService', () => { item.label?.includes('push_publish') ); - pushPublishAction?.command?.({} as unknown); + pushPublishAction?.command?.({} as MenuItemCommandEvent); expect(mockPushPublishDialogService.open).toHaveBeenCalledWith({ assetIdentifier: MOCK_HTMLPAGE_CONTENTLET.identifier, @@ -511,7 +512,7 @@ describe('DotPageActionsService', () => { (item) => item.label === MOCK_WORKFLOW_ACTION_NO_INPUTS.name ); - workflowAction?.command?.({} as unknown); + workflowAction?.command?.({} as MenuItemCommandEvent); tick(); expect(mockWorkflowActionsFireService.fireTo).toHaveBeenCalledWith({ @@ -533,7 +534,7 @@ describe('DotPageActionsService', () => { (item) => item.label === MOCK_WORKFLOW_ACTION_WITH_INPUTS.name ); - workflowAction?.command?.({} as unknown); + workflowAction?.command?.({} as MenuItemCommandEvent); expect(mockWorkflowEventHandlerService.open).toHaveBeenCalledWith({ workflow: MOCK_WORKFLOW_ACTION_WITH_INPUTS, @@ -555,7 +556,7 @@ describe('DotPageActionsService', () => { (item) => item.label === MOCK_WORKFLOW_ACTION_NO_INPUTS.name ); - workflowAction?.command?.({} as unknown); + workflowAction?.command?.({} as MenuItemCommandEvent); tick(); // Check that error handler was called with an error and true flag @@ -606,10 +607,12 @@ describe('DotPageActionsService', () => { describe('Edge Cases', () => { it('should handle contentlet without baseType', (done) => { + // The model declares `baseType` required; this test drives the guard for a + // contentlet that reaches the UI without one. const contentletWithoutBaseType = { ...MOCK_HTMLPAGE_CONTENTLET, baseType: undefined - } as DotCMSContentlet; + } as unknown as DotCMSContentlet; spectator.service.getItems(contentletWithoutBaseType).subscribe((items) => { const editAction = items.find((item) => item.label === 'Edit'); @@ -665,7 +668,7 @@ describe('DotPageActionsService', () => { expect(favoriteAction).toBeTruthy(); // Step 2: Dialog opens - favoriteAction?.command?.({} as unknown); + favoriteAction?.command?.({} as MenuItemCommandEvent); tick(); expect(mockDialogService.open).toHaveBeenCalled(); @@ -703,7 +706,7 @@ describe('DotPageActionsService', () => { expect(workflowAction).toBeTruthy(); // Step 2: Workflow executes - workflowAction?.command?.({} as unknown); + workflowAction?.command?.({} as MenuItemCommandEvent); tick(); // Step 3: Service fires workflow @@ -724,7 +727,7 @@ describe('DotPageActionsService', () => { expect(editAction).toBeTruthy(); // Step 2: Router navigates to edit page - editAction?.command?.({} as unknown); + editAction?.command?.({} as MenuItemCommandEvent); expect(mockRouterService.goToEditContentlet).toHaveBeenCalledWith( MOCK_HTMLPAGE_CONTENTLET.inode @@ -742,7 +745,7 @@ describe('DotPageActionsService', () => { expect(pushPublishAction).toBeTruthy(); // Step 2: Push publish dialog opens - pushPublishAction?.command?.({} as unknown); + pushPublishAction?.command?.({} as MenuItemCommandEvent); expect(mockPushPublishDialogService.open).toHaveBeenCalledWith({ assetIdentifier: MOCK_HTMLPAGE_CONTENTLET.identifier, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/services/dot-page-actions.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/services/dot-page-actions.service.ts index 4732f92b9351..20b43b53ee99 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/services/dot-page-actions.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/services/dot-page-actions.service.ts @@ -421,7 +421,7 @@ export class DotPageActionsService { } #getFavoritePageUrl(item: DotCMSContentlet): string { - const pageURI = item.urlMap ?? (item.url ? item.url.split('?')[0] : ''); + const pageURI = item['urlMap'] ?? (item.url ? item.url.split('?')[0] : ''); return generateDotFavoritePageUrl({ pageURI, languageId: item.languageId, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/store/store.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/store/store.spec.ts index 1bf3f0666eae..242a8401e16d 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/store/store.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/store/store.spec.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals import { createServiceFactory, SpectatorService } from '@openng/spectator/jest'; import { Subject, of, throwError } from 'rxjs'; +import { HttpErrorResponse } from '@angular/common/http'; import { signal } from '@angular/core'; import { DotHttpErrorManagerService } from '@dotcms/data-access'; @@ -181,7 +182,7 @@ describe('DotCMSPagesStore', () => { }); it('should set status=error and call httpErrorManagerService.handle(error) when request fails', () => { - const error = new Error('Pages failed'); + const error = new HttpErrorResponse({ status: 500, statusText: 'Pages failed' }); dotPageListService.getPages.mockReturnValueOnce(throwError(() => error)); store.getPages({ search: 'x' }); @@ -295,7 +296,10 @@ describe('DotCMSPagesStore', () => { dotPageListService.getPages.mockReturnValueOnce(of(createESResponse([p1, p2], 2))); store.getPages(); - const error = new Error('Single page failed'); + const error = new HttpErrorResponse({ + status: 500, + statusText: 'Single page failed' + }); dotPageListService.getSinglePage.mockReturnValueOnce(throwError(() => error)); store.updatePageNode('page-2'); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/store/store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/store/store.ts index 697769f08263..12935e0ea0f4 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/store/store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/store/store.ts @@ -2,6 +2,7 @@ import { patchState, signalMethod, signalStore, + signalStoreFeature, withComputed, withHooks, withMethods, @@ -31,7 +32,14 @@ export interface DotCMSPagesPortletState { pagination: DotPagination; filters: ListPagesParams; languages: DotLanguage[]; - currentUser?: DotCurrentUser; + /** + * `| null`, not optional: the initial state seeds it to `null`, which is what "no user loaded + * yet" means here. Declared optional, `withState(initialState)` did not type-check — and a + * failing `withState` collapses the whole store's type to `{ [x: string]: Function }`, which is + * why every named read of this store, in the store and across its spec, reported an + * index-signature access. + */ + currentUser: DotCurrentUser | null; bundleDialog: { show: boolean; pageIdentifier: string; @@ -66,101 +74,118 @@ const initialState: DotCMSPagesPortletState = { status: 'loading' }; -export const DotCMSPagesStore = signalStore( - withState(initialState), - withComputed((store) => { - return { - $totalRecords: computed(() => store.pagination.totalEntries()), - $showBundleDialog: computed(() => store.bundleDialog.show()), - $assetIdentifier: computed(() => store.bundleDialog.pageIdentifier()), - $isPagesLoading: computed(() => store.status() === 'loading') - }; - }), - withMethods((store) => { - const dotPageListService = inject(DotPageListService); - const httpErrorManagerService = inject(DotHttpErrorManagerService); +/** + * The state, computeds, methods and hooks `withFavorites` composes on top of, bundled into one + * feature. + * + * Passed as four separate arguments to `signalStore`, ngrx stopped carrying the earlier features' + * results forward — `withFavorites()` received `InnerSignalStore`, so its `state: DotCMSPagesPortletState` constraint could not be met and the + * whole store's type collapsed to `{ [x: string]: Function }`. Every named read of this store, here + * and across its spec, then reported an index-signature access. Composing them with + * `signalStoreFeature` first keeps the inference intact. + */ +const withPagesBase = () => + signalStoreFeature( + withState(initialState), + withComputed((store) => { + return { + $totalRecords: computed(() => store.pagination.totalEntries()), + $showBundleDialog: computed(() => store.bundleDialog.show()), + $assetIdentifier: computed(() => store.bundleDialog.pageIdentifier()), + $isPagesLoading: computed(() => store.status() === 'loading') + }; + }), + withMethods((store) => { + const dotPageListService = inject(DotPageListService); + const httpErrorManagerService = inject(DotHttpErrorManagerService); - const fetchPages = (params: Partial = {}) => { - const nextFilters: ListPagesParams = { ...store.filters(), ...params }; - const limit = nextFilters.limit ?? 40; - const offset = nextFilters.offset ?? 0; + const fetchPages = (params: Partial = {}) => { + const nextFilters: ListPagesParams = { ...store.filters(), ...params }; + const limit = nextFilters.limit ?? 40; + const offset = nextFilters.offset ?? 0; - patchState(store, { - status: 'loading', - filters: nextFilters - }); + patchState(store, { + status: 'loading', + filters: nextFilters + }); - dotPageListService.getPages(nextFilters).subscribe({ - next: ({ jsonObjectView, resultsSize }) => { - patchState(store, { - status: 'loaded', - pages: jsonObjectView.contentlets, - pagination: { - currentPage: Math.floor(offset / limit) + 1, - perPage: limit, - totalEntries: resultsSize - } - }); - }, - error: (error) => { - patchState(store, { status: 'error' }); - httpErrorManagerService.handle(error); - } - }); - }; - return { - getPages: (params: Partial = {}) => fetchPages(params), - searchPages: (search: string) => { - fetchPages({ search, offset: 0 }); - }, - filterByLanguage: (languageId: number) => { - fetchPages({ languageId, offset: 0 }); - }, - filterByArchived: (archived: boolean) => { - fetchPages({ archived, offset: 0 }); - }, - onLazyLoad: (event: LazyLoadEvent) => { - const { first, sortField, sortOrder } = event; - const offset = Math.max(0, first ?? 0); - const sort = sortField - ? `${sortField} ${sortOrder === 1 ? 'ASC' : 'DESC'}` - : 'title ASC'; - fetchPages({ offset, sort }); - }, - updatePageNode: (identifier: string) => { - dotPageListService.getSinglePage(identifier).subscribe({ - next: (updatedPage) => { - const currentPages = store.pages(); - const nextPages = currentPages.map((page) => - page?.identifier === identifier ? updatedPage : page - ); - patchState(store, { pages: nextPages }); + dotPageListService.getPages(nextFilters).subscribe({ + next: ({ jsonObjectView, resultsSize }) => { + patchState(store, { + status: 'loaded', + pages: jsonObjectView.contentlets, + pagination: { + currentPage: Math.floor(offset / limit) + 1, + perPage: limit, + totalEntries: resultsSize + } + }); }, error: (error) => { + patchState(store, { status: 'error' }); httpErrorManagerService.handle(error); } }); - }, - showBundleDialog: (pageIdentifier: string) => { - patchState(store, { bundleDialog: { show: true, pageIdentifier } }); - }, - hideBundleDialog: () => { - patchState(store, { bundleDialog: { show: false, pageIdentifier: '' } }); - } - }; - }), - withHooks((store) => { - const globalStore = inject(GlobalStore); - return { - onInit: () => { - const handleSwitchSite = signalMethod((site: DotSite) => { - if (!site) return; - const host = site.identifier; - store.getPages({ ...initialFilters, host }); - }); - handleSwitchSite(globalStore.siteDetails); - } - }; - }), - withFavorites() -); + }; + return { + getPages: (params: Partial = {}) => fetchPages(params), + searchPages: (search: string) => { + fetchPages({ search, offset: 0 }); + }, + filterByLanguage: (languageId: number) => { + fetchPages({ languageId, offset: 0 }); + }, + filterByArchived: (archived: boolean) => { + fetchPages({ archived, offset: 0 }); + }, + onLazyLoad: (event: LazyLoadEvent) => { + const { first, sortField, sortOrder } = event; + const offset = Math.max(0, first ?? 0); + const sort = sortField + ? `${sortField} ${sortOrder === 1 ? 'ASC' : 'DESC'}` + : 'title ASC'; + fetchPages({ offset, sort }); + }, + updatePageNode: (identifier: string) => { + dotPageListService.getSinglePage(identifier).subscribe({ + next: (updatedPage) => { + const currentPages = store.pages(); + const nextPages = currentPages.map((page) => + page?.identifier === identifier ? updatedPage : page + ); + patchState(store, { pages: nextPages }); + }, + error: (error) => { + httpErrorManagerService.handle(error); + } + }); + }, + showBundleDialog: (pageIdentifier: string) => { + patchState(store, { bundleDialog: { show: true, pageIdentifier } }); + }, + hideBundleDialog: () => { + patchState(store, { bundleDialog: { show: false, pageIdentifier: '' } }); + } + }; + }), + withHooks((store) => { + const globalStore = inject(GlobalStore); + return { + onInit: () => { + // `| null` matches `globalStore.siteDetails`, which is `Signal` + // — and the guard below was already written for it. + const handleSwitchSite = signalMethod( + (site: DotSite | null) => { + if (!site) return; + const host = site.identifier; + store.getPages({ ...initialFilters, host }); + } + ); + handleSwitchSite(globalStore.siteDetails); + } + }; + }) + ); + +export const DotCMSPagesStore = signalStore(withPagesBase(), withFavorites()); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/store/withFavorite/withFavorite.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/store/withFavorite/withFavorite.spec.ts index 7ae0488747d1..65a5f22825d3 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/store/withFavorite/withFavorite.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/store/withFavorite/withFavorite.spec.ts @@ -3,6 +3,7 @@ import { patchState, signalStore, withState } from '@ngrx/signals'; import { createServiceFactory, SpectatorService } from '@openng/spectator/jest'; import { of, throwError } from 'rxjs'; +import { HttpErrorResponse } from '@angular/common/http'; import { signal } from '@angular/core'; import { DotHttpErrorManagerService } from '@dotcms/data-access'; @@ -164,7 +165,7 @@ describe('withFavorites', () => { }); it('getFavoritePages() should call httpErrorManagerService.handle(error) and set favoriteState=error when request fails', () => { - const error = new Error('Favorites failed'); + const error = new HttpErrorResponse({ status: 500, statusText: 'Favorites failed' }); dotPageListService.getFavoritePages.mockReturnValueOnce(throwError(() => error)); store.getFavoritePages(); @@ -201,7 +202,7 @@ describe('withFavorites', () => { ]; patchState(store, { favoritePages: current, favoriteState: 'loaded' }); - const error = new Error('Single page failed'); + const error = new HttpErrorResponse({ status: 500, statusText: 'Single page failed' }); dotPageListService.getSinglePage.mockReturnValueOnce(throwError(() => error)); store.updateFavoritePageNode('page-2'); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/store/withFavorite/withFavorite.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/store/withFavorite/withFavorite.ts index 6642e2c9d017..02f4254c8f33 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/store/withFavorite/withFavorite.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/store/withFavorite/withFavorite.ts @@ -84,8 +84,11 @@ export const withFavorites = () => { const globalStore = inject(GlobalStore); return { onInit: () => { - const handleSwitchSite = signalMethod((site: DotSite) => { - if (!site) return; + const handleSwitchSite = signalMethod((site) => { + if (!site) { + return; + } + const host = site.identifier; store.getFavoritePages({ host }); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-contentlets/dot-contentlets.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-contentlets/dot-contentlets.component.ts index 747706ce3638..1f789ddbf235 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-contentlets/dot-contentlets.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-contentlets/dot-contentlets.component.ts @@ -25,7 +25,7 @@ export class DotContentletsComponent implements AfterViewInit { setTimeout(() => { this.dotContentletEditorService.edit({ data: { - inode: this.route.snapshot.params.asset + inode: this.route.snapshot.params['asset'] } }); }, 0); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-portlet-detail.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-portlet-detail.component.ts index 18a6474c949d..e453fc96f222 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-portlet-detail.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-portlet-detail.component.ts @@ -18,7 +18,7 @@ export class DotPortletDetailComponent implements OnInit { isContent = false; ngOnInit() { - const currentPortlet: string = this.route.parent.snapshot.params.id; + const currentPortlet: string = this.route.parent?.snapshot.params['id']; this.isWorkflow = currentPortlet === 'workflow'; this.isContent = !this.isWorkflow; } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-workflow-task/dot-workflow-task.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-workflow-task/dot-workflow-task.component.ts index 9b99b9b354c5..ff31637a26be 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-workflow-task/dot-workflow-task.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-workflow-task/dot-workflow-task.component.ts @@ -26,7 +26,7 @@ export class DotWorkflowTaskComponent implements OnInit { ngOnInit() { this.dotWorkflowTaskDetailService.view({ header: this.dotMessageService.get('workflow.task.dialog.header'), - id: this.route.snapshot.params.asset + id: this.route.snapshot.params['asset'] }); } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.ts index febd4f4e0f40..9c8efa18cc22 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.ts @@ -43,18 +43,18 @@ export class DotOnboardingAuthorComponent implements OnInit { private dotAccountService = inject(DotAccountService); @Output() eventEmitter = new EventEmitter<'reset-user-profile'>(); - userData$: Observable<{ + userData$!: Observable<{ username: string; showCreateContentLink: boolean; showCreateDataModelLink: boolean; showCreatePageLink: boolean; showCreateTemplateLink: boolean; }>; - username: string; - showCreateContentLink: boolean; - showCreateDataModelLink: boolean; - showCreatePageLink: boolean; - showCreateTemplateLink: boolean; + username!: string; + showCreateContentLink = false; + showCreateDataModelLink = false; + showCreatePageLink = false; + showCreateTemplateLink = false; resources = FOOTER_RESOURCES; apiAndServices = API_AND_SERVICES; @@ -92,13 +92,13 @@ export class DotOnboardingAuthorComponent implements OnInit { return { username: user.givenName, showCreateContentLink: - permissions[PermissionsType.CONTENTLETS].canWrite, + !!permissions[PermissionsType.CONTENTLETS].canWrite, showCreateDataModelLink: - permissions[PermissionsType.STRUCTURES].canWrite, + !!permissions[PermissionsType.STRUCTURES].canWrite, showCreatePageLink: - permissions[PermissionsType.HTMLPAGES].canWrite, + !!permissions[PermissionsType.HTMLPAGES].canWrite, showCreateTemplateLink: - permissions[PermissionsType.TEMPLATES].canWrite + !!permissions[PermissionsType.TEMPLATES].canWrite }; } ) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.spec.ts index d161d9dffbd8..c11de77f1b76 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.spec.ts @@ -65,7 +65,7 @@ describe('DotStarterComponent', () => { }); it('should show onboarding-dev and hide profile selection when developer card is clicked', () => { - spectator.click(spectator.query('[data-testid="developer-card"]')); + spectator.click(spectator.query('[data-testid="developer-card"]')!); spectator.detectChanges(); expect(spectator.query('[data-testid="profile-selection"]')).toBeFalsy(); @@ -75,7 +75,7 @@ describe('DotStarterComponent', () => { }); it('should show onboarding-author and hide profile selection when marketer card is clicked', () => { - spectator.click(spectator.query('[data-testid="marketer-card"]')); + spectator.click(spectator.query('[data-testid="marketer-card"]')!); spectator.detectChanges(); expect(spectator.query('[data-testid="profile-selection"]')).toBeFalsy(); @@ -123,7 +123,7 @@ describe('DotStarterComponent', () => { it('should show profile selection and hide onboarding when reset button is clicked', () => { expect(spectator.query('[data-testid="onboarding-dev"]')).toBeTruthy(); - spectator.click(spectator.query('[data-testid="reset-profile"]')); + spectator.click(spectator.query('[data-testid="reset-profile"]')!); spectator.detectChanges(); expect(spectator.query('[data-testid="profile-selection"]')).toBeTruthy(); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-advanced/dot-template-advanced.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-advanced/dot-template-advanced.component.spec.ts index 07234020bfa9..50b867769fbb 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-advanced/dot-template-advanced.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-advanced/dot-template-advanced.component.spec.ts @@ -17,6 +17,8 @@ import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotTemplateAdvancedComponent } from './dot-template-advanced.component'; +import { DotPortletToolbarActions } from '../../../../shared/models/dot-portlet-toolbar.model/dot-portlet-toolbar-actions.model'; + @Component({ selector: 'dot-portlet-base', template: '', @@ -30,7 +32,7 @@ export class DotPortletBaseMockComponent {} standalone: false }) export class DotPortletToolbarMockComponent { - @Input() actions; + @Input() actions!: DotPortletToolbarActions; } @Component({ @@ -63,25 +65,25 @@ export class DotContainerSelectorMockComponent { }) export class DotTextareaContentMockComponent implements ControlValueAccessor { @Input() - code; + code!: { mode: string; options: Record }; @Input() - height; + height!: string; @Input() - show; + show!: string[]; @Input() - value; + value!: string; @Input() - width; + width!: string; @Output() monacoInit = new EventEmitter(); @Input() - language; + language!: string; writeValue() { // @@ -168,12 +170,12 @@ describe('DotTemplateAdvancedComponent', () => { const code = de.query(By.css('dot-textarea-content')); expect(container).not.toBeNull(); - expect(container.attributes.class).toBeUndefined(); + expect(container.attributes['class']).toBeUndefined(); expect(code).not.toBeNull(); - expect(code.attributes.formControlName).toBe('body'); - expect(code.attributes.height).toBe('100%'); - expect(code.attributes.language).toBe('html'); + expect(code.attributes['formControlName']).toBe('body'); + expect(code.attributes['height']).toBe('100%'); + expect(code.attributes['language']).toBe('html'); const codeComponent = code.componentInstance as DotTextareaContentMockComponent; expect(codeComponent.show).toEqual(['code']); }); @@ -182,7 +184,7 @@ describe('DotTemplateAdvancedComponent', () => { describe('events', () => { it('should emit updateTemplate event when the form changes', () => { const updateTemplate = jest.spyOn(component.updateTemplate, 'emit'); - component.form.get('body').setValue(''); + component.form.get('body')!.setValue(''); expect(updateTemplate).toHaveBeenCalledWith({ body: '' }); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-advanced/dot-template-advanced.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-advanced/dot-template-advanced.component.ts index 804d62eeceb9..17ab52dfc94e 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-advanced/dot-template-advanced.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-advanced/dot-template-advanced.component.ts @@ -65,13 +65,13 @@ export class DotTemplateAdvancedComponent implements OnInit, OnDestroy, OnChange @Output() save = new EventEmitter(); @Output() cancel = new EventEmitter(); - @Input() body: string; - @Input() didTemplateChanged: boolean; + @Input() body!: string; + @Input() didTemplateChanged!: boolean; // `any` because the type of the editor in the ngx-monaco-editor package is not typed - editor: MonacoEditor; - form: UntypedFormGroup; - actions: DotPortletToolbarActions; + editor!: MonacoEditor; + form!: UntypedFormGroup; + actions!: DotPortletToolbarActions; private destroy$: Subject = new Subject(); ngOnInit(): void { @@ -85,8 +85,8 @@ export class DotTemplateAdvancedComponent implements OnInit, OnDestroy, OnChange } ngOnChanges(changes: SimpleChanges) { - if (changes.didTemplateChanged) { - this.actions = this.getActions(!changes.didTemplateChanged.currentValue); + if (changes['didTemplateChanged']) { + this.actions = this.getActions(!changes['didTemplateChanged'].currentValue); } } @@ -125,9 +125,13 @@ export class DotTemplateAdvancedComponent implements OnInit, OnDestroy, OnChange } private setContainerId({ identifier, hostName }: DotContainer): string { + if (!hostName) { + return identifier; + } + const regex = new RegExp('//' + hostName); - return identifier?.includes(hostName) ? identifier.replace(regex, '') : identifier; + return identifier.includes(hostName) ? identifier.replace(regex, '') : identifier; } private getActions(disabled = true): DotPortletToolbarActions { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-builder/dot-template-builder.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-builder/dot-template-builder.component.spec.ts index 6e960fff33c8..1cf8a5d07c7a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-builder/dot-template-builder.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-builder/dot-template-builder.component.spec.ts @@ -27,7 +27,7 @@ import { DotTemplateItem, DotTemplateItemDesign } from '../store/dot-template.st standalone: true }) class MockIframeComponent { - @Input() src: string; + @Input() src!: string; @Output() custom: EventEmitter = new EventEmitter(); iframeElement = { @@ -75,7 +75,7 @@ describe('DotTemplateBuilderComponent', () => { layout: { body: { rows: [] } }, containers: {}, ...overrides - } as DotTemplateItemDesign; + } as unknown as DotTemplateItemDesign; }; const createAdvancedItem = (overrides: Partial = {}): DotTemplateItem => { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-builder/dot-template-builder.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-builder/dot-template-builder.component.ts index e8e70e0fdeac..0fb7f5e98311 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-builder/dot-template-builder.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-builder/dot-template-builder.component.ts @@ -45,7 +45,7 @@ export const AUTOSAVE_DEBOUNCE_TIME = 5000; export class DotTemplateBuilderComponent implements OnInit, OnDestroy { readonly #dotRouterService = inject(DotRouterService); - private _item: DotTemplateItem; + private _item!: DotTemplateItem; @Input() set item(value: DotTemplateItem) { @@ -55,19 +55,19 @@ export class DotTemplateBuilderComponent implements OnInit, OnDestroy { get item(): DotTemplateItem { return this._item; } - @Input() didTemplateChanged: boolean; + @Input() didTemplateChanged!: boolean; @Output() saveAndPublish = new EventEmitter(); @Output() updateTemplate = new EventEmitter(); @Output() save = new EventEmitter(); @Output() cancel = new EventEmitter(); @Output() custom: EventEmitter = new EventEmitter(); - @ViewChild('historyIframe') historyIframe: IframeComponent; + @ViewChild('historyIframe') historyIframe!: IframeComponent; permissionsUrl = ''; historyUrl = ''; templateUpdate$ = new Subject(); destroy$: Subject = new Subject(); - lastTemplate: DotTemplateItem; + lastTemplate!: DotTemplateItem; ngOnInit() { this.permissionsUrl = `/html/templates/permissions.jsp?templateId=${this.item.identifier}&popup=true`; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.spec.ts index 3b76a00a7c3b..93dcb09002e4 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.spec.ts @@ -63,7 +63,7 @@ import { DotPortletBaseComponent } from '../../../view/components/dot-portlet-ba template: '' }) export class DotApiLinkMockComponent { - @Input() href; + @Input() href!: string; } @Component({ @@ -71,8 +71,8 @@ export class DotApiLinkMockComponent { template: '' }) export class DotTemplateBuilderMockComponent { - @Input() item; - @Input() didTemplateChanged; + @Input() item!: DotTemplateItem; + @Input() didTemplateChanged!: boolean; @Output() save = new EventEmitter(); @Output() cancel = new EventEmitter(); @Output() custom: EventEmitter = new EventEmitter(); @@ -83,7 +83,7 @@ export class DotTemplateBuilderMockComponent { template: '' }) export class DotPortletBaseMockComponent { - @Input() boxed; + @Input() boxed!: boolean; } @Component({ @@ -92,7 +92,7 @@ export class DotPortletBaseMockComponent { '
' }) export class DotPortletToolbarMockComponent { - @Input() title; + @Input() title!: string; } @Component({ @@ -161,11 +161,11 @@ class MockDotSystemConfigService { } } -async function makeFormValid(fixture) { +async function makeFormValid(fixture: ComponentFixture) { // can't use debugElement because the dialogs opens outside the component - const title: HTMLInputElement = document.querySelector( + const title = document.querySelector( '[data-testid="templatePropsTitleField"]' - ); + )!; title.value = 'Hello World'; @@ -184,7 +184,7 @@ async function makeFormValid(fixture) { themeButton.click(); fixture.detectChanges(); await fixture.whenRenderingDone(); - const item: HTMLElement = document.querySelector('.theme-selector__data-list-item'); + const item = document.querySelector('.theme-selector__data-list-item')!; item.click(); } @@ -424,9 +424,9 @@ describe('DotTemplateCreateEditComponent', () => { it('should go to template list when cancel dialog button is clicked', () => { // can't use debugElement because the dialogs opens outside the component - const button: HTMLButtonElement = document.querySelector( + const button = document.querySelector( '[data-testid="dotFormDialogCancel"]' - ); + )!; button.click(); expect(store.goToTemplateList).toHaveBeenCalledTimes(1); @@ -435,9 +435,9 @@ describe('DotTemplateCreateEditComponent', () => { xit('should save template when save dialog button is clicked', async () => { await makeFormValid(fixture); - const button: HTMLButtonElement = document.querySelector( + const button = document.querySelector( '[data-testid="dotFormDialogSave"]' - ); + )!; button.click(); @@ -504,9 +504,9 @@ describe('DotTemplateCreateEditComponent', () => { it('should save template when save dialog button is clicked', async () => { // can't use debugElement because the dialogs opens outside the component - const title: HTMLInputElement = document.querySelector( + const title = document.querySelector( '[data-testid="templatePropsTitleField"]' - ); + )!; title.value = 'Hello World'; @@ -517,9 +517,9 @@ describe('DotTemplateCreateEditComponent', () => { title.dispatchEvent(event); - const button: HTMLButtonElement = document.querySelector( + const button = document.querySelector( '[data-testid="dotFormDialogSave"]' - ); + )!; button.click(); await fixture.whenStable(); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.ts index 18a017ef2eb7..741ff260d8df 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.ts @@ -53,9 +53,9 @@ export class DotTemplateCreateEditComponent implements OnInit, OnDestroy { readonly #store = inject(DotTemplateStore); readonly #globalStore = inject(GlobalStore); - vm$: Observable; + vm$!: Observable; - form: UntypedFormGroup; + form!: UntypedFormGroup; private destroy$: Subject = new Subject(); ngOnInit() { @@ -191,7 +191,7 @@ export class DotTemplateCreateEditComponent implements OnInit, OnDestroy { } } }); - ref.onClose.pipe(takeUntil(this.destroy$)).subscribe((goToListing: boolean) => { + ref?.onClose.pipe(takeUntil(this.destroy$)).subscribe((goToListing: boolean) => { if (goToListing || goToListing === undefined) { this.cancelTemplate(); } @@ -221,7 +221,9 @@ export class DotTemplateCreateEditComponent implements OnInit, OnDestroy { }); } - private getFormValue(template: DotTemplateItem): { [key: string]: string | DotLayout } { + private getFormValue(template: DotTemplateItem): { + [key: string]: string | DotLayout | undefined | null; + } { if (template.type === 'design') { return { type: template.type, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-new/dot-template-new.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-new/dot-template-new.component.ts index 49ad6ebd1533..73b5d57950bd 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-new/dot-template-new.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-new/dot-template-new.component.ts @@ -45,7 +45,7 @@ export class DotTemplateNewComponent implements OnInit { contentStyle: { padding: '0px' } }); - ref.onClose.pipe(take(1)).subscribe((value) => { + ref?.onClose.pipe(take(1)).subscribe((value) => { value ? this.dotRouterService.gotoPortlet(`/templates/new/${value}`) : this.dotRouterService.goToURL(`/templates`); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-new/guards/dot-template.guard.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-new/guards/dot-template.guard.spec.ts index d885177d387a..f61d8aa4273b 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-new/guards/dot-template.guard.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-new/guards/dot-template.guard.spec.ts @@ -1,5 +1,5 @@ import { TestBed } from '@angular/core/testing'; -import { UrlSegment } from '@angular/router'; +import { Route, UrlSegment } from '@angular/router'; import { DotRouterService } from '@dotcms/data-access'; @@ -25,19 +25,22 @@ describe('DotTemplateGuard', () => { dotRouterService = TestBed.inject(DotRouterService); }); + /** `canLoad` declares this parameter `_route` and never reads it. */ + const UNUSED_ROUTE = null as unknown as Route; + it('should return true when path is /advanced', () => { - const segment = new UrlSegment('advanced', null); - expect(guard.canLoad(null, [segment])).toBe(true); + const segment = new UrlSegment('advanced', {}); + expect(guard.canLoad(UNUSED_ROUTE, [segment])).toBe(true); }); it('should return true when path is /designer', () => { - const segment = new UrlSegment('designer', null); - expect(guard.canLoad(null, [segment])).toBe(true); + const segment = new UrlSegment('designer', {}); + expect(guard.canLoad(UNUSED_ROUTE, [segment])).toBe(true); }); it('should return false and redirect with invalid path', () => { - const segment = new UrlSegment('xxxx', null); - expect(guard.canLoad(null, [segment])).toBe(false); + const segment = new UrlSegment('xxxx', {}); + expect(guard.canLoad(UNUSED_ROUTE, [segment])).toBe(false); expect(dotRouterService.gotoPortlet).toHaveBeenCalledWith('templates'); expect(dotRouterService.gotoPortlet).toHaveBeenCalledTimes(1); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-props.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-props.component.spec.ts index 0b1ec00e778e..22bce9cedab6 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-props.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-props.component.spec.ts @@ -175,13 +175,13 @@ describe('DotTemplatePropsComponent', () => { expect(field.classes['field']).toBe(true); expect(label.classes['p-label-input-required']).toBe(true); - expect(label.attributes.for).toBe('title'); + expect(label.attributes['for']).toBe('title'); expect(label.nativeElement.textContent.trim()).toBe('Title'); - expect(input.attributes.autofocus).toBeDefined(); - expect(input.attributes.pInputText).toBeDefined(); - expect(input.attributes.formControlName).toBe('title'); - expect(input.attributes.id).toBe('title'); + expect(input.attributes['autofocus']).toBeDefined(); + expect(input.attributes['pInputText']).toBeDefined(); + expect(input.attributes['formControlName']).toBe('title'); + expect(input.attributes['id']).toBe('title'); expect(message).toBeDefined(); }); @@ -193,11 +193,11 @@ describe('DotTemplatePropsComponent', () => { expect(field.classes['field']).toBe(true); - expect(label.attributes.for).toBe('theme'); + expect(label.attributes['for']).toBe('theme'); expect(label.nativeElement.textContent).toBe('Theme'); - expect(selector.attributes.formControlName).toBe('theme'); - expect(selector.attributes.id).toBe('theme'); + expect(selector.attributes['formControlName']).toBe('theme'); + expect(selector.attributes['id']).toBe('theme'); }); it('should setup description', () => { @@ -207,12 +207,12 @@ describe('DotTemplatePropsComponent', () => { expect(field.classes['field']).toBe(true); - expect(label.attributes.for).toBe('description'); + expect(label.attributes['for']).toBe('description'); expect(label.nativeElement.textContent.trim()).toBe('Description'); - expect(textarea.attributes.pInputTextarea).toBeDefined(); - expect(textarea.attributes.formControlName).toBe('friendlyName'); - expect(textarea.attributes.id).toBe('description'); + expect(textarea.attributes['pInputTextarea']).toBeDefined(); + expect(textarea.attributes['formControlName']).toBe('friendlyName'); + expect(textarea.attributes['id']).toBe('description'); }); it('should setup thumbnail', () => { @@ -221,7 +221,7 @@ describe('DotTemplatePropsComponent', () => { expect(field.classes['field']).toBe(true); - expect(label.attributes.for).toContain('thumbnail'); + expect(label.attributes['for']).toContain('thumbnail'); expect(label.nativeElement.textContent).toContain('Thumbnail'); // TODO: here we're using a webcomponent @@ -244,7 +244,7 @@ describe('DotTemplatePropsComponent', () => { }); it('should be valid when required fields are set', () => { - component.form.get('title').setValue('Hello World'); + component.form.get('title')!.setValue('Hello World'); expect(component.form.valid).toBe(true); expect(component.form.value).toEqual({ @@ -261,11 +261,11 @@ describe('DotTemplatePropsComponent', () => { const saveButton = de.query(By.css('[data-testid="dotFormDialogSave"]')); expect(saveButton.componentInstance.disabled).toBe(true); - component.form.get('title').setValue('Hello World'); + component.form.get('title')!.setValue('Hello World'); fixture.detectChanges(); expect(saveButton.componentInstance.disabled).toBe(false); - component.form.get('title').setValue(''); // back to original value + component.form.get('title')!.setValue(''); // back to original value fixture.detectChanges(); expect(saveButton.componentInstance.disabled).toBe(true); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-props.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-props.component.ts index 5216810e6da8..3993ce94d319 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-props.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-props.component.ts @@ -65,11 +65,11 @@ export class DotTemplatePropsComponent implements OnInit, OnDestroy { private el = inject(ElementRef); private destroy$ = new Subject(); - private originalTemplate: DotTemplateItem; + private originalTemplate!: DotTemplateItem; - form: UntypedFormGroup; + form!: UntypedFormGroup; - isFormValid$: Observable; + isFormValid$!: Observable; ngOnInit(): void { const { template } = this.config.data; @@ -99,9 +99,9 @@ export class DotTemplatePropsComponent implements OnInit, OnDestroy { ); // Handle keyboard shortcuts (Cmd/Ctrl+Enter to save) - fromEvent(this.el.nativeElement, 'keydown') + fromEvent(this.el.nativeElement, 'keydown') .pipe(takeUntil(this.destroy$)) - .subscribe((keyboardEvent: KeyboardEvent) => { + .subscribe((keyboardEvent) => { const nodeName = (keyboardEvent.target as Element).nodeName; const hasFormChanged = JSON.stringify(this.form.value) !== JSON.stringify(this.originalTemplate); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-thumbnail-field/dot-template-thumbnail-field.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-thumbnail-field/dot-template-thumbnail-field.component.ts index f4bee19ca1b9..24743b5f9eff 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-thumbnail-field/dot-template-thumbnail-field.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-thumbnail-field/dot-template-thumbnail-field.component.ts @@ -49,7 +49,8 @@ export class DotTemplateThumbnailFieldComponent implements ControlValueAccessor private dotCrudService = inject(DotCrudService); private dotMessageService = inject(DotMessageService); - asset: DotCMSTemplateThumbnail; + /** Null with no thumbnail set, which is what `writeValue('')` and clearing both do. */ + asset: DotCMSTemplateThumbnail | null = null; error = ''; loading = false; @@ -69,7 +70,21 @@ export class DotTemplateThumbnailFieldComponent implements ControlValueAccessor this.dotTempFileUploadService .upload(value) .pipe( - switchMap(([{ id, image }]: DotCMSTempFile[]) => { + switchMap((uploaded: DotCMSTempFile[] | string) => { + // A failed upload arrives as the HTTP status *string* — + // `DotTempFileUploadService.handleError` maps the error to + // `err.status.toString()` — so destructuring it as a temp-file list took + // the string's first character and carried on with nothing. + if (typeof uploaded === 'string' || !uploaded.length) { + return throwError(() => + this.dotMessageService.get( + 'templates.properties.form.thumbnail.error' + ) + ); + } + + const [{ id, image }] = uploaded; + if (!image) { return throwError(() => this.dotMessageService.get( @@ -151,7 +166,7 @@ export class DotTemplateThumbnailFieldComponent implements ControlValueAccessor }); } - registerOnChange(fn): void { + registerOnChange(fn: (value: unknown) => void): void { this.propagateChange = fn; } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/resolvers/dot-template-create-edit.resolver.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/resolvers/dot-template-create-edit.resolver.spec.ts index ca7e8e2dff09..174cd669a193 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/resolvers/dot-template-create-edit.resolver.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/resolvers/dot-template-create-edit.resolver.spec.ts @@ -3,6 +3,7 @@ import { of } from 'rxjs'; import { TestBed } from '@angular/core/testing'; +import { RouterStateSnapshot } from '@angular/router'; import { DotRouterService } from '@dotcms/data-access'; import { DotTemplate } from '@dotcms/dotcms-models'; @@ -33,6 +34,9 @@ const templateMock: DotTemplate = { working: true }; +/** Both resolvers declare this parameter `_state` and never read it. */ +const UNUSED_STATE = null as unknown as RouterStateSnapshot; + describe('DotTemplateDesignerService', () => { let service: DotTemplateCreateEditResolver; let templateService: DotTemplatesService; @@ -70,12 +74,12 @@ describe('DotTemplateDesignerService', () => { .resolve( { paramMap: { - get(param) { + get(param: string) { return param === 'inode' ? null : 'ID'; } } } as any, - null + UNUSED_STATE ) .subscribe((res) => { expect(templateService.getById).toHaveBeenCalledWith('ID'); @@ -93,12 +97,12 @@ describe('DotTemplateDesignerService', () => { .resolve( { paramMap: { - get(param) { + get(param: string) { return param === 'inode' ? 'inode123' : 'ID'; } } } as any, - null + UNUSED_STATE ) .subscribe((res) => { expect(templateService.getFiltered).toHaveBeenCalledWith({ filter: 'inode123' }); @@ -116,12 +120,12 @@ describe('DotTemplateDesignerService', () => { .resolve( { paramMap: { - get(param) { + get(param: string) { return param === 'inode' ? 'inode123' : 'ID'; } } } as any, - null + UNUSED_STATE ) .subscribe(() => { expect(templateService.getFiltered).toHaveBeenCalledWith({ filter: 'inode123' }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/resolvers/dot-template-create-edit.resolver.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/resolvers/dot-template-create-edit.resolver.ts index 90d37b204cbd..354357f659e8 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/resolvers/dot-template-create-edit.resolver.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/resolvers/dot-template-create-edit.resolver.ts @@ -11,27 +11,32 @@ import { DotTemplate } from '@dotcms/dotcms-models'; import { DotTemplatesService } from '../../../../api/services/dot-templates/dot-templates.service'; @Injectable() -export class DotTemplateCreateEditResolver implements Resolve { +export class DotTemplateCreateEditResolver implements Resolve { private service = inject(DotTemplatesService); private dotRouterService = inject(DotRouterService); - resolve(route: ActivatedRouteSnapshot, _state: RouterStateSnapshot): Observable { + resolve( + route: ActivatedRouteSnapshot, + _state: RouterStateSnapshot + ): Observable { const inode = route.paramMap.get('inode'); - return inode - ? this.service.getFiltered({ filter: inode }).pipe( - map((response: { templates: DotTemplate[]; totalRecords: number }) => { - const templates = response.templates; - if (templates.length) { - const firstTemplate = templates.find((t) => t.inode === inode); - if (firstTemplate) { - return firstTemplate; - } - } - - this.dotRouterService.gotoPortlet('templates'); - }) - ) - : this.service.getById(route.paramMap.get('id')); + if (!inode) { + return this.service.getById(route.paramMap.get('id') ?? ''); + } + + return this.service.getFiltered({ filter: inode }).pipe( + map((response: { templates: DotTemplate[]; totalRecords: number }) => { + const firstTemplate = response.templates.find((t) => t.inode === inode); + + if (firstTemplate) { + return firstTemplate; + } + + this.dotRouterService.gotoPortlet('templates'); + + return null; + }) + ); } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/store/dot-template.store.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/store/dot-template.store.spec.ts index 90bcd6d169c1..ce7bda564af6 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/store/dot-template.store.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/store/dot-template.store.spec.ts @@ -2,7 +2,6 @@ import { of, throwError } from 'rxjs'; -import { HttpErrorResponse } from '@angular/common/http'; import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; @@ -30,7 +29,15 @@ const messageServiceMock = new MockDotMessageService({ 'message.template.published': 'published' }); -function getTemplate({ identifier, name, body }) { +function getTemplate({ + identifier, + name, + body +}: { + identifier: string; + name: string; + body?: string; +}) { return { body: body || '', canPublish: true, @@ -223,6 +230,7 @@ describe('DotTemplateStore', () => { describe('effects', () => { it('should create template', () => { service.createTemplate({ + type: 'advanced', body: 'string', friendlyName: 'string', identifier: 'string', @@ -464,6 +472,7 @@ describe('DotTemplateStore', () => { describe('effects', () => { it('should update template and update the state', () => { service.saveTemplate({ + type: 'advanced', body: 'string', friendlyName: 'string', identifier: 'string', @@ -511,7 +520,8 @@ describe('DotTemplateStore', () => { }); it('should update template and update the state after 10 seconds if template has changed', fakeAsync(() => { - const newTemplate = { + const newTemplate: DotTemplateItem = { + type: 'advanced', body: 'string', friendlyName: 'string', identifier: 'string', @@ -565,6 +575,7 @@ describe('DotTemplateStore', () => { it('should save and publish template and update the state', () => { service.saveAndPublishTemplate({ + type: 'advanced', body: 'string', friendlyName: 'string', identifier: 'string', @@ -649,9 +660,10 @@ describe('DotTemplateStore', () => { }); it('should handle error on update template', (done) => { - const error = throwError(() => new HttpErrorResponse(mockResponseView(400))); + const error = throwError(() => mockResponseView(400)); dotTemplatesService.update = jest.fn().mockReturnValue(error); service.saveTemplate({ + type: 'advanced', body: 'string', friendlyName: 'string', identifier: 'string', @@ -668,6 +680,7 @@ describe('DotTemplateStore', () => { it('should not update template body when updates props', () => { service.saveProperties({ + type: 'advanced', body: 'string', friendlyName: 'string', identifier: 'string', diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/store/dot-template.store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/store/dot-template.store.ts index 922019017b0d..c20704fc155a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/store/dot-template.store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/store/dot-template.store.ts @@ -39,9 +39,10 @@ export interface DotTemplateItemDesign { identifier: string; layout: DotLayout; live?: boolean; - theme: string; + /** Null when the template carries neither `theme` nor `themeId`. */ + theme: string | null; title: string; - type?: 'design'; + type: 'design'; image?: string; } @@ -52,7 +53,7 @@ interface DotTemplateItemadvanced { identifier: string; live?: boolean; title: string; - type?: 'advanced'; + type: 'advanced'; image?: string; } @@ -161,6 +162,8 @@ export class DotTemplateStore extends ComponentStore { return this.dotTemplateService .saveAndPublish(this.cleanTemplateItem(template)) .pipe( + // See the note in `saveTemplate`: a failed publish arrives as `null`. + filter((saved): saved is DotTemplate => !!saved), tapResponse({ next: (template: DotTemplate) => { this.dotGlobalMessageService.success( @@ -207,6 +210,9 @@ export class DotTemplateStore extends ComponentStore { return this.dotTemplateService.update(this.cleanTemplateItem(template)); }), + // `DotTemplatesService` swallows a failed request into `of(null)`, so `catchError` + // never sees it and the tap below used to read `.drawed` off nothing. + filter((template): template is DotTemplate => !!template), tap((template: DotTemplate) => this.onSaveTemplate(template)), catchError((err: HttpErrorResponse) => this.onSaveTemplateError(err)) ); @@ -230,6 +236,9 @@ export class DotTemplateStore extends ComponentStore { return this.dotTemplateService.update(this.cleanTemplateItem(template)); }), + // `DotTemplatesService` swallows a failed request into `of(null)`, so `catchError` + // never sees it and the tap below used to read `.drawed` off nothing. + filter((template): template is DotTemplate => !!template), tap((template: DotTemplate) => this.onSaveTemplate(template)), catchError((err: HttpErrorResponse) => this.onSaveTemplateError(err)) ); @@ -253,6 +262,8 @@ export class DotTemplateStore extends ComponentStore { switchMap((template: DotTemplateItem) => this.dotTemplateService.update(this.cleanTemplateItem(template)) ), + // See the note in `saveTemplate`: a failed update arrives as `null`. + filter((template): template is DotTemplate => !!template), tap((template: DotTemplate) => { this.updateProperties(this.getTemplateItem(template)); }) @@ -263,14 +274,20 @@ export class DotTemplateStore extends ComponentStore { (origin$: Observable) => { return origin$.pipe( switchMap((template: DotTemplateItem) => { - if (template.type === 'design') { - delete template.containers; - } - - delete template.type; - - return this.dotTemplateService.create(template as DotTemplate); + // The create endpoint takes neither `type` (ours, for the editor's two modes) + // nor a design template's `containers`, which it derives from the layout. + // Dropped by omission rather than `delete`, which the required keys now reject. + const { type, ...rest } = template; + const payload = + type === 'design' + ? (({ containers: _containers, ...withoutContainers }) => + withoutContainers)(rest as DotTemplateItemDesign) + : rest; + + return this.dotTemplateService.create(payload as DotTemplate); }), + // See the note in `saveTemplate`: a failed create arrives as `null`. + filter((template): template is DotTemplate => !!template), tap(({ identifier }: DotTemplate) => { this.dotRouterService.goToEditTemplate(identifier); }) @@ -279,7 +296,7 @@ export class DotTemplateStore extends ComponentStore { ); constructor() { - super(null); + super(); const template$ = this.activatedRoute.data.pipe(pluck('template')); const type$ = this.activatedRoute.params.pipe(pluck('type')); @@ -304,7 +321,7 @@ export class DotTemplateStore extends ComponentStore { if (template.type === 'design') { this.canRouteBeDesativated(); - this.templateContainersCacheService.set(template.containers); + this.templateContainersCacheService.set(template.containers ?? {}); } this.setState({ @@ -329,13 +346,13 @@ export class DotTemplateStore extends ComponentStore { * * @memberof DotTemplateStore */ - goToEditTemplate = (id, inode) => { + goToEditTemplate = (id: string, inode?: string) => { this.dotRouterService.goToEditTemplate(id, inode); }; private onSaveTemplate(template: DotTemplate) { if (template.drawed) { - this.templateContainersCacheService.set(template.containers); + this.templateContainersCacheService.set(template.containers ?? {}); } this.updateTemplate(this.getTemplateItem(template)); @@ -367,7 +384,7 @@ export class DotTemplateStore extends ComponentStore { return isAdvanced ? EMPTY_TEMPLATE_ADVANCED : EMPTY_TEMPLATE_DESIGN; } - private getIsAdvanced(type: DotTemplateType, drawed: boolean): boolean { + private getIsAdvanced(type: DotTemplateType, drawed?: boolean): boolean { return type === 'advanced' || drawed === false; } @@ -380,8 +397,8 @@ export class DotTemplateStore extends ComponentStore { result = { type: 'design', identifier, - title, - friendlyName, + title: title ?? '', + friendlyName: friendlyName ?? '', layout: template.layout || EMPTY_TEMPLATE_DESIGN.layout, theme: template.theme ?? template.themeId ?? null, containers: template.containers, @@ -393,9 +410,9 @@ export class DotTemplateStore extends ComponentStore { result = { type: 'advanced', identifier, - title, - friendlyName, - body: template.body, + title: title ?? '', + friendlyName: friendlyName ?? '', + body: template.body ?? '', drawed: false, image: template.image }; @@ -449,17 +466,23 @@ export class DotTemplateStore extends ComponentStore { } private cleanTemplateItem(template: DotTemplateItem): DotTemplate { - delete template.type; - if (template.type === 'design') { - delete template.containers; - } - - return template as DotTemplate; + // `type` is ours, for the editor's two modes; the update endpoint does not take it. + // + // Two things about what this replaces. It deleted `type` and *then* tested + // `template.type === 'design'`, so the branch that strips a design template's + // `containers` was dead and those containers have always been sent — preserved here + // rather than changed as a side effect of the type work. And it deleted the key off the + // argument, which is the same object the store holds, so every later + // `template.type === 'design'` in this store was reading a key a save had removed. + const payload = { ...template } as Partial; + delete payload.type; + + return payload as DotTemplate; } private updateTemplateState(template: DotTemplate): void { if (template.drawed) { - this.templateContainersCacheService.set(template.containers); + this.templateContainersCacheService.set(template.containers ?? {}); } this.updateTemplate(this.getTemplateItem(template)); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.spec.ts index bb253eed65b0..0e93a46248df 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.spec.ts @@ -126,18 +126,7 @@ const templatesMock: DotTemplate[] = [ inode: '123', themeThumbnail: 'test', hostId: '123', - host: { - hostName: 'test', - inode: '123', - identifier: '123' - }, - defaultFileType: 'test', - filesMasks: 'test', - modDate: 123, - path: 'test', - sortOrder: 123, - showOnMenu: true, - type: 'test' + path: 'test' } }, { @@ -165,18 +154,7 @@ const templatesMock: DotTemplate[] = [ inode: 'SYSTEM_THEME', themeThumbnail: 'System Theme', hostId: '123', - host: { - hostName: 'System Theme', - inode: '123', - identifier: '123' - }, - defaultFileType: 'System Theme', - filesMasks: 'System Theme', - modDate: 123, - path: 'System Theme', - sortOrder: 123, - showOnMenu: true, - type: 'System Theme' + path: 'System Theme' } }, { @@ -203,18 +181,7 @@ const templatesMock: DotTemplate[] = [ inode: '123', themeThumbnail: 'test-2', hostId: '123', - host: { - hostName: 'test-2', - inode: '123', - identifier: '123' - }, - defaultFileType: 'test-2', - filesMasks: 'test-2', - modDate: 123, - path: 'test-2', - sortOrder: 123, - showOnMenu: true, - type: 'test-2' + path: 'test-2' } }, { @@ -240,18 +207,7 @@ const templatesMock: DotTemplate[] = [ inode: '123', themeThumbnail: 'test-3', hostId: '123', - host: { - hostName: 'test-3', - inode: '123', - identifier: '123' - }, - defaultFileType: 'test-3', - filesMasks: 'test-3', - modDate: 123, - path: 'test-3', - sortOrder: 123, - showOnMenu: true, - type: 'test-3' + path: 'test-3' } }, { @@ -278,18 +234,7 @@ const templatesMock: DotTemplate[] = [ inode: '123', themeThumbnail: 'test-4', hostId: '123', - host: { - hostName: 'test-4', - inode: '123', - identifier: '123' - }, - defaultFileType: 'test-4', - filesMasks: 'test-4', - modDate: 123, - path: 'test-4', - sortOrder: 123, - showOnMenu: true, - type: 'test-4' + path: 'test-4' } }, { @@ -437,12 +382,16 @@ describe('DotTemplateListComponent', () => { let dotTemplatesService: DotTemplatesServiceSpy; let dotMessageDisplayService: DotMessageDisplayService; let dotPushPublishDialogService: DotPushPublishDialogService; - let dotRouterService: DotRouterService; - let dialogService: DialogService; + let dotRouterService: { + gotoPortlet: jest.Mock; + goToEditTemplate: jest.Mock; + goToSiteBrowser: jest.Mock; + }; + let dialogService: { open: jest.Mock }; let comp: DotTemplateListComponent; let dotAlertConfirmService: DotAlertConfirmService; - let dotSiteBrowserService: DotSiteBrowserService; + let dotSiteBrowserService: { setSelectedFolder: jest.Mock }; let mockGoToFolder: jest.SpyInstance; const messageServiceMock = new MockDotMessageService(messages); @@ -634,7 +583,7 @@ describe('DotTemplateListComponent', () => { links.every( (link, i) => link.nativeElement.textContent.trim() === - templatesWithoutSystem[i].themeInfo.title + templatesWithoutSystem[i].themeInfo!.title ) ).toBe(true); })); @@ -679,7 +628,7 @@ describe('DotTemplateListComponent', () => { const lastCell = cells.pop(); expect(lastCell).toBeTruthy(); - expect(lastCell.nativeElement.textContent.trim()).toEqual(''); + expect(lastCell!.nativeElement.textContent.trim()).toEqual(''); })); it('should not trigger goToFolder when the theme is null or undefined', fakeAsync(() => { @@ -691,7 +640,7 @@ describe('DotTemplateListComponent', () => { const lastCell = cells.pop(); expect(lastCell).toBeTruthy(); - lastCell.nativeElement.click(); + lastCell!.nativeElement.click(); expect(mockGoToFolder).not.toHaveBeenCalled(); })); @@ -836,7 +785,7 @@ describe('DotTemplateListComponent', () => { jest.spyOn(comp, 'loadCurrentPage'); })); - const getActionIndex = (labels: string[], label: string) => + const getActionIndex = (labels: (string | undefined)[], label: string) => labels.findIndex((l) => l === label); it('should open add to bundle dialog', () => { @@ -947,7 +896,7 @@ describe('DotTemplateListComponent', () => { it('should call delete api, send notification and reload current page', () => { dotTemplatesService.delete.mockReturnValue(of(mockBulkResponseSuccess)); jest.spyOn(dotAlertConfirmService, 'confirm').mockImplementation((conf) => { - conf.accept(); + conf.accept!(); }); openRowContextMenu('123Archived'); const deleteIdx = getActionIndex( @@ -965,7 +914,7 @@ describe('DotTemplateListComponent', () => { it('should handle error request', () => { dotTemplatesService.delete.mockReturnValue(of(mockSingleResponseFail)); jest.spyOn(dotAlertConfirmService, 'confirm').mockImplementation((conf) => { - conf.accept(); + conf.accept!(); }); openRowContextMenu('123Archived'); const deleteIdx = getActionIndex( @@ -1093,7 +1042,7 @@ describe('DotTemplateListComponent', () => { it('should execute Delete action', () => { dotTemplatesService.delete.mockReturnValue(of(mockBulkResponseSuccess)); jest.spyOn(dotAlertConfirmService, 'confirm').mockImplementation((conf) => { - conf.accept(); + conf.accept!(); }); getBulkActions()[bulkActionIndex('Delete')].command!({ originalEvent: createFakeEvent('click') @@ -1150,7 +1099,7 @@ describe('DotTemplateListComponent', () => { it('should fire exception on delete', () => { dotTemplatesService.delete.mockReturnValue(of(mockBulkResponseFail)); jest.spyOn(dotAlertConfirmService, 'confirm').mockImplementation((conf) => { - conf.accept(); + conf.accept!(); }); getBulkActions()[bulkActionIndex('Delete')].command!({ originalEvent: createFakeEvent('click') diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.ts index 7bdca468f111..486f07007a64 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.ts @@ -288,9 +288,7 @@ export class DotTemplateListComponent implements OnInit { this.contextMenuItems = this.setTemplateActions(template).map( ({ menuItem }: DotActionMenuItem) => menuItem ); - if (this.contextMenu()) { - this.contextMenu().show(event); - } + this.contextMenu()?.show(event); } /** @@ -300,7 +298,14 @@ export class DotTemplateListComponent implements OnInit { * @memberof DotTemplateListComponent */ getTemplateState({ live, working, deleted, hasLiveVersion }: DotTemplate): DotContentState { - return { live, working, deleted, hasLiveVersion }; + // `live`, `working` and `hasLiveVersion` are required on `DotContentState` and optional on + // `DotTemplate`; a flag the endpoint omits means the template is not in that state. + return { + live: live ?? false, + working: working ?? false, + deleted, + hasLiveVersion: hasLiveVersion ?? false + }; } /** @@ -439,7 +444,7 @@ export class DotTemplateListComponent implements OnInit { this.dotTemplatesService .copy(template.identifier) .pipe(take(1)) - .subscribe((response: DotTemplate) => { + .subscribe((response: DotTemplate | null) => { if (response) { this.showToastNotification( this.dotMessageService.get('message.template.copy') @@ -600,7 +605,7 @@ export class DotTemplateListComponent implements OnInit { this.dotTemplatesService .delete(identifiers) .pipe(take(1)) - .subscribe((response: DotActionBulkResult) => { + .subscribe((response: DotActionBulkResult | null) => { this.notifyResult(response, 'message.template.full_delete'); }); }, @@ -616,7 +621,7 @@ export class DotTemplateListComponent implements OnInit { this.dotTemplatesService .publish(identifiers) .pipe(take(1)) - .subscribe((response: DotActionBulkResult) => { + .subscribe((response: DotActionBulkResult | null) => { this.notifyResult(response, 'message.template_list.published'); }); } @@ -625,7 +630,7 @@ export class DotTemplateListComponent implements OnInit { this.dotTemplatesService .unPublish(identifiers) .pipe(take(1)) - .subscribe((response: DotActionBulkResult) => { + .subscribe((response: DotActionBulkResult | null) => { this.notifyResult(response, 'message.template.unpublished'); }); } @@ -634,7 +639,7 @@ export class DotTemplateListComponent implements OnInit { this.dotTemplatesService .unArchive(identifiers) .pipe(take(1)) - .subscribe((response: DotActionBulkResult) => { + .subscribe((response: DotActionBulkResult | null) => { this.notifyResult(response, 'message.template.undelete'); }); } @@ -643,12 +648,18 @@ export class DotTemplateListComponent implements OnInit { this.dotTemplatesService .archive(identifiers) .pipe(take(1)) - .subscribe((response: DotActionBulkResult) => { + .subscribe((response: DotActionBulkResult | null) => { this.notifyResult(response, 'message.template.delete'); }); } - private notifyResult(response: DotActionBulkResult, messageKey: string): void { + private notifyResult(response: DotActionBulkResult | null, messageKey: string): void { + // `null` arrives when the request failed — `DotTemplatesService.handleError` sends it down + // the stream — and there is nothing to report on either channel. + if (!response) { + return; + } + if (response.fails.length) { this.showErrorDialog({ ...response, @@ -684,7 +695,7 @@ export class DotTemplateListComponent implements OnInit { private getFailsInfo(items: DotBulkFailItem[]): DotBulkFailItem[] { return items.map((item: DotBulkFailItem) => { - return { ...item, description: this.getTemplateName(item.element) }; + return { ...item, description: this.getTemplateName(item.element ?? '') }; }); } @@ -785,8 +796,10 @@ export class DotTemplateListComponent implements OnInit { clearSelection(): void { this.selectedTemplates = []; patchState(this.$state, { selectedTemplates: [] }); - if (this.dataTable()) { - this.dataTable().selection = []; + const dataTable = this.dataTable(); + + if (dataTable) { + dataTable.selection = []; } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-binary-settings/dot-binary-settings.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-binary-settings/dot-binary-settings.component.spec.ts index 90cf0399f13e..708b7843545d 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-binary-settings/dot-binary-settings.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-binary-settings/dot-binary-settings.component.spec.ts @@ -20,6 +20,7 @@ import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotBinarySettingsComponent } from './dot-binary-settings.component'; +import { aliasedProps } from '../../../../../test/spectator-aliased-props'; import { DotFieldVariablesService } from '../fields/dot-content-type-fields-variables/services/dot-field-variables.service'; const messageServiceMock = new MockDotMessageService({ @@ -37,7 +38,7 @@ const MOCK_FIELD: Partial = { id: 'f965a51b-130a-435f-b646-41e07d685363', name: 'testField', clazz: 'com.dotcms.contenttype.model.field.ImmutableBinaryField' -} as unknown; +}; describe('DotBinarySettingsComponent', () => { let spectator: Spectator; @@ -96,12 +97,7 @@ describe('DotBinarySettingsComponent', () => { beforeEach(() => { spectator = createComponent({ - props: { - field: MOCK_FIELD - // Note: Using `as unknown` because Spectator doesn't properly handle signal inputs - // with the `$` prefix (e.g., `$field`). The type assertion bypasses TypeScript's - // type checking for the props object. - } as unknown + props: aliasedProps({ field: MOCK_FIELD }) }); dotFieldVariableService = spectator.inject(DotFieldVariablesService); dotHttpErrorManagerService = spectator.inject(DotHttpErrorManagerService); @@ -110,8 +106,8 @@ describe('DotBinarySettingsComponent', () => { }); it('should setup form values', () => { - expect(component.form.get('accept').value).toBe('image/*'); - expect(component.form.get('systemOptions').value).toEqual({ + expect(component.form.get('accept')!.value).toBe('image/*'); + expect(component.form.get('systemOptions')!.value).toEqual({ allowURLImport: false, allowCodeWrite: true, allowGenerateImg: false @@ -130,7 +126,7 @@ describe('DotBinarySettingsComponent', () => { jest.spyOn(component.$valid, 'emit'); const acceptInput = spectator.query(byTestId('setting-accept')); - spectator.typeInElement('text/*', acceptInput); + spectator.typeInElement('text/*', acceptInput!); expect(component.$valid.emit).toHaveBeenCalled(); }); @@ -200,12 +196,7 @@ describe('DotBinarySettingsComponent', () => { beforeEach(() => { spectator = createComponent({ - props: { - field: MOCK_FIELD - // Note: Using `as unknown` because Spectator doesn't properly handle signal inputs - // with the `$` prefix (e.g., `$field`). The type assertion bypasses TypeScript's - // type checking for the props object. - } as unknown + props: aliasedProps({ field: MOCK_FIELD }) }); dotFieldVariableService = spectator.inject(DotFieldVariablesService); dotHttpErrorManagerService = spectator.inject(DotHttpErrorManagerService); @@ -221,7 +212,7 @@ describe('DotBinarySettingsComponent', () => { spectator.detectChanges(); - component.form.get('accept').setValue(''); + component.form.get('accept')!.setValue(''); component.saveSettings(); expect(dotFieldVariableService.delete).not.toHaveBeenCalled(); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-binary-settings/dot-binary-settings.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-binary-settings/dot-binary-settings.component.ts index 81d3e5f79643..8195154eb10c 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-binary-settings/dot-binary-settings.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-binary-settings/dot-binary-settings.component.ts @@ -48,7 +48,7 @@ export class DotBinarySettingsComponent implements OnInit, OnChanges { readonly $valid = output(); readonly $save = output(); - form: FormGroup; + form!: FormGroup; protected readonly systemOptions = [ { key: 'allowURLImport', @@ -95,7 +95,7 @@ export class DotBinarySettingsComponent implements OnInit, OnChanges { next: (fieldVariables: DotFieldVariable[]) => { fieldVariables.forEach((variable) => { const { key, value } = variable; - const control = this.form.get(key); + const control = this.form.controls[key]; if (control instanceof FormGroup) { const systemOptions = JSON.parse(value); @@ -114,7 +114,7 @@ export class DotBinarySettingsComponent implements OnInit, OnChanges { saveSettings(): void { const updateActions = Object.keys(this.form.controls).map((key) => { - const control = this.form.get(key); + const control = this.form.controls[key]; const value = control instanceof FormGroup ? JSON.stringify(control.value) : control.value; @@ -144,9 +144,10 @@ export class DotBinarySettingsComponent implements OnInit, OnChanges { this.dotHttpErrorManagerService.handle(err).pipe(take(1)) ) ) - .subscribe((value: DotFieldVariable[]) => { + .subscribe((value) => { this.form.markAsPristine(); - this.$save.emit(value); + // The stream carries the error handler's result alongside the saved variables. + this.$save.emit(value as DotFieldVariable[]); }); } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-block-editor-settings/dot-block-editor-settings.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-block-editor-settings/dot-block-editor-settings.component.spec.ts index b5b71594d8d1..74404f220a3f 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-block-editor-settings/dot-block-editor-settings.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-block-editor-settings/dot-block-editor-settings.component.spec.ts @@ -10,7 +10,7 @@ import { MultiSelect, MultiSelectModule } from 'primeng/multiselect'; import { getEditorBlockOptions } from '@dotcms/block-editor'; import { DotHttpErrorManagerService, DotMessageService } from '@dotcms/data-access'; -import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; +import { DotCMSContentTypeField, DotCMSContentTypeFieldVariable } from '@dotcms/dotcms-models'; import { MockDotMessageService, mockFieldVariables } from '@dotcms/utils-testing'; import { DotBlockEditorSettingsComponent } from './dot-block-editor-settings.component'; @@ -48,7 +48,7 @@ const MOCK_FIELD: Partial = { id: 'f965a51b-130a-435f-b646-41e07d685363', name: 'testField', clazz: 'com.dotcms.contenttype.model.field.ImmutableStoryBlockField' -} as unknown; +}; const CUSTOM_BLOCK_FIELD: Partial = { ...MOCK_FIELD, @@ -71,8 +71,8 @@ const CUSTOM_BLOCK_FIELD: Partial = { ] }) } - ] -} as unknown; + ] as DotCMSContentTypeFieldVariable[] +}; const CUSTOM_BLOCK_FIELD_WITH_NAME_FALLBACK: Partial = { ...MOCK_FIELD, @@ -112,13 +112,15 @@ const CUSTOM_BLOCK_FIELD_WITH_NAME_FALLBACK: Partial = { ] }) } - ] -} as unknown; + ] as DotCMSContentTypeFieldVariable[] +}; const MALFORMED_CUSTOM_BLOCK_FIELD: Partial = { ...MOCK_FIELD, - fieldVariables: [{ key: 'customBlocks', value: '{ not json' }] -} as unknown; + fieldVariables: [ + { key: 'customBlocks', value: '{ not json' } + ] as DotCMSContentTypeFieldVariable[] +}; describe('DotBlockEditorSettingsComponent', () => { describe('with existing variables', () => { @@ -170,7 +172,7 @@ describe('DotBlockEditorSettingsComponent', () => { const value = ['orderList', 'unorderList', 'table']; fixture.detectChanges(); const selector = de.query(By.css('p-multiselect')); - expect(component.form.get('allowedBlocks').value).toEqual(value); + expect(component.form.get('allowedBlocks')!.value).toEqual(value); expect(selector).toBeTruthy(); }); @@ -187,7 +189,7 @@ describe('DotBlockEditorSettingsComponent', () => { it('should emit valid output on form change', () => { jest.spyOn(component.$valid, 'emit'); fixture.detectChanges(); - component.form.get('allowedBlocks').setValue(['codeblock']); + component.form.get('allowedBlocks')!.setValue(['codeblock']); expect(component.$valid.emit).toHaveBeenCalled(); }); @@ -205,7 +207,7 @@ describe('DotBlockEditorSettingsComponent', () => { mockFieldVariablesServiceWithData.delete.mockReturnValue(of(mockFieldVariables[0])); jest.spyOn(component.$save, 'emit'); fixture.detectChanges(); - component.form.get('allowedBlocks').setValue([]); + component.form.get('allowedBlocks')!.setValue([]); component.saveSettings(); expect(dotFieldVariableService.delete).toHaveBeenCalled(); expect(component.$save.emit).toHaveBeenCalled(); @@ -291,12 +293,12 @@ describe('DotBlockEditorSettingsComponent', () => { it('should not setup form values when no variables exist', () => { fixture.detectChanges(); - expect(component.form.get('allowedBlocks').value).toBe(null); + expect(component.form.get('allowedBlocks')!.value).toBe(null); }); it('should not call save or delete when is empty and no previous variable exist', () => { fixture.detectChanges(); - component.form.get('allowedBlocks').setValue([]); + component.form.get('allowedBlocks')!.setValue([]); component.saveSettings(); expect(dotFieldVariableService.delete).not.toHaveBeenCalled(); expect(dotFieldVariableService.save).not.toHaveBeenCalled(); @@ -306,7 +308,7 @@ describe('DotBlockEditorSettingsComponent', () => { fixture.componentRef.setInput('field', CUSTOM_BLOCK_FIELD); fixture.detectChanges(); - component.form.get('allowedBlocks').setValue(['customGallery']); + component.form.get('allowedBlocks')!.setValue(['customGallery']); component.saveSettings(); expect(dotFieldVariableService.save).toHaveBeenCalledWith( @@ -354,8 +356,8 @@ describe('DotBlockEditorSettingsComponent', () => { const options = component.settingsMap.allowedBlocks.options; const paragraphOption = options.find( ({ label, code }) => - code.trim().toLowerCase() === 'paragraph' || - label.trim().toLowerCase() === 'paragraph' + code?.trim().toLowerCase() === 'paragraph' || + label?.trim().toLowerCase() === 'paragraph' ); expect(paragraphOption).not.toBeDefined(); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-block-editor-settings/dot-block-editor-settings.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-block-editor-settings/dot-block-editor-settings.component.ts index aa681f71b561..5d5c66d57f0a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-block-editor-settings/dot-block-editor-settings.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-block-editor-settings/dot-block-editor-settings.component.ts @@ -27,7 +27,22 @@ import { import { DotFieldVariablesService } from '../fields/dot-content-type-fields-variables/services/dot-field-variables.service'; -type BlockOption = { label: string; code: string }; +/** + * A selectable block. + * + * Both fields are optional because `getEditorBlockOptions` maps from `DotMenuItem`, whose `label` + * and `id` are — its own sort already reads the label as `label ?? ''`. + */ +type BlockOption = { label?: string; code?: string }; + +/** One row of {@link DotBlockEditorSettingsComponent.settingsMap}. */ +interface BlockEditorSetting { + label: string; + placeholder: string; + options: BlockOption[]; + key: string; + variable: DotFieldVariable | null; +} function getCustomBlockOptions(field: DotCMSContentTypeField): BlockOption[] { const raw = field?.fieldVariables?.find((variable) => variable.key === 'customBlocks')?.value; @@ -48,10 +63,6 @@ function getCustomBlockOptions(field: DotCMSContentTypeField): BlockOption[] { return (parsed.extensions || []).flatMap((extension) => (extension.actions || []).flatMap((action) => { const name = action?.name?.trim(); - // `menuLabel` was optional in existing payloads; when it is missing, empty, - // or whitespace-only, fall back to the required TipTap node name so - // preserved remote blocks remain selectable in settings. - const label = action?.menuLabel?.trim() || name; if (!name) { console.warn(REMOTE_BLOCK_NAME_REQUIRED_WARNING); @@ -59,6 +70,13 @@ function getCustomBlockOptions(field: DotCMSContentTypeField): BlockOption[] { return []; } + // `menuLabel` was optional in existing payloads; when it is missing, empty, + // or whitespace-only, fall back to the required TipTap node name so + // preserved remote blocks remain selectable in settings. Computed after the guard + // above, which is what makes the fallback a `string` rather than `string | + // undefined`. + const label = action?.menuLabel?.trim() || name; + return [{ code: name, label }]; }) ); @@ -111,8 +129,8 @@ export class DotBlockEditorSettingsComponent implements OnInit, OnDestroy, OnCha readonly $field = input.required({ alias: 'field' }); readonly $isVisible = input(false, { alias: 'isVisible' }); - public form: FormGroup; - public settingsMap = { + public form!: FormGroup; + public settingsMap: { allowedBlocks: BlockEditorSetting } = { allowedBlocks: { label: 'Allowed Blocks', placeholder: 'Select Blocks', @@ -151,10 +169,14 @@ export class DotBlockEditorSettingsComponent implements OnInit, OnDestroy, OnCha .subscribe((fieldVariables: DotFieldVariable[]) => { fieldVariables.forEach((variable) => { const { key, value } = variable; - - if (this.form.get(key)) { - this.settingsMap[key].variable = variable; - this.form.get(key)?.setValue(value.split(',')); + // Matched against the settings rather than indexed by key: the key comes from + // the saved field variable, which need not name a setting this form shows. + const setting = this.settings.find((item) => item.key === key); + const control = this.form.get(key); + + if (setting && control) { + setting.variable = variable; + control.setValue(value.split(',')); } }); }); @@ -182,8 +204,9 @@ export class DotBlockEditorSettingsComponent implements OnInit, OnDestroy, OnCha saveSettings(): void { forkJoin( - this.settings.map(({ variable, key }) => { - const value = this.form.get(key).value?.join(','); + this.settings.map((setting) => { + const { variable, key } = setting; + const value = this.form.get(key)?.value?.join(','); const fieldVariable = { ...variable, key, @@ -201,7 +224,7 @@ export class DotBlockEditorSettingsComponent implements OnInit, OnDestroy, OnCha value ? this.fieldVariablesService.save(this.$field(), fieldVariable) : this.fieldVariablesService.delete(this.$field(), fieldVariable) - ).pipe(tap((variable) => (this.settingsMap[key].variable = variable))); // Update Variable Reference + ).pipe(tap((saved) => (setting.variable = saved))); // Update Variable Reference }) ) .pipe( @@ -210,8 +233,8 @@ export class DotBlockEditorSettingsComponent implements OnInit, OnDestroy, OnCha this.dotHttpErrorManagerService.handle(err).pipe(take(1)) ) ) - .subscribe((value: DotFieldVariable[]) => { - this.$save.emit(value); + .subscribe((value) => { + this.$save.emit(value as DotFieldVariable[]); this.form.markAsPristine(); }); } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-custom-field-settings/dot-custom-field-settings.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-custom-field-settings/dot-custom-field-settings.component.spec.ts index b33fb6706de4..c4179d4fbbb4 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-custom-field-settings/dot-custom-field-settings.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-custom-field-settings/dot-custom-field-settings.component.spec.ts @@ -17,7 +17,7 @@ import { NEW_RENDER_MODE_VARIABLE_KEY } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; -import { MockDotMessageService } from '@dotcms/utils-testing'; +import { dotcmsContentTypeFieldBasicMock, MockDotMessageService } from '@dotcms/utils-testing'; import { DotCustomFieldSettingsComponent } from './dot-custom-field-settings.component'; import { DotHideLabelSettingsComponent } from './sections/dot-hide-label-settings'; @@ -46,29 +46,13 @@ const MOCK_SAVED_VARIABLE: DotFieldVariable = { }; const MOCK_FIELD: DotCMSContentTypeField = { + // Everything else comes from the shared mock: this literal had `null` in eighteen fields the + // model declares non-nullable, and the same eighteen were copied into all three specs. + ...dotcmsContentTypeFieldBasicMock, contentTypeId: 'content-type-id-123', id: 'field-id-456', clazz: DotCMSClazzes.CUSTOM_FIELD, - name: 'My Custom Field', - dataType: null, - fieldType: '', - fieldTypeLabel: '', - fieldVariables: [], - fixed: null, - iDate: null, - indexed: null, - listed: null, - modDate: null, - readOnly: null, - required: null, - searchable: null, - sortOrder: null, - unique: null, - variable: null, - defaultValue: null, - hint: null, - regexCheck: undefined, - values: null + name: 'My Custom Field' }; type RenderOptionsFormTree = FieldTree<{ @@ -131,7 +115,7 @@ describe('DotCustomFieldSettingsComponent', () => { }); it('should pass the field input to dot-hide-label-settings', () => { - const child = spectator.query(DotHideLabelSettingsComponent); + const child = spectator.query(DotHideLabelSettingsComponent)!; expect(child.$field()).toEqual(MOCK_FIELD); }); @@ -143,12 +127,12 @@ describe('DotCustomFieldSettingsComponent', () => { }); it('should render the dot-render-options-settings child component (default iframe mode)', () => { - const child = spectator.query(DotRenderOptionsSettingsComponent); + const child = spectator.query(DotRenderOptionsSettingsComponent)!; expect(child).not.toBeNull(); }); it('should pass the field input to dot-render-options-settings', () => { - const child = spectator.query(DotRenderOptionsSettingsComponent); + const child = spectator.query(DotRenderOptionsSettingsComponent)!; expect(child.$field()).toEqual(MOCK_FIELD); }); @@ -192,8 +176,9 @@ describe('DotCustomFieldSettingsComponent', () => { it('should emit true when a section becomes dirty and valid', () => { jest.spyOn(component.$valid, 'emit'); - const ft = (spectator.query(DotRenderOptionsSettingsComponent) as WithRenderFormTree) - .formTree; + const ft = ( + spectator.query(DotRenderOptionsSettingsComponent) as unknown as WithRenderFormTree + ).formTree; ft().markAsDirty(); spectator.flushEffects(); @@ -203,8 +188,9 @@ describe('DotCustomFieldSettingsComponent', () => { it('should emit false when a section is dirty but invalid', () => { jest.spyOn(component.$valid, 'emit'); - const ft = (spectator.query(DotRenderOptionsSettingsComponent) as WithRenderFormTree) - .formTree; + const ft = ( + spectator.query(DotRenderOptionsSettingsComponent) as unknown as WithRenderFormTree + ).formTree; ft.showAsModal().value.set(true); ft.customFieldWidth().value.set(0); ft().markAsDirty(); @@ -241,8 +227,9 @@ describe('DotCustomFieldSettingsComponent', () => { it('should call save on the renderOptions section when it is dirty', () => { jest.spyOn(component.$save, 'emit'); - const ft = (spectator.query(DotRenderOptionsSettingsComponent) as WithRenderFormTree) - .formTree; + const ft = ( + spectator.query(DotRenderOptionsSettingsComponent) as unknown as WithRenderFormTree + ).formTree; ft.showAsModal().value.set(true); ft().markAsDirty(); @@ -255,8 +242,9 @@ describe('DotCustomFieldSettingsComponent', () => { it('should emit $save after successful save', () => { jest.spyOn(component.$save, 'emit'); - const ft = (spectator.query(DotRenderOptionsSettingsComponent) as WithRenderFormTree) - .formTree; + const ft = ( + spectator.query(DotRenderOptionsSettingsComponent) as unknown as WithRenderFormTree + ).formTree; ft().markAsDirty(); component.saveSettings(); @@ -270,8 +258,9 @@ describe('DotCustomFieldSettingsComponent', () => { ); jest.spyOn(component.$save, 'emit'); - const ft = (spectator.query(DotRenderOptionsSettingsComponent) as WithRenderFormTree) - .formTree; + const ft = ( + spectator.query(DotRenderOptionsSettingsComponent) as unknown as WithRenderFormTree + ).formTree; ft().markAsDirty(); component.saveSettings(); @@ -283,8 +272,9 @@ describe('DotCustomFieldSettingsComponent', () => { it('should call save on the hideLabel section when it is dirty', () => { jest.spyOn(component.$save, 'emit'); - const ft = (spectator.query(DotHideLabelSettingsComponent) as WithHideLabelFormTree) - .formTree; + const ft = ( + spectator.query(DotHideLabelSettingsComponent) as unknown as WithHideLabelFormTree + ).formTree; ft.hideLabel().value.set(true); ft().markAsDirty(); @@ -301,13 +291,14 @@ describe('DotCustomFieldSettingsComponent', () => { jest.spyOn(component.$save, 'emit'); const renderFt = ( - spectator.query(DotRenderOptionsSettingsComponent) as WithRenderFormTree + spectator.query(DotRenderOptionsSettingsComponent) as unknown as WithRenderFormTree ).formTree; renderFt.showAsModal().value.set(true); renderFt().markAsDirty(); - const hideFt = (spectator.query(DotHideLabelSettingsComponent) as WithHideLabelFormTree) - .formTree; + const hideFt = ( + spectator.query(DotHideLabelSettingsComponent) as unknown as WithHideLabelFormTree + ).formTree; hideFt.hideLabel().value.set(true); hideFt().markAsDirty(); @@ -346,8 +337,9 @@ describe('DotCustomFieldSettingsComponent', () => { }); it('should emit $changeControls with accept.disabled false when a section is dirty and valid', () => { - const ft = (spectator.query(DotRenderOptionsSettingsComponent) as WithRenderFormTree) - .formTree; + const ft = ( + spectator.query(DotRenderOptionsSettingsComponent) as unknown as WithRenderFormTree + ).formTree; ft.showAsModal().value.set(true); ft().markAsDirty(); @@ -355,7 +347,7 @@ describe('DotCustomFieldSettingsComponent', () => { spectator.setInput('isVisible', true); const emitted = emitSpy.mock.calls[0][0] as DotDialogActions; - expect(emitted.accept.disabled).toBe(false); + expect(emitted.accept!.disabled).toBe(false); }); it('should call saveSettings when the emitted accept.action is invoked', () => { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-custom-field-settings/sections/dot-hide-label-settings/dot-hide-label-settings.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-custom-field-settings/sections/dot-hide-label-settings/dot-hide-label-settings.component.spec.ts index f89e6dd79ab4..2a998d28e96d 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-custom-field-settings/sections/dot-hide-label-settings/dot-hide-label-settings.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-custom-field-settings/sections/dot-hide-label-settings/dot-hide-label-settings.component.spec.ts @@ -13,7 +13,7 @@ import { HIDE_LABEL_VARIABLE_KEY } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; -import { MockDotMessageService } from '@dotcms/utils-testing'; +import { dotcmsContentTypeFieldBasicMock, MockDotMessageService } from '@dotcms/utils-testing'; import { DotHideLabelSettingsComponent } from './dot-hide-label-settings.component'; @@ -24,29 +24,13 @@ const messageServiceMock = new MockDotMessageService({ }); const MOCK_FIELD_BASE: DotCMSContentTypeField = { + // Everything else comes from the shared mock: this literal had `null` in eighteen fields the + // model declares non-nullable, and the same eighteen were copied into all three specs. + ...dotcmsContentTypeFieldBasicMock, contentTypeId: 'content-type-id-123', id: 'field-id-456', clazz: DotCMSClazzes.CUSTOM_FIELD, - name: 'My Custom Field', - dataType: null, - fieldType: '', - fieldTypeLabel: '', - fieldVariables: [], - fixed: null, - iDate: null, - indexed: null, - listed: null, - modDate: null, - readOnly: null, - required: null, - searchable: null, - sortOrder: null, - unique: null, - variable: null, - defaultValue: null, - hint: null, - regexCheck: undefined, - values: null + name: 'My Custom Field' }; const MOCK_SAVED_VARIABLE: DotFieldVariable = { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-custom-field-settings/sections/dot-render-options-settings/dot-render-options-settings.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-custom-field-settings/sections/dot-render-options-settings/dot-render-options-settings.component.spec.ts index 35ad42822001..7da63eb034db 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-custom-field-settings/sections/dot-render-options-settings/dot-render-options-settings.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-custom-field-settings/sections/dot-render-options-settings/dot-render-options-settings.component.spec.ts @@ -15,7 +15,7 @@ import { DotFieldVariable } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; -import { MockDotMessageService } from '@dotcms/utils-testing'; +import { dotcmsContentTypeFieldBasicMock, MockDotMessageService } from '@dotcms/utils-testing'; import { DotRenderOptionsSettingsComponent } from './dot-render-options-settings.component'; @@ -32,29 +32,13 @@ const messageServiceMock = new MockDotMessageService({ }); const MOCK_FIELD_BASE: DotCMSContentTypeField = { + // Everything else comes from the shared mock: this literal had `null` in eighteen fields the + // model declares non-nullable, and the same eighteen were copied into all three specs. + ...dotcmsContentTypeFieldBasicMock, contentTypeId: 'content-type-id-123', id: 'field-id-456', clazz: DotCMSClazzes.CUSTOM_FIELD, - name: 'My Custom Field', - dataType: null, - fieldType: '', - fieldTypeLabel: '', - fieldVariables: [], - fixed: null, - iDate: null, - indexed: null, - listed: null, - modDate: null, - readOnly: null, - required: null, - searchable: null, - sortOrder: null, - unique: null, - variable: null, - defaultValue: null, - hint: null, - regexCheck: undefined, - values: null + name: 'My Custom Field' }; const MOCK_FIELD_VARIABLE_OPTIONS: DotCMSContentTypeFieldVariable = { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-field-dragabble-item/content-type-field-dragabble-item.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-field-dragabble-item/content-type-field-dragabble-item.component.spec.ts index 42faa32521dc..74920274fe5b 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-field-dragabble-item/content-type-field-dragabble-item.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-field-dragabble-item/content-type-field-dragabble-item.component.spec.ts @@ -49,7 +49,11 @@ describe('ContentTypesFieldDragabbleItemComponent', () => { }); })); - function createComponent(field: DotCMSContentTypeField, isSmall = false) { + /** Nullable per field: two tests build one whose `variable` is missing or null. */ + function createComponent( + field: Partial<{ [K in keyof DotCMSContentTypeField]: DotCMSContentTypeField[K] | null }>, + isSmall = false + ) { fixture = TestBed.createComponent(ContentTypesFieldDragabbleItemComponent); fixture.componentRef.setInput('field', field); fixture.componentRef.setInput('isSmall', isSmall); @@ -161,7 +165,7 @@ describe('ContentTypesFieldDragabbleItemComponent', () => { expect(button).not.toBeNull(); expect(button.attributes['icon']).toEqual('pi pi-trash'); - let resp: DotCMSContentTypeField; + let resp: DotCMSContentTypeField | undefined; comp.remove.subscribe((fieldItem) => (resp = fieldItem)); button.triggerEventHandler('click', { stopPropagation: () => { @@ -203,7 +207,7 @@ describe('ContentTypesFieldDragabbleItemComponent', () => { createComponent(mockField); - let resp: DotCMSContentTypeField; + let resp: DotCMSContentTypeField | undefined; comp.edit.subscribe((field) => (resp = field)); de.triggerEventHandler('click', { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-field-dragabble-item/content-type-field-dragabble-item.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-field-dragabble-item/content-type-field-dragabble-item.component.ts index 68044464ecc8..4e97a39a02d0 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-field-dragabble-item/content-type-field-dragabble-item.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-field-dragabble-item/content-type-field-dragabble-item.component.ts @@ -43,16 +43,17 @@ export class ContentTypesFieldDragabbleItemComponent implements OnInit { readonly $overlayPanel = viewChild.required('op'); /** Local copy of field for access */ - field: DotCMSContentTypeField; + field!: DotCMSContentTypeField; isDragging = false; open = false; - fieldAttributesArray: string[]; + fieldAttributesArray: string[] = []; - fieldTypeLabel: string; - fieldAttributesString: string; - icon: string; + /** Empty when the field carries no type label. */ + fieldTypeLabel = ''; + fieldAttributesString!: string; + icon!: string; get variableToShow(): string { const field = this.$field(); @@ -61,7 +62,7 @@ export class ContentTypesFieldDragabbleItemComponent implements OnInit { ngOnInit(): void { this.field = this.$field(); - this.fieldTypeLabel = this.field.fieldTypeLabel ? this.field.fieldTypeLabel : null; + this.fieldTypeLabel = this.field.fieldTypeLabel ?? ''; this.fieldAttributesArray = [ { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-add-row/content-type-fields-add-row.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-add-row/content-type-fields-add-row.component.spec.ts index ea8f916f2bea..8d83a59457d7 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-add-row/content-type-fields-add-row.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-add-row/content-type-fields-add-row.component.spec.ts @@ -4,6 +4,7 @@ import { By } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; +import { MenuItemCommandEvent } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; import { SplitButtonModule } from 'primeng/splitbutton'; import { TooltipModule } from 'primeng/tooltip'; @@ -108,13 +109,13 @@ describe('ContentTypeFieldsAddRowComponent', () => { it('should bind send notification after click on Add Tab button', () => { jest.spyOn(dotEventsService, 'notify'); fixture.detectChanges(); - comp.actions[1].command(); + comp.actions[1].command!({} as MenuItemCommandEvent); expect(dotEventsService.notify).toHaveBeenCalledWith('add-tab-divider'); expect(dotEventsService.notify).toHaveBeenCalledTimes(1); }); it('should select columns number after click on li', () => { - let colsToEmit: number; + let colsToEmit: number | undefined; comp.rowState = 'select'; fixture.detectChanges(); const lis = de.queryAll(By.css('ul li')); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-add-row/content-type-fields-add-row.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-add-row/content-type-fields-add-row.component.ts index 572df0f42d1d..72053ca778da 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-add-row/content-type-fields-add-row.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-add-row/content-type-fields-add-row.component.ts @@ -42,7 +42,7 @@ export class ContentTypeFieldsAddRowComponent implements OnDestroy, OnInit { rowState = 'add'; selectedColumnIndex = 0; - actions: MenuItem[]; + actions: MenuItem[] = []; readonly $columns = input([1, 2, 3, 4], { alias: 'columns' }); readonly $disabled = input(false, { alias: 'disabled' }); @@ -79,14 +79,15 @@ export class ContentTypeFieldsAddRowComponent implements OnDestroy, OnInit { * Set columns active when mouse enter * @param col */ - onMouseEnter(col: number, event): void { + onMouseEnter(col: number, event: MouseEvent): void { this.selectedColumnIndex = col; this.setFocus(this.getElementSelected()); event.preventDefault(); } - onMouseLeave(event): void { - this.removeFocus(event.target); + onMouseLeave(event: MouseEvent): void { + // The handler is bound to the column element itself, so `target` is that element. + this.removeFocus(event.target as HTMLElement); } /** @@ -125,8 +126,8 @@ export class ContentTypeFieldsAddRowComponent implements OnDestroy, OnInit { * Set focus on element sent as param * @param elem */ - setFocus(elem: HTMLElement): void { - elem.focus({ preventScroll: true }); + setFocus(elem: HTMLElement | undefined): void { + elem?.focus({ preventScroll: true }); } /** @@ -135,8 +136,8 @@ export class ContentTypeFieldsAddRowComponent implements OnDestroy, OnInit { * @returns * * @memberof ContentTypeFieldsAddRowComponent */ - removeFocus(elem: HTMLElement): void { - elem.blur(); + removeFocus(elem: HTMLElement | undefined): void { + elem?.blur(); } /** @@ -161,8 +162,13 @@ export class ContentTypeFieldsAddRowComponent implements OnDestroy, OnInit { this.selectedColumnIndex = 0; } - private getElementSelected(): HTMLElement { - return this.$colContainerElem().nativeElement.children[this.selectedColumnIndex]; + /** + * `#colContainer` lives behind `@if (rowState === 'select')`, and `setColumnSelect` reaches for + * it after a 201 ms timeout — by which point the state may have moved back to `'add'` or the + * component may be gone. Both focus helpers above accept the absence. + */ + private getElementSelected(): HTMLElement | undefined { + return this.$colContainerElem()?.nativeElement.children[this.selectedColumnIndex]; } private loadActions(): void { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-drop-zone/content-type-fields-drop-zone.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-drop-zone/content-type-fields-drop-zone.component.spec.ts index 990f6eccfc8d..df2db6bcde09 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-drop-zone/content-type-fields-drop-zone.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-drop-zone/content-type-fields-drop-zone.component.spec.ts @@ -272,7 +272,7 @@ describe('ContentTypeFieldsDropZoneComponent', () => { }); it('should emit removeFields event when a Row is removed', () => { - let fieldsToRemove: DotCMSContentTypeField[]; + let fieldsToRemove: DotCMSContentTypeField[] | undefined; const fieldRow: DotCMSContentTypeLayoutRow = FieldUtil.createFieldRow(1); const field = { @@ -280,7 +280,7 @@ describe('ContentTypeFieldsDropZoneComponent', () => { clazz: DotCMSClazzes.TEXT, name: 'nameField' }; - fieldRow.columns[0].fields = [field]; + fieldRow.columns![0].fields = [field]; fieldRow.divider.id = 'test'; comp.fieldRows = [fieldRow]; @@ -289,7 +289,7 @@ describe('ContentTypeFieldsDropZoneComponent', () => { comp.removeFieldRow(fieldRow, 0); - expect([fieldRow.divider, fieldRow.columns[0].columnDivider, field]).toEqual( + expect([fieldRow.divider, fieldRow.columns![0].columnDivider, field]).toEqual( fieldsToRemove ); }); @@ -314,7 +314,7 @@ describe('ContentTypeFieldsDropZoneComponent', () => { clazz: DotCMSClazzes.TEXT, name: 'nameField' }; - fieldRow1.columns[0].fields = [field]; + fieldRow1.columns![0].fields = [field]; fixture.componentRef.setInput('layout', [fieldRow1]); @@ -324,8 +324,8 @@ describe('ContentTypeFieldsDropZoneComponent', () => { comp.cancelLastDragAndDrop(); expect(comp.fieldRows.length).toEqual(1); - expect(comp.fieldRows[0].columns.length).toEqual(1); - expect(comp.fieldRows[0].columns[0].fields).toEqual([field]); + expect(comp.fieldRows[0].columns!.length).toEqual(1); + expect(comp.fieldRows[0].columns![0].fields).toEqual([field]); }); it('should cancel last tab field drag and drop operation fields', () => { @@ -354,7 +354,7 @@ describe('ContentTypeFieldsDropZoneComponent', () => { name: 'nameField' }; const fieldRow: DotCMSContentTypeLayoutRow = FieldUtil.createFieldRow(1); - fieldRow.columns[0].fields = [field]; + fieldRow.columns![0].fields = [field]; comp.fieldRows = [fieldRow]; fixture.detectChanges(); @@ -378,7 +378,7 @@ describe('ContentTypeFieldsDropZoneComponent', () => { name: 'nameField' }; const fieldRow: DotCMSContentTypeLayoutRow = FieldUtil.createFieldRow(1); - fieldRow.columns[0].fields = [field]; + fieldRow.columns![0].fields = [field]; comp.fieldRows = [fieldRow]; fixture.detectChanges(); @@ -414,12 +414,12 @@ describe('ContentTypeFieldsDropZoneComponent', () => { fixture.componentRef.setInput('contentType', fakeContentType); const field = { ...dotcmsContentTypeFieldBasicMock, - clazz: 'com.dotcms.contenttype.model.field.ImmutableWysiwygField', + clazz: DotCMSClazzes.WYSIWYG, id: 'wysiwyg-id', name: 'WYSIWYG' }; const fieldRow: DotCMSContentTypeLayoutRow = FieldUtil.createFieldRow(1); - fieldRow.columns[0].fields = [field]; + fieldRow.columns![0].fields = [field]; comp.fieldRows = [fieldRow]; fixture.detectChanges(); @@ -429,7 +429,7 @@ describe('ContentTypeFieldsDropZoneComponent', () => { const blockField = { ...field, - clazz: 'com.dotcms.contenttype.model.field.ImmutableStoryBlockField', + clazz: DotCMSClazzes.BLOCK_EDITOR, fieldType: 'Story-Block' }; dialogOnClose.next({ kind: 'convert-to-block', field: blockField }); @@ -451,8 +451,8 @@ let fakeFields: DotCMSContentTypeLayoutRow[]; standalone: false }) class TestHostComponent { - layout: DotCMSContentTypeLayoutRow[]; - loading: boolean; + layout!: DotCMSContentTypeLayoutRow[]; + loading!: boolean; } // TODO: Upgrade tests to use FieldDragDropService (without mocking) and mocking DragulaService @@ -461,7 +461,7 @@ class TestHostComponent { const BLOCK_EDITOR_FIELD: DotCMSContentTypeField = { ...dotcmsContentTypeFieldBasicMock, - clazz: 'com.dotcms.contenttype.model.field.ImmutableStoryBlockField', + clazz: DotCMSClazzes.BLOCK_EDITOR, id: '12', name: 'field 12', sortOrder: 12, @@ -547,7 +547,7 @@ describe('Load fields and drag and drop', () => { loadFieldTypes() { return of([ { - clazz: 'com.dotcms.contenttype.model.field.ImmutableWysiwygField', + clazz: DotCMSClazzes.WYSIWYG, helpText: 'Show a rich text area for content input that allows a user to format content.', id: 'wysiwyg', @@ -563,7 +563,7 @@ describe('Load fields and drag and drop', () => { ] }, { - clazz: 'com.dotcms.contenttype.model.field.ImmutableStoryBlockField', + clazz: DotCMSClazzes.BLOCK_EDITOR, id: 'block editor', label: 'BLOCK EDITOR', properties: ['name', 'body', 'required', 'indexed'] @@ -621,7 +621,7 @@ describe('Load fields and drag and drop', () => { fields: [ { ...dotcmsContentTypeFieldBasicMock, - clazz: 'com.dotcms.contenttype.model.field.ImmutableWysiwygField', + clazz: DotCMSClazzes.WYSIWYG, id: '3', name: 'field 3', sortOrder: 2, @@ -740,7 +740,7 @@ describe('Load fields and drag and drop', () => { it('should save all updated fields', fakeAsync(() => { jest.spyOn(testFieldDragDropService, 'isDraggedEventStarted').mockReturnValue(false); - const updatedField = fakeFields[2].columns[0].fields[0]; + const updatedField = fakeFields[2].columns![0].fields[0]; fixture.detectChanges(); @@ -769,7 +769,7 @@ describe('Load fields and drag and drop', () => { comp.currentField = null; jest.spyOn(testFieldDragDropService, 'isDraggedEventStarted').mockReturnValue(true); - const updatedField = fakeFields[2].columns[0].fields[0]; + const updatedField = fakeFields[2].columns![0].fields[0]; fixture.detectChanges(); @@ -787,7 +787,7 @@ describe('Load fields and drag and drop', () => { const addRowsContainer = de.query(By.css('dot-add-rows')).componentInstance; addRowsContainer.$selectColums.emit(2); expect(comp.addRow).toHaveBeenCalled(); - expect(comp.fieldRows[0].columns.length).toBe(2); + expect(comp.fieldRows[0].columns!.length).toBe(2); }); it('should emit and create tab divider', () => { @@ -820,7 +820,7 @@ describe('Load fields and drag and drop', () => { it('should set dropped field if a drop event happen from source', () => { return fixture.whenStable().then(() => { - const dropField = fakeFields[2].columns[0].fields[0]; + const dropField = fakeFields[2].columns![0].fields[0]; becomeNewField(dropField); fixture.detectChanges(); @@ -896,10 +896,14 @@ describe('Load fields and drag and drop', () => { it('should save all the new fields and at the end DraggedStarted event should be false', () => { becomeNewField(fakeFields[2].divider); - becomeNewField(fakeFields[2].columns[0].columnDivider); - becomeNewField(fakeFields[2].columns[0].fields[0]); + becomeNewField(fakeFields[2].columns![0].columnDivider); + becomeNewField(fakeFields[2].columns![0].fields[0]); - const newlyField = fakeFields[2].columns[0].fields[0]; + // A copy, so the shared fixture keeps its id: this stands in for a field the user has + // just dropped and not yet saved. + const newlyField: Partial = { + ...fakeFields[2].columns![0].fields[0] + }; delete newlyField.id; fixture.detectChanges(); // select the fields[8] as the current field @@ -907,7 +911,7 @@ describe('Load fields and drag and drop', () => { item: newlyField }); - let emittedFields: DotCMSContentTypeLayoutRow[]; + let emittedFields: DotCMSContentTypeLayoutRow[] | undefined; comp.saveFields.subscribe((fields) => { emittedFields = fields; }); @@ -935,7 +939,7 @@ describe('Load fields and drag and drop', () => { it('should open the dialog when a drop event happens from source', () => { fixture.detectChanges(); - const fieldToEdit: DotCMSContentTypeField = fakeFields[2].columns[0].fields[0]; + const fieldToEdit: DotCMSContentTypeField = fakeFields[2].columns![0].fields[0]; testFieldDragDropService._fieldDropFromSource.next({ item: fieldToEdit, target: { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-drop-zone/content-type-fields-drop-zone.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-drop-zone/content-type-fields-drop-zone.component.ts index cf8d71864b1e..efc957ef8045 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-drop-zone/content-type-fields-drop-zone.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-drop-zone/content-type-fields-drop-zone.component.ts @@ -56,9 +56,11 @@ export class ContentTypeFieldsDropZoneComponent implements OnInit, OnChanges, On private dialogService = inject(DialogService); private elRef = inject(ElementRef); - currentField: DotCMSContentTypeField; - currentFieldType: FieldType; - fieldRows: DotCMSContentTypeLayoutRow[]; + /** The field the dialog is editing. Null between dialogs, and when a lookup misses. */ + currentField: DotCMSContentTypeField | null = null; + /** Undefined for a clazz the field-types endpoint does not know. */ + currentFieldType?: FieldType; + fieldRows: DotCMSContentTypeLayoutRow[] = []; /** Layout rows used to render the drop-zone. Changes trigger a structural clone. */ readonly $layout = input(undefined, { alias: 'layout' }); @@ -96,6 +98,13 @@ export class ContentTypeFieldsDropZoneComponent implements OnInit, OnChanges, On fieldRows: DotCMSContentTypeLayoutRow[] ): DotCMSContentTypeLayoutRow[] { return fieldRows.map((row: DotCMSContentTypeLayoutRow) => { + // A tab divider row has no `columns` (see `FieldUtil.createFieldTabDivider`) and there + // is nothing in it to split, so it passes through untouched. Reducing over `undefined` + // threw as soon as a column break was dropped into a layout that contained a tab. + if (!row.columns) { + return row; + } + return { ...row, columns: row.columns.reduce( @@ -162,7 +171,7 @@ export class ContentTypeFieldsDropZoneComponent implements OnInit, OnChanges, On .listen('add-row') .pipe(takeUntil(this.destroy$)) .subscribe(() => { - document.querySelector('dot-add-rows').scrollIntoView({ + document.querySelector('dot-add-rows')?.scrollIntoView({ behavior: 'smooth' }); }); @@ -199,7 +208,7 @@ export class ContentTypeFieldsDropZoneComponent implements OnInit, OnChanges, On } }); - ref.onClose.subscribe((result?: DotEditFieldDialogResult) => { + ref?.onClose.subscribe((result?: DotEditFieldDialogResult) => { switch (result?.kind) { case 'saved': this.saveFieldsHandler(result.field); @@ -216,12 +225,12 @@ export class ContentTypeFieldsDropZoneComponent implements OnInit, OnChanges, On } ngOnChanges(changes: SimpleChanges): void { - if (changes.$layout && changes.$layout.currentValue) { - this.fieldRows = structuredClone(changes.$layout.currentValue); + if (changes['$layout'] && changes['$layout'].currentValue) { + this.fieldRows = structuredClone(changes['$layout'].currentValue); } - if (changes.$loading) { - const loading = changes.$loading.currentValue; + if (changes['$loading']) { + const loading = changes['$loading'].currentValue; this._loading = loading; // Use setTimeout to defer loading indicator changes until after current change detection cycle @@ -256,12 +265,25 @@ export class ContentTypeFieldsDropZoneComponent implements OnInit, OnChanges, On * @param DotContentTypeField fieldToSave * @memberof ContentTypeFieldsDropZoneComponent */ - saveFieldsHandler(fieldToSave: DotCMSContentTypeField): void { + /** + * `fieldToSave` may have no `id`: that is exactly the case the `if (fieldToSave.id)` branch + * below distinguishes — a field the user has just dropped and not yet saved, which + * `removeFieldsWithoutId` is the other half of. The model declares `id` required, so the + * parameter states the looser shape this method actually accepts. + */ + saveFieldsHandler(fieldToSave: Partial): void { if (!this.currentField) { const tabDividerFields = FieldUtil.getTabDividerFields(this.fieldRows); - this.currentField = tabDividerFields.find( - (field: DotCMSContentTypeField) => fieldToSave.id === field.id - ); + this.currentField = + tabDividerFields.find( + (field: DotCMSContentTypeField) => fieldToSave.id === field.id + ) ?? null; + } + + // The lookup above misses when the saved field is not one of the layout's tab dividers. + // `Object.assign` would have thrown on the undefined it used to leave behind. + if (!this.currentField) { + return; } Object.assign(this.currentField, fieldToSave); @@ -281,10 +303,16 @@ export class ContentTypeFieldsDropZoneComponent implements OnInit, OnChanges, On editFieldHandler(fieldToEdit: DotCMSContentTypeField): void { if (!this.fieldDragDropService.isDraggedEventStarted()) { const fields = FieldUtil.getFieldsWithoutLayout(this.fieldRows); - this.currentField = fields.find( - (field: DotCMSContentTypeField) => fieldToEdit.id === field.id + const field = fields.find( + (candidate: DotCMSContentTypeField) => fieldToEdit.id === candidate.id ); - this.currentFieldType = this.fieldPropertyService.getFieldType(this.currentField.clazz); + + if (!field) { + return; + } + + this.currentField = field; + this.currentFieldType = this.fieldPropertyService.getFieldType(field.clazz); this.openFieldDialog(); } } @@ -299,10 +327,10 @@ export class ContentTypeFieldsDropZoneComponent implements OnInit, OnChanges, On // TODO needs an improvement for performance reasons fieldRows.forEach((row, rowIndex) => { if (row.columns) { - row.columns.forEach((col, colIndex) => { + row.columns.forEach((col) => { col.fields.forEach((field, fieldIndex) => { if (!field.id) { - row.columns[colIndex].fields.splice(fieldIndex, 1); + col.fields.splice(fieldIndex, 1); } }); }); @@ -333,7 +361,7 @@ export class ContentTypeFieldsDropZoneComponent implements OnInit, OnChanges, On if (!FieldUtil.isNewField(fieldRow.divider)) { fieldsToDelete.push(fieldRow.divider); - fieldRow.columns.forEach((fieldColumn: DotCMSContentTypeLayoutColumn) => { + fieldRow.columns?.forEach((fieldColumn: DotCMSContentTypeLayoutColumn) => { fieldsToDelete.push(fieldColumn.columnDivider); fieldColumn.fields.forEach((field) => fieldsToDelete.push(field)); }); @@ -357,7 +385,7 @@ export class ContentTypeFieldsDropZoneComponent implements OnInit, OnChanges, On * @memberof ContentTypeFieldsDropZoneComponent */ cancelLastDragAndDrop(): void { - this.fieldRows = structuredClone(this.$layout()); + this.fieldRows = structuredClone(this.$layout() ?? []); } private setDroppedField(droppedField: DotCMSContentTypeField): void { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/content-type-fields-properties-form.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/content-type-fields-properties-form.component.spec.ts index 4a6a33552331..94b3d0546601 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/content-type-fields-properties-form.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/content-type-fields-properties-form.component.spec.ts @@ -43,7 +43,7 @@ const mockDFormFieldData = { standalone: false }) class DotHostTesterComponent { - mockDFormFieldData: DotCMSContentTypeField; + mockDFormFieldData!: DotCMSContentTypeField; contentType = dotcmsContentTypeBasicMock; } @@ -53,11 +53,11 @@ class DotHostTesterComponent { }) class TestDynamicFieldPropertyDirective { @Input() - propertyName: string; + propertyName!: string; @Input() - field: DotCMSContentTypeField; + field!: DotCMSContentTypeField; @Input() - group: UntypedFormGroup; + group!: UntypedFormGroup; } @Injectable() @@ -71,7 +71,7 @@ class TestFieldPropertiesService { } getValue(field: DotCMSContentTypeField, propertyName: string): any { - return field[propertyName]; + return field[propertyName as keyof DotCMSContentTypeField]; } getDefaultValue(propertyName: string): any { @@ -180,11 +180,11 @@ describe('ContentTypeFieldsPropertiesFormComponent', () => { it('should init form', () => { expect(mockFieldPropertyService.getProperties).toHaveBeenCalledWith(DotCMSClazzes.TEXT); - expect(comp.form.get('clazz').value).toBe(DotCMSClazzes.TEXT); + expect(comp.form.get('clazz')!.value).toBe(DotCMSClazzes.TEXT); - expect(comp.form.get('id').value).toBe('123'); - expect(comp.form.get('property1').value).toBe(''); - expect(comp.form.get('property2').value).toBe(true); + expect(comp.form.get('id')!.value).toBe('123'); + expect(comp.form.get('property1')!.value).toBe(''); + expect(comp.form.get('property2')!.value).toBe(true); expect(comp.form.get('property3')).toBeNull(); }); @@ -218,33 +218,33 @@ describe('ContentTypeFieldsPropertiesFormComponent', () => { }); it('should set system indexed true when select user searchable', () => { - comp.form.get('indexed').setValue(false); - comp.form.get('searchable').setValue(true); + comp.form.get('indexed')!.setValue(false); + comp.form.get('searchable')!.setValue(true); - expect(comp.form.get('indexed').value).toBe(true); - expect(comp.form.get('indexed').disabled).toBe(true); + expect(comp.form.get('indexed')!.value).toBe(true); + expect(comp.form.get('indexed')!.disabled).toBe(true); }); it('should set system indexed true when you select show in list', () => { - comp.form.get('indexed').setValue(false); - comp.form.get('listed').setValue(true); + comp.form.get('indexed')!.setValue(false); + comp.form.get('listed')!.setValue(true); - expect(comp.form.get('indexed').value).toBe(true); - expect(comp.form.get('indexed').disabled).toBe(true); + expect(comp.form.get('indexed')!.value).toBe(true); + expect(comp.form.get('indexed')!.disabled).toBe(true); }); // TODO: fix because is failing intermittently xit('should set system indexed and required true when you select unique', () => { - comp.form.get('indexed').setValue(false); - comp.form.get('required').setValue(false); + comp.form.get('indexed')!.setValue(false); + comp.form.get('required')!.setValue(false); - comp.form.get('unique').setValue(true); + comp.form.get('unique')!.setValue(true); - expect(comp.form.get('indexed').value).toBe(true); - expect(comp.form.get('required').value).toBe(true); + expect(comp.form.get('indexed')!.value).toBe(true); + expect(comp.form.get('required')!.value).toBe(true); - expect(comp.form.get('indexed').disabled).toBe(true); - expect(comp.form.get('required').disabled).toBe(true); + expect(comp.form.get('indexed')!.disabled).toBe(true); + expect(comp.form.get('required')!.disabled).toBe(true); }); }); @@ -262,7 +262,7 @@ describe('ContentTypeFieldsPropertiesFormComponent', () => { }); it("should set unique and no break when indexed and required doesn't exist", () => { - comp.form.get('unique').setValue(true); + comp.form.get('unique')!.setValue(true); expect(comp.form.get('indexed')).toBe(null); expect(comp.form.get('required')).toBe(null); @@ -334,7 +334,9 @@ describe('ContentTypeFieldsPropertiesFormComponent', () => { }); it('should create fieldVariables array with newRenderMode when fieldVariables is undefined', () => { - comp.formFieldData.fieldVariables = undefined; + // The model declares `fieldVariables` required, but `transformFormValue`'s + // `|| []` exists for the case where it is absent — which is this test. + (comp.formFieldData as Partial).fieldVariables = undefined; const formValue = { newRenderMode: 'editable', name: 'customField' }; const result = comp.transformFormValue(formValue); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/content-type-fields-properties-form.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/content-type-fields-properties-form.component.ts index 5b6dea8cb465..b50fa0bb4b35 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/content-type-fields-properties-form.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/content-type-fields-properties-form.component.ts @@ -65,10 +65,10 @@ export class ContentTypeFieldsPropertiesFormComponent implements OnChanges, OnIn readonly $propertiesContainer = viewChild('properties'); /** Local copy of form field data for mutations */ - formFieldData: DotCMSContentTypeField; + formFieldData!: DotCMSContentTypeField; /** Reactive form group for field properties */ - form: UntypedFormGroup; + form!: UntypedFormGroup; /** Array of field property names to display */ fieldProperties: string[] = []; @@ -77,7 +77,7 @@ export class ContentTypeFieldsPropertiesFormComponent implements OnChanges, OnIn checkboxFields: string[] = ['indexed', 'listed', 'required', 'searchable', 'unique']; /** Original form value used for change detection */ - private originalValue: DotCMSContentTypeField; + private originalValue!: DotCMSContentTypeField; /** Subject for managing component destruction and unsubscribing from observables */ private destroy$: Subject = new Subject(); @@ -95,11 +95,13 @@ export class ContentTypeFieldsPropertiesFormComponent implements OnChanges, OnIn */ ngOnChanges(changes: SimpleChanges): void { if ( - changes.$formFieldData?.currentValue && - changes.$formFieldData.currentValue !== this.formFieldData + changes['$formFieldData']?.currentValue && + changes['$formFieldData'].currentValue !== this.formFieldData ) { - this.formFieldData = this.$formFieldData(); - if (this.formFieldData) { + const field = this.$formFieldData(); + + if (field) { + this.formFieldData = field; this.destroy(); this.init(); this.cdr.detectChanges(); @@ -111,8 +113,10 @@ export class ContentTypeFieldsPropertiesFormComponent implements OnChanges, OnIn * Angular lifecycle hook called after component initialization */ ngOnInit(): void { - this.formFieldData = this.$formFieldData(); - if (this.formFieldData) { + const field = this.$formFieldData(); + + if (field) { + this.formFieldData = field; // ngOnChanges runs before ngOnInit when formFieldData is provided up-front, // so the form may already be initialized. Re-running init() here would create // a second FormGroup, leaving the rendered inputs bound to the old one while @@ -142,7 +146,7 @@ export class ContentTypeFieldsPropertiesFormComponent implements OnChanges, OnIn const transformedValue = this.transformFormValue(this.form.value); this.saveField.emit(transformedValue); } else { - this.fieldProperties.forEach((property) => this.form.get(property).markAsTouched()); + this.fieldProperties.forEach((property) => this.form.get(property)?.markAsTouched()); } this.valid.emit(false); @@ -155,8 +159,8 @@ export class ContentTypeFieldsPropertiesFormComponent implements OnChanges, OnIn * @param value - The form value to transform */ transformFormValue( - value: Partial & { newRenderMode?: string } - ): DotCMSContentTypeField { + value: Partial & { newRenderMode?: string | null } + ): DotCMSContentTypeField & { newRenderMode?: string; label?: string } { if (this.formFieldData.clazz === DotCMSClazzes.CUSTOM_FIELD) { const existingVariables = this.formFieldData.fieldVariables || []; const otherVariables = existingVariables.filter( @@ -198,12 +202,10 @@ export class ContentTypeFieldsPropertiesFormComponent implements OnChanges, OnIn private init(): void { this.updateFormFieldData(); - const properties: string[] = this.fieldPropertyService.getProperties( - this.formFieldData.clazz - ); + const properties = this.fieldPropertyService.getProperties(this.formFieldData.clazz); this.initFormGroup(properties); - this.sortProperties(properties); + this.sortProperties(properties ?? []); } /** @@ -212,7 +214,7 @@ export class ContentTypeFieldsPropertiesFormComponent implements OnChanges, OnIn * @param [properties] - Optional array of property names to include in the form */ private initFormGroup(properties?: string[]): void { - const formFields = {}; + const formFields: Record = {}; if (properties) { properties @@ -337,7 +339,7 @@ export class ContentTypeFieldsPropertiesFormComponent implements OnChanges, OnIn */ private setIndexedValueChecked(propertyValue: boolean): void { if (this.form.get('indexed') && propertyValue) { - this.form.get('indexed').setValue(propertyValue); + this.form.controls['indexed'].setValue(propertyValue); } this.handleDisabledIndexed(propertyValue); @@ -353,7 +355,7 @@ export class ContentTypeFieldsPropertiesFormComponent implements OnChanges, OnIn this.setIndexedValueChecked(propertyValue); if (this.form.get('required') && propertyValue) { - this.form.get('required').setValue(propertyValue); + this.form.controls['required'].setValue(propertyValue); } this.handleDisabledRequired(propertyValue); @@ -367,7 +369,9 @@ export class ContentTypeFieldsPropertiesFormComponent implements OnChanges, OnIn */ private handleDisabledIndexed(disable: boolean): void { if (this.form.get('indexed')) { - disable ? this.form.get('indexed').disable() : this.form.get('indexed').enable(); + disable + ? this.form.controls['indexed'].disable() + : this.form.controls['indexed'].enable(); } } @@ -378,7 +382,9 @@ export class ContentTypeFieldsPropertiesFormComponent implements OnChanges, OnIn */ private handleDisabledRequired(disable: boolean): void { if (this.form.get('required')) { - disable ? this.form.get('required').disable() : this.form.get('required').enable(); + disable + ? this.form.controls['required'].disable() + : this.form.controls['required'].enable(); } } @@ -387,7 +393,10 @@ export class ContentTypeFieldsPropertiesFormComponent implements OnChanges, OnIn */ private updateFormFieldData() { if (!this.formFieldData.id) { - delete this.formFieldData['name']; + // `name` is required on `DotCMSContentTypeField` because that is what the endpoint + // returns; a new field is sent without it so the backend derives it. The cast states + // that difference instead of widening the model for all 27 of its consumers. + delete (this.formFieldData as Partial).name; } } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/categories-property/categories-property.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/categories-property/categories-property.component.spec.ts index 8cd230a2bd3e..bb01204118fb 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/categories-property/categories-property.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/categories-property/categories-property.component.spec.ts @@ -19,9 +19,9 @@ import { DOTTestBed } from '../../../../../../../../test/dot-test-bed'; }) class TestFieldValidationMessageComponent { @Input() - field: NgControl; + field!: NgControl; @Input() - message: string; + message!: string; } @Injectable() diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/categories-property/categories-property.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/categories-property/categories-property.component.ts index 5db8779de7e9..52b660403a0c 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/categories-property/categories-property.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/categories-property/categories-property.component.ts @@ -32,9 +32,9 @@ export class CategoriesPropertyComponent implements OnInit { categoriesCurrentPage: DotCMSContentTypeFieldCategories[] = []; loading = false; filterValue = ''; - property: FieldProperty; - group: UntypedFormGroup; - placeholder: string; + property!: FieldProperty; + group!: UntypedFormGroup; + placeholder!: string; ngOnInit(): void { this.placeholder = !this.property.value @@ -59,7 +59,7 @@ export class CategoriesPropertyComponent implements OnInit { * @param any event * @memberof CategoriesPropertyComponent */ - handlePageChange(event): void { + handlePageChange(event: { filter: string; first: number }): void { this.getCategoriesList(event.filter, event.first); } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/checkbox-property/checkbox-property.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/checkbox-property/checkbox-property.component.ts index d7b6afb193d5..dd80b2c79917 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/checkbox-property/checkbox-property.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/checkbox-property/checkbox-property.component.ts @@ -10,10 +10,10 @@ import { FieldProperty } from '../field-properties.model'; standalone: false }) export class CheckboxPropertyComponent { - property: FieldProperty; - group: UntypedFormGroup; + property!: FieldProperty; + group!: UntypedFormGroup; - private readonly labelMap = { + private readonly labelMap: Record = { indexed: 'contenttypes.field.properties.system_indexed.label', listed: 'contenttypes.field.properties.listed.label', required: 'contenttypes.field.properties.required.label', @@ -21,7 +21,7 @@ export class CheckboxPropertyComponent { unique: 'contenttypes.field.properties.unique.label' }; - setCheckboxLabel(field): string { + setCheckboxLabel(field: string): string { return this.labelMap[field] || field; } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/data-type-property/data-type-property.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/data-type-property/data-type-property.component.spec.ts index 416ff289b45e..5f60d30622c6 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/data-type-property/data-type-property.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/data-type-property/data-type-property.component.spec.ts @@ -26,7 +26,7 @@ describe('DataTypePropertyComponent', () => { 'contenttypes.field.properties.data_type.values.system': 'System-Field' }); - let group; + let group: UntypedFormGroup; beforeEach(waitForAsync(() => { DOTTestBed.configureTestingModule({ diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/data-type-property/data-type-property.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/data-type-property/data-type-property.component.ts index d6d3d1774442..0fe0a3c1ed97 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/data-type-property/data-type-property.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/data-type-property/data-type-property.component.ts @@ -11,9 +11,9 @@ import { FieldProperty } from '../field-properties.model'; standalone: false }) export class DataTypePropertyComponent implements OnInit { - property: FieldProperty; - group: UntypedFormGroup; - radioInputs: object; + property!: FieldProperty; + group!: UntypedFormGroup; + radioInputs!: object; ngOnInit(): void { this.radioInputs = DATA_TYPE_PROPERTY_INFO[this.property.field.clazz]; @@ -21,7 +21,7 @@ export class DataTypePropertyComponent implements OnInit { /** * Workaround because of this bug: https://github.com/primefaces/primeng/issues/9162#issuecomment-686370453 */ - const control = this.group.get(this.property.name); + const control = this.group.get(this.property.name)!; control.valueChanges.subscribe((value: string) => { control.setValue(value, { emitEvent: false diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/default-value-property/default-value-property.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/default-value-property/default-value-property.component.spec.ts index e5683a1c8850..a98773296df5 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/default-value-property/default-value-property.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/default-value-property/default-value-property.component.spec.ts @@ -105,7 +105,7 @@ describe('DefaultValuePropertyComponent', () => { const fieldValidationmessage: DebugElement = fixture.debugElement.query( By.css('dot-field-validation-message') ); - comp.group.get('name').setValue(''); + comp.group.get('name')!.setValue(''); fixture.detectChanges(); expect(fieldValidationmessage.componentInstance.defaultMessage).toContain('default error'); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/default-value-property/default-value-property.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/default-value-property/default-value-property.component.ts index 7f35b73d0915..c6bc6dc3ea2d 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/default-value-property/default-value-property.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/default-value-property/default-value-property.component.ts @@ -14,9 +14,9 @@ import { FieldProperty } from '../field-properties.model'; export class DefaultValuePropertyComponent implements OnInit { private dotMessageService = inject(DotMessageService); - property: FieldProperty; - group: UntypedFormGroup; - errorLabel: string; + property!: FieldProperty; + group!: UntypedFormGroup; + errorLabel!: string; private errorLabelsMap = new Map(); ngOnInit(): void { @@ -30,9 +30,11 @@ export class DefaultValuePropertyComponent implements OnInit { } private getErrorLabel(clazz: string | null): string { - return this.errorLabelsMap.get(clazz as string) - ? this.errorLabelsMap.get(clazz as string) - : this.errorLabelsMap.get('default'); + return ( + (clazz ? this.errorLabelsMap.get(clazz) : undefined) ?? + this.errorLabelsMap.get('default') ?? + '' + ); } private setErrorLabelMap(): void { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-cardinality-selector/dot-cardinality-selector.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-cardinality-selector/dot-cardinality-selector.component.ts index 65e10ba38a36..4fa88616ff61 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-cardinality-selector/dot-cardinality-selector.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-cardinality-selector/dot-cardinality-selector.component.ts @@ -35,15 +35,15 @@ export class DotCardinalitySelectorComponent implements OnInit { private dotRelationshipService = inject(DotRelationshipService); @Input() - value: number; + value!: number; @Input() - disabled: boolean; + disabled!: boolean; @Output() switch: EventEmitter = new EventEmitter(); - options: Observable; + options!: Observable; ngOnInit() { this.options = this.dotRelationshipService.loadCardinalities(); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-edit-relationship/dot-edit-relationships.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-edit-relationship/dot-edit-relationships.component.spec.ts index c1c36e373506..aad323b203b4 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-edit-relationship/dot-edit-relationships.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-edit-relationship/dot-edit-relationships.component.spec.ts @@ -53,15 +53,15 @@ const cardinalities = [ }) class MockSearchableDropdownComponent { @Input() - data: string[]; + data!: string[]; @Input() - labelPropertyName: string | string[]; + labelPropertyName!: string | string[]; @Input() pageLinkSize = 3; @Input() - rows: number; + rows!: number; @Input() - totalRecords: number; + totalRecords!: number; @Input() placeholder = ''; @@ -75,16 +75,16 @@ class MockSearchableDropdownComponent { @Injectable() class MockPaginatorService { - url: string; + url!: string; - public paginationPerPage: 10; - public maxLinksPage: 5; - public totalRecords: 40; + public paginationPerPage!: 10; + public maxLinksPage!: 5; + public totalRecords!: 40; setExtraParams(): void {} public getWithOffset(): Observable { - return null; + return of([]); } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-edit-relationship/dot-edit-relationships.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-edit-relationship/dot-edit-relationships.component.ts index f518d2c335c4..89c28a14050f 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-edit-relationship/dot-edit-relationships.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-edit-relationship/dot-edit-relationships.component.ts @@ -54,9 +54,9 @@ export class DotEditRelationshipsComponent implements OnInit { @Output() switch: EventEmitter = new EventEmitter(); - currentPage: Observable<{ label: string; relationship: DotRelationship }[]>; + currentPage!: Observable<{ label: string; relationship: DotRelationship }[]>; - private cardinalities: CardinalitySorted; + private cardinalities!: CardinalitySorted; ngOnInit() { this.dotPaginatorService.url = 'v1/relationships'; @@ -122,7 +122,7 @@ export class DotEditRelationshipsComponent implements OnInit { this.currentPage = this.getCardinalities().pipe( switchMap((cardinalities: CardinalitySorted) => { - return this.dotPaginatorService.getWithOffset(offset).pipe( + return this.dotPaginatorService.getWithOffset(offset).pipe( mergeMap((relationships: DotRelationship[]) => relationships), map((relationship: DotRelationship) => { return { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-new-relationships/dot-new-relationships.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-new-relationships/dot-new-relationships.component.spec.ts index 5be8ba6a33a6..07efc5a3590c 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-new-relationships/dot-new-relationships.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-new-relationships/dot-new-relationships.component.spec.ts @@ -105,7 +105,9 @@ describe('DotNewRelationshipsComponent', () => { }); it('should initialize with default values', () => { - expect(spectator.component.contentType).toBeUndefined(); + // `null`, not `undefined`: `contentType` is now seeded with the same value + // `onContentTypeChange(null)` and `loadContentType('')` set it back to. + expect(spectator.component.contentType).toBeNull(); expect(spectator.component.currentCardinalityIndex).toBeUndefined(); }); }); @@ -148,7 +150,11 @@ describe('DotNewRelationshipsComponent', () => { }); it('should handle null contentType from service', () => { - contentTypeService.getContentType.mockReturnValue(of(null)); + // `getContentType` declares a non-null content type; the component guards anyway, + // and this test is what drives that guard. + contentTypeService.getContentType.mockReturnValue( + of(null as unknown as DotCMSContentType) + ); spectator.setInput('velocityVar', 'NonExistent'); spectator.detectChanges(); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-new-relationships/dot-new-relationships.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-new-relationships/dot-new-relationships.component.ts index 977cbfa38c7c..c89d93f3ff02 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-new-relationships/dot-new-relationships.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-new-relationships/dot-new-relationships.component.ts @@ -32,24 +32,25 @@ import { DotRelationshipsPropertyValue } from '../model/dot-relationships-proper export class DotNewRelationshipsComponent implements OnChanges { private contentTypeService = inject(DotContentTypeService); - @Input() cardinality: number; + @Input() cardinality!: number; - @Input() velocityVar: string; + @Input() velocityVar!: string; - @Input() editing: boolean; + @Input() editing!: boolean; @Output() switch: EventEmitter = new EventEmitter(); - contentType: DotCMSContentType; - currentCardinalityIndex: number; + /** Null while no content type is selected, which is the state `onContentTypeChange` sets. */ + contentType: DotCMSContentType | null = null; + currentCardinalityIndex!: number; ngOnChanges(changes: SimpleChanges): void { - if (changes.velocityVar) { - this.loadContentType(changes.velocityVar.currentValue); + if (changes['velocityVar']) { + this.loadContentType(changes['velocityVar'].currentValue); } - if (changes.cardinality) { - this.currentCardinalityIndex = changes.cardinality.currentValue; + if (changes['cardinality']) { + this.currentCardinalityIndex = changes['cardinality'].currentValue; } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-relationships-property.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-relationships-property.component.spec.ts index d9c1cf439985..22a180bb0e26 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-relationships-property.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-relationships-property.component.spec.ts @@ -31,9 +31,9 @@ import { DOTTestBed } from '../../../../../../../../test/dot-test-bed'; }) class TestFieldValidationMessageComponent { @Input() - field: NgControl; + field!: NgControl; @Input() - message: string; + message!: string; } @Component({ @@ -43,13 +43,13 @@ class TestFieldValidationMessageComponent { }) class TestNewRelationshipsComponent { @Input() - cardinality: number; + cardinality!: number; @Input() - velocityVar: string; + velocityVar!: string; @Input() - editing: boolean; + editing!: boolean; @Output() switch: EventEmitter = new EventEmitter(); @@ -232,7 +232,7 @@ describe('DotRelationshipsPropertyComponent', () => { comp.clean(); - expect(comp.group.get('relationship').value).toEqual(comp.beforeValue); + expect(comp.group.get('relationship')!.value).toEqual(comp.beforeValue); }); }); @@ -266,7 +266,7 @@ describe('DotRelationshipsPropertyComponent', () => { expect(dotNewRelationships).toBeDefined(); expect(de.query(By.css('dot-edit-relationships'))).toBeNull(); - const relationshipValue = comp.group.get('relationship').value; + const relationshipValue = comp.group.get('relationship')!.value; expect(relationshipValue.velocityVar).toEqual('velocityVar'); expect(relationshipValue.cardinality).toEqual(1); }); @@ -274,7 +274,7 @@ describe('DotRelationshipsPropertyComponent', () => { describe('with inverse relationship', () => { it('should not have existing and new radio buttonand should show dot-new-relationships', () => { // Same object reference as the form control value (legacy DOTTestBed pattern). - comp.property.value.velocityVar = 'contentType.fieldName'; + comp.property.value['velocityVar'] = 'contentType.fieldName'; comp.ngOnInit(); fixture.detectChanges(); flushRelationshipHttpMocks(); @@ -285,7 +285,7 @@ describe('DotRelationshipsPropertyComponent', () => { expect(de.query(By.css('dot-new-relationships'))).toBeDefined(); expect(de.query(By.css('dot-edit-relationships'))).toBeNull(); - const relationshipValue = comp.group.get('relationship').value; + const relationshipValue = comp.group.get('relationship')!.value; expect(relationshipValue.velocityVar).toEqual('contentType.fieldName'); expect(relationshipValue.cardinality).toEqual(1); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-relationships-property.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-relationships-property.component.ts index a82a783b2ab4..33130d1bfd4b 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-relationships-property.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/dot-relationships-property.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit, inject, ChangeDetectionStrategy } from '@angular/core'; -import { FormsModule, UntypedFormGroup } from '@angular/forms'; +import { AbstractControl, FormsModule, UntypedFormGroup } from '@angular/forms'; import { RadioButtonModule } from 'primeng/radiobutton'; @@ -45,17 +45,17 @@ export class DotRelationshipsPropertyComponent implements OnInit { readonly STATUS_NEW = 'NEW'; readonly STATUS_EXISTING = 'EXISTING'; - property: FieldProperty<{ [key: string]: unknown }>; - group: UntypedFormGroup; + property!: FieldProperty<{ [key: string]: unknown }>; + group!: UntypedFormGroup; status = this.STATUS_NEW; - editing: boolean; + editing = false; - beforeValue: DotRelationshipsPropertyValue; + beforeValue!: DotRelationshipsPropertyValue; ngOnInit() { - this.beforeValue = structuredClone(this.group.get(this.property.name).value); - this.editing = !!this.group.get(this.property.name).value.velocityVar; + this.beforeValue = structuredClone(this.#control().value); + this.editing = !!this.#control().value.velocityVar; } /** @@ -65,7 +65,7 @@ export class DotRelationshipsPropertyComponent implements OnInit { * @memberof DotRelationshipsPropertyComponent */ handleChange(value: DotRelationshipsPropertyValue): void { - this.group.get(this.property.name).setValue(value); + this.#control().setValue(value); } /** @@ -74,7 +74,19 @@ export class DotRelationshipsPropertyComponent implements OnInit { * @memberof DotRelationshipsPropertyComponent */ clean(): void { - this.group.get(this.property.name).setValue(structuredClone(this.beforeValue)); + this.#control().setValue(structuredClone(this.beforeValue)); + } + + /** + * The control backing this property. + * + * Reached through `controls[...]` rather than `get(...)`: the parent form builds one control + * per property before rendering this component, and unlike `get()` — which returns + * `AbstractControl | null` for arbitrary paths — an `UntypedFormGroup`'s `controls` map is + * declared non-nullable, so there is no absence to invent a fallback for. + */ + #control(): AbstractControl { + return this.group.controls[this.property.name]; } /** diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/model/dot-relationships-property-value.model.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/model/dot-relationships-property-value.model.ts index 587a5f826023..32cf38736884 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/model/dot-relationships-property-value.model.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/model/dot-relationships-property-value.model.ts @@ -5,6 +5,10 @@ * @interface DotRelationshipsPropertyValue */ export interface DotRelationshipsPropertyValue { - velocityVar: string; + /** + * Absent until a content type is picked — `validateRelationship` treats that as invalid, which + * is how the form keeps a half-filled relationship from being saved. + */ + velocityVar?: string; cardinality: number; } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/services/dot-edit-content-type-cache.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/services/dot-edit-content-type-cache.service.ts index 6412ba5814f6..63acb54f28a3 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/services/dot-edit-content-type-cache.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/services/dot-edit-content-type-cache.service.ts @@ -10,7 +10,7 @@ import { DotCMSContentType } from '@dotcms/dotcms-models'; */ @Injectable() export class DotEditContentTypeCacheService { - private currentContentType: DotCMSContentType; + private currentContentType!: DotCMSContentType; /** *Strore the current {@see ContentTye} in cache diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/services/validators/dot-relationship-validator.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/services/validators/dot-relationship-validator.ts index ed122ad8c6f8..0c8ee8c23472 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/services/validators/dot-relationship-validator.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/services/validators/dot-relationship-validator.ts @@ -1,13 +1,13 @@ -import { UntypedFormControl } from '@angular/forms'; +import { AbstractControl, ValidationErrors } from '@angular/forms'; /** *Validate the values for a relationship property field are right. * * @export - * @param {FormControl} formControl + * @param {AbstractControl} formControl * @returns */ -export function validateRelationship(formControl: UntypedFormControl) { +export function validateRelationship(formControl: AbstractControl): ValidationErrors | null { if (formControl.value.cardinality !== undefined && formControl.value.velocityVar) { return null; } else { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/services/validators/no-whitespace-validator.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/services/validators/no-whitespace-validator.ts index 9e605c4ecffb..d5e1574f8f99 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/services/validators/no-whitespace-validator.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dot-relationships-property/services/validators/no-whitespace-validator.ts @@ -1,4 +1,4 @@ -import { UntypedFormControl } from '@angular/forms'; +import { AbstractControl, ValidationErrors } from '@angular/forms'; /** * Check if a valur has only white space @@ -7,7 +7,7 @@ import { UntypedFormControl } from '@angular/forms'; * @param {FormControl} formControl * @returns */ -export function noWhitespaceValidator(control: UntypedFormControl) { +export function noWhitespaceValidator(control: AbstractControl): ValidationErrors | null { const isWhitespace = (control.value || '').trim().length === 0; const isValid = !isWhitespace; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dynamic-field-property-directive/dynamic-field-property.directive.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dynamic-field-property-directive/dynamic-field-property.directive.spec.ts index 7fbd7b365afa..3462e9a9934c 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dynamic-field-property-directive/dynamic-field-property.directive.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dynamic-field-property-directive/dynamic-field-property.directive.spec.ts @@ -52,9 +52,9 @@ class CustomHostComponent { standalone: false }) class DynamicComponent { - property: FieldProperty; - group: UntypedFormGroup; - helpText: string; + property!: FieldProperty; + group!: UntypedFormGroup; + helpText!: string; } describe('Directive: DynamicFieldPropertyDirective', () => { @@ -93,7 +93,7 @@ describe('Directive: DynamicFieldPropertyDirective', () => { expect(hostSpectator.query('dot-test')).toContainText('Dynamic Component'); - const testComponent = hostSpectator.query(DynamicComponent); + const testComponent = hostSpectator.query(DynamicComponent)!; expect(testComponent).toBeDefined(); expect(testComponent.property).toEqual({ field: hostSpectator.hostComponent.field, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dynamic-field-property-directive/dynamic-field-property.directive.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dynamic-field-property-directive/dynamic-field-property.directive.ts index 7fb33fba111a..7944e0126970 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dynamic-field-property-directive/dynamic-field-property.directive.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/dynamic-field-property-directive/dynamic-field-property.directive.ts @@ -26,14 +26,14 @@ export class DynamicFieldPropertyDirective implements OnChanges, OnDestroy { private previousField: DotCMSContentTypeField | null = null; private previousPropertyName: string | null = null; - @Input() propertyName: string; - @Input() field: DotCMSContentTypeField; - @Input() group: UntypedFormGroup; + @Input() propertyName!: string; + @Input() field!: DotCMSContentTypeField; + @Input() group!: UntypedFormGroup; ngOnChanges(changes: SimpleChanges): void { - const fieldChanged = changes.field; - const propertyNameChanged = changes.propertyName; - const groupChanged = changes.group; + const fieldChanged = changes['field']; + const propertyNameChanged = changes['propertyName']; + const groupChanged = changes['group']; // Only create component if field, propertyName or group actually changed if ( @@ -75,6 +75,11 @@ export class DynamicFieldPropertyDirective implements OnChanges, OnDestroy { private createComponent(property: string): void { const component = this.fieldPropertyService.getComponent(property); + + if (!component) { + return; + } + this.componentRef = this.viewContainerRef.createComponent(component); this.updateComponent(); @@ -88,13 +93,12 @@ export class DynamicFieldPropertyDirective implements OnChanges, OnDestroy { this.componentRef.instance.property = { field: this.field, name: this.propertyName, - value: this.field[this.propertyName] + value: this.field[this.propertyName as keyof DotCMSContentTypeField] }; this.componentRef.instance.group = this.group; - this.componentRef.instance.helpText = this.fieldPropertyService.getFieldType( - this.field.clazz - ).helpText; + this.componentRef.instance.helpText = + this.fieldPropertyService.getFieldType(this.field.clazz)?.helpText ?? ''; } private destroyComponent(): void { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/hint-property/hint-property.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/hint-property/hint-property.component.ts index 3bb1af139c95..fa2888dbdcb1 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/hint-property/hint-property.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/hint-property/hint-property.component.ts @@ -10,6 +10,6 @@ import { FieldProperty } from '../field-properties.model'; standalone: false }) export class HintPropertyComponent { - property: FieldProperty; - group: UntypedFormGroup; + property!: FieldProperty; + group!: UntypedFormGroup; } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/name-property/name-property.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/name-property/name-property.component.spec.ts index 3206af13a1fd..dc4f6a0b10f5 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/name-property/name-property.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/name-property/name-property.component.spec.ts @@ -91,7 +91,7 @@ describe('NamePropertyComponent', () => { }); it('should focus on input on load using the directive', () => { - const input = spectator.query('input.name__input'); + const input = spectator.query('input.name__input')!; expect(input).toBeTruthy(); expect(input.getAttribute('dotautofocus')).toBeDefined(); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/name-property/name-property.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/name-property/name-property.component.ts index 22f9c217e218..0825e646c2e3 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/name-property/name-property.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/name-property/name-property.component.ts @@ -10,6 +10,6 @@ import { FieldProperty } from '../field-properties.model'; standalone: false }) export class NamePropertyComponent { - property: FieldProperty; - group: UntypedFormGroup; + property!: FieldProperty; + group!: UntypedFormGroup; } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/regex-check-property/regex-check-property.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/regex-check-property/regex-check-property.component.spec.ts index c262f351a26d..b4807709f8e2 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/regex-check-property/regex-check-property.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/regex-check-property/regex-check-property.component.spec.ts @@ -90,7 +90,7 @@ describe('RegexCheckPropertyComponent', () => { value: '^([a-zA-Z0-9]+[a-zA-Z0-9._%+-]*@(?:[a-zA-Z0-9-]+.)+[a-zA-Z]{2,4})$' }); - expect(spectator.component.group.get('regexCheck').value).toBe( + expect(spectator.component.group.get('regexCheck')!.value).toBe( '^([a-zA-Z0-9]+[a-zA-Z0-9._%+-]*@(?:[a-zA-Z0-9-]+.)+[a-zA-Z]{2,4})$' ); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/regex-check-property/regex-check-property.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/regex-check-property/regex-check-property.component.ts index 069a3acd15e4..5dc5d84f7205 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/regex-check-property/regex-check-property.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/regex-check-property/regex-check-property.component.ts @@ -21,8 +21,8 @@ export class RegexCheckPropertyComponent implements OnInit { regexCheckTemplates: RegexTemplate[] = []; - property: FieldProperty; - group: UntypedFormGroup; + property!: FieldProperty; + group!: UntypedFormGroup; ngOnInit() { this.regexCheckTemplates = [ @@ -85,7 +85,7 @@ export class RegexCheckPropertyComponent implements OnInit { ]; } - templateSelect(event): void { + templateSelect(event: { value: string }): void { this.group.controls[this.property.name].setValue(event.value); } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/render-mode-property/render-mode-property.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/render-mode-property/render-mode-property.component.ts index 1da868b6a8c1..c2a5de2a2414 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/render-mode-property/render-mode-property.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/render-mode-property/render-mode-property.component.ts @@ -18,8 +18,8 @@ interface RenderMode { standalone: false }) export class RenderModePropertyComponent { - property: FieldProperty; - group: UntypedFormGroup; + property!: FieldProperty; + group!: UntypedFormGroup; /** * Signals the render modes available for the field diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/values-property/values-property.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/values-property/values-property.component.spec.ts index 6545a36dc82b..cc475bc93bef 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/values-property/values-property.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/values-property/values-property.component.spec.ts @@ -26,8 +26,8 @@ import { DotFieldHelperComponent } from '../../../../../../../../view/components standalone: false }) class TestFieldValidationMessageComponent { - @Input() field: NgControl; - @Input() message: string; + @Input() field!: NgControl; + @Input() message!: string; } @Component({ @@ -43,8 +43,8 @@ class TestFieldValidationMessageComponent { ] }) class DotTextareaContentMockComponent implements ControlValueAccessor { - @Input() show: string[]; - @Input() height: string; + @Input() show!: string[]; + @Input() height!: string; propagateChange = (_: unknown) => { // diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/values-property/values-property.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/values-property/values-property.component.ts index 91bed71588a2..cd8b255a4d96 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/values-property/values-property.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-properties-form/field-properties/values-property/values-property.component.ts @@ -11,10 +11,10 @@ import { FieldProperty } from '../field-properties.model'; standalone: false }) export class ValuesPropertyComponent { - @ViewChild('value') value: DotTextareaContentComponent; - property: FieldProperty; - group: UntypedFormGroup; - helpText: string; + @ViewChild('value') value!: DotTextareaContentComponent; + property!: FieldProperty; + group!: UntypedFormGroup; + helpText!: string; private validTextHelperClazz = [ 'com.dotcms.contenttype.model.field.ImmutableRadioField', diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-row/content-type-fields-row.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-row/content-type-fields-row.component.spec.ts index 2eca9d94fd00..60183761b9b0 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-row/content-type-fields-row.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-row/content-type-fields-row.component.spec.ts @@ -24,7 +24,7 @@ import { FieldDragDropService } from '../service'; const mockFieldRow: DotCMSContentTypeLayoutRow = FieldUtil.createFieldRow(2); -mockFieldRow.columns[0].fields = [ +mockFieldRow.columns![0].fields = [ { ...dotcmsContentTypeFieldBasicMock, clazz: DotCMSClazzes.TEXT, @@ -37,7 +37,7 @@ mockFieldRow.columns[0].fields = [ } ]; -mockFieldRow.columns[1].fields = [ +mockFieldRow.columns![1].fields = [ { ...dotcmsContentTypeFieldBasicMock, clazz: DotCMSClazzes.TEXT, @@ -52,7 +52,7 @@ mockFieldRow.columns[1].fields = [ }) class TestContentTypeFieldDraggableItemComponent { @Input() - field: DotCMSContentTypeField; + field!: DotCMSContentTypeField; @Input() isSmall = false; @Output() @@ -67,7 +67,7 @@ class TestContentTypeFieldDraggableItemComponent { standalone: false }) class DotTestHostComponent { - data: DotCMSContentTypeLayoutRow; + data!: DotCMSContentTypeLayoutRow; setData(data: DotCMSContentTypeLayoutRow): void { this.data = data; @@ -134,7 +134,7 @@ describe('ContentTypeFieldsRowComponent', () => { const draggableItems = col.queryAll( By.css('dot-content-type-field-dragabble-item') ); - expect(mockFieldRow.columns[index].fields.length).toEqual(draggableItems.length); + expect(mockFieldRow.columns![index].fields.length).toEqual(draggableItems.length); }); }); @@ -175,7 +175,7 @@ describe('ContentTypeFieldsRowComponent', () => { rowFixture = DOTTestBed.createComponent(DotTestHostComponent); rowHostComp = rowFixture.componentInstance; const mock: DotCMSContentTypeLayoutRow = FieldUtil.createFieldRow(1); - mock.columns[0].fields = []; + mock.columns![0].fields = []; rowHostComp.data = mock; rowHostDe = rowFixture.debugElement; rowFixture.detectChanges(); @@ -215,8 +215,8 @@ describe('ContentTypeFieldsRowComponent', () => { colFixture = DOTTestBed.createComponent(DotTestHostComponent); colHostComp = colFixture.componentInstance; const mock: DotCMSContentTypeLayoutRow = FieldUtil.createFieldRow(2); - mock.columns[0].fields = []; - mock.columns[1].fields = []; + mock.columns![0].fields = []; + mock.columns![1].fields = []; colHostComp.data = mock; colHostDe = colFixture.debugElement; colFixture.detectChanges(); @@ -230,7 +230,7 @@ describe('ContentTypeFieldsRowComponent', () => { }); it('should emit remove field event when column has id', () => { - colComp.fieldRow.columns[0].columnDivider.id = 'test'; + colComp.fieldRow.columns![0].columnDivider.id = 'test'; let result; colComp.removeField.subscribe((col: DotCMSContentTypeField) => { @@ -240,7 +240,7 @@ describe('ContentTypeFieldsRowComponent', () => { const removeButton = colDe.query(By.css('p-button')); removeButton.nativeElement.querySelector('button').click(); - expect(result.clazz).toEqual( + expect(result!.clazz).toEqual( 'com.dotcms.contenttype.model.field.ImmutableColumnField' ); }); @@ -251,12 +251,12 @@ describe('ContentTypeFieldsRowComponent', () => { result = col; }); - expect(colComp.fieldRow.columns.length).toBe(2); + expect(colComp.fieldRow.columns!.length).toBe(2); const removeButton = colDe.query(By.css('p-button')); removeButton.nativeElement.querySelector('button').click(); - expect(colComp.fieldRow.columns.length).toBe(1); + expect(colComp.fieldRow.columns!.length).toBe(1); expect(result).toBeUndefined(); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-row/content-type-fields-row.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-row/content-type-fields-row.component.ts index 9ed276a4f434..8ffedafc3f8a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-row/content-type-fields-row.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-row/content-type-fields-row.component.ts @@ -4,6 +4,17 @@ import { DotAlertConfirmService, DotMessageService } from '@dotcms/data-access'; import { DotCMSContentTypeField, DotCMSContentTypeLayoutRow } from '@dotcms/dotcms-models'; import { FieldUtil } from '@dotcms/utils'; +/** + * A layout row that has columns. + * + * `DotCMSContentTypeLayoutRow.columns` is optional because a tab divider row has none (see + * `FieldUtil.createFieldTabDivider`), but the parent renders this component only inside + * `@if (row.columns && row.columns.length)` — a row without columns goes to + * `dot-content-type-fields-tab` instead. + */ +type FieldRowWithColumns = DotCMSContentTypeLayoutRow & + Required>; + /** * Display all the Field Types * @@ -30,12 +41,12 @@ export class ContentTypeFieldsRowComponent implements OnInit { readonly removeRow = output(); /** Local copy of fieldRow for mutations */ - fieldRow: DotCMSContentTypeLayoutRow; + fieldRow!: FieldRowWithColumns; emptyMessage = ''; ngOnInit() { - this.fieldRow = this.$fieldRow(); + this.fieldRow = this.$fieldRow() as FieldRowWithColumns; this.emptyMessage = this.dotMessageService.get('contenttypes.dropzone.rows.empty.message'); } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-tab/content-type-fields-tab.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-tab/content-type-fields-tab.component.spec.ts index 2418cd3b6d61..a046977257cc 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-tab/content-type-fields-tab.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-tab/content-type-fields-tab.component.spec.ts @@ -34,7 +34,7 @@ const mockFieldTab: DotCMSContentTypeLayoutRow = { standalone: false }) class DotTestHostComponent { - data: DotCMSContentTypeLayoutRow; + data!: DotCMSContentTypeLayoutRow; setData(data: DotCMSContentTypeLayoutRow): void { this.data = data; @@ -126,7 +126,7 @@ describe('ContentTypeFieldsTabComponent', () => { it('should emit delete evt', () => { jest.spyOn(dotDialogService, 'confirm').mockImplementation((conf) => { - conf.accept(); + conf.accept!(); }); jest.spyOn(comp.removeTab, 'emit'); const deleteButton = de.query(By.css('p-button')).nativeElement; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-tab/content-type-fields-tab.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-tab/content-type-fields-tab.component.ts index 7fdb2108cf1f..ae91ec3ebcec 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-tab/content-type-fields-tab.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-tab/content-type-fields-tab.component.ts @@ -28,9 +28,9 @@ export class ContentTypeFieldsTabComponent implements OnInit { readonly removeTab = output(); /** Local copy of fieldTab for access */ - fieldTab: DotCMSContentTypeLayoutRow; + fieldTab!: DotCMSContentTypeLayoutRow; - label: string; + label!: string; ngOnInit() { this.fieldTab = this.$fieldTab(); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-types-fields-list/content-types-fields-list.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-types-fields-list/content-types-fields-list.component.ts index f059a9892cca..db88cf063107 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-types-fields-list/content-types-fields-list.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-types-fields-list/content-types-fields-list.component.ts @@ -24,7 +24,7 @@ import { FieldType } from '..'; imports: [DragulaModule] }) export class ContentTypesFieldsListComponent implements OnInit { - @Input() baseType: string; + @Input() baseType!: string; $fieldTypes = signal<{ clazz: string; name: string }[]>([]); fieldIcons = FIELD_ICONS; @@ -76,7 +76,12 @@ export class ContentTypesFieldsListComponent implements OnInit { ); const COLUMN_BREAK_FIELD = FieldUtil.createColumnBreak(); - this.$fieldTypes.set([COLUMN_BREAK_FIELD, LINE_DIVIDER, ...fieldsFiltered]); + // The line divider is only prepended when the endpoint actually returned it. + this.$fieldTypes.set([ + COLUMN_BREAK_FIELD, + ...(LINE_DIVIDER ? [LINE_DIVIDER] : []), + ...fieldsFiltered + ]); }); } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/dot-content-type-fields-variables/dot-content-type-fields-variables.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/dot-content-type-fields-variables/dot-content-type-fields-variables.component.spec.ts index 5c911744a8f5..4c2870a97327 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/dot-content-type-fields-variables/dot-content-type-fields-variables.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/dot-content-type-fields-variables/dot-content-type-fields-variables.component.spec.ts @@ -32,7 +32,7 @@ class TestHostComponent { value: DotCMSContentTypeField = { ...dotcmsContentTypeFieldBasicMock, contentTypeId: 'ddf29c1e-babd-40a8-bfed-920fc9b8c77', - id: mockFieldVariables[0].fieldId + id: mockFieldVariables[0].fieldId! }; } @@ -135,7 +135,7 @@ describe('DotContentTypeFieldsVariablesComponent', () => { ...EMPTY_FIELD, clazz: DotCMSClazzes.BLOCK_EDITOR, contentTypeId: 'ddf29c1e-babd-40a8-bfed-920fc9b8c77', - id: mockFieldVariables[0].fieldId + id: mockFieldVariables[0].fieldId! }; beforeEach(() => { @@ -170,7 +170,7 @@ describe('DotContentTypeFieldsVariablesComponent', () => { ...EMPTY_FIELD, clazz: DotCMSClazzes.CUSTOM_FIELD, contentTypeId: 'ddf29c1e-babd-40a8-bfed-920fc9b8c77', - id: mockFieldVariables[0].fieldId + id: mockFieldVariables[0].fieldId! }; beforeEach(() => { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/dot-content-type-fields-variables/dot-content-type-fields-variables.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/dot-content-type-fields-variables/dot-content-type-fields-variables.component.ts index af56674c0dda..dcedaebc7cbb 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/dot-content-type-fields-variables/dot-content-type-fields-variables.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/dot-content-type-fields-variables/dot-content-type-fields-variables.component.ts @@ -45,13 +45,13 @@ export class DotContentTypeFieldsVariablesComponent implements OnChanges, OnDest private fieldVariablesService = inject(DotFieldVariablesService); /** The content-type field whose variables are loaded and managed. */ - readonly $field = input(undefined, { alias: 'field' }); + readonly $field = input.required({ alias: 'field' }); /** When `false`, hides the key-value table (used to embed without the table UI). */ readonly $showTable = input(true, { alias: 'showTable' }); /** Local snapshot of the field, updated on every `$field` change. */ - field: DotCMSContentTypeField; + field!: DotCMSContentTypeField; /** Signal holding the list of variables currently shown in the table. */ $fieldVariables = signal([]); @@ -60,7 +60,8 @@ export class DotContentTypeFieldsVariablesComponent implements OnChanges, OnDest * Per-field-type map of variable keys that must be hidden from the table. * These keys are owned by dedicated settings sections and should not be edited here. */ - blackList = { + /** Variable keys hidden per field clazz — most clazzes have no entry. */ + blackList: Record> = { 'com.dotcms.contenttype.model.field.ImmutableStoryBlockField': { allowedBlocks: true // contentAssets: true @@ -78,7 +79,7 @@ export class DotContentTypeFieldsVariablesComponent implements OnChanges, OnDest private destroy$: Subject = new Subject(); ngOnChanges(changes: SimpleChanges): void { - if (changes.$field?.currentValue) { + if (changes['$field']?.currentValue) { this.field = this.$field(); this.initTableData(); } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/dot-edit-field-dialog/dot-edit-field-dialog.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/dot-edit-field-dialog/dot-edit-field-dialog.component.spec.ts index 21b0594e1867..24c30a0a9e40 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/dot-edit-field-dialog/dot-edit-field-dialog.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/dot-edit-field-dialog/dot-edit-field-dialog.component.spec.ts @@ -214,7 +214,7 @@ describe('DotEditFieldDialogComponent', () => { }); it('should disable the Save button on init', () => { - expect(comp.saveBtn.disabled).toBeTruthy(); + expect(comp.saveBtn!.disabled).toBeTruthy(); }); it('should pass the contentType to the properties form', () => { @@ -226,10 +226,10 @@ describe('DotEditFieldDialogComponent', () => { it('should enable/disable Save through setDialogOkButtonState', () => { comp.setDialogOkButtonState(true); - expect(comp.saveBtn.disabled).toBe(false); + expect(comp.saveBtn!.disabled).toBe(false); comp.setDialogOkButtonState(false); - expect(comp.saveBtn.disabled).toBe(true); + expect(comp.saveBtn!.disabled).toBe(true); }); it('should replace Save button with accept controls in changesDialogActions', () => { @@ -257,12 +257,12 @@ describe('DotEditFieldDialogComponent', () => { }); it('should call ref.close with no argument from cancelBtn.action', () => { - comp.cancelBtn.action(); + comp.cancelBtn.action!(); expect(refMock.close).toHaveBeenCalledWith(); }); it('should call saveFieldProperties from saveBtn.action', () => { - comp.saveBtn.action(); + comp.saveBtn!.action!(); expect(comp.$propertiesForm().saveFieldProperties).toHaveBeenCalled(); }); @@ -315,17 +315,17 @@ describe('DotEditFieldDialogComponent', () => { // Switching back to Overview restores the enabled state comp.handleTabChange(comp.OVERVIEW_TAB_INDEX); - expect(comp.saveBtn.disabled).toBe(false); + expect(comp.saveBtn!.disabled).toBe(false); }); it('should keep Save enabled when switching to Settings and back after a change', () => { comp.activeTab = comp.OVERVIEW_TAB_INDEX; comp.setDialogOkButtonState(true); - expect(comp.saveBtn.disabled).toBe(false); + expect(comp.saveBtn!.disabled).toBe(false); comp.handleTabChange(comp.SETTINGS_TAB_INDEX); comp.handleTabChange(comp.OVERVIEW_TAB_INDEX); - expect(comp.saveBtn.disabled).toBe(false); + expect(comp.saveBtn!.disabled).toBe(false); }); it('should keep Save disabled when switching to Settings and back with no change', () => { @@ -334,7 +334,7 @@ describe('DotEditFieldDialogComponent', () => { comp.handleTabChange(comp.SETTINGS_TAB_INDEX); comp.handleTabChange(comp.OVERVIEW_TAB_INDEX); - expect(comp.saveBtn.disabled).toBe(true); + expect(comp.saveBtn!.disabled).toBe(true); }); it('should restore the Overview save action after a Settings tab swaps it', () => { @@ -352,11 +352,11 @@ describe('DotEditFieldDialogComponent', () => { // Returning to Overview must restore the Overview action, not keep the Settings one. comp.handleTabChange(comp.OVERVIEW_TAB_INDEX); - comp.saveBtn.action(); + comp.saveBtn!.action!(); expect(settingsAction).not.toHaveBeenCalled(); expect(comp.$propertiesForm().saveFieldProperties).toHaveBeenCalled(); - expect(comp.saveBtn.disabled).toBe(false); + expect(comp.saveBtn!.disabled).toBe(false); }); it('should hide the buttons when switching to the variables tab', () => { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/dot-edit-field-dialog/dot-edit-field-dialog.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/dot-edit-field-dialog/dot-edit-field-dialog.component.ts index 73784b0eb780..5ce5dabb3268 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/dot-edit-field-dialog/dot-edit-field-dialog.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/dot-edit-field-dialog/dot-edit-field-dialog.component.ts @@ -63,7 +63,12 @@ export class DotEditFieldDialogComponent { readonly $propertiesForm = viewChild.required('fieldPropertiesForm'); - private readonly data = this.config.data; + /** + * `DynamicDialogConfig.data` is optional, but this dialog is only ever opened with a payload + * (see `ContentTypeFieldsDropZoneComponent.openFieldDialog`). Asserting it once here keeps the + * three fields below non-nullable for the template and the nested forms. + */ + private readonly data = this.config.data as DotEditFieldDialogData; readonly currentField = this.data.currentField; readonly currentFieldType = this.data.currentFieldType; readonly contentType = this.data.contentType; @@ -76,7 +81,7 @@ export class DotEditFieldDialogComponent { activeTab = 0; hideButtons = false; - saveBtn: DialogButton = this.buildOverviewSaveBtn(); + saveBtn: DialogButton | null = this.buildOverviewSaveBtn(); cancelBtn: DialogButton = { label: this.dotMessageService.get('contenttypes.dropzone.action.cancel'), action: () => this.ref.close() @@ -149,7 +154,9 @@ export class DotEditFieldDialogComponent { this.overviewFormChanged = formChanged; } - this.saveBtn = { ...this.saveBtn, disabled: !formChanged }; + if (this.saveBtn) { + this.saveBtn = { ...this.saveBtn, disabled: !formChanged }; + } } /** diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/models/field-type.model.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/models/field-type.model.ts index ee4d2c06dd12..3d9a2c08abaa 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/models/field-type.model.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/models/field-type.model.ts @@ -1,3 +1,5 @@ // Re-exported from the shared models lib so feature code can keep importing `FieldType` // via `../models` while the canonical definition lives in `@dotcms/dotcms-models`. -export { FieldType } from '@dotcms/dotcms-models'; +// `export type`: `FieldType` is a type, and under `isolatedModules` a plain re-export cannot be +// erased at transpile time without knowing that. +export type { FieldType } from '@dotcms/dotcms-models'; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/data-type-property-info.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/data-type-property-info.ts index 9205ab525111..d796d6bfe601 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/data-type-property-info.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/data-type-property-info.ts @@ -1,4 +1,11 @@ -export const DATA_TYPE_PROPERTY_INFO = { +/** One selectable data type: a message key and the value the field stores. */ +export interface DataTypeOption { + text: string; + value: string; +} + +/** Keyed by a field's `clazz`, which the server supplies, so a lookup can miss. */ +export const DATA_TYPE_PROPERTY_INFO: Record = { // Radio inputs: binary, text, date, longText, bool, float, integer 'com.dotcms.contenttype.model.field.ImmutableRadioField': [ { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-drag-drop.service.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-drag-drop.service.spec.ts index 8683ecaab596..cdb5786b2934 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-drag-drop.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-drag-drop.service.spec.ts @@ -22,8 +22,21 @@ const by = (opt: string) => (source: Observable) => { const COLUMN_BREAK_FIELD = FieldUtil.createColumnBreak(); +/** + * The DOM stubs this spec hands to dragula's callbacks. They are duck-typed rather than real + * elements, so they are described by shape — the callbacks only ever reach these members. + */ +type TargetStub = { + parentElement: { + querySelectorAll: () => number[]; + parentElement: { style: Record }; + }; +}; +type ElStub = { dataset: { clazz: string } }; +type AcceptsFunc = (...args: unknown[]) => boolean; + class MockDragulaService { - name: string; + name!: string; options: any; mock: Subject = new Subject(); @@ -48,8 +61,8 @@ class MockDragulaService { } } -let fieldDragDropService; -let dragulaService; +let fieldDragDropService: FieldDragDropService; +let dragulaService: MockDragulaService; describe('FieldDragDropService', () => { let dotAlertConfirmService: DotAlertConfirmService; @@ -81,7 +94,7 @@ describe('FieldDragDropService', () => { }); fieldDragDropService = TestBed.inject(FieldDragDropService); - dragulaService = TestBed.inject(DragulaService); + dragulaService = TestBed.inject(DragulaService) as unknown as MockDragulaService; dotAlertConfirmService = TestBed.inject(DotAlertConfirmService); }); @@ -113,7 +126,7 @@ describe('FieldDragDropService', () => { }); describe('shouldAccepts', () => { - let acceptsFunc; + let acceptsFunc: AcceptsFunc; beforeEach(() => { fieldDragDropService.setFieldBagOptions(); acceptsFunc = dragulaService.options.accepts; @@ -167,8 +180,8 @@ describe('FieldDragDropService', () => { }); describe('style row', () => { - let target; - let el; + let target: TargetStub; + let el: ElStub; beforeEach(() => { target = { @@ -206,9 +219,12 @@ describe('FieldDragDropService', () => { } }); + // `''` rather than `null`: CSSOM coerces null to the empty string for a + // style property, so this is what a browser stored all along — the previous + // assertion pinned jsdom keeping the raw `null` the service used to pass. expect(target.parentElement.parentElement.style).toEqual({ - opacity: null, - cursor: null + opacity: '', + cursor: '' }); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-drag-drop.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-drag-drop.service.ts index efa2392c5509..92cd3a95386c 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-drag-drop.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-drag-drop.service.ts @@ -63,15 +63,18 @@ export class FieldDragDropService { private _fieldDropFromTarget: Observable; private _fieldRowDropFromTarget: Observable; private draggedEvent = false; - private currentFullRowEl: HTMLElement = null; - private currentColumnOvered: Element; + private currentFullRowEl: HTMLElement | null = null; + private currentColumnOvered!: Element; constructor() { const dragulaOver$ = this.dragulaService.over(); const dragulaDropModel$ = this.dragulaService.dropModel(); const isRowFull = () => !!this.currentFullRowEl; - const wasDrop = (target) => target === null; + // NOTE: `DragulaCustomEvent.target` is optional, so a drop arrives as `undefined` and this + // has always returned false — the `clearCurrentFullRowEl` branch below it is dead. Left as + // it stands: making it fire changes when the row highlight clears, which is behaviour. + const wasDrop = (target?: Element) => target === null; merge(this.dragulaService.drop(), dragulaOver$) .pipe(filter(isRowFull)) @@ -220,11 +223,11 @@ export class FieldDragDropService { return { item: data.item, source: { - columnId: (data.source).dataset.columnid, + columnId: (data.source).dataset['columnid'] ?? '', model: data.sourceModel }, target: { - columnId: (data.target).dataset.columnid, + columnId: (data.target).dataset['columnid'] ?? '', model: data.targetModel as DotCMSContentTypeField[] } }; @@ -240,12 +243,12 @@ export class FieldDragDropService { private isDraggingExistingField(data: DragulaDropModel): boolean { return ( data.name === FieldDragDropService.FIELD_BAG_NAME && - (data.source).dataset.dragType === 'target' + (data.source).dataset['dragType'] === 'target' ); } private isDraggingFromSource(source: HTMLElement): boolean { - return source.dataset.dragType === 'source'; + return source.dataset['dragType'] === 'source'; } private isFieldBeingDragFromColumns(data: DragulaDropModel): boolean { @@ -260,22 +263,26 @@ export class FieldDragDropService { return this.currentColumnOvered && this.currentColumnOvered !== container; } - private shouldCopy(_el: HTMLElement, source: HTMLElement): boolean { - return this.isDraggingFromSource(source); + private shouldCopy(_el: Element, source: Element): boolean { + return this.isDraggingFromSource(source as HTMLElement); } private shouldMoveRow( - _el: HTMLElement, - source: HTMLElement, - handle: HTMLElement, - _sibling: HTMLElement + _el?: Element, + source?: Element, + handle?: Element, + _sibling?: Element ): boolean { + if (!source || !handle) { + return false; + } + const noDrag = !handle.classList.contains('no-drag'); const isDragButton = - handle.parentElement.classList.contains('row-header__drag') || + handle.parentElement?.classList.contains('row-header__drag') || handle.classList.contains('row-header__drag'); - return noDrag && this.shouldDrag(source, isDragButton); + return noDrag && this.shouldDrag(source as HTMLElement, !!isDragButton); } private shouldDrag(source: HTMLElement, isDragButton: boolean): boolean { @@ -283,18 +290,28 @@ export class FieldDragDropService { } private shouldAccepts( - el: HTMLElement, - target: HTMLElement, - _source: HTMLElement, - _sibling: HTMLElement + el?: Element, + target?: Element, + _source?: Element, + _sibling?: Element ): boolean { - const columnsCount = target.parentElement.querySelectorAll('.row-columns__item').length; - const isColumnField = FieldUtil.isColumnBreak(el.dataset.clazz); + if (!el || !target) { + return false; + } + + const columnsCount = + target.parentElement?.querySelectorAll('.row-columns__item').length ?? 0; + const isColumnField = FieldUtil.isColumnBreak((el as HTMLElement).dataset['clazz'] ?? ''); const cantAddColumn = isColumnField && columnsCount >= MAX_COLS_PER_ROW; if (cantAddColumn) { this.clearCurrentFullRowEl(); - this.disableRowElement(target.parentElement.parentElement); + + const rowEl = target.parentElement?.parentElement; + + if (rowEl) { + this.disableRowElement(rowEl); + } return false; } @@ -303,12 +320,12 @@ export class FieldDragDropService { } private shouldMovesField( - el: HTMLElement, - _container: Element, - _handle: Element, - _sibling: Element + el?: Element, + _container?: Element, + _handle?: Element, + _sibling?: Element ): boolean { - return el.dataset.dragType !== 'not_field'; + return (el as HTMLElement | undefined)?.dataset['dragType'] !== 'not_field'; } private itShouldSetCurrentOveredContainer( @@ -326,8 +343,8 @@ export class FieldDragDropService { private clearCurrentFullRowEl(): void { if (this.currentFullRowEl && this.currentFullRowEl.style.opacity) { - this.currentFullRowEl.style.opacity = null; - this.currentFullRowEl.style.cursor = null; + this.currentFullRowEl.style.opacity = ''; + this.currentFullRowEl.style.cursor = ''; this.currentFullRowEl = null; } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-properties.service.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-properties.service.spec.ts index f6a3be7a5458..91e7158905d9 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-properties.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-properties.service.spec.ts @@ -107,7 +107,9 @@ describe('FieldPropertyService', () => { expect(1).toEqual(fieldPropertiesService.getOrder('dataType')); expect(4).toEqual(fieldPropertiesService.getOrder('defaultValue')); - expect(fieldPropertiesService.getOrder('property')).toBeNull(); + // `0`, not `null`: the declared return type is `number`, and an unknown property sorts + // first rather than producing `NaN` from `null - n`. + expect(fieldPropertiesService.getOrder('property')).toBe(0); }); it('shoukd return the right set of validations', () => { @@ -125,11 +127,14 @@ describe('FieldPropertyService', () => { }); it('should return if the property is editable in EditMode', () => { - expect(fieldPropertiesService.isDisabledInEditMode('categories')).toBeUndefined(); + // `false`, not `undefined`/`null`: the declared return type is `boolean`, and a property + // that does not carry the flag is simply not disabled — which is how the one caller, + // `initFormGroup`, already read it. + expect(fieldPropertiesService.isDisabledInEditMode('categories')).toBe(false); expect(true).toEqual(fieldPropertiesService.isDisabledInEditMode('dataType')); - expect(fieldPropertiesService.isDisabledInEditMode('defaultValue')).toBeUndefined(); + expect(fieldPropertiesService.isDisabledInEditMode('defaultValue')).toBe(false); - expect(fieldPropertiesService.isDisabledInEditMode('property')).toBeNull(); + expect(fieldPropertiesService.isDisabledInEditMode('property')).toBe(false); }); it('should return the right proeprties for a Field Class', () => { @@ -187,8 +192,8 @@ describe('FieldPropertyService', () => { const customFieldType = service.getFieldType(DotCMSClazzes.CUSTOM_FIELD); expect(customFieldType).toBeDefined(); - expect(customFieldType.properties).toContain(NEW_RENDER_MODE_VARIABLE_KEY); - expect(customFieldType.properties).toEqual([ + expect(customFieldType!.properties).toContain(NEW_RENDER_MODE_VARIABLE_KEY); + expect(customFieldType!.properties).toEqual([ 'property1', 'property2', NEW_RENDER_MODE_VARIABLE_KEY @@ -196,8 +201,8 @@ describe('FieldPropertyService', () => { const otherFieldType = service.getFieldType('otherFieldClass'); expect(otherFieldType).toBeDefined(); - expect(otherFieldType.properties).not.toContain(NEW_RENDER_MODE_VARIABLE_KEY); - expect(otherFieldType.properties).toEqual(['property1', 'property2']); + expect(otherFieldType!.properties).not.toContain(NEW_RENDER_MODE_VARIABLE_KEY); + expect(otherFieldType!.properties).toEqual(['property1', 'property2']); })); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-properties.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-properties.service.ts index 2dd8cd3a9f35..64536a5f83f0 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-properties.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-properties.service.ts @@ -1,6 +1,6 @@ import { Injectable, Type, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { ValidationErrors } from '@angular/forms'; +import { ValidatorFn } from '@angular/forms'; import { map } from 'rxjs/operators'; @@ -15,7 +15,7 @@ import { FEATURE_FLAG_NOT_FOUND } from '@dotcms/dotcms-models'; -import { DATA_TYPE_PROPERTY_INFO } from './data-type-property-info'; +import { DataTypeOption, DATA_TYPE_PROPERTY_INFO } from './data-type-property-info'; import { PROPERTY_INFO } from './field-property-info'; import { FieldType } from '../models'; @@ -89,7 +89,7 @@ export class FieldPropertyService { * @param propertyName - The name of the property * @returns The component type for the property, or null if not found */ - getComponent(propertyName: string): Type { + getComponent(propertyName: string): Type | null { return PROPERTY_INFO[propertyName] ? PROPERTY_INFO[propertyName].component : null; } @@ -121,16 +121,16 @@ export class FieldPropertyService { ); return fieldVariable?.value || this.$newRenderModeDefault(); } - return field[propertyName]; + return field[propertyName as keyof DotCMSContentTypeField]; } /** * Gets the display order for a property * @param propertyName - The name of the property - * @returns The order number for the property, or null if not found + * @returns The order number for the property, or 0 for one with no declared order */ getOrder(propertyName: string): number { - return PROPERTY_INFO[propertyName] ? PROPERTY_INFO[propertyName].order : null; + return PROPERTY_INFO[propertyName]?.order ?? 0; } /** @@ -140,17 +140,18 @@ export class FieldPropertyService { * @returns Array of validation errors, or empty array if no validations are defined * @see https://angular.io/guide/form-validation */ - getValidations(propertyName: string): ValidationErrors[] { - return PROPERTY_INFO[propertyName] ? PROPERTY_INFO[propertyName].validations || [] : []; + getValidations(propertyName: string): ValidatorFn[] { + return PROPERTY_INFO[propertyName]?.validations ?? []; } /** * Checks if a property should be disabled in edit mode * @param propertyName - The name of the property to check - * @returns True if the property should be disabled in edit mode, null if not specified + * @returns True if the property should be disabled in edit mode; a property that does not + * declare the flag is not disabled */ isDisabledInEditMode(propertyName: string): boolean { - return PROPERTY_INFO[propertyName] ? PROPERTY_INFO[propertyName].disabledInEdit : null; + return PROPERTY_INFO[propertyName]?.disabledInEdit ?? false; } /** @@ -158,10 +159,8 @@ export class FieldPropertyService { * @param fieldTypeClass - The field type's class identifier * @returns Array of property names for the field type, or undefined if field type not found */ - getProperties(fieldTypeClass: string): string[] { - const fieldType = this.fieldTypes.get(fieldTypeClass); - - return fieldType !== undefined ? fieldType.properties : undefined; + getProperties(fieldTypeClass: string): string[] | undefined { + return this.fieldTypes.get(fieldTypeClass)?.properties; } /** @@ -169,17 +168,17 @@ export class FieldPropertyService { * @param fieldTypeClass - The field type's class identifier * @returns The FieldType object, or undefined if not found */ - getFieldType(fieldTypeClass: string): FieldType { + getFieldType(fieldTypeClass: string): FieldType | undefined { return this.fieldTypes.get(fieldTypeClass); } /** * Gets the allowed values for the dataType property of a specific field type * @param fieldTypeClass - The field type's class identifier - * @returns Array of allowed data type values for the field type + * @returns The selectable data types for the field type, empty for an unknown class */ - getDataTypeValues(fieldTypeClass: string): string[] { - return DATA_TYPE_PROPERTY_INFO[fieldTypeClass]; + getDataTypeValues(fieldTypeClass: string): DataTypeOption[] { + return DATA_TYPE_PROPERTY_INFO[fieldTypeClass] ?? []; } /** @@ -188,10 +187,12 @@ export class FieldPropertyService { * @returns The default data type value, or null if not found * @private */ - private getDataType(fieldTypeClass: string): unknown { - return DATA_TYPE_PROPERTY_INFO[fieldTypeClass] - ? DATA_TYPE_PROPERTY_INFO[fieldTypeClass][0].value - : null; + private getDataType(fieldTypeClass?: string): unknown { + if (!fieldTypeClass) { + return null; + } + + return DATA_TYPE_PROPERTY_INFO[fieldTypeClass]?.[0]?.value ?? null; } /** @@ -201,6 +202,6 @@ export class FieldPropertyService { * @private */ private getPropInfo(propertyName: string): unknown { - return PROPERTY_INFO[propertyName] ? PROPERTY_INFO[propertyName].defaultValue : null; + return PROPERTY_INFO[propertyName]?.defaultValue ?? null; } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-property-info.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-property-info.ts index 60af06b8d452..65c50aceff11 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-property-info.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/field-property-info.ts @@ -1,4 +1,7 @@ -import { Validators } from '@angular/forms'; +import { Type } from '@angular/core'; +import { ValidatorFn, Validators } from '@angular/forms'; + +import { DotDynamicFieldComponent } from '@dotcms/dotcms-models'; import { validateDateDefaultValue } from './validators'; @@ -17,7 +20,25 @@ import { DotRelationshipsPropertyComponent } from '../content-type-fields-proper import { validateRelationship } from '../content-type-fields-properties-form/field-properties/dot-relationships-property/services/validators/dot-relationship-validator'; import { noWhitespaceValidator } from '../content-type-fields-properties-form/field-properties/dot-relationships-property/services/validators/no-whitespace-validator'; -export const PROPERTY_INFO = { +/** + * One row of {@link PROPERTY_INFO}. + * + * `validations` holds Angular `ValidatorFn`s, not `ValidationErrors` — that is what the reactive + * form builder is handed at the one call site. + */ +export interface FieldPropertyInfo { + component: Type; + defaultValue: unknown; + order: number; + validations?: ValidatorFn[]; + disabledInEdit?: boolean; +} + +/** + * Keyed by the property names the field-types endpoint sends, so a lookup can miss — hence the + * index signature rather than the inferred literal keys. + */ +export const PROPERTY_INFO: Record = { categories: { component: CategoriesPropertyComponent, defaultValue: '', diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/validators/date.validator.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/validators/date.validator.ts index cccad7169ef9..f32ac6c2ac4f 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/validators/date.validator.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/service/validators/date.validator.ts @@ -1,8 +1,9 @@ -import { UntypedFormControl } from '@angular/forms'; +import { AbstractControl, ValidationErrors } from '@angular/forms'; import { _isValid } from '@dotcms/data-access'; -const format = { +/** Date formats by field clazz, which is whatever the sibling control holds. */ +const format: Record = { 'com.dotcms.contenttype.model.field.ImmutableDateField': 'yyyy-MM-dd', 'com.dotcms.contenttype.model.field.ImmutableDateTimeField': 'yyyy-MM-dd HH:mm:ss', 'com.dotcms.contenttype.model.field.ImmutableTimeField': 'HH:mm:ss' @@ -15,7 +16,7 @@ const format = { * @param FormControl formControl * @returns */ -export function validateDateDefaultValue(formControl: UntypedFormControl) { +export function validateDateDefaultValue(formControl: AbstractControl): ValidationErrors | null { const invalidResponse = { validateDate: { valid: false @@ -31,8 +32,8 @@ export function validateDateDefaultValue(formControl: UntypedFormControl) { return valid ? null : invalidResponse; } -function isValueValid(formControl: UntypedFormControl): boolean { - const clazz: string = formControl.parent.controls['clazz'].value; +function isValueValid(formControl: AbstractControl): boolean { + const clazz: string = formControl.parent?.get('clazz')?.value; return format[clazz] ? _isValid(formControl.value, format[clazz]) || formControl.value === 'now' diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form-dialog-focus.integration.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form-dialog-focus.integration.spec.ts index 0f30af1369e5..7b2f4b21869c 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form-dialog-focus.integration.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form-dialog-focus.integration.spec.ts @@ -69,7 +69,7 @@ const NEW_EDIT_CONTENT_CHECKBOX_SELECTOR = '#newEditContentLabel'; */ describe('ContentTypesFormComponent inside p-dialog - Integration Tests', () => { let fixture: ComponentFixture; - let originalOffsetParent: PropertyDescriptor; + let originalOffsetParent: PropertyDescriptor | undefined; const queryElement = (selector: string): HTMLElement => fixture.debugElement.query(By.css(selector))?.nativeElement ?? null; @@ -91,8 +91,8 @@ describe('ContentTypesFormComponent inside p-dialog - Integration Tests', () => const openDialogAndSettleFocus = ({ focusOnShow, newContentEditorEnabled, - baseType = 'CONTENT', - id = null + baseType = DotCMSBaseTypesContentTypes.CONTENT, + id = undefined }: { focusOnShow: boolean; newContentEditorEnabled: boolean; @@ -129,7 +129,8 @@ describe('ContentTypesFormComponent inside p-dialog - Integration Tests', () => fixture.componentInstance.contentType = { ...dotcmsContentTypeBasicMock, baseType, - id + // Empty when the dialog opens for a content type that has not been saved. + id: id ?? '' }; fixture.detectChanges(); // The form focuses the Name input from afterNextRender, and those hooks run on the @@ -159,7 +160,7 @@ describe('ContentTypesFormComponent inside p-dialog - Integration Tests', () => }); afterAll(() => { - Object.defineProperty(HTMLElement.prototype, 'offsetParent', originalOffsetParent); + Object.defineProperty(HTMLElement.prototype, 'offsetParent', originalOffsetParent!); }); beforeEach(() => { @@ -174,7 +175,10 @@ describe('ContentTypesFormComponent inside p-dialog - Integration Tests', () => describe('create mode', () => { // The binding does not branch on baseType, but every base type reaches this same dialog // through create/:type — so the focus outcome is asserted for real, not just inferred. - it.each(['CONTENT', 'WIDGET'])( + it.each([ + DotCMSBaseTypesContentTypes.CONTENT, + DotCMSBaseTypesContentTypes.WIDGET + ])( 'should focus the name input instead of the new content banner checkbox for %s', (baseType) => { openDialogAndSettleFocus({ @@ -203,14 +207,14 @@ describe('ContentTypesFormComponent inside p-dialog - Integration Tests', () => openDialogAndSettleFocus({ focusOnShow: false, newContentEditorEnabled: true }); const form = formComponent().form; - const newEditContentBefore = form.get('newEditContent').value; + const newEditContentBefore = form.get('newEditContent')!.value; const nameInput = document.activeElement as HTMLInputElement; nameInput.value = 'My Content Type'; nameInput.dispatchEvent(new Event('input')); - expect(form.get('name').value).toBe('My Content Type'); - expect(form.get('newEditContent').value).toBe(newEditContentBefore); + expect(form.get('name')!.value).toBe('My Content Type'); + expect(form.get('newEditContent')!.value).toBe(newEditContentBefore); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.html b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.html index 4b563da011bf..89c0cfaa608b 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.html @@ -74,7 +74,7 @@ [workflows]="workflowsSelected$ | async" formControlName="NEW" />
- @if (form.get('workflows').disabled) { + @if (form.get('workflows')?.disabled) { {{ 'contenttypes.form.hint.error.only.default.scheme.available.in.Community' | dm }} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.spec.ts index 0c5ceeb18ae7..0c89ab6290e4 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.spec.ts @@ -4,7 +4,6 @@ import { of } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ApplicationRef } from '@angular/core'; -import { AbstractControl } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { @@ -17,6 +16,7 @@ import { } from '@dotcms/data-access'; import { DotCMSClazzes, + DotCMSContentType, DotCMSContentTypeLayoutRow, DotCMSSystemActionType, FeaturedFlags @@ -161,7 +161,7 @@ describe('ContentTypesFormComponent', () => { }); spectator.detectChanges(); - spectator.component.form.get('name').setValue('content type name'); + spectator.component.form.get('name')!.setValue('content type name'); expect(spectator.component.form.valid).toBe(true); }); @@ -209,7 +209,7 @@ describe('ContentTypesFormComponent', () => { spectator.detectChanges(); // Form is only valid when "name" property is set - spectator.component.form.get('description').setValue('hello world'); + spectator.component.form.get('description')!.setValue('hello world'); spectator.detectChanges(); expect(spectator.component.canSave).toBe(true); @@ -224,7 +224,7 @@ describe('ContentTypesFormComponent', () => { }); spectator.detectChanges(); - spectator.component.form.get('name').setValue(null); + spectator.component.form.get('name')!.setValue(null); spectator.detectChanges(); expect(spectator.component.canSave).toBe(false); @@ -239,7 +239,7 @@ describe('ContentTypesFormComponent', () => { }); spectator.detectChanges(); - spectator.component.form.get('description').setValue('some desc'); + spectator.component.form.get('description')!.setValue('some desc'); spectator.detectChanges(); expect(spectator.component.canSave).toBe(true); @@ -254,8 +254,8 @@ describe('ContentTypesFormComponent', () => { }); spectator.detectChanges(); - spectator.component.form.get('name').setValue(null); - spectator.component.form.get('description').setValue('some desc'); + spectator.component.form.get('name')!.setValue(null); + spectator.component.form.get('description')!.setValue('some desc'); spectator.detectChanges(); expect(spectator.component.canSave).toBe(false); @@ -276,11 +276,11 @@ describe('ContentTypesFormComponent', () => { // The form is valid in edit mode with a name, so canSave starts as false (no changes) expect(spectator.component.canSave).toBe(false); // by default is false - spectator.component.form.get('name').setValue('A new name'); + spectator.component.form.get('name')!.setValue('A new name'); spectator.detectChanges(); expect(spectator.component.canSave).toBe(true); // name updated set it to true - spectator.component.form.get('name').setValue('Hello World'); + spectator.component.form.get('name')!.setValue('Hello World'); spectator.detectChanges(); expect(spectator.component.canSave).toBe(false); // revert the change button disabled set it to false }); @@ -299,11 +299,11 @@ describe('ContentTypesFormComponent', () => { // The form is valid in edit mode with a name, so canSave starts as false (no changes) expect(spectator.component.canSave).toBe(false); // by default is false - spectator.component.form.get('name').setValue('A new name'); + spectator.component.form.get('name')!.setValue('A new name'); spectator.detectChanges(); expect(spectator.component.canSave).toBe(true); // name updated set it to true - spectator.component.form.get('name').setValue('Hello World'); + spectator.component.form.get('name')!.setValue('Hello World'); spectator.detectChanges(); expect(spectator.component.canSave).toBe(false); // revert the change button disabled set it to false }); @@ -354,7 +354,7 @@ describe('ContentTypesFormComponent', () => { expect(spectator.component.form.get('fixed')).not.toBeNull(); expect(spectator.component.form.get('system')).not.toBeNull(); expect(spectator.component.form.get('folder')).not.toBeNull(); - const workflowAction = spectator.component.form.get('systemActionMappings'); + const workflowAction = spectator.component.form.get('systemActionMappings')!; expect(workflowAction.get(DotCMSSystemActionType.NEW)).not.toBeNull(); expect(spectator.component.form.get('detailPage')).toBeNull(); @@ -408,7 +408,7 @@ describe('ContentTypesFormComponent', () => { expect(spectator.component.form.get('folder')).not.toBeNull(); expect(spectator.component.form.get('newEditContent')).not.toBeNull(); - const workflowAction = spectator.component.form.get('systemActionMappings'); + const workflowAction = spectator.component.form.get('systemActionMappings')!; expect(workflowAction.get(DotCMSSystemActionType.NEW)).not.toBeNull(); }); @@ -495,7 +495,7 @@ describe('ContentTypesFormComponent', () => { spectator.detectChanges(); - expect(spectator.component.form.get('systemActionMappings').value).toEqual({ + expect(spectator.component.form.get('systemActionMappings')!.value).toEqual({ NEW: '44d4d4cd-c812-49db-adb1-1030be73e69a' }); }); @@ -509,7 +509,7 @@ describe('ContentTypesFormComponent', () => { spectator.detectChanges(); - expect(spectator.component.form.get('systemActionMappings').value).toEqual({ + expect(spectator.component.form.get('systemActionMappings')!.value).toEqual({ NEW: '' }); }); @@ -549,8 +549,8 @@ describe('ContentTypesFormComponent', () => { const dateFieldMsg = spectator.query('#field-dates-hint'); expect(dateFieldMsg).toBeTruthy(); - expect(spectator.component.form.get('publishDateVar').disabled).toBe(true); - expect(spectator.component.form.get('expireDateVar').disabled).toBe(true); + expect(spectator.component.form.get('publishDateVar')!.disabled).toBe(true); + expect(spectator.component.form.get('expireDateVar')!.disabled).toBe(true); }); it('should render the new content banner when the feature flag is enabled', () => { @@ -560,7 +560,7 @@ describe('ContentTypesFormComponent', () => { id: '123' }); - activatedRoute.snapshot.data.featuredFlags[ + activatedRoute.snapshot.data['featuredFlags'][ FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED ] = true; @@ -574,7 +574,7 @@ describe('ContentTypesFormComponent', () => { it('should hide the new content banner when the feature flag is disabled', () => { // Need to update the flag before component initialization - activatedRoute.snapshot.data.featuredFlags[ + activatedRoute.snapshot.data['featuredFlags'][ FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED ] = false; @@ -593,7 +593,7 @@ describe('ContentTypesFormComponent', () => { expect(newContentBanner).toBeNull(); // Reset flag for other tests - activatedRoute.snapshot.data.featuredFlags[ + activatedRoute.snapshot.data['featuredFlags'][ FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED ] = true; }); @@ -614,12 +614,12 @@ describe('ContentTypesFormComponent', () => { }); it('should render enabled dates fields when date fields are passed', () => { - expect(spectator.component.form.get('publishDateVar').disabled).toBe(false); - expect(spectator.component.form.get('expireDateVar').disabled).toBe(false); + expect(spectator.component.form.get('publishDateVar')!.disabled).toBe(false); + expect(spectator.component.form.get('expireDateVar')!.disabled).toBe(false); }); it('should patch publishDateVar', () => { - const field: AbstractControl = spectator.component.form.get('publishDateVar'); + const field = spectator.component.form.get('publishDateVar')!; field.setValue('123'); spectator.component.handleDateVarChange({ value: '123' }, 'expireDateVar'); @@ -628,7 +628,7 @@ describe('ContentTypesFormComponent', () => { }); it('should patch expireDateVar', () => { - const field: AbstractControl = spectator.component.form.get('expireDateVar'); + const field = spectator.component.form.get('expireDateVar')!; field.setValue('123'); @@ -683,7 +683,7 @@ describe('ContentTypesFormComponent', () => { }); describe('send data with valid form', () => { - let data; + let data: DotCMSContentType | null; beforeEach(() => { jest.spyOn(dotLicenseService, 'isEnterprise').mockReturnValue(of(true)); @@ -695,14 +695,14 @@ describe('ContentTypesFormComponent', () => { data = null; jest.spyOn(spectator.component, 'submitForm'); spectator.component.$send.subscribe((res) => (data = res)); - spectator.component.form.controls.name.setValue('A content type name'); + spectator.component.form.controls['name'].setValue('A content type name'); // Set host to match SiteServiceMock currentSite identifier - spectator.component.form.controls.host.setValue('123-xyz-567-xxl'); + spectator.component.form.controls['host'].setValue('123-xyz-567-xxl'); spectator.detectChanges(); }); it('should submit form correctly', () => { - const metadata = {}; + const metadata: Record = {}; metadata[FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED] = false; spectator.component.submitForm(); @@ -756,11 +756,11 @@ describe('ContentTypesFormComponent', () => { it('should show workflow disabled and with message if the license community its true', () => { const workflowMsg = spectator.query('#field-workflow-hint'); expect(workflowMsg).toBeDefined(); - expect(spectator.component.form.get('workflows').disabled).toBe(true); + expect(spectator.component.form.get('workflows')!.disabled).toBe(true); expect( spectator.component.form - .get('systemActionMappings') - .get(DotCMSSystemActionType.NEW).disabled + .get('systemActionMappings')! + .get(DotCMSSystemActionType.NEW)!.disabled ).toBe(true); }); }); @@ -780,13 +780,13 @@ describe('ContentTypesFormComponent', () => { const workflowMsg = enterpriseSpectator.query('#field-workflow-hint'); expect(workflowMsg).toBeDefined(); - expect(enterpriseSpectator.component.form.get('workflows').disabled).toBe( + expect(enterpriseSpectator.component.form.get('workflows')!.disabled).toBe( false ); expect( enterpriseSpectator.component.form - .get('systemActionMappings') - .get(DotCMSSystemActionType.NEW).disabled + .get('systemActionMappings')! + .get(DotCMSSystemActionType.NEW)!.disabled ).toBe(false); }); }); @@ -813,7 +813,7 @@ describe('ContentTypesFormComponent', () => { }); jest.spyOn(dotLicenseService, 'isEnterprise').mockReturnValue(of(false)); spectator.detectChanges(); - expect(spectator.component.form.get('workflows').value).toEqual([ + expect(spectator.component.form.get('workflows')!.value).toEqual([ { ...mockWorkflows[0], id: '123', @@ -835,7 +835,7 @@ describe('ContentTypesFormComponent', () => { }); jest.spyOn(dotLicenseService, 'isEnterprise').mockReturnValue(of(false)); spectator.detectChanges(); - expect(spectator.component.form.get('workflows').value).toEqual([]); + expect(spectator.component.form.get('workflows')!.value).toEqual([]); }); it('should initialize workflowsSelected$ with the value from workflows field', async () => { spectator.setInput('contentType', { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.ts index 1aeaf6b50219..12aca9042062 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.ts @@ -106,12 +106,12 @@ export class ContentTypesFormComponent implements OnInit, OnDestroy { canSave = false; dateVarOptions: SelectItem[] = []; - form: UntypedFormGroup; - nameFieldLabel: string; - workflowsSelected$: Observable; - newContentEditorEnabled: boolean; + form!: UntypedFormGroup; + nameFieldLabel!: string; + workflowsSelected$!: Observable; + newContentEditorEnabled = false; - private originalValue: DotCMSContentType; + private originalValue!: DotCMSContentType; private destroy$: Subject = new Subject(); ngOnInit(): void { @@ -135,7 +135,7 @@ export class ContentTypesFormComponent implements OnInit, OnDestroy { } this.newContentEditorEnabled = - this.route.snapshot?.data?.featuredFlags[ + this.route.snapshot?.data?.['featuredFlags'][ FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED ]; } @@ -152,7 +152,7 @@ export class ContentTypesFormComponent implements OnInit, OnDestroy { * @param any field * @memberof ContentTypesFormComponent */ - handleDateVarChange($event, field): void { + handleDateVarChange($event: { value: string }, field: string): void { if (field === 'publishDateVar') { this.updateExpireDateVar($event.value); } else { @@ -258,9 +258,9 @@ export class ContentTypesFormComponent implements OnInit, OnDestroy { this.setOriginalValue(); this.setDateVarFieldsState(); this.setSystemWorkflow(); - this.workflowsSelected$ = this.form - .get('workflows') - .valueChanges.pipe(startWith(this.form.get('workflows').value)); + this.workflowsSelected$ = this.form.controls['workflows'].valueChanges.pipe( + startWith(this.form.controls['workflows'].value) + ); } private getActionIdentifier(actionMap: DotCMSSystemActionMappings): string { @@ -277,7 +277,7 @@ export class ContentTypesFormComponent implements OnInit, OnDestroy { return item && typeof item !== 'string' ? item.workflowAction.id : ''; } - private getProp(item: string): string { + private getProp(item?: string | null): string { return item || ''; } @@ -287,7 +287,7 @@ export class ContentTypesFormComponent implements OnInit, OnDestroy { .getSystem() .pipe(take(1)) .subscribe((workflow: DotCMSWorkflow) => { - this.form.get('workflows').setValue([workflow]); + this.form.controls['workflows'].setValue([workflow]); }); } } @@ -348,8 +348,8 @@ export class ContentTypesFormComponent implements OnInit, OnDestroy { if (this.isLayoutSet()) { this.dateVarOptions = this.getDateVarOptions(); - const publishDateVar = this.form.get('publishDateVar'); - const expireDateVar = this.form.get('expireDateVar'); + const publishDateVar = this.form.controls['publishDateVar']; + const expireDateVar = this.form.controls['expireDateVar']; if (this.dateVarOptions.length) { publishDateVar.enable(); @@ -371,10 +371,9 @@ export class ContentTypesFormComponent implements OnInit, OnDestroy { } private enableWorkflowFormControls(): void { - const workflowControl = this.form.get('workflows'); - const workflowActionControl = this.form - .get('systemActionMappings') - .get(DotCMSSystemActionType.NEW); + const workflowControl = this.form.controls['workflows']; + const systemActionMappings = this.form.controls['systemActionMappings'] as UntypedFormGroup; + const workflowActionControl = systemActionMappings.controls[DotCMSSystemActionType.NEW]; workflowControl.enable(); workflowActionControl.enable(); @@ -390,7 +389,7 @@ export class ContentTypesFormComponent implements OnInit, OnDestroy { } private updateExpireDateVar(value: string): void { - const expireDateVar = this.form.get('expireDateVar'); + const expireDateVar = this.form.controls['expireDateVar']; if (expireDateVar.value === value) { expireDateVar.patchValue(''); @@ -398,20 +397,20 @@ export class ContentTypesFormComponent implements OnInit, OnDestroy { } private updatePublishDateVar(value: string): void { - const publishDateVar = this.form.get('publishDateVar'); + const publishDateVar = this.form.controls['publishDateVar']; if (publishDateVar.value === value) { publishDateVar.patchValue(''); } } - private getMetaDataProperty(_prop: string): string | number | boolean { + private getMetaDataProperty(_prop: string): string | number | boolean | undefined { return this.$contentType().metadata?.[_prop]; } private addMetadataToForm(): DotCMSContentType { const metadata = this.$contentType().metadata || {}; - const newEditContent = this.form.get('newEditContent').value; + const newEditContent = this.form.controls['newEditContent'].value; const form = this.form.value; delete form.newEditContent; metadata[FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED] = newEditContent; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.spec.ts index 525a7ffd2e19..1b52d073d2a1 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.spec.ts @@ -76,7 +76,7 @@ import { DotStyleEditorBuilderComponent } from '../style-editor/dot-style-editor standalone: false }) class TestContentTypeFieldsListComponent { - @Input() baseType: string; + @Input() baseType!: string; } @Component({ @@ -91,7 +91,7 @@ class TestContentTypeFieldsRowListComponent {} template: '' }) class TestDotIframeComponent { - @Input() src: string; + @Input() src!: string; } @Component({ @@ -100,7 +100,7 @@ class TestDotIframeComponent { standalone: false }) class TestHostComponent { - @Input() contentType: DotCMSContentType; + @Input() contentType!: DotCMSContentType; @Output() openEditDialog: EventEmitter = new EventEmitter(); } @@ -110,7 +110,7 @@ class TestHostComponent { standalone: true }) class MockDotStyleEditorBuilderComponent { - @Input() contentType: DotCMSContentType; + @Input() contentType!: DotCMSContentType; } class FieldDragDropServiceMock { @@ -360,7 +360,7 @@ describe('ContentTypesLayoutComponent', () => { }); describe('Fields', () => { - let pTabPanel; + let pTabPanel: DebugElement; beforeEach(() => { const panels = de.queryAll(By.css('p-tabpanel')); pTabPanel = panels[0]; @@ -427,14 +427,14 @@ describe('ContentTypesLayoutComponent', () => { it('should set actions correctly', () => { const addRow: MenuItem = splitButton.componentInstance.model[0]; const addTabDivider: MenuItem = splitButton.componentInstance.model[1]; - addRow.command({ originalEvent: createFakeEvent('click') }); + addRow.command!({ originalEvent: createFakeEvent('click') }); expect(dotEventsService.notify).toHaveBeenCalledWith('add-row'); expect(dotEventsService.notify).toHaveBeenCalledTimes(1); // Clear the mock before the second call (dotEventsService.notify as jest.Mock).mockClear(); - addTabDivider.command({ originalEvent: createFakeEvent('click') }); + addTabDivider.command!({ originalEvent: createFakeEvent('click') }); expect(dotEventsService.notify).toHaveBeenCalledWith('add-tab-divider'); expect(dotEventsService.notify).toHaveBeenCalledTimes(1); }); @@ -442,7 +442,7 @@ describe('ContentTypesLayoutComponent', () => { }); describe('Permission', () => { - let pTabPanel; + let pTabPanel: DebugElement; beforeEach(() => { const panels = de.queryAll(By.css('p-tabpanel')); // panels[0]=Fields, [1]=StyleEditor, [2]=Permissions @@ -469,7 +469,7 @@ describe('ContentTypesLayoutComponent', () => { }); describe('Push History', () => { - let pTabPanel; + let pTabPanel: DebugElement; beforeEach(() => { const panels = de.queryAll(By.css('p-tabpanel')); // panels[0]=Fields, [1]=StyleEditor, [2]=Permissions, [3]=PushHistory diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.ts index 09345a53218e..01abed80ec5c 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.ts @@ -68,9 +68,9 @@ export class ContentTypesLayoutComponent implements OnInit { $contentTypeNameInput = viewChild.required('contentTypeNameInput'); $dotEditInline = viewChild.required('dotEditInline'); - permissionURL: string; - pushHistoryURL: string; - contentTypeNameInputSize: number; + permissionURL!: string; + pushHistoryURL!: string; + contentTypeNameInputSize!: number; readonly $showStyleEditorTab = signal( this.#route.snapshot.data['featuredFlags']?.[FeaturedFlags.FEATURE_FLAG_UVE_STYLE_EDITOR] ?? false @@ -81,7 +81,7 @@ export class ContentTypesLayoutComponent implements OnInit { readonly $activeTab = signal(this.#route.firstChild?.snapshot.url[0]?.path ?? 'fields'); readonly $addToMenuContentType = signal(false); - actions: MenuItem[]; + actions: MenuItem[] = []; /** Context menu items derived from the current content type. */ readonly $menuItems = computed(() => { @@ -166,7 +166,7 @@ export class ContentTypesLayoutComponent implements OnInit { * @memberof ContentTypesLayoutComponent */ editInlineActivate(event: MouseEvent): void { - this.contentTypeNameInputSize = event.target['offsetWidth'] + 20; + this.contentTypeNameInputSize = (event.target as HTMLElement).offsetWidth + 20; } /** @@ -181,7 +181,7 @@ export class ContentTypesLayoutComponent implements OnInit { } else if (event.key === 'Escape') { this.$dotEditInline().hideContent(); } else { - const newInputSize = event.target['value'].length * 8 + 22; + const newInputSize = (event.target as HTMLInputElement).value.length * 8 + 22; this.contentTypeNameInputSize = newInputSize > 485 ? 485 : newInputSize; } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/components/dot-style-editor-field/dot-style-editor-field-form.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/components/dot-style-editor-field/dot-style-editor-field-form.component.spec.ts index d684a698ea3c..c6f875e66230 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/components/dot-style-editor-field/dot-style-editor-field-form.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/components/dot-style-editor-field/dot-style-editor-field-form.component.spec.ts @@ -4,6 +4,7 @@ import { DotMessageService } from '@dotcms/data-access'; import { DotStyleEditorFieldFormComponent } from './dot-style-editor-field-form.component'; +import { aliasedProps } from '../../../../../../../test/spectator-aliased-props'; import { BuilderField } from '../../models'; const MOCK_MESSAGES: Record = { @@ -78,13 +79,13 @@ describe('DotStyleEditorFieldFormComponent', () => { isDuplicateIdentifier = false ): void { spectator = createComponent({ - props: { + props: aliasedProps({ field, isFirst: false, isLast: false, showErrors, isDuplicateIdentifier - } as unknown + }) }); } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/components/dot-style-editor-section/dot-style-editor-section.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/components/dot-style-editor-section/dot-style-editor-section.component.spec.ts index 818632164a0f..d08d7b39a2ec 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/components/dot-style-editor-section/dot-style-editor-section.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/components/dot-style-editor-section/dot-style-editor-section.component.spec.ts @@ -4,6 +4,7 @@ import { DotMessageService } from '@dotcms/data-access'; import { DotStyleEditorSectionComponent } from './dot-style-editor-section.component'; +import { aliasedProps } from '../../../../../../../test/spectator-aliased-props'; import { BuilderField, BuilderSection } from '../../models'; const MOCK_MESSAGES: Record = { @@ -79,7 +80,12 @@ describe('DotStyleEditorSectionComponent', () => { function setup(section: BuilderSection = MOCK_SECTION, isFirst = false, isLast = false): void { spectator = createComponent({ - props: { section, isFirst, isLast, showErrors: false } as unknown + props: aliasedProps({ + section, + isFirst, + isLast, + showErrors: false + }) }); } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/dot-style-editor-builder.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/dot-style-editor-builder.component.ts index d603d8fab22b..acd572457153 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/dot-style-editor-builder.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/style-editor/dot-style-editor-builder.component.ts @@ -332,7 +332,7 @@ export class DotStyleEditorBuilderComponent { return styleEditorField.dropdown({ id: field.identifier, label: field.label, - options: validOptions.map((o) => ({ label: o.label, value: o.value })) + options: validOptions.map((o) => ({ label: o.label, value: o.value ?? '' })) }); case 'radio': @@ -342,7 +342,7 @@ export class DotStyleEditorBuilderComponent { columns: field.columns, options: validOptions.map((o) => ({ label: o.label, - value: o.value, + value: o.value ?? '', ...(o.imageURL ? { imageURL: o.imageURL } : {}) })) }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.guard.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.guard.spec.ts index 2bb3cae6f2ed..28dc500f4e9a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.guard.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.guard.spec.ts @@ -1,4 +1,4 @@ -import { of } from 'rxjs'; +import { isObservable, Observable, of } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { HttpClientTestingModule } from '@angular/common/http/testing'; @@ -15,6 +15,22 @@ const STYLE_EDITOR_URL = `${BASE_URL}/style-editor`; const PERMISSIONS_URL = `${BASE_URL}/permissions`; const FIELDS_URL = `${BASE_URL}/fields`; +/** + * `CanActivateFn` declares `MaybeAsync` — the union of a plain value, a promise and an + * observable — so the annotation on these guards hides the fact that both always return an + * observable, and `.subscribe()` on the result does not type-check. + * + * `isObservable` is rxjs's own type guard, so this narrows without a cast and fails loudly if either + * guard is ever changed to return a plain value. + */ +function asObservable(result: T | Observable | Promise): Observable { + if (!isObservable(result)) { + throw new Error('Expected the guard to return an Observable'); + } + + return result; +} + const mockRoute = {} as ActivatedRouteSnapshot; function mockState(url: string): RouterStateSnapshot { @@ -50,8 +66,10 @@ describe('styleEditorTabGuard', () => { it('should allow access when feature flag is enabled', (done) => { setup(true); - TestBed.runInInjectionContext(() => - styleEditorTabGuard(mockRoute, mockState(STYLE_EDITOR_URL)) + asObservable( + TestBed.runInInjectionContext(() => + styleEditorTabGuard(mockRoute, mockState(STYLE_EDITOR_URL)) + ) ).subscribe((result) => { expect(result).toBe(true); expect(dotPropertiesService.getFeatureFlag).toHaveBeenCalledWith( @@ -64,8 +82,10 @@ describe('styleEditorTabGuard', () => { it('should redirect to fields when feature flag is disabled', (done) => { setup(false); - TestBed.runInInjectionContext(() => - styleEditorTabGuard(mockRoute, mockState(STYLE_EDITOR_URL)) + asObservable( + TestBed.runInInjectionContext(() => + styleEditorTabGuard(mockRoute, mockState(STYLE_EDITOR_URL)) + ) ).subscribe((result) => { expect(router.parseUrl).toHaveBeenCalledWith(FIELDS_URL); expect(result).not.toBe(false); @@ -77,8 +97,10 @@ describe('styleEditorTabGuard', () => { setup(false); const urlWithQuery = `${STYLE_EDITOR_URL}?foo=bar`; - TestBed.runInInjectionContext(() => - styleEditorTabGuard(mockRoute, mockState(urlWithQuery)) + asObservable( + TestBed.runInInjectionContext(() => + styleEditorTabGuard(mockRoute, mockState(urlWithQuery)) + ) ).subscribe(() => { expect(router.parseUrl).toHaveBeenCalledWith(`${FIELDS_URL}?foo=bar`); done(); @@ -117,8 +139,10 @@ describe('permissionsTabGuard', () => { it('should allow access when user has permissions portlet access', (done) => { setup(true); - TestBed.runInInjectionContext(() => - permissionsTabGuard(mockRoute, mockState(PERMISSIONS_URL)) + asObservable( + TestBed.runInInjectionContext(() => + permissionsTabGuard(mockRoute, mockState(PERMISSIONS_URL)) + ) ).subscribe((result) => { expect(result).toBe(true); expect(dotCurrentUserService.hasAccessToPortlet).toHaveBeenCalledWith('permissions'); @@ -129,8 +153,10 @@ describe('permissionsTabGuard', () => { it('should redirect to fields when user lacks permissions portlet access', (done) => { setup(false); - TestBed.runInInjectionContext(() => - permissionsTabGuard(mockRoute, mockState(PERMISSIONS_URL)) + asObservable( + TestBed.runInInjectionContext(() => + permissionsTabGuard(mockRoute, mockState(PERMISSIONS_URL)) + ) ).subscribe((result) => { expect(router.parseUrl).toHaveBeenCalledWith(FIELDS_URL); expect(result).not.toBe(false); @@ -142,8 +168,10 @@ describe('permissionsTabGuard', () => { setup(false); const urlWithQuery = `${PERMISSIONS_URL}?foo=bar`; - TestBed.runInInjectionContext(() => - permissionsTabGuard(mockRoute, mockState(urlWithQuery)) + asObservable( + TestBed.runInInjectionContext(() => + permissionsTabGuard(mockRoute, mockState(urlWithQuery)) + ) ).subscribe(() => { expect(router.parseUrl).toHaveBeenCalledWith(`${FIELDS_URL}?foo=bar`); done(); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.resolver.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.resolver.spec.ts index 310f7a023ebf..19f64e7efadb 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.resolver.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-type-tabs.resolver.spec.ts @@ -1,4 +1,4 @@ -import { of } from 'rxjs'; +import { isObservable, Observable, of } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { HttpClientTestingModule } from '@angular/common/http/testing'; @@ -7,14 +7,24 @@ import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; import { DotCurrentUserService } from '@dotcms/data-access'; -import { - DotContentTypeTabsResolvedData, - dotContentTypeTabsResolver -} from './dot-content-type-tabs.resolver'; +import { dotContentTypeTabsResolver } from './dot-content-type-tabs.resolver'; const mockRoute = {} as ActivatedRouteSnapshot; const mockState = {} as RouterStateSnapshot; +/** + * `ResolveFn` declares `MaybeAsync`, so the annotation on the resolver hides the fact that it + * always returns an observable and `.subscribe()` does not type-check. `isObservable` is rxjs's own + * type guard, so this narrows without a cast. + */ +function asObservable(result: T | Observable | Promise): Observable { + if (!isObservable(result)) { + throw new Error('Expected the resolver to return an Observable'); + } + + return result; +} + describe('dotContentTypeTabsResolver', () => { let dotCurrentUserService: DotCurrentUserService; @@ -36,9 +46,9 @@ describe('dotContentTypeTabsResolver', () => { it('should resolve showPermissionsTab as true when user has access', (done) => { setup(true); - TestBed.runInInjectionContext(() => - dotContentTypeTabsResolver(mockRoute, mockState) - ).subscribe((result: DotContentTypeTabsResolvedData) => { + asObservable( + TestBed.runInInjectionContext(() => dotContentTypeTabsResolver(mockRoute, mockState)) + ).subscribe((result) => { expect(dotCurrentUserService.hasAccessToPortlet).toHaveBeenCalledWith('permissions'); expect(result).toEqual({ showPermissionsTab: true }); done(); @@ -48,9 +58,9 @@ describe('dotContentTypeTabsResolver', () => { it('should resolve showPermissionsTab as false when user lacks access', (done) => { setup(false); - TestBed.runInInjectionContext(() => - dotContentTypeTabsResolver(mockRoute, mockState) - ).subscribe((result: DotContentTypeTabsResolvedData) => { + asObservable( + TestBed.runInInjectionContext(() => dotContentTypeTabsResolver(mockRoute, mockState)) + ).subscribe((result) => { expect(dotCurrentUserService.hasAccessToPortlet).toHaveBeenCalledWith('permissions'); expect(result).toEqual({ showPermissionsTab: false }); done(); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit-resolver.service.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit-resolver.service.spec.ts index f07edcd6ca91..8370fd388780 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit-resolver.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit-resolver.service.spec.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - import { createServiceFactory, SpectatorService } from '@openng/spectator/jest'; import { of as observableOf, throwError as observableThrowError } from 'rxjs'; @@ -13,7 +11,6 @@ import { DotRouterService } from '@dotcms/data-access'; import { LoginService } from '@dotcms/dotcms-js'; -import { DotCMSContentType } from '@dotcms/dotcms-models'; import { GlobalStore } from '@dotcms/store'; import { DotMessageDisplayServiceMock, LoginServiceMock } from '@dotcms/utils-testing'; @@ -76,7 +73,7 @@ describe('DotContentTypeEditResolver', () => { const contentType = { fake: 'content-type', object: 'right?' }; getDataByIdSpy.mockReturnValue(observableOf(contentType)); - spectator.service.resolve(route).subscribe((result: any) => { + spectator.service.resolve(route).subscribe((result) => { expect(result).toEqual(contentType); expect(getDataByIdSpy).toHaveBeenCalledWith('v1/contenttype', '123'); expect(getDataByIdSpy).toHaveBeenCalledTimes(1); @@ -140,7 +137,7 @@ describe('DotContentTypeEditResolver', () => { const route = createRouteSnapshot((key) => (key === 'type' ? 'content' : null)); getDataByIdSpy.mockReturnValue(observableOf(false)); - spectator.service.resolve(route).subscribe((res: DotCMSContentType) => { + spectator.service.resolve(route).subscribe((res) => { expect(res).toEqual({ baseType: 'content', clazz: 'com.dotcms.contenttype.model.type.ImmutableSimpleContentType', diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit-resolver.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit-resolver.service.ts index 3bbd7fb59aa7..beaf43ebeb5a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit-resolver.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit-resolver.service.ts @@ -24,29 +24,33 @@ import { DotCMSContentType } from '@dotcms/dotcms-models'; * @implements {Resolve} */ @Injectable() -export class DotContentTypeEditResolver implements Resolve { +export class DotContentTypeEditResolver implements Resolve { private contentTypesInfoService = inject(DotContentTypesInfoService); private crudService = inject(DotCrudService); private dotHttpErrorManagerService = inject(DotHttpErrorManagerService); private dotRouterService = inject(DotRouterService); private loginService = inject(LoginService); - resolve(route: ActivatedRouteSnapshot): Observable { - if (route.paramMap.get('id')) { - return this.getContentType(route.paramMap.get('id')); - } else { - const contentType = this.getFilterByParam(route) || route.paramMap.get('type'); + resolve(route: ActivatedRouteSnapshot): Observable { + // Read once: `paramMap.get` returns `string | null` and calling it twice does not carry the + // first check's narrowing to the second. + const id = route.paramMap.get('id'); - return this.getDefaultContentType(contentType); + if (id) { + return this.getContentType(id); } + + return this.getDefaultContentType( + this.getFilterByParam(route) || route.paramMap.get('type') || '' + ); } - private getFilterByParam(route: ActivatedRouteSnapshot): string { - return route.data && route.data.filterBy; + private getFilterByParam(route: ActivatedRouteSnapshot): string | undefined { + return route.data && route.data['filterBy']; } - private getContentType(id: string): Observable { - return this.crudService.getDataById('v1/contenttype', id).pipe( + private getContentType(id: string): Observable { + return this.crudService.getDataById('v1/contenttype', id).pipe( take(1), catchError((err: HttpErrorResponse) => { return this.dotHttpErrorManagerService.handle(err).pipe( @@ -65,6 +69,9 @@ export class DotContentTypeEditResolver implements Resolve { } private getDefaultContentType(type: string): Observable { + // The seed for a content type that does not exist yet: the endpoint fills in `id`, `iDate`, + // `modDate` and the rest on save, which is why they are null here against a model that + // describes what the endpoint *returns*. return of({ baseType: type, clazz: this.contentTypesInfoService.getClazz(type), @@ -85,6 +92,6 @@ export class DotContentTypeEditResolver implements Resolve { variable: null, versionable: false, workflows: [] - }); + } as unknown as DotCMSContentType); } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.component.spec.ts index 5adae845605a..b59d2ecaf75a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.component.spec.ts @@ -60,9 +60,9 @@ import { DotMenuService } from '../../../api/services/dot-menu.service'; standalone: false }) class TestContentTypeFieldsDropZoneComponent { - @Input() layout: DotCMSContentTypeLayoutRow[]; - @Input() loading: boolean; - @Input() contentType: DotCMSContentType; + @Input() layout!: DotCMSContentTypeLayoutRow[]; + @Input() loading!: boolean; + @Input() contentType!: DotCMSContentType; @Output() saveFields = new EventEmitter(); @Output() removeFields = new EventEmitter(); @@ -75,7 +75,7 @@ class TestContentTypeFieldsDropZoneComponent { standalone: true }) class TestContentTypeLayoutComponent { - @Input() contentType: DotCMSContentType; + @Input() contentType!: DotCMSContentType; @Output() openEditDialog: EventEmitter = new EventEmitter(); @Output() changeContentTypeName: EventEmitter = new EventEmitter(); } @@ -86,9 +86,9 @@ class TestContentTypeLayoutComponent { standalone: true }) class TestContentTypesFormComponent { - @Input() data: DotCMSContentType; - @Input() layout: DotCMSContentTypeField[]; - @Input() contentType: DotCMSContentType; + @Input() data!: DotCMSContentType; + @Input() layout!: DotCMSContentTypeField[]; + @Input() contentType!: DotCMSContentType; @Output() $send: EventEmitter = new EventEmitter(); @Output() $valid: EventEmitter = new EventEmitter(); @@ -103,9 +103,9 @@ class TestContentTypesFormComponent { standalone: false }) export class TestDotMenuComponent { - @Input() icon: string; - @Input() float: boolean; - @Input() model: MenuItem[]; + @Input() icon!: string; + @Input() float!: boolean; + @Input() model!: MenuItem[]; } const messageServiceMock = new MockDotMessageService({ @@ -132,7 +132,7 @@ describe('DotContentTypesEditComponent', () => { let dotHttpErrorManagerService: DotHttpErrorManagerService; let dialog: DebugElement; - const getConfig = (route) => { + const getConfig = (route: { contentType: Partial }) => { return { declarations: [ DotContentTypesEditComponent, @@ -348,7 +348,7 @@ describe('DotContentTypesEditComponent', () => { contentTypeForm.triggerEventHandler('$send', mockContentType); - const replacedWorkflowsPropContentType = { + const replacedWorkflowsPropContentType: Partial = { ...mockContentType }; @@ -435,7 +435,7 @@ describe('DotContentTypesEditComponent', () => { it('should bind save button disabled attribute to canSave property from the form', () => { form.triggerEventHandler('$valid', true); - expect(comp.dialogActions.accept.disabled).toBe(false); + expect(comp.dialogActions.accept!.disabled).toBe(false); }); it('should submit form when save button is clicked', fakeAsync(() => { @@ -443,7 +443,7 @@ describe('DotContentTypesEditComponent', () => { tick(); fixture.detectChanges(); // Call accept action directly via component - comp.dialogActions.accept.action(); + comp.dialogActions.accept!.action!(); expect(form.componentInstance.submitForm).toHaveBeenCalledTimes(1); })); }); @@ -617,12 +617,12 @@ describe('DotContentTypesEditComponent', () => { const dotEventsService = fixture.debugElement.injector.get(DotEventsService); jest.spyOn(dotEventsService, 'notify'); - comp.contentTypeActions[0].command({ originalEvent: createFakeEvent('click') }); + comp.contentTypeActions[0].command!({ originalEvent: createFakeEvent('click') }); expect(comp.contentTypeActions[0].label).toBe('Add rows'); expect(dotEventsService.notify).toHaveBeenCalledWith('add-row'); expect(dotEventsService.notify).toHaveBeenCalledTimes(1); - comp.contentTypeActions[1].command({ originalEvent: createFakeEvent('click') }); + comp.contentTypeActions[1].command!({ originalEvent: createFakeEvent('click') }); expect(comp.contentTypeActions[1].label).toBe('Add tab'); expect(dotEventsService.notify).toHaveBeenCalledWith('add-tab-divider'); expect(dotEventsService.notify).toHaveBeenCalledTimes(2); @@ -678,7 +678,7 @@ describe('DotContentTypesEditComponent', () => { it('should update fields attribute when a field is edit', () => { const layout: DotCMSContentTypeLayoutRow[] = structuredClone(currentLayoutInServer); - const fieldToUpdate: DotCMSContentTypeField = layout[0].columns[0].fields[0]; + const fieldToUpdate: DotCMSContentTypeField = layout[0].columns![0].fields[0]; fieldToUpdate.name = 'Updated field'; jest.spyOn(fieldService, 'saveFields').mockReturnValue(of(layout)); @@ -694,7 +694,7 @@ describe('DotContentTypesEditComponent', () => { it('should update fields on dropzone event', () => { const layout: DotCMSContentTypeLayoutRow[] = structuredClone(currentLayoutInServer); - const fieldToUpdate: DotCMSContentTypeField = layout[0].columns[0].fields[0]; + const fieldToUpdate: DotCMSContentTypeField = layout[0].columns![0].fields[0]; jest.spyOn(fieldService, 'updateField').mockReturnValue(of(layout)); @@ -788,8 +788,8 @@ describe('DotContentTypesEditComponent', () => { const fieldsReturnByServer: DotCMSContentTypeLayoutRow[] = structuredClone(currentLayoutInServer); - newFieldsAdded.concat(fieldsReturnByServer[0].columns[0].fields); - fieldsReturnByServer[0].columns[0].fields = newFieldsAdded; + newFieldsAdded.concat(fieldsReturnByServer[0].columns![0].fields); + fieldsReturnByServer[0].columns![0].fields = newFieldsAdded; jest.spyOn(fieldService, 'saveFields').mockReturnValue(of(fieldsReturnByServer)); @@ -815,9 +815,9 @@ describe('DotContentTypesEditComponent', () => { ); const layout: DotCMSContentTypeLayoutRow[] = structuredClone(currentLayoutInServer); - layout[0].columns[0].fields = fieldsReturnByServer; + layout[0].columns![0].fields = fieldsReturnByServer; layout[0].divider.id = new Date().getMilliseconds().toString(); - layout[0].columns[0].columnDivider.id = new Date().getMilliseconds().toString(); + layout[0].columns![0].columnDivider.id = new Date().getMilliseconds().toString(); const newRow: DotCMSContentTypeLayoutRow = { divider: { @@ -877,7 +877,7 @@ describe('DotContentTypesEditComponent', () => { it('should remove fields on dropzone event', () => { const layout: DotCMSContentTypeLayoutRow[] = structuredClone(currentLayoutInServer); - layout[0].columns[0].fields = layout[0].columns[0].fields.slice(-1); + layout[0].columns![0].fields = layout[0].columns![0].fields.slice(-1); jest.spyOn(fieldService, 'deleteFields').mockReturnValue( of({ fields: layout, deletedIds: ['3'] }) @@ -966,7 +966,7 @@ describe('DotContentTypesEditComponent', () => { contentTypeForm.triggerEventHandler('$send', fakeContentType); - const replacedWorkflowsPropContentType = { + const replacedWorkflowsPropContentType: Partial = { ...fakeContentType }; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.component.ts index c79d8739300b..4f07cba597ec 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/dot-content-types-edit.component.ts @@ -64,7 +64,7 @@ export class DotContentTypesEditComponent implements OnInit { readonly $contentTypesForm = viewChild('form'); readonly $fieldsDropZone = viewChild('fieldsDropZone'); - contentTypeActions: MenuItem[]; + contentTypeActions: MenuItem[] = []; dialogCloseable = false; /** * Turns off PrimeNG's `p-dialog` `focusOnShow`. PrimeNG focuses the first focusable element in @@ -73,9 +73,13 @@ export class DotContentTypesEditComponent implements OnInit { * decides what gets focused: the Name input when creating, nothing when editing. */ readonly dialogFocusOnShow = false; - data: DotCMSContentType; - dialogActions: DotDialogActions; - layout: DotCMSContentTypeLayoutRow[]; + data!: DotCMSContentType; + /** + * `accept` is required here even though `DotDialogActions` declares it optional: this component + * always builds one with a label, and updates its `disabled` flag by spreading it. + */ + dialogActions!: DotDialogActions & Required>; + layout: DotCMSContentTypeLayoutRow[] = []; show = signal(false); templateInfo = { icon: '', @@ -90,7 +94,7 @@ export class DotContentTypesEditComponent implements OnInit { ngOnInit(): void { this.route.data .pipe( - map((data) => data.contentType), + map((data) => data['contentType']), takeUntilDestroyed(this.destroyRef) ) .subscribe((contentType: DotCMSContentType) => { @@ -259,7 +263,7 @@ export class DotContentTypesEditComponent implements OnInit { }, error: (err) => { this.dotHttpErrorManagerService.handle(err).subscribe(() => { - this.$fieldsDropZone().cancelLastDragAndDrop(); + this.$fieldsDropZone()?.cancelLastDragAndDrop(); this.loadingFields.set(false); }); } @@ -281,7 +285,7 @@ export class DotContentTypesEditComponent implements OnInit { }, error: (err) => { this.dotHttpErrorManagerService.handle(err).subscribe(() => { - this.$fieldsDropZone().cancelLastDragAndDrop(); + this.$fieldsDropZone()?.cancelLastDragAndDrop(); this.loadingFields.set(false); }); } @@ -305,7 +309,7 @@ export class DotContentTypesEditComponent implements OnInit { ? this.dotMessageService.get('contenttypes.action.update') : this.dotMessageService.get('contenttypes.action.create'), action: () => { - this.$contentTypesForm().submitForm(); + this.$contentTypesForm()?.submitForm(); } }, cancel: { @@ -377,7 +381,10 @@ export class DotContentTypesEditComponent implements OnInit { private cleanUpFormValue(value: DotCMSContentType): DotCMSContentType { if (value.workflows) { value['workflow'] = this.getWorkflowsIds(value.workflows); - delete value.workflows; + // `workflows` is required on `DotCMSContentType` because that is the *response* shape; + // the cast states that the request shape differs rather than widening the model for + // every consumer. Both callers hand in a fresh spread, so the mutation is local. + delete (value as Partial).workflows; } return value; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-add-to-menu/dot-add-to-menu.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-add-to-menu/dot-add-to-menu.component.spec.ts index 0575ad0d2ce6..3e1e63deaf25 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-add-to-menu/dot-add-to-menu.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-add-to-menu/dot-add-to-menu.component.spec.ts @@ -215,9 +215,9 @@ describe('DotAddToMenuComponent', () => { }); it('should load form values when init', () => { - expect(component.form.get('defaultView').value).toEqual('list'); - expect(component.form.get('menuOption').value).toEqual('123'); - expect(component.form.get('title').value).toEqual(contentTypeVar.name); + expect(component.form.get('defaultView')!.value).toEqual('list'); + expect(component.form.get('menuOption')!.value).toEqual('123'); + expect(component.form.get('title')!.value).toEqual(contentTypeVar.name); expect(component.form.valid).toEqual(true); expect(dotMenuService.loadMenu).toHaveBeenCalledWith(true); expect(dotMenuService.loadMenu).toHaveBeenCalledTimes(1); @@ -252,7 +252,7 @@ describe('DotAddToMenuComponent', () => { expect(dotAddToMenuService.addToLayout).toHaveBeenCalledWith({ portletName: 'Nuevo', dataViewMode: 'list', - layoutId: component.form.get('menuOption').value + layoutId: component.form.get('menuOption')!.value }); expect(component.cancel.emit).toHaveBeenCalledTimes(1); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-add-to-menu/dot-add-to-menu.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-add-to-menu/dot-add-to-menu.component.ts index a6b15fc3ba34..5d69c68b9886 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-add-to-menu/dot-add-to-menu.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-add-to-menu/dot-add-to-menu.component.ts @@ -68,11 +68,15 @@ export class DotAddToMenuComponent implements OnInit, OnDestroy, OnChanges { private dotMenuService = inject(DotMenuService); private dotAddToMenuService = inject(DotAddToMenuService); - form: UntypedFormGroup; - menu$: Observable; + form!: UntypedFormGroup; + menu$!: Observable; placeholder = ''; dialogShow = false; - dialogActions: DotDialogActions; + /** + * `accept` is required here even though `DotDialogActions` declares it optional: this component + * always builds one with a label, and updates its `disabled` flag by spreading it. + */ + dialogActions!: DotDialogActions & Required>; readonly $contentType = input.required({ alias: 'contentType' }); readonly cancel = output(); @@ -95,7 +99,7 @@ export class DotAddToMenuComponent implements OnInit, OnDestroy, OnChanges { } ngOnChanges(changes: SimpleChanges): void { - if (changes.$contentType) { + if (changes['$contentType']) { this.dialogShow = !!this.$contentType(); if (this.$contentType()) { this.initForm(); @@ -124,9 +128,9 @@ export class DotAddToMenuComponent implements OnInit, OnDestroy, OnChanges { submit(): void { if (this.form.valid) { const params: DotCreateCustomTool = { - portletName: this.form.get('title').value, + portletName: this.form.controls['title'].value, contentTypes: this.$contentType().variable, - dataViewMode: this.form.get('defaultView').value + dataViewMode: this.form.controls['defaultView'].value }; this.dotAddToMenuService @@ -137,8 +141,8 @@ export class DotAddToMenuComponent implements OnInit, OnDestroy, OnChanges { return this.dotAddToMenuService .addToLayout({ portletName: params.portletName, - dataViewMode: this.form.get('defaultView').value, - layoutId: this.form.get('menuOption').value + dataViewMode: this.form.controls['defaultView'].value, + layoutId: this.form.controls['menuOption'].value }) .pipe(take(1)); }) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-content-type-copy-dialog/dot-content-type-copy-dialog.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-content-type-copy-dialog/dot-content-type-copy-dialog.component.spec.ts index bbbe3bb3f587..0ae18b3ebb3e 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-content-type-copy-dialog/dot-content-type-copy-dialog.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-content-type-copy-dialog/dot-content-type-copy-dialog.component.spec.ts @@ -150,7 +150,7 @@ describe('DotContentTypeCopyDialogComponent', () => { expect(copyButton).toBeDefined(); expect(component.form.valid).toEqual(false); - expect(component.dialogActions.accept.disabled).toEqual(true); + expect(component.dialogActions.accept!.disabled).toEqual(true); // Check that button component instance is disabled const buttonComponent = copyButton.componentInstance; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-content-type-copy-dialog/dot-content-type-copy-dialog.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-content-type-copy-dialog/dot-content-type-copy-dialog.component.ts index 42892d5cc2ab..f1b32fd03a0a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-content-type-copy-dialog/dot-content-type-copy-dialog.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-content-type-copy-dialog/dot-content-type-copy-dialog.component.ts @@ -65,7 +65,11 @@ export class DotContentTypeCopyDialogComponent implements OnInit, AfterViewCheck private readonly cd = inject(ChangeDetectorRef); private readonly destroy$ = new Subject(); - dialogActions: DotDialogActions; + /** + * `accept` is required here even though `DotDialogActions` declares it optional: this component + * always builds one with a label, and updates its `disabled` flag by spreading it. + */ + dialogActions!: DotDialogActions & Required>; inputNameWithType = ''; dialogTitle = ''; isVisibleDialog = false; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-type.store.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-type.store.spec.ts index c55eb85a7f2b..99747238acb5 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-type.store.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-type.store.spec.ts @@ -1,6 +1,6 @@ import { of, throwError } from 'rxjs'; -import { provideHttpClient, HttpErrorResponse } from '@angular/common/http'; +import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; @@ -100,11 +100,15 @@ describe('DotContentTypeComponentStore', () => { }); it('should handler error on update template', (done) => { - const error = new HttpErrorResponse(mockResponseView(400)); + const error = mockResponseView(400); jest.spyOn(dotContentTypeService, 'saveCopyContentType').mockReturnValue( throwError(() => error) ); + // Selected first, as the copy dialog does before it can be submitted — the effect no + // longer sends a request without an asset to copy. + store.setAssetSelected('content-type-id'); + store.saveCopyDialog({ name: 'new-name', host: 'host', diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-type.store.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-type.store.ts index 4a879b7f28bf..db733c3613a9 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-type.store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-type.store.ts @@ -4,7 +4,7 @@ import { Observable } from 'rxjs'; import { Injectable, inject } from '@angular/core'; import { Router } from '@angular/router'; -import { catchError, switchMap, tap, withLatestFrom } from 'rxjs/operators'; +import { catchError, filter, switchMap, tap, withLatestFrom } from 'rxjs/operators'; import { DotContentTypeService, DotHttpErrorManagerService } from '@dotcms/data-access'; import { DotCMSAssetDialogFields, DotCopyContentTypeDialogFormFields } from '@dotcms/dotcms-models'; @@ -55,6 +55,7 @@ export class DotContentTypeStore extends ComponentStore { return copyDialogFormFields$.pipe( tap(() => this.isSaving(true)), withLatestFrom(this.assetSelected$), + filter((pair): pair is [DotCopyContentTypeDialogFormFields, string] => !!pair[1]), switchMap(([formFields, assetIdentifier]) => this.dotContentTypeService .saveCopyContentType(assetIdentifier, formFields) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-types.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-types.component.spec.ts index 2934c30ac951..21fc74d757a7 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-types.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-types.component.spec.ts @@ -77,7 +77,7 @@ class MockDotContentTypeCloneDialogComponent { standalone: false }) class MockDotBaseTypeSelectorComponent { - @Input() value: SelectItem; + @Input() value!: SelectItem; @Output() selected = new EventEmitter(); } @@ -107,7 +107,7 @@ class MockDotContentTypeStore {} standalone: false }) class MockDotAddToBundleComponent { - @Input() assetIdentifier: string; + @Input() assetIdentifier!: string; @Output() cancel = new EventEmitter(); } @@ -124,7 +124,7 @@ class MockDotPortletBaseComponent { template: '' }) class MockDotAddToMenuComponent { - @Input() contentType; + @Input() contentType!: DotCMSContentType; @Output() cancel = new EventEmitter(); } @@ -287,11 +287,11 @@ describe('DotContentTypesPortletComponent', () => { const dotDialogService = fixture.debugElement.injector.get(DotAlertConfirmService); jest.spyOn(dotDialogService, 'confirm').mockImplementation((conf) => { - conf.accept(); + conf.accept!(); }); jest.spyOn(crudService, 'delete').mockReturnValue(of(mockContentType)); - comp.rowActions[DELETE_MENU_ITEM_INDEX].menuItem.command(mockContentType); + comp.rowActions[DELETE_MENU_ITEM_INDEX].menuItem.command!(mockContentType); fixture.detectChanges(); @@ -302,7 +302,7 @@ describe('DotContentTypesPortletComponent', () => { it('should have remove, push publish, Copy and Add to bundle actions to the list item', () => { fixture.detectChanges(); - expect(comp.rowActions.map((action) => action.menuItem.label)).toEqual([ + expect(comp.rowActions.map((action) => action.menuItem['label'])).toEqual([ 'Push Publish', 'Add to bundle', 'Add to Menu', @@ -318,8 +318,8 @@ describe('DotContentTypesPortletComponent', () => { expect( comp.rowActions.map((action) => { return { - label: action.menuItem.label, - icon: action.menuItem.icon + label: action.menuItem['label'], + icon: action.menuItem['icon'] }; }) ).toEqual([ @@ -334,7 +334,7 @@ describe('DotContentTypesPortletComponent', () => { jest.spyOn(pushPublishService, 'getEnvironments').mockReturnValue(of([])); fixture.detectChanges(); - expect(comp.rowActions.map((action) => action.menuItem.label)).toEqual([ + expect(comp.rowActions.map((action) => action.menuItem['label'])).toEqual([ 'Add to bundle', 'Add to Menu', 'Copy', @@ -361,7 +361,7 @@ describe('DotContentTypesPortletComponent', () => { expect(de.query(By.css('p-dialog'))).toBeNull(); - comp.rowActions[0].menuItem.command(mockContentType); + comp.rowActions[0].menuItem.command!(mockContentType); fixture.detectChanges(); expect(de.query(By.css('p-dialog'))).toBeDefined(); expect(dotPushPublishDialogService.open).toHaveBeenCalledWith({ @@ -389,7 +389,7 @@ describe('DotContentTypesPortletComponent', () => { }; expect(comp.addToBundleIdentifier).not.toBeDefined(); - comp.rowActions[ADD_TO_BUNDLE_MENU_ITEM_INDEX].menuItem.command(mockContentType); + comp.rowActions[ADD_TO_BUNDLE_MENU_ITEM_INDEX].menuItem.command!(mockContentType); // Verify the component state was updated correctly expect(comp.addToBundleIdentifier).toEqual(mockContentType.id); @@ -414,7 +414,7 @@ describe('DotContentTypesPortletComponent', () => { }; expect(comp.addToMenuContentType).not.toBeDefined(); - comp.rowActions[ADD_TO_MENU_INDEX].menuItem.command(mockContentType); + comp.rowActions[ADD_TO_MENU_INDEX].menuItem.command!(mockContentType); // Verify the component state was updated correctly expect(comp.addToMenuContentType).toEqual(mockContentType); @@ -423,12 +423,12 @@ describe('DotContentTypesPortletComponent', () => { it('should populate the actionHeaderOptions based on a call to dotContentletService', () => { fixture.detectChanges(); expect(dotContentletService.getAllContentTypes).toHaveBeenCalled(); - expect(comp.actionHeaderOptions.primary.model.length).toEqual(3); + expect(comp.actionHeaderOptions.primary!.model!.length).toEqual(3); }); it('should not set primary command in the header options', () => { fixture.detectChanges(); - expect(comp.actionHeaderOptions.primary.command).toBe(undefined); + expect(comp.actionHeaderOptions.primary!.command).toBe(undefined); }); it('should emit changes in base types selector', fakeAsync(() => { @@ -471,12 +471,12 @@ describe('DotContentTypesPortletComponent', () => { const dotDialogService = fixture.debugElement.injector.get(DotAlertConfirmService); jest.spyOn(dotDialogService, 'confirm').mockImplementation((conf) => { - conf.accept(); + conf.accept!(); }); jest.spyOn(dotHttpErrorManagerService, 'handle'); jest.spyOn(crudService, 'delete').mockReturnValue(observableThrowError(forbiddenError)); - comp.rowActions[DELETE_MENU_ITEM_INDEX].menuItem.command(mockContentType); + comp.rowActions[DELETE_MENU_ITEM_INDEX].menuItem.command!(mockContentType); fixture.detectChanges(); @@ -486,7 +486,7 @@ describe('DotContentTypesPortletComponent', () => { it('should show remove option', () => { fixture.detectChanges(); - const shouldShow = comp.rowActions[DELETE_MENU_ITEM_INDEX].shouldShow({ + const shouldShow = comp.rowActions[DELETE_MENU_ITEM_INDEX].shouldShow!({ fixed: false, defaultType: false }); @@ -496,7 +496,7 @@ describe('DotContentTypesPortletComponent', () => { it('should not show remove option if content type is defaultType', () => { fixture.detectChanges(); - const shouldShow = comp.rowActions[DELETE_MENU_ITEM_INDEX].shouldShow({ + const shouldShow = comp.rowActions[DELETE_MENU_ITEM_INDEX].shouldShow!({ fixed: false, defaultType: true }); @@ -505,7 +505,7 @@ describe('DotContentTypesPortletComponent', () => { it('should not show Add To Menu option if content type is HOST', () => { fixture.detectChanges(); - const shouldShow = comp.rowActions[ADD_TO_MENU_INDEX].shouldShow({ + const shouldShow = comp.rowActions[ADD_TO_MENU_INDEX].shouldShow!({ variable: 'Host' }); expect(shouldShow).toBeFalsy(); @@ -513,7 +513,7 @@ describe('DotContentTypesPortletComponent', () => { it('should show Add to Menu option', () => { fixture.detectChanges(); - expect(comp.rowActions[ADD_TO_MENU_INDEX].menuItem.label).toBe('Add to Menu'); + expect(comp.rowActions[ADD_TO_MENU_INDEX].menuItem['label']).toBe('Add to Menu'); }); describe('filterBy', () => { @@ -536,9 +536,11 @@ describe('DotContentTypesPortletComponent', () => { tick(1); fixture.detectChanges(); expect(comp.filterBy).toBe('Form'); - expect(comp.$listing().paginatorService.extraParams.get('type')).toBe('Form'); - expect(comp.actionHeaderOptions.primary.model).toBe(null); - expect(comp.actionHeaderOptions.primary.command).toBeDefined(); + expect(comp.$listing()!.paginatorService.extraParams.get('type')!).toBe('Form'); + // `undefined`, not `null`: `ActionHeaderOptionsPrimary.model` is optional, and + // "no model" is how the absence is spelled. + expect(comp.actionHeaderOptions.primary!.model).toBeUndefined(); + expect(comp.actionHeaderOptions.primary!.command).toBeDefined(); })); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-types.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-types.component.ts index c2caabd4e37a..bd97e16cfeba 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-types.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-types.component.ts @@ -101,14 +101,18 @@ export class DotContentTypesPortletComponent implements OnInit, OnDestroy { $listing = viewChild('listing'); $dotDynamicDialog = viewChild.required(DotDynamicDirective); - filterBy: string; + filterBy!: string; showTable = false; - paginatorExtraParams: { [key: string]: string }; - contentTypeColumns: DataTableColumn[]; - actionHeaderOptions: ActionHeaderOptions; - rowActions: DotActionMenuItem[]; - addToBundleIdentifier: string; - addToMenuContentType: DotCMSContentType; + paginatorExtraParams!: { [key: string]: string }; + contentTypeColumns: DataTableColumn[] = []; + /** + * `primary` is optional on `ActionHeaderOptions`, but `ngOnInit` always builds it with one and + * `setFilterByContentType` mutates that block's `command` and `model` in place. + */ + actionHeaderOptions!: ActionHeaderOptions & Required>; + rowActions: DotActionMenuItem[] = []; + addToBundleIdentifier!: string; + addToMenuContentType!: DotCMSContentType; private destroy$: Subject = new Subject(); private dialogDestroy$: Subject = new Subject(); @@ -122,7 +126,7 @@ export class DotContentTypesPortletComponent implements OnInit, OnDestroy { take(1) ), this.route.data.pipe( - map((x) => x?.filterBy), + map((x) => x?.['filterBy']), take(1) ) ).subscribe(([contentTypes, isEnterprise, environments, filterBy]) => { @@ -178,10 +182,21 @@ export class DotContentTypesPortletComponent implements OnInit, OnDestroy { * @memberof DotContentTypesPortletComponent */ changeBaseTypeSelector(value: string) { - value !== '' - ? this.$listing().paginatorService.setExtraParams('type', value) - : this.$listing().paginatorService.deleteExtraParams('type'); - this.$listing().loadFirstPage(); + // `#listing` sits behind `@if (showTable)`, so the read has to admit the absence even + // though the selector that fires this lives inside the table's own header. + const listing = this.$listing(); + + if (!listing) { + return; + } + + if (value !== '') { + listing.paginatorService.setExtraParams('type', value); + } else { + listing.paginatorService.deleteExtraParams('type'); + } + + listing.loadFirstPage(); } /** @@ -204,7 +219,7 @@ export class DotContentTypesPortletComponent implements OnInit, OnDestroy { this.createContentType(null, $event); }; - this.actionHeaderOptions.primary.model = null; + this.actionHeaderOptions.primary.model = undefined; } private createRowActions(rowActionsMap: DotRowActions): DotActionMenuItem[] { @@ -215,7 +230,7 @@ export class DotContentTypesPortletComponent implements OnInit, OnDestroy { command: (item: DotCMSContentType) => this.removeConfirmation(item), icon: 'pi pi-trash' }, - shouldShow: (item) => !item.fixed && !item.defaultType + shouldShow: (item) => !item['fixed'] && !item['defaultType'] } ]; @@ -268,7 +283,7 @@ export class DotContentTypesPortletComponent implements OnInit, OnDestroy { command: (item: DotCMSContentType) => this.addToBundleMenu(item) }, shouldShow: (item: Record) => { - return item.variable !== 'Host'; + return item['variable'] !== 'Host'; } }); } @@ -346,7 +361,7 @@ export class DotContentTypesPortletComponent implements OnInit, OnDestroy { ]; } - private createContentType(type: string, _event?): void { + private createContentType(type: string | null, _event?: unknown): void { const params = ['create']; if (type) { params.push(type); @@ -378,7 +393,7 @@ export class DotContentTypesPortletComponent implements OnInit, OnDestroy { .pipe(take(1)) .subscribe( () => { - this.$listing().loadCurrentPage(); + this.$listing()?.loadCurrentPage(); }, (error) => this.httpErrorManagerService.handle(error).pipe(take(1)).subscribe() ); @@ -405,8 +420,8 @@ export class DotContentTypesPortletComponent implements OnInit, OnDestroy { title: `${this.dotMessageService.get('contenttypes.content.copy')} ${item.name}`, baseType: item.baseType as DotCMSBaseTypesContentTypes, data: { - icon: item.icon, - host: item.host + icon: item.icon ?? '', + host: item.host ?? '' } }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/index.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/index.ts deleted file mode 100644 index 6a64065c0e39..000000000000 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './dot-content-types-listing.module'; -export * from './dot-content-types.component'; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/resolvers/dot-feature-flag-resolver.service.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/resolvers/dot-feature-flag-resolver.service.spec.ts index 1e5aceaaa7f4..4269823543a9 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/resolvers/dot-feature-flag-resolver.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/resolvers/dot-feature-flag-resolver.service.spec.ts @@ -2,7 +2,7 @@ import { Observable, of } from 'rxjs'; import { HttpClientModule } from '@angular/common/http'; import { TestBed } from '@angular/core/testing'; -import { ActivatedRouteSnapshot } from '@angular/router'; +import { ActivatedRouteSnapshot, convertToParamMap } from '@angular/router'; import { DotPropertiesService } from '@dotcms/data-access'; import { FeaturedFlags } from '@dotcms/dotcms-models'; @@ -36,16 +36,16 @@ describe('DotFeatureFlagResolver', () => { queryParams: {}, fragment: '', outlet: '', - component: undefined, - routeConfig: undefined, + component: null, + routeConfig: null, title: '', root: new ActivatedRouteSnapshot(), parent: new ActivatedRouteSnapshot(), firstChild: new ActivatedRouteSnapshot(), children: [], pathFromRoot: [], - paramMap: undefined, - queryParamMap: undefined + paramMap: convertToParamMap({}), + queryParamMap: convertToParamMap({}) }; const expectedFlagsResult: Record = { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/resolvers/dot-feature-flag-resolver.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/resolvers/dot-feature-flag-resolver.service.ts index 5c131d2fdf2d..e56620bd9de4 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/resolvers/dot-feature-flag-resolver.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/resolvers/dot-feature-flag-resolver.service.ts @@ -25,8 +25,8 @@ export class DotFeatureFlagResolver implements Resolve< private readonly dotConfigurationService = inject(DotPropertiesService); resolve(route: ActivatedRouteSnapshot) { - if (route.data.featuredFlagsToCheck) { - return this.dotConfigurationService.getFeatureFlags(route.data.featuredFlagsToCheck); + if (route.data['featuredFlagsToCheck']) { + return this.dotConfigurationService.getFeatureFlags(route.data['featuredFlagsToCheck']); } return of(false); diff --git a/core-web/apps/dotcms-ui/src/app/shared/directives/dot-show-hide-feature/dot-show-hide-feature.directive.ts b/core-web/apps/dotcms-ui/src/app/shared/directives/dot-show-hide-feature/dot-show-hide-feature.directive.ts index 2d168439a756..704ed7298a34 100644 --- a/core-web/apps/dotcms-ui/src/app/shared/directives/dot-show-hide-feature/dot-show-hide-feature.directive.ts +++ b/core-web/apps/dotcms-ui/src/app/shared/directives/dot-show-hide-feature/dot-show-hide-feature.directive.ts @@ -53,17 +53,17 @@ export class DotShowHideFeatureDirective implements OnInit { private viewContainer = inject(ViewContainerRef); private dotPropertiesService = inject(DotPropertiesService); - private _featureFlag: FeaturedFlags; + private _featureFlag!: FeaturedFlags; @Input() set dotShowHideFeature(featureFlag: FeaturedFlags) { this._featureFlag = featureFlag; } - private _alternateTemplateRef: TemplateRef; + private _alternateTemplateRef!: TemplateRef; @Input() set dotShowHideFeatureAlternate(alternateTemplateRef: TemplateRef) { this._alternateTemplateRef = alternateTemplateRef; } - @Input() dotShowOnNotFound: boolean; + @Input() dotShowOnNotFound!: boolean; get alternateTemplateRef(): TemplateRef { return this._alternateTemplateRef; diff --git a/core-web/apps/dotcms-ui/src/app/shared/dot-custom-reuse-strategy/dot-custom-reuse-strategy.service.ts b/core-web/apps/dotcms-ui/src/app/shared/dot-custom-reuse-strategy/dot-custom-reuse-strategy.service.ts index 64bd263d7bed..61f92596be1f 100644 --- a/core-web/apps/dotcms-ui/src/app/shared/dot-custom-reuse-strategy/dot-custom-reuse-strategy.service.ts +++ b/core-web/apps/dotcms-ui/src/app/shared/dot-custom-reuse-strategy/dot-custom-reuse-strategy.service.ts @@ -19,7 +19,7 @@ export class DotCustomReuseStrategyService implements RouteReuseStrategy { } // If it's not explicitly set to false, reuse the route - return future.data.reuseRoute !== false; + return future.data['reuseRoute'] !== false; } store(_route: ActivatedRouteSnapshot, _handle: DetachedRouteHandle | null): void { diff --git a/core-web/apps/dotcms-ui/src/app/shared/dot-save-on-deactivate-service/dot-save-on-deactivate.service.spec.ts b/core-web/apps/dotcms-ui/src/app/shared/dot-save-on-deactivate-service/dot-save-on-deactivate.service.spec.ts index 89de3a8908b6..050c79d72ceb 100644 --- a/core-web/apps/dotcms-ui/src/app/shared/dot-save-on-deactivate-service/dot-save-on-deactivate.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/shared/dot-save-on-deactivate-service/dot-save-on-deactivate.service.spec.ts @@ -2,6 +2,7 @@ import { Observable, of as observableOf } from 'rxjs'; import { Component } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; import { DotAlertConfirmService } from '@dotcms/data-access'; import { LoginService } from '@dotcms/dotcms-js'; @@ -32,6 +33,10 @@ class MockComponent implements OnSaveDeactivate { } } +/** `canDeactivate` declares both router parameters `_route`/`_state` and reads neither. */ +const UNUSED_ROUTE = null as unknown as ActivatedRouteSnapshot; +const UNUSED_STATE = null as unknown as RouterStateSnapshot; + describe('DotSaveOnDeactivateService', () => { let dotSaveOnDeactivateService: DotSaveOnDeactivateService; let mockComponent: MockComponent; @@ -57,42 +62,50 @@ describe('DotSaveOnDeactivateService', () => { it('should return true if there is not changes in the model', () => { jest.spyOn(mockComponent, 'shouldSaveBefore').mockReturnValue(false); - dotSaveOnDeactivateService.canDeactivate(mockComponent, null, null).subscribe((val) => { - expect(val).toBeTruthy(); - }); + dotSaveOnDeactivateService + .canDeactivate(mockComponent, UNUSED_ROUTE, UNUSED_STATE) + .subscribe((val) => { + expect(val).toBeTruthy(); + }); }); it('should return true AND call onDeactivateSave', () => { jest.spyOn(mockComponent, 'onDeactivateSave'); jest.spyOn(dotDialogService, 'confirm').mockImplementation((conf) => { - conf.accept(); - }); - dotSaveOnDeactivateService.canDeactivate(mockComponent, null, null).subscribe((val) => { - expect(val).toBeTruthy(); - expect(mockComponent.onDeactivateSave).toHaveBeenCalled(); + conf.accept!(); }); + dotSaveOnDeactivateService + .canDeactivate(mockComponent, UNUSED_ROUTE, UNUSED_STATE) + .subscribe((val) => { + expect(val).toBeTruthy(); + expect(mockComponent.onDeactivateSave).toHaveBeenCalled(); + }); }); it('should return true if the user decide NOT to save the latest changes', () => { jest.spyOn(mockComponent, 'onDeactivateSave'); jest.spyOn(dotDialogService, 'confirm').mockImplementation((conf) => { - conf.reject(); - }); - dotSaveOnDeactivateService.canDeactivate(mockComponent, null, null).subscribe((val) => { - expect(val).toBeTruthy(); - expect(mockComponent.onDeactivateSave).toHaveBeenCalledTimes(0); + conf.reject!(); }); + dotSaveOnDeactivateService + .canDeactivate(mockComponent, UNUSED_ROUTE, UNUSED_STATE) + .subscribe((val) => { + expect(val).toBeTruthy(); + expect(mockComponent.onDeactivateSave).toHaveBeenCalledTimes(0); + }); }); it('should return false if the save fails and stay in the current route', () => { jest.spyOn(mockComponent, 'onDeactivateSave').mockReturnValue(observableOf(false)); jest.spyOn(dotDialogService, 'confirm').mockImplementation((conf) => { - conf.accept(); + conf.accept!(); }); - dotSaveOnDeactivateService.canDeactivate(mockComponent, null, null).subscribe((val) => { - expect(val).toBeFalsy(); - expect(mockComponent.onDeactivateSave).toHaveBeenCalledTimes(1); - }); + dotSaveOnDeactivateService + .canDeactivate(mockComponent, UNUSED_ROUTE, UNUSED_STATE) + .subscribe((val) => { + expect(val).toBeFalsy(); + expect(mockComponent.onDeactivateSave).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/shared/models/action-header/action-header-delete-options.model.ts b/core-web/apps/dotcms-ui/src/app/shared/models/action-header/action-header-delete-options.model.ts index ad109d49fa91..6d28b4771d74 100644 --- a/core-web/apps/dotcms-ui/src/app/shared/models/action-header/action-header-delete-options.model.ts +++ b/core-web/apps/dotcms-ui/src/app/shared/models/action-header/action-header-delete-options.model.ts @@ -1,4 +1,4 @@ export interface ActionHeaderDeleteOptions { - confirmHeader?: string; - confirmMessage?: string; + confirmHeader: string; + confirmMessage: string; } diff --git a/core-web/apps/dotcms-ui/src/app/shared/models/data-table/data-table-column.ts b/core-web/apps/dotcms-ui/src/app/shared/models/data-table/data-table-column.ts index 87d43abbbe46..2a6e7d7019f8 100644 --- a/core-web/apps/dotcms-ui/src/app/shared/models/data-table/data-table-column.ts +++ b/core-web/apps/dotcms-ui/src/app/shared/models/data-table/data-table-column.ts @@ -2,7 +2,7 @@ export interface DataTableColumn { fieldName: string; format?: string; header: string; - icon?: (any) => string; + icon?: (rowData: { icon: string }) => string; sortable?: boolean; textAlign?: string; textContent?: string; diff --git a/core-web/apps/dotcms-ui/src/app/shared/models/dot-portlet-toolbar.model/dot-portlet-toolbar-actions.model.ts b/core-web/apps/dotcms-ui/src/app/shared/models/dot-portlet-toolbar.model/dot-portlet-toolbar-actions.model.ts index b066b75434ae..cee675bf8b7a 100644 --- a/core-web/apps/dotcms-ui/src/app/shared/models/dot-portlet-toolbar.model/dot-portlet-toolbar-actions.model.ts +++ b/core-web/apps/dotcms-ui/src/app/shared/models/dot-portlet-toolbar.model/dot-portlet-toolbar-actions.model.ts @@ -1,6 +1,7 @@ import { MenuItem } from 'primeng/api'; export interface DotPortletToolbarActions { - primary: MenuItem[]; + /** Null when the toolbar shows only a cancel button. */ + primary: MenuItem[] | null; cancel: (event: MouseEvent) => void; } diff --git a/core-web/apps/dotcms-ui/src/app/shared/models/notifications/notification.model.ts b/core-web/apps/dotcms-ui/src/app/shared/models/notifications/notification.model.ts index 426385804b6c..d219b2ae518c 100644 --- a/core-web/apps/dotcms-ui/src/app/shared/models/notifications/notification.model.ts +++ b/core-web/apps/dotcms-ui/src/app/shared/models/notifications/notification.model.ts @@ -18,7 +18,12 @@ export interface DotNotificationResponse { totalUnreadNotifications: number; } +/** + * Icons by notification level. An index signature because `DotNotification.level` is a plain + * string — the three below are the levels that have an icon, and the reader falls back for the rest. + */ export interface NotificationIcons { + [level: string]: string; ERROR: string; INFO: string; WARNING: string; diff --git a/core-web/apps/dotcms-ui/src/app/test/dot-test-bed.ts b/core-web/apps/dotcms-ui/src/app/test/dot-test-bed.ts index 45f84eb8dc22..13ac6624f170 100644 --- a/core-web/apps/dotcms-ui/src/app/test/dot-test-bed.ts +++ b/core-web/apps/dotcms-ui/src/app/test/dot-test-bed.ts @@ -130,16 +130,17 @@ export class DOTTestBed { }; public static configureTestingModule(config: TestModuleMetadata): typeof TestBed { - // tslint:disable-next-line:forin - for (const property in DOTTestBed.DEFAULT_CONFIG) { - if (config[property]) { - DOTTestBed.DEFAULT_CONFIG[property] - .filter((provider) => !config[property].includes(provider)) - .forEach((item) => config[property].unshift(item)); - } else { - config[property] = DOTTestBed.DEFAULT_CONFIG[property]; - } - } + // `imports` and `providers` are named rather than walked with `for...in` over the default + // config's keys, which is what turned every access into an index into `TestModuleMetadata`. + // Those are the only two keys the defaults carry. + config.imports = DOTTestBed.mergeDefaults( + DOTTestBed.DEFAULT_CONFIG.imports, + config.imports + ); + config.providers = DOTTestBed.mergeDefaults( + DOTTestBed.DEFAULT_CONFIG.providers, + config.providers + ); TestBed.configureTestingModule(config); TestBed.compileComponents(); @@ -150,4 +151,20 @@ export class DOTTestBed { public static createComponent(component: Type): ComponentFixture { return TestBed.createComponent(component); } + + /** + * Prepends whichever defaults the caller did not already list, in place — the same thing the + * `for...in` did, including handing back the defaults array itself when the caller passed none. + */ + private static mergeDefaults(defaults: unknown[], provided?: unknown[]): unknown[] { + if (!provided) { + return defaults; + } + + defaults + .filter((item) => !provided.includes(item)) + .forEach((item) => provided.unshift(item)); + + return provided; + } } diff --git a/core-web/apps/dotcms-ui/src/app/test/spectator-aliased-props.ts b/core-web/apps/dotcms-ui/src/app/test/spectator-aliased-props.ts new file mode 100644 index 000000000000..a6874ea1f1cb --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/test/spectator-aliased-props.ts @@ -0,0 +1,17 @@ +import { SpectatorOverrides } from '@openng/spectator'; + +/** + * Props for a component whose signal inputs are aliased. + * + * Spectator keys `props` (and `setInput`) by the class *property* name — `$field` — while the + * `ComponentRef.setInput` it calls underneath needs the public *alias*, `field`. A component that + * follows this repo's `$name` + `{ alias }` convention therefore cannot express its inputs through + * `props` at all: the name that type-checks throws at runtime, and the name that works does not + * type-check. + * + * This states that gap once, so specs do not each carry an `as unknown` cast. It keeps the props + * applied before the first change-detection pass, which `createComponent({ props })` guarantees and + * a `setInput` after construction does not. + */ +export const aliasedProps = (props: Record): SpectatorOverrides['props'] => + props as unknown as SpectatorOverrides['props']; diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-action-button/dot-action-button.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-action-button/dot-action-button.component.spec.ts index 7525b210c697..93408c44b00e 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-action-button/dot-action-button.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-action-button/dot-action-button.component.spec.ts @@ -52,7 +52,7 @@ describe('DotActionButtonComponent', () => { ]; spectator.setInput('model', model); - const menu = spectator.query(Menu); + const menu = spectator.query(Menu)!; expect(menu).toExist(); expect(menu.model).toEqual(model); }); @@ -85,7 +85,7 @@ describe('DotActionButtonComponent', () => { spectator.setInput('disabled', true); spectator.setInput('label', 'Label'); - const button = spectator.query(Button); + const button = spectator.query(Button)!; const label = spectator.query(byTestId('dot-action-button-label')); expect(button.disabled).toBe(true); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-alert-confirm/dot-alert-confirm.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-alert-confirm/dot-alert-confirm.spec.ts index f6136f18590e..e44daf3fa082 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-alert-confirm/dot-alert-confirm.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-alert-confirm/dot-alert-confirm.spec.ts @@ -55,7 +55,9 @@ describe('DotAlertConfirmComponent', () => { beforeEach(() => { spectator = createComponent(); detectChanges(); - dialogService = spectator.inject(DotAlertConfirmService) as DotAlertConfirmServiceTest; + dialogService = spectator.inject( + DotAlertConfirmService + ) as unknown as DotAlertConfirmServiceTest; }); it('should not show confirm or alert by default', () => { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-alert-confirm/dot-alert-confirm.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-alert-confirm/dot-alert-confirm.ts index 4dc8abf42531..ae077a596a8b 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-alert-confirm/dot-alert-confirm.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-alert-confirm/dot-alert-confirm.ts @@ -32,9 +32,9 @@ export class DotAlertConfirmComponent implements OnInit, OnDestroy { private confirmationService = inject(ConfirmationService); private injector = inject(Injector); - @ViewChild('cd') cd: ConfirmDialog; - @ViewChild('confirmBtn') confirmBtn: ElementRef; - @ViewChild('acceptBtn') acceptBtn: ElementRef; + @ViewChild('cd') cd!: ConfirmDialog; + @ViewChild('confirmBtn') confirmBtn!: ElementRef; + @ViewChild('acceptBtn') acceptBtn!: ElementRef; private destroy$ = new Subject(); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-autocomplete-tags/dot-autocomplete-tags.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-autocomplete-tags/dot-autocomplete-tags.component.spec.ts index 516acf47fbd5..c3f5b0cb5736 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-autocomplete-tags/dot-autocomplete-tags.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-autocomplete-tags/dot-autocomplete-tags.component.spec.ts @@ -14,8 +14,8 @@ import { createFakeEvent, MockDotMessageService } from '@dotcms/utils-testing'; import { DotAutocompleteTagsComponent } from './dot-autocomplete-tags.component'; const mockResponse = [ - { label: 'test', siteId: '1', siteName: 'Site', persona: false }, - { label: 'united', siteId: '1', siteName: 'Site', persona: false } + { id: '1', label: 'test', siteId: '1', siteName: 'Site', persona: false }, + { id: '2', label: 'united', siteId: '1', siteName: 'Site', persona: false } ]; class DotTagsServiceMock { @@ -69,16 +69,18 @@ describe('DotAutocompleteTagsComponent', () => { describe('events', () => { const preLoadedTags = [ { + id: '', label: 'enterEvent', siteId: '', siteName: '', - persona: null + persona: false }, { + id: '', label: 'Dotcms', siteId: '', siteName: '', - persona: null + persona: false } ]; @@ -161,10 +163,11 @@ describe('DotAutocompleteTagsComponent', () => { it('should call filterTags on completeMethod and remove already selected', () => { jest.spyOn(component, 'filterTags'); component.value.push({ + id: '', label: 'test', siteId: '', siteName: '', - persona: null + persona: false }); const fakeEvent = createFakeEvent('click'); autoComplete.completeMethod.emit({ diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-autocomplete-tags/dot-autocomplete-tags.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-autocomplete-tags/dot-autocomplete-tags.component.ts index 16c551257051..d65215e6ae00 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-autocomplete-tags/dot-autocomplete-tags.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-autocomplete-tags/dot-autocomplete-tags.component.ts @@ -40,15 +40,15 @@ import { DotTag } from '@dotcms/dotcms-models'; export class DotAutocompleteTagsComponent implements OnInit, ControlValueAccessor { private dotTagsService = inject(DotTagsService); - @Input() placeholder: string; + @Input() placeholder!: string; value: DotTag[] = []; - filteredOptions: DotTag[]; + filteredOptions: DotTag[] = []; disabled = false; - inputReference: HTMLInputElement; - @ViewChild('autoComplete', { static: true }) autoComplete: AutoComplete; + inputReference!: HTMLInputElement; + @ViewChild('autoComplete', { static: true }) autoComplete!: AutoComplete; - private lastDeletedTag: DotTag; + private lastDeletedTag: DotTag | null = null; propagateChange = (_: unknown) => { /* empty */ @@ -96,7 +96,10 @@ export class DotAutocompleteTagsComponent implements OnInit, ControlValueAccesso * @memberof DotAutocompleteTagsComponent */ addItem(): void { - this.value.unshift(this.value.pop()); + const selected = this.value.pop(); + if (selected) { + this.value.unshift(selected); + } this.propagateChange(this.getStringifyLabels()); } @@ -165,7 +168,7 @@ export class DotAutocompleteTagsComponent implements OnInit, ControlValueAccesso this.value.unshift(this.createNewTag(input.value)); this.propagateChange(this.getStringifyLabels()); this.filterTags({ query: input.value }); - input.value = null; + input.value = ''; this.autoComplete.hide(); } } @@ -179,10 +182,14 @@ export class DotAutocompleteTagsComponent implements OnInit, ControlValueAccesso private createNewTag(label: string): DotTag { return { + // `''` and `false` rather than omitting `id` and passing `persona: null`: both are + // required on `DotTag`, and a tag created here has not been persisted yet — the server + // assigns the id on save. + id: '', label: label, siteId: '', siteName: '', - persona: null + persona: false }; } } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-bulk-information/dot-bulk-information.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-bulk-information/dot-bulk-information.component.spec.ts index 4f06dd4707d0..6af1fafb4bf6 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-bulk-information/dot-bulk-information.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-bulk-information/dot-bulk-information.component.spec.ts @@ -90,8 +90,8 @@ describe('DotBulkInformationComponent', () => { }); it('should load labels correctly', () => { - const success: HTMLElement = document.querySelector('[data-testId="successful"]'); - const fail: HTMLElement = document.querySelector('[data-testId="fails"]'); + const success = document.querySelector('[data-testId="successful"]')!; + const fail = document.querySelector('[data-testId="fails"]')!; expect(success.textContent?.trim()).toEqual('Template archived: 1'); expect(fail.textContent?.trim()).toEqual('2 failed'); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-bulk-information/dot-bulk-information.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-bulk-information/dot-bulk-information.component.ts index a784390279a8..76c910fc6c49 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-bulk-information/dot-bulk-information.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-bulk-information/dot-bulk-information.component.ts @@ -16,7 +16,7 @@ export class DotBulkInformationComponent implements OnInit { ref = inject(DynamicDialogRef); config = inject(DynamicDialogConfig); - data: DotActionBulkResult; + data!: DotActionBulkResult; ngOnInit(): void { this.data = this.config.data; } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-custom-time.component/dot-custom-time.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-custom-time.component/dot-custom-time.component.ts index de28c26de021..060e16fd0e8e 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-custom-time.component/dot-custom-time.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-custom-time.component/dot-custom-time.component.ts @@ -11,5 +11,5 @@ import { DotRelativeDatePipe } from '@dotcms/ui'; imports: [DotRelativeDatePipe] }) export class CustomTimeComponent { - @Input() time: string; + @Input() time!: string; } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-download-bundle-dialog/dot-download-bundle-dialog.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-download-bundle-dialog/dot-download-bundle-dialog.component.spec.ts index 0cb5eb71f783..f7c8c7760517 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-download-bundle-dialog/dot-download-bundle-dialog.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-download-bundle-dialog/dot-download-bundle-dialog.component.spec.ts @@ -154,7 +154,7 @@ describe('DotDownloadBundleDialogComponent', () => { }); it('should close dialog on Cancel', () => { - component.dialogActions.cancel.action(); + component.dialogActions.cancel!.action!(); expect(component.showDialog).toBe(false); }); @@ -185,9 +185,9 @@ describe('DotDownloadBundleDialogComponent', () => { it('should disable buttons and change to label to downloading...', () => { component.handleSubmit(); - expect(component.dialogActions.accept.disabled).toBe(true); - expect(component.dialogActions.cancel.disabled).toBe(true); - expect(component.dialogActions.accept.label).toBe('Downloading...'); + expect(component.dialogActions.accept!.disabled).toBe(true); + expect(component.dialogActions.cancel!.disabled).toBe(true); + expect(component.dialogActions.accept!.label).toBe('Downloading...'); }); it('should fetch to the correct url when publish', fakeAsync(() => { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-download-bundle-dialog/dot-download-bundle-dialog.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-download-bundle-dialog/dot-download-bundle-dialog.component.ts index fac4bbbc1ea4..4813ed99b01c 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-download-bundle-dialog/dot-download-bundle-dialog.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-download-bundle-dialog/dot-download-bundle-dialog.component.ts @@ -66,16 +66,20 @@ export class DotDownloadBundleDialogComponent implements OnInit, OnDestroy { private dotDownloadBundleDialogService = inject(DotDownloadBundleDialogService); private cdr = inject(ChangeDetectorRef); - downloadOptions: SelectItem[]; - filterOptions: SelectItem[]; - dialogActions: DotDialogActions; - form: UntypedFormGroup; + downloadOptions: SelectItem[] = []; + filterOptions: SelectItem[] = []; + dialogActions!: DotDialogActions; + form!: UntypedFormGroup; showDialog = false; errorMessage = ''; - private currentFilterKey: string; + private currentFilterKey!: string; private destroy$: Subject = new Subject(); - private filters: SelectItem[] = null; + /** + * Null until the filter list has been fetched. `ngOnInit` branches on exactly that, so an + * empty array here would read as "already loaded" and skip the fetch. + */ + private filters: SelectItem[] | null = null; ngOnInit() { this.dotDownloadBundleDialogService.showDialog$ @@ -127,17 +131,22 @@ export class DotDownloadBundleDialogComponent implements OnInit, OnDestroy { if (this.form.valid) { this.errorMessage = ''; const value = this.form.value; - const bundleForm = {}; + const bundleForm: Record = {}; bundleForm['bundleId'] = value.bundleId; bundleForm['operation'] = value.downloadOptionSelected === DownloadType.PUBLISH ? '0' : '1'; bundleForm['filterKey'] = value.filterKey; - this.dialogActions.accept.disabled = true; - this.dialogActions.accept.label = this.dotMessageService.get( - 'download.bundle.downloading' - ); - this.dialogActions.cancel.disabled = true; + // `accept` and `cancel` are optional on `DotDialogActions`; this component sets both in + // `setDialogActions`, so the guard only covers the interval before the dialog is built. + if (this.dialogActions.accept && this.dialogActions.cancel) { + this.dialogActions.accept.disabled = true; + this.dialogActions.accept.label = this.dotMessageService.get( + 'download.bundle.downloading' + ); + this.dialogActions.cancel.disabled = true; + } + this.downloadFile(bundleForm); } } @@ -158,7 +167,7 @@ export class DotDownloadBundleDialogComponent implements OnInit, OnDestroy { private initDialog(bundleId: string): void { this.setDialogActions(); this.errorMessage = ''; - this.filterOptions = this.filters; + this.filterOptions = this.filters ?? []; this.form = this.fb.group({ downloadOptionSelected: [this.downloadOptions[0].value, [Validators.required]], filterKey: this.currentFilterKey, @@ -185,11 +194,17 @@ export class DotDownloadBundleDialogComponent implements OnInit, OnDestroy { }; }) .sort((a: SelectItem, b: SelectItem) => { - if (a.label > b.label) { + // `SelectItem.label` is optional; these are built with `filter.title` just + // above, so `''` only ever affects an item that never had one — which sorts + // first, as an unnamed entry should. + const aLabel = a.label ?? ''; + const bLabel = b.label ?? ''; + + if (aLabel > bLabel) { return 1; } - if (a.label < b.label) { + if (aLabel < bLabel) { return -1; } @@ -221,9 +236,8 @@ export class DotDownloadBundleDialogComponent implements OnInit, OnDestroy { } private listenForChanges(): void { - this.form - .get('downloadOptionSelected') - .valueChanges.pipe(takeUntil(this.destroy$)) + this.form.controls['downloadOptionSelected'].valueChanges + .pipe(takeUntil(this.destroy$)) .subscribe((state: string) => { this.handleDropDownState(state); }); @@ -236,7 +250,7 @@ export class DotDownloadBundleDialogComponent implements OnInit, OnDestroy { filterKey.setValue(''); this.filterOptions = []; } else { - this.filterOptions = this.filters; + this.filterOptions = this.filters ?? []; filterKey.enable(); filterKey.setValue(this.currentFilterKey); } @@ -257,7 +271,7 @@ export class DotDownloadBundleDialogComponent implements OnInit, OnDestroy { }) .then((res: Response) => { const contentDisposition = res.headers.get('content-disposition'); - fileName = this.getFilenameFromContentDisposition(contentDisposition); + fileName = this.getFilenameFromContentDisposition(contentDisposition ?? ''); return res.blob(); }) diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-empty-state/dot-empty-state.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-empty-state/dot-empty-state.component.spec.ts index e57c16fa2d4c..ab834e9d21a9 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-empty-state/dot-empty-state.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-empty-state/dot-empty-state.component.spec.ts @@ -60,7 +60,8 @@ describe('DotEmptyStateComponent', () => { expect(node.nativeElement.style.width).toEqual('24.125%'); }); - expect(checkbox.nativeElement.style.width).toEqual('3.5%', 'correct checkbox width'); + // Jest's `toEqual` takes one argument — the second was Jasmine's failure message. + expect(checkbox.nativeElement.style.width).toEqual('3.5%'); }); it('should have the correct attributes set', () => { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-empty-state/dot-empty-state.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-empty-state/dot-empty-state.component.ts index e021b688f22f..2a1df9f36087 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-empty-state/dot-empty-state.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-empty-state/dot-empty-state.component.ts @@ -17,15 +17,15 @@ import { ButtonModule } from 'primeng/button'; imports: [ButtonModule] }) export class DotEmptyStateComponent implements OnInit { - @Input() rows: number; + @Input() rows!: number; @Input() colsTextWidth: number[] = []; - @Input() icon: string; - @Input() title: string; - @Input() content: string; - @Input() buttonLabel: string; + @Input() icon!: string; + @Input() title!: string; + @Input() content!: string; + @Input() buttonLabel!: string; @Output() buttonClick = new EventEmitter(); - columnWidth: string; + columnWidth!: string; public readonly checkBoxWidth: number = 3.5; ngOnInit(): void { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-generate-secure-password/dot-generate-secure-password.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-generate-secure-password/dot-generate-secure-password.component.spec.ts index c1c6b6db6ad1..ce36c220d0ca 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-generate-secure-password/dot-generate-secure-password.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-generate-secure-password/dot-generate-secure-password.component.spec.ts @@ -65,7 +65,7 @@ describe('DotGenerateSecurePasswordComponent', () => { const dialogEl = spectator.query('p-dialog'); expect(dialogEl).toBeTruthy(); expect(spectator.component.dialogShow).toBe(true); - expect(spectator.component.value).toEqual(passwordGenerateData.password); + expect(spectator.component.value).toEqual(passwordGenerateData['password']); expect(spectator.component.typeInput).toBe('password'); }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-generate-secure-password/dot-generate-secure-password.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-generate-secure-password/dot-generate-secure-password.component.ts index 33e2868813a6..c5e09c0d1442 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-generate-secure-password/dot-generate-secure-password.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-generate-secure-password/dot-generate-secure-password.component.ts @@ -32,12 +32,12 @@ export class DotGenerateSecurePasswordComponent implements OnInit, OnDestroy { private dotGenerateSecurePassword = inject(DotGenerateSecurePasswordService); private cdr = inject(ChangeDetectorRef); - copyBtnLabel: string; - dialogActions: DotDialogActions; + copyBtnLabel!: string; + dialogActions!: DotDialogActions; dialogShow = false; - revealBtnLabel: string; + revealBtnLabel!: string; typeInput = 'password'; - value: string; + value!: string; private destroy$: Subject = new Subject(); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-global-message/dot-global-message.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-global-message/dot-global-message.component.ts index d3590396c784..21148ae07ed9 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-global-message/dot-global-message.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-global-message/dot-global-message.component.ts @@ -41,7 +41,7 @@ export class DotGlobalMessageComponent implements OnInit, OnDestroy { message: DotGlobalMessage = { value: '' }; private visibility = false; - private icons = { + private icons: Record = { loading: 'loading', success: 'pi pi-check-circle', error: 'pi pi-exclamation-circle', @@ -56,15 +56,18 @@ export class DotGlobalMessageComponent implements OnInit, OnDestroy { ngOnInit() { this.dotEventsService - .listen('dot-global-message') + .listen('dot-global-message') .pipe( - filter((event: DotEvent) => !!event.data), + filter( + (event): event is DotEvent & { data: DotGlobalMessage } => + !!event.data + ), takeUntil(this.destroy$) ) - .subscribe((event: DotEvent) => { + .subscribe((event) => { this.message = event.data; this.visibility = true; - this.message.icon = this.icons[this.message.type] || ''; + this.message.icon = this.icons[this.message.type ?? ''] || ''; if (this.message.life) { setTimeout(() => { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-inline-edit/dot-inline-edit.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-inline-edit/dot-inline-edit.component.ts index d4c8ab7c7985..6f62a79500dc 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-inline-edit/dot-inline-edit.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-inline-edit/dot-inline-edit.component.ts @@ -12,11 +12,11 @@ import { Inplace, InplaceModule } from 'primeng/inplace'; }) export class DotInlineEditComponent { @Input() - inlineEditDisplayTemplate: TemplateRef; + inlineEditDisplayTemplate!: TemplateRef; @Input() - inlineEditContentTemplate: TemplateRef; + inlineEditContentTemplate!: TemplateRef; - @ViewChild('contentTypeInlineEdit') contentTypeInlineEdit: Inplace; + @ViewChild('contentTypeInlineEdit') contentTypeInlineEdit!: Inplace; /** * Manually hides the content/edit section of p-inplace diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-md-icon-selector/dot-md-icon-selector.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-md-icon-selector/dot-md-icon-selector.component.ts index 8270ca002f58..f0de9a06a3c7 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-md-icon-selector/dot-md-icon-selector.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-md-icon-selector/dot-md-icon-selector.component.ts @@ -27,7 +27,7 @@ export class DotMdIconSelectorComponent implements ControlValueAccessor { onTouched = () => { // }; - onChange = (_) => { + onChange = (_: string) => { /* */ }; @@ -39,7 +39,7 @@ export class DotMdIconSelectorComponent implements ControlValueAccessor { this.onTouched = fn; } - registerOnChange(fn: () => void) { + registerOnChange(fn: (value: string) => void) { this.onChange = fn; } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-page-selector/dot-page-selector.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-page-selector/dot-page-selector.component.ts index 8ffc9942c85b..f972a49e1978 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-page-selector/dot-page-selector.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-page-selector/dot-page-selector.component.ts @@ -81,17 +81,19 @@ export class DotPageSelectorComponent implements ControlValueAccessor { @Output() selected = new EventEmitter(); @Input() folderSearch = false; - @ViewChild('autoComplete') autoComplete: AutoComplete; + @ViewChild('autoComplete') autoComplete!: AutoComplete; - val: DotPageSelectorItem; + val!: DotPageSelectorItem; suggestions$: Subject = new Subject(); - message: string; - searchType: string; + /** Null while there is nothing to tell the user — cleared at the start of each search. */ + message: string | null = null; + searchType!: string; isError = false; - private currentHost: Site; + /** Null until a site is picked, and cleared when the query stops naming one. */ + private currentHost: Site | null = null; private invalidHost = false; - propagateChange = (_: unknown) => { + propagateChange = (_: string | null) => { /* */ }; @@ -188,7 +190,7 @@ export class DotPageSelectorComponent implements ControlValueAccessor { * @param {(params) => void} fn * @memberof DotPageSelectorComponent */ - registerOnChange(fn: (params) => void): void { + registerOnChange(fn: (params: string | null) => void): void { this.propagateChange = fn; } @@ -201,7 +203,7 @@ export class DotPageSelectorComponent implements ControlValueAccessor { if (this.invalidHost) { this.message = this.getEmptyMessage(SearchType.SITE); } else if (this.isFolderAndHost(query)) { - this.propagateChange(this.autoComplete.inputEL.nativeElement.value); + this.propagateChange(this.autoComplete.inputEL?.nativeElement.value); this.message = this.dotMessageService.get('page.selector.folder.new'); } else { this.message = this.getEmptyMessage(this.searchType); @@ -239,7 +241,8 @@ export class DotPageSelectorComponent implements ControlValueAccessor { } private fullSearch(param: string): Observable { - const host = decodeURI(this.parseUrl(param).host); + // Reached only through `isTwoStepSearch`, which is `isHostAndPath` — so the query parses. + const host = decodeURI(this.parseUrl(param)?.host ?? ''); return this.dotPageSelectorService.getSites(host, true).pipe( take(1), @@ -286,12 +289,12 @@ export class DotPageSelectorComponent implements ControlValueAccessor { } private isHostAndPath(param: string): boolean { - const url: DotSimpleURL | { [key: string]: string } = this.parseUrl(param); + const url = this.parseUrl(param); - return url && !!(url.host && url.pathname.length > 0); + return !!url && !!url.host && url.pathname.length > 0; } - private parseUrl(query: string): DotSimpleURL { + private parseUrl(query: string): DotSimpleURL | null { try { const url = new URL(`http:${query}`); @@ -315,8 +318,9 @@ export class DotPageSelectorComponent implements ControlValueAccessor { private cleanAndValidateQuery(query: string): string { let cleanedQuery = ''; - if (this.isTwoStepSearch(query)) { - const url = this.parseUrl(query); + const url = this.isTwoStepSearch(query) ? this.parseUrl(query) : null; + + if (url) { url.host = this.cleanHost(decodeURI(url.host)); url.pathname = this.cleanPath(url.pathname); cleanedQuery = `//${url.host}/${url.pathname}`; @@ -326,7 +330,9 @@ export class DotPageSelectorComponent implements ControlValueAccessor { cleanedQuery = this.cleanPath(query); } - this.autoComplete.inputEL.nativeElement.value = cleanedQuery; + if (this.autoComplete.inputEL) { + this.autoComplete.inputEL.nativeElement.value = cleanedQuery; + } return cleanedQuery.startsWith('//') ? cleanedQuery @@ -356,6 +362,9 @@ export class DotPageSelectorComponent implements ControlValueAccessor { case 'folder': return this.dotMessageService.get('page.selector.no.folder.results'); + + default: + return ''; } } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-push-publish-dialog/dot-push-publish-dialog.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-push-publish-dialog/dot-push-publish-dialog.component.spec.ts index e28df377900c..6d30f3055e66 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-push-publish-dialog/dot-push-publish-dialog.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-push-publish-dialog/dot-push-publish-dialog.component.spec.ts @@ -37,7 +37,7 @@ class PushPublishServiceMock { template: '' }) class TestDotPushPublishFormComponent { - @Input() data: DotPushPublishDialogData; + @Input() data!: DotPushPublishDialogData; @Output() value = new EventEmitter(); @Output() valid = new EventEmitter(); } @@ -194,13 +194,13 @@ describe('DotPushPublishDialogComponent', () => { it('should enable dialog accept action and formValid when form becomes valid', () => { comp.updateFormValid(true); - expect(comp.dialogActions.accept.disabled).toEqual(false); + expect(comp.dialogActions.accept!.disabled).toEqual(false); expect(comp.formValid).toEqual(true); }); it('should disable accept action and formValid when form becomes invalid', () => { comp.updateFormValid(false); - expect(comp.dialogActions.accept.disabled).toEqual(true); + expect(comp.dialogActions.accept!.disabled).toEqual(true); expect(comp.formValid).toEqual(false); }); }); @@ -224,7 +224,11 @@ describe('DotPushPublishDialogComponent', () => { describe('on success pushPublishContent', () => { beforeEach(() => { - jest.spyOn(pushPublishService, 'pushPublishContent').mockReturnValue(of(null)); + // The dialog reads the result as `!result?.errors`, so a null response is a path it + // handles even though the service declares one non-null. + jest.spyOn(pushPublishService, 'pushPublishContent').mockReturnValue( + of(null as unknown as DotAjaxActionResponseView) + ); }); xit('should submit on accept and hide dialog', () => { @@ -259,7 +263,7 @@ describe('DotPushPublishDialogComponent', () => { }); it('should close the dialog', () => { - comp.dialogActions.cancel.action(); + comp.dialogActions.cancel!.action!(); expect(comp.cancel.emit).toHaveBeenCalled(); expect(comp.dialogShow).toEqual(false); expect(comp.eventData).toEqual(null); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-push-publish-dialog/dot-push-publish-dialog.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-push-publish-dialog/dot-push-publish-dialog.component.ts index fde15518f83a..61b55eee4b88 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-push-publish-dialog/dot-push-publish-dialog.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-push-publish-dialog/dot-push-publish-dialog.component.ts @@ -56,12 +56,16 @@ export class DotPushPublishDialogComponent implements OnInit, OnDestroy { private dotPushPublishDialogService = inject(DotPushPublishDialogService); private cdr = inject(ChangeDetectorRef); - dialogActions: DotDialogActions; + /** + * `accept` is required here even though `DotDialogActions` declares it optional: the setter + * below always builds one, and the handlers toggle its `disabled` flag in place. + */ + dialogActions!: DotDialogActions & Required>; dialogShow = false; - eventData: DotPushPublishDialogData; - formData: DotPushPublishData; + eventData: DotPushPublishDialogData | null = null; + formData!: DotPushPublishData; formValid = false; - errorMessage = null; + errorMessage: number | null = null; isSaving = false; cancel = output(); @@ -110,14 +114,11 @@ export class DotPushPublishDialogComponent implements OnInit, OnDestroy { * @memberof DotPushPublishDialogComponent */ submitPushAction(): void { - if (this.formValid) { + if (this.formValid && this.eventData) { + const eventData = this.eventData; this.isSaving = true; this.pushPublishService - .pushPublishContent( - this.eventData.assetIdentifier, - this.formData, - !!this.eventData.isBundle - ) + .pushPublishContent(eventData.assetIdentifier, this.formData, !!eventData.isBundle) .pipe(takeUntil(this.destroy$)) .subscribe((result: DotAjaxActionResponseView) => { this.isSaving = false; diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-push-publish-dialog/index.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-push-publish-dialog/index.ts deleted file mode 100644 index 15957809058b..000000000000 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-push-publish-dialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './dot-push-publish-dialog.module'; diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-push-publish-env-selector/dot-push-publish-env-selector.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-push-publish-env-selector/dot-push-publish-env-selector.component.spec.ts index 3e47eb1dca94..03494153dae2 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-push-publish-env-selector/dot-push-publish-env-selector.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-push-publish-env-selector/dot-push-publish-env-selector.component.spec.ts @@ -15,7 +15,7 @@ import { MockDotMessageService } from '@dotcms/utils-testing'; import { DOTTestBed } from '../../../../test/dot-test-bed'; export class PushPublishServiceMock { - _lastEnvironmentPushed: string[]; + _lastEnvironmentPushed!: string[]; get lastEnvironmentPushed(): string[] { return this._lastEnvironmentPushed; diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-textarea-content/dot-textarea-content.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-textarea-content/dot-textarea-content.component.spec.ts index b6c46e5166e8..1dc33f73a58d 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-textarea-content/dot-textarea-content.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-textarea-content/dot-textarea-content.component.spec.ts @@ -30,7 +30,7 @@ function cleanOptionText(option: string): string { ] }) class MonacoEditorMockComponent { - @Input() options: Record; + @Input() options!: Record; writeValue() {} diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-textarea-content/dot-textarea-content.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-textarea-content/dot-textarea-content.component.ts index 633a0a0de631..c65c13b5f9d4 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-textarea-content/dot-textarea-content.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-textarea-content/dot-textarea-content.component.ts @@ -42,22 +42,22 @@ export class DotTextareaContentComponent implements OnInit, ControlValueAccessor }; @Input() - height: string; + height!: string; @Input() - show; + show?: string[]; @Input() value = ''; @Input() - width: string; + width!: string; @Input() - customStyles: Record; + customStyles!: Record; @Input() - editorName: string; + editorName!: string; @Output() monacoInit = new EventEmitter(); @@ -78,8 +78,8 @@ export class DotTextareaContentComponent implements OnInit, ControlValueAccessor } selectOptions: SelectItem[] = []; - selected: string; - styles: Record | string; + selected!: string; + styles!: Record | string; editorOptions: MonacoEditorConstructionOptions = { theme: 'vs-light', minimap: { @@ -177,7 +177,7 @@ export class DotTextareaContentComponent implements OnInit, ControlValueAccessor * @param any fn * @memberof DotTextareaContentComponent */ - registerOnChange(fn): void { + registerOnChange(fn: (value: unknown) => void): void { this.propagateChange = fn; } @@ -191,7 +191,7 @@ export class DotTextareaContentComponent implements OnInit, ControlValueAccessor .map((item) => { return this.DEFAULT_OPTIONS.find((option) => option.value === item); }) - .filter((item) => item) // Remove undefined values in the array + .filter((item): item is SelectItem => !!item) // Remove undefined values : this.DEFAULT_OPTIONS; } } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-wizard/dot-wizard.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-wizard/dot-wizard.component.spec.ts index 46eebfc3ee38..42179c7f0a13 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-wizard/dot-wizard.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-wizard/dot-wizard.component.spec.ts @@ -62,7 +62,7 @@ const wizardInput: DotWizardInput = { standalone: false }) class FormOneComponent { - @Input() data: DotPushPublishDialogData; + @Input() data!: DotPushPublishDialogData; @Output() value = new EventEmitter(); @Output() valid = new EventEmitter(); } @@ -73,7 +73,7 @@ class FormOneComponent { standalone: false }) class FormTwoComponent { - @Input() data: DotPushPublishDialogData; + @Input() data!: DotPushPublishDialogData; @Output() value = new EventEmitter(); @Output() valid = new EventEmitter(); } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-wizard/dot-wizard.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-wizard/dot-wizard.component.ts index 5a3fb45c4af1..6984a93a0420 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-wizard/dot-wizard.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-wizard/dot-wizard.component.ts @@ -41,10 +41,10 @@ import { DotPushPublishFormComponent } from '../forms/dot-push-publish-form/dot- imports: [DialogModule, ButtonModule, DotContainerReferenceDirective] }) export class DotWizardComponent implements AfterViewInit { - #wizardData: { [key: string]: string }; + #wizardData!: { [key: string]: string }; #currentStep = 0; - #componentsHost: DotContainerReferenceDirective[]; - #stepsValidation: boolean[]; + #componentsHost: DotContainerReferenceDirective[] = []; + #stepsValidation: boolean[] = []; #wizardComponentMap: { [key in DotWizardComponentEnum]: Type } = { commentAndAssign: DotCommentAndAssignFormComponent, pushPublish: DotPushPublishFormComponent @@ -55,15 +55,15 @@ export class DotWizardComponent implements AfterViewInit { readonly #dotWizardService = inject(DotWizardService); readonly #destroyRef = inject(DestroyRef); - readonly $data = signal(null); + readonly $data = signal(null); readonly $dialogActions = signal(null); readonly $stepsVisible = signal(false); transform = ''; @ViewChildren(DotContainerReferenceDirective) - formHosts: QueryList; - @ViewChild('dialog', { static: true }) dialog: Dialog; + formHosts!: QueryList; + @ViewChild('dialog', { static: true }) dialog!: Dialog; constructor() { this.#dotWizardService.showDialog$ @@ -118,9 +118,9 @@ export class DotWizardComponent implements AfterViewInit { * @memberof DotWizardComponent */ handleTab(event: KeyboardEvent): void { - const [form]: HTMLFieldSetElement[] = event + const [form] = event .composedPath() - .filter((x: Node) => x.nodeName === 'FORM') as HTMLFieldSetElement[]; + .filter((target) => (target as Node).nodeName === 'FORM') as HTMLFieldSetElement[]; if (form) { if (form.elements.item(form.elements.length - 1) === event.target) { @@ -135,13 +135,24 @@ export class DotWizardComponent implements AfterViewInit { } getWizardComponent(type: DotWizardComponentEnum | string): Type { - return this.#wizardComponentMap[type]; + // The map is keyed by the enum, and this signature also accepts a plain `string` because the + // step's `component` field is one. An unknown component type has nothing to render. + return this.#wizardComponentMap[type as DotWizardComponentEnum]; } private loadComponents(): void { this.#componentsHost = this.formHosts.toArray(); this.#stepsValidation = []; - this.$data().steps.forEach((step: DotWizardStep, index: number) => { + + // `loadComponents` runs from the subscription that has just set `$data`, so this is the + // no-wizard-open interval — there are no steps to build hosts for. + const data = this.$data(); + + if (!data) { + return; + } + + data.steps.forEach((step: DotWizardStep, index: number) => { const componentClass = this.getWizardComponent(step.component); const viewContainerRef = this.#componentsHost[index].viewContainerRef; viewContainerRef.clear(); @@ -152,8 +163,8 @@ export class DotWizardComponent implements AfterViewInit { componentRef.instance.data = step.data; componentRef.instance.value .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe((data: { [key: string]: string }) => - this.consolidateValues(data, index) + .subscribe((data) => + this.consolidateValues(data as { [key: string]: string }, index) ); componentRef.instance.valid .pipe(takeUntilDestroyed(this.#destroyRef)) @@ -188,18 +199,28 @@ export class DotWizardComponent implements AfterViewInit { this.#currentStep += next; this.updateTransform(); this.focusFistFormElement(); + + const actions = this.$dialogActions(); + + // `setDialogActions` runs when the wizard opens and always sets both buttons, so a null + // signal here is the interval before that — when there is no dialog to relabel either. + // Read once rather than per line: the calls below mutate the same object. + if (!actions?.accept || !actions.cancel) { + return; + } + if (this.isLastStep()) { - this.$dialogActions().accept.label = this.#dotMessageService.get('send'); - this.$dialogActions().cancel.disabled = false; + actions.accept.label = this.#dotMessageService.get('send'); + actions.cancel.disabled = false; } else if (this.isFirstStep()) { - this.$dialogActions().cancel.disabled = true; - this.$dialogActions().accept.label = this.#dotMessageService.get('next'); + actions.cancel.disabled = true; + actions.accept.label = this.#dotMessageService.get('next'); } else { - this.$dialogActions().cancel.disabled = false; - this.$dialogActions().accept.label = this.#dotMessageService.get('next'); + actions.cancel.disabled = false; + actions.accept.label = this.#dotMessageService.get('next'); } - this.$dialogActions().accept.disabled = !this.#stepsValidation[this.#currentStep]; + actions.accept.disabled = !this.#stepsValidation[this.#currentStep]; } private getAcceptAction(): void { @@ -216,10 +237,15 @@ export class DotWizardComponent implements AfterViewInit { private setValid(valid: boolean, step: number): void { this.#stepsValidation[step] = valid; - if (this.#currentStep === step && this.$dialogActions()) { + + const actions = this.$dialogActions(); + + // The `accept` check is the one that matters: it is what the spread below rebuilds, and + // `DialogButton` has required members that spreading `undefined` would not supply. + if (this.#currentStep === step && actions?.accept) { this.$dialogActions.set({ - ...this.$dialogActions(), - accept: { ...this.$dialogActions().accept, disabled: !valid } + ...actions, + accept: { ...actions.accept, disabled: !valid } }); } } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-actions-selector-field/dot-workflows-actions-selector-field.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-actions-selector-field/dot-workflows-actions-selector-field.component.spec.ts index 267d482041e6..c01049a75e75 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-actions-selector-field/dot-workflows-actions-selector-field.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-actions-selector-field/dot-workflows-actions-selector-field.component.spec.ts @@ -66,7 +66,7 @@ class DotWorkflowsActionsSelectorFieldServiceMock { class FakeFormComponent implements OnInit { private fb = inject(UntypedFormBuilder); - form: UntypedFormGroup; + form!: UntypedFormGroup; workflows: DotCMSWorkflow[] = []; ngOnInit() { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-actions-selector-field/dot-workflows-actions-selector-field.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-actions-selector-field/dot-workflows-actions-selector-field.component.ts index 602c3d7b85bb..d5d702411744 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-actions-selector-field/dot-workflows-actions-selector-field.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-actions-selector-field/dot-workflows-actions-selector-field.component.ts @@ -45,12 +45,12 @@ export class DotWorkflowsActionsSelectorFieldComponent DotWorkflowsActionsSelectorFieldService ); - @ViewChild('dropdown') dropdown: Select; - @Input() workflows: DotCMSWorkflow[]; + @ViewChild('dropdown') dropdown!: Select; + @Input() workflows!: DotCMSWorkflow[]; - actions$: Observable; + actions$!: Observable; disabled = false; - value: string; + value!: string; ngOnInit() { this.actions$ = this.dotWorkflowsActionsSelectorFieldService.get().pipe( @@ -66,8 +66,8 @@ export class DotWorkflowsActionsSelectorFieldComponent } ngOnChanges(changes: SimpleChanges) { - if (!changes.workflows.firstChange) { - this.dotWorkflowsActionsSelectorFieldService.load(changes.workflows.currentValue); + if (!changes['workflows'].firstChange) { + this.dotWorkflowsActionsSelectorFieldService.load(changes['workflows'].currentValue); } } @@ -91,7 +91,7 @@ export class DotWorkflowsActionsSelectorFieldComponent * @param {*} fn * @memberof DotWorkflowsActionsSelectorFieldComponent */ - registerOnChange(fn): void { + registerOnChange(fn: (value: unknown) => void): void { this.propagateChange = fn; } @@ -137,6 +137,6 @@ export class DotWorkflowsActionsSelectorFieldComponent * and the current value is not in the list of options). Otherwise, returns `false`. */ private shouldClearDropdown(dropdown: Select, options: string[], value: string): boolean { - return dropdown && options.length && !options.includes(value); + return !!dropdown && options.length > 0 && !options.includes(value); } } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-actions-selector-field/services/dot-workflows-actions-selector-field.service.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-actions-selector-field/services/dot-workflows-actions-selector-field.service.spec.ts index 52c4b9075050..6fcab925c634 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-actions-selector-field/services/dot-workflows-actions-selector-field.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-actions-selector-field/services/dot-workflows-actions-selector-field.service.spec.ts @@ -9,6 +9,7 @@ import { SelectItemGroup } from 'primeng/api'; import { DotHttpErrorManagerService, DotWorkflowsActionsService } from '@dotcms/data-access'; import { HttpCode, ResponseView } from '@dotcms/dotcms-js'; +import { DotCMSResponse } from '@dotcms/dotcms-models'; import { mockWorkflows, mockWorkflowsActions } from '@dotcms/utils-testing'; import { DotWorkflowsActionsSelectorFieldService } from './dot-workflows-actions-selector-field.service'; @@ -92,10 +93,9 @@ describe('DotWorkflowsActionsSelectorFieldService', () => { it('should handle error', () => { const mock = new ResponseView( - new HttpResponse({ + new HttpResponse>({ body: null, status: HttpCode.BAD_REQUEST, - headers: null, url: '' }) ); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-actions-selector-field/services/dot-workflows-actions-selector-field.service.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-actions-selector-field/services/dot-workflows-actions-selector-field.service.ts index 3866dd77ebc2..cdde48a8cc26 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-actions-selector-field/services/dot-workflows-actions-selector-field.service.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-actions-selector-field/services/dot-workflows-actions-selector-field.service.ts @@ -15,7 +15,7 @@ export class DotWorkflowsActionsSelectorFieldService { private dotWorkflowsActionsService = inject(DotWorkflowsActionsService); private dotHttpErrorManagerService = inject(DotHttpErrorManagerService); - private data$: BehaviorSubject = new BehaviorSubject([]); + private data$: BehaviorSubject = new BehaviorSubject([]); /** * Get actions grouped by workflows @@ -62,7 +62,8 @@ export class DotWorkflowsActionsSelectorFieldService { const { label, value } = this.getSelectItem(workflow); return { - label, + // `SelectItemGroup.label` is required where `SelectItem.label` is not. + label: label ?? '', value, items: this.getActionsByWorkflowId(workflow, actions).map(this.getSelectItem) }; @@ -71,7 +72,7 @@ export class DotWorkflowsActionsSelectorFieldService { private getSelectItem({ name, id }: DotCMSWorkflowAction | DotCMSWorkflow): SelectItem { return { - label: name, + label: name ?? '', value: id }; } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-selector-field/dot-workflows-selector-field.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-selector-field/dot-workflows-selector-field.component.ts index 9493f70d9617..0bef7301f9bc 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-selector-field/dot-workflows-selector-field.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-workflows-selector-field/dot-workflows-selector-field.component.ts @@ -27,7 +27,7 @@ import { DotMessagePipe } from '@dotcms/ui'; export class DotWorkflowsSelectorFieldComponent implements ControlValueAccessor, OnInit { private dotWorkflowService = inject(DotWorkflowService); - options$: Observable; + options$!: Observable; value: DotCMSWorkflow[] = []; disabled = false; @@ -41,7 +41,7 @@ export class DotWorkflowsSelectorFieldComponent implements ControlValueAccessor, * @param {*} fn * @memberof DotWorkflowsSelectorFieldComponent */ - registerOnChange(fn): void { + registerOnChange(fn: (value: unknown) => void): void { this.propagateChange = fn; } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/forms/dot-comment-and-assign-form/dot-comment-and-assign-form.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/forms/dot-comment-and-assign-form/dot-comment-and-assign-form.component.ts index 2ed7fba0ffab..544b08fad92b 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/forms/dot-comment-and-assign-form/dot-comment-and-assign-form.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/forms/dot-comment-and-assign-form/dot-comment-and-assign-form.component.ts @@ -72,11 +72,11 @@ export class DotCommentAndAssignFormComponent private readonly cdr = inject(ChangeDetectorRef); fb = inject(UntypedFormBuilder); - @Input() data: DotCommentAndAssignData; + @Input() data!: DotCommentAndAssignData; @Output() value = new EventEmitter(); @Output() valid = new EventEmitter(); - form: UntypedFormGroup; - dotRoles: SelectItem[]; + form!: UntypedFormGroup; + dotRoles: SelectItem[] = []; private destroy$: Subject = new Subject(); @@ -84,7 +84,7 @@ export class DotCommentAndAssignFormComponent if (this.data) { if (this.data[DotActionInputs.ASSIGNABLE]) { this.dotRolesService - .get(this.data.roleId, this.data.roleHierarchy) + .get(this.data.roleId ?? '', this.data.roleHierarchy) .pipe(take(1)) .subscribe((items: DotRole[]) => { this.dotRoles = items.map((role) => { @@ -109,7 +109,9 @@ export class DotCommentAndAssignFormComponent private initForm(): void { this.form = this.fb.group({ - assign: this.dotRoles ? this.dotRoles[0].value : '', + // `?.length`, not truthiness: `[]` is truthy, so this took the first-role branch and + // read `[0].value` off an empty list. The check has always meant "if there are roles". + assign: this.dotRoles?.length ? this.dotRoles[0].value : '', comments: '', pathToMove: this.data[DotActionInputs.MOVEABLE] ? ['', [Validators.required]] : '' }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/forms/dot-push-publish-form/dot-push-publish-form.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/forms/dot-push-publish-form/dot-push-publish-form.component.spec.ts index 750e7aade83c..503f51398037 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/forms/dot-push-publish-form/dot-push-publish-form.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/forms/dot-push-publish-form/dot-push-publish-form.component.spec.ts @@ -27,10 +27,10 @@ import { import { DotcmsConfigService, LoginService } from '@dotcms/dotcms-js'; import { DotPushPublishDialogData } from '@dotcms/dotcms-models'; import { - DotDialogModule, DotFieldValidationMessageComponent, DotMessagePipe, - DotSafeHtmlPipe + DotSafeHtmlPipe, + PushPublishEnvSelectorComponent } from '@dotcms/ui'; import { DotcmsConfigServiceMock, @@ -61,8 +61,8 @@ const messageServiceMock = new MockDotMessageService({ standalone: false }) class TestHostComponent { - @Input() data: DotPushPublishDialogData; - valid: boolean; + @Input() data?: DotPushPublishDialogData; + valid!: boolean; value: any; } @@ -143,7 +143,6 @@ xdescribe('DotPushPublishFormComponent', () => { AutoFocusModule, FormsModule, DatePickerModule, - DotDialogModule, PushPublishEnvSelectorComponent, ReactiveFormsModule, SelectModule, @@ -156,9 +155,11 @@ xdescribe('DotPushPublishFormComponent', () => { }); beforeEach(() => { - jest.spyOn(Intl, 'DateTimeFormat').mockReturnValue({ + // No type argument: `jest.spyOn` takes either none or two (``), and + // only `resolvedOptions` is read from the stub — the rest of `DateTimeFormat` is not. + jest.spyOn(Intl, 'DateTimeFormat').mockReturnValue({ resolvedOptions: () => ({ timeZone: localTZ }) - }); + } as unknown as Intl.DateTimeFormat); jest.useFakeTimers(); jest.setSystemTime(mockDate); fixture = TestBed.createComponent(TestHostComponent); @@ -206,9 +207,9 @@ xdescribe('DotPushPublishFormComponent', () => { By.css('.push-publish-dialog__timezone-label span') ).nativeElement; expect(timezoneDropDownContainer.attributes['hidden']).toBeDefined(); - expect(timezoneDropDown.options.length).toEqual(mockDotTimeZones.length); + expect(timezoneDropDown.options!.length).toEqual(mockDotTimeZones.length); expect(timeZoneLabel.outerText).toEqual( - pushPublishForm.timeZoneOptions.find(({ value }) => value === localTZ)['label'] + pushPublishForm.timeZoneOptions.find(({ value }) => value === localTZ)!.label ); }); @@ -243,7 +244,7 @@ xdescribe('DotPushPublishFormComponent', () => { By.css('.push-publish-dialog__timezone-label span') ).nativeElement; expect(timeZoneLabel.outerText).toEqual( - pushPublishForm.timeZoneOptions.find(({ value }) => value === changedTZ)['label'] + pushPublishForm.timeZoneOptions.find(({ value }) => value === changedTZ)!.label ); }); @@ -289,7 +290,7 @@ xdescribe('DotPushPublishFormComponent', () => { }); it('should disable publish expired on removeOnly data ', () => { - hostComponent.data = null; + hostComponent.data = undefined; fixture.detectChanges(); hostComponent.data = { removeOnly: true, ...mockPublishFormData }; fixture.detectChanges(); @@ -300,7 +301,7 @@ xdescribe('DotPushPublishFormComponent', () => { }); it('should disable remove and publish expired on restricted data ', () => { - hostComponent.data = null; + hostComponent.data = undefined; fixture.detectChanges(); hostComponent.data = { restricted: true, ...mockPublishFormData }; fixture.detectChanges(); @@ -312,7 +313,7 @@ xdescribe('DotPushPublishFormComponent', () => { }); it('should disable remove and publish expired on cats data ', () => { - hostComponent.data = null; + hostComponent.data = undefined; fixture.detectChanges(); hostComponent.data = { cats: true, ...mockPublishFormData }; fixture.detectChanges(); @@ -331,7 +332,7 @@ xdescribe('DotPushPublishFormComponent', () => { customCode: '

Code

', ...mockPublishFormData }; - hostComponent.data = null; + hostComponent.data = undefined; fixture.detectChanges(); hostComponent.data = mockCustomCode; fixture.detectChanges(); @@ -347,7 +348,7 @@ xdescribe('DotPushPublishFormComponent', () => { }); it('should be valid when environment selected', () => { - pushPublishForm.form.get('environment').setValue(['123']); + pushPublishForm.form.get('environment')!.setValue(['123']); expect(hostComponent.valid).toEqual(true); expect(hostComponent.value).toEqual({ ...mockFormInitialValue, @@ -358,17 +359,17 @@ xdescribe('DotPushPublishFormComponent', () => { it('should show error messages', () => { selectActionButtons = fixture.debugElement.queryAll(By.css('p-selectbutton .p-button')); selectActionButtons[2].triggerEventHandler('click', {}); - pushPublishForm.form.get('environment').setValue(null); - pushPublishForm.form.get('environment').markAsDirty(); - pushPublishForm.form.get('environment').updateValueAndValidity(); + pushPublishForm.form.get('environment')!.setValue(null); + pushPublishForm.form.get('environment')!.markAsDirty(); + pushPublishForm.form.get('environment')!.updateValueAndValidity(); - pushPublishForm.form.get('publishDate').setValue(null); - pushPublishForm.form.get('publishDate').markAsDirty(); - pushPublishForm.form.get('publishDate').updateValueAndValidity(); + pushPublishForm.form.get('publishDate')!.setValue(null); + pushPublishForm.form.get('publishDate')!.markAsDirty(); + pushPublishForm.form.get('publishDate')!.updateValueAndValidity(); - pushPublishForm.form.get('expireDate').setValue(null); - pushPublishForm.form.get('expireDate').markAsDirty(); - pushPublishForm.form.get('expireDate').updateValueAndValidity(); + pushPublishForm.form.get('expireDate')!.setValue(null); + pushPublishForm.form.get('expireDate')!.markAsDirty(); + pushPublishForm.form.get('expireDate')!.updateValueAndValidity(); fixture.detectChanges(); const errorMessages = fixture.debugElement.queryAll(By.css('.p-invalid')); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/forms/dot-push-publish-form/dot-push-publish-form.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/forms/dot-push-publish-form/dot-push-publish-form.component.ts index c1d5715d99d4..27a867f5f1ce 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/forms/dot-push-publish-form/dot-push-publish-form.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/forms/dot-push-publish-form/dot-push-publish-form.component.ts @@ -76,37 +76,42 @@ export class DotPushPublishFormComponent readonly #dotMessageService = inject(DotMessageService); dateFieldMinDate = new Date(); - form: UntypedFormGroup; - pushActions: SelectItem[]; - filterOptions: SelectItem[] = null; - timeZoneOptions: SelectItem[] = null; + form!: UntypedFormGroup; + pushActions: SelectItem[] = []; + filterOptions: SelectItem[] = []; + timeZoneOptions: SelectItem[] = []; eventData: DotPushPublishDialogData = { assetIdentifier: '', title: '' }; - assetIdentifier: string; - localTimezone: string; + assetIdentifier!: string; + localTimezone!: string; showTimezonePicker = false; changeTimezoneActionLabel = this.#dotMessageService.get('Change'); - @Input() data: DotPushPublishDialogData; + @Input() data?: DotPushPublishDialogData; @Output() value = new EventEmitter(); @Output() valid = new EventEmitter(); - @ViewChild('customCode', { static: true }) customCodeContainer: ElementRef; + @ViewChild('customCode', { static: true }) customCodeContainer!: ElementRef; - private defaultFilterKey: string; - private _filterOptions: SelectItem[] = null; + private defaultFilterKey!: string; + private _filterOptions: SelectItem[] = []; private destroy$: Subject = new Subject(); ngOnInit() { - if (this.data) { + // Held in a local so the narrowing survives into the `loadFilters` callback below. + const data = this.data; + if (data) { this.setPreviousDayToMinDate(); - if (this.filterOptions) { - this.loadData(this.data); + // `.length`, not truthiness: this branch means "filters are already loaded", and an + // empty array is truthy — so it would have skipped the fetch and rendered no filters. + // No test covers this path. + if (this.filterOptions.length) { + this.loadData(data); } else { this.loadFilters() .pipe(take(1)) .subscribe(() => { - this.loadData(this.data); + this.loadData(data); }); } } @@ -132,7 +137,8 @@ export class DotPushPublishFormComponent * @memberof DotPushPublishFormComponent */ updateTimezoneLabel(timezone: string): void { - this.localTimezone = this.timeZoneOptions.find(({ value }) => value === timezone)['label']; + this.localTimezone = + this.timeZoneOptions.find(({ value }) => value === timezone)?.label ?? ''; } /** @@ -175,20 +181,26 @@ export class DotPushPublishFormComponent private loadCustomCode(): void { this.dotParseHtmlService.parse( - this.eventData.customCode, + this.eventData.customCode ?? '', this.customCodeContainer.nativeElement, true ); } private setUsersTimeZone(): void { - const ppTimezone = this.form.get('timezoneId'); + const ppTimezone = this.form.controls['timezoneId']; const localTZItem = this.timeZoneOptions.find( ({ value }) => value === Intl.DateTimeFormat().resolvedOptions().timeZone ); + // The list comes from the server; it need not carry the browser's resolved zone, in which + // case the form keeps whatever default it was built with. + if (!localTZItem) { + return; + } + ppTimezone.setValue(localTZItem.value); - this.localTimezone = localTZItem.label; + this.localTimezone = localTZItem.label ?? ''; } private loadTimezones(): void { @@ -241,9 +253,9 @@ export class DotPushPublishFormComponent environment: ['', [Validators.required]] }); - const publishDate = this.form.get('publishDate'); - const expireDate = this.form.get('expireDate'); - const ppFilter = this.form.get('filterKey'); + const publishDate = this.form.controls['publishDate']; + const expireDate = this.form.controls['expireDate']; + const ppFilter = this.form.controls['filterKey']; const enableFilters = () => { ppFilter.enable(); @@ -251,17 +263,15 @@ export class DotPushPublishFormComponent ppFilter.setValue(this.defaultFilterKey); }; - this.form - .get('filterKey') - .valueChanges.pipe(takeUntil(this.destroy$)) + this.form.controls['filterKey'].valueChanges + .pipe(takeUntil(this.destroy$)) .pipe(filter((value: string) => !!value)) .subscribe((filterSelected: string) => { this.defaultFilterKey = filterSelected; }); - this.form - .get('pushActionSelected') - .valueChanges.pipe(takeUntil(this.destroy$)) + this.form.controls['pushActionSelected'].valueChanges + .pipe(takeUntil(this.destroy$)) .subscribe((pushActionSelected: string) => { switch (pushActionSelected) { case 'publish': { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/dot-loading-indicator/dot-loading-indicator.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/dot-loading-indicator/dot-loading-indicator.component.ts index 2685133e9ea9..a2517934730a 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/dot-loading-indicator/dot-loading-indicator.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/dot-loading-indicator/dot-loading-indicator.component.ts @@ -22,7 +22,7 @@ export class DotLoadingIndicatorComponent { dotLoadingIndicatorService = inject(DotLoadingIndicatorService); @Input() - fullscreen: boolean; + fullscreen!: boolean; @Input() set show(status: ComponentStatus) { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.spec.ts index a46945552a3f..80e4621dd546 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.spec.ts @@ -143,7 +143,7 @@ describe('IframeComponent', () => { }); it('should bind src to the iframe', () => { - expect(iframeEl.properties.srcdoc).toBe(''); + expect(iframeEl.properties['srcdoc']).toBe(''); }); it('should reload iframe', () => { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.ts index 61c68f3a7ac9..97710f1cc88b 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.ts @@ -60,9 +60,9 @@ export class IframeComponent implements OnInit, OnDestroy { iframeOverlayService = inject(IframeOverlayService); loggerService = inject(LoggerService); - @ViewChild('iframeElement') iframeElement: ElementRef; + @ViewChild('iframeElement') iframeElement!: ElementRef; - @Input() src: string; + @Input() src!: string; $isLoading = input(false, { alias: 'isLoading' }); @@ -99,11 +99,12 @@ export class IframeComponent implements OnInit, OnDestroy { .ran() .pipe(takeUntil(this.destroy$)) .subscribe((func: DotFunctionInfo) => { - if ( - this.getIframeWindow() && - typeof this.getIframeWindow()[func.name] === 'function' - ) { - this.getIframeWindow()[func.name](...this.setArgs(func.args)); + const iframeWindow = this.getIframeWindow() as unknown as + | Record void> + | undefined; + + if (typeof iframeWindow?.[func.name] === 'function') { + iframeWindow[func.name](...this.setArgs(func.args)); } }); @@ -111,10 +112,10 @@ export class IframeComponent implements OnInit, OnDestroy { .reloadedColors() .pipe(takeUntil(this.destroy$)) .subscribe(() => { - const doc = this.getIframeDocument(); + const html = this.getIframeDocument()?.querySelector('html'); - if (doc) { - this.dotUiColorsService.setColors(doc.querySelector('html')); + if (html) { + this.dotUiColorsService.setColors(html); } }); @@ -132,9 +133,13 @@ export class IframeComponent implements OnInit, OnDestroy { * @param any $event * @memberof IframeComponent */ - onLoad($event): void { + onLoad($event: Event): void { + // The template binds the DOM `load` event, whose `target` is `EventTarget | null`, and a + // cross-origin or not-yet-ready frame has no `contentDocument`. `parseInt('')` is `NaN`, + // which fails the `> 400` test below exactly as a missing title always did. + const iframe = $event.target as HTMLIFrameElement | null; // JSP is setting the error number in the title - const errorCode = parseInt($event.target.contentDocument.title, 10); + const errorCode = parseInt(iframe?.contentDocument?.title ?? '', 10); if (errorCode > 400) { this.handleErrors(errorCode); } @@ -252,7 +257,7 @@ export class IframeComponent implements OnInit, OnDestroy { } private handleErrors(error: number): void { - const errorMapHandler = { + const errorMapHandler: Record void> = { 401: () => { this.dotRouterService.doLogOut(); } @@ -263,24 +268,31 @@ export class IframeComponent implements OnInit, OnDestroy { } } - private handleIframeEvents($event): void { + private handleIframeEvents($event: Event): void { + // `'ng-event'` is a custom event name, so `addEventListener` resolves to its `Event` + // overload and will not take a `CustomEvent` handler directly. + // + // NOTE: each `.bind(this)` below produces a fresh function, so neither + // `removeEventListener` call has ever removed anything — every iframe load adds another + // pair of listeners. Left alone here: changing listener identity changes what this + // component emits, which is more than a strict-mode pass should do. this.getIframeWindow().removeEventListener('keydown', this.emitKeyDown.bind(this)); this.getIframeWindow().document.removeEventListener( 'ng-event', - this.emitCustonEvent.bind(this) + this.emitCustonEvent.bind(this) as EventListener ); this.getIframeWindow().addEventListener('keydown', this.emitKeyDown.bind(this)); this.getIframeWindow().document.addEventListener( 'ng-event', - this.emitCustonEvent.bind(this) + this.emitCustonEvent.bind(this) as EventListener ); this.charge.emit($event); - const doc = this.getIframeDocument(); + const html = this.getIframeDocument()?.querySelector('html'); - if (doc) { - this.dotUiColorsService.setColors(doc.querySelector('html')); + if (html) { + this.dotUiColorsService.setColors(html); } } @@ -289,7 +301,7 @@ export class IframeComponent implements OnInit, OnDestroy { ?.length; } - private setArgs(args: unknown[]): unknown[] { + private setArgs(args?: unknown[]): unknown[] { return args ? args : []; } } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.spec.ts index 6def81845cbd..b475839d76e4 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.spec.ts @@ -50,7 +50,7 @@ import { DotMenuService } from '../../../../../api/services/dot-menu.service'; import { MockDotUiColorsService } from '../../../../../test/dot-test-bed'; import { DotContentletEditorService } from '../../../dot-contentlet-editor/services/dot-contentlet-editor.service'; import { DotDownloadBundleDialogComponent } from '../../dot-download-bundle-dialog/dot-download-bundle-dialog.component'; -import { IFrameModule } from '../index'; +import { IframeComponent } from '../iframe-component/iframe.component'; const routeDatamock = { canAccessPortlet: true @@ -85,7 +85,7 @@ xdescribe('IframePortletLegacyComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [], - imports: [IFrameModule, RouterTestingModule, DotDownloadBundleDialogComponent], + imports: [IframeComponent, RouterTestingModule, DotDownloadBundleDialogComponent], providers: [ provideHttpClient(), provideHttpClientTesting(), @@ -143,7 +143,7 @@ xdescribe('IframePortletLegacyComponent', () => { route.queryParams = of({ url: 'hello/world' }); route.params = of({ id: 'portlet-id' }); - let src: string; + let src: string | undefined; comp.url.subscribe((url) => { src = url; }); @@ -159,7 +159,7 @@ xdescribe('IframePortletLegacyComponent', () => { jest.spyOn(dotMenuService, 'getUrlById').mockReturnValue(of('fake-url')); - let src: string; + let src: string | undefined; comp.url.subscribe((url) => { src = url; diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.ts index d14f5b3e12c3..6dce3047dd5e 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.ts @@ -58,14 +58,14 @@ export class IframePortletLegacyComponent implements OnInit, OnDestroy { private dotEventsSocket = inject(DotEventsSocket); private dotIframeService = inject(DotIframeService); - canAccessPortlet: boolean; + canAccessPortlet = false; url: BehaviorSubject = new BehaviorSubject(''); isLoading = signal(false); private destroy$: Subject = new Subject(); ngOnInit(): void { - this.dotRouterService.portletReload$.subscribe((portletId: string) => { + this.dotRouterService.portletReload$.subscribe((portletId) => { if (this.dotRouterService.isJSPPortlet()) { this.reloadIframePortlet(portletId); } @@ -85,7 +85,7 @@ export class IframePortletLegacyComponent implements OnInit, OnDestroy { this.route.data .pipe( - map((x) => x?.canAccessPortlet), + map((x) => x?.['canAccessPortlet']), takeUntil(this.destroy$) ) .subscribe((canAccessPortlet: boolean) => { @@ -138,7 +138,7 @@ export class IframePortletLegacyComponent implements OnInit, OnDestroy { private setIframeSrc(): void { // We use the query param to load a page in edit mode in the iframe const queryUrl$ = this.route.queryParams.pipe( - map((x) => x?.url), + map((x) => x?.['url']), map((url: string) => url) ); @@ -152,17 +152,21 @@ export class IframePortletLegacyComponent implements OnInit, OnDestroy { } private setPortletUrl(): void { + const parent = this.route.parent; + + if (!parent) { + return; + } + const portletId$ = this.route.params.pipe( - map((x) => x?.id), + map((x) => x?.['id']), map((id: string) => id) ); portletId$ .pipe( withLatestFrom( - this.route.parent.url.pipe( - map((urlSegment: UrlSegment[]) => urlSegment[0].path) - ) + parent.url.pipe(map((urlSegment: UrlSegment[]) => urlSegment[0].path)) ), mergeMap(([id, url]) => url === 'add' diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/pipes/dot-safe-url/dot-safe-url.pipe.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/pipes/dot-safe-url/dot-safe-url.pipe.ts index 6a7322a16c00..1064b5f9e16b 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/pipes/dot-safe-url/dot-safe-url.pipe.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/pipes/dot-safe-url/dot-safe-url.pipe.ts @@ -10,7 +10,7 @@ export class DotSafeUrlPipe implements PipeTransform { private dotRouterService = inject(DotRouterService); private activatedRoute = inject(ActivatedRoute); - transform(url) { + transform(url: string) { if (url) { const urlWithParameters = this.addURLWithParameters(url); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/searchable-dropdown/component/searchable-dropdown.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/searchable-dropdown/component/searchable-dropdown.component.spec.ts index afa24d6265d1..8b60a85d905f 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/searchable-dropdown/component/searchable-dropdown.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/searchable-dropdown/component/searchable-dropdown.component.spec.ts @@ -13,6 +13,14 @@ import { MockDotMessageService } from '@dotcms/utils-testing'; import { SearchableDropdownComponent } from './searchable-dropdown.component'; +/** The rows this spec feeds the dropdown — the shape the host's `[data]` binding receives. */ +type SiteRow = { + id: number; + label: string; + name: string; + hostName: string; +}; + @Component({ selector: 'dot-host-component', template: ` @@ -36,45 +44,45 @@ import { SearchableDropdownComponent } from './searchable-dropdown.component'; }) class HostTestComponent { @Input() - data: any[]; + data!: any[]; @Input() - cssClass: string; + cssClass!: string; - @Input() action: (action: any) => void; + @Input() action!: (action: any) => void; @Input() - labelPropertyName: string | string[]; + labelPropertyName!: string | string[]; @Input() - valuePropertyName: string; + valuePropertyName!: string; @Input() pageLinkSize = 3; @Input() - rows: number; + rows!: number; @Input() - totalRecords: number; + totalRecords!: number; @Input() placeholder = ''; @Input() - persistentPlaceholder: boolean; + persistentPlaceholder!: boolean; @Input() - width: string; + width!: string; @Input() - overlayWidth: string; + overlayWidth!: string; @Input() - multiple: boolean; + multiple!: boolean; @Input() - disabled: boolean; + disabled!: boolean; } describe('SearchableDropdownComponent', () => { @@ -84,7 +92,7 @@ describe('SearchableDropdownComponent', () => { let hostComp: HostTestComponent; let de: DebugElement; let comp: SearchableDropdownComponent; - const data = []; + const data: SiteRow[] = []; let rows: number; let pageLinkSize: number; let mainButton: DebugElement; @@ -239,7 +247,7 @@ describe('SearchableDropdownComponent', () => { it('should display defaultFilterTemplate', () => { hostFixture.detectChanges(); const searchInput = de.query(By.css('[data-testid="searchInput"]')); - expect(searchInput.attributes.autofocus).toBeDefined(); + expect(searchInput.attributes['autofocus']).toBeDefined(); expect(searchInput).not.toBeNull(); }); @@ -266,7 +274,7 @@ describe('SearchableDropdownComponent', () => { const pageCount = 4; rows = 2; const filter = 'filter'; - let event; + let event: { first: number; rows: number; filter: string }; comp.pageChange.subscribe((e) => { event = e; @@ -293,8 +301,8 @@ describe('SearchableDropdownComponent', () => { }); describe('emit the change event', () => { - let items; - let dataExpected; + let items: DebugElement[]; + let dataExpected: SiteRow; beforeEach(() => { hostComp.data = data; @@ -420,42 +428,42 @@ describe('SearchableDropdownComponent', () => { standalone: false }) class HostTestExternalTemplateComponent { - @Input() data: any[]; + @Input() data!: any[]; @Input() - cssClass: string; + cssClass!: string; - @Input() action: (action: any) => void; + @Input() action!: (action: any) => void; @Input() - labelPropertyName: string | string[]; + labelPropertyName!: string | string[]; @Input() - valuePropertyName: string; + valuePropertyName!: string; @Input() pageLinkSize = 3; @Input() - rows: number; + rows!: number; @Input() - totalRecords: number; + totalRecords!: number; @Input() placeholder = ''; @Input() - persistentPlaceholder: boolean; + persistentPlaceholder!: boolean; @Input() - width: string; + width!: string; @Input() - multiple: boolean; + multiple!: boolean; @Input() - cssClassDataList: string; + cssClassDataList!: string; } describe('SearchableDropdownComponent', () => { @@ -465,7 +473,7 @@ describe('SearchableDropdownComponent', () => { let hostComp: HostTestExternalTemplateComponent; let de: DebugElement; let comp: SearchableDropdownComponent; - const data = []; + const data: SiteRow[] = []; let rows: number; let pageLinkSize: number; let mainButton: DebugElement; diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/searchable-dropdown/component/searchable-dropdown.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/searchable-dropdown/component/searchable-dropdown.component.ts index 366645621735..6248f0e3f2d0 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/searchable-dropdown/component/searchable-dropdown.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/searchable-dropdown/component/searchable-dropdown.component.ts @@ -39,6 +39,14 @@ import { DotIconComponent, DotMessagePipe } from '@dotcms/ui'; * @class SearchableDropdownComponent * @implements {ControlValueAccessor} */ +/** + * A dropdown row. + * + * The component reads whichever properties `labelPropertyName` and `valuePropertyName` name, so a + * row is only ever known by key — never by a fixed shape. + */ +type SearchableDropdownRow = Record; + @Component({ providers: [ { @@ -68,36 +76,36 @@ export class SearchableDropdownComponent private cd = inject(ChangeDetectorRef); @Input() - data: Record[]; + data!: Record[]; - @Input() action: (event: Event) => void; + @Input() action?: (event: Event) => void; @Input() - labelPropertyName: string | string[]; + labelPropertyName!: string | string[]; @Input() - valuePropertyName: string; + valuePropertyName!: string; @Input() pageLinkSize = 3; @Input() - rows: number; + rows!: number; @Input() - cssClass: string; + cssClass!: string; @Input() - cssClassDataList: string; + cssClassDataList!: string; @Input() - totalRecords: number; + totalRecords!: number; @Input() placeholder = ''; @Input() - persistentPlaceholder: boolean; + persistentPlaceholder!: boolean; /** * Sets the width of the searchable-dropdown button @@ -118,16 +126,16 @@ export class SearchableDropdownComponent overlayWidth = '300px'; @Input() - multiple: boolean; + multiple!: boolean; @Input() disabled = false; @Input() - externalItemListTemplate: TemplateRef; + externalItemListTemplate!: TemplateRef; @Input() - externalFilterTemplate: TemplateRef; + externalFilterTemplate!: TemplateRef; @Output() switch: EventEmitter = new EventEmitter(); @@ -145,27 +153,29 @@ export class SearchableDropdownComponent display: EventEmitter = new EventEmitter(); @ViewChild('searchInput', { static: false }) - searchInput: ElementRef; + searchInput!: ElementRef; @ViewChild('searchPanel', { static: true }) - searchPanelRef: Popover; + searchPanelRef!: Popover; @ViewChild('dataView', { static: true }) - dataViewRef: DataView; + dataViewRef!: DataView; @ViewChild('button') - button: ElementRef; + button!: ElementRef; - @ContentChildren(PrimeTemplate) templates: QueryList; + @ContentChildren(PrimeTemplate) templates!: QueryList; valueString = ''; - value: unknown; - overlayPanelMinHeight: string; - options: unknown[]; + /** Null until a row is picked, which is also what `writeValue(null)` sets. */ + value: SearchableDropdownRow | null = null; + overlayPanelMinHeight!: string; + options: SearchableDropdownRow[] = []; label: string | null = null; - externalSelectTemplate: TemplateRef; + externalSelectTemplate!: TemplateRef; - selectedOptionIndex = 0; + /** Null while the overlay is closed — `hideOverlayHandler` clears it. */ + selectedOptionIndex: number | null = 0; selectedOptionValue = ''; propagateChange = (_: unknown) => { @@ -173,7 +183,7 @@ export class SearchableDropdownComponent }; ngOnChanges(changes: SimpleChanges): void { - if (this.usePlaceholder(changes.placeholder) || changes.persistentPlaceholder) { + if (this.usePlaceholder(changes['placeholder']) || changes['persistentPlaceholder']) { this.setLabel(); } @@ -183,9 +193,9 @@ export class SearchableDropdownComponent ngAfterViewInit(): void { if (this.searchInput) { - fromEvent(this.searchInput.nativeElement, 'keyup') + fromEvent(this.searchInput.nativeElement, 'keyup') .pipe( - tap((keyboardEvent: KeyboardEvent) => { + tap((keyboardEvent) => { if ( keyboardEvent.key === 'ArrowUp' || keyboardEvent.key === 'ArrowDown' || @@ -194,7 +204,10 @@ export class SearchableDropdownComponent this.selectDropdownOption(keyboardEvent.key); } }), - map((keyboardEvent: KeyboardEvent) => keyboardEvent.target['value']), + map( + (keyboardEvent: KeyboardEvent) => + (keyboardEvent.target as HTMLInputElement).value + ), distinctUntilChanged(), debounceTime(500) ) @@ -248,10 +261,11 @@ export class SearchableDropdownComponent } setTimeout(() => { - if (!this.overlayPanelMinHeight) { - this.overlayPanelMinHeight = this.searchPanelRef.container - .getBoundingClientRect() - .height.toString(); + // `container` is only set while the popover is mounted, and this runs a tick later. + const container = this.searchPanelRef.container; + + if (!this.overlayPanelMinHeight && container) { + this.overlayPanelMinHeight = container.getBoundingClientRect().height.toString(); } }, 0); this.display.emit(); @@ -267,7 +281,7 @@ export class SearchableDropdownComponent * @param {PaginationEvent} event * @memberof SearchableDropdownComponent */ - paginate(event: DataViewLazyLoadEvent): void { + paginate(event: DataViewLazyLoadEvent | null): void { const paginationEvent = { first: event?.first ?? 0, rows: event?.rows ?? this.rows, @@ -285,7 +299,7 @@ export class SearchableDropdownComponent * @param * value * @memberof SearchableDropdownComponent */ - writeValue(value: unknown): void { + writeValue(value: SearchableDropdownRow | null): void { this.setValue(value); } @@ -295,7 +309,7 @@ export class SearchableDropdownComponent * @param {*} fn * @memberof SearchableDropdownComponent */ - registerOnChange(fn): void { + registerOnChange(fn: (value: unknown) => void): void { this.propagateChange = fn; } @@ -311,14 +325,19 @@ export class SearchableDropdownComponent * @returns {string} * @memberof SearchableDropdownComponent */ - getItemLabel(dropDownItem: unknown): string { - let resultProps; - if (dropDownItem && Array.isArray(this.labelPropertyName)) { - resultProps = this.labelPropertyName.map((item) => { + getItemLabel(dropDownItem: SearchableDropdownRow | null | undefined): string { + if (!dropDownItem) { + return ''; + } + + if (Array.isArray(this.labelPropertyName)) { + const resultProps = this.labelPropertyName.map((item) => { if (item.indexOf('.') > -1) { - let propertyName; + let propertyName: unknown; item.split('.').forEach((nested) => { - propertyName = propertyName ? propertyName[nested] : dropDownItem[nested]; + propertyName = propertyName + ? (propertyName as SearchableDropdownRow)[nested] + : dropDownItem[nested]; }); return propertyName; @@ -328,9 +347,9 @@ export class SearchableDropdownComponent }); return resultProps.join(' - '); - } else if (dropDownItem) { - return dropDownItem[`${this.labelPropertyName}`]; } + + return String(dropDownItem[`${this.labelPropertyName}`] ?? ''); } /** @@ -340,7 +359,7 @@ export class SearchableDropdownComponent * @param {*} item * @memberof SearchableDropdownComponent */ - handleClick(item: unknown): void { + handleClick(item: SearchableDropdownRow): void { if (this.value !== item || this.multiple) { this.setValue(item); this.propagateChange(this.getValueToPropagate()); @@ -386,31 +405,44 @@ export class SearchableDropdownComponent ? this.rows : this.options.length : this.options.length; - if (actionKey === 'ArrowDown' && itemsCount - 1 > this.selectedOptionIndex) { - this.selectedOptionIndex++; - this.selectedOptionValue = this.getItemLabel(this.options[this.selectedOptionIndex]); - } else if (actionKey === 'ArrowUp' && 0 < this.selectedOptionIndex) { - this.selectedOptionIndex--; - this.selectedOptionValue = this.getItemLabel(this.options[this.selectedOptionIndex]); - } else if (actionKey === 'Enter' && this.selectedOptionIndex !== null) { - this.handleClick(this.options[this.selectedOptionIndex]); + const index = this.selectedOptionIndex; + + if (index === null) { + return; + } + + if (actionKey === 'ArrowDown' && itemsCount - 1 > index) { + this.selectedOptionIndex = index + 1; + this.selectedOptionValue = this.getItemLabel(this.options[index + 1]); + } else if (actionKey === 'ArrowUp' && 0 < index) { + this.selectedOptionIndex = index - 1; + this.selectedOptionValue = this.getItemLabel(this.options[index - 1]); + } else if (actionKey === 'Enter') { + this.handleClick(this.options[index]); } this.cd.detectChanges(); } private setLabel(): void { + // Cast rather than coerced: the template's `[class.selected]` compares + // `item[getValueLabelPropertyName()]` against this, so both sides must read the property + // the same way — including when no `labelPropertyName` is configured and both are + // `undefined`. Coercing this side to `''` makes that comparison false and the selected row + // loses its class. this.valueString = this.value - ? this.value[this.getValueLabelPropertyName()] + ? (this.value[this.getValueLabelPropertyName()] as string) : this.placeholder; this.label = this.persistentPlaceholder ? this.placeholder : this.valueString; this.cd.markForCheck(); } private setOptions(change: SimpleChanges): void { - if (change.data && change.data.currentValue) { - this.options = structuredClone(change.data.currentValue).map((item) => { - item.label = this.getItemLabel(item); + if (change['data'] && change['data'].currentValue) { + this.options = ( + structuredClone(change['data'].currentValue) as SearchableDropdownRow[] + ).map((item) => { + item['label'] = this.getItemLabel(item); return item; }); @@ -423,7 +455,7 @@ export class SearchableDropdownComponent return placeholderChange && placeholderChange.currentValue && !this.value; } - private setValue(newValue: unknown): void { + private setValue(newValue: SearchableDropdownRow | null): void { this.value = newValue; this.setLabel(); @@ -436,7 +468,7 @@ export class SearchableDropdownComponent } private getValueToPropagate() { - return !this.valuePropertyName ? this.value : this.value[this.valuePropertyName]; + return !this.valuePropertyName ? this.value : this.value?.[this.valuePropertyName]; } } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/searchable-dropdown/index.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/searchable-dropdown/index.ts deleted file mode 100644 index 9611b0ff8c10..000000000000 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/searchable-dropdown/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './searchable-dropdown.module'; diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-add-persona-dialog.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-add-persona-dialog.component.spec.ts index bb45ff9ccccd..cac21fa7738f 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-add-persona-dialog.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-add-persona-dialog.component.spec.ts @@ -87,18 +87,18 @@ describe('DotAddPersonaDialogComponent', () => { it('should set dialog actions with correct labels and initial state', () => { expect(spectator.component.dialogActions).toBeDefined(); - expect(spectator.component.dialogActions.accept.label).toEqual('Accept'); - expect(spectator.component.dialogActions.accept.disabled).toBe(true); - expect(spectator.component.dialogActions.accept.action).toEqual(expect.any(Function)); - expect(spectator.component.dialogActions.cancel.label).toEqual('Cancel'); - expect(spectator.component.dialogActions.cancel.action).toEqual(expect.any(Function)); + expect(spectator.component.dialogActions.accept!.label).toEqual('Accept'); + expect(spectator.component.dialogActions.accept!.disabled).toBe(true); + expect(spectator.component.dialogActions.accept!.action).toEqual(expect.any(Function)); + expect(spectator.component.dialogActions.cancel!.label).toEqual('Cancel'); + expect(spectator.component.dialogActions.cancel!.action).toEqual(expect.any(Function)); }); it('should enable accept button when form becomes valid', () => { spectator.triggerEventHandler('dot-create-persona-form', 'isValid', true); spectator.detectChanges(); - expect(spectator.component.dialogActions.accept.disabled).toBe(false); + expect(spectator.component.dialogActions.accept!.disabled).toBe(false); }); it('should reset form, disable accept and set visible to false on closeDialog', () => { @@ -109,7 +109,7 @@ describe('DotAddPersonaDialogComponent', () => { expect(formComponent.resetForm).toHaveBeenCalled(); expect(spectator.component.visible).toBe(false); - expect(spectator.component.dialogActions.accept.disabled).toBe(true); + expect(spectator.component.dialogActions.accept!.disabled).toBe(true); }); it('should call closeDialog when p-dialog visibleChange emits false', () => { @@ -177,12 +177,12 @@ describe('DotAddPersonaDialogComponent', () => { ); expect(spectator.component.createdPersona.emit).toHaveBeenCalledTimes(1); expect(spectator.component.closeDialog).toHaveBeenCalled(); - expect(spectator.component.dialogActions.accept.disabled).toBe(true); + expect(spectator.component.dialogActions.accept!.disabled).toBe(true); }); it('should call dotHttpErrorManagerService when endpoint fails and re-enable accept button', () => { const fake500Response = mockResponseView(500); - spectator.component.dialogActions.accept.disabled = true; + spectator.component.dialogActions.accept!.disabled = true; jest.spyOn( dotWorkflowActionsFireService, 'publishContentletAndWaitForIndex' @@ -191,7 +191,7 @@ describe('DotAddPersonaDialogComponent', () => { submitForm(); expect(spectator.component.createdPersona.emit).not.toHaveBeenCalled(); - expect(spectator.component.dialogActions.accept.disabled).toBe(false); + expect(spectator.component.dialogActions.accept!.disabled).toBe(false); expect(dotHttpErrorManagerService.handle).toHaveBeenCalledTimes(1); expect(dotHttpErrorManagerService.handle).toHaveBeenCalledWith(fake500Response); }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-add-persona-dialog.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-add-persona-dialog.component.ts index e776ab99c1c5..d9db91d3baba 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-add-persona-dialog.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-add-persona-dialog.component.ts @@ -39,11 +39,15 @@ export class DotAddPersonaDialogComponent implements OnInit { private dotHttpErrorManagerService = inject(DotHttpErrorManagerService); @Input() visible = false; - @Input() personaName: string; + @Input() personaName!: string; @Output() createdPersona: EventEmitter = new EventEmitter(); - @ViewChild('personaForm') personaForm: DotCreatePersonaFormComponent; + @ViewChild('personaForm') personaForm!: DotCreatePersonaFormComponent; - dialogActions: DotDialogActions; + /** + * `accept` is required here even though `DotDialogActions` declares it optional: the setter + * below always builds one, and the handlers toggle its `disabled` flag in place. + */ + dialogActions!: DotDialogActions & Required>; ngOnInit() { this.setDialogActions(); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-create-persona-form/dot-create-persona-form.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-create-persona-form/dot-create-persona-form.component.spec.ts index de5f5d496cfe..fe49ef690662 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-create-persona-form/dot-create-persona-form.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-create-persona-form/dot-create-persona-form.component.spec.ts @@ -162,15 +162,15 @@ describe('DotCreatePersonaFormComponent', () => { const hostFolderInput: DebugElement = fixture.debugElement.query( By.css('#content-type-form-host') ); - component.form.get('hostFolder').setValue(mockSites[0].identifier); + component.form.get('hostFolder')!.setValue(mockSites[0].identifier); fixture.detectChanges(); expect(hostFolderInput).toBeTruthy(); - expect(component.form.get('hostFolder').value).toEqual(mockSites[0].identifier); + expect(component.form.get('hostFolder')!.value).toEqual(mockSites[0].identifier); }); it('should update input name when set form name', () => { const nameInput: DebugElement = fixture.debugElement.query(By.css('#persona-name')); - component.form.get('name').setValue('John'); + component.form.get('name')!.setValue('John'); fixture.detectChanges(); expect(nameInput.nativeElement.value).toEqual('John'); }); @@ -178,7 +178,7 @@ describe('DotCreatePersonaFormComponent', () => { it('should set Key Tag camel case based on the name value', () => { const nameInput: DebugElement = fixture.debugElement.query(By.css('#persona-name')); const keyTagInput: DebugElement = fixture.debugElement.query(By.css('#persona-keyTag')); - component.form.get('name').setValue('John Doe'); + component.form.get('name')!.setValue('John Doe'); nameInput.triggerEventHandler('keyup', {}); fixture.detectChanges(); expect(keyTagInput.nativeElement.value).toEqual('johnDoe'); @@ -210,7 +210,7 @@ describe('DotCreatePersonaFormComponent', () => { expect(fileUpload).toBeTruthy(); fileUpload.triggerEventHandler('onUpload', mockFileUploadResponse); fixture.detectChanges(); - expect(component.form.get('photo').value).toEqual('temp-file_123'); + expect(component.form.get('photo')!.value).toEqual('temp-file_123'); expect(component.tempUploadedFile).toEqual(mockDotCMSTempFile); }); @@ -219,12 +219,12 @@ describe('DotCreatePersonaFormComponent', () => { // binding changes 22→-1 in the same cycle). To use a click-based test, the NG0100 // cause (e.g. DotSiteComponent mock or form control) would need to be fixed first. it('should clear photo form value and tempUploadedFile when removeImage is called', () => { - component.form.get('photo').setValue('test'); + component.form.get('photo')!.setValue('test'); component.tempUploadedFile = mockDotCMSTempFile; component.removeImage(); - expect(component.form.get('photo').value).toEqual(''); + expect(component.form.get('photo')!.value).toEqual(''); expect(component.tempUploadedFile).toEqual(null); }); @@ -243,7 +243,7 @@ describe('DotCreatePersonaFormComponent', () => { it('should emit if form is invalid after changes', () => { jest.spyOn(component.isValid, 'emit'); - component.form.get('photo').setValue('test'); + component.form.get('photo')!.setValue('test'); expect(component.isValid.emit).toHaveBeenCalledWith(false); expect(component.isValid.emit).toHaveBeenCalledTimes(1); }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-create-persona-form/dot-create-persona-form.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-create-persona-form/dot-create-persona-form.component.ts index 64b9d5b4574c..b573319e3a82 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-create-persona-form/dot-create-persona-form.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-create-persona-form/dot-create-persona-form.component.ts @@ -54,8 +54,8 @@ export class DotCreatePersonaFormComponent implements OnInit, OnDestroy { @Input() personaName = ''; @Output() isValid: EventEmitter = new EventEmitter(); - form: UntypedFormGroup; - tempUploadedFile: DotCMSTempFile; + form!: UntypedFormGroup; + tempUploadedFile: DotCMSTempFile | null = null; private destroy$: Subject = new Subject(); @@ -76,8 +76,13 @@ export class DotCreatePersonaFormComponent implements OnInit, OnDestroy { */ onFileUpload(event: DotFileUpload) { const body = event.originalEvent.body; + + if (!body?.tempFiles?.length) { + return; + } + this.tempUploadedFile = body.tempFiles[0] as DotCMSTempFile; - this.form.get('photo').setValue(this.tempUploadedFile.id); + this.form.controls['photo'].setValue(this.tempUploadedFile.id); } /** @@ -87,7 +92,7 @@ export class DotCreatePersonaFormComponent implements OnInit, OnDestroy { */ removeImage(): void { this.tempUploadedFile = null; - this.form.get('photo').setValue(''); + this.form.controls['photo'].setValue(''); } /** @@ -96,7 +101,7 @@ export class DotCreatePersonaFormComponent implements OnInit, OnDestroy { * @memberof DotCreatePersonaFormComponent */ setKeyTag(): void { - this.form.get('keyTag').setValue(camelCase(this.form.get('name').value)); + this.form.controls['keyTag'].setValue(camelCase(this.form.controls['name'].value)); } /** @@ -107,7 +112,7 @@ export class DotCreatePersonaFormComponent implements OnInit, OnDestroy { resetForm(): void { this.tempUploadedFile = null; this.form.reset(); - this.form.get('hostFolder').setValue(this.globalStore.currentSiteId() ?? ''); + this.form.controls['hostFolder'].setValue(this.globalStore.currentSiteId() ?? ''); } private initPersonaForm(): void { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-base-type-selector/dot-base-type-selector.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-base-type-selector/dot-base-type-selector.component.ts index ee91146e773e..55b30b3aca2a 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-base-type-selector/dot-base-type-selector.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-base-type-selector/dot-base-type-selector.component.ts @@ -31,10 +31,10 @@ export class DotBaseTypeSelectorComponent implements OnInit { private dotContentTypeService = inject(DotContentTypeService); private dotMessageService = inject(DotMessageService); - @Input() value: SelectItem; + @Input() value!: SelectItem; @Output() selected = new EventEmitter(); - options: Observable; + options!: Observable; ngOnInit() { this.options = this.dotContentTypeService.getAllContentTypes().pipe( diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-base-type-selector/index.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-base-type-selector/index.ts deleted file mode 100644 index 67ed420b858c..000000000000 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-base-type-selector/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './dot-base-type-selector.module'; diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-container-selector/dot-container-selector.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-container-selector/dot-container-selector.component.spec.ts index 1eba14ff0110..8ed314df70e3 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-container-selector/dot-container-selector.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-container-selector/dot-container-selector.component.spec.ts @@ -163,7 +163,7 @@ describe('ContainerSelectorComponent', () => { searchable.pageChange.emit({ filter: '', first: 0 } as PaginationEvent); tick(); spectator.detectChanges(); - expect(searchable.data[0].identifier).toEqual('427c47a4-c380-439f'); - expect(searchable.data[1].identifier).toEqual('container/path'); + expect(searchable.data[0]['identifier']).toEqual('427c47a4-c380-439f'); + expect(searchable.data[1]['identifier']).toEqual('container/path'); })); }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-container-selector/dot-container-selector.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-container-selector/dot-container-selector.component.ts index cbdfe498eba8..eedfd2d7dcd9 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-container-selector/dot-container-selector.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-container-selector/dot-container-selector.component.ts @@ -42,8 +42,8 @@ export class DotContainerSelectorComponent implements OnInit { @Input() innerClass = ''; - totalRecords: number; - currentContainers: Observable; + totalRecords!: number; + currentContainers!: Observable; ngOnInit(): void { this.paginationService.url = 'v1/containers'; @@ -82,7 +82,7 @@ export class DotContainerSelectorComponent implements OnInit { private getContainersList(filter = '', offset = 0): void { this.paginationService.filter = filter; - this.currentContainers = this.paginationService.getWithOffset(offset).pipe( + this.currentContainers = this.paginationService.getWithOffset(offset).pipe( take(1), map((items: DotContainer[]) => this.setIdentifierReference(items.splice(0))) ); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-content-type-selector/dot-content-type-selector.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-content-type-selector/dot-content-type-selector.component.ts index 85aeae5f60f1..6a1ddca511e2 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-content-type-selector/dot-content-type-selector.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-content-type-selector/dot-content-type-selector.component.ts @@ -31,10 +31,10 @@ export class DotContentTypeSelectorComponent implements OnInit { private dotContentTypeService = inject(DotContentTypeService); private dotMessageService = inject(DotMessageService); - @Input() value: SelectItem; + @Input() value!: SelectItem; @Output() selected = new EventEmitter(); - options$: Observable; + options$!: Observable; ngOnInit() { this.options$ = this.dotContentTypeService.getContentTypes({ per_page: 999 }).pipe( diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-content-type-selector/index.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-content-type-selector/index.ts deleted file mode 100644 index c18f0e7f4c4f..000000000000 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-content-type-selector/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './dot-content-type-selector.module'; diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-add-contentlet/dot-add-contentlet.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-add-contentlet/dot-add-contentlet.component.ts index e7386f4bbd58..9ad09414ffb3 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-add-contentlet/dot-add-contentlet.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-add-contentlet/dot-add-contentlet.component.ts @@ -35,8 +35,8 @@ export class DotAddContentletComponent implements OnInit { @Output() custom: EventEmitter = new EventEmitter(); - url$: Observable; - header$: Observable; + url$!: Observable; + header$!: Observable; ngOnInit() { this.url$ = this.dotContentletEditorService.addUrl$; diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-contentlet-wrapper/dot-contentlet-wrapper.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-contentlet-wrapper/dot-contentlet-wrapper.component.spec.ts index 15feb53f1c37..cbdae3d49c22 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-contentlet-wrapper/dot-contentlet-wrapper.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-contentlet-wrapper/dot-contentlet-wrapper.component.spec.ts @@ -291,7 +291,7 @@ describe('DotContentletWrapperComponent', () => { it('should show confirmation dialog and handle accept', () => { jest.spyOn(dotAlertConfirmService, 'confirm').mockImplementation((conf) => { - conf.accept(); + conf.accept!(); }); dotIframeDialog.triggerEventHandler('custom', { @@ -323,7 +323,7 @@ describe('DotContentletWrapperComponent', () => { it('should show confirmation dialog and handle reject', () => { jest.spyOn(dotAlertConfirmService, 'confirm').mockImplementation((conf) => { - conf.reject(); + conf.reject!(); }); dotIframeDialog.triggerEventHandler('custom', { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-contentlet-wrapper/dot-contentlet-wrapper.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-contentlet-wrapper/dot-contentlet-wrapper.component.ts index b27e92ac790f..c363e55ff45d 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-contentlet-wrapper/dot-contentlet-wrapper.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-contentlet-wrapper/dot-contentlet-wrapper.component.ts @@ -60,7 +60,7 @@ export class DotContentletWrapperComponent { header = ''; @Input() - url: string; + url: string | null = null; @Output() shutdown: EventEmitter = new EventEmitter(); @@ -70,61 +70,63 @@ export class DotContentletWrapperComponent { private isContentletModified = false; private _appMainTitle = ''; - private readonly customEventsHandler; + /** + * Handlers for the custom events the contentlet iframe raises, keyed by event name — the + * dialog forwards anything not listed to `DotCustomEventHandlerService`. + */ + private readonly customEventsHandler: Record void>; private dotCustomEventHandlerService = inject(DotCustomEventHandlerService); constructor() { - if (!this.customEventsHandler) { - this.customEventsHandler = { - close: ({ detail: { data } }: CustomEvent) => { - this.onClose(); - if (data?.redirectUrl) { - this.dotRouterService.goToEditPage({ - url: data.redirectUrl, - language_id: data.languageId - }); - } - }, - 'edit-page': ({ detail: { data } }: CustomEvent) => { + this.customEventsHandler = { + close: ({ detail: { data } }: CustomEvent) => { + this.onClose(); + if (data?.redirectUrl) { this.dotRouterService.goToEditPage({ - url: data.url, - language_id: data.languageId, - host_id: data.hostId - }); - }, - 'deleted-page': () => { - this.onClose(); - }, - 'edit-contentlet-data-updated': (e: CustomEvent) => { - this.isContentletModified = e.detail.payload; - }, - 'save-page': (data: DotCSMSavePageEvent) => { - if (this.shouldRefresh(data)) { - this.dotIframeService.reload(); - } - - // Message emitted to notify DotPagesComponent - this.dotEventsService.notify('save-page', { - payload: data.detail.payload, - value: this.dotMessageService.get('message.content.saved') + url: data.redirectUrl, + language_id: data.languageId }); - - this.isContentletModified = false; - }, - 'edit-contentlet-loaded': (e: CustomEvent) => { - this._appMainTitle = this.titleService.getTitle(); - this.header = e.detail.data.contentType; - this.titleService.setTitle( - `${ - e.detail.data.pageTitle - ? e.detail.data.pageTitle + ' -' - : `${this.dotMessageService.get('New')} ${this.header} -` - } ${this.titleService.getTitle().split(' - ')[1]}` - ); } - }; - } + }, + 'edit-page': ({ detail: { data } }: CustomEvent) => { + this.dotRouterService.goToEditPage({ + url: data.url, + language_id: data.languageId, + host_id: data.hostId + }); + }, + 'deleted-page': () => { + this.onClose(); + }, + 'edit-contentlet-data-updated': (e: CustomEvent) => { + this.isContentletModified = e.detail.payload; + }, + 'save-page': (data: DotCSMSavePageEvent) => { + if (this.shouldRefresh(data)) { + this.dotIframeService.reload(); + } + + // Message emitted to notify DotPagesComponent + this.dotEventsService.notify('save-page', { + payload: data.detail.payload, + value: this.dotMessageService.get('message.content.saved') + }); + + this.isContentletModified = false; + }, + 'edit-contentlet-loaded': (e: CustomEvent) => { + this._appMainTitle = this.titleService.getTitle(); + this.header = e.detail.data.contentType; + this.titleService.setTitle( + `${ + e.detail.data.pageTitle + ? e.detail.data.pageTitle + ' -' + : `${this.dotMessageService.get('New')} ${this.header} -` + } ${this.titleService.getTitle().split(' - ')[1]}` + ); + } + }; } /** @@ -133,7 +135,7 @@ export class DotContentletWrapperComponent { * @param * $event * @memberof DotContentletWrapperComponent */ - onBeforeClose($event?: { close: () => void }): void { + onBeforeClose($event: { close: () => void }): void { if (this.isContentletModified) { this.dotAlertConfirmService.confirm({ accept: () => { @@ -203,7 +205,7 @@ export class DotContentletWrapperComponent { * @param any $event * @memberof DotContentletWrapperComponent */ - onKeyDown($event): void { + onKeyDown($event: KeyboardEvent): void { if (this.dotContentletEditorService.keyDown) { this.dotContentletEditorService.keyDown($event); } @@ -215,7 +217,7 @@ export class DotContentletWrapperComponent { * @param any $event * @memberof DotContentletWrapperComponent */ - onLoad($event): void { + onLoad($event: Event): void { if (this.dotContentletEditorService.load) { this.dotContentletEditorService.load($event); } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.component.spec.ts index d91ec84c5a7a..cf987d62c4bd 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.component.spec.ts @@ -39,8 +39,8 @@ class DotContentletEditorServiceMock { template: `` }) class DotIframeMockComponent { - @Input() url; - @Input() header; + @Input() url!: string; + @Input() header!: string; } describe('DotCreateContentletComponent', () => { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.component.ts index c1bf7cd28d5d..631c23da9f8c 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.component.ts @@ -40,19 +40,15 @@ export class DotCreateContentletComponent implements OnInit { private route = inject(ActivatedRoute); @Output() shutdown: EventEmitter = new EventEmitter(); - url$: Observable; + url$!: Observable; @Output() custom: EventEmitter = new EventEmitter(); ngOnInit() { this.url$ = merge( this.dotContentletEditorService.createUrl$, - this.route.data.pipe(map((x) => x?.url)) - ).pipe( - filter((url: string) => { - return url !== undefined; - }) - ); + this.route.data.pipe(map((x) => x?.['url'])) + ).pipe(filter((url): url is string => url !== undefined)); } /** diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.resolver.service.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.resolver.service.spec.ts index 24fd2df53dc0..2dcf8d14a514 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.resolver.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.resolver.service.spec.ts @@ -1,7 +1,4 @@ -/* eslint-disable @typescript-eslint/no-empty-function */ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import { of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { TestBed, waitForAsync } from '@angular/core/testing'; import { ActivatedRouteSnapshot } from '@angular/router'; @@ -10,14 +7,23 @@ import { DotCreateContentletResolver } from './dot-create-contentlet.resolver.se import { DotContentletEditorService } from '../../services/dot-contentlet-editor.service'; -const activatedRouteSnapshotMock: any = jest.fn('ActivatedRouteSnapshot', [ - 'toString' -]); -activatedRouteSnapshotMock.paramMap = {}; -activatedRouteSnapshotMock.queryParamMap = {}; +// A plain object rather than `jest.fn(name, methods)`: that shape is `jasmine.createSpyObj` +// migrated mechanically, and `jest.fn` accepts neither argument. The spec only assigns and reads +// `paramMap` / `queryParamMap`. +const activatedRouteSnapshotMock = { + paramMap: {} as { get?: () => string | null }, + queryParamMap: {} as { get?: () => string | null } +} as unknown as ActivatedRouteSnapshot & { + paramMap: { get?: () => string | null }; + queryParamMap: { get?: () => string | null }; +}; class DotContentletEditorServiceMock { - getActionUrl(_url: string) {} + // Matches the real service, which returns `Observable`. Declared `void`, every + // `.mockReturnValue(of(...))` below was assigning an observable to a method that returns nothing. + getActionUrl(_url: string): Observable { + return of(''); + } } describe('DotCreateContentletResolver', () => { @@ -43,46 +49,46 @@ describe('DotCreateContentletResolver', () => { })); it('should get and return the action url', () => { - jest.spyOn(dotContentletEditorService, 'getActionUrl').mockReturnValue(of('urlTest')); + jest.spyOn(dotContentletEditorService, 'getActionUrl').mockReturnValue(of('urlTest')); - dotCreateContentletResolver.resolve(activatedRouteSnapshotMock).subscribe((url: string) => { + dotCreateContentletResolver.resolve(activatedRouteSnapshotMock).subscribe((url) => { expect(url).toEqual('urlTest'); }); }); it('should append the folder inode with `?` when the action url has no query string', () => { activatedRouteSnapshotMock.queryParamMap.get = () => 'inode-1'; - jest.spyOn(dotContentletEditorService, 'getActionUrl').mockReturnValue(of('urlTest')); + jest.spyOn(dotContentletEditorService, 'getActionUrl').mockReturnValue(of('urlTest')); - dotCreateContentletResolver.resolve(activatedRouteSnapshotMock).subscribe((url: string) => { + dotCreateContentletResolver.resolve(activatedRouteSnapshotMock).subscribe((url) => { expect(url).toEqual('urlTest?folder=inode-1'); }); }); it('should append the folder inode with `&` when the action url already has a query string', () => { activatedRouteSnapshotMock.queryParamMap.get = () => 'inode-1'; - jest.spyOn(dotContentletEditorService, 'getActionUrl').mockReturnValue( + jest.spyOn(dotContentletEditorService, 'getActionUrl').mockReturnValue( of('urlTest?foo=bar') ); - dotCreateContentletResolver.resolve(activatedRouteSnapshotMock).subscribe((url: string) => { + dotCreateContentletResolver.resolve(activatedRouteSnapshotMock).subscribe((url) => { expect(url).toEqual('urlTest?foo=bar&folder=inode-1'); }); }); it('should encode the folder inode', () => { activatedRouteSnapshotMock.queryParamMap.get = () => 'a b/c'; - jest.spyOn(dotContentletEditorService, 'getActionUrl').mockReturnValue(of('urlTest')); + jest.spyOn(dotContentletEditorService, 'getActionUrl').mockReturnValue(of('urlTest')); - dotCreateContentletResolver.resolve(activatedRouteSnapshotMock).subscribe((url: string) => { + dotCreateContentletResolver.resolve(activatedRouteSnapshotMock).subscribe((url) => { expect(url).toEqual('urlTest?folder=a%20b%2Fc'); }); }); it('should not append anything when there is no folder query param', () => { - jest.spyOn(dotContentletEditorService, 'getActionUrl').mockReturnValue(of('urlTest')); + jest.spyOn(dotContentletEditorService, 'getActionUrl').mockReturnValue(of('urlTest')); - dotCreateContentletResolver.resolve(activatedRouteSnapshotMock).subscribe((url: string) => { + dotCreateContentletResolver.resolve(activatedRouteSnapshotMock).subscribe((url) => { expect(url).toEqual('urlTest'); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.resolver.service.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.resolver.service.ts index 10306327bba5..89b1e7c78a76 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.resolver.service.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.resolver.service.ts @@ -12,26 +12,28 @@ import { DotContentletEditorService } from '../../services/dot-contentlet-editor * * @export * @class DotCreateContentletResolver - * @implements {Resolve>} + * @implements {Resolve>} */ @Injectable() -export class DotCreateContentletResolver implements Resolve> { +export class DotCreateContentletResolver implements Resolve> { private dotContentletEditorService = inject(DotContentletEditorService); - resolve(route: ActivatedRouteSnapshot): Observable { + resolve(route: ActivatedRouteSnapshot): Observable { // When the create flow is opened from a folder context (e.g. Content Drive), a `folder` // inode is passed as a route query param. Append it to the action URL loaded in the legacy // editor iframe so its Host/Folder field pre-selects that folder (edit_contentlet.jsp reads // request.getParameter("folder")). const folder = route.queryParamMap.get('folder'); - return this.dotContentletEditorService.getActionUrl(route.paramMap.get('contentType')).pipe( - take(1), - map((url) => this.appendFolder(url, folder)) - ); + return this.dotContentletEditorService + .getActionUrl(route.paramMap.get('contentType') ?? '') + .pipe( + take(1), + map((url) => this.appendFolder(url, folder)) + ); } - private appendFolder(url: string, folder: string | null): string { + private appendFolder(url: string | null, folder: string | null): string | null { if (!url || !folder) { return url; } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-edit-contentlet/dot-edit-contentlet.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-edit-contentlet/dot-edit-contentlet.component.ts index 5b17a4bad23b..ef3bd8f26f51 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-edit-contentlet/dot-edit-contentlet.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-edit-contentlet/dot-edit-contentlet.component.ts @@ -32,13 +32,13 @@ export class DotEditContentletComponent implements OnInit { private dotContentletEditorService = inject(DotContentletEditorService); @Input() - inode: string; + inode!: string; @Output() shutdown: EventEmitter = new EventEmitter(); @Output() custom: EventEmitter = new EventEmitter(); - url$: Observable; + url$!: Observable; ngOnInit() { this.url$ = this.dotContentletEditorService.editUrl$; diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-reorder-menu/dot-reorder-menu.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-reorder-menu/dot-reorder-menu.component.spec.ts index 4c5086237fb8..8d04493adccd 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-reorder-menu/dot-reorder-menu.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-reorder-menu/dot-reorder-menu.component.spec.ts @@ -10,16 +10,14 @@ import { DotMessageService, DotIframeService, DotRouterService, - DotUiColorsService, - DotLoadingIndicatorService + DotUiColorsService } from '@dotcms/data-access'; import { LoginService, LoggerService, StringUtils } from '@dotcms/dotcms-js'; import { DotMessagePipe } from '@dotcms/ui'; import { LoginServiceMock, MockDotMessageService, - MockDotRouterService, - MockDotUiColorsService + MockDotRouterService } from '@dotcms/utils-testing'; import { DotReorderMenuComponent } from './dot-reorder-menu.component'; @@ -68,7 +66,6 @@ describe('DotReorderMenuComponent', () => { }, { provide: DotRouterService, useClass: MockDotRouterService }, { provide: DotUiColorsService, useClass: MockDotUiColorsService }, - { provide: DotLoadingIndicatorService, useValue: {} }, { provide: IframeOverlayService, useValue: { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-reorder-menu/dot-reorder-menu.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-reorder-menu/dot-reorder-menu.component.ts index 405c378d21e8..1f658a703cbb 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-reorder-menu/dot-reorder-menu.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-reorder-menu/dot-reorder-menu.component.ts @@ -11,7 +11,7 @@ import { DotIframeDialogComponent } from '../../../dot-iframe-dialog/dot-iframe- imports: [DotMessagePipe, DotIframeDialogComponent] }) export class DotReorderMenuComponent { - @Input() url: string; + @Input() url!: string; @Output() shutdown: EventEmitter = new EventEmitter(); /** diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/services/dot-contentlet-editor.service.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/services/dot-contentlet-editor.service.spec.ts index 17992bd135fd..9204192267aa 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/services/dot-contentlet-editor.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/services/dot-contentlet-editor.service.spec.ts @@ -44,7 +44,7 @@ describe('DotContentletEditorService', () => { it('should get action url', () => { const url = '/api/v1/portlet/_actionurl/test'; - service.getActionUrl('test').subscribe((urlString: string) => { + service.getActionUrl('test').subscribe((urlString) => { expect(urlString).toEqual('testString'); }); @@ -56,7 +56,7 @@ describe('DotContentletEditorService', () => { }); it('should set data to add', () => { - service.editUrl$.subscribe((url: string) => { + service.editUrl$.subscribe((url) => { expect(url).toEqual( [ `/c/portal/layout`, @@ -70,7 +70,7 @@ describe('DotContentletEditorService', () => { ); }); - service.header$.subscribe((header: string) => { + service.header$.subscribe((header) => { expect(header).toEqual('This is a header for add'); }); @@ -99,7 +99,7 @@ describe('DotContentletEditorService', () => { }, writable: true }); - service.editUrl$.subscribe((url: string) => { + service.editUrl$.subscribe((url) => { expect(url).toEqual( [ '/c/portal/layout', @@ -113,7 +113,7 @@ describe('DotContentletEditorService', () => { ); }); - service.header$.subscribe((header: string) => { + service.header$.subscribe((header) => { expect(header).toEqual('This is a header for edit'); }); @@ -133,7 +133,7 @@ describe('DotContentletEditorService', () => { }, writable: true }); - service.editUrl$.subscribe((url: string) => { + service.editUrl$.subscribe((url) => { expect(url).toEqual( [ `/c/portal/layout`, @@ -147,7 +147,7 @@ describe('DotContentletEditorService', () => { ); }); - service.header$.subscribe((header: string) => { + service.header$.subscribe((header) => { expect(header).toEqual('This is a header for edit'); }); @@ -167,7 +167,7 @@ describe('DotContentletEditorService', () => { }, writable: true }); - service.editUrl$.subscribe((url: string) => { + service.editUrl$.subscribe((url) => { expect(url).toEqual( [ `/c/portal/layout`, @@ -181,7 +181,7 @@ describe('DotContentletEditorService', () => { ); }); - service.header$.subscribe((header: string) => { + service.header$.subscribe((header) => { expect(header).toEqual('This is a header for edit'); }); @@ -194,11 +194,11 @@ describe('DotContentletEditorService', () => { }); it('should set url to create a contentlet', () => { - service.createUrl$.subscribe((url: string) => { + service.createUrl$.subscribe((url) => { expect(url).toEqual('hello.world.com'); }); - service.header$.subscribe((header: string) => { + service.header$.subscribe((header) => { expect(header).toEqual('This is a header for create'); }); @@ -211,11 +211,11 @@ describe('DotContentletEditorService', () => { }); it('should clear url and undbind', () => { - service.addUrl$.subscribe((url: string) => { + service.addUrl$.subscribe((url) => { expect(url).toEqual(''); }); - service.editUrl$.subscribe((url: string) => { + service.editUrl$.subscribe((url) => { expect(url).toEqual(''); }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/services/dot-contentlet-editor.service.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/services/dot-contentlet-editor.service.ts index b2538704bf68..91df12309de6 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/services/dot-contentlet-editor.service.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/services/dot-contentlet-editor.service.ts @@ -39,28 +39,29 @@ export class DotContentletEditorService { DotCMSContentType | DotCMSContentlet >(); - private data: Subject = new Subject(); + private data: Subject = new Subject(); private _header: Subject = new Subject(); - private _load: ($event: unknown) => void; - private _keyDown: ($event: KeyboardEvent) => void; + /** Both are null until an action binds them, and back to null on `clear()`. */ + private _load: (($event: Event) => void) | null = null; + private _keyDown: (($event: KeyboardEvent) => void) | null = null; get addUrl$(): Observable { return this.data.pipe( - filter((action: DotEditorAction) => this.isAddUrl(action)), + filter((action): action is DotEditorAction => !!action && this.isAddUrl(action)), map((action: DotEditorAction) => this.geAddtUrl(action)) ); } get editUrl$(): Observable { return this.data.pipe( - filter((action: DotEditorAction) => this.isEditUrl(action)), + filter((action): action is DotEditorAction => !!action && this.isEditUrl(action)), mergeMap((action: DotEditorAction) => of(this.getEditUrl(action))) ); } - get createUrl$(): Observable { + get createUrl$(): Observable { return this.data.pipe( - filter((action: DotEditorAction) => this.isCreateUrl(action)), + filter((action): action is DotEditorAction => !!action && this.isCreateUrl(action)), map((action: DotEditorAction) => this.getCreateUrl(action)) ); } @@ -69,11 +70,11 @@ export class DotContentletEditorService { return this._header; } - get loadHandler(): ($event: unknown) => void { + get loadHandler(): (($event: Event) => void) | null { return this._load; } - get keyDownHandler(): ($event: KeyboardEvent) => void { + get keyDownHandler(): (($event: KeyboardEvent) => void) | null { return this._keyDown; } @@ -137,7 +138,7 @@ export class DotContentletEditorService { * @param unknown $event * @memberof DotContentletEditorService */ - load($event: unknown): void { + load($event: Event): void { if (this._load) { this._load($event); } @@ -149,7 +150,7 @@ export class DotContentletEditorService { * @returns Observable * @memberof DotContentletEditorService */ - getActionUrl(contentTypeVariable: string): Observable { + getActionUrl(contentTypeVariable: string): Observable { return this.http .get>(`/api/v1/portlet/_actionurl/${contentTypeVariable}`) .pipe( @@ -185,11 +186,11 @@ export class DotContentletEditorService { private geAddtUrl(action: DotEditorAction): string { return action === null ? '' - : `/html/ng-contentlet-selector.jsp?ng=true&container_id=${action.data.container}&add=${action.data.baseTypes}`; + : `/html/ng-contentlet-selector.jsp?ng=true&container_id=${action.data['container']}&add=${action.data['baseTypes']}`; } - private getCreateUrl(action: DotEditorAction): string { - return action === null ? '' : action.data.url; + private getCreateUrl(action: DotEditorAction): string | undefined { + return action === null ? '' : action.data['url']; } private getEditUrl(action: DotEditorAction): string { @@ -202,20 +203,20 @@ export class DotContentletEditorService { `&p_p_state=maximized`, `&p_p_mode=view`, `&_content_struts_action=%2Fext%2Fcontentlet%2Fedit_contentlet`, - `&_content_cmd=edit&inode=${action.data.inode}` + `&_content_cmd=edit&inode=${action.data['inode']}` ].join(''); } private isAddUrl(action: DotEditorAction): boolean { - return action === null || !!action.data.container; + return action === null || !!action.data['container']; } private isCreateUrl(action: DotEditorAction): boolean { - return action === null || !!action.data.url; + return action === null || !!action.data['url']; } private isEditUrl(action: DotEditorAction): boolean { - return action === null || !!action.data.inode; + return action === null || !!action.data['inode']; } private setData(action: DotEditorAction): void { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-copy-link/dot-copy-link.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-copy-link/dot-copy-link.component.spec.ts index a13a8b10be10..74f77a325697 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-copy-link/dot-copy-link.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-copy-link/dot-copy-link.component.spec.ts @@ -73,9 +73,9 @@ describe('DotCopyLinkComponent', () => { }); it('should have pTooltip attributes', () => { - expect(button.attributes.appendTo).toEqual('body'); - expect(button.attributes.tooltipPosition).toEqual('bottom'); - expect(button.attributes.hideDelay).toEqual('300'); + expect(button.attributes['appendTo']).toEqual('body'); + expect(button.attributes['tooltipPosition']).toEqual('bottom'); + expect(button.attributes['hideDelay']).toEqual('300'); }); it('should copy text to clipboard', () => { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-copy-link/dot-copy-link.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-copy-link/dot-copy-link.component.ts index feed1436f164..3c741f968082 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-copy-link/dot-copy-link.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-copy-link/dot-copy-link.component.ts @@ -26,8 +26,8 @@ export class DotCopyLinkComponent implements OnInit { private dotMessageService = inject(DotMessageService); @Input() copy = ''; - @Input() label: string; - @Input() tooltipText: string; + @Input() label!: string; + @Input() tooltipText!: string; ngOnInit() { this.tooltipText = this.tooltipText || this.dotMessageService.get('Copy'); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-crumbtrail/dot-crumbtrail.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-crumbtrail/dot-crumbtrail.component.spec.ts index cf286bd4d8bd..6dcab2113f88 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-crumbtrail/dot-crumbtrail.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-crumbtrail/dot-crumbtrail.component.spec.ts @@ -41,7 +41,7 @@ describe('DotCrumbtrailComponent', () => { it('should use dot-collapse-breadcrumb component', () => { spectator.detectChanges(); - const breadcrumbMenu = spectator.query(DotCollapseBreadcrumbComponent); + const breadcrumbMenu = spectator.query(DotCollapseBreadcrumbComponent)!; expect(breadcrumbMenu).toBeTruthy(); }); @@ -52,10 +52,10 @@ describe('DotCrumbtrailComponent', () => { { label: 'Last', url: '/last' } ]; - patchState(unprotected(store), { breadcrumbs: crumbs }); + patchState(unprotected(store), { breadcrumbs: crumbs as MenuItem[] }); spectator.detectChanges(); - const breadcrumbMenu = spectator.query(DotCollapseBreadcrumbComponent); + const breadcrumbMenu = spectator.query(DotCollapseBreadcrumbComponent)!; expect(breadcrumbMenu.$model()).toEqual([ { label: 'First', url: '/first' }, { label: 'Second', url: '/second' } @@ -72,17 +72,17 @@ describe('DotCrumbtrailComponent', () => { patchState(unprotected(store), { breadcrumbs: crumbs }); spectator.detectChanges(); - const breadcrumbLast = spectator.query(byTestId('breadcrumb-title')); + const breadcrumbLast = spectator.query(byTestId('breadcrumb-title'))!; expect(breadcrumbLast.textContent.trim()).toBe('Last'); }); it('should display empty collapsed breadcrumbs when only one item is provided', () => { const crumbs = [{ label: 'Single Item', url: '/single' }]; - patchState(unprotected(store), { breadcrumbs: crumbs }); + patchState(unprotected(store), { breadcrumbs: crumbs as MenuItem[] }); spectator.detectChanges(); - const breadcrumbMenu = spectator.query(DotCollapseBreadcrumbComponent); + const breadcrumbMenu = spectator.query(DotCollapseBreadcrumbComponent)!; expect(breadcrumbMenu.$model()).toEqual([]); }); @@ -92,7 +92,7 @@ describe('DotCrumbtrailComponent', () => { patchState(unprotected(store), { breadcrumbs: crumbs }); spectator.detectChanges(); - const breadcrumbLast = spectator.query(byTestId('breadcrumb-title')); + const breadcrumbLast = spectator.query(byTestId('breadcrumb-title'))!; expect(breadcrumbLast.textContent.trim()).toBe('Single Item'); }); @@ -102,17 +102,17 @@ describe('DotCrumbtrailComponent', () => { patchState(unprotected(store), { breadcrumbs: crumbs }); spectator.detectChanges(); - const breadcrumbLast = spectator.query(byTestId('breadcrumb-title')); + const breadcrumbLast = spectator.query(byTestId('breadcrumb-title'))!; expect(breadcrumbLast).toBeFalsy(); }); it('should display empty collapsed breadcrumbs when no items are provided', () => { const crumbs: MenuItem[] = []; - patchState(unprotected(store), { breadcrumbs: crumbs }); + patchState(unprotected(store), { breadcrumbs: crumbs as MenuItem[] }); spectator.detectChanges(); - const breadcrumbMenu = spectator.query(DotCollapseBreadcrumbComponent); + const breadcrumbMenu = spectator.query(DotCollapseBreadcrumbComponent)!; expect(breadcrumbMenu.$model()).toEqual([]); }); @@ -123,16 +123,16 @@ describe('DotCrumbtrailComponent', () => { { label: 'Last', url: '/last' } ]; - patchState(unprotected(store), { breadcrumbs: crumbs }); + patchState(unprotected(store), { breadcrumbs: crumbs as MenuItem[] }); spectator.detectChanges(); - const breadcrumbMenu = spectator.query(DotCollapseBreadcrumbComponent); + const breadcrumbMenu = spectator.query(DotCollapseBreadcrumbComponent)!; expect(breadcrumbMenu.$model()).toEqual([ { label: 'First', target: '_self', url: '/first' }, { label: 'Second', target: '_blank', url: '/second' } ]); - const breadcrumbLast = spectator.query(byTestId('breadcrumb-title')); + const breadcrumbLast = spectator.query(byTestId('breadcrumb-title'))!; expect(breadcrumbLast.textContent.trim()).toBe('Last'); }); @@ -146,7 +146,7 @@ describe('DotCrumbtrailComponent', () => { spectator.detectChanges(); let breadcrumbMenu = spectator.query(DotCollapseBreadcrumbComponent); - expect(breadcrumbMenu.$model()).toEqual([{ label: 'First', url: '/first' }]); + expect(breadcrumbMenu!.$model()).toEqual([{ label: 'First', url: '/first' }]); const updatedCrumbs = [ { label: 'Home', url: '/home' }, @@ -158,12 +158,12 @@ describe('DotCrumbtrailComponent', () => { spectator.detectChanges(); breadcrumbMenu = spectator.query(DotCollapseBreadcrumbComponent); - expect(breadcrumbMenu.$model()).toEqual([ + expect(breadcrumbMenu!.$model()).toEqual([ { label: 'Home', url: '/home' }, { label: 'Section', url: '/section' } ]); - const breadcrumbLast = spectator.query(byTestId('breadcrumb-title')); + const breadcrumbLast = spectator.query(byTestId('breadcrumb-title'))!; expect(breadcrumbLast.textContent.trim()).toBe('Page'); }); @@ -174,16 +174,16 @@ describe('DotCrumbtrailComponent', () => { { label: 'Last', url: '/last' } ]; - patchState(unprotected(store), { breadcrumbs: crumbs }); + patchState(unprotected(store), { breadcrumbs: crumbs as MenuItem[] }); spectator.detectChanges(); - const breadcrumbMenu = spectator.query(DotCollapseBreadcrumbComponent); + const breadcrumbMenu = spectator.query(DotCollapseBreadcrumbComponent)!; expect(breadcrumbMenu.$model()).toEqual([ { label: 'First', url: '/first' }, { label: '', url: '/empty' } ]); - const breadcrumbLast = spectator.query(byTestId('breadcrumb-title')); + const breadcrumbLast = spectator.query(byTestId('breadcrumb-title'))!; expect(breadcrumbLast.textContent.trim()).toBe('Last'); }); @@ -194,16 +194,16 @@ describe('DotCrumbtrailComponent', () => { { label: 'Last', url: '/last' } ]; - patchState(unprotected(store), { breadcrumbs: crumbs }); + patchState(unprotected(store), { breadcrumbs: crumbs as MenuItem[] }); spectator.detectChanges(); - const breadcrumbMenu = spectator.query(DotCollapseBreadcrumbComponent); + const breadcrumbMenu = spectator.query(DotCollapseBreadcrumbComponent)!; expect(breadcrumbMenu.$model()).toEqual([ { label: 'First', url: '/first' }, { label: null, url: '/null' } ]); - const breadcrumbLast = spectator.query(byTestId('breadcrumb-title')); + const breadcrumbLast = spectator.query(byTestId('breadcrumb-title'))!; expect(breadcrumbLast.textContent.trim()).toBe('Last'); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-device-selector/dot-device-selector.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-device-selector/dot-device-selector.component.ts index 5ee63295c641..d7588502d108 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-device-selector/dot-device-selector.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-device-selector/dot-device-selector.component.ts @@ -34,9 +34,9 @@ export class DotDeviceSelectorComponent implements OnInit, OnChanges { private dotMessageService = inject(DotMessageService); private readonly cd = inject(ChangeDetectorRef); - @Input() value: DotDevice; + @Input() value!: DotDevice; @Output() selected = new EventEmitter(); - @HostBinding('class.disabled') disabled: boolean; + @HostBinding('class.disabled') disabled = false; options: DotDevice[] = []; placeholder = ''; @@ -46,7 +46,7 @@ export class DotDeviceSelectorComponent implements OnInit, OnChanges { } ngOnChanges(changes: SimpleChanges) { - if (changes.value && !changes.value.firstChange) { + if (changes['value'] && !changes['value'].firstChange) { this.loadOptions(); } } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-field-helper/dot-field-helper.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-field-helper/dot-field-helper.component.ts index 29b267e3d941..559f77e226f2 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-field-helper/dot-field-helper.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-field-helper/dot-field-helper.component.ts @@ -11,5 +11,5 @@ import { PopoverModule } from 'primeng/popover'; imports: [ButtonModule, PopoverModule] }) export class DotFieldHelperComponent { - @Input() message: string; + @Input() message!: string; } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-iframe-dialog/dot-iframe-dialog.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-iframe-dialog/dot-iframe-dialog.component.spec.ts index a591de5a6dd4..0984ec38e173 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-iframe-dialog/dot-iframe-dialog.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-iframe-dialog/dot-iframe-dialog.component.spec.ts @@ -27,8 +27,8 @@ import { IframeOverlayService } from '../_common/iframe/service/iframe-overlay.s imports: [DotIframeDialogComponent] }) class TestHostComponent { - url: string; - header: string; + url: string | null = null; + header!: string; onBeforeClose = jest.fn(); } @@ -40,8 +40,8 @@ class TestHostComponent { imports: [DotIframeDialogComponent] }) class TestHost2Component { - url: string; - header: string; + url: string | null = null; + header!: string; onBeforeClose = jest.fn(); } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-iframe-dialog/dot-iframe-dialog.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-iframe-dialog/dot-iframe-dialog.component.ts index d6e0f450d67e..7419eeb289c1 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-iframe-dialog/dot-iframe-dialog.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-iframe-dialog/dot-iframe-dialog.component.ts @@ -22,10 +22,10 @@ import { IframeComponent } from '../_common/iframe/iframe-component/iframe.compo }) export class DotIframeDialogComponent implements OnChanges { @ViewChild('dialog', { static: true }) - dotDialog: Dialog; + dotDialog!: Dialog; @Input() - url: string; + url: string | null = null; @Input() header = ''; @@ -47,15 +47,15 @@ export class DotIframeDialogComponent implements OnChanges { @Output() keyWasDown: EventEmitter = new EventEmitter(); - show: boolean; + show = false; ngOnChanges(changes: SimpleChanges) { - if (changes.url) { - this.show = !!changes.url.currentValue; + if (changes['url']) { + this.show = !!changes['url'].currentValue; } - if (changes.header) { - this.header = changes.header.currentValue; + if (changes['header']) { + this.header = changes['header'].currentValue; } } @@ -80,7 +80,7 @@ export class DotIframeDialogComponent implements OnChanges { * @memberof DotIframeDialogComponent */ onLoad($event: { target: HTMLIFrameElement }): void { - $event.target.contentWindow.focus(); + $event.target.contentWindow?.focus(); this.charge.emit($event); } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-language-selector/dot-language-selector.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-language-selector/dot-language-selector.component.spec.ts index 727079f0d8c1..95fabca41f07 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-language-selector/dot-language-selector.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-language-selector/dot-language-selector.component.spec.ts @@ -50,14 +50,14 @@ describe('DotLanguageSelectorComponent', () => { expect(dotLanguagesService.getLanguagesUsedPage).toHaveBeenCalledTimes(1); expect(spectator.component.languagesList().length).toBe(mockLanguageArray.length); - const pSelect = spectator.query(Select); + const pSelect = spectator.query(Select)!; expect(pSelect?.options).toEqual(mockLanguageArray); }); it('should have right attributes on dropdown', () => { const valueKey = 'id'; const labelKey = 'language'; - const pSelect = spectator.query(Select); + const pSelect = spectator.query(Select)!; expect(pSelect).toBeTruthy(); expect(pSelect.dataKey).toBe(valueKey); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-language-selector/dot-language-selector.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-language-selector/dot-language-selector.component.ts index f25726d58ad5..4cc3cbba6872 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-language-selector/dot-language-selector.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-language-selector/dot-language-selector.component.ts @@ -25,10 +25,10 @@ import { DotLanguage } from '@dotcms/dotcms-models'; changeDetection: ChangeDetectionStrategy.OnPush }) export class DotLanguageSelectorComponent implements OnChanges { - @Input() value: DotLanguage; - @Input() readonly: boolean; + @Input() value!: DotLanguage; + @Input() readonly!: boolean; @Output() selected = new EventEmitter(); - @HostBinding('class.disabled') disabled: boolean; + @HostBinding('class.disabled') disabled = false; languagesList = signal([]); @@ -46,9 +46,10 @@ export class DotLanguageSelectorComponent implements OnChanges { ngOnChanges(changes: SimpleChanges): void { const { value } = changes; - if (value && value.currentValue) { + const pageId = this._pageId(); + if (value && value.currentValue && pageId) { this.dotLanguagesService - .getLanguagesUsedPage(this._pageId()) + .getLanguagesUsedPage(pageId) .subscribe((languages: DotLanguage[]) => { this.languagesList.set(languages); }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-large-message-display/dot-large-message-display.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-large-message-display/dot-large-message-display.component.ts index 5f8157acd8ba..b4fc7bf387c5 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-large-message-display/dot-large-message-display.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-large-message-display/dot-large-message-display.component.ts @@ -43,12 +43,12 @@ export class DotLargeMessageDisplayComponent implements OnInit, OnDestroy, After private dotEventsSocket = inject(DotEventsSocket); private dotParseHtmlService = inject(DotParseHtmlService); - @ViewChildren(Dialog) dialogs: QueryList; + @ViewChildren(Dialog) dialogs!: QueryList; messages: DotLargeMessageDisplayParams[] = []; messageVisibility: Map = new Map(); private destroy$: Subject = new Subject(); - private recentlyDialogAdded: boolean; + private recentlyDialogAdded = false; getMessageVisibility(message: DotLargeMessageDisplayParams): boolean { return this.messageVisibility.get(message) ?? false; diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-link/dot-link.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-link/dot-link.component.spec.ts index 8006992a0198..2288a220c51d 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-link/dot-link.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-link/dot-link.component.spec.ts @@ -58,14 +58,14 @@ describe('DotLinkComponent', () => { }); it('should set link properties and attr correctly', () => { - expect(link.attributes.target).toEqual('_blank'); - expect(link.properties.href).toEqual('/api/v1/123'); - expect(link.properties.title).toEqual('/api/v1/123'); + expect(link.attributes['target']).toEqual('_blank'); + expect(link.properties['href']).toEqual('/api/v1/123'); + expect(link.properties['title']).toEqual('/api/v1/123'); }); it('should update link when href is change', () => { - expect(link.properties.href).toEqual('/api/v1/123'); - expect(link.properties.title).toEqual('/api/v1/123'); + expect(link.properties['href']).toEqual('/api/v1/123'); + expect(link.properties['title']).toEqual('/api/v1/123'); hostComp.updateLink('/api/new/1000'); hostFixture.detectChanges(); @@ -73,8 +73,8 @@ describe('DotLinkComponent', () => { // Re-query the link after changes link = de.query(By.css('a')); - expect(link.properties.href).toEqual('/api/new/1000'); - expect(link.properties.title).toEqual('/api/new/1000'); + expect(link.properties['href']).toEqual('/api/new/1000'); + expect(link.properties['title']).toEqual('/api/new/1000'); }); it('should set the link relative always', () => { @@ -84,7 +84,7 @@ describe('DotLinkComponent', () => { // Re-query the link after changes link = de.query(By.css('a')); - expect(link.properties.href).toEqual('/api/no/start/slash'); - expect(link.properties.title).toEqual('/api/no/start/slash'); + expect(link.properties['href']).toEqual('/api/no/start/slash'); + expect(link.properties['title']).toEqual('/api/no/start/slash'); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-link/dot-link.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-link/dot-link.component.ts index d38eb5c96ee8..34a79efbcc54 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-link/dot-link.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-link/dot-link.component.ts @@ -13,11 +13,11 @@ import { DotMessagePipe } from '@dotcms/ui'; }) export class DotLinkComponent { @Input() - label: string; + label!: string; - classNames: string; + classNames!: string; - link: string; + link!: string; @Input() set href(value: string) { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/action-header/action-header.component.html b/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/action-header/action-header.component.html index 223fabe1fe36..cb34582fcc07 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/action-header/action-header.component.html +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/action-header/action-header.component.html @@ -1,28 +1,28 @@
- @if (options() && options().primary) { + @if (options()?.primary; as primary) { }
- @if ((options() && options().secondary) || selectedItems().length) { + @if (options()?.secondary || selectedItems().length) {
{{ selectedItems().length }} {{ 'selected' | dm }} diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/action-header/action-header.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/action-header/action-header.component.ts index 64cff8fcd50a..2221431f3ebb 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/action-header/action-header.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/action-header/action-header.component.ts @@ -14,8 +14,10 @@ import { SplitButtonModule } from 'primeng/splitbutton'; import { DotAlertConfirmService, DotMessageService } from '@dotcms/data-access'; import { DotMessagePipe } from '@dotcms/ui'; +import { ActionHeaderDeleteOptions } from '../../../../shared/models/action-header/action-header-delete-options.model'; import { ActionHeaderOptions } from '../../../../shared/models/action-header/action-header-options.model'; import { ButtonAction } from '../../../../shared/models/action-header/button-action.model'; +import { ButtonModel } from '../../../../shared/models/action-header/button.model'; import { DotActionButtonComponent } from '../../_common/dot-action-button/dot-action-button.component'; @Component({ @@ -45,10 +47,12 @@ export class ActionHeaderComponent { }); effect(() => { - const opts = this.options(); - if (opts?.secondary) { + // Held in a local: TypeScript drops a property narrowing inside a callback, and + // `untracked` takes one. + const secondary = this.options()?.secondary; + if (secondary) { untracked(() => { - this.setCommandWrapper(opts.secondary); + this.setCommandWrapper(secondary); }); } }); @@ -69,7 +73,10 @@ export class ActionHeaderComponent { private setCommandWrapper(options: ButtonAction[]): void { options.forEach((actionButton) => { actionButton.model - .filter((model) => model.deleteOptions) + .filter( + (model): model is ButtonModel & { deleteOptions: ActionHeaderDeleteOptions } => + !!model.deleteOptions + ) .forEach((model) => { if ( typeof model.command === 'function' && @@ -83,8 +90,8 @@ export class ActionHeaderComponent { accept: () => { callback(originalEvent); }, - header: model.deleteOptions?.confirmHeader, - message: model.deleteOptions?.confirmMessage, + header: model.deleteOptions.confirmHeader, + message: model.deleteOptions.confirmMessage, footerLabel: { accept: this.dotMessageService.get( 'contenttypes.action.delete' diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/dot-listing-data-table.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/dot-listing-data-table.component.spec.ts index 8ac7300b5b1a..11de6eb88d06 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/dot-listing-data-table.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/dot-listing-data-table.component.spec.ts @@ -63,15 +63,15 @@ class EmptyMockComponent {} standalone: false }) class TestHostComponent { - @Input() columns: DataTableColumn[]; + @Input() columns!: DataTableColumn[]; @Input() url = '/api/data'; - @Input() actionHeaderOptions: ActionHeaderOptions; + @Input() actionHeaderOptions!: ActionHeaderOptions; @Input() buttonActions: ButtonAction[] = []; - @Input() sortOrder: string; - @Input() sortField: string; + @Input() sortOrder!: string; + @Input() sortField!: string; @Input() multipleSelection = false; @Input() paginationPerPage = 40; - @Input() actions: DotActionMenuItem[]; + @Input() actions!: DotActionMenuItem[]; @Input() dataKey = ''; @Input() checkbox = false; @Input() paginatorExtraParams: { [key: string]: string } = {}; @@ -108,15 +108,30 @@ Object.defineProperty(window, 'matchMedia', { })) }); +/** The rows this spec feeds the table; `identifier` marks the ones the table disables. */ +type ListingRow = { + field1: string; + field2: string; + /** A number when the column is rendered with the `date` format. */ + field3: string | number; + nEntries: string; + variable: string; + identifier?: string; + /** Added by the host's `mapItems`, not present in the seeded rows. */ + disableInteraction?: boolean; + /** The assertions look rows up by the column's `fieldName`, which is a plain string. */ + [key: string]: string | number | boolean | undefined; +}; + describe('DotListingDataTableComponent', () => { let comp: DotListingDataTableComponent; let hostFixture: ComponentFixture; let hostComponent: TestHostComponent; let de: DebugElement; let el: HTMLElement; - let items; - let enabledItems; - let disabledItems; + let items: ListingRow[]; + let enabledItems: ListingRow[]; + let disabledItems: ListingRow[]; let httpMock: HttpTestingController; const favoritePagesItem = { field1: 'item7-value1', @@ -301,7 +316,7 @@ describe('DotListingDataTableComponent', () => { const rows = el.querySelectorAll('[data-testclass="testTableRow"]'); expect(items.length).toEqual(rows.length); - const headRow = el.querySelector('[data-testclass="testHeadTableRow"]'); + const headRow = el.querySelector('[data-testclass="testHeadTableRow"]')!; const headers = headRow.querySelectorAll('th'); expect(5).toEqual(headers.length); @@ -317,13 +332,13 @@ describe('DotListingDataTableComponent', () => { const item = items[rowIndex]; cells.forEach((_cell, cellIndex) => { if (cellIndex < 3) { - expect(cells[cellIndex].querySelector('span').textContent).toContain( + expect(cells[cellIndex].querySelector('span')!.textContent).toContain( item[hostComponent.columns[cellIndex].fieldName] ); } if (cellIndex === 3) { - const anchor = cells[cellIndex].querySelector('a'); + const anchor = cells[cellIndex].querySelector('a')!; expect(anchor.textContent).toContain( `View (${item[hostComponent.columns[cellIndex].fieldName]})` ); @@ -355,7 +370,7 @@ describe('DotListingDataTableComponent', () => { const rows = el.querySelectorAll('[data-testclass="testTableRow"]'); expect(items.length).toEqual(rows.length); - const headRow = el.querySelector('[data-testclass="testHeadTableRow"]'); + const headRow = el.querySelector('[data-testclass="testHeadTableRow"]')!; const headers = headRow.querySelectorAll('th'); expect(5).toEqual(headers.length); @@ -371,16 +386,17 @@ describe('DotListingDataTableComponent', () => { cells.forEach((_cell, cellIndex) => { if (cellIndex < 4) { const textContent = cells[cellIndex].textContent; + const cellValue = item[comp.columns[cellIndex].fieldName]; const itemContent = comp.columns[cellIndex].format === 'date' - ? new Date( - item[comp.columns[cellIndex].fieldName] - ).toLocaleDateString('en-US', { + ? // A `date` column is seeded with a timestamp; `boolean` is only + // in the row's index signature for `disableInteraction`. + new Date(cellValue as number).toLocaleDateString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric' }) - : item[comp.columns[cellIndex].fieldName]; + : cellValue; expect(textContent).toContain(itemContent); } }); @@ -399,7 +415,7 @@ describe('DotListingDataTableComponent', () => { const rows = el.querySelectorAll('[data-testclass="testTableRow"]'); expect(items.length).toEqual(rows.length); - const headRow = el.querySelector('[data-testclass="testHeadTableRow"]'); + const headRow = el.querySelector('[data-testclass="testHeadTableRow"]')!; const headers = headRow.querySelectorAll('th'); expect(5).toEqual(headers.length); })); @@ -487,7 +503,7 @@ describe('DotListingDataTableComponent', () => { comp.globalSearch.nativeElement.dispatchEvent( new KeyboardEvent('keydown', { key: 'arrowDown' }) ); - expect(comp.dataTable.tableViewChild.nativeElement.rows[1]).toBe(document.activeElement); + expect(comp.dataTable.tableViewChild!.nativeElement.rows[1]).toBe(document.activeElement); })); it('should set the pagination size in the Table', fakeAsync(() => { @@ -557,7 +573,7 @@ describe('DotListingDataTableComponent', () => { // The SYSTEM_TEMPLATE row should have disableInteraction (disabled) const disabledRowData = items.find((item) => item.identifier === 'SYSTEM_TEMPLATE'); - expect(disabledRowData.disableInteraction).toBe(true); + expect(disabledRowData?.disableInteraction).toBe(true); })); describe('with checkBox', () => { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/dot-listing-data-table.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/dot-listing-data-table.component.ts index 50d9a699aa06..8fdfcfcb133f 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/dot-listing-data-table.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/dot-listing-data-table.component.ts @@ -87,33 +87,33 @@ export class DotListingDataTableComponent implements OnInit, AfterViewInit { loggerService = inject(LoggerService); paginatorService = inject(PaginatorService); - @Input() columns: DataTableColumn[]; - @Input() url: string; - @Input() actionHeaderOptions: ActionHeaderOptions; + @Input() columns!: DataTableColumn[]; + @Input() url!: string; + @Input() actionHeaderOptions!: ActionHeaderOptions; @Input() buttonActions: ButtonAction[] = []; - @Input() sortOrder: string; - @Input() sortField: string; + @Input() sortOrder!: string; + @Input() sortField!: string; @Input() multipleSelection = false; @Input() paginationPerPage = 40; @Input() paginatorExtraParams: { [key: string]: string } = {}; @Input() actions: DotActionMenuItem[] = []; @Input() dataKey = ''; @Input() checkbox = false; - @Input() mapItems: []>(item: T) => T; + @Input() mapItems!: []>(item: T) => T; @Input() contextMenu = false; @Output() rowWasClicked: EventEmitter = new EventEmitter(); @Output() selectedItems: EventEmitter = new EventEmitter(); @Output() contextMenuSelect: EventEmitter = new EventEmitter(); @ViewChild('gf', { static: true }) - globalSearch: ElementRef; + globalSearch!: ElementRef; @ViewChild('dataTable', { static: true }) - dataTable: Table; + dataTable!: Table; @ViewChild('cm', { static: false }) contextMenuRef: ContextMenu | undefined; - @ContentChildren(PrimeTemplate) templates: QueryList; + @ContentChildren(PrimeTemplate) templates!: QueryList; // Signal to track when contextMenuRef is available private readonly contextMenuRefSignal = signal(undefined); @@ -125,20 +125,20 @@ export class DotListingDataTableComponent implements OnInit, AfterViewInit { return hasContextMenu && ref ? ref : null; }); - @ContentChild('rowTemplate') rowTemplate: TemplateRef; - @ContentChild('beforeSearchTemplate') beforeSearchTemplate: TemplateRef; - @ContentChild('headerTemplate') headerTemplate: TemplateRef; + @ContentChild('rowTemplate') rowTemplate!: TemplateRef; + @ContentChild('beforeSearchTemplate') beforeSearchTemplate!: TemplateRef; + @ContentChild('headerTemplate') headerTemplate!: TemplateRef; readonly DATE_FORMAT = 'date'; - items: unknown[]; - selected: Record[]; - filter; + items: unknown[] = []; + selected: Record[] = []; + filter = ''; isContentFiltered = false; - dateColumns: DataTableColumn[]; + dateColumns: DataTableColumn[] = []; loading = true; - contextMenuItems: MenuItem[]; - maxLinksPage: number; - totalRecords: number; + contextMenuItems: MenuItem[] = []; + maxLinksPage!: number; + totalRecords!: number; constructor() { this.paginatorService.url = this.url; @@ -196,8 +196,8 @@ export class DotListingDataTableComponent implements OnInit, AfterViewInit { handleRowClick(rowData: Record): void { // If the system template or system container is clicked, do nothing. if ( - rowData?.identifier === 'SYSTEM_TEMPLATE' || - rowData?.identifier === 'SYSTEM_CONTAINER' + rowData?.['identifier'] === 'SYSTEM_TEMPLATE' || + rowData?.['identifier'] === 'SYSTEM_CONTAINER' ) { return; } @@ -212,7 +212,11 @@ export class DotListingDataTableComponent implements OnInit, AfterViewInit { * @memberof DotListingDataTableComponent */ loadDataPaginationEvent(event: TableLazyLoadEvent): void { - this.loadData(event.first, event.sortField as string, event.sortOrder); + this.loadData( + event.first ?? 0, + event.sortField as string, + (event.sortOrder as OrderDirection) ?? undefined + ); } /** @@ -242,7 +246,7 @@ export class DotListingDataTableComponent implements OnInit, AfterViewInit { loadFirstPage(): void { this.loading = true; this.paginatorService - .get() + .get() .pipe(take(1)) .subscribe((items) => { this.setItems(items); @@ -258,7 +262,7 @@ export class DotListingDataTableComponent implements OnInit, AfterViewInit { this.loading = true; if (this.columns) { this.paginatorService - .getCurrentPage() + .getCurrentPage() .pipe(take(1)) .subscribe((items) => this.setItems(items)); } @@ -280,8 +284,12 @@ export class DotListingDataTableComponent implements OnInit, AfterViewInit { * @memberof ListingDataTableComponent */ focusFirstRow(): void { - const rows: HTMLTableRowElement[] = this.dataTable.tableViewChild.nativeElement.rows; - if (rows.length > 1) { + // PrimeNG only assigns `tableViewChild` once the table renders, and this is called from a + // keyboard handler that can arrive first. + const rows: HTMLTableRowElement[] | undefined = + this.dataTable.tableViewChild?.nativeElement.rows; + + if (rows && rows.length > 1) { rows[1].focus(); } } @@ -297,7 +305,7 @@ export class DotListingDataTableComponent implements OnInit, AfterViewInit { ); } - private setItems(items): void { + private setItems(items: unknown[]): void { // Defer state updates to avoid NG0100 ExpressionChangedAfterItHasBeenCheckedError // This is needed because p-table with lazy loading triggers onLazyLoad during initialization setTimeout(() => { @@ -310,7 +318,9 @@ export class DotListingDataTableComponent implements OnInit, AfterViewInit { } private isTypeNumber(col: DataTableColumn): boolean { - return this.items && this.items[0] && typeof this.items[0][col.fieldName] === 'number'; + const first = this.items?.[0] as Record | undefined; + + return typeof first?.[col.fieldName] === 'number'; } private setSortParams(sortFieldParam?: string, sortOrderParam?: OrderDirection) { @@ -322,7 +332,7 @@ export class DotListingDataTableComponent implements OnInit, AfterViewInit { private getPage(offset: number): void { this.paginatorService - .getWithOffset(offset) + .getWithOffset(offset) .pipe(take(1)) .subscribe((items) => this.setItems(items)); } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/index.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/index.ts deleted file mode 100644 index ed35d239f92c..000000000000 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-listing-data-table/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './dot-listing-data-table.module'; diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-header/dot-nav-header.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-header/dot-nav-header.component.spec.ts index b009cfff7f95..e58f3856c6e1 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-header/dot-nav-header.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-header/dot-nav-header.component.spec.ts @@ -62,7 +62,7 @@ describe('DotNavHeaderComponent', () => { }); it('should have pi-bars icon on toggle button', () => { - const toggleButton = spectator.query(byTestId('dot-nav-header-toggle-button')); + const toggleButton = spectator.query(byTestId('dot-nav-header-toggle-button'))!; expect(toggleButton).toBeTruthy(); expect(toggleButton.getAttribute('icon')).toBe('pi pi-bars'); @@ -71,7 +71,7 @@ describe('DotNavHeaderComponent', () => { it('should emit toggle event when button is clicked', () => { const spy = jest.spyOn(component.toggle, 'emit'); - const toggleButton = spectator.query(byTestId('dot-nav-header-toggle-button')); + const toggleButton = spectator.query(byTestId('dot-nav-header-toggle-button'))!; spectator.click(toggleButton); expect(spy).toHaveBeenCalledTimes(1); @@ -80,7 +80,7 @@ describe('DotNavHeaderComponent', () => { it('should emit toggle event with no parameters', () => { const spy = jest.spyOn(component.toggle, 'emit'); - const toggleButton = spectator.query(byTestId('dot-nav-header-toggle-button')); + const toggleButton = spectator.query(byTestId('dot-nav-header-toggle-button'))!; spectator.click(toggleButton); expect(spy).toHaveBeenCalledWith(); @@ -258,11 +258,11 @@ describe('DotNavHeaderComponent', () => { expect(spectator.query(byTestId('whitelabel-logo'))).toBeFalsy(); }); - it('should handle undefined logo from service', () => { - dotNavLogoService.navBarLogo$.next(undefined); + it('should handle a logo the service could not resolve', () => { + dotNavLogoService.navBarLogo$.next(null); spectator.detectChanges(); - // Undefined should show default logo + // No logo should show the default one expect(spectator.query(byTestId('default-logo'))).toBeTruthy(); expect(spectator.query(byTestId('whitelabel-logo'))).toBeFalsy(); }); @@ -309,7 +309,7 @@ describe('DotNavHeaderComponent', () => { spectator.detectChanges(); const spy = jest.spyOn(component.toggle, 'emit'); - const toggleButton = spectator.query(byTestId('dot-nav-header-toggle-button')); + const toggleButton = spectator.query(byTestId('dot-nav-header-toggle-button'))!; // Click multiple times spectator.click(toggleButton); @@ -325,7 +325,7 @@ describe('DotNavHeaderComponent', () => { spectator.detectChanges(); const spy = jest.spyOn(component.toggle, 'emit'); - const toggleButton = spectator.query(byTestId('dot-nav-header-toggle-button')); + const toggleButton = spectator.query(byTestId('dot-nav-header-toggle-button'))!; spectator.click(toggleButton); @@ -341,14 +341,14 @@ describe('DotNavHeaderComponent', () => { }); it('should have accessible button element', () => { - const toggleButton = spectator.query(byTestId('dot-nav-header-toggle-button')); + const toggleButton = spectator.query(byTestId('dot-nav-header-toggle-button'))!; expect(toggleButton).toBeTruthy(); expect(toggleButton.tagName.toLowerCase()).toBe('button'); }); it('should maintain button functionality across logo changes', () => { const spy = jest.spyOn(component.toggle, 'emit'); - const toggleButton = spectator.query(byTestId('dot-nav-header-toggle-button')); + const toggleButton = spectator.query(byTestId('dot-nav-header-toggle-button'))!; // Test with default logo spectator.click(toggleButton); @@ -365,7 +365,7 @@ describe('DotNavHeaderComponent', () => { it('should maintain consistent testid attributes', () => { // Test that testid attributes are always present for testing - const toggleButton = spectator.query(byTestId('dot-nav-header-toggle-button')); + const toggleButton = spectator.query(byTestId('dot-nav-header-toggle-button'))!; expect(toggleButton).toBeTruthy(); const defaultLogo = spectator.query(byTestId('default-logo')); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-icon/dot-nav-icon.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-icon/dot-nav-icon.component.ts index cec071afe389..231c6a7869a5 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-icon/dot-nav-icon.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-icon/dot-nav-icon.component.ts @@ -11,7 +11,7 @@ import { DotIconComponent } from '@dotcms/ui'; }) export class DotNavIconComponent { @Input() - icon: string; + icon!: string; isFaIcon(icon: string): boolean { return icon.startsWith('fa-'); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-item/dot-nav-item.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-item/dot-nav-item.component.spec.ts index 51300ec81e47..1115093714e1 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-item/dot-nav-item.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-item/dot-nav-item.component.spec.ts @@ -149,7 +149,7 @@ describe('DotNavItemComponent', () => { host = spectator.component; host.menu = { ...defaultMenu }; host.collapsed = false; - component = spectator.query(DotNavItemComponent); + component = spectator.query(DotNavItemComponent)!; globalStore = spectator.inject(GlobalStore); globalStore.loadMenu([menuForStore]); spectator.detectChanges(); @@ -252,7 +252,7 @@ describe('DotNavItemComponent', () => { host = spectator.component; host.menu = { ...defaultMenu }; host.collapsed = true; - component = spectator.query(DotNavItemComponent); + component = spectator.query(DotNavItemComponent)!; globalStore = spectator.inject(GlobalStore); globalStore.loadMenu([menuForStore]); spectator.detectChanges(); @@ -279,7 +279,7 @@ describe('DotNavItemComponent', () => { host = spectator.component; host.menu = { ...defaultMenu }; host.collapsed = true; - component = spectator.query(DotNavItemComponent); + component = spectator.query(DotNavItemComponent)!; globalStore = spectator.inject(GlobalStore); globalStore.loadMenu([menuForStore]); spectator.detectChanges(); @@ -314,7 +314,7 @@ describe('DotNavItemComponent', () => { host = spectator.component; host.menu = { ...defaultMenu }; host.collapsed = true; - component = spectator.query(DotNavItemComponent); + component = spectator.query(DotNavItemComponent)!; globalStore = spectator.inject(GlobalStore); globalStore.loadMenu([menuForStore]); spectator.detectChanges(); @@ -348,7 +348,7 @@ describe('DotNavItemComponent', () => { host = spectator.component; host.menu = { ...defaultMenu }; host.collapsed = true; - component = spectator.query(DotNavItemComponent); + component = spectator.query(DotNavItemComponent)!; globalStore = spectator.inject(GlobalStore); globalStore.loadMenu([menuForStore]); spectator.detectChanges(); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-item/dot-nav-item.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-item/dot-nav-item.component.ts index 497861e8fb07..8c22d8ae931f 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-item/dot-nav-item.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-item/dot-nav-item.component.ts @@ -32,7 +32,7 @@ import { DotSubNavComponent } from '../dot-sub-nav/dot-sub-nav.component'; export class DotNavItemComponent { private hostElRef = inject(ElementRef); - @ViewChild('subnav', { static: true }) subnav: DotSubNavComponent; + @ViewChild('subnav', { static: true }) subnav!: DotSubNavComponent; $data = input.required({ alias: 'data' }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-sub-nav/dot-sub-nav.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-sub-nav/dot-sub-nav.component.ts index 842d441b9a17..a38e6c2988c8 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-sub-nav/dot-sub-nav.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-sub-nav/dot-sub-nav.component.ts @@ -40,14 +40,14 @@ import { DotMenu, DotMenuItem } from '@dotcms/dotcms-models'; imports: [RouterModule] }) export class DotSubNavComponent { - @ViewChild('ul', { static: true }) ul: ElementRef; + @ViewChild('ul', { static: true }) ul!: ElementRef; - @Input() data: DotMenu; + @Input() data!: DotMenu; @Output() itemClick: EventEmitter<{ originalEvent: MouseEvent; data: DotMenuItem }> = new EventEmitter(); - @Input() collapsed: boolean; + @Input() collapsed!: boolean; @HostBinding('@expandAnimation') get getAnimation(): string { return !this.collapsed && this.data.isOpen ? 'expanded' : 'collapsed'; diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/dot-navigation.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/dot-navigation.component.spec.ts index 142051605fd0..bcbaf94f64c8 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/dot-navigation.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/dot-navigation.component.spec.ts @@ -210,7 +210,7 @@ describe('DotNavigationComponent collapsed', () => { globalStore.collapseNavigation(); spectator.detectChanges(); - expect(spectator.debugElement.styles.cssText).toEqual(''); + expect(spectator.debugElement.styles['cssText']).toEqual(''); }); }); }); @@ -352,7 +352,7 @@ describe('DotNavigationComponent expanded', () => { it('should have scroll', () => { spectator.detectChanges(); - expect(spectator.debugElement.styles.cssText).toEqual('overflow-y: auto;'); + expect(spectator.debugElement.styles['cssText']).toEqual('overflow-y: auto;'); }); }); @@ -391,7 +391,7 @@ describe('DotNavigationComponent expanded', () => { spectator.component.onMenuClick({ originalEvent: {} as unknown as MouseEvent, - data: mockMenu + data: mockMenu! }); expect(dotRouterService.gotoPortlet).not.toHaveBeenCalled(); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.spec.ts index a80450ebc08e..f94cdcaf1f85 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.spec.ts @@ -294,6 +294,7 @@ describe('DotNavigationService', () => { active: false, id: '123', isOpen: false, + label: 'Nav 1', menuItems: [ { active: false, diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.ts index e09650ed04e9..b788f3d4db28 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.ts @@ -2,7 +2,7 @@ import { Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { Title } from '@angular/platform-browser'; -import { Event, NavigationEnd, Router } from '@angular/router'; +import { NavigationEnd, Router } from '@angular/router'; import { filter, map, switchMap, take, tap } from 'rxjs/operators'; @@ -108,8 +108,10 @@ export class DotNavigationService { }); } - onNavigationEnd(): Observable { - return this.router.events.pipe(filter((event: Event) => event instanceof NavigationEnd)); + onNavigationEnd(): Observable { + return this.router.events.pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd) + ); } /** diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selected-item/dot-persona-selected-item.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selected-item/dot-persona-selected-item.component.ts index a2b2ba85acd7..a737219a4a78 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selected-item/dot-persona-selected-item.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selected-item/dot-persona-selected-item.component.ts @@ -22,14 +22,14 @@ import { DotAvatarDirective, DotMessagePipe } from '@dotcms/ui'; imports: [AvatarModule, BadgeModule, TooltipModule, DotAvatarDirective, DotMessagePipe] }) export class DotPersonaSelectedItemComponent { - @Input() persona: DotPersona; + @Input() persona!: DotPersona; @Input() isEditMode = false; @Input() readonly = false; @Input() @HostBinding('class.disabled') - disabled: boolean; + disabled = false; @Output() selected = new EventEmitter(); } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector-option/dot-persona-selector-option.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector-option/dot-persona-selector-option.component.spec.ts index 2c0753ea7ab6..b5604bd054b4 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector-option/dot-persona-selector-option.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector-option/dot-persona-selector-option.component.spec.ts @@ -81,8 +81,8 @@ describe('DotPersonaSelectorOptionComponent', () => { it('should have personalized button with right properties', () => { const btnElement: DebugElement = de.query(By.css('button')); expect(btnElement.nativeElement.textContent.trim()).toBe('Personalized'); - expect(btnElement.attributes.icon).toBe('pi pi-times'); - expect(btnElement.attributes.iconPos).toBe('right'); + expect(btnElement.attributes['icon']).toBe('pi pi-times'); + expect(btnElement.attributes['iconPos']).toBe('right'); }); it('should label set personalized class', () => { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector-option/dot-persona-selector-option.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector-option/dot-persona-selector-option.component.ts index 0e81cefe69c9..9d090fdd7438 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector-option/dot-persona-selector-option.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector-option/dot-persona-selector-option.component.ts @@ -32,9 +32,9 @@ import { DotAvatarDirective, DotMessagePipe } from '@dotcms/ui'; export class DotPersonaSelectorOptionComponent { @Input() canDespersonalize = true; - @Input() persona: DotPersona; + @Input() persona!: DotPersona; - @Input() selected: boolean; + @Input() selected!: boolean; @Output() switch = new EventEmitter(); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector/dot-persona-selector.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector/dot-persona-selector.component.spec.ts index 72e8245d06af..930e1ce5f5c6 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector/dot-persona-selector.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector/dot-persona-selector.component.spec.ts @@ -39,9 +39,9 @@ import { IframeOverlayService } from '../_common/iframe/service/iframe-overlay.s import { DotAddPersonaDialogComponent } from '../dot-add-persona-dialog/dot-add-persona-dialog.component'; class TestPaginatorService { - filter: string; - url: string; - paginationPerPage: string; + filter!: string; + url!: string; + paginationPerPage!: string; totalRecords = [mockDotPersona].length; getWithOffset(_offset: number) { @@ -133,7 +133,7 @@ describe('DotPersonaSelectorComponent', () => { const openOverlay = () => { spectator.component.disabled = false; - const personaSelectedItem = spectator.query('dot-persona-selected-item'); + const personaSelectedItem = spectator.query('dot-persona-selected-item')!; personaSelectedItem.dispatchEvent(new MouseEvent('click')); spectator.detectChanges(); }; @@ -188,7 +188,7 @@ describe('DotPersonaSelectorComponent', () => { }); it('should set dot-persona-selected-item with right attributes', () => { - const personaSelectedItem = spectator.query('dot-persona-selected-item'); + const personaSelectedItem = spectator.query('dot-persona-selected-item')!; expect(personaSelectedItem.getAttribute('appendTo')).toBe('target'); expect(personaSelectedItem.getAttribute('tooltipPosition')).toBe('bottom'); const nameSpan = spectator.query('dot-persona-selected-item .dot-persona-selector__name'); @@ -200,7 +200,7 @@ describe('DotPersonaSelectorComponent', () => { await spectator.fixture.whenStable(); const selectedItem = spectator.query('dot-persona-selected-item'); - spectator.click(selectedItem); + spectator.click(selectedItem!); expect(spectator.component.searchableDropdown.toggleOverlayPanel).toHaveBeenCalled(); }); @@ -214,7 +214,7 @@ describe('DotPersonaSelectorComponent', () => { await spectator.fixture.whenStable(); spectator.detectChanges(); - const personaOption = spectator.query('dot-persona-selector-option'); + const personaOption = spectator.query('dot-persona-selector-option')!; expect(personaOption).toBeTruthy(); expect(personaOption.classList.contains('highlight')).toEqual(true); }); @@ -230,7 +230,7 @@ describe('DotPersonaSelectorComponent', () => { spectator.detectChanges(); const mockPersonaData = { ...mockDotPersona, label: 'Global Investor' }; - const personaOption = spectator.query('dot-persona-selector-option'); + const personaOption = spectator.query('dot-persona-selector-option')!; expect(personaOption).toBeTruthy(); const personaComponent = spectator.debugElement.query( (el) => el.name === 'dot-persona-selector-option' @@ -296,7 +296,7 @@ describe('DotPersonaSelectorComponent', () => { jest.spyOn(spectator.component.searchableDropdown, 'toggleOverlayPanel'); spectator.triggerEventHandler('dot-searchable-dropdown', 'filterChange', 'Bill'); - spectator.click(addPersonaIcon); + spectator.click(addPersonaIcon!); spectator.detectChanges(); expect(spectator.component.searchableDropdown.toggleOverlayPanel).toHaveBeenCalled(); expect(personaDialog.visible).toBe(true); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector/dot-persona-selector.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector/dot-persona-selector.component.ts index 05755b44d4a6..882f2be5a684 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector/dot-persona-selector.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector/dot-persona-selector.component.ts @@ -61,28 +61,28 @@ export class DotPersonaSelectorComponent implements OnInit { iframeOverlayService = inject(IframeOverlayService); private dotSessionStorageService = inject(DotSessionStorageService); - @Input() disabled: boolean; - @Input() readonly: boolean; + @Input() disabled!: boolean; + @Input() readonly!: boolean; @Output() selected: EventEmitter = new EventEmitter(); @Output() delete: EventEmitter = new EventEmitter(); @ViewChild('searchableDropdown', { static: true }) - searchableDropdown: SearchableDropdownComponent; - @ViewChild('personaDialog', { static: true }) personaDialog: DotAddPersonaDialogComponent; + searchableDropdown!: SearchableDropdownComponent; + @ViewChild('personaDialog', { static: true }) personaDialog!: DotAddPersonaDialogComponent; - addAction: (item: DotPersona) => void; + addAction!: (item: DotPersona) => void; canDespersonalize = false; isEditMode = false; paginationPerPage = 10; personas: DotPersona[] = []; - totalRecords: number; - value: DotPersona; + totalRecords!: number; + value: DotPersona | undefined; defaultPersonaIdentifier = DEFAULT_PERSONA_IDENTIFIER_BY_BACKEND; - private personaSeachQuery: string; + private personaSeachQuery!: string; - private _pageState: DotPageRenderState; + private _pageState!: DotPageRenderState; get pageState(): DotPageRenderState { return this._pageState; @@ -155,7 +155,10 @@ export class DotPersonaSelectorComponent implements OnInit { * @memberof DotPersonaSelectorComponent */ reloadPersonasListCurrentPage(): void { - this.paginationService.getCurrentPage().pipe(take(1)).subscribe(this.setList.bind(this)); + this.paginationService + .getCurrentPage() + .pipe(take(1)) + .subscribe(this.setList.bind(this)); } /** @@ -186,7 +189,7 @@ export class DotPersonaSelectorComponent implements OnInit { // Set filter if undefined this.paginationService.filter = filter; this.paginationService - .getWithOffset(offset) + .getWithOffset(offset) .pipe(take(1), delay(0)) .subscribe(this.setList.bind(this)); } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-portlet-base/components/dot-portlet-toolbar/dot-portlet-toolbar.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-portlet-base/components/dot-portlet-toolbar/dot-portlet-toolbar.component.spec.ts index 2b544322bce9..41047f4a928f 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-portlet-base/components/dot-portlet-toolbar/dot-portlet-toolbar.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-portlet-base/components/dot-portlet-toolbar/dot-portlet-toolbar.component.spec.ts @@ -211,8 +211,8 @@ describe('DotPortletToolbarComponent', () => { const actionsMenuButton = de.query(By.css('[data-testId="actionsMenuButton"]')); expect(actionsMenuButton.nativeElement.textContent).toBe('Actions'); - expect(actionsMenuButton.attributes.icon).toBe('pi pi-chevron-down'); - expect(actionsMenuButton.attributes.iconPos).toBe('right'); + expect(actionsMenuButton.attributes['icon']).toBe('pi pi-chevron-down'); + expect(actionsMenuButton.attributes['iconPos']).toBe('right'); const actionsMenu = de.query(By.css('[data-testId="actionsMenu"]')); expect(actionsMenu.componentInstance.model).toEqual([ diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-portlet-base/components/dot-portlet-toolbar/dot-portlet-toolbar.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-portlet-base/components/dot-portlet-toolbar/dot-portlet-toolbar.component.ts index cdea5317fc43..71d79ed5c638 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-portlet-base/components/dot-portlet-toolbar/dot-portlet-toolbar.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-portlet-base/components/dot-portlet-toolbar/dot-portlet-toolbar.component.ts @@ -16,13 +16,13 @@ import { DotPortletToolbarActions } from '../../../../../shared/models/dot-portl imports: [ToolbarModule, ButtonModule, MenuModule, DotMessagePipe] }) export class DotPortletToolbarComponent { - @Input() title: string; + @Input() title!: string; - @Input() cancelButtonLabel: string; + @Input() cancelButtonLabel!: string; - @Input() actionsButtonLabel: string; + @Input() actionsButtonLabel!: string; - @Input() actions: DotPortletToolbarActions; + @Input() actions!: DotPortletToolbarActions; /** * Handle cancel button click @@ -46,7 +46,9 @@ export class DotPortletToolbarComponent { */ onPrimaryClick($event: Event): void { try { - this.actions.primary[0].command({ originalEvent: $event }); + // Only reachable from the primary button, which the template renders behind + // `@if (actions?.primary?.length)`. + this.actions.primary?.[0].command?.({ originalEvent: $event }); } catch (error) { console.error(error); } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-relationship-tree/dot-relationship-tree.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-relationship-tree/dot-relationship-tree.component.ts index a27020f47da3..bfc44cdfd958 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-relationship-tree/dot-relationship-tree.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-relationship-tree/dot-relationship-tree.component.ts @@ -11,12 +11,12 @@ import { DotIconComponent } from '@dotcms/ui'; imports: [DotIconComponent] }) export class DotRelationshipTreeComponent implements OnChanges { - @Input() velocityVar: string; - @Input() contentType: DotCMSContentType; - @Input() isParentField: boolean; + @Input() velocityVar!: string; + @Input() contentType!: DotCMSContentType; + @Input() isParentField!: boolean; - child: string; - parent: string; + child!: string; + parent!: string; ngOnChanges(): void { this.setValues(); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.spec.ts index b6d4a95af965..618c7faeebcc 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.spec.ts @@ -128,7 +128,7 @@ describe('DotReportIssueComponent', () => { }); it('should disable submit while request is in flight', () => { - const response$ = new Subject(); + const response$ = new Subject(); reportIssueMock.mockReturnValue(response$); component.form.get('description')?.setValue('Report issue'); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.ts index e3caf5c9f65c..4676e078e9a2 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.ts @@ -245,7 +245,7 @@ export class DotReportIssueComponent { }; if (this.document.referrer) { - metadata.referrer = this.document.referrer; + metadata['referrer'] = this.document.referrer; } return { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-login-as/dot-login-as.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-login-as/dot-login-as.component.spec.ts index 5815baf8588b..fd1ce318e96e 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-login-as/dot-login-as.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-login-as/dot-login-as.component.spec.ts @@ -157,7 +157,7 @@ describe('DotLoginAsComponent', () => { spectator.detectChanges(); // Act - Simulate user selecting a user and submitting - component.form.get('loginAsUser').setValue(testUser); + component.form.get('loginAsUser')!.setValue(testUser); spectator.detectChanges(); // Verify that the form is valid before clicking @@ -202,8 +202,8 @@ describe('DotLoginAsComponent', () => { spectator.detectChanges(); // Set form values as a user would - component.form.get('loginAsUser').setValue(mockUser()); - component.form.get('password').setValue('password'); + component.form.get('loginAsUser')!.setValue(mockUser()); + component.form.get('password')!.setValue('password'); spectator.detectChanges(); // Mock the passwordElem viewChild to simulate the element being available @@ -257,7 +257,7 @@ describe('DotLoginAsComponent', () => { spectator.detectChanges(); // Act - Simulate user login - component.form.get('loginAsUser').setValue(mockUser()); + component.form.get('loginAsUser')!.setValue(mockUser()); spectator.detectChanges(); component.doLoginAs(); @@ -288,8 +288,8 @@ describe('DotLoginAsComponent', () => { spectator.detectChanges(); // Fill the form - component.form.get('loginAsUser').setValue(mockUser()); - component.form.get('password').setValue('password'); + component.form.get('loginAsUser')!.setValue(mockUser()); + component.form.get('password')!.setValue('password'); spectator.detectChanges(); // Act - Call doLoginAs directly @@ -352,14 +352,14 @@ describe('DotLoginAsComponent', () => { spectator.setInput('visible', true); spectator.detectChanges(); - component.form.get('loginAsUser').setValue(mockUser()); + component.form.get('loginAsUser')!.setValue(mockUser()); component.errorMessage.set('some error'); component.needPassword.set(true); component.close(); expect(cancelSpy).toHaveBeenCalledWith(true); - expect(component.form.get('loginAsUser').value).toBeNull(); + expect(component.form.get('loginAsUser')!.value).toBeNull(); expect(component.errorMessage()).toBe(''); expect(component.needPassword()).toBe(false); }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-login-as/dot-login-as.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-login-as/dot-login-as.component.ts index 80ac83ef1163..dd1b6c0368c0 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-login-as/dot-login-as.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-login-as/dot-login-as.component.ts @@ -56,7 +56,7 @@ export class DotLoginAsComponent implements OnInit, OnDestroy { dropdown = viewChild element - * @default null + * Value specifies the value of the element. `string | File`, because both are assigned: `handleURLPaste` stores the pasted URL and `handleFilePaste` stores the pasted `File` itself. The `` in `render` accepts neither directly, so it coerces — which is what Stencil's attribute serialization already did, meaning a pasted file renders as `[object File]` rather than its name. That is a display bug, but repairing it changes what the user sees; see the note in `render`. + * @default '' */ - "value": any; + "value": string | File; } /** * Represent a dotcms text field for the binary file element. @@ -304,7 +297,7 @@ export namespace Components { /** * (optional) Describes a type of file that may be selected by the user, separated by comma eg: .pdf,.jpg */ - "accept": string; + "accept"?: string; /** * (optional) Text that be shown in the browse file button * @default '' @@ -334,12 +327,15 @@ export namespace Components { interface DotCard { } interface DotCardContentlet { - "checked": boolean; + "checked"?: boolean; "hideMenu": () => Promise; /** * @default '96px' */ "iconSize": string; + /** + * Required in practice, not optional: `render` reads `contentlet.language` and `contentlet.locked` with no guard, so a missing item has always thrown. Third component with this shape, after `dot-contentlet-thumbnail` and `dot-video-thumbnail`. + */ "item": DotCardContentletItem; "showMenu": (x: number, y: number) => Promise; /** @@ -362,7 +358,7 @@ export namespace Components { * @default true */ "showVideoThumbnail": boolean; - "value": string; + "value"?: string; } interface DotCheckbox { /** @@ -444,7 +440,7 @@ export namespace Components { "size": string; } interface DotContentletLockIcon { - "locked": boolean; + "locked"?: boolean; /** * @default '16px' */ @@ -466,6 +462,9 @@ export namespace Components { * @default false */ "backgroundImage": boolean; + /** + * Required in practice, not optional: `componentWillLoad` destructures it on the first line, so a missing contentlet has always thrown rather than degraded. Declared with a definite assignment so the eighteen accesses below read it directly, as they already did. + */ "contentlet": DotContentletItem; /** * @default '' @@ -505,7 +504,7 @@ export namespace Components { "show": (x: number, y: number, position?: string) => Promise; } interface DotDataViewButton { - "value": string; + "value"?: string; } interface DotDate { /** @@ -711,7 +710,7 @@ export namespace Components { /** * (optional) List of fields (variableName) separated by comma, to be shown */ - "fieldsToShow": string; + "fieldsToShow"?: string; /** * Layout metada to be rendered * @default [] @@ -737,21 +736,21 @@ export namespace Components { /** * Fields metada to be rendered */ - "column": DotCMSContentTypeLayoutColumn; + "column"?: DotCMSContentTypeLayoutColumn; /** * (optional) List of fields (variableName) separated by comma, to be shown */ - "fieldsToShow": string; + "fieldsToShow"?: string; } interface DotFormRow { /** * (optional) List of fields (variableName) separated by comma, to be shown */ - "fieldsToShow": string; + "fieldsToShow"?: string; /** * Fields metada to be rendered */ - "row": DotCMSContentTypeLayoutRow; + "row"?: DotCMSContentTypeLayoutRow; } interface DotHtmlToImage { /** @@ -827,23 +826,23 @@ export namespace Components { /** * (optional) Label for the add button in the key-value-form */ - "formAddButtonLabel": string; + "formAddButtonLabel"?: string; /** * (optional) The string to use in the key label in the key-value-form */ - "formKeyLabel": string; + "formKeyLabel"?: string; /** * (optional) Placeholder for the key input text in the key-value-form */ - "formKeyPlaceholder": string; + "formKeyPlaceholder"?: string; /** * (optional) The string to use in the value label in the key-value-form */ - "formValueLabel": string; + "formValueLabel"?: string; /** * (optional) Placeholder for the value input text in the key-value-form */ - "formValuePlaceholder": string; + "formValuePlaceholder"?: string; /** * (optional) Hint text that suggest a clue of the field * @default '' @@ -857,7 +856,7 @@ export namespace Components { /** * (optional) The string to use in the delete button of a key/value item */ - "listDeleteLabel": string; + "listDeleteLabel"?: string; /** * Name that will be used as ID * @default '' @@ -890,11 +889,11 @@ export namespace Components { /** * (optional) The string containing the value to be parsed for whitelist key/value */ - "whiteList": string; + "whiteList"?: string; /** * (optional) The string to use in the empty option of whitelist dropdown key/value item */ - "whiteListEmptyOptionLabel": string; + "whiteListEmptyOptionLabel"?: string; } /** * Represent a dotcms label control. @@ -941,14 +940,12 @@ export namespace Components { "placeholder": string; /** * Show/Hide color picker - * @default null */ - "showColor": string; + "showColor"?: string; /** * Size value set for font-size - * @default null */ - "size": string; + "size"?: string; /** * Values that the auto-complete textbox should search for * @default MaterialIconClasses @@ -1147,29 +1144,23 @@ export namespace Components { */ "value": string; } - /** - * @deprecated Use dot-contentlet-status-badge instead - */ interface DotStateIcon { /** * @default { archived: 'Archived', published: 'Published', revision: 'Revision', draft: 'Draft' } */ - "labels": { archived: string; published: string; revision: string; draft: string; }; + "labels": Record; /** * @default '16px' */ "size": string; - /** - * @default null - */ - "state": DotContentState; + "state"?: DotContentState; } interface DotTags { /** * Function or array of string to get the data to use for the autocomplete search * @default null */ - "data": () => Promise | string[]; + "data": (() => Promise | string[]) | null; /** * Duraction in ms to start search into the autocomplete * @default 300 @@ -1410,9 +1401,9 @@ export namespace Components { "value": string; } interface DotTooltip { - "content": string; - "delay": number; - "for": string; + "content"?: string; + "delay"?: number; + "for"?: string; /** * @default 'center bottom' */ @@ -1420,8 +1411,7 @@ export namespace Components { } interface DotVideoThumbnail { /** - * @type {DotContentletItem} - * @memberof DotVideoThumbnail + * Required in practice, not optional: `render` destructures it and the video URL interpolates its inode, so a missing contentlet has always thrown rather than degraded — the same reason `dot-contentlet-thumbnail` declares it this way. */ "contentlet": DotContentletItem; /** @@ -1441,7 +1431,7 @@ export namespace Components { * @type {string} * @memberof variable */ - "variable": string; + "variable"?: string; } interface KeyValueForm { /** @@ -1639,7 +1629,7 @@ declare global { new (): HTMLDotAssetDropZoneElement; }; interface HTMLDotAutocompleteElementEventMap { - "selection": string; + "selection": SelectionFeedback; "enter": string; "lostFocus": FocusEvent; } @@ -1963,8 +1953,10 @@ declare global { }; interface HTMLDotHtmlToImageElementEventMap { "pageThumbnail": { - file: File; - error?: string; + /** Null on every failure path — a document that would not open, a script that would not load. */ + file: File | null; + /** A message, or the caught value itself when the failure came from a `try`/`catch`. */ + error?: unknown; }; } interface HTMLDotHtmlToImageElement extends Components.DotHtmlToImage, HTMLStencilElement { @@ -2142,9 +2134,6 @@ declare global { prototype: HTMLDotSelectButtonElement; new (): HTMLDotSelectButtonElement; }; - /** - * @deprecated Use dot-contentlet-status-badge instead - */ interface HTMLDotStateIconElement extends Components.DotStateIcon, HTMLStencilElement { } var HTMLDotStateIconElement: { @@ -2407,10 +2396,10 @@ declare namespace LocalJSX { } interface DotAutocomplete { /** - * Function or array of string to get the data to use for the autocomplete search + * Function or array of string to get the data to use for the autocomplete search. Null until a consumer supplies one, which `componentDidLoad` checks before initialising. * @default null */ - "data"?: () => Promise | string[]; + "data"?: (() => Promise | string[]) | null; /** * (optional) Duraction in ms to start search into the autocomplete * @default 300 @@ -2428,7 +2417,10 @@ declare namespace LocalJSX { "maxResults"?: number; "onEnter"?: (event: DotAutocompleteCustomEvent) => void; "onLostFocus"?: (event: DotAutocompleteCustomEvent) => void; - "onSelection"?: (event: DotAutocompleteCustomEvent) => void; + /** + * Emitted when a suggestion is chosen. Typed `SelectionFeedback`, not `string`: nothing in this component calls `.emit()` — the event is dispatched by autocomplete.js on the inner input and bubbles to the host — and its payload is the library's own feedback object, which is what `dot-tags.onSelectHandler` reads (`detail.selection.value`). The declaration exists to type the `onSelection` prop. + */ + "onSelection"?: (event: DotAutocompleteCustomEvent) => void; /** * (optional) text to show when no value is set * @default '' @@ -2441,21 +2433,12 @@ declare namespace LocalJSX { "threshold"?: number; } interface DotBadge { - /** - * @default null - */ "bgColor"?: string; /** * @default false */ "bordered"?: boolean; - /** - * @default null - */ "color"?: string; - /** - * @default null - */ "size"?: string; } /** @@ -2606,10 +2589,10 @@ declare namespace LocalJSX { */ "required"?: boolean; /** - * Value specifies the value of the element - * @default null + * Value specifies the value of the element. `string | File`, because both are assigned: `handleURLPaste` stores the pasted URL and `handleFilePaste` stores the pasted `File` itself. The `` in `render` accepts neither directly, so it coerces — which is what Stencil's attribute serialization already did, meaning a pasted file renders as `[object File]` rather than its name. That is a display bug, but repairing it changes what the user sees; see the note in `render`. + * @default '' */ - "value"?: any; + "value"?: string | File; } /** * Represent a dotcms text field for the binary file element. @@ -2656,7 +2639,10 @@ declare namespace LocalJSX { * @default '96px' */ "iconSize"?: string; - "item"?: DotCardContentletItem; + /** + * Required in practice, not optional: `render` reads `contentlet.language` and `contentlet.locked` with no guard, so a missing item has always thrown. Third component with this shape, after `dot-contentlet-thumbnail` and `dot-video-thumbnail`. + */ + "item": DotCardContentletItem; "onCheckboxChange"?: (event: DotCardContentletCustomEvent) => void; "onContextMenuClick"?: (event: DotCardContentletCustomEvent) => void; /** @@ -2781,7 +2767,10 @@ declare namespace LocalJSX { * @default false */ "backgroundImage"?: boolean; - "contentlet"?: DotContentletItem; + /** + * Required in practice, not optional: `componentWillLoad` destructures it on the first line, so a missing contentlet has always thrown rather than degraded. Declared with a definite assignment so the eighteen accesses below read it directly, as they already did. + */ + "contentlet": DotContentletItem; /** * @default '' */ @@ -3070,8 +3059,10 @@ declare namespace LocalJSX { */ "height"?: string; "onPageThumbnail"?: (event: DotHtmlToImageCustomEvent<{ - file: File; - error?: string; + /** Null on every failure path — a document that would not open, a script that would not load. */ + file: File | null; + /** A message, or the caught value itself when the failure came from a `try`/`catch`. */ + error?: unknown; }>) => void; /** * @default '' @@ -3253,12 +3244,10 @@ declare namespace LocalJSX { "placeholder"?: string; /** * Show/Hide color picker - * @default null */ "showColor"?: string; /** * Size value set for font-size - * @default null */ "size"?: string; /** @@ -3452,21 +3441,15 @@ declare namespace LocalJSX { */ "value"?: string; } - /** - * @deprecated Use dot-contentlet-status-badge instead - */ interface DotStateIcon { /** * @default { archived: 'Archived', published: 'Published', revision: 'Revision', draft: 'Draft' } */ - "labels"?: { archived: string; published: string; revision: string; draft: string; }; + "labels"?: Record; /** * @default '16px' */ "size"?: string; - /** - * @default null - */ "state"?: DotContentState; } interface DotTags { @@ -3474,7 +3457,7 @@ declare namespace LocalJSX { * Function or array of string to get the data to use for the autocomplete search * @default null */ - "data"?: () => Promise | string[]; + "data"?: (() => Promise | string[]) | null; /** * Duraction in ms to start search into the autocomplete * @default 300 @@ -3716,10 +3699,9 @@ declare namespace LocalJSX { } interface DotVideoThumbnail { /** - * @type {DotContentletItem} - * @memberof DotVideoThumbnail + * Required in practice, not optional: `render` destructures it and the video URL interpolates its inode, so a missing contentlet has always thrown rather than degraded — the same reason `dot-contentlet-thumbnail` declares it this way. */ - "contentlet"?: DotContentletItem; + "contentlet": DotContentletItem; /** * @type {boolean} * @memberof DotVideoThumbnail @@ -3964,9 +3946,6 @@ declare module "@stencil/core" { */ "dot-select": LocalJSX.DotSelect & JSXBase.HTMLAttributes; "dot-select-button": LocalJSX.DotSelectButton & JSXBase.HTMLAttributes; - /** - * @deprecated Use dot-contentlet-status-badge instead - */ "dot-state-icon": LocalJSX.DotStateIcon & JSXBase.HTMLAttributes; "dot-tags": LocalJSX.DotTags & JSXBase.HTMLAttributes; /** diff --git a/core-web/libs/dotcms-webcomponents/src/components/contenttypes-fields/dot-binary-file/components/dot-binary-file-preview/dot-binary-file-preview.tsx b/core-web/libs/dotcms-webcomponents/src/components/contenttypes-fields/dot-binary-file/components/dot-binary-file-preview/dot-binary-file-preview.tsx index c65d78ca0d1d..2b9ac0ef279b 100644 --- a/core-web/libs/dotcms-webcomponents/src/components/contenttypes-fields/dot-binary-file/components/dot-binary-file-preview/dot-binary-file-preview.tsx +++ b/core-web/libs/dotcms-webcomponents/src/components/contenttypes-fields/dot-binary-file/components/dot-binary-file-preview/dot-binary-file-preview.tsx @@ -12,7 +12,7 @@ import { Component, Element, Event, EventEmitter, Prop, Host, h } from '@stencil }) export class DotBinaryFilePreviewComponent { @Element() - el: HTMLElement; + el!: HTMLElement; /** file name to be displayed */ @Prop({ reflect: true, mutable: true }) @@ -28,7 +28,7 @@ export class DotBinaryFilePreviewComponent { /** Emit when the file is deleted */ @Event() - delete: EventEmitter; + delete!: EventEmitter; render() { return this.fileName ? ( @@ -46,8 +46,10 @@ export class DotBinaryFilePreviewComponent { private clearFile(): void { this.delete.emit(); - this.fileName = null; - this.previewUrl = null; + // Back to the props' own declared default rather than null: `render` gates on `fileName` + // being truthy, so both are equivalent, and `''` is what the component started with. + this.fileName = ''; + this.previewUrl = ''; } private getPreviewElement() { diff --git a/core-web/libs/dotcms-webcomponents/src/components/contenttypes-fields/dot-binary-file/components/dot-binary-text-field/dot-binary-text-field.tsx b/core-web/libs/dotcms-webcomponents/src/components/contenttypes-fields/dot-binary-file/components/dot-binary-text-field/dot-binary-text-field.tsx index bd872d5f178b..0b924d94491e 100644 --- a/core-web/libs/dotcms-webcomponents/src/components/contenttypes-fields/dot-binary-file/components/dot-binary-text-field/dot-binary-text-field.tsx +++ b/core-web/libs/dotcms-webcomponents/src/components/contenttypes-fields/dot-binary-file/components/dot-binary-text-field/dot-binary-text-field.tsx @@ -14,11 +14,19 @@ import { getErrorClass, getHintId, isFileAllowed, isValidURL } from '../../../.. }) export class DotBinaryTextFieldComponent { @Element() - el: HTMLElement; - - /** Value specifies the value of the element */ + el!: HTMLElement; + + /** + * Value specifies the value of the element. + * + * `string | File`, because both are assigned: `handleURLPaste` stores the pasted URL and + * `handleFilePaste` stores the pasted `File` itself. The `` in `render` accepts + * neither directly, so it coerces — which is what Stencil's attribute serialization already + * did, meaning a pasted file renders as `[object File]` rather than its name. That is a + * display bug, but repairing it changes what the user sees; see the note in `render`. + */ @Prop({ mutable: true, reflect: true }) - value = null; + value: string | File = ''; /** (optional) Hint text that suggest a clue of the field */ @Prop({ reflect: true }) @@ -34,19 +42,19 @@ export class DotBinaryTextFieldComponent { /** (optional) Describes a type of file that may be selected by the user, separated by comma eg: .pdf,.jpg */ @Prop({ reflect: true }) - accept: string; + accept?: string; /** (optional) Disables field's interaction */ @Prop({ reflect: true }) disabled = false; @State() - status: DotFieldStatus; + status!: DotFieldStatus; @Event() - fileChange: EventEmitter; + fileChange!: EventEmitter; @Event() - lostFocus: EventEmitter; + lostFocus!: EventEmitter; render() { return ( @@ -57,7 +65,10 @@ export class DotBinaryTextFieldComponent { class={getErrorClass(this.isValid())} disabled={this.disabled} placeholder={this.placeholder} - value={this.value} + // Explicit coercion, not a change: Stencil already stringified this on its + // way to the attribute. Rendering a pasted `File`'s name instead of + // `[object File]` is the real fix and needs its own issue. + value={String(this.value)} onBlur={() => this.lostFocus.emit()} onKeyDown={(event: KeyboardEvent) => this.keyDownHandler(event)} onPaste={(event: ClipboardEvent) => this.pasteHandler(event)} @@ -87,8 +98,9 @@ export class DotBinaryTextFieldComponent { private pasteHandler(event: ClipboardEvent): void { event.preventDefault(); this.value = ''; - const clipboardData: DataTransfer = event.clipboardData; - if (clipboardData.items.length) { + // Null when the paste carried no data at all, which the length check below already covers. + const clipboardData: DataTransfer | null = event.clipboardData; + if (clipboardData?.items.length) { if (this.isPastingFile(clipboardData)) { this.handleFilePaste(clipboardData.items); } else { @@ -99,9 +111,9 @@ export class DotBinaryTextFieldComponent { } private handleFilePaste(items: DataTransferItemList) { - const clipBoardFile = items[1].getAsFile(); + const clipBoardFile = items[1]?.getAsFile(); - if (isFileAllowed(clipBoardFile.name, clipBoardFile.type, this.accept)) { + if (clipBoardFile && isFileAllowed(clipBoardFile.name, clipBoardFile.type, this.accept)) { this.value = clipBoardFile; this.emitFile(clipBoardFile); } else { @@ -128,7 +140,10 @@ export class DotBinaryTextFieldComponent { return !(this.required && !!this.value); } - private emitFile(file: File | string, errorType?: DotBinaryMessageError): void { + private emitFile( + file: File | string | null, + errorType: DotBinaryMessageError | null = null + ): void { this.fileChange.emit({ file: file, errorType: errorType diff --git a/core-web/libs/dotcms-webcomponents/src/components/contenttypes-fields/dot-binary-file/components/dot-binary-text-field/readme.md b/core-web/libs/dotcms-webcomponents/src/components/contenttypes-fields/dot-binary-file/components/dot-binary-text-field/readme.md index 1a99d629ee52..e05aae8f5318 100644 --- a/core-web/libs/dotcms-webcomponents/src/components/contenttypes-fields/dot-binary-file/components/dot-binary-text-field/readme.md +++ b/core-web/libs/dotcms-webcomponents/src/components/contenttypes-fields/dot-binary-file/components/dot-binary-text-field/readme.md @@ -9,14 +9,14 @@ Represent a dotcms text field for the binary file element. ## Properties -| Property | Attribute | Description | Type | Default | -| ------------- | ------------- | ------------------------------------------------------------------------------------------------------- | --------- | ----------- | -| `accept` | `accept` | (optional) Describes a type of file that may be selected by the user, separated by comma eg: .pdf,.jpg | `string` | `undefined` | -| `disabled` | `disabled` | (optional) Disables field's interaction | `boolean` | `false` | -| `hint` | `hint` | (optional) Hint text that suggest a clue of the field | `string` | `''` | -| `placeholder` | `placeholder` | (optional) Placeholder specifies a short hint that describes the expected value of the input field | `string` | `''` | -| `required` | `required` | (optional) Determine if it is mandatory | `boolean` | `false` | -| `value` | `value` | Value specifies the value of the element | `any` | `null` | +| Property | Attribute | Description | Type | Default | +| ------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ----------- | +| `accept` | `accept` | (optional) Describes a type of file that may be selected by the user, separated by comma eg: .pdf,.jpg | `string \| undefined` | `undefined` | +| `disabled` | `disabled` | (optional) Disables field's interaction | `boolean` | `false` | +| `hint` | `hint` | (optional) Hint text that suggest a clue of the field | `string` | `''` | +| `placeholder` | `placeholder` | (optional) Placeholder specifies a short hint that describes the expected value of the input field | `string` | `''` | +| `required` | `required` | (optional) Determine if it is mandatory | `boolean` | `false` | +| `value` | `value` | Value specifies the value of the element. `string \| File`, because both are assigned: `handleURLPaste` stores the pasted URL and `handleFilePaste` stores the pasted `File` itself. The `` in `render` accepts neither directly, so it coerces — which is what Stencil's attribute serialization already did, meaning a pasted file renders as `[object File]` rather than its name. That is a display bug, but repairing it changes what the user sees; see the note in `render`. | `File \| string` | `''` | ## Events diff --git a/core-web/libs/dotcms-webcomponents/src/components/contenttypes-fields/dot-binary-file/components/dot-binary-upload-button/dot-binary-upload-button.tsx b/core-web/libs/dotcms-webcomponents/src/components/contenttypes-fields/dot-binary-file/components/dot-binary-upload-button/dot-binary-upload-button.tsx index f0367a40b434..b464d5e150d5 100644 --- a/core-web/libs/dotcms-webcomponents/src/components/contenttypes-fields/dot-binary-file/components/dot-binary-upload-button/dot-binary-upload-button.tsx +++ b/core-web/libs/dotcms-webcomponents/src/components/contenttypes-fields/dot-binary-file/components/dot-binary-upload-button/dot-binary-upload-button.tsx @@ -14,7 +14,7 @@ import { getId, isFileAllowed } from '../../../../../utils'; }) export class DotBinaryUploadButtonComponent { @Element() - el: HTMLElement; + el!: HTMLElement; /** Name that will be used as ID */ @Prop({ reflect: true }) @@ -26,7 +26,7 @@ export class DotBinaryUploadButtonComponent { /** (optional) Describes a type of file that may be selected by the user, separated by comma eg: .pdf,.jpg */ @Prop({ reflect: true }) - accept: string; + accept?: string; /** (optional) Disables field's interaction */ @Prop({ reflect: true }) @@ -41,7 +41,7 @@ export class DotBinaryUploadButtonComponent { buttonLabel = ''; @Event() - fileChange: EventEmitter; + fileChange!: EventEmitter; render() { return ( @@ -51,7 +51,7 @@ export class DotBinaryUploadButtonComponent { disabled={this.disabled} id={getId(this.name)} onChange={(event: Event) => this.fileChangeHandler(event)} - required={this.required || null} + required={this.required || undefined} type="file" />