diff --git a/client/modules/_hooks/src/favouritesApi.ts b/client/modules/_hooks/src/favouritesApi.ts new file mode 100644 index 0000000000..7c3196714a --- /dev/null +++ b/client/modules/_hooks/src/favouritesApi.ts @@ -0,0 +1,104 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from './apiClient'; + +export interface FavoriteTool { + tool_id: string; + version?: string; +} + +const fetchFavorites = async ({ + signal, +}: { + signal: AbortSignal; +}): Promise => { + const res = await apiClient.get('/api/workspace/user-favorites/', { signal }); + return res.data; +}; + +const addFavorite = async (toolId: string): Promise => { + await apiClient.post('/api/workspace/user-favorites/', { tool_id: toolId }); +}; + +const removeFavorite = async (toolId: string): Promise => { + await apiClient.post('/api/workspace/user-favorites/remove/', { + tool_id: toolId, + }); +}; + +export const useFavorites = () => { + return useQuery({ + queryKey: ['workspace', 'favorites'], + queryFn: fetchFavorites, + staleTime: 5 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); +}; + +export const useAddFavorite = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: addFavorite, + onMutate: async (toolId) => { + await queryClient.cancelQueries({ queryKey: ['workspace', 'favorites'] }); + + const previousFavorites = queryClient.getQueryData([ + 'workspace', + 'favorites', + ]); + + queryClient.setQueryData( + ['workspace', 'favorites'], + (old = []) => [...old, { tool_id: toolId }] + ); + + return { previousFavorites }; + }, + onError: (_err, _toolId, context) => { + if (context?.previousFavorites) { + queryClient.setQueryData( + ['workspace', 'favorites'], + context.previousFavorites + ); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace', 'favorites'] }); + }, + }); +}; + +export const useRemoveFavorite = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: removeFavorite, + onMutate: async (toolId) => { + await queryClient.cancelQueries({ queryKey: ['workspace', 'favorites'] }); + + const previousFavorites = queryClient.getQueryData([ + 'workspace', + 'favorites', + ]); + + queryClient.setQueryData( + ['workspace', 'favorites'], + (old = []) => old.filter((fav) => fav.tool_id !== toolId) + ); + + return { previousFavorites }; + }, + onError: (_err, _toolId, context) => { + if (context?.previousFavorites) { + queryClient.setQueryData( + ['workspace', 'favorites'], + context.previousFavorites + ); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace', 'favorites'] }); + }, + }); +}; diff --git a/client/modules/_hooks/src/index.ts b/client/modules/_hooks/src/index.ts index 701dc2d83d..ee429505ca 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'; diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.module.css b/client/modules/dashboard/src/Dashboard/Dashboard.module.css index 45c2aa47e9..fadcc6c90e 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.module.css +++ b/client/modules/dashboard/src/Dashboard/Dashboard.module.css @@ -1,7 +1,55 @@ -/* - * Replace this with your own classes - * - * e.g. - * .container { - * } -*/ +.dashboardContainer { + display: flex; + min-height: 100vh; + background-color: #fafafa; + overflow: hidden; +} + +.sidebar { + width: 260px; + background-color: #f5f7fa; + padding: 1.5rem; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.05); + position: sticky; + top: 80px; + border-radius: 8px; + min-height: calc(100vh - 80px); + overflow-y: auto; +} + +.sidebarMargin { + flex: 1; + padding: 1rem 2rem; + overflow-y: auto; + min-height: calc(100vh - 80px); +} + +.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.5rem; + font-weight: 500; + transition: all 0.2s ease; + text-decoration: none; +} + +.sidebarLink:hover { + color: #007bff; + text-decoration: underline; + padding-left: 5px; +} + +.sidebarIcon { + margin-right: 8px; + vertical-align: middle; +} diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.tsx b/client/modules/dashboard/src/Dashboard/Dashboard.tsx index 5a2aad7804..7a96ebe1f9 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.tsx +++ b/client/modules/dashboard/src/Dashboard/Dashboard.tsx @@ -1,13 +1,37 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Collapse, Typography } from 'antd'; +import Quicklinks from './QuickLinksNavbar'; +import RecentlyAccessed from './RecentlyAccessed'; +import RecentProjects from './RecentProjects'; +import { TicketList } from './TicketList'; import styles from './Dashboard.module.css'; -/* eslint-disable-next-line */ -export interface DashboardProps {} +const { Text } = Typography; +const { Panel } = Collapse; +const queryClient = new QueryClient(); -export function Dashboard(props: DashboardProps) { +export function Dashboard() { return ( -
-

Welcome to Dashboard!

-
+ +
+
+ +
+
+ + Recent Projects} key="1"> + + + Recently Accessed Tools} key="2"> + + + My Tickets} key="3"> + + + +
+
+
); } diff --git a/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx b/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx new file mode 100644 index 0000000000..147d2dfc5f --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx @@ -0,0 +1,158 @@ +import React, { useState } from 'react'; +import { Typography, List, Button } from 'antd'; +import { useFavorites, useRemoveFavorite, useAppsListing } from '@client/hooks'; +import type { TPortalApp } from '@client/hooks'; + +const { Link, Text } = Typography; + +interface Favorite { + app_id: string; + version?: string; + id: string; +} + +interface RecentTool { + label: string; + path: string; +} + +const parseToolId = (tool_id: string): { app_id: string; version?: string } => { + const parts = tool_id.split('-'); + if (parts.length > 1 && /^\d+(\.\d+)*$/.test(parts[parts.length - 1])) { + return { + app_id: parts.slice(0, -1).join('-'), + version: parts[parts.length - 1], + }; + } + return { app_id: tool_id }; +}; + +const makeFavoriteKey = (fav: Favorite) => + fav.version ? `${fav.app_id}-${fav.version}` : fav.app_id; + +const QuickLinksMenu: React.FC = () => { + const [showFavorites, setShowFavorites] = useState(false); + const { data: favoritesData, isLoading: loadingFavs } = useFavorites(); + const { data: appsData, isLoading: loadingApps } = useAppsListing(); + const removeFavoriteMutation = useRemoveFavorite(); + const [removingIds, setRemovingIds] = useState>(new Set()); + + if (loadingFavs || loadingApps) return
Loading favorites...
; + + const allApps: TPortalApp[] = + appsData?.categories.flatMap((cat) => cat.apps) ?? []; + + const favorites: Favorite[] = (favoritesData ?? []).map((fav) => { + const { app_id, version } = parseToolId(fav.tool_id); + return { app_id, version, id: fav.tool_id }; + }); + + const resolvedFavorites = favorites + .map((fav) => { + const matchedApp = allApps.find( + (app) => + app.app_id === fav.app_id && + (!fav.version || app.version === fav.version) + ); + if (!matchedApp) return null; + return { + key: makeFavoriteKey(fav), + app: matchedApp, + id: fav.id, + }; + }) + .filter(Boolean) as { key: string; app: TPortalApp; id: string }[]; + + const handleRemove = async (key: string) => { + if (removingIds.has(key)) return; + + setRemovingIds((prev) => new Set(prev).add(key)); + + try { + await removeFavoriteMutation.mutateAsync(key); + } catch (error) { + console.error('Failed to remove favorite:', error); + } finally { + setRemovingIds((prev) => { + const newSet = new Set(prev); + newSet.delete(key); + return newSet; + }); + } + }; + + const handleToolClick = (app: TPortalApp) => { + const href = app.version + ? `/workspace/${app.app_id}?appVersion=${app.version}` + : `/workspace/${app.app_id}`; + + const recent: RecentTool[] = JSON.parse( + localStorage.getItem('recentTools') ?? '[]' + ); + const updated = [ + { label: app.label, path: href }, + ...recent.filter((r) => r.path !== href), + ].slice(0, 5); + localStorage.setItem('recentTools', JSON.stringify(updated)); + + window.open(href, '_blank', 'noopener,noreferrer'); + }; + + return ( + + ); +}; + +export default QuickLinksMenu; diff --git a/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx b/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx new file mode 100644 index 0000000000..c98d8ab453 --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx @@ -0,0 +1,24 @@ +import { Typography, Space } from 'antd'; +import FavoriteTools from './FavoriteTools'; + +const { Title, Link } = Typography; + +const Quicklinks = () => { + return ( +
+ Quick Links + + + Manage Account + Tools & Applications + Training + +
+ ); +}; + +export default Quicklinks; diff --git a/client/modules/dashboard/src/Dashboard/RecentProjects.tsx b/client/modules/dashboard/src/Dashboard/RecentProjects.tsx new file mode 100644 index 0000000000..30d733c61b --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/RecentProjects.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useState } from 'react'; +import axios from 'axios'; +import { Table, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import styles from './Dashboard.module.css'; + +type Project = { + uuid: string; + title: string; + projectId: string; + lastUpdated: string; + pi: string; +}; + +interface RawUser { + role: string; + fname: string; + lname: string; +} + +interface RawProject { + uuid: string; + lastUpdated: string; + value: { + title: string; + projectId: string; + users?: RawUser[]; + }; +} + +const { Link, Text } = Typography; + +const RecentProjects: React.FC = () => { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const fetchProjects = async () => { + setLoading(true); + try { + const response = await axios.get( + '/api/projects/v2/?offset=0&limit=100' + ); + const rawProjects: RawProject[] = response.data.result; + + const mapped: Project[] = rawProjects.map((proj) => { + const piUser = proj.value.users?.find((user) => 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); + } finally { + setLoading(false); + } + }; + + fetchProjects(); + }, []); + + if (projects.length === 0) return null; + + const columns: ColumnsType = [ + { + title: 'Title', + dataIndex: 'title', + key: 'title', + render: (text: string, record: Project) => ( + + {text || record.projectId} + + ), + }, + { + title: 'PI', + dataIndex: 'pi', + key: 'pi', + }, + { + title: 'ID', + dataIndex: 'projectId', + key: 'projectId', + className: styles.projectId, + render: (text: string) => {text}, + }, + ]; + + return ( +
+ + + ); +}; + +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..642bb3877c --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/RecentlyAccessed.tsx @@ -0,0 +1,49 @@ +import React, { useEffect, useState } from 'react'; +import { List, Typography } from 'antd'; +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([]); + } + } + }, []); + + if (recentTools.length === 0) return null; + + return ( +
+ ( + { + window.location.href = tool.path; + }} + > + {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..7642d34636 --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/TicketList.tsx @@ -0,0 +1,254 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import axios from 'axios'; +import { + Table, + Button, + Input, + Alert, + Space, + Typography, + Spin, + Modal, +} from 'antd'; +import { + CloseOutlined, + CommentOutlined, + PlusOutlined, +} from '@ant-design/icons'; +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); + + // Normalize status: replace unknown or unexpected values with 'new' + const normalizeStatus = (status?: string) => { + if (!status) return 'unknown'; + const s = status.toLowerCase().trim(); + const allowedStatuses = ['new', 'open', 'pending', 'resolved', 'closed']; + return allowedStatuses.includes(s) ? s : 'new'; + }; + + const normalizeTicket = useCallback( + (ticket: RawTicket): NormalizedTicket => ({ + id: ticket.id, + subject: ticket.subject || ticket.Subject || 'No Subject', + status: normalizeStatus(ticket.status || ticket.Status), + created_at: ticket.created_at || ticket.Created || '', + updated_at: ticket.updated_at || ticket.LastUpdated, + }), + [] + ); + + const fetchTickets = useCallback(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); + } + }, [normalizeTicket]); + + useEffect(() => { + fetchTickets(); + }, [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 = (ticketId: number) => { + Modal.confirm({ + title: 'Confirm Close', + content: 'Are you sure you want to close this ticket?', + okText: 'Yes', + cancelText: 'No', + onOk: async () => { + try { + await axios.post(`/help/tickets/${ticketId}/close/`); + fetchTickets(); + } catch { + Modal.error({ + title: 'Error', + content: 'Failed to close ticket.', + }); + } + }, + }); + }; + + const columns = [ + { + title: 'Status', + dataIndex: 'status', + key: 'status', + width: 120, + render: (status: string) => ( + + {status} + + ), + }, + { + title: 'Ticket ID / Subject', + dataIndex: 'subject', + key: 'subject', + render: (_: unknown, record: NormalizedTicket) => ( + + {record.id} / {record.subject} + + ), + }, + { + title: 'Last Updated', + dataIndex: 'updated_at', + key: 'updated_at', + width: 180, + render: (date: string | undefined, record: NormalizedTicket) => + formatDate(date ?? record.created_at), + }, + { + title: 'Actions', + key: 'actions', + width: 160, + render: (_: unknown, record: NormalizedTicket) => { + const resolved = isResolved(record.status); + return ( + + + {!resolved && ( + + )} + + ); + }, + }, + ]; + + return ( +
+
+ +
+ +
+ + + setFilter(e.target.value)} + style={{ maxWidth: 300 }} + /> +
+ + {loading ? ( +
+ +
+ ) : error ? ( + + ) : filteredTickets.length === 0 ? ( + + ) : ( +
+
+ + )} + + ); +}; diff --git a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx index c635099b1a..574808a2bf 100644 --- a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx +++ b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx @@ -1,133 +1,205 @@ -import React from 'react'; -import { Menu, MenuProps } from 'antd'; +import React, { useState } from 'react'; +import { Menu, MenuProps, Switch } from 'antd'; import { NavLink } from 'react-router-dom'; -import { TAppCategory, TPortalApp } from '@client/hooks'; +import { + TAppCategory, + TPortalApp, + useFavorites, + useAddFavorite, + useRemoveFavorite, +} from '@client/hooks'; import { useGetAppParams } from '../utils'; export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ categories, }) => { + const { data: favoritesData = [], isLoading: isLoadingFavorites } = + useFavorites(); + + const addFavoriteMutation = useAddFavorite(); + const removeFavoriteMutation = useRemoveFavorite(); + + const [updatingToolIds, setUpdatingToolIds] = useState>( + new Set() + ); + const { appId, appVersion } = useGetAppParams(); + + if (isLoadingFavorites) { + return ( +
+
+ Applications: +
+
Loading favorites...
+
+ ); + } + + const favoriteToolIds = Array.isArray(favoritesData) + ? favoritesData.map((fav) => fav.tool_id) + : []; + + const handleStarClick = async (toolId: string) => { + const isFavorite = favoriteToolIds.includes(toolId); + setUpdatingToolIds((prev) => new Set(prev).add(toolId)); + + try { + if (isFavorite) { + await removeFavoriteMutation.mutateAsync(toolId); + } else { + await addFavoriteMutation.mutateAsync(toolId); + } + } catch (err) { + console.error('Failed to update favorites', err); + } finally { + setUpdatingToolIds((prev) => { + const newSet = new Set(prev); + newSet.delete(toolId); + return newSet; + }); + } + }; + 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 getCategoryApps = (category: TAppCategory): MenuItem[] => { + const bundles: Record = {}; const categoryItems: MenuItem[] = []; category.apps.forEach((app) => { + const toolId = app.version ? `${app.app_id}-${app.version}` : app.app_id; + const isFavorite = favoriteToolIds.includes(toolId); + const linkPath = `${app.app_id}${ + app.version ? `?appVersion=${app.version}` : '' + }`; + const linkLabel = + app.shortLabel || app.label || app.bundle_label || 'Unknown'; + + const switchControl = ( + e.stopPropagation()} style={{ marginLeft: 6 }}> + handleStarClick(toolId)} + checkedChildren="★" + unCheckedChildren="☆" + /> + + ); + + const labelContent = ( +
+ handleToolClick(linkLabel, linkPath)} + style={{ + flex: 1, + whiteSpace: 'normal', + overflowWrap: 'break-word', + wordBreak: 'break-word', + color: 'inherit', + textDecoration: '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( - - {app.shortLabel || app.label || app.bundle_label} - , - `${app.app_id}${app.version}${app.bundle_id}`, - app.priority - ) - ); - } else { + if (!bundles[bundleKey]) { 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, + apps: [], + label: app.bundle_label || 'Bundle', }; } + bundles[bundleKey].apps.push(item); } else { - categoryItems.push( - getItem( - - {app.shortLabel || app.label || app.bundle_label} - , - `${app.app_id}${app.version}${app.bundle_id}`, - app.priority - ) - ); + categoryItems.push(item); } }); + const bundleItems = Object.entries(bundles).map( - ([bundleKey, bundle], index) => + ([bundleKey, bundle], idx) => getItem( `${bundle.label} [${bundle.apps.length}]`, bundleKey, - index, + idx, 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 { appId, appVersion } = useGetAppParams(); + 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 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) => cat.apps.includes(currentApp as TPortalApp) ); + const currentSubMenu = currentApp?.is_bundled ? `${currentApp.bundle_label}${currentApp.bundle_id}` : ''; - const selectedKey = `${appId}${appVersion || ''}${currentApp?.bundle_id}`; + + const selectedKey = appVersion ? `${appId}-${appVersion}` : appId; return ( - <> +
= ({
- +
+ ); +}; + +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/src/main.tsx b/client/src/main.tsx index bb8e6b55e3..e3746ffcc3 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) { - + + + diff --git a/designsafe/apps/api/datafiles/models.py b/designsafe/apps/api/datafiles/models.py index b4ee5868dc..1c28b24619 100644 --- a/designsafe/apps/api/datafiles/models.py +++ b/designsafe/apps/api/datafiles/models.py @@ -29,4 +29,4 @@ class PublicationSymlink(models.Model): ) def __str__(self): - return f"{self.tapis_accessor} -> {self.type}" + return f"{self.tapis_accessor} -> {self.type}" \ No newline at end of file diff --git a/designsafe/apps/api/datafiles/urls.py b/designsafe/apps/api/datafiles/urls.py index 68d85d93c5..02a4f6fdcc 100644 --- a/designsafe/apps/api/datafiles/urls.py +++ b/designsafe/apps/api/datafiles/urls.py @@ -11,4 +11,4 @@ url(r'^(?P[\w.-]+)/(?P[\w.-]+)/(?P[\w.-]+)/(?P[\w.-]+)/$', DataFilesView.as_view(), name='agave_files'), url(r'^microsurvey/$', MicrosurveyView.as_view(), name='microsurvey') -] +] \ No newline at end of file diff --git a/designsafe/apps/api/datafiles/utils.py b/designsafe/apps/api/datafiles/utils.py index d19a6ce1bb..c0740a7d19 100644 --- a/designsafe/apps/api/datafiles/utils.py +++ b/designsafe/apps/api/datafiles/utils.py @@ -169,4 +169,4 @@ def create_meta(path, system, meta): system=system, meta=meta ) - sa_client.meta.addMetadata(body=json.dumps(meta_body)) + sa_client.meta.addMetadata(body=json.dumps(meta_body)) \ No newline at end of file diff --git a/designsafe/apps/api/datafiles/views.py b/designsafe/apps/api/datafiles/views.py index 90b90e8914..dea547a5fa 100644 --- a/designsafe/apps/api/datafiles/views.py +++ b/designsafe/apps/api/datafiles/views.py @@ -203,4 +203,4 @@ def put(self, request): counter = DataFilesSurveyCounter.objects.all()[0] counter.count += 1 counter.save() - return JsonResponse({'show': (counter.count % 7 == 0)}) + return JsonResponse({'show': (counter.count % 7 == 0)}) \ No newline at end of file diff --git a/designsafe/apps/workspace/api/urls.py b/designsafe/apps/workspace/api/urls.py index 1b239767a8..028b2a3aaa 100644 --- a/designsafe/apps/workspace/api/urls.py +++ b/designsafe/apps/workspace/api/urls.py @@ -1,5 +1,4 @@ -"""Workpace API Urls -""" +"""Workpace API Urls""" from django.urls import path, re_path from designsafe.apps.workspace.api import views @@ -19,4 +18,12 @@ re_path(r"^jobs/(?P\w+)/?$", views.JobsView.as_view()), path("jobs", views.JobsView.as_view()), path("allocations", views.AllocationsView.as_view()), + path( + "user-favorites/", views.UserFavoriteList.as_view(), name="user_favorite_list" + ), + path( + "user-favorites/remove/", + views.RemoveFavoriteTool.as_view(), + name="remove_favorite_tool", + ), ] diff --git a/designsafe/apps/workspace/api/views.py b/designsafe/apps/workspace/api/views.py index 67b60b5685..d1fd68a227 100644 --- a/designsafe/apps/workspace/api/views.py +++ b/designsafe/apps/workspace/api/views.py @@ -28,6 +28,10 @@ from designsafe.apps.api.users.utils import get_allocations from designsafe.apps.workspace.api.utils import check_job_for_timeout from designsafe.apps.onboarding.steps.system_access_v3 import create_system_credentials +from django.views import View +from django.http import JsonResponse, HttpResponseBadRequest +from django.contrib.auth.mixins import LoginRequiredMixin +from designsafe.apps.workspace.models.user_favorites import UserFavorite logger = logging.getLogger(__name__) @@ -918,3 +922,40 @@ def get(self, request): "response": data, } ) + + +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) + + 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/workspace/migrations/0019_userfavorite.py b/designsafe/apps/workspace/migrations/0019_userfavorite.py new file mode 100644 index 0000000000..cfecec6159 --- /dev/null +++ b/designsafe/apps/workspace/migrations/0019_userfavorite.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.20 on 2025-06-30 16:49 + +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), + ("workspace", "0018_appvariant_external_href"), + ] + + operations = [ + migrations.CreateModel( + name="UserFavorite", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("tool_id", models.CharField(max_length=100)), + ("added_on", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("user", "tool_id")}, + }, + ), + ] diff --git a/designsafe/apps/workspace/models/user_favorites.py b/designsafe/apps/workspace/models/user_favorites.py new file mode 100644 index 0000000000..68133f8ee7 --- /dev/null +++ b/designsafe/apps/workspace/models/user_favorites.py @@ -0,0 +1,24 @@ +""" +Models related to user favorites functionality. +""" + +from django.db import models +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class UserFavorite(models.Model): + """Model to store user favorite tools.""" + + user = models.ForeignKey(User, on_delete=models.CASCADE) + tool_id = models.CharField( + max_length=100 + ) # Consider UUIDField if tool IDs are UUIDs + added_on = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("user", "tool_id") # Prevent duplicates + + def __str__(self): + return f"{self.user.username} - {self.tool_id}" diff --git a/designsafe/urls.py b/designsafe/urls.py index 8205038581..a8756c0d89 100644 --- a/designsafe/urls.py +++ b/designsafe/urls.py @@ -1,4 +1,4 @@ -""" DesignSafe-CI URL Configuration. +"""DesignSafe-CI URL Configuration. The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/1.8/topics/http/urls/ @@ -37,156 +37,264 @@ # sitemap - classes must be imported and added to sitemap dictionary from django.contrib.sitemaps.views import sitemap, index -from designsafe.sitemaps import StaticViewSitemap, DynamicViewSitemap, HomeSitemap, ProjectSitemap, SubSitemap, DesignSafeCMSSitemap +from designsafe.sitemaps import ( + StaticViewSitemap, + DynamicViewSitemap, + HomeSitemap, + ProjectSitemap, + SubSitemap, + DesignSafeCMSSitemap, +) sitemaps = { - 'home': HomeSitemap, - 'subsite': SubSitemap, - 'static': StaticViewSitemap, - 'dynamic': DynamicViewSitemap, - 'projects': ProjectSitemap, - 'cmspages': DesignSafeCMSSitemap, - } + "home": HomeSitemap, + "subsite": SubSitemap, + "static": StaticViewSitemap, + "dynamic": DynamicViewSitemap, + "projects": ProjectSitemap, + "cmspages": DesignSafeCMSSitemap, +} urlpatterns = [ - # admin - path( - "admin/impersonate/stop/", - impersonate_views.stop_impersonate, - name="impersonate-stop", - ), - path( - "admin/impersonate/list/", - impersonate_views.list_users, - {"template": "impersonate/list_users.html"}, - name="impersonate-list", - ), - path( - "admin/impersonate/search/", - impersonate_views.search_users, - {"template": "impersonate/search_users.html"}, - name="impersonate-search", - ), - path( - "admin/impersonate//", - impersonate_views.impersonate, - name="impersonate-start", - ), - path("admin/", admin.site.urls), - - path( - "sitemap.xml", - index, - {"sitemaps": sitemaps}, - name="django.contrib.sitemaps.views.index", - ), - path( - "sitemap-
.xml", - sitemap, - {"sitemaps": sitemaps}, - name="django.contrib.sitemaps.views.sitemap", + # admin + path( + "admin/impersonate/stop/", + impersonate_views.stop_impersonate, + name="impersonate-stop", + ), + path( + "admin/impersonate/list/", + impersonate_views.list_users, + {"template": "impersonate/list_users.html"}, + name="impersonate-list", + ), + path( + "admin/impersonate/search/", + impersonate_views.search_users, + {"template": "impersonate/search_users.html"}, + name="impersonate-search", + ), + path( + "admin/impersonate//", + impersonate_views.impersonate, + name="impersonate-start", + ), + path("admin/", admin.site.urls), + path( + "sitemap.xml", + index, + {"sitemaps": sitemaps}, + name="django.contrib.sitemaps.views.index", + ), + path( + "sitemap-
.xml", + sitemap, + {"sitemaps": sitemaps}, + name="django.contrib.sitemaps.views.sitemap", + ), + # terms-and-conditions + url(r"^terms/", include("termsandconditions.urls")), + # RAMP verification + url( + r"{}.html$".format(settings.RAMP_VERIFICATION_ID), + TemplateView.as_view(template_name="ramp_verification.html"), + ), + # api urls, just for the samples. + url( + r"^applications/", + include( + ("designsafe.apps.applications.urls", "desigsnafe.apps.applications"), + namespace="designsafe_applications", ), - - # terms-and-conditions - url(r'^terms/', include('termsandconditions.urls')), - - # RAMP verification - url(r'{}.html$'.format(settings.RAMP_VERIFICATION_ID), TemplateView.as_view(template_name='ramp_verification.html')), - - # api urls, just for the samples. - url(r'^applications/', include(('designsafe.apps.applications.urls', 'desigsnafe.apps.applications'), - namespace='designsafe_applications')), - # NOTE: /data is redirected to /data/browser via the CMS. - url(r'^data/', include(('designsafe.apps.data.urls', 'designsafe.apps.data'), namespace='designsafe_data')), - url(r'^workspace/', include(('designsafe.apps.workspace.urls', 'designsafe.apps.workspace'), - namespace='designsafe_workspace')), - url(r'^rw/workspace/', include(('designsafe.apps.workspace.urls', 'designsafe.apps.workspace'))), - path('api/workspace/', include('designsafe.apps.workspace.api.urls', namespace='workspace_api')), - url(r'^notifications/', include(('designsafe.apps.notifications.urls', 'designsafe.apps.notifications'), - namespace='designsafe_notifications')), - url(r'^search/', include(('designsafe.apps.search.urls', 'designsafe.apps.search'), - namespace='designsafe_search')), - url(r'^geo/', include(('designsafe.apps.geo.urls', 'designsafe.apps.geo'), - namespace='designsafe_geo')), - url(r'^recon-portal/', include(('designsafe.apps.rapid.urls', 'designsafe.apps.rapid'), - namespace='designsafe_rapid')), - - url(r'^nco/api/', include(('designsafe.apps.nco.api_urls', 'designsafe.apps.nco'), namespace='nco_api')), - - url(r'^nco/', include(('designsafe.apps.nco.urls', 'designsafe.apps.nco'), - namespace='nco')), - - - url(r'^api/', include(('designsafe.apps.api.urls', 'designsafe.apps.api'), namespace='designsafe_api')), - - - # auth - url(r'^account/', include(('designsafe.apps.accounts.urls', 'designsafe.apps.accounts'), - namespace='designsafe_accounts')), - url(r'^register/$', RedirectView.as_view( - pattern_name='designsafe_accounts:register', permanent=True), name='register'), - - # onboarding - url(r'^onboarding/', include(('designsafe.apps.onboarding.urls', 'designsafe.apps.onboarding'), - namespace='designsafe_onboarding')), - path('api/onboarding/', include('designsafe.apps.onboarding.api.urls', namespace='designsafe_onboarding_api')), - - - # dashboard - url(r'^dashboard/', include(('designsafe.apps.dashboard.urls', 'designsafe.apps.dashboard'), - namespace='designsafe_dashboard')), - + ), + # NOTE: /data is redirected to /data/browser via the CMS. + url( + r"^data/", + include( + ("designsafe.apps.data.urls", "designsafe.apps.data"), + namespace="designsafe_data", + ), + ), + url( + r"^workspace/", + include( + ("designsafe.apps.workspace.urls", "designsafe.apps.workspace"), + namespace="designsafe_workspace", + ), + ), + url( + r"^rw/workspace/", + include(("designsafe.apps.workspace.urls", "designsafe.apps.workspace")), + ), + path( + "api/workspace/", + include("designsafe.apps.workspace.api.urls", namespace="workspace_api"), + ), + url( + r"^notifications/", + include( + ("designsafe.apps.notifications.urls", "designsafe.apps.notifications"), + namespace="designsafe_notifications", + ), + ), + url( + r"^search/", + include( + ("designsafe.apps.search.urls", "designsafe.apps.search"), + namespace="designsafe_search", + ), + ), + url( + r"^geo/", + include( + ("designsafe.apps.geo.urls", "designsafe.apps.geo"), + namespace="designsafe_geo", + ), + ), + url( + r"^recon-portal/", + include( + ("designsafe.apps.rapid.urls", "designsafe.apps.rapid"), + namespace="designsafe_rapid", + ), + ), + url( + r"^nco/api/", + include( + ("designsafe.apps.nco.api_urls", "designsafe.apps.nco"), namespace="nco_api" + ), + ), + url( + r"^nco/", + include(("designsafe.apps.nco.urls", "designsafe.apps.nco"), namespace="nco"), + ), + url( + r"^api/", + include( + ("designsafe.apps.api.urls", "designsafe.apps.api"), + namespace="designsafe_api", + ), + ), + # auth + url( + r"^account/", + include( + ("designsafe.apps.accounts.urls", "designsafe.apps.accounts"), + namespace="designsafe_accounts", + ), + ), + url( + r"^register/$", + RedirectView.as_view( + pattern_name="designsafe_accounts:register", permanent=True + ), + name="register", + ), + # onboarding + url( + r"^onboarding/", + include( + ("designsafe.apps.onboarding.urls", "designsafe.apps.onboarding"), + namespace="designsafe_onboarding", + ), + ), + path( + "api/onboarding/", + include( + "designsafe.apps.onboarding.api.urls", namespace="designsafe_onboarding_api" + ), + ), + # dashboard + url( + r"^dashboard/", + include( + ("designsafe.apps.dashboard.urls", "designsafe.apps.dashboard"), + namespace="designsafe_dashboard", + ), + ), # need a fancier redirect here to pass the code param along - url(r'^activate/(?:(?P.+)/)?$', + url( + r"^activate/(?:(?P.+)/)?$", lambda x, code: HttpResponseRedirect( - reverse('designsafe_accounts:email_confirmation', - args=[code] if code else None) - )), - url(r'^password-reset/(?:(?P.+)/)?$', - lambda x, code: HttpResponseRedirect( - reverse('designsafe_accounts:password_reset', - args=[code] if code else None) - )), - - # box - url(r'^account/applications/box/', include(('designsafe.apps.box_integration.urls', 'designsafe.apps.box_integration'), - namespace='box_integration')), - + reverse( + "designsafe_accounts:email_confirmation", args=[code] if code else None + ) + ), + ), + url( + r"^password-reset/(?:(?P.+)/)?$", + lambda x, code: HttpResponseRedirect( + reverse("designsafe_accounts:password_reset", args=[code] if code else None) + ), + ), + # box + url( + r"^account/applications/box/", + include( + ("designsafe.apps.box_integration.urls", "designsafe.apps.box_integration"), + namespace="box_integration", + ), + ), # dropbox - url(r'^account/applications/dropbox/', include(('designsafe.apps.dropbox_integration.urls', 'designsafe.apps.dropbox_integration'), - namespace='dropbox_integration')), - + url( + r"^account/applications/dropbox/", + include( + ( + "designsafe.apps.dropbox_integration.urls", + "designsafe.apps.dropbox_integration", + ), + namespace="dropbox_integration", + ), + ), # googledrive - url(r'^account/applications/googledrive/', include(('designsafe.apps.googledrive_integration.urls', 'designsafe.apps.googledrive_integration'), - namespace='googledrive_integration')), - + url( + r"^account/applications/googledrive/", + include( + ( + "designsafe.apps.googledrive_integration.urls", + "designsafe.apps.googledrive_integration", + ), + namespace="googledrive_integration", + ), + ), # google site verification - url(r'{}.html$'.format(settings.GOOGLE_SITE_VERIFICATION_ID), TemplateView.as_view(template_name='google_verification.html')), - + url( + r"{}.html$".format(settings.GOOGLE_SITE_VERIFICATION_ID), + TemplateView.as_view(template_name="google_verification.html"), + ), # auth - url(r'^auth/', include(('designsafe.apps.auth.urls', 'designsafe.apps.auth'), namespace='designsafe_auth')), - - url(r'^login/$', login, name='login'), - url(r'^logout/$', des_logout.as_view(), name='logout'), - + url( + r"^auth/", + include( + ("designsafe.apps.auth.urls", "designsafe.apps.auth"), + namespace="designsafe_auth", + ), + ), + url(r"^login/$", login, name="login"), + url(r"^logout/$", des_logout.as_view(), name="logout"), # help - url(r'^help/', include(('designsafe.apps.djangoRT.urls', 'designsafe.apps.djangoRT'), namespace='djangoRT')), - + url( + r"^help/", + include( + ("designsafe.apps.djangoRT.urls", "designsafe.apps.djangoRT"), + namespace="djangoRT", + ), + ), # webhooks - path('webhooks/', include('designsafe.apps.webhooks.urls', namespace='webhooks')), - + path("webhooks/", include("designsafe.apps.webhooks.urls", namespace="webhooks")), # version check - url(r'^version/', des_version), - + url(r"^version/", des_version), # old NEES urls - url(r'^warehouse/project/(?P[0-9]+)/?', redirect_old_nees), - url(r'^warehouse/experiment/[0-9]+/project/(?P[0-9]+)/?', redirect_old_nees), - url(r'^warehouse/hybrid/[0-9]+/project/(?P[0-9]+)/?', redirect_old_nees), - + url(r"^warehouse/project/(?P[0-9]+)/?", redirect_old_nees), + url( + r"^warehouse/experiment/[0-9]+/project/(?P[0-9]+)/?", + redirect_old_nees, + ), + url(r"^warehouse/hybrid/[0-9]+/project/(?P[0-9]+)/?", redirect_old_nees), # cms handles everything else - url(r'^', include('djangocms_forms.urls')), - url(r'^', include('cms.urls')), + url(r"^", include("djangocms_forms.urls")), + url(r"^", include("cms.urls")), ] if settings.DEBUG: # https://docs.djangoproject.com/en/4.2/howto/static-files/#serving-files-uploaded-by-a-user-during-development