diff --git a/src/actions.ts b/src/actions.ts index 306850000..20335d41f 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -28,5 +28,8 @@ export const actions = addPrefix(ACTION_PREFIX, { REGISTER: "REGISTER", SET_ENVIRONMENTS: "SET_ENVIRONMENTS", SET_SELECTED_CODE_SCOPE: "SET_SELECTED_CODE_SCOPE", - SET_IS_MICROMETER_PROJECT: "SET_IS_MICROMETER_PROJECT" + SET_IS_MICROMETER_PROJECT: "SET_IS_MICROMETER_PROJECT", + SAVE_TO_PERSISTENCE: "SAVE_TO_PERSISTENCE", + GET_FROM_PERSISTENCE: "GET_FROM_PERSISTENCE", + SET_FROM_PERSISTENCE: "SET_FROM_PERSISTENCE" }); diff --git a/src/components/Assets/AssetList/AssetEntry/AssetEntry.stories.tsx b/src/components/Assets/AssetList/AssetEntry/AssetEntry.stories.tsx index 7c17934b6..eaf5adb9e 100644 --- a/src/components/Assets/AssetList/AssetEntry/AssetEntry.stories.tsx +++ b/src/components/Assets/AssetList/AssetEntry/AssetEntry.stories.tsx @@ -55,7 +55,7 @@ export const Default: Story = { } ], latestSpanTimestamp: "2023-02-20T14:36:03.480951Z", - instrumentationLibrary: "Global" + instrumentationLibrary: "Very long long long long long long name" } } }; diff --git a/src/components/Assets/AssetList/AssetEntry/index.tsx b/src/components/Assets/AssetList/AssetEntry/index.tsx index 7cb8fee08..d4bd6956b 100644 --- a/src/components/Assets/AssetList/AssetEntry/index.tsx +++ b/src/components/Assets/AssetList/AssetEntry/index.tsx @@ -199,9 +199,13 @@ export const AssetEntry = (props: AssetEntryProps) => { Scope - - {props.entry.instrumentationLibrary} - + + + + {props.entry.instrumentationLibrary} + + + )} diff --git a/src/components/Assets/AssetList/AssetEntry/styles.ts b/src/components/Assets/AssetList/AssetEntry/styles.ts index e52acbff1..7aaa73f18 100644 --- a/src/components/Assets/AssetList/AssetEntry/styles.ts +++ b/src/components/Assets/AssetList/AssetEntry/styles.ts @@ -134,7 +134,6 @@ export const ValueContainer = styled.div` return "#c6c6c6"; } }}; - width: fit-content; `; export const Suffix = styled.span` @@ -164,3 +163,9 @@ export const ImpactScoreIndicator = styled.div` height: 10px; background: hsl(14deg 66% ${({ $score }) => 100 - 50 * $score}%); `; + +export const ScopeName = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; diff --git a/src/components/Assets/AssetList/AssetList.stories.tsx b/src/components/Assets/AssetList/AssetList.stories.tsx index ffef353a3..6c8e2c827 100644 --- a/src/components/Assets/AssetList/AssetList.stories.tsx +++ b/src/components/Assets/AssetList/AssetList.stories.tsx @@ -19,6 +19,7 @@ type Story = StoryObj; // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args export const Default: Story = { args: { + searchQuery: "", assetTypeId: "Endpoint", services: [], data: { diff --git a/src/components/Assets/AssetList/index.tsx b/src/components/Assets/AssetList/index.tsx index f60fd1dec..6d8b81550 100644 --- a/src/components/Assets/AssetList/index.tsx +++ b/src/components/Assets/AssetList/index.tsx @@ -1,8 +1,7 @@ -import { ChangeEvent, useContext, useEffect, useRef, useState } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { DefaultTheme, useTheme } from "styled-components"; import { dispatcher } from "../../../dispatcher"; import { getFeatureFlagValue } from "../../../featureFlags"; -import { useDebounce } from "../../../hooks/useDebounce"; import { usePrevious } from "../../../hooks/usePrevious"; import { isNumber } from "../../../typeGuards/isNumber"; import { isString } from "../../../typeGuards/isString"; @@ -16,11 +15,11 @@ import { Popover } from "../../common/Popover"; import { PopoverContent } from "../../common/Popover/PopoverContent"; import { PopoverTrigger } from "../../common/Popover/PopoverTrigger"; import { ChevronIcon } from "../../common/icons/ChevronIcon"; -import { MagnifierIcon } from "../../common/icons/MagnifierIcon"; import { SortIcon } from "../../common/icons/SortIcon"; import { Direction } from "../../common/icons/types"; +import { AssetFilterQuery } from "../AssetsFilter/types"; import { actions } from "../actions"; -import { getAssetTypeInfo } from "../utils"; +import { checkIfAnyFiltersApplied, getAssetTypeInfo } from "../utils"; import { AssetEntry as AssetEntryComponent } from "./AssetEntry"; import * as s from "./styles"; import { @@ -126,6 +125,39 @@ const getSortingCriterionInfo = ( return sortingCriterionInfoMap[sortingCriterion]; }; +const getData = ( + assetTypeId: string, + page: number, + sorting: Sorting, + searchQuery: string, + filters: AssetFilterQuery | undefined, + services: string[] | undefined, + isComplexFilterEnabled: boolean +) => { + window.sendMessageToDigma({ + action: actions.GET_DATA, + payload: { + query: { + assetType: assetTypeId, + page, + pageSize: PAGE_SIZE, + sortBy: sorting.criterion, + sortOrder: sorting.order, + ...(searchQuery && searchQuery.length > 0 + ? { displayName: searchQuery } + : {}), + ...(isComplexFilterEnabled + ? filters || { + services: [], + operations: [], + insights: [] + } + : { services: services || [] }) + } + } + }); +}; + export const AssetList = (props: AssetListProps) => { const [data, setData] = useState(); const previousData = usePrevious(data); @@ -138,16 +170,11 @@ export const AssetList = (props: AssetListProps) => { }); const previousSorting = usePrevious(sorting); const [isSortingMenuOpen, setIsSortingMenuOpen] = useState(false); - const [searchInputValue, setSearchInputValue] = useState(""); - const debouncedSearchInputValue = useDebounce(searchInputValue, 1000); - const previousDebouncedSearchInputValue = usePrevious( - debouncedSearchInputValue - ); + const previousSearchQuery = usePrevious(props.searchQuery); const theme = useTheme(); const backIconColor = getBackIconColor(theme); const assetTypeIconColor = getAssetTypeIconColor(theme); const sortingMenuChevronColor = getSortingMenuChevronColor(theme); - const searchInputIconColor = sortingMenuChevronColor; const [page, setPage] = useState(0); const previousPage = usePrevious(page); const filteredCount = data?.filteredCount || 0; @@ -162,6 +189,7 @@ export const AssetList = (props: AssetListProps) => { const previousEnvironment = usePrevious(config.environment); const previousAssetTypeId = usePrevious(props.assetTypeId); const previousServices = usePrevious(props.services); + const previousFilters = usePrevious(props.filters); const entries = data?.data || []; @@ -172,6 +200,24 @@ export const AssetList = (props: AssetListProps) => { FeatureFlag.IS_ASSETS_OVERALL_IMPACT_HIDDEN ); + const isComplexFilterEnabled = useMemo( + () => + Boolean( + getFeatureFlagValue( + config, + FeatureFlag.IS_ASSETS_COMPLEX_FILTER_ENABLED + ) + ), + [config] + ); + + const areAnyFiltersApplied = checkIfAnyFiltersApplied( + isComplexFilterEnabled, + props.filters, + props.services, + props.searchQuery + ); + const sortingCriteria = isOverallImpactHidden ? Object.values(SORTING_CRITERION).filter( (x) => x !== SORTING_CRITERION.OVERALL_IMPACT @@ -179,22 +225,15 @@ export const AssetList = (props: AssetListProps) => { : Object.values(SORTING_CRITERION); useEffect(() => { - window.sendMessageToDigma({ - action: actions.GET_DATA, - payload: { - query: { - assetType: props.assetTypeId, - page, - pageSize: PAGE_SIZE, - sortBy: sorting.criterion, - sortOrder: sorting.order, - ...(debouncedSearchInputValue.length > 0 - ? { displayName: debouncedSearchInputValue } - : {}), - services: props.services - } - } - }); + getData( + props.assetTypeId, + page, + sorting, + props.searchQuery, + props.filters, + props.services, + isComplexFilterEnabled + ); setIsInitialLoading(true); const handleAssetsData = (data: unknown, timeStamp: number) => { @@ -216,34 +255,29 @@ export const AssetList = (props: AssetListProps) => { (isString(previousEnvironment) && previousEnvironment !== config.environment) || (previousSorting && previousSorting !== sorting) || - (isString(previousDebouncedSearchInputValue) && - previousDebouncedSearchInputValue !== debouncedSearchInputValue) || + (isString(previousSearchQuery) && + previousSearchQuery !== props.searchQuery) || (isString(previousAssetTypeId) && previousAssetTypeId !== props.assetTypeId) || - (Array.isArray(previousServices) && previousServices !== props.services) + (Array.isArray(previousServices) && + previousServices !== props.services) || + (previousFilters && previousFilters !== props.filters) ) { - window.sendMessageToDigma({ - action: actions.GET_DATA, - payload: { - query: { - assetType: props.assetTypeId, - page, - pageSize: PAGE_SIZE, - sortBy: sorting.criterion, - sortOrder: sorting.order, - ...(debouncedSearchInputValue.length > 0 - ? { displayName: debouncedSearchInputValue } - : {}), - services: props.services - } - } - }); + getData( + props.assetTypeId, + page, + sorting, + props.searchQuery, + props.filters, + props.services, + isComplexFilterEnabled + ); } }, [ props.assetTypeId, previousAssetTypeId, - previousDebouncedSearchInputValue, - debouncedSearchInputValue, + previousSearchQuery, + props.searchQuery, previousSorting, sorting, previousPage, @@ -251,29 +285,25 @@ export const AssetList = (props: AssetListProps) => { previousEnvironment, config.environment, props.services, - previousServices + previousServices, + props.filters, + previousFilters, + isComplexFilterEnabled ]); useEffect(() => { if (previousLastSetDataTimeStamp !== lastSetDataTimeStamp) { window.clearTimeout(refreshTimerId.current); refreshTimerId.current = window.setTimeout(() => { - window.sendMessageToDigma({ - action: actions.GET_DATA, - payload: { - query: { - assetType: props.assetTypeId, - page, - pageSize: PAGE_SIZE, - sortBy: sorting.criterion, - sortOrder: sorting.order, - ...(debouncedSearchInputValue.length > 0 - ? { displayName: debouncedSearchInputValue } - : {}), - services: props.services - } - } - }); + getData( + props.assetTypeId, + page, + sorting, + props.searchQuery, + props.filters, + props.services, + isComplexFilterEnabled + ); }, REFRESH_INTERVAL); } }, [ @@ -282,9 +312,11 @@ export const AssetList = (props: AssetListProps) => { props.assetTypeId, page, sorting, - debouncedSearchInputValue, + props.searchQuery, config.environment, - props.services + props.services, + props.filters, + isComplexFilterEnabled ]); useEffect(() => { @@ -301,31 +333,16 @@ export const AssetList = (props: AssetListProps) => { useEffect(() => { setPage(0); - }, [ - config.environment, - debouncedSearchInputValue, - sorting, - props.assetTypeId - ]); + }, [config.environment, props.searchQuery, sorting, props.assetTypeId]); useEffect(() => { listRef.current?.scrollTo(0, 0); - }, [ - config.environment, - debouncedSearchInputValue, - sorting, - page, - props.assetTypeId - ]); + }, [config.environment, props.searchQuery, sorting, page, props.assetTypeId]); const handleBackButtonClick = () => { props.onBackButtonClick(); }; - const handleSearchInputChange = (e: ChangeEvent) => { - setSearchInputValue(e.target.value); - }; - const handleSortingMenuToggle = () => { setIsSortingMenuOpen(!isSortingMenuOpen); }; @@ -402,8 +419,17 @@ export const AssetList = (props: AssetListProps) => { > ) : ( - Not seeing your data here? Maybe you're missing some - instrumentation! + {areAnyFiltersApplied ? ( + <> + It seems there are no assets matching your selected filters at the + moment + > + ) : ( + <> + Not seeing your data here? Maybe you're missing some + instrumentation! + > + )} ); }; @@ -425,17 +451,6 @@ export const AssetList = (props: AssetListProps) => { {data && {data.totalCount}} - {window.assetsSearch === true && ( - - - - - - - )} { - switch (theme.mode) { - case "light": - return "#4d668a"; - case "dark": - case "dark-jetbrains": - return "#dadada"; - } - }}; - color: ${({ theme }) => { - switch (theme.mode) { - case "light": - return "#4d668a"; - case "dark": - case "dark-jetbrains": - return "#dadada"; - } - }}; - border: 1px solid - ${({ theme }) => { - switch (theme.mode) { - case "light": - return "#d0d6eb"; - case "dark": - case "dark-jetbrains": - return "#606060"; - } - }}; - - &:focus, - &:hover { - border: 1px solid - ${({ theme }) => { - switch (theme.mode) { - case "light": - return "#7891d0"; - case "dark": - case "dark-jetbrains": - return "#9b9b9b"; - } - }}; - } - - &::placeholder { - color: ${({ theme }) => { - switch (theme.mode) { - case "light": - return "#4d668a"; - case "dark": - case "dark-jetbrains": - return "#dadada"; - } - }}; - } - - &:focus::placeholder { - color: transparent; - } -`; - export const SortingMenuButton = styled.button` background: none; cursor: pointer; diff --git a/src/components/Assets/AssetList/types.ts b/src/components/Assets/AssetList/types.ts index 7c1678a73..f1faac922 100644 --- a/src/components/Assets/AssetList/types.ts +++ b/src/components/Assets/AssetList/types.ts @@ -1,10 +1,13 @@ import { Duration } from "../../../globals"; +import { AssetFilterQuery } from "../AssetsFilter/types"; export interface AssetListProps { data?: AssetsData; onBackButtonClick: () => void; assetTypeId: string; - services: string[]; + services?: string[]; + filters?: AssetFilterQuery; + searchQuery: string; } export enum SORTING_CRITERION { diff --git a/src/components/Assets/AssetTypeList/AssetTypeList.stories.tsx b/src/components/Assets/AssetTypeList/AssetTypeList.stories.tsx index e22d7fa0c..a73cf43c5 100644 --- a/src/components/Assets/AssetTypeList/AssetTypeList.stories.tsx +++ b/src/components/Assets/AssetTypeList/AssetTypeList.stories.tsx @@ -19,6 +19,7 @@ type Story = StoryObj; // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args export const Default: Story = { args: { + searchQuery: "", services: [], data: { assetCategories: [ diff --git a/src/components/Assets/AssetTypeList/index.tsx b/src/components/Assets/AssetTypeList/index.tsx index ba5e18b29..f6521d837 100644 --- a/src/components/Assets/AssetTypeList/index.tsx +++ b/src/components/Assets/AssetTypeList/index.tsx @@ -1,17 +1,20 @@ -import { useContext, useEffect, useRef, useState } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { actions as globalActions } from "../../../actions"; import { dispatcher } from "../../../dispatcher"; +import { getFeatureFlagValue } from "../../../featureFlags"; import { usePrevious } from "../../../hooks/usePrevious"; import { trackingEvents as globalTrackingEvents } from "../../../trackingEvents"; import { isNumber } from "../../../typeGuards/isNumber"; import { isString } from "../../../typeGuards/isString"; +import { FeatureFlag } from "../../../types"; import { sendTrackingEvent } from "../../../utils/sendTrackingEvent"; import { ConfigContext } from "../../common/App/ConfigContext"; import { EmptyState } from "../../common/EmptyState"; import { NewCircleLoader } from "../../common/NewCircleLoader"; import { CardsIcon } from "../../common/icons/CardsIcon"; +import { AssetFilterQuery } from "../AssetsFilter/types"; import { actions } from "../actions"; -import { getAssetTypeInfo } from "../utils"; +import { checkIfAnyFiltersApplied, getAssetTypeInfo } from "../utils"; import { AssetTypeListItem } from "./AssetTypeListItem"; import * as s from "./styles"; import { AssetCategoriesData, AssetTypeListProps } from "./types"; @@ -29,6 +32,25 @@ const ASSET_TYPE_IDS = [ "Other" ]; +const getData = ( + filters: AssetFilterQuery | undefined, + services: string[] | undefined, + searchQuery: string, + isComplexFilterEnabled: boolean +) => { + window.sendMessageToDigma({ + action: actions.GET_CATEGORIES_DATA, + payload: { + query: { + ...(isComplexFilterEnabled + ? filters || { services: [], operations: [], insights: [] } + : { services: services || [] }), + ...(searchQuery.length > 0 ? { displayName: searchQuery } : {}) + } + } + }); +}; + export const AssetTypeList = (props: AssetTypeListProps) => { const [data, setData] = useState(); const previousData = usePrevious(data); @@ -39,14 +61,34 @@ export const AssetTypeList = (props: AssetTypeListProps) => { const previousEnvironment = usePrevious(config.environment); const refreshTimerId = useRef(); const previousServices = usePrevious(props.services); + const previousFilters = usePrevious(props.filters); + const previousSearchQuery = usePrevious(props.searchQuery); + + const isComplexFilterEnabled = useMemo( + () => + Boolean( + getFeatureFlagValue( + config, + FeatureFlag.IS_ASSETS_COMPLEX_FILTER_ENABLED + ) + ), + [config] + ); + + const areAnyFiltersApplied = checkIfAnyFiltersApplied( + isComplexFilterEnabled, + props.filters, + props.services, + props.searchQuery + ); useEffect(() => { - window.sendMessageToDigma({ - action: actions.GET_CATEGORIES_DATA, - payload: { - services: props.services - } - }); + getData( + props.filters, + props.services, + props.searchQuery, + isComplexFilterEnabled + ); setIsInitialLoading(true); const handleCategoriesData = (data: unknown, timeStamp: number) => { @@ -72,35 +114,51 @@ export const AssetTypeList = (props: AssetTypeListProps) => { if ( (isString(previousEnvironment) && previousEnvironment !== config.environment) || - (Array.isArray(previousServices) && previousServices !== props.services) + (Array.isArray(previousServices) && + previousServices !== props.services) || + (previousFilters && previousFilters !== props.filters) || + (isString(previousSearchQuery) && + previousSearchQuery !== props.searchQuery) ) { - window.sendMessageToDigma({ - action: actions.GET_CATEGORIES_DATA, - payload: { - services: props.services - } - }); + getData( + props.filters, + props.services, + props.searchQuery, + isComplexFilterEnabled + ); } }, [ previousEnvironment, config.environment, previousServices, - props.services + props.services, + previousFilters, + props.filters, + previousSearchQuery, + props.searchQuery, + isComplexFilterEnabled ]); useEffect(() => { if (previousLastSetDataTimeStamp !== lastSetDataTimeStamp) { window.clearTimeout(refreshTimerId.current); refreshTimerId.current = window.setTimeout(() => { - window.sendMessageToDigma({ - action: actions.GET_CATEGORIES_DATA, - payload: { - services: props.services - } - }); + getData( + props.filters, + props.services, + props.searchQuery, + isComplexFilterEnabled + ); }, REFRESH_INTERVAL); } - }, [props.services, previousLastSetDataTimeStamp, lastSetDataTimeStamp]); + }, [ + props.services, + previousLastSetDataTimeStamp, + lastSetDataTimeStamp, + props.filters, + props.searchQuery, + isComplexFilterEnabled + ]); useEffect(() => { if (props.data) { @@ -137,23 +195,32 @@ export const AssetTypeList = (props: AssetTypeListProps) => { } if (data?.assetCategories.every((x) => x.count === 0)) { + let title = "No data yet"; + let content = ( + <> + + Trigger actions that call this application to learn more about its + runtime behavior + + + Not seeing your application data? + + > + ); + + if (areAnyFiltersApplied) { + title = "No results"; + content = ( + + It seems there are no assets matching your selected filters at the + moment + + ); + } + return ( - - - Trigger actions that call this application to learn more about - its runtime behavior - - - Not seeing your application data? - - > - } - /> + ); } diff --git a/src/components/Assets/AssetTypeList/types.ts b/src/components/Assets/AssetTypeList/types.ts index 5ad35604f..d955edf51 100644 --- a/src/components/Assets/AssetTypeList/types.ts +++ b/src/components/Assets/AssetTypeList/types.ts @@ -1,7 +1,11 @@ +import { AssetFilterQuery } from "../AssetsFilter/types"; + export interface AssetTypeListProps { data?: AssetCategoriesData; onAssetTypeSelect: (assetTypeId: string) => void; - services: string[]; + services?: string[]; + filters?: AssetFilterQuery; + searchQuery: string; } export interface AssetCategoriesData { diff --git a/src/components/Assets/AssetsFilter/AssetsFilter.stories.tsx b/src/components/Assets/AssetsFilter/AssetsFilter.stories.tsx new file mode 100644 index 000000000..a7416457b --- /dev/null +++ b/src/components/Assets/AssetsFilter/AssetsFilter.stories.tsx @@ -0,0 +1,211 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import { AssetsFilter } from "."; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: "Assets/AssetsFilter", + component: AssetsFilter, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout + layout: "fullscreen" + } +}; + +export default meta; + +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args +export const Default: Story = { + args: { + data: { + categories: [ + { + categoryName: "Services", + entries: [ + { + enabled: true, + selected: true, + name: "ClientTesterOfPetClinic" + }, + { + enabled: true, + selected: false, + name: "spring-petclinic" + } + ] + }, + { + categoryName: "Operations", + categories: [ + { + categoryName: "Endpoints", + entries: [ + { + enabled: true, + selected: true, + name: "HTTP GET /" + }, + { + enabled: false, + selected: false, + name: "HTTP GET /**" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /oups" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /owners" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /owners/{ownerId}" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /owners/find" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /owners/new" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /SampleInsights/ErrorHotspot" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /SampleInsights/ErrorRecordedOnCurrentSpan" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /SampleInsights/ErrorRecordedOnDeeplyNestedSpan" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /SampleInsights/ErrorRecordedOnLocalRootSpan" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /SampleInsights/GenAsyncSpanVar01" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /SampleInsights/HighUsage" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /SampleInsights/NPlusOneWithInternalSpan" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /SampleInsights/NPlusOneWithoutInternalSpan" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /SampleInsights/req-map-get" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /SampleInsights/SlowEndpoint" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /SampleInsights/SpanBottleneck" + }, + { + enabled: true, + selected: false, + name: "HTTP GET /vets.html" + } + ] + }, + { + categoryName: "Consumers", + entries: [] + }, + { + categoryName: "Internal", + entries: [ + { + enabled: true, + selected: false, + name: "ClientTester.generateInsightData" + } + ] + } + ] + }, + { + categoryName: "Insights", + entries: [ + { + enabled: true, + selected: false, + name: "EndpointBreakdown" + }, + { + enabled: true, + selected: false, + name: "EndpointChattyApi" + }, + { + enabled: true, + selected: false, + name: "EndpointHighNumberOfQueries" + }, + { + enabled: true, + selected: false, + name: "EndpointSpaNPlusOne" + }, + { + enabled: true, + selected: false, + name: "HighUsage" + }, + { + enabled: true, + selected: false, + name: "LowUsage" + }, + { + enabled: true, + selected: false, + name: "SlowEndpoint" + }, + { + enabled: true, + selected: false, + name: "SlowestSpans" + }, + { + enabled: true, + selected: false, + name: "SpanScalingInsufficientData" + } + ] + } + ] + } + } +}; diff --git a/src/components/Assets/AssetsFilter/FilterButton/index.tsx b/src/components/Assets/AssetsFilter/FilterButton/index.tsx new file mode 100644 index 000000000..d1be0ef25 --- /dev/null +++ b/src/components/Assets/AssetsFilter/FilterButton/index.tsx @@ -0,0 +1,17 @@ +import { isNumber } from "../../../../typeGuards/isNumber"; +import { FunnelIcon } from "../../../common/icons/FunnelIcon"; +import * as s from "./styles"; +import { FilterButtonProps } from "./types"; + +export const FilterButton = (props: FilterButtonProps) => ( + 0} + > + + {props.title} + {props.showCount && + isNumber(props.selectedCount) && + props.selectedCount > 0 && + !props.isLoading && {props.selectedCount}} + +); diff --git a/src/components/Assets/AssetsFilter/FilterButton/styles.ts b/src/components/Assets/AssetsFilter/FilterButton/styles.ts new file mode 100644 index 000000000..4c656ecc1 --- /dev/null +++ b/src/components/Assets/AssetsFilter/FilterButton/styles.ts @@ -0,0 +1,46 @@ +import styled from "styled-components"; +import { grayScale } from "../../../common/App/getTheme"; +import { ButtonProps } from "./types"; + +export const Button = styled.button` + border-radius: 4px; + padding: 4px 8px; + display: flex; + gap: 4px; + align-items: center; + border: 1px solid ${({ theme }) => theme.colors.stroke.primary}; + background: ${({ theme, $hasSelectedItems }) => { + if ($hasSelectedItems) { + return theme.colors.surface.brandDark; + } + + switch (theme.mode) { + case "light": + return grayScale[50]; + case "dark": + case "dark-jetbrains": + return grayScale[1000]; + } + }}; + box-shadow: 1px 1px 4px 0 rgba(0 0 0 / 25%); + color: ${({ theme }) => theme.colors.icon.primary}; + font-size: 14px; + font-weight: 500; + cursor: pointer; +`; + +export const Title = styled.span` + color: ${({ theme }) => theme.colors.text.subtext}; +`; + +export const Number = styled.span` + width: 16px; + height: 16px; + font-weight: 500; + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + background: #4b4db4; +`; diff --git a/src/components/Assets/AssetsFilter/FilterButton/types.ts b/src/components/Assets/AssetsFilter/FilterButton/types.ts new file mode 100644 index 000000000..88266f49a --- /dev/null +++ b/src/components/Assets/AssetsFilter/FilterButton/types.ts @@ -0,0 +1,11 @@ +export interface FilterButtonProps { + title: string; + isLoading?: boolean; + isMenuOpen: boolean; + selectedCount?: number; + showCount?: boolean; +} + +export interface ButtonProps { + $hasSelectedItems: boolean; +} diff --git a/src/components/Assets/AssetsFilter/index.tsx b/src/components/Assets/AssetsFilter/index.tsx new file mode 100644 index 000000000..49eee102d --- /dev/null +++ b/src/components/Assets/AssetsFilter/index.tsx @@ -0,0 +1,434 @@ +import { ComponentType, useContext, useEffect, useRef, useState } from "react"; +import { dispatcher } from "../../../dispatcher"; +import { usePersistence } from "../../../hooks/usePersistence"; +import { usePrevious } from "../../../hooks/usePrevious"; +import { isNull } from "../../../typeGuards/isNull"; +import { isNumber } from "../../../typeGuards/isNumber"; +import { isString } from "../../../typeGuards/isString"; +import { isUndefined } from "../../../typeGuards/isUndefined"; +import { InsightType } from "../../../types"; +import { getInsightTypeInfo } from "../../../utils/getInsightTypeInfo"; +import { sendTrackingEvent } from "../../../utils/sendTrackingEvent"; +import { ConfigContext } from "../../common/App/ConfigContext"; +import { NewButton } from "../../common/NewButton"; +import { NewPopover } from "../../common/NewPopover"; +import { Select } from "../../common/Select"; +import { WrenchIcon } from "../../common/icons/12px/WrenchIcon"; +import { EndpointIcon } from "../../common/icons/EndpointIcon"; +import { SparkleIcon } from "../../common/icons/SparkleIcon"; +import { IconProps } from "../../common/icons/types"; +import { actions } from "../actions"; +import { trackingEvents } from "../tracking"; +import { FilterButton } from "./FilterButton"; +import * as s from "./styles"; +import { + AssetFilterCategory, + AssetFilterQuery, + AssetsFilterProps, + AssetsFiltersData +} from "./types"; + +const REFRESH_INTERVAL = isNumber(window.assetsRefreshInterval) + ? window.assetsRefreshInterval + : 10 * 1000; // in milliseconds + +const renderFilterCategory = ( + category: AssetFilterCategory, + icon: ComponentType, + placeholder: string, + selectedValues: string[], + onChange: (value: string | string[], categoryName?: string) => void, + transformLabel?: (value: string) => string +): JSX.Element => { + const items = + category.entries?.map((entry) => ({ + value: entry.name, + label: transformLabel ? transformLabel(entry.name) : entry.name, + enabled: entry.enabled, + selected: selectedValues.includes(entry.name) + })) || []; + + return ( + onChange(value, category.categoryName)} + placeholder={placeholder} + multiselect={true} + icon={icon} + disabled={category.entries?.length === 0} + /> + ); +}; + +export const AssetsFilter = (props: AssetsFilterProps) => { + const [data, setData] = useState<{ data: AssetsFiltersData | null }>(); + const previousData = usePrevious(data); + const [isOpen, setIsOpen] = useState(false); + const previousIsOpen = usePrevious(isOpen); + const [persistedFilters, setPersistedFilters] = + usePersistence("assetsFilters", "project"); + const previousPersistedFilters = usePrevious(persistedFilters); + const [selectedServices, setSelectedServices] = useState([]); + const [selectedEndpoints, setSelectedEndpoints] = useState([]); + const [selectedConsumers, setSelectedConsumers] = useState([]); + const [selectedInternals, setSelectedInternals] = useState([]); + const [selectedInsights, setSelectedInsights] = useState([]); + const refreshTimerId = useRef(); + const config = useContext(ConfigContext); + const previousEnvironment = usePrevious(config.environment); + + const getData = ( + services: string[], + operations: string[], + insights: InsightType[] + ) => { + window.sendMessageToDigma({ + action: actions.GET_ASSET_FILTERS_DATA, + payload: { + query: { + services, + operations, + insights + } + } + }); + }; + + useEffect(() => { + if ( + isUndefined(previousPersistedFilters) && + previousPersistedFilters !== persistedFilters + ) { + getData( + persistedFilters?.services || selectedServices, + persistedFilters?.operations || [ + ...selectedEndpoints, + ...selectedConsumers, + ...selectedInternals + ], + (persistedFilters?.insights as InsightType[]) || selectedInsights + ); + } + }, [ + previousPersistedFilters, + persistedFilters, + selectedServices, + selectedEndpoints, + selectedConsumers, + selectedInternals, + selectedInsights + ]); + + useEffect(() => { + const handleData = (data: unknown) => { + const filtersData = data as AssetsFiltersData | null; + setData({ data: filtersData }); + }; + + dispatcher.addActionListener(actions.SET_ASSET_FILTERS_DATA, handleData); + + return () => { + dispatcher.removeActionListener( + actions.SET_ASSET_FILTERS_DATA, + handleData + ); + window.clearTimeout(refreshTimerId.current); + }; + }, []); + + useEffect(() => { + if ( + isString(previousEnvironment) && + previousEnvironment !== config.environment + ) { + const defaultFilters = { + services: [], + operations: [], + insights: [] + }; + setPersistedFilters(defaultFilters); + props.onApply(defaultFilters); + getData([], [], []); + } + }, [ + previousEnvironment, + config.environment, + setPersistedFilters, + props.onApply + ]); + + useEffect(() => { + if (props.data) { + setData({ data: props.data }); + } + }, [props.data]); + + useEffect(() => { + if (previousData !== data) { + if (!isNull(data?.data)) { + const servicesToSelect = + data?.data?.categories + .find((x) => x.categoryName === "Services") + ?.entries?.filter((x) => x.selected) + .map((x) => x.name) || []; + setSelectedServices(servicesToSelect); + + const operationsCategory = data?.data?.categories.find( + (x) => x.categoryName === "Operations" + ); + + const endpointsToSelect = + operationsCategory?.categories + ?.find((x) => x.categoryName === "Endpoints") + ?.entries?.filter((x) => x.selected) + .map((x) => x.name) || []; + setSelectedEndpoints(endpointsToSelect); + + const consumersToSelect = + operationsCategory?.categories + ?.find((x) => x.categoryName === "Consumers") + ?.entries?.filter((x) => x.selected) + .map((x) => x.name) || []; + setSelectedConsumers(consumersToSelect); + + const internalsToSelect = + operationsCategory?.categories + ?.find((x) => x.categoryName === "Internal") + ?.entries?.filter((x) => x.selected) + .map((x) => x.name) || []; + setSelectedInternals(internalsToSelect); + + const insightsToSelect = (data?.data?.categories + .find((x) => x.categoryName === "Insights") + ?.entries?.filter((x) => x.selected) + .map((x) => x.name) || []) as InsightType[]; + setSelectedInsights(insightsToSelect); + + if (!props.filters) { + const filtersQuery = { + services: servicesToSelect, + operations: [ + ...endpointsToSelect, + ...consumersToSelect, + ...internalsToSelect + ], + insights: insightsToSelect + }; + + setPersistedFilters(filtersQuery); + props.onApply(filtersQuery); + } + + window.clearTimeout(refreshTimerId.current); + refreshTimerId.current = window.setTimeout(() => { + getData( + servicesToSelect, + [...endpointsToSelect, ...consumersToSelect, ...internalsToSelect], + insightsToSelect + ); + }, REFRESH_INTERVAL); + } else { + window.clearTimeout(refreshTimerId.current); + refreshTimerId.current = window.setTimeout(() => { + getData( + selectedServices, + [...selectedEndpoints, ...selectedConsumers, ...selectedInternals], + selectedInsights + ); + }, REFRESH_INTERVAL); + } + } + }, [ + previousData, + data, + props.filters, + props.onApply, + selectedServices, + selectedEndpoints, + selectedConsumers, + selectedInternals, + selectedInsights, + setPersistedFilters + ]); + + useEffect(() => { + if (previousIsOpen && !isOpen) { + const filtersQuery = { + services: selectedServices, + operations: [ + ...selectedEndpoints, + ...selectedConsumers, + ...selectedInternals + ], + insights: selectedInsights + }; + props.onApply(filtersQuery); + setPersistedFilters(filtersQuery); + sendTrackingEvent(trackingEvents.FILTER_APPLIED); + } + }, [ + previousIsOpen, + isOpen, + props.onApply, + selectedConsumers, + selectedEndpoints, + selectedInsights, + selectedInternals, + selectedServices, + setPersistedFilters + ]); + + const handleClearFiltersButtonClick = () => { + getData([], [], []); + }; + + const handleSelectedItemsChange = ( + value: string | string[], + category?: string + ) => { + const newValue = Array.isArray(value) ? value : [value]; + + let services = selectedServices; + let endpoints = selectedEndpoints; + let consumers = selectedConsumers; + let internals = selectedInternals; + let insights = selectedInsights; + + switch (category) { + case "Services": + services = newValue; + break; + case "Endpoints": + endpoints = newValue; + break; + case "Consumers": + consumers = newValue; + break; + case "Internal": + internals = newValue; + break; + case "Insights": + insights = newValue as InsightType[]; + break; + } + + getData(services, [...endpoints, ...consumers, ...internals], insights); + }; + + const servicesCategory = data?.data?.categories.find( + (x) => x.categoryName === "Services" + ) || { + categoryName: "Services", + entries: [] + }; + + const operationsCategory = data?.data?.categories.find( + (x) => x.categoryName === "Operations" + ); + const endpointsCategory = operationsCategory?.categories?.find( + (x) => x.categoryName === "Endpoints" + ) || { + categoryName: "Endpoints", + entries: [] + }; + const consumersCategory = operationsCategory?.categories?.find( + (x) => x.categoryName === "Consumers" + ) || { + categoryName: "Consumers", + entries: [] + }; + const internalsCategory = operationsCategory?.categories?.find( + (x) => x.categoryName === "Internal" + ) || { + categoryName: "Internal", + entries: [] + }; + + const insightsCategory = data?.data?.categories.find( + (x) => x.categoryName === "Insights" + ) || { + categoryName: "Insights", + entries: [] + }; + + const selectedFilters = [ + ...selectedServices, + ...selectedEndpoints, + ...selectedConsumers, + ...selectedInternals, + ...selectedInsights + ]; + + return ( + + Filters + Services + {renderFilterCategory( + servicesCategory, + WrenchIcon, + selectedServices.length > 0 ? "Services" : "All", + selectedServices, + handleSelectedItemsChange + )} + Operations + {renderFilterCategory( + endpointsCategory, + EndpointIcon, + "Endpoints", + selectedEndpoints, + handleSelectedItemsChange + )} + {renderFilterCategory( + consumersCategory, + EndpointIcon, + "Consumers", + selectedConsumers, + handleSelectedItemsChange + )} + {renderFilterCategory( + internalsCategory, + EndpointIcon, + "Internal", + selectedInternals, + handleSelectedItemsChange + )} + Insights + {insightsCategory && + renderFilterCategory( + insightsCategory, + SparkleIcon, + "Insights", + selectedInsights, + handleSelectedItemsChange, + (value) => getInsightTypeInfo(value)?.label || value + )} + + + + + } + onOpenChange={setIsOpen} + isOpen={isOpen} + placement={"bottom-end"} + > + + + + + ); +}; diff --git a/src/components/Assets/AssetsFilter/styles.ts b/src/components/Assets/AssetsFilter/styles.ts new file mode 100644 index 000000000..fc830852f --- /dev/null +++ b/src/components/Assets/AssetsFilter/styles.ts @@ -0,0 +1,49 @@ +import styled from "styled-components"; +import { grayScale } from "../../common/App/getTheme"; + +export const Container = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; + border-radius: 4px; + background: ${({ theme }) => theme.colors.surface.primary}; + box-shadow: 0 2px 4px 0 rgba(0 0 0 / 29%); + font-size: 14px; + color: ${grayScale[400]}; +`; + +export const Header = styled.div` + color: ${({ theme }) => theme.colors.select.menu.text.primary}; + padding: 0 4px; + display: flex; + align-items: center; +`; + +export const FilterCategoryName = styled.div` + display: flex; + padding: 4px; + color: ${grayScale[400]}; +`; + +export const MenuButton = styled.button` + border: 1px solid ${({ theme }) => theme.colors.stroke.primary}; + background: ${({ theme }) => theme.colors.surface.secondary}; + border-radius: 4px; + padding: 4px 8px; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +`; + +export const MenuButtonChevronIconContainer = styled.span` + color: ${({ theme }) => theme.colors.icon.primary}; +`; + +export const Footer = styled.div` + padding: 8px 0; + display: flex; + justify-content: space-between; + align-items: center; +`; diff --git a/src/components/Assets/AssetsFilter/types.ts b/src/components/Assets/AssetsFilter/types.ts new file mode 100644 index 000000000..de7845757 --- /dev/null +++ b/src/components/Assets/AssetsFilter/types.ts @@ -0,0 +1,27 @@ +export interface AssetsFilterProps { + data?: AssetsFiltersData; + filters?: AssetFilterQuery; + onApply: (filter: AssetFilterQuery) => void; +} + +export interface AssetFilterEntry { + enabled: boolean; + selected: boolean; + name: string; +} + +export interface AssetFilterCategory { + categoryName?: string; + categories?: AssetFilterCategory[]; + entries?: AssetFilterEntry[]; +} + +export interface AssetsFiltersData { + categories: AssetFilterCategory[]; +} + +export interface AssetFilterQuery { + services: string[]; + operations: string[]; + insights: string[]; +} diff --git a/src/components/Assets/ServicesFilter/FilterButton/index.tsx b/src/components/Assets/ServicesFilter/FilterButton/index.tsx new file mode 100644 index 000000000..d1cabaaf6 --- /dev/null +++ b/src/components/Assets/ServicesFilter/FilterButton/index.tsx @@ -0,0 +1,33 @@ +import { isNumber } from "../../../../typeGuards/isNumber"; +import { ChevronIcon } from "../../../common/icons/ChevronIcon"; +import { FilterIcon } from "../../../common/icons/FilterIcon"; +import { Direction } from "../../../common/icons/types"; +import * as s from "./styles"; +import { FilterButtonProps } from "./types"; + +export const FilterButton = (props: FilterButtonProps) => ( + + + + {props.title} + {props.showCount ? ( + isNumber(props.selectedCount) && + props.selectedCount > 0 && + !props.isLoading ? ( + {props.selectedCount} + ) : ( + + All + + ) + ) : null} + + + + + +); diff --git a/src/components/Assets/ServicesFilter/FilterButton/styles.ts b/src/components/Assets/ServicesFilter/FilterButton/styles.ts new file mode 100644 index 000000000..9757d8356 --- /dev/null +++ b/src/components/Assets/ServicesFilter/FilterButton/styles.ts @@ -0,0 +1,67 @@ +import styled from "styled-components"; + +export const Button = styled.button` + border: 1px solid #4e5157; + background: transparent; + border-radius: 4px; + padding: 8px; + display: flex; + gap: 10px; + align-items: center; +`; + +export const Label = styled.span` + display: flex; + gap: 4px; + align-items: center; + font-size: 14px; + color: ${({ theme }) => { + switch (theme.mode) { + case "light": + return "#818594"; + case "dark": + case "dark-jetbrains": + return "#b4b8bf"; + } + }}; +`; + +export const SelectedItemsNumberPlaceholder = styled.span` + color: ${({ theme }) => { + switch (theme.mode) { + case "light": + return "#494b57"; + case "dark": + case "dark-jetbrains": + return "#dfe1e5"; + } + }}; + user-select: none; +`; + +export const Number = styled.span` + min-width: 18px; + height: 18px; + flex-shrink: 0; + font-size: 14px; + line-height: 100%; + font-weight: 500; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + background: #5053d4; +`; + +export const ChevronIconContainer = styled.span` + color: ${({ theme }) => { + switch (theme.mode) { + case "light": + return "#494b57"; + case "dark": + case "dark-jetbrains": + return "#dfe1e5"; + } + }}; +`; diff --git a/src/components/Assets/ServicesFilter/FilterButton/types.ts b/src/components/Assets/ServicesFilter/FilterButton/types.ts new file mode 100644 index 000000000..a15c293c5 --- /dev/null +++ b/src/components/Assets/ServicesFilter/FilterButton/types.ts @@ -0,0 +1,7 @@ +export interface FilterButtonProps { + title: string; + isLoading?: boolean; + isMenuOpen: boolean; + selectedCount?: number; + showCount?: boolean; +} diff --git a/src/components/Assets/ServicesFilter/index.tsx b/src/components/Assets/ServicesFilter/index.tsx new file mode 100644 index 000000000..e52eb5c92 --- /dev/null +++ b/src/components/Assets/ServicesFilter/index.tsx @@ -0,0 +1,152 @@ +import { useContext, useEffect, useState } from "react"; +import { dispatcher } from "../../../dispatcher"; +import { usePrevious } from "../../../hooks/usePrevious"; +import { isNumber } from "../../../typeGuards/isNumber"; +import { isString } from "../../../typeGuards/isString"; +import { ConfigContext } from "../../common/App/ConfigContext"; +import { NewPopover } from "../../common/NewPopover"; +import { FilterMenu } from "../FilterMenu"; +import { actions } from "../actions"; +import { ServiceData } from "../types"; +import { FilterButton } from "./FilterButton"; +import { ServicesFilterProps } from "./types"; + +const REFRESH_INTERVAL = isNumber(window.assetsRefreshInterval) + ? window.assetsRefreshInterval + : 10 * 1000; // in milliseconds + +const preselectedServices = + Array.isArray(window.assetsSelectedServices) && + window.assetsSelectedServices.every(isString) + ? window.assetsSelectedServices + : []; + +export const ServicesFilter = (props: ServicesFilterProps) => { + const [isServiceMenuOpen, setIsServiceMenuOpen] = useState(false); + const [areServicesLoading, setAreServicesLoading] = useState(false); + const [lastSetDataTimeStamp, setLastSetDataTimeStamp] = useState(); + const [services, setServices] = useState(); + const previousSelectedServices = usePrevious(props.selectedServices); + const config = useContext(ConfigContext); + const previousEnvironment = usePrevious(config.environment); + + useEffect(() => { + window.sendMessageToDigma({ + action: actions.GET_SERVICES + }); + setAreServicesLoading(true); + }, []); + + useEffect(() => { + const handleServicesData = (data: unknown, timeStamp: number) => { + const serviceData = data as ServiceData; + setLastSetDataTimeStamp(timeStamp); + const oldSelectedServices = Array.isArray(props.selectedServices) + ? props.selectedServices + : preselectedServices; + + const newSelectedServices = serviceData.services.filter((x) => + oldSelectedServices.includes(x) + ); + + props.onChange(newSelectedServices); + setServices(serviceData.services); + setAreServicesLoading(false); + }; + + dispatcher.addActionListener(actions.SET_SERVICES, handleServicesData); + + return () => { + dispatcher.removeActionListener(actions.SET_SERVICES, handleServicesData); + }; + }, [props.selectedServices, props.onChange]); + + useEffect(() => { + if ( + isString(previousEnvironment) && + previousEnvironment !== config.environment + ) { + setServices(undefined); + window.sendMessageToDigma({ + action: actions.GET_SERVICES + }); + setAreServicesLoading(true); + } + }, [previousEnvironment, config.environment, services]); + + useEffect(() => { + const timerId = window.setTimeout(() => { + window.sendMessageToDigma({ + action: actions.GET_SERVICES + }); + }, REFRESH_INTERVAL); + + return () => { + window.clearTimeout(timerId); + }; + }, [lastSetDataTimeStamp]); + + useEffect(() => { + if ( + previousSelectedServices && + previousSelectedServices !== props.selectedServices + ) { + window.sendMessageToDigma({ + action: actions.SET_SELECTED_SERVICES, + payload: { + services: props.selectedServices + } + }); + } + }, [previousSelectedServices, props.selectedServices]); + + const handleServiceMenuClose = () => { + setIsServiceMenuOpen(false); + }; + + const handleServiceMenuItemClick = (service: string) => { + const oldSelectedServices = props.selectedServices || []; + const serviceIndex = oldSelectedServices.findIndex((x) => x === service); + + if (serviceIndex < 0) { + props.onChange([...oldSelectedServices, service]); + } else { + props.onChange([ + ...oldSelectedServices.slice(0, serviceIndex), + ...oldSelectedServices.slice(serviceIndex + 1) + ]); + } + }; + + const filterMenuItems = (services || []).map((x) => ({ + label: x, + value: x, + selected: (props.selectedServices || []).includes(x) + })); + + return ( + + } + onOpenChange={setIsServiceMenuOpen} + isOpen={isServiceMenuOpen} + placement={"bottom-end"} + > + + + + + ); +}; diff --git a/src/components/Assets/ServicesFilter/styles.ts b/src/components/Assets/ServicesFilter/styles.ts new file mode 100644 index 000000000..03db0e560 --- /dev/null +++ b/src/components/Assets/ServicesFilter/styles.ts @@ -0,0 +1,67 @@ +import styled from "styled-components"; + +export const ServiceMenuButton = styled.button` + border: 1px solid #4e5157; + background: transparent; + border-radius: 4px; + padding: 8px; + display: flex; + gap: 10px; + align-items: center; +`; + +export const ServiceMenuButtonLabel = styled.span` + display: flex; + gap: 4px; + align-items: center; + font-size: 14px; + color: ${({ theme }) => { + switch (theme.mode) { + case "light": + return "#818594"; + case "dark": + case "dark-jetbrains": + return "#b4b8bf"; + } + }}; +`; + +export const SelectedServiceNumberPlaceholder = styled.span` + color: ${({ theme }) => { + switch (theme.mode) { + case "light": + return "#494b57"; + case "dark": + case "dark-jetbrains": + return "#dfe1e5"; + } + }}; + user-select: none; +`; + +export const Number = styled.span` + min-width: 18px; + height: 18px; + flex-shrink: 0; + font-size: 14px; + line-height: 100%; + font-weight: 500; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + background: #5053d4; +`; + +export const ServiceMenuChevronIconContainer = styled.span` + color: ${({ theme }) => { + switch (theme.mode) { + case "light": + return "#494b57"; + case "dark": + case "dark-jetbrains": + return "#dfe1e5"; + } + }}; +`; diff --git a/src/components/Assets/ServicesFilter/types.ts b/src/components/Assets/ServicesFilter/types.ts new file mode 100644 index 000000000..4e5641d78 --- /dev/null +++ b/src/components/Assets/ServicesFilter/types.ts @@ -0,0 +1,4 @@ +export interface ServicesFilterProps { + selectedServices?: string[]; + onChange: (services: string[]) => void; +} diff --git a/src/components/Assets/actions.ts b/src/components/Assets/actions.ts index e580ae2a0..3c7a27e86 100644 --- a/src/components/Assets/actions.ts +++ b/src/components/Assets/actions.ts @@ -11,5 +11,7 @@ export const actions = addPrefix(ACTION_PREFIX, { SET_CATEGORIES_DATA: "SET_CATEGORIES_DATA", GET_SERVICES: "GET_SERVICES", SET_SERVICES: "SET_SERVICES", - SET_SELECTED_SERVICES: "SET_SELECTED_SERVICES" + SET_SELECTED_SERVICES: "SET_SELECTED_SERVICES", + GET_ASSET_FILTERS_DATA: "GET_ASSET_FILTERS_DATA", + SET_ASSET_FILTERS_DATA: "SET_ASSET_FILTERS_DATA" }); diff --git a/src/components/Assets/index.tsx b/src/components/Assets/index.tsx index 463b39170..b15c817b7 100644 --- a/src/components/Assets/index.tsx +++ b/src/components/Assets/index.tsx @@ -1,168 +1,118 @@ import { + ChangeEvent, useContext, - useEffect, useLayoutEffect, useMemo, useState } from "react"; -import { dispatcher } from "../../dispatcher"; -import { getFeatureFlagValue } from "../../featureFlags"; -import { usePrevious } from "../../hooks/usePrevious"; -import { isNumber } from "../../typeGuards/isNumber"; -import { isString } from "../../typeGuards/isString"; +import { lt, valid } from "semver"; +import { + featureFlagMinBackendVersions, + getFeatureFlagValue +} from "../../featureFlags"; +import { useDebounce } from "../../hooks/useDebounce"; import { FeatureFlag } from "../../types"; import { ConfigContext } from "../common/App/ConfigContext"; -import { NewPopover } from "../common/NewPopover"; -import { ChevronIcon } from "../common/icons/ChevronIcon"; -import { FilterIcon } from "../common/icons/FilterIcon"; -import { Direction } from "../common/icons/types"; +import { EmptyState } from "../common/EmptyState"; +import { NewCircleLoader } from "../common/NewCircleLoader"; +import { MagnifierIcon } from "../common/icons/MagnifierIcon"; import { AssetList } from "./AssetList"; import { AssetTypeList } from "./AssetTypeList"; -import { FilterMenu } from "./FilterMenu"; +import { AssetsFilter } from "./AssetsFilter"; +import { AssetFilterQuery } from "./AssetsFilter/types"; +import { ServicesFilter } from "./ServicesFilter"; import { actions } from "./actions"; import * as s from "./styles"; -import { ServiceData } from "./types"; - -const REFRESH_INTERVAL = isNumber(window.assetsRefreshInterval) - ? window.assetsRefreshInterval - : 10 * 1000; // in milliseconds - -const preselectedServices = - Array.isArray(window.assetsSelectedServices) && - window.assetsSelectedServices.every(isString) - ? window.assetsSelectedServices - : []; export const Assets = () => { const [selectedAssetTypeId, setSelectedAssetTypeId] = useState( null ); - const [services, setServices] = useState(); - const [areServicesLoading, setAreServicesLoading] = useState(false); - const [lastSetDataTimeStamp, setLastSetDataTimeStamp] = useState(); + const [searchInputValue, setSearchInputValue] = useState(""); + const debouncedSearchInputValue = useDebounce(searchInputValue, 1000); const [selectedServices, setSelectedServices] = useState(); - const previousSelectedServices = usePrevious(selectedServices); - const [isServiceMenuOpen, setIsServiceMenuOpen] = useState(false); + const [selectedFilters, setSelectedFilters] = useState(); const config = useContext(ConfigContext); - const previousEnvironment = usePrevious(config.environment); const isServiceFilterVisible = getFeatureFlagValue( config, FeatureFlag.IS_ASSETS_SERVICE_FILTER_VISIBLE ); + const isComplexFilterEnabled = getFeatureFlagValue( + config, + FeatureFlag.IS_ASSETS_COMPLEX_FILTER_ENABLED + ); + + const isBackendUpgradeMessageVisible = useMemo(() => { + const backendVersion = config.backendInfo?.applicationVersion; + + return Boolean( + backendVersion && + valid(backendVersion) && + lt( + backendVersion, + featureFlagMinBackendVersions[ + FeatureFlag.IS_ASSETS_COMPLEX_FILTER_ENABLED + ] + ) + ); + }, [config]); + useLayoutEffect(() => { window.sendMessageToDigma({ action: actions.INITIALIZE }); - window.sendMessageToDigma({ - action: actions.GET_SERVICES - }); - setAreServicesLoading(true); - - const handleServicesData = (data: unknown, timeStamp: number) => { - const serviceData = data as ServiceData; - setLastSetDataTimeStamp(timeStamp); - if (services === undefined) { - setSelectedServices((selectedServices) => { - const oldSelectedServices = Array.isArray(selectedServices) - ? selectedServices - : preselectedServices; - - const newSelectedServices = serviceData.services.filter((x) => - oldSelectedServices.includes(x) - ); - return newSelectedServices; - }); - } - setServices(serviceData.services); - setAreServicesLoading(false); - }; - - dispatcher.addActionListener(actions.SET_SERVICES, handleServicesData); - - return () => { - dispatcher.removeActionListener(actions.SET_SERVICES, handleServicesData); - }; }, []); - useEffect(() => { - if ( - isString(previousEnvironment) && - previousEnvironment !== config.environment - ) { - setServices(undefined); - window.sendMessageToDigma({ - action: actions.GET_SERVICES - }); - setAreServicesLoading(true); - } - }, [previousEnvironment, config.environment, services]); - - useEffect(() => { - const timerId = window.setTimeout(() => { - window.sendMessageToDigma({ - action: actions.GET_SERVICES - }); - }, REFRESH_INTERVAL); - - return () => { - window.clearTimeout(timerId); - }; - }, [lastSetDataTimeStamp]); - - useEffect(() => { - if ( - previousSelectedServices && - previousSelectedServices !== selectedServices - ) { - window.sendMessageToDigma({ - action: actions.SET_SELECTED_SERVICES, - payload: { - services: selectedServices - } - }); - } - }, [previousSelectedServices, selectedServices]); - - const handleServiceMenuClose = () => { - setIsServiceMenuOpen(false); + const handleBackButtonClick = () => { + setSelectedAssetTypeId(null); }; - const handleServiceMenuItemClick = (service: string) => { - const oldSelectedServices = selectedServices || []; - const serviceIndex = oldSelectedServices.findIndex((x) => x === service); - - if (serviceIndex < 0) { - setSelectedServices([...oldSelectedServices, service]); - } else { - setSelectedServices([ - ...oldSelectedServices.slice(0, serviceIndex), - ...oldSelectedServices.slice(serviceIndex + 1) - ]); - } + const handleSearchInputChange = (e: ChangeEvent) => { + setSearchInputValue(e.target.value); }; - const filterMenuItems = (services || []).map((x) => ({ - label: x, - value: x, - selected: (selectedServices || []).includes(x) - })); + const handleAssetTypeSelect = (assetTypeId: string) => { + setSelectedAssetTypeId(assetTypeId); + }; - const handleBackButtonClick = () => { - setSelectedAssetTypeId(null); + const handleServicesChange = (services: string[]) => { + setSelectedServices(services); }; - const handleAssetTypeSelect = (assetTypeId: string) => { - setSelectedAssetTypeId(assetTypeId); + const handleApplyFilters = (filters: AssetFilterQuery) => { + setSelectedFilters(filters); }; - const renderContent = useMemo((): JSX.Element => { + const renderContent = () => { + if (isBackendUpgradeMessageVisible) { + return ( + + We've added some new features. + + Please update the Digma Engine to the latest version using the + action above to continue using Digma + + + } + /> + ); + } + + if (!selectedFilters && !selectedServices) { + return } />; + } + if (!selectedAssetTypeId) { return ( ); } @@ -171,56 +121,44 @@ export const Assets = () => { ); - }, [selectedAssetTypeId, selectedServices]); + }; return ( Assets - {isServiceFilterVisible && ( - - } - onOpenChange={setIsServiceMenuOpen} - isOpen={isServiceMenuOpen} - placement={"bottom-end"} - > - - - - Services - {selectedServices && - selectedServices.length > 0 && - !areServicesLoading ? ( - {selectedServices.length} - ) : ( - - All - - )} - - - - - - + {window.assetsSearch === true && ( + + + + + + + )} + {isComplexFilterEnabled ? ( + + ) : ( + // TODO: Remove this clause when the feature flag is removed + isServiceFilterVisible && ( + + ) )} - {renderContent} + {renderContent()} ); }; diff --git a/src/components/Assets/styles.ts b/src/components/Assets/styles.ts index b41025276..a257e17af 100644 --- a/src/components/Assets/styles.ts +++ b/src/components/Assets/styles.ts @@ -1,4 +1,5 @@ import styled from "styled-components"; +import { grayScale } from "../common/App/getTheme"; export const Container = styled.div` height: 100%; @@ -20,7 +21,6 @@ export const Header = styled.div` display: flex; gap: 8px; align-items: center; - justify-content: space-between; font-size: 16px; flex-shrink: 0; height: 36px; @@ -35,68 +35,86 @@ export const Header = styled.div` }}; `; -export const ServiceMenuButton = styled.button` - border: 1px solid #4e5157; - background: transparent; - border-radius: 4px; - padding: 8px; +export const SearchInputContainer = styled.div` display: flex; - gap: 10px; - align-items: center; + position: relative; + margin-left: auto; + width: 290px; `; -export const ServiceMenuButtonLabel = styled.span` +export const SearchInputIconContainer = styled.div` display: flex; - gap: 4px; align-items: center; + margin: auto; + position: absolute; + top: 0; + bottom: 0; + left: 4px; + color: ${({ theme }) => theme.colors.icon.disabledAlt}; +`; + +export const SearchInput = styled.input` + width: 100%; font-size: 14px; - color: ${({ theme }) => { + padding: 4px 4px 4px 20px; + border-radius: 4px; + outline: none; + border: 1px solid ${({ theme }) => theme.colors.stroke.primary}; + background: ${({ theme }) => { switch (theme.mode) { case "light": - return "#818594"; + return grayScale[50]; case "dark": case "dark-jetbrains": - return "#b4b8bf"; + return grayScale[1000]; } }}; -`; - -export const SelectedServiceNumberPlaceholder = styled.span` - color: ${({ theme }) => { + box-shadow: 1px 1px 4px 0 rgba(0 0 0 / 25%); + caret-color: ${({ theme }) => { switch (theme.mode) { case "light": - return "#494b57"; + return "#4d668a"; case "dark": case "dark-jetbrains": - return "#dfe1e5"; + return "#dadada"; } }}; - user-select: none; -`; - -export const Number = styled.span` - min-width: 18px; - height: 18px; - flex-shrink: 0; - font-size: 14px; - line-height: 100%; - font-weight: 500; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - color: #fff; - background: #5053d4; -`; - -export const ServiceMenuChevronIconContainer = styled.span` color: ${({ theme }) => { switch (theme.mode) { case "light": - return "#494b57"; + return "#4d668a"; case "dark": case "dark-jetbrains": - return "#dfe1e5"; + return "#dadada"; } }}; + + &:focus, + &:hover { + border: 1px solid + ${({ theme }) => { + switch (theme.mode) { + case "light": + return "#7891d0"; + case "dark": + case "dark-jetbrains": + return "#9b9b9b"; + } + }}; + } + + &::placeholder { + color: ${({ theme }) => theme.colors.text.disabledAlt}; + } + + &:focus::placeholder { + color: transparent; + } +`; + +export const UpgradeMessage = styled.div` + display: flex; + flex-direction: column; + text-align: center; + gap: 8px; `; diff --git a/src/components/Assets/tracking.ts b/src/components/Assets/tracking.ts new file mode 100644 index 000000000..c55fc99fd --- /dev/null +++ b/src/components/Assets/tracking.ts @@ -0,0 +1,11 @@ +import { addPrefix } from "../../utils/addPrefix"; + +const TRACKING_PREFIX = "assets"; + +export const trackingEvents = addPrefix( + TRACKING_PREFIX, + { + FILTER_APPLIED: "filter applied" + }, + " " +); diff --git a/src/components/Assets/utils.tsx b/src/components/Assets/utils.tsx index 27a59b256..43496362c 100644 --- a/src/components/Assets/utils.tsx +++ b/src/components/Assets/utils.tsx @@ -5,6 +5,7 @@ import { EndpointIcon } from "../common/icons/EndpointIcon"; import { HTTPClientIcon } from "../common/icons/HTTPClientIcon"; import { UserIcon } from "../common/icons/UserIcon"; import { IconProps } from "../common/icons/types"; +import { AssetFilterQuery } from "./AssetsFilter/types"; export const getAssetTypeInfo = ( assetTypeId: string @@ -48,3 +49,15 @@ export const getAssetTypeInfo = ( return assetTypeInfoMap[assetTypeId]; }; + +export const checkIfAnyFiltersApplied = ( + isComplexFilterEnabled: boolean, + filters: AssetFilterQuery | undefined, + services: string[] | undefined, + searchQuery: string +) => + (isComplexFilterEnabled + ? filters && + [...filters.insights, ...filters.operations, ...filters.services].length > + 0 + : services && services.length > 0) || searchQuery.length > 0; diff --git a/src/components/Dashboard/index.tsx b/src/components/Dashboard/index.tsx index 750b18097..75ddc6206 100644 --- a/src/components/Dashboard/index.tsx +++ b/src/components/Dashboard/index.tsx @@ -133,7 +133,7 @@ export const Dashboard = () => { Open in browser - + )} diff --git a/src/components/Documentation/pages/RunDigma/runDigmaWithGradleTasks.tsx b/src/components/Documentation/pages/RunDigma/runDigmaWithGradleTasks.tsx index 783eb6291..563289d00 100644 --- a/src/components/Documentation/pages/RunDigma/runDigmaWithGradleTasks.tsx +++ b/src/components/Documentation/pages/RunDigma/runDigmaWithGradleTasks.tsx @@ -17,7 +17,7 @@ export const runDigmaWithGradleTasks: PageContent = { To do so, simply add the environment variable{" "} DIGMA_OBSERVABILITY=true - + That's it! Digma will now collect information about your task. diff --git a/src/components/Insights/BottleneckInsight/index.tsx b/src/components/Insights/BottleneckInsight/index.tsx index 912ba9c33..aa845cbcc 100644 --- a/src/components/Insights/BottleneckInsight/index.tsx +++ b/src/components/Insights/BottleneckInsight/index.tsx @@ -69,11 +69,10 @@ export const BottleneckInsight = (props: BottleneckInsightProps) => { diff --git a/src/components/Insights/EndpointNPlusOneInsight/index.tsx b/src/components/Insights/EndpointNPlusOneInsight/index.tsx index 814321158..8e74712c9 100644 --- a/src/components/Insights/EndpointNPlusOneInsight/index.tsx +++ b/src/components/Insights/EndpointNPlusOneInsight/index.tsx @@ -104,7 +104,7 @@ export const EndpointNPlusOneInsight = ( onTicketInfoButtonClick={handleTicketInfoButtonClick} spanCodeObjectId={spanInfo.spanCodeObjectId} ticketLink={span.ticketLink} - buttonType="small" + buttonType={"small"} /> {config.isJaegerEnabled && ( diff --git a/src/components/Insights/HighNumberOfQueriesInsight/index.tsx b/src/components/Insights/HighNumberOfQueriesInsight/index.tsx index de9e08664..bd7813e19 100644 --- a/src/components/Insights/HighNumberOfQueriesInsight/index.tsx +++ b/src/components/Insights/HighNumberOfQueriesInsight/index.tsx @@ -76,7 +76,7 @@ export const HighNumberOfQueriesInsight = ( onTicketInfoButtonClick={handleCreateJiraTicketButtonClick} spanCodeObjectId={insight.spanInfo?.spanCodeObjectId} ticketLink={insight.ticketLink} - buttonType="small" + buttonType={"small"} /> {traceId && ( diff --git a/src/components/Insights/InsightCard/index.tsx b/src/components/Insights/InsightCard/index.tsx index 66d87f331..b6c49884b 100644 --- a/src/components/Insights/InsightCard/index.tsx +++ b/src/components/Insights/InsightCard/index.tsx @@ -139,7 +139,7 @@ export const InsightCard = (props: InsightCardProps) => { {insightTypeInfo?.description && ( }> - + )} diff --git a/src/components/Insights/NPlusOneInsight/index.tsx b/src/components/Insights/NPlusOneInsight/index.tsx index 57940d4c8..e84833084 100644 --- a/src/components/Insights/NPlusOneInsight/index.tsx +++ b/src/components/Insights/NPlusOneInsight/index.tsx @@ -137,7 +137,7 @@ export const NPlusOneInsight = (props: NPlusOneInsightProps) => { onTicketInfoButtonClick={handleCreateJiraTicketButtonClick} spanCodeObjectId={props.insight.spanInfo?.spanCodeObjectId} ticketLink={props.insight.ticketLink} - buttonType="large" + buttonType={"large"} /> ]} /> diff --git a/src/components/Insights/QueryOptimizationInsight/index.tsx b/src/components/Insights/QueryOptimizationInsight/index.tsx index 0f57428bd..ecef00493 100644 --- a/src/components/Insights/QueryOptimizationInsight/index.tsx +++ b/src/components/Insights/QueryOptimizationInsight/index.tsx @@ -107,7 +107,7 @@ export const QueryOptimizationInsight = ( onTicketInfoButtonClick={handleCreateJiraTicketButtonClick} spanCodeObjectId={props.insight.spanInfo?.spanCodeObjectId} ticketLink={props.insight.ticketLink} - buttonType="large" + buttonType={"large"} /> ]} /> diff --git a/src/components/Insights/SpanBottleneckInsight/index.tsx b/src/components/Insights/SpanBottleneckInsight/index.tsx index 516a3fc0c..fceadc3f0 100644 --- a/src/components/Insights/SpanBottleneckInsight/index.tsx +++ b/src/components/Insights/SpanBottleneckInsight/index.tsx @@ -65,7 +65,7 @@ export const SpanBottleneckInsight = (props: SpanBottleneckInsightProps) => { onTicketInfoButtonClick={handleTicketInfoButtonClick} spanCodeObjectId={spanCodeObjectId} ticketLink={span.ticketLink} - buttonType="small" + buttonType={"small"} /> diff --git a/src/components/Insights/common/JiraButton/index.tsx b/src/components/Insights/common/JiraButton/index.tsx index 013fe80ff..a655d72a1 100644 --- a/src/components/Insights/common/JiraButton/index.tsx +++ b/src/components/Insights/common/JiraButton/index.tsx @@ -25,7 +25,7 @@ export const JiraButton = (props: JiraButtonProps) => { return ( <> {!props.ticketLink && ( - + @@ -59,7 +59,7 @@ export const JiraButton = (props: JiraButtonProps) => { } ]} onSelect={handleJiraButtonClick} - > + /> } isOpen={isJiraPopoverOpen} onOpenChange={handleJiraButtonClick} diff --git a/src/components/Tests/TestTicket/index.tsx b/src/components/Tests/TestTicket/index.tsx index e8c5f5e7f..a088a025f 100644 --- a/src/components/Tests/TestTicket/index.tsx +++ b/src/components/Tests/TestTicket/index.tsx @@ -39,7 +39,7 @@ export const TestTicket = (props: TestTicketProps) => { Duration: ${getDurationString(duration)}, <> {relatedSpans.length > 0 && ( - {`Related spans:\n${relatedSpans}`} + {`Related spans:\n${relatedSpans}`} )} >, diff --git a/src/components/common/App/getTheme.ts b/src/components/common/App/getTheme.ts index 2a968c607..25cc2d12a 100644 --- a/src/components/common/App/getTheme.ts +++ b/src/components/common/App/getTheme.ts @@ -243,17 +243,28 @@ const darkThemeColors: ThemeColors = { panel: { background: grayScale[1000] }, + select: { + menu: { + text: { + primary: grayScale[0] + }, + background: grayScale[800] + } + }, text: { base: grayScale[0], subtext: grayScale[400], link: primaryScale[100], - success: greenScale[500] + success: greenScale[500], + disabledAlt: grayScale[500] }, surface: { + primary: grayScale[1000], primaryLight: grayScale[800], highlight: grayScale[750], card: grayScale[1100], brand: primaryScale[300], + brandDark: primaryScale[700], secondary: grayScale[1100] }, stroke: { @@ -447,21 +458,32 @@ const lightThemeColors: ThemeColors = { text: grayScale[800] }, panel: { background: grayScale[150] }, + select: { + menu: { + text: { + primary: grayScale[900] + }, + background: grayScale[100] + } + }, text: { base: grayScale[900], subtext: grayScale[600], link: primaryScale[300], - success: grayScale[900] + success: grayScale[900], + disabledAlt: grayScale[500] }, surface: { + primary: grayScale[150], primaryLight: grayScale[50], highlight: grayScale[150], card: grayScale[0], brand: primaryScale[300], + brandDark: grayScale[50], secondary: grayScale[50] }, stroke: { - primary: grayScale[500], + primary: grayScale[300], secondary: grayScale[800], brand: primaryScale[300] } diff --git a/src/components/common/JiraTicket/TicketLinkButton/index.tsx b/src/components/common/JiraTicket/TicketLinkButton/index.tsx index ccc2dc19f..b720e1ba7 100644 --- a/src/components/common/JiraTicket/TicketLinkButton/index.tsx +++ b/src/components/common/JiraTicket/TicketLinkButton/index.tsx @@ -46,7 +46,7 @@ export const TicketLinkButton = (props: TicketLinkButtonProps) => { return ( { ref={refs.setFloating} style={{ ...floatingStyles, + width: props.sameWidth + ? context.elements.reference?.getBoundingClientRect().width + : isUndefined(props.width) + ? undefined + : props.width, zIndex: LAYERS.MODAL }} {...getFloatingProps()} diff --git a/src/components/common/NewPopover/types.ts b/src/components/common/NewPopover/types.ts index 207a68c2b..af844a016 100644 --- a/src/components/common/NewPopover/types.ts +++ b/src/components/common/NewPopover/types.ts @@ -7,6 +7,8 @@ export interface PopoverProps { placement?: Placement; arrow?: boolean; isOpen: boolean; - onOpenChange: (isOpen: boolean) => void; + onOpenChange?: (isOpen: boolean) => void; boundary?: HTMLElement; + width?: number | string; + sameWidth?: boolean; } diff --git a/src/components/common/Select/Select.stories.tsx b/src/components/common/Select/Select.stories.tsx new file mode 100644 index 000000000..f56f52ada --- /dev/null +++ b/src/components/common/Select/Select.stories.tsx @@ -0,0 +1,101 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import { useState } from "react"; +import { Select } from "."; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: "Common/Select", + component: Select, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout + layout: "fullscreen" + } +}; + +export default meta; + +type Story = StoryObj; + +const mockedData = { + counts: { + filtered: 6, + total: 10 + }, + items: [ + { + label: "Very long long long long long long long long long long long name", + value: "item_1" + }, + { + label: "Item 2", + value: "item_2" + }, + { + label: "Item 3", + value: "item_3" + }, + { + label: "Item 4", + value: "item_4" + }, + { + label: "Item 5", + value: "item_5" + }, + { + label: "Item 6", + value: "item_6" + } + ] +}; + +export const Default: Story = { + args: { + items: mockedData.items.map((x, i) => ({ ...x, enabled: i !== 0 })) + } +}; + +export const Searchable: Story = { + args: { + searchable: true, + items: mockedData.items.map((x, i) => ({ ...x, enabled: i !== 0 })) + } +}; + +export const Empty: Story = { + args: { + searchable: true, + items: [] + } +}; + +export const Multiselect: Story = { + render: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [selectedItems, setSelectedItems] = useState([]); + + const handleChange = (value: string | string[]) => { + setSelectedItems(Array.isArray(value) ? value : [value]); + }; + + const items = args.items.map((x, i) => ({ + ...x, + enabled: i !== 0, + selected: selectedItems.includes(x.value) + })); + + return ( + + ); + }, + args: { + ...mockedData + } +}; diff --git a/src/components/common/Select/index.tsx b/src/components/common/Select/index.tsx new file mode 100644 index 000000000..6b416b8aa --- /dev/null +++ b/src/components/common/Select/index.tsx @@ -0,0 +1,168 @@ +import { useCallback, useRef, useState } from "react"; +import useDimensions from "react-cool-dimensions"; +import { isString } from "../../../typeGuards/isString"; +import { Checkbox } from "../Checkbox"; +import { NewPopover } from "../NewPopover"; +import { Tooltip } from "../Tooltip"; +import { ChevronIcon } from "../icons/ChevronIcon"; +import { MagnifierIcon } from "../icons/MagnifierIcon"; +import { Direction } from "../icons/types"; +import * as s from "./styles"; +import { SelectItem, SelectProps } from "./types"; + +const sortItemsBySelectedState = (a: SelectItem, b: SelectItem) => { + if (a.selected && !b.selected) { + return -1; + } + + if (!a.selected && b.selected) { + return 1; + } + + return 0; +}; + +export const Select = (props: SelectProps) => { + const [isOpen, setIsOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + const optionListRef = useRef(null); + const { observe } = useDimensions(); + + const getOptionListRef = useCallback( + (el: HTMLUListElement | null) => { + observe(el); + optionListRef.current = el; + }, + [observe] + ); + + const handleButtonClick = () => { + setIsOpen(!isOpen); + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchValue(e.target.value); + }; + + const handleItemClick = (item: SelectItem) => { + if (!item.enabled) { + return; + } + + if (!props.multiselect) { + props.onChange(item.value); + return; + } + + const otherSelectedItems = props.items + .filter((x) => x.selected && x.value !== item.value) + .map((x) => x.value); + + const newValue = item.selected + ? otherSelectedItems + : [...otherSelectedItems, item.value]; + + props.onChange(newValue); + }; + + const selectedValues = props.items + .filter((x) => x.selected) + .map((x) => x.value); + + const filteredItems = props.items.filter((x) => + x.label.toLocaleLowerCase().includes(searchValue) + ); + + const sortedItems = filteredItems.sort(sortItemsBySelectedState); + + const optionListHasVerticalScrollbar = Boolean( + optionListRef?.current && + optionListRef.current.scrollHeight > optionListRef.current.clientHeight + ); + + const isSearchBarVisible = + props.searchable && + (optionListHasVerticalScrollbar || searchValue.length > 0); + + return ( + + {isSearchBarVisible && ( + + + + + + + )} + + {sortedItems.length > 0 ? ( + sortedItems.map((x) => ( + handleItemClick(x)} + $enabled={x.enabled} + $selected={x.selected} + > + {props.multiselect && ( + undefined} + disabled={!x.enabled} + /> + )} + + {x.label} + + + )) + ) : ( + No results + )} + + + } + onOpenChange={props.disabled ? undefined : setIsOpen} + isOpen={props.disabled ? false : isOpen} + placement={"bottom-start"} + > + + {props.icon && } + {isString(props.placeholder) && ( + {props.placeholder} + )} + {selectedValues.length > 0 && ( + {selectedValues.length} + )} + {props.counts && ( + + {props.counts.filtered < props.counts.total ? ( + {props.counts.filtered} + ) : ( + props.counts.filtered + )} + /{props.counts.total} + + )} + + + + + + ); +}; diff --git a/src/components/common/Select/styles.ts b/src/components/common/Select/styles.ts new file mode 100644 index 000000000..e50215a96 --- /dev/null +++ b/src/components/common/Select/styles.ts @@ -0,0 +1,191 @@ +import styled from "styled-components"; +import { grayScale, primaryScale } from "../App/getTheme"; +import { + ButtonProps, + ChevronIconContainerProps, + OptionListItemProps +} from "./types"; + +export const Button = styled.button` + border: 1px solid + ${({ theme, $isActive }) => + $isActive ? theme.colors.stroke.brand : theme.colors.stroke.primary}; + background: ${({ theme }) => { + switch (theme.mode) { + case "light": + return grayScale[50]; + case "dark": + case "dark-jetbrains": + return grayScale[1000]; + } + }}; + border-radius: 4px; + padding: 4px 8px; + display: flex; + gap: 6px; + align-items: center; + width: 100%; + justify-content: flex-end; + font-size: 14px; + color: ${({ theme, $isActive }) => + $isActive ? primaryScale[100] : theme.colors.select.menu.text.primary}; + + &:hover { + border: 1px solid ${({ theme }) => theme.colors.stroke.secondary}; + } + + &:focus, + &:active { + border: 1px solid ${({ theme }) => theme.colors.stroke.brand}; + } + + &:disabled, + &:disabled:hover, + &:disabled:focus, + &:disabled:active { + border: 1px solid ${({ theme }) => theme.colors.stroke.primary}; + color: ${({ theme }) => theme.colors.text.disabledAlt}; + } +`; + +export const ButtonLabel = styled.span` + margin-right: auto; + font-weight: 500; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +`; + +export const Number = styled.span` + width: 16px; + height: 16px; + font-weight: 500; + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + color: ${grayScale[0]}; + background: #4b4db4; + margin-left: auto; +`; + +export const Counts = styled.span` + color: ${grayScale[300]}; +`; + +export const FilteredCount = styled.span` + color: ${primaryScale[200]}; +`; + +export const ChevronIconContainer = styled.span` + display: flex; + color: ${({ theme, $disabled }) => + $disabled ? theme.colors.icon.disabledAlt : theme.colors.icon.primary}; +`; + +export const MenuContainer = styled.div` + padding: 4px; + border-radius: 4px; + border: 1px solid ${({ theme }) => theme.colors.stroke.primary}; + box-shadow: 0 2px 4px 0 rgba(0 0 0 / 2%); + gap: 4px; + display: flex; + flex-direction: column; + max-height: 162px; + box-sizing: border-box; + background: ${({ theme }) => { + switch (theme.mode) { + case "light": + return grayScale[50]; + case "dark": + case "dark-jetbrains": + return grayScale[1000]; + } + }}; +`; + +export const SearchInputContainer = styled.div` + border-radius: 4px; + border: 1px solid ${grayScale[700]}; + padding: 5px 8px; + gap: 4px; + display: flex; + align-items: center; +`; + +export const SearchInput = styled.input` + width: 100%; + background: transparent; + border: none; + outline: none; + font-size: 14px; + padding: 0; + color: ${({ theme }) => { + switch (theme.mode) { + case "light": + return grayScale[1000]; + case "dark": + case "dark-jetbrains": + return grayScale[0]; + } + }}; + + &::placeholder { + color: ${grayScale[500]}; + } +`; + +export const SearchInputIconContainer = styled.div` + pointer-events: none; + color: ${grayScale[400]}; +`; + +export const OptionList = styled.ul` + display: flex; + flex-direction: column; + gap: 4px; + padding: 0; + margin: 0; + font-size: 14px; + overflow: hidden; + overflow-y: auto; +`; + +export const OptionListItem = styled.li` + display: flex; + padding: 4px 8px; + align-items: center; + gap: 6px; + align-self: stretch; + cursor: ${({ $enabled }) => ($enabled ? "pointer" : "default")}; + color: ${({ theme, $selected, $enabled }) => { + if (!$enabled) { + switch (theme.mode) { + case "light": + return "#dadada"; + case "dark": + case "dark-jetbrains": + return "#49494d"; + } + } + if ($selected) { + return primaryScale[100]; + } + return theme.colors.select.menu.text.primary; + }}; +`; + +export const OptionListItemLabel = styled.span` + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +`; + +export const NoResultsContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + padding: 8px 0; + font-size: 14px; + color: ${({ theme }) => theme.colors.text.subtext}; +`; diff --git a/src/components/common/Select/types.ts b/src/components/common/Select/types.ts new file mode 100644 index 000000000..669352aca --- /dev/null +++ b/src/components/common/Select/types.ts @@ -0,0 +1,45 @@ +import { ComponentType } from "react"; +import { IconProps } from "../icons/types"; + +export interface SelectThemeColors { + menu: { + text: { + primary: string; + }; + background: string; + }; +} + +export interface SelectItem { + value: string; + label: string; + enabled?: boolean; + selected?: boolean; +} + +export interface SelectProps { + items: SelectItem[]; + multiselect?: boolean; + searchable?: boolean; + onChange: (value: string | string[]) => void; + counts?: { + total: number; + filtered: number; + }; + placeholder?: string; + disabled?: boolean; + icon?: ComponentType; +} + +export interface ButtonProps { + $isActive: boolean; +} + +export interface OptionListItemProps { + $selected?: boolean; + $enabled?: boolean; +} + +export interface ChevronIconContainerProps { + $disabled?: boolean; +} diff --git a/src/components/common/icons/12px/WrenchIcon.tsx b/src/components/common/icons/12px/WrenchIcon.tsx new file mode 100644 index 000000000..ffdce29a8 --- /dev/null +++ b/src/components/common/icons/12px/WrenchIcon.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { useIconProps } from "../hooks"; +import { IconProps } from "../types"; + +const WrenchIconComponent = (props: IconProps) => { + const { size, color } = useIconProps(props); + + return ( + + + + + + + + + + + ); +}; + +export const WrenchIcon = React.memo(WrenchIconComponent); diff --git a/src/components/common/icons/FunnelIcon.tsx b/src/components/common/icons/FunnelIcon.tsx new file mode 100644 index 000000000..85b503fbf --- /dev/null +++ b/src/components/common/icons/FunnelIcon.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { useIconProps } from "./hooks"; +import { IconProps } from "./types"; + +const FunnelIconComponent = (props: IconProps) => { + const { size, color } = useIconProps(props); + + return ( + + + + + + + + + + + ); +}; + +export const FunnelIcon = React.memo(FunnelIconComponent); diff --git a/src/components/common/icons/SparkleIcon.tsx b/src/components/common/icons/SparkleIcon.tsx new file mode 100644 index 000000000..a52447850 --- /dev/null +++ b/src/components/common/icons/SparkleIcon.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { useIconProps } from "./hooks"; +import { IconProps } from "./types"; + +const SparkleIconComponent = (props: IconProps) => { + const { size, color } = useIconProps(props); + + return ( + + + + ); +}; + +export const SparkleIcon = React.memo(SparkleIconComponent); diff --git a/src/featureFlags.ts b/src/featureFlags.ts index 18a9532a9..bcb718825 100644 --- a/src/featureFlags.ts +++ b/src/featureFlags.ts @@ -2,12 +2,13 @@ import { gte, valid } from "semver"; import { ConfigContextData } from "./components/common/App/types"; import { FeatureFlag } from "./types"; -const featureFlagMinBackendVersions: Record = { +export const featureFlagMinBackendVersions: Record = { [FeatureFlag.IS_DASHBOARD_CLIENT_SPANS_OVERALL_IMPACT_ENABLED]: "v0.2.172-alpha.8", [FeatureFlag.IS_ASSETS_SERVICE_FILTER_VISIBLE]: "v0.2.174", [FeatureFlag.IS_ASSETS_OVERALL_IMPACT_HIDDEN]: "v0.2.181-alpha.1", - [FeatureFlag.IS_INSIGHT_TICKET_LINKAGE_ENABLED]: "v0.2.200" + [FeatureFlag.IS_INSIGHT_TICKET_LINKAGE_ENABLED]: "v0.2.200", + [FeatureFlag.IS_ASSETS_COMPLEX_FILTER_ENABLED]: "v0.2.215" }; export const getFeatureFlagValue = ( diff --git a/src/hooks/usePersistence.ts b/src/hooks/usePersistence.ts new file mode 100644 index 000000000..33808ff0d --- /dev/null +++ b/src/hooks/usePersistence.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useState } from "react"; +import { actions } from "../actions"; +import { dispatcher } from "../dispatcher"; +import { isObject } from "../typeGuards/isObject"; + +type PersistenceScope = "application" | "project"; + +interface SetFromPersistencePayload { + key: string; + value: T | null; + scope: PersistenceScope; + error: { message: string } | null; +} + +export const usePersistence = ( + key: string, + scope: PersistenceScope +): [T | null | undefined, (value: T | null) => void] => { + const [value, setValue] = useState(); + + const saveToPersistence = useCallback( + (value: T | null) => { + window.sendMessageToDigma({ + action: actions.SAVE_TO_PERSISTENCE, + payload: { + key, + value, + scope + } + }); + }, + [key, scope] + ); + + useEffect(() => { + window.sendMessageToDigma({ + action: actions.GET_FROM_PERSISTENCE, + payload: { + key, + scope + } + }); + + const handlePersistedData = (data: unknown) => { + if (isObject(data) && data.key === key) { + const persistenceData = data as unknown as SetFromPersistencePayload; + setValue(persistenceData.value); + } + }; + + dispatcher.addActionListener( + actions.SET_FROM_PERSISTENCE, + handlePersistedData + ); + + return () => { + dispatcher.removeActionListener( + actions.SET_FROM_PERSISTENCE, + handlePersistedData + ); + }; + }, []); + + return [value, saveToPersistence]; +}; diff --git a/src/styled.d.ts b/src/styled.d.ts index f070bffae..84691a1fb 100644 --- a/src/styled.d.ts +++ b/src/styled.d.ts @@ -7,6 +7,7 @@ import { AttachmentTagThemeColors } from "./components/common/JiraTicket/Attachm import { FieldThemeColors } from "./components/common/JiraTicket/Field/types"; import { JiraTicketThemeColors } from "./components/common/JiraTicket/types"; import { ButtonThemeColors } from "./components/common/NewButton/types"; +import { SelectThemeColors } from "./components/common/Select/types"; import { TagThemeColors } from "./components/common/Tag/types"; import { TooltipThemeColors } from "./components/common/Tooltip/types"; import { Mode } from "./globals"; @@ -35,6 +36,7 @@ export interface ThemeColors { attachmentTag: AttachmentTagThemeColors; jiraTicket: JiraTicketThemeColors; field: FieldThemeColors; + select: SelectThemeColors; panel: { background: string; }; @@ -43,12 +45,15 @@ export interface ThemeColors { link: string; subtext: string; success: string; + disabledAlt: string; }; surface: { + primary: string; primaryLight: string; highlight: string; card: string; brand: string; + brandDark: string; secondary: string; }; stroke: { diff --git a/src/typeGuards/isObject.ts b/src/typeGuards/isObject.ts index acdab276c..c8d899b47 100644 --- a/src/typeGuards/isObject.ts +++ b/src/typeGuards/isObject.ts @@ -1,4 +1,6 @@ import { isNull } from "./isNull"; -export const isObject = (x: unknown): x is Record => +export const isObject = ( + x: unknown +): x is Record => typeof x === "object" && !isNull(x); diff --git a/src/types.ts b/src/types.ts index 16dff3355..ca9ac9167 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,7 +4,8 @@ export enum FeatureFlag { IS_DASHBOARD_CLIENT_SPANS_OVERALL_IMPACT_ENABLED, IS_ASSETS_SERVICE_FILTER_VISIBLE, IS_ASSETS_OVERALL_IMPACT_HIDDEN, - IS_INSIGHT_TICKET_LINKAGE_ENABLED + IS_INSIGHT_TICKET_LINKAGE_ENABLED, + IS_ASSETS_COMPLEX_FILTER_ENABLED } export enum InsightType { diff --git a/src/utils/getInsightTypeInfo.ts b/src/utils/getInsightTypeInfo.ts index f6d6e12a6..b700d3010 100644 --- a/src/utils/getInsightTypeInfo.ts +++ b/src/utils/getInsightTypeInfo.ts @@ -127,7 +127,7 @@ export const getInsightTypeInfo = ( description: descriptionProvider.HighNumberOfQueriesDescription }, [InsightType.SpanNexus]: { - icon: BottleneckIcon, // todo changes + icon: BottleneckIcon, label: "Code Nexus Point", description: descriptionProvider.CodeNexusDescription },
DIGMA_OBSERVABILITY=true