diff --git a/projects/core/src/consts/filters/feed-filter.const.ts b/projects/core/src/consts/filters/feed-filter.const.ts index f2d8085c9..6d5a5279e 100644 --- a/projects/core/src/consts/filters/feed-filter.const.ts +++ b/projects/core/src/consts/filters/feed-filter.const.ts @@ -1,6 +1,12 @@ /** @format */ export const feedFilter = [ + { + id: 0, + name: "все новости", + value: "", + icon: "feed", + }, { id: 1, name: "новости проектов", 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 new file mode 100644 index 000000000..7020b7f94 --- /dev/null +++ b/projects/social_platform/src/app/api/feed/facades/feed-info.service.spec.ts @@ -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); + } + }); +}); 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 8727db7d2..42c409c9a 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 @@ -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"; @@ -49,7 +48,7 @@ export class FeedInfoService { map(r => r["data"]), takeUntilDestroyed(this.destroyRef), ) - .subscribe((feed: ApiPagination) => { + .subscribe((feed: FeedPage) => { this.feedUIInfoService.applyInitializationFeedNewsEvent(feed); this.observer?.disconnect(); @@ -97,18 +96,18 @@ export class FeedInfoService { private onScroll(target: HTMLElement, feedRoot: ElementRef): Observable { 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, @@ -116,12 +115,12 @@ export class FeedInfoService { 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]) @@ -163,6 +162,9 @@ 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 : [])), ); @@ -170,10 +172,9 @@ export class FeedInfoService { 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( @@ -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; 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 e67ea92d7..be49b9188 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 @@ -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`. */ @@ -18,13 +17,22 @@ export class FeedUIInfoService { }); readonly totalItemsCount = signal(0); + readonly categoryCounts = signal({ + 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): 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 { diff --git a/projects/social_platform/src/app/api/feed/use-cases/fetch-feed.use-case.ts b/projects/social_platform/src/app/api/feed/use-cases/fetch-feed.use-case.ts index 235d1636b..857c85b98 100644 --- a/projects/social_platform/src/app/api/feed/use-cases/fetch-feed.use-case.ts +++ b/projects/social_platform/src/app/api/feed/use-cases/fetch-feed.use-case.ts @@ -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`. */ @@ -16,9 +15,9 @@ export class FetchFeedUseCase { offset: number, limit: number, type: string, - ): Observable, { kind: "fetch_feed_error"; cause?: unknown }>> { + ): Observable> { return this.feedRepositoryPort.fetchFeed(offset, limit, type).pipe( - map(feed => ok>(feed)), + map(feed => ok(feed)), catchError(error => of(fail({ kind: "fetch_feed_error" as const, cause: error }))), ); } 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 f2f4d1ce8..1a16e689a 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 @@ -2,6 +2,7 @@ import { FeedNews } from "../news/project-news.model"; import { Vacancy } from "../vacancy/vacancy.model"; +import { ApiPagination } from "../other/api-pagination.model"; /** Модели данных для элементов ленты */ /** Интерфейс проекта в ленте */ @@ -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 { + counts?: FeedCategoryCounts; +} diff --git a/projects/social_platform/src/app/domain/feed/ports/feed.repository.port.ts b/projects/social_platform/src/app/domain/feed/ports/feed.repository.port.ts index 923ef544a..55673038f 100644 --- a/projects/social_platform/src/app/domain/feed/ports/feed.repository.port.ts +++ b/projects/social_platform/src/app/domain/feed/ports/feed.repository.port.ts @@ -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>; + abstract fetchFeed(offset: number, limit: number, type: string): Observable; } diff --git a/projects/social_platform/src/app/infrastructure/adapters/feed/feed-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/feed/feed-http.adapter.ts index 72d8694aa..2b67ebcd2 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/feed/feed-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/feed/feed-http.adapter.ts @@ -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" }) @@ -14,8 +13,8 @@ export class FeedHttpAdapter { private readonly apiService = inject(ApiService); - fetchFeed(offset: number, limit: number, type: string): Observable> { - return this.apiService.get>( + fetchFeed(offset: number, limit: number, type: string): Observable { + return this.apiService.get( `${this.FEED_URL}/`, new HttpParams({ fromObject: { limit, offset, type } }), ); diff --git a/projects/social_platform/src/app/infrastructure/repository/feed/feed.repository.ts b/projects/social_platform/src/app/infrastructure/repository/feed/feed.repository.ts index cd5468ae8..552ca354c 100644 --- a/projects/social_platform/src/app/infrastructure/repository/feed/feed.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/feed/feed.repository.ts @@ -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"; @@ -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> { + fetchFeed(offset: number, limit: number, type: string): Observable { return this.feedAdapter.fetchFeed(offset, limit, type); } } diff --git a/projects/social_platform/src/app/ui/pages/feed/feed.component.html b/projects/social_platform/src/app/ui/pages/feed/feed.component.html index bf435a607..d8558d3a6 100644 --- a/projects/social_platform/src/app/ui/pages/feed/feed.component.html +++ b/projects/social_platform/src/app/ui/pages/feed/feed.component.html @@ -2,28 +2,33 @@
-
+
@if (feedItems().length > 0) { - @for (item of feedItems(); track item.content.id) { + @for (item of feedItems(); track item.typeModel + ":" + item.content.id) { @if (item.typeModel === "vacancy") { } @else if (item.typeModel === "project") { } @else if (item.typeModel === "news") { } diff --git a/projects/social_platform/src/app/ui/pages/feed/feed.component.scss b/projects/social_platform/src/app/ui/pages/feed/feed.component.scss index 7ae1bc2fb..0fb97a489 100644 --- a/projects/social_platform/src/app/ui/pages/feed/feed.component.scss +++ b/projects/social_platform/src/app/ui/pages/feed/feed.component.scss @@ -7,12 +7,19 @@ padding-top: 40px; &__feed { - column-count: 2; - column-gap: 20px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; + align-items: start; + + @media (max-width: 800px) { + grid-template-columns: minmax(0, 1fr); + } &--no-items { display: flex; flex-direction: column; + grid-column: 1 / -1; gap: 20px; align-items: center; justify-content: center; @@ -27,8 +34,6 @@ &__item { display: block; - width: 100%; - margin-bottom: 20px; - break-inside: avoid; + min-width: 0; } } diff --git a/projects/social_platform/src/app/ui/pages/feed/feed.component.ts b/projects/social_platform/src/app/ui/pages/feed/feed.component.ts index 764ac0709..f1ba9d926 100644 --- a/projects/social_platform/src/app/ui/pages/feed/feed.component.ts +++ b/projects/social_platform/src/app/ui/pages/feed/feed.component.ts @@ -4,13 +4,10 @@ import { AfterViewInit, ChangeDetectionStrategy, Component, - computed, ElementRef, inject, OnDestroy, - OnInit, viewChild, - ViewChild, } from "@angular/core"; import { CommonModule } from "@angular/common"; import { NewsCardComponent } from "@ui/widgets/news-card/news-card.component"; @@ -22,6 +19,7 @@ import { FeedInfoService } from "@api/feed/facades/feed-info.service"; import { FeedUIInfoService } from "@api/feed/facades/ui/feed-ui-info.service"; import { AppRoutes } from "@api/paths/app-routes"; import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team-ui.service"; +import { FeedItem } from "@domain/feed/feed-item.model"; /** Страница ленты активности. */ @Component({ @@ -36,11 +34,10 @@ import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team- FeedFilterComponent, NewsCardComponent, OpenVacancyComponent, - IconComponent, ], providers: [FeedInfoService, FeedUIInfoService, ProjectTeamUIService], }) -export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { +export class FeedComponent implements AfterViewInit, OnDestroy { readonly feedRoot = viewChild>("feedRoot"); private readonly feedInfoService = inject(FeedInfoService); @@ -50,22 +47,28 @@ export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { protected readonly AppRoutes = AppRoutes; /** Ссылка-маршрут для карточки новости в зависимости от типа источника. */ - protected resourceLink(content: any): (string | number)[] { - if (content.contentObject && "email" in content.contentObject) { - return [AppRoutes.profile.detail(content.contentObject.id)]; + protected resourceLink(item: FeedItem): (string | number)[] { + if (item.typeModel !== "news") return []; + const source = item.content.contentObject; + if ("email" in source) { + return [AppRoutes.profile.detail(source.id)]; } - return [AppRoutes.projects.detail(content.contentObject.id)]; + return [AppRoutes.projects.detail(source.id)]; } - ngOnInit() { - this.feedInfoService.initializationFeedNews(this.feedRoot()!); + protected isPersonNews(item: FeedItem): boolean { + return item.typeModel === "news" && "email" in item.content.contentObject; } ngAfterViewInit() { const target = document.querySelector(".office__body") as HTMLElement; - if (target || this.feedRoot) { - this.feedInfoService.initScroll(target, this.feedRoot()!); + const root = this.feedRoot(); + if (root) { + this.feedInfoService.initializationFeedNews(root); + } + if (target && root) { + this.feedInfoService.initScroll(target, root); } } 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 108db63a3..bd624010a 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,21 +3,20 @@ import { inject } from "@angular/core"; import { ResolveFn } from "@angular/router"; import { map } from "rxjs"; -import { ApiPagination } from "@domain/other/api-pagination.model"; -import { FeedItem, FeedItemType } from "@domain/feed/feed-item.model"; +import { FeedItemType, 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"]; /** Предзагружает ленту новостей. */ -export const FeedResolver: ResolveFn> = route => { +export const FeedResolver: ResolveFn = route => { const fetchFeedUseCase = inject(FetchFeedUseCase); - // Загружаем первую страницу ленты (offset: 0, limit: 20) + // Загружаем первую страницу ленты тремя рядами по две карточки. // По умолчанию включаем вакансии, новости и проекты return fetchFeedUseCase - .execute(0, 20, route.queryParams["includes"] ?? DEFAULT_FEED_TYPES.join(FILTER_SPLIT_SYMBOL)) + .execute(0, 6, route.queryParams["includes"] ?? DEFAULT_FEED_TYPES.join(FILTER_SPLIT_SYMBOL)) .pipe( map(result => result.ok diff --git a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.html b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.html index efbf27e9e..47cf19a81 100644 --- a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.html +++ b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.html @@ -1,63 +1,36 @@ -
-
- - -
-
-

- {{ feedItem().name | truncate: 30 }} -

- -
- -
-

- @if (industryRepository.getOne(feedItem().industry); as industry) { - - {{ industry.name }} - - } -

- - -
+
+
+ Новый проект + @if (publishedAt()) { + + } +
-

{{ feedItem().shortDescription }}

-
+
+ +

{{ feedItem().name }}

-
- поддержать проект + @if (feedItem().shortDescription) { +

{{ feedItem().shortDescription }}

+ } - перейти в проект -
-
+ @if (industryRepository.getOne(feedItem().industry); as industry) { + {{ industry.name }} + } + + + Перейти в проект + + diff --git a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.scss b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.scss index 72abb7cf1..26c03819b 100644 --- a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.scss +++ b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.scss @@ -1,64 +1,74 @@ -@use "styles/typography"; -@use "styles/responsive"; - .card { - padding: 37px 24px 24px; - color: var(--light-white); - background-color: var(--accent); + display: flex; + flex-direction: column; + gap: 18px; + min-height: 100%; + padding: 24px; + color: var(--white); + background: var(--accent); border-radius: var(--rounded-xl); - &__avatar { + &__meta { display: flex; + flex-wrap: wrap; + gap: 8px; align-items: center; - justify-content: center; - text-align: center; - } - - &__project { - display: grid; - grid-template-columns: 2fr 3fr; - grid-gap: 40px; - margin-bottom: 20px; + justify-content: space-between; } - &__info { - display: flex; - flex-direction: column; - - &:first-child { - margin-bottom: 4px; - } - - &--main, - &--additional { - display: flex; - align-items: center; - justify-content: space-between; - } + &__type { + padding: 7px 12px; + font-size: 12px; + font-weight: 600; + color: var(--accent); + background: var(--white); + border-radius: var(--rounded-xxl); } - &__industry { - display: inline-flex; + &__date { + font-size: 12px; } - &__actions { + &__heading { display: flex; - gap: 10px; + gap: 16px; align-items: center; - width: 100%; + min-width: 0; } - &__score { - display: flex; - gap: 3px; - align-items: center; + &__title { + font-size: 20px; + font-weight: 700; + line-height: 1.3; + overflow-wrap: anywhere; } + /* stylelint-disable value-no-vendor-prefix */ &__description { - margin-top: 7px; - color: var(--white); - word-break: break-word; + display: -webkit-box; + overflow: hidden; + font-size: 14px; + line-height: 1.5; overflow-wrap: anywhere; - white-space: wrap; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + } + /* stylelint-enable value-no-vendor-prefix */ + + &__industry { + align-self: flex-start; + min-height: 32px; + padding: 7px 14px; + font-size: 14px; + font-weight: 600; + line-height: 18px; + color: var(--accent); + background: var(--white); + border-radius: var(--rounded-xxl); + } + + &__cta { + align-self: flex-start; + margin-top: auto; } } diff --git a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.spec.ts b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.spec.ts new file mode 100644 index 000000000..7c542ea77 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.spec.ts @@ -0,0 +1,40 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { RouterTestingModule } from "@angular/router/testing"; +import { FeedProject } from "@domain/feed/feed-item.model"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; +import { NewProjectComponent } from "./new-project.component"; + +describe("NewProjectComponent", () => { + it("показывает тип, дату, название, описание, индустрию и CTA", () => { + TestBed.configureTestingModule({ + imports: [NewProjectComponent, RouterTestingModule], + providers: [ + { provide: IndustryRepositoryPort, useValue: { getOne: () => ({ name: "Инженерия" }) } }, + ], + }); + const fixture = TestBed.createComponent(NewProjectComponent); + fixture.componentRef.setInput("feedItem", { + id: 5, + name: "Проект Б", + shortDescription: "Краткое описание проекта", + industry: 1, + imageAddress: "", + viewsCount: 0, + leader: 2, + } satisfies FeedProject); + fixture.componentRef.setInput("publishedAt", "2026-09-23T12:00:00Z"); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + expect(element.querySelector(".card__type")?.textContent?.trim()).toBe("Новый проект"); + expect(element.querySelector(".card__date")?.textContent?.trim()).toBe("23.09.2026"); + expect(element.querySelector(".card__title")?.textContent?.trim()).toBe("Проект Б"); + expect(element.querySelector(".card__description")?.textContent?.trim()).toBe( + "Краткое описание проекта", + ); + expect(element.querySelector(".card__industry")?.textContent?.trim()).toBe("Инженерия"); + expect(element.querySelector(".card__cta")?.textContent).toContain("Перейти в проект"); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.ts b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.ts index ddb19c4fd..e0c979b23 100644 --- a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.ts +++ b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.ts @@ -1,28 +1,26 @@ /** @format */ -import { ChangeDetectionStrategy, Component, input, Input } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ButtonComponent, IconComponent } from "@ui/primitives"; -import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; -import { Router, RouterLink } from "@angular/router"; -import { TagComponent } from "@ui/primitives/tag/tag.component"; -import { TruncatePipe, DayjsPipe } from "@corelib"; +import { ChangeDetectionStrategy, Component, inject, input } from "@angular/core"; +import { RouterLink } from "@angular/router"; +import { DayjsPipe } from "@corelib"; import { FeedProject } from "@domain/feed/feed-item.model"; import { AppRoutes } from "@api/paths/app-routes"; import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { ButtonComponent } from "@ui/primitives"; -/** Карточка нового проекта в ленте новостей. */ +/** Карточка нового проекта в общей ленте. */ @Component({ selector: "app-new-project", - imports: [CommonModule, ButtonComponent, AvatarComponent, RouterLink, TruncatePipe, TagComponent], + imports: [RouterLink, DayjsPipe, AvatarComponent, ButtonComponent], templateUrl: "./new-project.component.html", styleUrl: "./new-project.component.scss", changeDetection: ChangeDetectionStrategy.OnPush, }) export class NewProjectComponent { readonly feedItem = input.required(); + readonly publishedAt = input(""); protected readonly AppRoutes = AppRoutes; - - constructor(public readonly industryRepository: IndustryRepositoryPort) {} + protected readonly industryRepository = inject(IndustryRepositoryPort); } diff --git a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.html b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.html index 0b903ae13..1a52a730f 100644 --- a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.html +++ b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.html @@ -1,113 +1,55 @@ -@if (feedItem()) { -
-
- vacancy-card-background - @if (feedItem().project; as project) { -
- +
+
+ Новая вакансия + @if (publishedAt()) { + + } +
-

- {{ project.name | truncate: 30 }} -

+ @if (feedItem().project; as project) { +
+ +
+ Проект / компания + {{ project.name }} +
+
+ } - @if (industryRepositoryGetOne(project.industry); as industry) { - - {{ industry.name }} - - } -
- } - @if (feedItem(); as vacancy) { -
-
-

- {{ vacancy.role | truncate: 30 }} -

-

{{ vacancy.datetimeCreated | dayjs: "format" : "DD MM YY" }}

-
+

{{ feedItem().role }}

-
- @if (vacancy.requiredSkills.length; as skillsLength) { - @if (vacancy.requiredSkills; as requiredSkills) { - @if (requiredSkills) { -
    - @for (skill of requiredSkills.slice(0, 3); track $index) { - {{ skill.name }} - } -
- } -
- @if (requiredSkills) { -
    - @for (skill of requiredSkills.slice(8); track $index) { - {{ skill.name }} - } -
- } -
- } - @if (skillsLength > 8) { -
- {{ readFullSkills ? "cкрыть" : "подробнее" }} -
- } - } -
+ @if (feedItem().description) { +

+ } - @if (feedItem().description) { -
-
-

- @if (descriptionExpandable()) { -
- {{ readFullDescription() ? "скрыть" : "подробнее" }} -
- } -
-
- } -
+ @if (feedItem().requiredSkills.length) { +
    + @for (skill of feedItem().requiredSkills.slice(0, 5); track skill.id) { +
  • {{ skill.name }}
  • } -
+ + } - @if (feedItem()) { -
- перейти в проект - откликнуться на вакансию -
+ @if (feedItem().project.industry; as industryId) { + @if (industryRepository.getOne(industryId); as industry) { + {{ industry.name }} } -
-} + } + + + Подробнее о вакансии + + diff --git a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.scss b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.scss index b83b39471..8c1d7409f 100644 --- a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.scss +++ b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.scss @@ -1,191 +1,108 @@ -@use "styles/responsive"; -@use "styles/typography"; - -@mixin expandable-list { - &__remaining { - display: grid; - grid-template-rows: 0fr; - overflow: hidden; - transition: all 0.5s ease-in-out; - - &--show { - grid-template-rows: 1fr; - margin-top: 12px; - } - - ul { - min-height: 0; - } - - li { - &:first-child { - margin-top: 12px; - } - - &:not(:last-child) { - margin-bottom: 12px; - } - } - } -} - .card { - position: relative; - z-index: 100; - padding: 27px 24px 24px; - overflow-y: hidden; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); + display: flex; + flex-direction: column; + gap: 14px; + min-height: 100%; + padding: 24px; + background: var(--light-white); + border: 1px solid var(--medium-grey-for-outline); border-radius: var(--rounded-xl); - &__inner { - display: grid; - grid-template-columns: 2fr 3fr; - gap: 10px; - align-items: flex-start; - } - - &__background { - position: absolute; - top: 0; - left: 0; - z-index: 0; - } - - &__industry { - display: inline-flex; - } - - &__description { - word-break: break-word; - overflow-wrap: anywhere; - white-space: wrap; - } - - &__project { - display: flex; - flex-direction: column; - gap: 6px; - align-items: center; - text-align: center; - } - - &__vacancy { + &__meta { display: flex; - flex-direction: column; + flex-wrap: wrap; gap: 8px; - } - - &__actions { - display: flex; - gap: 10px; align-items: center; - margin-top: 15px; - - &:last-child { - ::ng-deep { - app-button { - .button--big { - padding: 4px 16px; - } - } - } - } - } -} - -.lists { - &__section { - display: flex; justify-content: space-between; - margin-bottom: 8px; - border-bottom: 0.5px solid var(--accent); } - &__icon { + &__type { + padding: 7px 12px; + font-size: 12px; + font-weight: 600; color: var(--accent); + background: var(--white); + border: 1px solid var(--accent); + border-radius: var(--rounded-xxl); } - &__title { - margin-bottom: 8px; - color: var(--accent); + &__date, + &__eyebrow { + font-size: 12px; + color: var(--dark-grey); } - &__item { + &__source { display: flex; - gap: 6px; + gap: 12px; align-items: center; + min-width: 0; } -} -.about { + &__source-text { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; - /* stylelint-disable value-no-vendor-prefix */ - &__text { - p { - display: -webkit-box; - overflow: hidden; + a { + font-size: 14px; + font-weight: 600; color: var(--black); - text-overflow: ellipsis; - -webkit-box-orient: vertical; - -webkit-line-clamp: 5; - transition: all 0.7s ease-in-out; - - &.expanded { - -webkit-line-clamp: unset; - } - } - - ::ng-deep a { - color: var(--accent); - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 3px; - transition: text-decoration-color 0.2s; - - &:hover { - text-decoration-color: var(--accent); - } + overflow-wrap: anywhere; } } - /* stylelint-enable value-no-vendor-prefix */ - - &__read-full { - margin-top: 2px; - color: var(--accent); - cursor: pointer; - } -} - -.read-more { - margin-top: 12px; - color: var(--accent); - cursor: pointer; - transition: color 0.2s; - - &:hover { - color: var(--accent-dark); + &__title { + font-size: 20px; + font-weight: 700; + line-height: 1.3; + color: var(--black); + overflow-wrap: anywhere; } -} -.skills { - &__title { - margin-bottom: 5px; - color: var(--dark-grey); + /* stylelint-disable value-no-vendor-prefix */ + &__description { + display: -webkit-box; + overflow: hidden; + font-size: 14px; + line-height: 1.5; + color: var(--grey-for-text); + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; } + /* stylelint-enable value-no-vendor-prefix */ - &__list { + &__skills { display: flex; flex-wrap: wrap; - gap: 10px; + gap: 8px; } - li { - &:not(:last-child) { - margin-bottom: 12px; - } + &__skill { + padding: 6px 10px; + font-size: 12px; + color: var(--accent); + background: var(--white); + border: 1px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xxl); } - @include expandable-list; + &__industry { + align-self: flex-start; + min-height: 32px; + padding: 7px 14px; + font-size: 14px; + font-weight: 600; + line-height: 18px; + color: var(--white); + background: var(--green); + border-radius: var(--rounded-xxl); + } + + &__cta { + align-self: flex-start; + margin-top: auto; + } } diff --git a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.spec.ts b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.spec.ts new file mode 100644 index 000000000..04cac7959 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.spec.ts @@ -0,0 +1,45 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { RouterTestingModule } from "@angular/router/testing"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { OpenVacancyComponent } from "./open-vacancy.component"; + +describe("OpenVacancyComponent", () => { + it("показывает тип, дату, источник, роль, описание, навыки и CTA", () => { + TestBed.configureTestingModule({ + imports: [OpenVacancyComponent, RouterTestingModule], + providers: [ + { provide: IndustryRepositoryPort, useValue: { getOne: () => ({ name: "IT" }) } }, + ], + }); + const fixture = TestBed.createComponent(OpenVacancyComponent); + fixture.componentRef.setInput("feedItem", { + id: 7, + role: "Frontend-разработчик", + description: "Создание интерфейсов для платформы.", + project: { id: 3, name: "Проект А", imageAddress: "", industry: 1 }, + requiredSkills: [ + { id: 1, name: "Angular" }, + { id: 2, name: "TypeScript" }, + ], + } as Vacancy); + fixture.componentRef.setInput("publishedAt", "2026-09-23T12:00:00Z"); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + expect(element.querySelector(".card__type")?.textContent?.trim()).toBe("Новая вакансия"); + expect(element.querySelector(".card__date")?.textContent?.trim()).toBe("23.09.2026"); + expect(element.querySelector(".card__source-text")?.textContent).toContain("Проект А"); + expect(element.querySelector(".card__title")?.textContent?.trim()).toBe("Frontend-разработчик"); + expect(element.querySelector(".card__description")?.textContent).toContain( + "Создание интерфейсов", + ); + expect( + Array.from(element.querySelectorAll(".card__skill")).map(node => node.textContent?.trim()), + ).toEqual(["Angular", "TypeScript"]); + expect(element.querySelector(".card__industry")?.textContent?.trim()).toBe("IT"); + expect(element.querySelector(".card__cta")?.textContent).toContain("Подробнее о вакансии"); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.ts b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.ts index b8210a370..d276f6367 100644 --- a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.ts +++ b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.ts @@ -1,77 +1,33 @@ /** @format */ -import { - AfterViewInit, - ChangeDetectionStrategy, - Component, - ElementRef, - inject, - input, - Input, - viewChild, - ViewChild, -} from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ButtonComponent } from "@ui/primitives"; -import { Router, RouterLink } from "@angular/router"; -import { TagComponent } from "@ui/primitives/tag/tag.component"; +import { ChangeDetectionStrategy, Component, inject, input } from "@angular/core"; +import { RouterLink } from "@angular/router"; +import { DayjsPipe, ParseBreaksPipe, ParseLinksPipe } from "@corelib"; import { Vacancy } from "@domain/vacancy/vacancy.model"; -import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; -import { TruncatePipe, DayjsPipe, ParseBreaksPipe, ParseLinksPipe } from "@corelib"; import { AppRoutes } from "@api/paths/app-routes"; import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; -import { ExpandService } from "@api/expand/expand.service"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { ButtonComponent } from "@ui/primitives"; -/** Карточка вакансии в ленте с поддержкой разворачивания контента. */ +/** Карточка вакансии в ленте: источник, роль, описание, навыки и переход к вакансии. */ @Component({ selector: "app-open-vacancy", imports: [ - CommonModule, - ButtonComponent, RouterLink, - TagComponent, DayjsPipe, ParseLinksPipe, ParseBreaksPipe, - TruncatePipe, AvatarComponent, + ButtonComponent, ], templateUrl: "./open-vacancy.component.html", styleUrl: "./open-vacancy.component.scss", changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ExpandService], }) -export class OpenVacancyComponent implements AfterViewInit { +export class OpenVacancyComponent { readonly feedItem = input.required(); + readonly publishedAt = input(""); - private readonly expandService = inject(ExpandService); protected readonly AppRoutes = AppRoutes; - private readonly industryRepository = inject(IndustryRepositoryPort); - - private descEl = viewChild("descEl"); - - readFullSkills = false; - - protected readonly descriptionExpandable = this.expandService.descriptionExpandable; - protected readonly readFullDescription = this.expandService.readFullDescription; - protected readonly industryRepositoryGetOne = (id: number) => this.industryRepository.getOne(id); - - ngAfterViewInit(): void { - setTimeout(() => { - this.expandService.checkExpandable("description", true, this.descEl()); - }); - } - - protected onExpandDescription(elem: HTMLElement): void { - this.expandService.onExpand( - "description", - elem, - "expanded", - this.expandService.readFullDescription(), - ); - } - - protected toggleSkills(): void { - this.readFullSkills = !this.readFullSkills; - } + protected readonly industryRepository = inject(IndustryRepositoryPort); } diff --git a/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.spec.ts index 8ccee9677..7a7fa23cf 100644 --- a/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/project-activity-card/project-activity-card.component.spec.ts @@ -10,8 +10,8 @@ describe("ProjectActivityCardComponent", () => { my: 3, subs: 0, myLeader: 2, - myInProgram: 1, - mySubmitted: 4, + myInProgram: 4, + mySubmitted: 2, ...overrides, }); @@ -31,8 +31,8 @@ describe("ProjectActivityCardComponent", () => { expect(rows.map(row => row.textContent?.replace(/\s+/g, " ").trim())).toEqual([ "Проектов 3", "Я лидер 2", - "В программе 1", - "Сдано 4", + "В программе 4", + "Сдано 2", ]); }); 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 7774f3f65..2dfb9185a 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 @@ -36,12 +36,18 @@ class="filter__option" [class.filter__option--disable]="filterItem.id === 5" [class.filter__option--active]="filterItem.value === includedFilters()" - (click)="setFilter(filterItem.value)" + (click)="filterItem.id !== 5 && setFilter(filterItem.value)" >

{{ filterItem.name }}

+ + {{ categoryCount(filterItem.value) }} +
}
diff --git a/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.scss b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.scss index 640a68b85..8a503fce5 100644 --- a/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.scss +++ b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.scss @@ -74,10 +74,10 @@ } .filter { - display: flex; - gap: 20px; - align-items: center; - justify-content: space-between; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 30px 12px; + padding-top: 25px; &__option { position: relative; @@ -85,9 +85,9 @@ flex-direction: column; align-items: center; justify-content: center; - width: 156px; - height: 66px; - padding: 40px 24px 12px; + min-width: 0; + min-height: 88px; + padding: 36px 10px 12px; cursor: pointer; background-color: var(--light-white); border: 0.5px solid var(--medium-grey-for-outline); @@ -99,6 +99,8 @@ } &--active { + border-color: var(--accent); + i { color: var(--light-white) !important; } @@ -106,6 +108,10 @@ .filter__option--icon { background-color: var(--accent); } + + p { + color: var(--accent); + } } &--icon { @@ -133,10 +139,12 @@ } } - span { + &__count { display: block; - margin-bottom: 6px; - color: var(--gray); + margin-top: 4px; + font-size: 14px; + font-weight: 600; + color: var(--accent); } &__checkbox { 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 7951f824e..9f89fcca4 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 @@ -5,6 +5,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { FeedFilterComponent } from "./feed-filter.component"; import { RouterTestingModule } from "@angular/router/testing"; import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { FeedUIInfoService } from "@api/feed/facades/ui/feed-ui-info.service"; describe("FeedComponent", () => { let component: FeedFilterComponent; @@ -13,6 +14,7 @@ describe("FeedComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [FeedFilterComponent, RouterTestingModule, HttpClientTestingModule], + providers: [FeedUIInfoService], }).compileComponents(); fixture = TestBed.createComponent(FeedFilterComponent); @@ -22,4 +24,24 @@ describe("FeedComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + + it("показывает счётчики всех шести категорий независимо от фильтра", () => { + TestBed.inject(FeedUIInfoService).categoryCounts.set({ + all: 12, + project: 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__count")?.textContent?.trim()), + ).toEqual(["12", "5", "3", "4", "0", "0"]); + }); }); 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 4294faca1..464fcb042 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 @@ -9,6 +9,8 @@ import { feedFilter } from "@core/consts/filters/feed-filter.const"; 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({ @@ -30,6 +32,7 @@ import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profi }) export class FeedFilterComponent implements OnInit { private readonly feedFilterInfoService = inject(FeedFilterInfoService); + private readonly feedUIInfoService = inject(FeedUIInfoService); // Состояние выпадающего меню фильтров protected readonly filterOpen = this.feedFilterInfoService.filterOpen; @@ -38,6 +41,11 @@ export class FeedFilterComponent implements OnInit { protected readonly includedFilters = this.feedFilterInfoService.includedFilters; 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/news-card/news-card.component.html b/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.html index 1968d7cef..2c7782c11 100644 --- a/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.html +++ b/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.html @@ -1,5 +1,15 @@ -
+
+ @if (feedType()) { +
+ + {{ feedType() === "people" ? "Новости людей" : "Новости проекта" }} + + +
+ }
{{ feedItem().name | truncate: 30 }}
-
- {{ feedItem().datetimeCreated | dayjs: "format" : "DD.MM.YY" }} -
+ @if (!feedType()) { +
+ {{ feedItem().datetimeCreated | dayjs: "format" : "DD.MM.YYYY" }} +
+ }
@if (isOwner()) { @@ -33,11 +45,21 @@ @if (feedItem().text) {
@if (!editMode) { -

+ @if (feedType()) { +

{{ feedText().headline }}

+ @if (feedText().summary) { +

+ } + } @else { +

+ } } @else { @if (editForm.get("text"); as text) { @@ -71,7 +93,7 @@ } } - @if (descriptionExpandable() && !editMode) { + @if (descriptionExpandable() && !editMode && !feedType()) {
{{ readFullDescription() ? "скрыть" : "подробнее" }}
@@ -93,6 +115,16 @@ }
} + @if (feedType() && !editMode) { + @if (feedIndustryId(); as industryId) { + @if (industryRepository.getOne(industryId); as industry) { + {{ industry.name }} + } + } + + {{ feedType() === "people" ? "Перейти в профиль" : "Перейти в проект" }} + + } @if (!editMode) {