From 6f5bd63de3213a838cbada746d4dcbb58e9d1c8f Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 17 Oct 2026 22:15:42 +0300 Subject: [PATCH] feat: organizer cases and project form settings --- .../use-cases/program-management.use-case.ts | 7 + .../edit/project-additional.service.spec.ts | 43 +- .../edit/project-additional.service.ts | 44 +- .../edit/projects-edit-info.service.spec.ts | 2 +- .../edit/projects-edit-info.service.ts | 6 + .../api/project/program-link-fields-error.ts | 4 + .../program-management.repository.port.ts | 6 + .../program/program-management.model.ts | 2 +- .../program/program-project-form.model.ts | 33 ++ .../project/program-link-fields.model.ts | 1 + .../program-management-http.adapter.ts | 10 + .../program/program-management.repository.ts | 7 + .../management/management.component.html | 10 +- .../management/management.component.scss | 5 + .../management/management.component.ts | 42 +- .../project-form/project-form.component.html | 370 ++++++++++++++++ .../project-form/project-form.component.scss | 274 ++++++++++++ .../project-form.component.spec.ts | 146 +++++++ .../project-form/project-form.component.ts | 397 ++++++++++++++++++ .../project-additional-step.component.html | 220 ++-------- .../project-additional-step.component.spec.ts | 242 ++++------- .../project-additional-step.component.ts | 102 +---- .../program-fields.component.html | 119 ++++++ .../program-fields.component.scss | 80 ++++ .../program-fields.component.ts | 112 +++++ .../src/app/utils/program-field-controls.ts | 36 ++ .../layout/ui-adapters.directive.ts | 5 +- 27 files changed, 1834 insertions(+), 491 deletions(-) create mode 100644 projects/social_platform/src/app/domain/program/program-project-form.model.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.html create mode 100644 projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.scss create mode 100644 projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.ts create mode 100644 projects/social_platform/src/app/ui/widgets/program-fields/program-fields.component.html create mode 100644 projects/social_platform/src/app/ui/widgets/program-fields/program-fields.component.scss create mode 100644 projects/social_platform/src/app/ui/widgets/program-fields/program-fields.component.ts create mode 100644 projects/social_platform/src/app/utils/program-field-controls.ts diff --git a/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts index 48aab261e..53ff4ed23 100644 --- a/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts +++ b/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts @@ -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, @@ -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); } diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.spec.ts index 55bc2765d..aa65de497 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.spec.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.spec.ts @@ -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" }, @@ -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(); }); @@ -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); + }); }); diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.ts index 7bb7b9a43..8351573d0 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.ts @@ -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"; @@ -67,6 +68,8 @@ export class ProjectAdditionalService { readonly loadFailed = computed(() => this.fieldsState().status === "failure"); readonly saveError = signal(null); readonly caseError = signal(null); + readonly submissionAttempt = signal(0); + readonly uploading = signal(false); readonly isSend$ = signal>(initial()); readonly errorAssignProjectToProgramModalMessage = signal<{ non_field_errors: string[] } | null>( null, @@ -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(); @@ -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 @@ -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), + ), + ]; }); } @@ -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({ kind: "context" })); + } if (submit && this.validateRequiredFields()) { + this.saveError.set( + "Заполните отмеченные поля перед сдачей решения. Черновик можно сохранить незаполненным.", + ); return of( fail({ kind: this.additionalForm.get(PROGRAM_CASE_FIELD_NAME)?.invalid @@ -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" || diff --git a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts index 369d82ef3..4224e90e9 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts @@ -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); diff --git a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts index 6cb3930d3..a257387bf 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts @@ -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); @@ -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")); diff --git a/projects/social_platform/src/app/api/project/program-link-fields-error.ts b/projects/social_platform/src/app/api/project/program-link-fields-error.ts index cb08f3cdb..f204992bd 100644 --- a/projects/social_platform/src/app/api/project/program-link-fields-error.ts +++ b/projects/social_platform/src/app/api/project/program-link-fields-error.ts @@ -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 }; } @@ -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": diff --git a/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts index b170d2561..2b12c44cd 100644 --- a/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts +++ b/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts @@ -1,6 +1,7 @@ /** @format */ import { Observable } from "rxjs"; +import { ManagementProjectForm, ManagementProjectFormWrite } from "../program-project-form.model"; import { ManagementEvaluation, ManagementMaterial, @@ -15,6 +16,11 @@ import { } from "../program-management.model"; export abstract class ProgramManagementRepositoryPort { + abstract projectForm(programId: number): Observable; + abstract saveProjectForm( + programId: number, + body: ManagementProjectFormWrite, + ): Observable; abstract get(programId: number): Observable; abstract participants( programId: number, diff --git a/projects/social_platform/src/app/domain/program/program-management.model.ts b/projects/social_platform/src/app/domain/program/program-management.model.ts index fdb9d2108..ac6e15337 100644 --- a/projects/social_platform/src/app/domain/program/program-management.model.ts +++ b/projects/social_platform/src/app/domain/program/program-management.model.ts @@ -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; diff --git a/projects/social_platform/src/app/domain/program/program-project-form.model.ts b/projects/social_platform/src/app/domain/program/program-project-form.model.ts new file mode 100644 index 000000000..2fe111386 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-project-form.model.ts @@ -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[]; + }[]; +} diff --git a/projects/social_platform/src/app/domain/project/program-link-fields.model.ts b/projects/social_platform/src/app/domain/project/program-link-fields.model.ts index 683c03d1d..b05a5f243 100644 --- a/projects/social_platform/src/app/domain/project/program-link-fields.model.ts +++ b/projects/social_platform/src/app/domain/project/program-link-fields.model.ts @@ -26,6 +26,7 @@ export interface ProgramLinkFieldsError { kind: | "case_required" | "case_unavailable" + | "required_fields" | "submission_closed" | "already_submitted" | "not_competitive" diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts index 97cb816e4..9430f2891 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts @@ -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, @@ -20,6 +24,12 @@ import { @Injectable() export class ProgramManagementHttpAdapter implements ProgramManagementRepositoryPort { private readonly api = inject(ApiService); + projectForm(id: number) { + return this.api.get(`${this.root(id)}/project-form/`); + } + saveProjectForm(id: number, body: ManagementProjectFormWrite) { + return this.api.put(`${this.root(id)}/project-form/`, body); + } private root(id: number): string { return `/programs/${id}/management`; } diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts index 09d130f96..6090468b9 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts @@ -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"; @@ -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); } diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.html b/projects/social_platform/src/app/ui/pages/program/management/management.component.html index 1d646bd5f..c5212496b 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.html @@ -386,7 +386,15 @@

{{ evaluation.name }}

@if (saved()) {

Изменения сохранены.

} - @if (settingsTab() === "basic") { + @if (settingsTab() === "project-form") { + + } @else if (settingsTab() === "basic") {

Основная информация

@for (field of basicFields; track field.key) { diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.scss b/projects/social_platform/src/app/ui/pages/program/management/management.component.scss index 9883d4201..e76a6cac5 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.scss @@ -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; diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.ts b/projects/social_platform/src/app/ui/pages/program/management/management.component.ts index ccb592ba9..95df16710 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.ts @@ -13,6 +13,7 @@ import { inject, signal, untracked, + viewChild, } from "@angular/core"; import { DatePipe, DOCUMENT } from "@angular/common"; import { ActivatedRoute, NavigationEnd, Router, RouterLink } from "@angular/router"; @@ -49,6 +50,7 @@ import { AppRoutes } from "@api/paths/app-routes"; import { ModalComponent } from "@ui/primitives/modal/modal.component"; import { ProgramAnalyticsComponent } from "../detail/analytics/analytics.component"; import { ProgramBrandingPreviewComponent } from "./branding-preview/branding-preview.component"; +import { ManagementProjectFormComponent } from "./project-form/project-form.component"; import { presentationHref } from "@utils/program-case-analytics"; import { evaluationLabel, fromMoscowInput, toMoscowInput } from "@utils/program-management"; import { DEFAULT_IMAGE_FRAME, programImageFraming } from "@utils/program-branding"; @@ -74,6 +76,7 @@ import { ProgramImageFraming, ProgramImageKey } from "@domain/program/program-im ModalComponent, ProgramAnalyticsComponent, ProgramBrandingPreviewComponent, + ManagementProjectFormComponent, ], providers: [ ProgramManagementService, @@ -107,6 +110,18 @@ export class ProgramManagementComponent implements OnInit { readonly uploading = signal(null); readonly formError = signal(null); readonly saved = signal(false); + readonly projectForm = viewChild(ManagementProjectFormComponent); + readonly projectFormStatus = signal({ dirty: false, busy: false }); + readonly confirmProjectFormDiscard = () => + this.confirm( + "Есть несохранённые изменения", + "При выходе изменения будут потеряны.", + "Выйти без сохранения", + true, + ); + projectFormSaved(updatedAt: string): void { + this.state.program.update(program => (program ? { ...program, updatedAt } : program)); + } readonly editingMaterial = signal(null); readonly materialDragActive = signal(false); readonly materialFileName = signal(""); @@ -130,6 +145,7 @@ export class ProgramManagementComponent implements OnInit { { id: "basic", label: "Основное" }, { id: "dates", label: "Сроки" }, { id: "materials", label: "Материалы" }, + { id: "project-form", label: "Кейсы и поля" }, ]; readonly basicForm = this.fb.group({ name: ["", [Validators.required, Validators.pattern(/\S/)]], @@ -222,7 +238,7 @@ export class ProgramManagementComponent implements OnInit { this.mobile.set(window.innerWidth < 1000); } @HostListener("window:beforeunload", ["$event"]) onBeforeUnload(event: BeforeUnloadEvent): void { - if (this.dirty() || this.saving() || this.uploading()) { + if (this.dirty() || this.saving() || this.uploading() || this.projectFormStatus().busy) { event.preventDefault(); event.returnValue = ""; } @@ -252,7 +268,9 @@ export class ProgramManagementComponent implements OnInit { : "participants"; const q = this.route.snapshot.queryParamMap; const tab = q.get("tab"); - this.settingsTab.set(tab === "dates" || tab === "materials" ? tab : "basic"); + this.settingsTab.set( + tab === "dates" || tab === "materials" || tab === "project-form" ? tab : "basic", + ); this.section.set(section); this.formError.set(null); this.saved.set(false); @@ -357,10 +375,15 @@ export class ProgramManagementComponent implements OnInit { }; } dirty(): boolean { - return this.basicForm.dirty || this.datesForm.dirty || this.materialForm.dirty; + return ( + this.basicForm.dirty || + this.datesForm.dirty || + this.materialForm.dirty || + this.projectFormStatus().dirty + ); } async canLeave(): Promise { - if (this.saving() || this.uploading()) return false; + if (this.saving() || this.uploading() || this.projectFormStatus().busy) return false; if (!this.dirty()) return true; const yes = await this.confirm( "Есть несохранённые изменения", @@ -373,6 +396,8 @@ export class ProgramManagementComponent implements OnInit { this.datesForm.markAsPristine(); this.materialForm.markAsPristine(); this.editingMaterial.set(null); + this.projectForm()?.discard(); + this.projectFormStatus.set({ dirty: false, busy: false }); } return yes; } @@ -406,7 +431,14 @@ export class ProgramManagementComponent implements OnInit { save(confirmReopen = false): void { const p = this.state.program(), section = this.settingsTab(); - if (!p || section === "materials" || this.saving() || this.uploading()) return; + if ( + !p || + section === "materials" || + section === "project-form" || + this.saving() || + this.uploading() + ) + return; const form = section === "basic" ? this.basicForm : this.datesForm; if (form.invalid) { form.markAllAsTouched(); diff --git a/projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.html b/projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.html new file mode 100644 index 000000000..c771fcdc2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.html @@ -0,0 +1,370 @@ + +
+ @if (loading()) { +
+ Загружаем кейсы и поля… +
+ } @else { + @if (error(); as message) { +
+ {{ message }} + +
+ } + @if (saved()) { +

Кейсы и поля сохранены.

+ } + @if (snapshot()) { +
+
+

Кейсы и поля проекта

+

Настройте выбор кейса и общую форму для всех проектов программы.

+
+ +
+
+
+
+

Кейсы программы

+

+ У кейса только название. Задания и описание разместите в + . +

+
+
+ @for (item of cases(); track $index; let i = $index) { + @if (caseIndex() !== i) { +
+
+

{{ item.name }}

+

Проектов: {{ item.projectsCount }}

+
+
+ +
+
+ } + } @empty { +

+ Кейсов пока нет. Добавьте названия, если командам нужно выбрать кейс. +

+ } + @if (caseIndex() !== null) { + + + + @if (caseError()) { + + } +
+ +
+ + } @else { + + } +

+ Если кейс уже выбран командой, его нельзя переименовать или удалить. +

+
+
+
+
+

Поля проекта

+

+ Одна форма для всех кейсов. Название проекта и другие стандартные поля остаются без + изменений. +

+
+
+ @for (field of fields(); track field.name) { +
+
+

{{ field.label }}

+

+ {{ typeLabels[field.fieldType] }} · + {{ field.isRequired ? "обязательно для сдачи" : "необязательно" }} +

+ @if (field.helpText) { +

{{ field.helpText }}

+ } +
+
+ +
+
+ } @empty { +

+ Дополнительных полей пока нет. Добавьте то, что командам нужно указать перед сдачей. +

+ } + +

+ Обязательные поля проверяются перед сдачей решения. Черновик можно сохранить + незаполненным. +

+ @if (snapshot()?.hasProjects) { +

+ Новые обязательные поля заполняют только ещё не сдавшие решение команды. Уже сданные + проекты не меняются. +

+ } +

+ Поле с ответами нельзя удалить или изменить его тип. Использованные варианты ответа также + сохраняются. +

+
+
+

+ {{ + dirty() + ? "Есть несохранённые изменения" + : "Изменения применятся после сохранения настроек." + }} +

+
+ +
+
+ } + } +
+ + +
+
+

+ {{ editing() === "new" ? "Добавить поле" : "Настройка поля" }} +

+ +
+

Поле появится в общей форме всех проектов программы.

+ + + @if (editor.controls.fieldType.disabled) { +

Тип существующего поля с ответами или специального поля изменить нельзя.

+ } + + +
+ Команда сможет создать и сохранить черновик без ответа. Поле нужно заполнить перед сдачей + решения. +
+ @if (snapshot()?.hasProjects) { +

Изменения требований действуют только для ещё не сданных проектов.

+ } + @if (choiceEditor) { +
+ Варианты ответа * +
+ @for (option of editor.controls.options.controls; track option; let i = $index) { +
+ +
+ } +
+ +
+

Использованные командами варианты нельзя удалить или переименовать.

+ } + @if (editorError()) { +

{{ editorError() }}

+ } +
+

После добавления поля сохраните настройки страницы.

+
+ +
+
+
+
+ + +
+
+

Предпросмотр формы команды

+ +
+
+ Так будут выглядеть дополнительные поля проекта. Это предпросмотр: ответы и файлы не + отправляются. Здесь показаны и ещё не сохранённые настройки. +
+ @if (previewMessage(); as message) { +

{{ message }}

+ } + @if (previewFields().length) { + + } @else { +

Дополнительных полей и кейсов пока нет.

+ } +
+
+ +
+

Пустой черновик можно сохранить. Обязательные поля проверяются перед сдачей.

+
+
+
diff --git a/projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.scss b/projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.scss new file mode 100644 index 000000000..37a1c8250 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.scss @@ -0,0 +1,274 @@ +/** @format */ + +@use "styles/ui-foundation"; + +:host { + display: block; + min-width: 0; +} + +.ui-button { + @include ui-foundation.button; +} + +.ui-field { + @include ui-foundation.control; + + width: 100%; +} + +.ui-choice { + @include ui-foundation.choice; +} + +.project-form { + display: grid; + gap: 20px; + font-size: 12px; + line-height: 1.5; + color: var(--black); +} + +.project-form h2, +.project-form__dialog h2 { + margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 24px; +} + +.project-form h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + line-height: 22px; +} + +.project-form h4 { + margin: 0; + font-size: 12px; + font-weight: 600; +} + +.project-form p, +.project-form__dialog p { + margin: 6px 0 0; + color: var(--grey-for-text); + overflow-wrap: anywhere; +} + +.project-form__heading, +.project-form__section-heading, +.project-form__row, +.project-form__footer, +.project-form__dialog-heading { + display: flex; + gap: 20px; + align-items: center; + justify-content: space-between; +} + +.project-form__heading { + align-items: flex-start; +} + +.project-form__heading > div, +.project-form__row > div { + min-width: 0; + overflow-wrap: anywhere; +} + +.project-form__heading > button { + flex-shrink: 0; +} + +.project-form__card { + padding: 24px; + background: var(--light-white); + border-radius: 15px; +} + +.project-form__section-heading { + margin-bottom: 20px; +} + +.project-form__section-heading .ui-button { + min-height: 0; + padding: 0; + font: inherit; +} + +.project-form__row { + padding: 12px 0; + border-top: 1px solid var(--medium-grey-for-outline); +} + +.project-form__row:last-of-type { + margin-bottom: 20px; + border-bottom: 1px solid var(--medium-grey-for-outline); +} + +.project-form__actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.project-form__row > .project-form__actions { + flex-shrink: 0; +} + +.project-form__notice { + margin-top: 16px !important; +} + +.project-form__empty { + padding: 12px 0 24px; +} + +.project-form__case-editor { + display: grid; + gap: 12px; + padding: 20px; + margin: 12px 0 20px; + background: var(--light-gray); + border-radius: 12px; +} + +.project-form__footer { + flex-wrap: wrap; + align-items: flex-start; +} + +.project-form__dialog { + display: grid; + gap: 20px; + width: 600px; + max-width: 100%; + font-size: 12px; + line-height: 1.5; + color: var(--black); +} + +.project-form__dialog > label { + display: grid; + gap: 8px; +} + +.project-form__dialog .project-form__checkbox { + display: flex; + gap: 12px; + align-items: center; +} + +.project-form__dialog textarea { + resize: vertical; +} + +.project-form__dialog-heading { + align-items: flex-start; +} + +.project-form__dialog-heading button { + flex-shrink: 0; +} + +.project-form__note { + padding: 16px; + background: var(--light-gray); + border-radius: 12px; +} + +.project-form__options { + display: grid; + gap: 12px; + min-width: 0; + padding: 0; + margin: 0; + border: 0; +} + +.project-form__options legend { + margin-bottom: 12px; +} + +.project-form__option { + display: flex; + gap: 12px; + margin-bottom: 12px; +} + +.project-form__option .ui-field { + flex: 1; +} + +.project-form__option button { + flex-shrink: 0; + white-space: nowrap; +} + +.project-form__options > .ui-button { + justify-self: start; +} + +.project-form__dialog footer { + display: grid; + gap: 20px; + padding-top: 20px; + border-top: 1px solid var(--medium-grey-for-outline); +} + +.project-form .project-form__error { + color: var(--red-dark); +} + +.project-form .ui-alert--error, +.project-form__dialog .ui-alert--error { + padding: 16px; + color: var(--red-dark); + background: var(--red-light); + border-radius: 12px; +} + +.project-form .ui-alert--success, +.project-form__dialog .ui-alert--success { + padding: 16px; + color: var(--green-dark); + background: var(--green-light); + border-radius: 12px; +} + +@media (max-width: 650px) { + .project-form__heading, + .project-form__row { + flex-direction: column; + gap: 12px; + align-items: flex-start; + } + + .project-form__card { + padding: 16px; + } + + .project-form__case-editor { + padding: 12px; + } + + .project-form__footer > .project-form__actions { + width: 100%; + } + + .project-form__footer .ui-button { + width: 100%; + } + + .project-form__dialog { + width: 100%; + } + + .project-form__dialog footer .project-form__actions { + flex-direction: column; + align-items: stretch; + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.spec.ts new file mode 100644 index 000000000..a5756220b --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.spec.ts @@ -0,0 +1,146 @@ +/** @format */ +import { provideZonelessChangeDetection } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { HttpErrorResponse } from "@angular/common/http"; +import { of, Subject, throwError } from "rxjs"; +import { FileService } from "@corelib"; +import { ProgramManagementUseCase } from "@api/program/use-cases/program-management.use-case"; +import { ManagementProjectForm } from "@domain/program/program-project-form.model"; +import { ManagementProjectFormComponent } from "./project-form.component"; + +const initial = (): ManagementProjectForm => ({ + updatedAt: "2026-10-17T10:00:00Z", + revision: "original", + hasProjects: true, + cases: [{ name: "Кейс А", projectsCount: 2 }], + fields: [], + supportedTypes: ["text", "textarea", "select", "file"], +}); +describe("Organizer cases and common fields", () => { + const api = { projectForm: vi.fn(), saveProjectForm: vi.fn() }; + async function setup() { + const pending = new Subject(); + api.projectForm.mockReset().mockReturnValue(pending); + api.saveProjectForm.mockReset(); + await TestBed.configureTestingModule({ + imports: [ManagementProjectFormComponent], + providers: [ + provideZonelessChangeDetection(), + { provide: ProgramManagementUseCase, useValue: api }, + { provide: FileService, useValue: {} }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(ManagementProjectFormComponent); + fixture.componentRef.setInput("programId", 8); + fixture.componentRef.setInput("confirmDiscard", () => Promise.resolve(true)); + await fixture.whenStable(); + return { fixture, component: fixture.componentInstance, pending }; + } + it("loads asynchronously, protects used case and saves the entire explicit draft", async () => { + const { fixture, component, pending } = await setup(); + expect(fixture.nativeElement.textContent).toContain("Загружаем кейсы"); + pending.next(initial()); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector(".project-form__row button").disabled).toBe(true); + component.editCase(); + await fixture.whenStable(); + const input = fixture.nativeElement.querySelector("#case-name"); + input.value = "Кейс Б"; + input.dispatchEvent(new Event("input")); + const event = new Event("submit", { bubbles: true, cancelable: true }); + fixture.nativeElement.querySelector(".project-form__case-editor").dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(component.cases().map(c => c.name)).toEqual(["Кейс А", "Кейс Б"]); + component.editField("new"); + component.editor.controls.label.setValue("Прототип"); + component.editor.controls.isRequired.setValue(true); + component.saveField(); + expect(component.dirty()).toBe(true); + expect(api.saveProjectForm).not.toHaveBeenCalled(); + const saved = { + ...initial(), + updatedAt: "2026-10-17T12:00:00Z", + revision: "new", + cases: component.cases(), + fields: component.fields().map(f => ({ ...f, id: 11, name: "server_name" })), + }; + api.saveProjectForm.mockReturnValue(of(saved)); + component.save(); + expect(api.saveProjectForm).toHaveBeenCalledWith( + 8, + expect.objectContaining({ + expectedRevision: "original", + cases: ["Кейс А", "Кейс Б"], + fields: [expect.objectContaining({ id: null, isRequired: true, fieldType: "text" })], + }), + ); + expect(component.dirty()).toBe(false); + expect(component.saved()).toBe(true); + }); + it("retains draft after conflict; cancelled refresh does not overwrite it", async () => { + const { fixture, component, pending } = await setup(); + pending.next(initial()); + await fixture.whenStable(); + component.editCase(); + component.caseName.setValue("Новый"); + component.saveCase(); + api.saveProjectForm.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 409 }))); + component.save(); + expect(component.conflict()).toBe(true); + expect(component.cases().length).toBe(2); + fixture.componentRef.setInput("confirmDiscard", () => Promise.resolve(false)); + await component.reload(); + expect(api.projectForm).toHaveBeenCalledTimes(1); + expect(component.dirty()).toBe(true); + }); + it("preview uses the same required controls; an empty draft succeeds locally", async () => { + const { fixture, component, pending } = await setup(); + pending.next(initial()); + await fixture.whenStable(); + component.openPreview(); + component.previewSubmit(); + expect(component.previewInvalid()).toBe(true); + expect(component.previewForm.get("case")?.hasError("required")).toBe(true); + component.previewDraft(); + expect(component.previewInvalid()).toBe(false); + expect(component.previewMessage()).toContain("Черновик сохранён"); + expect(api.saveProjectForm).not.toHaveBeenCalled(); + component.previewForm.get("case")?.setValue("Кейс А"); + component.previewSubmit(); + expect(component.previewInvalid()).toBe(false); + }); + it("rejects duplicate case names and preserves special legacy settings", async () => { + const { fixture, component, pending } = await setup(); + const data = initial(); + data.fields = [ + { + id: 5, + name: "legacy", + label: "Выбор", + fieldType: "radio", + isRequired: true, + helpText: "", + options: ["Один", "Два"], + answersCount: 1, + editableType: false, + }, + ]; + pending.next(data); + await fixture.whenStable(); + component.editCase(); + component.caseName.setValue("КЕЙС А"); + component.saveCase(); + expect(component.caseError()).toBeTruthy(); + component.editField(data.fields[0]); + expect(component.editor.controls.fieldType.disabled).toBe(true); + expect(component.editor.controls.isRequired.disabled).toBe(true); + component.editor.controls.label.setValue("Новая подпись"); + component.saveField(); + expect(component.fields()[0]).toMatchObject({ + name: "legacy", + fieldType: "radio", + options: ["Один", "Два"], + label: "Новая подпись", + }); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.ts b/projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.ts new file mode 100644 index 000000000..89e26658b --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.ts @@ -0,0 +1,397 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal, + untracked, +} from "@angular/core"; +import { + FormArray, + FormBuilder, + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from "@angular/forms"; +import { HttpErrorResponse } from "@angular/common/http"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { finalize } from "rxjs"; +import { + AlertDirective, + ButtonDirective, + ChoiceDirective, + FieldDirective, + IconComponent, +} from "@uilib"; +import { ProgramManagementUseCase } from "@api/program/use-cases/program-management.use-case"; +import { + ManagementCase, + ManagementFormField, + ManagementProjectForm, + ManagementProjectFormWrite, +} from "@domain/program/program-project-form.model"; +import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { ProgramFieldsComponent } from "@ui/widgets/program-fields/program-fields.component"; +import { PROGRAM_FIELD_TYPE_LABELS, programFieldControl } from "@utils/program-field-controls"; + +@Component({ + selector: "app-management-project-form", + templateUrl: "./project-form.component.html", + styleUrl: "./project-form.component.scss", + imports: [ + ReactiveFormsModule, + AlertDirective, + ButtonDirective, + ChoiceDirective, + FieldDirective, + IconComponent, + ModalComponent, + ProgramFieldsComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ManagementProjectFormComponent { + readonly programId = input.required(); + readonly confirmDiscard = input.required<() => Promise>(); + readonly statusChange = output<{ dirty: boolean; busy: boolean }>(); + readonly savedAt = output(); + readonly materials = output(); + private readonly api = inject(ProgramManagementUseCase); + private readonly fb = inject(FormBuilder).nonNullable; + private readonly destroyRef = inject(DestroyRef); + readonly snapshot = signal(null); + readonly cases = signal([]); + readonly fields = signal([]); + readonly loading = signal(false); + readonly saving = signal(false); + readonly error = signal(null); + readonly conflict = signal(false); + readonly saved = signal(false); + readonly caseIndex = signal(null); // -1 is new + readonly caseName = this.fb.control("", [ + Validators.required, + Validators.pattern(/\S/), + Validators.maxLength(1000), + ]); + readonly caseError = signal(null); + readonly editing = signal(null); + readonly editorDirty = signal(false); + readonly editorError = signal(null); + readonly editor = this.fb.group({ + label: ["", [Validators.required, Validators.pattern(/\S/), Validators.maxLength(256)]], + fieldType: this.fb.control("text"), + helpText: ["", Validators.maxLength(4000)], + isRequired: [false], + options: this.fb.array>([]), + }); + readonly typeLabels = PROGRAM_FIELD_TYPE_LABELS; + readonly previewOpen = signal(false); + readonly previewFields = signal([]); + readonly previewForm = new FormGroup({}); + readonly previewAttempt = signal(0); + readonly previewMessage = signal(null); + readonly previewInvalid = signal(false); + readonly dirty = computed(() => { + const original = this.snapshot(); + return ( + !!original && + (JSON.stringify(this.writeBody()) !== JSON.stringify(this.writeBody(original)) || + this.editorDirty() || + this.caseIndex() !== null) + ); + }); + private newId = -10; + private request = 0; + + constructor() { + effect(() => { + const id = this.programId(); + untracked(() => this.load(id)); + }); + effect(() => + this.statusChange.emit({ dirty: this.dirty(), busy: this.saving() || this.loading() }), + ); + this.editor.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(() => this.editorDirty.set(this.editor.dirty)); + } + private writeBody(source?: ManagementProjectForm): ManagementProjectFormWrite { + const snapshot = source ?? this.snapshot(); + return { + expectedUpdatedAt: snapshot?.updatedAt ?? "", + expectedRevision: snapshot?.revision ?? "", + cases: (source?.cases ?? this.cases()).map(c => c.name), + fields: (source?.fields ?? this.fields()).map(f => ({ + id: f.id > 0 ? f.id : null, + label: f.label, + fieldType: f.fieldType, + isRequired: f.isRequired, + helpText: f.helpText, + options: f.options, + })), + }; + } + private load(id: number): void { + const request = ++this.request; + this.loading.set(true); + this.error.set(null); + this.api + .projectForm(id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: data => { + if (request !== this.request) return; + this.snapshot.set(data); + this.discard(); + this.loading.set(false); + }, + error: (error: HttpErrorResponse) => { + if (request !== this.request) return; + this.error.set(this.errorText(error)); + this.loading.set(false); + }, + }); + } + async reload(): Promise { + if (this.saving() || (this.dirty() && !(await this.confirmDiscard()()))) return; + this.load(this.programId()); + } + discard(): void { + const snapshot = this.snapshot(); + this.cases.set(snapshot?.cases.map(c => ({ ...c })) ?? []); + this.fields.set(snapshot?.fields.map(f => ({ ...f, options: [...f.options] })) ?? []); + this.caseIndex.set(null); + this.editing.set(null); + this.editorDirty.set(false); + this.error.set(null); + this.conflict.set(false); + this.saved.set(false); + } + async cancel(): Promise { + if (!this.dirty() || (await this.confirmDiscard()())) this.discard(); + } + editCase(index = -1): void { + if (this.saving()) return; + this.caseIndex.set(index); + this.caseName.reset(index < 0 ? "" : this.cases()[index].name); + this.caseError.set(null); + this.saved.set(false); + setTimeout(() => document.getElementById("case-name")?.focus()); + } + saveCase(): void { + const name = this.caseName.value.trim(), + index = this.caseIndex(); + if (this.caseName.invalid || index === null) { + this.caseError.set("Укажите название кейса."); + return; + } + if ( + name.includes("|") || + this.cases().some( + (c, i) => i !== index && c.name.toLocaleLowerCase() === name.toLocaleLowerCase(), + ) + ) { + this.caseError.set("Укажите уникальное название без символа |."); + return; + } + this.cases.update(cases => + index < 0 + ? [...cases, { name, projectsCount: 0 }] + : cases.map((c, i) => (i === index ? { ...c, name } : c)), + ); + this.caseIndex.set(null); + } + deleteCase(index: number): void { + if (!this.cases()[index].projectsCount && !this.saving()) + this.cases.update(cases => cases.filter((_, i) => i !== index)); + } + editField(field: ManagementFormField | "new"): void { + if (this.saving()) return; + this.editor.controls.options.clear(); + const data = + field === "new" + ? { label: "", fieldType: "text" as const, helpText: "", isRequired: false, options: [] } + : field; + for (const option of data.options) + this.editor.controls.options.push(this.fb.control(option, Validators.required)); + this.editor.reset({ + label: data.label, + fieldType: data.fieldType, + helpText: data.helpText, + isRequired: data.isRequired, + options: data.options, + }); + this.editor.controls.fieldType.enable({ emitEvent: false }); + this.editor.controls.isRequired.enable({ emitEvent: false }); + this.editor.controls.options.enable({ emitEvent: false }); + if (field !== "new" && (field.answersCount > 0 || !field.editableType)) + this.editor.controls.fieldType.disable({ emitEvent: false }); + if (field !== "new" && !field.editableType) { + this.editor.controls.isRequired.disable({ emitEvent: false }); + this.editor.controls.options.disable({ emitEvent: false }); + } + this.editorDirty.set(false); + this.editorError.set(null); + this.editing.set(field); + this.saved.set(false); + } + addOption(): void { + this.editor.controls.options.push(this.fb.control("", Validators.required)); + this.editor.markAsDirty(); + this.editorDirty.set(true); + } + removeOption(index: number): void { + this.editor.controls.options.removeAt(index); + this.editor.markAsDirty(); + this.editorDirty.set(true); + } + get choiceEditor(): boolean { + return ["select", "radio"].includes(this.editor.controls.fieldType.value); + } + async closeEditor(): Promise { + if (this.editorDirty() && !(await this.confirmDiscard()())) return; + this.editing.set(null); + this.editorDirty.set(false); + } + saveField(): void { + const editing = this.editing(); + if (!editing) return; + const data = this.editor.getRawValue(); + const options = this.choiceEditor + ? data.options.map(o => o.trim()) + : editing !== "new" && !editing.editableType + ? editing.options + : []; + if (!this.choiceEditor) this.editor.controls.options.clear(); + if ( + this.editor.invalid || + (this.choiceEditor && + (!options.length || + options.some(o => !o || o.includes("|")) || + new Set(options.map(o => o.toLocaleLowerCase())).size !== options.length)) + ) { + this.editor.markAllAsTouched(); + this.editorError.set( + "Заполните название и укажите неповторяющиеся варианты ответа без символа |.", + ); + return; + } + const id = editing === "new" ? this.newId-- : editing.id; + const field: ManagementFormField = { + ...(editing === "new" + ? { id, name: `local_${-id}`, answersCount: 0, editableType: true } + : editing), + ...data, + label: data.label.trim(), + helpText: data.helpText.trim(), + options, + }; + this.fields.update(fields => + editing === "new" ? [...fields, field] : fields.map(f => (f.id === field.id ? field : f)), + ); + this.editing.set(null); + this.editorDirty.set(false); + } + deleteField(field: ManagementFormField): void { + if (!field.answersCount && !this.saving()) + this.fields.update(fields => fields.filter(f => f.id !== field.id)); + } + save(): void { + if ( + !this.snapshot() || + !this.dirty() || + this.saving() || + this.caseIndex() !== null || + this.editing() + ) + return; + this.saving.set(true); + this.error.set(null); + this.saved.set(false); + this.api + .saveProjectForm(this.programId(), this.writeBody()) + .pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: data => { + this.snapshot.set(data); + this.discard(); + this.saved.set(true); + this.savedAt.emit(data.updatedAt); + }, + error: (error: HttpErrorResponse) => { + this.conflict.set(error.status === 409); + this.error.set(this.errorText(error)); + }, + }); + } + openPreview(): void { + const fields: PartnerProgramFields[] = [ + ...(this.cases().length + ? [ + { + id: -1, + name: "case", + label: "Кейс", + fieldType: "select" as const, + isRequired: true, + helpText: "", + options: this.cases().map(c => c.name), + }, + ] + : []), + ...this.fields(), + ]; + for (const name of Object.keys(this.previewForm.controls)) this.previewForm.removeControl(name); + for (const field of fields) this.previewForm.addControl(field.name, programFieldControl(field)); + this.previewFields.set(fields); + this.previewAttempt.set(0); + this.previewMessage.set(null); + this.previewOpen.set(true); + } + previewDraft(): void { + this.previewAttempt.set(0); + this.previewForm.markAsUntouched(); + this.previewInvalid.set(false); + this.previewMessage.set( + "Черновик сохранён в предпросмотре. Обязательные поля можно заполнить перед сдачей.", + ); + } + previewSubmit(): void { + this.previewAttempt.update(n => n + 1); + this.previewForm.markAllAsTouched(); + this.previewInvalid.set(this.previewForm.invalid); + this.previewMessage.set( + this.previewForm.invalid + ? "Заполните отмеченные поля перед сдачей. Черновик и введённые значения сохранены в предпросмотре." + : "Все поля заполнены. Форма готова к сдаче. Это предпросмотр — решение не отправлено.", + ); + } + private errorText(error: HttpErrorResponse): string { + if (error.status === 403) return "У вас нет доступа к настройкам этой программы."; + if (error.status === 401) return "Войдите в аккаунт, чтобы продолжить."; + if (error.status === 409) + return "Настройки уже изменены. Обновите данные и внесите изменения ещё раз."; + if (error.status === 400) { + const detail = error.error?.fields ?? error.error?.cases ?? error.error?.detail; + return ( + (typeof detail === "string" + ? detail + : Array.isArray(detail) && typeof detail[0] === "string" + ? detail.join(" ") + : "Проверьте название, тип и варианты ответа.") + " Изменения остались в форме." + ); + } + return "Не удалось загрузить или сохранить настройки. Проверьте соединение и повторите попытку."; + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.html index c638c0925..0cde70f56 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.html @@ -1,218 +1,46 @@ -
-
+
- @if (pending()) { + @if (state.pending()) { - } @else if (loadFailed()) { + } @else if (state.loadFailed()) {

Не удалось загрузить дополнительные сведения программы.

- Повторить + Повторить
} @else { - @if (saveError(); as message) { - + @if (state.saveError(); as message) { + } - @if (submitted()) { + @if (state.submitted()) {

Проект уже сдан. Дополнительные сведения изменить нельзя.

} - @if (partnerProgramFields().length) { - @for (field of partnerProgramFields(); track field.id) { - -
- @switch (field.fieldType) { - @case ("text") { - @if (additionalForm.get(field.name); as control) { - - - @if (control | controlError) { - - } - - } - } - @case ("textarea") { - - @if (additionalForm.get(field.name); as control) { - - } - } - @case ("checkbox") { - @if (submitted()) { -

- {{ field.label }}: {{ additionalForm.get(field.name)?.value ? "Да" : "Нет" }} -

- } @else { -
- - -
- } - } - @case ("radio") { - @if (submitted()) { -

- {{ field.label }}: {{ additionalForm.get(field.name)?.value ? "Да" : "Нет" }} -

- } @else { - @if (additionalForm.get(field.name); as control) { - - } - - } - } - @case ("select") { - - @if (additionalForm.get(field.name); as control) { - @if (submitted() && control.value && !field.options.includes(control.value)) { -

- {{ control.value }} -

- } @else { - - } - } - } - } - @if (additionalForm.get(field.name); as control) { - @if (field.name === caseFieldName && caseError()) { - - } @else if (control | controlError: "required") { -
- {{ - field.name === caseFieldName - ? "Выберите кейс" - : errorMessage.VALIDATION_REQUIRED - }} -
- } @else if ( - field.name === caseFieldName && (control | controlError: "caseUnavailable") - ) { - - } - } -
- } + @if (state.partnerProgramFields().length) { +

Дополнительные сведения

+ + } @else if (state.hasProgramLink()) { +

+ Проект привязан к программе, но дополнительных полей для заполнения нет. +

} @else { - @if (isProjectAssignToProgram()) { -
-

- проект привязан к программе, но дополнительных полей для заполнения нет -

- -
- } @else { -
-

Пока вы не участвуете ни в одной программе

- - - программы - - - - - -
- } +

Пока вы не участвуете ни в одной программе.

+ Программы } }
diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.spec.ts index 5531f90a6..6ce69fbe1 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.spec.ts @@ -1,56 +1,41 @@ /** @format */ - import { HttpErrorResponse } from "@angular/common/http"; -import { Component, inject, provideZonelessChangeDetection } from "@angular/core"; +import { provideZonelessChangeDetection } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { provideRouter } from "@angular/router"; -import { By } from "@angular/platform-browser"; -import { provideNgxMask } from "ngx-mask"; -import { firstValueFrom, of, Subject, throwError } from "rxjs"; +import { firstValueFrom, of, Subject } from "rxjs"; +import { FileService } from "@corelib"; import { LoggerService } from "@core/lib/services/logger/logger.service"; import { ProjectAdditionalService } from "@api/project/facades/edit/project-additional.service"; -import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; import { ProjectProgramRepositoryPort } from "@domain/project/ports/project-program.repository.port"; import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; import { programLinkFields } from "@domain/project/program-link-fields.fixture"; import { ProgramLinkFields } from "@domain/project/program-link-fields.model"; -import { SelectComponent } from "@ui/primitives/select/select.component"; import { ProjectAdditionalStepComponent } from "./project-additional-step.component"; -@Component({ - imports: [ProjectAdditionalStepComponent], - template: '', -}) -class SubmitHost { - private readonly fields = inject(ProjectAdditionalService); - submit(): void { - this.fields.save(700, true).subscribe(); - } -} - -describe("ProjectAdditionalStepComponent canonical form", () => { +describe("ProjectAdditionalStepComponent common form", () => { let fixture: ComponentFixture; let service: ProjectAdditionalService; let response: Subject; const repo = { getProgramLinkFields: vi.fn(), updateProgramLinkFields: vi.fn() }; const programRepo = { submitCompettetiveProject: vi.fn() }; - + const files = { uploadFile: vi.fn() }; beforeEach(async () => { response = new Subject(); repo.getProgramLinkFields.mockReset().mockReturnValue(response); repo.updateProgramLinkFields.mockReset().mockReturnValue(of(undefined)); programRepo.submitCompettetiveProject.mockReset().mockReturnValue(of({})); + files.uploadFile.mockReset(); await TestBed.configureTestingModule({ imports: [ProjectAdditionalStepComponent], providers: [ provideZonelessChangeDetection(), provideRouter([]), - provideNgxMask(), ProjectAdditionalService, { provide: ProjectProgramRepositoryPort, useValue: repo }, { provide: ProgramRepositoryPort, useValue: programRepo }, { provide: LoggerService, useValue: { error: vi.fn() } }, - { provide: TooltipInfoService, useValue: { isVisible: () => false } }, + { provide: FileService, useValue: files }, ], }).compileComponents(); service = TestBed.inject(ProjectAdditionalService); @@ -58,170 +43,89 @@ describe("ProjectAdditionalStepComponent canonical form", () => { fixture = TestBed.createComponent(ProjectAdditionalStepComponent); await fixture.whenStable(); }); - - function caseSelect(): SelectComponent { - return fixture.debugElement - .queryAll(By.directive(SelectComponent)) - .find(element => element.nativeElement.id === "case")!.componentInstance; - } - - it("required indicators follow backend metadata; optional text has no fake required error", async () => { - const snapshot = programLinkFields(); - snapshot.fields = [ - { - ...snapshot.fields[2], - id: 31, - name: "requiredText", - fieldType: "text", - isRequired: true, - value: "", - }, - { - ...snapshot.fields[2], - id: 32, - name: "optionalText", - fieldType: "text", - isRequired: false, - value: "", - }, - ]; - response.next(snapshot); - service.getAdditionalForm().markAllAsTouched(); - fixture.changeDetectorRef.markForCheck(); - await fixture.whenStable(); - const required = fixture.nativeElement.querySelector("#requiredText").closest("fieldset"); - const optional = fixture.nativeElement.querySelector("#optionalText").closest("fieldset"); - expect(required.querySelector(".project__input-error")).not.toBeNull(); - expect(required.querySelector(".error")).not.toBeNull(); - expect(optional.querySelector(".project__input-error")).toBeNull(); - expect(optional.querySelector(".error")).toBeNull(); - }); - - it("loading is distinct from empty; an asynchronous GET renders blank case with explicit placeholder", async () => { + it("distinguishes loading, error, retry and empty", async () => { expect(fixture.nativeElement.textContent).toContain("Загружаем дополнительные сведения"); - expect(fixture.nativeElement.textContent).not.toContain("полей для заполнения нет"); - response.next(programLinkFields()); + response.error(new HttpErrorResponse({ status: 500 })); await fixture.whenStable(); - expect(caseSelect().placeholder()).toBe("Выберите кейс"); - expect(caseSelect().selectedId).toBeUndefined(); - expect(service.getAdditionalForm().get("case")?.value).toBe(""); - expect( - fixture.nativeElement.querySelector("app-select#case .field__input")?.textContent, - ).toContain("Выберите кейс"); - expect( - fixture.nativeElement.querySelector("app-select#track .field__input")?.textContent, - ).toContain("Y"); - }); - - it("saved B is restored on opening and an explicit dropdown choice updates the control", async () => { - const snapshot = programLinkFields(); - snapshot.fields[0].value = "B"; - response.next(snapshot); + expect(fixture.nativeElement.textContent).toContain("Не удалось загрузить"); + repo.getProgramLinkFields.mockReturnValue(of(programLinkFields({ fields: [] }))); + fixture.nativeElement.querySelector("button").click(); await fixture.whenStable(); - const display: HTMLElement = fixture.nativeElement.querySelector( - "app-select#case .field__input", - ); - expect(display.textContent?.trim()).toBe("B"); - display.click(); + expect(fixture.nativeElement.textContent).toContain("полей для заполнения нет"); + }); + it("restores saved case and changes it through the labelled native selector", async () => { + const data = programLinkFields(); + data.fields[0].value = "B"; + response.next(data); await fixture.whenStable(); - const option = Array.from(document.querySelectorAll(".field__option")).find( - element => element.querySelector("p")?.textContent?.trim() === "A", - )!; - option.click(); + const select = fixture.nativeElement.querySelector("#answer-case") as HTMLSelectElement; + expect(select.value).toBe("B"); + select.value = "A"; + select.dispatchEvent(new Event("change")); await fixture.whenStable(); expect(service.getAdditionalForm().get("case")?.value).toBe("A"); - expect(display.textContent?.trim()).toBe("A"); + expect(fixture.nativeElement.querySelector('label[for="answer-case"]')).not.toBeNull(); }); - - it("required case error says Выберите кейс", async () => { + it("empty draft saves; required errors appear only at submission and preserve values", async () => { response.next(programLinkFields()); - const host = TestBed.createComponent(SubmitHost); - await host.whenStable(); - host.nativeElement.querySelector("button").click(); - await host.whenStable(); - expect(host.nativeElement.querySelector(".error")?.textContent.trim()).toBe("Выберите кейс"); - expect(repo.updateProgramLinkFields).not.toHaveBeenCalled(); - }); - - it("GET error shows controlled message and retry, not an empty state", async () => { - response.error(new HttpErrorResponse({ status: 500, error: "raw backend body" })); await fixture.whenStable(); - expect(fixture.nativeElement.textContent).toContain( - "Не удалось загрузить дополнительные сведения программы.", - ); - expect(fixture.nativeElement.textContent).not.toContain("raw backend"); - repo.getProgramLinkFields.mockReturnValue(of(programLinkFields({ fields: [] }))); - fixture.nativeElement.querySelector("button").click(); + service.getAdditionalForm().markAllAsTouched(); + fixture.changeDetectorRef.markForCheck(); await fixture.whenStable(); - expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [700]]); - expect(fixture.nativeElement.textContent).toContain("полей для заполнения нет"); + expect(fixture.nativeElement.querySelector('[aria-invalid="true"]')).toBeNull(); + expect((await firstValueFrom(service.save(700, false))).ok).toBe(true); + expect((await firstValueFrom(service.save(700, true))).ok).toBe(false); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain("Выберите кейс."); + expect(service.getAdditionalForm().get("track")?.value).toBe("Y"); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); }); - - it("submitted fields are visible but disabled and boolean controls are read-only", async () => { - const snapshot = programLinkFields({ submitted: true }); - snapshot.fields[0].value = "B"; - response.next(snapshot); + it("submitted values are disabled and missing historic case options remain visible", async () => { + const data = programLinkFields({ submitted: true }); + data.fields[0].value = "Старый кейс"; + response.next(data); await fixture.whenStable(); - expect(fixture.nativeElement.textContent).toContain( - "Проект уже сдан. Дополнительные сведения изменить нельзя.", - ); - expect(caseSelect().disabled).toBe(true); expect( - fixture.nativeElement.querySelector("app-select#case .field__input")?.textContent.trim(), - ).toBe("B"); - expect(fixture.nativeElement.querySelector("app-checkbox")).toBeNull(); + fixture.nativeElement.querySelector('[data-testid="saved-field-value"]').textContent, + ).toContain("Старый кейс"); + expect(fixture.nativeElement.querySelector("#answer-track").disabled).toBe(true); + expect(fixture.nativeElement.querySelector("input[type=checkbox]").disabled).toBe(true); await firstValueFrom(service.save(700, false)); expect(repo.updateProgramLinkFields).not.toHaveBeenCalled(); }); - - it("preserves a frozen saved case even if it is no longer an available option", async () => { - const snapshot = programLinkFields({ submitted: true }); - snapshot.fields[0].value = "Previously available case"; - response.next(snapshot); + it("uploads through FileService, blocks save while pending, then saves URL with the link", async () => { + const data = programLinkFields(); + data.fields.push({ + ...data.fields[2], + id: 55, + name: "document", + label: "Финансовая модель", + fieldType: "file", + isRequired: true, + value: null, + }); + response.next(data); await fixture.whenStable(); - expect(service.getAdditionalForm().get("case")?.value).toBe("Previously available case"); - expect(service.getAdditionalForm().get("case")?.disabled).toBe(true); - expect( - fixture.nativeElement.querySelector('[data-testid="saved-field-value"]')?.textContent.trim(), - ).toBe("Previously available case"); - expect(fixture.nativeElement.textContent).not.toContain("больше недоступен"); - }); - - it("renders read-only state after an already-submitted race refresh without an extra detectChanges", async () => { - response.next(programLinkFields()); - service.getAdditionalForm().get("case")?.setValue("A"); - const refresh = new Subject(); - repo.getProgramLinkFields.mockReturnValue(refresh); - programRepo.submitCompettetiveProject.mockReturnValue( - throwError( - () => - new HttpErrorResponse({ - status: 400, - error: { detail: "Проект уже был сдан на проверку." }, - }), - ), - ); - const host = TestBed.createComponent(SubmitHost); - await host.whenStable(); - host.nativeElement.querySelector("button").click(); - await host.whenStable(); - expect(host.nativeElement.textContent).toContain("Загружаем дополнительные сведения"); - const snapshot = programLinkFields({ submitted: true, canSubmit: false }); - snapshot.fields[0].value = "B"; - refresh.next(snapshot); - await host.whenStable(); - expect(host.nativeElement.textContent).toContain( - "Проект уже сдан. Дополнительные сведения изменить нельзя.", - ); - expect( - host.nativeElement.querySelector("app-select#case .field__input")?.textContent.trim(), - ).toBe("B"); - expect(host.debugElement.query(By.directive(SelectComponent)).componentInstance.disabled).toBe( - true, + const uploaded = new Subject<{ url: string }>(); + files.uploadFile.mockReturnValue(uploaded); + const input = fixture.nativeElement.querySelector("input[type=file]"); + Object.defineProperty(input, "files", { value: [new File(["data"], "model.pdf")] }); + input.dispatchEvent(new Event("change")); + await fixture.whenStable(); + expect(files.uploadFile).toHaveBeenCalledOnce(); + expect(service.uploading()).toBe(true); + expect((await firstValueFrom(service.save(700, false))).ok).toBe(false); + uploaded.next({ url: "https://example.com/model.pdf" }); + uploaded.complete(); + await fixture.whenStable(); + expect(service.uploading()).toBe(false); + expect(service.getAdditionalForm().get("document")?.value).toBe( + "https://example.com/model.pdf", ); - host.nativeElement.querySelector("button").click(); - await host.whenStable(); - expect(programRepo.submitCompettetiveProject).toHaveBeenCalledTimes(1); - expect(repo.updateProgramLinkFields).toHaveBeenCalledTimes(1); + await firstValueFrom(service.save(700, false)); + expect(repo.updateProgramLinkFields.mock.calls[0][1]).toContainEqual({ + fieldId: 55, + valueText: "https://example.com/model.pdf", + }); }); }); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.ts index eafef14ec..324aae556 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.ts @@ -1,107 +1,23 @@ /** @format */ -import { CommonModule } from "@angular/common"; -import { Component, computed, inject, ChangeDetectionStrategy } from "@angular/core"; -import { isFailure, isLoading } from "@domain/shared/async-state"; -import { ReactiveFormsModule } from "@angular/forms"; -import { - InputComponent, - CheckboxComponent, - SelectComponent, - ButtonComponent, -} from "@ui/primitives"; -import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; -import { SwitchComponent } from "@ui/primitives/switch/switch.component"; -import { ControlErrorPipe, ToSelectOptionsPipe } from "@corelib"; -import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ChangeDetectionStrategy, Component, inject } from "@angular/core"; import { RouterLink } from "@angular/router"; -import { IconComponent, StateComponent } from "@uilib"; -import { TooltipComponent } from "@ui/primitives/tooltip/tooltip.component"; +import { ButtonComponent } from "@ui/primitives"; +import { StateComponent } from "@uilib"; import { ProjectAdditionalService } from "@api/project/facades/edit/project-additional.service"; -import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; import { AppRoutes } from "@api/paths/app-routes"; -import { PROGRAM_CASE_FIELD_NAME } from "@domain/program/program-case-field.const"; +import { ProgramFieldsComponent } from "@ui/widgets/program-fields/program-fields.component"; -/** Шаг редактирования проекта: дополнительные поля программы. */ @Component({ selector: "app-project-additional-step", templateUrl: "./project-additional-step.component.html", styleUrl: "./project-additional-step.component.scss", - imports: [ - StateComponent, - CommonModule, - ReactiveFormsModule, - InputComponent, - IconComponent, - CheckboxComponent, - SwitchComponent, - SelectComponent, - TextareaComponent, - ControlErrorPipe, - ToSelectOptionsPipe, - ButtonComponent, - RouterLink, - TooltipComponent, - ], + imports: [StateComponent, ButtonComponent, RouterLink, ProgramFieldsComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProjectAdditionalStepComponent { - private readonly projectAdditionalService = inject(ProjectAdditionalService); - private readonly tooltipInfoService = inject(TooltipInfoService); - - protected readonly isProjectAssignToProgram = this.projectAdditionalService.hasProgramLink; - protected readonly pending = this.projectAdditionalService.pending; - protected readonly loadFailed = this.projectAdditionalService.loadFailed; - protected readonly submitted = this.projectAdditionalService.submitted; - protected readonly saveError = this.projectAdditionalService.saveError; - protected readonly caseError = this.projectAdditionalService.caseError; - protected readonly caseFieldName = PROGRAM_CASE_FIELD_NAME; - - retry(): void { - this.projectAdditionalService.retry(); - } - - setBooleanValue(name: string, value: boolean): void { - this.projectAdditionalService.setBooleanValue(name, value); - } - - protected readonly AppRoutes = AppRoutes; - - // Геттеры для получения данных из сервиса - protected readonly additionalForm = this.projectAdditionalService.getAdditionalForm(); - - protected readonly partnerProgramFields = this.projectAdditionalService.partnerProgramFields; - protected readonly isSendingDecision = computed(() => - isLoading(this.projectAdditionalService.isSend$()), - ); - - protected readonly isAssignProjectToProgramError = computed(() => - isFailure(this.projectAdditionalService.isSend$()), - ); - - protected readonly errorAssignProjectToProgramModalMessage = - this.projectAdditionalService.errorAssignProjectToProgramModalMessage; - - /** Наличие подсказки */ - protected readonly haveHint = this.tooltipInfoService.haveHint; - - /** Позиция подсказки */ - protected readonly tooltipPosition = this.tooltipInfoService.tooltipPosition; - - /** Состояние видимости подсказки */ - protected readonly isTooltipVisible = this.tooltipInfoService.isVisible; - - protected readonly errorMessage = ErrorMessage; - - /** Показать подсказку */ - toggleTooltip(): void { - this.tooltipInfoService.toggleTooltip("base"); - } - - toggleAdditionalFormValues( - fieldType: "text" | "textarea" | "checkbox" | "select" | "radio" | "file", - fieldName: string, - ): void { - this.projectAdditionalService.toggleAdditionalFormValues(fieldType, fieldName); - } + protected readonly state = inject(ProjectAdditionalService); + protected readonly additionalForm = this.state.getAdditionalForm(); + protected readonly paths = AppRoutes; + protected readonly setUploadBusy = (busy: boolean) => this.state.uploading.set(busy); } diff --git a/projects/social_platform/src/app/ui/widgets/program-fields/program-fields.component.html b/projects/social_platform/src/app/ui/widgets/program-fields/program-fields.component.html new file mode 100644 index 000000000..d406ca281 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/program-fields/program-fields.component.html @@ -0,0 +1,119 @@ + +
+ @if (uploadError()) { +

{{ uploadError() }}

+ } + @for (field of fields(); track field.name) { + @if (form().get(field.name); as control) { +
+ + @switch (field.fieldType) { + @case ("text") { + + } + @case ("textarea") { + + } + @case ("checkbox") { + + } + @case ("file") { +
+ @if (control.value) { + @if (!preview() && href(control.value); as url) { + {{ + fileNames()[field.name] || "Открыть файл" + }} + } @else { + {{ fileNames()[field.name] || "Файл выбран" }} + } + + } + + +
+ } + @default { + @if (control.disabled && control.value && !field.options.includes(control.value)) { +

{{ control.value }}

+ } @else { + + } + } + } +
+ @if (field.helpText) { +

{{ field.helpText }}

+ } + @if (field.isRequired || field.name === "case") { +

Обязательно перед сдачей решения. В черновике можно оставить пустым.

+ } + @if (error(field); as message) { + + } +
+
+ } + } +
diff --git a/projects/social_platform/src/app/ui/widgets/program-fields/program-fields.component.scss b/projects/social_platform/src/app/ui/widgets/program-fields/program-fields.component.scss new file mode 100644 index 000000000..a2747136e --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/program-fields/program-fields.component.scss @@ -0,0 +1,80 @@ +/** @format */ + +@use "styles/ui-foundation"; + +:host { + display: block; + min-width: 0; +} + +.ui-button { + @include ui-foundation.button; +} + +.ui-field { + @include ui-foundation.control; + + width: 100%; +} + +.ui-choice { + @include ui-foundation.choice; +} + +.program-fields { + display: grid; + gap: 24px; + font-size: 12px; + line-height: 1.5; +} + +.program-fields__field { + display: grid; + gap: 8px; + min-width: 0; +} + +.program-fields label { + font-weight: 600; +} + +.program-fields textarea { + min-height: 100px; + resize: vertical; +} + +.program-fields p { + margin: 0; +} + +.program-fields__hint { + color: var(--grey-for-text); +} + +.program-fields__error { + color: var(--red-dark); +} + +.program-fields .ui-alert--error { + padding: 16px; + color: var(--red-dark); + background: var(--red-light); + border-radius: 12px; +} + +.program-fields__file { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + overflow-wrap: anywhere; +} + +.program-fields a { + color: var(--accent); + overflow-wrap: anywhere; +} + +[aria-invalid="true"].ui-field { + border-color: var(--ui-danger); +} diff --git a/projects/social_platform/src/app/ui/widgets/program-fields/program-fields.component.ts b/projects/social_platform/src/app/ui/widgets/program-fields/program-fields.component.ts new file mode 100644 index 000000000..c54240c8a --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/program-fields/program-fields.component.ts @@ -0,0 +1,112 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + effect, + inject, + input, + signal, +} from "@angular/core"; +import { FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { finalize } from "rxjs"; +import { FileService } from "@corelib"; +import { AlertDirective, ButtonDirective, ChoiceDirective, FieldDirective } from "@uilib"; +import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; +import { presentationHref } from "@utils/program-case-analytics"; + +@Component({ + selector: "app-program-fields", + templateUrl: "./program-fields.component.html", + styleUrl: "./program-fields.component.scss", + imports: [ReactiveFormsModule, AlertDirective, ButtonDirective, ChoiceDirective, FieldDirective], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProgramFieldsComponent { + readonly fields = input.required(); + readonly form = input.required(); + readonly preview = input(false); + readonly submissionAttempt = input(0); + readonly caseError = input(null); + // A callback also clears the owner's guard when an in-flight upload is cancelled + // by destroying this step; Angular outputs are already closed during teardown. + readonly setUploadBusy = input<(busy: boolean) => void>(() => {}); + readonly uploading = signal(null); + readonly uploadError = signal(null); + readonly fileNames = signal>({}); + readonly href = presentationHref; + private readonly files = inject(FileService); + private readonly destroyRef = inject(DestroyRef); + private readonly element: ElementRef = inject(ElementRef); + + constructor() { + effect(() => { + const attempt = this.submissionAttempt(); + if (attempt) + setTimeout(() => + this.element.nativeElement.querySelector("[aria-invalid='true']")?.focus(), + ); + }); + this.destroyRef.onDestroy(() => this.setUploadBusy()(false)); + } + error(field: PartnerProgramFields): string | null { + const c = this.form().get(field.name); + if (field.name === "case" && this.caseError()) return this.caseError(); + if (!c?.invalid || (!this.submissionAttempt() && !c.touched)) return null; + if (c.hasError("required")) + return this.submissionAttempt() + ? field.name === "case" + ? "Выберите кейс." + : "Заполните поле перед сдачей решения." + : null; + if (c.hasError("maxlength")) return "Текст слишком длинный."; + return "Выбранный вариант больше недоступен. Выберите другой."; + } + chooseFile(event: Event, field: PartnerProgramFields): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ""; + const control = this.form().get(field.name); + if (!file || !control || control.disabled || this.uploading()) return; + this.uploadError.set(null); + if (this.preview()) { + this.fileNames.update(names => ({ ...names, [field.name]: file.name })); + control.setValue("preview-file"); + control.markAsDirty(); + return; + } + this.uploading.set(field.name); + this.setUploadBusy()(true); + this.files + .uploadFile(file) + .pipe( + finalize(() => { + this.uploading.set(null); + this.setUploadBusy()(false); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: result => { + if (!this.href(result.url)) { + this.uploadError.set("Сервер не вернул ссылку на файл. Повторите загрузку."); + return; + } + this.fileNames.update(names => ({ ...names, [field.name]: file.name })); + control.setValue(result.url); + control.markAsDirty(); + }, + error: () => this.uploadError.set("Не удалось загрузить файл. Выберите его ещё раз."), + }); + } + clearFile(field: PartnerProgramFields): void { + const c = this.form().get(field.name); + if (!c || c.disabled || this.uploading()) return; + c.setValue(""); + c.markAsDirty(); + this.fileNames.update(names => ({ ...names, [field.name]: "" })); + } +} diff --git a/projects/social_platform/src/app/utils/program-field-controls.ts b/projects/social_platform/src/app/utils/program-field-controls.ts new file mode 100644 index 000000000..602944ff2 --- /dev/null +++ b/projects/social_platform/src/app/utils/program-field-controls.ts @@ -0,0 +1,36 @@ +/** @format */ + +import { FormControl, ValidatorFn, Validators } from "@angular/forms"; +import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; + +/** The same controls are used by the team and the organizer's local preview. */ +export function programFieldControl(field: PartnerProgramFields, value: unknown = ""): FormControl { + const required = field.isRequired || field.name === "case"; + const validators: ValidatorFn[] = []; + if (required) + validators.push(control => + control.value == null || String(control.value).trim() === "" ? { required: true } : null, + ); + if (field.fieldType === "select" || field.fieldType === "radio") + validators.push(control => + !control.value || field.options.includes(String(control.value)) + ? null + : field.name === "case" + ? { caseUnavailable: true } + : { choiceUnavailable: true }, + ); + if (field.fieldType === "text") validators.push(Validators.maxLength(500)); + if (field.fieldType === "textarea") validators.push(Validators.maxLength(300)); + if (field.fieldType === "checkbox") + value = value === true || (typeof value === "string" && value.toLowerCase() === "true"); + return new FormControl(value ?? "", validators); +} + +export const PROGRAM_FIELD_TYPE_LABELS: Record = { + text: "Короткий текст", + textarea: "Развёрнутый текст", + select: "Выбор из списка", + file: "Файл", + checkbox: "Флажок", + radio: "Один вариант", +}; diff --git a/projects/ui/src/lib/components/layout/ui-adapters.directive.ts b/projects/ui/src/lib/components/layout/ui-adapters.directive.ts index 114a2ae71..102ec81c1 100644 --- a/projects/ui/src/lib/components/layout/ui-adapters.directive.ts +++ b/projects/ui/src/lib/components/layout/ui-adapters.directive.ts @@ -11,7 +11,10 @@ export class CardDirective { readonly interactive = input(false); } -@Directive({ selector: "input[appField],textarea[appField]", host: { class: "ui-field" } }) +@Directive({ + selector: "input[appField],textarea[appField],select[appField]", + host: { class: "ui-field" }, +}) export class FieldDirective {} @Directive({ selector: "input[appChoice]", host: { class: "ui-choice" } })