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
@@ -0,0 +1,22 @@
/** @format */
import { inject, Injectable } from "@angular/core";
import { HttpParams } from "@angular/common/http";
import { catchError, map, of } from "rxjs";
import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port";
import { fail, ok } from "@domain/shared/result.type";

@Injectable({ providedIn: "root" })
export class GetPublishedProgramProjectsUseCase {
private readonly repository = inject(ProgramRepositoryPort);
execute(programId: number, search: string, offset: number) {
return this.repository
.getPublishedProjects(
programId,
new HttpParams().set("search", search).set("limit", 20).set("offset", offset),
)
.pipe(
map(value => ok(value)),
catchError(() => of(fail({ kind: "unavailable" as const }))),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ export abstract class ProgramRepositoryPort {
params?: HttpParams,
): Observable<ApiPagination<Project>>;

abstract getPublishedProjects(
programId: number,
params?: HttpParams,
): Observable<ApiPagination<Project>>;

abstract getAllMembers(
programId: number,
skip: number,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class Program {
currentApplication!: ProgramCurrentApplication | null;
welcomeAcknowledgedAt!: string | null;
publishProjectsAfterFinish!: boolean;
publishedProjectsAvailable?: boolean;
courseId!: number | null;
courses!: { id: number; title: string; isAvailable: boolean }[];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ export class ProgramHttpAdapter {
return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/`);
}

getPublishedProjects(programId: number, params?: HttpParams): Observable<ApiPagination<Project>> {
return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/published-projects/`, params);
}

getManagerOverview(programId: number): Observable<ProgramAnalyticsOverview> {
return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/manager-overview/`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,10 @@ export class ProgramRepository implements ProgramRepositoryPort {
return this.programAdapter.getAllProjects(programId, params);
}

getPublishedProjects(programId: number, params?: HttpParams): Observable<ApiPagination<Project>> {
return this.programAdapter.getPublishedProjects(programId, params);
}

getAllMembers(programId: number, skip: number, take: number): Observable<ApiPagination<User>> {
return this.programAdapter.getAllMembers(programId, skip, take).pipe(
map(result => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ import { catchError, EMPTY, map, of } from "rxjs";
import { Project } from "@domain/project/project.model";
import { GetAllProjectsUseCase } from "@api/program/use-cases/get-all-projects.use-case";
import { CreateProgramFiltersUseCase } from "@api/program/use-cases/create-program-filters.use-case";
import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service";

/** Предзагружает проекты программы с фильтрацией. */
export const ProgramProjectsResolver: ResolveFn<ApiPagination<Project>> = (
route: ActivatedRouteSnapshot,
) => {
const program = inject(ProgramDetailMainUIInfoService).program();
if (program && !program.isUserManager)
return of({ count: 0, results: [], next: "", previous: "" });
const getAllProjectsUseCase = inject(GetAllProjectsUseCase);
const createProgramFiltersUseCase = inject(CreateProgramFiltersUseCase);
const programId = route.parent?.params["programId"];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
<main class="project">
<section class="program__main">
<!-- advertisementImageAddress поле только для тех кто не участник программы поэтому нельзя сделать toggle и давать появляться или нет полю -->
@if (!program()!.isUserMember && !program()!.isUserManager && !program()!.isUserExpert) {
@if (
!program()!.isUserMember &&
!program()!.isUserManager &&
!program()!.isUserExpert &&
program()!.advertisementImageAddress
) {
<div class="program__advertisement">
<img
[src]="program()!.advertisementImageAddress"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,19 @@ describe("Program page: program-scoped news and role visibility", () => {
expect(mine).not.toHaveBeenCalled();
});
it("preserves advertisement and no internal content for an unrelated user", () => {
const f = render({});
const f = render({ advertisementImageAddress: "/advertisement.webp" });
expect(f.nativeElement.querySelector(".program__advertisement")).toBeTruthy();
expect(
f.nativeElement.querySelector("app-program-role-widget,app-news-card,app-news-form"),
).toBeNull();
expect(fetch).not.toHaveBeenCalled();
});
it("does not render a broken advertisement when the program has no image", () => {
const f = render({});
expect(f.nativeElement.querySelector(".program__advertisement img")).toBeNull();
expect(
f.nativeElement.querySelector("app-program-role-widget,app-news-card,app-news-form"),
).toBeNull();
expect(fetch).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/p
</main>`,
styles: `
.public-program {
max-width: 1200px;
max-width: 864px;
margin: 24px auto;
padding: 0 16px;
font-family: Mont, sans-serif;
Expand All @@ -28,7 +28,8 @@ import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/p
margin-bottom: 24px;
}
.public-program__title {
font-size: 24px;
font-size: 18px;
font-weight: 400;
overflow-wrap: anywhere;
}
@media (max-width: 999px) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/** @format */
import { ChangeDetectionStrategy, Component, inject, signal } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { FormControl, ReactiveFormsModule } from "@angular/forms";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { debounceTime, distinctUntilChanged, switchMap, tap } from "rxjs";
import { GetPublishedProgramProjectsUseCase } from "@api/program/use-cases/get-published-projects.use-case";
import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service";
import { ApiPagination } from "@domain/other/api-pagination.model";
import { Project } from "@domain/project/project.model";
import { InfoCardComponent } from "@ui/widgets/info-card/info-card.component";
import { SearchComponent } from "@ui/primitives/search/search.component";
import { PaginationComponent, StateComponent } from "@uilib";
import { ProgramListComponent } from "../detail/list/list.component";

@Component({
selector: "app-published-program-projects",
imports: [
ReactiveFormsModule,
SearchComponent,
InfoCardComponent,
PaginationComponent,
StateComponent,
],
template: ` <section class="public-projects" aria-label="Проекты-участники">
<app-search [formControl]="search" placeholder="Поиск по названию проекта" />
@if (loading()) {
<app-state kind="loading" title="Загружаем проекты…" />
} @else if (error()) {
<app-state kind="error" title="Публичные проекты пока недоступны." />
} @else if (data(); as page) {
<ul class="public-projects__list">
@for (project of page.results; track project.id) {
<li><app-info-card type="projects" [profileId]="project.id" [info]="project" /></li>
} @empty {
<li><app-state title="Опубликованные проекты не найдены." /></li>
}
</ul>
@if (page.count) {
<app-pagination
[previousDisabled]="offset() === 0"
[nextDisabled]="offset() + 20 >= page.count"
(previous)="changePage(-1)"
(next)="changePage(1)"
>
{{ offset() + 1 }}–{{ offset() + page.results.length }} из {{ page.count }}
</app-pagination>
}
}
</section>`,
styles: `
:host {
display: block;
min-width: 0;
}
.public-projects {
padding-block: 12px 40px;
}
.public-projects__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr));
gap: 30px 20px;
margin-block: 30px;
list-style: none;
padding: 0;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PublishedProgramProjectsComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly read = inject(GetPublishedProgramProjectsUseCase);
private readonly program = inject(ProgramDetailMainUIInfoService).program;
readonly search = new FormControl("", { nonNullable: true });
readonly offset = signal(0);
readonly data = signal<ApiPagination<Project> | null>(null);
readonly loading = signal(true);
readonly error = signal(false);
constructor() {
this.search.valueChanges
.pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed())
.subscribe(
search =>
void this.router.navigate([], {
relativeTo: this.route,
queryParams: { search: search.trim() || null, offset: null },
}),
);
this.route.queryParamMap
.pipe(
tap(query => {
this.search.setValue(query.get("search") ?? "", { emitEvent: false });
const offset = Number(query.get("offset"));
this.offset.set(Number.isSafeInteger(offset) && offset >= 0 ? offset : 0);
this.loading.set(true);
this.error.set(false);
}),
switchMap(() => this.read.execute(this.program()!.id, this.search.value, this.offset())),
takeUntilDestroyed(),
)
.subscribe(result => {
this.loading.set(false);
if (result.ok) this.data.set(result.value);
else {
this.data.set(null);
this.error.set(true);
}
});
}
changePage(direction: number): void {
void this.router.navigate([], {
relativeTo: this.route,
queryParams: { offset: Math.max(0, this.offset() + 20 * direction) || null },
queryParamsHandling: "merge",
});
}
}

@Component({
selector: "app-program-projects-page",
imports: [ProgramListComponent, PublishedProgramProjectsComponent],
template: `@if (program()?.isUserManager) {
<app-list />
} @else {
<app-published-program-projects />
}`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProgramProjectsPageComponent {
readonly program = inject(ProgramDetailMainUIInfoService).program;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@
>К решениям программы</a
>
}
<h2 id="published-title">{{ solution()?.application?.name || "Решения программы" }}</h2>
<h2 id="published-title" class="text-heading-1">
{{ solution()?.application?.name || "Решения программы" }}
</h2>
@if (solutionId() === null) {
<label class="published__search"
>Поиск решений<input
appField
type="search"
[formControl]="search"
placeholder="Поиск по названию решения"
/></label>
<app-search
class="published__search"
[openable]="false"
[formControl]="search"
placeholder="Поиск по названию решения"
aria-label="Поиск решений"
/>
}
@if (loading()) {
<p role="status">Загружаем опубликованные решения…</p>
<app-state kind="loading" title="Загружаем опубликованные решения…" />
} @else if (error(); as problem) {
<p role="alert">
{{
Expand All @@ -28,34 +30,31 @@ <h2 id="published-title">{{ solution()?.application?.name || "Решения п
<button appButton="secondary" type="button" (click)="reload()">Повторить</button>
} @else if (solution(); as item) {
<p class="published__muted">Опубликовано {{ item.publishedAt | date: "dd.MM.yyyy" }}</p>
<nav class="published__tabs" aria-label="Разделы решения">
<a
appButton="secondary"
[routerLink]="[]"
[queryParams]="{ tab: null }"
queryParamsHandling="merge"
[attr.aria-current]="!teamTab() ? 'page' : null"
>Решение</a
>
<a
appButton="secondary"
[routerLink]="[]"
[queryParams]="{ tab: 'team' }"
queryParamsHandling="merge"
[attr.aria-current]="teamTab() ? 'page' : null"
>Команда</a
>
</nav>
<app-tabs
appearance="steps"
label="Разделы решения"
[items]="sectionTabs"
[active]="teamTab() ? 'team' : 'materials'"
(selected)="navigate({ tab: $event === 'team' ? 'team' : null })"
/>
@if (teamTab()) {
<h3>Команда · {{ item.team.acceptedCount }}</h3>
<h3 class="text-heading-2">Команда · {{ item.team.acceptedCount }}</h3>
<ul class="published__team">
@for (member of item.team.members; track member.user.id) {
<li>
<span>{{ member.user.firstName }} {{ member.user.lastName }}</span
><small
>{{ member.role || member.specialization || "Участник"
}}{{ member.isCaptain ? " · Капитан" : "" }}</small
>
<app-avatar [url]="member.user.avatarUrl || ''" [size]="40" />
<div>
<span>{{ member.user.firstName }} {{ member.user.lastName }}</span
><small
>{{ member.role || member.specialization || "Участник"
}}{{
member.isCaptain &&
(member.role || member.specialization || "").trim().toLowerCase() !== "капитан"
? " · Капитан"
: ""
}}</small
>
</div>
</li>
}
</ul>
Expand Down Expand Up @@ -85,16 +84,22 @@ <h3>Команда · {{ item.team.acceptedCount }}</h3>
@for (card of list.results; track card.application.solutionId) {
<li>
<a
appCard
[interactive]="true"
class="published__card"
[routerLink]="
paths.program.publishedSolution(programId(), card.application.solutionId)
"
[queryParams]="listQuery()"
>{{ card.application.name }}</a
>
<p>{{ card.captain.firstName }} {{ card.captain.lastName }}</p>
<small
>В команде: {{ card.acceptedCount }} ·
{{ card.publishedAt | date: "dd.MM.yyyy" }}</small
><h3 class="text-heading-2">{{ card.application.name }}</h3>
<div class="published__captain">
<app-avatar [url]="card.captain.avatarUrl || ''" [size]="32" />
<p>{{ card.captain.firstName }} {{ card.captain.lastName }}</p>
</div>
<small
>В команде: {{ card.acceptedCount }} ·
{{ card.publishedAt | date: "dd.MM.yyyy" }}</small
></a
>
</li>
}
Expand Down
Loading