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
40 changes: 25 additions & 15 deletions projects/core/src/consts/filters/feed-filter.const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(
Expand All @@ -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 },
Expand All @@ -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("догружает шесть элементов в конец в исходном порядке", () => {
Expand All @@ -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,
]);
Expand All @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 {
Expand All @@ -40,7 +39,10 @@ export class FeedInfoService {

readonly feedItems = this.feedUIInfoService.feedItems;

private readonly includes = signal<FeedItemType[] | string>([]);
private readonly feedType$ = this.route.queryParams.pipe(
map(params => getFeedFilter(params["includes"]).apiType),
distinctUntilChanged(),
);

initializationFeedNews(feedRoot: ElementRef<HTMLElement>): void {
this.route.data
Expand All @@ -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),
)
Expand All @@ -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<HTMLElement>): Observable<FeedItem[]> {
private onScroll(
target: HTMLElement,
feedRoot: ElementRef<HTMLElement>,
type: string,
): Observable<FeedItem[]> {
if (
!this.feedUIInfoService.totalItemsCount() ||
this.feedUIInfoService.feedPage() >= this.feedUIInfoService.totalItemsCount()
Expand All @@ -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}`;
Expand All @@ -137,28 +131,21 @@ export class FeedInfoService {

initScroll(target: HTMLElement, feedRoot: ElementRef<HTMLElement>): 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ export class FeedUIInfoService {

readonly totalItemsCount = signal(0);
readonly categoryCounts = signal<FeedCategoryCounts>({
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/** Страница ленты с метаданными категорий. */
Expand Down
33 changes: 14 additions & 19 deletions projects/social_platform/src/app/ui/pages/feed/feed.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FeedPage> = 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: "",
},
),
);
};
Loading