Skip to content

Commit 10f2bcd

Browse files
author
Michael Buchar
committed
feat(ui): persist workflow page tabs and filters in URLs (reanahub#440)
Added persistent ?page= parameters in WorkflowDetails and WorkFlowList. Changed /details/<id> to /workflows/<id> and added redirect. Improved WorkflowFiles to synchronize search and pagination in URL. Enhanced WorkflowLogs with step persistence using ?step= parameter. Made URL the source of truth for search/page/status/show-deleted /shared/shared-with/sort. Made status single select with no deleted status in dropdown. Deletion toggle fully controls the deleted status workflows. Prevented duplicate fetches and stabilized polling. Use job ids instead of job names in the URL. Use URL args instead of URL paths for tabs. Use Service Logs component in URL. Closes reanahub#437
1 parent 0874498 commit 10f2bcd

File tree

10 files changed

+685
-196
lines changed

10 files changed

+685
-196
lines changed

reana-ui/src/components/App.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
BrowserRouter,
1515
Route,
1616
Routes,
17+
useParams,
1718
useLocation,
1819
} from "react-router-dom";
1920
import { useSelector } from "react-redux";
@@ -51,6 +52,12 @@ function RequireAuth({ children }) {
5152
}
5253
}
5354

55+
function RedirectDetailsToWorkflows() {
56+
const { id } = useParams();
57+
const location = useLocation();
58+
return <Navigate to={`/workflows/${id}${location.search}`} replace />;
59+
}
60+
5461
export default function App() {
5562
const userLoading = useSelector(loadingUser);
5663
const configLoading = useSelector(loadingConfig);
@@ -90,13 +97,21 @@ export default function App() {
9097
}
9198
/>
9299
<Route
93-
path="/details/:id"
100+
path="/workflows/:id/:tab?/:job?"
94101
element={
95102
<RequireAuth>
96103
<WorkflowDetails />
97104
</RequireAuth>
98105
}
99106
/>
107+
<Route
108+
path="/details/:id"
109+
element={
110+
<RequireAuth>
111+
<RedirectDetailsToWorkflows />
112+
</RequireAuth>
113+
}
114+
/>
100115
<Route
101116
path="/profile"
102117
element={

reana-ui/src/components/Search.js

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,33 +9,68 @@
99
*/
1010

1111
import PropTypes from "prop-types";
12-
import { Input } from "semantic-ui-react";
13-
import debounce from "lodash/debounce";
12+
import { Input, Icon } from "semantic-ui-react";
13+
import isEqual from "lodash/isEqual";
1414

1515
import styles from "./Search.module.scss";
1616

17-
const TYPING_DELAY = 1000;
17+
export default function Search({
18+
value = "",
19+
onChange,
20+
onSubmit,
21+
loading = false,
22+
}) {
23+
const handleChange = (text) => {
24+
if (typeof onChange === "function") {
25+
onChange(text);
26+
}
27+
};
28+
29+
const handleKeyDown = (e) => {
30+
if (e.key === "Enter" && typeof onSubmit === "function") {
31+
onSubmit();
32+
}
33+
};
34+
35+
const handleClick = () => {
36+
if (typeof onSubmit === "function") {
37+
onSubmit();
38+
}
39+
};
1840

19-
export default function Search({ search, loading = false }) {
20-
const handleChange = debounce(search, TYPING_DELAY);
2141
return (
2242
<Input
2343
fluid
2444
icon="search"
2545
placeholder="Search..."
46+
value={value}
2647
className={styles.input}
2748
onChange={(_, data) => handleChange(data.value)}
49+
onKeyDown={handleKeyDown}
50+
iconPosition="right"
2851
loading={loading}
29-
/>
52+
aria-label="Search workflows"
53+
>
54+
<input />
55+
<Icon name="search" link onClick={handleClick} title="Search" />
56+
</Input>
3057
);
3158
}
3259

3360
Search.propTypes = {
34-
search: PropTypes.func.isRequired,
61+
value: PropTypes.string,
62+
onChange: PropTypes.func,
63+
onSubmit: PropTypes.func,
3564
loading: PropTypes.bool,
65+
search: PropTypes.func,
3666
};
3767

38-
export const applyFilter = (filter, pagination, setPagination) => (value) => {
39-
filter(value);
40-
setPagination({ ...pagination, page: 1 });
68+
export const applyFilter = (setFilter, resetPage) => (nextValue) => {
69+
setFilter((prevValue) => {
70+
if (isEqual(prevValue, nextValue)) {
71+
return prevValue;
72+
}
73+
resetPage();
74+
return nextValue;
75+
});
4176
};

reana-ui/src/pages/workflowDetails/WorkflowDetails.js

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import { useCallback, useEffect, useRef } from "react";
1212
import { useSelector, useDispatch } from "react-redux";
13-
import { useParams } from "react-router-dom";
13+
import { useParams, useSearchParams, useNavigate } from "react-router-dom";
1414
import { Container, Dimmer, Icon, Loader, Tab } from "semantic-ui-react";
1515

1616
import { fetchWorkflow, fetchWorkflowLogs } from "~/actions";
@@ -43,8 +43,13 @@ import styles from "./WorkflowDetails.module.scss";
4343
const FINISHED_STATUSES = ["finished", "failed", "stopped", "deleted"];
4444

4545
export default function WorkflowDetails() {
46-
const { id: workflowId } = useParams();
47-
46+
const {
47+
id: workflowId,
48+
tab: tabFromPath = "",
49+
job: jobFromPath,
50+
} = useParams();
51+
const navigate = useNavigate();
52+
const [searchParams, setSearchParams] = useSearchParams();
4853
const dispatch = useDispatch();
4954
const workflow = useSelector(getWorkflow(workflowId));
5055
const loading = useSelector(loadingWorkflows);
@@ -53,6 +58,43 @@ export default function WorkflowDetails() {
5358
const interval = useRef(null);
5459
const workflowRefresh = useSelector(getWorkflowRefresh);
5560

61+
const getPageFromUrl = () => {
62+
const n = parseInt(searchParams.get("page") || "", 10);
63+
return Number.isFinite(n) && n > 0 ? n : 1;
64+
};
65+
66+
const page = getPageFromUrl();
67+
68+
// if ?page= param is not in a valid format, or page is 1, remove page from URL
69+
useEffect(() => {
70+
const raw = searchParams.get("page");
71+
const n = parseInt(raw || "", 10);
72+
const shouldRemovePage =
73+
searchParams.has("page") &&
74+
(!raw || // page=empty string
75+
!Number.isFinite(n) || // page=abc
76+
n <= 1); // page=1, page=0
77+
78+
if (shouldRemovePage) {
79+
const next = new URLSearchParams(searchParams);
80+
next.delete("page");
81+
setSearchParams(next, { replace: true });
82+
}
83+
}, [searchParams, setSearchParams]);
84+
85+
const gotoPage = (nextPage) => {
86+
// Merge with existing params - keeps search, tab, etc.
87+
setSearchParams(
88+
(prev) => {
89+
const next = new URLSearchParams(prev);
90+
if (nextPage > 1) next.set("page", String(nextPage));
91+
else next.delete("page"); // if page 1, remove param
92+
return next;
93+
},
94+
{ replace: false },
95+
);
96+
};
97+
5698
const refetchWorkflow = useCallback(() => {
5799
const options = { refetch: true, showLoader: false };
58100
dispatch(fetchWorkflow(workflowId, options));
@@ -129,7 +171,14 @@ export default function WorkflowDetails() {
129171
icon: "folder outline",
130172
content: "Workspace",
131173
},
132-
render: () => <WorkflowFiles title="Workspace" id={workflow.id} />,
174+
render: () => (
175+
<WorkflowFiles
176+
title="Workspace"
177+
id={workflow.id}
178+
page={page}
179+
onPageChange={gotoPage}
180+
/>
181+
),
133182
},
134183
{
135184
menuItem: {
@@ -141,14 +190,20 @@ export default function WorkflowDetails() {
141190
},
142191
];
143192

144-
// If the workflow has finished and it did not fail, then engine logs are shown.
193+
// If the workflow has finished, and it did not fail, then engine logs are shown.
145194
// Otherwise, job logs are displayed.
146195
const hasFinished = FINISHED_STATUSES.includes(workflow.status);
147196
let defaultActiveIndex = 1; // job logs
148197
if (hasFinished && workflow.status !== "failed") {
149198
defaultActiveIndex = 0; // engine logs
150199
}
151200

201+
// If URL has a /:tab value, use it to find the index
202+
const tabKeys = panes.map((p) => p.menuItem.key);
203+
const activeTabIndex = tabFromPath
204+
? Math.max(tabKeys.indexOf(tabFromPath), 0)
205+
: defaultActiveIndex;
206+
152207
const pageTitle = `${workflow.name} #${workflow.run}`;
153208

154209
return (
@@ -174,7 +229,27 @@ export default function WorkflowDetails() {
174229
<Tab
175230
menu={{ secondary: true, pointing: true }}
176231
panes={panes}
177-
defaultActiveIndex={defaultActiveIndex}
232+
activeIndex={activeTabIndex}
233+
onTabChange={(_, data) => {
234+
const nextKey = tabKeys[data.activeIndex];
235+
// Preserve query params only if needed (page and search are only meaningful for workspace)
236+
const keepQuery =
237+
nextKey === "workspace"
238+
? (() => {
239+
const q = new URLSearchParams(searchParams);
240+
return q.toString() ? `?${q.toString()}` : "";
241+
})()
242+
: "";
243+
244+
// Build new path, for job-logs, preserve current :job path segment if present.
245+
const base = `/workflows/${workflowId}`;
246+
const path =
247+
nextKey === "job-logs"
248+
? `${base}/job-logs${jobFromPath ? `/${jobFromPath}` : ""}`
249+
: `${base}/${nextKey}`;
250+
251+
navigate(`${path}${keepQuery}`, { replace: false });
252+
}}
178253
/>
179254
<InteractiveSessionModal />
180255
<WorkflowDeleteModal />

reana-ui/src/pages/workflowDetails/components/WorkflowFiles.js

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@
1010

1111
import sortBy from "lodash/sortBy";
1212
import PropTypes from "prop-types";
13-
import { useEffect, useState } from "react";
13+
import { useEffect, useState, useMemo } from "react";
14+
import { useSearchParams } from "react-router-dom";
1415
import { useDispatch, useSelector } from "react-redux";
1516
import { Icon, Loader, Message, Segment, Table } from "semantic-ui-react";
1617

1718
import { fetchWorkflowFiles } from "~/actions";
1819
import { Pagination, Search } from "~/components";
19-
import { applyFilter } from "~/components/Search";
2020
import {
2121
getWorkflowFiles,
2222
getWorkflowFilesCount,
@@ -29,21 +29,36 @@ import styles from "./WorkflowFiles.module.scss";
2929

3030
const PAGE_SIZE = 15;
3131

32-
export default function WorkflowFiles({ id }) {
32+
export default function WorkflowFiles({ id, page = 1, onPageChange }) {
3333
const dispatch = useDispatch();
3434
const loading = useSelector(loadingDetails);
3535
const _files = useSelector(getWorkflowFiles(id));
3636
const filesCount = useSelector(getWorkflowFilesCount(id));
3737

3838
const [files, setFiles] = useState();
3939
const [sorting, setSorting] = useState({ column: null, direction: null });
40-
const [pagination, setPagination] = useState({ page: 1, size: PAGE_SIZE });
41-
const [searchFilter, setSearchFilter] = useState();
40+
const [searchParams, setSearchParams] = useSearchParams();
41+
const [searchText, setSearchText] = useState(
42+
() => searchParams.get("search") || "",
43+
);
44+
const searchQuery = (searchParams.get("search") || "").trim();
4245
const [filePreview, setFilePreview] = useState(null);
4346

47+
// Use pagination from parent-controlled URL page (memoized to avoid extra effects)
48+
const pagination = useMemo(
49+
() => ({ page: page || 1, size: PAGE_SIZE }),
50+
[page],
51+
);
52+
53+
// Keep input in sync when URL changes
4454
useEffect(() => {
45-
dispatch(fetchWorkflowFiles(id, pagination, searchFilter));
46-
}, [dispatch, id, pagination, searchFilter]);
55+
const urlSearch = searchParams.get("search") || "";
56+
setSearchText((prev) => (prev !== urlSearch ? urlSearch : prev));
57+
}, [searchParams]);
58+
59+
useEffect(() => {
60+
dispatch(fetchWorkflowFiles(id, pagination, searchQuery));
61+
}, [dispatch, id, pagination, searchQuery]);
4762

4863
useEffect(() => {
4964
setFiles(_files);
@@ -81,6 +96,23 @@ export default function WorkflowFiles({ id }) {
8196
/>
8297
);
8398

99+
// Submit search on Enter/click, then reset to page 1 (remove ?page)
100+
const submitSearch = () => {
101+
const q = searchText.trim();
102+
setSearchParams(
103+
(prev) => {
104+
const next = new URLSearchParams(prev);
105+
if (q) next.set("search", q);
106+
else next.delete("search");
107+
// Reset to first page by removing page param
108+
next.delete("page");
109+
return next;
110+
},
111+
{ replace: true },
112+
);
113+
resetSort();
114+
};
115+
84116
return files === null ? (
85117
<Message
86118
icon="info circle"
@@ -91,7 +123,9 @@ export default function WorkflowFiles({ id }) {
91123
<>
92124
<WorkflowRetentionRules id={id} />
93125
<Search
94-
search={applyFilter(setSearchFilter, pagination, setPagination)}
126+
value={searchText}
127+
onChange={setSearchText}
128+
onSubmit={submitSearch}
95129
/>
96130
{loading ? (
97131
<Loader active inline="centered" className={styles["loader"]} />
@@ -163,7 +197,9 @@ export default function WorkflowFiles({ id }) {
163197
activePage={pagination.page}
164198
totalPages={Math.ceil(filesCount / PAGE_SIZE)}
165199
onPageChange={(_, { activePage }) => {
166-
setPagination({ ...pagination, page: activePage });
200+
if (typeof onPageChange === "function") {
201+
onPageChange(activePage);
202+
}
167203
resetSort();
168204
}}
169205
size="mini"
@@ -178,4 +214,6 @@ export default function WorkflowFiles({ id }) {
178214

179215
WorkflowFiles.propTypes = {
180216
id: PropTypes.string.isRequired,
217+
page: PropTypes.number, // Current page coming from the URL
218+
onPageChange: PropTypes.func, // Notify parent to update the URL when user paginates
181219
};

0 commit comments

Comments
 (0)