From 324a3577b5a393bb77ad176188b66094b670c17b Mon Sep 17 00:00:00 2001 From: Fedor Date: Wed, 2 Sep 2026 02:06:41 +0300 Subject: [PATCH 1/2] Polish platform UI analytics and region selection --- .../lists/russian-regions-list.const.spec.ts | 31 ++++ .../lists/russian-regions-list.const.ts | 112 +++++++++++++ .../facades/edit/project-form.factory.spec.ts | 11 ++ .../facades/edit/project-form.factory.ts | 24 ++- .../facades/edit/project-form.service.spec.ts | 20 +++ .../facades/edit/project-form.service.ts | 17 +- .../ui/pages/auth/login/login.component.html | 8 +- .../pages/auth/login/login.component.spec.ts | 16 ++ .../auth/register/register.component.html | 2 +- .../auth/register/register.component.spec.ts | 20 +++ .../profile-left-side.component.scss | 11 +- .../profile-right-side.component.html | 4 +- .../profile-right-side.component.scss | 11 ++ .../detail/analytics/analytics.component.html | 26 ++-- .../detail/analytics/analytics.component.scss | 57 ++++++- .../analytics/analytics.component.spec.ts | 7 +- .../program-projects-filter.component.html | 2 +- .../program-projects-filter.component.scss | 16 +- .../program-projects-filter.component.spec.ts | 29 +++- .../project-main-step.component.html | 16 +- .../project-main-step.component.spec.ts | 114 ++++++++++++++ .../project-main-step.component.ts | 2 + .../vacancies-left-side.component.html | 5 +- .../vacancies-left-side.component.scss | 14 +- .../vacancy-responses.component.html | 29 ++-- .../vacancy-responses.component.scss | 34 +++- .../vacancy-responses.component.spec.ts | 18 +++ .../project-vacancy-card.component.html | 3 +- .../project-vacancy-card.component.scss | 30 +++- .../project-vacancy-card.component.spec.ts | 16 ++ .../region-select.component.html | 51 ++++++ .../region-select.component.scss | 102 ++++++++++++ .../region-select.component.spec.ts | 87 +++++++++++ .../region-select/region-select.component.ts | 147 ++++++++++++++++++ .../vacancy-card/vacancy-card.component.html | 16 +- .../vacancy-card/vacancy-card.component.scss | 10 ++ .../vacancy-card.component.spec.ts | 12 ++ .../src/styles/components/_vacancy-skill.scss | 25 +++ .../src/styles/pages/auth.scss | 12 +- 39 files changed, 1090 insertions(+), 77 deletions(-) create mode 100644 projects/core/src/consts/lists/russian-regions-list.const.spec.ts create mode 100644 projects/core/src/consts/lists/russian-regions-list.const.ts create mode 100644 projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/widgets/region-select/region-select.component.html create mode 100644 projects/social_platform/src/app/ui/widgets/region-select/region-select.component.scss create mode 100644 projects/social_platform/src/app/ui/widgets/region-select/region-select.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/widgets/region-select/region-select.component.ts create mode 100644 projects/social_platform/src/styles/components/_vacancy-skill.scss diff --git a/projects/core/src/consts/lists/russian-regions-list.const.spec.ts b/projects/core/src/consts/lists/russian-regions-list.const.spec.ts new file mode 100644 index 000000000..81572ada2 --- /dev/null +++ b/projects/core/src/consts/lists/russian-regions-list.const.spec.ts @@ -0,0 +1,31 @@ +/** @format */ + +import { + filterRussianRegions, + findCanonicalRussianRegion, + russianRegions, +} from "./russian-regions-list.const"; + +describe("russianRegions", () => { + it("contains a unique canonical list", () => { + expect(russianRegions).toHaveLength(89); + expect(new Set(russianRegions).size).toBe(russianRegions.length); + }); + + it.each([ + ["Москва", "Москва"], + [" мОскВа ", "Москва"], + ["санкт-петербург", "Санкт-Петербург"], + ])("normalizes safe case and whitespace differences for %s", (value, expected) => { + expect(findCanonicalRussianRegion(value)).toBe(expected); + }); + + it("does not guess misspelled or arbitrary legacy values", () => { + expect(findCanonicalRussianRegion("Миксва")).toBeNull(); + expect(findCanonicalRussianRegion("Россия")).toBeNull(); + }); + + it("filters by a case-insensitive substring", () => { + expect(filterRussianRegions(" татар ")).toEqual(["Республика Татарстан"]); + }); +}); diff --git a/projects/core/src/consts/lists/russian-regions-list.const.ts b/projects/core/src/consts/lists/russian-regions-list.const.ts new file mode 100644 index 000000000..389c19ea1 --- /dev/null +++ b/projects/core/src/consts/lists/russian-regions-list.const.ts @@ -0,0 +1,112 @@ +/** Канонические display-названия субъектов РФ для формы проекта. */ +export const russianRegions = [ + "Республика Адыгея", + "Республика Алтай", + "Республика Башкортостан", + "Республика Бурятия", + "Республика Дагестан", + "Донецкая Народная Республика", + "Республика Ингушетия", + "Кабардино-Балкарская Республика", + "Республика Калмыкия", + "Карачаево-Черкесская Республика", + "Республика Карелия", + "Республика Коми", + "Республика Крым", + "Луганская Народная Республика", + "Республика Марий Эл", + "Республика Мордовия", + "Республика Саха (Якутия)", + "Республика Северная Осетия — Алания", + "Республика Татарстан", + "Республика Тыва", + "Удмуртская Республика", + "Республика Хакасия", + "Чеченская Республика", + "Чувашская Республика", + "Алтайский край", + "Забайкальский край", + "Камчатский край", + "Краснодарский край", + "Красноярский край", + "Пермский край", + "Приморский край", + "Ставропольский край", + "Хабаровский край", + "Амурская область", + "Архангельская область", + "Астраханская область", + "Белгородская область", + "Брянская область", + "Владимирская область", + "Волгоградская область", + "Вологодская область", + "Воронежская область", + "Запорожская область", + "Ивановская область", + "Иркутская область", + "Калининградская область", + "Калужская область", + "Кемеровская область — Кузбасс", + "Кировская область", + "Костромская область", + "Курганская область", + "Курская область", + "Ленинградская область", + "Липецкая область", + "Магаданская область", + "Московская область", + "Мурманская область", + "Нижегородская область", + "Новгородская область", + "Новосибирская область", + "Омская область", + "Оренбургская область", + "Орловская область", + "Пензенская область", + "Псковская область", + "Ростовская область", + "Рязанская область", + "Самарская область", + "Саратовская область", + "Сахалинская область", + "Свердловская область", + "Смоленская область", + "Тамбовская область", + "Тверская область", + "Томская область", + "Тульская область", + "Тюменская область", + "Ульяновская область", + "Херсонская область", + "Челябинская область", + "Ярославская область", + "Москва", + "Санкт-Петербург", + "Севастополь", + "Еврейская автономная область", + "Ненецкий автономный округ", + "Ханты-Мансийский автономный округ — Югра", + "Чукотский автономный округ", + "Ямало-Ненецкий автономный округ", +] as const; + +const normalizeForComparison = (value: string): string => + value.trim().toLocaleLowerCase("ru-RU"); + +/** Нормализует только безопасные различия регистра и внешних пробелов. */ +export function findCanonicalRussianRegion(value: unknown): string | null { + if (typeof value !== "string") return null; + + const normalized = normalizeForComparison(value); + if (!normalized) return null; + + return russianRegions.find(region => normalizeForComparison(region) === normalized) ?? null; +} + +export function filterRussianRegions(query: string): readonly string[] { + const normalized = normalizeForComparison(query); + if (!normalized) return russianRegions; + + return russianRegions.filter(region => normalizeForComparison(region).includes(normalized)); +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.spec.ts index 28a73c819..7557db54d 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.spec.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.spec.ts @@ -31,6 +31,17 @@ describe("project-form.factory", () => { expect(linkControl?.valid).toBe(true); }); + it("не принимает произвольный регион нового проекта", () => { + const form = createProjectForm(fb); + const region = form.get("region")!; + + region.setValue("Москва"); + expect(region.valid).toBe(true); + + region.setValue("Миксва"); + expect(region.hasError("canonicalRegion")).toBe(true); + }); + it("создаёт группу достижения с fallback id и валидацией года", () => { const achievement = createProjectAchievementGroup( fb, diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.ts index 896c016e3..8374ae843 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.ts @@ -1,7 +1,14 @@ /** @format */ -import { FormBuilder, FormGroup, Validators } from "@angular/forms"; +import { + AbstractControl, + FormBuilder, + FormGroup, + ValidationErrors, + Validators, +} from "@angular/forms"; import { Project } from "@domain/project/project.model"; +import { findCanonicalRussianRegion } from "@core/consts/lists/russian-regions-list.const"; type ProjectAchievement = Project["achievements"][number]; @@ -9,7 +16,7 @@ export function createProjectForm(fb: FormBuilder): FormGroup { return fb.group({ imageAddress: [""], name: ["", [Validators.required]], - region: ["", [Validators.required]], + region: ["", [Validators.required, projectRegionValidator()]], implementationDeadline: [null], trl: [null], links: fb.array([]), @@ -29,6 +36,19 @@ export function createProjectForm(fb: FormBuilder): FormGroup { }); } +export function projectRegionValidator(legacyValue = "") { + const normalizedLegacy = legacyValue.trim(); + + return (control: AbstractControl): ValidationErrors | null => { + const value = typeof control.value === "string" ? control.value.trim() : ""; + if (!value) return null; + if (findCanonicalRussianRegion(value)) return null; + if (normalizedLegacy && value === normalizedLegacy) return null; + + return { canonicalRegion: true }; + }; +} + export function createProjectAchievementGroup( fb: FormBuilder, achievement: Partial, diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.spec.ts index c96eb9c66..8bfc91dc5 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.spec.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.spec.ts @@ -87,4 +87,24 @@ describe("ProjectFormService", () => { expect(service.achievements.length).toBe(1); expect(service.achievements.at(0).get("title")?.value).toBe("Second"); }); + + it("нормализует безопасные различия регистра и пробелов", () => { + const project = Project.default(); + project.region = " мОскВа "; + + service.initializeProjectData(project); + + expect(service.region?.value).toBe("Москва"); + expect(service.region?.valid).toBe(true); + }); + + it("сохраняет неизвестное legacy-значение без потери при открытии формы", () => { + const project = Project.default(); + project.region = "Миксва"; + + service.initializeProjectData(project); + + expect(service.region?.value).toBe("Миксва"); + expect(service.region?.valid).toBe(true); + }); }); diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts index f6347df34..61ace91d1 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts @@ -12,8 +12,13 @@ import { import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; import { stripNullish } from "@utils/stripNull"; import { Project } from "@domain/project/project.model"; -import { createProjectAchievementGroup, createProjectForm } from "./project-form.factory"; +import { + createProjectAchievementGroup, + createProjectForm, + projectRegionValidator, +} from "./project-form.factory"; import { ProjectFormAutosaveService } from "./project-form-autosave.service"; +import { findCanonicalRussianRegion } from "@core/consts/lists/russian-regions-list.const"; /** Управляет основной формой проекта и формой дополнительных полей партнерской программы. */ @Injectable({ providedIn: "root" }) export class ProjectFormService { @@ -47,11 +52,18 @@ export class ProjectFormService { } public initializeProjectData(project: Project): void { + const rawRegion = typeof project.region === "string" ? project.region.trim() : ""; + const canonicalRegion = findCanonicalRussianRegion(rawRegion); + this.region?.setValidators([ + Validators.required, + projectRegionValidator(canonicalRegion ? "" : rawRegion), + ]); + // Заполняем простые поля this.projectForm.patchValue({ imageAddress: project.imageAddress, name: project.name, - region: project.region, + region: canonicalRegion ?? rawRegion, industryId: project.industry, description: project.description, implementationDeadline: project.implementationDeadline ?? null, @@ -63,6 +75,7 @@ export class ProjectFormService { coverImageAddress: project.coverImageAddress, partnerProgramId: project.partnerProgram?.programId ?? null, }); + this.region?.updateValueAndValidity({ emitEvent: false }); if (project.partnerProgram) { this.relationId.set(project.partnerProgram?.programLinkId); diff --git a/projects/social_platform/src/app/ui/pages/auth/login/login.component.html b/projects/social_platform/src/app/ui/pages/auth/login/login.component.html index 465fcb939..6bdd0beea 100644 --- a/projects/social_platform/src/app/ui/pages/auth/login/login.component.html +++ b/projects/social_platform/src/app/ui/pages/auth/login/login.component.html @@ -3,7 +3,13 @@