-
Notifications
You must be signed in to change notification settings - Fork 481
feat(users): Profile tab, dialog shell & list CRUD wiring (#36717) #36990
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c3237e0
76384d6
36e0042
981b1cf
a91ba1f
f9291d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
| 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([]) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| }); | ||
| } | ||
|
|
||
| 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"> | ||
|
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> | ||
|
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">— {{ 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; | ||
|
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; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.