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
@@ -0,0 +1,12 @@
<mat-form-field class="assignee-field" subscriptSizing="dynamic">
<mat-select [value]="value" (selectionChange)="onSelectionChange($event.value)">
<mat-option value="">
<span class="unassigned-option">Unassigned</span>
</mat-option>
@for (userId of getOptions(); track userId) {
<mat-option [value]="userId">
<app-owner [ownerRef]="userId" [includeAvatar]="true"></app-owner>
</mat-option>
}
</mat-select>
</mat-form-field>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
:host {
--mat-form-field-container-vertical-padding: 8px;
--mat-form-field-container-height: 40px;
}

.assignee-field {
width: 200px;
font-size: 14px;

.unassigned-option {
font-style: italic;
color: var(--color-text-secondary);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { Component } from '@angular/core';
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { mock, when } from 'ts-mockito';
import { OnlineStatusService } from 'xforge-common/online-status.service';
import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers';
import { TestOnlineStatusService } from 'xforge-common/test-online-status.service';
import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils';
import { UserService } from 'xforge-common/user.service';
import { OnboardingRequestAssigneeSelectComponent } from './onboarding-request-assignee-select.component';

const mockedUserService = mock(UserService);

const CURRENT_USER_ID = 'user01';
const ASSIGNEE_USER_ID = 'user02';
const OTHER_USER_ID = 'user03';

/** Host component used to drive the OnboardingRequestAssigneeSelectComponent under test. */
@Component({
template: `<app-onboarding-request-assignee-select
[value]="value"
[knownAssigneeIds]="knownAssigneeIds"
[currentUserId]="currentUserId"
(selectionChange)="lastEmitted = $event"
></app-onboarding-request-assignee-select>`,
imports: [OnboardingRequestAssigneeSelectComponent]
})
class TestHostComponent {
value: string = '';
knownAssigneeIds: string[] = [];
currentUserId?: string;
lastEmitted?: string;
}

describe('OnboardingRequestAssigneeSelectComponent', () => {
configureTestingModule(() => ({
imports: [TestHostComponent, getTestTranslocoModule()],
providers: [
provideTestOnlineStatus(),
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
{ provide: OnlineStatusService, useClass: TestOnlineStatusService },
{ provide: UserService, useMock: mockedUserService }
]
}));

describe('getOptions()', () => {
it('should include the current user in options', fakeAsync(() => {
const env = new TestEnvironment({ currentUserId: CURRENT_USER_ID });
env.wait();

const options = env.component.getOptions();

expect(options).toContain(CURRENT_USER_ID);
}));

it('should include a known assignee who is not the current user', fakeAsync(() => {
const env = new TestEnvironment({ currentUserId: CURRENT_USER_ID, knownAssigneeIds: [ASSIGNEE_USER_ID] });
env.wait();

const options = env.component.getOptions();

expect(options).toContain(CURRENT_USER_ID);
expect(options).toContain(ASSIGNEE_USER_ID);
}));

it('should list the current user first', fakeAsync(() => {
const env = new TestEnvironment({ currentUserId: CURRENT_USER_ID, knownAssigneeIds: [ASSIGNEE_USER_ID] });
env.wait();

const options = env.component.getOptions();

expect(options[0]).toBe(CURRENT_USER_ID);
}));

it('should not duplicate the current user if they are also in knownAssigneeIds', fakeAsync(() => {
const env = new TestEnvironment({
currentUserId: CURRENT_USER_ID,
knownAssigneeIds: [CURRENT_USER_ID, ASSIGNEE_USER_ID]
});
env.wait();

const options = env.component.getOptions();

expect(options.filter(id => id === CURRENT_USER_ID).length).toBe(1);
}));

it('should return only the current user when there are no known assignees', fakeAsync(() => {
const env = new TestEnvironment({ currentUserId: CURRENT_USER_ID, knownAssigneeIds: [] });
env.wait();

const options = env.component.getOptions();

expect(options).toEqual([CURRENT_USER_ID]);
}));

it('should return multiple known assignees after the current user', fakeAsync(() => {
const env = new TestEnvironment({
currentUserId: CURRENT_USER_ID,
knownAssigneeIds: [ASSIGNEE_USER_ID, OTHER_USER_ID]
});
env.wait();

const options = env.component.getOptions();

expect(options).toEqual([CURRENT_USER_ID, ASSIGNEE_USER_ID, OTHER_USER_ID]);
}));
});

/**
* Test environment for OnboardingRequestAssigneeSelectComponent tests.
* Uses a TestHostComponent to drive inputs and capture output.
*/
class TestEnvironment {
readonly host: TestHostComponent;
readonly fixture: ComponentFixture<TestHostComponent>;
readonly component: OnboardingRequestAssigneeSelectComponent;

constructor({
value = '',
knownAssigneeIds = [],
currentUserId
}: {
value?: string;
knownAssigneeIds?: string[];
currentUserId?: string;
} = {}) {
when(mockedUserService.currentUserId).thenReturn(CURRENT_USER_ID);

this.fixture = TestBed.createComponent(TestHostComponent);
this.host = this.fixture.componentInstance;
this.host.value = value;
this.host.knownAssigneeIds = knownAssigneeIds;
this.host.currentUserId = currentUserId;
this.component = this.fixture.debugElement.children[0]
.componentInstance as OnboardingRequestAssigneeSelectComponent;
this.fixture.detectChanges();
}

wait(): void {
tick();
this.fixture.detectChanges();
}
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { OwnerComponent } from 'xforge-common/owner/owner.component';

/**
* A dropdown component for selecting an assignee on an onboarding request.
* Always shows the current user first. Used in the onboarding requests list and detail views.
*/
@Component({
selector: 'app-onboarding-request-assignee-select',
templateUrl: './onboarding-request-assignee-select.component.html',
styleUrls: ['./onboarding-request-assignee-select.component.scss'],
imports: [FormsModule, MatFormFieldModule, MatSelectModule, OwnerComponent]
})
export class OnboardingRequestAssigneeSelectComponent {
/** The ID of the currently selected assignee. An empty string means unassigned. */
@Input() value: string = '';
/** IDs of users who are already known assignees and should appear in the dropdown. */
@Input() knownAssigneeIds: string[] = [];
/** The current user's ID. Always shown first in the dropdown. */
@Input() currentUserId?: string;
/** Emitted when the user selects a new assignee. */
@Output() selectionChange = new EventEmitter<string>();

protected onSelectionChange(newValue: string): void {
this.selectionChange.emit(newValue);
}

/**
* Returns the ordered list of user IDs to show in the dropdown.
* The current user is shown first, followed by any other known assignees.
*/
getOptions(): string[] {
const options: string[] = [];
if (this.currentUserId != null) {
options.push(this.currentUserId);
}
for (const id of this.knownAssigneeIds) {
if (!options.includes(id)) options.push(id);
}
return options;
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,28 +86,36 @@ <h1>{{ pageTitle }}</h1>
<mat-card-header> <mat-card-title>Request Information</mat-card-title> </mat-card-header>
<mat-card-content>
<div class="info-row skip-in-data-export">
<span class="label">Assignee:</span>
<span class="value">
@if (request.assigneeId) {
<app-owner [ownerRef]="request.assigneeId" [includeAvatar]="true"></app-owner>
} @else {
<em>Unassigned</em>
}
</span>
<span class="label">Status:</span>
<span class="value">{{ getStatus(request.status).label }}</span>
</div>
<div class="info-row skip-in-data-export">
<span class="label">Status:</span>
<span class="value" [style.color]="getStatus(request.status).color">
<mat-icon>{{ getStatus(request.status).icon }}</mat-icon>
{{ getStatus(request.status).label }}
<span class="label">Assignee:</span>
<span class="value">
<app-onboarding-request-assignee-select
[value]="request.assigneeId"
[knownAssigneeIds]="existingAssigneeIds"
[currentUserId]="userService.currentUserId"
(selectionChange)="onAssigneeChange($event)"
></app-onboarding-request-assignee-select>
</span>
</div>
<div class="info-row skip-in-data-export">
<span class="label">Resolution:</span>
<span class="value" [style.color]="getResolution(request.resolution).color">
<mat-icon>{{ getResolution(request.resolution).icon }}</mat-icon>
{{ getResolution(request.resolution).label }}</span
>
<span class="value">
<mat-form-field class="resolution-field" subscriptSizing="dynamic">
<mat-select
[ngModel]="request.resolution"
(selectionChange)="onResolutionChange($event.value)"
[compareWith]="onboardingRequestService.compareResolutions"
canSelectNullableOptions
>
@for (option of resolutionOptions; track option) {
<mat-option [value]="option.key">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</span>
</div>
</mat-card-content>
</mat-card>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,8 @@
.book-list {
word-wrap: anywhere;
}

.resolution-field {
--mat-form-field-container-vertical-padding: 8px;
--mat-form-field-container-height: 40px;
}
Loading
Loading