Skip to content

Commit 73216cd

Browse files
authored
feat: Filtering/search workflows (keephq#3740)
Signed-off-by: Ihor Panasiuk <[email protected]>
1 parent e75cd6d commit 73216cd

File tree

16 files changed

+1049
-131
lines changed

16 files changed

+1049
-131
lines changed

keep-ui/app/(keep)/workflows/page.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,27 @@
11
import React from "react";
22
import WorkflowsPage from "./workflows.client";
3+
import { FacetDto } from "@/features/filter";
4+
import { createServerApiClient } from "@/shared/api/server";
5+
import { getInitialFacets } from "@/features/filter/api";
36

4-
export default function Page() {
5-
return <WorkflowsPage />;
7+
export default async function Page() {
8+
let initialFacets: FacetDto[] | null = null;
9+
10+
try {
11+
const api = await createServerApiClient();
12+
initialFacets = await getInitialFacets(api, "workflows");
13+
} catch (error) {
14+
console.log(error);
15+
}
16+
return (
17+
<WorkflowsPage
18+
initialFacetsData={
19+
initialFacets
20+
? { facets: initialFacets, facetOptions: null }
21+
: undefined
22+
}
23+
/>
24+
);
625
}
726

827
export const metadata = {

keep-ui/app/(keep)/workflows/workflows.client.tsx

Lines changed: 258 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
"use client";
22

3-
import { ChangeEvent, useRef, useState } from "react";
3+
import {
4+
ChangeEvent,
5+
useCallback,
6+
useEffect,
7+
useMemo,
8+
useRef,
9+
useState,
10+
} from "react";
411
import { Subtitle, Button } from "@tremor/react";
512
import {
613
ArrowUpOnSquareStackIcon,
714
PlusCircleIcon,
815
} from "@heroicons/react/24/outline";
9-
import { KeepLoader, PageTitle } from "@/shared/ui";
16+
import { EmptyStateCard, KeepLoader, PageTitle } from "@/shared/ui";
1017
import WorkflowsEmptyState from "./noworkflows";
1118
import WorkflowTile from "./workflow-tile";
1219
import { ArrowRightIcon } from "@radix-ui/react-icons";
@@ -16,10 +23,25 @@ import { WorkflowTemplates } from "./mockworkflows";
1623
import { useApi } from "@/shared/lib/hooks/useApi";
1724
import { Input, ErrorComponent } from "@/shared/ui";
1825
import { Textarea } from "@/components/ui";
19-
import { useWorkflowsV2 } from "utils/hooks/useWorkflowsV2";
26+
import { useWorkflowsV2, WorkflowsQuery } from "utils/hooks/useWorkflowsV2";
2027
import { useWorkflowActions } from "@/entities/workflows/model/useWorkflowActions";
2128
import { PageSubtitle } from "@/shared/ui/PageSubtitle";
2229
import { PlusIcon } from "@heroicons/react/20/solid";
30+
import { UserStatefulAvatar } from "@/entities/users/ui";
31+
import { FacetsConfig } from "@/features/filter/models";
32+
import {
33+
CheckCircleIcon,
34+
XCircleIcon,
35+
ExclamationCircleIcon,
36+
MagnifyingGlassIcon,
37+
FunnelIcon,
38+
ArrowPathIcon,
39+
} from "@heroicons/react/24/outline";
40+
import { useUser } from "@/entities/users/model/useUser";
41+
import { Pagination, SearchInput } from "@/features/filter";
42+
import { FacetsPanelServerSide } from "@/features/filter/facet-panel-server-side";
43+
import { InitialFacetsData } from "@/features/filter/api";
44+
import { v4 as uuidV4 } from "uuid";
2345

2446
const EXAMPLE_WORKFLOW_DEFINITIONS = {
2547
slack: `
@@ -62,11 +84,58 @@ const EXAMPLE_WORKFLOW_DEFINITIONS = {
6284

6385
type ExampleWorkflowKey = keyof typeof EXAMPLE_WORKFLOW_DEFINITIONS;
6486

65-
export default function WorkflowsPage() {
87+
const AssigneeLabel = ({ email }: { email: string }) => {
88+
const user = useUser(email);
89+
return user ? user.name : email;
90+
};
91+
92+
export default function WorkflowsPage({
93+
initialFacetsData,
94+
}: {
95+
initialFacetsData?: InitialFacetsData;
96+
}) {
6697
const router = useRouter();
6798
const fileInputRef = useRef<HTMLInputElement | null>(null);
6899
const [workflowDefinition, setWorkflowDefinition] = useState("");
69100
const [isModalOpen, setIsModalOpen] = useState(false);
101+
const [clearFiltersToken, setClearFiltersToken] = useState<string | null>(
102+
null
103+
);
104+
const [filterCel, setFilterCel] = useState<string | null>(null);
105+
const [searchedValue, setSearchedValue] = useState<string | null>(null);
106+
const [paginationState, setPaginationState] = useState<{
107+
offset: number;
108+
limit: number;
109+
} | null>(null);
110+
const [workflowsQuery, setWorkflowsQuery] = useState<WorkflowsQuery | null>(
111+
null
112+
);
113+
114+
const searchCel = useMemo(() => {
115+
if (!searchedValue) {
116+
return;
117+
}
118+
119+
return `name.contains("${searchedValue}") || description.contains("${searchedValue}")`;
120+
}, [searchedValue]);
121+
122+
useEffect(() => {
123+
if (!paginationState) {
124+
return;
125+
}
126+
127+
const celList = [searchCel, filterCel].filter((cel) => cel);
128+
const cel = celList.join(" && ");
129+
const query: WorkflowsQuery = {
130+
cel,
131+
limit: paginationState?.limit,
132+
offset: paginationState?.offset,
133+
sortBy: "createdAt",
134+
sortDir: "desc",
135+
};
136+
137+
setWorkflowsQuery(query);
138+
}, [searchCel, filterCel, paginationState]);
70139

71140
// Only fetch data when the user is authenticated
72141
/**
@@ -81,15 +150,151 @@ export default function WorkflowsPage() {
81150
-> last_executions: Used for the workflow execution graph.
82151
->last_execution_started: Used for showing the start time of execution in real-time.
83152
**/
84-
const { workflows, error, isLoading } = useWorkflowsV2();
153+
154+
const {
155+
workflows: filteredWorkflows,
156+
totalCount: filteredWorkflowsCount,
157+
error,
158+
isLoading: isFilteredWorkflowsLoading,
159+
} = useWorkflowsV2(workflowsQuery, { keepPreviousData: true });
160+
161+
const isFirstLoading = isFilteredWorkflowsLoading && !filteredWorkflows;
162+
85163
const { uploadWorkflowFiles } = useWorkflowActions();
86164

87-
if (error) {
88-
return <ErrorComponent error={error} reset={() => {}} />;
165+
const isTableEmpty = filteredWorkflowsCount === 0;
166+
const isEmptyState =
167+
!isFilteredWorkflowsLoading && isTableEmpty && !workflowsQuery?.cel;
168+
169+
const showFilterEmptyState = isTableEmpty && !!filterCel;
170+
const showSearchEmptyState =
171+
isTableEmpty && !!searchCel && !showFilterEmptyState;
172+
173+
const setPaginationStateCallback = useCallback(
174+
(pageIndex: number, limit: number, offset: number) => {
175+
setPaginationState({ limit, offset });
176+
},
177+
[setPaginationState]
178+
);
179+
180+
const facetsConfig: FacetsConfig = useMemo(() => {
181+
return {
182+
["Status"]: {
183+
renderOptionIcon: (facetOption) => {
184+
switch (facetOption.value) {
185+
case "success": {
186+
return <CheckCircleIcon className="w-5 h-5 text-green-500" />;
187+
}
188+
case "failed": {
189+
return <XCircleIcon className="w-5 h-5 text-red-500" />;
190+
}
191+
case "in_progress": {
192+
return <ArrowPathIcon className="w-5 h-5 text-orange-500" />;
193+
}
194+
default: {
195+
return (
196+
<ExclamationCircleIcon className="w-5 h-5 text-gray-500" />
197+
);
198+
}
199+
}
200+
},
201+
renderOptionLabel: (facetOption) => {
202+
switch (facetOption.value) {
203+
case "success": {
204+
return "Success";
205+
}
206+
case "failed": {
207+
return "Failed";
208+
}
209+
case "in_progress": {
210+
return "In progress";
211+
}
212+
default: {
213+
return "Not run yet";
214+
}
215+
}
216+
},
217+
},
218+
["Created by"]: {
219+
renderOptionIcon: (facetOption) => (
220+
<UserStatefulAvatar email={facetOption.display_name} size="xs" />
221+
),
222+
renderOptionLabel: (facetOption) => {
223+
if (facetOption.display_name === "null") {
224+
return "Not assigned";
225+
}
226+
return <AssigneeLabel email={facetOption.display_name} />;
227+
},
228+
},
229+
["Enabling status"]: {
230+
renderOptionLabel: (facetOption) =>
231+
facetOption.display_name.toLocaleLowerCase() === "true"
232+
? "Disabled"
233+
: "Enabled",
234+
},
235+
};
236+
}, []);
237+
238+
function renderFilterEmptyState() {
239+
return (
240+
<>
241+
<div className="flex items-center h-full w-full">
242+
<div className="flex flex-col justify-center items-center w-full p-4">
243+
<EmptyStateCard
244+
title="No workflows to display matching your filter"
245+
icon={FunnelIcon}
246+
>
247+
<Button
248+
color="orange"
249+
variant="secondary"
250+
onClick={() => setClearFiltersToken(uuidV4())}
251+
>
252+
Reset filter
253+
</Button>
254+
</EmptyStateCard>
255+
</div>
256+
</div>
257+
</>
258+
);
259+
}
260+
261+
function renderSearchEmptyState() {
262+
return (
263+
<>
264+
<div className="flex items-center h-full w-full">
265+
<div className="flex flex-col justify-center items-center w-full p-4">
266+
<EmptyStateCard
267+
title="No workflows to display matching your search"
268+
icon={MagnifyingGlassIcon}
269+
>
270+
<Button
271+
color="orange"
272+
variant="secondary"
273+
onClick={() => setSearchedValue(null)}
274+
>
275+
Clear search
276+
</Button>
277+
</EmptyStateCard>
278+
</div>
279+
</div>
280+
</>
281+
);
89282
}
90283

91-
if (isLoading || !workflows) {
92-
return <KeepLoader />;
284+
function renderData() {
285+
return (
286+
<>
287+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 w-full gap-4">
288+
{filteredWorkflows?.map((workflow) => (
289+
<WorkflowTile key={workflow.id} workflow={workflow} />
290+
))}
291+
</div>
292+
</>
293+
);
294+
}
295+
296+
if (error) {
297+
return <ErrorComponent error={error} reset={() => {}} />;
93298
}
94299

95300
const onDrop = async (files: ChangeEvent<HTMLInputElement>) => {
@@ -147,13 +352,19 @@ export default function WorkflowsPage() {
147352
<>
148353
<main className="flex flex-col gap-8">
149354
<div className="flex flex-col gap-6">
150-
<div className="flex justify-between items-center">
355+
<div className="flex justify-between items-end">
151356
<div>
152-
<PageTitle>Workflows</PageTitle>
357+
<PageTitle>My Workflows</PageTitle>
153358
<PageSubtitle>
154359
Automate your alert management with workflows
155360
</PageSubtitle>
156361
</div>
362+
<SearchInput
363+
className="flex-1 mx-4"
364+
placeholder="Search workflows"
365+
value={searchedValue as string}
366+
onValueChange={setSearchedValue}
367+
/>
157368
<div className="flex gap-2">
158369
<Button
159370
color="orange"
@@ -178,13 +389,44 @@ export default function WorkflowsPage() {
178389
</Button>
179390
</div>
180391
</div>
181-
{workflows.length === 0 ? (
392+
{isEmptyState ? (
182393
<WorkflowsEmptyState />
183394
) : (
184-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 w-full gap-4">
185-
{workflows.map((workflow) => (
186-
<WorkflowTile key={workflow.id} workflow={workflow} />
187-
))}
395+
<div className="flex gap-4">
396+
<FacetsPanelServerSide
397+
entityName={"workflows"}
398+
facetsConfig={facetsConfig}
399+
facetOptionsCel={searchCel}
400+
usePropertyPathsSuggestions={true}
401+
clearFiltersToken={clearFiltersToken}
402+
initialFacetsData={initialFacetsData}
403+
onCelChange={(cel) => setFilterCel(cel)}
404+
/>
405+
406+
<div className="flex flex-col flex-1 relative">
407+
{isFirstLoading && (
408+
<div className="flex items-center justify-center h-96 w-full">
409+
<KeepLoader includeMinHeight={false} />
410+
</div>
411+
)}
412+
{!isFirstLoading && (
413+
<>
414+
{showFilterEmptyState && renderFilterEmptyState()}
415+
{showSearchEmptyState && renderSearchEmptyState()}
416+
{!isTableEmpty && renderData()}
417+
</>
418+
)}
419+
<div className={`mt-4 ${isFirstLoading ? "hidden" : ""}`}>
420+
<Pagination
421+
totalCount={filteredWorkflowsCount}
422+
isRefreshAllowed={false}
423+
isRefreshing={false}
424+
pageSizeOptions={[12, 24, 48]}
425+
onRefresh={() => {}}
426+
onStateChange={setPaginationStateCallback}
427+
/>
428+
</div>
429+
</div>
188430
</div>
189431
)}
190432
</div>

keep-ui/features/filter/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
export { FacetsPanel, type FacetsPanelProps } from "./facets-panel";
2+
export { Pagination } from "./pagination";
23
export {
34
useFacetActions,
45
useFacets,
56
useFacetOptions,
67
type UseFacetActionsValue,
78
} from "./hooks";
9+
export { SearchInput } from "./search-input";
810
export type { FacetDto, FacetOptionDto, CreateFacetDto } from "./models";

0 commit comments

Comments
 (0)