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
6 changes: 6 additions & 0 deletions projects/core/src/consts/filters/feed-filter.const.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
/** @format */

export const feedFilter = [
{
id: 0,
name: "все новости",
value: "",
icon: "feed",
},
{
id: 1,
name: "новости проектов",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/** @format */

import { ElementRef } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { ActivatedRoute } from "@angular/router";
import { of } 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";
import { ReadFeedNewsUseCase } from "../use-cases/read-feed-news.use-case";
import { ToggleFeedLikeUseCase } from "../use-cases/toggle-feed-like.use-case";
import { FeedItem } from "@domain/feed/feed-item.model";
import { ok } from "@domain/shared/result.type";

describe("FeedInfoService", () => {
const item = (id: number): FeedItem => ({
typeModel: "project",
content: {
id,
name: `Проект ${id}`,
shortDescription: "",
industry: 1,
imageAddress: "",
viewsCount: 0,
leader: 1,
},
});

function setup(nextIds: number[]) {
const fetch = { execute: vi.fn() };
const toggle = { execute: vi.fn().mockReturnValue(of(ok(undefined))) };
fetch.execute.mockReturnValue(
of(
ok({
count: 12,
results: nextIds.map(item),
next: "",
previous: "",
}),
),
);
TestBed.configureTestingModule({
providers: [
FeedInfoService,
FeedUIInfoService,
{ provide: ActivatedRoute, useValue: {} },
{ provide: FetchFeedUseCase, useValue: fetch },
{ provide: ReadFeedNewsUseCase, useValue: {} },
{ provide: ToggleFeedLikeUseCase, useValue: toggle },
],
});
const ui = TestBed.inject(FeedUIInfoService);
ui.applyInitializationFeedNewsEvent({
count: 12,
results: [1, 2, 3, 4, 5, 6].map(item),
next: "",
previous: "",
});

const target = document.createElement("div");
const root = document.createElement("div");
target.getBoundingClientRect = () => ({ bottom: 600 }) as DOMRect;
root.getBoundingClientRect = () => ({ bottom: 700 }) as DOMRect;
const service = TestBed.inject(FeedInfoService);
service.initScroll(target, new ElementRef(root));

return { ui, fetch, toggle, service, target };
}

it("догружает шесть элементов в конец в исходном порядке", () => {
const { ui, fetch, target } = setup([7, 8, 9, 10, 11, 12]);

target.dispatchEvent(new Event("scroll"));

expect(fetch.execute).toHaveBeenCalledOnce();
expect(fetch.execute.mock.calls[0].slice(0, 2)).toEqual([6, 6]);
expect(ui.feedItems().map(entry => entry.content.id)).toEqual([
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
]);
expect(ui.feedPage()).toBe(12);
});

it("не переставляет старые карточки при дубликате на границе страниц", () => {
const { ui, target } = setup([6, 7, 8, 9, 10, 11]);

target.dispatchEvent(new Event("scroll"));

expect(ui.feedItems().map(entry => entry.content.id)).toEqual([
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
]);
expect(ui.feedPage()).toBe(12);
});

it("обрабатывает лайк новости при совпадении её id с id проекта", () => {
const { ui, toggle, service } = setup([]);
const news = {
typeModel: "news",
content: {
id: 1,
contentObject: { id: 42, email: "user@example.com" },
isUserLiked: false,
likesCount: 0,
},
} as FeedItem;
ui.applyInitializationFeedNewsEvent({
count: 2,
results: [item(1), news],
next: "",
previous: "",
});

service.onLike(1);

expect(toggle.execute).toHaveBeenCalledOnce();
expect(ui.feedItems()[0].typeModel).toBe("project");
const likedItem = ui.feedItems()[1];
expect(likedItem.typeModel).toBe("news");
if (likedItem.typeModel === "news") {
expect(likedItem.content.isUserLiked).toBe(true);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ import {
tap,
throttleTime,
} from "rxjs";
import { FeedItem, FeedItemType } from "@domain/feed/feed-item.model";
import { ApiPagination } from "@domain/other/api-pagination.model";
import { FeedItem, FeedItemType, 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";
Expand Down Expand Up @@ -49,7 +48,7 @@ export class FeedInfoService {
map(r => r["data"]),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((feed: ApiPagination<FeedItem>) => {
.subscribe((feed: FeedPage) => {
this.feedUIInfoService.applyInitializationFeedNewsEvent(feed);

this.observer?.disconnect();
Expand Down Expand Up @@ -97,31 +96,31 @@ export class FeedInfoService {

private onScroll(target: HTMLElement, feedRoot: ElementRef<HTMLElement>): Observable<FeedItem[]> {
if (
this.feedUIInfoService.totalItemsCount() &&
this.feedItems().length >= this.feedUIInfoService.totalItemsCount()
!this.feedUIInfoService.totalItemsCount() ||
this.feedUIInfoService.feedPage() >= this.feedUIInfoService.totalItemsCount()
)
return EMPTY;

if (!target || !feedRoot) return EMPTY;

const diff =
target.scrollTop - feedRoot.nativeElement.getBoundingClientRect().height + window.innerHeight;
feedRoot.nativeElement.getBoundingClientRect().bottom - target.getBoundingClientRect().bottom;

if (diff > 0) {
const currentOffset = this.feedItems().length;
if (diff < 400) {
const currentOffset = this.feedUIInfoService.feedPage();

return this.onFetch(
currentOffset,
this.feedUIInfoService.perFetchTake(),
this.includes(),
).pipe(
tap((feedChunk: FeedItem[]) => {
this.feedUIInfoService.feedPage.set(currentOffset + feedChunk.length);
const keyOf = (item: FeedItem) => `${item.typeModel}:${item.content.id}`;
const existingIds = new Set(this.feedItems().map(keyOf));
const uniqueNewItems = feedChunk.filter(item => !existingIds.has(keyOf(item)));

if (uniqueNewItems.length > 0) {
this.feedUIInfoService.feedPage.update(page => page + uniqueNewItems.length);
this.feedUIInfoService.feedItems$.update(state =>
isSuccess(state)
? success([...state.data, ...uniqueNewItems])
Expand Down Expand Up @@ -163,17 +162,19 @@ export class FeedInfoService {
return this.fetchFeedUseCase.execute(offset, limit, type).pipe(
tap(result => {
this.feedUIInfoService.totalItemsCount.set(result.ok ? result.value.count : 0);
if (result.ok && result.value.counts) {
this.feedUIInfoService.categoryCounts.set(result.value.counts);
}
}),
map(result => (result.ok ? result.value.results : [])),
);
}

private onFeedItemView(entries: IntersectionObserverEntry[]): void {
const items = entries
.map(e => {
return Number((e.target as HTMLElement).dataset["id"]);
})
.map(id => this.feedItems().find(item => item.content.id === id))
.filter(entry => entry.isIntersecting)
.map(e => (e.target as HTMLElement).dataset["key"])
.map(key => this.feedItems().find(item => `${item.typeModel}:${item.content.id}` === key))
.filter(Boolean) as FeedItem[];

const projectNews = items.filter(
Expand Down Expand Up @@ -203,7 +204,9 @@ export class FeedInfoService {
}

onLike(newsId: number) {
const itemIdx = this.feedItems().findIndex(n => n.content.id === newsId);
const itemIdx = this.feedItems().findIndex(
n => n.typeModel === "news" && n.content.id === newsId,
);

const item = this.feedItems()[itemIdx];
if (!item || item.typeModel !== "news") return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/** @format */

import { computed, Injectable, signal } from "@angular/core";
import { ApiPagination } from "@domain/other/api-pagination.model";
import { FeedItem } from "@domain/feed/feed-item.model";
import { FeedCategoryCounts, FeedItem, FeedPage } from "@domain/feed/feed-item.model";
import { AsyncState, initial, isLoading, isSuccess, success } from "@domain/shared/async-state";

/** UI-проекция ленты: computed-сигналы страницы `FeedItem`. */
Expand All @@ -18,13 +17,22 @@ export class FeedUIInfoService {
});

readonly totalItemsCount = signal(0);
readonly categoryCounts = signal<FeedCategoryCounts>({
all: 0,
project: 0,
vacancy: 0,
news: 0,
partnerprogram: 0,
education: 0,
});
readonly feedPage = signal(0);
readonly perFetchTake = signal(20);
readonly perFetchTake = signal(6);

applyInitializationFeedNewsEvent(feed: ApiPagination<FeedItem>): void {
applyInitializationFeedNewsEvent(feed: FeedPage): void {
this.feedItems$.set(success(feed.results));
this.totalItemsCount.set(feed.count);
this.feedPage.set(feed.results.length);
if (feed.counts) this.categoryCounts.set(feed.counts);
}

applyFeedFilters(feed: FeedItem[]): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
import { inject, Injectable } from "@angular/core";
import { catchError, map, Observable, of } from "rxjs";
import { FeedRepositoryPort } from "@domain/feed/ports/feed.repository.port";
import { FeedItem } from "@domain/feed/feed-item.model";
import { ApiPagination } from "@domain/other/api-pagination.model";
import { FeedPage } from "@domain/feed/feed-item.model";
import { fail, ok, Result } from "@domain/shared/result.type";

/** Сценарий: страница ленты (offset/limit/type); ошибка → `fetch_feed_error`. */
Expand All @@ -16,9 +15,9 @@ export class FetchFeedUseCase {
offset: number,
limit: number,
type: string,
): Observable<Result<ApiPagination<FeedItem>, { kind: "fetch_feed_error"; cause?: unknown }>> {
): Observable<Result<FeedPage, { kind: "fetch_feed_error"; cause?: unknown }>> {
return this.feedRepositoryPort.fetchFeed(offset, limit, type).pipe(
map(feed => ok<ApiPagination<FeedItem>>(feed)),
map(feed => ok<FeedPage>(feed)),
catchError(error => of(fail({ kind: "fetch_feed_error" as const, cause: error }))),
);
}
Expand Down
26 changes: 23 additions & 3 deletions projects/social_platform/src/app/domain/feed/feed-item.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { FeedNews } from "../news/project-news.model";
import { Vacancy } from "../vacancy/vacancy.model";
import { ApiPagination } from "../other/api-pagination.model";

/** Модели данных для элементов ленты */
/** Интерфейс проекта в ленте */
Expand All @@ -20,9 +21,28 @@ export type FeedItemType = "vacancy" | "news" | "project";

/** Объединенный тип элемента ленты */
export type FeedItem =
| ({ typeModel: FeedItemType } & {
| ({ typeModel: FeedItemType; publishedAt?: string } & {
typeModel: "project";
content: FeedProject;
})
| { typeModel: "vacancy"; content: Vacancy }
| { typeModel: "news"; content: FeedNews & { contentObject: { id: number } } };
| { typeModel: "vacancy"; content: Vacancy; publishedAt?: string }
| {
typeModel: "news";
content: FeedNews & { contentObject: { id: number; email?: string; industry?: number } };
publishedAt?: string;
};

/** Счётчики всех доступных категорий, независимо от выбранной страницы. */
export interface FeedCategoryCounts {
all: number;
project: number;
vacancy: number;
news: number;
partnerprogram: number;
education: number;
}

/** Страница ленты с метаданными категорий. */
export interface FeedPage extends ApiPagination<FeedItem> {
counts?: FeedCategoryCounts;
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
/** @format */

import { Observable } from "rxjs";
import { ApiPagination } from "../../other/api-pagination.model";
import { FeedItem } from "../feed-item.model";
import { FeedPage } from "../feed-item.model";

/** Порт репозитория ленты: страница `FeedItem` (offset/limit/type). */
export abstract class FeedRepositoryPort {
abstract fetchFeed(
offset: number,
limit: number,
type: string,
): Observable<ApiPagination<FeedItem>>;
abstract fetchFeed(offset: number, limit: number, type: string): Observable<FeedPage>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import { inject, Injectable } from "@angular/core";
import { ApiService } from "@corelib";
import { Observable } from "rxjs";
import { HttpParams } from "@angular/common/http";
import { ApiPagination } from "@domain/other/api-pagination.model";
import { FeedItem } from "@domain/feed/feed-item.model";
import { FeedPage } from "@domain/feed/feed-item.model";

/** HTTP-адаптер ленты: `/feed` (offset/limit/type). */
@Injectable({ providedIn: "root" })
Expand All @@ -14,8 +13,8 @@ export class FeedHttpAdapter {

private readonly apiService = inject(ApiService);

fetchFeed(offset: number, limit: number, type: string): Observable<ApiPagination<FeedItem>> {
return this.apiService.get<ApiPagination<FeedItem>>(
fetchFeed(offset: number, limit: number, type: string): Observable<FeedPage> {
return this.apiService.get<FeedPage>(
`${this.FEED_URL}/`,
new HttpParams({ fromObject: { limit, offset, type } }),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

import { inject, Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { ApiPagination } from "@domain/other/api-pagination.model";
import { FeedItem } from "@domain/feed/feed-item.model";
import { FeedPage } from "@domain/feed/feed-item.model";
import { FeedRepositoryPort } from "@domain/feed/ports/feed.repository.port";
import { FeedHttpAdapter } from "../../adapters/feed/feed-http.adapter";

Expand All @@ -12,7 +11,7 @@ import { FeedHttpAdapter } from "../../adapters/feed/feed-http.adapter";
export class FeedRepository implements FeedRepositoryPort {
private readonly feedAdapter = inject(FeedHttpAdapter);

fetchFeed(offset: number, limit: number, type: string): Observable<ApiPagination<FeedItem>> {
fetchFeed(offset: number, limit: number, type: string): Observable<FeedPage> {
return this.feedAdapter.fetchFeed(offset, limit, type);
}
}
Loading