Skip to content

FEAT: Optimize List View Components with Table Design and Sorting Across Project Pages #819

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -73,7 +73,9 @@ function AddCustomToolFormModal({
});
setOpen(false);
clearFormDetails();
navigate(success?.tool_id);
if (!isEdit) {
navigate(success?.tool_id);
}
})
.catch((err) => {
handleException(err, "", setBackendErrors);
388 changes: 84 additions & 304 deletions frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import PropTypes from "prop-types";
import { useCallback } from "react";
import { AddCustomToolFormModal } from "../add-custom-tool-form-modal/AddCustomToolFormModal";

function ListOfToolsModal({ open, setOpen, editItem, isEdit, handleAddItem }) {
const handleAddNewTool = useCallback(
(itemData) => handleAddItem(itemData, editItem?.tool_id, isEdit),
[handleAddItem, isEdit, editItem?.tool_id]
);

return (
<AddCustomToolFormModal
open={open}
setOpen={setOpen}
editItem={editItem}
isEdit={isEdit}
handleAddNewTool={handleAddNewTool}
/>
);
}

ListOfToolsModal.propTypes = {
open: PropTypes.bool.isRequired,
setOpen: PropTypes.func.isRequired,
editItem: PropTypes.shape({
tool_id: PropTypes.number,
tool_name: PropTypes.string,
description: PropTypes.string,
icon: PropTypes.string,
}),
isEdit: PropTypes.bool.isRequired,
handleAddItem: PropTypes.func.isRequired,
};

export default ListOfToolsModal;
38 changes: 21 additions & 17 deletions frontend/src/components/custom-tools/view-tools/ViewTools.jsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,48 @@
import PropTypes from "prop-types";
import { Typography } from "antd";
import { useCallback } from "react";

import { ListView } from "../../widgets/list-view/ListView";
import { SpinnerLoader } from "../../widgets/spinner-loader/SpinnerLoader.jsx";
import { SpinnerLoader } from "../../widgets/spinner-loader/SpinnerLoader";
import "./ViewTools.css";
import { EmptyState } from "../../widgets/empty-state/EmptyState.jsx";
import { EmptyState } from "../../widgets/empty-state/EmptyState";

function ViewTools({
isLoading,
isEmpty,
listOfTools,
setOpenAddTool,
listOfTools = [],
setOpenAddTool = () => {},
handleEdit,
handleDelete,
titleProp,
descriptionProp,
iconProp,
descriptionProp = "",
iconProp = "",
idProp,
centered,
centered = false,
isClickable = true,
handleShare,
showOwner,
type,
handleShare = null,
showOwner = false,
type = "",
}) {
Comment on lines 10 to 26
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: Move props with default values to bottom for better code readability.

const handleEmptyStateClick = useCallback(() => {
setOpenAddTool(true);
}, [setOpenAddTool]);

if (isLoading) {
return <SpinnerLoader />;
}

if (isEmpty) {
let text = "No tools available";
let btnText = "New Tool";
if (type) {
text = `No ${type.toLowerCase()} available`;
btnText = type;
}
const text = type
? `No ${type.toLowerCase()} available`
: "No tools available";
const btnText = type || "New Tool";

return (
<EmptyState
text={text}
btnText={btnText}
handleClick={() => setOpenAddTool(true)}
handleClick={handleEmptyStateClick}
/>
);
}
Original file line number Diff line number Diff line change
@@ -29,3 +29,10 @@
.tool-bar-segment {
background-color: #0000000f;
}

.tool-nav-title {
font-weight: 600;
font-size: 18px;
display: inline;
line-height: 24px;
}
55 changes: 37 additions & 18 deletions frontend/src/components/navigations/tool-nav-bar/ToolNavBar.jsx
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ import { ArrowLeftOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { debounce } from "lodash";
import PropTypes from "prop-types";
import { useCallback, useMemo, useEffect } from "react";

function ToolNavBar({
title,
@@ -17,9 +18,38 @@ function ToolNavBar({
onSearch,
}) {
const navigate = useNavigate();
const onSearchDebounce = debounce(({ target: { value } }) => {
onSearch(value, setSearchList);
}, 600);

const handleBackClick = useCallback(() => {
if (previousRoute) {
navigate(previousRoute);
}
}, [previousRoute]);

const onSearchDebounce = useMemo(() => {
const debouncedFunction = debounce(({ target: { value } }) => {
if (onSearch) {
onSearch(value, setSearchList);
}
}, 600);
return debouncedFunction;
}, [onSearch, setSearchList]);

// Cleanup debounced function on unmount
useEffect(() => {
return () => {
onSearchDebounce.cancel();
};
}, [onSearchDebounce]);

// Handle segment change
const handleSegmentChange = useCallback(
(value) => {
if (segmentFilter) {
segmentFilter(value);
}
},
[segmentFilter]
);

return (
<Row align="middle" justify="space-between" className="searchNav">
@@ -29,25 +59,14 @@ function ToolNavBar({
type="text"
shape="circle"
icon={<ArrowLeftOutlined />}
onClick={() => navigate(previousRoute)}
onClick={handleBackClick}
/>
)}
{title && (
<Typography
style={{
fontWeight: 600,
fontSize: "18px",
display: "inline",
lineHeight: "24px",
}}
>
{title}
</Typography>
)}
{title && <Typography className="tool-nav-title">{title}</Typography>}
{segmentFilter && segmentOptions && (
<Segmented
options={segmentOptions}
onChange={segmentFilter}
onChange={handleSegmentChange}
className="tool-bar-segment"
/>
)}
@@ -70,7 +89,7 @@ function ToolNavBar({
ToolNavBar.propTypes = {
title: PropTypes.string,
enableSearch: PropTypes.bool,
CustomButtons: PropTypes.func,
CustomButtons: PropTypes.oneOfType([PropTypes.func, PropTypes.elementType]),
setSearchList: PropTypes.func,
previousRoute: PropTypes.string,
segmentOptions: PropTypes.array,
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
/* Styles for ListOfItems */

.list-of-items-empty {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}

.cover-img .fit-cover {
object-fit: cover;
width: 100%;
height: auto;
object-fit: cover;
width: 100%;
height: auto;
}
362 changes: 168 additions & 194 deletions frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import PropTypes from "prop-types";
import { useCallback } from "react";
import { AddSourceModal } from "../../input-output/add-source-modal/AddSourceModal";

function ToolSettingsModal({
open,
setOpen,
editItem,
isEdit,
type,
updateList,
}) {
const handleAddNewItem = useCallback(
(itemData) => {
updateList(itemData, editItem?.id, isEdit);
setOpen(false);
},
[isEdit, editItem?.id]
);

return (
<AddSourceModal
open={open}
setOpen={setOpen}
type={type}
addNewItem={handleAddNewItem}
editItemId={editItem?.id}
setEditItemId={() => {}}
/>
);
}

ToolSettingsModal.propTypes = {
open: PropTypes.bool.isRequired,
setOpen: PropTypes.func.isRequired,
editItem: PropTypes.object,
isEdit: PropTypes.bool.isRequired,
type: PropTypes.string.isRequired,
updateList: PropTypes.func.isRequired,
};

export default ToolSettingsModal;
159 changes: 159 additions & 0 deletions frontend/src/components/view-projects/ListView.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { useEffect, useState, useCallback } from "react";
import PropTypes from "prop-types";
import { CustomButton } from "../widgets/custom-button/CustomButton";
import { ToolNavBar } from "../navigations/tool-nav-bar/ToolNavBar";
import { ViewTools } from "../custom-tools/view-tools/ViewTools";
import "./ViewProjects.css";

function ListView({
title,
useListManagerHook,
CustomModalComponent,
customButtonText,
customButtonIcon,
onCustomButtonClick,
handleEditItem,
handleDeleteItem,
itemProps,
setPostHogCustomEvent,
newButtonEventName,
type,
}) {
const {
list,
filteredList,
loading,
fetchList,
handleSearch,
handleAddItem,
handleDeleteItem: deleteItem,
updateList,
} = useListManagerHook;

const [openModal, setOpenModal] = useState(false);
const [editingItem, setEditingItem] = useState(null);
const [isEdit, setIsEdit] = useState(false);

useEffect(() => {
fetchList();
}, [title]);

const handleCustomButtonClick = useCallback(() => {
setEditingItem(null);
setIsEdit(false);
setOpenModal(true);
if (onCustomButtonClick) onCustomButtonClick();
try {
setPostHogCustomEvent?.(newButtonEventName, {
info: `Clicked on '${customButtonText}' button`,
});
} catch (err) {
// Ignore error
}
}, [onCustomButtonClick, newButtonEventName, customButtonText]);

const CustomButtons = useCallback(
() => (
<CustomButton
type="primary"
icon={customButtonIcon}
onClick={handleCustomButtonClick}
>
{customButtonText}
</CustomButton>
),
[customButtonIcon, handleCustomButtonClick, customButtonText]
);

const handleEdit = useCallback(
(_event, item) => {
setEditingItem(item);
setIsEdit(true);
setOpenModal(true);
if (handleEditItem) handleEditItem(item);
},
[handleEditItem]
);

const handleDelete = useCallback(
(_event, item) => {
deleteItem(item?.[itemProps?.idProp]);
if (handleDeleteItem) handleDeleteItem(item);
},
[deleteItem, handleDeleteItem, itemProps?.idProp]
);

return (
<>
<ToolNavBar
title={title}
enableSearch
onSearch={handleSearch}
searchList={list}
setSearchList={filteredList}
CustomButtons={CustomButtons}
/>
<div className="list-of-tools-layout">
<div className="list-of-tools-island">
<div className="list-of-tools-body">
<ViewTools
isLoading={loading}
isEmpty={!filteredList?.length}
listOfTools={filteredList}
setOpenAddTool={setOpenModal}
handleEdit={handleEdit}
handleDelete={handleDelete}
{...itemProps}
/>
</div>
</div>
</div>
{openModal && CustomModalComponent && (
<CustomModalComponent
open={openModal}
setOpen={setOpenModal}
editItem={editingItem}
isEdit={isEdit}
handleAddItem={handleAddItem}
updateList={updateList}
type={type}
/>
)}
</>
);
}

ListView.propTypes = {
title: PropTypes.string.isRequired,
useListManagerHook: PropTypes.shape({
list: PropTypes.array.isRequired,
filteredList: PropTypes.array.isRequired,
loading: PropTypes.bool.isRequired,
fetchList: PropTypes.func.isRequired,
handleSearch: PropTypes.func.isRequired,
handleAddItem: PropTypes.func.isRequired,
handleDeleteItem: PropTypes.func.isRequired,
updateList: PropTypes.func.isRequired,
}).isRequired,
CustomModalComponent: PropTypes.oneOfType([
PropTypes.func,
PropTypes.elementType,
]),
customButtonText: PropTypes.string.isRequired,
customButtonIcon: PropTypes.element,
onCustomButtonClick: PropTypes.func,
handleEditItem: PropTypes.func,
handleDeleteItem: PropTypes.func,
itemProps: PropTypes.shape({
titleProp: PropTypes.string.isRequired,
descriptionProp: PropTypes.string,
iconProp: PropTypes.string,
idProp: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
}).isRequired,
setPostHogCustomEvent: PropTypes.func,
newButtonEventName: PropTypes.string,
type: PropTypes.string,
};

export { ListView };
52 changes: 52 additions & 0 deletions frontend/src/components/view-projects/ViewProjects.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/* Styles for ListOfTools */

.list-of-tools-layout {
height: 100%;
background-color: var(--page-bg-2);
padding: 12px;
display: flex;
flex-direction: column;
overflow-y: hidden;
}

.list-of-tools-island {
background-color: var(--page-bg-1);
flex: 1;
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
}

.list-of-tools-wrap {
height: 100%;
display: flex;
flex-direction: column;
}

.list-of-tools-header {
display: grid;
grid-template-columns: auto 1fr;
}

.list-of-tools-title {
font-size: 18px;
font-weight: 600;
}

.list-of-tools-header2 {
display: grid;
grid-auto-flow: column;
column-gap: 10px;
justify-self: end;
}

.list-of-tools-divider {
margin-top: 12px;
}

.list-of-tools-body {
flex: 1;
display: flex;
justify-content: center;
}
10 changes: 2 additions & 8 deletions frontend/src/components/widgets/list-view/ListView.css
Original file line number Diff line number Diff line change
@@ -52,17 +52,11 @@
}

.adapter-cover-img .fit-cover {
width: 120px;
height: 90px;
width: 50px;
height: 50px;
object-fit: contain;
}

.adapters-list-profile-container {
align-items: center;
display: flex;
justify-content: center;
}

.adapters-list-user-prefix {
margin: 0 5px;
font-weight: 500;
346 changes: 213 additions & 133 deletions frontend/src/components/widgets/list-view/ListView.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {
Avatar,
Flex,
Image,
List,
Popconfirm,
Space,
Table,
Tooltip,
Typography,
} from "antd";
@@ -17,164 +17,245 @@ import {
UserOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import moment from "moment";
import { useMemo, useCallback } from "react";

import { useSessionStore } from "../../../store/session-store";

function ListView({
listOfTools,
handleEdit,
handleDelete,
handleShare,
handleShare = null,
titleProp,
descriptionProp,
iconProp,
descriptionProp = "",
iconProp = "",
idProp,
centered,
centered = false,
isClickable = true,
showOwner = true,
type,
type = "",
}) {
const navigate = useNavigate();
const { sessionDetails } = useSessionStore();
const handleDeleteClick = (event, tool) => {
event.stopPropagation(); // Stop propagation to prevent list item click
handleDelete(event, tool);
};

const handleShareClick = (event, tool, isEdit) => {
event.stopPropagation(); // Stop propagation to prevent list item click
handleShare(event, tool, isEdit);
};
const handleRowClick = useCallback(
(record) => {
if (isClickable) {
navigate(`${record[idProp]}`);
}
},
[navigate, idProp, isClickable]
);

const renderTitle = (item) => {
let title = null;
if (iconProp && item[iconProp].length > 4) {
title = (
<div className="adapter-cover-img">
<Image src={item[iconProp]} preview={false} className="fit-cover" />
const renderNameColumn = useCallback(
(text, record) => {
let titleContent = null;
if (iconProp && record[iconProp]?.length > 4) {
titleContent = (
<Space className="adapter-cover-img" size={10}>
<Image
src={record[iconProp]}
preview={false}
className="fit-cover"
/>
<Typography.Text className="adapters-list-title">
{record[titleProp]}
</Typography.Text>
</Space>
);
} else if (iconProp) {
titleContent = (
<Typography.Text className="adapters-list-title">
{item[titleProp]}
{`${record[iconProp]} ${record[titleProp]}`}
</Typography.Text>
</div>
);
} else if (iconProp) {
title = (
<Typography.Text className="adapters-list-title">
{`${item[iconProp]} ${item[titleProp]}`}
</Typography.Text>
);
} else {
titleContent = (
<Typography.Text className="adapters-list-title">
{record[titleProp]}
</Typography.Text>
);
}
return (
<>
<Typography.Text strong className="display-flex-left">
{titleContent}
</Typography.Text>
<Typography.Text type="secondary" ellipsis>
<Tooltip title={record?.[descriptionProp]}>
{record?.[descriptionProp]}
</Tooltip>
</Typography.Text>
</>
);
} else {
title = (
<Typography.Text className="adapters-list-title">
{item[titleProp]}
</Typography.Text>
},
[iconProp, titleProp, descriptionProp]
);

const renderOwnedByColumn = useCallback(
(text) => {
const owner = text === sessionDetails?.email ? "Me" : text || "-";
return (
<div className="adapters-list-profile-container">
<Avatar
size={20}
className="adapters-list-user-avatar"
icon={<UserOutlined />}
/>
<Typography.Text disabled className="adapters-list-user-prefix">
Owned By:
</Typography.Text>
<Typography.Text className="shared-username">{owner}</Typography.Text>
</div>
);
}
},
[sessionDetails?.email]
);

const renderDateColumn = useCallback(
(text) => (text ? moment(text).format("YYYY-MM-DD HH:mm:ss") : "-"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move the data formatter to static func? Are they reuseable?

[]
);

const handleEditClick = useCallback(
(event, record) => {
event.stopPropagation();
handleEdit(event, record);
},
[handleEdit]
);

const handleShareClick = useCallback(
(event, record) => {
event.stopPropagation();
handleShare?.(record, true);
},
[handleShare]
);

return (
<Flex
gap={20}
align="center"
justify="space-between"
className="list-view-container"
const handleDeleteClick = useCallback(
(event, record) => {
event.stopPropagation();
handleDelete(event, record);
},
[handleDelete]
);

const renderActionsColumn = useCallback(
(text, record) => (
<div
className="action-button-container"
onClick={(event) => event.stopPropagation()}
role="none"
>
<div className="list-view-content">
<div className="adapters-list-title-container display-flex-left">
{title}
</div>
{showOwner && (
<div className="adapters-list-profile-container">
<Avatar
size={20}
className="adapters-list-user-avatar"
icon={<UserOutlined />}
/>
<Typography.Text disabled className="adapters-list-user-prefix">
Owned By:
</Typography.Text>
<Typography.Text className="shared-username">
{item?.created_by_email
? item?.created_by_email === sessionDetails.email
? "Me"
: item?.created_by_email
: "-"}
</Typography.Text>
</div>
)}
</div>
<div
className="action-button-container"
onClick={(event) => event.stopPropagation()}
role="none"
>
<EditOutlined
key={`${item.id}-edit`}
onClick={(event) => handleEdit(event, item)}
className="action-icon-buttons edit-icon"
<EditOutlined
key={`${record[idProp]}-edit`}
onClick={(event) => handleEditClick(event, record)}
className="action-icon-buttons edit-icon"
/>
{handleShare && (
<ShareAltOutlined
key={`${record[idProp]}-share`}
className="action-icon-buttons"
onClick={(event) => handleShareClick(event, record)}
/>
{handleShare && (
<ShareAltOutlined
key={`${item.id}-share`}
className="action-icon-buttons"
onClick={(event) => handleShareClick(event, item, true)}
/>
)}
<Popconfirm
key={`${item.id}-delete`}
title={`Delete the ${type}`}
description={`Are you sure to delete ${item[titleProp]}`}
okText="Yes"
cancelText="No"
icon={<QuestionCircleOutlined />}
onConfirm={(event) => {
handleDeleteClick(event, item);
}}
>
<Typography.Text>
<DeleteOutlined className="action-icon-buttons delete-icon" />
</Typography.Text>
</Popconfirm>
</div>
</Flex>
);
};
)}
<Popconfirm
key={`${record[idProp]}-delete`}
title={`Delete the ${type}`}
description={`Are you sure to delete ${record[titleProp]}?`}
okText="Yes"
cancelText="No"
icon={<QuestionCircleOutlined />}
onConfirm={(event) => {
handleDeleteClick(event, record);
}}
>
<Typography.Text>
<DeleteOutlined className="action-icon-buttons delete-icon" />
</Typography.Text>
</Popconfirm>
</div>
),
[
idProp,
titleProp,
type,
handleEditClick,
handleShare,
handleShareClick,
handleDeleteClick,
]
);

const columns = useMemo(
() => [
{
title: "Name",
dataIndex: titleProp,
key: "name",
sorter: (a, b) =>
a[titleProp]
?.toLowerCase()
.localeCompare(b[titleProp]?.toLowerCase()),
render: renderNameColumn,
},
{
title: "Owned By",
dataIndex: "created_by_email",
key: "ownedBy",
sorter: (a, b) =>
(a.created_by_email || "")
.toLowerCase()
.localeCompare((b.created_by_email || "").toLowerCase()),
render: renderOwnedByColumn,
},
{
title: "Created At",
dataIndex: "created_at",
key: "createdAt",
sorter: (a, b) =>
new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
render: renderDateColumn,
},
{
title: "Modified At",
dataIndex: "modified_at",
key: "modifiedAt",
sorter: (a, b) =>
new Date(a.modified_at).getTime() - new Date(b.modified_at).getTime(),
render: renderDateColumn,
},
{
title: "Actions",
key: "actions",
align: "center",
render: renderActionsColumn,
},
],
[
titleProp,
renderNameColumn,
renderOwnedByColumn,
renderDateColumn,
renderActionsColumn,
]
);

return (
<List
size="large"
<Table
rowKey={idProp}
dataSource={listOfTools}
style={{ marginInline: "4px" }}
columns={columns}
pagination={{
position: "bottom",
align: "end",
position: ["bottomRight"],
size: "small",
}}
className="list-view-wrapper"
renderItem={(item) => {
return (
<List.Item
key={item?.id}
onClick={(event) => {
isClickable
? navigate(`${item[idProp]}`)
: handleShareClick(event, item, false);
}}
className={`cur-pointer ${centered ? "centered" : ""}`}
>
<List.Item.Meta
className="list-item-desc"
title={renderTitle(item)}
description={
<Typography.Text type="secondary" ellipsis>
<Tooltip title={item[descriptionProp]}>
{item[descriptionProp]}
</Tooltip>
</Typography.Text>
}
></List.Item.Meta>
</List.Item>
);
}}
onRow={(record) => ({
onClick: () => handleRowClick(record),
className: `cur-pointer ${centered ? "centered" : ""}`,
})}
className="width-100"
/>
);
}
@@ -190,7 +271,6 @@ ListView.propTypes = {
idProp: PropTypes.string.isRequired,
centered: PropTypes.bool,
isClickable: PropTypes.bool,
showOwner: PropTypes.bool,
type: PropTypes.string,
};

Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@ function NewWorkflow({
description = "",
onDone = () => {},
onClose = () => {},
loading = {},
loading,
toggleModal = () => {},
openModal = {},
backendErrors,
79 changes: 79 additions & 0 deletions frontend/src/components/workflows/workflow/WorkflowModal.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import PropTypes from "prop-types";
import { useState } from "react";
import { LazyLoader } from "../../widgets/lazy-loader/LazyLoader.jsx";
import { useAlertStore } from "../../../store/alert-store";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx";
import { useWorkflowStore } from "../../../store/workflow-store";
import { useNavigate } from "react-router-dom";
import { useSessionStore } from "../../../store/session-store";

function WorkflowModal({
open,
setOpen,
editItem,
isEdit,
handleAddItem,
loading,
backendErrors,
}) {
const { setAlertDetails } = useAlertStore();
const handleException = useExceptionHandler();
const [localBackendErrors, setLocalBackendErrors] = useState(backendErrors);
const navigate = useNavigate();
const { updateWorkflow } = useWorkflowStore();
const sessionDetails = useSessionStore((state) => state?.sessionDetails);
const orgName = sessionDetails?.orgName ?? "";

const handleOnDone = (name, description) => {
handleAddItem({ name, description }, editItem?.id, isEdit)
.then((project) => {
if (!isEdit) {
updateWorkflow({ projectName: project?.workflow_name ?? "" });
navigate(`/${orgName}/workflows/${project?.id ?? ""}`);
}
setAlertDetails({
type: "success",
content: "Workflow updated successfully",
});
setOpen(false);
})
.catch((err) => {
handleException(err, "", setLocalBackendErrors);
});
};

const handleOnClose = () => {
setOpen(false);
};

return (
<LazyLoader
component={() => import("../new-workflow/NewWorkflow.jsx")}
componentName="NewWorkflow"
name={editItem?.workflow_name ?? ""}
description={editItem?.description ?? ""}
onDone={handleOnDone}
onClose={handleOnClose}
loading={loading}
openModal={open}
backendErrors={localBackendErrors}
setBackendErrors={setLocalBackendErrors}
/>
);
}

WorkflowModal.propTypes = {
open: PropTypes.bool.isRequired,
setOpen: PropTypes.func.isRequired,
editItem: PropTypes.shape({
id: PropTypes.number,
workflow_name: PropTypes.string,
description: PropTypes.string,
}),
isEdit: PropTypes.bool.isRequired,
handleAddItem: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
backendErrors: PropTypes.any,
};

export default WorkflowModal;
306 changes: 48 additions & 258 deletions frontend/src/components/workflows/workflow/Workflows.jsx
Original file line number Diff line number Diff line change
@@ -1,273 +1,63 @@
import { PlusOutlined, UserOutlined } from "@ant-design/icons";
import { Typography } from "antd";
import isEmpty from "lodash/isEmpty";
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";

import { useAlertStore } from "../../../store/alert-store";
import { useSessionStore } from "../../../store/session-store";
import { useWorkflowStore } from "../../../store/workflow-store";
import { CustomButton } from "../../widgets/custom-button/CustomButton.jsx";
import { EmptyState } from "../../widgets/empty-state/EmptyState.jsx";
import { LazyLoader } from "../../widgets/lazy-loader/LazyLoader.jsx";
import { SpinnerLoader } from "../../widgets/spinner-loader/SpinnerLoader.jsx";
import "./Workflows.css";
import { PlusOutlined } from "@ant-design/icons";
import usePostHogEvents from "../../../hooks/usePostHogEvents";
import { useListManager } from "../../../hooks/useListManager";
import WorkflowModal from "./WorkflowModal";
import { ListView } from "../../view-projects/ListView";
import { workflowService } from "./workflow-service";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx";
import { ToolNavBar } from "../../navigations/tool-nav-bar/ToolNavBar.jsx";
import { ViewTools } from "../../custom-tools/view-tools/ViewTools.jsx";
import usePostHogEvents from "../../../hooks/usePostHogEvents.js";

const PROJECT_FILTER_OPTIONS = [
{ label: "My Workflows", value: "mine" },
{ label: "Organization Workflows", value: "all" },
];

const { Title, Text } = Typography;

function Workflows() {
const navigate = useNavigate();
const location = useLocation();
const projectApiService = workflowService();
const handleException = useExceptionHandler();
const { setPostHogCustomEvent } = usePostHogEvents();
const projectApiService = workflowService();

const [projectList, setProjectList] = useState();
const [editingProject, setEditProject] = useState();
const [loading, setLoading] = useState(false);
const [openModal, toggleModal] = useState(true);
const projectListRef = useRef();
const filterViewRef = useRef(PROJECT_FILTER_OPTIONS[0].value);
const [backendErrors, setBackendErrors] = useState(null);

const { setAlertDetails } = useAlertStore();
const sessionDetails = useSessionStore((state) => state?.sessionDetails);
const { updateWorkflow } = useWorkflowStore();
const orgName = sessionDetails?.orgName;

useEffect(() => {
if (location.pathname === `/${orgName}/workflows`) {
getProjectList();
}
}, [location.pathname]);

function getProjectList() {
projectApiService
.getProjectList(filterViewRef.current === PROJECT_FILTER_OPTIONS[0].value)
.then((res) => {
projectListRef.current = res.data;
setProjectList(res.data);
})
.catch(() => {
console.error("Unable to get project list");
});
}
const getListApiCall = ({ initialFilter }) =>
projectApiService.getProjectList(initialFilter);

function onSearch(searchText, setSearchList) {
if (!searchText.trim()) {
setSearchList(projectListRef.current);
return;
}
const filteredList = projectListRef.current.filter((item) =>
item.workflow_name.toLowerCase().includes(searchText.toLowerCase())
const addItemApiCall = ({ itemData }) =>
projectApiService.editProject(
itemData?.name ?? "",
itemData?.description ?? ""
);
setSearchList(filteredList);
}

function applyFilter(value) {
filterViewRef.current = value;
projectListRef.current = "";
setProjectList("");
getProjectList();
}

function editProject(name, description) {
setLoading(true);
projectApiService
.editProject(name, description, editingProject?.id)
.then((res) => {
closeNewProject();
if (editingProject?.name) {
getProjectList();
} else {
openProject(res.data);
}
setAlertDetails({
type: "success",
content: "Workflow updated successfully",
});
getProjectList();
})
.catch((err) => {
handleException(err, "", setBackendErrors);
})
.finally(() => {
setLoading(false);
});
}

function openProject(project) {
updateWorkflow({ projectName: project?.workflow_name });
navigate(`/${orgName}/workflows/${project.id}`);
}

function showNewProject() {
setEditProject({ name: "", description: "" });
}

function updateProject(_event, project) {
toggleModal(true);
setEditProject({
name: project.workflow_name,
description: project.description || "",
id: project.id,
});
}

const canDeleteProject = async (id) => {
let status = false;
await projectApiService.canUpdate(id).then((res) => {
status = res?.data?.can_update || false;
});
return status;
};

const deleteProject = async (_evt, project) => {
const canDelete = await canDeleteProject(project.id);
if (canDelete) {
projectApiService
.deleteProject(project.id)
.then(() => {
getProjectList();
setAlertDetails({
type: "success",
content: "Workflow deleted successfully",
});
})
.catch(() => {
setAlertDetails({
type: "error",
content: `Unable to delete workflow ${project.id}`,
});
});
} else {
setAlertDetails({
type: "error",
content:
"Cannot delete this Workflow, since it is used in one or many of the API/ETL/Task pipelines",
});
}
};

function closeNewProject() {
setEditProject();
}

const handleNewWorkflowBtnClick = () => {
showNewProject();
toggleModal(true);

try {
setPostHogCustomEvent("intent_new_wf_project", {
info: "Clicked on '+ New Workflow' button",
});
} catch (err) {
// If an error occurs while setting custom posthog event, ignore it and continue
}
};

const CustomButtons = () => {
return (
<CustomButton
type="primary"
icon={<PlusOutlined />}
onClick={handleNewWorkflowBtnClick}
>
New Workflow
</CustomButton>
const editItemApiCall = ({ itemData, itemId }) =>
projectApiService.editProject(
itemData?.name ?? "",
itemData?.description ?? "",
itemId
);
};

const deleteItemApiCall = ({ itemId }) =>
projectApiService.deleteProject(itemId);

const useListManagerHook = useListManager({
getListApiCall,
addItemApiCall,
editItemApiCall,
deleteItemApiCall,
searchProperty: "workflow_name",
itemIdProp: "id",
itemNameProp: "workflow_name",
itemDescriptionProp: "description",
itemType: "Workflow",
initialFilter: "mine",
});

return (
<>
<ToolNavBar
enableSearch
searchList={projectList}
setSearchList={setProjectList}
CustomButtons={CustomButtons}
segmentFilter={applyFilter}
segmentOptions={PROJECT_FILTER_OPTIONS}
onSearch={onSearch}
/>
<div className="workflows-pg-layout">
<div className="workflows-pg-body">
{!projectListRef.current && <SpinnerLoader />}
{projectListRef.current && isEmpty(projectListRef?.current) && (
<div className="list-of-workflows-body">
<EmptyState
text="No Workflow available"
btnText="New Workflow"
handleClick={() => {
showNewProject();
toggleModal(true);
}}
/>
</div>
)}
{isEmpty(projectList) && !isEmpty(projectListRef?.current) && (
<div className="center">
<Title level={5}>No results found for this search</Title>
</div>
)}
{!isEmpty(projectList) && (
<ViewTools
isLoading={loading}
isEmpty={!projectList?.length}
listOfTools={projectList}
setOpenAddTool={toggleModal}
handleEdit={updateProject}
handleDelete={deleteProject}
titleProp="workflow_name"
descriptionProp="description"
idProp="id"
type="Workflow"
/>
)}
{editingProject && (
<LazyLoader
component={() => import("../new-workflow/NewWorkflow.jsx")}
componentName={"NewWorkflow"}
name={editingProject.name}
description={editingProject.description}
onDone={editProject}
onClose={closeNewProject}
loading={loading}
toggleModal={toggleModal}
openModal={openModal}
backendErrors={backendErrors}
setBackendErrors={setBackendErrors}
/>
)}
</div>
</div>
</>
<ListView
title="Workflows"
useListManagerHook={useListManagerHook}
CustomModalComponent={WorkflowModal}
customButtonText="New Workflow"
customButtonIcon={<PlusOutlined />}
itemProps={{
titleProp: "workflow_name",
descriptionProp: "description",
idProp: "id",
type: "Workflow",
}}
setPostHogCustomEvent={setPostHogCustomEvent}
newButtonEventName="intent_new_wf_project"
/>
);
}

function User({ name }) {
return name ? (
<div className="sessionDetails">
<UserOutlined />
<Text italic ellipsis>
{name}
</Text>
</div>
) : null;
}

User.propTypes = {
name: PropTypes.string,
};

export { Workflows };
Original file line number Diff line number Diff line change
@@ -24,12 +24,10 @@ function workflowService() {
};
return axiosPrivate(options);
},
getProjectList: (myProjects = false) => {
const params = myProjects ? { created_by: sessionDetails?.id } : {};
getProjectList: () => {
options = {
url: `${path}/workflow/`,
method: "GET",
params,
};
return axiosPrivate(options);
},
150 changes: 150 additions & 0 deletions frontend/src/hooks/useListManager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { useState, useEffect } from "react";
import { useExceptionHandler } from "./useExceptionHandler";
import { useAlertStore } from "../store/alert-store";
import { useSessionStore } from "../store/session-store";
import { useAxiosPrivate } from "./useAxiosPrivate";

export function useListManager({
getListApiCall,
addItemApiCall,
editItemApiCall,
deleteItemApiCall,
searchProperty = "",
itemIdProp = "id",
itemType = "item",
initialFilter,
onAddSuccess,
onEditSuccess,
onDeleteSuccess,
onError,
}) {
const [list, setList] = useState([]);
const [filteredList, setFilteredList] = useState([]);
const [loading, setLoading] = useState(false);

const handleException = useExceptionHandler();
const { setAlertDetails } = useAlertStore();
const sessionDetails = useSessionStore((state) => state?.sessionDetails);
const axiosPrivate = useAxiosPrivate();

useEffect(() => {
fetchList();
}, [initialFilter]);

const fetchList = () => {
if (!getListApiCall) return;

setLoading(true);
getListApiCall({ axiosPrivate, sessionDetails, initialFilter })
.then((res) => {
const data = res?.data || [];
setList(data);
setFilteredList(data);
})
.catch((err) => {
const errorMsg = handleException(
err,
`Failed to get the list of ${itemType}s`
);
setAlertDetails(errorMsg);
onError?.(err);
})
.finally(() => {
setLoading(false);
});
};

const updateList = (itemData, itemId, isEdit = false) => {
let updatedList = [];

if (isEdit) {
updatedList = list.map((item) =>
item?.[itemIdProp] === itemId ? itemData : item
);
onEditSuccess?.(itemData);
} else {
updatedList = [itemData, ...list];
onAddSuccess?.(itemData);
}

setList(updatedList);
setFilteredList(updatedList);
};

const handleSearch = (searchText = "") => {
if (!searchText.trim()) {
setFilteredList(list);
return;
}
const filtered = list.filter((item) =>
item?.[searchProperty]?.toLowerCase()?.includes(searchText.toLowerCase())
);
setFilteredList(filtered);
};

const handleAddItem = (itemData, itemId, isEdit = false) => {
const apiCall = isEdit ? editItemApiCall : addItemApiCall;
if (!apiCall) return Promise.reject(new Error("API call is not defined"));

return apiCall({ axiosPrivate, sessionDetails, itemData, itemId })
.then((res) => {
const updatedItem = res?.data;
let updatedList = [];

if (isEdit) {
updatedList = list.map((item) =>
item?.[itemIdProp] === itemId ? updatedItem : item
);
onEditSuccess?.(updatedItem);
} else {
updatedList = [...list, updatedItem];
onAddSuccess?.(updatedItem);
}

setList(updatedList);
setFilteredList(updatedList);
return updatedItem;
})
.catch((err) => {
const errorMsg = handleException(
err,
`Failed to ${isEdit ? "edit" : "add"} ${itemType}`
);
setAlertDetails(errorMsg);
onError?.(err);
throw err;
});
};

const handleDeleteItem = (itemId) => {
if (!deleteItemApiCall)
return Promise.reject(new Error("API call is not defined"));

return deleteItemApiCall({ axiosPrivate, sessionDetails, itemId })
.then(() => {
const updatedList = list.filter(
(item) => item?.[itemIdProp] !== itemId
);
setList(updatedList);
setFilteredList(updatedList);
onDeleteSuccess?.(itemId);
})
.catch((err) => {
const errorMsg = handleException(err, `Failed to delete ${itemType}`);
setAlertDetails(errorMsg);
onError?.(err);
throw err;
});
};

return {
list,
filteredList,
updateList,
loading,
fetchList,
handleSearch,
handleAddItem,
handleDeleteItem,
};
}