Skip to content

Commit fd74d88

Browse files
hmorerasclaude
andauthored
fix(lara-theme): UX and styling fixes across Apps, Tags, Locales portlets and legacy UI (#35157)
## Summary This PR addresses several visual and UX regressions introduced during the Lara Theme migration, and improves the UX of the Tags and Locales portlets. --- ## Legacy UI / SCSS fixes ### Firefox button styling (`_button.scss`, `_common.scss`) Dojo's `hccss.js` script false-positives high-contrast mode detection inside Firefox iframes, adding the `dj_a11y` class to `<body>`. This caused a Dojo rule (`border: black outset medium !important`) to override button styles in the Permissions tab of Containers and Templates. Fixed by adding a `.dj_a11y` override block with `!important` to cancel the border and restore padding. ### File upload button (`_fields.scss`) Added the standard `::file-selector-button` pseudo-element rule alongside the existing `-webkit-file-upload-button` one. Kept hover rules in separate selectors to prevent Firefox from invalidating the entire combined rule (cross-browser selector invalidation). ### Native `<dialog>` centering (`dotcms.scss`) Added a `dialog:modal` rule that reliably centers native `<dialog>` elements opened via `showModal()` using `position: fixed` + `transform: translate(-50%, -50%)`, with a `::backdrop` overlay. Uses the `:modal` pseudo-class which only targets `showModal()` dialogs — not inline `<dialog open>` elements or PrimeNG dialogs (which use `<div>`). ### Compiled output All SCSS changes compiled into `dotcms.css`. --- ## Login page Prevented the "Remember Me" label from wrapping onto a second line by adding `flex items-center gap-2` to the checkbox wrapper div and `whitespace-nowrap` to the label. --- ## Apps portlet (Settings > Apps) Two field types existed in the backend API response but had no rendering logic in the Angular template, making them invisible after the Lara Theme migration: - **`HEADING`** — now rendered as an `<h3>` inside a `dot-apps-configuration-detail__section-header` div (the SCSS class was already defined). - **`INFO`** — now rendered as a markdown content box inside a `dot-apps-configuration-detail__info-box` div. Neither type is added to the reactive form group. - Fixed missing `gap-2` between checkboxes and labels in `BOOL` field rows. - Added tests covering HEADING rendering, INFO rendering, and form group exclusion. --- ## Tags portlet ### Bulk action toolbar When one or more tags are selected, the Add split-button is hidden and only the bulk action buttons are shown. Button order follows the pattern of least-destructive on the left: **Delete** → **Export**. ### Import dialog UX Previously the dialog stayed open after a successful import, showing a result banner and a "Done" button. The new flow: 1. User selects a CSV file and clicks **Import** 2. Dialog closes immediately on success 3. A global notification appears via `DotMessageDisplayService` (success or warning for partial imports) 4. The tags list refreshes automatically Additional changes: - "Download CSV File" button renamed to **"Download Example CSV File"** for clarity (`Language.properties`). - The result banner and Done button are removed from the dialog; buttons are now always **Cancel / Download Example CSV File / Import**. - Uses the app-wide `DotMessageDisplayService` (feeds the global `<p-toast />` in the main layout) — no local `MessageService` or `<p-toast />` added to the portlet. --- ## Locales portlet - Added `[rowHover]="true"` to the table for visual consistency with other portlets. - Changed the "Add Locale" button label to use the generic `'add'` i18n key. - Added `cursor-pointer` to table rows to signal they are clickable. --- ## Test plan - [ ] Verify Firefox button styling in Containers/Templates > Permissions tab - [ ] Verify file upload button hover state in legacy content edit forms - [ ] Verify native `<dialog>` in Maintenance portlet (e.g. delete key) is centered and has backdrop - [ ] Verify "Remember Me" label stays on one line on the login page - [ ] Verify HEADING and INFO fields appear correctly in Settings > Apps > Content Analytics (and other apps) - [ ] Verify Tags bulk actions: select tags → only Delete + Export shown; deselect → Add restored - [ ] Verify Tags import: dialog closes immediately after import, global toast appears, list refreshes - [ ] Verify "Download Example CSV File" label in import dialog - [ ] Verify Locales table has hover state and cursor-pointer on rows - [ ] Run `yarn nx test portlets-dot-tags-portlet` — all 79 tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <[email protected]>
1 parent c28c581 commit fd74d88

File tree

18 files changed

+407
-227
lines changed

18 files changed

+407
-227
lines changed

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.html

Lines changed: 85 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -16,75 +16,96 @@
1616
</ng-template>
1717

1818
@for (field of $formFields(); track field) {
19-
<div [attr.data-testid]="field.name" class="field">
20-
@switch (field.type) {
21-
@case ('BUTTON') {
22-
<ng-container *ngTemplateOutlet="labelField; context: { field: field }" />
23-
<div>
24-
<button
25-
(click)="onIntegrate(field.value)"
19+
@if (field.type === 'HEADING') {
20+
<div
21+
class="dot-apps-configuration-detail__section-header"
22+
[attr.data-testid]="field.name">
23+
<h3>{{ field.label }}</h3>
24+
</div>
25+
} @else if (field.type === 'INFO') {
26+
<div
27+
class="dot-apps-configuration-detail__info-box"
28+
[attr.data-testid]="field.name">
29+
<markdown>{{ field.hint }}</markdown>
30+
</div>
31+
} @else {
32+
<div [attr.data-testid]="field.name" class="field">
33+
@switch (field.type) {
34+
@case ('BUTTON') {
35+
<ng-container
36+
*ngTemplateOutlet="labelField; context: { field: field }" />
37+
<div>
38+
<button
39+
(click)="onIntegrate(field.value)"
40+
[id]="field.name"
41+
[label]="field.label"
42+
[disabled]="!$appConfigured()"
43+
pButton
44+
type="button"></button>
45+
<ng-container
46+
*ngTemplateOutlet="warningIcon; context: { field: field }" />
47+
</div>
48+
<span class="form__group-hint">
49+
<markdown>{{ field.hint }}</markdown>
50+
</span>
51+
}
52+
@case ('STRING') {
53+
<ng-container
54+
*ngTemplateOutlet="labelField; context: { field: field }" />
55+
<ng-container
56+
*ngTemplateOutlet="warningIcon; context: { field: field }" />
57+
<textarea
58+
(click)="field.hidden ? $event.target.select() : null"
2659
[id]="field.name"
27-
[label]="field.label"
28-
[disabled]="!$appConfigured()"
29-
pButton
30-
type="button"></button>
60+
[formControlName]="field.name"
61+
#inputTextarea
62+
pTextarea
63+
[autoResize]="true"></textarea>
64+
<span class="p-field-hint">
65+
<markdown>{{ field.hint }}</markdown>
66+
</span>
67+
}
68+
@case ('GENERATED_STRING') {
69+
<ng-container
70+
*ngTemplateOutlet="labelField; context: { field: field }" />
71+
<dot-apps-configuration-detail-generated-string-field
72+
data-testid="generated-string-field"
73+
[formControlName]="field.name"
74+
[field]="field" />
75+
}
76+
@case ('BOOL') {
77+
<div class="flex items-center gap-2">
78+
<p-checkbox
79+
[class.required]="field.required"
80+
[inputId]="field.name"
81+
[formControlName]="field.name"
82+
[value]="field.value"
83+
[binary]="true" />
84+
<label [for]="field.name">{{ field.label }}</label>
85+
</div>
3186
<ng-container
3287
*ngTemplateOutlet="warningIcon; context: { field: field }" />
33-
</div>
34-
<span class="form__group-hint">
35-
<markdown>{{ field.hint }}</markdown>
36-
</span>
37-
}
38-
@case ('STRING') {
39-
<ng-container *ngTemplateOutlet="labelField; context: { field: field }" />
40-
<ng-container *ngTemplateOutlet="warningIcon; context: { field: field }" />
41-
<textarea
42-
(click)="field.hidden ? $event.target.select() : null"
43-
[id]="field.name"
44-
[formControlName]="field.name"
45-
#inputTextarea
46-
pTextarea
47-
[autoResize]="true"></textarea>
48-
<span class="p-field-hint">
49-
<markdown>{{ field.hint }}</markdown>
50-
</span>
51-
}
52-
@case ('GENERATED_STRING') {
53-
<ng-container *ngTemplateOutlet="labelField; context: { field: field }" />
54-
<dot-apps-configuration-detail-generated-string-field
55-
data-testid="generated-string-field"
56-
[formControlName]="field.name"
57-
[field]="field" />
58-
}
59-
@case ('BOOL') {
60-
<div class="flex items-center">
61-
<p-checkbox
62-
[class.required]="field.required"
63-
[inputId]="field.name"
88+
<span class="p-field-hint">
89+
<markdown>{{ field.hint }}</markdown>
90+
</span>
91+
}
92+
@case ('SELECT') {
93+
<ng-container
94+
*ngTemplateOutlet="labelField; context: { field: field }" />
95+
<ng-container
96+
*ngTemplateOutlet="warningIcon; context: { field: field }" />
97+
<p-select
98+
[id]="field.name"
6499
[formControlName]="field.name"
65-
[value]="field.value"
66-
[binary]="true" />
67-
<label [for]="field.name">{{ field.label }}</label>
68-
</div>
69-
<ng-container *ngTemplateOutlet="warningIcon; context: { field: field }" />
70-
<span class="p-field-hint">
71-
<markdown>{{ field.hint }}</markdown>
72-
</span>
73-
}
74-
@case ('SELECT') {
75-
<ng-container *ngTemplateOutlet="labelField; context: { field: field }" />
76-
<ng-container *ngTemplateOutlet="warningIcon; context: { field: field }" />
77-
<p-select
78-
[id]="field.name"
79-
[formControlName]="field.name"
80-
[class.required]="field.required"
81-
[options]="field.options" />
82-
<span class="p-field-hint">
83-
<markdown>{{ field.hint }}</markdown>
84-
</span>
100+
[class.required]="field.required"
101+
[options]="field.options" />
102+
<span class="p-field-hint">
103+
<markdown>{{ field.hint }}</markdown>
104+
</span>
105+
}
85106
}
86-
}
87-
</div>
107+
</div>
108+
}
88109
}
89110
</form>
90111
}

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,34 @@ import { DotAppsConfigurationDetailFormComponent } from './dot-apps-configuratio
1818

1919
import { DotAppsConfigurationDetailGeneratedStringFieldComponent } from '../dot-apps-configuration-detail-generated-string-field/dot-apps-configuration-detail-generated-string-field.component';
2020

21+
const headingSecret = {
22+
dynamic: false,
23+
name: 'sectionHeader',
24+
hidden: false,
25+
hint: '',
26+
label: 'Server-Rendered Pages Configuration',
27+
required: false,
28+
type: 'HEADING',
29+
value: '',
30+
hasEnvVar: false,
31+
envShow: false,
32+
hasEnvVarValue: false
33+
};
34+
35+
const infoSecret = {
36+
dynamic: false,
37+
name: 'infoBox',
38+
hidden: false,
39+
hint: 'These settings apply ONLY to pages rendered by dotCMS servers.',
40+
label: '',
41+
required: false,
42+
type: 'INFO',
43+
value: '',
44+
hasEnvVar: false,
45+
envShow: false,
46+
hasEnvVarValue: false
47+
};
48+
2149
const secrets = [
2250
{
2351
dynamic: false,
@@ -315,6 +343,42 @@ describe('DotAppsConfigurationDetailFormComponent', () => {
315343
expect(spyValidOutput).toHaveBeenCalledTimes(3);
316344
});
317345

346+
it('should render HEADING field as section header with label text', () => {
347+
const spectatorWithHeading = createComponent({
348+
props: { formFields: [headingSecret, ...secrets] } as unknown
349+
});
350+
spectatorWithHeading.detectChanges();
351+
352+
const header = spectatorWithHeading.query('[data-testid="sectionHeader"]');
353+
expect(header).toBeTruthy();
354+
expect(header.classList).toContain('dot-apps-configuration-detail__section-header');
355+
expect(header.querySelector('h3').textContent.trim()).toBe(headingSecret.label);
356+
});
357+
358+
it('should render INFO field as info box with hint text', () => {
359+
const spectatorWithInfo = createComponent({
360+
props: { formFields: [infoSecret, ...secrets] } as unknown
361+
});
362+
spectatorWithInfo.detectChanges();
363+
364+
const infoBox = spectatorWithInfo.query('[data-testid="infoBox"]');
365+
expect(infoBox).toBeTruthy();
366+
expect(infoBox.classList).toContain('dot-apps-configuration-detail__info-box');
367+
expect(infoBox.querySelector('markdown')).toBeTruthy();
368+
});
369+
370+
it('should not add HEADING or INFO fields to the form group', () => {
371+
const spectatorWithExtra = createComponent({
372+
props: {
373+
formFields: [headingSecret, infoSecret, ...secrets]
374+
} as unknown
375+
});
376+
spectatorWithExtra.detectChanges();
377+
378+
expect(spectatorWithExtra.component.myFormGroup.contains('sectionHeader')).toBe(false);
379+
expect(spectatorWithExtra.component.myFormGroup.contains('infoBox')).toBe(false);
380+
});
381+
318382
it('should emit form state disabled when required field empty', () => {
319383
const spyValidOutput = jest.spyOn(spectator.component.valid, 'emit');
320384

core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@ import {
3131
DotWorkflowActionsFireService,
3232
PaginatorService
3333
} from '@dotcms/data-access';
34-
import { SiteService } from '@dotcms/dotcms-js';
34+
import { DotcmsEventsService, SiteService } from '@dotcms/dotcms-js';
3535
import { DotSystemConfig } from '@dotcms/dotcms-models';
3636
import { DotFormDialogComponent, DotMessagePipe, DotApiLinkComponent } from '@dotcms/ui';
3737
import {
3838
DotCurrentUserServiceMock,
39+
DotcmsEventsServiceMock,
3940
MockDotMessageService,
4041
MockDotRouterService,
4142
mockDotThemes,
@@ -299,6 +300,10 @@ describe('DotTemplateCreateEditComponent', () => {
299300
get: jest.fn().mockReturnValue(of(mockDotThemes[1]))
300301
}
301302
},
303+
{
304+
provide: DotcmsEventsService,
305+
useValue: new DotcmsEventsServiceMock()
306+
},
302307
{ provide: DotSystemConfigService, useClass: MockDotSystemConfigService },
303308
{ provide: DotRouterService, useClass: MockDotRouterService }
304309
]

core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-edit-contentlet/dot-edit-contentlet.component.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { of as observableOf } from 'rxjs';
22

3+
import { HttpTestingController } from '@angular/common/http/testing';
34
import { DebugElement } from '@angular/core';
45
import { ComponentFixture, waitForAsync } from '@angular/core/testing';
56
import { By } from '@angular/platform-browser';
@@ -27,6 +28,7 @@ describe('DotEditContentletComponent', () => {
2728
let dotEditContentletWrapper: DebugElement;
2829
let dotEditContentletWrapperComponent: DotContentletWrapperComponent;
2930
let dotContentletEditorService: DotContentletEditorService;
31+
let httpMock: HttpTestingController;
3032

3133
beforeEach(waitForAsync(() => {
3234
DOTTestBed.configureTestingModule({
@@ -71,6 +73,7 @@ describe('DotEditContentletComponent', () => {
7173
de = fixture.debugElement;
7274
component = de.componentInstance;
7375
dotContentletEditorService = de.injector.get(DotContentletEditorService);
76+
httpMock = de.injector.get(HttpTestingController);
7477

7578
jest.spyOn(component.shutdown, 'emit');
7679

@@ -80,6 +83,10 @@ describe('DotEditContentletComponent', () => {
8083
dotEditContentletWrapperComponent = dotEditContentletWrapper.componentInstance;
8184
});
8285

86+
afterEach(() => {
87+
httpMock.match(() => true).forEach((req) => req.flush({}));
88+
});
89+
8390
describe('default', () => {
8491
it('should have dot-contentlet-wrapper', () => {
8592
expect(dotEditContentletWrapper).toBeTruthy();

core-web/apps/dotcms-ui/src/app/view/components/login/dot-login-component/dot-login.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ <h3 class="text-2xl font-bold" data-testId="header">
7171
formControlName="rememberMe"
7272
data-testId="rememberMe"
7373
[binary]="true" />
74-
<label [for]="'rememberMe'">
74+
<label [for]="'rememberMe'" class="whitespace-nowrap">
7575
{{ loginInfo.i18nMessagesMap['remember-me'] }}
7676
</label>
7777
</div>

core-web/libs/dotcms-scss/jsp/scss/backend/dot-admin/forms/_fields.scss

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,19 @@ Styles for commons fields along the backend
3232
width: 100%;
3333
}
3434

35+
input[type="file"]::file-selector-button,
3536
input[type="file"]::-webkit-file-upload-button {
3637
background-color: $color-palette-primary;
3738
height: 36px;
3839
border: 0;
3940
color: $white;
4041
cursor: pointer;
4142
padding: 0 1rem;
43+
}
4244

43-
&:hover {
44-
background-color: $color-palette-primary-400;
45-
}
45+
input[type="file"]::file-selector-button:hover,
46+
input[type="file"]::-webkit-file-upload-button:hover {
47+
background-color: $color-palette-primary-400;
4648
}
4749

4850
&Actions div {

core-web/libs/dotcms-scss/jsp/scss/dijit/form/_button.scss

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@
3636
margin: 0;
3737
}
3838

39+
// Dojo's hccss.js false-positively detects high-contrast mode in Firefox,
40+
// adding `dj_a11y` to <body> and applying `border: black outset medium !important`
41+
// to .dijitButtonNode. Override here to keep the dotCMS custom theme intact.
42+
.dj_a11y {
43+
.dijitButtonNode {
44+
border: none !important;
45+
padding: 0 $spacing-3 !important;
46+
}
47+
48+
.dijitArrowButton {
49+
padding: 0 !important;
50+
}
51+
}
52+
3953
.dijit {
4054
&Button {
4155
@include btn(
@@ -46,6 +60,7 @@
4660
);
4761

4862
&Node {
63+
appearance: none;
4964
border: none;
5065
font-size: $font-size-md;
5166
}

core-web/libs/dotcms-scss/jsp/scss/dijit/form/_common.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
.dijitDropDownButton .dijitArrowButtonInner,
3232
.dijitSelect .dijitArrowButtonContainer,
3333
.dijitSpinner .dijitSpinnerButtonContainer {
34+
appearance: none;
3435
height: 100%;
3536
border: none;
3637
position: relative;

core-web/libs/dotcms-scss/jsp/scss/dotcms.scss

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,25 @@ td {
102102
-moz-osx-font-smoothing: grayscale;
103103
}
104104

105+
// Native HTML <dialog> opened via showModal() — explicit centering and
106+
// PrimeNG-compatible styling. :modal targets only showModal() dialogs,
107+
// not inline <dialog open> elements.
108+
dialog:modal {
109+
position: fixed;
110+
top: 50%;
111+
left: 50%;
112+
transform: translate(-50%, -50%);
113+
margin: 0;
114+
border: none;
115+
border-radius: $border-radius-md;
116+
box-shadow: 0px 10px 18px 0px hsla(230deg, 13%, 9%, 0.1);
117+
padding: $spacing-4;
118+
119+
&::backdrop {
120+
background-color: rgba(0, 0, 0, 0.4);
121+
}
122+
}
123+
105124
.edit-content-full-screen {
106125
width: 100% !important;
107126
max-width: 100% !important;

0 commit comments

Comments
 (0)