From d2cde555715a7b4f33f60c3b0297ccce9d700deb Mon Sep 17 00:00:00 2001 From: Kyrylo Shmidt Date: Tue, 14 Mar 2023 12:21:46 +0100 Subject: [PATCH 1/3] Add sorting menu to the Assets page Add theme toolbar to Storybook --- .storybook/preview.tsx | 21 +- package-lock.json | 106 ++++++++- package.json | 1 + src/components/Assets/AssetList/index.tsx | 203 ++++++++++++++++-- src/components/Assets/AssetList/styles.ts | 28 ++- src/components/Assets/AssetTypeList/types.ts | 4 +- .../RecentActivityTable/types.ts | 4 +- src/components/RecentActivity/types.ts | 12 +- src/components/common/App/index.tsx | 14 +- src/components/common/App/types.ts | 3 + src/components/common/AssetEntry/index.tsx | 11 +- src/components/common/Menu/Menu.stories.tsx | 36 ++++ src/components/common/Menu/index.tsx | 24 +++ src/components/common/Menu/styles.ts | 34 +++ src/components/common/Menu/types.ts | 8 + .../common/Popover/PopoverContent/index.tsx | 48 +++++ .../common/Popover/PopoverContent/types.ts | 6 + .../common/Popover/PopoverTrigger/index.tsx | 61 ++++++ .../common/Popover/PopoverTrigger/styles.ts | 9 + .../common/Popover/PopoverTrigger/types.ts | 4 + src/components/common/Popover/hooks.ts | 81 +++++++ src/components/common/Popover/index.tsx | 21 ++ src/components/common/Popover/types.ts | 19 ++ src/environment.ts | 10 +- src/globals.d.ts | 26 +-- 25 files changed, 730 insertions(+), 64 deletions(-) create mode 100644 src/components/common/Menu/Menu.stories.tsx create mode 100644 src/components/common/Menu/index.tsx create mode 100644 src/components/common/Menu/styles.ts create mode 100644 src/components/common/Menu/types.ts create mode 100644 src/components/common/Popover/PopoverContent/index.tsx create mode 100644 src/components/common/Popover/PopoverContent/types.ts create mode 100644 src/components/common/Popover/PopoverTrigger/index.tsx create mode 100644 src/components/common/Popover/PopoverTrigger/styles.ts create mode 100644 src/components/common/Popover/PopoverTrigger/types.ts create mode 100644 src/components/common/Popover/hooks.ts create mode 100644 src/components/common/Popover/index.tsx create mode 100644 src/components/common/Popover/types.ts diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 6cd2edb23..a317b41d3 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,5 +1,3 @@ -/// - import { StoryFn } from "@storybook/react"; import React from "react"; import { @@ -7,17 +5,19 @@ import { initializeDigmaMessageListener, sendMessage } from "../src/api"; -import { App } from "../src/components/common/App"; +import { App, THEMES } from "../src/components/common/App"; import { dispatcher } from "../src/dispatcher"; +import { Mode } from "../src/globals"; export const decorators = [ - (Story: StoryFn): JSX.Element => { + (Story: StoryFn, context: { globals: { theme: Mode } }): JSX.Element => { + const theme = context.globals.theme; initializeDigmaMessageListener(dispatcher); window.sendMessageToDigma = sendMessage; window.cancelMessageToDigma = cancelMessage; return ( - + ); @@ -33,3 +33,14 @@ export const parameters = { } } }; + +export const globalTypes = { + theme: { + name: "Theme", + description: "Theme", + toolbar: { + title: "Theme", + items: THEMES + } + } +}; diff --git a/package-lock.json b/package-lock.json index fe0c4f988..b2838ca2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@floating-ui/react": "^0.21.0", "@tanstack/react-table": "^8.7.8", "date-fns": "^2.29.3", "react": "^18.2.0", @@ -2290,6 +2291,45 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@floating-ui/core": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.2.3.tgz", + "integrity": "sha512-upVRtrNZuYNsw+EoxkiBFRPROnU8UTy/u/dZ9U0W14BlemPYODwhhxYXSR2Y9xOnvr1XtptJRWx7gL8Te1qaog==" + }, + "node_modules/@floating-ui/dom": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.2.4.tgz", + "integrity": "sha512-4+k+BLhtWj+peCU60gp0+rHeR8+Ohqx6kjJf/lHMnJ8JD5Qj6jytcq1+SZzRwD7rvHKRhR7TDiWWddrNrfwQLg==", + "dependencies": { + "@floating-ui/core": "^1.2.3" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.21.0.tgz", + "integrity": "sha512-4Zut7tjeDVEKHaR6N3uG4m1dl114UkLuK4SNAeHlAb4pKu5KEkMkI34Y8NmCc4ARfXIu25UGUhYBUzShDhbofA==", + "dependencies": { + "@floating-ui/react-dom": "^1.3.0", + "aria-hidden": "^1.1.3", + "tabbable": "^6.0.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-1.3.0.tgz", + "integrity": "sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==", + "dependencies": { + "@floating-ui/dom": "^1.2.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -10251,6 +10291,17 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/aria-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.3.tgz", + "integrity": "sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", @@ -23650,6 +23701,11 @@ "integrity": "sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==", "dev": true }, + "node_modules/tabbable": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.1.1.tgz", + "integrity": "sha512-4kl5w+nCB44EVRdO0g/UGoOp3vlwgycUVtkk/7DPyeLZUCuNFFKCFG6/t/DgHLrUPHjrZg6s5tNm+56Q2B0xyg==" + }, "node_modules/table": { "version": "6.8.1", "resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz", @@ -24102,8 +24158,7 @@ "node_modules/tslib": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", - "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==", - "dev": true + "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -27481,6 +27536,37 @@ "strip-json-comments": "^3.1.1" } }, + "@floating-ui/core": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.2.3.tgz", + "integrity": "sha512-upVRtrNZuYNsw+EoxkiBFRPROnU8UTy/u/dZ9U0W14BlemPYODwhhxYXSR2Y9xOnvr1XtptJRWx7gL8Te1qaog==" + }, + "@floating-ui/dom": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.2.4.tgz", + "integrity": "sha512-4+k+BLhtWj+peCU60gp0+rHeR8+Ohqx6kjJf/lHMnJ8JD5Qj6jytcq1+SZzRwD7rvHKRhR7TDiWWddrNrfwQLg==", + "requires": { + "@floating-ui/core": "^1.2.3" + } + }, + "@floating-ui/react": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.21.0.tgz", + "integrity": "sha512-4Zut7tjeDVEKHaR6N3uG4m1dl114UkLuK4SNAeHlAb4pKu5KEkMkI34Y8NmCc4ARfXIu25UGUhYBUzShDhbofA==", + "requires": { + "@floating-ui/react-dom": "^1.3.0", + "aria-hidden": "^1.1.3", + "tabbable": "^6.0.1" + } + }, + "@floating-ui/react-dom": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-1.3.0.tgz", + "integrity": "sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==", + "requires": { + "@floating-ui/dom": "^1.2.1" + } + }, "@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -33685,6 +33771,14 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "aria-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.3.tgz", + "integrity": "sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==", + "requires": { + "tslib": "^2.0.0" + } + }, "aria-query": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", @@ -44102,6 +44196,11 @@ "integrity": "sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==", "dev": true }, + "tabbable": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.1.1.tgz", + "integrity": "sha512-4kl5w+nCB44EVRdO0g/UGoOp3vlwgycUVtkk/7DPyeLZUCuNFFKCFG6/t/DgHLrUPHjrZg6s5tNm+56Q2B0xyg==" + }, "table": { "version": "6.8.1", "resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz", @@ -44442,8 +44541,7 @@ "tslib": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", - "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==", - "dev": true + "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" }, "tsutils": { "version": "3.21.0", diff --git a/package.json b/package.json index 45adbb39d..6ff043b89 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "webpack-merge": "^5.8.0" }, "dependencies": { + "@floating-ui/react": "^0.21.0", "@tanstack/react-table": "^8.7.8", "date-fns": "^2.29.3", "react": "^18.2.0", diff --git a/src/components/Assets/AssetList/index.tsx b/src/components/Assets/AssetList/index.tsx index 743c97c63..4209a0fbd 100644 --- a/src/components/Assets/AssetList/index.tsx +++ b/src/components/Assets/AssetList/index.tsx @@ -1,12 +1,123 @@ +import { useMemo, useState } from "react"; import { AssetEntry as AssetEntryComponent } from "../../common/AssetEntry"; import { ChevronIcon } from "../../common/icons/ChevronIcon"; import { DIRECTION } from "../../common/icons/types"; +import { Menu } from "../../common/Menu"; +import { Popover } from "../../common/Popover"; +import { PopoverContent } from "../../common/Popover/PopoverContent"; +import { PopoverTrigger } from "../../common/Popover/PopoverTrigger"; import { AssetEntry } from "../types"; import { getAssetTypeInfo } from "../utils"; import * as s from "./styles"; import { AssetListProps } from "./types"; +const SORTING_CRITERION = [ + "Insight Importance", + "Services", + "Duration", + "Latest", + "Errors" +]; + +interface AssetEntryWithServices extends AssetEntry { + id: string; + services: string[]; +} + +interface Sorting { + criterion: string; + isDesc: boolean; +} + +const sortEntries = ( + entries: AssetEntryWithServices[], + sorting: Sorting +): AssetEntryWithServices[] => { + entries = [...entries]; + + const sortByName = (a: AssetEntryWithServices, b: AssetEntryWithServices) => + a.span.displayName.localeCompare(b.span.displayName); + + switch (sorting.criterion) { + case "Insight Importance": + return entries.sort((a, b) => { + const aImportance = Math.min(...a.insights.map((x) => x.importance)); + const bImportance = Math.min(...b.insights.map((x) => x.importance)); + + return ( + (sorting.isDesc + ? aImportance - bImportance + : bImportance - aImportance) || sortByName(a, b) + ); + }); + case "Services": + return entries.sort( + (a, b) => + (sorting.isDesc + ? b.serviceName.localeCompare(a.serviceName) + : a.serviceName.localeCompare(b.serviceName)) || sortByName(a, b) + ); + case "Duration": + return entries.sort((a, b) => { + const aDuration = a.durationPercentiles.find( + (duration) => duration.percentile === 0.5 + )?.currentDuration.raw; + const bDuration = b.durationPercentiles.find( + (duration) => duration.percentile === 0.5 + )?.currentDuration.raw; + + if (!aDuration && !bDuration) { + return 0; + } + + if (!aDuration) { + return sorting.isDesc ? -1 : 1; + } + + if (!bDuration) { + return sorting.isDesc ? -1 : 1; + } + + return ( + (sorting.isDesc ? bDuration - aDuration : aDuration - bDuration) || + sortByName(a, b) + ); + }); + + case "Latest": + return entries.sort((a, b) => { + const aDateTime = new Date(a.lastSpanInstanceInfo.startTime).valueOf(); + const bDateTime = new Date(b.lastSpanInstanceInfo.startTime).valueOf(); + + return ( + (sorting.isDesc ? bDateTime - aDateTime : aDateTime - bDateTime) || + sortByName(a, b) + ); + }); + case "Errors": + return entries.sort((a, b) => { + const aErrors = a.insights.filter((x) => x.type === "Errors").length; + const bErrors = b.insights.filter((x) => x.type === "Errors").length; + return ( + (sorting.isDesc ? bErrors - aErrors : aErrors - bErrors) || + sortByName(a, b) + ); + }); + default: + return entries; + } +}; + export const AssetList = (props: AssetListProps) => { + const [sorting, setSorting] = useState<{ + criterion: string; + isDesc: boolean; + }>({ + criterion: "Insight Importance", + isDesc: true + }); + const [isSortingMenuOpen, setIsSortingMenuOpen] = useState(false); + const handleBackButtonClick = () => { props.onBackButtonClick(); }; @@ -15,9 +126,49 @@ export const AssetList = (props: AssetListProps) => { props.onAssetLinkClick(entry); }; + const handleSortingMenuToggle = () => { + setIsSortingMenuOpen(!isSortingMenuOpen); + }; + + const handleSortingMenuItemSelect = (value: string) => { + if (sorting.criterion === value) { + setSorting({ + ...sorting, + isDesc: !sorting.isDesc + }); + } else { + setSorting({ + criterion: value, + isDesc: false + }); + } + handleSortingMenuToggle(); + }; + const assetTypeInfo = getAssetTypeInfo(props.assetTypeId); - const uniqueEntryIds = Object.keys(props.entries); + const entries = useMemo( + () => + Object.keys(props.entries) + .map((entryId) => { + const entries = props.entries[entryId]; + return entries.map((entry) => { + const services = entries.map((entry) => entry.serviceName); + return { + ...entry, + id: entryId, + services + }; + }); + }) + .flat(), + [props.entries] + ); + + const sortedEntries = useMemo( + () => sortEntries(entries, sorting), + [entries, sorting] + ); return ( @@ -31,22 +182,42 @@ export const AssetList = (props: AssetListProps) => { {Object.values(props.entries).flat().length} - {uniqueEntryIds.length > 0 ? ( + + + + + Sort by + {sorting.criterion} + + + + + ({ value: x, label: x }))} + onSelect={handleSortingMenuItemSelect} + /> + + + + {sortedEntries.length > 0 ? ( - {uniqueEntryIds.map((entryId) => { - const entries = props.entries[entryId]; - return entries.map((entry) => { - const services = entries.map((entry) => entry.serviceName); - - return ( - - ); - }); + {sortedEntries.map((entry) => { + return ( + + ); })} ) : ( diff --git a/src/components/Assets/AssetList/styles.ts b/src/components/Assets/AssetList/styles.ts index ec1d9091f..565fbd327 100644 --- a/src/components/Assets/AssetList/styles.ts +++ b/src/components/Assets/AssetList/styles.ts @@ -25,14 +25,38 @@ export const Header = styled.div` padding: 8px 12px 8px 8px; `; +export const Toolbar = styled.div` + display: flex; + justify-content: flex-end; + padding: 8px; + gap: 12px; +`; + +export const SortingMenuContainer = styled.div` + display: flex; + gap: 2px; + font-weight: 500; + font-size: 10px; + line-height: 12px; + color: #9b9b9b; + align-items: center; + height: 20px; +`; + +export const SortingLabel = styled.span` + font-weight: 500; + font-size: 10px; + line-height: 12px; + color: #dadada; +`; + export const ItemsCount = styled.span` margin-left: auto; - font-weight: 400; color: #9f9f9f; `; export const List = styled.ul` - padding: 8px 9px; + padding: 0 9px 8px; display: flex; flex-direction: column; gap: 8px; diff --git a/src/components/Assets/AssetTypeList/types.ts b/src/components/Assets/AssetTypeList/types.ts index 6c429b800..60e6319dd 100644 --- a/src/components/Assets/AssetTypeList/types.ts +++ b/src/components/Assets/AssetTypeList/types.ts @@ -1,6 +1,6 @@ import { AssetEntry } from "../types"; -export type AssetListProps = { +export interface AssetListProps { data: { [key: string]: { [key: string]: AssetEntry[] } }; onAssetTypeSelect: (categoryId: string) => void; -}; +} diff --git a/src/components/RecentActivity/RecentActivityTable/types.ts b/src/components/RecentActivity/RecentActivityTable/types.ts index 43f3310b4..5954bcbde 100644 --- a/src/components/RecentActivity/RecentActivityTable/types.ts +++ b/src/components/RecentActivity/RecentActivityTable/types.ts @@ -1,12 +1,12 @@ import { ViewMode } from "../EnvironmentPanel/types"; import { ActivityEntry, EntrySpan } from "../types"; -export type RecentActivityTableProps = { +export interface RecentActivityTableProps { data: ActivityEntry[]; onSpanLinkClick: (span: EntrySpan) => void; onTraceButtonClick: (traceId: string, span: EntrySpan) => void; viewMode: ViewMode; -}; +} export enum INSIGHT_TYPES { SpanUsageStatus = "SpanUsageStatus", diff --git a/src/components/RecentActivity/types.ts b/src/components/RecentActivity/types.ts index a28fa79c2..b1bac30ed 100644 --- a/src/components/RecentActivity/types.ts +++ b/src/components/RecentActivity/types.ts @@ -1,19 +1,19 @@ import { Duration } from "../../globals"; -export type EntrySpan = { +export interface EntrySpan { displayText: string; serviceName: string; scopeId: string; spanCodeObjectId: string; methodCodeObjectId: string | null; -}; +} -export type SlimInsight = { +export interface SlimInsight { type: string; codeObjectIds: string[]; -}; +} -export type ActivityEntry = { +export interface ActivityEntry { environment: string; traceFlowDisplayName: string; firstEntrySpan: EntrySpan; @@ -22,7 +22,7 @@ export type ActivityEntry = { latestTraceTimestamp: string; latestTraceDuration: Duration; slimAggregatedInsights: SlimInsight[]; -}; +} export interface RecentActivityData { environments: string[]; diff --git a/src/components/common/App/index.tsx b/src/components/common/App/index.tsx index cae63d448..aa6842c43 100644 --- a/src/components/common/App/index.tsx +++ b/src/components/common/App/index.tsx @@ -7,11 +7,10 @@ import { isString } from "../../../typeGuards/isString"; import { GlobalStyle } from "./styles"; import { AppProps } from "./types"; +export const THEMES = ["light", "dark", "dark-jetbrains"]; + const isMode = (mode: unknown): mode is Mode => { - return ( - typeof mode === "string" && - ["light", "dark", "dark-jetbrains"].includes(mode) - ); + return typeof mode === "string" && THEMES.includes(mode); }; const getMode = (): Mode => { @@ -36,6 +35,13 @@ export const App = (props: AppProps) => { const [mainFont, setMainFont] = useState(""); const [codeFont, setCodeFont] = useState(""); + useEffect(() => { + if (!props.theme) { + return; + } + setMode(props.theme); + }, [props.theme]); + useEffect(() => { const handleSetColorMode = (data: unknown) => { if (isObject(data) && isMode(data.theme)) { diff --git a/src/components/common/App/types.ts b/src/components/common/App/types.ts index aecc635ce..d648581f9 100644 --- a/src/components/common/App/types.ts +++ b/src/components/common/App/types.ts @@ -1,3 +1,6 @@ +import { Mode } from "../../../globals"; + export interface AppProps { children: React.ReactNode; + theme?: Mode; } diff --git a/src/components/common/AssetEntry/index.tsx b/src/components/common/AssetEntry/index.tsx index f5fa1ff0f..583f390a8 100644 --- a/src/components/common/AssetEntry/index.tsx +++ b/src/components/common/AssetEntry/index.tsx @@ -16,9 +16,10 @@ export const AssetEntry = (props: AssetEntryProps) => { const otherServices = props.relatedServices.filter( (service) => service !== props.entry.serviceName ); - const performanceDuration = props.entry.durationPercentiles.find( + const duration = props.entry.durationPercentiles.find( (duration) => duration.percentile === 0.5 )?.currentDuration; + const lastSeenDateTime = props.entry.lastSpanInstanceInfo.startTime; return ( @@ -54,12 +55,10 @@ export const AssetEntry = (props: AssetEntryProps) => { - Performance + Duration - {performanceDuration ? performanceDuration.value : "N/A"} - {performanceDuration && ( - {performanceDuration.unit} - )} + {duration ? duration.value : "N/A"} + {duration && {duration.unit}} diff --git a/src/components/common/Menu/Menu.stories.tsx b/src/components/common/Menu/Menu.stories.tsx new file mode 100644 index 000000000..2ecbe2473 --- /dev/null +++ b/src/components/common/Menu/Menu.stories.tsx @@ -0,0 +1,36 @@ +import { ComponentMeta, ComponentStory } from "@storybook/react"; + +import { Menu } from "."; +import { MenuProps } from "./types"; + +export default { + title: "Common/Menu", + component: Menu, + parameters: { + // More on Story layout: https://storybook.js.org/docs/react/configure/story-layout + layout: "fullscreen" + } +} as ComponentMeta; + +const Template: ComponentStory = (args: MenuProps) => ( + +); + +export const Default = Template.bind({}); +Default.args = { + title: "Title", + items: [ + { + label: "Item 1", + value: "item_1" + }, + { + label: "Item 2", + value: "item_2" + }, + { + label: "Item 3", + value: "item_3" + } + ] +}; diff --git a/src/components/common/Menu/index.tsx b/src/components/common/Menu/index.tsx new file mode 100644 index 000000000..83fc5360a --- /dev/null +++ b/src/components/common/Menu/index.tsx @@ -0,0 +1,24 @@ +import * as s from "./styles"; +import { MenuProps } from "./types"; + +export const Menu = (props: MenuProps) => { + const handleMenuItemClick = (value: string) => { + props.onSelect(value); + }; + + return ( + + {props.title} + + {props.items.map((item) => ( + handleMenuItemClick(item.value)} + > + {item.label} + + ))} + + + ); +}; diff --git a/src/components/common/Menu/styles.ts b/src/components/common/Menu/styles.ts new file mode 100644 index 000000000..ec10931fd --- /dev/null +++ b/src/components/common/Menu/styles.ts @@ -0,0 +1,34 @@ +import styled from "styled-components"; + +export const Container = styled.div` + display: flex; + flex-direction: column; + background: #2e2e2e; + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.12); + border-radius: 2px; +`; + +export const Header = styled.div` + padding: 2px 8px; + font-size: 10px; + line-height: 14px; + color: #7c7c94; +`; + +export const List = styled.ul` + display: flex; + flex-direction: column; + margin: 0; + padding: 0; +`; + +export const ListItem = styled.li` + flex-direction: row; + width: 100%; + list-style-type: none; + padding: 6px 8px; + font-size: 10px; + line-height: 12px; + color: #9b9b9b; + cursor: pointer; +`; diff --git a/src/components/common/Menu/types.ts b/src/components/common/Menu/types.ts new file mode 100644 index 000000000..bfa1095d3 --- /dev/null +++ b/src/components/common/Menu/types.ts @@ -0,0 +1,8 @@ +export interface MenuProps { + title: string; + items: { + label: string; + value: string; + }[]; + onSelect: (value: string) => void; +} diff --git a/src/components/common/Popover/PopoverContent/index.tsx b/src/components/common/Popover/PopoverContent/index.tsx new file mode 100644 index 000000000..0e56bb390 --- /dev/null +++ b/src/components/common/Popover/PopoverContent/index.tsx @@ -0,0 +1,48 @@ +// Source: https://floating-ui.com/docs/popover#reusable-popover-component + +import { + FloatingFocusManager, + FloatingPortal, + useMergeRefs +} from "@floating-ui/react"; +import { CSSProperties, ForwardedRef, forwardRef, HTMLProps } from "react"; +import { usePopoverContext } from "../hooks"; + +const PopoverContentComponent = ( + props: HTMLProps, + propRef: ForwardedRef +) => { + const { context: floatingContext, ...context } = usePopoverContext(); + const ref = useMergeRefs([context.refs.setFloating, propRef]); + + return ( + + {context.open && ( + +
+ {props.children} +
+
+ )} +
+ ); +}; + +export const PopoverContent = forwardRef< + HTMLDivElement, + HTMLProps +>(PopoverContentComponent); diff --git a/src/components/common/Popover/PopoverContent/types.ts b/src/components/common/Popover/PopoverContent/types.ts new file mode 100644 index 000000000..c06cb2981 --- /dev/null +++ b/src/components/common/Popover/PopoverContent/types.ts @@ -0,0 +1,6 @@ +import { CSSProperties, ReactNode } from "react"; + +export interface PopoverContentProps { + children: ReactNode; + style: CSSProperties; +} diff --git a/src/components/common/Popover/PopoverTrigger/index.tsx b/src/components/common/Popover/PopoverTrigger/index.tsx new file mode 100644 index 000000000..2f9b4b576 --- /dev/null +++ b/src/components/common/Popover/PopoverTrigger/index.tsx @@ -0,0 +1,61 @@ +// Source: https://floating-ui.com/docs/popover#reusable-popover-component + +import { useMergeRefs } from "@floating-ui/react"; +import { + cloneElement, + ForwardedRef, + forwardRef, + HTMLProps, + isValidElement, + Ref +} from "react"; +import { usePopoverContext } from "../hooks"; +import * as s from "./styles"; +import { PopoverTriggerProps } from "./types"; + +const PopoverTriggerComponent = ( + { + children, + asChild = false, + ...props + }: HTMLProps & PopoverTriggerProps, + propRef: ForwardedRef +) => { + const context = usePopoverContext(); + // TODO: improve types + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const childrenRef: Ref = (children as any).ref; + const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]); + + // `asChild` allows the user to pass any element as the anchor + if (asChild && isValidElement(children)) { + return cloneElement( + children, + // TODO: improve types + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + context.getReferenceProps({ + ref, + ...props, + ...children.props, + "data-state": context.open ? "open" : "closed" + }) + ); + } + + return ( + + {children} + + ); +}; + +export const PopoverTrigger = forwardRef< + HTMLElement, + HTMLProps & PopoverTriggerProps +>(PopoverTriggerComponent); diff --git a/src/components/common/Popover/PopoverTrigger/styles.ts b/src/components/common/Popover/PopoverTrigger/styles.ts new file mode 100644 index 000000000..f228829d5 --- /dev/null +++ b/src/components/common/Popover/PopoverTrigger/styles.ts @@ -0,0 +1,9 @@ +import styled from "styled-components"; + +export const Button = styled.button` + background: none; + border: none; + display: flex; + padding: 0; + cursor: pointer; +`; diff --git a/src/components/common/Popover/PopoverTrigger/types.ts b/src/components/common/Popover/PopoverTrigger/types.ts new file mode 100644 index 000000000..e8fb05493 --- /dev/null +++ b/src/components/common/Popover/PopoverTrigger/types.ts @@ -0,0 +1,4 @@ +export interface PopoverTriggerProps { + children: React.ReactNode; + asChild?: boolean; +} diff --git a/src/components/common/Popover/hooks.ts b/src/components/common/Popover/hooks.ts new file mode 100644 index 000000000..bc286a58c --- /dev/null +++ b/src/components/common/Popover/hooks.ts @@ -0,0 +1,81 @@ +// Source: https://floating-ui.com/docs/popover#reusable-popover-component + +import { + autoUpdate, + flip, + offset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, + useRole +} from "@floating-ui/react"; +import { createContext, useContext, useMemo, useState } from "react"; +import { ContextType, PopoverProps } from "./types"; + +export const usePopover = ({ + initialOpen = false, + placement = "bottom", + modal, + open: controlledOpen, + onOpenChange: setControlledOpen +}: PopoverProps = {}) => { + const [uncontrolledOpen, setUncontrolledOpen] = useState(initialOpen); + const [labelId, setLabelId] = useState(); + const [descriptionId, setDescriptionId] = useState(); + + const open = controlledOpen ?? uncontrolledOpen; + const setOpen = setControlledOpen ?? setUncontrolledOpen; + + const data = useFloating({ + placement, + open, + onOpenChange: setOpen, + whileElementsMounted: autoUpdate, + middleware: [ + offset(5), + flip({ + fallbackAxisSideDirection: "end" + }), + shift({ padding: 5 }) + ] + }); + + const context = data.context; + + const click = useClick(context, { + enabled: controlledOpen == null + }); + const dismiss = useDismiss(context); + const role = useRole(context); + + const interactions = useInteractions([click, dismiss, role]); + + return useMemo( + () => ({ + open, + setOpen, + ...interactions, + ...data, + modal, + labelId, + descriptionId, + setLabelId, + setDescriptionId + }), + [open, setOpen, interactions, data, modal, labelId, descriptionId] + ); +}; + +export const PopoverContext = createContext(null); + +export const usePopoverContext = () => { + const context = useContext(PopoverContext); + + if (context == null) { + throw new Error("Popover components must be wrapped in "); + } + + return context; +}; diff --git a/src/components/common/Popover/index.tsx b/src/components/common/Popover/index.tsx new file mode 100644 index 000000000..9fb94a750 --- /dev/null +++ b/src/components/common/Popover/index.tsx @@ -0,0 +1,21 @@ +// Source: https://floating-ui.com/docs/popover#reusable-popover-component + +import { PopoverContext, usePopover } from "./hooks"; +import { PopoverProps } from "./types"; + +export function Popover({ + children, + modal = false, + ...restOptions +}: { + children: React.ReactNode; +} & PopoverProps) { + // This can accept any props as options, e.g. `placement`, + // or other positioning options. + const popover = usePopover({ modal, ...restOptions }); + return ( + + {children} + + ); +} diff --git a/src/components/common/Popover/types.ts b/src/components/common/Popover/types.ts new file mode 100644 index 000000000..a50d019f0 --- /dev/null +++ b/src/components/common/Popover/types.ts @@ -0,0 +1,19 @@ +import { Placement } from "@floating-ui/react"; +import { usePopover } from "./hooks"; + +export type ContextType = + | (ReturnType & { + setLabelId: React.Dispatch>; + setDescriptionId: React.Dispatch< + React.SetStateAction + >; + }) + | null; + +export interface PopoverProps { + initialOpen?: boolean; + placement?: Placement; + modal?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} diff --git a/src/environment.ts b/src/environment.ts index 561d8e02a..376fc109b 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -1,9 +1,11 @@ import { Environment } from "./globals"; +const ENVIRONMENTS = ["JetBrains", "VS Code", "Other"]; + +const isEnvironment = (environment: unknown): environment is Environment => + typeof environment === "string" && ENVIRONMENTS.includes(environment); + export const getEnvironment = (): Environment => - typeof window.environment === "string" && - ["JetBrains", "VS Code", "Other"].includes(window.environment) - ? window.environment - : "Other"; + isEnvironment(window.environment) ? window.environment : "Other"; export const environment = getEnvironment(); diff --git a/src/globals.d.ts b/src/globals.d.ts index 6bbff55bf..611270b72 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -6,29 +6,29 @@ export type Mode = "light" | "dark" | "dark-jetbrains"; declare global { interface Window { - sendMessageToVSCode: (message) => void; - cefQuery: (query: { + sendMessageToVSCode?: (message) => void; + cefQuery?: (query: { request: string; persistent?: boolean; onSuccess: (response) => void; onFailure: (error_code, error_message) => void; }) => string; - cefQueryCancel: (request_id: string) => void; + cefQueryCancel?: (request_id: string) => void; sendMessageToDigma: (message: any) => string | undefined; cancelMessageToDigma: (request_id: string) => void; - theme?: Mode; - environment?: Environment; - mainFont?: string; - codeFont?: string; - recentActivityRefreshInterval?: number; - recentActivityExpirationLimit?: number; - recentActivityDocumentationURL?: string; - assetsRefreshInterval?: number; + theme?: unknown; + environment?: unknown; + mainFont?: unknown; + codeFont?: unknown; + recentActivityRefreshInterval?: unknown; + recentActivityExpirationLimit?: unknown; + recentActivityDocumentationURL?: unknown; + assetsRefreshInterval?: unknown; } } -export type Duration = { +export interface Duration { value: number; unit: string; raw: number; -}; +} From 769ed42f13e5e9b5328e9bdbba35be5741101e46 Mon Sep 17 00:00:00 2001 From: Kyrylo Shmidt Date: Tue, 14 Mar 2023 12:40:37 +0100 Subject: [PATCH 2/3] Update sorting criteria and types --- src/components/Assets/AssetList/index.tsx | 93 ++++++------------- src/components/Assets/AssetList/types.ts | 15 ++- src/components/Assets/AssetTypeList/types.ts | 4 +- .../common/AssetEntry/AssetEntry.stories.tsx | 3 +- src/components/common/AssetEntry/index.tsx | 2 +- src/components/common/AssetEntry/types.ts | 7 +- 6 files changed, 50 insertions(+), 74 deletions(-) diff --git a/src/components/Assets/AssetList/index.tsx b/src/components/Assets/AssetList/index.tsx index 4209a0fbd..0711526a9 100644 --- a/src/components/Assets/AssetList/index.tsx +++ b/src/components/Assets/AssetList/index.tsx @@ -6,58 +6,44 @@ import { Menu } from "../../common/Menu"; import { Popover } from "../../common/Popover"; import { PopoverContent } from "../../common/Popover/PopoverContent"; import { PopoverTrigger } from "../../common/Popover/PopoverTrigger"; -import { AssetEntry } from "../types"; import { getAssetTypeInfo } from "../utils"; import * as s from "./styles"; -import { AssetListProps } from "./types"; - -const SORTING_CRITERION = [ - "Insight Importance", - "Services", - "Duration", - "Latest", - "Errors" -]; - -interface AssetEntryWithServices extends AssetEntry { - id: string; - services: string[]; -} - -interface Sorting { - criterion: string; - isDesc: boolean; -} +import { + AssetListProps, + ExtendedAssetEntryWithServices, + Sorting +} from "./types"; + +const SORTING_CRITERION = ["Critical insights", "Performance", "Name"]; const sortEntries = ( - entries: AssetEntryWithServices[], + entries: ExtendedAssetEntryWithServices[], sorting: Sorting -): AssetEntryWithServices[] => { +): ExtendedAssetEntryWithServices[] => { entries = [...entries]; - const sortByName = (a: AssetEntryWithServices, b: AssetEntryWithServices) => - a.span.displayName.localeCompare(b.span.displayName); + const sortByName = ( + a: ExtendedAssetEntryWithServices, + b: ExtendedAssetEntryWithServices + ) => a.span.displayName.localeCompare(b.span.displayName); switch (sorting.criterion) { - case "Insight Importance": + case "Critical insights": return entries.sort((a, b) => { - const aImportance = Math.min(...a.insights.map((x) => x.importance)); - const bImportance = Math.min(...b.insights.map((x) => x.importance)); + const aCriticalInsights = a.insights.filter( + (x) => x.importance < 3 + ).length; + const bCriticalInsights = b.insights.filter( + (x) => x.importance < 3 + ).length; return ( (sorting.isDesc - ? aImportance - bImportance - : bImportance - aImportance) || sortByName(a, b) + ? aCriticalInsights - bCriticalInsights + : bCriticalInsights - aCriticalInsights) || sortByName(a, b) ); }); - case "Services": - return entries.sort( - (a, b) => - (sorting.isDesc - ? b.serviceName.localeCompare(a.serviceName) - : a.serviceName.localeCompare(b.serviceName)) || sortByName(a, b) - ); - case "Duration": + case "Performance": return entries.sort((a, b) => { const aDuration = a.durationPercentiles.find( (duration) => duration.percentile === 0.5 @@ -83,26 +69,8 @@ const sortEntries = ( sortByName(a, b) ); }); - - case "Latest": - return entries.sort((a, b) => { - const aDateTime = new Date(a.lastSpanInstanceInfo.startTime).valueOf(); - const bDateTime = new Date(b.lastSpanInstanceInfo.startTime).valueOf(); - - return ( - (sorting.isDesc ? bDateTime - aDateTime : aDateTime - bDateTime) || - sortByName(a, b) - ); - }); - case "Errors": - return entries.sort((a, b) => { - const aErrors = a.insights.filter((x) => x.type === "Errors").length; - const bErrors = b.insights.filter((x) => x.type === "Errors").length; - return ( - (sorting.isDesc ? bErrors - aErrors : aErrors - bErrors) || - sortByName(a, b) - ); - }); + case "Name": + return entries.sort(sortByName); default: return entries; } @@ -113,7 +81,7 @@ export const AssetList = (props: AssetListProps) => { criterion: string; isDesc: boolean; }>({ - criterion: "Insight Importance", + criterion: "Critical insights", isDesc: true }); const [isSortingMenuOpen, setIsSortingMenuOpen] = useState(false); @@ -122,7 +90,7 @@ export const AssetList = (props: AssetListProps) => { props.onBackButtonClick(); }; - const handleAssetLinkClick = (entry: AssetEntry) => { + const handleAssetLinkClick = (entry: ExtendedAssetEntryWithServices) => { props.onAssetLinkClick(entry); }; @@ -147,17 +115,17 @@ export const AssetList = (props: AssetListProps) => { const assetTypeInfo = getAssetTypeInfo(props.assetTypeId); - const entries = useMemo( + const entries: ExtendedAssetEntryWithServices[] = useMemo( () => Object.keys(props.entries) .map((entryId) => { const entries = props.entries[entryId]; return entries.map((entry) => { - const services = entries.map((entry) => entry.serviceName); + const relatedServices = entries.map((entry) => entry.serviceName); return { ...entry, id: entryId, - services + relatedServices }; }); }) @@ -214,7 +182,6 @@ export const AssetList = (props: AssetListProps) => { ); diff --git a/src/components/Assets/AssetList/types.ts b/src/components/Assets/AssetList/types.ts index 83957346e..2606334f7 100644 --- a/src/components/Assets/AssetList/types.ts +++ b/src/components/Assets/AssetList/types.ts @@ -1,8 +1,17 @@ -import { AssetEntry } from "../types"; +import { ExtendedAssetEntry } from "../types"; + +export interface ExtendedAssetEntryWithServices extends ExtendedAssetEntry { + relatedServices: string[]; +} export interface AssetListProps { onBackButtonClick: () => void; assetTypeId: string; - entries: { [key: string]: AssetEntry[] }; - onAssetLinkClick: (entry: AssetEntry) => void; + entries: { [key: string]: ExtendedAssetEntry[] }; + onAssetLinkClick: (entry: ExtendedAssetEntryWithServices) => void; +} + +export interface Sorting { + criterion: string; + isDesc: boolean; } diff --git a/src/components/Assets/AssetTypeList/types.ts b/src/components/Assets/AssetTypeList/types.ts index 60e6319dd..df5949294 100644 --- a/src/components/Assets/AssetTypeList/types.ts +++ b/src/components/Assets/AssetTypeList/types.ts @@ -1,6 +1,6 @@ -import { AssetEntry } from "../types"; +import { ExtendedAssetEntry } from "../types"; export interface AssetListProps { - data: { [key: string]: { [key: string]: AssetEntry[] } }; + data: { [key: string]: { [key: string]: ExtendedAssetEntry[] } }; onAssetTypeSelect: (categoryId: string) => void; } diff --git a/src/components/common/AssetEntry/AssetEntry.stories.tsx b/src/components/common/AssetEntry/AssetEntry.stories.tsx index e11311ef3..46d157394 100644 --- a/src/components/common/AssetEntry/AssetEntry.stories.tsx +++ b/src/components/common/AssetEntry/AssetEntry.stories.tsx @@ -18,8 +18,9 @@ const Template: ComponentStory = (args: AssetEntryProps) => ( export const Default = Template.bind({}); Default.args = { - relatedServices: ["service1", "service2"], entry: { + id: "span:io.opentelemetry.tomcat-10.0$_$HTTP GET /SampleInsights/ErrorRecordedOnLocalRootSpan", + relatedServices: ["service1", "service2"], span: { classification: "Endpoint", role: "Entry", diff --git a/src/components/common/AssetEntry/index.tsx b/src/components/common/AssetEntry/index.tsx index 583f390a8..03e51f9ca 100644 --- a/src/components/common/AssetEntry/index.tsx +++ b/src/components/common/AssetEntry/index.tsx @@ -13,7 +13,7 @@ export const AssetEntry = (props: AssetEntryProps) => { }; const name = props.entry.span.displayName; - const otherServices = props.relatedServices.filter( + const otherServices = props.entry.relatedServices.filter( (service) => service !== props.entry.serviceName ); const duration = props.entry.durationPercentiles.find( diff --git a/src/components/common/AssetEntry/types.ts b/src/components/common/AssetEntry/types.ts index e0f560c0d..b64b9a849 100644 --- a/src/components/common/AssetEntry/types.ts +++ b/src/components/common/AssetEntry/types.ts @@ -1,7 +1,6 @@ -import { AssetEntry } from "../../Assets/types"; +import { ExtendedAssetEntryWithServices } from "../../Assets/AssetList/types"; export interface AssetEntryProps { - entry: AssetEntry; - relatedServices: string[]; - onAssetLinkClick: (entry: AssetEntry) => void; + entry: ExtendedAssetEntryWithServices; + onAssetLinkClick: (entry: ExtendedAssetEntryWithServices) => void; } From 29843ce24fc9381eaad82c10e8d54031f13edb3e Mon Sep 17 00:00:00 2001 From: Kyrylo Shmidt Date: Tue, 14 Mar 2023 12:44:34 +0100 Subject: [PATCH 3/3] Revert column renaming --- src/components/common/AssetEntry/index.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/common/AssetEntry/index.tsx b/src/components/common/AssetEntry/index.tsx index 03e51f9ca..9ea68ecbd 100644 --- a/src/components/common/AssetEntry/index.tsx +++ b/src/components/common/AssetEntry/index.tsx @@ -16,7 +16,7 @@ export const AssetEntry = (props: AssetEntryProps) => { const otherServices = props.entry.relatedServices.filter( (service) => service !== props.entry.serviceName ); - const duration = props.entry.durationPercentiles.find( + const performanceDuration = props.entry.durationPercentiles.find( (duration) => duration.percentile === 0.5 )?.currentDuration; @@ -55,10 +55,12 @@ export const AssetEntry = (props: AssetEntryProps) => { - Duration + Performance - {duration ? duration.value : "N/A"} - {duration && {duration.unit}} + {performanceDuration ? performanceDuration.value : "N/A"} + {performanceDuration && ( + {performanceDuration.unit} + )}