diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/ProjectSection/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/ProjectSection/index.tsx index eea74806fe..5466d528ea 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/ProjectSection/index.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/ProjectSection/index.tsx @@ -3,6 +3,7 @@ import { formatCount } from '$/lib/formatCount' import { ROUTES } from '$/services/routes' import { useActiveRunsCount } from '$/stores/runs/activeRuns' +import useFeature from '$/stores/useFeature' import { Commit } from '@latitude-data/core/schema/models/types/Commit' import { Project } from '@latitude-data/core/schema/models/types/Project' @@ -84,6 +85,7 @@ export default function ProjectSection({ limitedView?: boolean }) { const disableRunsNotifications = !!limitedView + const issuesFeature = useFeature('issues') const { data: active } = useActiveRunsCount({ project: project, realtime: !disableRunsNotifications, @@ -113,6 +115,13 @@ export default function ProjectSection({ : `There are ${count} runs in progress`, }, }, + issuesFeature.isEnabled && { + label: 'Issues', + iconName: 'shieldAlert', + route: ROUTES.projects + .detail({ id: project.id }) + .commits.detail({ uuid: commit.uuid }).issues.root, + }, { label: 'Analytics', route: ROUTES.projects @@ -128,7 +137,13 @@ export default function ProjectSection({ iconName: 'history', }, ].filter(Boolean) as ProjectRoute[], - [project, commit, active, disableRunsNotifications], + [ + project, + commit, + issuesFeature.isEnabled, + active, + disableRunsNotifications, + ], ) return ( diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/(withTabs)/logs/page.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/(withTabs)/logs/page.tsx index ae16290450..868af19123 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/(withTabs)/logs/page.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/(withTabs)/logs/page.tsx @@ -103,7 +103,7 @@ export default async function DocumentPage({ await getDocumentLogsApproximatedCountCached(documentUuid) if (approximatedCount > LIMITED_VIEW_THRESHOLD) { return DocumentLogsLimitedPage({ - workspace: workspace, + workspace, projectId: projectId, commit: commit, document: document, diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesDashboard/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesDashboard/index.tsx new file mode 100644 index 0000000000..d97a08b040 --- /dev/null +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesDashboard/index.tsx @@ -0,0 +1,93 @@ +'use client' + +import { useMemo, useRef } from 'react' +import { useSearchParams } from 'next/navigation' +import { useIssues } from '$/stores/issues' +import { + SafeIssuesParams, + convertIssuesParamsToQueryParams, +} from '@latitude-data/constants/issues' +import { useCurrentCommit } from '$/app/providers/CommitProvider' +import { useCurrentProject } from '$/app/providers/ProjectProvider' +import { useIssuesParameters } from '$/stores/issues/useIssuesParameters' +import { useOnce } from '$/hooks/useMount' +import { TableWithHeader } from '@latitude-data/web-ui/molecules/ListingHeader' +import { ROUTES } from '$/services/routes' +import { IssuesServerResponse } from '../../page' +import { IssuesTable } from '../IssuesTable' +import { IssuesFilters } from '../IssuesFilters' +import { SearchIssuesInput } from '../SearchIssuesInput' + +export function IssuesDashboard({ + serverResponse, + params, +}: { + serverResponse: IssuesServerResponse + params: SafeIssuesParams +}) { + const { init, urlParameters, onSuccessIssuesFetch } = useIssuesParameters( + (state) => ({ + init: state.init, + urlParameters: state.urlParameters, + onSuccessIssuesFetch: state.onSuccessIssuesFetch, + }), + ) + const queryParams = useSearchParams() + const { project } = useCurrentProject() + const { commit } = useCurrentCommit() + const page = Number(queryParams.get('page') ?? '1') + const initialPage = useRef(page) + const currentRoute = ROUTES.projects + .detail({ id: project.id }) + .commits.detail({ uuid: commit.uuid }).issues.root + const searchParams = useMemo(() => { + if (!urlParameters) return convertIssuesParamsToQueryParams(params) + return urlParameters + }, [urlParameters, params]) + const { data: issues, isLoading } = useIssues( + { + projectId: project.id, + commitUuid: commit.uuid, + searchParams, + initialPage: initialPage.current, + onSuccess: onSuccessIssuesFetch, + }, + { + fallbackData: serverResponse, + }, + ) + + useOnce(() => { + init({ + params: { + ...params, + totalCount: serverResponse.totalCount, + }, + onStateChange: (queryParams) => { + // NOTE: Next.js do RSC navigation, so we need to use the History API to avoid a full page reload + window.history.replaceState( + null, + '', + `${currentRoute}?${new URLSearchParams(queryParams).toString()}`, + ) + }, + }) + }) + + return ( +
+ } + actions={} + table={ + + } + /> +
+ ) +} diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/index.tsx new file mode 100644 index 0000000000..02a424550d --- /dev/null +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/index.tsx @@ -0,0 +1,81 @@ +import { useCallback } from 'react' +import { useIssuesParameters } from '$/stores/issues/useIssuesParameters' +import { DatePickerRange } from '@latitude-data/web-ui/atoms/DatePicker' +import { RadioToggleInput } from '@latitude-data/web-ui/molecules/RadioToggleInput' +import { Select } from '@latitude-data/web-ui/atoms/Select' +import { + SafeIssuesParams, + ISSUE_STATUS, + IssueStatus, +} from '@latitude-data/constants/issues' +import { useDocuments } from './useDocuments' +import { useSeenAtDatePicker } from './useSeenAtDatePicker' + +const STATUS_OPTIONS = [ + { label: 'Active', value: ISSUE_STATUS.active }, + { label: 'Regressed', value: ISSUE_STATUS.regressed }, + { label: 'Archived', value: ISSUE_STATUS.archived }, +] +export function IssuesFilters({ + serverParams, +}: { + serverParams: SafeIssuesParams +}) { + const { filters, setFilters } = useIssuesParameters((state) => ({ + filters: state.filters, + setFilters: state.setFilters, + })) + const { dateWindow, onDateWindowChange } = useSeenAtDatePicker({ + serverParams, + }) + const { documentOptions, isLoading: isLoadingDocuments } = useDocuments() + + const onStatusChange = useCallback( + (status: IssueStatus) => { + setFilters({ status }) + }, + [setFilters], + ) + + const onDocumentChange = useCallback( + (documentUuid?: string | null) => { + setFilters({ documentUuid }) + }, + [setFilters], + ) + + return ( + <> +
+ + removable + searchable + loading={isLoadingDocuments} + width='full' + align='end' + name='document-filter' + placeholder='Select document' + options={documentOptions} + value={filters.documentUuid ?? serverParams.filters.documentUuid} + onChange={onDocumentChange} + /> +
+ + + + ) +} diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/useDocuments.ts b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/useDocuments.ts new file mode 100644 index 0000000000..abadcfd4cb --- /dev/null +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/useDocuments.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react' +import { useCurrentCommit } from '$/app/providers/CommitProvider' +import { useCurrentProject } from '$/app/providers/ProjectProvider' +import useDocumentVersions from '$/stores/documentVersions' + +function formatDocumentPath(path: string): string { + // Remove .promptl extension if present + const cleanPath = path.replace(/\.promptl$/, '') + const segments = cleanPath.split('/') + + if (segments.length === 1) return segments[0] + + const filename = segments[segments.length - 1] + const firstFolder = segments[0] + + if (segments.length === 2) { + return `${firstFolder}/${filename}` + } + + return `${firstFolder}/.../${filename}` +} + +export function useDocuments() { + const { project } = useCurrentProject() + const { commit } = useCurrentCommit() + const { data: documents, isLoading } = useDocumentVersions({ + projectId: project?.id, + commitUuid: commit?.uuid, + }) + + const documentOptions = useMemo( + () => + documents?.map((doc) => ({ + value: doc.documentUuid, + label: formatDocumentPath(doc.path), + })), + [documents], + ) + + return useMemo( + () => ({ + documentOptions, + isLoading, + }), + [documentOptions, isLoading], + ) +} diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/useSeenAtDatePicker.ts b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/useSeenAtDatePicker.ts new file mode 100644 index 0000000000..ecd04ea332 --- /dev/null +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/useSeenAtDatePicker.ts @@ -0,0 +1,66 @@ +import { useMemo, useCallback } from 'react' +import { useIssuesParameters } from '$/stores/issues/useIssuesParameters' +import { SafeIssuesParams } from '@latitude-data/constants/issues' +import { DateRange } from '@latitude-data/web-ui/atoms/DatePicker' + +export function useSeenAtDatePicker({ + serverParams, +}: { + serverParams: SafeIssuesParams +}) { + const { filters, setFilters } = useIssuesParameters((state) => ({ + filters: state.filters, + setFilters: state.setFilters, + })) + const firstSeen = filters.firstSeen ?? serverParams.filters.firstSeen + const lastSeen = filters.lastSeen ?? serverParams.filters.lastSeen + const firstSeenDate = firstSeen instanceof Date ? firstSeen : undefined + const lastSeenDate = lastSeen instanceof Date ? lastSeen : undefined + + const dateWindow: DateRange | undefined = useMemo( + () => + firstSeenDate && lastSeenDate + ? { from: firstSeenDate, to: lastSeenDate } + : firstSeenDate + ? { from: firstSeenDate, to: firstSeenDate } + : lastSeenDate + ? { from: lastSeenDate, to: lastSeenDate } + : undefined, + [firstSeenDate, lastSeenDate], + ) + + const onDateWindowChange = useCallback( + (range: DateRange | undefined) => { + // Convert DateRange back to single Date values + // If only one date is selected (from without to), treat it as lastSeen filter only + // This gives "show issues last seen until this date" behavior + if (range?.from && !range?.to) { + setFilters({ + firstSeen: undefined, + lastSeen: range.from, + }) + } else if (range?.from && range?.to) { + // Full range: firstSeen represents the start, lastSeen represents the end + setFilters({ + firstSeen: range.from, + lastSeen: range.to, + }) + } else { + // Clear filters when no date is selected + setFilters({ + firstSeen: undefined, + lastSeen: undefined, + }) + } + }, + [setFilters], + ) + + return useMemo( + () => ({ + dateWindow, + onDateWindowChange, + }), + [dateWindow, onDateWindowChange], + ) +} diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesTable/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesTable/index.tsx new file mode 100644 index 0000000000..eb06895f0c --- /dev/null +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesTable/index.tsx @@ -0,0 +1,145 @@ +import { LinkableTablePaginationFooter } from '$/components/TablePaginationFooter' +import { buildPagination } from '@latitude-data/core/lib/pagination/buildPagination' +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, +} from '@latitude-data/web-ui/atoms/Table' +import { TableBlankSlate } from '@latitude-data/web-ui/molecules/TableBlankSlate' +import { Skeleton } from '@latitude-data/web-ui/atoms/Skeleton' +import { useIssuesParameters } from '$/stores/issues/useIssuesParameters' +import { SerializedIssue } from '$/stores/issues' +import { IssuesTitle } from '../IssuesTitle' +import { LastSeenCell } from '../LastSeenCell' +import { Text } from '@latitude-data/web-ui/atoms/Text' +import { SafeIssuesParams } from '@latitude-data/constants/issues' +import { useMemo } from 'react' + +const FAKE_ROWS = Array.from({ length: 20 }) +function IssuesTableLoader({ showStatus }: { showStatus: boolean }) { + const headerCells = useMemo( + () => Array.from({ length: showStatus ? 4 : 3 }), + [showStatus], + ) + return ( + <> + {FAKE_ROWS.map((_, index) => ( + + {headerCells.map((_, cellIndex) => ( + + + + ))} + + ))} + + ) +} + +function StatusCell({ issue }: { issue: SerializedIssue }) { + const status = issue.isResolved + ? 'Resolved' + : issue.isIgnored + ? 'Ignored' + : 'Active' + return {status} +} + +export function IssuesTable({ + serverParams, + isLoading, + issues, + currentRoute, +}: { + serverParams: SafeIssuesParams + isLoading: boolean + issues: SerializedIssue[] + currentRoute: string +}) { + const { + page, + hasPrevPage, + prevPage, + hasNextPage, + nextPage, + totalCount, + limit, + filters, + } = useIssuesParameters((state) => ({ + init: state.init, + page: state.page, + urlParameters: state.urlParameters, + prevPage: state.prevPage, + hasPrevPage: state.hasPrevPage, + nextPage: state.nextPage, + hasNextPage: state.hasNextPage, + totalCount: state.totalCount, + limit: state.limit, + filters: state.filters, + })) + const noData = !isLoading && !issues.length + const status = filters.status ?? serverParams.filters.status + const showStatus = status !== 'active' + + if (noData) return + + return ( + `${count} issues`} + onPrev={prevPage} + prevPageDisabled={!hasPrevPage} + nextPageDisabled={!hasNextPage} + onNext={nextPage} + pagination={buildPagination({ + count: totalCount, + baseUrl: currentRoute, + page: Number(page), + pageSize: limit, + })} + /> + } + > + + + + Seen at + Events + {showStatus ? Status : null} + + + + {isLoading ? ( + + ) : ( + issues.map((issue) => ( + + + + + + + + {issue.totalCount} + {showStatus ? ( + + + + ) : null} + + )) + )} + +
+ ) +} diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesTitle/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesTitle/index.tsx new file mode 100644 index 0000000000..d7acae05e5 --- /dev/null +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesTitle/index.tsx @@ -0,0 +1,37 @@ +import { SerializedIssue } from '$/stores/issues' +import { Tooltip } from '@latitude-data/web-ui/atoms/Tooltip' +import { Badge } from '@latitude-data/web-ui/atoms/Badge' +import { Text } from '@latitude-data/web-ui/atoms/Text' +import { + NEW_ISSUES_DAYS, + RECENT_ISSUES_DAYS, +} from '@latitude-data/constants/issues' + +function StatusBadge({ issue }: { issue: SerializedIssue }) { + const isNew = issue.isNew + const isEscalating = issue.isEscalating + if (!isNew && !isEscalating) return null + + if (isNew) { + return ( + New}> + {`Has appeared in the last ${NEW_ISSUES_DAYS} days`} + + ) + } + + return ( + Escalating}> + {`${issue.escalatingCount} events in the last ${RECENT_ISSUES_DAYS} days.`} + + ) +} + +export function IssuesTitle({ issue }: { issue: SerializedIssue }) { + return ( +
+ + {issue.title} +
+ ) +} diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/LastSeenCell/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/LastSeenCell/index.tsx new file mode 100644 index 0000000000..45fe61cac1 --- /dev/null +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/LastSeenCell/index.tsx @@ -0,0 +1,66 @@ +import { useMemo } from 'react' +import { SerializedIssue } from '$/stores/issues' +import { Text } from '@latitude-data/web-ui/atoms/Text' +import { + differenceInMinutes, + differenceInHours, + differenceInDays, + differenceInWeeks, + differenceInMonths, + differenceInYears, + format, +} from 'date-fns' +import { Tooltip } from '@latitude-data/web-ui/atoms/Tooltip' + +function formatTimeDistance(date: Date, baseDate: Date = new Date()): string { + const minutes = differenceInMinutes(baseDate, date) + const hours = differenceInHours(baseDate, date) + const days = differenceInDays(baseDate, date) + const weeks = differenceInWeeks(baseDate, date) + const months = differenceInMonths(baseDate, date) + const years = differenceInYears(baseDate, date) + + if (minutes < 1) return 'less than a minute' + if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'}` + if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'}` + if (days < 7) return `${days} day${days === 1 ? '' : 's'}` + if (days < 30) return `${weeks} week${weeks === 1 ? '' : 's'}` + if (months < 12) return `${months} month${months === 1 ? '' : 's'}` + return `${years} year${years === 1 ? '' : 's'}` +} + +function getRelativeTimeText(createdAt: Date, lastSeenDate: Date | null) { + const dateToUse = lastSeenDate || createdAt + const now = new Date() + + const ageText = formatTimeDistance(createdAt, now) + const lastSeenText = formatTimeDistance(dateToUse, now) + + return `${lastSeenText} ago / ${ageText} old` +} + +export function LastSeenCell({ issue }: { issue: SerializedIssue }) { + const text = useMemo( + () => getRelativeTimeText(issue.createdAt, issue.lastSeenDate), + [issue.createdAt, issue.lastSeenDate], + ) + + const tooltipContent = useMemo(() => { + const firstSeenFormatted = format(issue.createdAt, 'PPpp') + const lastSeenFormatted = issue.lastSeenDate + ? format(issue.lastSeenDate, 'PPpp') + : 'Never' + return ( +
+
Last seen: {lastSeenFormatted}
+
First seen: {firstSeenFormatted}
+
+ ) + }, [issue.createdAt, issue.lastSeenDate]) + + return ( + {text}}> + {tooltipContent} + + ) +} diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/SearchIssuesInput/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/SearchIssuesInput/index.tsx new file mode 100644 index 0000000000..8e2ebc8224 --- /dev/null +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/SearchIssuesInput/index.tsx @@ -0,0 +1,54 @@ +import { ChangeEvent, useCallback, useState } from 'react' +import { useIssuesParameters } from '$/stores/issues/useIssuesParameters' +import { SafeIssuesParams } from '@latitude-data/constants/issues' +import { Icon } from '@latitude-data/web-ui/atoms/Icons' +import { cn } from '@latitude-data/web-ui/utils' +import { useDebouncedCallback } from 'use-debounce' + +export function SearchIssuesInput({ + serverParams, +}: { + serverParams: SafeIssuesParams +}) { + const { filters, setFilters } = useIssuesParameters((state) => ({ + filters: state.filters, + setFilters: state.setFilters, + })) + const [query, setQuery] = useState( + filters.query ?? serverParams.filters?.query ?? '', + ) + const onChangeQuery = useDebouncedCallback((value: string) => { + setFilters({ + ...filters, + query: value || undefined, + }) + }, 300) + const onChange = useCallback( + (e: ChangeEvent) => { + setQuery(e.target.value) + onChangeQuery(e.target.value) + }, + [onChangeQuery], + ) + return ( +
+ + +
+ ) +} diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/layout.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/layout.tsx new file mode 100644 index 0000000000..e1cfd9a1a2 --- /dev/null +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/layout.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from 'react' +import { Metadata } from 'next' +import buildMetatags from '$/app/_lib/buildMetatags' +import ProjectLayout from '../_components/ProjectLayout' + +export async function generateMetadata(): Promise { + return buildMetatags({ + title: 'Issues', + }) +} + +export default async function IssuesLayout({ + children, + params, +}: { + children: ReactNode + params: Promise<{ projectId: string; commitUuid: string }> +}) { + const { projectId, commitUuid } = await params + return ( + + {children} + + ) +} diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/page.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/page.tsx new file mode 100644 index 0000000000..a05fc7a7d6 --- /dev/null +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/page.tsx @@ -0,0 +1,68 @@ +import { IssuesDashboard } from './_components/IssuesDashboard' +import { + findCommitCached, + findProjectCached, +} from '$/app/(private)/_data-access' +import { getCurrentUserOrRedirect } from '$/services/auth/getCurrentUser' +import { IssuesRepository } from '@latitude-data/core/repositories' +import { OkType } from '@latitude-data/core/lib/Result' +import { + buildIssuesCacheKey, + convertIssuesParamsToQueryParams, + parseIssuesQueryParams, + QueryParams, +} from '@latitude-data/constants/issues' +import { SWRProvider } from '$/components/Providers/SWRProvider' + +export type IssuesServerResponse = Awaited< + OkType +> + +export default async function IssuesPageRoute({ + params, + searchParams, +}: { + params: Promise<{ projectId: string; commitUuid: string }> + searchParams: Promise +}) { + const queryParams = await searchParams + const { projectId, commitUuid } = await params + const session = await getCurrentUserOrRedirect() + const project = await findProjectCached({ + workspaceId: session.workspace.id, + projectId: Number(projectId), + }) + const commit = await findCommitCached({ + uuid: commitUuid, + projectId: Number(projectId), + }) + const parsedParams = parseIssuesQueryParams({ + params: queryParams, + defaultFilters: { documentUuid: commit.mainDocumentUuid ?? undefined }, + }) + const issuesRepo = new IssuesRepository(session.workspace.id) + const args = { + project, + commit, + filters: parsedParams.filters, + sorting: parsedParams.sorting, + page: parsedParams.page, + } + const serverResponse = await issuesRepo + .fetchIssuesFiltered({ + ...args, + limit: parsedParams.limit, + }) + .then((r) => r.unwrap()) + + const key = buildIssuesCacheKey({ + projectId: project.id, + commitUuid: commit.uuid, + searchParams: convertIssuesParamsToQueryParams(parsedParams), + }) + return ( + + + + ) +} diff --git a/apps/web/src/app/_lib/buildMetatags.ts b/apps/web/src/app/_lib/buildMetatags.ts index a9c5ef494a..31febd803e 100644 --- a/apps/web/src/app/_lib/buildMetatags.ts +++ b/apps/web/src/app/_lib/buildMetatags.ts @@ -6,7 +6,7 @@ const DEFAULT_DESCRIPTION = // This function is necessary to define default metadata correctly, because // Nextjs metadata merging would overwrite the nested objects totally. -export default function buildMetatags({ +export default async function buildMetatags({ title, description, locationDescription, @@ -16,7 +16,7 @@ export default function buildMetatags({ description?: string locationDescription?: string parent?: ResolvedMetadata -}): Metadata { +}): Promise { let parentTitle = parent?.title?.absolute || '' let metaTitle = title && parentTitle diff --git a/apps/web/src/app/api/projects/[projectId]/commits/[commitUuid]/issues/route.ts b/apps/web/src/app/api/projects/[projectId]/commits/[commitUuid]/issues/route.ts new file mode 100644 index 0000000000..94e8ca5f55 --- /dev/null +++ b/apps/web/src/app/api/projects/[projectId]/commits/[commitUuid]/issues/route.ts @@ -0,0 +1,63 @@ +import { z } from 'zod' +import { authHandler } from '$/middlewares/authHandler' +import { errorHandler } from '$/middlewares/errorHandler' +import { + ProjectsRepository, + IssuesRepository, +} from '@latitude-data/core/repositories' +import { CommitsRepository } from '@latitude-data/core/repositories' +import { Workspace } from '@latitude-data/core/schema/models/types/Workspace' +import { NextRequest, NextResponse } from 'next/server' +import { parseIssuesQueryParams } from '@latitude-data/constants/issues' + +const paramsSchema = z.object({ + projectId: z.coerce.number(), + commitUuid: z.string(), +}) + +export const GET = errorHandler( + authHandler( + async ( + request: NextRequest, + { + params, + workspace, + }: { + params: { + projectId: string + commitUuid: string + } + workspace: Workspace + }, + ) => { + const { projectId, commitUuid } = paramsSchema.parse({ + projectId: params.projectId, + commitUuid: params.commitUuid, + }) + const query = request.nextUrl.searchParams + const parsed = parseIssuesQueryParams({ params: query }) + const projectsRepo = new ProjectsRepository(workspace.id) + const project = await projectsRepo.find(projectId).then((r) => r.unwrap()) + const commitsRepo = new CommitsRepository(workspace.id) + const commit = await commitsRepo + .getCommitByUuid({ + projectId, + uuid: commitUuid, + }) + .then((r) => r.unwrap()) + const issuesRepo = new IssuesRepository(workspace.id) + const result = await issuesRepo + .fetchIssuesFiltered({ + project, + commit, + filters: parsed.filters, + sorting: parsed.sorting, + page: parsed.page, + limit: parsed.limit, + }) + .then((r) => r.unwrap()) + + return NextResponse.json(result, { status: 200 }) + }, + ), +) diff --git a/apps/web/src/components/TablePaginationFooter/GoToPageInput/index.tsx b/apps/web/src/components/TablePaginationFooter/GoToPageInput/index.tsx index c19b5b07de..03cd6cb12d 100644 --- a/apps/web/src/components/TablePaginationFooter/GoToPageInput/index.tsx +++ b/apps/web/src/components/TablePaginationFooter/GoToPageInput/index.tsx @@ -1,5 +1,3 @@ -'use client' - import { FormEvent, useCallback } from 'react' import { buildPaginatedUrl } from '@latitude-data/core/lib/pagination/buildPaginatedUrl' @@ -52,6 +50,7 @@ export function GoToPageInput({ hideNativeAppearance type='number' name='page' + size='small' min={1} max={totalPages} defaultValue={page} diff --git a/apps/web/src/components/TablePaginationFooter/index.tsx b/apps/web/src/components/TablePaginationFooter/index.tsx index d7a8455e79..e65bbd6360 100644 --- a/apps/web/src/components/TablePaginationFooter/index.tsx +++ b/apps/web/src/components/TablePaginationFooter/index.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { IPagination } from '@latitude-data/core/lib/pagination/buildPagination' import { Button } from '@latitude-data/web-ui/atoms/Button' import { Skeleton } from '@latitude-data/web-ui/atoms/Skeleton' @@ -9,7 +10,12 @@ type Props = { pagination?: IPagination countLabel?: (count: number) => string isLoading?: boolean + onNext?: () => void + nextPageDisabled?: boolean + onPrev?: () => void + prevPageDisabled?: boolean } + function CountLabel({ count, isLoading = false, @@ -32,29 +38,51 @@ function CountLabel({ function NavLink({ url, direction, + onClick, + disabled = false, }: { url?: string direction: 'prev' | 'next' + disabled?: boolean + onClick?: () => void }) { - const button = ( - diff --git a/packages/web-ui/src/ds/atoms/Icons/index.tsx b/packages/web-ui/src/ds/atoms/Icons/index.tsx index 244f38d193..c2dde7b3a7 100644 --- a/packages/web-ui/src/ds/atoms/Icons/index.tsx +++ b/packages/web-ui/src/ds/atoms/Icons/index.tsx @@ -152,6 +152,7 @@ import { Zap, SquareDashed, Brush, + ShieldAlertIcon, } from 'lucide-react' import { MouseEvent } from 'react' @@ -211,6 +212,7 @@ import Wordpress from './custom-icons/logos/Wordpress' import YepCode from './custom-icons/logos/YepCode' const Icons = { + shieldAlert: ShieldAlertIcon, addCircle: CirclePlus, addSquare: SquarePlus, squareChart: SquareChartGantt, diff --git a/packages/web-ui/src/ds/atoms/Select/Primitives/index.tsx b/packages/web-ui/src/ds/atoms/Select/Primitives/index.tsx index de983dc2c7..41872ac23a 100644 --- a/packages/web-ui/src/ds/atoms/Select/Primitives/index.tsx +++ b/packages/web-ui/src/ds/atoms/Select/Primitives/index.tsx @@ -205,12 +205,15 @@ const SelectScrollDownButton = forwardRef< SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName +export type SelectContentProps = ComponentPropsWithoutRef< + typeof SelectPrimitive.Content +> & { + autoScroll?: boolean + maxHeightAuto?: boolean +} const SelectContent = forwardRef< ElementRef, - ComponentPropsWithoutRef & { - autoScroll?: boolean - maxHeightAuto?: boolean - } + SelectContentProps >( ( { @@ -228,7 +231,7 @@ const SelectContent = forwardRef< ref={ref} position={position} className={cn( - 'min-w-[8rem] relative z-50 overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-md', + 'min-w-52 relative z-50 overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-md', 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', 'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className, diff --git a/packages/web-ui/src/ds/atoms/Select/index.tsx b/packages/web-ui/src/ds/atoms/Select/index.tsx index 43b22c2253..e3498befca 100644 --- a/packages/web-ui/src/ds/atoms/Select/index.tsx +++ b/packages/web-ui/src/ds/atoms/Select/index.tsx @@ -9,6 +9,7 @@ import { Skeleton } from '../Skeleton' import { Text } from '../Text' import { SelectContent, + type SelectContentProps, SelectGroup, SelectItem, SelectRoot, @@ -38,29 +39,30 @@ export function Options({ options }: { options: SelectOption[] }) { export type SelectProps = Omit< FormFieldProps, 'children' -> & { - name: string - options: SelectOption[] - defaultValue?: V - value?: V - trigger?: ReactNode - placeholder?: string - loading?: boolean - disabled?: boolean - required?: boolean - onChange?: (value: V) => void - width?: 'auto' | 'full' - size?: 'small' | 'default' - removable?: boolean - searchable?: boolean - open?: boolean - onOpenChange?: (open: boolean) => void - footerAction?: { - label: string - icon?: IconName - onClick: () => void +> & + Pick & { + name: string + options: SelectOption[] + defaultValue?: V + value?: V + trigger?: ReactNode + placeholder?: string + loading?: boolean + disabled?: boolean + required?: boolean + onChange?: (value: V) => void + width?: 'auto' | 'full' + size?: 'small' | 'default' + removable?: boolean + searchable?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + footerAction?: { + label: string + icon?: IconName + onClick: () => void + } } -} export function Select({ name, label, @@ -77,6 +79,7 @@ export function Select({ onChange, width = 'full', size = 'default', + align = 'start', loading = false, disabled = false, required = false, @@ -147,7 +150,7 @@ export function Select({ /> )} - + {searchable ? ( options={options} diff --git a/packages/web-ui/src/ds/molecules/PaginatedSelect/index.tsx b/packages/web-ui/src/ds/molecules/PaginatedSelect/index.tsx index c33537da36..7217ffd630 100644 --- a/packages/web-ui/src/ds/molecules/PaginatedSelect/index.tsx +++ b/packages/web-ui/src/ds/molecules/PaginatedSelect/index.tsx @@ -34,7 +34,7 @@ import { } from '../../atoms/Select' import { useDebouncedCallback } from 'use-debounce' -export type PaginatedSelectProps = Omit< +type PaginatedSelectProps = Omit< FormFieldProps, 'children' > & { diff --git a/packages/web-ui/src/ds/molecules/RadioToggleInput/index.tsx b/packages/web-ui/src/ds/molecules/RadioToggleInput/index.tsx new file mode 100644 index 0000000000..12d6089297 --- /dev/null +++ b/packages/web-ui/src/ds/molecules/RadioToggleInput/index.tsx @@ -0,0 +1,131 @@ +'use client' +import { + ChangeEvent, + ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react' + +import { cn } from '../../../lib/utils' +import { Text } from '../../atoms/Text' + +export type RadioToggleOption = { + label: ReactNode | string + value: T + disabled?: boolean +} + +function LabelText({ + isSelected, + children, +}: { + isSelected: boolean + children: ReactNode | string +}) { + if (typeof children === 'string') { + return ( + + {children} + + ) + } + return <>{children} +} + +export function RadioToggleInput({ + name, + options, + value, + disabled, + onChange, +}: { + name: string + options: RadioToggleOption[] + value?: T + onChange?: (value: T) => void + disabled?: boolean +}) { + const selectedOptionButtonRef = useRef(null) + const selectedOptionBackgroundRef = useRef(null) + const [selected, setSelected] = useState(value) + + useEffect(() => { + setSelected(value) + }, [value]) + + const handleChange = useCallback( + (event: ChangeEvent) => { + const newValue = event.target.value as T + setSelected(newValue) + onChange?.(newValue) + }, + [onChange], + ) + + useEffect(() => { + const background = selectedOptionBackgroundRef.current + if (!background) return + + const button = selectedOptionButtonRef.current + if (!button) { + background.style.display = 'none' + return + } + + const updateBackgroundPosition = () => { + background.style.left = `${button.offsetLeft}px` + background.style.width = `${button.offsetWidth}px` + background.style.display = 'block' + } + + updateBackgroundPosition() + + const resizeObserver = new ResizeObserver(updateBackgroundPosition) + resizeObserver.observe(button) + + return () => resizeObserver.disconnect() + }, [selected]) + + return ( +
+ + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faafdf7ad2..30f9a96825 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -692,6 +692,9 @@ importers: ai: specifier: 'catalog:' version: 5.0.44(zod@4.1.8) + date-fns: + specifier: ^4.1.0 + version: 4.1.0 json-schema: specifier: ^0.4.0 version: 0.4.0