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..4d5d885b0 --- /dev/null +++ b/projects/core/src/consts/lists/russian-regions-list.const.spec.ts @@ -0,0 +1,37 @@ +/** @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(["Республика Татарстан"]); + }); + + it("ranks exact, prefix and substring matches deterministically", () => { + expect(filterRussianRegions("моск").slice(0, 2)).toEqual(["Москва", "Московская область"]); + expect(filterRussianRegions("Москов")).toEqual(["Московская область"]); + 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..8a3611c74 --- /dev/null +++ b/projects/core/src/consts/lists/russian-regions-list.const.ts @@ -0,0 +1,134 @@ +/** + * Канонические display-названия субъектов РФ для формы проекта. + * + * @format + */ + +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)) + .sort((first, second) => { + const firstNormalized = normalizeForComparison(first); + const secondNormalized = normalizeForComparison(second); + const getRank = (region: string): number => { + if (region === normalized) return 0; + if (region.startsWith(normalized)) return 1; + return 2; + }; + const rankDifference = getRank(firstNormalized) - getRank(secondNormalized); + + if (rankDifference !== 0) return rankDifference; + + const lengthDifference = firstNormalized.length - secondNormalized.length; + if (lengthDifference !== 0) return lengthDifference; + + return first.localeCompare(second, "ru-RU"); + }); +} 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 @@