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
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
/** @format */

import { DestroyRef, ElementRef, inject, Injectable } from "@angular/core";
import { concatMap, filter, map, tap } from "rxjs";
import { DestroyRef, inject, Injectable } from "@angular/core";
import { filter, map, switchMap, tap } from "rxjs";
import { ProfileNews } from "@domain/profile/profile-news.model";
import { ActivatedRoute } from "@angular/router";
import { ExpandService } from "../../../expand/expand.service";
import { calculateProfileProgress } from "@utils/calculateProgress";
import { ProfileDetailUIInfoService } from "./ui/profile-detail-ui-info.service";
import { NewsInfoService } from "../../../news/news-info.service";
Expand All @@ -23,7 +22,6 @@ export class ProfileDetailInfoService {
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);

private readonly expandService = inject(ExpandService);
private readonly profileDetailUIInfoService = inject(ProfileDetailUIInfoService);
private readonly profileInfoService = inject(ProfileInfoService);
private readonly newsInfoService = inject(NewsInfoService);
Expand Down Expand Up @@ -69,16 +67,10 @@ export class ProfileDetailInfoService {
},
});

this.initializationProfileVields();
this.initializationProfileFields();
this.initializationProfileNews();
}

initCheckDescription(descEl?: ElementRef): void {
setTimeout(() => {
this.expandService.checkExpandable("description", !!this.profile()?.personal.aboutMe, descEl);
}, 150);
}

onAddNews(news: { text: string; files: string[] }) {
return this.addProfileNewsUseCase.execute(this.route.snapshot.params["id"], news).pipe(
tap(result => {
Expand Down Expand Up @@ -136,16 +128,16 @@ export class ProfileDetailInfoService {
.subscribe();
}

private initializationProfileVields(): void {
private initializationProfileFields(): void {
this.profileDetailUIInfoService.applySetLoggedUserId("logged", this.profile()!.id);
this.profileDetailUIInfoService.applyProfileEmpty();
}

private initializationProfileNews(descEl?: ElementRef): void {
private initializationProfileNews(): void {
this.route.params
.pipe(
map(r => r["id"]),
concatMap(userId => this.fetchProfileNewsUseCase.execute(Number(userId))),
tap(() => this.newsInfoService.applySetNews({ results: [], count: 0 })),
switchMap(userId => this.fetchProfileNewsUseCase.execute(Number(userId))),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(result => {
Expand All @@ -157,8 +149,6 @@ export class ProfileDetailInfoService {
this.setupNewsObserver();
}, 100);
});

this.initCheckDescription(descEl);
}

private setupNewsObserver(): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @format */

import { Injectable, signal } from "@angular/core";
import { computed, Injectable, signal } from "@angular/core";
import { DirectionItem, directionItemBuilder } from "@utils/directionItemBuilder";
import { User } from "@domain/auth/user.model";

Expand All @@ -12,7 +12,18 @@ export class ProfileDetailUIInfoService {
readonly loggedUserId = signal<number>(0);
readonly profileId = signal<number>(0); // ID текущего пользователя.

readonly isProfileEmpty = signal<boolean | undefined>(undefined);
readonly isProfileEmpty = computed<boolean | undefined>(() => {
const user = this.user();
if (!user) return undefined;

return !(
user.firstName &&
user.lastName &&
user.email &&
user.personal.avatar &&
user.personal.birthday
);
});
readonly isProfileFill = signal<boolean>(false);

readonly directions = signal<DirectionItem[]>([]);
Expand All @@ -35,18 +46,6 @@ export class ProfileDetailUIInfoService {
}
}

applyProfileEmpty(): void {
this.isProfileEmpty.set(
!(
this.user()?.firstName &&
this.user()?.lastName &&
this.user()?.email &&
this.user()?.personal.avatar &&
this.user()?.personal.birthday
),
);
}

applySetLoggedUserId(type: "logged" | "profile", profileId: number): void {
type === "logged" ? this.loggedUserId.set(profileId) : this.profileId.set(profileId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe("VacancyDetailInfoService", () => {
let downloadCvUseCase: any;
let fileService: any;
let snackbar: any;
let queryParams$: Subject<Record<string, unknown>>;

beforeEach(() => {
sendUseCase = { execute: vi.fn() };
Expand All @@ -38,6 +39,7 @@ describe("VacancyDetailInfoService", () => {
downloadCvUseCase = { execute: vi.fn() };
fileService = { uploadFile: vi.fn() };
snackbar = { success: vi.fn(), error: vi.fn() };
queryParams$ = new Subject<Record<string, unknown>>();

TestBed.configureTestingModule({
providers: [
Expand All @@ -48,7 +50,7 @@ describe("VacancyDetailInfoService", () => {
provide: ActivatedRoute,
useValue: {
data: of({}),
queryParams: of({}),
queryParams: queryParams$,
snapshot: { paramMap: { get: () => "10" } },
},
},
Expand Down Expand Up @@ -110,6 +112,28 @@ describe("VacancyDetailInfoService", () => {
expect(ui.openModal()).toBe(false);
});

it("opens manager responses from a guarded query parameter", () => {
ui.vacancy.update(vacancy =>
vacancy ? Object.assign(new Vacancy(), vacancy, { canManageResponses: true }) : vacancy,
);
getResponsesUseCase.execute.mockReturnValue(of(ok([])));
service.initializeDetailInfoQueryParams();

queryParams$.next({ manageResponses: "true" });

expect(ui.responsesModal()).toBe(true);
expect(getResponsesUseCase.execute).toHaveBeenCalledExactlyOnceWith(10);
});

it("does not open or load manager responses without backend permission", () => {
service.initializeDetailInfoQueryParams();

queryParams$.next({ manageResponses: "true" });

expect(ui.responsesModal()).toBe(false);
expect(getResponsesUseCase.execute).not.toHaveBeenCalled();
});

it.each([
[["Вы уже откликнулись на эту вакансию."], "Вы уже откликнулись на эту вакансию"],
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ export class VacancyDetailInfoService {
initializeDetailInfoQueryParams(): void {
this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: r => {
if (r["manageResponses"] === true || r["manageResponses"] === "true") {
this.openVacancyResponses();
return;
}

this.vacancyDetailUIInfoService.applyNoResponseOpenModal(r);
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ <h4 class="text-heading-4">библиотека</h4>
[title]="skillGroup.name"
[options]="skillGroup.skills"
[selected]="stageForm.getRawValue().skills"
[disabled]="isSkillGroupDisabled(skillGroup.name)"
[hasOpenGroups]="hasOpenSkillsGroups()"
[isOpen]="openSkillGroup() === skillGroup.name"
(groupToggled)="onSkillGroupToggled($event, skillGroup.name)"
(optionToggled)="onOptionToggled($event)"
></app-skills-group>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,7 @@ <h3 class="text-body-12 about__title">обо мне</h3>
</div>

<div class="text-body-10 about__text">
<p #descEl [innerHTML]="about | parseLinks | parseBreaks"></p>
@if (descriptionExpandable()) {
<div
class="read-more text-body-10"
(click)="onExpandDescription(descEl, 'expanded', readFullDescription())"
>
{{ readFullDescription() ? "скрыть" : "подробнее" }}
</div>
}
<p [innerHTML]="about | parseLinks | parseBreaks"></p>
</div>
</div>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,21 +68,11 @@
margin-bottom: 8px;
color: var(--accent);
}
/* stylelint-disable value-no-vendor-prefix */

&__text {
p {
display: -webkit-box;
overflow: hidden;
color: var(--black);
text-overflow: ellipsis;
word-break: break-word;
transition: all 0.7s ease-in-out;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;

&.expanded {
-webkit-line-clamp: unset;
}
}

::ng-deep a {
Expand All @@ -98,8 +88,6 @@
}
}

/* stylelint-enable value-no-vendor-prefix */

&__read-full {
margin-top: 2px;
color: var(--accent);
Expand All @@ -118,14 +106,3 @@
margin-top: 20px;
}
}

.read-more {
margin-top: 12px;
color: var(--accent);
cursor: pointer;
transition: color 0.2s;

&:hover {
color: var(--accent-dark);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { signal } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { provideRouter } from "@angular/router";
import { ExpandService } from "@api/expand/expand.service";
import { FileService, ValidationService } from "@corelib";
import { NewsInfoService } from "@api/news/news-info.service";
import { ProfileDetailInfoService } from "@api/profile/facades/detail/profile-detail-info.service";
import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service";
Expand All @@ -12,39 +12,55 @@ import { ProfileMidSideComponent } from "./profile-mid-side.component";

describe("ProfileMidSideComponent", () => {
let fixture: ComponentFixture<ProfileMidSideComponent>;
let ui: ProfileDetailUIInfoService;

const createUser = (id: number, filled: boolean, aboutMe = ""): User =>
({
id,
firstName: filled ? "Анна" : "",
lastName: filled ? "Иванова" : "",
email: filled ? "anna@example.test" : "",
personal: {
aboutMe,
avatar: filled ? "https://example.test/avatar.png" : "",
birthday: filled ? "2000-01-01" : null,
},
relations: {
progress: filled ? 100 : 0,
profileFillPromptAcknowledgedAt: null,
skills: [],
achievements: [],
},
}) as unknown as User;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProfileMidSideComponent],
providers: [
provideRouter([]),
{ provide: ProfileDetailInfoService, useValue: {} },
{ provide: NewsInfoService, useValue: { news: signal([]) } },
{
provide: ProfileDetailUIInfoService,
useValue: {
loggedUserId: signal(7),
isProfileEmpty: signal(true),
directions: signal([]),
},
},
{
provide: ExpandService,
provide: ProfileDetailInfoService,
useValue: {
descriptionExpandable: signal(false),
readFullDescription: signal(false),
onExpand: vi.fn(),
onAddNews: vi.fn(),
onDeleteNews: vi.fn(),
onLike: vi.fn(),
onEditNews: vi.fn(),
onNewsInView: vi.fn(),
},
},
{ provide: NewsInfoService, useValue: { news: signal([]) } },
ProfileDetailUIInfoService,
{ provide: ValidationService, useValue: { getFormValidation: vi.fn() } },
{ provide: FileService, useValue: {} },
],
}).compileComponents();

fixture = TestBed.createComponent(ProfileMidSideComponent);
fixture.componentRef.setInput("user", {
id: 7,
personal: { aboutMe: "" },
relations: { skills: [], achievements: [] },
} as User);
ui = TestBed.inject(ProfileDetailUIInfoService);
const user = createUser(7, false);
ui.applySetLoggedUserId("logged", 7);
ui.applyInitProfile({ data: { user } }, 7);
fixture.componentRef.setInput("user", user);
fixture.detectChanges();
});

Expand All @@ -56,4 +72,44 @@ describe("ProfileMidSideComponent", () => {
expect(emptyState).toBeTruthy();
expect(emptyState.textContent).toContain("заполните профиль и начните пользоваться PROCOLLAB");
});

it("does not leak empty state across foreign and own profile navigation", () => {
const foreignEmpty = createUser(20, false);
const ownFilled = createUser(10, true, "Заполненный профиль");
ui.applySetLoggedUserId("logged", 10);

ui.applyInitProfile({ data: { user: foreignEmpty } }, 10);
fixture.componentRef.setInput("user", foreignEmpty);
fixture.detectChanges();
expect(ui.isProfileEmpty()).toBe(true);
expect(fixture.nativeElement.querySelector('[data-testid="empty-profile-state"]')).toBeFalsy();

ui.applyInitProfile({ data: { user: ownFilled } }, 10);
fixture.componentRef.setInput("user", ownFilled);
fixture.detectChanges();
expect(ui.isProfileEmpty()).toBe(false);
expect(fixture.nativeElement.querySelector('[data-testid="empty-profile-state"]')).toBeFalsy();
expect(fixture.nativeElement.querySelector("app-news-form")).toBeTruthy();

ui.applyInitProfile({ data: { user: foreignEmpty } }, 10);
fixture.componentRef.setInput("user", foreignEmpty);
fixture.detectChanges();
ui.applyInitProfile({ data: { user: ownFilled } }, 10);
fixture.componentRef.setInput("user", ownFilled);
fixture.detectChanges();

expect(ui.isProfileEmpty()).toBe(false);
expect(fixture.nativeElement.querySelector("app-news-form")).toBeTruthy();
});

it("shows the full 300-character about me text without expansion control", () => {
const aboutMe = "Я".repeat(300);
const user = createUser(7, true, aboutMe);
ui.applyInitProfile({ data: { user } }, 7);
fixture.componentRef.setInput("user", user);
fixture.detectChanges();

expect(fixture.nativeElement.querySelector(".about__text p").textContent).toBe(aboutMe);
expect(fixture.nativeElement.querySelector(".about .read-more")).toBeFalsy();
});
});
Loading