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
37 changes: 37 additions & 0 deletions projects/core/src/consts/lists/russian-regions-list.const.spec.ts
Original file line number Diff line number Diff line change
@@ -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(["Москва"]);
});
});
134 changes: 134 additions & 0 deletions projects/core/src/consts/lists/russian-regions-list.const.ts
Original file line number Diff line number Diff line change
@@ -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");
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
/** @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];

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([]),
Expand All @@ -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<ProjectAchievement>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
<div class="login">
<div class="login__left">
<div class="login__left-content">
<form class="auth" [formGroup]="loginForm" (ngSubmit)="onSubmit()" (keyup.enter)="onSubmit()">
<form
class="auth"
[formGroup]="loginForm"
novalidate
(ngSubmit)="onSubmit()"
(keyup.enter)="onSubmit()"
>
<div class="auth__wrapper">
<img
class="login__logo"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,21 @@ describe("LoginComponent", () => {
expect(password.autocomplete).toBe("current-password");
expect(passwordField.classList).toContain("auth__password-input");
expect(emailField.classList).not.toContain("auth__password-input");
expect((fixture.nativeElement.querySelector("form") as HTMLFormElement).noValidate).toBe(true);
});

it("keeps the custom password visibility control clickable", () => {
const password = fixture.nativeElement.querySelector(
'input[name="password"]',
) as HTMLInputElement;
const toggle = password
.closest("app-input")
?.querySelector(".field__right-icon i") as HTMLElement;

expect(password.type).toBe("password");
toggle.click();
fixture.detectChanges();

expect(password.type).toBe("text");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
decoding="async"
/>
</div>
<form class="auth__wrapper" [formGroup]="registerForm" (ngSubmit)="onSendForm()">
<form class="auth__wrapper" [formGroup]="registerForm" novalidate (ngSubmit)="onSendForm()">
<ng-container>
<div class="auth__row">
@if (registerForm.get("firstName"); as name) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,25 @@ describe("RegisterComponent", () => {
expect(password.closest("app-input")?.classList).toContain("auth__password-input");
expect(repeatedPassword.closest("app-input")?.classList).toContain("auth__password-input");
expect(birthdayField.classList).not.toContain("auth__password-input");
expect((fixture.nativeElement.querySelector("form") as HTMLFormElement).noValidate).toBe(true);
});

it("keeps both custom password visibility controls clickable", () => {
const password = fixture.nativeElement.querySelector(
'input[name="new-password"]',
) as HTMLInputElement;
const repeatedPassword = fixture.nativeElement.querySelector(
'input[name="new-password-confirmation"]',
) as HTMLInputElement;

(password.closest("app-input")?.querySelector(".field__right-icon i") as HTMLElement).click();
fixture.detectChanges();
expect(password.type).toBe("text");

(
repeatedPassword.closest("app-input")?.querySelector(".field__right-icon i") as HTMLElement
).click();
fixture.detectChanges();
expect(repeatedPassword.type).toBe("text");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,20 @@
background-color: var(--accent-light);
}

/* stylelint-disable value-no-vendor-prefix */
&__program-name {
display: -webkit-box;
min-width: 0;
overflow: hidden;
font-size: 12px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 16px;
color: var(--black);
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
/* stylelint-enable value-no-vendor-prefix */

&__icon {
color: var(--accent);
Expand Down
Loading