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
Expand Up @@ -820,7 +820,12 @@ <h2>Календарь программы</h2>
type="datetime-local"
[id]="field.key"
[formControlName]="field.key"
[attr.aria-invalid]="dateError(field.key) ? 'true' : null"
[attr.aria-describedby]="dateError(field.key) ? field.key + '-error' : null"
/></label>
@if (dateError(field.key); as error) {
<p appAlert="error" role="alert" [id]="field.key + '-error'">{{ error }}</p>
}
}
<p class="management__muted">
Если отдельный срок сдачи не задан, действует срок окончания регистрации.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,49 @@ describe("Сохранение настроек организатора", () =>
expect(component.basicForm.dirty).toBe(true);
expect(component.formError()).toContain("Данные изменились");
});
it.each(["2026-09-30T18:00", "2026-10-01T15:00", "2026-10-01T15:00:00"])(
"не отправляет несогласованные сроки и сохраняет введенное окончание %s",
finish => {
const { component, save } = setup();
component.settingsTab.set("dates");
component.datesForm.controls.datetimeFinished.setValue(finish);
component.datesForm.controls.datetimeFinished.markAsDirty();
component.save();
expect(save).not.toHaveBeenCalled();
expect(component.dateError("datetimeFinished")).toContain("позже начала");
expect(component.dateError("datetimeRegistrationEnds")).toContain("не позже окончания");
expect(component.datesForm.controls.datetimeFinished.value).toBe(finish);
expect(component.datesForm.dirty).toBe(true);
},
);
it("разрешает регистрацию после старта вплоть до окончания программы", () => {
const { component, save } = setup();
component.settingsTab.set("dates");
const registration = component.datesForm.controls.datetimeRegistrationEnds;
registration.setValue(component.datesForm.controls.datetimeFinished.value);
registration.markAsDirty();
component.save();
expect(component.dateError("datetimeRegistrationEnds")).toBeNull();
expect(save).toHaveBeenCalledWith(1, "dates", {
expectedUpdatedAt: program.updatedAt,
confirmReopen: false,
datetimeRegistrationEnds: new Date(program.datetimeFinished!).toISOString(),
});
});
it("не требует полного календаря черновика", () => {
const { component, state, save } = setup();
state.program.set({ ...program, draft: true });
component.resetForms();
component.settingsTab.set("dates");
component.datesForm.controls.datetimeFinished.setValue("");
component.datesForm.controls.datetimeFinished.markAsDirty();
component.save();
expect(save).toHaveBeenCalledWith(1, "dates", {
expectedUpdatedAt: program.updatedAt,
confirmReopen: false,
datetimeFinished: null,
});
});
it("сохраняет mobile URL и полный framing; 409 оставляет кадр для повторной попытки", () => {
const { component, save } = setup();
component.settingsTab.set("basic");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1197,6 +1197,18 @@ export class ProgramManagementComponent implements OnInit {
control.updateValueAndValidity({ emitEvent: false });
}
}
dateError(field: string): string | null {
const values = this.datesForm.getRawValue();
const timestamp = (value: string | null) => (value ? Date.parse(`${value}+03:00`) : Number.NaN);
const start = timestamp(values.datetimeStarted),
finish = timestamp(values.datetimeFinished),
registration = timestamp(values.datetimeRegistrationEnds);
if (field === "datetimeFinished" && finish <= start)
return "Окончание программы должно быть позже начала.";
if (field === "datetimeRegistrationEnds" && registration > finish)
return "Регистрация должна завершиться не позже окончания программы.";
return null;
}
save(confirmReopen = false): void {
const p = this.state.program(),
section = this.settingsTab();
Expand All @@ -1212,9 +1224,13 @@ export class ProgramManagementComponent implements OnInit {
)
return;
const form = section === "basic" ? this.basicForm : this.datesForm;
if (form.invalid) {
const dateError =
section === "dates"
? this.dateFields.map(field => this.dateError(field.key)).find(Boolean)
: null;
if (form.invalid || dateError) {
form.markAllAsTouched();
this.formError.set("Заполните обязательные поля.");
this.formError.set(dateError || "Заполните обязательные поля.");
return;
}
const body: ManagementSettingsWrite = { expectedUpdatedAt: p.updatedAt, confirmReopen };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ <h4>{{ field.label }}{{ field.required ? " *" : "" }}</h4>
добавьте его заново.
</p>
}
@if (optionsProblem(field); as problem) {
<p appAlert="warning">{{ problem }}</p>
}
</div>
<div class="project-form__actions">
<button
Expand All @@ -125,7 +128,7 @@ <h4>{{ field.label }}{{ field.required ? " *" : "" }}</h4>
>
Ниже</button
><button appButton="text" type="button" [disabled]="saving()" (click)="edit(field)">
Изменить</button
{{ optionsAction(field) }}</button
><button
appButton="text"
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,32 @@ describe("Конструктор формы решения", () => {
true,
);
});
it.each(["case_championship", "hackathon"] as const)(
"guides an incomplete %s template to its case editor without a failed request",
async template => {
read.execute.mockReturnValue(
of(ok({ ...structuredClone(base), hasAnswers: false, fieldsUsage: [] })),
);
const fixture = await setup(),
component = fixture.componentInstance;
component.templateChoice.setValue(template);
await component.applyTemplate();
fixture.detectChanges();
const caseField = component.fields().find(field => field.purpose === "case")!;
const action = template === "hackathon" ? "Добавить задачи" : "Добавить кейсы";
expect(fixture.nativeElement.textContent).toContain(action);
component.save();
expect(save.execute).not.toHaveBeenCalled();
expect(component.editing()).toBe(caseField.clientKey);
expect(component.editorError()).toContain(template === "hackathon" ? "трек" : "кейс");
component.addOption();
component.editor.controls.options.at(0).setValue("Городская среда");
component.applyField();
component.save();
expect(save.execute).toHaveBeenCalledTimes(1);
expect(save.execute.mock.calls[0][1].fields[0].options).toEqual(["Городская среда"]);
},
);
it("keeps local edits on failure and merges fresh fields on revision conflict", async () => {
const fixture = await setup(),
component = fixture.componentInstance;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,24 @@ export class ManagementSolutionFormComponent {
this.previewGroup.set(solutionFieldsGroup(fields));
this.previewOpen.set(true);
}
optionsProblem(field: EditableSolutionField): string | null {
if (
!["select", "radio"].includes(field.type) ||
(field.options.length && field.options.every(option => option.trim()))
)
return null;
if (field.purpose === "case")
return this.template() === "hackathon"
? "Добавьте хотя бы одну задачу или трек."
: "Добавьте хотя бы один кейс.";
return "Добавьте хотя бы один вариант выбора.";
}
optionsAction(field: EditableSolutionField): string {
if (!this.optionsProblem(field)) return "Изменить";
if (field.purpose === "case")
return this.template() === "hackathon" ? "Добавить задачи" : "Добавить кейсы";
return "Добавить варианты";
}
save() {
if (
this.saving() ||
Expand All @@ -495,6 +513,12 @@ export class ManagementSolutionFormComponent {
this.missingIds().length
)
return;
const incomplete = this.fields().find(field => this.optionsProblem(field));
if (incomplete) {
this.edit(incomplete);
this.editorError.set(this.optionsProblem(incomplete)!);
return;
}
this.saving.set(true);
this.error.set("");
this.notice.set("");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,13 @@ describe("Публичная регистрация участника", () => {
const { component, api } = setup(true);
component.submit();
expect(api.submit).not.toHaveBeenCalled();
expect(component.error()).toBeTruthy();
expect(component.error()).toBe("Заполните обязательные поля анкеты.");
});
it("напоминает о согласиях только при создании аккаунта", () => {
const { component, api } = setup();
component.submit();
expect(api.submit).not.toHaveBeenCalled();
expect(component.error()).toBe("Заполните обязательные поля и подтвердите согласия.");
});
it("не сохраняет пароль в браузере после регистрации", () => {
const { component } = setup();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,11 @@ export class PublicRegistrationComponent {
this.answers.markAllAsTouched();
if (!this.authenticated()) this.account.markAllAsTouched();
if (this.answers.invalid || (!this.authenticated() && this.account.invalid)) {
this.error.set("Заполните обязательные поля и подтвердите согласие.");
this.error.set(
this.authenticated()
? "Заполните обязательные поля анкеты."
: "Заполните обязательные поля и подтвердите согласия.",
);
return;
}
const account = this.account.getRawValue();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { ProjectFormService } from "@api/project/project-form.service";
import { ProjectAdditionalService } from "@api/project/facades/edit/project-additional.service";
import { ChatStateService } from "@domain/shared/chat-state.service";
import { Program } from "@domain/program/program.model";
import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service";
import { PublicProgramSolutionsComponent } from "@ui/pages/program/published-solutions/public-program-solutions.component";
import { DeatilComponent } from "./detail.component";
import { DetailInfoService } from "./services/detail-info.service";
import { DetailProgramInfoService } from "./services/program/detail-program-info.service";
Expand Down Expand Up @@ -49,9 +51,10 @@ describe("Detail cleanup actions", () => {
},
);
await TestBed.configureTestingModule({
imports: [DeatilComponent],
imports: [DeatilComponent, PublicProgramSolutionsComponent],
providers: [
provideRouter([]),
{ provide: ProgramDetailMainUIInfoService, useValue: { program: info } },
{ provide: ProjectFormService, useValue: { getForm: () => new FormGroup({}) } },
{ provide: ChatStateService, useValue: { userOnlineStatusCache: of({}) } },
],
Expand Down Expand Up @@ -133,6 +136,39 @@ describe("Detail cleanup actions", () => {
fixture.detectChanges();
expect(button().disabled).toBe(true);
});
it.each(["", "https://example.test/cover.png"])(
"shows exactly one program heading on desktop and mobile with cover %s",
coverImageAddress => {
info.set({ ...Program.default(), name: "Название программы", coverImageAddress });
const fixture = TestBed.createComponent(DeatilComponent);
fixture.detectChanges();
for (const width of [1440, 390]) {
Object.assign(fixture.componentInstance, { appWidth: width });
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
const headings = fixture.nativeElement.querySelectorAll("h1");
expect(headings).toHaveLength(1);
expect(headings[0].textContent).toContain("Название программы");
}
},
);
it.each(["", "https://example.test/cover.png"])(
"keeps one desktop heading in the public solutions page with cover %s",
coverImageAddress => {
const previousWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1440 });
try {
info.set({ ...Program.default(), name: "Публичная программа", coverImageAddress });
const fixture = TestBed.createComponent(PublicProgramSolutionsComponent);
fixture.detectChanges();
const headings = fixture.nativeElement.querySelectorAll("h1");
expect(headings).toHaveLength(1);
expect(headings[0].textContent).toContain("Публичная программа");
} finally {
Object.defineProperty(window, "innerWidth", { configurable: true, value: previousWidth });
}
},
);
it("organizer has management without a duplicate public-solutions action", () => {
manager.set(true);
info.set({ ...Program.default(), solutionStorageMode: "solution" });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@
></ng-container>
</div>

@if (appWidth >= 1000 && listType() === "program" && !publicationsOnly()) {
<h1 class="info__program-heading text-body-18">{{ info().name }}</h1>
}

@if (appWidth >= 1000 && listType() === "project") {
<h1 class="info__project-title text-body-12">{{ info().name }}</h1>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@ $detail-bar-mb: 12px;
overflow-wrap: anywhere;
}

&__program-heading {
display: block;
margin-bottom: 20px;
overflow-wrap: anywhere;
}

&__cover-frame {
position: absolute;
inset: 0;
Expand Down