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
23 changes: 10 additions & 13 deletions src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,34 @@
import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { TestBed, waitForAsync } from "@angular/core/testing";
import { RouterTestingModule } from "@angular/router/testing";
import { AuthService } from "@shared/services/auth/auth.service";
import { SharedModule } from "@shared/shared.module";
import { SpinnerService } from "@shared/services/spinners/spinner-service";
import {
MockAuthService,
mostUsedImports,
mostUsedServices,
testUtilStubs,
} from "@shared/test-utils";
import { AppComponent } from "./app.component";

describe("AppComponent", () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: [
...mostUsedServices,
{ provide: AuthService, useClass: MockAuthService },
...testUtilStubs,
...mostUsedServices,
SpinnerService,
],
imports: [RouterTestingModule, ...mostUsedImports],
declarations: [AppComponent],
imports: [RouterTestingModule, SharedModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
});
}));

it("should create the app", () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});

it(`should have as title 'Techinterview.space'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual("Techinterview.space");
});
});
4 changes: 0 additions & 4 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,11 @@ import { untilDestroyed } from "./shared/subscriptions/until-destroyed";
standalone: false,
})
export class AppComponent implements OnInit, OnDestroy {
isAuthenticated = false;
title = "Techinterview.space";

get showAdminNavbar(): boolean {
return this.router.url.startsWith("/admin");
}

constructor(
private readonly authService: AuthService,
private readonly router: Router,
) {}

Expand Down
5 changes: 5 additions & 0 deletions src/app/components/admin-navbar/admin-navbar.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ export class AdminNavbarComponent {
url: "/admin/salaries/imported-salaries",
isExternal: false,
},
{
title: "График исторических данных",
url: "/admin/salaries/historical-data-templates",
isExternal: false,
},
],
},
{
Expand Down
5 changes: 5 additions & 0 deletions src/app/modules/admin/admin-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { GitHubJobsPageComponent } from "./components/github/github-jobs-page/gi
import { OpenAiPromptsAdminPageComponent } from "./components/openai-prompts/openai-prompts-admin-page.component";
import { CompanyReviewsStatSubscriptionsComponent } from "./components/telegram/reviews-stat-subscriptions/reviews-stat-subscriptions.component";
import { JobPostingMessageSubscriptionsComponent } from "./components/telegram/job-posting-message-subscriptions/job-posting-message-subscriptions.component";
import { HistoricalDataTemplatesComponent } from "./components/salaries/historical-data-templates/historical-data-templates.component";

const routes: Routes = [
{ path: "", component: AdminStartPageComponent },
Expand Down Expand Up @@ -73,6 +74,10 @@ const routes: Routes = [
{ path: "github/processing-jobs", component: GitHubJobsPageComponent },

{ path: "openai-prompts", component: OpenAiPromptsAdminPageComponent },
{
path: "salaries/historical-data-templates",
component: HistoricalDataTemplatesComponent,
},
];

@NgModule({
Expand Down
6 changes: 6 additions & 0 deletions src/app/modules/admin/admin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CommonModule } from "@angular/common";
import { AdminRoutingModule } from "./admin-routing.module";
import { SharedModule } from "@shared/shared.module";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { NgSelectModule } from "@ng-select/ng-select";
import { BackgroundJobsComponent } from "./components/background-jobs/background-jobs.component";
import { HealthCheckTableComponent } from "./components/health-check-table/health-check-table.component";
import { JobsTableComponent } from "./components/jobs-table/jobs-table.component";
Expand Down Expand Up @@ -36,6 +37,8 @@ import { CompanyReviewsStatSubscriptionsComponent } from "./components/telegram/
import { JobPostingMessageSubscriptionsComponent } from "./components/telegram/job-posting-message-subscriptions/job-posting-message-subscriptions.component";
import { JobPostingMessageSubscriptionsService } from "@services/job-posting-message-subscriptions.service";
import { TelegramSubscriptionsService } from "@services/telegram-subscriptions.service";
import { HistoricalDataTemplatesComponent } from "./components/salaries/historical-data-templates/historical-data-templates.component";
import { SalariesHistoricalDataTemplatesService } from "@services/salaries-historical-data-templates.service";

const adminServices = [
AdminDashboardService,
Expand All @@ -44,6 +47,7 @@ const adminServices = [
CompanyReviewsTelegramSubscriptionsService,
JobPostingMessageSubscriptionsService,
TelegramSubscriptionsService,
SalariesHistoricalDataTemplatesService,
];

@NgModule({
Expand Down Expand Up @@ -74,13 +78,15 @@ const adminServices = [
OpenAiPromptsAdminPageComponent,
CompanyReviewsStatSubscriptionsComponent,
JobPostingMessageSubscriptionsComponent,
HistoricalDataTemplatesComponent,
],
imports: [
CommonModule,
SharedModule,
AdminRoutingModule,
FormsModule,
ReactiveFormsModule,
NgSelectModule,
],
providers: adminServices,
exports: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
>
</td>
<td>{{ item.rating | number: "1.2-2" }}</td>
<td>{{ item.reviews.length || item.reviewsCount }}</td>
<td>{{ item.reviews?.length || item.reviewsCount }}</td>
<td>{{ item.viewsCount }}</td>
<td>{{ item.slug }}</td>
<td>{{ item.createdAt | date: "yyyy-MM-dd HH:mm" }}</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
<div class="row mb-3">
<div class="col-md-3">Кол-во отзывов</div>
<div class="col-md-9">
{{ company.reviews.length || company.reviewsCount }}
{{ company.reviews?.length || company.reviewsCount }}
</div>
</div>

Expand Down Expand Up @@ -143,7 +143,7 @@
<div class="card-body p-5">
<div
class="table-responsive"
*ngIf="company.reviews.length > 0; else nothingToShow"
*ngIf="company.reviews?.length; else nothingToShow"
>
<table class="table table-hover">
<thead>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { FormControl, FormGroup, Validators } from "@angular/forms";
import {
CreateSalariesHistoricalDataRecordTemplateRequest,
SalariesHistoricalDataRecordTemplateDto,
} from "@services/salaries-historical-data-templates.service";

export class HistoricalDataTemplateEditForm extends FormGroup {
constructor(
private readonly item: SalariesHistoricalDataRecordTemplateDto | null,
) {
super({
name: new FormControl(item?.name ?? null, [
Validators.required,
Validators.maxLength(100),
]),
professionIds: new FormControl(item?.professionIds ?? [], [
Validators.required,
]),
Comment on lines +16 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validators.required doesn't validate array length.

The Validators.required validator only checks for null/undefined, so an empty array [] would pass validation. For array fields that must contain at least one item, use Validators.minLength(1) or a custom validator.

🔎 Fix array validation
 professionIds: new FormControl(item?.professionIds ?? [], [
   Validators.required,
+  Validators.minLength(1),
 ]),

Or create a custom validator:

function requiredArray(control: AbstractControl): ValidationErrors | null {
  const value = control.value;
  return Array.isArray(value) && value.length > 0 ? null : { requiredArray: true };
}
 professionIds: new FormControl(item?.professionIds ?? [], [
-  Validators.required,
+  requiredArray,
 ]),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
professionIds: new FormControl(item?.professionIds ?? [], [
Validators.required,
]),
professionIds: new FormControl(item?.professionIds ?? [], [
Validators.required,
Validators.minLength(1),
]),
🤖 Prompt for AI Agents
In
src/app/modules/admin/components/salaries/historical-data-templates/historical-data-template-edit-form.ts
around lines 16 to 18, the FormControl for professionIds uses
Validators.required which does not fail for an empty array; change the validator
to enforce non-empty arrays by replacing Validators.required with
Validators.minLength(1) or attach a custom validator that returns an error when
the control value is not an array with length > 0 (e.g., requiredArray), and
update any related error keys/messages in the template or form handling to
reflect the new validator.

});
}

hasItemToEdit(): boolean {
return this.item != null;
}

getItemId(): string | null {
return this.item?.id ?? null;
}

createRequestOrNull(): CreateSalariesHistoricalDataRecordTemplateRequest | null {
if (!this.valid) {
this.markAllAsTouched();
return null;
}

const professionIds = this.value.professionIds as Array<number>;

return {
name: this.value.name,
professionIds: professionIds ?? [],
};
}
Comment on lines +30 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Type safety concern and redundant null coalescing.

Line 36 uses a type assertion as Array<number> without checking if the value is actually an array. If the form control value is null or undefined, this could cause runtime issues.

Line 40's null coalescing professionIds ?? [] is redundant since professionIds is already assigned on line 36.

🔎 Improve type safety
 createRequestOrNull(): CreateSalariesHistoricalDataRecordTemplateRequest | null {
   if (!this.valid) {
     this.markAllAsTouched();
     return null;
   }
 
-  const professionIds = this.value.professionIds as Array<number>;
+  const professionIds = (this.value.professionIds as Array<number> | null) ?? [];
 
   return {
     name: this.value.name,
-    professionIds: professionIds ?? [],
+    professionIds: professionIds,
   };
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
createRequestOrNull(): CreateSalariesHistoricalDataRecordTemplateRequest | null {
if (!this.valid) {
this.markAllAsTouched();
return null;
}
const professionIds = this.value.professionIds as Array<number>;
return {
name: this.value.name,
professionIds: professionIds ?? [],
};
}
createRequestOrNull(): CreateSalariesHistoricalDataRecordTemplateRequest | null {
if (!this.valid) {
this.markAllAsTouched();
return null;
}
const professionIds = (this.value.professionIds as Array<number> | null) ?? [];
return {
name: this.value.name,
professionIds: professionIds,
};
}
🤖 Prompt for AI Agents
In
src/app/modules/admin/components/salaries/historical-data-templates/historical-data-template-edit-form.ts
around lines 30 to 42, the code unsafely asserts this.value.professionIds as
Array<number> and then redundantly uses professionIds ?? []; instead, safely
read the control value (e.g. const val = this.value.professionIds), validate its
type (Array.isArray(val)), and assign professionIds = Array.isArray(val) ? val
as Array<number> : []; then return the request using that safe professionIds
value (remove the redundant null-coalescing). Ensure you still call
markAllAsTouched() when invalid and keep the same return shape.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<app-page-header>Шаблоны исторических данных</app-page-header>

<div class="container my-5" *ngIf="items; else dataLoading">
<div class="card">
<div class="card-body">
<div class="mb-2">
<button class="btn btn-sm btn-primary" (click)="create()">
Создать
</button>
</div>

<div
class="mb-3 table-responsive"
*ngIf="items && items.length > 0; else nothingToShow"
>
<table class="table table-hover table-sm">
<thead class="small">
<tr class="small">
<th>ID</th>
<th>Название</th>
<th>Специальности</th>
<th>Создано</th>
<th>Обновлено</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr class="small" *ngFor="let item of items; let i = index">
<td>
<span class="text-muted small" [title]="item.id">...{{ item.id.split('-').pop() }}</span>
</td>
<td>{{ item.name }}</td>
<td>
<a
*ngIf="item.professionIds && item.professionIds.length > 0"
href="#"
class="text-primary"
(click)="openProfessionsModal(item.professionIds); $event.preventDefault()"
>
{{ item.professionIds.length }} специальност{{ item.professionIds.length === 1 ? 'ь' : (item.professionIds.length < 5 ? 'и' : 'ей') }}
</a>
<span *ngIf="!item.professionIds || item.professionIds.length === 0">-</span>
</td>
<td>{{ item.createdAt | date: "yyyy-MM-dd HH:mm" }}</td>
<td>{{ item.updatedAt | date: "yyyy-MM-dd HH:mm" }}</td>
<td>
<div class="btn-group" role="group">
<button
type="button"
class="btn btn-outline-dark dropdown-toggle btn-sm"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Действия
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" (click)="openEditDlg(item)"
>Редактировать</a
>
</li>
<li>
<a
class="dropdown-item text-danger"
href="#"
(click)="delete(item); $event.preventDefault()"
>Удалить</a
>
</li>
</ul>
</div>
</td>
</tr>
Comment on lines +28 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add trackBy functions for performance.

Multiple *ngFor loops lack trackBy functions:

  • Line 28: items loop
  • Line 157: selectedProfessionsForModal loop

This can cause unnecessary re-renders, especially after edit/delete operations.

🔎 Proposed fix

Add to component:

trackById(index: number, item: { id: string }): string {
  return item.id;
}

trackByProfessionId(index: number, profession: LabelEntityDto): number {
  return profession.id;
}

Update template:

-<tr class="small" *ngFor="let item of items; let i = index">
+<tr class="small" *ngFor="let item of items; trackBy: trackById">

-*ngFor="let profession of selectedProfessionsForModal"
+*ngFor="let profession of selectedProfessionsForModal; trackBy: trackByProfessionId"

As per coding guidelines, trackBy functions are required for ngFor loops.

Also applies to: 157-161

🤖 Prompt for AI Agents
In
src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.html
around lines 28 and 157-161, the *ngFor loops are missing trackBy functions
which causes unnecessary DOM re-renders after edits/deletes; add two methods to
the component class — e.g. trackById(index: number, item: { id: string }) {
return item.id; } and trackByProfessionId(index: number, profession: { id:
number }) { return profession.id; } — then update the template *ngFor directives
to use trackBy: *ngFor="let item of items; let i = index; trackBy: trackById"
and *ngFor="let profession of selectedProfessionsForModal; trackBy:
trackByProfessionId".

</tbody>
</table>
</div>

<div class="" *ngIf="source && source.results && source.results.length > 0">
<app-pagination-buttons
[source]="source"
(pageChange)="loadData($event)"
></app-pagination-buttons>
</div>
</div>
</div>
</div>

<ng-template #nothingToShow>
<div class="card">
<div class="card-body">
<div class="text-center">Нет данных</div>
</div>
</div>
</ng-template>

<ng-template #dataLoading>
<div class="container mt-5">
<app-data-loader></app-data-loader>
</div>
</ng-template>

<app-dialog
[show]="!!editForm"
(closed)="onEditModalDlgClose()"
[header]="editForm?.hasItemToEdit() ? 'Редактирование шаблона' : 'Новый шаблон'"
>
<form
class="mb-5"
*ngIf="!!editForm"
[formGroup]="editForm"
(ngSubmit)="onEditFormSubmit()"
>
<div class="mb-3">
<label>Название шаблона</label>
<input type="text" class="form-control" formControlName="name" />
<app-field-error [field]="editForm.get('name')"></app-field-error>
</div>

<div class="mb-3">
<label>Специальности</label>
<ng-select
[items]="professionsAsOptions"
[hideSelected]="true"
[selectOnTab]="true"
bindLabel="label"
bindValue="item"
placeholder="Выберите специальности"
formControlName="professionIds"
[multiple]="true"
[clearable]="true"
>
</ng-select>
<app-field-error
[field]="editForm.get('professionIds')"
></app-field-error>
</div>

<div class="mb-3">
<button class="btn btn-primary" type="submit">Сохранить</button>
</div>
</form>
</app-dialog>

<app-confirm-dialog
*ngIf="confirmDeletionMessage"
[message]="confirmDeletionMessage"
></app-confirm-dialog>

<app-dialog
[show]="!!selectedProfessionsForModal"
(closed)="closeProfessionsModal()"
[header]="'Специальности'"
>
<ul class="list-group" *ngIf="selectedProfessionsForModal">
<li
class="list-group-item d-flex justify-content-between align-items-center"
*ngFor="let profession of selectedProfessionsForModal"
>
<span>{{ profession.title }}</span>
<span class="badge bg-secondary">ID: {{ profession.id }}</span>
</li>
</ul>
</app-dialog>
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Component styles

// Ensure ng-select dropdown is visible inside modal
:host ::ng-deep {
.modal-body {
overflow: visible;
}

.modal-content {
overflow: visible;
}
}

// Ensure action dropdown is visible outside table container
// Using overflow: clip on x-axis preserves horizontal scroll behavior if needed
// while allowing dropdowns to overflow vertically
.table-responsive {
overflow: visible;

.dropdown-menu {
position: absolute;
z-index: 1050;
}
}

Loading