From 0bf844a8494a5a2c92d436314c24a331eede5d12 Mon Sep 17 00:00:00 2001 From: vani-walvekar1494 Date: Mon, 16 Jun 2025 20:43:08 -0500 Subject: [PATCH 01/26] feat: recently accessed components --- .../src/Dashboard/Dashboard.module.css | 178 ++++++++++++++++- .../dashboard/src/Dashboard/Dashboard.tsx | 14 +- .../src/Dashboard/QuickLinksNavbar.tsx | 37 ++++ .../src/Dashboard/RecentProjects.tsx | 80 ++++++++ .../src/Dashboard/RecentlyAccessed.tsx | 54 +++++ .../dashboard/src/Dashboard/TicketList.tsx | 185 ++++++++++++++++++ .../workspace/src/AppsSideNav/AppsSideNav.tsx | 100 ++++++---- client/package-lock.json | 10 + client/package.json | 1 + client/src/main.tsx | 4 +- 10 files changed, 620 insertions(+), 43 deletions(-) create mode 100644 client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx create mode 100644 client/modules/dashboard/src/Dashboard/RecentProjects.tsx create mode 100644 client/modules/dashboard/src/Dashboard/RecentlyAccessed.tsx create mode 100644 client/modules/dashboard/src/Dashboard/TicketList.tsx diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.module.css b/client/modules/dashboard/src/Dashboard/Dashboard.module.css index 45c2aa47e9..03407b3c0d 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.module.css +++ b/client/modules/dashboard/src/Dashboard/Dashboard.module.css @@ -1,7 +1,171 @@ -/* - * Replace this with your own classes - * - * e.g. - * .container { - * } -*/ +.sidebar { + background-color: #F5F7FA; + color: #333; + padding: 1.5rem; + border-radius: 8px; + width: 260px; + min-height: calc(100vh - 80px); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.05); +} +.sidebarTitle { + font-size: 1.55rem; + font-weight: 600; + margin-bottom: 1.2rem; + color: #2C3E50; + border-bottom: 1px solid #ddd; + padding-bottom: 0.4rem; +} +.sidebarLink { + display: block; + margin: 0.75rem 0; + color: #1F2D3D; + font-size: 1.50rem; + font-weight: 500; + text-decoration: none; + transition: all 0.2s ease; +} +.sidebarLink:hover { + color: #007BFF; + text-decoration: underline; + padding-left: 5px; +} +.sidebar { + position: sticky; + top: 80px; /* after navbar */ +} +.sidebarIcon { + margin-right: 8px; + vertical-align: middle; +} + + +.recentContainer { + position: fixed; + bottom: 20px; + right: 20px; + width: 300px; + background-color: #ffffff; + border: 1px solid #ccc; + border-radius: 8px; + padding: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + font-size: 1.5rem; +} + +.recentHeading { + margin-top: 0; +} + +.recentList { + list-style-type: none; + padding-left: 0; + margin: 0; +} + +.recentItem { + margin-bottom: 6px; + color: #007bff; + cursor: pointer; + text-decoration: underline; +} + +.recentItem:hover { + color: #0056b3; +} + + +.recentProjectsContainer { + position: absolute; + top: 500px; + right: 20px; + width: 300px; + padding: 0.75rem; + border: 1px solid #ccc; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(0,0,0,0.06); + background-color: #fff; + font-size: 1.5rem; +} + +.recentProjectsTitle { + margin-top: 0; + border-bottom: 1px solid #e6e6e6; + padding-bottom: 0.5rem; + font-size: 1.1rem; + color: #333; +} + +.recentProjectsTable { + width: 100%; + border-collapse: collapse; +} + +.recentProjectsTable thead tr { + text-align: left; + font-weight: 600; + color: #555; +} + +.recentProjectsTable th, +.recentProjectsTable td { + padding: 6px 8px; +} + +.recentProjectsTable tbody tr { + border-top: 1px solid #eee; + line-height: 1.5; +} + +.projectLink { + color: #007bff; + text-decoration: none; + transition: color 0.2s ease; +} + +.projectLink:hover { + color: #0056b3; +} + +.projectId { + font-family: monospace; + color: #444; +} + + +.ticketListContainer { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + width: 95%; + max-width: 900px; + max-height: 190px; + background-color: #ffffff; + padding: 1rem; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + overflow: hidden; + z-index: 999; + + display: flex; + flex-direction: column; + justify-content: flex-start; +} + +.ticketListHeader { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.ticketListControls { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.ticketListTableWrapper { + overflow-y: auto; + flex-grow: 1; +} diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.tsx b/client/modules/dashboard/src/Dashboard/Dashboard.tsx index 5a2aad7804..e5242dc46f 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.tsx +++ b/client/modules/dashboard/src/Dashboard/Dashboard.tsx @@ -1,12 +1,22 @@ import styles from './Dashboard.module.css'; +import QuickLinksNavbar from './QuickLinksNavbar'; // ✅ Default import +import RecentlyAccessed from './RecentlyAccessed'; +import RecentProjects from './RecentProjects'; +import { TicketList } from './TicketList'; /* eslint-disable-next-line */ export interface DashboardProps {} export function Dashboard(props: DashboardProps) { return ( -
-

Welcome to Dashboard!

+
+ + +
+ + + +
); } diff --git a/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx b/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx new file mode 100644 index 0000000000..c68bb0f81f --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import styles from './Dashboard.module.css'; // ✅ Match case with filename + +import { MdManageAccounts, MdCastForEducation } from "react-icons/md"; +import { TbDatabaseShare } from "react-icons/tb"; +import { IoIosApps } from "react-icons/io"; +import { FaMapMarkedAlt } from "react-icons/fa"; + +const QuickLinksNavbar = () => { + return ( +
+
Quick Links
+ + + Manage Account + + + + Data Depot + + + + Tools & Applications + + + + Recon Portal + + + + Training + +
+ ); +}; + +export default QuickLinksNavbar; diff --git a/client/modules/dashboard/src/Dashboard/RecentProjects.tsx b/client/modules/dashboard/src/Dashboard/RecentProjects.tsx new file mode 100644 index 0000000000..c0d981d3b1 --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/RecentProjects.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useState } from 'react'; +import axios from 'axios'; +import styles from './Dashboard.module.css'; + +type Project = { + uuid: string; + title: string; + projectId: string; + lastUpdated: string; + pi: string; +}; + +const RecentProjects: React.FC = () => { + const [projects, setProjects] = useState([]); + + useEffect(() => { + const fetchProjects = async () => { + try { + const response = await axios.get('/api/projects/v2/?offset=0&limit=100'); + const rawProjects = response.data.result; + + const mapped: Project[] = rawProjects.map((proj: any) => { + const piUser = proj.value.users?.find((user: any) => user.role === 'pi'); + return { + uuid: proj.uuid, + title: proj.value.title, + projectId: proj.value.projectId, + lastUpdated: proj.lastUpdated, + pi: piUser ? `${piUser.fname} ${piUser.lname}` : 'N/A', + }; + }); + + const sortedRecent = mapped + .sort((a, b) => new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()) + .slice(0, 3); + + setProjects(sortedRecent); + } catch (error) { + console.error('Failed to fetch recent projects:', error); + } + }; + + fetchProjects(); + }, []); + + if (projects.length === 0) return null; + + return ( +
+

Recent Projects

+ + + + + + + + + + {projects.map((proj) => ( + + + + + + ))} + +
TitlePIID
+ + {proj.title || proj.projectId} + + {proj.pi}{proj.projectId}
+
+ ); +}; + +export default RecentProjects; diff --git a/client/modules/dashboard/src/Dashboard/RecentlyAccessed.tsx b/client/modules/dashboard/src/Dashboard/RecentlyAccessed.tsx new file mode 100644 index 0000000000..f43d25ccd4 --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/RecentlyAccessed.tsx @@ -0,0 +1,54 @@ +import React, { useEffect, useState } from 'react'; +import styles from './Dashboard.module.css'; + +type RecentTool = { + label: string; + path: string; +}; + +const RecentlyAccessed: React.FC = () => { + const [recentTools, setRecentTools] = useState([]); + + useEffect(() => { + const stored = localStorage.getItem('recentTools'); + if (stored) { + try { + const parsed: RecentTool[] = JSON.parse(stored); + setRecentTools(parsed); + } catch (e) { + console.error('Failed to parse recentTools:', e); + setRecentTools([]); + } + } + }, []); + + const normalizePath = (path: string) => { + return path.startsWith('/workspace/') + ? path + : `/workspace/${path.replace(/^\//, '')}`; + }; + + if (recentTools.length === 0) return null; + + return ( +
+

Recently Accessed

+
    + {recentTools.map((tool, index) => ( +
  • { + const fullPath = normalizePath(tool.path); + window.location.href = fullPath; // Full page reload + }} + > + {tool.label} +
  • + ))} +
+
+ ); +}; + +export default RecentlyAccessed; diff --git a/client/modules/dashboard/src/Dashboard/TicketList.tsx b/client/modules/dashboard/src/Dashboard/TicketList.tsx new file mode 100644 index 0000000000..c54b5f44a1 --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/TicketList.tsx @@ -0,0 +1,185 @@ +import React, { useEffect, useState } from 'react'; +import axios from 'axios'; +import styles from './Dashboard.module.css'; + +interface RawTicket { + id: number; + Subject?: string; + subject?: string; + Status?: string; + status?: string; + created_at?: string; + Created?: string; + updated_at?: string; + LastUpdated?: string; +} + +interface NormalizedTicket { + id: number; + subject: string; + status: string; + created_at: string; + updated_at?: string; +} + +export const TicketList: React.FC = () => { + const [tickets, setTickets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(''); + const [showResolved, setShowResolved] = useState(false); + + const normalizeTicket = (ticket: RawTicket): NormalizedTicket => ({ + id: ticket.id, + subject: ticket.subject || ticket.Subject || 'No Subject', + status: ticket.status || ticket.Status || 'unknown', + created_at: ticket.created_at || ticket.Created || '', + updated_at: ticket.updated_at || ticket.LastUpdated, + }); + + const fetchTickets = async () => { + setLoading(true); + setError(null); + + try { + const res = await axios.get('/help/tickets/', { + params: { + fmt: 'json', + show_resolved: true, + }, + }); + const normalized = res.data.map((ticket: RawTicket) => normalizeTicket(ticket)); + setTickets(normalized); + } catch (e) { + setError('Failed to load tickets.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchTickets(); + }, []); + + const formatDate = (input?: string) => { + if (!input) return 'N/A'; + const normalized = input.includes('T') ? input : input.replace(' ', 'T'); + const date = new Date(normalized); + return isNaN(date.getTime()) ? 'N/A' : date.toLocaleString(); + }; + + const isResolved = (status: string) => { + const s = status.toLowerCase().trim(); + return s === 'resolved' || s === 'closed'; + }; + + const filteredTickets = tickets.filter(ticket => { + const matchesFilter = + ticket.subject.toLowerCase().includes(filter.toLowerCase()) || + ticket.id.toString().includes(filter); + + return matchesFilter && (showResolved === isResolved(ticket.status)); + }); + + const handleClose = async (ticketId: number) => { + const confirmClose = window.confirm('Are you sure you want to close this ticket?'); + if (!confirmClose) return; + + try { + await axios.post(`/help/tickets/${ticketId}/close/`); + fetchTickets(); + } catch { + alert('Failed to close ticket.'); + } + }; + + return ( +
+
+

My Tickets

+ + + New Ticket + +
+ +
+ + + setFilter(e.target.value)} + /> +
+ + {loading ? ( +
+ +
+ ) : error ? ( +
{error}
+ ) : filteredTickets.length === 0 ? ( +
No tickets found.
+ ) : ( +
+ + + + + + + + + + + {filteredTickets.map(ticket => { + const isClosed = isResolved(ticket.status); + + return ( + + + + + + + ); + })} + +
StatusTicket ID / SubjectLast UpdatedActions
{ticket.status} + + {ticket.id} / {ticket.subject} + + {formatDate(ticket.updated_at ?? ticket.created_at)} +
+ + Reply + + {!isClosed && ( + + )} +
+
+
+ )} +
+ ); +}; diff --git a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx index 120208a4e0..57c7d52855 100644 --- a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx +++ b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx @@ -3,6 +3,25 @@ import { Menu, MenuProps } from 'antd'; import { NavLink } from 'react-router-dom'; import { TAppCategory, TPortalApp } from '@client/hooks'; import { useGetAppParams } from '../utils'; +//my code +interface RecentTool { + label: string; + path: string; +} + +const handleToolClick = (toolName: string, toolPath: string) => { + const correctedPath = toolPath.startsWith('/workspace/') + ? toolPath + : `/workspace/${toolPath.replace(/^\//, '')}`; + + const existing: { label: string; path: string }[] = JSON.parse(localStorage.getItem('recentTools') || '[]'); + const updated = [{ label: toolName, path: correctedPath }, ...existing.filter(t => t.path !== correctedPath)].slice(0, 5); + localStorage.setItem('recentTools', JSON.stringify(updated)); +}; + + +// my code + export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ categories, @@ -40,48 +59,63 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ if (bundles[bundleKey]) { bundles[bundleKey].apps.push( getItem( - - {app.shortLabel || app.label || app.bundle_label} - , + + handleToolClick( + app.shortLabel || app.label || app.bundle_label, + `/workspace/${app.app_id}${app.version ? `?appVersion=${app.version}` : ''}` + ) + } +> + {app.shortLabel || app.label || app.bundle_label} + + +, `${app.app_id}${app.version}${app.bundle_id}`, app.priority ) ); } else { - bundles[bundleKey] = { - apps: [ - getItem( - - {app.shortLabel || app.label || app.bundle_label} - , - `${app.app_id}${app.version}${app.bundle_id}`, - app.priority - ), - ], - label: app.bundle_label, - }; + bundles[bundleKey] = { + apps: [ + getItem( + + handleToolClick( + app.shortLabel || app.label || app.bundle_label, + `/workspace/${app.app_id}${app.version ? `?appVersion=${app.version}` : ''}` + ) + } +> + {app.shortLabel || app.label || app.bundle_label} + + +, + `${app.app_id}${app.version}${app.bundle_id}`, + app.priority + ) + ], + label: app.bundle_label, + }; } } else { categoryItems.push( getItem( - - {app.shortLabel || app.label || app.bundle_label} - , + + handleToolClick( + app.shortLabel || app.label || app.bundle_label, + `/workspace/${app.app_id}${app.version ? `?appVersion=${app.version}` : ''}` + ) + } +> + {app.shortLabel || app.label || app.bundle_label} + + +, `${app.app_id}${app.version}${app.bundle_id}`, app.priority ) diff --git a/client/package-lock.json b/client/package-lock.json index d3c3fb56c3..c432631fb6 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -21,6 +21,7 @@ "react-error-boundary": "^4.0.13", "react-hook-form": "^7.51.3", "react-hook-form-antd": "^1.1.0", + "react-icons": "^5.5.0", "react-router-dom": "^6.21.1", "react-use-websocket": "^4.8.1", "tslib": "^2.3.0", @@ -11974,6 +11975,15 @@ "react-hook-form": "^7" } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", diff --git a/client/package.json b/client/package.json index 6597c37856..0130dc9665 100644 --- a/client/package.json +++ b/client/package.json @@ -21,6 +21,7 @@ "react-error-boundary": "^4.0.13", "react-hook-form": "^7.51.3", "react-hook-form-antd": "^1.1.0", + "react-icons": "^5.5.0", "react-router-dom": "^6.21.1", "react-use-websocket": "^4.8.1", "tslib": "^2.3.0", diff --git a/client/src/main.tsx b/client/src/main.tsx index bb8e6b55e3..ee986d8c5b 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,7 +1,7 @@ import './styles.css'; import { StrictMode } from 'react'; import * as ReactDOM from 'react-dom/client'; -import { RouterProvider } from 'react-router-dom'; +import { BrowserRouter, RouterProvider } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import workspaceRouter from './workspace/workspaceRouter'; import datafilesRouter from './datafiles/datafilesRouter'; @@ -99,7 +99,9 @@ if (dashboardElement) { + + From 7a7c3b206a7c46dd77f6e4c1b5cea572ed87f75f Mon Sep 17 00:00:00 2001 From: vani-walvekar1494 Date: Tue, 17 Jun 2025 09:55:35 -0500 Subject: [PATCH 02/26] fix: format files to pass nx format check --- .../src/Dashboard/Dashboard.module.css | 15 +-- .../dashboard/src/Dashboard/Dashboard.tsx | 1 - .../src/Dashboard/QuickLinksNavbar.tsx | 8 +- .../src/Dashboard/RecentProjects.tsx | 14 ++- .../dashboard/src/Dashboard/TicketList.tsx | 22 ++-- .../workspace/src/AppsSideNav/AppsSideNav.tsx | 114 ++++++++++-------- client/src/main.tsx | 2 +- 7 files changed, 99 insertions(+), 77 deletions(-) diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.module.css b/client/modules/dashboard/src/Dashboard/Dashboard.module.css index 03407b3c0d..3027db0fec 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.module.css +++ b/client/modules/dashboard/src/Dashboard/Dashboard.module.css @@ -1,5 +1,5 @@ .sidebar { - background-color: #F5F7FA; + background-color: #f5f7fa; color: #333; padding: 1.5rem; border-radius: 8px; @@ -11,21 +11,21 @@ font-size: 1.55rem; font-weight: 600; margin-bottom: 1.2rem; - color: #2C3E50; + color: #2c3e50; border-bottom: 1px solid #ddd; padding-bottom: 0.4rem; } .sidebarLink { display: block; margin: 0.75rem 0; - color: #1F2D3D; - font-size: 1.50rem; + color: #1f2d3d; + font-size: 1.5rem; font-weight: 500; text-decoration: none; transition: all 0.2s ease; } .sidebarLink:hover { - color: #007BFF; + color: #007bff; text-decoration: underline; padding-left: 5px; } @@ -38,7 +38,6 @@ vertical-align: middle; } - .recentContainer { position: fixed; bottom: 20px; @@ -74,7 +73,6 @@ color: #0056b3; } - .recentProjectsContainer { position: absolute; top: 500px; @@ -83,7 +81,7 @@ padding: 0.75rem; border: 1px solid #ccc; border-radius: 10px; - box-shadow: 0 2px 8px rgba(0,0,0,0.06); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); background-color: #fff; font-size: 1.5rem; } @@ -132,7 +130,6 @@ color: #444; } - .ticketListContainer { position: fixed; bottom: 20px; diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.tsx b/client/modules/dashboard/src/Dashboard/Dashboard.tsx index e5242dc46f..bd4dfe30df 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.tsx +++ b/client/modules/dashboard/src/Dashboard/Dashboard.tsx @@ -10,7 +10,6 @@ export interface DashboardProps {} export function Dashboard(props: DashboardProps) { return (
-
diff --git a/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx b/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx index c68bb0f81f..ff2c67427d 100644 --- a/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx +++ b/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx @@ -1,10 +1,10 @@ import React from 'react'; import styles from './Dashboard.module.css'; // ✅ Match case with filename -import { MdManageAccounts, MdCastForEducation } from "react-icons/md"; -import { TbDatabaseShare } from "react-icons/tb"; -import { IoIosApps } from "react-icons/io"; -import { FaMapMarkedAlt } from "react-icons/fa"; +import { MdManageAccounts, MdCastForEducation } from 'react-icons/md'; +import { TbDatabaseShare } from 'react-icons/tb'; +import { IoIosApps } from 'react-icons/io'; +import { FaMapMarkedAlt } from 'react-icons/fa'; const QuickLinksNavbar = () => { return ( diff --git a/client/modules/dashboard/src/Dashboard/RecentProjects.tsx b/client/modules/dashboard/src/Dashboard/RecentProjects.tsx index c0d981d3b1..f0a5e51156 100644 --- a/client/modules/dashboard/src/Dashboard/RecentProjects.tsx +++ b/client/modules/dashboard/src/Dashboard/RecentProjects.tsx @@ -16,11 +16,15 @@ const RecentProjects: React.FC = () => { useEffect(() => { const fetchProjects = async () => { try { - const response = await axios.get('/api/projects/v2/?offset=0&limit=100'); + const response = await axios.get( + '/api/projects/v2/?offset=0&limit=100' + ); const rawProjects = response.data.result; const mapped: Project[] = rawProjects.map((proj: any) => { - const piUser = proj.value.users?.find((user: any) => user.role === 'pi'); + const piUser = proj.value.users?.find( + (user: any) => user.role === 'pi' + ); return { uuid: proj.uuid, title: proj.value.title, @@ -31,7 +35,11 @@ const RecentProjects: React.FC = () => { }); const sortedRecent = mapped - .sort((a, b) => new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()) + .sort( + (a, b) => + new Date(b.lastUpdated).getTime() - + new Date(a.lastUpdated).getTime() + ) .slice(0, 3); setProjects(sortedRecent); diff --git a/client/modules/dashboard/src/Dashboard/TicketList.tsx b/client/modules/dashboard/src/Dashboard/TicketList.tsx index c54b5f44a1..48bc308c9e 100644 --- a/client/modules/dashboard/src/Dashboard/TicketList.tsx +++ b/client/modules/dashboard/src/Dashboard/TicketList.tsx @@ -48,7 +48,9 @@ export const TicketList: React.FC = () => { show_resolved: true, }, }); - const normalized = res.data.map((ticket: RawTicket) => normalizeTicket(ticket)); + const normalized = res.data.map((ticket: RawTicket) => + normalizeTicket(ticket) + ); setTickets(normalized); } catch (e) { setError('Failed to load tickets.'); @@ -73,16 +75,18 @@ export const TicketList: React.FC = () => { return s === 'resolved' || s === 'closed'; }; - const filteredTickets = tickets.filter(ticket => { + const filteredTickets = tickets.filter((ticket) => { const matchesFilter = ticket.subject.toLowerCase().includes(filter.toLowerCase()) || ticket.id.toString().includes(filter); - return matchesFilter && (showResolved === isResolved(ticket.status)); + return matchesFilter && showResolved === isResolved(ticket.status); }); const handleClose = async (ticketId: number) => { - const confirmClose = window.confirm('Are you sure you want to close this ticket?'); + const confirmClose = window.confirm( + 'Are you sure you want to close this ticket?' + ); if (!confirmClose) return; try { @@ -105,7 +109,7 @@ export const TicketList: React.FC = () => {
@@ -116,7 +120,7 @@ export const TicketList: React.FC = () => { style={{ maxWidth: '200px' }} placeholder="Search tickets" value={filter} - onChange={e => setFilter(e.target.value)} + onChange={(e) => setFilter(e.target.value)} />
@@ -140,7 +144,7 @@ export const TicketList: React.FC = () => { - {filteredTickets.map(ticket => { + {filteredTickets.map((ticket) => { const isClosed = isResolved(ticket.status); return ( @@ -151,7 +155,9 @@ export const TicketList: React.FC = () => { {ticket.id} / {ticket.subject} - {formatDate(ticket.updated_at ?? ticket.created_at)} + + {formatDate(ticket.updated_at ?? ticket.created_at)} +
{ ? toolPath : `/workspace/${toolPath.replace(/^\//, '')}`; - const existing: { label: string; path: string }[] = JSON.parse(localStorage.getItem('recentTools') || '[]'); - const updated = [{ label: toolName, path: correctedPath }, ...existing.filter(t => t.path !== correctedPath)].slice(0, 5); + const existing: { label: string; path: string }[] = JSON.parse( + localStorage.getItem('recentTools') || '[]' + ); + const updated = [ + { label: toolName, path: correctedPath }, + ...existing.filter((t) => t.path !== correctedPath), + ].slice(0, 5); localStorage.setItem('recentTools', JSON.stringify(updated)); }; -// my code - export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ categories, @@ -59,63 +62,72 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ if (bundles[bundleKey]) { bundles[bundleKey].apps.push( getItem( - - handleToolClick( - app.shortLabel || app.label || app.bundle_label, - `/workspace/${app.app_id}${app.version ? `?appVersion=${app.version}` : ''}` - ) - } -> - {app.shortLabel || app.label || app.bundle_label} - + + handleToolClick( + app.shortLabel || app.label || app.bundle_label, + `/workspace/${app.app_id}${ + app.version ? `?appVersion=${app.version}` : '' + }` + ) + } + > + {app.shortLabel || app.label || app.bundle_label} + , -, `${app.app_id}${app.version}${app.bundle_id}`, app.priority ) ); } else { - bundles[bundleKey] = { - apps: [ - getItem( - - handleToolClick( - app.shortLabel || app.label || app.bundle_label, - `/workspace/${app.app_id}${app.version ? `?appVersion=${app.version}` : ''}` - ) - } -> - {app.shortLabel || app.label || app.bundle_label} - - -, - `${app.app_id}${app.version}${app.bundle_id}`, - app.priority - ) - ], - label: app.bundle_label, - }; + bundles[bundleKey] = { + apps: [ + getItem( + + handleToolClick( + app.shortLabel || app.label || app.bundle_label, + `/workspace/${app.app_id}${ + app.version ? `?appVersion=${app.version}` : '' + }` + ) + } + > + {app.shortLabel || app.label || app.bundle_label} + , + + `${app.app_id}${app.version}${app.bundle_id}`, + app.priority + ), + ], + label: app.bundle_label, + }; } } else { categoryItems.push( getItem( - - handleToolClick( - app.shortLabel || app.label || app.bundle_label, - `/workspace/${app.app_id}${app.version ? `?appVersion=${app.version}` : ''}` - ) - } -> - {app.shortLabel || app.label || app.bundle_label} - + + handleToolClick( + app.shortLabel || app.label || app.bundle_label, + `/workspace/${app.app_id}${ + app.version ? `?appVersion=${app.version}` : '' + }` + ) + } + > + {app.shortLabel || app.label || app.bundle_label} + , -, `${app.app_id}${app.version}${app.bundle_id}`, app.priority ) diff --git a/client/src/main.tsx b/client/src/main.tsx index ee986d8c5b..e3746ffcc3 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -100,7 +100,7 @@ if (dashboardElement) { - + From 6292b87954145b42c0aefc898104cea6071fe30b Mon Sep 17 00:00:00 2001 From: vani-walvekar1494 Date: Tue, 17 Jun 2025 11:58:20 -0500 Subject: [PATCH 03/26] Fix: resolved merge conflicts and updated dashboard styles --- client/modules/dashboard/src/Dashboard/RecentProjects.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/modules/dashboard/src/Dashboard/RecentProjects.tsx b/client/modules/dashboard/src/Dashboard/RecentProjects.tsx index f0a5e51156..a553a1b78a 100644 --- a/client/modules/dashboard/src/Dashboard/RecentProjects.tsx +++ b/client/modules/dashboard/src/Dashboard/RecentProjects.tsx @@ -44,7 +44,7 @@ const RecentProjects: React.FC = () => { setProjects(sortedRecent); } catch (error) { - console.error('Failed to fetch recent projects:', error); + console.error('Failed to fetch recent projects!', error); } }; From 35f37add60fca55006b54f0081c98683040060ef Mon Sep 17 00:00:00 2001 From: vani-walvekar1494 Date: Tue, 17 Jun 2025 13:33:19 -0500 Subject: [PATCH 04/26] chore: format AppsSideNav to pass format:check --- client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx index ec2fe619a3..e1aba92c47 100644 --- a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx +++ b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx @@ -24,8 +24,6 @@ const handleToolClick = (toolName: string, toolPath: string) => { localStorage.setItem('recentTools', JSON.stringify(updated)); }; - - export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ categories, }) => { From 3ccaa74f6561859197eb7a1ae07aa80b914314e0 Mon Sep 17 00:00:00 2001 From: vani-walvekar1494 Date: Tue, 17 Jun 2025 13:49:16 -0500 Subject: [PATCH 05/26] Fix: Replace 'any' with proper types in RecentProjects to satisfy lint rules --- .../dashboard/src/Dashboard/Dashboard.tsx | 4 +-- .../src/Dashboard/RecentProjects.tsx | 28 +++++++++++++++---- .../workspace/src/AppsSideNav/AppsSideNav.tsx | 4 --- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.tsx b/client/modules/dashboard/src/Dashboard/Dashboard.tsx index bd4dfe30df..4bcef4c496 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.tsx +++ b/client/modules/dashboard/src/Dashboard/Dashboard.tsx @@ -1,5 +1,5 @@ -import styles from './Dashboard.module.css'; -import QuickLinksNavbar from './QuickLinksNavbar'; // ✅ Default import +//import styles from './Dashboard.module.css'; +import QuickLinksNavbar from './QuickLinksNavbar'; import RecentlyAccessed from './RecentlyAccessed'; import RecentProjects from './RecentProjects'; import { TicketList } from './TicketList'; diff --git a/client/modules/dashboard/src/Dashboard/RecentProjects.tsx b/client/modules/dashboard/src/Dashboard/RecentProjects.tsx index a553a1b78a..4b00490b74 100644 --- a/client/modules/dashboard/src/Dashboard/RecentProjects.tsx +++ b/client/modules/dashboard/src/Dashboard/RecentProjects.tsx @@ -10,21 +10,37 @@ type Project = { pi: string; }; +// Define the structure of the API response +interface RawUser { + role: string; + fname: string; + lname: string; +} + +interface RawProject { + uuid: string; + lastUpdated: string; + value: { + title: string; + projectId: string; + users?: RawUser[]; + }; +} + const RecentProjects: React.FC = () => { const [projects, setProjects] = useState([]); useEffect(() => { const fetchProjects = async () => { try { - const response = await axios.get( - '/api/projects/v2/?offset=0&limit=100' - ); - const rawProjects = response.data.result; + const response = await axios.get('/api/projects/v2/?offset=0&limit=100'); + const rawProjects: RawProject[] = response.data.result; - const mapped: Project[] = rawProjects.map((proj: any) => { + const mapped: Project[] = rawProjects.map((proj: RawProject) => { const piUser = proj.value.users?.find( - (user: any) => user.role === 'pi' + (user) => user.role === 'pi' ); + return { uuid: proj.uuid, title: proj.value.title, diff --git a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx index e1aba92c47..d56ff9daa2 100644 --- a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx +++ b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx @@ -4,10 +4,6 @@ import { NavLink } from 'react-router-dom'; import { TAppCategory, TPortalApp } from '@client/hooks'; import { useGetAppParams } from '../utils'; -interface RecentTool { - label: string; - path: string; -} const handleToolClick = (toolName: string, toolPath: string) => { const correctedPath = toolPath.startsWith('/workspace/') From 9a6aa1b92a32cc38cdae82cd7d193fcd7ed382a2 Mon Sep 17 00:00:00 2001 From: vani-walvekar1494 Date: Tue, 17 Jun 2025 13:52:12 -0500 Subject: [PATCH 06/26] chore: format files to pass nx format check --- client/modules/dashboard/src/Dashboard/Dashboard.tsx | 2 +- client/modules/dashboard/src/Dashboard/RecentProjects.tsx | 8 ++++---- client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.tsx b/client/modules/dashboard/src/Dashboard/Dashboard.tsx index 4bcef4c496..488f4fd0c0 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.tsx +++ b/client/modules/dashboard/src/Dashboard/Dashboard.tsx @@ -1,5 +1,5 @@ //import styles from './Dashboard.module.css'; -import QuickLinksNavbar from './QuickLinksNavbar'; +import QuickLinksNavbar from './QuickLinksNavbar'; import RecentlyAccessed from './RecentlyAccessed'; import RecentProjects from './RecentProjects'; import { TicketList } from './TicketList'; diff --git a/client/modules/dashboard/src/Dashboard/RecentProjects.tsx b/client/modules/dashboard/src/Dashboard/RecentProjects.tsx index 4b00490b74..a7177494ed 100644 --- a/client/modules/dashboard/src/Dashboard/RecentProjects.tsx +++ b/client/modules/dashboard/src/Dashboard/RecentProjects.tsx @@ -33,13 +33,13 @@ const RecentProjects: React.FC = () => { useEffect(() => { const fetchProjects = async () => { try { - const response = await axios.get('/api/projects/v2/?offset=0&limit=100'); + const response = await axios.get( + '/api/projects/v2/?offset=0&limit=100' + ); const rawProjects: RawProject[] = response.data.result; const mapped: Project[] = rawProjects.map((proj: RawProject) => { - const piUser = proj.value.users?.find( - (user) => user.role === 'pi' - ); + const piUser = proj.value.users?.find((user) => user.role === 'pi'); return { uuid: proj.uuid, diff --git a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx index d56ff9daa2..11e25ac9c4 100644 --- a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx +++ b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx @@ -4,7 +4,6 @@ import { NavLink } from 'react-router-dom'; import { TAppCategory, TPortalApp } from '@client/hooks'; import { useGetAppParams } from '../utils'; - const handleToolClick = (toolName: string, toolPath: string) => { const correctedPath = toolPath.startsWith('/workspace/') ? toolPath From 2fb24415cf93ccc7a4b09624557b8dd9543c1f33 Mon Sep 17 00:00:00 2001 From: vani-walvekar1494 Date: Mon, 23 Jun 2025 15:09:07 -0500 Subject: [PATCH 07/26] wip: partial DB connection setup and fix for recent tools issue --- .../src/Dashboard/Dashboard.module.css | 54 +++++++ .../dashboard/src/Dashboard/Dashboard.tsx | 6 +- .../dashboard/src/Dashboard/FavoriteTools.tsx | 148 ++++++++++++++++++ .../src/Dashboard/RecentlyAccessed.tsx | 9 +- .../dashboard/src/api/favouritesApi.ts | 18 +++ .../workspace/src/AppsSideNav/AppsSideNav.tsx | 69 ++++---- .../datafiles/migrations/0002_userfavorite.py | 24 +++ ..._added_on_userfavorite_tool_id_and_more.py | 36 +++++ designsafe/apps/api/datafiles/models.py | 12 +- designsafe/apps/api/datafiles/urls.py | 23 ++- designsafe/apps/api/datafiles/views.py | 51 ++++++ ...remove_filemetamodel_unique_system_path.py | 17 ++ ...nalicense_user_alter_matlablicense_user.py | 26 +++ designsafe/urls.py | 4 + 14 files changed, 439 insertions(+), 58 deletions(-) create mode 100644 client/modules/dashboard/src/Dashboard/FavoriteTools.tsx create mode 100644 client/modules/dashboard/src/api/favouritesApi.ts create mode 100644 designsafe/apps/api/datafiles/migrations/0002_userfavorite.py create mode 100644 designsafe/apps/api/datafiles/migrations/0003_userfavorite_added_on_userfavorite_tool_id_and_more.py create mode 100644 designsafe/apps/api/filemeta/migrations/0003_remove_filemetamodel_unique_system_path.py create mode 100644 designsafe/apps/licenses/migrations/0006_alter_lsdynalicense_user_alter_matlablicense_user.py diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.module.css b/client/modules/dashboard/src/Dashboard/Dashboard.module.css index 3027db0fec..75ce016c08 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.module.css +++ b/client/modules/dashboard/src/Dashboard/Dashboard.module.css @@ -166,3 +166,57 @@ overflow-y: auto; flex-grow: 1; } + + +/* Dashboard.module.css */ + +.favoriteToggle { + position: fixed; + top: 20px; + right: 20px; + background: transparent; + border: none; + font-size: 28px; + color: gold; + cursor: pointer; + z-index: 999; +} + +.favoritePanel { + position: fixed; + top: 60px; + right: 20px; + background: white; + border: 1px solid #ccc; + padding: 12px; + width: 250px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + z-index: 1000; + border-radius: 8px; +} + +.favoriteList { + list-style: none; + padding: 0; + margin: 0; +} + +.favoriteItem { + display: flex; + justify-content: space-between; + align-items: center; + margin: 6px 0; +} + +.starIcon { + font-size: 18px; + color: gold; + cursor: pointer; + transition: transform 0.2s ease; +} + +.starIcon:hover { + transform: scale(1.2); + color: darkorange; +} + diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.tsx b/client/modules/dashboard/src/Dashboard/Dashboard.tsx index 488f4fd0c0..4683ea4758 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.tsx +++ b/client/modules/dashboard/src/Dashboard/Dashboard.tsx @@ -1,8 +1,9 @@ //import styles from './Dashboard.module.css'; -import QuickLinksNavbar from './QuickLinksNavbar'; +import Quicklinks from './QuickLinksNavbar'; import RecentlyAccessed from './RecentlyAccessed'; import RecentProjects from './RecentProjects'; import { TicketList } from './TicketList'; +import FavoriteTools from './FavoriteTools'; /* eslint-disable-next-line */ export interface DashboardProps {} @@ -10,10 +11,11 @@ export interface DashboardProps {} export function Dashboard(props: DashboardProps) { return (
- +
+
diff --git a/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx b/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx new file mode 100644 index 0000000000..3b105c2a8f --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useState } from 'react'; +import { getUserFavorites, removeFavorite } from '../api/favouritesApi'; +import { useAppsListing } from '@client/hooks'; +import styles from './Dashboard.module.css'; + +// Define your favorite tool shape (minimal) +interface FavoriteTool { + id: string; + version?: string; +} + +// Define TPortalApp based on what the API returns +// Adjust fields as per your actual API shape +interface TPortalApp { + app_id: string; + version: string; + id?: string; + label?: string; + // ...other fields your API returns +} + +// Shape expected by your UI logic with a definition property +interface AppData { + app_id: string; + version: string; + definition: { + id: string; + notes?: { + label?: string; + }; + }; +} + +const FavoriteTools = () => { + const [favorites, setFavorites] = useState([]); + const [showPanel, setShowPanel] = useState(false); + + // Fetch the full apps list from hook + const { data, isLoading, isError } = useAppsListing(); + + // Convert TPortalApp[] to AppData[] by adding definition property dynamically +const allApps: AppData[] = data?.categories?.flatMap(cat => + cat.apps.map(app => ({ + app_id: app.app_id, + version: app.version || '', // fallback if undefined + definition: { + id: app.app_id, // Use app_id here because 'id' doesn't exist + notes: { + label: app.label, + }, + }, + })) +) ?? []; + + + + // Map user favorites to resolved app info for display + const resolvedFavorites = favorites + .map((fav) => { + const matchedApp = allApps.find( + (app) => + app.app_id === fav.id && (!fav.version || app.version === fav.version) + ); + + if (!matchedApp) return null; + + const label = + matchedApp.definition?.notes?.label || matchedApp.definition?.id; + const href = `/apps/${matchedApp.definition.id}`; + + return { + id: fav.id, + label, + href, + }; + }) + .filter(Boolean) as { id: string; label: string; href: string }[]; + + // Load favorites on mount + useEffect(() => { + async function fetchFavorites() { + try { + const data = await getUserFavorites(); + setFavorites(data); + } catch (err) { + console.error('Failed to load favorites:', err); + } + } + fetchFavorites(); + }, []); + + // Remove favorite handler + const handleRemove = async (toolId: string) => { + try { + await removeFavorite(toolId); + setFavorites((prev) => prev.filter((tool) => tool.id !== toolId)); + } catch (err) { + console.error('Failed to remove favorite:', err); + } + }; + + if (isLoading) return
Loading favorite tools...
; + if (isError) return
Failed to load apps data.
; + + return ( + <> + {/* Floating star icon */} + + + {/* Favorite tools panel */} + {showPanel && ( +
+ )} + + ); +}; + +export default FavoriteTools; diff --git a/client/modules/dashboard/src/Dashboard/RecentlyAccessed.tsx b/client/modules/dashboard/src/Dashboard/RecentlyAccessed.tsx index f43d25ccd4..860d3182f4 100644 --- a/client/modules/dashboard/src/Dashboard/RecentlyAccessed.tsx +++ b/client/modules/dashboard/src/Dashboard/RecentlyAccessed.tsx @@ -22,12 +22,6 @@ const RecentlyAccessed: React.FC = () => { } }, []); - const normalizePath = (path: string) => { - return path.startsWith('/workspace/') - ? path - : `/workspace/${path.replace(/^\//, '')}`; - }; - if (recentTools.length === 0) return null; return ( @@ -39,8 +33,7 @@ const RecentlyAccessed: React.FC = () => { key={index} className={styles.recentItem} onClick={() => { - const fullPath = normalizePath(tool.path); - window.location.href = fullPath; // Full page reload + window.location.href = tool.path; }} > {tool.label} diff --git a/client/modules/dashboard/src/api/favouritesApi.ts b/client/modules/dashboard/src/api/favouritesApi.ts new file mode 100644 index 0000000000..3cd21c23cd --- /dev/null +++ b/client/modules/dashboard/src/api/favouritesApi.ts @@ -0,0 +1,18 @@ +// client/src/api/favouritesApi.ts + +import axios from 'axios'; + +export const getUserFavorites = async () => { + const response = await axios.get('/api/datafiles/favorites/'); + return response.data; +}; + +export const addFavorite = async (toolId: string) => { + const response = await axios.post('/api/datafiles/favorites/add/', { tool_id: toolId }); + return response.data; +}; + +export const removeFavorite = async (toolId: string) => { + const response = await axios.post('/api/datafiles/favorites/remove/', { tool_id: toolId }); + return response.data; +}; diff --git a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx index 11e25ac9c4..527771d484 100644 --- a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx +++ b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx @@ -4,18 +4,24 @@ import { NavLink } from 'react-router-dom'; import { TAppCategory, TPortalApp } from '@client/hooks'; import { useGetAppParams } from '../utils'; +// Add your recent tools feature here: const handleToolClick = (toolName: string, toolPath: string) => { + // Add /workspace/ prefix only if missing const correctedPath = toolPath.startsWith('/workspace/') ? toolPath : `/workspace/${toolPath.replace(/^\//, '')}`; + // Get existing recent tools from localStorage const existing: { label: string; path: string }[] = JSON.parse( localStorage.getItem('recentTools') || '[]' ); + + // Add new tool at front, remove duplicates, keep max 5 const updated = [ { label: toolName, path: correctedPath }, ...existing.filter((t) => t.path !== correctedPath), ].slice(0, 5); + localStorage.setItem('recentTools', JSON.stringify(updated)); }; @@ -50,28 +56,22 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ const categoryItems: MenuItem[] = []; category.apps.forEach((app) => { + // Construct NavLink 'to' path as original (no /workspace prefix) + const linkPath = `${app.app_id}${app.version ? `?appVersion=${app.version}` : ''}`; + const linkLabel = app.shortLabel || app.label || app.bundle_label; + if (app.is_bundled) { const bundleKey = `${app.bundle_label}${app.bundle_id}`; if (bundles[bundleKey]) { bundles[bundleKey].apps.push( getItem( - handleToolClick( - app.shortLabel || app.label || app.bundle_label, - `/workspace/${app.app_id}${ - app.version ? `?appVersion=${app.version}` : '' - }` - ) - } + to={linkPath} + onClick={() => handleToolClick(linkLabel, linkPath)} > - {app.shortLabel || app.label || app.bundle_label} + {linkLabel} , - - `${app.app_id}${app.version}${app.bundle_id}`, + `${app.app_id}${app.version || ''}${app.bundle_id}`, app.priority ) ); @@ -80,22 +80,12 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ apps: [ getItem( - handleToolClick( - app.shortLabel || app.label || app.bundle_label, - `/workspace/${app.app_id}${ - app.version ? `?appVersion=${app.version}` : '' - }` - ) - } + to={linkPath} + onClick={() => handleToolClick(linkLabel, linkPath)} > - {app.shortLabel || app.label || app.bundle_label} + {linkLabel} , - - `${app.app_id}${app.version}${app.bundle_id}`, + `${app.app_id}${app.version || ''}${app.bundle_id}`, app.priority ), ], @@ -106,27 +96,18 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ categoryItems.push( getItem( - handleToolClick( - app.shortLabel || app.label || app.bundle_label, - `/workspace/${app.app_id}${ - app.version ? `?appVersion=${app.version}` : '' - }` - ) - } + to={linkPath} + onClick={() => handleToolClick(linkLabel, linkPath)} > - {app.shortLabel || app.label || app.bundle_label} + {linkLabel} , - - `${app.app_id}${app.version}${app.bundle_id}`, + `${app.app_id}${app.version || ''}${app.bundle_id}`, app.priority ) ); } }); + const bundleItems = Object.entries(bundles).map( ([bundleKey, bundle], index) => getItem( @@ -157,12 +138,15 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ .map((cat) => cat.apps) .flat() .find((app) => app.app_id === appId && app.version === (appVersion || '')); + const currentCategory = categories.find((cat) => cat.apps.includes(currentApp as TPortalApp) ); + const currentSubMenu = currentApp?.is_bundled ? `${currentApp.bundle_label}${currentApp.bundle_id}` : ''; + const selectedKey = `${appId}${appVersion || ''}${currentApp?.bundle_id}`; return ( @@ -187,6 +171,7 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ selectedKeys={[selectedKey]} items={items} inlineIndent={10} + style={{ height: '100%' }} /> ); diff --git a/designsafe/apps/api/datafiles/migrations/0002_userfavorite.py b/designsafe/apps/api/datafiles/migrations/0002_userfavorite.py new file mode 100644 index 0000000000..726b114231 --- /dev/null +++ b/designsafe/apps/api/datafiles/migrations/0002_userfavorite.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.15 on 2025-06-19 04:34 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('datafiles', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='UserFavorite', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file_path', models.TextField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/designsafe/apps/api/datafiles/migrations/0003_userfavorite_added_on_userfavorite_tool_id_and_more.py b/designsafe/apps/api/datafiles/migrations/0003_userfavorite_added_on_userfavorite_tool_id_and_more.py new file mode 100644 index 0000000000..406646090f --- /dev/null +++ b/designsafe/apps/api/datafiles/migrations/0003_userfavorite_added_on_userfavorite_tool_id_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.15 on 2025-06-19 15:38 + +import datetime +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('datafiles', '0002_userfavorite'), + ] + + operations = [ + migrations.AddField( + model_name='userfavorite', + name='added_on', + field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2025, 6, 19, 15, 35, 58, 410115)), + preserve_default=False, + ), + migrations.AddField( + model_name='userfavorite', + name='tool_id', + field=models.CharField(default='unknown-tool', max_length=100), + preserve_default=False, + ), + migrations.AlterUniqueTogether( + name='userfavorite', + unique_together={('user', 'tool_id')}, + ), + migrations.RemoveField( + model_name='userfavorite', + name='file_path', + ), + ] diff --git a/designsafe/apps/api/datafiles/models.py b/designsafe/apps/api/datafiles/models.py index 460fbccb1a..5ea2f03798 100644 --- a/designsafe/apps/api/datafiles/models.py +++ b/designsafe/apps/api/datafiles/models.py @@ -1,5 +1,6 @@ from django.conf import settings from django.db import models +from django.contrib.auth.models import User class DataFilesSurveyResult(models.Model): project_id = models.CharField(max_length=255) @@ -10,4 +11,13 @@ class DataFilesSurveyResult(models.Model): created = models.DateTimeField(auto_now_add=True) class DataFilesSurveyCounter(models.Model): - count = models.IntegerField() \ No newline at end of file + count = models.IntegerField() + + +class UserFavorite(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + tool_id = models.CharField(max_length=100) # or use UUIDField if tool IDs are UUIDs + added_on = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('user', 'tool_id') # Prevent duplicates \ No newline at end of file diff --git a/designsafe/apps/api/datafiles/urls.py b/designsafe/apps/api/datafiles/urls.py index 68d85d93c5..0677030df9 100644 --- a/designsafe/apps/api/datafiles/urls.py +++ b/designsafe/apps/api/datafiles/urls.py @@ -1,14 +1,27 @@ -from django.urls import re_path as url -from designsafe.apps.api.datafiles.views import DataFilesView, TransferFilesView, MicrosurveyView +from django.urls import re_path as url, path +from designsafe.apps.api.datafiles.views import ( + DataFilesView, + TransferFilesView, + MicrosurveyView, + UserFavoriteList, + AddFavoriteTool, + RemoveFavoriteTool, +) urlpatterns = [ url(r'^transfer/(?P[\w.-]+)/$', TransferFilesView.as_view(), name='file_transfer'), + # Browsing: - # - # GET /listing//// url(r'^(?P[\w.-]+)/(?P[\w.-]+)/(?P[\w.-]+)/(?P[\w.-]+)/(?P[ \S]+)/$', DataFilesView.as_view(), name='agave_files'), + url(r'^(?P[\w.-]+)/(?P[\w.-]+)/(?P[\w.-]+)/(?P[\w.-]+)/$', DataFilesView.as_view(), name='agave_files'), - url(r'^microsurvey/$', MicrosurveyView.as_view(), name='microsurvey') + + url(r'^microsurvey/$', MicrosurveyView.as_view(), name='microsurvey'), + + # Favorites (added using modern path, compatible with url) + path('favorites/', UserFavoriteList.as_view(), name='favorites-list'), + path('favorites/add/', AddFavoriteTool.as_view(), name='add-favorite'), + path('favorites/remove/', RemoveFavoriteTool.as_view(), name='remove-favorite'), ] diff --git a/designsafe/apps/api/datafiles/views.py b/designsafe/apps/api/datafiles/views.py index 90b90e8914..d5a6fcd671 100644 --- a/designsafe/apps/api/datafiles/views.py +++ b/designsafe/apps/api/datafiles/views.py @@ -16,6 +16,15 @@ from designsafe.apps.api.agave import service_account from designsafe.apps.api.utils import get_client_ip +import json +from django.views import View +from django.http import JsonResponse, HttpResponseBadRequest +from django.contrib.auth.mixins import LoginRequiredMixin +from .models import UserFavorite + + + + logger = logging.getLogger(__name__) metrics = logging.getLogger('metrics') @@ -204,3 +213,45 @@ def put(self, request): counter.count += 1 counter.save() return JsonResponse({'show': (counter.count % 7 == 0)}) + +class UserFavoriteList(LoginRequiredMixin, View): + def get(self, request): + favorites = UserFavorite.objects.filter(user=request.user) + data = [{ + 'id': fav.id, + 'tool_id': fav.tool_id, + 'added_on': fav.added_on.isoformat() + } for fav in favorites] + return JsonResponse(data, safe=False) + +class AddFavoriteTool(LoginRequiredMixin, View): + def post(self, request): + try: + data = json.loads(request.body) + tool_id = data.get('tool_id') + if not tool_id: + return HttpResponseBadRequest("Missing tool_id") + favorite, created = UserFavorite.objects.get_or_create(user=request.user, tool_id=tool_id) + return JsonResponse({'success': True, 'created': created}) + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) + +class RemoveFavoriteTool(LoginRequiredMixin, View): + def post(self, request): + try: + data = json.loads(request.body) + tool_id = data.get('tool_id') + if not tool_id: + return HttpResponseBadRequest("Missing tool_id") + UserFavorite.objects.filter(user=request.user, tool_id=tool_id).delete() + return JsonResponse({'success': True}) + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) + + + + + + + + diff --git a/designsafe/apps/api/filemeta/migrations/0003_remove_filemetamodel_unique_system_path.py b/designsafe/apps/api/filemeta/migrations/0003_remove_filemetamodel_unique_system_path.py new file mode 100644 index 0000000000..b50a8e094f --- /dev/null +++ b/designsafe/apps/api/filemeta/migrations/0003_remove_filemetamodel_unique_system_path.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.15 on 2025-06-23 15:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('filemeta_api', '0002_auto_20240418_1650'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='filemetamodel', + name='unique_system_path', + ), + ] diff --git a/designsafe/apps/licenses/migrations/0006_alter_lsdynalicense_user_alter_matlablicense_user.py b/designsafe/apps/licenses/migrations/0006_alter_lsdynalicense_user_alter_matlablicense_user.py new file mode 100644 index 0000000000..b25e66cddb --- /dev/null +++ b/designsafe/apps/licenses/migrations/0006_alter_lsdynalicense_user_alter_matlablicense_user.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.15 on 2025-06-23 15:09 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('designsafe_licenses', '0005_auto_20200423_1957'), + ] + + operations = [ + migrations.AlterField( + model_name='lsdynalicense', + name='user', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='matlablicense', + name='user', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/designsafe/urls.py b/designsafe/urls.py index 8205038581..98756d2a5f 100644 --- a/designsafe/urls.py +++ b/designsafe/urls.py @@ -88,6 +88,10 @@ name="django.contrib.sitemaps.views.sitemap", ), + #URL for Favourites + path('api/datafiles/', include('designsafe.apps.api.datafiles.urls')), + + # terms-and-conditions url(r'^terms/', include('termsandconditions.urls')), From c439559767dad76ec99befc2e3bcc4e78951f9d2 Mon Sep 17 00:00:00 2001 From: vani-walvekar1494 Date: Mon, 23 Jun 2025 15:12:58 -0500 Subject: [PATCH 08/26] wip: partial DB connection setup and fix for recent tools issue --- .../src/Dashboard/QuickLinksNavbar.tsx | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx b/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx index ff2c67427d..ed60a038a7 100644 --- a/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx +++ b/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx @@ -1,37 +1,25 @@ import React from 'react'; -import styles from './Dashboard.module.css'; // ✅ Match case with filename - -import { MdManageAccounts, MdCastForEducation } from 'react-icons/md'; -import { TbDatabaseShare } from 'react-icons/tb'; -import { IoIosApps } from 'react-icons/io'; -import { FaMapMarkedAlt } from 'react-icons/fa'; - -const QuickLinksNavbar = () => { +import styles from './Dashboard.module.css'; +const Quicklinks = () => { return ( ); }; - -export default QuickLinksNavbar; +export default Quicklinks; \ No newline at end of file From 89f43e8a6240ec6d7e6fe30b82763fcb1889dbd9 Mon Sep 17 00:00:00 2001 From: vani-walvekar1494 Date: Tue, 24 Jun 2025 16:48:40 -0500 Subject: [PATCH 09/26] Apply local changes after merging feat/react-dashboard --- .../dashboard/src/Dashboard/FavoriteTools.tsx | 69 ++--- .../dashboard/src/api/favouritesApi.ts | 70 ++++- .../workspace/src/AppsSideNav/AppsSideNav.tsx | 246 +++++++++++------- ...nalicense_user_alter_matlablicense_user.py | 26 -- 4 files changed, 242 insertions(+), 169 deletions(-) delete mode 100644 designsafe/apps/licenses/migrations/0006_alter_lsdynalicense_user_alter_matlablicense_user.py diff --git a/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx b/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx index 3b105c2a8f..a0798502c7 100644 --- a/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx +++ b/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx @@ -3,23 +3,18 @@ import { getUserFavorites, removeFavorite } from '../api/favouritesApi'; import { useAppsListing } from '@client/hooks'; import styles from './Dashboard.module.css'; -// Define your favorite tool shape (minimal) interface FavoriteTool { - id: string; + tool_id: string; version?: string; } -// Define TPortalApp based on what the API returns -// Adjust fields as per your actual API shape interface TPortalApp { app_id: string; version: string; id?: string; label?: string; - // ...other fields your API returns } -// Shape expected by your UI logic with a definition property interface AppData { app_id: string; version: string; @@ -34,77 +29,82 @@ interface AppData { const FavoriteTools = () => { const [favorites, setFavorites] = useState([]); const [showPanel, setShowPanel] = useState(false); + const [isLoadingFavorites, setIsLoadingFavorites] = useState(true); + const [favoritesError, setFavoritesError] = useState(null); - // Fetch the full apps list from hook const { data, isLoading, isError } = useAppsListing(); - // Convert TPortalApp[] to AppData[] by adding definition property dynamically -const allApps: AppData[] = data?.categories?.flatMap(cat => - cat.apps.map(app => ({ - app_id: app.app_id, - version: app.version || '', // fallback if undefined - definition: { - id: app.app_id, // Use app_id here because 'id' doesn't exist - notes: { - label: app.label, + const allApps: AppData[] = data?.categories?.flatMap((cat) => + cat.apps.map((app) => ({ + app_id: app.app_id, + version: app.version || '', + definition: { + id: app.app_id, + notes: { + label: app.label, + }, }, - }, - })) -) ?? []; + })) + ) ?? []; - - - // Map user favorites to resolved app info for display const resolvedFavorites = favorites .map((fav) => { const matchedApp = allApps.find( (app) => - app.app_id === fav.id && (!fav.version || app.version === fav.version) + app.app_id === fav.tool_id && + (!fav.version || app.version === fav.version) ); if (!matchedApp) return null; const label = - matchedApp.definition?.notes?.label || matchedApp.definition?.id; + matchedApp.definition?.notes?.label || matchedApp.definition.id; const href = `/apps/${matchedApp.definition.id}`; return { - id: fav.id, + id: fav.tool_id, label, href, }; }) .filter(Boolean) as { id: string; label: string; href: string }[]; - // Load favorites on mount useEffect(() => { async function fetchFavorites() { + setIsLoadingFavorites(true); + setFavoritesError(null); try { const data = await getUserFavorites(); setFavorites(data); } catch (err) { console.error('Failed to load favorites:', err); + setFavoritesError('Failed to load favorites.'); + } finally { + setIsLoadingFavorites(false); } } fetchFavorites(); }, []); - // Remove favorite handler const handleRemove = async (toolId: string) => { try { await removeFavorite(toolId); - setFavorites((prev) => prev.filter((tool) => tool.id !== toolId)); + setFavorites((prev) => + prev.filter((tool) => tool.tool_id !== toolId) + ); } catch (err) { console.error('Failed to remove favorite:', err); } }; - if (isLoading) return
Loading favorite tools...
; + if (isLoadingFavorites) return
Loading favorite tools...
; + if (favoritesError) return
{favoritesError}
; + + if (isLoading) return
Loading apps data...
; if (isError) return
Failed to load apps data.
; return ( <> - {/* Floating star icon */} - {/* Favorite tools panel */} {showPanel && (

Your Favorite Tools

@@ -130,8 +129,16 @@ const allApps: AppData[] = data?.categories?.flatMap(cat => handleRemove(tool.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleRemove(tool.id); + } + }} title={`Remove ${tool.label}`} + aria-label={`Remove ${tool.label} from favorites`} > ★ diff --git a/client/modules/dashboard/src/api/favouritesApi.ts b/client/modules/dashboard/src/api/favouritesApi.ts index 3cd21c23cd..b07c86351b 100644 --- a/client/modules/dashboard/src/api/favouritesApi.ts +++ b/client/modules/dashboard/src/api/favouritesApi.ts @@ -1,18 +1,66 @@ -// client/src/api/favouritesApi.ts - import axios from 'axios'; -export const getUserFavorites = async () => { - const response = await axios.get('/api/datafiles/favorites/'); - return response.data; +const getCSRFToken = (): string => { + const match = document.cookie.match(/(^| )csrftoken=([^;]+)/); + return match ? match[2] : ''; +}; + +const axiosInstance = axios.create({ + withCredentials: true, + headers: { + 'Content-Type': 'application/json', + }, +}); + +export interface FavoriteTool { + tool_id: string; + version?: string; +} + +export const getUserFavorites = async (): Promise => { + try { + const response = await axiosInstance.get('/api/datafiles/favorites/'); + return response.data; + } catch (error) { + console.error('Error fetching favorites:', error); + return []; + } }; -export const addFavorite = async (toolId: string) => { - const response = await axios.post('/api/datafiles/favorites/add/', { tool_id: toolId }); - return response.data; +export const addFavorite = async (toolId: string): Promise => { + try { + const csrfToken = getCSRFToken(); + await axiosInstance.post( + '/api/datafiles/favorites/add/', + { tool_id: toolId }, + { + headers: { + 'X-CSRFToken': csrfToken, + }, + } + ); + return true; + } catch (error) { + console.error(`Error adding favorite (${toolId}):`, error); + return false; + } }; -export const removeFavorite = async (toolId: string) => { - const response = await axios.post('/api/datafiles/favorites/remove/', { tool_id: toolId }); - return response.data; +export const removeFavorite = async (toolId: string): Promise => { + try { + const csrfToken = getCSRFToken(); + await axiosInstance.post( + '/api/datafiles/favorites/remove/', + { tool_id: toolId }, + { + headers: { + 'X-CSRFToken': csrfToken, + }, + } + ); + return true; + } catch (error) { + console.error(`Error removing favorite (${toolId}):`, error); + return false; + } }; diff --git a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx index 7fcff93953..cec058fc29 100644 --- a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx +++ b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx @@ -1,142 +1,167 @@ -import React from 'react'; -import { Menu, MenuProps } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { Menu, MenuProps, Switch } from 'antd'; import { NavLink } from 'react-router-dom'; import { TAppCategory, TPortalApp } from '@client/hooks'; import { useGetAppParams } from '../utils'; +import { + getUserFavorites, + addFavorite, + removeFavorite, +} from '../../../dashboard/src/api/favouritesApi'; + +export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ categories }) => { + const [favoriteToolIds, setFavoriteToolIds] = useState([]); + const [loadingFavorites, setLoadingFavorites] = useState(true); + const [updatingToolIds, setUpdatingToolIds] = useState>(new Set()); + + useEffect(() => { + const fetchFavorites = async () => { + try { + const favs = await getUserFavorites(); + const toolIds = (favs || []).map((fav: { tool_id: string; version?: string }) => + fav.tool_id + (fav.version || '') + ); + setFavoriteToolIds(toolIds); + } catch (err) { + console.error('Failed to load favorites', err); + } finally { + setLoadingFavorites(false); + } + }; + fetchFavorites(); + }, []); + + const handleStarClick = async (toolId: string) => { + const isCurrentlyFavorite = favoriteToolIds.includes(toolId); + const prevFavorites = [...favoriteToolIds]; + const updatedFavorites = isCurrentlyFavorite + ? favoriteToolIds.filter((id) => id !== toolId) + : [...favoriteToolIds, toolId]; + + setFavoriteToolIds(updatedFavorites); + setUpdatingToolIds((prev) => new Set(prev).add(toolId)); + + try { + if (isCurrentlyFavorite) { + await removeFavorite(toolId); + } else { + await addFavorite(toolId); + } + } catch (err) { + console.error('Failed to update favorites', err); + setFavoriteToolIds(prevFavorites); + } finally { + setUpdatingToolIds((prev) => { + const newSet = new Set(prev); + newSet.delete(toolId); + return newSet; + }); + } + }; -// Add your recent tools feature here: -const handleToolClick = (toolName: string, toolPath: string) => { - // Add /workspace/ prefix only if missing - const correctedPath = toolPath.startsWith('/workspace/') - ? toolPath - : `/workspace/${toolPath.replace(/^\//, '')}`; - - // Get existing recent tools from localStorage - const existing: { label: string; path: string }[] = JSON.parse( - localStorage.getItem('recentTools') || '[]' - ); - - // Add new tool at front, remove duplicates, keep max 5 - const updated = [ - { label: toolName, path: correctedPath }, - ...existing.filter((t) => t.path !== correctedPath), - ].slice(0, 5); - - localStorage.setItem('recentTools', JSON.stringify(updated)); -}; - -export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ - categories, -}) => { type MenuItem = Required['items'][number] & { priority: number }; - function getItem( + const getItem = ( label: React.ReactNode, key: string, priority: number, children?: MenuItem[], type?: 'group' - ): MenuItem { - return { - label, - key, - priority, - children, - type, - } as MenuItem; - } + ): MenuItem => ({ + label, + key, + priority, + children, + ...(type === 'group' ? { type } : {}), + }); const getCategoryApps = (category: TAppCategory) => { - const bundles: { - [dynamic: string]: { - apps: MenuItem[]; - label: string; - }; - } = {}; + const bundles: Record = {}; const categoryItems: MenuItem[] = []; category.apps.forEach((app) => { - // Construct NavLink 'to' path as original (no /workspace prefix) + const toolId = app.app_id + (app.version || ''); const linkPath = `${app.app_id}${app.version ? `?appVersion=${app.version}` : ''}`; const linkLabel = app.shortLabel || app.label || app.bundle_label; + const isFavorite = favoriteToolIds.includes(toolId); + + const switchControl = ( + e.stopPropagation()} + style={{ marginLeft: 6, display: 'inline-block' }} + > + handleStarClick(toolId)} + checkedChildren="★" + unCheckedChildren="☆" + /> + + ); + const labelContent = ( + + handleToolClick(linkLabel, linkPath)} + style={{ + flexGrow: 1, + textDecoration: 'none', + color: 'inherit', + outline: 'none', + }} + > + {linkLabel} + + {switchControl} + + ); + + const item = getItem(labelContent, toolId, app.priority); if (app.is_bundled) { const bundleKey = `${app.bundle_label}${app.bundle_id}`; if (bundles[bundleKey]) { - bundles[bundleKey].apps.push( - getItem( - handleToolClick(linkLabel, linkPath)} - > - {linkLabel} - , - `${app.app_id}${app.version || ''}${app.bundle_id}`, - app.priority - ) - ); + bundles[bundleKey].apps.push(item); } else { bundles[bundleKey] = { - apps: [ - getItem( - handleToolClick(linkLabel, linkPath)} - > - {linkLabel} - , - `${app.app_id}${app.version || ''}${app.bundle_id}`, - app.priority - ), - ], + apps: [item], label: app.bundle_label, }; } } else { - categoryItems.push( - getItem( - handleToolClick(linkLabel, linkPath)} - > - {linkLabel} - , - `${app.app_id}${app.version || ''}${app.bundle_id}`, - app.priority - ) - ); + categoryItems.push(item); } }); - const bundleItems = Object.entries(bundles).map( - ([bundleKey, bundle], index) => - getItem( - `${bundle.label} [${bundle.apps.length}]`, - bundleKey, - index, - bundle.apps.sort((a, b) => a.priority - b.priority) - ) + const bundleItems = Object.entries(bundles).map(([bundleKey, bundle], index) => + getItem( + `${bundle.label} [${bundle.apps.length}]`, + bundleKey, + index, + bundle.apps.sort((a, b) => a.priority - b.priority) + ) ); - return categoryItems - .concat(bundleItems) - .sort((a, b) => (a?.key as string).localeCompare(b?.key as string)); + return [...categoryItems.sort((a, b) => a.priority - b.priority), ...bundleItems]; }; - const items: MenuItem[] = categories.map((category) => { - return getItem( - `${category.title} [${category.apps.length}]`, - category.title, - category.priority, - getCategoryApps(category) - ); - }); + const items: MenuItem[] = categories + .map((category) => + getItem( + `${category.title} [${category.apps.length}]`, + category.title, + category.priority, + getCategoryApps(category) + ) + ) + .sort((a, b) => a.priority - b.priority); const { appId, appVersion } = useGetAppParams(); const currentApp = categories - .map((cat) => cat.apps) - .flat() + .flatMap((cat) => cat.apps) .find((app) => app.app_id === appId && app.version === (appVersion || '')); const currentCategory = categories.find((cat) => @@ -147,7 +172,9 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ ? `${currentApp.bundle_label}${currentApp.bundle_id}` : ''; - const selectedKey = `${appId}${appVersion || ''}${currentApp?.bundle_id}`; + const selectedKey = `${appId}${appVersion || ''}${currentApp?.bundle_id ?? ''}`; + + if (loadingFavorites) return
Loading tools...
; return ( <> @@ -178,3 +205,20 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ ); }; + +const handleToolClick = (toolName: string, toolPath: string) => { + const correctedPath = toolPath.startsWith('/workspace/') + ? toolPath + : `/workspace/${toolPath.replace(/^\//, '')}`; + + const existing: { label: string; path: string }[] = JSON.parse( + localStorage.getItem('recentTools') || '[]' + ); + + const updated = [ + { label: toolName, path: correctedPath }, + ...existing.filter((t) => t.path !== correctedPath), + ].slice(0, 5); + + localStorage.setItem('recentTools', JSON.stringify(updated)); +}; diff --git a/designsafe/apps/licenses/migrations/0006_alter_lsdynalicense_user_alter_matlablicense_user.py b/designsafe/apps/licenses/migrations/0006_alter_lsdynalicense_user_alter_matlablicense_user.py deleted file mode 100644 index b25e66cddb..0000000000 --- a/designsafe/apps/licenses/migrations/0006_alter_lsdynalicense_user_alter_matlablicense_user.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 4.2.15 on 2025-06-23 15:09 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('designsafe_licenses', '0005_auto_20200423_1957'), - ] - - operations = [ - migrations.AlterField( - model_name='lsdynalicense', - name='user', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s', to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name='matlablicense', - name='user', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s', to=settings.AUTH_USER_MODEL), - ), - ] From ae7e243bb9b7bf7d967303ac338e3405bfe7e301 Mon Sep 17 00:00:00 2001 From: vani-walvekar1494 Date: Fri, 27 Jun 2025 14:57:26 -0500 Subject: [PATCH 10/26] Fix lint error and other updates --- .../modules/_common_components/src/index.ts | 1 + .../src/lib}/favouritesApi.ts | 0 .../src/Dashboard/Dashboard.module.css | 2 - .../dashboard/src/Dashboard/Dashboard.tsx | 25 +- .../dashboard/src/Dashboard/FavoriteTools.tsx | 103 +++++--- .../src/Dashboard/QuickLinksNavbar.tsx | 2 +- .../dashboard/src/Dashboard/TicketList.tsx | 11 +- .../workspace/src/AppsSideNav/AppsSideNav.tsx | 121 ++++----- client/modules/workspace/src/utils/apps.ts | 240 +++--------------- 9 files changed, 177 insertions(+), 328 deletions(-) rename client/modules/{dashboard/src/api => _common_components/src/lib}/favouritesApi.ts (100%) diff --git a/client/modules/_common_components/src/index.ts b/client/modules/_common_components/src/index.ts index f93d99586e..923ecc8695 100644 --- a/client/modules/_common_components/src/index.ts +++ b/client/modules/_common_components/src/index.ts @@ -2,3 +2,4 @@ export * from './datafiles'; export { PrimaryButton, SecondaryButton } from './lib/Button'; export { Icon } from './lib/Icon'; export { Spinner } from './lib/Spinner'; +export * from './lib/favouritesApi'; \ No newline at end of file diff --git a/client/modules/dashboard/src/api/favouritesApi.ts b/client/modules/_common_components/src/lib/favouritesApi.ts similarity index 100% rename from client/modules/dashboard/src/api/favouritesApi.ts rename to client/modules/_common_components/src/lib/favouritesApi.ts diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.module.css b/client/modules/dashboard/src/Dashboard/Dashboard.module.css index 75ce016c08..9656b997c6 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.module.css +++ b/client/modules/dashboard/src/Dashboard/Dashboard.module.css @@ -167,7 +167,6 @@ flex-grow: 1; } - /* Dashboard.module.css */ .favoriteToggle { @@ -219,4 +218,3 @@ transform: scale(1.2); color: darkorange; } - diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.tsx b/client/modules/dashboard/src/Dashboard/Dashboard.tsx index 4683ea4758..eb41deb2c3 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.tsx +++ b/client/modules/dashboard/src/Dashboard/Dashboard.tsx @@ -1,24 +1,29 @@ -//import styles from './Dashboard.module.css'; +import React from 'react'; import Quicklinks from './QuickLinksNavbar'; import RecentlyAccessed from './RecentlyAccessed'; import RecentProjects from './RecentProjects'; import { TicketList } from './TicketList'; import FavoriteTools from './FavoriteTools'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; /* eslint-disable-next-line */ -export interface DashboardProps {} +export interface DashboardProps { } + +const queryClient = new QueryClient(); export function Dashboard(props: DashboardProps) { return ( -
- -
- - - - + +
+ +
+ + + + +
-
+ ); } diff --git a/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx b/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx index a0798502c7..c252b690e3 100644 --- a/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx +++ b/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { getUserFavorites, removeFavorite } from '../api/favouritesApi'; +import { getUserFavorites, removeFavorite } from '@client/common-components'; import { useAppsListing } from '@client/hooks'; import styles from './Dashboard.module.css'; @@ -8,13 +8,6 @@ interface FavoriteTool { version?: string; } -interface TPortalApp { - app_id: string; - version: string; - id?: string; - label?: string; -} - interface AppData { app_id: string; version: string; @@ -26,26 +19,38 @@ interface AppData { }; } +const makeToolKey = (tool_id: string, version?: string) => + version ? `${tool_id}-${version}` : tool_id; + +const parseToolId = (toolId: string): FavoriteTool => { + const versionMatch = toolId.match(/(native|s|ds)?\d+(\.\d+)+$/); + if (!versionMatch) return { tool_id: toolId }; + const version = versionMatch[0]; + const tool_id = toolId.slice(0, -version.length).replace(/-$/, ''); + return { tool_id, version }; +}; + const FavoriteTools = () => { const [favorites, setFavorites] = useState([]); const [showPanel, setShowPanel] = useState(false); const [isLoadingFavorites, setIsLoadingFavorites] = useState(true); const [favoritesError, setFavoritesError] = useState(null); - + const [removingIds, setRemovingIds] = useState>(new Set()); const { data, isLoading, isError } = useAppsListing(); - const allApps: AppData[] = data?.categories?.flatMap((cat) => - cat.apps.map((app) => ({ - app_id: app.app_id, - version: app.version || '', - definition: { - id: app.app_id, - notes: { - label: app.label, + const allApps: AppData[] = + data?.categories?.flatMap((cat) => + cat.apps.map((app) => ({ + app_id: app.app_id, + version: app.version || '', + definition: { + id: app.app_id, + notes: { + label: app.label, + }, }, - }, - })) - ) ?? []; + })) + ) ?? []; const resolvedFavorites = favorites .map((fav) => { @@ -54,20 +59,31 @@ const FavoriteTools = () => { app.app_id === fav.tool_id && (!fav.version || app.version === fav.version) ); - if (!matchedApp) return null; const label = matchedApp.definition?.notes?.label || matchedApp.definition.id; - const href = `/apps/${matchedApp.definition.id}`; + + // 🚨 use `/workspace/` instead of `/apps/` + const href = matchedApp.version + ? `/workspace/${matchedApp.definition.id}?appVersion=${matchedApp.version}` + : `/workspace/${matchedApp.definition.id}`; return { + key: makeToolKey(fav.tool_id, fav.version), id: fav.tool_id, + version: fav.version, label, href, }; }) - .filter(Boolean) as { id: string; label: string; href: string }[]; + .filter(Boolean) as { + key: string; + id: string; + version?: string; + label: string; + href: string; + }[]; useEffect(() => { async function fetchFavorites() { @@ -75,7 +91,8 @@ const FavoriteTools = () => { setFavoritesError(null); try { const data = await getUserFavorites(); - setFavorites(data); + const parsedFavorites = data.map((fav) => parseToolId(fav.tool_id)); + setFavorites(parsedFavorites); } catch (err) { console.error('Failed to load favorites:', err); setFavoritesError('Failed to load favorites.'); @@ -86,20 +103,29 @@ const FavoriteTools = () => { fetchFavorites(); }, []); - const handleRemove = async (toolId: string) => { + const handleRemove = async (toolKey: string) => { + if (removingIds.has(toolKey)) return; + setRemovingIds((prev) => new Set(prev).add(toolKey)); try { - await removeFavorite(toolId); + await removeFavorite(toolKey); setFavorites((prev) => - prev.filter((tool) => tool.tool_id !== toolId) + prev.filter( + (tool) => makeToolKey(tool.tool_id, tool.version) !== toolKey + ) ); } catch (err) { console.error('Failed to remove favorite:', err); + } finally { + setRemovingIds((prev) => { + const newSet = new Set(prev); + newSet.delete(toolKey); + return newSet; + }); } }; if (isLoadingFavorites) return
Loading favorite tools...
; if (favoritesError) return
{favoritesError}
; - if (isLoading) return
Loading apps data...
; if (isError) return
Failed to load apps data.
; @@ -113,7 +139,6 @@ const FavoriteTools = () => { > ★ - {showPanel && (

Your Favorite Tools

@@ -122,26 +147,20 @@ const FavoriteTools = () => { ) : (
    {resolvedFavorites.map((tool) => ( -
  • +
  • {tool.label} - handleRemove(tool.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleRemove(tool.id); - } - }} - title={`Remove ${tool.label}`} + onClick={() => handleRemove(tool.key)} + disabled={removingIds.has(tool.key)} aria-label={`Remove ${tool.label} from favorites`} + title={`Remove ${tool.label}`} + type="button" > ★ - +
  • ))}
diff --git a/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx b/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx index ed60a038a7..ec83a74e3f 100644 --- a/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx +++ b/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx @@ -22,4 +22,4 @@ const Quicklinks = () => {
); }; -export default Quicklinks; \ No newline at end of file +export default Quicklinks; diff --git a/client/modules/dashboard/src/Dashboard/TicketList.tsx b/client/modules/dashboard/src/Dashboard/TicketList.tsx index 48bc308c9e..d8dcf0603d 100644 --- a/client/modules/dashboard/src/Dashboard/TicketList.tsx +++ b/client/modules/dashboard/src/Dashboard/TicketList.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import axios from 'axios'; import styles from './Dashboard.module.css'; @@ -37,7 +37,8 @@ export const TicketList: React.FC = () => { updated_at: ticket.updated_at || ticket.LastUpdated, }); - const fetchTickets = async () => { + // Memoize fetchTickets so useEffect dependencies are stable + const fetchTickets = useCallback(async () => { setLoading(true); setError(null); @@ -57,11 +58,13 @@ export const TicketList: React.FC = () => { } finally { setLoading(false); } - }; + }, []); // no dependencies because normalizeTicket is stable (function inside component but doesn't use state) useEffect(() => { fetchTickets(); - }, []); + }, [fetchTickets]); // add fetchTickets here + + // ... rest of your code unchanged ... const formatDate = (input?: string) => { if (!input) return 'N/A'; diff --git a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx index cec058fc29..d76477b659 100644 --- a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx +++ b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx @@ -3,24 +3,23 @@ import { Menu, MenuProps, Switch } from 'antd'; import { NavLink } from 'react-router-dom'; import { TAppCategory, TPortalApp } from '@client/hooks'; import { useGetAppParams } from '../utils'; -import { - getUserFavorites, - addFavorite, - removeFavorite, -} from '../../../dashboard/src/api/favouritesApi'; +import { getUserFavorites, addFavorite, removeFavorite } from '@client/common-components'; -export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ categories }) => { + +export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ + categories, +}) => { const [favoriteToolIds, setFavoriteToolIds] = useState([]); const [loadingFavorites, setLoadingFavorites] = useState(true); const [updatingToolIds, setUpdatingToolIds] = useState>(new Set()); + const { appId, appVersion } = useGetAppParams(); useEffect(() => { const fetchFavorites = async () => { try { const favs = await getUserFavorites(); - const toolIds = (favs || []).map((fav: { tool_id: string; version?: string }) => - fav.tool_id + (fav.version || '') - ); + const toolIds = (favs || []).map((fav: { tool_id: string }) => fav.tool_id); + console.log(':white_check_mark: Loaded favorite tool IDs:', toolIds); setFavoriteToolIds(toolIds); } catch (err) { console.error('Failed to load favorites', err); @@ -32,17 +31,14 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ categori }, []); const handleStarClick = async (toolId: string) => { - const isCurrentlyFavorite = favoriteToolIds.includes(toolId); + const isFavorite = favoriteToolIds.includes(toolId); const prevFavorites = [...favoriteToolIds]; - const updatedFavorites = isCurrentlyFavorite - ? favoriteToolIds.filter((id) => id !== toolId) - : [...favoriteToolIds, toolId]; - - setFavoriteToolIds(updatedFavorites); + setFavoriteToolIds((prev) => + isFavorite ? prev.filter((id) => id !== toolId) : [...prev, toolId] + ); setUpdatingToolIds((prev) => new Set(prev).add(toolId)); - try { - if (isCurrentlyFavorite) { + if (isFavorite) { await removeFavorite(toolId); } else { await addFavorite(toolId); @@ -75,21 +71,19 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ categori ...(type === 'group' ? { type } : {}), }); - const getCategoryApps = (category: TAppCategory) => { + const getCategoryApps = (category: TAppCategory): MenuItem[] => { const bundles: Record = {}; const categoryItems: MenuItem[] = []; - category.apps.forEach((app) => { - const toolId = app.app_id + (app.version || ''); + const toolId = app.version ? `${app.app_id}-${app.version}` : app.app_id; + const isFavorite = favoriteToolIds.includes(toolId); + console.log( + `:jigsaw: App: ${app.app_id}, Version: ${app.version}, ToolID: ${toolId}, IsFavorite: ${isFavorite}` + ); const linkPath = `${app.app_id}${app.version ? `?appVersion=${app.version}` : ''}`; const linkLabel = app.shortLabel || app.label || app.bundle_label; - const isFavorite = favoriteToolIds.includes(toolId); - const switchControl = ( - e.stopPropagation()} - style={{ marginLeft: 6, display: 'inline-block' }} - > + e.stopPropagation()} style={{ marginLeft: 6 }}> = ({ categori ); const labelContent = ( - + handleToolClick(linkLabel, linkPath)} @@ -117,33 +111,25 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ categori {switchControl} ); - const item = getItem(labelContent, toolId, app.priority); - if (app.is_bundled) { const bundleKey = `${app.bundle_label}${app.bundle_id}`; - if (bundles[bundleKey]) { - bundles[bundleKey].apps.push(item); - } else { - bundles[bundleKey] = { - apps: [item], - label: app.bundle_label, - }; + if (!bundles[bundleKey]) { + bundles[bundleKey] = { apps: [], label: app.bundle_label }; } + bundles[bundleKey].apps.push(item); } else { categoryItems.push(item); } }); - - const bundleItems = Object.entries(bundles).map(([bundleKey, bundle], index) => + const bundleItems = Object.entries(bundles).map(([bundleKey, bundle], idx) => getItem( `${bundle.label} [${bundle.apps.length}]`, bundleKey, - index, + idx, bundle.apps.sort((a, b) => a.priority - b.priority) ) ); - return [...categoryItems.sort((a, b) => a.priority - b.priority), ...bundleItems]; }; @@ -158,8 +144,6 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ categori ) .sort((a, b) => a.priority - b.priority); - const { appId, appVersion } = useGetAppParams(); - const currentApp = categories .flatMap((cat) => cat.apps) .find((app) => app.app_id === appId && app.version === (appVersion || '')); @@ -172,36 +156,34 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ categori ? `${currentApp.bundle_label}${currentApp.bundle_id}` : ''; - const selectedKey = `${appId}${appVersion || ''}${currentApp?.bundle_id ?? ''}`; - - if (loadingFavorites) return
Loading tools...
; + const selectedKey = appVersion ? `${appId}-${appVersion}` : appId; + // ALWAYS render "Applications:" immediately, only render Menu after favorites loaded return ( <> -
- Applications: +
+
+ Applications: +
+ {!loadingFavorites && ( + + )}
- ); }; @@ -210,15 +192,12 @@ const handleToolClick = (toolName: string, toolPath: string) => { const correctedPath = toolPath.startsWith('/workspace/') ? toolPath : `/workspace/${toolPath.replace(/^\//, '')}`; - const existing: { label: string; path: string }[] = JSON.parse( localStorage.getItem('recentTools') || '[]' ); - const updated = [ { label: toolName, path: correctedPath }, ...existing.filter((t) => t.path !== correctedPath), ].slice(0, 5); - localStorage.setItem('recentTools', JSON.stringify(updated)); }; diff --git a/client/modules/workspace/src/utils/apps.ts b/client/modules/workspace/src/utils/apps.ts index 9aced94c0a..c08d2b580c 100644 --- a/client/modules/workspace/src/utils/apps.ts +++ b/client/modules/workspace/src/utils/apps.ts @@ -14,76 +14,50 @@ import { UseFormSetValue } from 'react-hook-form'; import { getSystemName } from '../utils'; export const TARGET_PATH_FIELD_PREFIX = '_TargetPath_'; -export const DEFAULT_JOB_MAX_MINUTES = 60 * 24 * 2; // 2 days +export const DEFAULT_JOB_MAX_MINUTES = 60 * 24 * 2; -/** - * Get the execution system object for a given id of the execution system. - */ export const getExecSystemFromId = ( - execSystems: TTapisSystem[], - execSystemId: string -) => { - if (execSystems?.length) { - return execSystems.find((exec_sys) => exec_sys.id === execSystemId); - } - - return null; + execSystems: TTapisSystem[] | undefined, + execSystemId: string | undefined +): TTapisSystem | null => { + if (!execSystems?.length || !execSystemId) return null; + return execSystems.find((exec_sys) => exec_sys.id === execSystemId) || null; }; -/** - * Filters available execution systems if dynamicExecSystems is defined. - * Otherwise, return all available systems. - */ export const getExecSystemsFromApp = ( - definition: TTapisApp, - execSystems: TTapisSystem[] -) => { + definition: TTapisApp | undefined, + execSystems: TTapisSystem[] | undefined +): TTapisSystem[] => { + if (!definition || !execSystems) return []; + if (isAppUsingDynamicExecSystem(definition)) { - if ( - definition.notes.dynamicExecSystems?.length === 1 && - definition.notes.dynamicExecSystems[0] === 'ALL' - ) - return execSystems; - - return execSystems.filter((s) => - definition.notes.dynamicExecSystems?.includes(s.id) - ); + const dynamics = definition.notes.dynamicExecSystems; + if (dynamics?.length === 1 && dynamics[0] === 'ALL') return execSystems; + return execSystems.filter((s) => dynamics?.includes(s.id)); } - const sys = execSystems.find( - (s) => s.id === definition.jobAttributes.execSystemId - ); + const execSystemId = definition.jobAttributes?.execSystemId; + const sys = execSystems.find((s) => s.id === execSystemId); return sys ? [sys] : []; }; -/** - * Gets the exec system for the default set in the job attributes. - * Otherwise, get the first entry. - */ export const getDefaultExecSystem = ( - definition: TTapisApp, - execSystems: TTapisSystem[] -) => { - // If dynamic exec system is not setup, use from job attributes. - if (!isAppUsingDynamicExecSystem(definition)) { - return getExecSystemFromId( - execSystems, - definition.jobAttributes.execSystemId - ); - } + definition: TTapisApp | undefined, + execSystems: TTapisSystem[] | undefined +): TTapisSystem | null => { + if (!definition || !execSystems || execSystems.length === 0) return null; - if (execSystems?.length) { - const execSystemId = definition.jobAttributes.execSystemId; - // Check if the app's default execSystemId is in provided list - // If not found, return the first execSystem from the provided list - return ( - getExecSystemFromId(execSystems, execSystemId) || - getExecSystemFromId(execSystems, execSystems[0].id) - ); + const execSystemId = definition.jobAttributes?.execSystemId; + + if (!isAppUsingDynamicExecSystem(definition)) { + return getExecSystemFromId(execSystems, execSystemId); } - return null; + return ( + getExecSystemFromId(execSystems, execSystemId) || + getExecSystemFromId(execSystems, execSystems[0].id) + ); }; export const getQueueMaxMinutes = ( @@ -108,34 +82,19 @@ export const preprocessStringToNumber = (value: unknown): unknown => { return value; }; -/** - * Get validator for system. Only runs for apps - * with dynamic execution system. - * @param {definition} app definition - * @param {executionSystems} collection of systems - * @returns {z.string()} exec system validation if it is enabled for app - */ export const getExecSystemIdValidation = ( definition: TTapisApp, executionSystems: TTapisSystem[] ) => { return definition.jobType === 'BATCH' && !!definition.notes.dynamicExecSystems ? z - .string() - .refine((value) => executionSystems?.some((e) => e.id === value), { - message: 'A system is required to run this application.', - }) + .string() + .refine((value) => executionSystems?.some((e) => e.id === value), { + message: 'A system is required to run this application.', + }) : z.string().optional(); }; -/** - * Get validator for max minutes of a queue - * - * @function - * @param {Object} definition App definition - * @param {Object} queue - * @returns {z.number()} min/max validation of max minutes - */ export const getMaxMinutesValidation = ( definition: TTapisApp, queue: TTapisSystemQueue @@ -165,14 +124,6 @@ export const getMaxMinutesValidation = ( ); }; -/** - * Get validator for a node count of a queue - * - * @function - * @param {Object} definition App definition - * @param {Object} queue - * @returns {z.number()} min/max validation of node count - */ export const getNodeCountValidation = ( definition: TTapisApp, queue: TTapisSystemQueue @@ -196,14 +147,6 @@ export const getNodeCountValidation = ( ); }; -/** - * Get validator for cores on each node - * - * @function - * @param {Object} definition App definition - * @param {Object} queue - * @returns {z.number()} min/max validation of coresPerNode - */ export const getCoresPerNodeValidation = ( definition: TTapisApp, queue: TTapisSystemQueue @@ -220,17 +163,6 @@ export const getCoresPerNodeValidation = ( ); }; -/** - * Get corrected values for a new queue - * - * Check values and if any do not work with the current queue, then fix those - * values. - * - * @function - * @param {Object} execSystems - * @param {Object} values - * @returns {Object} updated/fixed values - */ export const updateValuesForQueue = ( execSystems: TTapisSystem[], execSystemId: string, @@ -259,7 +191,7 @@ export const updateValuesForQueue = ( setValue('configuration.coresPerNode', queue.minCoresPerNode); } if ( - queue.maxCoresPerNode !== -1 /* e.g. Frontera rtx/rtx-dev queue */ && + queue.maxCoresPerNode !== -1 && (values.configuration.coresPerNode as number) > queue.maxCoresPerNode ) { setValue('configuration.coresPerNode', queue.maxCoresPerNode); @@ -300,13 +232,6 @@ export const getAppExecSystems = ( .sort((a, b) => a.label.localeCompare(b.label)); }; -/** - * Get the default queue for a execution system. - * Queue Name determination order: - * 1. Use given queue name. - * 2. Otherwise, use the app default queue. - * 3. Otherwise, use the execution system default queue. - */ export const getQueueValueForExecSystem = ({ definition, exec_sys, @@ -326,27 +251,12 @@ export const getQueueValueForExecSystem = ({ ); }; -/** - * Apply the following two filters and get the list of queues applicable. - * Filters: - * 1. If Node and Core per Node is enabled, only allow - * queues which match min and max node count with job attributes - * 2. if queue filter list is set, only allow queues in that list. - * @function - * @param {any} definition App definition - * @param {any} queues - * @returns list of queues in sorted order - */ export const getAppQueueValues = ( definition: TTapisApp, queues: TTapisSystemQueue[] ) => { return ( (queues ?? []) - /* - Hide queues for which the app default nodeCount does not meet the minimum or maximum requirements - while hideNodeCountAndCoresPerNode is true - */ .filter( (q) => !definition.notes.hideNodeCountAndCoresPerNode || @@ -354,7 +264,6 @@ export const getAppQueueValues = ( definition.jobAttributes.nodeCount <= q.maxNodeCount) ) .map((q) => q.name) - // Hide queues when app includes a queueFilter and queue is not present in queueFilter .filter( (queueName) => !definition.notes.queueFilter || @@ -364,48 +273,20 @@ export const getAppQueueValues = ( ); }; -/** - * Get the field name used for target path in AppForm - * - * @function - * @param {String} inputFieldName - * @returns {String} field Name prefixed with target path - */ export const getTargetPathFieldName = (inputFieldName: string) => { return TARGET_PATH_FIELD_PREFIX + inputFieldName; }; -/** - * Whether a field name is a system defined field for Target Path - * - * @function - * @param {String} inputFieldName - * @returns {String} field Name suffixed with target path - */ export const isTargetPathField = (inputFieldName: string) => { return inputFieldName && inputFieldName.startsWith(TARGET_PATH_FIELD_PREFIX); }; -/** - * From target path field name, derive the original input field name. - * - * @function - * @param {String} targetPathFieldName - * @returns {String} actual field name - */ export const getInputFieldFromTargetPathField = ( targetPathFieldName: string ) => { return targetPathFieldName.replace(TARGET_PATH_FIELD_PREFIX, ''); }; -/** - * Check if targetPath is empty on input field - * - * @function - * @param {String} targetPathFieldValue - * @returns {boolean} if target path is empty - */ export const isTargetPathEmpty = (targetPathFieldValue?: string) => { if (targetPathFieldValue === null || targetPathFieldValue === undefined) { return true; @@ -420,13 +301,6 @@ export const isTargetPathEmpty = (targetPathFieldValue?: string) => { return false; }; -/** - * Sets the default value if target path is not set. - * - * @function - * @param {String} targetPathFieldValue - * @returns {String} target path value - */ export const checkAndSetDefaultTargetPath = (targetPathFieldValue?: string) => { if (isTargetPathEmpty(targetPathFieldValue)) { return '*'; @@ -473,10 +347,6 @@ export const getExecSystemLogicalQueueValidation = ( ); }; -/** - * Provides allocation list matching - * the execution host of the selected app. - */ export const getAllocationList = ( definition: TTapisApp, execSystems: TTapisSystem[], @@ -512,12 +382,6 @@ export const useGetAppParams = () => { return { appId, appVersion, jobUUID }; }; -/** - * Find app in app tray categories and get the icon info. - * @param data TAppCategories or undefined - * @param appId string - id of an app. - * @returns icon name if available, otherwise null - */ export const findAppById = ( data: TAppCategories | undefined, appId: string @@ -533,14 +397,6 @@ export const findAppById = ( return null; }; -/** - * Get list of env variables that are on demand and hidden - * in App Form. - * This could be useful to populate these env variables before - * submission to tapis - * @param definition app definition - * @returns list of key, value - */ export const getOnDemandEnvVariables = ( definition: TTapisApp ): { key: string; value: string }[] => { @@ -568,13 +424,6 @@ export const getOnDemandEnvVariables = ( return includeOnDemandVars; }; -/** - * Returns 'interactive session' as app type if it is interactive, otherwise 'job' - * - * @param definition - TTapisApp - * @param titleCase - boolean, default is false. - * @returns string - */ export const getAppRuntimeLabel = ( definition: TTapisApp, titleCase: boolean = false @@ -583,20 +432,15 @@ export const getAppRuntimeLabel = ( return titleCase ? label - .split(' ') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') : label; }; -/** - * Generic to compare arrays. - * @param a1 T[] - * @param a2 T[] - * @returns true if array elements are same. - */ -export const areArraysEqual = (a1: T[], a2: T[]): boolean => { - return ( - a1.length === a2.length && a1.every((value, index) => value === a2[index]) - ); -}; +export const areArraysEqual = (a: T[], b: T[]): boolean => { + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((value, index) => value === sortedB[index]); +}; \ No newline at end of file From 9698ff43c0de265c5f9a90f410a3915c2f3f3401 Mon Sep 17 00:00:00 2001 From: vani-walvekar1494 Date: Fri, 27 Jun 2025 15:04:18 -0500 Subject: [PATCH 11/26] Fix lint error and update files after merging feat/react-dashboard --- .../modals/DownloadDatasetModal.tsx | 8 +++--- .../SystemStatusModal/SystemStatusModal.tsx | 9 ++++++- .../datafiles/migrations/0002_userfavorite.py | 24 ++++++++++++++---- ..._added_on_userfavorite_tool_id_and_more.py | 25 +++++++++++-------- ...remove_filemetamodel_unique_system_path.py | 17 ------------- designsafe/settings/rt_settings.py | 1 + designsafe/settings/test_settings.py | 1 + 7 files changed, 47 insertions(+), 38 deletions(-) delete mode 100644 designsafe/apps/api/filemeta/migrations/0003_remove_filemetamodel_unique_system_path.py diff --git a/client/modules/datafiles/src/publications/modals/DownloadDatasetModal.tsx b/client/modules/datafiles/src/publications/modals/DownloadDatasetModal.tsx index 274142c434..a72cfe28b3 100644 --- a/client/modules/datafiles/src/publications/modals/DownloadDatasetModal.tsx +++ b/client/modules/datafiles/src/publications/modals/DownloadDatasetModal.tsx @@ -241,7 +241,7 @@ export const DownloadDatasetModal: React.FC<{ archivePath, isModalOpen ); - const FILE_SIZE_LIMIT = 2147483648; + const FILE_SIZE_LIMIT = 5368709120; // 5 GB const exceedsLimit = useMemo( () => (data?.length ?? 0) > FILE_SIZE_LIMIT, [data?.length] @@ -275,7 +275,7 @@ export const DownloadDatasetModal: React.FC<{ {exceedsLimit ? (

This project zipped is {toBytes(data.length)}, - exceeding the 2 GB download limit. To download, + exceeding the 5 GB download limit. To download, - . Alternatively, download files individually by selecting the - file and using the download button in the toolbar. + . Alternatively, download subsets of files or individually by + selecting the file and using the download button in the toolbar.

) : (

diff --git a/client/modules/workspace/src/components/SystemStatusModal/SystemStatusModal.tsx b/client/modules/workspace/src/components/SystemStatusModal/SystemStatusModal.tsx index d24fddb771..3818ceb169 100644 --- a/client/modules/workspace/src/components/SystemStatusModal/SystemStatusModal.tsx +++ b/client/modules/workspace/src/components/SystemStatusModal/SystemStatusModal.tsx @@ -46,10 +46,17 @@ const SystemStatusContent: React.FC = ({ }, [app, appId, executionSystems]); const { data: systems, isLoading, error } = useSystemOverview(); + const availableSystems = systems?.map((sys) => sys.display_name) || []; const selectedSystem = systems?.find( (sys) => sys.display_name === activeSystem ); + useEffect(() => { + if (systems && !availableSystems.includes(activeSystem)) { + setActiveSystem('Vista'); + } + }, [systems, activeSystem]); + return ( = ({

) : ( -
No data found for {activeSystem}
+
No data found
)}
diff --git a/designsafe/apps/api/datafiles/migrations/0002_userfavorite.py b/designsafe/apps/api/datafiles/migrations/0002_userfavorite.py index 726b114231..528e727ea5 100644 --- a/designsafe/apps/api/datafiles/migrations/0002_userfavorite.py +++ b/designsafe/apps/api/datafiles/migrations/0002_userfavorite.py @@ -9,16 +9,30 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('datafiles', '0001_initial'), + ("datafiles", "0001_initial"), ] operations = [ migrations.CreateModel( - name='UserFavorite', + name="UserFavorite", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('file_path', models.TextField()), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("file_path", models.TextField()), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], ), ] diff --git a/designsafe/apps/api/datafiles/migrations/0003_userfavorite_added_on_userfavorite_tool_id_and_more.py b/designsafe/apps/api/datafiles/migrations/0003_userfavorite_added_on_userfavorite_tool_id_and_more.py index 406646090f..6922d41115 100644 --- a/designsafe/apps/api/datafiles/migrations/0003_userfavorite_added_on_userfavorite_tool_id_and_more.py +++ b/designsafe/apps/api/datafiles/migrations/0003_userfavorite_added_on_userfavorite_tool_id_and_more.py @@ -9,28 +9,31 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('datafiles', '0002_userfavorite'), + ("datafiles", "0002_userfavorite"), ] operations = [ migrations.AddField( - model_name='userfavorite', - name='added_on', - field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2025, 6, 19, 15, 35, 58, 410115)), + model_name="userfavorite", + name="added_on", + field=models.DateTimeField( + auto_now_add=True, + default=datetime.datetime(2025, 6, 19, 15, 35, 58, 410115), + ), preserve_default=False, ), migrations.AddField( - model_name='userfavorite', - name='tool_id', - field=models.CharField(default='unknown-tool', max_length=100), + model_name="userfavorite", + name="tool_id", + field=models.CharField(default="unknown-tool", max_length=100), preserve_default=False, ), migrations.AlterUniqueTogether( - name='userfavorite', - unique_together={('user', 'tool_id')}, + name="userfavorite", + unique_together={("user", "tool_id")}, ), migrations.RemoveField( - model_name='userfavorite', - name='file_path', + model_name="userfavorite", + name="file_path", ), ] diff --git a/designsafe/apps/api/filemeta/migrations/0003_remove_filemetamodel_unique_system_path.py b/designsafe/apps/api/filemeta/migrations/0003_remove_filemetamodel_unique_system_path.py deleted file mode 100644 index b50a8e094f..0000000000 --- a/designsafe/apps/api/filemeta/migrations/0003_remove_filemetamodel_unique_system_path.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.15 on 2025-06-23 15:09 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('filemeta_api', '0002_auto_20240418_1650'), - ] - - operations = [ - migrations.RemoveConstraint( - model_name='filemetamodel', - name='unique_system_path', - ), - ] diff --git a/designsafe/settings/rt_settings.py b/designsafe/settings/rt_settings.py index 9995e2bbb2..ac0258bb4b 100644 --- a/designsafe/settings/rt_settings.py +++ b/designsafe/settings/rt_settings.py @@ -12,6 +12,7 @@ ('DATA_CURATION_PUBLICATION', 'Data Curation & Publication'), ('DATA_DEPOT', 'Data Depot'), ('TOOLS_APPS', 'Tools & Applications'), + ('ALLOCATIONS', 'Allocations'), ('LOGIN', 'Login/Registration'), ('OTHER', 'Other'), ) diff --git a/designsafe/settings/test_settings.py b/designsafe/settings/test_settings.py index f8ee229aa1..bc87725ca2 100644 --- a/designsafe/settings/test_settings.py +++ b/designsafe/settings/test_settings.py @@ -778,6 +778,7 @@ ('DATA_CURATION_PUBLICATION', 'Data Curation & Publication'), ('DATA_DEPOT', 'Data Depot'), ('DISCOVERY_WORKSPACE', 'Discovery Workspace'), + ('ALLOCATIONS', 'Allocations'), ('LOGIN', 'Login/Registration'), ('OTHER', 'Other'), ) From c4e91a43291200d88ee9196cd18b46d45ec41479 Mon Sep 17 00:00:00 2001 From: vani-walvekar1494 Date: Fri, 27 Jun 2025 16:07:41 -0500 Subject: [PATCH 12/26] Fix formatting issues --- .../modules/_common_components/src/index.ts | 2 +- .../dashboard/src/Dashboard/Dashboard.tsx | 2 +- .../dashboard/src/Dashboard/FavoriteTools.tsx | 12 ++--- .../workspace/src/AppsSideNav/AppsSideNav.tsx | 44 +++++++++++------ client/modules/workspace/src/utils/apps.ts | 47 +++++++++---------- 5 files changed, 60 insertions(+), 47 deletions(-) diff --git a/client/modules/_common_components/src/index.ts b/client/modules/_common_components/src/index.ts index 923ecc8695..9f33265cc3 100644 --- a/client/modules/_common_components/src/index.ts +++ b/client/modules/_common_components/src/index.ts @@ -2,4 +2,4 @@ export * from './datafiles'; export { PrimaryButton, SecondaryButton } from './lib/Button'; export { Icon } from './lib/Icon'; export { Spinner } from './lib/Spinner'; -export * from './lib/favouritesApi'; \ No newline at end of file +export * from './lib/favouritesApi'; diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.tsx b/client/modules/dashboard/src/Dashboard/Dashboard.tsx index eb41deb2c3..3f03f90a7c 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.tsx +++ b/client/modules/dashboard/src/Dashboard/Dashboard.tsx @@ -7,7 +7,7 @@ import FavoriteTools from './FavoriteTools'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; /* eslint-disable-next-line */ -export interface DashboardProps { } +export interface DashboardProps {} const queryClient = new QueryClient(); diff --git a/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx b/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx index c252b690e3..fe341c9ea9 100644 --- a/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx +++ b/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx @@ -78,12 +78,12 @@ const FavoriteTools = () => { }; }) .filter(Boolean) as { - key: string; - id: string; - version?: string; - label: string; - href: string; - }[]; + key: string; + id: string; + version?: string; + label: string; + href: string; + }[]; useEffect(() => { async function fetchFavorites() { diff --git a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx index d76477b659..73b60b07ad 100644 --- a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx +++ b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx @@ -3,22 +3,29 @@ import { Menu, MenuProps, Switch } from 'antd'; import { NavLink } from 'react-router-dom'; import { TAppCategory, TPortalApp } from '@client/hooks'; import { useGetAppParams } from '../utils'; -import { getUserFavorites, addFavorite, removeFavorite } from '@client/common-components'; - +import { + getUserFavorites, + addFavorite, + removeFavorite, +} from '@client/common-components'; export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ categories, }) => { const [favoriteToolIds, setFavoriteToolIds] = useState([]); const [loadingFavorites, setLoadingFavorites] = useState(true); - const [updatingToolIds, setUpdatingToolIds] = useState>(new Set()); + const [updatingToolIds, setUpdatingToolIds] = useState>( + new Set() + ); const { appId, appVersion } = useGetAppParams(); useEffect(() => { const fetchFavorites = async () => { try { const favs = await getUserFavorites(); - const toolIds = (favs || []).map((fav: { tool_id: string }) => fav.tool_id); + const toolIds = (favs || []).map( + (fav: { tool_id: string }) => fav.tool_id + ); console.log(':white_check_mark: Loaded favorite tool IDs:', toolIds); setFavoriteToolIds(toolIds); } catch (err) { @@ -80,7 +87,9 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ console.log( `:jigsaw: App: ${app.app_id}, Version: ${app.version}, ToolID: ${toolId}, IsFavorite: ${isFavorite}` ); - const linkPath = `${app.app_id}${app.version ? `?appVersion=${app.version}` : ''}`; + const linkPath = `${app.app_id}${ + app.version ? `?appVersion=${app.version}` : '' + }`; const linkLabel = app.shortLabel || app.label || app.bundle_label; const switchControl = ( e.stopPropagation()} style={{ marginLeft: 6 }}> @@ -122,15 +131,19 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ categoryItems.push(item); } }); - const bundleItems = Object.entries(bundles).map(([bundleKey, bundle], idx) => - getItem( - `${bundle.label} [${bundle.apps.length}]`, - bundleKey, - idx, - bundle.apps.sort((a, b) => a.priority - b.priority) - ) + const bundleItems = Object.entries(bundles).map( + ([bundleKey, bundle], idx) => + getItem( + `${bundle.label} [${bundle.apps.length}]`, + bundleKey, + idx, + bundle.apps.sort((a, b) => a.priority - b.priority) + ) ); - return [...categoryItems.sort((a, b) => a.priority - b.priority), ...bundleItems]; + return [ + ...categoryItems.sort((a, b) => a.priority - b.priority), + ...bundleItems, + ]; }; const items: MenuItem[] = categories @@ -176,7 +189,10 @@ export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ {!loadingFavorites && ( { if (!definition || !execSystems || execSystems.length === 0) return null; - const execSystemId = definition.jobAttributes?.execSystemId; if (!isAppUsingDynamicExecSystem(definition)) { @@ -88,10 +87,10 @@ export const getExecSystemIdValidation = ( ) => { return definition.jobType === 'BATCH' && !!definition.notes.dynamicExecSystems ? z - .string() - .refine((value) => executionSystems?.some((e) => e.id === value), { - message: 'A system is required to run this application.', - }) + .string() + .refine((value) => executionSystems?.some((e) => e.id === value), { + message: 'A system is required to run this application.', + }) : z.string().optional(); }; @@ -255,22 +254,20 @@ export const getAppQueueValues = ( definition: TTapisApp, queues: TTapisSystemQueue[] ) => { - return ( - (queues ?? []) - .filter( - (q) => - !definition.notes.hideNodeCountAndCoresPerNode || - (definition.jobAttributes.nodeCount >= q.minNodeCount && - definition.jobAttributes.nodeCount <= q.maxNodeCount) - ) - .map((q) => q.name) - .filter( - (queueName) => - !definition.notes.queueFilter || - definition.notes.queueFilter.includes(queueName) - ) - .sort() - ); + return (queues ?? []) + .filter( + (q) => + !definition.notes.hideNodeCountAndCoresPerNode || + (definition.jobAttributes.nodeCount >= q.minNodeCount && + definition.jobAttributes.nodeCount <= q.maxNodeCount) + ) + .map((q) => q.name) + .filter( + (queueName) => + !definition.notes.queueFilter || + definition.notes.queueFilter.includes(queueName) + ) + .sort(); }; export const getTargetPathFieldName = (inputFieldName: string) => { @@ -432,9 +429,9 @@ export const getAppRuntimeLabel = ( return titleCase ? label - .split(' ') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') : label; }; @@ -443,4 +440,4 @@ export const areArraysEqual = (a: T[], b: T[]): boolean => { const sortedA = [...a].sort(); const sortedB = [...b].sort(); return sortedA.every((value, index) => value === sortedB[index]); -}; \ No newline at end of file +}; From 98b6f969b37b67f001d7d6b703a48b48b1ac0f03 Mon Sep 17 00:00:00 2001 From: vani-walvekar1494 Date: Fri, 27 Jun 2025 16:33:30 -0500 Subject: [PATCH 13/26] Format migration files with Black --- designsafe/apps/api/datafiles/migrations/0002_userfavorite.py | 1 - .../0003_userfavorite_added_on_userfavorite_tool_id_and_more.py | 1 - 2 files changed, 2 deletions(-) diff --git a/designsafe/apps/api/datafiles/migrations/0002_userfavorite.py b/designsafe/apps/api/datafiles/migrations/0002_userfavorite.py index 528e727ea5..7197875d9a 100644 --- a/designsafe/apps/api/datafiles/migrations/0002_userfavorite.py +++ b/designsafe/apps/api/datafiles/migrations/0002_userfavorite.py @@ -11,7 +11,6 @@ class Migration(migrations.Migration): migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("datafiles", "0001_initial"), ] - operations = [ migrations.CreateModel( name="UserFavorite", diff --git a/designsafe/apps/api/datafiles/migrations/0003_userfavorite_added_on_userfavorite_tool_id_and_more.py b/designsafe/apps/api/datafiles/migrations/0003_userfavorite_added_on_userfavorite_tool_id_and_more.py index 6922d41115..909b260fa1 100644 --- a/designsafe/apps/api/datafiles/migrations/0003_userfavorite_added_on_userfavorite_tool_id_and_more.py +++ b/designsafe/apps/api/datafiles/migrations/0003_userfavorite_added_on_userfavorite_tool_id_and_more.py @@ -11,7 +11,6 @@ class Migration(migrations.Migration): migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("datafiles", "0002_userfavorite"), ] - operations = [ migrations.AddField( model_name="userfavorite", From db1aec1bbe405cc3b22d32759756d8e1342eada3 Mon Sep 17 00:00:00 2001 From: vani-walvekar1494 Date: Mon, 30 Jun 2025 15:56:40 -0500 Subject: [PATCH 14/26] chore: refactor dashboard API, cleanup migrations, remove unused icons --- .../modules/_common_components/src/index.ts | 2 +- .../src/lib/favouritesApi.ts | 66 --- client/modules/_hooks/src/favouritesApi.ts | 74 ++++ client/modules/_hooks/src/index.ts | 1 + .../src/Dashboard/Dashboard.module.css | 60 ++- .../dashboard/src/Dashboard/FavoriteTools.tsx | 107 +++-- .../workspace/src/AppsSideNav/AppsSideNav.tsx | 92 ++--- client/package.json | 3 +- ..._added_on_userfavorite_tool_id_and_more.py | 38 -- designsafe/apps/api/datafiles/models.py | 14 +- designsafe/apps/api/datafiles/urls.py | 33 +- designsafe/apps/api/datafiles/views.py | 342 +++++++++------- designsafe/apps/workspace/api/urls.py | 14 +- designsafe/apps/workspace/api/views.py | 42 ++ .../migrations/0019_userfavorite.py} | 12 +- .../apps/workspace/models/user_favorites.py | 17 + designsafe/urls.py | 380 +++++++++++------- 17 files changed, 766 insertions(+), 531 deletions(-) delete mode 100644 client/modules/_common_components/src/lib/favouritesApi.ts create mode 100644 client/modules/_hooks/src/favouritesApi.ts delete mode 100644 designsafe/apps/api/datafiles/migrations/0003_userfavorite_added_on_userfavorite_tool_id_and_more.py rename designsafe/apps/{api/datafiles/migrations/0002_userfavorite.py => workspace/migrations/0019_userfavorite.py} (73%) create mode 100644 designsafe/apps/workspace/models/user_favorites.py diff --git a/client/modules/_common_components/src/index.ts b/client/modules/_common_components/src/index.ts index 9f33265cc3..2a32efb3e6 100644 --- a/client/modules/_common_components/src/index.ts +++ b/client/modules/_common_components/src/index.ts @@ -2,4 +2,4 @@ export * from './datafiles'; export { PrimaryButton, SecondaryButton } from './lib/Button'; export { Icon } from './lib/Icon'; export { Spinner } from './lib/Spinner'; -export * from './lib/favouritesApi'; + diff --git a/client/modules/_common_components/src/lib/favouritesApi.ts b/client/modules/_common_components/src/lib/favouritesApi.ts deleted file mode 100644 index b07c86351b..0000000000 --- a/client/modules/_common_components/src/lib/favouritesApi.ts +++ /dev/null @@ -1,66 +0,0 @@ -import axios from 'axios'; - -const getCSRFToken = (): string => { - const match = document.cookie.match(/(^| )csrftoken=([^;]+)/); - return match ? match[2] : ''; -}; - -const axiosInstance = axios.create({ - withCredentials: true, - headers: { - 'Content-Type': 'application/json', - }, -}); - -export interface FavoriteTool { - tool_id: string; - version?: string; -} - -export const getUserFavorites = async (): Promise => { - try { - const response = await axiosInstance.get('/api/datafiles/favorites/'); - return response.data; - } catch (error) { - console.error('Error fetching favorites:', error); - return []; - } -}; - -export const addFavorite = async (toolId: string): Promise => { - try { - const csrfToken = getCSRFToken(); - await axiosInstance.post( - '/api/datafiles/favorites/add/', - { tool_id: toolId }, - { - headers: { - 'X-CSRFToken': csrfToken, - }, - } - ); - return true; - } catch (error) { - console.error(`Error adding favorite (${toolId}):`, error); - return false; - } -}; - -export const removeFavorite = async (toolId: string): Promise => { - try { - const csrfToken = getCSRFToken(); - await axiosInstance.post( - '/api/datafiles/favorites/remove/', - { tool_id: toolId }, - { - headers: { - 'X-CSRFToken': csrfToken, - }, - } - ); - return true; - } catch (error) { - console.error(`Error removing favorite (${toolId}):`, error); - return false; - } -}; diff --git a/client/modules/_hooks/src/favouritesApi.ts b/client/modules/_hooks/src/favouritesApi.ts new file mode 100644 index 0000000000..02909ce44d --- /dev/null +++ b/client/modules/_hooks/src/favouritesApi.ts @@ -0,0 +1,74 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; + +const getCSRFToken = (): string => { + const match = document.cookie.match(/(^| )csrftoken=([^;]+)/); + return match ? match[2] : ''; +}; + +const axiosInstance = axios.create({ + withCredentials: true, + headers: { + 'Content-Type': 'application/json', + }, +}); + +export interface FavoriteTool { + tool_id: string; + version?: string; +} + +const fetchFavorites = async (): Promise => { + const response = await axiosInstance.get('/api/workspace/user-favorites/'); + return response.data; +}; + +const postAddFavorite = async (toolId: string): Promise => { + const csrfToken = getCSRFToken(); + await axiosInstance.post( + '/api/workspace/user-favorites/add/', + { tool_id: toolId }, + { headers: { 'X-CSRFToken': csrfToken } } + ); +}; + +const postRemoveFavorite = async (toolId: string): Promise => { + const csrfToken = getCSRFToken(); + await axiosInstance.post( + '/api/workspace/user-favorites/remove/', + { tool_id: toolId }, + { headers: { 'X-CSRFToken': csrfToken } } + ); +}; + +export const getUserFavorites = fetchFavorites; +export const addFavorite = postAddFavorite; +export const removeFavorite = postRemoveFavorite; + +export const useFavorites = () => { + return useQuery({ + queryKey: ['favorites'], + queryFn: fetchFavorites, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +}; + +export const useAddFavorite = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: postAddFavorite, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['favorites'] }); + }, + }); +}; + +export const useRemoveFavorite = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: postRemoveFavorite, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['favorites'] }); + }, + }); +}; diff --git a/client/modules/_hooks/src/index.ts b/client/modules/_hooks/src/index.ts index 701dc2d83d..2f6e3aa4f7 100644 --- a/client/modules/_hooks/src/index.ts +++ b/client/modules/_hooks/src/index.ts @@ -8,3 +8,4 @@ export * from './datafiles'; export * from './systems'; export * from './notifications'; export * from './onboarding'; +export * from './favouritesApi'; \ No newline at end of file diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.module.css b/client/modules/dashboard/src/Dashboard/Dashboard.module.css index 9656b997c6..a9fa94b3a1 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.module.css +++ b/client/modules/dashboard/src/Dashboard/Dashboard.module.css @@ -179,6 +179,13 @@ color: gold; cursor: pointer; z-index: 999; + user-select: none; + transition: transform 0.2s ease; +} + +.favoriteToggle:hover { + transform: scale(1.2); + color: darkorange; } .favoritePanel { @@ -187,11 +194,24 @@ right: 20px; background: white; border: 1px solid #ccc; - padding: 12px; - width: 250px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + padding: 16px 20px; + width: 280px; + max-height: 400px; + overflow-y: auto; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.2); z-index: 1000; - border-radius: 8px; + border-radius: 10px; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +} + +.favoritePanel h4 { + margin-top: 0; + margin-bottom: 12px; + color: #333; + font-weight: 600; + border-bottom: 1px solid #eee; + padding-bottom: 8px; + font-size: 1.25rem; } .favoriteList { @@ -204,17 +224,39 @@ display: flex; justify-content: space-between; align-items: center; - margin: 6px 0; + margin: 8px 0; + font-size: 1rem; +} + +.favoriteItem a { + color: #007bff; + text-decoration: none; + transition: color 0.2s ease; +} + +.favoriteItem a:hover { + color: #0056b3; + text-decoration: underline; } .starIcon { - font-size: 18px; + font-size: 20px; color: gold; cursor: pointer; - transition: transform 0.2s ease; + background-color: transparent; + border: none; + padding: 0; + margin-left: 10px; + user-select: none; + transition: transform 0.2s ease, color 0.2s ease; } -.starIcon:hover { - transform: scale(1.2); +.starIcon:hover:not(:disabled) { + transform: scale(1.3); color: darkorange; } + +.starIcon:disabled { + cursor: not-allowed; + opacity: 0.6; +} diff --git a/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx b/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx index fe341c9ea9..64ac7b7f7a 100644 --- a/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx +++ b/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState } from 'react'; -import { getUserFavorites, removeFavorite } from '@client/common-components'; +import React, { useState, useRef, useEffect } from 'react'; +import { useFavorites, useRemoveFavorite } from '@client/hooks'; import { useAppsListing } from '@client/hooks'; import styles from './Dashboard.module.css'; @@ -23,21 +23,48 @@ const makeToolKey = (tool_id: string, version?: string) => version ? `${tool_id}-${version}` : tool_id; const parseToolId = (toolId: string): FavoriteTool => { - const versionMatch = toolId.match(/(native|s|ds)?\d+(\.\d+)+$/); - if (!versionMatch) return { tool_id: toolId }; - const version = versionMatch[0]; - const tool_id = toolId.slice(0, -version.length).replace(/-$/, ''); - return { tool_id, version }; + const parts = toolId.split('-'); + if (parts.length > 1) { + const versionPart = parts[parts.length - 1]; + if (/^\d+(\.\d+)*$/.test(versionPart)) { + return { + tool_id: parts.slice(0, -1).join('-'), + version: versionPart, + }; + } + } + return { tool_id: toolId }; }; const FavoriteTools = () => { - const [favorites, setFavorites] = useState([]); - const [showPanel, setShowPanel] = useState(false); - const [isLoadingFavorites, setIsLoadingFavorites] = useState(true); - const [favoritesError, setFavoritesError] = useState(null); + const { data: favoritesData, isLoading: isLoadingFavorites, isError: isFavoritesError } = useFavorites(); + const removeFavoriteMutation = useRemoveFavorite(); const [removingIds, setRemovingIds] = useState>(new Set()); + const [showPanel, setShowPanel] = useState(false); const { data, isLoading, isError } = useAppsListing(); + const panelRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(event.target as Node)) { + setShowPanel(false); + } + }; + + if (showPanel) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [showPanel]); + + const favorites = (favoritesData ?? []).map((fav) => parseToolId(fav.tool_id)); + const allApps: AppData[] = data?.categories?.flatMap((cat) => cat.apps.map((app) => ({ @@ -64,7 +91,6 @@ const FavoriteTools = () => { const label = matchedApp.definition?.notes?.label || matchedApp.definition.id; - // 🚨 use `/workspace/` instead of `/apps/` const href = matchedApp.version ? `/workspace/${matchedApp.definition.id}?appVersion=${matchedApp.version}` : `/workspace/${matchedApp.definition.id}`; @@ -85,34 +111,11 @@ const FavoriteTools = () => { href: string; }[]; - useEffect(() => { - async function fetchFavorites() { - setIsLoadingFavorites(true); - setFavoritesError(null); - try { - const data = await getUserFavorites(); - const parsedFavorites = data.map((fav) => parseToolId(fav.tool_id)); - setFavorites(parsedFavorites); - } catch (err) { - console.error('Failed to load favorites:', err); - setFavoritesError('Failed to load favorites.'); - } finally { - setIsLoadingFavorites(false); - } - } - fetchFavorites(); - }, []); - const handleRemove = async (toolKey: string) => { if (removingIds.has(toolKey)) return; setRemovingIds((prev) => new Set(prev).add(toolKey)); try { - await removeFavorite(toolKey); - setFavorites((prev) => - prev.filter( - (tool) => makeToolKey(tool.tool_id, tool.version) !== toolKey - ) - ); + await removeFavoriteMutation.mutateAsync(toolKey); } catch (err) { console.error('Failed to remove favorite:', err); } finally { @@ -124,10 +127,22 @@ const FavoriteTools = () => { } }; - if (isLoadingFavorites) return
Loading favorite tools...
; - if (favoritesError) return
{favoritesError}
; - if (isLoading) return
Loading apps data...
; - if (isError) return
Failed to load apps data.
; + const handleToolClick = (toolName: string, toolPath: string) => { + const correctedPath = toolPath.startsWith('/workspace/') + ? toolPath + : `/workspace/${toolPath.replace(/^\//, '')}`; + const existing: { label: string; path: string }[] = JSON.parse( + localStorage.getItem('recentTools') || '[]' + ); + const updated = [ + { label: toolName, path: correctedPath }, + ...existing.filter((t) => t.path !== correctedPath), + ].slice(0, 5); + localStorage.setItem('recentTools', JSON.stringify(updated)); + }; + + if (isLoadingFavorites || isLoading) return
Loading favorite tools...
; + if (isFavoritesError || isError) return
Failed to load data.
; return ( <> @@ -135,12 +150,14 @@ const FavoriteTools = () => { className={styles.favoriteToggle} onClick={() => setShowPanel(!showPanel)} aria-label="Toggle Favorites Panel" + aria-pressed={showPanel} title="Toggle Favorites Panel" + type="button" > ★ {showPanel && ( -
+

Your Favorite Tools

{resolvedFavorites.length === 0 ? (

No favorite tools yet.

@@ -148,7 +165,13 @@ const FavoriteTools = () => {
    {resolvedFavorites.map((tool) => (
  • - + handleToolClick(tool.label, tool.href)} + style={{ flexGrow: 1, textDecoration: 'none', color: '#007bff' }} + > {tool.label}