Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b373b9e
feat(apps): replace UVE configuration textarea with a Routes editor (…
rjvelazco Oct 5, 2026
718a116
test: verify GPG signing
rjvelazco Oct 5, 2026
90b5561
Merge branch 'main' into issue-37897-replace-uve-app-configuration-te…
rjvelazco Oct 5, 2026
ff11980
Merge branch 'issue-37897-replace-uve-app-configuration-textarea-with…
rjvelazco Oct 5, 2026
3b0d9a3
refactor(apps): use computeds for the UVE editor and centered form la…
rjvelazco Oct 5, 2026
07d0377
test(apps): allow third-party selectors on test stubs (#37897)
rjvelazco Oct 5, 2026
821e20e
test(apps): make the UVE editor code pass the strict-mode gate (#37897)
rjvelazco Oct 5, 2026
57880b7
fix(apps): truncate long UVE route titles instead of growing the card…
rjvelazco Oct 5, 2026
d476e4e
feat(apps): split UVE route titles so pattern and server truncate sep…
rjvelazco Oct 5, 2026
000754c
Merge branch 'main' into issue-37897-replace-uve-app-configuration-te…
rjvelazco Oct 5, 2026
b585679
fix(apps): persist JSON app params as STRING so a rollback can read t…
rjvelazco Oct 5, 2026
45e462b
refactor(apps): address review feedback on the UVE editor (#37897)
rjvelazco Oct 5, 2026
c5358d0
test(apps): cover the JSON param save and descriptor checks (#37897)
rjvelazco Oct 5, 2026
ba54163
refactor(apps): accessible names, # privates and typed refs in the UV…
rjvelazco Oct 5, 2026
2883dc6
refactor(apps): use the max-w-content token for the Apps config form …
rjvelazco Oct 6, 2026
8be5998
test(apps): integration test for JSON params on create, update and re…
rjvelazco Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
@if (myFormGroup) {
<form [formGroup]="myFormGroup" class="form" #form="ngForm" #formContainer novalidate>
<form
[formGroup]="myFormGroup"
class="form max-w-content"
[class.mx-auto]="$shouldCenterForm()"
#form="ngForm"
#formContainer
novalidate>
<ng-template #warningIcon let-field="field">
@if (field.warnings && field.warnings.length) {
<dot-icon
Expand All @@ -26,7 +32,7 @@ <h3>{{ field.label }}</h3>
<div
class="dot-apps-configuration-detail__info-box"
[attr.data-testid]="field.name">
<markdown>{{ field.hint }}</markdown>
<markdown dotAppsCodeBlocks>{{ field.hint }}</markdown>
</div>
} @else {
<div [attr.data-testid]="field.name" class="field">
Expand All @@ -46,7 +52,7 @@ <h3>{{ field.label }}</h3>
*ngTemplateOutlet="warningIcon; context: { field: field }" />
</div>
<span class="form__group-hint">
<markdown>{{ field.hint }}</markdown>
<markdown dotAppsCodeBlocks>{{ field.hint }}</markdown>
</span>
}
@case ('STRING') {
Expand All @@ -62,9 +68,33 @@ <h3>{{ field.label }}</h3>
pTextarea
[autoResize]="true"></textarea>
<span class="p-field-hint">
<markdown>{{ field.hint }}</markdown>
<markdown dotAppsCodeBlocks>{{ field.hint }}</markdown>
</span>
}
@case ('JSON') {
<ng-container
*ngTemplateOutlet="labelField; context: { field: field }" />
<ng-container
*ngTemplateOutlet="warningIcon; context: { field: field }" />
@if ($isUveApp()) {
<!-- The UVE field shows the hint itself, only on its JSON tab. -->
<dot-apps-uve-config-field
data-testid="uve-config-field"
[fieldId]="field.name"
[label]="field.label"
[hint]="field.hint"
[formControlName]="field.name" />
} @else {
<dot-apps-configuration-detail-json-field
data-testid="json-field"
[fieldId]="field.name"
[label]="field.label"
[formControlName]="field.name" />
<span class="p-field-hint">
<markdown dotAppsCodeBlocks>{{ field.hint }}</markdown>
</span>
}
}
@case ('GENERATED_STRING') {
<ng-container
*ngTemplateOutlet="labelField; context: { field: field }" />
Expand All @@ -86,7 +116,7 @@ <h3>{{ field.label }}</h3>
<ng-container
*ngTemplateOutlet="warningIcon; context: { field: field }" />
<span class="p-field-hint">
<markdown>{{ field.hint }}</markdown>
<markdown dotAppsCodeBlocks>{{ field.hint }}</markdown>
</span>
}
@case ('SELECT') {
Expand All @@ -100,7 +130,7 @@ <h3>{{ field.label }}</h3>
[class.required]="field.required"
[options]="field.options" />
<span class="p-field-hint">
<markdown>{{ field.hint }}</markdown>
<markdown dotAppsCodeBlocks>{{ field.hint }}</markdown>
</span>
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,109 @@
@use "variables" as *;

:host {
form {
max-width: 64.25rem;
}

textarea {
font-family: fonts.$font-code;
max-height: 21.5rem;
overflow: auto !important;
}
}

::ng-deep {
.p-field-hint {
markdown pre {
background-color: colors.$color-palette-secondary-100;
}
}
// Rendered markdown hints. Selectors stay fully qualified (not nested) to keep at most three
// levels while preserving specificity, so the code-block rules still override the plain ones.
:host ::ng-deep {
.p-field-hint markdown {
display: block;
line-height: 1.5;
}

.p-field-hint markdown p,
.p-field-hint markdown ul {
margin: 0 0 spacing.$spacing-2;
}

.p-field-hint markdown ul {
padding-left: spacing.$spacing-4;
list-style: disc;
}

.p-field-hint markdown code {
font-family: fonts.$font-code;
font-size: fonts.$font-size-sm;
background-color: colors.$color-palette-gray-100;
border-radius: $border-radius-sm;
padding: 0 spacing.$spacing-0;
}

.p-field-hint markdown pre {
background-color: colors.$color-palette-gray-100;
border: 1px solid colors.$color-palette-gray-300;
border-radius: $border-radius-md;
margin: spacing.$spacing-2 0 0;
max-height: 21.5rem; // same cap as the textarea above
overflow: auto;
padding: spacing.$spacing-3;
}

.p-field-hint markdown pre code {
background: none;
color: colors.$color-palette-gray-800;
font-size: fonts.$font-size-sm;
line-height: 1.6;
padding: 0;
white-space: pre;
}

// Code blocks upgraded by DotAppsCodeBlocksDirective: white panel with a purple header,
// using the primary palette so it follows the instance theme color.
.p-field-hint markdown .dot-code-block {
background-color: colors.$white;
border: 1px solid colors.$color-palette-primary-200;
border-radius: $border-radius-md;
margin-top: spacing.$spacing-2;
overflow: hidden;
}

.p-field-hint markdown .dot-code-block pre {
background: none;
border: 0;
border-radius: 0;
margin: 0;
}

.p-field-hint markdown .dot-code-block pre code {
color: colors.$color-palette-gray-700;
text-shadow: none;
}

.dot-code-block__header {
align-items: center;
background-color: colors.$color-palette-primary-100;
border-bottom: 1px solid colors.$color-palette-primary-200;
display: flex;
justify-content: space-between;
padding: spacing.$spacing-1 spacing.$spacing-1 spacing.$spacing-1 spacing.$spacing-3;
}

.dot-code-block__language {
color: colors.$color-palette-primary-700;
font-family: fonts.$font-code;
font-size: fonts.$font-size-sm;
font-weight: fonts.$font-weight-semi-bold;
text-transform: uppercase;
}

.dot-code-block__key {
color: colors.$color-palette-primary-700;
font-weight: fonts.$font-weight-semi-bold;
}

.dot-code-block__string {
color: colors.$color-palette-primary-500;
}

.dot-code-block__number,
.dot-code-block__literal {
color: colors.$color-palette-secondary-600;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { DotFieldRequiredDirective } from '@dotcms/ui';
import { DotAppsConfigurationDetailFormComponent } from './dot-apps-configuration-detail-form.component';

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

const headingSecret = {
dynamic: false,
Expand Down Expand Up @@ -166,10 +167,77 @@ describe('DotAppsConfigurationDetailFormComponent', () => {
MockComponent(DotAppsConfigurationDetailGeneratedStringFieldComponent),
MockComponent(MarkdownComponent)
],
overrideComponents: [
[
DotAppsConfigurationDetailFormComponent,
{
remove: { imports: [DotAppsConfigurationDetailJsonFieldComponent] },
add: { imports: [MockComponent(DotAppsConfigurationDetailJsonFieldComponent)] }
}
]
],
providers: [FormGroupDirective],
declarations: []
});

describe('Form layout', () => {
it('should not center the form by default', () => {
spectator = createComponent({ props: { formFields: secrets } as never });
spectator.detectChanges();

expect(spectator.query('form')).not.toHaveClass('mx-auto');
expect(spectator.query('form')).toHaveClass('max-w-content');
});

it('should center the form for apps in the centered list', () => {
spectator = createComponent({
props: { formFields: secrets, appKey: 'dotema-config-v2' } as never
});
spectator.detectChanges();

expect(spectator.query('form')).toHaveClass('mx-auto');
});
});

describe('With a JSON field', () => {
const jsonSecret = {
dynamic: false,
name: 'configuration',
hidden: false,
hint: 'UVE configuration',
label: 'Configuration',
required: true,
type: 'JSON',
value: '{"config":[]}',
hasEnvVar: false,
envShow: true,
hasEnvVarValue: false
};

beforeEach(() => {
spectator = createComponent({
props: {
formFields: [jsonSecret]
} as never
});
spectator.detectChanges();
});

it('should render the JSON editor field with its label and hint', () => {
const row = spectator.query(byTestId('configuration'));

expect(row?.querySelector('dot-apps-configuration-detail-json-field')).toBeTruthy();
expect(row?.querySelector('label')?.textContent?.trim()).toBe(jsonSecret.label);
expect(row?.querySelector('.p-field-hint')?.textContent).toBe(jsonSecret.hint);
});

it('should load the saved JSON into the form control', () => {
expect(spectator.component.myFormGroup.get('configuration')?.value).toBe(
jsonSecret.value
);
});
});

describe('Without warnings', () => {
beforeEach(() => {
spectator = createComponent({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Subscription } from 'rxjs';
import { NgTemplateOutlet } from '@angular/common';
import {
Component,
computed,
effect,
ElementRef,
inject,
Expand Down Expand Up @@ -33,7 +34,14 @@ import { DotMessageService } from '@dotcms/data-access';
import { DotAppsSecret } from '@dotcms/dotcms-models';
import { DotFieldRequiredDirective, DotIconComponent } from '@dotcms/ui';

import { DotAppsCodeBlocksDirective } from '../../directives/dot-apps-code-block.directive';
import { DotAppsConfigurationDetailGeneratedStringFieldComponent } from '../dot-apps-configuration-detail-generated-string-field/dot-apps-configuration-detail-generated-string-field.component';
import { DotAppsConfigurationDetailJsonFieldComponent } from '../dot-apps-configuration-detail-json-field/dot-apps-configuration-detail-json-field.component';
import { DotAppsUveConfigFieldComponent } from '../dot-apps-uve-config-field/dot-apps-uve-config-field.component';
import { UVE_APP_KEY } from '../dot-apps-uve-config-field/dot-apps-uve-config.utils';

/** Apps whose configuration form is centered on the page; add keys as more apps are redesigned. */
const CENTERED_FORM_APP_KEYS: readonly string[] = [UVE_APP_KEY];

enum FieldStatus {
EDITABLE,
Expand All @@ -56,7 +64,10 @@ enum FieldStatus {
DotIconComponent,
DotFieldRequiredDirective,
MarkdownComponent,
DotAppsCodeBlocksDirective,
DotAppsConfigurationDetailGeneratedStringFieldComponent,
DotAppsConfigurationDetailJsonFieldComponent,
DotAppsUveConfigFieldComponent,
NgTemplateOutlet
],
changeDetection: ChangeDetectionStrategy.Eager,
Expand All @@ -69,6 +80,14 @@ export class DotAppsConfigurationDetailFormComponent implements OnInit, OnDestro

$formFields = input<DotAppsSecret[]>([], { alias: 'formFields' });
$appConfigured = input<boolean>(false, { alias: 'appConfigured' });
/** Key of the app being configured; the UVE app gets a rule editor for its JSON param. */
$appKey = input<string>('', { alias: 'appKey' });

/** True on the UVE app, whose JSON param gets the route editor. */
readonly $isUveApp = computed(() => this.$appKey() === UVE_APP_KEY);

/** True for apps that use the centered form layout. */
readonly $shouldCenterForm = computed(() => CENTERED_FORM_APP_KEYS.includes(this.$appKey()));

readonly data = output<{ [key: string]: string }>();
readonly valid = output<boolean>();
Expand Down Expand Up @@ -126,6 +145,9 @@ export class DotAppsConfigurationDetailFormComponent implements OnInit, OnDestro
}

private getFieldValueFn = {
JSON: (field: DotAppsSecret, status: FieldStatus) => {
return this.getFieldValueFn.STRING(field, status);
},
BOOL: (field: DotAppsSecret) => {
return field.value ? JSON.parse(field.value) : field.value;
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
@let parseError = $parseError();

<div class="flex flex-col gap-2">
<div
class="h-96 overflow-hidden rounded-sm border"
[class.border-red-500]="parseError"
[class.border-surface-300]="!parseError"
[id]="$fieldId()"
Comment thread
rjvelazco marked this conversation as resolved.
role="group"
[attr.aria-label]="$label() || null"
data-testid="json-field-editor-wrapper">
<ngx-monaco-editor
class="h-full"
[options]="$editorOptions()"
(init)="onEditorInit($event)"
data-testid="json-field-editor" />
</div>

<div class="flex items-center justify-between gap-2">
@if (parseError) {
<small class="p-field-error" data-testid="json-field-error">
{{ 'apps.json.field.invalid' | dm: [parseError] }}
</small>
} @else {
<small class="p-field-hint" data-testid="json-field-valid">
{{ 'apps.json.field.valid' | dm }}
</small>
}

<button
type="button"
data-testid="json-field-format"
[disabled]="$isDisabled() || !!parseError || !$value().trim()"
[label]="'apps.json.field.format' | dm"
(click)="format()"
pButton
class="p-button-sm p-button-outlined"></button>
</div>
</div>
Loading
Loading