diff --git a/packages/manager/.changeset/pr-10807-upcoming-features-1724226251911.md b/packages/manager/.changeset/pr-10807-upcoming-features-1724226251911.md new file mode 100644 index 00000000000..b710ea68af5 --- /dev/null +++ b/packages/manager/.changeset/pr-10807-upcoming-features-1724226251911.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add new CloudPulseCustomSelect component and integrate with the global filter builder ([#10807](https://github.com/linode/manager/pull/10807)) diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index c1758bc1d13..ac019a321d3 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -21,7 +21,10 @@ import { getIntervalIndex, } from '../Widget/components/CloudPulseIntervalSelect'; -import type { CloudPulseWidgetProperties } from '../Widget/CloudPulseWidget'; +import type { + CloudPulseMetricsAdditionalFilters, + CloudPulseWidgetProperties, +} from '../Widget/CloudPulseWidget'; import type { AvailableMetrics, Dashboard, @@ -31,6 +34,11 @@ import type { } from '@linode/api-v4'; export interface DashboardProperties { + /** + * Apart from above explicit filters, any additional filters for metrics endpoint will go here + */ + additionalFilters?: CloudPulseMetricsAdditionalFilters[]; + /** * Id of the selected dashboard */ @@ -64,6 +72,7 @@ export interface DashboardProperties { export const CloudPulseDashboard = (props: DashboardProperties) => { const { + additionalFilters, dashboardId, duration, manualRefreshTimeStamp, @@ -81,6 +90,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { widget: Widgets ): CloudPulseWidgetProperties => { const graphProp: CloudPulseWidgetProperties = { + additionalFilters, ariaLabel: widget.label, authToken: '', availableMetrics: undefined, diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx index 9cfd7c3494f..89f5f6db2c9 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx @@ -7,7 +7,10 @@ import { StyledPlaceholder } from 'src/features/StackScripts/StackScriptBase/Sta import { GlobalFilters } from '../Overview/GlobalFilters'; import { REGION, RESOURCE_ID } from '../Utils/constants'; -import { checkIfAllMandatoryFiltersAreSelected } from '../Utils/FilterBuilder'; +import { + checkIfAllMandatoryFiltersAreSelected, + getMetricsCallCustomFilters, +} from '../Utils/FilterBuilder'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; import { useLoadUserPreferences } from '../Utils/UserPreference'; import { CloudPulseDashboard } from './CloudPulseDashboard'; @@ -97,6 +100,10 @@ export const CloudPulseDashboardLanding = () => { return ( { const regionConfig = linodeConfig?.filters.find( (filterObj) => filterObj.name === 'Region' @@ -183,3 +191,76 @@ it('test checkIfAllMandatoryFiltersAreSelected method', () => { expect(result).toEqual(false); }); + +it('test getCustomSelectProperties method', () => { + const customSelectEngineConfig = dbaasConfig?.filters.find( + (filterObj) => filterObj.name === 'DB Engine' + ); + + expect(customSelectEngineConfig).toBeDefined(); + + if (customSelectEngineConfig) { + let result = getCustomSelectProperties( + { + config: customSelectEngineConfig, + dashboard: { ...mockDashboard, service_type: 'dbaas' }, + isServiceAnalyticsIntegration: true, + }, + vi.fn() + ); + + expect(result.options).toBeDefined(); + expect(result.options?.length).toEqual(2); + expect(result.savePreferences).toEqual(false); + expect(result.isMultiSelect).toEqual(false); + expect(result.disabled).toEqual(false); + expect(result.clearDependentSelections).toBeDefined(); + expect(result.clearDependentSelections?.includes(RESOURCES)).toBe(true); + + customSelectEngineConfig.configuration.type = CloudPulseSelectTypes.dynamic; + customSelectEngineConfig.configuration.apiV4QueryKey = + databaseQueries.engines; + customSelectEngineConfig.configuration.isMultiSelect = true; + customSelectEngineConfig.configuration.options = undefined; + + result = getCustomSelectProperties( + { + config: customSelectEngineConfig, + dashboard: mockDashboard, + isServiceAnalyticsIntegration: true, + }, + vi.fn() + ); + + expect(result.apiV4QueryKey).toEqual(databaseQueries.engines); + expect(result.type).toEqual(CloudPulseSelectTypes.dynamic); + expect(result.savePreferences).toEqual(false); + expect(result.isMultiSelect).toEqual(true); + } +}); + +it('test getFiltersForMetricsCallFromCustomSelect method', () => { + const result = getMetricsCallCustomFilters( + { + resource_id: [1, 2, 3], + }, + 'linode' + ); + + expect(result).toBeDefined(); + expect(result.length).toEqual(0); +}); + +it('test constructAdditionalRequestFilters method', () => { + const result = constructAdditionalRequestFilters( + getMetricsCallCustomFilters( + { + resource_id: [1, 2, 3], + }, + 'linode' + ) + ); + + expect(result).toBeDefined(); + expect(result.length).toEqual(0); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index 90a1ea45f07..57a6b6e7bbc 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -1,15 +1,18 @@ -import { RELATIVE_TIME_DURATION } from './constants'; +import { RELATIVE_TIME_DURATION, RESOURCE_ID, RESOURCES } from './constants'; import { FILTER_CONFIG } from './FilterConfig'; +import { CloudPulseSelectTypes } from './models'; import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; +import type { CloudPulseCustomSelectProps } from '../shared/CloudPulseCustomSelect'; import type { CloudPulseRegionSelectProps } from '../shared/CloudPulseRegionSelect'; import type { CloudPulseResources, CloudPulseResourcesSelectProps, } from '../shared/CloudPulseResourcesSelect'; import type { CloudPulseTimeRangeSelectProps } from '../shared/CloudPulseTimeRangeSelect'; +import type { CloudPulseMetricsAdditionalFilters } from '../Widget/CloudPulseWidget'; import type { CloudPulseServiceTypeFilters } from './models'; -import type { Dashboard, Filter, TimeDuration } from '@linode/api-v4'; +import type { Dashboard, Filter, Filters, TimeDuration } from '@linode/api-v4'; interface CloudPulseFilterProperties { config: CloudPulseServiceTypeFilters; @@ -86,6 +89,54 @@ export const getResourcesProperties = ( }; }; +/** + * @param props The cloudpulse filter properties selected so far + * @param handleCustomSelectChange The callback function when a filter change happens + * @returns {CloudPulseCustomSelectProps} Returns a property compatible for CloudPulseCustomSelect Component + */ +export const getCustomSelectProperties = ( + props: CloudPulseFilterProperties, + handleCustomSelectChange: (filterKey: string, value: FilterValueType) => void +): CloudPulseCustomSelectProps => { + const { + apiIdField, + apiLabelField, + apiV4QueryKey, + filterKey, + filterType, + isMultiSelect, + maxSelections, + options, + placeholder, + } = props.config.configuration; + const { dashboard, dependentFilters, isServiceAnalyticsIntegration } = props; + return { + apiResponseIdField: apiIdField, + apiResponseLabelField: apiLabelField, + apiV4QueryKey, + clearDependentSelections: getDependentFiltersByFilterKey( + filterKey, + dashboard + ), + disabled: checkIfWeNeedToDisableFilterByFilterKey( + filterKey, + dependentFilters ?? {}, + dashboard + ), + filterKey, + filterType, + handleSelectionChange: handleCustomSelectChange, + isMultiSelect, + maxSelections, + options, + placeholder, + savePreferences: !isServiceAnalyticsIntegration, + type: options + ? CloudPulseSelectTypes.static + : CloudPulseSelectTypes.dynamic, + }; +}; + /** * This function helps in building the properties needed for time duration filter * @@ -206,3 +257,80 @@ export const checkIfAllMandatoryFiltersAreSelected = ( return value !== undefined && (!Array.isArray(value) || value.length > 0); }); }; + +/** + * @param selectedFilters The selected filters from the global filters view from custom select component + * @param serviceType The serviceType assosicated with the dashboard like linode, dbaas etc., + * @returns Constructs and returns the metrics call filters based on selected filters and service type + */ +export const getMetricsCallCustomFilters = ( + selectedFilters: { + [key: string]: FilterValueType; + }, + serviceType: string +): CloudPulseMetricsAdditionalFilters[] => { + const serviceTypeConfig = FILTER_CONFIG.get(serviceType); + + // If configuration exists, filter and map it to the desired CloudPulseMetricsAdditionalFilters format + return ( + serviceTypeConfig?.filters + .filter( + ({ configuration }) => + configuration.isFilterable && + !configuration.isMetricsFilter && + selectedFilters[configuration.filterKey] + ) + .map(({ configuration }) => ({ + filterKey: configuration.filterKey, + filterValue: selectedFilters[configuration.filterKey], + })) ?? [] + ); +}; + +/** + * @param additionalFilters The additional filters selected from custom select components + * @returns The list of filters for the metric API call, based the additional custom select components + */ +export const constructAdditionalRequestFilters = ( + additionalFilters: CloudPulseMetricsAdditionalFilters[] +): Filters[] => { + const filters: Filters[] = []; + for (const filter of additionalFilters) { + if (filter) { + // push to the filters + filters.push({ + key: filter.filterKey, + operator: Array.isArray(filter.filterValue) ? 'in' : 'eq', + value: Array.isArray(filter.filterValue) + ? Array.of(filter.filterValue).join(',') + : String(filter.filterValue), + }); + } + } + return filters; +}; + +/** + * + * @param filterKey The filterKey of the actual filter + * @param dashboard The selected dashboard from the global filter view + * @returns The filterKeys that needs to be removed from the preferences + */ +const getDependentFiltersByFilterKey = ( + filterKey: string, + dashboard: Dashboard +): string[] => { + const serviceTypeConfig = FILTER_CONFIG.get(dashboard.service_type); + + if (!serviceTypeConfig) { + return []; + } + + return serviceTypeConfig.filters + .filter((filter) => filter?.configuration?.dependency?.includes(filterKey)) + .map(({ configuration }) => + configuration.filterKey === RESOURCE_ID + ? RESOURCES + : configuration.filterKey + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index 906b537651a..a7172590950 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -12,6 +12,7 @@ import { getCloudPulseMetricRequest, } from '../Utils/CloudPulseWidgetUtils'; import { AGGREGATE_FUNCTION, SIZE, TIME_GRANULARITY } from '../Utils/constants'; +import { constructAdditionalRequestFilters } from '../Utils/FilterBuilder'; import { convertValueToUnit, formatToolTip } from '../Utils/unitConversion'; import { getUserPreferenceObject, @@ -23,17 +24,23 @@ import { CloudPulseIntervalSelect } from './components/CloudPulseIntervalSelect' import { CloudPulseLineGraph } from './components/CloudPulseLineGraph'; import { ZoomIcon } from './components/Zoomer'; +import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; +import type { Widgets } from '@linode/api-v4'; import type { AvailableMetrics, TimeDuration, TimeGranularity, } from '@linode/api-v4'; -import type { Widgets } from '@linode/api-v4'; import type { DataSet } from 'src/components/LineGraph/LineGraph'; import type { Metrics } from 'src/utilities/statMetrics'; export interface CloudPulseWidgetProperties { + /** + * Apart from above explicit filters, any additional filters for metrics endpoint will go here + */ + additionalFilters?: CloudPulseMetricsAdditionalFilters[]; + /** * Aria label for this widget */ @@ -100,6 +107,11 @@ export interface CloudPulseWidgetProperties { widget: Widgets; } +export interface CloudPulseMetricsAdditionalFilters { + filterKey: string; + filterValue: FilterValueType; +} + export interface LegendRow { data: Metrics; format: (value: number) => {}; @@ -115,6 +127,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { const [widget, setWidget] = React.useState({ ...props.widget }); const { + additionalFilters, ariaLabel, authToken, availableMetrics, @@ -228,12 +241,15 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { status, } = useCloudPulseMetricsQuery( serviceType, - getCloudPulseMetricRequest({ - duration, - resourceIds, - resources, - widget, - }), + { + ...getCloudPulseMetricRequest({ + duration, + resourceIds, + resources, + widget, + }), + filters: constructAdditionalRequestFilters(additionalFilters ?? []), // any additional dimension filters will be constructed and passed here + }, { authToken, isFlags: Boolean(flags), diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx index c30a360d2e1..224956a45ca 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx @@ -2,10 +2,12 @@ import React from 'react'; import NullComponent from 'src/components/NullComponent'; +import { CloudPulseCustomSelect } from './CloudPulseCustomSelect'; import { CloudPulseRegionSelect } from './CloudPulseRegionSelect'; import { CloudPulseResourcesSelect } from './CloudPulseResourcesSelect'; import { CloudPulseTimeRangeSelect } from './CloudPulseTimeRangeSelect'; +import type { CloudPulseCustomSelectProps } from './CloudPulseCustomSelect'; import type { CloudPulseRegionSelectProps } from './CloudPulseRegionSelect'; import type { CloudPulseResourcesSelectProps } from './CloudPulseResourcesSelect'; import type { CloudPulseTimeRangeSelectProps } from './CloudPulseTimeRangeSelect'; @@ -14,6 +16,7 @@ import type { MemoExoticComponent } from 'react'; export interface CloudPulseComponentRendererProps { componentKey: string; componentProps: + | CloudPulseCustomSelectProps | CloudPulseRegionSelectProps | CloudPulseResourcesSelectProps | CloudPulseTimeRangeSelectProps; @@ -23,12 +26,14 @@ export interface CloudPulseComponentRendererProps { const Components: { [key: string]: MemoExoticComponent< React.ComponentType< + | CloudPulseCustomSelectProps | CloudPulseRegionSelectProps | CloudPulseResourcesSelectProps | CloudPulseTimeRangeSelectProps > >; } = { + customSelect: CloudPulseCustomSelect, region: CloudPulseRegionSelect, relative_time_duration: CloudPulseTimeRangeSelect, resource_id: CloudPulseResourcesSelect, diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx new file mode 100644 index 00000000000..4b7ecbd4c29 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.test.tsx @@ -0,0 +1,153 @@ +import { fireEvent } from '@testing-library/react'; +import React from 'react'; + +import { databaseQueries } from 'src/queries/databases/databases'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseSelectTypes } from '../Utils/models'; +import { CloudPulseCustomSelect } from './CloudPulseCustomSelect'; + +import type { CloudPulseServiceTypeFiltersOptions } from '../Utils/models'; + +const mockOptions: CloudPulseServiceTypeFiltersOptions[] = [ + { + id: '1', + label: 'Test1', + }, + { + id: '2', + label: 'Test2', + }, +]; + +const queryMocks = vi.hoisted(() => ({ + useGetCustomFiltersQuery: vi.fn().mockReturnValue({ + data: [ + { + id: '1', + label: 'Test1', + }, + { + id: '2', + label: 'Test2', + }, + ], + isError: false, + isLoading: false, + status: 'success', + }), +})); + +vi.mock('src/queries/cloudpulse/customfilters', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/customfilters'); + return { + ...actual, + useGetCustomFiltersQuery: queryMocks.useGetCustomFiltersQuery, + }; +}); + +const testFilter = 'Select a Test Filter'; +const keyboardArrowDownIcon = 'KeyboardArrowDownIcon'; + +describe('CloudPulseCustomSelect component tests', () => { + it('should render a component successfully with required props static', () => { + const screen = renderWithTheme( + + ); + + expect(screen.getByPlaceholderText(testFilter)).toBeDefined(); + const keyDown = screen.getByTestId(keyboardArrowDownIcon); + fireEvent.click(keyDown); + fireEvent.click(screen.getByText('Test1')); + const textField = screen.getByTestId('textfield-input'); + expect(textField.getAttribute('value')).toEqual('Test1'); + }); + + it('should render a component successfully with required props static with multi select', () => { + const screen = renderWithTheme( + + ); + + expect(screen.getByPlaceholderText(testFilter)).toBeDefined(); + const keyDown = screen.getByTestId(keyboardArrowDownIcon); + fireEvent.click(keyDown); + expect(screen.getAllByText('Test1').length).toEqual(2); // here it should be 2 + expect(screen.getAllByText('Test2').length).toEqual(1); // since we didn't select this option it should be 1 + fireEvent.click(screen.getByText('Test2')); + + expect(screen.getAllByText('Test1').length).toEqual(2); // here it should be 2 + expect(screen.getAllByText('Test2').length).toEqual(2); // since we did select this option it should be 2 + + fireEvent.click(keyDown); // close the drop down + + expect(screen.getAllByText('Test1').length).toEqual(1); + expect(screen.getAllByText('Test2').length).toEqual(1); + }); + + it('should render a component successfully with required props dynamic', () => { + const selectionChnage = vi.fn(); + const screen = renderWithTheme( + + ); + expect(screen.getByPlaceholderText(testFilter)).toBeDefined(); + const keyDown = screen.getByTestId(keyboardArrowDownIcon); + fireEvent.click(keyDown); + fireEvent.click(screen.getByText('Test1')); + const textField = screen.getByTestId('textfield-input'); + expect(textField.getAttribute('value')).toEqual('Test1'); + expect(selectionChnage).toHaveBeenCalledTimes(1); + }); + + it('should render a component successfully with required props dynamic multi select', () => { + const selectionChnage = vi.fn(); + const screen = renderWithTheme( + + ); + expect(screen.getByPlaceholderText(testFilter)).toBeDefined(); + const keyDown = screen.getByTestId(keyboardArrowDownIcon); + fireEvent.click(keyDown); + fireEvent.click(screen.getByText('Test1')); + expect(screen.getAllByText('Test1').length).toEqual(2); // here it should be 2 + expect(screen.getAllByText('Test2').length).toEqual(1); // since we didn't select this option it should be 1 + fireEvent.click(screen.getByText('Test2')); + + expect(screen.getAllByText('Test1').length).toEqual(2); // here it should be 2 + expect(screen.getAllByText('Test2').length).toEqual(2); // since we did select this option it should be 2 + + fireEvent.click(keyDown); // close the drop down + + expect(screen.getAllByText('Test1').length).toEqual(1); + expect(screen.getAllByText('Test2').length).toEqual(1); + expect(selectionChnage).toHaveBeenCalledTimes(2); // check if selection change is called twice as we selected two options + }); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx new file mode 100644 index 00000000000..f43f0c8c74f --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx @@ -0,0 +1,248 @@ +import deepEqual from 'fast-deep-equal'; +import React from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { useGetCustomFiltersQuery } from 'src/queries/cloudpulse/customfilters'; + +import { + getInitialDefaultSelections, + handleCustomSelectionChange, +} from './CloudPulseCustomSelectUtils'; + +import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; +import type { + CloudPulseServiceTypeFiltersOptions, + QueryFunctionAndKey, +} from '../Utils/models'; + +/** + * These are the properties requires for CloudPulseCustomSelect Components + * + */ +export interface CloudPulseCustomSelectProps { + /** + * The id field of the response returned from the API + */ + apiResponseIdField?: string; + + /** + * The label field of the response returned from the API + */ + apiResponseLabelField?: string; + + /** + * The api query key factory which contains the queries to fetch the list of filters, passed when the select type is dynamic + */ + apiV4QueryKey?: QueryFunctionAndKey; + + /** + * The dependent selections to be cleared on this filter update + */ + clearDependentSelections?: string[]; + + /** + * This property says, whether or not to disable the selection component + */ + disabled?: boolean; + + /** + * The errorText that needs to be displayed + */ + errorText?: string; + + /** + * The filterKey that needs to be used + */ + filterKey: string; + + /** + * The type of the filter like string, number etc., + */ + filterType: string; + + /** + * The callback function , that will be called on a filter change + * @param filterKey - The filterKey of the component + * @param value - The selected filter value + */ + handleSelectionChange: (filterKey: string, value: FilterValueType) => void; + /** + * If true, multiselect is allowed, otherwise false + */ + isMultiSelect?: boolean; + + /** + * The maximum selections that the user can make incase of multiselect + */ + maxSelections?: number; + + /** + * The options to be listed down in the autocomplete if the select type is static + */ + options?: CloudPulseServiceTypeFiltersOptions[]; + + /** + * The placeholder that needs to displayed + */ + placeholder?: string; + + /** + * This property controls whether to save the preferences or not + */ + savePreferences?: boolean; + + /** + * The cloud pulse select types, it can be static or dynamic depending on the use case + */ + type: CloudPulseSelectTypes; +} + +export enum CloudPulseSelectTypes { + dynamic, + static, +} + +export const CloudPulseCustomSelect = React.memo( + (props: CloudPulseCustomSelectProps) => { + const { + apiResponseIdField, + apiResponseLabelField, + apiV4QueryKey, + clearDependentSelections, + disabled, + filterKey, + handleSelectionChange, + isMultiSelect, + maxSelections, + options, + placeholder, + savePreferences, + type, + } = props; + + const [selectedResource, setResource] = React.useState< + | CloudPulseServiceTypeFiltersOptions + | CloudPulseServiceTypeFiltersOptions[] + | undefined + >(); + + const { + data: queriedResources, + isError, + isLoading, + } = useGetCustomFiltersQuery({ + apiV4QueryKey, + enabled: Boolean(apiV4QueryKey && !disabled), + filter: {}, + idField: apiResponseIdField ?? 'id', + labelField: apiResponseLabelField ?? 'label', + }); + + React.useEffect(() => { + if (!selectedResource) { + setResource( + getInitialDefaultSelections({ + filterKey, + handleSelectionChange, + isMultiSelect: isMultiSelect ?? false, + options: options ?? [], + savePreferences: savePreferences ?? false, + }) + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [savePreferences, options, apiV4QueryKey]); // only execute this use efffect one time or if savePreferences or options or dataApiUrl changes + + const handleChange = ( + _: React.SyntheticEvent, + value: + | CloudPulseServiceTypeFiltersOptions + | CloudPulseServiceTypeFiltersOptions[] + | null + ) => { + const filteredValue = handleCustomSelectionChange({ + clearSelections: clearDependentSelections ?? [], + filterKey, + handleSelectionChange, + maxSelections, + value, + }); + setResource( + Array.isArray(filteredValue) + ? [...filteredValue] + : filteredValue ?? undefined + ); + }; + + let staticErrorText = ''; + + // check for input prop errors + if ( + (CloudPulseSelectTypes.static === type && + (!options || options.length === 0)) || + (CloudPulseSelectTypes.dynamic === type && !apiV4QueryKey) + ) { + staticErrorText = 'Pass either options or API query key'; + } + + const isAutoCompleteDisabled = + disabled || + ((isLoading || isError) && type === CloudPulseSelectTypes.dynamic) || + (!queriedResources && !(options && options.length)) || + staticErrorText.length > 0; + + staticErrorText = + staticErrorText.length > 0 + ? staticErrorText + : isError + ? 'Error while loading from API' + : ''; + + return ( + option.label === value.label} + label="Select a Value" + multiple={isMultiSelect} + onChange={handleChange} + placeholder={placeholder ?? 'Select a Value'} + value={selectedResource ?? (isMultiSelect ? [] : null)} + /> + ); + }, + compareProps +); + +function compareProps( + prevProps: CloudPulseCustomSelectProps, + nextProps: CloudPulseCustomSelectProps +): boolean { + // these properties can be extended going forward + const keysToCompare: (keyof CloudPulseCustomSelectProps)[] = [ + 'apiV4QueryKey', + 'disabled', + ]; + + for (const key of keysToCompare) { + if (prevProps[key] !== nextProps[key]) { + return false; + } + } + + // Deep comparison for options + if (!deepEqual(prevProps.options, nextProps.options)) { + return false; + } + + // Ignore function props in comparison + return true; +} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.test.ts b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.test.ts new file mode 100644 index 00000000000..0c1bc3e1844 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.test.ts @@ -0,0 +1,135 @@ +import { + getInitialDefaultSelections, + handleCustomSelectionChange, +} from './CloudPulseCustomSelectUtils'; + +import type { CloudPulseServiceTypeFiltersOptions } from '../Utils/models'; + +const queryMocks = vi.hoisted(() => ({ + getUserPreferenceObject: vi.fn().mockReturnValue({ + test: '1', + }), +})); + +vi.mock('../Utils/UserPreference', async () => { + const actual = await vi.importActual('../Utils/UserPreference'); + return { + ...actual, + getUserPreferenceObject: queryMocks.getUserPreferenceObject, + }; +}); + +it('test handleCustomSelectionChange method for single selection', () => { + const selectedValue: CloudPulseServiceTypeFiltersOptions = { + id: '1', + label: 'Test', + }; + const handleSelectionChange = vi.fn(); + const result = handleCustomSelectionChange({ + clearSelections: [], + filterKey: 'test', + handleSelectionChange, + value: selectedValue, + }); + + expect(result).toBeDefined(); + expect(result).toEqual(selectedValue); + expect(handleSelectionChange).toBeCalledTimes(1); +}); + +it('test handleCustomSelectionChange method for multiple selection', () => { + const selectedValue: CloudPulseServiceTypeFiltersOptions[] = [ + { + id: '1', + label: 'Test', + }, + ]; + const handleSelectionChange = vi.fn(); + const result = handleCustomSelectionChange({ + clearSelections: [], + filterKey: 'test', + handleSelectionChange, + value: selectedValue, + }); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result).toEqual(selectedValue); + expect(handleSelectionChange).toBeCalledTimes(1); +}); + +it('test getInitialDefaultSelections method for single selection', () => { + const handleSelectionChange = vi.fn(); + + const options: CloudPulseServiceTypeFiltersOptions[] = [ + { + id: '1', + label: 'Test', + }, + ]; + + let result = getInitialDefaultSelections({ + filterKey: 'test', + handleSelectionChange, + isMultiSelect: false, + options, + savePreferences: true, + }); + + expect(Array.isArray(result)).toBe(false); + expect(result).toEqual(options[0]); + expect(handleSelectionChange).toBeCalledTimes(1); + queryMocks.getUserPreferenceObject.mockReturnValue({ + test: '2', + }); + + result = getInitialDefaultSelections({ + filterKey: 'test', + handleSelectionChange, + isMultiSelect: false, + options, + savePreferences: true, + }); + expect(result).toEqual(undefined); + expect(handleSelectionChange).toBeCalledTimes(2); +}); + +it('test getInitialDefaultSelections method for multi selection', () => { + const handleSelectionChange = vi.fn(); + + queryMocks.getUserPreferenceObject.mockReturnValue({ + test: '1', + }); + + const options: CloudPulseServiceTypeFiltersOptions[] = [ + { + id: '1', + label: 'Test', + }, + ]; + + let result = getInitialDefaultSelections({ + filterKey: 'test', + handleSelectionChange, + isMultiSelect: true, + options, + savePreferences: true, + }); + + expect(Array.isArray(result)).toBe(true); + expect(result).toEqual(options); + expect(handleSelectionChange).toBeCalledTimes(1); + queryMocks.getUserPreferenceObject.mockReturnValue({ + test: '2', + }); + + result = getInitialDefaultSelections({ + filterKey: 'test', + handleSelectionChange, + isMultiSelect: false, + options, + savePreferences: true, + }); + expect(result).toEqual(undefined); + expect(handleSelectionChange).toBeCalledTimes(2); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.ts b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.ts new file mode 100644 index 00000000000..4bd030e8627 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.ts @@ -0,0 +1,171 @@ +import { + getUserPreferenceObject, + updateGlobalFilterPreference, +} from '../Utils/UserPreference'; + +import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; +import type { CloudPulseServiceTypeFiltersOptions } from '../Utils/models'; + +/** + * The interface for selecting the default value from the user preferences + */ +interface CloudPulseCustomSelectDefaultValueProps { + /** + * The filter Key of the current rendered custom select component + */ + filterKey: string; + /** + * The callback for the selection changes happening in the custom select component + */ + handleSelectionChange: (filterKey: string, value: FilterValueType) => void; + + /** + * Indicates whether we need multiselect for the component or not + */ + isMultiSelect: boolean; + + /** + * The current listed options in the custom select component + */ + options: CloudPulseServiceTypeFiltersOptions[]; + + /** + * Indicates whether we need to save preferences or not + */ + savePreferences: boolean; +} + +/** + * The interface of publishing the selection change and updating the user preferences accordingly + */ +interface CloudPulseCustomSelectionChangeProps { + /** + * The list of filters needs to be cleared on selections + */ + clearSelections: string[]; + /** + * The current filter key of the rendered custom select component + */ + filterKey: string; + /** + * The callback for the selection changes happening in the custom select component + */ + handleSelectionChange: (filterKey: string, value: FilterValueType) => void; + + /** + * The maximum number of selections that needs to be allowed + */ + maxSelections?: number; + + /** + * The listed options in the custom select component + */ + value: + | CloudPulseServiceTypeFiltersOptions + | CloudPulseServiceTypeFiltersOptions[] + | null; +} + +/** + * This function returns the default selections based on the user preference and options listed + * @param defaultSelectionProps - The props needed for getting the default selections + */ +export const getInitialDefaultSelections = ( + defaultSelectionProps: CloudPulseCustomSelectDefaultValueProps +): + | CloudPulseServiceTypeFiltersOptions + | CloudPulseServiceTypeFiltersOptions[] + | undefined => { + const { + filterKey, + handleSelectionChange, + isMultiSelect, + options, + savePreferences, + } = defaultSelectionProps; + + const defaultValue = savePreferences + ? getUserPreferenceObject()[filterKey] + : undefined; + if (!options || options.length === 0) { + return isMultiSelect ? [] : undefined; + } + + // Handle the case when there is no default value and preferences are not saved + if (!defaultValue && !savePreferences) { + const initialSelection = isMultiSelect ? [options[0]] : options[0]; + handleSelectionChange( + filterKey, + isMultiSelect ? [options[0].id] : options[0].id + ); + return initialSelection; + } + + const selectedValues = options.filter(({ id }) => + (Array.isArray(defaultValue) ? defaultValue : [defaultValue]).includes( + String(id) + ) + ); + + handleSelectionChange( + filterKey, + selectedValues && selectedValues.length > 0 + ? isMultiSelect + ? selectedValues.map(({ id }) => id) + : selectedValues[0].id + : undefined // if this is multiselect, return list of ids, otherwise return single id + ); + return selectedValues?.length + ? isMultiSelect + ? selectedValues + : selectedValues[0] + : undefined; +}; + +/** + * This functions calls the selection change callback and updates the latest selected filter in the preferences + * @param selectionChangeProps - The props needed for selecting the new filter and updating the global preferences + */ + +export const handleCustomSelectionChange = ( + selectionChangeProps: CloudPulseCustomSelectionChangeProps +): + | CloudPulseServiceTypeFiltersOptions + | CloudPulseServiceTypeFiltersOptions[] + | null => { + const { + clearSelections, + filterKey, + handleSelectionChange, + maxSelections, + } = selectionChangeProps; + + let { value } = selectionChangeProps; + + if (Array.isArray(value) && maxSelections && value.length > maxSelections) { + value = value.slice(0, maxSelections); + } + + const result = value + ? Array.isArray(value) + ? value.map(({ id }) => String(id)) // if array publish list of ids, else only id + : String(value.id) + : undefined; + + // publish the selection change + handleSelectionChange(filterKey, result); + + // update the preferences + updateGlobalFilterPreference({ + [filterKey]: result, + }); + + // update the clear selections in the preference + if (clearSelections) { + clearSelections.forEach((selection) => + updateGlobalFilterPreference({ [selection]: undefined }) + ); + } + + return value; +}; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index 43a29430142..a0e1cd76801 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -16,6 +16,7 @@ import { RESOURCE_ID, } from '../Utils/constants'; import { + getCustomSelectProperties, getRegionProperties, getResourcesProperties, } from '../Utils/FilterBuilder'; @@ -111,6 +112,13 @@ export const CloudPulseDashboardFilterBuilder = React.memo( [emitFilterChangeByFilterKey] ); + const handleCustomSelectChange = React.useCallback( + (filterKey: string, value: FilterValueType) => { + emitFilterChangeByFilterKey(filterKey, value); + }, + [emitFilterChangeByFilterKey] + ); + const getProps = React.useCallback( (config: CloudPulseServiceTypeFilters) => { if (config.configuration.filterKey === REGION) { @@ -129,13 +137,22 @@ export const CloudPulseDashboardFilterBuilder = React.memo( handleResourceChange ); } else { - return {}; + return getCustomSelectProperties( + { + config, + dashboard, + dependentFilters: dependentFilterReference.current, + isServiceAnalyticsIntegration, + }, + handleCustomSelectChange + ); } }, [ dashboard, handleRegionChange, handleResourceChange, + handleCustomSelectChange, isServiceAnalyticsIntegration, ] ); @@ -168,7 +185,10 @@ export const CloudPulseDashboardFilterBuilder = React.memo( .map((filter, index) => ( {RenderComponent({ - componentKey: filter.configuration.filterKey, + componentKey: + filter.configuration.type !== undefined + ? 'customSelect' + : filter.configuration.filterKey, componentProps: { ...getProps(filter) }, key: index + filter.configuration.filterKey, })} diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 8a1a0572734..4c7a494b66a 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -16,6 +16,7 @@ import { contactFactory, credentialFactory, creditPaymentResponseFactory, + dashboardFactory, databaseBackupFactory, databaseEngineFactory, databaseFactory, @@ -2270,56 +2271,14 @@ export const handlers = [ http.get('*/v4/monitor/services/linode/dashboards', () => { const response = { data: [ - { - created: '2024-04-29T17:09:29', - id: 1, - label: 'Linode Service I/O Statistics', + dashboardFactory.build({ + label: 'Linode Dashboard', service_type: 'linode', - type: 'standard', - updated: null, - widgets: [ - { - aggregate_function: 'avg', - chart_type: 'area', - color: 'blue', - label: 'CPU utilization', - metric: 'system_cpu_utilization_percent', - size: 12, - unit: '%', - y_label: 'system_cpu_utilization_ratio', - }, - { - aggregate_function: 'avg', - chart_type: 'area', - color: 'red', - label: 'Memory Usage', - metric: 'system_memory_usage_by_resource', - size: 12, - unit: 'Bytes', - y_label: 'system_memory_usage_bytes', - }, - { - aggregate_function: 'avg', - chart_type: 'area', - color: 'green', - label: 'Network Traffic', - metric: 'system_network_io_by_resource', - size: 6, - unit: 'Bytes', - y_label: 'system_network_io_bytes_total', - }, - { - aggregate_function: 'avg', - chart_type: 'area', - color: 'yellow', - label: 'Disk I/O', - metric: 'system_disk_OPS_total', - size: 6, - unit: 'OPS', - y_label: 'system_disk_operations_total', - }, - ], - }, + }), + dashboardFactory.build({ + label: 'DBaaS Dashboard', + service_type: 'dbaas', + }), ], }; diff --git a/packages/manager/src/queries/cloudpulse/customfilters.ts b/packages/manager/src/queries/cloudpulse/customfilters.ts index ad60921b5c6..72ea43af0c4 100644 --- a/packages/manager/src/queries/cloudpulse/customfilters.ts +++ b/packages/manager/src/queries/cloudpulse/customfilters.ts @@ -15,7 +15,7 @@ interface CustomFilterQueryProps { /** * The Built in API-V4 query factory functions like databaseQueries.types, databaseQueries.engines etc., makes use of existing query key and optimises cache */ - apiV4QueryKey: QueryFunctionAndKey; + apiV4QueryKey?: QueryFunctionAndKey; /** * This indicates whether or not to enable the query */ @@ -53,8 +53,8 @@ export const useGetCustomFiltersQuery = ( CloudPulseServiceTypeFiltersOptions[] >({ // receive filters and return only id and label - enabled, - ...apiV4QueryKey, + enabled: enabled && apiV4QueryKey !== undefined, + ...(apiV4QueryKey ?? {}), select: ( filters: QueryFunctionType ): CloudPulseServiceTypeFiltersOptions[] => {