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
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/** @format */
import { Injectable } from "@angular/core";

/** In-memory position only; assessment values are never persisted here. */
@Injectable({ providedIn: "root" })
export class EvaluationNavigationService {
private readonly positions = new Map<string, { top: number; windowTop: number; limit: number }>();
remember(url: string, limit = 10): void {
this.positions.set(url, {
top: document.querySelector(".office__body")?.scrollTop ?? 0,
windowTop: window.scrollY,
limit,
});
}
limit(url: string): number {
return this.positions.get(url)?.limit ?? 10;
}
restore(url: string): void {
const position = this.positions.get(url);
if (!position) return;
requestAnimationFrame(() =>
requestAnimationFrame(() => {
document.querySelector(".office__body")?.scrollTo({ top: position.top });
window.scrollTo({ top: position.windowTop });
}),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ export interface ProjectRate {
viewsCount: number;
industry: number;
scored: boolean;
scoredExpertId: number | null;
ratedExperts: User[];
scoredExpertId?: number | null;
ratedExperts: (number | User)[];
ratedCount: number;
maxRates: number;
criterias: ProjectRatingCriterion[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,9 @@ <h3>{{ project.name }}</h3>
@if (project.canRate || project.isCompleted) {
<a
appButton="secondary"
[routerLink]="paths.program.projectsRating(project.programId)"
[queryParams]="{ project_id: project.projectId, returnUrl: router.url }"
[routerLink]="[paths.program.projectsRating(project.programId), project.projectId]"
[queryParams]="{ returnUrl: router.url }"
(click)="navigation.remember(router.url)"
>{{ project.isCompleted ? "Посмотреть" : "Оценить" }}</a
>
} @else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ describe("Кабинет эксперта: только реальные наз
offset: 0,
});
const link = fixture.nativeElement.querySelector(".cabinet__project a") as HTMLAnchorElement;
expect(link.getAttribute("href")).toContain("/office/program/7/projects-rating?project_id=42");
expect(link.getAttribute("href")).toContain("/office/program/7/projects-rating/42?returnUrl=");
expect(fixture.nativeElement.textContent).toContain("Осталось 1 из 2");
expect(fixture.nativeElement.textContent).not.toContain("Продолжить");
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { ProfileInfoService } from "@api/profile/facades/profile-info.service";
import { AuthInfoService } from "@api/auth/facades/auth-info.service";
import { evaluationError } from "@utils/evaluation-workspace";
import { presentationHref } from "@utils/program-case-analytics";
import { EvaluationNavigationService } from "@api/program/evaluation-navigation.service";

type CabinetTab = "pending" | "completed" | "invitations";
const positive = (value: string | null) => {
Expand All @@ -55,6 +56,7 @@ const positive = (value: string | null) => {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EvaluationProgramsComponent {
readonly navigation = inject(EvaluationNavigationService);
private readonly api = inject(EvaluationUseCase);
private readonly profile = inject(ProfileInfoService);
private readonly auth = inject(AuthInfoService);
Expand Down Expand Up @@ -167,7 +169,10 @@ export class EvaluationProgramsComponent {
if (!data.projects || !Array.isArray(data.projects.results)) {
this.snapshot.set(null);
this.failed.set("Кабинет временно недоступен. Попробуйте обновить страницу.");
} else this.snapshot.set({ accountId, data });
} else {
this.snapshot.set({ accountId, data });
this.navigation.restore(this.router.url);
}
this.loading.set(false);
},
error: e => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@
routerLink="/office/evaluation"
routerLinkActive="nav__link--active"
><li class="text-body-14 nav__item">
<i appIcon icon="check" appSquare="24" class="nav-bar__icon"></i>кабинет эксперта
<i appIcon icon="check" appSquare="24" class="nav-bar__icon"></i>оценивание
</li></a
>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export class NavComponent implements OnInit, OnDestroy {
projects: "Проекты",
members: "Участники",
program: "Программы",
evaluation: "Кабинет эксперта",
evaluation: "Оценивание",
courses: "Курсы",
vacancies: "Вакансии",
profile: "Профиль",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export class OfficeComponent implements OnInit {
const items = [...this.officeUIInfoService.navItems()];
if (this.profile()?.relations.hasEvaluationAccess) {
const index = items.findIndex(item => item.link === "program");
items.splice(index + 1, 0, { name: "кабинет эксперта", icon: "check", link: "evaluation" });
items.splice(index + 1, 0, { name: "оценивание", icon: "check", link: "evaluation" });
}
return items;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
criterion.type === "str" && criterion.description === "Доп. поле для впечатлений о проекте"
? ""
: criterion.description;
<section class="rating__criterion" [class.rating__criterion--text]="criterion.type === 'str'">
<section
class="rating__criterion"
[attr.data-criterion]="criterion.id"
[class.rating__criterion--text]="criterion.type === 'str'"
>
@if (layout() === "compact" && description) {
<details class="rating__details">
<summary>{{ criterion.name }}</summary>
Expand Down Expand Up @@ -47,7 +51,7 @@ <h4>{{ criterion.name }}</h4>
"
[attr.aria-pressed]="form.get(criterion.id.toString())?.value === true"
[disabled]="disabled"
(click)="form.get(criterion.id.toString())?.setValue(true)"
(click)="chooseBoolean(criterion.id, true)"
>
Да
</button>
Expand All @@ -58,14 +62,11 @@ <h4>{{ criterion.name }}</h4>
"
[attr.aria-pressed]="form.get(criterion.id.toString())?.value === false"
[disabled]="disabled"
(click)="form.get(criterion.id.toString())?.setValue(false)"
(click)="chooseBoolean(criterion.id, false)"
>
Нет
</button>
</div>
@if (layout() === "full") {
<p class="rating__hint">«Нет» — заполненный ответ.</p>
}
} @else {
@if (layout() === "full" && description) {
<p class="rating__description">{{ description }}</p>
Expand All @@ -74,18 +75,26 @@ <h4>{{ criterion.name }}</h4>
appField
[attr.aria-label]="criterion.name"
[formControlName]="criterion.id"
maxlength="50"
maxlength="1000"
[rows]="layout() === 'compact' ? 2 : 6"
placeholder="Впечатления и рекомендации эксперта"
></textarea>
<p class="rating__hint rating__counter">
{{ form.get(criterion.id.toString())?.value?.length || 0 }} / 50 · необязательно
{{ form.get(criterion.id.toString())?.value?.length || 0 }} / 1000 · необязательно
</p>
}
@if (
form.get(criterion.id.toString())?.invalid && form.get(criterion.id.toString())?.touched
) {
<p class="rating__error" role="alert">Проверьте тип ответа и допустимый диапазон.</p>
<p class="rating__error" role="alert">
{{
criterion.type === "bool"
? "Выберите «Да» или «Нет»."
: criterion.type === "str"
? "Не больше 1000 символов."
: "Введите баллы в допустимом диапазоне."
}}
</p>
}
</section>
} @empty {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,21 @@ p {
}
}
}

.rating:not(.rating--compact) .ui-button {
height: 32px;
min-height: 32px;
padding: 0 16px;
}

@media (max-width: 999px) {
.rating:not(.rating--compact) .ui-button {
position: relative;

&::after {
position: absolute;
inset: -6px 0;
content: "";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe("Shared expert form lifecycle", () => {
expect(fixture.componentInstance.form.getRawValue()).toEqual(answers);
});

it("publishes zero and default No after replacing criteria, validates the new scale", async () => {
it("requires a deliberate boolean answer after criteria change and preserves zero", async () => {
await TestBed.configureTestingModule({
imports: [ProjectRatingComponent],
providers: [provideZonelessChangeDetection()],
Expand Down Expand Up @@ -75,9 +75,11 @@ describe("Shared expert form lifecycle", () => {
{ ...points, id: 2, name: "Новый", type: "bool", value: "" },
]);
await fixture.whenStable();
expect(changed).toHaveBeenLastCalledWith({ 1: 0, 2: false });
expect(changed).toHaveBeenLastCalledWith({ 1: 0, 2: null });
expect(component.form.invalid).toBe(true);
component.form.get("1")?.setValue(1);
expect(component.form.invalid).toBe(true);
component.chooseBoolean(2, false);
expect(component.form.valid).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
ChangeDetectionStrategy,
Component,
forwardRef,
ElementRef,
inject,
input,
Input,
OnDestroy,
Expand Down Expand Up @@ -58,8 +60,9 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor,
@Input({ required: true })
set criteria(val: ProjectRatingCriterion[]) {
if (!val) return;
this._criteria.set(val);
this.createFormControls(val);
const ordered = [...val.filter(c => c.type !== "str"), ...val.filter(c => c.type === "str")];
this._criteria.set(ordered);
this.createFormControls(ordered);
this.trackFormValueChange();
queueMicrotask(() => {
this.onChange(this.form.getRawValue());
Expand All @@ -75,7 +78,9 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor,
set disabled(value: boolean) {
this._disabled = value;
if (this.form) {
this.disabled ? this.form.disable() : this.form.enable();
this.disabled
? this.form.disable({ emitEvent: false })
: this.form.enable({ emitEvent: false });
}
}

Expand All @@ -85,6 +90,27 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor,

private _disabled = false;
private controlDisabled = false;
private readonly element = inject<ElementRef<HTMLElement>>(ElementRef);

chooseBoolean(id: number, value: boolean): void {
const control = this.form.get(String(id));
control?.markAsDirty();
control?.markAsTouched();
control?.setValue(value);
}

validateAndFocus(): boolean {
this.form.markAllAsTouched();
if (this.form.valid) return true;
const invalid = this.criteria.find(c => this.form.get(String(c.id))?.invalid);
queueMicrotask(() => {
const section = this.element.nativeElement.querySelector(`[data-criterion="${invalid?.id}"]`);
const target = section?.querySelector<HTMLElement>("input, textarea, button");
target?.focus();
target?.scrollIntoView?.({ block: "center", behavior: "smooth" });
});
return false;
}

readonly currentUserId = input<number>();
readonly layout = input<"compact" | "full">("compact");
Expand Down Expand Up @@ -141,7 +167,10 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor,

setDisabledState(disabled: boolean): void {
this.controlDisabled = disabled;
if (this.form) this.disabled ? this.form.disable() : this.form.enable();
if (this.form)
this.disabled
? this.form.disable({ emitEvent: false })
: this.form.enable({ emitEvent: false });
}

ngOnDestroy(): void {
Expand All @@ -161,7 +190,7 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor,
if (this.pendingValue) this.form.patchValue(this.pendingValue, { emitEvent: false });

if (this.disabled) {
this.form.disable();
this.form.disable({ emitEvent: false });
}
this.validatorChanged();
}
Expand Down
Loading