From 7fc59d98e914754a4370aa3c6c5cd34953da2f73 Mon Sep 17 00:00:00 2001 From: Praveen Kumar Date: Wed, 3 Jun 2026 16:06:50 +0530 Subject: [PATCH 01/11] Add sorting and filtering features to Risks management - Introduced sorting functionality for risk items with a new SortButton component. - Added ColumnsDropdown for managing visible columns in the Kanban view. - Enhanced RisksProvider to manage sorting state and integrate with useRisksData. - Updated context and types to support sorting and user details. - Refactored useRisksData to include sorting in data fetching logic. --- frappe-ui-react | 2 +- .../project-details/tabs/risks/context.ts | 14 ++- .../project-details/tabs/risks/header.tsx | 23 ++-- .../tabs/risks/kanban/kanbanView.tsx | 20 +--- .../project-details/tabs/risks/provider.tsx | 21 +++- .../tabs/risks/toolbar/columnsDropdown.tsx | 60 +++++++++++ .../tabs/risks/toolbar/sortButton.tsx | 101 ++++++++++++++++++ .../tabs/risks/toolbar/toolbar.tsx | 101 ++++++++++++++++++ .../pages/project-details/tabs/risks/types.ts | 5 + .../tabs/risks/useRisksData.ts | 93 ++++++++++++---- 10 files changed, 390 insertions(+), 50 deletions(-) create mode 100644 frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/columnsDropdown.tsx create mode 100644 frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/sortButton.tsx create mode 100644 frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/toolbar.tsx diff --git a/frappe-ui-react b/frappe-ui-react index 285675696..ba9690539 160000 --- a/frappe-ui-react +++ b/frappe-ui-react @@ -1 +1 @@ -Subproject commit 2856756967c7bbba18bbb1c247b06d1797e45b95 +Subproject commit ba9690539c4c463555e7663e27bdedaea5d6e358 diff --git a/frontend/packages/app/src/pages/project-details/tabs/risks/context.ts b/frontend/packages/app/src/pages/project-details/tabs/risks/context.ts index d7233cbf7..8df54abe5 100644 --- a/frontend/packages/app/src/pages/project-details/tabs/risks/context.ts +++ b/frontend/packages/app/src/pages/project-details/tabs/risks/context.ts @@ -7,7 +7,13 @@ import { createContext, useContextSelector } from "use-context-selector"; * Internal dependencies. */ import { RISK_STATUSES, type RiskStatus } from "./constants"; -import type { RiskFilters, RiskItem, RiskVisibleColumns } from "./types"; +import type { + RiskFilters, + RiskItem, + RiskSort, + RiskVisibleColumns, + UserDetails, +} from "./types"; export interface RisksContextProps { state: { @@ -16,14 +22,17 @@ export interface RisksContextProps { error: unknown; filters: RiskFilters; visibleColumns: RiskVisibleColumns; + sort: RiskSort | null; isCreateRiskOpen: boolean; editRiskName: string | null; createRiskInitialStatus: RiskStatus | ""; deleteRiskName: string | null; + allOwnersWithDetails: Record; }; actions: { setFilters: (filters: Partial) => void; setVisibleColumns: (cols: Partial) => void; + setSort: (sort: RiskSort | null) => void; updateRiskStatus: (name: string, status: RiskStatus) => Promise; openCreateRisk: () => void; closeCreateRisk: () => void; @@ -54,14 +63,17 @@ export const RisksContext = createContext({ advanced: [], }, visibleColumns: defaultVisibleColumns, + sort: null, isCreateRiskOpen: false, editRiskName: null, createRiskInitialStatus: "", deleteRiskName: null, + allOwnersWithDetails: {}, }, actions: { setFilters: noop, setVisibleColumns: noop, + setSort: noop, updateRiskStatus: async () => {}, openCreateRisk: noop, closeCreateRisk: noop, diff --git a/frontend/packages/app/src/pages/project-details/tabs/risks/header.tsx b/frontend/packages/app/src/pages/project-details/tabs/risks/header.tsx index 11ee4880a..d8af5a12a 100644 --- a/frontend/packages/app/src/pages/project-details/tabs/risks/header.tsx +++ b/frontend/packages/app/src/pages/project-details/tabs/risks/header.tsx @@ -8,19 +8,24 @@ import { Plus } from "lucide-react"; * Internal dependencies. */ import { useRisks } from "./context"; +import { RisksToolbar } from "./toolbar/toolbar"; export function RisksHeader() { const openCreateRisk = useRisks((c) => c.actions.openCreateRisk); return ( -
-

Risks

-
+ <> +
+

Risks

+
+ + + ); } diff --git a/frontend/packages/app/src/pages/project-details/tabs/risks/kanban/kanbanView.tsx b/frontend/packages/app/src/pages/project-details/tabs/risks/kanban/kanbanView.tsx index 18bfb8a01..d004170e5 100644 --- a/frontend/packages/app/src/pages/project-details/tabs/risks/kanban/kanbanView.tsx +++ b/frontend/packages/app/src/pages/project-details/tabs/risks/kanban/kanbanView.tsx @@ -1,7 +1,7 @@ /** * External dependencies. */ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { move } from "@dnd-kit/helpers"; import { DragDropProvider } from "@dnd-kit/react"; import { Draggable, Droppable } from "@next-pms/design-system/components"; @@ -45,21 +45,9 @@ export function RisksKanbanView() { return map; }, [data]); - useEffect(() => { - setItems((current) => { - const fromServer = groupIdsByStatus(data); - const merged = {} as RiskIdsByStatus; - for (const status of RISK_STATUSES) { - const serverSet = new Set(fromServer[status]); - // Preserve existing drag-and-drop order; drop items that moved away - const kept = current[status].filter((id) => serverSet.has(id)); - const keptSet = new Set(kept); - // Append any items newly added on the server side - const added = fromServer[status].filter((id) => !keptSet.has(id)); - merged[status as RiskStatus] = [...kept, ...added]; - } - return merged; - }); + useMemo(() => { + if (!data) return; + setItems(groupIdsByStatus(data)); }, [data]); return ( diff --git a/frontend/packages/app/src/pages/project-details/tabs/risks/provider.tsx b/frontend/packages/app/src/pages/project-details/tabs/risks/provider.tsx index 0f47b7ae8..34131a1bf 100644 --- a/frontend/packages/app/src/pages/project-details/tabs/risks/provider.tsx +++ b/frontend/packages/app/src/pages/project-details/tabs/risks/provider.tsx @@ -10,7 +10,7 @@ import { useFrappeUpdateDoc } from "frappe-react-sdk"; */ import { RISK_DETAIL_PARAM, RISK_STATUSES, type RiskStatus } from "./constants"; import { RisksContext, type RisksContextProps } from "./context"; -import type { RiskFilters, RiskVisibleColumns } from "./types"; +import type { RiskFilters, RiskSort, RiskVisibleColumns } from "./types"; import { useRisksData } from "./useRisksData"; const defaultFilters: RiskFilters = { @@ -35,9 +35,16 @@ export function RisksProvider({ children }: PropsWithChildren) { RiskStatus | "" >(""); const [deleteRiskName, setDeleteRiskName] = useState(null); + const [sort, setSortState] = useState(null); const [, setSearchParams] = useSearchParams(); - const { data, isLoading, error, mutate: refreshRiskList } = useRisksData(); + const { + data, + isLoading, + error, + mutate: refreshRiskList, + allOwnersWithDetails, + } = useRisksData(filters, sort); const { updateDoc } = useFrappeUpdateDoc(); @@ -52,6 +59,10 @@ export function RisksProvider({ children }: PropsWithChildren) { [], ); + const setSort = useCallback((s: RiskSort | null) => { + setSortState(s); + }, []); + const updateRiskStatus = useCallback( async (name: string, status: RiskStatus) => { await updateDoc("Risk", name, { status }); @@ -114,14 +125,17 @@ export function RisksProvider({ children }: PropsWithChildren) { error, filters, visibleColumns, + sort, isCreateRiskOpen, editRiskName, createRiskInitialStatus, deleteRiskName, + allOwnersWithDetails, }, actions: { setFilters, setVisibleColumns, + setSort, updateRiskStatus, openCreateRisk, closeCreateRisk, @@ -139,12 +153,15 @@ export function RisksProvider({ children }: PropsWithChildren) { error, filters, visibleColumns, + sort, isCreateRiskOpen, editRiskName, createRiskInitialStatus, deleteRiskName, + allOwnersWithDetails, setFilters, setVisibleColumns, + setSort, updateRiskStatus, openCreateRisk, closeCreateRisk, diff --git a/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/columnsDropdown.tsx b/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/columnsDropdown.tsx new file mode 100644 index 000000000..f002b751a --- /dev/null +++ b/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/columnsDropdown.tsx @@ -0,0 +1,60 @@ +/** + * External dependencies. + */ +import { useMemo } from "react"; +import { MultiSelect } from "@rtcamp/frappe-ui-react"; +import type { MultiSelectOption } from "@rtcamp/frappe-ui-react"; + +/** + * Internal dependencies. + */ +import { RISK_STATUSES } from "../constants"; +import type { RiskStatus } from "../constants"; +import { RiskStatusBadge } from "../riskStatusBadge"; +import type { RiskVisibleColumns } from "../types"; + +interface ColumnsDropdownProps { + visibleColumns: RiskVisibleColumns; + setVisibleColumns: (partial: Partial) => void; +} + +const COLUMN_OPTIONS: MultiSelectOption[] = RISK_STATUSES.map((status) => ({ + value: status, + label: status, +})); + +export function ColumnsDropdown({ + visibleColumns, + setVisibleColumns, +}: ColumnsDropdownProps) { + const selectedValues = useMemo( + () => + RISK_STATUSES.filter((status) => visibleColumns[status]).map( + (status) => status, + ), + [visibleColumns], + ); + + const handleChange = (newValues: string[]) => { + const partial = Object.fromEntries( + RISK_STATUSES.map((status) => [status, newValues.includes(status)]), + ) as unknown as RiskVisibleColumns; + setVisibleColumns(partial); + }; + + return ( +
+ ( + + )} + popupClassName="w-full" + /> +
+ ); +} diff --git a/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/sortButton.tsx b/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/sortButton.tsx new file mode 100644 index 000000000..ba32c6f05 --- /dev/null +++ b/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/sortButton.tsx @@ -0,0 +1,101 @@ +/** + * External dependencies. + */ +import { useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { Popover } from "@base-ui/react"; +import { ArrowDown, ArrowUp } from "@rtcamp/frappe-ui-react/icons"; +import { ArrowUpDown } from "lucide-react"; + +/** + * Internal dependencies. + */ +import { RISK_VIEW_PARAM } from "../constants"; +import { useRisks } from "../context"; + +const SORT_FIELDS: { field: string; label: string }[] = [ + { field: "modified", label: "Last updated on" }, + { field: "status", label: "Status" }, + { field: "risk_category", label: "Risk category" }, + { field: "risk_level", label: "Risk level" }, +]; + +export function SortButton() { + const [open, setOpen] = useState(false); + const [searchParams] = useSearchParams(); + const isKanban = searchParams.get(RISK_VIEW_PARAM) === "kanban"; + const sort = useRisks((c) => c.state.sort); + const setSort = useRisks((c) => c.actions.setSort); + + const sortFields = isKanban + ? SORT_FIELDS.filter((f) => f.field !== "risk_category") + : SORT_FIELDS; + + const handleFieldClick = (field: string) => { + if (sort?.field === field) { + setSort({ field, order: sort.order === "asc" ? "desc" : "asc" }); + } else { + setSort({ field, order: "desc" }); + } + }; + + const isActive = sort !== null; + + return ( + + + + Sort + {isActive && ( + + {sortFields.find((f) => f.field === sort.field)?.label} + + )} + + + + + {sortFields.map(({ field, label }) => { + const isSelected = sort?.field === field; + return ( + + ); + })} + {isActive && ( + <> +
+ + + )} + + + + + ); +} diff --git a/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/toolbar.tsx b/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/toolbar.tsx new file mode 100644 index 000000000..1e540b896 --- /dev/null +++ b/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/toolbar.tsx @@ -0,0 +1,101 @@ +/** + * External dependencies. + */ +import { useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; +import { Filter, Select } from "@rtcamp/frappe-ui-react"; + +/** + * Internal dependencies. + */ +import { RISK_STATUSES, RISK_VIEW_PARAM } from "../constants"; +import type { RiskStatus } from "../constants"; +import { useRisks } from "../context"; +import { ColumnsDropdown } from "./columnsDropdown"; +import { SortButton } from "./sortButton"; + +const RISK_LEVEL_OPTIONS = [ + { label: "All", value: "" }, + { label: "Low", value: "Low" }, + { label: "Medium", value: "Medium" }, + { label: "High", value: "High" }, +]; + +const STATUS_OPTIONS = [ + { label: "All", value: "" }, + ...RISK_STATUSES.map((s) => ({ label: s, value: s })), +]; + +export function RisksToolbar() { + const [searchParams] = useSearchParams(); + const isKanban = searchParams.get(RISK_VIEW_PARAM) === "kanban"; + + const filters = useRisks((c) => c.state.filters); + const allOwnersWithDetails = useRisks((c) => c.state.allOwnersWithDetails); + const visibleColumns = useRisks((c) => c.state.visibleColumns); + const setFilters = useRisks((c) => c.actions.setFilters); + const setVisibleColumns = useRisks((c) => c.actions.setVisibleColumns); + + const ownerOptions = useMemo(() => { + return [ + { label: "All owners", value: "" }, + ...Object.entries(allOwnersWithDetails).map(([email, details]) => ({ + label: details?.full_name ?? email, + value: email, + })), + ]; + }, [allOwnersWithDetails]); + + return ( +
+ {/* Left: quick filters */} +
+ setFilters({ status: (v ?? "") as RiskStatus | "" })} + options={STATUS_OPTIONS} + /> +