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 ( + + ); + }, + 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 },