Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/modules/program.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ DI-биндинги (`infrastructure/di/program/`):
## Детализация экспертного оценивания в аналитике

На `/office/program/:programId/analytics` организатор открывает назначения из трёх
метрик: «Назначений всего», «Выполнено», «Ожидает». Нулевые метрики остаются видимыми,
метрик: «Назначений», «Выполнено», «Ожидает». Нулевые метрики остаются видимыми,
но неактивными. Доступ окончательно проверяет backend. Новых маршрутов нет.

### API и архитектура
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/** @format */

import { User } from "@domain/auth/user.model";
import { ProjectRatingCriterion } from "./project-rating-criterion"; // Assuming this is where ProjectRatingCriterion is declared

/** Интерфейс проекта для оценки */
Expand All @@ -15,8 +14,7 @@ export interface ProjectRate {
viewsCount: number;
industry: number;
scored: boolean;
scoredExpertId: number | null;
ratedExperts: User[];
ratedExperts: number[];
ratedCount: number;
maxRates: number;
criterias: ProjectRatingCriterion[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,34 @@ describe("ProjectRatingRepository", () => {

expect(out).toEqual([{ criterionId: 3, value: "hello" }]);
});

it("formValuesToDTO всегда отправляет полный backend snapshot критериев", () => {
setup();
const criteria = [
{ id: 1, type: "int", value: 2 },
{ id: 2, type: "bool", value: "true" },
{ id: 3, type: "str", value: "Existing" },
] as ProjectRatingCriterion[];

expect(repository.formValuesToDTO(criteria, {})).toEqual([
{ criterionId: 1, value: 2 },
{ criterionId: 2, value: "true" },
{ criterionId: 3, value: "Existing" },
]);
});

it("formValuesToDTO сохраняет полный набор при изменении одного критерия", () => {
setup();
const criteria = [
{ id: 1, type: "int", value: 2 },
{ id: 2, type: "bool", value: "false" },
{ id: 3, type: "str", value: "Existing" },
] as ProjectRatingCriterion[];

expect(repository.formValuesToDTO(criteria, { 1: "5" })).toEqual([
{ criterionId: 1, value: 5 },
{ criterionId: 2, value: "false" },
{ criterionId: 3, value: "Existing" },
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -40,24 +40,24 @@ export class ProjectRatingRepository implements ProjectRatingRepositoryPort {
criteria: ProjectRatingCriterion[],
outputVals: Record<string, string | number | boolean>,
): ProjectRatingCriterionOutput[] {
const output: ProjectRatingCriterionOutput[] = [];
const normalizedOutputVals = { ...outputVals };
return criteria.map(criterion => {
const key = String(criterion.id);
let value = Object.prototype.hasOwnProperty.call(outputVals, key)
? outputVals[key]
: criterion.value;

for (const key in normalizedOutputVals) {
// оценки с boolean значением переводятся в "string-boolean" (true => "True")
if (typeof normalizedOutputVals[key] === "boolean") {
const boolString = String(normalizedOutputVals[key]);
normalizedOutputVals[key] = boolString.charAt(0).toUpperCase() + boolString.slice(1);
if (typeof value === "boolean") {
const boolString = String(value);
value = boolString.charAt(0).toUpperCase() + boolString.slice(1);
}

// оценки с числовым значением из инпута приходят строкой, их нужно привести к number
if (criteria.find(c => c.id === Number(key))?.type === "int") {
normalizedOutputVals[key] = Number(normalizedOutputVals[key]);
if (criterion.type === "int") {
value = Number(value);
}

output.push({ criterionId: Number(key), value: normalizedOutputVals[key] });
}

return output;
return { criterionId: criterion.id, value };
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,36 +8,29 @@ <h1 class="text-heading-1-bold">Новый пароль</h1>
<fieldset class="auth__field">
<app-input
size="big"
class="set-password__password"
class="set-password__password auth__password-input"
[error]="password | controlError"
id="password"
[type]="showPassword() ? 'text' : 'password'"
formControlName="password"
name="new-password"
autocomplete="new-password"
placeholder="пароль"
>
@if (showPassword()) {
<button
type="button"
class="auth__password-toggle auth__password-toggle--muted"
[attr.aria-label]="showPassword() ? 'Скрыть пароль' : 'Показать пароль'"
[attr.aria-pressed]="showPassword()"
(click)="$event.stopPropagation(); toggleShowPassword('first')"
>
<i
appIcon
icon="eye"
appSquare="24"
appWidth="24"
appHeight="24"
appViewBox="0 0 24 24"
class="icon"
(click)="toggleShowPassword()"
[icon]="showPassword() ? 'eye' : 'eye-off'"
appSquare="15"
aria-hidden="true"
></i>
} @else {
<i
appIcon
icon="eye-off"
appSquare="24"
appWidth="24"
appHeight="24"
appViewBox="0 0 24 24"
class="icon"
(click)="toggleShowPassword()"
></i>
}
</button>
</app-input>
@if (credsSubmitInitiated()) {
<ng-container>
Expand Down Expand Up @@ -98,13 +91,32 @@ <h1 class="text-heading-1-bold">Новый пароль</h1>
<fieldset class="auth__field">
<app-input
size="big"
class="set-password__password"
class="set-password__password auth__password-input"
[error]="passwordRepeated | controlError"
id="password_repeated"
type="password"
[type]="showPasswordRepeat() ? 'text' : 'password'"
formControlName="passwordRepeated"
name="new-password-confirmation"
autocomplete="new-password"
placeholder="повторите пароль"
></app-input>
>
<button
type="button"
class="auth__password-toggle auth__password-toggle--muted"
[attr.aria-label]="
showPasswordRepeat() ? 'Скрыть повторный пароль' : 'Показать повторный пароль'
"
[attr.aria-pressed]="showPasswordRepeat()"
(click)="$event.stopPropagation(); toggleShowPassword('repeat')"
>
<i
appIcon
[icon]="showPasswordRepeat() ? 'eye' : 'eye-off'"
appSquare="15"
aria-hidden="true"
></i>
</button>
</app-input>
@if (credsSubmitInitiated()) {
<ng-container>
@if (passwordRepeated | controlError: "required") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,35 @@ describe("SetPasswordComponent", () => {
it("should create", () => {
expect(component).toBeTruthy();
});

it("uses independent accessible new-password controls for both fields", () => {
const inputs = fixture.nativeElement.querySelectorAll(
'input[autocomplete="new-password"]',
) as NodeListOf<HTMLInputElement>;
const toggles = fixture.nativeElement.querySelectorAll(
".auth__password-toggle",
) as NodeListOf<HTMLButtonElement>;

expect(inputs).toHaveLength(2);
expect(inputs[0].name).toBe("new-password");
expect(inputs[1].name).toBe("new-password-confirmation");
expect(inputs[0].type).toBe("password");
expect(inputs[1].type).toBe("password");
expect(toggles).toHaveLength(2);
expect(toggles[0].type).toBe("button");
expect(toggles[0].getAttribute("aria-label")).toBe("Показать пароль");
expect(toggles[1].getAttribute("aria-label")).toBe("Показать повторный пароль");

toggles[0].click();
fixture.detectChanges();
expect(inputs[0].type).toBe("text");
expect(inputs[1].type).toBe("password");
expect(toggles[0].getAttribute("aria-pressed")).toBe("true");

toggles[1].click();
fixture.detectChanges();
expect(inputs[0].type).toBe("text");
expect(inputs[1].type).toBe("text");
expect(toggles[1].getAttribute("aria-pressed")).toBe("true");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/cor
import { ReactiveFormsModule } from "@angular/forms";
import { ControlErrorPipe } from "@corelib";
import { ErrorMessage } from "@core/lib/models/error/error-message";
import { ButtonComponent, InputComponent } from "@ui/primitives";
import { ButtonComponent, IconComponent, InputComponent } from "@ui/primitives";
import { AuthPasswordService } from "@api/auth/facades/auth-password.service";
import { AuthUIInfoService } from "@api/auth/facades/ui/auth-ui-info.service";

Expand All @@ -14,7 +14,7 @@ import { AuthUIInfoService } from "@api/auth/facades/ui/auth-ui-info.service";
templateUrl: "./set-password.component.html",
styleUrl: "./set-password.component.scss",
providers: [AuthPasswordService, AuthUIInfoService],
imports: [ReactiveFormsModule, InputComponent, ButtonComponent, ControlErrorPipe],
imports: [ReactiveFormsModule, InputComponent, ButtonComponent, IconComponent, ControlErrorPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SetPasswordComponent implements OnInit {
Expand All @@ -30,13 +30,14 @@ export class SetPasswordComponent implements OnInit {
protected readonly errorMessage = ErrorMessage;

protected readonly showPassword = this.authUIInfoService.showPassword;
protected readonly showPasswordRepeat = this.authUIInfoService.showPasswordRepeat;

ngOnInit(): void {
this.authPasswordService.init();
}

toggleShowPassword() {
this.authUIInfoService.toggleShowPassword("login");
toggleShowPassword(type: "first" | "repeat") {
this.authUIInfoService.toggleShowPassword("register", type);
}

onSubmit() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -741,7 +741,7 @@ describe("ProgramAnalyticsComponent", () => {
"Хотя бы один назначенный эксперт полностью оценил проект, но не все назначенные эксперты завершили оценивание.",
);
expect(evaluation?.querySelector('[data-testid="assignment-statuses"]')?.textContent).toContain(
"Назначений всего",
"Назначений",
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ export class ProgramAnalyticsComponent implements OnInit {
const assignments = this.data()?.evaluationStatus.assignments;
if (!assignments) return [];
return [
{ ...this.metric("assignments-total", "Назначений всего", assignments.total), scope: "all" },
{ ...this.metric("assignments-total", "Назначений", assignments.total), scope: "all" },
{
...this.metric("assignments-evaluated", "Выполнено", assignments.evaluated),
scope: "completed",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => {
modal = fixture.debugElement.query(By.directive(ModalComponent)).componentInstance;
state = fixture.debugElement.injector.get(ProgramAnalyticsDrilldownService);
trigger = document.createElement("button");
trigger.textContent = "Назначений всего";
trigger.textContent = "Назначений";
document.body.append(trigger);
trigger.focus();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/** @format */

import { Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormControl, ReactiveFormsModule } from "@angular/forms";
import { ProjectRatingCriterion } from "@domain/project/project-rating-criterion";
import { ProjectRatingComponent } from "./project-rating.component";

type RatingValue = Record<string, string | number | boolean>;

@Component({
imports: [ReactiveFormsModule, ProjectRatingComponent],
template: `<app-project-rating [criteria]="criteria" [formControl]="rating" />`,
})
class ProjectRatingHostComponent {
readonly criteria: ProjectRatingCriterion[] = [
{
id: 1,
name: "Score",
description: "",
type: "int",
minValue: 0,
maxValue: 5,
value: 4,
expertId: 7,
},
{
id: 2,
name: "Approved",
description: "",
type: "bool",
minValue: null,
maxValue: null,
value: "true",
expertId: 7,
},
{
id: 3,
name: "Comment",
description: "",
type: "str",
minValue: null,
maxValue: null,
value: "Existing",
expertId: 7,
},
];
readonly rating = new FormControl<RatingValue | null>(null);
}

describe("ProjectRatingComponent", () => {
let fixture: ComponentFixture<ProjectRatingHostComponent>;
let host: ProjectRatingHostComponent;
let rating: ProjectRatingComponent;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProjectRatingHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(ProjectRatingHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
rating = fixture.debugElement.children[0].componentInstance as ProjectRatingComponent;
});

it("synchronizes every backend criterion into the parent control without user input", () => {
expect(rating.form.getRawValue()).toEqual({ 1: 4, 2: true, 3: "Existing" });
expect(host.rating.getRawValue()).toEqual({ 1: 4, 2: true, 3: "Existing" });
});

it("keeps the full parent snapshot when one criterion changes", () => {
rating.form.get("1")!.setValue(5);

expect(host.rating.getRawValue()).toEqual({ 1: 5, 2: true, 3: "Existing" });
});

it("applies a parent value written after rebuild without dropping other criteria", () => {
host.rating.setValue({ 1: 3, 2: false, 3: "Updated" });
fixture.detectChanges();

expect(rating.form.getRawValue()).toEqual({ 1: 3, 2: false, 3: "Updated" });
expect(host.rating.getRawValue()).toEqual({ 1: 3, 2: false, 3: "Updated" });
});
});
Loading