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
@@ -1,12 +1,16 @@
<!-- @format -->

<div class="field" [class.field--error]="error()">
<div class="field" [class.field--compact]="compact()" [class.field--error]="error()">
<input
appField
type="number"
placeholder="Введите баллы"
[placeholder]="compact() ? '—' : 'Введите баллы'"
[value]="value"
[attr.aria-label]="label()"
[title]="
(min() === null ? 'Без минимума' : 'От ' + min()) +
(max() === null ? ', без максимума' : ' до ' + max())
"
[min]="min()"
[max]="max()"
[step]="step()"
Expand All @@ -18,4 +22,13 @@
class="field__input text-body-10"
[class.field__input--error]="error()"
/>
@if (compact()) {
<span class="field__scale" aria-hidden="true">
@if (max() !== null) {
{{ min() !== null && min() !== 0 ? min() + "–" : "/ " }}{{ max() }}
} @else if (min() !== null) {
≥ {{ min() }}
}
</span>
}
</div>
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @format */
@use "styles/ui-foundation";
@use "styles/responsive";

:host {
display: block;
Expand All @@ -16,3 +17,38 @@
.field__input--error {
border-color: var(--red-dark);
}

.field--compact {
display: flex;
gap: 6px;
align-items: center;

.field__input {
width: 36px;
min-height: 32px;
padding: 4px;
font-size: 12px;
text-align: center;
border-radius: var(--rounded-sm);
appearance: textfield;
}

.field__input::-webkit-inner-spin-button,
.field__input::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}

.field__scale {
font-size: 10px;
color: var(--grey-for-text);
white-space: nowrap;
}

@include responsive.apply-mobile {
.field__input {
width: 44px;
min-height: 44px;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export class RangeCriterionInputComponent implements ControlValueAccessor {
readonly step = input<number | "any">(1);
readonly label = input<string>("Оценка");
readonly error = input<boolean>(false);
readonly compact = input(false);

value!: number | null;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
<!-- @format -->
<div class="rating" [formGroup]="form">
<div class="rating" [class.rating--compact]="layout() === 'compact'" [formGroup]="form">
@for (criterion of criteria; track criterion.id) {
<section class="rating__criterion">
<h4>{{ criterion.name }}</h4>
<section class="rating__criterion" [class.rating__criterion--text]="criterion.type === 'str'">
@if (layout() === "compact" && criterion.description) {
<details class="rating__details">
<summary>{{ criterion.name }}</summary>
<p class="rating__description">{{ criterion.description }}</p>
</details>
} @else {
<h4>{{ criterion.name }}</h4>
}
@if (criterion.type === "int" || criterion.type === "float") {
<app-range-criterion-input
[label]="criterion.name"
[compact]="layout() === 'compact'"
[min]="criterion.minValue"
[max]="criterion.maxValue"
[step]="criterion.type === 'int' ? 1 : 'any'"
Expand All @@ -15,14 +23,16 @@ <h4>{{ criterion.name }}</h4>
!!form.get(criterion.id.toString())?.touched
"
/>
<p class="rating__hint">
{{ criterion.description }}
{{ criterion.type === "int" ? "Целые баллы" : "Дробные баллы" }} ·
{{ criterion.minValue === null ? "без минимума" : "от " + criterion.minValue
}}{{ criterion.maxValue === null ? ", без максимума" : " до " + criterion.maxValue }}
</p>
@if (layout() === "full") {
<p class="rating__hint">
{{ criterion.description }}
{{ criterion.type === "int" ? "Целые баллы" : "Дробные баллы" }} ·
{{ criterion.minValue === null ? "без минимума" : "от " + criterion.minValue
}}{{ criterion.maxValue === null ? ", без максимума" : " до " + criterion.maxValue }}
</p>
}
} @else if (criterion.type === "bool") {
@if (criterion.description) {
@if (layout() === "full" && criterion.description) {
<p class="rating__description">{{ criterion.description }}</p>
}
<div class="rating__boolean" role="group" [attr.aria-label]="criterion.name">
Expand All @@ -49,17 +59,19 @@ <h4>{{ criterion.name }}</h4>
Нет
</button>
</div>
<p class="rating__hint">«Нет» — заполненный ответ.</p>
@if (layout() === "full") {
<p class="rating__hint">«Нет» — заполненный ответ.</p>
}
} @else {
@if (criterion.description) {
@if (layout() === "full" && criterion.description) {
<p class="rating__description">{{ criterion.description }}</p>
}
<textarea
appField
[attr.aria-label]="criterion.name"
[formControlName]="criterion.id"
maxlength="50"
rows="6"
[rows]="layout() === 'compact' ? 2 : 6"
placeholder="Впечатления и рекомендации эксперта"
></textarea>
<p class="rating__hint rating__counter">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @format */
@use "styles/ui-foundation";
@use "styles/responsive";

:host {
display: block;
Expand Down Expand Up @@ -72,3 +73,99 @@ p {
resize: vertical;
border-radius: 15px;
}

.rating--compact {
gap: 8px;

.rating__criterion {
grid-template-columns: 72px minmax(0, 1fr);
gap: 6px 8px;
align-items: center;
}

h4,
summary {
font-size: 10px;
font-weight: 400;
line-height: 1.4;
color: var(--grey-for-text);
overflow-wrap: anywhere;
}

summary {
cursor: pointer;
}

summary:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}

.rating__details p {
margin-top: 6px;
}

.rating__criterion:not(.rating__criterion--text) {
h4,
.rating__details {
grid-row: 1;
grid-column: 2;
}
}

app-range-criterion-input,
.rating__boolean {
grid-row: 1;
grid-column: 1;
align-self: start;
}

.rating__boolean {
gap: 4px;
}

.rating__boolean .ui-button {
width: 36px;
min-width: 36px;
min-height: 32px;
padding: 4px;
font-size: 12px;
}

.rating__criterion--text {
grid-template-columns: minmax(0, 1fr);
}

textarea.ui-field {
min-height: 66px;
padding: 10px 12px;
font-size: 12px;
}

.rating__counter,
.rating__error {
grid-column: 1 / -1;
font-size: 10px;
}

@include responsive.apply-mobile {
.rating__criterion:not(.rating__criterion--text) {
grid-template-columns: 92px minmax(0, 1fr);
}

h4,
summary {
font-size: 12px;
}

.rating__boolean .ui-button {
width: 44px;
min-width: 44px;
min-height: 44px;
}

summary {
padding-block: 6px;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,48 @@ import { ProjectRatingComponent } from "./project-rating.component";
import { ProjectRatingCriterion } from "@domain/project/project-rating-criterion";

describe("Shared expert form lifecycle", () => {
it("keeps answers and descriptions when switching between compact cards and full preview", async () => {
await TestBed.configureTestingModule({
imports: [ProjectRatingComponent],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const fixture = TestBed.createComponent(ProjectRatingComponent);
const criterion: ProjectRatingCriterion = {
id: 1,
name: "Обоснованность решения",
description: "Проверьте расчёты команды.",
type: "int",
minValue: 0,
maxValue: 10,
value: 0,
expertId: 0,
};
fixture.componentRef.setInput("criteria", [
criterion,
{ ...criterion, id: 2, name: "Соответствует кейсу", type: "bool", value: "false" },
{ ...criterion, id: 3, name: "Комментарий", type: "str", value: "Хорошее решение" },
]);
await fixture.whenStable();
const root: HTMLElement = fixture.nativeElement;
expect(root.querySelector(".rating--compact")).not.toBeNull();
expect(root.querySelector("summary")?.textContent).toContain(criterion.name);
expect(root.querySelector("details")?.textContent).toContain(criterion.description);
expect(root.querySelector("details")?.open).toBe(false);
expect(root.querySelector(".field__scale")?.textContent).toContain("/ 10");
expect(root.querySelector("textarea")?.rows).toBe(2);
const answers = fixture.componentInstance.form.getRawValue();
expect(answers).toEqual({ 1: 0, 2: false, 3: "Хорошее решение" });

fixture.componentRef.setInput("layout", "full");
await fixture.whenStable();
expect(root.querySelector(".rating--compact")).toBeNull();
expect(root.querySelector("details")).toBeNull();
expect(root.textContent).toContain(criterion.description);
expect(root.textContent).toContain("Целые баллы");
expect(root.querySelector("textarea")?.rows).toBe(6);
expect(fixture.componentInstance.form.getRawValue()).toEqual(answers);
});

it("publishes zero and default No after replacing criteria, validates the new scale", async () => {
await TestBed.configureTestingModule({
imports: [ProjectRatingComponent],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor,
private controlDisabled = false;

readonly currentUserId = input<number>();
readonly layout = input<"compact" | "full">("compact");

/** Сигнал для хранения критериев оценки */
_criteria = signal<ProjectRatingCriterion[]>([]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
display: grid;
grid-template-columns: 1fr;
gap: 20px;
align-items: start;

@include responsive.apply-desktop {
grid-template-columns: 5fr 3fr;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,11 @@ <h2 id="criteria-preview-title">Предпросмотр формы экспер
<button type="button" appButton="secondary" (click)="previewOpen.set(false)">Закрыть</button>
</header>
<p>Так эксперт увидит критерии. Ответы в предпросмотре не сохраняются и не фиксируют набор.</p>
<app-project-rating [criteria]="previewCriteria()" [formControl]="previewControl" />
<app-project-rating
layout="full"
[criteria]="previewCriteria()"
[formControl]="previewControl"
/>
<footer>
<p>
Целые баллы проверяются по заданной шкале. Ноль и «Нет» — допустимые ответы. Комментарий —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ describe("SearchComponent", () => {
expect(component).toBeTruthy();
});

it("closes search without cancelling native controls elsewhere on the page", async () => {
await fixture.whenStable();
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
document.body.append(checkbox);
try {
checkbox.click();
expect(onSwitchSearchSpy).toHaveBeenCalledWith(false);
expect(checkbox.checked).toBe(true);
} finally {
checkbox.remove();
}
});

it("should not call onSwitchSearch when clicked and openable is false", () => {
fixture.componentRef.setInput("openable", false);
const searchDiv = fixture.nativeElement.querySelector(".search__other");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,7 @@ export class SearchComponent implements ControlValueAccessor {
}

/** Обработчик клика вне поля - сворачивает поиск */
onClickOutside(event: Event) {
event.stopPropagation();
event.preventDefault();

onClickOutside(_: Event) {
this.onSwitchSearch(false);
}
}