Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Expand Up @@ -336,6 +336,12 @@ describe('DotRouterService', () => {
);
});

it('should resolve the /users URL slug to the users-beta portlet ID', () => {
expect(service.getPortletId('/users')).toBe('users-beta');
expect(service.getPortletId('/c/users')).toBe('users-beta');
expect(service.getPortletId('#/users?test=value')).toBe('users-beta');
});

it('should navigate replacing URL params', () => {
const params = { id: 'content' };
service.replaceQueryParams(params);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -480,5 +480,10 @@ const PORTLET_ID_RESOLVERS: Record<string, (urlSegments: string[]) => string> =
// URL is kebab-case; backend portlet-name stays snake_case for DB compat (#35809).
'velocity-playground': () => {
return 'velocity_playground';
}
},
// The Angular Users portlet is registered under `users-beta` in portlet.xml
// with a `<portlet-url>/users</portlet-url>` override, so the URL is `/users`
// while the menu id stays `users-beta`. Without this mapping the
// MenuGuardService can't match the menu entry and bounces to Getting Started.
users: () => 'users-beta'
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<p-autoComplete
[inputId]="inputId()"
[ngModel]="value()"
(ngModelChange)="onSelect($event)"
[suggestions]="suggestions()"
(completeMethod)="onSearch($event)"
optionLabel="fullName"
[placeholder]="placeholderKey() | dm"
[minLength]="1"
[delay]="300"
[forceSelection]="true"
[showClear]="true"
[invalid]="invalid()"
appendTo="body"
styleClass="w-full"
inputStyleClass="w-full"
data-testid="users-replacement-picker">
<ng-template let-candidate pTemplate="selectedItem">
{{ displayName(candidate) }}
</ng-template>
<ng-template let-candidate pTemplate="item">
<div class="flex flex-col">
<span class="font-medium">{{ displayName(candidate) }}</span>
<span class="text-color-secondary text-xs">{{ candidate.emailAddress }}</span>
</div>
</ng-template>
</p-autoComplete>
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
input,
output,
signal
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';

import { AutoCompleteCompleteEvent, AutoCompleteModule } from 'primeng/autocomplete';

import { take } from 'rxjs/operators';

import { DotMessagePipe } from '@dotcms/ui';

import { DotUserListItem, DotUsersService } from '../../services/dot-users.service';

/**
* Server-backed replacement-user picker used by the delete flows.
* Reused by the single-user delete confirm (inside the profile
* dialog) and the bulk-delete confirm (on the list toolbar).
*
* The excluded-ids input keeps deletion targets out of the
* suggestion list on the client — the backend also rejects invalid
* replacements, but pre-filtering avoids showing picks that would
* fail on submit.
*/
@Component({
selector: 'dot-users-replacement-picker',
standalone: true,
imports: [FormsModule, AutoCompleteModule, DotMessagePipe],
templateUrl: './dot-users-replacement-picker.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
Comment thread
AP2300 marked this conversation as resolved.
host: { class: 'block' }
})
export class DotUsersReplacementPickerComponent {
private readonly usersService = inject(DotUsersService);
private readonly destroyRef = inject(DestroyRef);

/** ID passed to the underlying <input> so an external <label for> hooks up. */
readonly inputId = input<string>('users-replacement-picker');

/** i18n key for the input placeholder. */
readonly placeholderKey = input<string>('users.dialog.delete-confirm.replacement-placeholder');

/** User IDs that must not appear as replacement candidates. */
readonly excludedUserIds = input<string[]>([]);

/** Currently selected user, or null when the picker is empty. */
readonly value = input<DotUserListItem | null>(null);

/**
* When true, the underlying p-autoComplete renders in its error
* state (red outline). Field-level error text is the caller's
* responsibility so this component stays reusable.
*/
readonly invalid = input<boolean>(false);

/** Emits every selection change (user or null when cleared). */
readonly selectionChange = output<DotUserListItem | null>();

protected readonly suggestions = signal<DotUserListItem[]>([]);

protected onSearch(event: AutoCompleteCompleteEvent): void {
this.usersService
.getUsersPaginated({
filter: event.query,
page: 1,
perPage: 10
})
.pipe(take(1), takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
const excluded = new Set(this.excludedUserIds());
this.suggestions.set(
response.entity.filter((user) => !excluded.has(user.userId))
);
},
error: () => this.suggestions.set([])

@hmoreras hmoreras Aug 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swallowing the error makes a 500 look identical to "no matches". There's also no loading or empty state for the suggestions, and a new subscription per keystroke with no cancellation — the 300ms delay narrows the race but a slow early response can still overwrite a newer one. A Subject + switchMap handles all three.

});
}

protected onSelect(value: DotUserListItem | null): void {
this.selectionChange.emit(value);
}

/**
* Builds the display string shown inside the input and in each
* suggestion row. `fullName` is populated for most accounts but can
* be blank for legacy or partially-imported users, so we fall back
* to `name`, then to the concatenated first/last, then to the email
* to guarantee the row is never rendered as `[object Object]`.
*/
protected displayName(user: DotUserListItem): string {
const fullName = (user.fullName ?? '').trim();
if (fullName) {
return fullName;
}

const name = (user.name ?? '').trim();
if (name) {
return name;
}

const first = (user.firstName ?? '').trim();
const last = (user.lastName ?? '').trim();
const combined = `${first} ${last}`.trim();
if (combined) {
return combined;
}

return user.emailAddress ?? '';
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,183 @@
<div class="flex flex-col gap-6 p-2" data-testid="users-create-stub">
<p class="text-color-secondary m-0">
{{ 'users.dialog.stub.message' | dm }}
</p>
<div class="flex justify-end">
<div
class="flex min-h-0 flex-1 flex-col border-t border-surface-200"
data-testid="users-dialog-body">
<header
class="flex items-center gap-5 border-b border-surface-200 px-7 py-6"
data-testid="users-dialog-header">
<p-avatar
[label]="initials()"
shape="circle"
size="xlarge"
styleClass="bg-primary-100 text-primary"
data-testid="users-dialog-avatar" />
<div class="flex min-w-0 flex-col gap-2">
<h2 class="m-0 truncate text-xl font-semibold" data-testid="users-dialog-title">
{{ displayName() }}
</h2>
@if (isEdit) {
<div class="flex flex-wrap items-center gap-2">
<p-tag
[value]="
(isActive()
? 'users.dialog.status.active'
: 'users.dialog.status.inactive'
) | dm
"
[severity]="isActive() ? 'success' : 'secondary'"
[rounded]="true"
data-testid="users-dialog-header-status" />
@if (canLoginToAdmin()) {
<p-tag
[value]="'users.dialog.header.can-login' | dm"
severity="info"
[rounded]="true"
data-testid="users-dialog-header-can-login" />
}
</div>
}
</div>
</header>

<p-tabs [(value)]="activeTab" class="flex min-h-0 flex-1 flex-col">
<div class="border-b border-surface-200 px-7 pt-6">
<p-tablist>
<p-tab [value]="0" data-testid="users-dialog-tab-profile">
{{ 'users.dialog.tabs.profile' | dm }}
</p-tab>
<p-tab [value]="1" data-testid="users-dialog-tab-roles">
{{ 'users.dialog.tabs.roles' | dm }}
</p-tab>
<p-tab [value]="2" data-testid="users-dialog-tab-permissions">
{{ 'users.dialog.tabs.permissions' | dm }}
</p-tab>
<p-tab [value]="3" data-testid="users-dialog-tab-api-tokens">
{{ 'users.dialog.tabs.api-tokens' | dm }}
</p-tab>
</p-tablist>
</div>

<p-tabpanels class="flex min-h-0 flex-1 flex-col">
<p-tabpanel [value]="0" class="block min-h-0 flex-1 overflow-auto">
<div class="px-7 pt-[22px] pb-7">
Comment thread
AP2300 marked this conversation as resolved.
<dot-users-profile-tab
[form]="form"
[isEdit]="isEdit"
[user]="user"
(deleteRequested)="openDeleteConfirm()"
data-testid="users-dialog-profile-tab" />
</div>
</p-tabpanel>
<p-tabpanel [value]="1" class="block min-h-0 flex-1 overflow-auto">
<div
class="text-color-secondary px-7 pt-[22px] pb-7 text-sm"
data-testid="users-dialog-roles-tab-placeholder">
{{ 'users.dialog.tabs.coming-soon' | dm }}
</div>
</p-tabpanel>
<p-tabpanel [value]="2" class="block min-h-0 flex-1 overflow-auto">
<div
class="text-color-secondary px-7 pt-[22px] pb-7 text-sm"
data-testid="users-dialog-permissions-tab-placeholder">
{{ 'users.dialog.tabs.coming-soon' | dm }}
</div>
</p-tabpanel>
<p-tabpanel [value]="3" class="block min-h-0 flex-1 overflow-auto">
<div
class="text-color-secondary px-7 pt-[22px] pb-7 text-sm"
data-testid="users-dialog-api-tokens-tab-placeholder">
{{ 'users.dialog.tabs.coming-soon' | dm }}
</div>
</p-tabpanel>
</p-tabpanels>
</p-tabs>
</div>

<footer
class="flex items-center justify-end gap-2 border-t border-surface-200 px-6 py-4"
data-testid="users-dialog-footer">
<p-button
[label]="'users.cancel' | dm"
severity="secondary"
[text]="true"
(onClick)="close()"
data-testid="users-dialog-cancel-btn" />
<p-button
[label]="(isEdit ? 'users.dialog.save' : 'users.dialog.create') | dm"
[disabled]="isSaveDisabled()"
(onClick)="save()"
data-testid="users-dialog-save-btn" />
</footer>

<p-dialog
[visible]="deleteConfirmVisible()"
(visibleChange)="deleteConfirmVisible.set($event)"
[modal]="true"
[closable]="true"
[closeOnEscape]="true"
[draggable]="false"
[style]="{ width: '500px' }"
[header]="'users.dialog.delete-confirm.header' | dm"
data-testid="users-delete-confirm-dialog">
<div class="flex flex-col gap-4">
<p class="m-0 text-color">
{{ 'users.dialog.delete-confirm.message' | dm }}
</p>
<div class="field flex flex-col gap-2">
<label for="users-delete-replacement-picker">
{{ 'users.dialog.delete-confirm.replacement-label' | dm }}
<span class="text-red-500" aria-hidden="true">*</span>
Comment thread
AP2300 marked this conversation as resolved.
</label>
<dot-users-replacement-picker
inputId="users-delete-replacement-picker"
[value]="replacementUser()"
[excludedUserIds]="excludedReplacementIds"
[invalid]="!!replacementError()"
(selectionChange)="onReplacementSelect($event)"
data-testid="users-delete-replacement-picker" />
@if (replacementError(); as key) {
<small class="text-red-600" data-testid="users-delete-replacement-error">
{{ key | dm }}
</small>
} @else {
<small class="text-color-secondary">
{{ 'users.dialog.delete-confirm.replacement-help' | dm }}
</small>
}
</div>
<div class="field flex flex-col gap-2">
<label for="users-delete-confirm-input">
{{ 'users.dialog.delete-confirm.confirm-label' | dm }}
@if (user?.emailAddress) {
<span class="font-mono text-sm">&mdash; {{ user?.emailAddress }}</span>
}
</label>
<input
pInputText
id="users-delete-confirm-input"
[ngModel]="deleteConfirmationInput()"
(ngModelChange)="onDeleteInputChange($event)"
[placeholder]="'users.dialog.delete-confirm.confirm-placeholder' | dm"
[class.ng-invalid]="!!emailConfirmError()"
[class.ng-dirty]="!!emailConfirmError()"
data-testid="users-delete-confirm-input" />
@if (emailConfirmError(); as key) {
<small class="text-red-600" data-testid="users-delete-confirm-error">
{{ key | dm }}
</small>
}
</div>
</div>
<ng-template pTemplate="footer">
<p-button
[label]="'users.close' | dm"
[label]="'users.cancel' | dm"
severity="secondary"
[outlined]="true"
(onClick)="close()"
data-testid="users-create-stub-close-btn" />
</div>
</div>
[text]="true"
(onClick)="closeDeleteConfirm()"
data-testid="users-delete-confirm-cancel-btn" />
<p-button
[label]="'users.dialog.delete.button' | dm"
severity="danger"
(onClick)="confirmDelete()"
data-testid="users-delete-confirm-btn" />
</ng-template>
</p-dialog>
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
:host {
display: flex;
Comment thread
AP2300 marked this conversation as resolved.
flex-direction: column;
height: 100%;
min-height: 0;
}

// Force the flex-fill chain from the body wrapper down to each p-tabpanel.
// Tailwind's `flex`/`block` utilities lose against PrimeNG's default block
// display on `.p-tabpanel` (higher specificity via the injected preset), so
// we assert the layout explicitly here.
:host ::ng-deep {
p-tabs,
p-tabpanels {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}

// Only the active tab receives layout — the others carry the `hidden`
// attribute which resolves to `display: none` before this ever applies.
p-tabpanel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
padding: 0;
}
}
Loading
Loading