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 @@ -59,6 +59,21 @@ export class ProgramAnalyticsDrilldownService {
});
private readonly allSelectionCount = signal(0);
readonly preview = signal<ProgramAttentionPreview | null>(null);
readonly excludedRecipients = signal<number[]>([]);
readonly remainingRecipients = computed(() =>
Math.max(0, (this.preview()?.recipientCount ?? 0) - this.excludedRecipients().length),
);
readonly canRemind = computed(() => this.view() !== "participants-without-team");
readonly selectedWithoutExpert = computed(
() =>
this.projectsPage()?.results.some(
row => this.isSelected(row.programProjectId) && row.reason === "no_assignments",
) ?? false,
);
excludeRecipient(id: number): void {
if (!this.actionPending())
this.excludedRecipients.update(ids => (ids.includes(id) ? ids : [...ids, id]));
}
readonly actionPending = signal<"preview" | "send" | "export" | null>(null);
readonly actionError = signal("");
readonly actionSuccess = signal("", { equal: () => false });
Expand Down Expand Up @@ -128,6 +143,8 @@ export class ProgramAnalyticsDrilldownService {
if (
this.programId === null ||
!category ||
!this.canRemind() ||
this.selectedWithoutExpert() ||
!this.selectedCount() ||
this.actionPending() ||
this.attentionPending() ||
Expand All @@ -145,19 +162,32 @@ export class ProgramAnalyticsDrilldownService {
.pipe(takeUntil(this.cancelActions), takeUntilDestroyed(this.destroyRef))
.subscribe(result => {
this.actionPending.set(null);
if (result.ok) this.preview.set(result.value);
else this.setActionError(result.error.kind);
if (result.ok) {
this.excludedRecipients.set([]);
this.preview.set(result.value);
} else this.setActionError(result.error.kind);
});
}

sendReminder(): void {
const category = this.actionCategory();
const token = this.preview()?.token;
if (this.programId === null || !category || !token || this.actionPending()) return;
if (
this.programId === null ||
!category ||
!token ||
this.actionPending() ||
!this.remainingRecipients() ||
this.preview()?.withoutAssignmentsCount
)
return;
this.actionPending.set("send");
this.actionError.set("");
this.actions
.send(this.programId, category, token)
const excluded = this.excludedRecipients();
(excluded.length
? this.actions.send(this.programId, category, token, excluded)
: this.actions.send(this.programId, category, token)
)
.pipe(takeUntil(this.cancelActions), takeUntilDestroyed(this.destroyRef))
.subscribe(result => {
this.actionPending.set(null);
Expand All @@ -173,7 +203,7 @@ export class ProgramAnalyticsDrilldownService {
});
}

exportAttention(): void {
exportAttention(selected = this.selectedCount() > 0): void {
const category = this.actionCategory();
if (
this.programId === null ||
Expand All @@ -189,9 +219,7 @@ export class ProgramAnalyticsDrilldownService {
this.actions
.export(this.programId, category, {
search: this.appliedSearch(),
selection: this.selectedCount()
? this.selection()
: { mode: "all", ids: [], excludedIds: [] },
selection: selected ? this.selection() : { mode: "all", ids: [], excludedIds: [] },
})
.pipe(takeUntil(this.cancelActions), takeUntilDestroyed(this.destroyRef))
.subscribe(result => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,16 @@ export class ProgramAttentionActionsUseCase {
return this.safe(this.repository.previewAttentionReminder(programId, category, query));
}

send(programId: number, category: ProgramAttentionCategory, token: string) {
return this.safe(this.repository.sendAttentionReminder(programId, category, token));
send(
programId: number,
category: ProgramAttentionCategory,
token: string,
excludedRecipientIds?: number[],
) {
const request = excludedRecipientIds?.length
? this.repository.sendAttentionReminder(programId, category, token, excludedRecipientIds)
: this.repository.sendAttentionReminder(programId, category, token);
return this.safe(request);
}

export(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,12 @@ import { createProjectAchievementGroup, createProjectForm } from "./project-form
describe("project-form.factory", () => {
const fb = new FormBuilder();

it("создаёт основную форму проекта с ожидаемыми обязательными полями", () => {
it("требует только название для основной формы проекта", () => {
const form = createProjectForm(fb);

expect(form.get("name")?.hasError("required")).toBe(true);
expect(form.get("region")?.hasError("required")).toBe(true);
expect(form.get("industryId")?.hasError("required")).toBe(true);
expect(form.get("description")?.hasError("required")).toBe(true);
expect(form.get("coverImageAddress")?.hasError("required")).toBe(true);
expect(form.get("targetAudience")?.hasError("required")).toBe(true);
expect(form.get("problem")?.hasError("required")).toBe(true);
form.get("name")?.setValue("Проект");
expect(form.valid).toBe(true);
expect(form.get("links")).toEqual(expect.any(FormArray));
expect(form.get("achievements")).toEqual(expect.any(FormArray));
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,19 @@ type ProjectAchievement = Project["achievements"][number];
export function createProjectForm(fb: FormBuilder): FormGroup {
return fb.group({
imageAddress: [""],
name: ["", [Validators.required]],
region: ["", [Validators.required, projectRegionValidator()]],
name: ["", [Validators.required, Validators.pattern(/\S/), Validators.maxLength(256)]],
region: ["", [projectRegionValidator()]],
implementationDeadline: [null],
trl: [null],
links: fb.array([]),
link: ["", Validators.pattern(/^(https?:\/\/)/)],
industryId: [undefined, [Validators.required]],
description: ["", [Validators.required, Validators.minLength(0), Validators.maxLength(800)]],
industryId: [undefined],
description: ["", [Validators.maxLength(800)]],
presentationAddress: [""],
coverImageAddress: ["", [Validators.required]],
coverImageAddress: [""],
actuality: ["", [Validators.maxLength(400)]],
targetAudience: ["", [Validators.required, Validators.maxLength(400)]],
problem: ["", [Validators.required, Validators.maxLength(400)]],
targetAudience: ["", [Validators.maxLength(400)]],
problem: ["", [Validators.maxLength(400)]],
partnerProgramId: [null],
achievements: fb.array([]),
title: [""],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,7 @@ export class ProjectFormService {
this.projectId.set(Number.isSafeInteger(project.id) && project.id > 0 ? project.id : null);
const rawRegion = typeof project.region === "string" ? project.region.trim() : "";
const canonicalRegion = findCanonicalRussianRegion(rawRegion);
this.region?.setValidators([
Validators.required,
projectRegionValidator(canonicalRegion ? "" : rawRegion),
]);
this.region?.setValidators([projectRegionValidator(canonicalRegion ? "" : rawRegion)]);

// Заполняем простые поля
this.projectForm.patchValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ describe("ProjectsEditInfoService canonical relation integration", () => {
expect(service.isCompetitive()).toBe(true);
expect(update.execute).toHaveBeenCalledWith({
id: 55,
data: { name: "Project", draft: false },
data: { name: "Project", draft: true },
});
});

Expand Down Expand Up @@ -399,7 +399,7 @@ describe("ProjectsEditInfoService canonical relation integration", () => {
});
});

it("single-link happy path: validation, confirmation, PUT, submit, main save and success exactly once", () => {
it("saves project content and program fields before freezing the copy", () => {
const single = project();
single.partnerProgram.programId = 12;
single.partnerProgram.programLinkId = 700;
Expand All @@ -419,17 +419,20 @@ describe("ProjectsEditInfoService canonical relation integration", () => {
expect(repo.updateProgramLinkFields).toHaveBeenCalledTimes(1);
expect(repo.updateProgramLinkFields.mock.calls[0][0]).toBe(700);
expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled();
expect(update.execute).not.toHaveBeenCalled();
expect(update.execute).toHaveBeenCalledExactlyOnceWith({
id: 55,
data: { name: "Project", draft: true },
});
expect(update.execute.mock.invocationCallOrder[0]).toBeLessThan(
repo.updateProgramLinkFields.mock.invocationCallOrder[0],
);
put.next();
put.complete();
expect(programRepo.submitCompettetiveProject).toHaveBeenCalledExactlyOnceWith(700);
expect(update.execute).not.toHaveBeenCalled();
expect(update.execute).toHaveBeenCalledOnce();
submission.next({});
submission.complete();
expect(update.execute).toHaveBeenCalledExactlyOnceWith({
id: 55,
data: { name: "Project", draft: false },
});
expect(update.execute).toHaveBeenCalledOnce();
expect(service.projectForm.get("partnerProgramId")?.value).toBe(12);
expect(additional.submitted()).toBe(true);
expect(additional.canSubmit()).toBe(false);
Expand Down Expand Up @@ -608,7 +611,7 @@ describe("ProjectsEditInfoService canonical relation integration", () => {
expect(repo.updateProgramLinkFields).toHaveBeenCalledOnce();
expect(programRepo.submitCompettetiveProject).toHaveBeenCalledExactlyOnceWith(700);
expect(ui.applyOpenSendDescisionLateModal).toHaveBeenCalledOnce();
expect(update.execute).not.toHaveBeenCalled();
expect(update.execute).toHaveBeenCalledOnce();
expect(TestBed.inject(SnackbarService).success).not.toHaveBeenCalled();
expect(service.projFormIsSubmitting$().status).toBe("failure");
expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [700]]);
Expand All @@ -625,15 +628,15 @@ describe("ProjectsEditInfoService canonical relation integration", () => {
expect(ui.applySendDescision).toHaveBeenCalledOnce();
expect(repo.updateProgramLinkFields).toHaveBeenCalledOnce();
expect(programRepo.submitCompettetiveProject).toHaveBeenCalledExactlyOnceWith(700);
expect(update.execute).not.toHaveBeenCalled();
expect(update.execute).toHaveBeenCalledOnce();
expect(TestBed.inject(SnackbarService).success).not.toHaveBeenCalled();
});

it.each([
["submit", "Проект уже был сдан на проверку."],
["put", "Нельзя изменять значения полей программы после сдачи проекта на проверку."],
])(
"already-submitted %s race refreshes read-only state without main save or success",
"already-submitted %s race refreshes read-only state without further writes or success",
(source, detail) => {
service.loadProgramTagsAndProject();
service.additionalForm.get("case")?.setValue("B");
Expand All @@ -657,7 +660,7 @@ describe("ProjectsEditInfoService canonical relation integration", () => {
expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [700]]);
expect(service.additionalForm.disabled).toBe(true);
expect(additional.submitted()).toBe(true);
expect(update.execute).not.toHaveBeenCalled();
expect(update.execute).toHaveBeenCalledOnce();
expect(TestBed.inject(SnackbarService).success).not.toHaveBeenCalled();
expect(service.projFormIsSubmitting$().status).toBe("failure");
expect(repo.updateProgramLinkFields).toHaveBeenCalledOnce();
Expand Down
Loading