From 45049879e545b53b23d8b3250b9d5b0985f5683d Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 16 Oct 2026 17:45:11 +0300 Subject: [PATCH] feat: release searchable university picker with standard chevron --- .../profile-edit-education-info.service.ts | 2 +- .../edit/profile-edit-info.service.spec.ts | 25 +++ .../facades/edit/profile-edit-info.service.ts | 2 +- .../facades/edit/profile-form.service.ts | 2 +- .../app/domain/profile/university.model.ts | 9 + .../profile/university-http.adapter.ts | 20 ++ .../profile-education-step.component.html | 9 +- .../profile-education-step.component.ts | 2 + .../university-select.component.html | 102 ++++++++++ .../university-select.component.scss | 128 ++++++++++++ .../university-select.component.spec.ts | 144 +++++++++++++ .../university-select.component.ts | 189 ++++++++++++++++++ 12 files changed, 625 insertions(+), 9 deletions(-) create mode 100644 projects/social_platform/src/app/domain/profile/university.model.ts create mode 100644 projects/social_platform/src/app/infrastructure/adapters/profile/university-http.adapter.ts create mode 100644 projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.html create mode 100644 projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.scss create mode 100644 projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.ts diff --git a/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-education-info.service.ts b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-education-info.service.ts index e32ce1056..412cf1ce2 100644 --- a/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-education-info.service.ts +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-education-info.service.ts @@ -68,7 +68,7 @@ export class ProfileEditEducationInfoService { } const educationOverflow = - (this.profileForm.get("organizationName")?.value?.length ?? 0) > 100 || + (this.profileForm.get("organizationName")?.value?.length ?? 0) > 255 || (this.profileForm.get("description")?.value?.length ?? 0) > 400; if (educationOverflow) { diff --git a/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.spec.ts b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.spec.ts index 9b303cd7c..df142800a 100644 --- a/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.spec.ts +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.spec.ts @@ -2,6 +2,7 @@ import { signal } from "@angular/core"; import { TestBed } from "@angular/core/testing"; +import { FormControl, FormGroup } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; import { of } from "rxjs"; import { Skill } from "@domain/skills/skill.model"; @@ -148,4 +149,28 @@ describe("ProfileEditInfoService", () => { expect(service.isModalErrorSkillChooseText()).toContain("Регион"); expect(service.isModalErrorSkillChooseText()).not.toContain("Город"); }); + + it("preserves legacy education names over 100 characters when saving other profile fields", () => { + const legacyName = "Название университета ".repeat(7); + profileFormService.profileId.set(42); + profileFormService.education.push( + new FormGroup({ organizationName: new FormControl(legacyName) }), + ); + profileFormService.getForm().patchValue({ + firstName: "Иван", + lastName: "Иванов", + birthday: "25.03.1990", + city: "Москва", + speciality: "Frontend developer", + skills: [{ id: 1, name: "Angular" }] as Skill[], + organizationName: legacyName, + }); + + service.saveProfile(); + + expect(saveProfileUseCase.execute).toHaveBeenCalledOnce(); + expect(saveProfileUseCase.execute.mock.calls[0][1].education).toEqual([ + { organizationName: legacyName }, + ]); + }); }); diff --git a/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.ts b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.ts index ad633bf9a..571760741 100644 --- a/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.ts +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.ts @@ -114,7 +114,7 @@ export class ProfileEditInfoService { const lengthLimits: { field: string; limit: number }[] = [ { field: "city", limit: 100 }, { field: "aboutMe", limit: 300 }, - { field: "organizationName", limit: 100 }, + { field: "organizationName", limit: 255 }, { field: "description", limit: 400 }, { field: "organization", limit: 50 }, { field: "descriptionWork", limit: 400 }, diff --git a/projects/social_platform/src/app/api/profile/facades/edit/profile-form.service.ts b/projects/social_platform/src/app/api/profile/facades/edit/profile-form.service.ts index 13bc28d15..fa357e61d 100644 --- a/projects/social_platform/src/app/api/profile/facades/edit/profile-form.service.ts +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-form.service.ts @@ -96,7 +96,7 @@ export class ProfileFormService { coverImageAddress: [null], // education - organizationName: ["", Validators.maxLength(100)], + organizationName: ["", Validators.maxLength(255)], entryYear: [null], completionYear: [null], description: [null, Validators.maxLength(400)], diff --git a/projects/social_platform/src/app/domain/profile/university.model.ts b/projects/social_platform/src/app/domain/profile/university.model.ts new file mode 100644 index 000000000..a3b7ee365 --- /dev/null +++ b/projects/social_platform/src/app/domain/profile/university.model.ts @@ -0,0 +1,9 @@ +/** @format */ + +export interface University { + id: number; + name: string; + fullName: string; + aliases: string; + city: string; +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/profile/university-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/profile/university-http.adapter.ts new file mode 100644 index 000000000..9d743f25d --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/profile/university-http.adapter.ts @@ -0,0 +1,20 @@ +/** @format */ + +import { HttpParams } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { University } from "@domain/profile/university.model"; +import { Observable } from "rxjs"; + +@Injectable({ providedIn: "root" }) +export class UniversityHttpAdapter { + private readonly api = inject(ApiService); + + search(search: string, offset = 0): Observable> { + return this.api.get( + "/auth/universities/", + new HttpParams({ fromObject: { search, offset, limit: 30 } }), + ); + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.html b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.html index 754e859c6..0d89ef016 100644 --- a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.html +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.html @@ -48,14 +48,11 @@
@if (profileForm.get("organizationName"); as organizationName) {
- - название учреждения + + > @if (organizationName | controlError: "required") {
{{ errorMessage.VALIDATION_REQUIRED }} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.ts b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.ts index 1a87a5cc3..0dc991ed6 100644 --- a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.ts +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.ts @@ -10,6 +10,7 @@ import { ProfileFormService } from "@api/profile/facades/edit/profile-form.servi import { ProfileEditEducationInfoService } from "@api/profile/facades/edit/profile-edit-education-info.service"; import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; import { TruncatePipe, ControlErrorPipe } from "@corelib"; +import { UniversitySelectComponent } from "../university-select/university-select.component"; /** Шаг редактирования образования в общей форме профиля. */ @Component({ @@ -22,6 +23,7 @@ import { TruncatePipe, ControlErrorPipe } from "@corelib"; SelectComponent, ButtonComponent, InputComponent, + UniversitySelectComponent, ReactiveFormsModule, ControlErrorPipe, TruncatePipe, diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.html b/projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.html new file mode 100644 index 000000000..75c314fce --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.html @@ -0,0 +1,102 @@ + + +
+
+ + +
+ @if (custom()) { +

Другое — укажите название учреждения

+ } + @if (open()) { +
+
+ @if (loading()) { + Поиск вузов… + } @else if (failed()) { + Не удалось загрузить список. Попробуйте снова или выберите «Другое». + } @else if (!universities().length) { + Совпадений нет. Выберите «Другое», чтобы указать название. + } +
+
+ @for (university of universities(); track university.id; let index = $index) { +
+ {{ university.name }} + @if (university.city) { + {{ university.city }} + } +
+ } +
+ Другое +
+
+ @if (universities().length < total()) { + + } +
+ } +
diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.scss b/projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.scss new file mode 100644 index 000000000..62a677341 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.scss @@ -0,0 +1,128 @@ +@use "styles/typography"; + +.university { + position: relative; + width: 100%; + + &__field { + position: relative; + } + + &__input { + width: 100%; + padding: 12px 42px 12px 12px; + color: var(--black); + background: var(--white); + border: 0.5px solid var(--gray); + border-radius: var(--rounded-xxl); + outline: none; + + @include typography.body-12; + + &::placeholder { + color: var(--dark-grey); + } + + &:focus { + border-color: var(--accent); + box-shadow: 0 0 6px rgb(109 40 255 / 30%); + } + + &--error { + border-color: var(--red); + } + } + + &__toggle { + position: absolute; + top: 0; + right: 0; + display: flex; + align-items: center; + justify-content: flex-end; + width: 44px; + height: 100%; + padding: 0 12px; + color: var(--dark-grey); + cursor: pointer; + background: transparent; + border: none; + } + + &__arrow { + flex-shrink: 0; + color: var(--dark-grey); + transition: + transform 0.2s, + color 0.2s; + transform: rotate(180deg); + + &--open { + color: var(--accent); + transform: rotate(0deg); + } + } + + &__dropdown { + position: absolute; + right: 0; + left: 0; + z-index: 20; + max-height: 300px; + padding: 6px; + margin-top: 4px; + overflow-y: auto; + background: var(--white); + border: 0.5px solid var(--gray); + border-radius: var(--rounded-lg); + box-shadow: 0 5px 20px rgb(0 0 0 / 10%); + + @include typography.body-12; + } + + &__option { + padding: 10px; + overflow-wrap: anywhere; + cursor: pointer; + border-radius: var(--rounded-lg); + + &:hover, + &--active { + background: var(--light-gray); + } + + small { + display: block; + margin-top: 3px; + color: var(--grey-for-text); + } + + &--other { + color: var(--accent); + } + } + + &__hint, + &__status { + color: var(--grey-for-text); + + @include typography.body-10; + } + + &__hint { + margin-top: 6px; + } + + &__status:not(:empty) { + padding: 6px 10px; + } + + &__more { + width: 100%; + padding: 10px; + color: var(--accent); + cursor: pointer; + background: transparent; + border: none; + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.spec.ts b/projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.spec.ts new file mode 100644 index 000000000..970d95bd8 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.spec.ts @@ -0,0 +1,144 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Subject, of, throwError } from "rxjs"; +import { UniversityHttpAdapter } from "@infrastructure/adapters/profile/university-http.adapter"; +import { UniversitySelectComponent } from "./university-select.component"; + +const university = { + id: 1, + name: "Московский политехнический университет", + fullName: "", + aliases: "Московский политех", + city: "Москва", +}; +const page = { results: [university], count: 1, next: null, previous: null }; + +describe("UniversitySelectComponent", () => { + let fixture: ComponentFixture; + let component: UniversitySelectComponent; + let search: ReturnType; + let changed: ReturnType; + + beforeEach(async () => { + search = vi.fn().mockReturnValue(of(page)); + await TestBed.configureTestingModule({ + imports: [UniversitySelectComponent], + providers: [{ provide: UniversityHttpAdapter, useValue: { search } }], + }).compileComponents(); + fixture = TestBed.createComponent(UniversitySelectComponent); + component = fixture.componentInstance; + changed = vi.fn(); + component.registerOnChange(changed); + fixture.detectChanges(); + }); + + afterEach(() => fixture.destroy()); + + function type(value: string) { + const input: HTMLInputElement = fixture.nativeElement.querySelector("input"); + input.value = value; + input.dispatchEvent(new Event("input")); + fixture.detectChanges(); + } + + it("preserves a legacy name exactly and never writes during initialization or lookup", async () => { + const oldName = " Старое название ВУЗа "; + component.writeValue(oldName); + component.onFocus(); + await vi.waitFor(() => expect(search).toHaveBeenCalled()); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector("input").value).toBe(oldName); + expect(changed).not.toHaveBeenCalled(); + }); + + it("opens the list without typing and commits a selected name", async () => { + component.browse(); + await vi.waitFor(() => expect(component.universities()).toEqual([university])); + fixture.detectChanges(); + expect(search).toHaveBeenCalledWith("", 0); + (fixture.nativeElement.querySelector('[role="option"]') as HTMLElement).click(); + expect(changed).toHaveBeenLastCalledWith(university.name); + expect(component.open()).toBe(false); + }); + + it("offers Other when nothing matches and keeps the typed institution name", async () => { + search.mockReturnValue(of({ ...page, results: [], count: 0 })); + type("Школа № 42"); + expect(changed).toHaveBeenLastCalledWith(""); + await vi.waitFor(() => expect(search).toHaveBeenCalledWith("Школа № 42", 0)); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain("Совпадений нет"); + (fixture.nativeElement.querySelector('[role="option"]') as HTMLElement).click(); + expect(changed).toHaveBeenLastCalledWith("Школа № 42"); + type("Школа № 43"); + expect(changed).toHaveBeenLastCalledWith("Школа № 43"); + }); + + it("ignores late results from an earlier query", async () => { + const oldResponse = new Subject(); + search.mockReturnValueOnce(oldResponse).mockReturnValue(of({ ...page, results: [], count: 0 })); + type("моск"); + await vi.waitFor(() => expect(search).toHaveBeenCalledTimes(1)); + type("несуществующий"); + oldResponse.next(page); + expect(component.universities()).toEqual([]); + await vi.waitFor(() => expect(search).toHaveBeenCalledTimes(2)); + expect(component.universities()).toEqual([]); + }); + + it("shows a retryable error and allows Other if the API is unavailable", async () => { + search.mockReturnValue(throwError(() => new Error("offline"))); + component.browse(); + await vi.waitFor(() => expect(component.failed()).toBe(true)); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain("Не удалось загрузить список"); + expect(fixture.nativeElement.textContent).toContain("Другое"); + }); + + it("supports keyboard selection and Escape", async () => { + component.browse(); + await vi.waitFor(() => expect(component.universities()).toHaveLength(1)); + component.onKeyDown(new KeyboardEvent("keydown", { key: "ArrowDown" })); + component.onKeyDown(new KeyboardEvent("keydown", { key: "Enter" })); + expect(changed).toHaveBeenLastCalledWith(university.name); + component.onFocus(); + component.onKeyDown(new KeyboardEvent("keydown", { key: "Escape" })); + expect(component.open()).toBe(false); + }); + + it("loads the next page and respects disabled state", async () => { + search + .mockReturnValueOnce(of({ ...page, count: 2 })) + .mockReturnValue(of({ ...page, results: [{ ...university, id: 2 }], count: 2 })); + component.browse(); + await vi.waitFor(() => expect(component.universities()).toHaveLength(1)); + component.loadMore(); + await vi.waitFor(() => expect(component.universities()).toHaveLength(2)); + expect(search).toHaveBeenLastCalledWith("", 1); + component.setDisabledState(true); + component.browse(); + fixture.detectChanges(); + expect(component.open()).toBe(false); + expect(fixture.nativeElement.querySelector("input").disabled).toBe(true); + }); + + it("keeps the load-more button focused and ignores duplicate clicks while loading", async () => { + const nextPage = new Subject(); + search.mockReturnValueOnce(of({ ...page, count: 2 })).mockReturnValue(nextPage); + component.browse(); + await vi.waitFor(() => expect(component.universities()).toHaveLength(1)); + fixture.detectChanges(); + const more: HTMLButtonElement = fixture.nativeElement.querySelector(".university__more"); + more.focus(); + more.click(); + fixture.detectChanges(); + expect(more.disabled).toBe(false); + expect(more.getAttribute("aria-disabled")).toBe("true"); + more.click(); + await vi.waitFor(() => expect(search).toHaveBeenCalledTimes(2)); + nextPage.next({ ...page, results: [{ ...university, id: 2 }], count: 2 }); + expect(component.open()).toBe(true); + expect(component.universities()).toHaveLength(2); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.ts b/projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.ts new file mode 100644 index 000000000..c08be47cd --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.ts @@ -0,0 +1,189 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + forwardRef, + inject, + input, + signal, + viewChild, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { IconComponent } from "@uilib"; +import { University } from "@domain/profile/university.model"; +import { UniversityHttpAdapter } from "@infrastructure/adapters/profile/university-http.adapter"; +import { catchError, map, of, Subject, switchMap, timer } from "rxjs"; + +/** Suggestions commit only on selection; writeValue never changes a legacy profile value. */ +@Component({ + selector: "app-university-select", + templateUrl: "./university-select.component.html", + styleUrl: "./university-select.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IconComponent], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => UniversitySelectComponent), + multi: true, + }, + ], +}) +export class UniversitySelectComponent implements ControlValueAccessor { + readonly error = input(false); + readonly inputId = input("organizationName"); + readonly inputElement = viewChild>("nameInput"); + readonly text = signal(""); + readonly custom = signal(false); + readonly disabled = signal(false); + readonly open = signal(false); + readonly loading = signal(false); + readonly failed = signal(false); + readonly universities = signal([]); + readonly total = signal(0); + readonly activeIndex = signal(-1); + + private readonly host = inject>(ElementRef); + private readonly adapter = inject(UniversityHttpAdapter); + private readonly destroyRef = inject(DestroyRef); + private readonly requests = new Subject<{ query: string; offset: number; delay: number }>(); + private query = ""; + private onChange: (value: string) => void = () => {}; + private onTouched: () => void = () => {}; + + constructor() { + this.requests + .pipe( + switchMap(request => + timer(request.delay).pipe( + switchMap(() => this.adapter.search(request.query, request.offset)), + map(page => ({ request, page })), + catchError(() => of({ request, page: null })), + ), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(({ request, page }) => { + this.loading.set(false); + this.failed.set(!page); + if (page) { + this.universities.update(items => + request.offset ? [...items, ...page.results] : page.results, + ); + this.total.set(page.count); + } + }); + } + + writeValue(value: string | null): void { + this.text.set(value ?? ""); + this.custom.set(false); + this.close(); + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(disabled: boolean): void { + this.disabled.set(disabled); + if (disabled) this.close(); + } + + onInput(event: Event): void { + const value = (event.target as HTMLInputElement).value; + this.text.set(value); + this.onChange(this.custom() ? value.trim() : ""); + if (!this.custom()) this.search(value, 200); + } + + onFocus(): void { + if (!this.custom() && !this.disabled()) this.search(this.text(), 0); + } + + browse(): void { + if (this.disabled()) return; + this.custom.set(false); + this.inputElement()?.nativeElement.focus(); + this.search("", 0); + } + + private search(query: string, delay: number): void { + this.query = query.trim(); + this.open.set(true); + this.loading.set(true); + this.failed.set(false); + this.universities.set([]); + this.total.set(0); + this.activeIndex.set(-1); + this.requests.next({ query: this.query, offset: 0, delay }); + } + + loadMore(): void { + if (this.loading()) return; + this.loading.set(true); + this.requests.next({ query: this.query, offset: this.universities().length, delay: 0 }); + } + + select(university: University): void { + this.text.set(university.name); + this.onChange(university.name); + this.onTouched(); + this.close(); + } + + selectOther(): void { + this.custom.set(true); + this.onChange(this.text().trim()); + this.onTouched(); + this.close(); + this.inputElement()?.nativeElement.focus(); + } + + onFocusOut(event: FocusEvent): void { + if (!this.host.nativeElement.contains(event.relatedTarget as Node | null)) { + this.close(); + this.onTouched(); + } + } + + close(): void { + this.open.set(false); + this.activeIndex.set(-1); + } + + onKeyDown(event: KeyboardEvent): void { + if (event.key === "Escape") { + event.preventDefault(); + this.close(); + } else if (event.key === "ArrowDown" || event.key === "ArrowUp") { + if (this.custom()) return; + event.preventDefault(); + if (!this.open()) this.onFocus(); + const count = this.universities().length + 1; // Includes Other. + this.activeIndex.update(index => { + if (index < 0) return event.key === "ArrowDown" ? 0 : count - 1; + return (index + (event.key === "ArrowDown" ? 1 : -1) + count) % count; + }); + queueMicrotask(() => + this.host.nativeElement + .querySelector(`#${this.inputId()}-option-${this.activeIndex()}`) + ?.scrollIntoView({ block: "nearest" }), + ); + } else if (event.key === "Enter") { + event.preventDefault(); + if (!this.open() || this.activeIndex() < 0) return; + const university = this.universities()[this.activeIndex()]; + if (university) this.select(university); + else this.selectOther(); + } + } +}