feat(table,runs,sessions): add server-side sorting and pagination
- add sortable column support to Table with asc/desc/unsorted icons - active sort icon uses --color-primary; no header background change - Table supports server-side mode via total/page/onPageChange props - wire server-side sort+pagination in ScenariosPage, AllRunsPage, RunsPage, SessionsPage - remove steps count column from run tables - fix server: RunsQueryDto now extends PaginationQueryDto with orderBy/orderDir - fix findAllRuns to use QueryBuilder; sort by scenario.name via JOIN - add per-resource typed query DTOs with @IsIn allowlist on orderBy - prevents SQL injection and returns 400 for unknown orderBy values
This commit is contained in:
@@ -11,8 +11,8 @@ import {
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { PaginationQueryDto } from "../common/dto/pagination.dto";
|
||||
import { CredentialOrderBy, CredentialService } from "./credential.service";
|
||||
import { CredentialQueryDto } from "./dto/credential-query.dto";
|
||||
import { CredentialService } from "./credential.service";
|
||||
import { CreateCredentialDto } from "./dto/create-credential.dto";
|
||||
import { CredentialExportDto } from "./dto/credential-export.dto";
|
||||
import { UpdateCredentialDto } from "./dto/update-credential.dto";
|
||||
@@ -39,7 +39,7 @@ export class CredentialController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all credentials (paginated)" })
|
||||
@ApiResponse({ status: 200, description: "Paginated credentials" })
|
||||
findAll(@Query() query: PaginationQueryDto<CredentialOrderBy>) {
|
||||
findAll(@Query() query: CredentialQueryDto) {
|
||||
return this.credentialService.findAll(query);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional } from "class-validator";
|
||||
import { PaginationQueryDto } from "../../common/dto/pagination.dto";
|
||||
import { CredentialOrderBy } from "../credential.service";
|
||||
|
||||
const CREDENTIAL_ORDER_BY: CredentialOrderBy[] = ["id", "name", "lastUsedAt"];
|
||||
|
||||
export class CredentialQueryDto extends PaginationQueryDto<CredentialOrderBy> {
|
||||
@ApiPropertyOptional({ enum: CREDENTIAL_ORDER_BY })
|
||||
@IsOptional()
|
||||
@IsIn(CREDENTIAL_ORDER_BY)
|
||||
declare orderBy?: CredentialOrderBy;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional } from "class-validator";
|
||||
import { PaginationQueryDto } from "../../common/dto/pagination.dto";
|
||||
import { EnvironmentOrderBy } from "../environment.service";
|
||||
|
||||
const ENVIRONMENT_ORDER_BY: EnvironmentOrderBy[] = ["id", "name", "createdAt", "updatedAt"];
|
||||
|
||||
export class EnvironmentQueryDto extends PaginationQueryDto<EnvironmentOrderBy> {
|
||||
@ApiPropertyOptional({ enum: ENVIRONMENT_ORDER_BY })
|
||||
@IsOptional()
|
||||
@IsIn(ENVIRONMENT_ORDER_BY)
|
||||
declare orderBy?: EnvironmentOrderBy;
|
||||
}
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { PaginationQueryDto } from "../common/dto/pagination.dto";
|
||||
import { EnvironmentQueryDto } from "./dto/environment-query.dto";
|
||||
import { CreateEnvironmentDto } from "./dto/create-environment.dto";
|
||||
import { EnvironmentExportDto } from "./dto/environment-export.dto";
|
||||
import { UpdateEnvironmentDto } from "./dto/update-environment.dto";
|
||||
import { EnvironmentOrderBy, EnvironmentService } from "./environment.service";
|
||||
import { EnvironmentService } from "./environment.service";
|
||||
|
||||
@ApiTags("environments")
|
||||
@Controller("environments")
|
||||
@@ -40,7 +40,7 @@ export class EnvironmentController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all environments (paginated)" })
|
||||
@ApiResponse({ status: 200, description: "Paginated environments" })
|
||||
findAll(@Query() query: PaginationQueryDto<EnvironmentOrderBy>) {
|
||||
findAll(@Query() query: EnvironmentQueryDto) {
|
||||
return this.environmentService.findAll(query);
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,15 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional } from "class-validator";
|
||||
import { PaginationQueryDto } from "../../common/dto/pagination.dto";
|
||||
import { ScenarioOrderBy } from "../scenario.service";
|
||||
|
||||
export { PaginationQueryDto } from "../../common/dto/pagination.dto";
|
||||
|
||||
const SCENARIO_ORDER_BY: ScenarioOrderBy[] = ["id", "name", "createdAt", "updatedAt"];
|
||||
|
||||
export class ScenarioQueryDto extends PaginationQueryDto<ScenarioOrderBy> {
|
||||
@ApiPropertyOptional({ enum: SCENARIO_ORDER_BY })
|
||||
@IsOptional()
|
||||
@IsIn(SCENARIO_ORDER_BY)
|
||||
declare orderBy?: ScenarioOrderBy;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsIn, IsInt, IsOptional, Min } from "class-validator";
|
||||
import { IsIn, IsOptional } from "class-validator";
|
||||
import { RunStatus } from "../scenario-run.entity";
|
||||
import { PaginationQueryDto } from "../../common/dto/pagination.dto";
|
||||
|
||||
export class RunsQueryDto {
|
||||
@ApiPropertyOptional({ example: 1, default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
export type RunOrderBy = "createdAt" | "status" | "scenario.name";
|
||||
|
||||
@ApiPropertyOptional({ example: 20, default: 20 })
|
||||
const RUN_ORDER_BY: RunOrderBy[] = ["createdAt", "status", "scenario.name"];
|
||||
|
||||
export class RunsQueryDto extends PaginationQueryDto<RunOrderBy> {
|
||||
@ApiPropertyOptional({ enum: RUN_ORDER_BY })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number = 20;
|
||||
@IsIn(RUN_ORDER_BY)
|
||||
declare orderBy?: RunOrderBy;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ["pending", "in_progress", "pass", "fail"],
|
||||
|
||||
@@ -18,12 +18,12 @@ import { AddScenarioCredentialDto } from "./dto/add-scenario-credential.dto";
|
||||
import { CreateScenarioRunDto } from "./dto/create-scenario-run.dto";
|
||||
import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
|
||||
import { CreateScenarioDto } from "./dto/create-scenario.dto";
|
||||
import { PaginationQueryDto } from "./dto/pagination-query.dto";
|
||||
import { ScenarioQueryDto } from "./dto/pagination-query.dto";
|
||||
import { RunsQueryDto } from "./dto/runs-query.dto";
|
||||
import { ExportEntity } from "./dto/scenario-export.dto";
|
||||
import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
|
||||
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
|
||||
import { ScenarioOrderBy, ScenarioService } from "./scenario.service";
|
||||
import { ScenarioService } from "./scenario.service";
|
||||
|
||||
@ApiTags("scenarios")
|
||||
@Controller("scenarios")
|
||||
@@ -49,7 +49,7 @@ export class ScenarioController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all scenarios (paginated)" })
|
||||
@ApiResponse({ status: 200 })
|
||||
findAll(@Query() query: PaginationQueryDto<ScenarioOrderBy>) {
|
||||
findAll(@Query() query: ScenarioQueryDto) {
|
||||
return this.scenarioService.findAll(query);
|
||||
}
|
||||
|
||||
|
||||
@@ -287,12 +287,13 @@ export class ScenarioService {
|
||||
await this.findOne(scenarioId); // 404 guard
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const orderBy = query.orderBy ?? "createdAt";
|
||||
const orderDir = query.orderDir ?? "DESC";
|
||||
const where: Record<string, unknown> = { scenarioId };
|
||||
if (query.status) where["status"] = query.status;
|
||||
const [data, total] = await this.runRepo.findAndCount({
|
||||
where,
|
||||
relations: ["stepRuns"],
|
||||
order: { createdAt: "DESC" },
|
||||
order: { [orderBy]: orderDir },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
@@ -306,15 +307,19 @@ export class ScenarioService {
|
||||
> {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const where: Record<string, unknown> = {};
|
||||
if (query.status) where["status"] = query.status;
|
||||
const [data, total] = await this.runRepo.findAndCount({
|
||||
where,
|
||||
relations: ["stepRuns", "scenario"],
|
||||
order: { createdAt: "DESC" },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
const orderBy = query.orderBy ?? "createdAt";
|
||||
const orderDir = query.orderDir ?? "DESC";
|
||||
const qb = this.runRepo
|
||||
.createQueryBuilder("run")
|
||||
.leftJoinAndSelect("run.scenario", "scenario");
|
||||
if (query.status) qb.where("run.status = :status", { status: query.status });
|
||||
if (orderBy === "scenario.name") {
|
||||
qb.orderBy("scenario.name", orderDir);
|
||||
} else {
|
||||
qb.orderBy(`run.${orderBy}`, orderDir);
|
||||
}
|
||||
qb.skip((page - 1) * limit).take(limit);
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
return { data, total, page, limit } as PaginatedResult<
|
||||
ScenarioRunEntity & { scenario: ScenarioEntity }
|
||||
>;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional } from "class-validator";
|
||||
import { PaginationQueryDto } from "../common/dto/pagination.dto";
|
||||
import { SessionOrderBy } from "./session.service";
|
||||
|
||||
const SESSION_ORDER_BY: SessionOrderBy[] = ["id", "sessionName", "status", "lastUsedAt", "createdAt", "updatedAt"];
|
||||
|
||||
export class SessionQueryDto extends PaginationQueryDto<SessionOrderBy> {
|
||||
@ApiPropertyOptional({ enum: SESSION_ORDER_BY })
|
||||
@IsOptional()
|
||||
@IsIn(SESSION_ORDER_BY)
|
||||
declare orderBy?: SessionOrderBy;
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { PaginationQueryDto } from "../common/dto/pagination.dto";
|
||||
import { SessionQueryDto } from "./session-query.dto";
|
||||
import { SessionContextService } from "./session-context.service";
|
||||
import { SessionOrderBy, SessionService } from "./session.service";
|
||||
import { SessionService } from "./session.service";
|
||||
|
||||
function maskToken(token: string, head = 6, tail = 4): string {
|
||||
if (token.length <= head + tail + 1) return token;
|
||||
@@ -29,7 +29,7 @@ export class SessionController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all stored sessions (paginated)" })
|
||||
@ApiResponse({ status: 200, description: "Paginated sessions" })
|
||||
findAll(@Query() query: PaginationQueryDto<SessionOrderBy>) {
|
||||
findAll(@Query() query: SessionQueryDto) {
|
||||
return this.sessionService.findAll(query);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional } from "class-validator";
|
||||
import { PaginationQueryDto } from "../../common/dto/pagination.dto";
|
||||
import { SnippetOrderBy } from "../snippet.service";
|
||||
|
||||
const SNIPPET_ORDER_BY: SnippetOrderBy[] = ["id", "alias", "title"];
|
||||
|
||||
export class SnippetQueryDto extends PaginationQueryDto<SnippetOrderBy> {
|
||||
@ApiPropertyOptional({ enum: SNIPPET_ORDER_BY })
|
||||
@IsOptional()
|
||||
@IsIn(SNIPPET_ORDER_BY)
|
||||
declare orderBy?: SnippetOrderBy;
|
||||
}
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { PaginationQueryDto } from "../common/dto/pagination.dto";
|
||||
import { SnippetQueryDto } from "./dto/snippet-query.dto";
|
||||
import { CreateSnippetDto } from "./dto/create-snippet.dto";
|
||||
import { SnippetExportDto } from "./dto/snippet-export.dto";
|
||||
import { UpdateSnippetDto } from "./dto/update-snippet.dto";
|
||||
import { SnippetOrderBy, SnippetService } from "./snippet.service";
|
||||
import { SnippetService } from "./snippet.service";
|
||||
|
||||
@ApiTags("snippets")
|
||||
@Controller("snippets")
|
||||
@@ -40,7 +40,7 @@ export class SnippetController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all snippets (paginated)" })
|
||||
@ApiResponse({ status: 200, description: "Paginated snippets" })
|
||||
findAll(@Query() query: PaginationQueryDto<SnippetOrderBy>) {
|
||||
findAll(@Query() query: SnippetQueryDto) {
|
||||
return this.snippetService.findAll(query);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user