diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/V2WorkspacesHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/V2WorkspacesHeader.tsx index 1acf3e0c45f..9417d6eea6f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/V2WorkspacesHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/V2WorkspacesHeader.tsx @@ -58,52 +58,48 @@ export function V2WorkspacesHeader({ counts }: V2WorkspacesHeaderProps) { }; return ( -
-
-

Workspaces

-

- Every workspace you can access across your devices. Open one to jump - in, or add it to your sidebar. -

-
+
+
+

Workspaces

-
- - - - - setSearchQuery(event.target.value)} - /> - +
+ + + + + setSearchQuery(event.target.value)} + /> + - { - if (value) setDeviceFilter(value as V2WorkspacesDeviceFilter); - }} - > - {DEVICE_FILTER_OPTIONS.map(({ value, label, Icon }) => ( - - {Icon ? : null} - {label} - - {countForFilter(value)} - - - ))} - + { + if (value) setDeviceFilter(value as V2WorkspacesDeviceFilter); + }} + > + {DEVICE_FILTER_OPTIONS.map(({ value, label, Icon }) => ( + + {Icon ? : null} + {label} + + {countForFilter(value)} + + + ))} + +
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx index 6bbb0d5e46d..c237c351b1b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx @@ -7,10 +7,10 @@ import { EmptyMedia, EmptyTitle, } from "@superset/ui/empty"; -import { ItemGroup } from "@superset/ui/item"; import { ScrollArea } from "@superset/ui/scroll-area"; +import { cn } from "@superset/ui/utils"; import { useMatchRoute } from "@tanstack/react-router"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { LuLayers, LuSearchX } from "react-icons/lu"; import type { AccessibleV2Workspace, @@ -20,17 +20,20 @@ import { useV2WorkspacesFilterStore, type V2WorkspacesDeviceFilter, } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore"; +import { SortableHeader } from "./components/SortableHeader"; import { V2WorkspaceRow } from "./components/V2WorkspaceRow"; +import { V2_WORKSPACES_ROW_GRID } from "./constants"; +import type { SortDirection, SortField } from "./types"; interface V2WorkspacesListProps { - pinned: AccessibleV2Workspace[]; - others: AccessibleV2Workspace[]; + workspaces: AccessibleV2Workspace[]; } interface ProjectGroup { projectId: string; projectName: string; workspaces: AccessibleV2Workspace[]; + latestCreatedAt: number; } function matchesDeviceFilter( @@ -49,32 +52,95 @@ function matchesDeviceFilter( } } -function groupByProject(workspaces: AccessibleV2Workspace[]): ProjectGroup[] { - const groupsById = new Map(); +// Host-type rank used as a tiebreaker when sorting by host — keeps local +// device first, then remote devices, then cloud. +function hostTypeRank(hostType: V2WorkspaceHostType): number { + switch (hostType) { + case "local-device": + return 0; + case "remote-device": + return 1; + case "cloud": + return 2; + } +} + +function compareWorkspaces( + a: AccessibleV2Workspace, + b: AccessibleV2Workspace, + field: SortField, + direction: SortDirection, +): number { + let cmp = 0; + switch (field) { + case "sidebar": + cmp = Number(a.isInSidebar) - Number(b.isInSidebar); + break; + case "name": + cmp = a.name.localeCompare(b.name); + break; + case "host": + cmp = hostTypeRank(a.hostType) - hostTypeRank(b.hostType); + if (cmp === 0) cmp = a.hostName.localeCompare(b.hostName); + break; + case "branch": + cmp = a.branch.localeCompare(b.branch); + break; + case "created": + cmp = a.createdAt.getTime() - b.createdAt.getTime(); + break; + } + if (cmp === 0) { + cmp = b.createdAt.getTime() - a.createdAt.getTime(); + } + return direction === "asc" ? cmp : -cmp; +} + +function groupByProject( + workspaces: AccessibleV2Workspace[], + sortField: SortField, + sortDirection: SortDirection, +): ProjectGroup[] { + const projectsById = new Map(); + for (const workspace of workspaces) { - const existing = groupsById.get(workspace.projectId); - if (existing) { - existing.workspaces.push(workspace); - } else { - groupsById.set(workspace.projectId, { + let project = projectsById.get(workspace.projectId); + if (!project) { + project = { projectId: workspace.projectId, projectName: workspace.projectName, - workspaces: [workspace], - }); + workspaces: [], + latestCreatedAt: 0, + }; + projectsById.set(workspace.projectId, project); + } + project.workspaces.push(workspace); + const createdAt = workspace.createdAt.getTime(); + if (createdAt > project.latestCreatedAt) { + project.latestCreatedAt = createdAt; } } - return Array.from(groupsById.values()).sort((a, b) => { - const aLatest = Math.max( - ...a.workspaces.map((workspace) => workspace.createdAt.getTime()), - ); - const bLatest = Math.max( - ...b.workspaces.map((workspace) => workspace.createdAt.getTime()), + + for (const project of projectsById.values()) { + project.workspaces.sort((a, b) => + compareWorkspaces(a, b, sortField, sortDirection), ); - return bLatest - aLatest; - }); + } + + return Array.from(projectsById.values()).sort( + (a, b) => b.latestCreatedAt - a.latestCreatedAt, + ); } -export function V2WorkspacesList({ pinned, others }: V2WorkspacesListProps) { +const DEFAULT_DIRECTION_BY_FIELD: Record = { + sidebar: "desc", + name: "asc", + host: "asc", + branch: "asc", + created: "desc", +}; + +export function V2WorkspacesList({ workspaces }: V2WorkspacesListProps) { const matchRoute = useMatchRoute(); const currentWorkspaceMatch = matchRoute({ to: "/v2-workspace/$workspaceId", @@ -88,120 +154,150 @@ export function V2WorkspacesList({ pinned, others }: V2WorkspacesListProps) { ); const resetFilters = useV2WorkspacesFilterStore((state) => state.reset); - const filteredPinnedGroups = useMemo(() => { - const filtered = pinned.filter((workspace) => - matchesDeviceFilter(workspace.hostType, deviceFilter), - ); - return groupByProject(filtered); - }, [pinned, deviceFilter]); + const [sortField, setSortField] = useState("created"); + const [sortDirection, setSortDirection] = useState("desc"); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortField(field); + setSortDirection(DEFAULT_DIRECTION_BY_FIELD[field]); + } + }; - const filteredOtherGroups = useMemo(() => { - const filtered = others.filter((workspace) => + const projectGroups = useMemo(() => { + const filtered = workspaces.filter((workspace) => matchesDeviceFilter(workspace.hostType, deviceFilter), ); - return groupByProject(filtered); - }, [others, deviceFilter]); + return groupByProject(filtered, sortField, sortDirection); + }, [workspaces, deviceFilter, sortField, sortDirection]); - const pinnedCount = filteredPinnedGroups.reduce( - (total, group) => total + group.workspaces.length, + const totalCount = projectGroups.reduce( + (total, project) => total + project.workspaces.length, 0, ); - const othersCount = filteredOtherGroups.reduce( - (total, group) => total + group.workspaces.length, - 0, - ); - const hasAnyMatches = pinnedCount > 0 || othersCount > 0; const hasActiveFilters = searchQuery.trim() !== "" || deviceFilter !== "all"; - if (!hasAnyMatches) { + const columnHeader = ( +
+ + + + + + +
+ ); + + if (totalCount === 0) { return ( - - - - {hasActiveFilters ? : } - - - {hasActiveFilters - ? "No workspaces match your filters" - : "No workspaces yet"} - - - {hasActiveFilters - ? "Try a different search term or clear the device filter." - : "Workspaces you have access to across all your devices will show up here."} - - - {hasActiveFilters ? ( - - - - ) : null} - +
+ {columnHeader} + + + + {hasActiveFilters ? : } + + + {hasActiveFilters + ? "No workspaces match your filters" + : "No workspaces yet"} + + + {hasActiveFilters + ? "Try a different search term or clear the device filter." + : "Workspaces you have access to across all your devices will show up here."} + + + {hasActiveFilters ? ( + + + + ) : null} + +
); } - const renderProjectGroups = (groups: ProjectGroup[]) => ( -
- {groups.map((group) => ( -
-
-

- {group.projectName} -

- - {group.workspaces.length} - -
- - {group.workspaces.map((workspace) => ( - - ))} - -
- ))} -
- ); - return ( -
- {pinnedCount > 0 ? ( -
-
-

- In your sidebar -

- - {pinnedCount} - -
- {renderProjectGroups(filteredPinnedGroups)} -
- ) : null} - - {othersCount > 0 ? ( -
-
-

- Other workspaces -

- - {othersCount} +
+ {columnHeader} + + {projectGroups.map((project) => ( +
+
+

+ {project.projectName} +

+ + {project.workspaces.length}
- {renderProjectGroups(filteredOtherGroups)} -
- ) : null} +
    + {project.workspaces.map((workspace) => ( + + ))} +
+
+ ))}
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/SortableHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/SortableHeader.tsx new file mode 100644 index 00000000000..3d5a0cf8de3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/SortableHeader.tsx @@ -0,0 +1,60 @@ +import { cn } from "@superset/ui/utils"; +import { LuChevronDown, LuChevronsUpDown, LuChevronUp } from "react-icons/lu"; +import type { SortDirection, SortField } from "../../types"; + +interface SortableHeaderProps { + field: SortField; + label: string; + align?: "start" | "center"; + className?: string; + sortField: SortField; + sortDirection: SortDirection; + onSort: (field: SortField) => void; + srOnlyLabel?: boolean; +} + +export function SortableHeader({ + field, + label, + align = "start", + className, + sortField, + sortDirection, + onSort, + srOnlyLabel = false, +}: SortableHeaderProps) { + const isActive = sortField === field; + const Icon = !isActive + ? LuChevronsUpDown + : sortDirection === "asc" + ? LuChevronUp + : LuChevronDown; + const sortLabel = isActive + ? sortDirection === "asc" + ? "ascending" + : "descending" + : "not sorted"; + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/index.ts new file mode 100644 index 00000000000..c85413e268f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/index.ts @@ -0,0 +1 @@ +export { SortableHeader } from "./SortableHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx index 4cc638df286..566c3bc3709 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx @@ -1,13 +1,6 @@ -import { Badge } from "@superset/ui/badge"; import { Button } from "@superset/ui/button"; -import { - Item, - ItemActions, - ItemContent, - ItemDescription, - ItemMedia, - ItemTitle, -} from "@superset/ui/item"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; import { useNavigate } from "@tanstack/react-router"; import { useCallback } from "react"; import { @@ -19,32 +12,44 @@ import { LuPlus, } from "react-icons/lu"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import type { AccessibleV2Workspace } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; +import type { + AccessibleV2Workspace, + V2WorkspaceHostType, +} from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils"; -import { V2WorkspaceDeviceBadge } from "./components/V2WorkspaceDeviceBadge"; +import { V2_WORKSPACES_ROW_GRID } from "../../constants"; interface V2WorkspaceRowProps { workspace: AccessibleV2Workspace; - showProjectName: boolean; isCurrentRoute: boolean; } +function hostIconFor(hostType: V2WorkspaceHostType) { + switch (hostType) { + case "cloud": + return LuCloud; + case "local-device": + return LuLaptop; + case "remote-device": + return LuMonitor; + } +} + export function V2WorkspaceRow({ workspace, - showProjectName, isCurrentRoute, }: V2WorkspaceRowProps) { const navigate = useNavigate(); const { ensureWorkspaceInSidebar, removeWorkspaceFromSidebar } = useDashboardSidebarState(); - const HostIcon = - workspace.hostType === "cloud" - ? LuCloud - : workspace.hostType === "local-device" - ? LuLaptop - : LuMonitor; + const HostIcon = hostIconFor(workspace.hostType); + + // The local device is always reachable from here — ignore any stale + // isOnline flag on that row. + const treatAsOffline = + !workspace.hostIsOnline && workspace.hostType !== "local-device"; const handleOpen = useCallback(() => { navigateToV2Workspace(workspace.id, navigate); @@ -70,8 +75,16 @@ export function V2WorkspaceRow({ ? "you" : (workspace.createdByName ?? "unknown"); + const timeLabel = getRelativeTime(workspace.createdAt.getTime(), { + format: "compact", + }); + const handleRowKeyDown = useCallback( (event: React.KeyboardEvent) => { + // Ignore keystrokes bubbling from focused descendants (e.g. the + // Add/Remove icon buttons) — `stopPropagation` on their click handlers + // doesn't catch keyboard events. + if (event.target !== event.currentTarget) return; if (event.key === "Enter" || event.key === " ") { event.preventDefault(); handleOpen(); @@ -80,72 +93,129 @@ export function V2WorkspaceRow({ [handleOpen], ); + const hostCell = ( + + + {workspace.hostName} + {treatAsOffline ? ( + + ) : null} + + ); + return ( - - - - - - - - {workspace.name} - - - {showProjectName ? ( - - {workspace.projectName} - + {/* biome-ignore lint/a11y/useSemanticElements: interactive row needs nested buttons, so the outer element is a div with role/tabIndex */} +
+ + {workspace.isInSidebar ? ( + ) : null} - - - {workspace.branch} - - - - {getRelativeTime(workspace.createdAt.getTime(), { - format: "compact", - })}{" "} - by {creatorLabel} - - - - - - {workspace.isInSidebar ? ( - + {workspace.name} + + + + {treatAsOffline ? ( + + {hostCell} + Host is offline + ) : ( - + hostCell )} - - + + + + + {workspace.branch} + + + + + {timeLabel} · {creatorLabel} + + +
+ {workspace.isInSidebar ? ( + + + + + + {isCurrentRoute + ? "Can't remove the current workspace" + : "Remove from sidebar"} + + + ) : ( + + + + + Add to sidebar + + )} +
+
+ ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/V2WorkspaceDeviceBadge.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/V2WorkspaceDeviceBadge.tsx deleted file mode 100644 index 96872d2e490..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/V2WorkspaceDeviceBadge.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Badge } from "@superset/ui/badge"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { cn } from "@superset/ui/utils"; -import { LuCloud, LuLaptop, LuMonitor } from "react-icons/lu"; -import type { V2WorkspaceHostType } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; - -interface V2WorkspaceDeviceBadgeProps { - hostType: V2WorkspaceHostType; - hostName: string; - isOnline: boolean; -} - -export function V2WorkspaceDeviceBadge({ - hostType, - hostName, - isOnline, -}: V2WorkspaceDeviceBadgeProps) { - const Icon = - hostType === "cloud" - ? LuCloud - : hostType === "local-device" - ? LuLaptop - : LuMonitor; - - // The local device is always reachable from here — ignore any stale - // isOnline flag on that row. - const treatAsOffline = !isOnline && hostType !== "local-device"; - - const badge = ( - - - {hostName} - {treatAsOffline ? ( - - ) : null} - - ); - - if (!treatAsOffline) { - return badge; - } - - return ( - - {badge} - Host is offline - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/index.ts deleted file mode 100644 index ca6ed56dfa0..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/components/V2WorkspaceDeviceBadge/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { V2WorkspaceDeviceBadge } from "./V2WorkspaceDeviceBadge"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/constants.ts new file mode 100644 index 00000000000..738705ecda4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/constants.ts @@ -0,0 +1,5 @@ +// Shared grid template used by the column header row and every workspace row +// so the Sidebar / Name / Host / Branch / Created / Action columns align +// across the whole view. Columns hide progressively on narrower viewports. +export const V2_WORKSPACES_ROW_GRID = + "grid grid-cols-[1.25rem_minmax(0,1fr)_2.5rem] gap-4 md:grid-cols-[1.25rem_minmax(0,1fr)_12rem_2.5rem] lg:grid-cols-[1.25rem_minmax(0,1fr)_12rem_14rem_2.5rem] xl:grid-cols-[1.25rem_minmax(0,1fr)_12rem_14rem_11rem_2.5rem] items-center"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/types.ts new file mode 100644 index 00000000000..09ae323f499 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/types.ts @@ -0,0 +1,2 @@ +export type SortField = "sidebar" | "name" | "host" | "branch" | "created"; +export type SortDirection = "asc" | "desc"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx index fee586a4f3a..ebaa0ba0e7e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx @@ -22,12 +22,12 @@ function V2WorkspacesPage() { resetFilters(); }, [resetFilters]); - const { pinned, others, counts } = useAccessibleV2Workspaces({ searchQuery }); + const { all, counts } = useAccessibleV2Workspaces({ searchQuery }); return (
- +
); }