Type fixes and PIT history pagination

This commit is contained in:
carlosmonastyrski
2025-05-21 23:43:41 -03:00
parent f06004370d
commit 2b948a18f3
16 changed files with 383 additions and 162 deletions

View File

@@ -84,9 +84,10 @@ const getZodDefaultValue = (type: unknown, value: string | number | boolean | Ob
}
};
const bigIntegerColumns = {
const bigIntegerColumns: Record<string, string[]> = {
"folder_commits": ["commitId"]
}
};
const main = async () => {
const tables = (

View File

@@ -10,8 +10,8 @@ import { removeTrailingSlash } from "@app/lib/fn";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { booleanSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type";
import { ChangeType, ResourceChange } from "@app/services/folder-commit/folder-commit-service";
import { ActorAuthMethod, ActorType, AuthMode } from "@app/services/auth/auth-type";
import { ChangeType } from "@app/services/folder-commit/folder-commit-service";
import { commitChangesResponseSchema } from "@app/services/folder-commit/folder-commit-types";
const commitHistoryItemSchema = z.object({
@@ -98,22 +98,34 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
querystring: z.object({
environment: z.string().trim(),
path: z.string().trim().default("/").transform(removeTrailingSlash),
workspaceId: z.string().trim()
workspaceId: z.string().trim(),
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().trim().optional(),
sort: z.enum(["asc", "desc"]).default("desc")
}),
response: {
200: commitHistoryItemSchema.array()
200: z.object({
commits: commitHistoryItemSchema.array(),
total: z.number(),
hasMore: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const commits = await server.services.folderCommit.getCommitsForFolder({
const result = await server.services.folderCommit.getCommitsForFolder({
actor: req.permission?.type,
actorId: req.permission?.id,
actorOrgId: req.permission?.orgId,
actorAuthMethod: req.permission?.authMethod,
projectId: req.query.workspaceId,
environment: req.query.environment,
path: req.query.path
path: req.query.path,
offset: req.query.offset,
limit: req.query.limit,
search: req.query.search,
sort: req.query.sort
});
await server.services.auditLog.createAuditLog({
@@ -124,30 +136,47 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
metadata: {
environment: req.query.environment,
path: req.query.path,
commitCount: commits.length.toString()
commitCount: result.commits.length.toString(),
offset: req.query.offset.toString(),
limit: req.query.limit.toString(),
search: req.query.search,
sort: req.query.sort
}
}
});
return commits.map((commit) => ({
...commit,
commitId: commit.commitId.toString()
}));
return {
commits: result.commits.map((commit) => ({
...commit,
commitId: commit.commitId.toString()
})),
total: result.total,
hasMore: result.hasMore
};
}
});
const getChangeVersions = async (
change: ResourceChange,
change: {
secretVersion?: string;
secretId?: string;
id?: string;
isUpdate?: boolean;
changeType?: string;
},
previousVersion: string,
actorId: string,
actor: string,
actor: ActorType,
actorOrgId: string,
actorAuthMethod: string,
actorAuthMethod: ActorAuthMethod,
folderId: string
) => {
if (change.secretVersion) {
const currentVersion = change.secretVersion || "1";
const secretId = change.secretId ? change.secretId : change.id;
if (!secretId) {
return;
}
// eslint-disable-next-line no-await-in-loop
const versions = await server.services.secret.getSecretVersionsV2ByIds({
actorId,
@@ -176,7 +205,15 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
}
};
const getFolderVersions = async (change: ResourceChange, fromVersion: string, folderId: string) => {
const getFolderVersions = async (
change: {
folderVersion?: string;
isUpdate?: boolean;
changeType?: string;
},
fromVersion: string,
folderId: string
) => {
const currentVersion = change.folderVersion || "1";
// eslint-disable-next-line no-await-in-loop
const versions = await server.services.folder.getFolderVersionsByIds({
@@ -218,20 +255,18 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
commitId: req.params.commitId
});
for (const change of changes.changes) {
if (change.secretVersionId) {
change.objectType = "secret";
if (change.secretVersionId && change.secretVersion) {
// eslint-disable-next-line no-await-in-loop
change.versions = await getChangeVersions(
change,
(Number.parseInt(change.secretVersion, 10) - 1).toString(),
req.permission?.id,
req.permission?.type,
req.permission?.orgId,
req.permission?.authMethod,
req.permission.id,
req.permission.type,
req.permission.orgId,
req.permission.authMethod,
change.folderId
);
} else if (change.folderVersionId && change.folderChangeId) {
change.objectType = "folder";
} else if (change.folderVersionId && change.folderChangeId && change.folderVersion) {
// eslint-disable-next-line no-await-in-loop
change.versions = await getFolderVersions(
change,
@@ -340,10 +375,10 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
change.versions = await getChangeVersions(
change,
change.fromVersion || "1",
req.permission?.id,
req.permission?.type,
req.permission?.orgId,
req.permission?.authMethod,
req.permission.id,
req.permission.type,
req.permission.orgId,
req.permission.authMethod,
diff.folderId
);
}

View File

@@ -2927,6 +2927,10 @@ interface GetProjectPitCommitsEvent {
commitCount: string;
environment: string;
path: string;
offset: string;
limit: string;
search?: string;
sort: string;
};
}

View File

@@ -101,7 +101,7 @@ export const folderCheckpointDALFactory = (db: TDbClient) => {
`${TableName.FolderCommit}.id`
)
.where(`${TableName.FolderCommit}.folderId`, "=", folderId)
.where(`${TableName.FolderCommit}.commitId`, "<=", folderCommitId)
.where(`${TableName.FolderCommit}.commitId`, "<=", folderCommitId.toString())
.select(selectAllTableCols(TableName.FolderCheckpoint))
.select(
db.ref("actorMetadata").withSchema(TableName.FolderCommit),

View File

@@ -14,7 +14,7 @@ import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex";
export type TFolderCommitChangesDALFactory = ReturnType<typeof folderCommitChangesDALFactory>;
type CommitChangeWithCommitInfo = TFolderCommitChanges & {
export type CommitChangeWithCommitInfo = TFolderCommitChanges & {
actorMetadata: unknown;
actorType: string;
message?: string | null;
@@ -25,6 +25,7 @@ type CommitChangeWithCommitInfo = TFolderCommitChanges & {
secretVersion?: string;
secretId?: string;
folderChangeId?: string;
objectType?: string;
versions?: {
secretKey?: string;
secretComment?: string;

View File

@@ -9,7 +9,7 @@ import {
TSecretFolderVersions,
TSecretVersionsV2
} from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { DatabaseError, NotFoundError } from "@app/lib/errors";
import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex";
export type TFolderCommitDALFactory = ReturnType<typeof folderCommitDALFactory>;
@@ -209,8 +209,8 @@ export const folderCommitDALFactory = (db: TDbClient) => {
const commits = await (tx || db.replicaNode())(TableName.FolderCommit)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter({ folderId }, TableName.FolderCommit))
.andWhere(`${TableName.FolderCommit}.commitId`, ">", checkpointCommitNumber)
.andWhere(`${TableName.FolderCommit}.commitId`, "<=", targetCommitNumber)
.andWhere(`${TableName.FolderCommit}.commitId`, ">", checkpointCommitNumber.toString())
.andWhere(`${TableName.FolderCommit}.commitId`, "<=", targetCommitNumber.toString())
.select(selectAllTableCols(TableName.FolderCommit))
.orderBy(`${TableName.FolderCommit}.commitId`, "asc");
@@ -391,12 +391,89 @@ export const folderCommitDALFactory = (db: TDbClient) => {
.select(selectAllTableCols(TableName.FolderCommit))
.orderBy("commitId", "desc")
.first();
if (!doc) {
throw new NotFoundError({
message: `Folder commit not found for ID ${id}`
});
}
return doc;
} catch (error) {
throw new DatabaseError({ error, name: "FindById" });
}
};
const findByFolderIdPaginated = async (
folderId: string,
options: {
offset?: number;
limit?: number;
search?: string;
sort?: "asc" | "desc";
} = {},
tx?: Knex
): Promise<{
commits: TFolderCommits[];
total: number;
hasMore: boolean;
}> => {
try {
const { offset = 0, limit = 20, search, sort = "desc" } = options;
const trx = tx || db.replicaNode();
// Build base query
let baseQuery = trx(TableName.FolderCommit).where({ folderId });
// Add search functionality
if (search) {
baseQuery = baseQuery.where((qb) => {
void qb.whereILike("message", `%${search}%`);
});
}
// Get total count
const totalResult = await baseQuery.clone().count("*", { as: "count" }).first();
const total = Number(totalResult?.count || 0);
// Get paginated commits
const folderCommits = await baseQuery.select("*").orderBy("createdAt", sort).limit(limit).offset(offset);
if (folderCommits.length === 0) {
return { commits: [], total, hasMore: false };
}
// Get all commit IDs for changes
const commitIds = folderCommits.map((commit) => commit.id);
// Get all related changes
const changes = await trx(TableName.FolderCommitChanges).whereIn("folderCommitId", commitIds).select("*");
const changesMap = changes.reduce(
(acc, change) => {
const { folderCommitId } = change;
if (!acc[folderCommitId]) acc[folderCommitId] = [];
acc[folderCommitId].push(change);
return acc;
},
{} as Record<string, TFolderCommitChanges[]>
);
const commitsWithChanges = folderCommits.map((commit) => ({
...commit,
changes: changesMap[commit.id] || []
}));
const hasMore = offset + limit < total;
return {
commits: commitsWithChanges,
total,
hasMore
};
} catch (error) {
throw new DatabaseError({ error, name: "FindByFolderIdPaginated" });
}
};
return {
...restOfOrm,
findByFolderId,
@@ -411,6 +488,7 @@ export const folderCommitDALFactory = (db: TDbClient) => {
findLatestCommitByFolderIds,
findAllFolderCommitsAfter,
findPreviousCommitTo,
findById
findById,
findByFolderIdPaginated
};
};

View File

@@ -107,7 +107,7 @@ describe("folderCommitServiceFactory", () => {
deleteById: vi.fn().mockResolvedValue({}),
create: vi.fn().mockResolvedValue({}),
updateById: vi.fn().mockResolvedValue({}),
find: vi.fn().mockResolvedValue([]),
find: vi.fn().mockResolvedValue({}), // Changed from [] to {} to match Object.values() expectation
findByIdsWithLatestVersion: vi.fn().mockResolvedValue({})
};
@@ -260,6 +260,17 @@ describe("folderCommitServiceFactory", () => {
mockFolderDAL.findByParentId.mockResolvedValue([]);
mockSecretVersionV2BridgeDAL.findLatestVersionByFolderId.mockResolvedValue([]);
// Mock folderVersionDAL.find to return an object with folder version data
mockFolderVersionDAL.find.mockResolvedValue({
"folder-version-1": {
id: "folder-version-1",
folderId: "sub-folder-id",
envId: "env-id",
name: "Test Folder",
version: 1
}
});
const data = {
actor: {
type: ActorType.IDENTITY,
@@ -449,6 +460,12 @@ describe("folderCommitServiceFactory", () => {
const actorType = ActorType.USER;
const projectId = "project-id";
// Mock the transaction to properly handle the error
mockFolderCommitDAL.transaction.mockImplementation(async (callback) => {
return await callback({} as Knex);
});
// Mock findById to return null inside the transaction
mockFolderCommitDAL.findById.mockResolvedValue(null);
// Act & Assert
@@ -501,8 +518,8 @@ describe("folderCommitServiceFactory", () => {
const actorType = ActorType.USER;
const differences = [
{ type: "secret", id: "secret-1", versionId: "v1", changeType: ChangeType.CREATE, commitId: 1 },
{ type: "folder", id: "folder-1", versionId: "v2", changeType: ChangeType.UPDATE, commitId: 1 }
{ type: "secret", id: "secret-1", versionId: "v1", changeType: ChangeType.CREATE, commitId: BigInt(1) },
{ type: "folder", id: "folder-1", versionId: "v2", changeType: ChangeType.UPDATE, commitId: BigInt(1) }
];
const secretVersions = {

View File

@@ -76,6 +76,7 @@ export type ResourceChange = {
commitId: bigint;
createdAt?: Date;
parentId?: string;
isUpdate?: boolean;
secretKey?: string;
secretVersion?: string;
secretId?: string;
@@ -997,7 +998,11 @@ export const folderCommitServiceFactory = ({
actorOrgId,
projectId,
environment,
path
path,
offset = 0,
limit = 20,
search,
sort = "desc"
}: {
actor: ActorType;
actorId: string;
@@ -1006,6 +1011,10 @@ export const folderCommitServiceFactory = ({
projectId: string;
environment: string;
path: string;
offset: number;
limit: number;
search?: string;
sort: "asc" | "desc";
}) => {
await checkProjectPermission({
actor,
@@ -1020,7 +1029,12 @@ export const folderCommitServiceFactory = ({
message: `Folder not found for project ID ${projectId}, environment ${environment}, path ${path}`
});
}
const folderCommits = await folderCommitDAL.findByFolderId(folder.id);
const folderCommits = await folderCommitDAL.findByFolderIdPaginated(folder.id, {
offset,
limit,
search,
sort
});
return folderCommits;
};
@@ -1516,6 +1530,9 @@ export const folderCommitServiceFactory = ({
projectId: string;
message?: string;
}) => {
if (!permissionService) {
throw new Error("Permission service not initialized");
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
@@ -1551,8 +1568,11 @@ export const folderCommitServiceFactory = ({
}
// Sort commits by commitId (which appears to be numeric)
const sortedCommits = allCommits.sort((a, b) => a.commitId - b.commitId);
const sortedCommits = allCommits.sort((a, b) => {
if (a.commitId < b.commitId) return -1;
if (a.commitId > b.commitId) return 1;
return 0;
});
// Find the index of the commit to revert
const commitIndex = sortedCommits.findIndex((c) => c.id === commitId);
if (commitIndex === -1) {

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
const secretVersionSchema = z.object({
secretKey: z.string(),
secretKey: z.string().optional().nullable(),
secretComment: z.string().optional().nullable(),
skipMultilineEncoding: z.boolean().optional().nullable(),
secretReminderRepeatDays: z.number().optional().nullable(),
@@ -13,7 +13,7 @@ const secretVersionSchema = z.object({
});
const folderVersionSchema = z.object({
name: z.string()
name: z.string().optional().nullable()
});
const baseChangeSchema = z.object({
@@ -37,46 +37,37 @@ const baseChangeSchema = z.object({
folderId: z.string().optional()
});
const secretChangeSchema = baseChangeSchema.extend({
objectType: z.literal("secret"),
secretVersionId: z.string(),
folderVersionId: z.null(),
folderName: z.null(),
folderChangeId: z.null(),
secretKey: z.string(),
secretVersion: z.union([z.string(), z.number()]),
secretId: z.string(),
versions: z.array(secretVersionSchema).optional()
const commitChangeSchema = baseChangeSchema.extend({
secretVersionId: z.string().optional().nullable(),
folderVersionId: z.string().optional().nullable(),
folderName: z.string().optional().nullable(),
folderChangeId: z.string().optional().nullable(),
secretKey: z.string().optional().nullable(),
secretVersion: z.union([z.string(), z.number()]).optional().nullable(),
secretId: z.string().optional().nullable(),
folderVersion: z.union([z.string(), z.number()]).optional().nullable(),
versions: z.array(z.union([secretVersionSchema, folderVersionSchema])).optional()
});
const folderChangeSchema = baseChangeSchema.extend({
objectType: z.literal("folder"),
secretVersionId: z.null(),
folderVersionId: z.string(),
folderName: z.string(),
folderChangeId: z.string(),
folderVersion: z.union([z.string(), z.number()]),
secretKey: z.null(),
secretId: z.null(),
versions: z.array(folderVersionSchema).optional()
});
const commitChangeSchema = z.discriminatedUnion("objectType", [secretChangeSchema, folderChangeSchema]);
const commitSchema = z.object({
id: z.string(),
commitId: z.string(),
actorMetadata: z.object({
id: z.string(),
name: z.string()
}),
actorMetadata: z
.union([
z.object({
id: z.string().optional(),
name: z.string().optional()
}),
z.unknown()
])
.optional(),
actorType: z.string(),
message: z.string().nullable(),
message: z.string().nullable().optional(),
folderId: z.string(),
envId: z.string(),
createdAt: z.union([z.string(), z.date()]),
updatedAt: z.union([z.string(), z.date()]),
changes: z.array(commitChangeSchema)
changes: z.array(commitChangeSchema).optional()
});
export const commitChangesResponseSchema = z.object({

View File

@@ -41,7 +41,7 @@ export const folderTreeCheckpointDALFactory = (db: TDbClient) => {
)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(`${TableName.FolderCommit}.envId`, "=", envId)
.where(`${TableName.FolderCommit}.commitId`, "<=", folderCommitId)
.andWhere(`${TableName.FolderCommit}.commitId`, "<=", folderCommitId.toString())
.select(selectAllTableCols(TableName.FolderTreeCheckpoint))
.select(db.ref("commitId").withSchema(TableName.FolderCommit))
.orderBy(`${TableName.FolderCommit}.commitId`, "desc")

View File

@@ -145,7 +145,9 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => {
// Create lookup map for max versions
const maxVersionMap = maxVersionsQuery.reduce<Record<string, number>>((acc, item) => {
acc[item.folderId] = item.maxVersion;
if (item.maxVersion) {
acc[item.folderId] = item.maxVersion;
}
return acc;
}, {});
@@ -160,6 +162,7 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => {
try {
if (!folderIds.length && (!versionIds || !versionIds.length)) return {};
// Run both queries in parallel
const [latestVersions, specificVersionsWithLatest] = await Promise.all([
folderIds.length ? getLatestFolderVersions(folderIds, tx) : [],
versionIds?.length ? getSpecificFolderVersionsWithLatest(versionIds, tx) : []

View File

@@ -324,7 +324,7 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
.whereIn("secretId", specificSecretIds)
.groupBy("secretId")
.select("secretId")
.max("version as maxVersion");
.max("version", { as: "maxVersion" });
// Create a lookup map for max versions
const maxVersionMap = maxVersionsQuery.reduce(

View File

@@ -68,13 +68,29 @@ const fetchFolderCommitsCount = async ({
const fetchFolderCommitHistory = async (
workspaceId: string,
environment: string,
directory: string
): Promise<CommitHistoryItem[]> => {
const res = await apiRequest.get<CommitHistoryItem[]>("/api/v1/pit/commits", {
directory: string,
offset: number = 0,
limit: number = 20,
search?: string,
sort: "asc" | "desc" = "desc"
): Promise<{
commits: CommitHistoryItem[];
total: number;
hasMore: boolean;
}> => {
const res = await apiRequest.get<{
commits: CommitHistoryItem[];
total: number;
hasMore: boolean;
}>("/api/v1/pit/commits", {
params: {
environment,
path: directory,
workspaceId
workspaceId,
offset,
limit,
search,
sort
}
});
return res.data;
@@ -225,15 +241,30 @@ export const useGetFolderCommitsCount = ({
export const useGetFolderCommitHistory = ({
workspaceId,
environment,
directory
directory,
offset = 0,
limit = 20,
search,
sort = "desc"
}: {
workspaceId: string;
environment: string;
directory: string;
offset?: number;
limit?: number;
search?: string;
sort?: "asc" | "desc";
}) => {
return useQuery({
queryKey: commitKeys.history({ workspaceId, environment, directory }),
queryFn: () => fetchFolderCommitHistory(workspaceId, environment, directory),
queryKey: [
commitKeys.history({ workspaceId, environment, directory }),
offset,
limit,
search,
sort
],
queryFn: () =>
fetchFolderCommitHistory(workspaceId, environment, directory, offset, limit, search, sort),
enabled: Boolean(workspaceId && environment)
});
};

View File

@@ -11,7 +11,8 @@ export enum ApprovalStatus {
export enum CommitType {
DELETE = "delete",
UPDATE = "update",
CREATE = "create"
CREATE = "create",
ADD = "add"
}
export type TSecretApprovalSecChangeData = {

View File

@@ -22,6 +22,7 @@ import {
import { usePopUp } from "@app/hooks";
import { CommitWithChanges } from "@app/hooks/api/folderCommits";
import { useCommitRevert, useGetCommitDetails } from "@app/hooks/api/folderCommits/queries";
import { CommitType } from "@app/hooks/api/types";
import { SecretVersionDiffView } from "../SecretVersionDiffView";
import { MergedItem } from "./types";
@@ -147,9 +148,9 @@ export const CommitDetailsTab = ({
const commitChanges = parsedCommitDetails.changes?.changes || [];
// Separate changes by type
const addedChanges = commitChanges.filter((c) => c.changeType === "add" && !c.isUpdate);
const updatedChanges = commitChanges.filter((c) => c.changeType === "add" && c.isUpdate);
const deletedChanges = commitChanges.filter((c) => c.changeType === "delete");
const addedChanges = commitChanges.filter((c) => c.changeType === CommitType.ADD && !c.isUpdate);
const updatedChanges = commitChanges.filter((c) => c.changeType === CommitType.ADD && c.isUpdate);
const deletedChanges = commitChanges.filter((c) => c.changeType === CommitType.DELETE);
// Create merged item list from changes only
const changedItems: MergedItem[] = [];

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
faArrowDownWideShort,
faArrowUpWideShort,
@@ -144,41 +144,68 @@ export const CommitHistoryTab = ({
secretPath: string;
}) => {
const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [visibleCommits, setVisibleCommits] = useState(10);
const [offset, setOffset] = useState(0);
const [allCommits, setAllCommits] = useState<Commit[]>([]);
const debounceTimeoutRef = useRef<NodeJS.Timeout>();
const limit = 5;
const { data: commits, isLoading } = useGetFolderCommitHistory({
// Debounce search term
useEffect(() => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
debounceTimeoutRef.current = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 500);
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, [searchTerm]);
const {
data: response,
isLoading,
isFetching
} = useGetFolderCommitHistory({
workspaceId: projectId,
environment,
directory: secretPath
directory: secretPath,
offset,
limit,
search: debouncedSearchTerm,
sort: sortDirection
});
const filteredCommits = useMemo(() => {
if (!commits?.length) return [];
const commits = response?.commits || [];
const hasMore = response?.hasMore || false;
return commits.filter(
(commit) =>
commit.id?.includes(searchTerm) ||
commit.message?.toLowerCase().includes(searchTerm.toLowerCase()) ||
commit.actorMetadata?.name?.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [commits, searchTerm]);
// Reset accumulated commits when search or sort changes
useEffect(() => {
setAllCommits([]);
setOffset(0);
}, [debouncedSearchTerm, sortDirection]);
const sortedCommits = useMemo(() => {
if (!filteredCommits?.length) return [];
// Accumulate commits instead of replacing them
useEffect(() => {
if (commits.length > 0) {
if (offset === 0) {
// First load or after search/sort change - replace all commits
setAllCommits(commits);
} else {
// Subsequent loads - append new commits
setAllCommits((prev) => [...prev, ...commits]);
}
}
}, [commits, offset]);
return [...filteredCommits].sort((a, b) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return sortDirection === "desc" ? dateB - dateA : dateA - dateB;
});
}, [filteredCommits, sortDirection]);
const displayedCommits = useMemo(() => {
return sortedCommits.slice(0, visibleCommits);
}, [sortedCommits, visibleCommits]);
const groupedCommits = useMemo(() => {
return displayedCommits.reduce(
return allCommits.reduce(
(acc, commit) => {
const date = format(new Date(commit.createdAt), "MMM d, yyyy");
if (!acc[date]) {
@@ -189,25 +216,21 @@ export const CommitHistoryTab = ({
},
{} as Record<string, Commit[]>
);
}, [displayedCommits]);
}, [allCommits]);
const handleSort = useCallback(() => {
setSortDirection((prev) => (prev === "desc" ? "asc" : "desc"));
}, []);
const loadMoreCommits = useCallback(() => {
setVisibleCommits((prev) => prev + 10);
const handleSearch = useCallback((value: string) => {
setSearchTerm(value);
}, []);
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Spinner size="lg" aria-label="Loading commits" />
</div>
);
}
const hasMoreCommits = sortedCommits.length > visibleCommits;
const loadMoreCommits = useCallback(() => {
if (hasMore && !isFetching) {
setOffset((prev) => prev + limit);
}
}, [hasMore, isFetching, limit]);
return (
<div className="w-full">
@@ -217,7 +240,8 @@ export const CommitHistoryTab = ({
<Input
placeholder="Search commits..."
className="h-10 w-full rounded-md border-transparent bg-zinc-800 pl-9 pr-3 text-sm text-white placeholder-gray-400 focus:border-gray-600 focus:ring-primary-500/20"
onChange={(e) => setSearchTerm(e.target.value)}
onChange={(e) => handleSearch(e.target.value)}
value={searchTerm}
aria-label="Search commits"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 transform text-gray-400">
@@ -239,43 +263,57 @@ export const CommitHistoryTab = ({
</div>
</div>
<div className="space-y-8">
{Object.keys(groupedCommits).length > 0 ? (
<>
{Object.entries(groupedCommits).map(([date, dateCommits]) => (
<DateGroup
key={date}
date={date}
commits={dateCommits}
onSelectCommit={onSelectCommit}
{isLoading && offset === 0 ? (
<div className="flex h-64 items-center justify-center">
<Spinner size="lg" aria-label="Loading commits" />
</div>
) : (
<div className="space-y-8">
{Object.keys(groupedCommits).length > 0 ? (
<>
{Object.entries(groupedCommits).map(([date, dateCommits]) => (
<DateGroup
key={date}
date={date}
commits={dateCommits}
onSelectCommit={onSelectCommit}
/>
))}
</>
) : (
<div className="text-white-400 flex min-h-40 flex-col items-center justify-center rounded-lg bg-zinc-900 py-8 text-center">
<FontAwesomeIcon
icon={faSearch}
className="text-white-500 mb-3 text-3xl"
aria-hidden="true"
/>
))}
</>
) : (
<div className="text-white-400 flex min-h-40 flex-col items-center justify-center rounded-lg bg-zinc-900 py-8 text-center">
<FontAwesomeIcon
icon={faSearch}
className="text-white-500 mb-3 text-3xl"
aria-hidden="true"
/>
<p>No matching commits found. Try a different search term.</p>
</div>
)}
<p>No matching commits found. Try a different search term.</p>
</div>
)}
{hasMoreCommits && (
<div className="flex justify-center pb-2">
<Button
variant="outline_bg"
size="md"
className="rounded-md bg-zinc-900 px-6 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
onClick={loadMoreCommits}
aria-label="Load more commits"
>
Load more commits
</Button>
</div>
)}
</div>
{hasMore && (
<div className="flex justify-center pb-2">
<Button
variant="outline_bg"
size="md"
className="rounded-md bg-zinc-900 px-6 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
onClick={loadMoreCommits}
disabled={isFetching}
aria-label="Load more commits"
>
{isFetching ? (
<>
<Spinner size="sm" className="mr-2" />
Loading...
</>
) : (
"Load more commits"
)}
</Button>
</div>
)}
</div>
)}
</div>
);
};