From 6983c6bbff8c88c82ae4bb43b4c0887a326351c1 Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Fri, 26 Jun 2026 14:50:00 -0500 Subject: [PATCH 01/10] MARS-1189 Integrate `ScrollArea` components * Target `DataTable` with upgraded container --- client/src/components/DataTable/index.tsx | 309 +++++++++--------- client/src/components/Navigation/index.tsx | 4 +- client/src/pages/account/Login.tsx | 2 +- client/src/styles/styles.scss | 38 --- .../test/integration/actions/entity.test.ts | 2 +- .../test/integration/actions/project.test.ts | 8 +- client/test/integration/helpers.ts | 5 +- .../test/integration/workflows/import.test.ts | 6 +- .../integration/workflows/template.test.ts | 2 +- 9 files changed, 170 insertions(+), 206 deletions(-) diff --git a/client/src/components/DataTable/index.tsx b/client/src/components/DataTable/index.tsx index c732cf14..8260ba13 100644 --- a/client/src/components/DataTable/index.tsx +++ b/client/src/components/DataTable/index.tsx @@ -16,6 +16,7 @@ import { Fieldset, Field, Input, + ScrollArea, } from "@chakra-ui/react"; import { flexRender, @@ -846,170 +847,170 @@ const DataTable = (props: DataTableProps) => { flexDirection={"column"} css={{ WebkitOverflowScrolling: "touch" }} > - - - {headerGroups.length > 0 && ( - + + + - {headerGroups[0].headers - .filter((header) => header.column.getIsVisible()) - .map((header, headerIndex, visibleHeaders) => { - const isSelectColumn = header.id === "select"; - const isLastColumn = headerIndex === visibleHeaders.length - 1; - const columnWidth = getColumnWidth(header.id, isLastColumn); - const columnMinWidth = getColumnMinWidth(header.id); - const align = getColumnAlign(header.id); - const headerMeta = header.column.columnDef.meta as ColumnMeta | undefined; + {headerGroups.length > 0 && ( + + {headerGroups[0].headers + .filter((header) => header.column.getIsVisible()) + .map((header, headerIndex, visibleHeaders) => { + const isSelectColumn = header.id === "select"; + const isLastColumn = headerIndex === visibleHeaders.length - 1; + const columnWidth = getColumnWidth(header.id, isLastColumn); + const columnMinWidth = getColumnMinWidth(header.id); + const align = getColumnAlign(header.id); + const headerMeta = header.column.columnDef.meta as ColumnMeta | undefined; + return ( + + {isSelectColumn ? ( + flexRender(header.column.columnDef.header, header.getContext()) + ) : ( + + + {flexRender(header.column.columnDef.header, header.getContext())} + + + {canSortColumn(header.id) && ( + + )} + {canSortColumn(header.id) && props.showColumnFilters && ( + columnId={header.id} data={props.data} table={table} /> + )} + + + )} + + ); + })} + + )} + + + {rows.map((row, rowIndex) => { + const isSelected = row.getIsSelected(); + const visibleCells = row.getVisibleCells(); return ( - {isSelectColumn ? ( - flexRender(header.column.columnDef.header, header.getContext()) - ) : ( - - - {flexRender(header.column.columnDef.header, header.getContext())} - - - {canSortColumn(header.id) && ( - - )} - {canSortColumn(header.id) && props.showColumnFilters && ( - columnId={header.id} data={props.data} table={table} /> - )} - - - )} + {visibleCells.map((cell, cellIndex) => { + const isLastCell = cellIndex === visibleCells.length - 1; + const columnWidth = getColumnWidth(cell.column.id, isLastCell); + const columnMinWidth = getColumnMinWidth(cell.column.id); + const align = getColumnAlign(cell.column.id); + const cellMeta = cell.column.columnDef.meta as ColumnMeta | undefined; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} ); })} - - )} - - - {rows.map((row, rowIndex) => { - const isSelected = row.getIsSelected(); - const visibleCells = row.getVisibleCells(); - return ( - - {visibleCells.map((cell, cellIndex) => { - const isLastCell = cellIndex === visibleCells.length - 1; - const columnWidth = getColumnWidth(cell.column.id, isLastCell); - const columnMinWidth = getColumnMinWidth(cell.column.id); - const align = getColumnAlign(cell.column.id); - const cellMeta = cell.column.columnDef.meta as ColumnMeta | undefined; - return ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ); - })} - - ); - })} - - - + + + + + + + + {/* */} diff --git a/client/src/components/Navigation/index.tsx b/client/src/components/Navigation/index.tsx index 6d3d28b3..3d4b4941 100644 --- a/client/src/components/Navigation/index.tsx +++ b/client/src/components/Navigation/index.tsx @@ -274,7 +274,7 @@ const Navigation = () => { {/* Version number */} - v{import.meta.env.VERSION} + v{import.meta.env.VITE_VERSION} @@ -398,7 +398,7 @@ const Navigation = () => { {/* Version number */} - v{import.meta.env.VERSION} + v{import.meta.env.VITE_VERSION} diff --git a/client/src/pages/account/Login.tsx b/client/src/pages/account/Login.tsx index bb5e7aea..1d6519bb 100644 --- a/client/src/pages/account/Login.tsx +++ b/client/src/pages/account/Login.tsx @@ -279,7 +279,7 @@ const Login = () => { - v{import.meta.env.VERSION} + v{import.meta.env.VITE_VERSION} diff --git a/client/src/styles/styles.scss b/client/src/styles/styles.scss index 315b0633..d8997c90 100644 --- a/client/src/styles/styles.scss +++ b/client/src/styles/styles.scss @@ -8,44 +8,6 @@ a:hover { transition: 0.5s; } -/* Custom scrollbar styles for DataTable */ -.data-table-scroll-container { - /* For Webkit browsers (Chrome, Safari, Edge) */ - &::-webkit-scrollbar { - width: 3px; - height: 3px; - } - - &::-webkit-scrollbar-track { - background: transparent; - border-radius: 2px; - } - - &::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.2); - border-radius: 2px; - transition: background 0.2s ease; - } - - &::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.3); - } - - /* For Firefox */ - scrollbar-width: thin; - scrollbar-color: rgba(0, 0, 0, 0.2) transparent; - - /* Minimize spacing, remove any default margins/padding on scrollbar */ - &::-webkit-scrollbar-corner { - background: transparent; - } - - /* Reduce gap by making scrollbar track flush with content */ - &::-webkit-scrollbar:horizontal { - margin-top: 0; - } -} - /* Custom scrollbar styles for Error component */ .error-scroll-container { /* For Webkit browsers (Chrome, Safari, Edge) */ diff --git a/client/test/integration/actions/entity.test.ts b/client/test/integration/actions/entity.test.ts index d034fe3d..03ab9a7e 100644 --- a/client/test/integration/actions/entity.test.ts +++ b/client/test/integration/actions/entity.test.ts @@ -75,7 +75,7 @@ test.describe("Entity", () => { test("should appear in the Entities list after creation", async ({ page }) => { await navigateToSection(page, "Entities"); - const table = page.locator(".data-table-scroll-container"); + const table = page.getByTestId("data-table-scroll-container"); await table.waitFor({ state: "visible", timeout: 5000 }); await expect(table.locator(`text=1-Entity-List`)).toBeVisible({ timeout: 10000 }); }); diff --git a/client/test/integration/actions/project.test.ts b/client/test/integration/actions/project.test.ts index e26e23c5..99474cd0 100644 --- a/client/test/integration/actions/project.test.ts +++ b/client/test/integration/actions/project.test.ts @@ -86,7 +86,7 @@ test.describe("Project", () => { await addEntityToProject(page, "1-Entity-Project"); // Check for the View button rather than the entity name since the table column truncates long names - const table = page.locator(".data-table-scroll-container"); + const table = page.getByTestId("data-table-scroll-container"); await expect(table.locator('button[aria-label="View Entity"]')).toBeVisible({ timeout: 10000 }); await page.reload(); @@ -106,11 +106,11 @@ test.describe("Project", () => { await page.click("#editProjectButton"); await addEntityToProject(page, "2-Project-Entity"); - const table = page.locator(".data-table-scroll-container"); + const table = page.getByTestId("data-table-scroll-container"); await table.locator('button[aria-label="View Entity"]').first().click(); await page.click("#editEntityButton"); - const projectsTable = page.locator(".data-table-scroll-container").filter({ hasText: "2-Entity-Project" }); + const projectsTable = page.getByTestId("data-table-scroll-container").filter({ hasText: "2-Entity-Project" }); await projectsTable.locator('button[aria-label="Remove Project"]').first().click(); await saveAndWait(page); @@ -138,7 +138,7 @@ test.describe("Project", () => { test("should appear in the Projects list after creation", async ({ page }) => { await navigateToSection(page, "Projects"); - const table = page.locator(".data-table-scroll-container"); + const table = page.getByTestId("data-table-scroll-container"); await table.waitFor({ state: "visible", timeout: 5000 }); await expect(table.locator("text=1-Project-List")).toBeVisible({ timeout: 10000 }); }); diff --git a/client/test/integration/helpers.ts b/client/test/integration/helpers.ts index 0c8a7d41..8b491cc5 100644 --- a/client/test/integration/helpers.ts +++ b/client/test/integration/helpers.ts @@ -22,9 +22,6 @@ import { IAttribute, IEntity, IProject, IWorkspace, ResponseData } from "../../. // Utility functions import dayjs from "dayjs"; -// Variables -const TABLE_CONTAINER = ".data-table-scroll-container"; - /** * Create an Entity for use in testing * @param {string} name The name of the Entity to create @@ -250,7 +247,7 @@ export const openItemFromTable = async ( itemName: string, viewButtonLabel: "View Entity" | "View Project" | "View Template", ): Promise => { - const table = page.locator(TABLE_CONTAINER); + const table = page.getByTestId("data-table-scroll-container"); await table.waitFor({ state: "visible", timeout: 5000 }); // Wait for table to be populated - check that at least one view button exists diff --git a/client/test/integration/workflows/import.test.ts b/client/test/integration/workflows/import.test.ts index 1a54f3ac..5fdf4a1c 100644 --- a/client/test/integration/workflows/import.test.ts +++ b/client/test/integration/workflows/import.test.ts @@ -54,7 +54,11 @@ test.describe("Import", () => { // Verify import success await page.click("#navProjectsButtonDesktop"); - await page.locator(".data-table-scroll-container").locator('button[aria-label="View Project"]').first().click(); + await page + .getByTestId("data-table-scroll-container") + .locator('button[aria-label="View Project"]') + .first() + .click(); await expect(page.locator("text=Mini Box 1 (CSV)").first()).toBeVisible(); }); diff --git a/client/test/integration/workflows/template.test.ts b/client/test/integration/workflows/template.test.ts index 0fcb2fdf..54b539c2 100644 --- a/client/test/integration/workflows/template.test.ts +++ b/client/test/integration/workflows/template.test.ts @@ -33,7 +33,7 @@ test.describe("Template", () => { test("should create a Template and appear in the list", async ({ page }) => { await navigateToSection(page, "Templates"); - const table = page.locator(".data-table-scroll-container"); + const table = page.getByTestId("data-table-scroll-container"); await table.waitFor({ state: "visible", timeout: 5000 }); await expect(table.locator(`text=1-Template-Create`)).toBeVisible({ timeout: 10000 }); }); From 792c4915a9e06844aa5977a821d3a5e4a3dc1b7a Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Fri, 26 Jun 2026 16:09:16 -0500 Subject: [PATCH 02/10] MARS-1189 Update `WorkspaceSwitcher` * Add Workspace name visibility to core pages * Update page header styling --- .../components/WorkspaceSwitcher/index.tsx | 41 ++++++++++++++----- client/src/pages/Dashboard.tsx | 41 ++++++++++++++++--- client/src/pages/Search.tsx | 41 ++++++++++++++++--- client/src/pages/view/Activity.tsx | 29 ++++++++++--- client/src/pages/view/Entities.tsx | 38 +++++++++++++++-- client/src/pages/view/Projects.tsx | 39 ++++++++++++++---- client/src/pages/view/Templates.tsx | 39 +++++++++++++++--- 7 files changed, 226 insertions(+), 42 deletions(-) diff --git a/client/src/components/WorkspaceSwitcher/index.tsx b/client/src/components/WorkspaceSwitcher/index.tsx index c26f1ff0..f84dbcad 100644 --- a/client/src/components/WorkspaceSwitcher/index.tsx +++ b/client/src/components/WorkspaceSwitcher/index.tsx @@ -197,7 +197,7 @@ const WorkspaceSwitcher = (props: { id?: string }) => { variant={"surface"} onClick={() => setOpen(!open)} > - + {_.truncate(label, { length: 18 })} @@ -211,20 +211,35 @@ const WorkspaceSwitcher = (props: { id?: string }) => { + + + + + Available Workspaces + + + {/* Create a list of all Workspaces the user has access to */} {workspaces.length > 0 ? ( workspaces.map((accessible) => { return ( handleWorkspaceClick(accessible)} + onClick={() => { + if (workspace !== accessible._id) { + handleWorkspaceClick(accessible); + } + }} key={"w_" + accessible._id} + cursor={workspace !== accessible._id ? "pointer" : undefined} > - - - {_.truncate(accessible.name, { length: 24 })} - + + {_.truncate(accessible.name, { length: 24 })} @@ -256,13 +271,18 @@ const WorkspaceSwitcher = (props: { id?: string }) => { {/* Option to create a new Workspace */} - handleUpdateClick()} disabled={workspaces.length === 0}> + handleUpdateClick()} + disabled={workspaces.length === 0} + cursor={"pointer"} + > Edit workspace - handleCreateClick()}> + handleCreateClick()} cursor={"pointer"}> Create workspace @@ -273,7 +293,7 @@ const WorkspaceSwitcher = (props: { id?: string }) => { - handleProfileClick()}> + handleProfileClick()} cursor={"pointer"}> Account settings @@ -285,6 +305,7 @@ const WorkspaceSwitcher = (props: { id?: string }) => { value={"admin"} fontSize={"xs"} onClick={() => navigate("/admin")} + cursor={"pointer"} > @@ -292,7 +313,7 @@ const WorkspaceSwitcher = (props: { id?: string }) => { )} - handleLogoutClick()}> + handleLogoutClick()} cursor={"pointer"}> Log out diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 6ba3c62c..38a3f1c6 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; // Existing and custom components -import { Button, Flex, Heading, Text, Tag, Badge, EmptyState } from "@chakra-ui/react"; +import { Button, Flex, Heading, Text, Tag, Badge, EmptyState, SkeletonText } from "@chakra-ui/react"; import { createColumnHelper, ColumnFiltersState } from "@tanstack/react-table"; import { Content } from "@components/Container"; import DataTable from "@components/DataTable"; @@ -16,7 +16,15 @@ import SearchBox from "@components/SearchBox"; import ActivityFeed from "@components/ActivityFeed"; // Existing and custom types -import { ProjectModel, EntityModel, EntityMetrics, ProjectMetrics, TemplateMetrics, WorkspaceMetrics } from "@types"; +import { + ProjectModel, + EntityModel, + EntityMetrics, + ProjectMetrics, + TemplateMetrics, + WorkspaceMetrics, + IGenericItem, +} from "@types"; // Utility functions and libraries import dayjs from "dayjs"; @@ -84,6 +92,10 @@ const GET_DASHBOARD = gql` } total } + workspace(_id: $workspace) { + _id + name + } entityMetrics { all addedDay @@ -130,6 +142,9 @@ const Dashboard = () => { [] as { _id: string; name: string; description: string; created: string }[], ); + // Display state + const [workspaceName, setWorkspaceName] = useState(); + // Metrics const [entityMetrics, setEntityMetrics] = useState({} as EntityMetrics); const [projectMetrics, setProjectMetrics] = useState({} as ProjectMetrics); @@ -162,6 +177,7 @@ const Dashboard = () => { projects: ProjectModel[]; projectMetrics: ProjectMetrics; entities: { entities: EntityModel[]; total: number }; + workspace: IGenericItem; entityMetrics: EntityMetrics; templateMetrics: TemplateMetrics; workspaceMetrics: WorkspaceMetrics; @@ -185,6 +201,11 @@ const Dashboard = () => { setProjectData(data.projects); } + // Display state + if (data?.workspace) { + setWorkspaceName(data.workspace.name); + } + // Metrics if (data?.entityMetrics) { setEntityMetrics(data.entityMetrics); @@ -475,11 +496,19 @@ const Dashboard = () => { )} {/* Header */} - - - - Dashboard + + + + + Dashboard + + + + {workspaceName} + + + diff --git a/client/src/pages/Search.tsx b/client/src/pages/Search.tsx index 7ffaa09f..12b4d37b 100644 --- a/client/src/pages/Search.tsx +++ b/client/src/pages/Search.tsx @@ -15,6 +15,7 @@ import { Checkbox, Collapsible, InputGroup, + SkeletonText, } from "@chakra-ui/react"; import ActorTag from "@components/ActorTag"; import { Content } from "@components/Container"; @@ -27,9 +28,10 @@ import { toaster } from "@components/Toast"; // Custom hooks import { useBreakpoint } from "@hooks/useBreakpoint"; import { useFeatures } from "@hooks/useFeatures"; +import { useWorkspace } from "@hooks/useWorkspace"; // Existing and custom types -import { EntityModel, DataTableAction, SearchQuery } from "@types"; +import { EntityModel, DataTableAction, SearchQuery, IGenericItem } from "@types"; // Utility functions and libraries import _ from "lodash"; @@ -40,7 +42,7 @@ import { useNavigate } from "react-router-dom"; // GraphQL imports import { gql } from "@apollo/client"; -import { useLazyQuery } from "@apollo/client/react"; +import { useLazyQuery, useQuery } from "@apollo/client/react"; // Utility libraries and functions import { buildMongoQuery, ignoreAbort } from "@lib/util"; @@ -59,6 +61,9 @@ const Search = () => { const posthog = usePostHog(); const { features } = useFeatures(); + const { workspace } = useWorkspace(); + const [workspaceName, setWorkspaceName] = useState(""); + // Search status const [hasSearched, setHasSearched] = useState(false); const [isSearching, setIsSearching] = useState(false); @@ -97,6 +102,27 @@ const Search = () => { // Active filter count for text search filters const [activeFilterCount, setActiveFilterCount] = useState(0); + // Query to get the Workspace name on load + const SEARCH_PAGE_LOAD = gql` + query SearchPageLoad($workspace: String) { + workspace(_id: $workspace) { + _id + name + } + } + `; + const { data, loading } = useQuery<{ workspace: IGenericItem }>(SEARCH_PAGE_LOAD, { + variables: { + workspace: workspace, + }, + }); + + useEffect(() => { + if (data?.workspace) { + setWorkspaceName(data.workspace.name); + } + }, []); + // Query to search by text value const SEARCH_TEXT = gql` query Search( @@ -569,11 +595,16 @@ const Search = () => { return ( - - + + - Search + Search + + + {workspaceName} + + diff --git a/client/src/pages/view/Activity.tsx b/client/src/pages/view/Activity.tsx index 64df27c0..680e3a39 100644 --- a/client/src/pages/view/Activity.tsx +++ b/client/src/pages/view/Activity.tsx @@ -14,6 +14,7 @@ import { Input, Field, Collapsible, + SkeletonText, } from "@chakra-ui/react"; import ActorTag from "@components/ActorTag"; import { Content } from "@components/Container"; @@ -24,10 +25,11 @@ import ActivityGraph from "@components/ActivityGraph"; import { createColumnHelper, ColumnFiltersState } from "@tanstack/react-table"; // Existing and custom types -import { ActivityModel } from "@types"; +import { ActivityModel, IGenericItem } from "@types"; // Context and hooks import { useBreakpoint } from "@hooks/useBreakpoint"; +import { useWorkspace } from "@hooks/useWorkspace"; // Utility functions and libraries import { gql } from "@apollo/client"; @@ -43,6 +45,9 @@ dayjs.extend(isSameOrBefore); import { GLOBAL_STYLES } from "@variables"; const Activity = () => { + const { workspace } = useWorkspace(); + const [workspaceName, setWorkspaceName] = useState(""); + const [activityData, setActivityData] = useState([] as ActivityModel[]); const [initialLoaded, setInitialLoaded] = useState(false); @@ -93,7 +98,7 @@ const Activity = () => { // Query to retrieve Activity const GET_ACTIVITY = gql` - query GetActivity($limit: Int) { + query GetActivity($limit: Int, $workspace: String) { activity(limit: $limit) { _id timestamp @@ -107,13 +112,19 @@ const Activity = () => { type } } + workspace(_id: $workspace) { + _id + name + } } `; const { loading, error, data } = useQuery<{ activity: ActivityModel[]; + workspace: IGenericItem; }>(GET_ACTIVITY, { variables: { limit: 10000, // High limit to get all activity + workspace: workspace, }, fetchPolicy: "network-only", pollInterval: 5000, // Poll every 5 seconds to refresh activity @@ -126,6 +137,9 @@ const Activity = () => { setFilteredActivityData(data.activity); setInitialLoaded(true); } + if (data?.workspace) { + setWorkspaceName(data.workspace.name); + } }, [data]); // Apply filters to activity data @@ -279,11 +293,16 @@ const Activity = () => { return ( - - + + - Workspace Activity + Activity + + + {workspaceName} + + diff --git a/client/src/pages/view/Entities.tsx b/client/src/pages/view/Entities.tsx index 7a494eeb..cf626fee 100644 --- a/client/src/pages/view/Entities.tsx +++ b/client/src/pages/view/Entities.tsx @@ -15,6 +15,7 @@ import { Input, Checkbox, Collapsible, + SkeletonText, } from "@chakra-ui/react"; import ActorTag from "@components/ActorTag"; import { Content } from "@components/Container"; @@ -25,13 +26,14 @@ import DataTable from "@components/DataTable"; import { createColumnHelper, ColumnFiltersState } from "@tanstack/react-table"; // Existing and custom types -import { DataTableAction, EntityModel } from "@types"; +import { DataTableAction, EntityModel, IGenericItem } from "@types"; // Routing and navigation import { useNavigate } from "react-router-dom"; // Context and hooks import { useBreakpoint } from "@hooks/useBreakpoint"; +import { useWorkspace } from "@hooks/useWorkspace"; // GraphQL imports import { gql } from "@apollo/client"; @@ -51,6 +53,9 @@ import { GLOBAL_STYLES } from "@variables"; const Entities = () => { const navigate = useNavigate(); + const { workspace } = useWorkspace(); + const [workspaceName, setWorkspaceName] = useState(""); + const [entityData, setEntityData] = useState([] as EntityModel[]); const { breakpoint } = useBreakpoint(); @@ -101,7 +106,13 @@ const Entities = () => { // Query to retrieve Entities const GET_ENTITIES = gql` - query GetEntities($page: Int, $pageSize: Int, $filter: EntityFilterInput, $sort: EntitySortInput) { + query GetEntities( + $page: Int + $pageSize: Int + $filter: EntityFilterInput + $sort: EntitySortInput + $workspace: String + ) { entities(page: $page, pageSize: $pageSize, filter: $filter, sort: $sort) { entities { _id @@ -125,6 +136,10 @@ const Entities = () => { } total } + workspace(_id: $workspace) { + _id + name + } } `; // Build filter object (only include non-empty values) @@ -151,9 +166,11 @@ const Entities = () => { const { loading, error, data } = useQuery<{ entities: { entities: EntityModel[]; total: number }; + workspace: IGenericItem; }>(GET_ENTITIES, { fetchPolicy: "network-only", variables: { + workspace, page, pageSize, filter: filterVariables, @@ -167,6 +184,10 @@ const Entities = () => { // Set the paginated Entity data (already filtered and sorted on server) setEntityData(data.entities.entities); } + if (data?.workspace) { + // Store the Workspace name + setWorkspaceName(data.workspace.name); + } }, [data]); // Calculate active filter count @@ -322,8 +343,17 @@ const Entities = () => { - - Entities + + + + Entities + + + + {workspaceName} + + +