Skip to content

Commit fabfa6a

Browse files
authored
Merge pull request #1426 from rtCamp/feat/issue-1089
Add sorting and filtering toolbar to Risks management
2 parents 872ed84 + d1b813e commit fabfa6a

12 files changed

Lines changed: 466 additions & 50 deletions

File tree

frontend/packages/app/src/pages/project-details/tabs/risks/context.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ import { createContext, useContextSelector } from "use-context-selector";
77
* Internal dependencies.
88
*/
99
import { RISK_STATUSES, type RiskStatus } from "./constants";
10-
import type { RiskFilters, RiskItem, RiskVisibleColumns } from "./types";
10+
import type {
11+
RiskFilters,
12+
RiskItem,
13+
RiskSort,
14+
RiskVisibleColumns,
15+
UserDetails,
16+
} from "./types";
1117

1218
export interface RisksContextProps {
1319
state: {
@@ -16,14 +22,17 @@ export interface RisksContextProps {
1622
error: unknown;
1723
filters: RiskFilters;
1824
visibleColumns: RiskVisibleColumns;
25+
sort: RiskSort | null;
1926
isCreateRiskOpen: boolean;
2027
editRiskName: string | null;
2128
createRiskInitialStatus: RiskStatus | "";
2229
deleteRiskName: string | null;
30+
allOwnersWithDetails: Record<string, UserDetails | undefined>;
2331
};
2432
actions: {
2533
setFilters: (filters: Partial<RiskFilters>) => void;
2634
setVisibleColumns: (cols: Partial<RiskVisibleColumns>) => void;
35+
setSort: (sort: RiskSort | null) => void;
2736
updateRiskStatus: (name: string, status: RiskStatus) => Promise<void>;
2837
openCreateRisk: () => void;
2938
closeCreateRisk: () => void;
@@ -54,14 +63,17 @@ export const RisksContext = createContext<RisksContextProps>({
5463
advanced: [],
5564
},
5665
visibleColumns: defaultVisibleColumns,
66+
sort: null,
5767
isCreateRiskOpen: false,
5868
editRiskName: null,
5969
createRiskInitialStatus: "",
6070
deleteRiskName: null,
71+
allOwnersWithDetails: {},
6172
},
6273
actions: {
6374
setFilters: noop,
6475
setVisibleColumns: noop,
76+
setSort: noop,
6577
updateRiskStatus: async () => {},
6678
openCreateRisk: noop,
6779
closeCreateRisk: noop,

frontend/packages/app/src/pages/project-details/tabs/risks/header.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,24 @@ import { Plus } from "lucide-react";
88
* Internal dependencies.
99
*/
1010
import { useRisks } from "./context";
11+
import { RisksToolbar } from "./toolbar/toolbar";
1112

1213
export function RisksHeader() {
1314
const openCreateRisk = useRisks((c) => c.actions.openCreateRisk);
1415

1516
return (
16-
<div className="flex items-center justify-between mb-3.5">
17-
<h1 className="text-xl font-semibold text-ink-gray-8">Risks</h1>
18-
<Button
19-
variant="solid"
20-
label="Create"
21-
iconLeft={() => <Plus />}
22-
onClick={openCreateRisk}
23-
/>
24-
</div>
17+
<>
18+
<div className="flex items-center justify-between mb-3.5">
19+
<h1 className="text-xl font-semibold text-ink-gray-8">Risks</h1>
20+
<Button
21+
variant="solid"
22+
label="Create"
23+
iconLeft={() => <Plus />}
24+
onClick={openCreateRisk}
25+
/>
26+
</div>
27+
28+
<RisksToolbar />
29+
</>
2530
);
2631
}

frontend/packages/app/src/pages/project-details/tabs/risks/index.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* External dependencies.
33
*/
44
import { useSearchParams } from "react-router-dom";
5+
import { mergeClassNames as cn } from "@next-pms/design-system";
6+
import { Spinner } from "@next-pms/design-system/components";
57

68
/**
79
* Internal dependencies.
@@ -31,6 +33,7 @@ function RisksContent() {
3133
const deleteRiskName = useRisks((c) => c.state.deleteRiskName);
3234
const closeCreateRisk = useRisks((c) => c.actions.closeCreateRisk);
3335
const closeDeleteRisk = useRisks((c) => c.actions.closeDeleteRisk);
36+
const isLoading = useRisks((c) => c.state.isLoading);
3437

3538
return (
3639
<>
@@ -46,9 +49,21 @@ function RisksContent() {
4649
{riskId ? (
4750
<RiskDetailView riskId={riskId} />
4851
) : (
49-
<div className="flex flex-col h-full">
52+
<div className="relative flex flex-col h-full">
5053
<RisksHeader />
51-
{activeView === "kanban" ? <RisksKanbanView /> : <RisksListView />}
54+
<div
55+
className={cn("flex flex-col flex-1 min-h-0", {
56+
"opacity-50 transition-opacity duration-150": isLoading,
57+
})}
58+
>
59+
{activeView === "kanban" ? <RisksKanbanView /> : <RisksListView />}
60+
</div>
61+
{isLoading && (
62+
<Spinner
63+
isFull
64+
className="absolute top-0 left-0 w-full h-full cursor-wait"
65+
/>
66+
)}
5267
</div>
5368
)}
5469
</>

frontend/packages/app/src/pages/project-details/tabs/risks/kanban/kanbanView.tsx

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,8 @@ export function RisksKanbanView() {
4646
}, [data]);
4747

4848
useEffect(() => {
49-
setItems((current) => {
50-
const fromServer = groupIdsByStatus(data);
51-
const merged = {} as RiskIdsByStatus;
52-
for (const status of RISK_STATUSES) {
53-
const serverSet = new Set(fromServer[status]);
54-
// Preserve existing drag-and-drop order; drop items that moved away
55-
const kept = current[status].filter((id) => serverSet.has(id));
56-
const keptSet = new Set(kept);
57-
// Append any items newly added on the server side
58-
const added = fromServer[status].filter((id) => !keptSet.has(id));
59-
merged[status as RiskStatus] = [...kept, ...added];
60-
}
61-
return merged;
62-
});
49+
if (!data) return;
50+
setItems(groupIdsByStatus(data));
6351
}, [data]);
6452

6553
return (

frontend/packages/app/src/pages/project-details/tabs/risks/provider.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
*/
44
import { useCallback, useMemo, useState, type PropsWithChildren } from "react";
55
import { useSearchParams } from "react-router-dom";
6-
import { useFrappeUpdateDoc } from "frappe-react-sdk";
6+
import { useFrappeUpdateDoc, useSWRConfig } from "frappe-react-sdk";
77

88
/**
99
* Internal dependencies.
1010
*/
1111
import { RISK_DETAIL_PARAM, RISK_STATUSES, type RiskStatus } from "./constants";
1212
import { RisksContext, type RisksContextProps } from "./context";
13-
import type { RiskFilters, RiskVisibleColumns } from "./types";
13+
import type { RiskFilters, RiskSort, RiskVisibleColumns } from "./types";
1414
import { useRisksData } from "./useRisksData";
1515

1616
const defaultFilters: RiskFilters = {
@@ -35,9 +35,17 @@ export function RisksProvider({ children }: PropsWithChildren) {
3535
RiskStatus | ""
3636
>("");
3737
const [deleteRiskName, setDeleteRiskName] = useState<string | null>(null);
38+
const [sort, setSortState] = useState<RiskSort | null>(null);
3839
const [, setSearchParams] = useSearchParams();
40+
const { mutate } = useSWRConfig();
3941

40-
const { data, isLoading, error, mutate: refreshRiskList } = useRisksData();
42+
const {
43+
data,
44+
isLoading,
45+
error,
46+
mutate: refreshRiskList,
47+
allOwnersWithDetails,
48+
} = useRisksData(filters, sort);
4149

4250
const { updateDoc } = useFrappeUpdateDoc();
4351

@@ -52,6 +60,18 @@ export function RisksProvider({ children }: PropsWithChildren) {
5260
[],
5361
);
5462

63+
const setSort = useCallback((s: RiskSort | null) => {
64+
setSortState(s);
65+
mutate(
66+
(key) =>
67+
typeof key === "string" &&
68+
key.includes("/api/resource/Risk?") &&
69+
key.includes("order_by="),
70+
undefined,
71+
{ revalidate: true },
72+
);
73+
}, []);
74+
5575
const updateRiskStatus = useCallback(
5676
async (name: string, status: RiskStatus) => {
5777
await updateDoc("Risk", name, { status });
@@ -114,14 +134,17 @@ export function RisksProvider({ children }: PropsWithChildren) {
114134
error,
115135
filters,
116136
visibleColumns,
137+
sort,
117138
isCreateRiskOpen,
118139
editRiskName,
119140
createRiskInitialStatus,
120141
deleteRiskName,
142+
allOwnersWithDetails,
121143
},
122144
actions: {
123145
setFilters,
124146
setVisibleColumns,
147+
setSort,
125148
updateRiskStatus,
126149
openCreateRisk,
127150
closeCreateRisk,
@@ -139,12 +162,15 @@ export function RisksProvider({ children }: PropsWithChildren) {
139162
error,
140163
filters,
141164
visibleColumns,
165+
sort,
142166
isCreateRiskOpen,
143167
editRiskName,
144168
createRiskInitialStatus,
145169
deleteRiskName,
170+
allOwnersWithDetails,
146171
setFilters,
147172
setVisibleColumns,
173+
setSort,
148174
updateRiskStatus,
149175
openCreateRisk,
150176
closeCreateRisk,
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* External dependencies.
3+
*/
4+
import { useMemo } from "react";
5+
import { MultiSelect } from "@rtcamp/frappe-ui-react";
6+
import type { MultiSelectOption } from "@rtcamp/frappe-ui-react";
7+
8+
/**
9+
* Internal dependencies.
10+
*/
11+
import { RISK_STATUSES } from "../constants";
12+
import type { RiskStatus } from "../constants";
13+
import { RiskStatusBadge } from "../riskStatusBadge";
14+
import type { RiskVisibleColumns } from "../types";
15+
16+
interface ColumnsDropdownProps {
17+
visibleColumns: RiskVisibleColumns;
18+
setVisibleColumns: (partial: Partial<RiskVisibleColumns>) => void;
19+
}
20+
21+
const COLUMN_OPTIONS: MultiSelectOption[] = RISK_STATUSES.map((status) => ({
22+
value: status,
23+
label: status,
24+
}));
25+
26+
export function ColumnsDropdown({
27+
visibleColumns,
28+
setVisibleColumns,
29+
}: ColumnsDropdownProps) {
30+
const selectedValues = useMemo(
31+
() =>
32+
RISK_STATUSES.filter((status) => visibleColumns[status]).map(
33+
(status) => status,
34+
),
35+
[visibleColumns],
36+
);
37+
38+
const handleChange = (newValues: string[]) => {
39+
const partial = Object.fromEntries(
40+
RISK_STATUSES.map((status) => [status, newValues.includes(status)]),
41+
) as unknown as RiskVisibleColumns;
42+
setVisibleColumns(partial);
43+
};
44+
45+
return (
46+
<div>
47+
<MultiSelect
48+
options={COLUMN_OPTIONS}
49+
value={selectedValues}
50+
triggerLabel="Columns"
51+
hideSearch
52+
onChange={handleChange}
53+
renderOption={(option) => (
54+
<RiskStatusBadge status={option.value as RiskStatus} />
55+
)}
56+
popupClassName="w-full"
57+
/>
58+
</div>
59+
);
60+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* External dependencies.
3+
*/
4+
import { useSearchParams } from "react-router-dom";
5+
import { SortButton as SortButtonBase } from "@next-pms/design-system/components";
6+
7+
/**
8+
* Internal dependencies.
9+
*/
10+
import { RISK_VIEW_PARAM } from "../constants";
11+
import { useRisks } from "../context";
12+
13+
const SORT_FIELDS = [
14+
{ field: "modified", label: "Last updated on" },
15+
{ field: "status", label: "Status" },
16+
{ field: "risk_category", label: "Risk category" },
17+
{ field: "risk_level", label: "Risk level" },
18+
];
19+
20+
export function SortButton() {
21+
const [searchParams] = useSearchParams();
22+
const isKanban = searchParams.get(RISK_VIEW_PARAM) === "kanban";
23+
const sort = useRisks((c) => c.state.sort);
24+
const setSort = useRisks((c) => c.actions.setSort);
25+
26+
const sortFields = isKanban
27+
? SORT_FIELDS.filter((f) => f.field !== "status")
28+
: SORT_FIELDS;
29+
30+
return (
31+
<SortButtonBase fields={sortFields} sort={sort} onSortChange={setSort} />
32+
);
33+
}

0 commit comments

Comments
 (0)