From c16870fa099657e08f6b7a951321dd31e7960c25 Mon Sep 17 00:00:00 2001 From: Fedor Date: Mon, 9 Nov 2026 00:57:50 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B7=D0=BD=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=BE=D0=B2=D0=BE=D1=81=D1=82=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D1=8B=20=D0=BF=D0=BE=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4?= =?UTF-8?q?=D0=BA=D0=B0=D0=BC=20=D0=BB=D0=B5=D0=BD=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/consts/filters/feed-filter.const.ts | 40 +++++++----- .../feed/facades/feed-info.service.spec.ts | 30 +++++++-- .../app/api/feed/facades/feed-info.service.ts | 65 ++++++++----------- .../facades/ui/feed-ui-info.service.spec.ts | 9 ++- .../feed/facades/ui/feed-ui-info.service.ts | 6 +- .../src/app/domain/feed/feed-item.model.ts | 6 +- .../src/app/ui/pages/feed/feed.resolver.ts | 33 ++++------ .../feed-filter/feed-filter.component.html | 28 +++----- .../feed-filter/feed-filter.component.spec.ts | 27 +++++--- .../feed-filter/feed-filter.component.ts | 5 -- .../service/feed-filter-info.service.ts | 45 ++----------- 11 files changed, 138 insertions(+), 156 deletions(-) diff --git a/projects/core/src/consts/filters/feed-filter.const.ts b/projects/core/src/consts/filters/feed-filter.const.ts index 6d5a5279e..2d6eb32f7 100644 --- a/projects/core/src/consts/filters/feed-filter.const.ts +++ b/projects/core/src/consts/filters/feed-filter.const.ts @@ -2,39 +2,49 @@ export const feedFilter = [ { - id: 0, name: "все новости", value: "", icon: "feed", + apiType: "all_news", + countKey: "allNews", }, { - id: 1, name: "новости проектов", value: "project", icon: "projects", + apiType: "project_news", + countKey: "projectNews", + }, + { + name: "новости сообщества", + value: "news", + icon: "people-bold", + apiType: "news", + countKey: "news", }, { - id: 2, name: "свежие вакансии", value: "vacancy", icon: "suitcase", + apiType: "vacancy", + countKey: "vacancy", }, { - id: 3, - name: "новости сообщества", - value: "news", - icon: "people-bold", + name: "новые проекты", + value: "new_project", + icon: "projects", + apiType: "new_project", + countKey: "newProject", }, { - id: 4, name: "новости программ", value: "partnerprogram", icon: "procollab", + apiType: "partnerprogram", + countKey: "partnerprogram", }, - { - id: 5, - name: "образование", - value: "education", - icon: "trajectories", - }, -]; +] as const; + +export function getFeedFilter(includes: unknown) { + return feedFilter.find(filter => filter.value === includes) ?? feedFilter[0]; +} diff --git a/projects/social_platform/src/app/api/feed/facades/feed-info.service.spec.ts b/projects/social_platform/src/app/api/feed/facades/feed-info.service.spec.ts index b02c01123..ea82abd9a 100644 --- a/projects/social_platform/src/app/api/feed/facades/feed-info.service.spec.ts +++ b/projects/social_platform/src/app/api/feed/facades/feed-info.service.spec.ts @@ -3,7 +3,7 @@ import { ElementRef } from "@angular/core"; import { TestBed } from "@angular/core/testing"; import { ActivatedRoute } from "@angular/router"; -import { of } from "rxjs"; +import { BehaviorSubject, of, Subject } from "rxjs"; import { FeedInfoService } from "./feed-info.service"; import { FeedUIInfoService } from "./ui/feed-ui-info.service"; import { FetchFeedUseCase } from "../use-cases/fetch-feed.use-case"; @@ -26,8 +26,9 @@ describe("FeedInfoService", () => { }, }); - function setup(nextIds: number[]) { + function setup(nextIds: number[], includes?: string) { const fetch = { execute: vi.fn() }; + const queryParams = new BehaviorSubject<{ includes?: string }>({ includes }); const toggle = { execute: vi.fn().mockReturnValue(of(ok(undefined))) }; fetch.execute.mockReturnValue( of( @@ -43,7 +44,7 @@ describe("FeedInfoService", () => { providers: [ FeedInfoService, FeedUIInfoService, - { provide: ActivatedRoute, useValue: {} }, + { provide: ActivatedRoute, useValue: { queryParams, data: of() } }, { provide: FetchFeedUseCase, useValue: fetch }, { provide: ReadFeedNewsUseCase, useValue: {} }, { provide: ToggleFeedLikeUseCase, useValue: toggle }, @@ -62,9 +63,10 @@ describe("FeedInfoService", () => { target.getBoundingClientRect = () => ({ bottom: 600 }) as DOMRect; root.getBoundingClientRect = () => ({ bottom: 700 }) as DOMRect; const service = TestBed.inject(FeedInfoService); - service.initScroll(target, new ElementRef(root)); + const feedRoot = new ElementRef(root); + service.initScroll(target, feedRoot); - return { ui, fetch, toggle, service, target }; + return { ui, fetch, toggle, service, target, feedRoot, queryParams }; } it("догружает шесть элементов в конец в исходном порядке", () => { @@ -73,7 +75,7 @@ describe("FeedInfoService", () => { target.dispatchEvent(new Event("scroll")); expect(fetch.execute).toHaveBeenCalledOnce(); - expect(fetch.execute.mock.calls[0].slice(0, 2)).toEqual([6, 6]); + expect(fetch.execute).toHaveBeenCalledWith(6, 6, "all_news"); expect(ui.feedItems().map(entry => entry.content.id)).toEqual([ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, ]); @@ -91,6 +93,22 @@ describe("FeedInfoService", () => { expect(ui.feedPage()).toBe(12); }); + it("отменяет догрузку старой вкладки и запрашивает первую страницу новой", () => { + const { ui, fetch, target, service, feedRoot, queryParams } = setup([21], "new_project"); + const oldPage = new Subject(); + fetch.execute.mockReturnValueOnce(oldPage); + service.initializationFeedNews(feedRoot); + target.dispatchEvent(new Event("scroll")); + expect(fetch.execute).toHaveBeenNthCalledWith(1, 6, 6, "new_project"); + + queryParams.next({ includes: "project" }); + expect(fetch.execute).toHaveBeenNthCalledWith(2, 0, 6, "project_news"); + + oldPage.next(ok({ count: 12, results: [item(7)] })); + expect(ui.feedItems().map(entry => entry.content.id)).toEqual([21]); + expect(ui.feedPage()).toBe(1); + }); + it("обрабатывает лайк новости при совпадении ее id с id проекта", () => { const { ui, toggle, service } = setup([]); const news = { diff --git a/projects/social_platform/src/app/api/feed/facades/feed-info.service.ts b/projects/social_platform/src/app/api/feed/facades/feed-info.service.ts index 42c409c9a..563282b86 100644 --- a/projects/social_platform/src/app/api/feed/facades/feed-info.service.ts +++ b/projects/social_platform/src/app/api/feed/facades/feed-info.service.ts @@ -1,9 +1,10 @@ /** @format */ -import { DestroyRef, ElementRef, inject, Injectable, signal } from "@angular/core"; +import { DestroyRef, ElementRef, inject, Injectable } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { concatMap, + distinctUntilChanged, EMPTY, fromEvent, map, @@ -13,17 +14,15 @@ import { tap, throttleTime, } from "rxjs"; -import { FeedItem, FeedItemType, FeedPage } from "@domain/feed/feed-item.model"; +import { FeedItem, FeedPage } from "@domain/feed/feed-item.model"; import { FeedUIInfoService } from "./ui/feed-ui-info.service"; import { FetchFeedUseCase } from "../use-cases/fetch-feed.use-case"; import { ReadFeedNewsUseCase } from "../use-cases/read-feed-news.use-case"; import { ToggleFeedLikeUseCase } from "../use-cases/toggle-feed-like.use-case"; import { isSuccess, loading, success } from "@domain/shared/async-state"; -import { FILTER_SPLIT_SYMBOL } from "@core/consts/other/filter-split-symbol.const"; +import { getFeedFilter } from "@core/consts/filters/feed-filter.const"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -const DEFAULT_FEED_TYPES: FeedItemType[] = ["vacancy", "project", "news"]; - /** Координирует глобальную ленту: данные резолвера, фильтры, пагинация, просмотры, лайки. */ @Injectable() export class FeedInfoService { @@ -40,7 +39,10 @@ export class FeedInfoService { readonly feedItems = this.feedUIInfoService.feedItems; - private readonly includes = signal([]); + private readonly feedType$ = this.route.queryParams.pipe( + map(params => getFeedFilter(params["includes"]).apiType), + distinctUntilChanged(), + ); initializationFeedNews(feedRoot: ElementRef): void { this.route.data @@ -61,25 +63,17 @@ export class FeedInfoService { queueMicrotask(() => this.observeFeedItems()); }); - this.route.queryParams + this.feedType$ .pipe( - map(params => params["includes"]), - tap(includes => { - this.includes.set(includes); - }), skip(1), - switchMap(includes => { + switchMap(type => { this.feedUIInfoService.totalItemsCount.set(0); this.feedUIInfoService.feedPage.set(0); const prev = this.feedUIInfoService.feedItems(); this.feedUIInfoService.feedItems$.set(loading(prev)); - return this.onFetch( - 0, - this.feedUIInfoService.perFetchTake(), - includes ?? DEFAULT_FEED_TYPES, - ); + return this.onFetch(0, this.feedUIInfoService.perFetchTake(), type); }), takeUntilDestroyed(this.destroyRef), ) @@ -89,12 +83,16 @@ export class FeedInfoService { queueMicrotask(() => this.observeFeedItems()); setTimeout(() => { - feedRoot?.nativeElement.children[0].scrollIntoView({ behavior: "smooth" }); + feedRoot.nativeElement.children[0]?.scrollIntoView({ behavior: "smooth" }); }); }); } - private onScroll(target: HTMLElement, feedRoot: ElementRef): Observable { + private onScroll( + target: HTMLElement, + feedRoot: ElementRef, + type: string, + ): Observable { if ( !this.feedUIInfoService.totalItemsCount() || this.feedUIInfoService.feedPage() >= this.feedUIInfoService.totalItemsCount() @@ -109,11 +107,7 @@ export class FeedInfoService { if (diff < 400) { const currentOffset = this.feedUIInfoService.feedPage(); - return this.onFetch( - currentOffset, - this.feedUIInfoService.perFetchTake(), - this.includes(), - ).pipe( + return this.onFetch(currentOffset, this.feedUIInfoService.perFetchTake(), type).pipe( tap((feedChunk: FeedItem[]) => { this.feedUIInfoService.feedPage.set(currentOffset + feedChunk.length); const keyOf = (item: FeedItem) => `${item.typeModel}:${item.content.id}`; @@ -137,28 +131,21 @@ export class FeedInfoService { initScroll(target: HTMLElement, feedRoot: ElementRef): void { if (target) { - fromEvent(target, "scroll") + this.feedType$ .pipe( - throttleTime(100), - concatMap(() => this.onScroll(target, feedRoot)), + switchMap(type => + fromEvent(target, "scroll").pipe( + throttleTime(100), + concatMap(() => this.onScroll(target, feedRoot, type)), + ), + ), takeUntilDestroyed(this.destroyRef), ) .subscribe(); } } - private onFetch( - offset: number, - limit: number, - includes: FeedItemType[] | string = DEFAULT_FEED_TYPES, - ) { - const type = - typeof includes === "string" - ? includes || DEFAULT_FEED_TYPES.join(FILTER_SPLIT_SYMBOL) - : includes.length === 0 - ? DEFAULT_FEED_TYPES.join(FILTER_SPLIT_SYMBOL) - : includes.join(FILTER_SPLIT_SYMBOL); - + private onFetch(offset: number, limit: number, type: string) { return this.fetchFeedUseCase.execute(offset, limit, type).pipe( tap(result => { this.feedUIInfoService.totalItemsCount.set(result.ok ? result.value.count : 0); diff --git a/projects/social_platform/src/app/api/feed/facades/ui/feed-ui-info.service.spec.ts b/projects/social_platform/src/app/api/feed/facades/ui/feed-ui-info.service.spec.ts index 01c986c60..570f90078 100644 --- a/projects/social_platform/src/app/api/feed/facades/ui/feed-ui-info.service.spec.ts +++ b/projects/social_platform/src/app/api/feed/facades/ui/feed-ui-info.service.spec.ts @@ -10,7 +10,14 @@ describe("FeedUIInfoService: единый input карточки и окна", ( typeModel: "news", content: { ...FeedNews.default(), id, isUserLiked: false, likesCount: 0 }, })); - const counts = { all: 86, project: 56, vacancy: 3, news: 27, partnerprogram: 0, education: 0 }; + const counts = { + allNews: 86, + projectNews: 59, + newProject: 56, + vacancy: 3, + news: 27, + partnerprogram: 0, + }; ui.applyInitializationFeedNewsEvent({ count: 86, results, counts }); ui.applyLikeNews(1); expect(ui.feedItems().map(item => item.content.id)).toEqual([1, 2, 3, 4, 5, 6]); diff --git a/projects/social_platform/src/app/api/feed/facades/ui/feed-ui-info.service.ts b/projects/social_platform/src/app/api/feed/facades/ui/feed-ui-info.service.ts index be49b9188..4947a30d1 100644 --- a/projects/social_platform/src/app/api/feed/facades/ui/feed-ui-info.service.ts +++ b/projects/social_platform/src/app/api/feed/facades/ui/feed-ui-info.service.ts @@ -18,12 +18,12 @@ export class FeedUIInfoService { readonly totalItemsCount = signal(0); readonly categoryCounts = signal({ - all: 0, - project: 0, + allNews: 0, + projectNews: 0, + newProject: 0, vacancy: 0, news: 0, partnerprogram: 0, - education: 0, }); readonly feedPage = signal(0); readonly perFetchTake = signal(6); diff --git a/projects/social_platform/src/app/domain/feed/feed-item.model.ts b/projects/social_platform/src/app/domain/feed/feed-item.model.ts index 1a16e689a..5d7cdedaf 100644 --- a/projects/social_platform/src/app/domain/feed/feed-item.model.ts +++ b/projects/social_platform/src/app/domain/feed/feed-item.model.ts @@ -34,12 +34,12 @@ export type FeedItem = /** Счётчики всех доступных категорий, независимо от выбранной страницы. */ export interface FeedCategoryCounts { - all: number; - project: number; + allNews: number; + projectNews: number; + newProject: number; vacancy: number; news: number; partnerprogram: number; - education: number; } /** Страница ленты с метаданными категорий. */ diff --git a/projects/social_platform/src/app/ui/pages/feed/feed.resolver.ts b/projects/social_platform/src/app/ui/pages/feed/feed.resolver.ts index bd624010a..482f2ecd8 100644 --- a/projects/social_platform/src/app/ui/pages/feed/feed.resolver.ts +++ b/projects/social_platform/src/app/ui/pages/feed/feed.resolver.ts @@ -3,30 +3,25 @@ import { inject } from "@angular/core"; import { ResolveFn } from "@angular/router"; import { map } from "rxjs"; -import { FeedItemType, FeedPage } from "@domain/feed/feed-item.model"; +import { FeedPage } from "@domain/feed/feed-item.model"; import { FetchFeedUseCase } from "@api/feed/use-cases/fetch-feed.use-case"; -import { FILTER_SPLIT_SYMBOL } from "@core/consts/other/filter-split-symbol.const"; - -const DEFAULT_FEED_TYPES: FeedItemType[] = ["vacancy", "news", "project"]; +import { getFeedFilter } from "@core/consts/filters/feed-filter.const"; /** Предзагружает ленту новостей. */ export const FeedResolver: ResolveFn = route => { const fetchFeedUseCase = inject(FetchFeedUseCase); // Загружаем первую страницу ленты тремя рядами по две карточки. - // По умолчанию включаем вакансии, новости и проекты - return fetchFeedUseCase - .execute(0, 6, route.queryParams["includes"] ?? DEFAULT_FEED_TYPES.join(FILTER_SPLIT_SYMBOL)) - .pipe( - map(result => - result.ok - ? result.value - : { - count: 0, - results: [], - next: "", - previous: "", - }, - ), - ); + return fetchFeedUseCase.execute(0, 6, getFeedFilter(route.queryParams["includes"]).apiType).pipe( + map(result => + result.ok + ? result.value + : { + count: 0, + results: [], + next: "", + previous: "", + }, + ), + ); }; diff --git a/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.html b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.html index e00165f08..b73be9640 100644 --- a/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.html +++ b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.html @@ -32,25 +32,21 @@
- @for (filterItem of feedFilterOptions; track $index) { + @for (filterItem of feedFilterOptions; track filterItem.value) { + @let count = categoryCounts()[filterItem.countKey] ?? 0; } @@ -63,26 +59,22 @@
- @for (filterItem of feedFilterOptions; track $index) { + @for (filterItem of feedFilterOptions; track filterItem.value) { + @let count = categoryCounts()[filterItem.countKey] ?? 0; } diff --git a/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.spec.ts b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.spec.ts index eecc17119..924d73a5a 100644 --- a/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.spec.ts +++ b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.spec.ts @@ -25,24 +25,33 @@ describe("FeedComponent", () => { expect(component).toBeTruthy(); }); - it("показывает счётчики всех шести категорий независимо от фильтра", () => { + it("показывает шесть категорий в новом порядке с отдельными счетчиками", () => { TestBed.inject(FeedUIInfoService).categoryCounts.set({ - all: 12, - project: 5, + allNews: 6, + projectNews: 2, + newProject: 5, vacancy: 3, news: 4, partnerprogram: 0, - education: 0, }); fixture.detectChanges(); const options = Array.from( fixture.nativeElement.querySelectorAll(".desktop .filter__option"), ) as HTMLElement[]; - expect(options).toHaveLength(6); + expect( + options.map(option => option.querySelector(".filter__title")?.textContent?.trim()), + ).toEqual([ + "все новости", + "новости проектов", + "новости сообщества", + "свежие вакансии", + "новые проекты", + "новости программ", + ]); expect( options.map(option => option.querySelector(".filter__count")?.textContent?.trim()), - ).toEqual(["12", "5", "3", "4", "0", "0"]); + ).toEqual(["6", "2", "4", "3", "5", "0"]); for (const option of options) { expect(option.tagName).toBe("BUTTON"); expect(Array.from(option.children).map(child => child.className)).toEqual([ @@ -54,10 +63,10 @@ describe("FeedComponent", () => { "true", ); } - expect((options[5] as HTMLButtonElement).disabled).toBe(true); + expect((options[4] as HTMLButtonElement).disabled).toBe(false); expect(options[0].getAttribute("aria-pressed")).toBe("true"); const setFilter = vi.spyOn(component, "setFilter"); - options[1].click(); - expect(setFilter).toHaveBeenCalledExactlyOnceWith("project"); + options[4].click(); + expect(setFilter).toHaveBeenCalledExactlyOnceWith("new_project"); }); }); diff --git a/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.ts b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.ts index b4f42f338..b811632b3 100644 --- a/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.ts +++ b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.ts @@ -11,7 +11,6 @@ import { FeedFilterInfoService } from "./service/feed-filter-info.service"; import { DetailProfileInfoService } from "../detail/services/profile/detail-profile-info.service"; import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; import { FeedUIInfoService } from "@api/feed/facades/ui/feed-ui-info.service"; -import { FeedCategoryCounts } from "@domain/feed/feed-item.model"; /** Компонент фильтрации ленты по типам контента с мгновенной синхронизацией через URL. */ @Component({ @@ -45,10 +44,6 @@ export class FeedFilterComponent implements OnInit { protected readonly feedFilterOptions = feedFilter; protected readonly categoryCounts = this.feedUIInfoService.categoryCounts; - protected categoryCount(value: string): number { - return this.categoryCounts()[(value || "all") as keyof FeedCategoryCounts] ?? 0; - } - ngOnInit() { this.feedFilterInfoService.initializationFeedFilter(); } diff --git a/projects/social_platform/src/app/ui/widgets/feed-filter/service/feed-filter-info.service.ts b/projects/social_platform/src/app/ui/widgets/feed-filter/service/feed-filter-info.service.ts index 1fe2d98f2..e2f145342 100644 --- a/projects/social_platform/src/app/ui/widgets/feed-filter/service/feed-filter-info.service.ts +++ b/projects/social_platform/src/app/ui/widgets/feed-filter/service/feed-filter-info.service.ts @@ -4,6 +4,7 @@ import { DestroyRef, inject, Injectable, signal } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { LoggerService } from "@core/lib/services/logger/logger.service"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { getFeedFilter } from "@core/consts/filters/feed-filter.const"; @Injectable() export class FeedFilterInfoService { @@ -15,57 +16,25 @@ export class FeedFilterInfoService { // Состояние выпадающего меню фильтров readonly filterOpen = signal(false); - // Массив активных фильтров + // Активная категория ленты readonly includedFilters = signal(""); initializationFeedFilter(): void { this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(queries => { - if (queries["includes"]) { - this.includedFilters.set(queries["includes"]); - } else { - this.includedFilters.set(""); - } + this.includedFilters.set(getFeedFilter(queries["includes"]).value); }); } - /** Переключает фильтр (добавляет/удаляет) с мгновенным обновлением URL. */ + /** Выбирает категорию и обновляет URL. */ setFilter(keyword: string): void { - this.includedFilters.update(included => { - if (keyword.startsWith("project/")) { - // Если уже активен этот же вложенный фильтр - сбрасываем к "projects" - if (included === keyword) { - return "project"; - } - return keyword; - } - - // Если кликнули на "projects" - if (keyword === "project") { - if (included.startsWith("project/")) { - return "project"; - } - - if (included === "project") { - return ""; - } - - return "project"; - } - - if (included === keyword) { - return ""; - } - return keyword; - }); - - // Мгновенно обновляем URL + this.includedFilters.set(keyword); + this.filterOpen.set(false); this.updateUrl(); } /** Сбрасывает все фильтры. */ resetFilter(): void { - this.includedFilters.set(""); - this.updateUrl(); + this.setFilter(""); } /** Закрывает выпадающее меню (ClickOutside). */