+
+
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 ? (
-
- resetFilters()}>
- Clear filters
-
-
- ) : 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 ? (
+
+ resetFilters()}
+ >
+ Clear filters
+
+
+ ) : 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 (
+ onSort(field)}
+ aria-label={`Sort by ${label}, currently ${sortLabel}`}
+ className={cn(
+ "group flex min-w-0 items-center gap-1 rounded outline-none transition-colors",
+ "hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40",
+ align === "center" && "justify-center",
+ isActive && "text-foreground",
+ className,
+ )}
+ >
+ {label}
+
+
+ );
+}
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 ? (
-
+
+
+
-
- Remove from sidebar
-
+ {workspace.name}
+
+
+
+ {treatAsOffline ? (
+
+ {hostCell}
+ Host is offline
+
) : (
-
-
- Add to sidebar
-
+ 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 (
-
+
);
}