diff --git a/src/api/services/configs.ts b/src/api/services/configs.ts index b8652d1fa..5550e2c4e 100644 --- a/src/api/services/configs.ts +++ b/src/api/services/configs.ts @@ -19,8 +19,9 @@ export const getAllConfigs = () => resolve(ConfigDB.get(`/configs`)); export const getConfigsTags = async () => { - const res = - await ConfigDB.get<{ key: string; value: string }[]>("/config_tags"); + const res = await ConfigDB.get<{ key: string; value: string }[]>( + "/config_tags" + ); return res.data ?? []; }; @@ -209,6 +210,7 @@ export type GetConfigsRelatedChangesParams = { pageSize?: string; sortBy?: string; sortOrder?: "asc" | "desc"; + arbitraryFilter?: Record; }; export async function getConfigsChanges({ @@ -224,7 +226,8 @@ export async function getConfigsChanges({ page, pageSize, sortBy, - sortOrder + sortOrder, + arbitraryFilter }: GetConfigsRelatedChangesParams) { const queryParams = new URLSearchParams(); if (id) { @@ -248,6 +251,14 @@ export async function getConfigsChanges({ queryParams.set("type", value); } } + if (arbitraryFilter) { + Object.entries(arbitraryFilter).forEach(([key, value]) => { + const filterExpression = tristateOutputToQueryParamValue(value); + if (filterExpression) { + queryParams.set(key, filterExpression); + } + }); + } if (from) { queryParams.set("from", from); } diff --git a/src/components/Configs/Changes/ConfigChangeHistory/index.tsx b/src/components/Configs/Changes/ConfigChangeHistory/index.tsx index e2c4f92e3..57d1d8761 100644 --- a/src/components/Configs/Changes/ConfigChangeHistory/index.tsx +++ b/src/components/Configs/Changes/ConfigChangeHistory/index.tsx @@ -2,6 +2,7 @@ import { useGetConfigChangesById } from "@flanksource-ui/api/query-hooks/useGetC import { ConfigChange } from "@flanksource-ui/api/types/configs"; import GetUserAvatar from "@flanksource-ui/components/Users/GetUserAvatar"; import { PaginationOptions } from "@flanksource-ui/ui/DataTable"; +import FilterByCellValue from "@flanksource-ui/ui/DataTable/FilterByCellValue"; import { DateCell } from "@flanksource-ui/ui/table"; import { SortingState, Updater } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/table-core"; @@ -11,6 +12,10 @@ import { DataTable } from "../../../index"; import ConfigLink from "../../ConfigLink/ConfigLink"; import { ConfigDetailChangeModal } from "../ConfigDetailsChanges/ConfigDetailsChanges"; +export const paramsToReset = { + configChanges: ["pageIndex", "pageSize"] +}; + const columns: ColumnDef[] = [ { header: "Created", @@ -30,11 +35,18 @@ const columns: ColumnDef[] = [ enableHiding: true, enableSorting: false, cell: function ConfigLinkCell({ row }) { + const configId = row.original.config_id; return ( - + + + ); }, size: 84 @@ -45,10 +57,16 @@ const columns: ColumnDef[] = [ cell: function ConfigChangeTypeCell({ row, column }) { const changeType = row?.getValue(column.id) as string; return ( -
- - {changeType} -
+ +
+ + {changeType} +
+
); }, maxSize: 70 @@ -59,7 +77,20 @@ const columns: ColumnDef[] = [ meta: { cellClassName: "text-ellipsis overflow-hidden" }, - maxSize: 150 + maxSize: 150, + cell: ({ getValue }) => { + const summary = getValue(); + + return ( + + {summary} + + ); + } }, { header: "Created By", @@ -68,18 +99,43 @@ const columns: ColumnDef[] = [ cell: ({ row }) => { const userID = row.original.created_by; if (userID) { - return ; + return ( + + + + ); } + const externalCreatedBy = row.original.external_created_by; if (externalCreatedBy) { - return {externalCreatedBy}; + return ( + + {externalCreatedBy} + + ); } + const source = row.original.source; if (source) { - return {source}; + return ( + + {source} + + ); } - return null; } } diff --git a/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters.tsx b/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters.tsx index 180cd2299..d5169215d 100644 --- a/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters.tsx +++ b/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters.tsx @@ -1,26 +1,89 @@ +import ClosableBadge from "@flanksource-ui/ui/Badge/ClosableBadge"; import clsx from "clsx"; +import { useCallback } from "react"; +import { FaBan } from "react-icons/fa"; +import { useSearchParams } from "react-router-dom"; +import { paramsToReset } from "../ConfigChangeHistory"; import { ChangesTypesDropdown } from "./ChangeTypesDropdown"; import { ConfigChangeSeverity } from "./ConfigChangeSeverity"; import ConfigChangesDateRangeFilter from "./ConfigChangesDateRangeFIlter"; import ConfigTypesTristateDropdown from "./ConfigTypesTristateDropdown"; +type FilterBadgeProps = { + filters: string; + paramKey: string; +}; + +function FilterBadge({ filters, paramKey }: FilterBadgeProps) { + const [params, setParams] = useSearchParams(); + + const onRemove = useCallback( + (key: string, value: string) => { + const currentValue = params.get(key); + const arrayValue = currentValue?.split(",") || []; + const newValues = arrayValue.filter( + (v) => decodeURIComponent(v) !== decodeURIComponent(value) + ); + if (newValues.length === 0) { + params.delete(key); + } else { + const updateValue = newValues.join(","); + params.set(key, updateValue); + } + paramsToReset.configChanges.forEach((param) => params.delete(param)); + setParams(params); + }, + [params, setParams] + ); + + const filtersArray = filters.split(","); + + return ( + <> + {filtersArray.map((filter) => ( + onRemove(paramKey, filter)} + > + {paramKey}: + + {decodeURIComponent(filter.split(":")[0])} + + {filter.split(":")[1] === "-1" && } + + ))} + + ); +} + type ConfigChangeFiltersProps = React.HTMLProps & { paramsToReset?: string[]; + arbitraryFilters?: Record; }; export function ConfigChangeFilters({ className, paramsToReset = [], + arbitraryFilters, ...props }: ConfigChangeFiltersProps) { return ( -
- - - - +
+
+ + + + +
+
+ {Object.entries(arbitraryFilters ?? {}).map(([key, value]) => ( + + ))} +
); } diff --git a/src/pages/config/ConfigChangesPage.tsx b/src/pages/config/ConfigChangesPage.tsx index 22fc0cb2e..bbfab951d 100644 --- a/src/pages/config/ConfigChangesPage.tsx +++ b/src/pages/config/ConfigChangesPage.tsx @@ -38,6 +38,31 @@ export function ConfigChangesPage() { const pageSize = params.get("pageSize") ?? "200"; const sortBy = params.get("sortBy") ?? undefined; const sortDirection = params.get("sortDirection") === "asc" ? "asc" : "desc"; + const configId = params.get("id") ?? undefined; + const changeSummary = params.get("summary") ?? undefined; + const source = params.get("source") ?? undefined; + const createdBy = params.get("created_by") ?? undefined; + const externalCreatedBy = params.get("external_created_by") ?? undefined; + + const arbitraryFilter = useMemo(() => { + const filter = new Map(); + if (configId) { + filter.set("id", configId); + } + if (changeSummary) { + filter.set("summary", changeSummary); + } + if (source) { + filter.set("source", source); + } + if (createdBy) { + filter.set("created_by", createdBy); + } + if (externalCreatedBy) { + filter.set("created_by", externalCreatedBy); + } + return Object.fromEntries(filter); + }, [changeSummary, configId, createdBy, externalCreatedBy, source]); const sortState: SortingState = useMemo( () => [ @@ -66,7 +91,8 @@ export function ConfigChangesPage() { sortBy, sortOrder: sortDirection === "desc" ? "desc" : "asc", page: page, - pageSize: pageSize + pageSize: pageSize, + arbitraryFilter }, { keepPreviousData: true @@ -160,7 +186,10 @@ export function ConfigChangesPage() { ) : ( <> - + void; +}; + +export default function ClosableBadge({ + children, + size = "sm", + dot, + title, + color = "blue", + className, + roundedClass = "rounded", + onRemove = () => {} +}: BadgeProps) { + if (children == null || children === "") { + return null; + } + + const colorClass = + color === "blue" + ? "bg-blue-100 text-blue-800" + : "bg-gray-200 text-gray-700"; + const spanClassName = + size === "sm" ? "text-sm px-1 py-0.5" : "text-xs px-1 py-0.5"; + const svgClassName = + size === "sm" ? "mr-1.5 h-2 w-2" : "-ml-0.5 mr-1.5 h-2 w-2"; + + return ( +
+ {dot != null && ( + + + + )} + {children} +
+ ); +} diff --git a/src/ui/DataTable/FilterByCell.stories.tsx b/src/ui/DataTable/FilterByCell.stories.tsx new file mode 100644 index 000000000..343620f35 --- /dev/null +++ b/src/ui/DataTable/FilterByCell.stories.tsx @@ -0,0 +1,48 @@ +import { Meta, StoryFn } from "@storybook/react"; +import React from "react"; +import { MemoryRouter, useSearchParams } from "react-router-dom"; + +import { JSONViewer } from "../JSONViewer"; +import FilterByCellValue from "./FilterByCellValue"; + +export default { + title: "FilterByCellValue", + component: FilterByCellValue, + decorators: [ + (Story: React.FC) => ( + + + + ) + ] +} satisfies Meta; + +const Template: StoryFn = (arg) => { + const [searchParams] = useSearchParams(); + + const jsonSearchParams = JSON.stringify( + Object.fromEntries(searchParams.entries()), + null, + 2 + ); + + return ( +
+ + + + paramKey: key1, filterValue: value1 + + + + paramKey: key2, filterValue: value2 + + + + paramKey: key3, filterValue: value3 + +
+ ); +}; + +export const Default = Template.bind({}); diff --git a/src/ui/DataTable/FilterByCellValue.tsx b/src/ui/DataTable/FilterByCellValue.tsx new file mode 100644 index 000000000..39f2cc7fc --- /dev/null +++ b/src/ui/DataTable/FilterByCellValue.tsx @@ -0,0 +1,67 @@ +import { ReactNode, useCallback } from "react"; +import { GoPlus } from "react-icons/go"; +import { HiMiniMinusSmall } from "react-icons/hi2"; +import { useSearchParams } from "react-router-dom"; +import { IconButton } from "../Buttons/IconButton"; + +type FilterByCellProps = { + paramKey: string; + children: ReactNode; + filterValue: string; + paramsToReset?: string[]; +}; + +export default function FilterByCellValue({ + paramKey, + children, + filterValue, + paramsToReset = [] +}: FilterByCellProps) { + const [params, setParams] = useSearchParams(); + + const onClick = useCallback( + (e: React.MouseEvent, action: "include" | "exclude") => { + e.preventDefault(); + e.stopPropagation(); + const currentValue = params.get(paramKey); + const arrayValue = currentValue?.split(",") || []; + // if include, we need to remove all exclude values and + // if exclude, we need to remove all include values + const newValues = arrayValue.filter( + (value) => + (action === "include" && parseInt(value.split(":")[1]) === 1) || + (action === "exclude" && parseInt(value.split(":")[1]) === -1) + ); + // append the new value + const updateValue = newValues + .concat( + `${encodeURIComponent(filterValue)}:${action === "include" ? 1 : -1}` + ) + // remove duplicates + .filter((value, index, self) => self.indexOf(value) === index) + .join(","); + params.set(paramKey, updateValue); + paramsToReset.forEach((param) => params.delete(param)); + setParams(params); + }, + [filterValue, paramKey, params, paramsToReset, setParams] + ); + + return ( +
+
{children}
+
+ onClick(e, "include")} + icon={} + title="Include" + /> + onClick(e, "exclude")} + icon={} + title="Exclude" + /> +
+
+ ); +} diff --git a/src/ui/DataTable/__tests__/FilterByCellValue.unit.test.tsx b/src/ui/DataTable/__tests__/FilterByCellValue.unit.test.tsx new file mode 100644 index 000000000..0ca3562fe --- /dev/null +++ b/src/ui/DataTable/__tests__/FilterByCellValue.unit.test.tsx @@ -0,0 +1,58 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { useEffect } from "react"; +import { MemoryRouter, useSearchParams } from "react-router-dom"; +import FilterByCellValue from "../FilterByCellValue"; + +const TestComponent = ({ onClick }: { onClick: (value?: string) => void }) => { + const [searchParams] = useSearchParams(); + + const searchValue = searchParams.get("paramKey"); + + useEffect(() => { + if (searchValue) { + onClick(searchValue); + } + }, [onClick, searchValue]); + + return ( +
+ +
Test
+
+
+ ); +}; + +test("renders correct value, when include button is clicked", async () => { + const fn = jest.fn(); + + render( + + + + ); + + const includeButton = await screen.findByTitle("Include"); + includeButton.click(); + + await waitFor(() => { + expect(fn).toHaveBeenCalledWith("TestParam1:1"); + }); +}); + +test("renders correct value, when exclude button is clicked", async () => { + const fn = jest.fn(); + + render( + + + + ); + + const excludeButton = await screen.findByTitle("Exclude"); + excludeButton.click(); + + await waitFor(() => { + expect(fn).toHaveBeenCalledWith("TestParam1:-1"); + }); +}); diff --git a/src/ui/Dropdowns/TristateReactSelect.tsx b/src/ui/Dropdowns/TristateReactSelect.tsx index 5fad0621f..16ddb222e 100644 --- a/src/ui/Dropdowns/TristateReactSelect.tsx +++ b/src/ui/Dropdowns/TristateReactSelect.tsx @@ -52,13 +52,18 @@ declare module "react-select/dist/declarations/src/Select" { * to key,!key2 that can be used in a query string to filter the results. * */ -export function tristateOutputToQueryParamValue(param: string | undefined) { +export function tristateOutputToQueryParamValue( + param: string | undefined, + encodeValue: boolean = false +) { return param ?.split(",") .map((type) => { const [changeType, symbol] = type.split(":"); const symbolFilter = symbol?.toString() === "-1" ? "!" : ""; - return `${symbolFilter}${changeType}`; + return `${symbolFilter}${ + encodeValue ? encodeURIComponent(changeType) : changeType + }`; }) .join(","); } @@ -278,6 +283,22 @@ export default function TristateReactSelect({ } ); + useEffect(() => { + if (value) { + setToggleState( + value.split(",").reduce((acc, item) => { + const [key, value] = item.split(":"); + return { + ...acc, + [key]: parseInt(value, 10) + }; + }, {}) + ); + } else { + setToggleState({}); + } + }, [value]); + // When the toggle state changes, update the value, which is a string of comma-separated key:value pairs useEffect(() => { const newChangeValue = Object.entries(currentToggleState).reduce(