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 @@ -2,6 +2,7 @@

import { inject, Injectable } from "@angular/core";
import { ProgramManagementRepositoryPort } from "@domain/program/ports/program-management.repository.port";
import { ManagementProjectFormWrite } from "@domain/program/program-project-form.model";
import {
ManagementMaterial,
ManagementMaterialWrite,
Expand All @@ -12,6 +13,12 @@ import {
@Injectable()
export class ProgramManagementUseCase {
private readonly repository = inject(ProgramManagementRepositoryPort);
projectForm(id: number) {
return this.repository.projectForm(id);
}
saveProjectForm(id: number, body: ManagementProjectFormWrite) {
return this.repository.saveProjectForm(id, body);
}
get(id: number) {
return this.repository.get(id);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,13 @@ describe("ProjectAdditionalService canonical fields", () => {
);

it.each([null, undefined, "", " "])(
"draft omits empty case %j without suppressing generic values",
"draft explicitly clears empty case %j without suppressing generic values",
async value => {
service.setContext(55, 700);
caseControl().setValue(value);
expect((await firstValueFrom(service.save(700, false))).ok).toBe(true);
expect(repo.updateProgramLinkFields).toHaveBeenCalledExactlyOnceWith(700, [
{ fieldId: 5, valueText: "" },
{ fieldId: 6, valueText: "Y" },
{ fieldId: 7, valueText: "" },
{ fieldId: 8, valueText: "false" },
Expand Down Expand Up @@ -325,16 +326,17 @@ describe("ProjectAdditionalService canonical fields", () => {
},
);

it("draft PUT does not require canSubmit and still omits empty case", async () => {
it("draft PUT does not require canSubmit and can clear case", async () => {
repo.getProgramLinkFields.mockReturnValue(
of(programLinkFields({ submissionOpen: false, canSubmit: false })),
);
service.setContext(55, 700);
expect((await firstValueFrom(service.save(700, false))).ok).toBe(true);
expect(repo.updateProgramLinkFields).toHaveBeenCalledOnce();
expect(repo.updateProgramLinkFields.mock.calls[0][1]).not.toContainEqual(
expect.objectContaining({ fieldId: 5 }),
);
expect(repo.updateProgramLinkFields.mock.calls[0][1]).toContainEqual({
fieldId: 5,
valueText: "",
});
expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -515,4 +517,35 @@ describe("ProjectAdditionalService canonical fields", () => {
expect(service.isCompetitive()).toBe(false);
expect(service.saveError()).toBeNull();
});
it("refreshes newly required definitions after submit without losing local answers", async () => {
service.setContext(55, 700);
caseControl().setValue("B");
service.getAdditionalForm().get("note")?.setValue("local answer");
const changed = programLinkFields();
changed.fields.push({
...changed.fields[2],
id: 91,
name: "new_required",
isRequired: true,
value: null,
});
repo.getProgramLinkFields.mockReturnValue(of(changed));
programRepo.submitCompettetiveProject.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 400,
error: {
code: "required_program_fields",
detail: "Не заполнены обязательные поля программы.",
},
}),
),
);
expect((await firstValueFrom(service.save(700, true))).ok).toBe(false);
expect(service.getAdditionalForm().get("new_required")?.hasError("required")).toBe(true);
expect(service.getAdditionalForm().get("note")?.value).toBe("local answer");
expect(caseControl().value).toBe("B");
expect(service.submitted()).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/** @format */

import { computed, DestroyRef, inject, Injectable, signal } from "@angular/core";
import { FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms";
import { FormBuilder, FormGroup } from "@angular/forms";
import { programFieldControl } from "@utils/program-field-controls";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { defer, map, Observable, of, Subject, Subscription, switchMap, takeUntil, tap } from "rxjs";
import { LoggerService } from "@core/lib/services/logger/logger.service";
Expand Down Expand Up @@ -67,6 +68,8 @@ export class ProjectAdditionalService {
readonly loadFailed = computed(() => this.fieldsState().status === "failure");
readonly saveError = signal<string | null>(null);
readonly caseError = signal<string | null>(null);
readonly submissionAttempt = signal(0);
readonly uploading = signal(false);
readonly isSend$ = signal<AsyncState<void>>(initial());
readonly errorAssignProjectToProgramModalMessage = signal<{ non_field_errors: string[] } | null>(
null,
Expand Down Expand Up @@ -102,6 +105,8 @@ export class ProjectAdditionalService {
this.clearForm();
this.saveError.set(null);
this.caseError.set(null);
this.submissionAttempt.set(0);
this.uploading.set(false);
this.isSend$.set(initial());
this.fieldsState.set(initial());
if (this.context) this.loadFields();
Expand Down Expand Up @@ -169,23 +174,12 @@ export class ProjectAdditionalService {
for (const field of snapshot.fields) {
const isCase = field.name === PROGRAM_CASE_FIELD_NAME;
let value = edits && field.name in edits ? edits[field.name] : field.value;
if (field.fieldType === "checkbox" || field.fieldType === "radio") {
value =
value === true || (typeof value === "string" && value.trim().toLowerCase() === "true");
} else value ??= "";
value ??= "";
if (isCase && !snapshot.submitted && value !== "" && !field.options.includes(String(value))) {
value = "";
this.caseError.set("Выбранный кейс больше недоступен. Выберите актуальный кейс.");
}
const validators = field.isRequired || isCase ? [Validators.required] : [];
if (isCase)
validators.push(control => {
if (typeof control.value !== "string" || !control.value.trim()) return { required: true };
return field.options.includes(control.value) ? null : { caseUnavailable: true };
});
if (field.fieldType === "text") validators.push(Validators.maxLength(500));
if (field.fieldType === "textarea") validators.push(Validators.maxLength(300));
const control = new FormControl(value, validators);
const control = programFieldControl(field, value);
this.additionalForm.addControl(field.name, control, { emitEvent: false });
if (isCase)
control.valueChanges
Expand Down Expand Up @@ -217,13 +211,16 @@ export class ProjectAdditionalService {
return this.additionalForm.invalid;
}

/** Empty case is omitted from partial draft PUT; generic serialization stays unchanged. */
/** Explicit empty case clears its saved draft choice; omitted fields remain partial. */
private valuesForSave(): ProjectNewAdditionalProgramFields[] {
return this.partnerProgramFields().flatMap(field => {
const value = this.additionalForm.get(field.name)?.value;
if (field.name === PROGRAM_CASE_FIELD_NAME && (value == null || String(value).trim() === ""))
return [];
return [ProjectNewAdditionalProgramFields.fromField(field, String(value))];
return [
ProjectNewAdditionalProgramFields.fromField(
field,
value == null ? "" : String(value).trim() === "" ? "" : String(value),
),
];
});
}

Expand All @@ -244,7 +241,15 @@ export class ProjectAdditionalService {
this.isSend$.set(failure(blocked));
return of(fail(error));
}
if (submit) this.submissionAttempt.update(n => n + 1);
if (this.uploading()) {
this.saveError.set("Дождитесь загрузки файла перед сохранением.");
return of(fail<ProgramLinkFieldsError>({ kind: "context" }));
}
if (submit && this.validateRequiredFields()) {
this.saveError.set(
"Заполните отмеченные поля перед сдачей решения. Черновик можно сохранить незаполненным.",
);
return of(
fail<ProgramLinkFieldsError>({
kind: this.additionalForm.get(PROGRAM_CASE_FIELD_NAME)?.invalid
Expand Down Expand Up @@ -280,8 +285,9 @@ export class ProjectAdditionalService {
const control = this.additionalForm.get(PROGRAM_CASE_FIELD_NAME);
control?.setErrors({ ...(control.errors ?? {}), caseBackend: true });
control?.markAsTouched();
if (result.error.kind === "case_unavailable") this.loadFields(true);
if (result.error.kind === "case_unavailable" || !control) this.loadFields(true);
}
if (result.error.kind === "required_fields") this.loadFields(true);
if (
result.error.kind === "submission_closed" ||
result.error.kind === "already_submitted" ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ describe("ProjectsEditInfoService canonical relation integration", () => {
repo.updateProgramLinkFields.mock.calls[0][1].some(
(value: { fieldId: number }) => value.fieldId === 5,
),
).toBe(false);
).toBe(true);
expect(update.execute).toHaveBeenCalledOnce();
expect(update.execute).toHaveBeenCalledWith({ id: 55, data: { name: "Project", draft: true } });
expect(service.projectForm.get("partnerProgramId")?.value).toBe(99);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,8 @@ export class ProjectsEditInfoService {
const vacancyInvalid = this.projectVacancyUIService.applyValidateForm();
if (projectValid && additionalValid && !vacancyInvalid) return true;

if (!additionalValid) this.projectAdditionalService.submissionAttempt.update(n => n + 1);

this.projectForm.markAllAsTouched();
if (!isDraft) this.additionalForm.markAllAsTouched();
this.projSubmitInitiated.set(!isDraft);
Expand Down Expand Up @@ -559,6 +561,10 @@ export class ProjectsEditInfoService {
}

private additionalContextReady(): boolean {
if (this.projectAdditionalService.uploading()) {
this.snackBarService.error("Дождитесь загрузки файла перед сохранением.");
return false;
}
if (!this.projectAdditionalService.hasProgramLink() || this.activeProgramLinkId()) return true;
this.projectStepService.setStepFromRoute("additional");
this.projFormIsSubmitting$.set(failure("additional_fields_unavailable"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export function mapProgramLinkFieldsError(cause: unknown): ProgramLinkFieldsErro
if (cause.status === 0) return { kind: "network", cause };
if (cause.status >= 500) return { kind: "server", cause };
if (cause.status === 400) {
if (cause.error?.code === "required_program_fields")
return { kind: "required_fields", cause };
if (cause.error?.detail === "Срок подачи проектов в программу завершён.") {
return { kind: "submission_closed", cause };
}
Expand Down Expand Up @@ -38,6 +40,8 @@ export function programLinkFieldsErrorMessage(error: ProgramLinkFieldsError): st
const teamMessage = teamOperationErrorMessage(error.cause);
if (teamMessage) return teamMessage;
switch (error.kind) {
case "required_fields":
return "Заполните обязательные поля программы перед сдачей. Настройки формы обновлены, ваши ответы сохранены.";
case "submission_closed":
return "Срок подачи проектов в программу завершён.";
case "already_submitted":
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** @format */

import { Observable } from "rxjs";
import { ManagementProjectForm, ManagementProjectFormWrite } from "../program-project-form.model";
import {
ManagementEvaluation,
ManagementMaterial,
Expand All @@ -15,6 +16,11 @@ import {
} from "../program-management.model";

export abstract class ProgramManagementRepositoryPort {
abstract projectForm(programId: number): Observable<ManagementProjectForm>;
abstract saveProjectForm(
programId: number,
body: ManagementProjectFormWrite,
): Observable<ManagementProjectForm>;
abstract get(programId: number): Observable<ProgramManagement>;
abstract participants(
programId: number,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type ManagementSection =
| "analytics"
| "settings"
| "evaluation";
export type SettingsSection = "basic" | "dates" | "materials";
export type SettingsSection = "basic" | "dates" | "materials" | "project-form";
export interface ManagementMaterial {
id: number;
title: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/** @format */

import { PartnerProgramFields } from "./partner-program-fields.model";

export interface ManagementCase {
name: string;
projectsCount: number;
}
export interface ManagementFormField extends PartnerProgramFields {
answersCount: number;
editableType: boolean;
}
export interface ManagementProjectForm {
updatedAt: string;
revision: string;
hasProjects: boolean;
cases: ManagementCase[];
fields: ManagementFormField[];
supportedTypes: PartnerProgramFields["fieldType"][];
}
export interface ManagementProjectFormWrite {
expectedUpdatedAt: string;
expectedRevision: string;
cases: string[];
fields: {
id?: number | null;
label: string;
fieldType: PartnerProgramFields["fieldType"];
isRequired: boolean;
helpText: string;
options: string[];
}[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface ProgramLinkFieldsError {
kind:
| "case_required"
| "case_unavailable"
| "required_fields"
| "submission_closed"
| "already_submitted"
| "not_competitive"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
import { inject, Injectable } from "@angular/core";
import { HttpParams } from "@angular/common/http";
import { ApiService } from "@corelib";
import {
ManagementProjectForm,
ManagementProjectFormWrite,
} from "@domain/program/program-project-form.model";
import { ProgramManagementRepositoryPort } from "@domain/program/ports/program-management.repository.port";
import {
ManagementEvaluation,
Expand All @@ -20,6 +24,12 @@ import {
@Injectable()
export class ProgramManagementHttpAdapter implements ProgramManagementRepositoryPort {
private readonly api = inject(ApiService);
projectForm(id: number) {
return this.api.get<ManagementProjectForm>(`${this.root(id)}/project-form/`);
}
saveProjectForm(id: number, body: ManagementProjectFormWrite) {
return this.api.put<ManagementProjectForm>(`${this.root(id)}/project-form/`, body);
}
private root(id: number): string {
return `/programs/${id}/management`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { inject, Injectable } from "@angular/core";
import { tap } from "rxjs";
import { ManagementProjectFormWrite } from "@domain/program/program-project-form.model";
import { EventBus } from "@domain/shared/event-bus";
import { ProgramManagementRepositoryPort } from "@domain/program/ports/program-management.repository.port";
import { ProgramManagementHttpAdapter } from "@infrastructure/adapters/program/program-management-http.adapter";
Expand All @@ -17,6 +18,12 @@ import {
export class ProgramManagementRepository implements ProgramManagementRepositoryPort {
private readonly adapter = inject(ProgramManagementHttpAdapter);
private readonly events = inject(EventBus);
projectForm(id: number) {
return this.adapter.projectForm(id);
}
saveProjectForm(id: number, body: ManagementProjectFormWrite) {
return this.adapter.saveProjectForm(id, body).pipe(tap(() => this.changed(id)));
}
get(id: number) {
return this.adapter.get(id);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,15 @@ <h3>{{ evaluation.name }}</h3>
@if (saved()) {
<p appAlert="success">Изменения сохранены.</p>
}
@if (settingsTab() === "basic") {
@if (settingsTab() === "project-form") {
<app-management-project-form
[programId]="program.id"
[confirmDiscard]="confirmProjectFormDiscard"
(statusChange)="projectFormStatus.set($event)"
(savedAt)="projectFormSaved($event)"
(materials)="changeQuery({ tab: 'materials' })"
/>
} @else if (settingsTab() === "basic") {
<form [formGroup]="basicForm" (ngSubmit)="save()" class="management__card management__form">
<h2>Основная информация</h2>
@for (field of basicFields; track field.key) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,11 @@
display: flex;
margin-bottom: 20px;

@media (max-width: 650px) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}

a {
display: grid;
flex: 1;
Expand Down
Loading