From 82bb9865ef25a3910ecf297063cef79df47ecbff Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Thu, 30 Jan 2025 15:19:46 +0000 Subject: [PATCH 1/6] feat(alerts): tabs for filtering table --- src/App.tsx | 5 +- .../alerts/components/search-field-alerts.tsx | 3 +- .../switch-malicious-alerts-filter.tsx | 34 ----- .../alerts/components/table-alerts.tsx | 123 ++++++++---------- .../alerts/components/tabs-alerts.tsx | 118 +++++++++++++++++ .../hooks/use-alerts-filter-search-params.ts | 29 ++++- src/hooks/useAlertSearch.ts | 23 ---- src/routes/__tests__/route-dashboard.test.tsx | 88 +++++++++---- src/routes/route-dashboard.tsx | 10 +- src/types.ts | 34 ----- 10 files changed, 272 insertions(+), 195 deletions(-) delete mode 100644 src/features/alerts/components/switch-malicious-alerts-filter.tsx create mode 100644 src/features/alerts/components/tabs-alerts.tsx delete mode 100644 src/hooks/useAlertSearch.ts delete mode 100644 src/types.ts diff --git a/src/App.tsx b/src/App.tsx index e9f9922b..e03ecedf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,7 +17,10 @@ function App() {
-
+
diff --git a/src/features/alerts/components/search-field-alerts.tsx b/src/features/alerts/components/search-field-alerts.tsx index 7d084de6..7a1a7a7b 100644 --- a/src/features/alerts/components/search-field-alerts.tsx +++ b/src/features/alerts/components/search-field-alerts.tsx @@ -7,7 +7,7 @@ import { import { useAlertsFilterSearchParams } from "../hooks/use-alerts-filter-search-params"; import { SearchMd } from "@untitled-ui/icons-react"; -export function SearchFieldAlerts() { +export function SearchFieldAlerts({ className }: { className?: string }) { const { setSearch, state } = useAlertsFilterSearchParams(); return ( @@ -16,6 +16,7 @@ export function SearchFieldAlerts() { aria-label="Search alerts" value={state.search ?? ""} onChange={(value) => setSearch(value.toLowerCase().trim())} + className={className} > - { - switch (isSelected) { - case true: - return setView(AlertsFilterView.MALICIOUS); - case false: - return setView(AlertsFilterView.ALL); - default: - return isSelected satisfies never; - } - }} - > - Malicious Packages - - - -

Filter by malicious packages

-
- - ); -} diff --git a/src/features/alerts/components/table-alerts.tsx b/src/features/alerts/components/table-alerts.tsx index 683a8be4..a22e4b56 100644 --- a/src/features/alerts/components/table-alerts.tsx +++ b/src/features/alerts/components/table-alerts.tsx @@ -6,7 +6,6 @@ import { Table, TableBody, TableHeader, - Badge, Button, ResizableTableContainer, } from "@stacklok/ui-kit"; @@ -16,14 +15,12 @@ import { parsingPromptText, getIssueDetectedType, } from "@/lib/utils"; -import { useAlertSearch } from "@/hooks/useAlertSearch"; import { useNavigate } from "react-router-dom"; import { useClientSidePagination } from "@/hooks/useClientSidePagination"; import { TableAlertTokenUsage } from "./table-alert-token-usage"; import { Key01, PackageX } from "@untitled-ui/icons-react"; -import { SearchFieldAlerts } from "./search-field-alerts"; import { useQueryGetWorkspaceAlertTable } from "../hooks/use-query-get-workspace-alerts-table"; -import { SwitchMaliciousAlertsFilter } from "./switch-malicious-alerts-filter"; +import { useAlertsFilterSearchParams } from "../hooks/use-alerts-filter-search-params"; const getTitle = (alert: AlertConversation) => { const prompt = alert.conversation; @@ -75,84 +72,70 @@ function IssueDetectedCellContent({ alert }: { alert: AlertConversation }) { } export function TableAlerts() { - const { page, nextPage, prevPage } = useAlertSearch(); const navigate = useNavigate(); + const { state, prevPage, nextPage } = useAlertsFilterSearchParams(); + const { data: filteredAlerts = [] } = useQueryGetWorkspaceAlertTable(); const { dataView, hasNextPage, hasPreviousPage } = useClientSidePagination( filteredAlerts, - page, + state.page, 15, ); return ( <> -
-
-

All Alerts

- - {filteredAlerts.length} - -
- -
- - -
-
-
- - - - - - Time - - Type - Event - Issue Detected - Token usage - - - - {dataView.map((alert) => { - return ( - - navigate(`/prompt/${alert.conversation.chat_id}`) - } - > - - {formatDistanceToNow(new Date(alert.timestamp), { - addSuffix: true, - })} - - - - - {getTitle(alert)} - -
- -
-
- - - -
- ); - })} -
-
-
-
+ + + + + + Time + + Type + Event + Issue Detected + Token usage + + + + {dataView.map((alert) => { + return ( + + navigate(`/prompt/${alert.conversation.chat_id}`) + } + > + + {formatDistanceToNow(new Date(alert.timestamp), { + addSuffix: true, + })} + + + + + {getTitle(alert)} + +
+ +
+
+ + + +
+ ); + })} +
+
+
-
+
diff --git a/src/features/alerts/components/tabs-alerts.tsx b/src/features/alerts/components/tabs-alerts.tsx new file mode 100644 index 00000000..a14f9f35 --- /dev/null +++ b/src/features/alerts/components/tabs-alerts.tsx @@ -0,0 +1,118 @@ +import { useQueryGetWorkspaceAlerts } from "../hooks/use-query-get-workspace-alerts"; +import { isAlertMalicious } from "../lib/is-alert-malicious"; +import { multiFilter } from "@/lib/multi-filter"; +import { isAlertCritical } from "../lib/is-alert-critical"; +import { isAlertSecret } from "../lib/is-alert-secret"; +import { V1GetWorkspaceAlertsResponse } from "@/api/generated"; +import { + Tab as BaseTab, + Tabs, + TabList, + TabPanel, + Badge, + Card, + CardBody, +} from "@stacklok/ui-kit"; +import { + AlertsFilterView, + useAlertsFilterSearchParams, +} from "../hooks/use-alerts-filter-search-params"; +import { SearchFieldAlerts } from "./search-field-alerts"; +import { tv } from "tailwind-variants"; + +type AlertsCount = { + all: number; + malicious: number; + secrets: number; +}; + +function select(data: V1GetWorkspaceAlertsResponse | undefined): AlertsCount { + const all: number = multiFilter(data, [isAlertCritical]).length; + + const malicious: number = multiFilter(data, [ + isAlertCritical, + isAlertMalicious, + ]).length; + + const secrets: number = multiFilter(data, [ + isAlertCritical, + isAlertSecret, + ]).length; + + return { + all, + malicious, + secrets, + }; +} + +const tabStyle = tv({ + base: [ + "my-1 mx-0.5 first:ml-1 last:mr-1", + "rounded bg-transparent h-[calc(2rem-2px)] flex text-secondary items-center gap-1 !border-0", + "hover:bg-gray-50 hover:text-secondary", + "selected:bg-base hover:selected:bg-base selected:shadow-sm selected:border-gray-200 selected:text-secondary", + ], +}); + +function Tab({ + id, + title, + count, +}: { + title: string; + id: AlertsFilterView; + count: number; +}) { + return ( + + {title} + + {count} + + + ); +} + +export function TabsAlerts({ children }: { children: React.ReactNode }) { + const { state, setView } = useAlertsFilterSearchParams(); + + const { data } = useQueryGetWorkspaceAlerts({ + select, + }); + + return ( + setView(key.toString() as AlertsFilterView)} + selectedKey={state.view} + defaultSelectedKey={AlertsFilterView.ALL} + > +
+ + + + + + + +
+ + + {children} + + +
+ ); +} diff --git a/src/features/alerts/hooks/use-alerts-filter-search-params.ts b/src/features/alerts/hooks/use-alerts-filter-search-params.ts index 6fe5c200..a6e700dc 100644 --- a/src/features/alerts/hooks/use-alerts-filter-search-params.ts +++ b/src/features/alerts/hooks/use-alerts-filter-search-params.ts @@ -10,14 +10,15 @@ export enum AlertsFilterView { const alertsFilterSchema = z.object({ search: z.string().optional(), - view: z.nativeEnum(AlertsFilterView), + view: z.nativeEnum(AlertsFilterView).optional().default(AlertsFilterView.ALL), + page: z.coerce.number().optional().default(0), }); -type AlertsFilterSchema = z.output; +type AlertsFilterSchema = z.input; -const DEFAULT_FILTER: AlertsFilterSchema = { +const DEFAULT_FILTER = { view: AlertsFilterView.ALL, -}; +} as const satisfies AlertsFilterSchema; export const useAlertsFilterSearchParams = () => { const [searchParams, setSearchParams] = useSearchParams( @@ -29,6 +30,8 @@ export const useAlertsFilterSearchParams = () => { setSearchParams((prev) => { if (view) prev.set("view", view); if (!view) prev.delete("view"); + + prev.delete("page"); return prev; }); }, @@ -46,7 +49,23 @@ export const useAlertsFilterSearchParams = () => { [setSearchParams], ); + const nextPage = useCallback(() => { + setSearchParams((prev) => { + const page = Number(prev.get("page") ?? 0); + prev.set("page", (page + 1).toString()); + return prev; + }); + }, [setSearchParams]); + + const prevPage = useCallback(() => { + setSearchParams((prev) => { + const page = Number(prev.get("page") ?? 0); + prev.set("page", (page - 1).toString()); + return prev; + }); + }, [setSearchParams]); + const state = alertsFilterSchema.parse(Object.fromEntries(searchParams)); - return { state, setView, setSearch }; + return { state, setView, setSearch, nextPage, prevPage }; }; diff --git a/src/hooks/useAlertSearch.ts b/src/hooks/useAlertSearch.ts deleted file mode 100644 index cfc1fb08..00000000 --- a/src/hooks/useAlertSearch.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { create } from "zustand"; -import { AlertSearchState } from "../types"; - -export const useAlertSearch = create((set) => ({ - isMaliciousFilterActive: false, - search: "", - setSearch: (search: string) => { - set({ search, page: 0 }); - }, - setIsMaliciousFilterActive: (isActive: boolean) => { - set({ - isMaliciousFilterActive: isActive, - page: 0, - }); - }, - page: 0, - nextPage: () => { - set((state) => ({ page: state.page + 1 })); - }, - prevPage: () => { - set((state) => ({ page: state.page - 1 })); - }, -})); diff --git a/src/routes/__tests__/route-dashboard.test.tsx b/src/routes/__tests__/route-dashboard.test.tsx index 224dacc0..68591485 100644 --- a/src/routes/__tests__/route-dashboard.test.tsx +++ b/src/routes/__tests__/route-dashboard.test.tsx @@ -122,11 +122,11 @@ describe("Dashboard", () => { render(); expect( - screen.getByRole("heading", { - name: /all alerts/i, + screen.getByRole("grid", { + name: /alerts table/i, }), ).toBeVisible(); - expect(screen.getByTestId(/alerts-count/i)).toHaveTextContent("0"); + expect(screen.getByTestId(/tab-all-count/i)).toHaveTextContent("0"); expect( screen.getByRole("columnheader", { @@ -157,11 +157,6 @@ describe("Dashboard", () => { }), ).toBeVisible(); - expect( - screen.getByRole("switch", { - name: /malicious packages/i, - }), - ).toBeVisible(); expect(screen.getByRole("searchbox")).toBeVisible(); await waitFor(() => { @@ -219,7 +214,7 @@ describe("Dashboard", () => { ).toBeGreaterThan(1); }); - expect(screen.getByTestId(/alerts-count/i)).toHaveTextContent("2"); + expect(screen.getByTestId(/tab-all-count/i)).toHaveTextContent("2"); expect( screen.getAllByRole("gridcell", { name: /chat/i, @@ -227,29 +222,78 @@ describe("Dashboard", () => { ).toBeGreaterThanOrEqual(1); userEvent.click( - screen.getByRole("switch", { - name: /malicious packages/i, + screen.getByRole("tab", { + name: /malicious/i, }), ); - await waitFor(() => - expect(screen.getByTestId(/alerts-count/i)).toHaveTextContent("1"), + await waitFor(() => { + expect( + screen.queryAllByRole("gridcell", { + name: /blocked secret exposure/i, + }).length, + ).toBe(0); + }); + + userEvent.click( + screen.getByRole("tab", { + name: /all/i, + }), ); + await waitFor(() => { + expect( + screen.queryAllByRole("gridcell", { + name: /blocked secret exposure/i, + }).length, + ).toBe(1); + }); + }); + + it("should filter by secrets", async () => { + mockAlertsWithMaliciousPkg(); + render(); + + await waitFor(() => { + expect( + within(screen.getByTestId("alerts-table")).getAllByRole("row").length, + ).toBeGreaterThan(1); + }); + + expect(screen.getByTestId(/tab-all-count/i)).toHaveTextContent("2"); expect( - screen.queryAllByRole("gridcell", { - name: /blocked secret exposure/i, + screen.getAllByRole("gridcell", { + name: /chat/i, }).length, - ).toBe(0); + ).toBeGreaterThanOrEqual(1); userEvent.click( - screen.getByRole("switch", { - name: /malicious packages/i, + screen.getByRole("tab", { + name: /secrets/i, }), ); - await waitFor(() => - expect(screen.getByTestId(/alerts-count/i)).toHaveTextContent("2"), + + await waitFor(() => { + expect( + screen.queryAllByRole("gridcell", { + name: /blocked malicious package/i, + }).length, + ).toBe(0); + }); + + userEvent.click( + screen.getByRole("tab", { + name: /all/i, + }), ); + + await waitFor(() => { + expect( + screen.queryAllByRole("gridcell", { + name: /blocked malicious package/i, + }).length, + ).toBe(1); + }); }); it("should search by secrets alert", async () => { @@ -262,7 +306,7 @@ describe("Dashboard", () => { ).toBeGreaterThan(1); }); - expect(screen.getByTestId(/alerts-count/i)).toHaveTextContent("2"); + expect(screen.getByTestId(/tab-all-count/i)).toHaveTextContent("2"); expect( screen.getAllByRole("gridcell", { name: /chat/i, @@ -272,7 +316,7 @@ describe("Dashboard", () => { await userEvent.type(screen.getByRole("searchbox"), "codegate-secrets"); waitFor(() => - expect(screen.getByTestId(/alerts-count/i)).toHaveTextContent("1"), + expect(screen.getByTestId(/tab-all-count/i)).toHaveTextContent("1"), ); const row = within(screen.getByTestId("alerts-table")).getAllByRole( "row", diff --git a/src/routes/route-dashboard.tsx b/src/routes/route-dashboard.tsx index c8345091..2625e332 100644 --- a/src/routes/route-dashboard.tsx +++ b/src/routes/route-dashboard.tsx @@ -1,21 +1,21 @@ -import { Separator } from "@stacklok/ui-kit"; import { TableAlerts } from "@/features/alerts/components/table-alerts"; import { AlertsSummaryMaliciousPkg } from "@/features/alerts/components/alerts-summary-malicious-pkg"; import { AlertsSummaryWorkspaceTokenUsage } from "@/features/alerts/components/alerts-summary-workspace-token-usage"; import { AlertsSummaryMaliciousSecrets } from "@/features/alerts/components/alerts-summary-secrets"; +import { TabsAlerts } from "@/features/alerts/components/tabs-alerts"; export function RouteDashboard() { return (
-
+
- - - + + +
); } diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index c1fa2ddf..00000000 --- a/src/types.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Conversation } from "./api/generated"; - -export type PromptState = { - prompts: Conversation[]; - loading: boolean; - currentPromptId: string; - setCurrentPromptId: (id: string) => void; - fetchPrompts: () => void; -}; - -export type AlertSearchState = { - isMaliciousFilterActive: boolean; - search: string; - setSearch: (search: string) => void; - setIsMaliciousFilterActive: (isChecked: boolean) => void; - page: number; - nextPage: () => void; - prevPage: () => void; -}; - -export type TriggerType = - | "codegate-version" - | "codegate-context-retriever" - | "system-prompt" - | "code-snippet-extractor" - | "codegate-secrets" - | string; - -export type MaliciousPkgType = { - name: string; - type: string; - status: string; - description: string; -}; From f28b9ff9dcf7931abb188e27d3a47a11803914de Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 31 Jan 2025 08:46:47 +0000 Subject: [PATCH 2/6] fix: header spacing bug --- src/features/header/components/header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/header/components/header.tsx b/src/features/header/components/header.tsx index 40e11259..ce7f7c2e 100644 --- a/src/features/header/components/header.tsx +++ b/src/features/header/components/header.tsx @@ -37,7 +37,7 @@ export function Header({ hasError }: { hasError?: boolean }) {
-
+
From 896bec083ff99fe74d7828bd65107a25dfbebc9f Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 31 Jan 2025 12:45:55 +0000 Subject: [PATCH 3/6] feat: richer empty states --- package-lock.json | 7 + package.json | 1 + .../table-alerts.empty-state.test.tsx | 331 ++++++++++++++++++ .../__tests__/table-alerts.test.tsx | 122 +------ .../components/table-alerts-empty-state.tsx | 40 +++ .../alerts/components/table-alerts.tsx | 240 ++++++------- src/features/alerts/constants/strings.ts | 25 ++ .../alerts/hooks/use-alerts-empty-state.tsx | 190 ++++++++++ .../hooks/use-query-get-workspace-alerts.ts | 24 +- .../hooks/use-query-list-all-workspaces.ts | 1 + src/lib/hrefs.ts | 4 + src/routes/__tests__/route-dashboard.test.tsx | 30 +- 12 files changed, 729 insertions(+), 286 deletions(-) create mode 100644 src/features/alerts/components/__tests__/table-alerts.empty-state.test.tsx create mode 100644 src/features/alerts/components/table-alerts-empty-state.tsx create mode 100644 src/features/alerts/constants/strings.ts create mode 100644 src/features/alerts/hooks/use-alerts-empty-state.tsx diff --git a/package-lock.json b/package-lock.json index 3f8f0be3..96ad32bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "tailwind-merge": "^2.5.5", "tailwind-variants": "^0.3.0", "tailwindcss-animate": "^1.0.7", + "ts-pattern": "^5.6.2", "zod": "^3.24.1", "zustand": "^5.0.3" }, @@ -11533,6 +11534,12 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/ts-pattern": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.6.2.tgz", + "integrity": "sha512-d4IxJUXROL5NCa3amvMg6VQW2HVtZYmUTPfvVtO7zJWGYLJ+mry9v2OmYm+z67aniQoQ8/yFNadiEwtNS9qQiw==", + "license": "MIT" + }, "node_modules/tsconfck": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.4.tgz", diff --git a/package.json b/package.json index 9fa218d0..d735c323 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "tailwind-merge": "^2.5.5", "tailwind-variants": "^0.3.0", "tailwindcss-animate": "^1.0.7", + "ts-pattern": "^5.6.2", "zod": "^3.24.1", "zustand": "^5.0.3" }, diff --git a/src/features/alerts/components/__tests__/table-alerts.empty-state.test.tsx b/src/features/alerts/components/__tests__/table-alerts.empty-state.test.tsx new file mode 100644 index 00000000..841bae8b --- /dev/null +++ b/src/features/alerts/components/__tests__/table-alerts.empty-state.test.tsx @@ -0,0 +1,331 @@ +import { test } from "vitest"; +import { render, waitFor } from "@/lib/test-utils"; +import { server } from "@/mocks/msw/node"; +import { emptyStateStrings } from "../../constants/strings"; +import { useSearchParams } from "react-router-dom"; +import { delay, http, HttpHandler, HttpResponse } from "msw"; +import { makeMockAlert } from "../../mocks/alert.mock"; +import { AlertsFilterView } from "../../hooks/use-alerts-filter-search-params"; +import { TableAlerts } from "../table-alerts"; +import { hrefs } from "@/lib/hrefs"; + +enum IllustrationTestId { + ALERT = "illustration-alert", + DONE = "illustration-done", + DRAG_AND_DROP = "illustration-drag-and-drop", + LOADER = "illustration-loader", + NO_SEARCH_RESULTS = "illustration-no-search-results", +} + +type TestCaseAction = + | { + role: "button"; + name: string; + href?: never; + } + | { + role: "link"; + name: string; + href: string; + }; + +type TestCase = { + testDescription: string; + handlers: HttpHandler[]; + searchParams: { + view: AlertsFilterView; + search: string | null; + }; + expected: { + title: string; + body: string; + illustrationTestId: IllustrationTestId; + actions: TestCaseAction[] | null; + }; +}; + +vi.mock("react-router-dom", async () => { + const original = + await vi.importActual( + "react-router-dom", + ); + return { + ...original, + useSearchParams: vi.fn(() => [new URLSearchParams({}), () => {}]), + }; +}); + +vi.mock("@stacklok/ui-kit", async () => { + const original = + await vi.importActual( + "@stacklok/ui-kit", + ); + return { + ...original, + IllustrationDone: () =>
, + IllustrationDragAndDrop: () => ( +
+ ), + IllustrationAlert: () =>
, + IllustrationNoSearchResults: () => ( +
+ ), + Loader: () =>
, + }; +}); + +const TEST_CASES: TestCase[] = [ + { + testDescription: "Loading state", + handlers: [ + http.get("*/api/v1/workspaces", () => { + delay("infinite"); + }), + ], + searchParams: { + search: null, + view: AlertsFilterView.ALL, + }, + expected: { + title: emptyStateStrings.title.loading, + body: emptyStateStrings.body.loading, + illustrationTestId: IllustrationTestId.LOADER, + actions: null, + }, + }, + { + testDescription: "Only 1 workspace, no alerts", + handlers: [ + http.get("*/api/v1/workspaces", () => { + return HttpResponse.json({ + workspaces: [ + { + name: "default", + is_active: true, + }, + ], + }); + }), + http.get("*/api/v1/workspaces/archive", () => { + return HttpResponse.json({ + workspaces: [], + }); + }), + http.get("*/api/v1/workspaces/:name/alerts", () => { + return HttpResponse.json([]); + }), + ], + searchParams: { + search: null, + view: AlertsFilterView.ALL, + }, + expected: { + body: emptyStateStrings.body.getStartedDesc, + title: emptyStateStrings.title.getStarted, + illustrationTestId: IllustrationTestId.DRAG_AND_DROP, + actions: [ + { + role: "link", + name: "CodeGate docs", + href: hrefs.external.docs, + }, + ], + }, + }, + { + testDescription: "No search results", + handlers: [ + http.get("*/api/v1/workspaces", () => { + return HttpResponse.json({ + workspaces: [ + { + name: "default", + is_active: true, + }, + ], + }); + }), + http.get("*/api/v1/workspaces/archive", () => { + return HttpResponse.json({ + workspaces: [], + }); + }), + http.get("*/api/v1/workspaces/:name/alerts", () => { + return HttpResponse.json( + Array.from({ length: 10 }, () => + makeMockAlert({ type: "malicious" }), + ), + ); + }), + ], + searchParams: { search: "foo-bar", view: AlertsFilterView.ALL }, + expected: { + title: emptyStateStrings.title.noSearchResultsFor("foo-bar"), + body: emptyStateStrings.body.tryChangingSearch, + illustrationTestId: IllustrationTestId.NO_SEARCH_RESULTS, + actions: [ + { + role: "button", + name: "Clear search", + }, + ], + }, + }, + { + testDescription: "No alerts, multiple workspaces", + handlers: [ + http.get("*/api/v1/workspaces", () => { + return HttpResponse.json({ + workspaces: [ + { + name: "default", + is_active: true, + }, + { + name: "foo-bar", + is_active: false, + }, + ], + }); + }), + http.get("*/api/v1/workspaces/archive", () => { + return HttpResponse.json({ + workspaces: [], + }); + }), + http.get("*/api/v1/workspaces/:name/alerts", () => { + return HttpResponse.json([]); + }), + ], + searchParams: { + search: null, + view: AlertsFilterView.ALL, + }, + expected: { + title: emptyStateStrings.title.noAlertsFoundWorkspace, + body: emptyStateStrings.body.alertsWillShowUpWhenWorkspace, + illustrationTestId: IllustrationTestId.DONE, + actions: [ + { + role: "link", + name: "Manage workspaces", + href: hrefs.workspaces.all, + }, + ], + }, + }, + { + testDescription: 'Has alerts, view is "malicious"', + handlers: [ + http.get("*/api/v1/workspaces", () => { + return HttpResponse.json({ + workspaces: [ + { + name: "default", + is_active: true, + }, + { + name: "foo-bar", + is_active: false, + }, + ], + }); + }), + http.get("*/api/v1/workspaces/archive", () => { + return HttpResponse.json({ + workspaces: [], + }); + }), + http.get("*/api/v1/workspaces/:name/alerts", () => { + return HttpResponse.json( + Array.from({ length: 10 }).map(() => + makeMockAlert({ type: "secret" }), + ), + ); + }), + ], + searchParams: { + view: AlertsFilterView.MALICIOUS, + search: null, + }, + expected: { + title: emptyStateStrings.title.noMaliciousPackagesDetected, + body: emptyStateStrings.body.maliciousDesc, + illustrationTestId: IllustrationTestId.DONE, + actions: null, + }, + }, + { + testDescription: 'Has alerts, view is "secret"', + handlers: [ + http.get("*/api/v1/workspaces", () => { + return HttpResponse.json({ + workspaces: [ + { + name: "default", + is_active: true, + }, + { + name: "foo-bar", + is_active: false, + }, + ], + }); + }), + http.get("*/api/v1/workspaces/archive", () => { + return HttpResponse.json({ + workspaces: [], + }); + }), + http.get("*/api/v1/workspaces/:name/alerts", () => { + return HttpResponse.json( + Array.from({ length: 10 }).map(() => + makeMockAlert({ type: "malicious" }), + ), + ); + }), + ], + searchParams: { + view: AlertsFilterView.SECRETS, + search: null, + }, + expected: { + title: emptyStateStrings.title.noLeakedSecretsDetected, + body: emptyStateStrings.body.secretsDesc, + illustrationTestId: IllustrationTestId.DONE, + actions: null, + }, + }, +]; + +test.each(TEST_CASES)("$testDescription", async (testCase) => { + server.use(...testCase.handlers); + + vi.mocked(useSearchParams).mockReturnValue([ + new URLSearchParams({ + search: testCase.searchParams.search ?? "", + view: testCase.searchParams.view, + }), + () => {}, + ]); + + const { getByText, getByRole, getByTestId } = render(); + + await waitFor(() => { + expect( + getByRole("heading", { level: 4, name: testCase.expected.title }), + ).toBeVisible(); + expect(getByText(testCase.expected.body)).toBeVisible(); + expect(getByTestId(testCase.expected.illustrationTestId)).toBeVisible(); + + if (testCase.expected.actions) { + for (const action of testCase.expected.actions) { + const actionButton = getByRole(action.role, { name: action.name }); + expect(actionButton).toBeVisible(); + if (action.href) { + expect(actionButton).toHaveAttribute("href", action.href); + } + } + } + }); +}); diff --git a/src/features/alerts/components/__tests__/table-alerts.test.tsx b/src/features/alerts/components/__tests__/table-alerts.test.tsx index d0c29852..96be6f03 100644 --- a/src/features/alerts/components/__tests__/table-alerts.test.tsx +++ b/src/features/alerts/components/__tests__/table-alerts.test.tsx @@ -1,6 +1,6 @@ import {} from "vitest"; import { TableAlerts } from "../table-alerts"; -import { render, screen, waitFor, within } from "@/lib/test-utils"; +import { render, waitFor } from "@/lib/test-utils"; import { server } from "@/mocks/msw/node"; import { http, HttpResponse } from "msw"; import { makeMockAlert } from "../../mocks/alert.mock"; @@ -39,13 +39,7 @@ test("renders token usage cell correctly", async () => { const { getByRole, getByTestId, queryByText } = render(); await waitFor(() => { - expect( - within(screen.getByTestId("alerts-table")).getAllByRole("row"), - ).toHaveLength(2); - }); - - await waitFor(() => { - expect(queryByText(/loading alerts/i)).not.toBeInTheDocument(); + expect(queryByText(/loading.../i)).not.toBeInTheDocument(); }); expect(getByTestId("icon-arrow-up")).toBeVisible(); @@ -70,118 +64,8 @@ test("renders N/A when token usage is missing", async () => { const { getByText, queryByText } = render(); await waitFor(() => { - expect( - within(screen.getByTestId("alerts-table")).getAllByRole("row"), - ).toHaveLength(2); - }); - - await waitFor(() => { - expect(queryByText(/loading alerts/i)).not.toBeInTheDocument(); + expect(queryByText(/loading.../i)).not.toBeInTheDocument(); }); expect(getByText("N/A")).toBeVisible(); }); - -test("renders empty state when the API returns no alerts - user has not created multipe workspaces", async () => { - server.use( - http.get("*/workspaces/:name/alerts", () => { - return HttpResponse.json([]); - }), - http.get("*/workspaces", () => { - return HttpResponse.json({ - workspaces: [ - { - name: "my-awesome-workspace", - is_active: true, - last_updated: new Date(Date.now()).toISOString(), - }, - ], - }); - }), - ); - - const { getByText, queryByText, getByRole } = render(); - - await waitFor(() => { - expect(queryByText(/loading alerts/i)).not.toBeInTheDocument(); - }); - - expect(getByText("Connect CodeGate to your IDE")).toBeVisible(); - expect(getByText(/learn how to get set up using/i)).toBeVisible(); - - expect(getByRole("link", { name: /continue/i })).toHaveAttribute( - "href", - "https://docs.codegate.ai/quickstart-continue", - ); - expect(getByRole("link", { name: /continue/i })).toHaveAttribute( - "target", - "_blank", - ); - expect(getByRole("link", { name: /copilot/i })).toHaveAttribute( - "href", - "https://docs.codegate.ai/quickstart", - ); - expect(getByRole("link", { name: /copilot/i })).toHaveAttribute( - "target", - "_blank", - ); - expect(getByRole("link", { name: /aider/i })).toHaveAttribute( - "href", - "https://docs.codegate.ai/how-to/use-with-aider", - ); - expect(getByRole("link", { name: /aider/i })).toHaveAttribute( - "target", - "_blank", - ); - - expect( - getByRole("link", { name: /codegate documentation/i }), - ).toHaveAttribute("href", "https://docs.codegate.ai/"); - expect( - getByRole("link", { name: /codegate documentation/i }), - ).toHaveAttribute("target", "_blank"); -}); - -test("does not render table empty state when the API responds with alerts", async () => { - server.use( - http.get("*/workspaces/:name/alerts", () => { - return HttpResponse.json([ - makeMockAlert({ token_usage: false, type: "malicious" }), - ]); - }), - ); - - const { queryByText } = render(); - - await waitFor(() => { - expect(queryByText("Connect CodeGate to your IDE")).not.toBeInTheDocument(); - }); -}); - -test("renders empty state when the API returns no alerts - user has multiple workspaces", async () => { - server.use( - http.get("*/workspaces/:name/alerts", () => { - return HttpResponse.json([]); - }), - ); - - const { getByText, queryByText, getByRole } = render(); - - await waitFor(() => { - expect(queryByText(/loading alerts/i)).not.toBeInTheDocument(); - }); - - expect(getByText(/no alerts found/i)).toBeVisible(); - expect( - getByText( - /alerts will show up here when you use this workspace in your IDE/i, - ), - ).toBeVisible(); - - expect( - getByRole("link", { name: /learn about workspaces/i }), - ).toHaveAttribute("href", "https://docs.codegate.ai/features/workspaces"); - expect( - getByRole("link", { name: /learn about workspaces/i }), - ).toHaveAttribute("target", "_blank"); -}); diff --git a/src/features/alerts/components/table-alerts-empty-state.tsx b/src/features/alerts/components/table-alerts-empty-state.tsx new file mode 100644 index 00000000..010f2e2e --- /dev/null +++ b/src/features/alerts/components/table-alerts-empty-state.tsx @@ -0,0 +1,40 @@ +import { Heading } from "@stacklok/ui-kit"; +import { ReactNode } from "react"; +import { tv } from "tailwind-variants"; +import { useAlertsEmptyState } from "../hooks/use-alerts-empty-state"; + +const actionsStyle = tv({ + base: "mx-auto mt-6", + variants: { + actions: { + 1: "", + 2: "grid grid-cols-2 gap-2", + }, + }, +}); + +export function Actions({ actions }: { actions: [ReactNode, ReactNode?] }) { + return ( +
{actions}
+ ); +} + +export function TableAlertsEmptyState({ isLoading }: { isLoading: boolean }) { + const { + actions, + body, + illustration: Illustration, + title, + } = useAlertsEmptyState(isLoading); + + return ( +
+ + + {title} + +

{body}

+ {actions ? : null} +
+ ); +} diff --git a/src/features/alerts/components/table-alerts.tsx b/src/features/alerts/components/table-alerts.tsx index 96c3a335..10500aa3 100644 --- a/src/features/alerts/components/table-alerts.tsx +++ b/src/features/alerts/components/table-alerts.tsx @@ -8,10 +8,6 @@ import { TableHeader, Button, ResizableTableContainer, - Link, - LinkButton, - IllustrationDragAndDrop, - IllustrationPackage, } from "@stacklok/ui-kit"; import { AlertConversation, QuestionType } from "@/api/generated"; import { @@ -19,14 +15,16 @@ import { parsingPromptText, getIssueDetectedType, } from "@/lib/utils"; -import { useNavigate } from "react-router-dom"; + import { useClientSidePagination } from "@/hooks/useClientSidePagination"; import { TableAlertTokenUsage } from "./table-alert-token-usage"; import { useQueryGetWorkspaceAlertTable } from "../hooks/use-query-get-workspace-alerts-table"; import { useAlertsFilterSearchParams } from "../hooks/use-alerts-filter-search-params"; -import { useListWorkspaces } from "@/features/workspace/hooks/use-list-workspaces"; -import { Key01, LinkExternal02, PackageX } from "@untitled-ui/icons-react"; +import { Key01, PackageX } from "@untitled-ui/icons-react"; +import { TableAlertsEmptyState } from "./table-alerts-empty-state"; +import { ComponentProps } from "react"; +import { hrefs } from "@/lib/hrefs"; const getTitle = (alert: AlertConversation) => { const prompt = alert.conversation; @@ -77,92 +75,79 @@ function IssueDetectedCellContent({ alert }: { alert: AlertConversation }) { } } -function EmptyState({ - hasMultipleWorkspaces, +type ColumnId = "time" | "type" | "event" | "issue_detected" | "token_usage"; + +type Column = { id: ColumnId } & Omit, "id">; + +const COLUMNS: Column[] = [ + { + id: "time", + isRowHeader: true, + children: "Time", + width: 150, + }, + { + id: "type", + children: "Type", + width: 150, + }, + { + id: "event", + children: "Event", + }, + { + id: "issue_detected", + children: "Issue detected", + width: 325, + }, + { + id: "token_usage", + children: "Token usage", + width: 200, + }, +]; + +function CellRenderer({ + column, + row, }: { - hasMultipleWorkspaces: boolean; + column: Column; + row: AlertConversation; }) { - if (hasMultipleWorkspaces) { - return ( -
- -

No alerts found

-

- Alerts will show up here when you use this workspace in your IDE -

- - Learn about Workspaces - - -
- ); - } + switch (column.id) { + case "time": + return ( + + {formatDistanceToNow(new Date(row.timestamp), { + addSuffix: true, + })} + + ); + case "type": + return ; + case "event": + return getTitle(row); + case "issue_detected": + return ( +
+ +
+ ); + case "token_usage": + return ; - return ( -
- -

- Connect CodeGate to your IDE -

-

- Learn how to get set up using{" "} - - Continue - - ,{" "} - - Copilot - - , or{" "} - - Aider - - . -

- - CodeGate Documentation - - -
- ); + default: + return column.id satisfies never; + } } export function TableAlerts() { - const navigate = useNavigate(); const { state, prevPage, nextPage } = useAlertsFilterSearchParams(); - const { data: filteredAlerts = [], isLoading: isLoadingAlerts } = - useQueryGetWorkspaceAlertTable(); - - const { - data: { workspaces } = { workspaces: [] }, - isLoading: isLoadingWorkspaces, - } = useListWorkspaces(); - - const isLoading = isLoadingAlerts || isLoadingWorkspaces; + const { data = [], isLoading } = useQueryGetWorkspaceAlertTable(); const { dataView, hasNextPage, hasPreviousPage } = useClientSidePagination( - filteredAlerts, + data, state.page, 15, ); @@ -171,71 +156,48 @@ export function TableAlerts() { <> - - - - Time - - Type - Event - Issue Detected - Token usage - + + {(column) => } - isLoading ? ( -
Loading alerts
- ) : ( - 1} /> - ) - } + renderEmptyState={() => ( + + )} + items={dataView} > - {dataView.map((alert) => { - return ( - - navigate(`/prompt/${alert.conversation.chat_id}`) - } - > - - {formatDistanceToNow(new Date(alert.timestamp), { - addSuffix: true, - })} - - - - - {getTitle(alert)} - -
- -
-
- - + {(row) => ( + + {(column) => ( + + - - ); - })} + )} +
+ )}
-
-
- - + {hasNextPage || hasPreviousPage ? ( +
+
+ + +
-
+ ) : null} ); } diff --git a/src/features/alerts/constants/strings.ts b/src/features/alerts/constants/strings.ts new file mode 100644 index 00000000..b07ca4a5 --- /dev/null +++ b/src/features/alerts/constants/strings.ts @@ -0,0 +1,25 @@ +export const emptyStateStrings = { + title: { + loading: "Loading...", + getStarted: "Get started with CodeGate", + noAlertsFound: "No alerts found", + noAlertsFoundWorkspace: "This workspace hasn't triggered any alerts", + noLeakedSecretsDetected: "No leaked secrets detected", + noMaliciousPackagesDetected: "No malicious packages detected", + noSearchResultsFor: (x: string | undefined): string => + !x ? "No search results" : `No search results for "${x}"`, + }, + body: { + loading: "Checking for the latest alerts.", + getStartedDesc: "Learn how to get started with CodeGate in your IDE.", + tryChangingSearch: "Try changing your search query or clearing the search.", + alertsWillShowUpWhenWorkspace: + "Alerts will show up here when they are detected for this workspace.", + alertsDesc: + "Alerts are issues that CodeGate has detected and mitigated in your interactions with the LLM.", + secretsDesc: + "CodeGate helps you protect sensitive information from being accidentally exposed to AI models and third-party AI provider systems by redacting detected secrets from your prompts using encryption.", + maliciousDesc: + "CodeGate's dependency risk insight helps protect your codebase from malicious or vulnerable dependencies. It identifies potentially risky packages and suggests fixed versions or alternative packages to consider.", + }, +} as const; diff --git a/src/features/alerts/hooks/use-alerts-empty-state.tsx b/src/features/alerts/hooks/use-alerts-empty-state.tsx new file mode 100644 index 00000000..44faa8fc --- /dev/null +++ b/src/features/alerts/hooks/use-alerts-empty-state.tsx @@ -0,0 +1,190 @@ +import { JSX, ReactNode, SVGProps } from "react"; +import { + AlertsFilterView, + useAlertsFilterSearchParams, +} from "./use-alerts-filter-search-params"; +import { match, P } from "ts-pattern"; +import { + IllustrationDone, + IllustrationDragAndDrop, + IllustrationAlert, + IllustrationNoSearchResults, + Loader, + LinkButton, + Button, +} from "@stacklok/ui-kit"; +import { emptyStateStrings } from "../constants/strings"; +import { useQueryGetWorkspaceAlerts } from "./use-query-get-workspace-alerts"; +import { useListAllWorkspaces } from "@/features/workspace/hooks/use-query-list-all-workspaces"; +import { twMerge } from "tailwind-merge"; +import { hrefs } from "@/lib/hrefs"; +import { LinkExternal02 } from "@untitled-ui/icons-react"; + +type Input = { + isLoading: boolean; + hasWorkspaceAlerts: boolean; + hasMultipleWorkspaces: boolean; + search: string | null; + view: AlertsFilterView | null; +}; + +type Output = { + illustration: (props: SVGProps) => JSX.Element; + title: string; + body: string; + actions: [ReactNode, ReactNode?] | null; +}; + +export function useAlertsEmptyState(controlledIsLoading: boolean) { + const { state, setSearch } = useAlertsFilterSearchParams(); + + const { data: alerts = [], isLoading: isAlertsLoading } = + useQueryGetWorkspaceAlerts(); + + const { data: workspaces = [], isLoading: isWorkspacesLoading } = + useListAllWorkspaces(); + + const isLoading = + controlledIsLoading || isAlertsLoading || isWorkspacesLoading; + + return match({ + isLoading, + hasWorkspaceAlerts: alerts.length > 0, + hasMultipleWorkspaces: + workspaces.filter((w) => w.name !== "default").length > 0, + search: state.search || null, + view: state.view, + }) + .with( + { + isLoading: true, + hasWorkspaceAlerts: P._, + hasMultipleWorkspaces: P._, + search: P._, + view: P._, + }, + () => ({ + title: emptyStateStrings.title.loading, + body: emptyStateStrings.body.loading, + actions: null, + illustration: (props) => ( + path]:stroke-[0.1px]")} + /> + ), + }), + ) + .with( + { + hasWorkspaceAlerts: false, + hasMultipleWorkspaces: false, + search: P._, + view: P._, + }, + () => ({ + title: emptyStateStrings.title.getStarted, + body: emptyStateStrings.body.getStartedDesc, + actions: [ + + CodeGate docs + + , + ], + illustration: IllustrationDragAndDrop, + }), + ) + .with( + { + hasWorkspaceAlerts: true, + hasMultipleWorkspaces: P.any, + search: P.string.select(), + view: P._, + }, + (search) => ({ + title: emptyStateStrings.title.noSearchResultsFor(search), + body: emptyStateStrings.body.tryChangingSearch, + actions: [ + , + ], + illustration: IllustrationNoSearchResults, + }), + ) + .with( + { + hasWorkspaceAlerts: false, + hasMultipleWorkspaces: true, + search: P._, + view: P.any, + }, + () => ({ + title: emptyStateStrings.title.noAlertsFoundWorkspace, + body: emptyStateStrings.body.alertsWillShowUpWhenWorkspace, + actions: [ + + Manage workspaces + , + ], + illustration: IllustrationDone, + }), + ) + .with( + { + hasWorkspaceAlerts: true, + hasMultipleWorkspaces: true, + search: P._, + view: AlertsFilterView.MALICIOUS, + }, + () => ({ + title: emptyStateStrings.title.noMaliciousPackagesDetected, + body: emptyStateStrings.body.maliciousDesc, + actions: null, + illustration: IllustrationDone, + }), + ) + .with( + { + hasWorkspaceAlerts: true, + hasMultipleWorkspaces: P.any, + view: AlertsFilterView.SECRETS, + }, + () => ({ + title: emptyStateStrings.title.noLeakedSecretsDetected, + body: emptyStateStrings.body.secretsDesc, + actions: null, + illustration: IllustrationDone, + }), + ) + .otherwise(() => ({ + title: "An error occurred", + body: "Please try refreshing the page. If this issue persists, please let us know on Discord, or open a a new Github Issue", + illustration: IllustrationAlert, + actions: [ + + Discord + , + + Github issues + , + ], + })); +} diff --git a/src/features/alerts/hooks/use-query-get-workspace-alerts.ts b/src/features/alerts/hooks/use-query-get-workspace-alerts.ts index 608f41c1..1a2e9f8d 100644 --- a/src/features/alerts/hooks/use-query-get-workspace-alerts.ts +++ b/src/features/alerts/hooks/use-query-get-workspace-alerts.ts @@ -11,7 +11,13 @@ export function useQueryGetWorkspaceAlerts({ }: { select?: (data: V1GetWorkspaceAlertsResponse) => T; } = {}) { - const { data: activeWorkspaceName } = useActiveWorkspaceName(); + const { + data: activeWorkspaceName, + isPending: isWorkspacePending, + isFetching: isWorkspaceFetching, + isLoading: isWorkspaceLoading, + isRefetching: isWorkspaceRefetching, + } = useActiveWorkspaceName(); const options: V1GetWorkspaceAlertsData = { path: { @@ -19,11 +25,25 @@ export function useQueryGetWorkspaceAlerts({ }, }; - return useQuery({ + const { + isPending: isAlertsPending, + isFetching: isAlertsFetching, + isLoading: isAlertsLoading, + isRefetching: isAlertsRefetching, + ...rest + } = useQuery({ ...v1GetWorkspaceAlertsOptions(options), refetchOnMount: true, refetchOnReconnect: true, refetchOnWindowFocus: true, select, }); + + return { + ...rest, + isPending: isAlertsPending || isWorkspacePending, + isFetching: isAlertsFetching || isWorkspaceFetching, + isLoading: isAlertsLoading || isWorkspaceLoading, + isRefetching: isAlertsRefetching || isWorkspaceRefetching, + }; } diff --git a/src/features/workspace/hooks/use-query-list-all-workspaces.ts b/src/features/workspace/hooks/use-query-list-all-workspaces.ts index 37a50551..875a0e6f 100644 --- a/src/features/workspace/hooks/use-query-list-all-workspaces.ts +++ b/src/features/workspace/hooks/use-query-list-all-workspaces.ts @@ -48,6 +48,7 @@ const combine = (results: UseQueryDataReturn) => { data: [...active, ...archived], isPending: results.some((r) => r.isPending), isFetching: results.some((r) => r.isFetching), + isLoading: results.some((r) => r.isLoading), isRefetching: results.some((r) => r.isRefetching), }; }; diff --git a/src/lib/hrefs.ts b/src/lib/hrefs.ts index cb3b9407..231dfb5e 100644 --- a/src/lib/hrefs.ts +++ b/src/lib/hrefs.ts @@ -1,4 +1,8 @@ export const hrefs = { + external: { + docs: "https://docs.codegate.ai/", + }, + prompt: (id: string) => `/prompt/${id}`, workspaces: { all: "/workspaces", create: "/workspace/create", diff --git a/src/routes/__tests__/route-dashboard.test.tsx b/src/routes/__tests__/route-dashboard.test.tsx index f8c2a92b..0bb5097c 100644 --- a/src/routes/__tests__/route-dashboard.test.tsx +++ b/src/routes/__tests__/route-dashboard.test.tsx @@ -189,13 +189,7 @@ describe("Dashboard", () => { render(); await waitFor(() => { - expect( - within(screen.getByTestId("alerts-table")).getAllByRole("row").length, - ).toBeGreaterThan(1); - }); - - await waitFor(() => { - expect(screen.queryByText(/loading alerts/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/loading.../i)).not.toBeInTheDocument(); }); expect( @@ -219,14 +213,9 @@ describe("Dashboard", () => { render(); await waitFor(() => { - expect( - within(screen.getByTestId("alerts-table")).getAllByRole("row").length, - ).toBeGreaterThan(1); + expect(screen.queryByText(/loading.../i)).not.toBeInTheDocument(); }); - await waitFor(() => { - expect(screen.queryByText(/loading alerts/i)).not.toBeInTheDocument(); - }); expect(screen.getByTestId(/tab-all-count/i)).toHaveTextContent("2"); expect( @@ -269,14 +258,9 @@ describe("Dashboard", () => { render(); await waitFor(() => { - expect( - within(screen.getByTestId("alerts-table")).getAllByRole("row").length, - ).toBeGreaterThan(1); + expect(screen.queryByText(/loading.../i)).not.toBeInTheDocument(); }); - await waitFor(() => { - expect(screen.queryByText(/loading alerts/i)).not.toBeInTheDocument(); - }); expect(screen.getByTestId(/tab-all-count/i)).toHaveTextContent("2"); expect( screen.getAllByRole("gridcell", { @@ -318,13 +302,7 @@ describe("Dashboard", () => { render(); await waitFor(() => { - expect( - within(screen.getByTestId("alerts-table")).getAllByRole("row").length, - ).toBeGreaterThan(1); - }); - - await waitFor(() => { - expect(screen.queryByText(/loading alerts/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/loading.../i)).not.toBeInTheDocument(); }); expect(screen.getByTestId(/tab-all-count/i)).toHaveTextContent("2"); From 62715ba51006e5b8aaf513d2d62a27ed44f0ee1f Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 31 Jan 2025 16:25:06 +0000 Subject: [PATCH 4/6] refactor: tidy up pattern matching --- src/components/empty-state.tsx | 42 +++ .../components/table-alerts-empty-state.tsx | 250 +++++++++++++++--- .../alerts/components/table-alerts.tsx | 8 +- src/features/alerts/constants/strings.ts | 3 + .../alerts/hooks/use-alerts-empty-state.tsx | 190 ------------- 5 files changed, 267 insertions(+), 226 deletions(-) create mode 100644 src/components/empty-state.tsx delete mode 100644 src/features/alerts/hooks/use-alerts-empty-state.tsx diff --git a/src/components/empty-state.tsx b/src/components/empty-state.tsx new file mode 100644 index 00000000..3fb1d3f8 --- /dev/null +++ b/src/components/empty-state.tsx @@ -0,0 +1,42 @@ +import { Heading } from "@stacklok/ui-kit"; +import { JSX, ReactNode, SVGProps } from "react"; +import { tv } from "tailwind-variants"; + +const actionsStyle = tv({ + base: "mx-auto mt-8", + variants: { + actions: { + 1: "", + 2: "grid grid-cols-2 gap-2", + }, + }, +}); + +export function Actions({ actions }: { actions: [ReactNode, ReactNode?] }) { + return ( +
{actions}
+ ); +} + +export function EmptyState({ + actions, + body, + illustration: Illustration, + title, +}: { + illustration: (props: SVGProps) => JSX.Element; + title: string; + body: string; + actions: [ReactNode, ReactNode?] | null; +}) { + return ( +
+ + + {title} + +

{body}

+ {actions ? : null} +
+ ); +} diff --git a/src/features/alerts/components/table-alerts-empty-state.tsx b/src/features/alerts/components/table-alerts-empty-state.tsx index 010f2e2e..8f49e481 100644 --- a/src/features/alerts/components/table-alerts-empty-state.tsx +++ b/src/features/alerts/components/table-alerts-empty-state.tsx @@ -1,40 +1,228 @@ -import { Heading } from "@stacklok/ui-kit"; +import { + Button, + IllustrationAlert, + IllustrationDone, + IllustrationDragAndDrop, + IllustrationNoSearchResults, + LinkButton, + Loader, +} from "@stacklok/ui-kit"; import { ReactNode } from "react"; -import { tv } from "tailwind-variants"; -import { useAlertsEmptyState } from "../hooks/use-alerts-empty-state"; - -const actionsStyle = tv({ - base: "mx-auto mt-6", - variants: { - actions: { - 1: "", - 2: "grid grid-cols-2 gap-2", - }, - }, -}); - -export function Actions({ actions }: { actions: [ReactNode, ReactNode?] }) { + +import { emptyStateStrings } from "../constants/strings"; +import { EmptyState } from "@/components/empty-state"; +import { hrefs } from "@/lib/hrefs"; +import { LinkExternal02 } from "@untitled-ui/icons-react"; +import { useListAllWorkspaces } from "@/features/workspace/hooks/use-query-list-all-workspaces"; +import { + AlertsFilterView, + useAlertsFilterSearchParams, +} from "../hooks/use-alerts-filter-search-params"; +import { useQueryGetWorkspaceAlerts } from "../hooks/use-query-get-workspace-alerts"; +import { match, P } from "ts-pattern"; + +function EmptyStateLoading() { + return ( + + ); +} + +function EmptyStateGetStarted() { + return ( + + CodeGate docs + + , + ]} + /> + ); +} + +function EmptyStateSearch({ + search, + setSearch, +}: { + search: string; + setSearch: (v: string | null) => void; +}) { + return ( + setSearch(null)}> + Clear search + , + ]} + /> + ); +} + +function EmptyStateNoAlertsInWorkspace() { return ( -
{actions}
+ + Manage workspaces + , + ]} + /> ); } -export function TableAlertsEmptyState({ isLoading }: { isLoading: boolean }) { - const { - actions, - body, - illustration: Illustration, - title, - } = useAlertsEmptyState(isLoading); +function EmptyStateMalicious() { + return ( + + ); +} + +function EmptyStateSecrets() { + return ( + + ); +} +function EmptyStateError() { return ( -
- - - {title} - -

{body}

- {actions ? : null} -
+ + Discord + + , + + Github issues + + , + ]} + /> ); } + +type MatchInput = { + isLoading: boolean; + hasWorkspaceAlerts: boolean; + hasMultipleWorkspaces: boolean; + search: string | null; + view: AlertsFilterView | null; +}; + +export function TableAlertsEmptyState() { + const { state, setSearch } = useAlertsFilterSearchParams(); + + const { data: alerts = [], isLoading: isAlertsLoading } = + useQueryGetWorkspaceAlerts(); + + const { data: workspaces = [], isLoading: isWorkspacesLoading } = + useListAllWorkspaces(); + + const isLoading = isAlertsLoading || isWorkspacesLoading; + + return match({ + isLoading, + hasWorkspaceAlerts: alerts.length > 0, + hasMultipleWorkspaces: + workspaces.filter((w) => w.name !== "default").length > 0, + search: state.search || null, + view: state.view, + }) + .with( + { + isLoading: true, + hasWorkspaceAlerts: P._, + hasMultipleWorkspaces: P._, + search: P._, + view: P._, + }, + () => , + ) + .with( + { + hasWorkspaceAlerts: false, + hasMultipleWorkspaces: false, + search: P._, + view: P._, + }, + () => , + ) + .with( + { + hasWorkspaceAlerts: true, + hasMultipleWorkspaces: P.any, + search: P.string.select(), + view: P._, + }, + (search) => , + ) + .with( + { + hasWorkspaceAlerts: false, + hasMultipleWorkspaces: true, + search: P._, + view: P.any, + }, + () => , + ) + .with( + { + hasWorkspaceAlerts: true, + hasMultipleWorkspaces: true, + search: P._, + view: AlertsFilterView.MALICIOUS, + }, + () => , + ) + .with( + { + hasWorkspaceAlerts: true, + hasMultipleWorkspaces: P.any, + view: AlertsFilterView.SECRETS, + }, + () => , + ) + .otherwise(() => ); +} diff --git a/src/features/alerts/components/table-alerts.tsx b/src/features/alerts/components/table-alerts.tsx index 31b40df5..df58e066 100644 --- a/src/features/alerts/components/table-alerts.tsx +++ b/src/features/alerts/components/table-alerts.tsx @@ -83,7 +83,7 @@ const COLUMNS: Column[] = [ id: "time", isRowHeader: true, children: "Time", - width: 150, + width: 200, }, { id: "type", @@ -143,7 +143,7 @@ function CellRenderer({ export function TableAlerts() { const { state, prevPage, nextPage } = useAlertsFilterSearchParams(); - const { data = [], isLoading } = useQueryGetWorkspaceAlertTable(); + const { data = [] } = useQueryGetWorkspaceAlertTable(); const { dataView, hasNextPage, hasPreviousPage } = useClientSidePagination( data, @@ -159,9 +159,7 @@ export function TableAlerts() { {(column) => } ( - - )} + renderEmptyState={() => } items={dataView} > {(row) => ( diff --git a/src/features/alerts/constants/strings.ts b/src/features/alerts/constants/strings.ts index b07ca4a5..119e1ae5 100644 --- a/src/features/alerts/constants/strings.ts +++ b/src/features/alerts/constants/strings.ts @@ -4,6 +4,7 @@ export const emptyStateStrings = { getStarted: "Get started with CodeGate", noAlertsFound: "No alerts found", noAlertsFoundWorkspace: "This workspace hasn't triggered any alerts", + anErrorOccurred: "An error occurred", noLeakedSecretsDetected: "No leaked secrets detected", noMaliciousPackagesDetected: "No malicious packages detected", noSearchResultsFor: (x: string | undefined): string => @@ -11,6 +12,8 @@ export const emptyStateStrings = { }, body: { loading: "Checking for the latest alerts.", + errorDesc: + "Please try refreshing the page. If this issue persists, please let us know on Discord, or open a a new Github Issue", getStartedDesc: "Learn how to get started with CodeGate in your IDE.", tryChangingSearch: "Try changing your search query or clearing the search.", alertsWillShowUpWhenWorkspace: diff --git a/src/features/alerts/hooks/use-alerts-empty-state.tsx b/src/features/alerts/hooks/use-alerts-empty-state.tsx deleted file mode 100644 index 44faa8fc..00000000 --- a/src/features/alerts/hooks/use-alerts-empty-state.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { JSX, ReactNode, SVGProps } from "react"; -import { - AlertsFilterView, - useAlertsFilterSearchParams, -} from "./use-alerts-filter-search-params"; -import { match, P } from "ts-pattern"; -import { - IllustrationDone, - IllustrationDragAndDrop, - IllustrationAlert, - IllustrationNoSearchResults, - Loader, - LinkButton, - Button, -} from "@stacklok/ui-kit"; -import { emptyStateStrings } from "../constants/strings"; -import { useQueryGetWorkspaceAlerts } from "./use-query-get-workspace-alerts"; -import { useListAllWorkspaces } from "@/features/workspace/hooks/use-query-list-all-workspaces"; -import { twMerge } from "tailwind-merge"; -import { hrefs } from "@/lib/hrefs"; -import { LinkExternal02 } from "@untitled-ui/icons-react"; - -type Input = { - isLoading: boolean; - hasWorkspaceAlerts: boolean; - hasMultipleWorkspaces: boolean; - search: string | null; - view: AlertsFilterView | null; -}; - -type Output = { - illustration: (props: SVGProps) => JSX.Element; - title: string; - body: string; - actions: [ReactNode, ReactNode?] | null; -}; - -export function useAlertsEmptyState(controlledIsLoading: boolean) { - const { state, setSearch } = useAlertsFilterSearchParams(); - - const { data: alerts = [], isLoading: isAlertsLoading } = - useQueryGetWorkspaceAlerts(); - - const { data: workspaces = [], isLoading: isWorkspacesLoading } = - useListAllWorkspaces(); - - const isLoading = - controlledIsLoading || isAlertsLoading || isWorkspacesLoading; - - return match({ - isLoading, - hasWorkspaceAlerts: alerts.length > 0, - hasMultipleWorkspaces: - workspaces.filter((w) => w.name !== "default").length > 0, - search: state.search || null, - view: state.view, - }) - .with( - { - isLoading: true, - hasWorkspaceAlerts: P._, - hasMultipleWorkspaces: P._, - search: P._, - view: P._, - }, - () => ({ - title: emptyStateStrings.title.loading, - body: emptyStateStrings.body.loading, - actions: null, - illustration: (props) => ( - path]:stroke-[0.1px]")} - /> - ), - }), - ) - .with( - { - hasWorkspaceAlerts: false, - hasMultipleWorkspaces: false, - search: P._, - view: P._, - }, - () => ({ - title: emptyStateStrings.title.getStarted, - body: emptyStateStrings.body.getStartedDesc, - actions: [ - - CodeGate docs - - , - ], - illustration: IllustrationDragAndDrop, - }), - ) - .with( - { - hasWorkspaceAlerts: true, - hasMultipleWorkspaces: P.any, - search: P.string.select(), - view: P._, - }, - (search) => ({ - title: emptyStateStrings.title.noSearchResultsFor(search), - body: emptyStateStrings.body.tryChangingSearch, - actions: [ - , - ], - illustration: IllustrationNoSearchResults, - }), - ) - .with( - { - hasWorkspaceAlerts: false, - hasMultipleWorkspaces: true, - search: P._, - view: P.any, - }, - () => ({ - title: emptyStateStrings.title.noAlertsFoundWorkspace, - body: emptyStateStrings.body.alertsWillShowUpWhenWorkspace, - actions: [ - - Manage workspaces - , - ], - illustration: IllustrationDone, - }), - ) - .with( - { - hasWorkspaceAlerts: true, - hasMultipleWorkspaces: true, - search: P._, - view: AlertsFilterView.MALICIOUS, - }, - () => ({ - title: emptyStateStrings.title.noMaliciousPackagesDetected, - body: emptyStateStrings.body.maliciousDesc, - actions: null, - illustration: IllustrationDone, - }), - ) - .with( - { - hasWorkspaceAlerts: true, - hasMultipleWorkspaces: P.any, - view: AlertsFilterView.SECRETS, - }, - () => ({ - title: emptyStateStrings.title.noLeakedSecretsDetected, - body: emptyStateStrings.body.secretsDesc, - actions: null, - illustration: IllustrationDone, - }), - ) - .otherwise(() => ({ - title: "An error occurred", - body: "Please try refreshing the page. If this issue persists, please let us know on Discord, or open a a new Github Issue", - illustration: IllustrationAlert, - actions: [ - - Discord - , - - Github issues - , - ], - })); -} From 0f391b1a87c9c4081ec2475c56b2d4dffa8c6193 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Mon, 3 Feb 2025 08:23:10 +0000 Subject: [PATCH 5/6] chore: tidy ups --- .../components/table-alerts-empty-state.tsx | 16 +++++++++++----- src/lib/hrefs.ts | 7 ++++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/features/alerts/components/table-alerts-empty-state.tsx b/src/features/alerts/components/table-alerts-empty-state.tsx index 8f49e481..b8bf7e41 100644 --- a/src/features/alerts/components/table-alerts-empty-state.tsx +++ b/src/features/alerts/components/table-alerts-empty-state.tsx @@ -42,7 +42,7 @@ function EmptyStateGetStarted() { CodeGate docs @@ -81,8 +81,14 @@ function EmptyStateNoAlertsInWorkspace() { body={emptyStateStrings.body.alertsWillShowUpWhenWorkspace} illustration={IllustrationDone} actions={[ - - Manage workspaces + + Learn about Workspaces + , ]} /> @@ -121,7 +127,7 @@ function EmptyStateError() { @@ -131,7 +137,7 @@ function EmptyStateError() { diff --git a/src/lib/hrefs.ts b/src/lib/hrefs.ts index 231dfb5e..53b2d7e6 100644 --- a/src/lib/hrefs.ts +++ b/src/lib/hrefs.ts @@ -1,6 +1,11 @@ export const hrefs = { external: { - docs: "https://docs.codegate.ai/", + discord: "https://discord.gg/stacklok", + github: { newIssue: "https://github.com/stacklok/codegate/issues/new" }, + docs: { + home: "https://docs.codegate.ai/", + workspaces: "https://docs.codegate.ai/features/workspaces", + }, }, prompt: (id: string) => `/prompt/${id}`, workspaces: { From f50729e5c092b1c46c6dbffad6d5f07b89f6a86a Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Mon, 3 Feb 2025 08:24:31 +0000 Subject: [PATCH 6/6] fix: tests --- .../components/__tests__/table-alerts.empty-state.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/features/alerts/components/__tests__/table-alerts.empty-state.test.tsx b/src/features/alerts/components/__tests__/table-alerts.empty-state.test.tsx index 841bae8b..c6ce31f4 100644 --- a/src/features/alerts/components/__tests__/table-alerts.empty-state.test.tsx +++ b/src/features/alerts/components/__tests__/table-alerts.empty-state.test.tsx @@ -127,7 +127,7 @@ const TEST_CASES: TestCase[] = [ { role: "link", name: "CodeGate docs", - href: hrefs.external.docs, + href: hrefs.external.docs.home, }, ], }, @@ -208,8 +208,8 @@ const TEST_CASES: TestCase[] = [ actions: [ { role: "link", - name: "Manage workspaces", - href: hrefs.workspaces.all, + name: "Learn about Workspaces", + href: hrefs.external.docs.workspaces, }, ], },