From 4d61cd62589a23ee0511c2a9c6c284918e28a169 Mon Sep 17 00:00:00 2001 From: Ankita Date: Thu, 21 Aug 2025 11:54:53 +0530 Subject: [PATCH 01/73] [DI-26731] - Alerts contextual view enhancement (#12730) * [DI-26371] - Update handler, reset state, update query * [DI-26371] - Update useffect * [DI-26371] - update mutation hook * [DI-26371] - Fix mutation query hook * [DI-26371] - Fix mutation query hook * [DI-26731] - Add comments * [DI-26731] - Add changeset * [DI-26731] - Fix saved state issue --- ...r-12730-upcoming-features-1755666320168.md | 5 +++ .../AlertInformationActionTable.tsx | 41 ++++++++++++++----- .../ContextualView/AlertReusableComponent.tsx | 6 ++- .../src/features/CloudPulse/Utils/utils.ts | 8 +++- .../manager/src/queries/cloudpulse/alerts.ts | 36 +++++++++++++++- 5 files changed, 82 insertions(+), 14 deletions(-) create mode 100644 packages/manager/.changeset/pr-12730-upcoming-features-1755666320168.md diff --git a/packages/manager/.changeset/pr-12730-upcoming-features-1755666320168.md b/packages/manager/.changeset/pr-12730-upcoming-features-1755666320168.md new file mode 100644 index 00000000000..5e89783bec4 --- /dev/null +++ b/packages/manager/.changeset/pr-12730-upcoming-features-1755666320168.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse - Alerts: Update handler in `AlertReusableComponenr.tsx`, reset state in `AlertInformationActionTable.tsx`, update query in `alerts.ts` ([#12730](https://github.com/linode/manager/pull/12730)) diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx index 4e6781952af..1cd473c745b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx @@ -16,6 +16,7 @@ import { ALERTS_BETA_PROMPT } from 'src/features/Linodes/constants'; import { useServiceAlertsMutation } from 'src/queries/cloudpulse/alerts'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { compareArrays } from '../../Utils/FilterBuilder'; import { useContextualAlertsState } from '../../Utils/utils'; import { AlertConfirmationDialog } from '../AlertsLanding/AlertConfirmationDialog'; import { ALERT_SCOPE_TOOLTIP_CONTEXTUAL } from '../constants'; @@ -57,10 +58,13 @@ export interface AlertInformationActionTableProps { /** * Called when an alert is toggled on or off. - * Only use in create flow. * @param payload enabled alerts ids + * @param hasUnsavedChanges boolean to check if there are unsaved changes */ - onToggleAlert?: (payload: CloudPulseAlertsPayload) => void; + onToggleAlert?: ( + payload: CloudPulseAlertsPayload, + hasUnsavedChanges?: boolean + ) => void; /** * Column name by which columns will be ordered by default @@ -104,9 +108,13 @@ export interface AlertRowPropsOptions { /** * Callback function to handle alert toggle - * Only use in create flow. + * @param payload enabled alerts ids + * @param hasUnsavedChanges boolean to check if there are unsaved changes */ - onToggleAlert?: (payload: CloudPulseAlertsPayload) => void; + onToggleAlert?: ( + payload: CloudPulseAlertsPayload, + hasUnsavedChanges?: boolean + ) => void; } export const AlertInformationActionTable = ( @@ -135,8 +143,13 @@ export const AlertInformationActionTable = ( const isEditMode = !!entityId; const isCreateMode = !isEditMode; - const { enabledAlerts, setEnabledAlerts, hasUnsavedChanges } = - useContextualAlertsState(alerts, entityId); + const { + enabledAlerts, + setEnabledAlerts, + hasUnsavedChanges, + initialState, + resetToInitialState, + } = useContextualAlertsState(alerts, entityId); const { mutateAsync: updateAlerts } = useServiceAlertsMutation( serviceType, @@ -145,7 +158,7 @@ export const AlertInformationActionTable = ( // To send initial state of alerts through toggle handler function React.useEffect(() => { - if (onToggleAlert) { + if (!isEditMode && onToggleAlert) { onToggleAlert(enabledAlerts); } }, []); @@ -165,6 +178,8 @@ export const AlertInformationActionTable = ( enqueueSnackbar('Your settings for alerts have been saved.', { variant: 'success', }); + // Reset the state to sync with the updated alerts from API + resetToInitialState(); }) .catch(() => { enqueueSnackbar('Alerts changes were not saved, please try again.', { @@ -176,7 +191,7 @@ export const AlertInformationActionTable = ( setIsDialogOpen(false); }); }, - [updateAlerts, enqueueSnackbar] + [updateAlerts, enqueueSnackbar, resetToInitialState] ); const handleToggleAlert = React.useCallback( @@ -199,15 +214,19 @@ export const AlertInformationActionTable = ( alertIds.push(alert.id); } - // Only call onToggleAlert in create flow + const hasNewUnsavedChanges = + !compareArrays(newPayload.system ?? [], initialState.system ?? []) || + !compareArrays(newPayload.user ?? [], initialState.user ?? []); + + // Call onToggleAlert in both create and edit flow if (onToggleAlert) { - onToggleAlert(newPayload); + onToggleAlert(newPayload, hasNewUnsavedChanges); } return newPayload; }); }, - [onToggleAlert, setEnabledAlerts] + [initialState, onToggleAlert, setEnabledAlerts] ); const handleCustomPageChange = React.useCallback( diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx index e49121f398b..928456ad510 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx @@ -44,8 +44,12 @@ interface AlertReusableComponentProps { /** * Called when an alert is toggled on or off. * @param payload enabled alerts ids + * @param hasUnsavedChanges boolean to check if there are unsaved changes */ - onToggleAlert?: (payload: CloudPulseAlertsPayload) => void; + onToggleAlert?: ( + payload: CloudPulseAlertsPayload, + hasUnsavedChanges?: boolean + ) => void; /** * Region ID for the selected entity diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 6c56b0e9be6..e19a3cbd0e8 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -84,7 +84,7 @@ export const useIsACLPEnabled = (): { /** * @param alerts List of alerts to be displayed * @param entityId Id of the selected entity - * @returns enabledAlerts, setEnabledAlerts, hasUnsavedChanges, initialState + * @returns enabledAlerts, setEnabledAlerts, hasUnsavedChanges, initialState, resetToInitialState */ export const useContextualAlertsState = ( alerts: Alert[], @@ -123,6 +123,11 @@ export const useContextualAlertsState = ( const [enabledAlerts, setEnabledAlerts] = React.useState(initialState); + // Reset function to sync with latest initial state + const resetToInitialState = React.useCallback(() => { + setEnabledAlerts(initialState); + }, [initialState]); + // Check if the enabled alerts have changed from the initial state const hasUnsavedChanges = React.useMemo(() => { return ( @@ -136,6 +141,7 @@ export const useContextualAlertsState = ( setEnabledAlerts, hasUnsavedChanges, initialState, + resetToInitialState, }; }; diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts index 1a1bdac1fc2..8b78ef91fa8 100644 --- a/packages/manager/src/queries/cloudpulse/alerts.ts +++ b/packages/manager/src/queries/cloudpulse/alerts.ts @@ -252,10 +252,44 @@ export const useServiceAlertsMutation = ( mutationFn: (payload: CloudPulseAlertsPayload) => { return updateServiceAlerts(serviceType, entityId, payload); }, - onSuccess() { + onSuccess(_, payload) { + const allAlerts = queryClient.getQueryData( + queryFactory.alerts._ctx.all().queryKey + ); + + // Get alerts previously enabled for this entity + const oldEnabledAlertIds = + allAlerts + ?.filter((alert) => alert.entity_ids.includes(entityId)) + .map((alert) => alert.id) || []; + + // Combine enabled user and system alert IDs from payload + const newEnabledAlertIds = [ + ...(payload.user ?? []), + ...(payload.system ?? []), + ]; + + // Get unique list of all enabled alert IDs for cache invalidation + const alertIdsToInvalidate = Array.from( + new Set([...oldEnabledAlertIds, ...newEnabledAlertIds]) + ); + queryClient.invalidateQueries({ queryKey: queryFactory.resources(serviceType).queryKey, }); + + queryClient.invalidateQueries({ + queryKey: queryFactory.alerts._ctx.all().queryKey, + }); + + alertIdsToInvalidate.forEach((alertId) => { + queryClient.invalidateQueries({ + queryKey: queryFactory.alerts._ctx.alertByServiceTypeAndId( + serviceType, + String(alertId) + ).queryKey, + }); + }); }, }); }; From 1ad25dd10132bf386b06e6725d2d030c895a4e12 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Thu, 21 Aug 2025 14:56:36 -0400 Subject: [PATCH 02/73] fix: [M3-10266] - Add automatic redirect for empty paginated Access Keys pages (#12598) * fix: [M3-10266] - OBJ Pagination Redirects * Added changeset: Add automatic redirect for empty paginated Access Keys pages * Add todo comment --------- Co-authored-by: Jaalah Ramos --- .../pr-12598-fixed-1753820650689.md | 5 +++++ .../AccessKeyLanding/AccessKeyLanding.tsx | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 packages/manager/.changeset/pr-12598-fixed-1753820650689.md diff --git a/packages/manager/.changeset/pr-12598-fixed-1753820650689.md b/packages/manager/.changeset/pr-12598-fixed-1753820650689.md new file mode 100644 index 00000000000..e5a659a9a84 --- /dev/null +++ b/packages/manager/.changeset/pr-12598-fixed-1753820650689.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Add automatic redirect for empty paginated Access Keys pages ([#12598](https://github.com/linode/manager/pull/12598)) diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx index 44bbd5898f5..e624b21443f 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx @@ -5,6 +5,7 @@ import { } from '@linode/api-v4/lib/object-storage'; import { useAccountSettings } from '@linode/queries'; import { useErrors, useOpenClose } from '@linode/utilities'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -53,6 +54,7 @@ export const AccessKeyLanding = (props: Props) => { openAccessDrawer, } = props; + const navigate = useNavigate(); const pagination = usePaginationV2({ currentRoute: '/object-storage/access-keys', initialPage: 1, @@ -88,6 +90,25 @@ export const AccessKeyLanding = (props: Props) => { const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); + // Redirect to base access keys route if current page has no data + // TODO: Remove this implementation and replace `usePagination` with `usePaginate` hook. See [M3-10442] + React.useEffect(() => { + const currentPage = Number(pagination.page); + + // Only redirect if we have data, no results, and we're not on page 1 + if ( + !isLoading && + data && + (data.results === 0 || data.data.length === 0) && + currentPage > 1 + ) { + navigate({ + to: '/object-storage/access-keys', + search: { page: undefined, pageSize: undefined }, + }); + } + }, [data, isLoading, pagination.page, navigate]); + const handleCreateKey = ( values: CreateObjectStorageKeyPayload, { From a819d7395388a8aed6fb4c0be6035868c2fe0fa3 Mon Sep 17 00:00:00 2001 From: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:03:54 +0530 Subject: [PATCH 03/73] upcoming: [DI-26793] - Legend row label shown as per group by order (#12742) * upcoming: [DI-26793] - Update logic to show legend rows based on the group by order * Added changeset * Fixed typecheck failures --- ...r-12742-upcoming-features-1755755136546.md | 5 ++ .../dbaas-widgets-verification.spec.ts | 1 + .../linode-widget-verification.spec.ts | 1 + .../nodebalancer-widget-verification.spec.ts | 1 + .../Utils/CloudPulseWidgetUtils.test.ts | 3 + .../CloudPulse/Utils/CloudPulseWidgetUtils.ts | 88 ++++++++++++++----- .../CloudPulse/Widget/CloudPulseWidget.tsx | 1 + 7 files changed, 79 insertions(+), 21 deletions(-) create mode 100644 packages/manager/.changeset/pr-12742-upcoming-features-1755755136546.md diff --git a/packages/manager/.changeset/pr-12742-upcoming-features-1755755136546.md b/packages/manager/.changeset/pr-12742-upcoming-features-1755755136546.md new file mode 100644 index 00000000000..c53c59da3a8 --- /dev/null +++ b/packages/manager/.changeset/pr-12742-upcoming-features-1755755136546.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +ACLP: Order of each legend row label value is based on group by sequence ([#12742](https://github.com/linode/manager/pull/12742)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts index 8de25cf4d55..3522398093c 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts @@ -145,6 +145,7 @@ const getWidgetLegendRowValuesFromResponse = ( status: 'success', unit, serviceType, + groupBy: ['entity_id'], }); // Destructure metrics data from the first legend row diff --git a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts index 9c30ccef63e..becda1f1629 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts @@ -130,6 +130,7 @@ const getWidgetLegendRowValuesFromResponse = ( status: 'success', unit, serviceType, + groupBy: ['entity_id'], }); // Destructure metrics data from the first legend row diff --git a/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts index 77d1c026048..e56b912b86f 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts @@ -123,6 +123,7 @@ const getWidgetLegendRowValuesFromResponse = ( status: 'success', unit, serviceType, + groupBy: ['entity_id'], }); // Destructure metrics data from the first legend row diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts index aa0428494d9..aba673b33a6 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts @@ -70,6 +70,7 @@ describe('getLabelName method', () => { resources: [{ id: '123', label: 'linode-1' }], serviceType: 'linode', unit: '%', + groupBy: ['entity_id'], }; it('returns resource label when all data is valid', () => { @@ -121,6 +122,7 @@ it('test generateGraphData with metrics data', () => { status: 'success', unit: '%', serviceType: 'linode', + groupBy: ['entity_id'], }); expect(result.areas[0].dataKey).toBe('linode-1'); @@ -148,6 +150,7 @@ describe('getDimensionName method', () => { serviceType: 'linode', metric: { entity_id: '123' }, resources: [{ id: '123', label: 'linode-1' }], + groupBy: ['entity_id'], }; it('returns resource label when all data is valid', () => { diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index b90ef39de90..125da415326 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -29,6 +29,11 @@ import type { AreaProps } from 'src/components/AreaChart/AreaChart'; import type { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay'; export interface LabelNameOptionsProps { + /** + * array of group by fields + */ + groupBy: string[]; + /** * Boolean to check if metric name should be hidden */ @@ -61,6 +66,11 @@ export interface LabelNameOptionsProps { } interface GraphDataOptionsProps { + /** + * array of group by fields + */ + groupBy: string[]; + /** * label for the graph title */ @@ -120,6 +130,10 @@ interface MetricRequestProps { } export interface DimensionNameProperties { + /** + * array of group by fields + */ + groupBy: string[]; /** * Boolean to check if metric name should be hidden */ @@ -168,7 +182,8 @@ interface GraphData { * @returns parameters which will be necessary to populate graph & legends */ export const generateGraphData = (props: GraphDataOptionsProps): GraphData => { - const { label, metricsList, resources, serviceType, status, unit } = props; + const { label, metricsList, resources, serviceType, status, unit, groupBy } = + props; const legendRowsData: MetricsDisplayRow[] = []; const dimension: { [timestamp: number]: { [label: string]: number } } = {}; const areas: AreaProps[] = []; @@ -205,6 +220,7 @@ export const generateGraphData = (props: GraphDataOptionsProps): GraphData => { unit, hideMetricName, serviceType, + groupBy, }; const labelName = getLabelName(labelOptions); const data = seriesDataFormatter(transformedData.values, start, end); @@ -331,6 +347,7 @@ export const getLabelName = (props: LabelNameOptionsProps): string => { unit, hideMetricName = false, serviceType, + groupBy, } = props; // aggregated metric, where metric keys will be 0 if (!Object.keys(metric).length) { @@ -338,7 +355,13 @@ export const getLabelName = (props: LabelNameOptionsProps): string => { return `${label} (${unit})`; } - return getDimensionName({ metric, resources, hideMetricName, serviceType }); + return getDimensionName({ + metric, + resources, + hideMetricName, + serviceType, + groupBy, + }); }; /** @@ -347,30 +370,53 @@ export const getLabelName = (props: LabelNameOptionsProps): string => { */ // ... existing code ... export const getDimensionName = (props: DimensionNameProperties): string => { - const { metric, resources, hideMetricName = false, serviceType } = props; - return Object.entries(metric) - .map(([key, value]) => { - if (key === 'entity_id') { - return mapResourceIdToName(value, resources); + const { + metric, + resources, + hideMetricName = false, + serviceType, + groupBy, + } = props; + const labels: string[] = new Array(groupBy.length).fill(''); + Object.entries(metric).forEach(([key, value]) => { + if (key === 'entity_id') { + const resourceName = mapResourceIdToName(value, resources); + const index = groupBy.indexOf(key); + if (index !== -1) { + labels[index] = resourceName; + } else { + labels.push(resourceName); } + return; + } - if (key === 'linode_id') { - return ( - resources.find((resource) => resource.entities?.[value] !== undefined) - ?.entities?.[value] ?? value - ); + if (key === 'linode_id') { + const linodeLabel = + resources.find((resource) => resource.entities?.[value] !== undefined) + ?.entities?.[value] ?? value; + const index = groupBy.indexOf('linode_id'); + if (index !== -1) { + labels[index] = linodeLabel; + } else { + labels.push(linodeLabel); } + return; + } - if (key === 'metric_name' && hideMetricName) { - return ''; - } + if (key === 'metric_name' && hideMetricName) { + return; + } - return ( - DIMENSION_TRANSFORM_CONFIG[serviceType]?.[key]?.(value) ?? value ?? '' - ); - }) - .filter(Boolean) - .join(' | '); + const dimensionValue = + DIMENSION_TRANSFORM_CONFIG[serviceType]?.[key]?.(value) ?? value ?? ''; + const index = groupBy.indexOf(key); + if (index !== -1) { + labels[index] = dimensionValue; + } else { + labels.push(dimensionValue); + } + }); + return labels.filter(Boolean).join(' | '); }; /** diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index 660b488713e..8294202ac6c 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -277,6 +277,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { status, unit, serviceType, + groupBy: widgetProp.group_by, }); data = generatedData.dimensions; From 2b03ff4944d222e42fb28872980c66dc6c3f9748 Mon Sep 17 00:00:00 2001 From: mduda-akamai Date: Fri, 22 Aug 2025 10:01:06 +0200 Subject: [PATCH 04/73] upcoming: [DPS-34039] Add actions in Streams list (#12645) --- ...r-12645-upcoming-features-1754489643667.md | 5 + packages/api-v4/src/account/types.ts | 2 + packages/api-v4/src/datastream/streams.ts | 28 ++- packages/api-v4/src/datastream/types.ts | 14 +- ...r-12645-upcoming-features-1754489744402.md | 5 + .../features/DataStream/DataStreamLanding.tsx | 7 +- .../LabelValue.tsx} | 33 +-- .../src/features/DataStream/Shared/types.ts | 22 +- .../Streams/StreamActionMenu.test.tsx | 52 +++++ .../DataStream/Streams/StreamActionMenu.tsx | 46 ++++ .../StreamCreateSubmitBar.test.tsx | 38 ---- ...ationLinodeObjectStorageDetailsSummary.tsx | 26 --- .../Streams/StreamCreate/StreamCreate.tsx | 120 ----------- .../StreamCreateGeneralInfo.test.tsx | 54 ----- .../StreamFormCheckoutBar.styles.ts} | 0 .../StreamFormCheckoutBar.test.tsx} | 12 +- .../CheckoutBar/StreamFormCheckoutBar.tsx} | 8 +- .../CheckoutBar/StreamFormSubmitBar.test.tsx | 60 ++++++ .../CheckoutBar/StreamFormSubmitBar.tsx} | 25 ++- ...LinodeObjectStorageDetailsSummary.test.tsx | 29 ++- ...ationLinodeObjectStorageDetailsSummary.tsx | 34 +++ .../Delivery/StreamFormDelivery.test.tsx} | 10 +- .../Delivery/StreamFormDelivery.tsx} | 23 +- .../Streams/StreamForm/StreamCreate.tsx | 86 ++++++++ .../Streams/StreamForm/StreamEdit.test.tsx | 84 ++++++++ .../Streams/StreamForm/StreamEdit.tsx | 131 ++++++++++++ .../Streams/StreamForm/StreamForm.tsx | 52 +++++ .../StreamFormClusters.test.tsx} | 14 +- .../StreamFormClusters.tsx} | 33 ++- .../StreamFormClustersData.ts} | 2 +- .../StreamForm/StreamFormGeneralInfo.test.tsx | 101 +++++++++ .../StreamFormGeneralInfo.tsx} | 35 +-- .../streamCreateLazyRoute.ts | 2 +- .../Streams/StreamForm/streamEditLazyRoute.ts | 9 + .../{StreamCreate => StreamForm}/types.ts | 6 +- .../Streams/StreamTableRow.test.tsx | 54 +++++ .../DataStream/Streams/StreamTableRow.tsx | 25 ++- .../Streams/StreamsLanding.test.tsx | 202 ++++++++++++++---- .../DataStream/Streams/StreamsLanding.tsx | 97 ++++++++- .../features/DataStream/dataStreamUtils.ts | 40 +++- .../features/Events/factories/datastream.tsx | 16 ++ .../src/mocks/presets/crud/datastream.ts | 11 +- .../mocks/presets/crud/handlers/datastream.ts | 78 +++++++ .../manager/src/routes/datastream/index.ts | 21 +- ...r-12645-upcoming-features-1754489801598.md | 5 + .../queries/src/datastreams/datastream.ts | 43 ++++ packages/validation/src/datastream.schema.ts | 35 +-- 47 files changed, 1418 insertions(+), 417 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12645-upcoming-features-1754489643667.md create mode 100644 packages/manager/.changeset/pr-12645-upcoming-features-1754489744402.md rename packages/manager/src/features/DataStream/{Streams/StreamCreate/Delivery/DestinationDetail.tsx => Shared/LabelValue.tsx} (52%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx create mode 100644 packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx delete mode 100644 packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx delete mode 100644 packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx delete mode 100644 packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx delete mode 100644 packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.test.tsx rename packages/manager/src/features/DataStream/Streams/{StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles.ts => StreamForm/CheckoutBar/StreamFormCheckoutBar.styles.ts} (100%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate/CheckoutBar/StreamCreateCheckoutBar.test.tsx => StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx} (85%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate/CheckoutBar/StreamCreateCheckoutBar.tsx => StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx} (86%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx rename packages/manager/src/features/DataStream/Streams/{StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx => StreamForm/CheckoutBar/StreamFormSubmitBar.tsx} (62%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate => StreamForm}/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx (78%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx rename packages/manager/src/features/DataStream/Streams/{StreamCreate/Delivery/StreamCreateDelivery.test.tsx => StreamForm/Delivery/StreamFormDelivery.test.tsx} (96%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate/Delivery/StreamCreateDelivery.tsx => StreamForm/Delivery/StreamFormDelivery.tsx} (86%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx rename packages/manager/src/features/DataStream/Streams/{StreamCreate/StreamCreateClusters.test.tsx => StreamForm/StreamFormClusters.test.tsx} (96%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate/StreamCreateClusters.tsx => StreamForm/StreamFormClusters.tsx} (90%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate/StreamCreateClustersData.ts => StreamForm/StreamFormClustersData.ts} (92%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx rename packages/manager/src/features/DataStream/Streams/{StreamCreate/StreamCreateGeneralInfo.tsx => StreamForm/StreamFormGeneralInfo.tsx} (65%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate => StreamForm}/streamCreateLazyRoute.ts (90%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts rename packages/manager/src/features/DataStream/Streams/{StreamCreate => StreamForm}/types.ts (71%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx create mode 100644 packages/queries/.changeset/pr-12645-upcoming-features-1754489801598.md diff --git a/packages/api-v4/.changeset/pr-12645-upcoming-features-1754489643667.md b/packages/api-v4/.changeset/pr-12645-upcoming-features-1754489643667.md new file mode 100644 index 00000000000..5d8062cea97 --- /dev/null +++ b/packages/api-v4/.changeset/pr-12645-upcoming-features-1754489643667.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +DELETE, PUT API endpoints for Streams ([#12645](https://github.com/linode/manager/pull/12645)) diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 36def13c55b..079952d2d79 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -470,6 +470,8 @@ export const EventActionKeys = [ 'stackscript_revise', 'stackscript_update', 'stream_create', + 'stream_delete', + 'stream_update', 'subnet_create', 'subnet_delete', 'subnet_update', diff --git a/packages/api-v4/src/datastream/streams.ts b/packages/api-v4/src/datastream/streams.ts index 2e9ef2229cd..621bafa7247 100644 --- a/packages/api-v4/src/datastream/streams.ts +++ b/packages/api-v4/src/datastream/streams.ts @@ -1,4 +1,4 @@ -import { createStreamSchema } from '@linode/validation'; +import { createStreamSchema, updateStreamSchema } from '@linode/validation'; import { BETA_API_ROOT } from '../constants'; import Request, { @@ -10,7 +10,7 @@ import Request, { } from '../request'; import type { Filter, ResourcePage as Page, Params } from '../types'; -import type { CreateStreamPayload, Stream } from './types'; +import type { CreateStreamPayload, Stream, UpdateStreamPayload } from './types'; /** * Returns all the information about a specified Stream. @@ -47,3 +47,27 @@ export const createStream = (data: CreateStreamPayload) => setURL(`${BETA_API_ROOT}/monitor/streams`), setMethod('POST'), ); + +/** + * Updates a Stream. + * + * @param streamId { number } The ID of the Stream. + * @param data { object } Options for type, status, etc. + */ +export const updateStream = (streamId: number, data: UpdateStreamPayload) => + Request( + setData(data, updateStreamSchema), + setURL(`${BETA_API_ROOT}/monitor/streams/${encodeURIComponent(streamId)}`), + setMethod('PUT'), + ); + +/** + * Deletes a Stream. + * + * @param streamId { number } The ID of the Stream. + */ +export const deleteStream = (streamId: number) => + Request<{}>( + setURL(`${BETA_API_ROOT}/monitor/streams/${encodeURIComponent(streamId)}`), + setMethod('DELETE'), + ); diff --git a/packages/api-v4/src/datastream/types.ts b/packages/api-v4/src/datastream/types.ts index 76f50d5e919..523509524e9 100644 --- a/packages/api-v4/src/datastream/types.ts +++ b/packages/api-v4/src/datastream/types.ts @@ -103,12 +103,24 @@ interface CustomHeader { export interface CreateStreamPayload { destinations: number[]; - details?: StreamDetails; + details: StreamDetails; label: string; status?: StreamStatus; type: StreamType; } +export interface UpdateStreamPayload { + destinations: number[]; + details: StreamDetails; + label: string; + status: StreamStatus; + type: StreamType; +} + +export interface UpdateStreamPayloadWithId extends UpdateStreamPayload { + id: number; +} + export interface CreateDestinationPayload { details: CustomHTTPsDetails | LinodeObjectStorageDetails; label: string; diff --git a/packages/manager/.changeset/pr-12645-upcoming-features-1754489744402.md b/packages/manager/.changeset/pr-12645-upcoming-features-1754489744402.md new file mode 100644 index 00000000000..2347c3716ba --- /dev/null +++ b/packages/manager/.changeset/pr-12645-upcoming-features-1754489744402.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +DataStreams: add actions with handlers in Streams list, add Edit Stream component ([#12645](https://github.com/linode/manager/pull/12645)) diff --git a/packages/manager/src/features/DataStream/DataStreamLanding.tsx b/packages/manager/src/features/DataStream/DataStreamLanding.tsx index 2750bfe465c..5480d990ee6 100644 --- a/packages/manager/src/features/DataStream/DataStreamLanding.tsx +++ b/packages/manager/src/features/DataStream/DataStreamLanding.tsx @@ -1,7 +1,10 @@ import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { LandingHeader } from 'src/components/LandingHeader'; +import { + LandingHeader, + type LandingHeaderProps, +} from 'src/components/LandingHeader'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; @@ -23,7 +26,7 @@ const Streams = React.lazy(() => ); export const DataStreamLanding = React.memo(() => { - const landingHeaderProps = { + const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { pathname: '/datastream', }, diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationDetail.tsx b/packages/manager/src/features/DataStream/Shared/LabelValue.tsx similarity index 52% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationDetail.tsx rename to packages/manager/src/features/DataStream/Shared/LabelValue.tsx index 5e4fb9a05a6..ddb57224006 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationDetail.tsx +++ b/packages/manager/src/features/DataStream/Shared/LabelValue.tsx @@ -2,30 +2,37 @@ import { Box, Typography } from '@linode/ui'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; -type DestinationDetailProps = { +type LabelValueProps = { + compact?: boolean; + 'data-testid'?: string; label: string; value: string; }; -export const DestinationDetail = (props: DestinationDetailProps) => { - const { label, value } = props; +export const LabelValue = (props: LabelValueProps) => { + const { compact = false, label, value, 'data-testid': dataTestId } = props; const theme = useTheme(); return ( - - {label}: - {value} + + + {label}: + + {value} ); }; -const StyledLabel = styled(Typography, { - label: 'StyledLabel', -})(({ theme }) => ({ - font: theme.font.bold, - width: 160, -})); - const StyledValue = styled(Box, { label: 'StyledValue', })(({ theme }) => ({ diff --git a/packages/manager/src/features/DataStream/Shared/types.ts b/packages/manager/src/features/DataStream/Shared/types.ts index 57d8fb68233..e0e4a08a142 100644 --- a/packages/manager/src/features/DataStream/Shared/types.ts +++ b/packages/manager/src/features/DataStream/Shared/types.ts @@ -1,18 +1,15 @@ -import { destinationType, streamStatus } from '@linode/api-v4'; +import { destinationType, streamStatus, streamType } from '@linode/api-v4'; import type { CreateDestinationPayload } from '@linode/api-v4'; -export interface DestinationTypeOption { - label: string; - value: string; -} +export type FormMode = 'create' | 'edit'; export interface LabelValueOption { label: string; value: string; } -export const destinationTypeOptions: DestinationTypeOption[] = [ +export const destinationTypeOptions: LabelValueOption[] = [ { value: destinationType.CustomHttps, label: 'Custom HTTPS', @@ -23,7 +20,18 @@ export const destinationTypeOptions: DestinationTypeOption[] = [ }, ]; -export const streamStatusOptions = [ +export const streamTypeOptions: LabelValueOption[] = [ + { + value: streamType.AuditLogs, + label: 'Audit Logs', + }, + { + value: streamType.LKEAuditLogs, + label: 'Kubernetes Audit Logs', + }, +]; + +export const streamStatusOptions: LabelValueOption[] = [ { value: streamStatus.Active, label: 'Enabled', diff --git a/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx new file mode 100644 index 00000000000..58f84216c9a --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx @@ -0,0 +1,52 @@ +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import * as React from 'react'; + +import { streamFactory } from 'src/factories/datastream'; +import { StreamActionMenu } from 'src/features/DataStream/Streams/StreamActionMenu'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import type { StreamStatus } from '@linode/api-v4'; + +const fakeHandler = vi.fn(); + +describe('Stream action menu', () => { + const renderComponent = (status: StreamStatus) => { + renderWithTheme( + + ); + }; + + describe('when stream is active', () => { + it('should include proper Stream actions', async () => { + renderComponent('active'); + + const actionMenuButton = screen.queryByLabelText(/^Action menu for/)!; + + await userEvent.click(actionMenuButton); + + for (const action of ['Edit', 'Disable', 'Delete']) { + expect(screen.getByText(action)).toBeVisible(); + } + }); + }); + + describe('when stream is inactive', () => { + it('should include proper Stream actions', async () => { + renderComponent('inactive'); + + const actionMenuButton = screen.queryByLabelText(/^Action menu for/)!; + + await userEvent.click(actionMenuButton); + + for (const action of ['Edit', 'Enable', 'Delete']) { + expect(screen.getByText(action)).toBeVisible(); + } + }); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx new file mode 100644 index 00000000000..a5d061c54cf --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx @@ -0,0 +1,46 @@ +import { type Stream, streamStatus } from '@linode/api-v4'; +import * as React from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +export interface Handlers { + onDelete: (stream: Stream) => void; + onDisableOrEnable: (stream: Stream) => void; + onEdit: (stream: Stream) => void; +} + +interface StreamActionMenuProps extends Handlers { + stream: Stream; +} + +export const StreamActionMenu = (props: StreamActionMenuProps) => { + const { stream, onDelete, onDisableOrEnable, onEdit } = props; + + const menuActions = [ + { + onClick: () => { + onEdit(stream); + }, + title: 'Edit', + }, + { + onClick: () => { + onDisableOrEnable(stream); + }, + title: stream.status === streamStatus.Active ? 'Disable' : 'Enable', + }, + { + onClick: () => { + onDelete(stream); + }, + title: 'Delete', + }, + ]; + + return ( + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx deleted file mode 100644 index cc366229d01..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { destinationType } from '@linode/api-v4'; -import { screen } from '@testing-library/react'; -import React from 'react'; -import { describe, expect } from 'vitest'; - -import { StreamCreateSubmitBar } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar'; -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; - -describe('StreamCreateSubmitBar', () => { - const createStream = () => {}; - - const renderComponent = () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - destination: { type: destinationType.LinodeObjectStorage }, - }, - }, - }); - }; - - it('should render checkout bar with enabled checkout button', async () => { - renderComponent(); - const submitButton = screen.getByText('Create Stream'); - - expect(submitButton).toBeEnabled(); - }); - - it('should render Delivery summary with destination type', () => { - renderComponent(); - const deliveryTitle = screen.getByText('Delivery'); - const deliveryType = screen.getByText('Linode Object Storage'); - - expect(deliveryTitle).toBeInTheDocument(); - expect(deliveryType).toBeInTheDocument(); - }); -}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx deleted file mode 100644 index 275a4dfb64c..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useRegionsQuery } from '@linode/queries'; -import React from 'react'; - -import { DestinationDetail } from 'src/features/DataStream/Streams/StreamCreate/Delivery/DestinationDetail'; - -import type { LinodeObjectStorageDetails } from '@linode/api-v4'; - -export const DestinationLinodeObjectStorageDetailsSummary = ( - props: LinodeObjectStorageDetails -) => { - const { bucket_name, host, region, path } = props; - const { data: regions } = useRegionsQuery(); - - const regionValue = regions?.find(({ id }) => id === region)?.label || region; - - return ( - <> - - - - - - - - ); -}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx deleted file mode 100644 index 3e689d3025a..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { yupResolver } from '@hookform/resolvers/yup'; -import { destinationType, streamType } from '@linode/api-v4'; -import { useCreateStreamMutation } from '@linode/queries'; -import { omitProps, Stack } from '@linode/ui'; -import { createStreamAndDestinationFormSchema } from '@linode/validation'; -import Grid from '@mui/material/Grid'; -import { useNavigate } from '@tanstack/react-router'; -import * as React from 'react'; -import { FormProvider, useForm, useWatch } from 'react-hook-form'; - -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { LandingHeader } from 'src/components/LandingHeader'; -import { StreamCreateSubmitBar } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar'; -import { StreamCreateDelivery } from 'src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery'; -import { sendCreateStreamEvent } from 'src/utilities/analytics/customEventAnalytics'; - -import { StreamCreateClusters } from './StreamCreateClusters'; -import { StreamCreateGeneralInfo } from './StreamCreateGeneralInfo'; - -import type { CreateStreamPayload } from '@linode/api-v4'; -import type { LandingHeaderProps } from 'src/components/LandingHeader'; -import type { - CreateStreamAndDestinationForm, - CreateStreamForm, -} from 'src/features/DataStream/Streams/StreamCreate/types'; - -export const StreamCreate = () => { - const { mutateAsync: createStream } = useCreateStreamMutation(); - const navigate = useNavigate(); - - const form = useForm({ - defaultValues: { - stream: { - type: streamType.AuditLogs, - details: {}, - }, - destination: { - type: destinationType.LinodeObjectStorage, - details: { - region: '', - }, - }, - }, - mode: 'onBlur', - resolver: yupResolver(createStreamAndDestinationFormSchema), - }); - - const { control, handleSubmit } = form; - const selectedStreamType = useWatch({ - control, - name: 'stream.type', - }); - - const landingHeaderProps: LandingHeaderProps = { - breadcrumbProps: { - pathname: '/datastream/streams/create', - crumbOverrides: [ - { - label: 'DataStream', - linkTo: '/datastream/streams', - position: 1, - }, - ], - }, - removeCrumbX: 2, - title: 'Create Stream', - }; - - const onSubmit = () => { - const { - stream: { label, type, destinations, details }, - } = form.getValues(); - const payload: CreateStreamForm = { - label, - type, - destinations, - details, - }; - - if (type === streamType.LKEAuditLogs && details) { - if (details.is_auto_add_all_clusters_enabled) { - payload.details = omitProps(details, ['cluster_ids']); - } else { - payload.details = omitProps(details, [ - 'is_auto_add_all_clusters_enabled', - ]); - } - } - - createStream(payload as CreateStreamPayload).then(() => { - sendCreateStreamEvent('Stream Create Page'); - navigate({ to: '/datastream/streams' }); - }); - }; - - return ( - <> - - - -
- - - - - {selectedStreamType === streamType.LKEAuditLogs && ( - - )} - - - - - - - -
-
- - ); -}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.test.tsx deleted file mode 100644 index dacb024ab50..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { streamType } from '@linode/api-v4'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; - -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; - -import { StreamCreateGeneralInfo } from './StreamCreateGeneralInfo'; - -describe('StreamCreateGeneralInfo', () => { - it('should render Name input and allow to type text', async () => { - renderWithThemeAndHookFormContext({ - component: , - }); - - // Type test value inside the input - const nameInput = screen.getByPlaceholderText('Stream name...'); - await userEvent.type(nameInput, 'Test'); - - await waitFor(() => { - expect(nameInput.getAttribute('value')).toEqual('Test'); - }); - }); - - it('should render Stream type input and allow to select different options', async () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - stream: { - type: streamType.AuditLogs, - }, - }, - }, - }); - - const streamTypesAutocomplete = screen.getByRole('combobox'); - - expect(streamTypesAutocomplete).toHaveValue('Audit Logs'); - - // Open the dropdown - await userEvent.click(streamTypesAutocomplete); - - // Select the "Kubernetes Audit Logs" option - const kubernetesAuditLogs = await screen.findByText( - 'Kubernetes Audit Logs' - ); - await userEvent.click(kubernetesAuditLogs); - - await waitFor(() => { - expect(streamTypesAutocomplete).toHaveValue('Kubernetes Audit Logs'); - }); - }); -}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles.ts similarity index 100% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles.ts rename to packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles.ts diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx similarity index 85% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.test.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx index 4665e078caa..7eefd4a16b1 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx @@ -5,20 +5,20 @@ import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { describe, expect } from 'vitest'; -import { StreamCreateCheckoutBar } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar'; -import { StreamCreateGeneralInfo } from 'src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo'; +import { StreamFormCheckoutBar } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar'; +import { StreamFormGeneralInfo } from 'src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo'; import { renderWithTheme, renderWithThemeAndHookFormContext, } from 'src/utilities/testHelpers'; -describe('StreamCreateCheckoutBar', () => { +describe('StreamFormCheckoutBar', () => { const getDeliveryPriceContext = () => screen.getByText(/\/unit/i).textContent; const createStream = () => {}; const renderComponent = () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { destination: { @@ -61,8 +61,8 @@ describe('StreamCreateCheckoutBar', () => { return (
- - + +
); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx similarity index 86% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx index c48bcaa3c3e..cc4dab35826 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx @@ -5,17 +5,17 @@ import { useFormContext, useWatch } from 'react-hook-form'; import { CheckoutBar } from 'src/components/CheckoutBar/CheckoutBar'; import { displayPrice } from 'src/components/DisplayPrice'; import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; -import { StyledHeader } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles'; +import { StyledHeader } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles'; -import type { CreateStreamAndDestinationForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; export interface Props { createStream: () => void; } -export const StreamCreateCheckoutBar = (props: Props) => { +export const StreamFormCheckoutBar = (props: Props) => { const { createStream } = props; - const { control } = useFormContext(); + const { control } = useFormContext(); const destinationType = useWatch({ control, name: 'destination.type' }); const formValues = useWatch({ control, diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx new file mode 100644 index 00000000000..df6554a590b --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx @@ -0,0 +1,60 @@ +import { destinationType, streamType } from '@linode/api-v4'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { StreamFormSubmitBar } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import type { FormMode } from 'src/features/DataStream/Shared/types'; + +describe('StreamFormSubmitBar', () => { + const createStream = () => {}; + + const renderComponent = (mode: FormMode) => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + type: streamType.AuditLogs, + details: {}, + }, + destination: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + }, + }, + }); + }; + + describe('when in create mode', () => { + it('should render checkout bar with enabled Create Stream button', async () => { + renderComponent('create'); + const submitButton = screen.getByText('Create Stream'); + + expect(submitButton).toBeEnabled(); + }); + }); + + describe('when in edit mode', () => { + it('should render checkout bar with enabled Edit Stream button', async () => { + renderComponent('edit'); + const submitButton = screen.getByText('Edit Stream'); + + expect(submitButton).toBeEnabled(); + }); + }); + + it('should render Delivery summary with destination type', () => { + renderComponent('create'); + const deliveryTitle = screen.getByText('Delivery'); + const deliveryType = screen.getByText('Linode Object Storage'); + + expect(deliveryTitle).toBeInTheDocument(); + expect(deliveryType).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.tsx similarity index 62% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.tsx index 9c009208eb8..74d5eb83e2f 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.tsx @@ -2,19 +2,24 @@ import { Box, Button, Divider, Paper, Stack, Typography } from '@linode/ui'; import * as React from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; -import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; -import { StyledHeader } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles'; +import { + getDestinationTypeOption, + isFormInEditMode, +} from 'src/features/DataStream/dataStreamUtils'; +import { StyledHeader } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles'; -import type { CreateStreamAndDestinationForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { FormMode } from 'src/features/DataStream/Shared/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; -type StreamCreateSidebarProps = { - createStream: () => void; +type StreamFormSubmitBarProps = { + mode: FormMode; + onSubmit: () => void; }; -export const StreamCreateSubmitBar = (props: StreamCreateSidebarProps) => { - const { createStream } = props; +export const StreamFormSubmitBar = (props: StreamFormSubmitBarProps) => { + const { onSubmit, mode } = props; - const { control } = useFormContext(); + const { control } = useFormContext(); const destinationType = useWatch({ control, name: 'destination.type' }); return ( @@ -32,7 +37,7 @@ export const StreamCreateSubmitBar = (props: StreamCreateSidebarProps) => { diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx similarity index 78% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx index cff5103b0ed..b66fe12a369 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx @@ -1,5 +1,5 @@ import { regionFactory } from '@linode/utilities'; -import { screen, waitFor, within } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import React from 'react'; import { makeResourcePage } from 'src/mocks/serverHandlers'; @@ -33,26 +33,23 @@ describe('DestinationLinodeObjectStorageDetailsSummary', () => { ); + // Host: expect(screen.getByText('test host')).toBeVisible(); - + // Bucket: expect(screen.getByText('test bucket')).toBeVisible(); - + // Log Path: expect(screen.getByText('test/path')).toBeVisible(); - + // Region: await waitFor(() => { expect(screen.getByText('US, Chicago, IL')).toBeVisible(); }); - - expect( - within(screen.getByText('Access Key ID:').closest('div')!).getByText( - '*****************' - ) - ).toBeInTheDocument(); - - expect( - within(screen.getByText('Secret Access Key:').closest('div')!).getByText( - '*****************' - ) - ).toBeInTheDocument(); + // Access Key ID: + expect(screen.getByTestId('access-key-id')).toHaveTextContent( + '*****************' + ); + // Secret Access Key: + expect(screen.getByTestId('secret-access-key')).toHaveTextContent( + '*****************' + ); }); }); diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx new file mode 100644 index 00000000000..f031a4997ef --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx @@ -0,0 +1,34 @@ +import { useRegionsQuery } from '@linode/queries'; +import React from 'react'; + +import { LabelValue } from 'src/features/DataStream/Shared/LabelValue'; + +import type { LinodeObjectStorageDetails } from '@linode/api-v4'; + +export const DestinationLinodeObjectStorageDetailsSummary = ( + props: LinodeObjectStorageDetails +) => { + const { bucket_name, host, region, path } = props; + const { data: regions } = useRegionsQuery(); + + const regionValue = regions?.find(({ id }) => id === region)?.label || region; + + return ( + <> + + + + + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx similarity index 96% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.test.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx index 5e58470ad1a..71e0ed84dca 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx @@ -9,13 +9,13 @@ import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; -import { StreamCreateDelivery } from './StreamCreateDelivery'; +import { StreamFormDelivery } from './StreamFormDelivery'; const loadingTestId = 'circle-progress'; const mockDestinations = destinationFactory.buildList(5); -describe('StreamCreateDelivery', () => { +describe('StreamFormDelivery', () => { beforeEach(async () => { server.use( http.get('*/monitor/streams/destinations', () => { @@ -26,7 +26,7 @@ describe('StreamCreateDelivery', () => { it('should render disabled Destination Type input with proper selection', async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { destination: { @@ -50,7 +50,7 @@ describe('StreamCreateDelivery', () => { it('should render Destination Name input and allow to select an existing option', async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { destination: { @@ -81,7 +81,7 @@ describe('StreamCreateDelivery', () => { const renderComponentAndAddNewDestinationName = async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { destination: { diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx similarity index 86% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx index 223a7bb8fb7..c3d24570a90 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -16,13 +16,13 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; import { DestinationLinodeObjectStorageDetailsForm } from 'src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm'; import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; -import { DestinationLinodeObjectStorageDetailsSummary } from 'src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary'; +import { DestinationLinodeObjectStorageDetailsSummary } from 'src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary'; import type { DestinationType, LinodeObjectStorageDetails, } from '@linode/api-v4'; -import type { CreateStreamAndDestinationForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; type DestinationName = { create?: boolean; @@ -40,15 +40,12 @@ const controlPaths = { region: 'destination.details.region', }; -export const StreamCreateDelivery = () => { +export const StreamFormDelivery = () => { const theme = useTheme(); - const { control, setValue } = - useFormContext(); + const { control, setValue } = useFormContext(); const [showDestinationForm, setShowDestinationForm] = React.useState(false); - const [showExistingDestination, setShowExistingDestination] = - React.useState(false); const { data: destinations, isLoading, error } = useAllDestinationsQuery(); const destinationNameOptions: DestinationName[] = (destinations || []).map( @@ -118,16 +115,12 @@ export const StreamCreateDelivery = () => { label="Destination Name" onBlur={field.onBlur} onChange={(_, newValue) => { - const selectedExistingDestination = !!( - newValue?.label && newValue?.id + setValue( + 'stream.destinations', + newValue?.id ? [newValue?.id] : [] ); - if (selectedExistingDestination) { - setValue('stream.destinations', [newValue?.id as number]); - } field.onChange(newValue?.label || newValue); - setValue('stream.destinations', [newValue?.id as number]); setShowDestinationForm(!!newValue?.create); - setShowExistingDestination(selectedExistingDestination); }} options={destinationNameOptions.filter( ({ type }) => type === selectedDestinationType @@ -158,7 +151,7 @@ export const StreamCreateDelivery = () => { controlPaths={controlPaths} /> )} - {showExistingDestination && ( + {!!selectedDestinations?.length && ( id === selectedDestinations[0] diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx new file mode 100644 index 00000000000..c5fa4ee0007 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx @@ -0,0 +1,86 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { destinationType, streamType } from '@linode/api-v4'; +import { useCreateStreamMutation } from '@linode/queries'; +import { streamAndDestinationFormSchema } from '@linode/validation'; +import { useNavigate } from '@tanstack/react-router'; +import * as React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { + LandingHeader, + type LandingHeaderProps, +} from 'src/components/LandingHeader'; +import { getStreamPayloadDetails } from 'src/features/DataStream/dataStreamUtils'; +import { StreamForm } from 'src/features/DataStream/Streams/StreamForm/StreamForm'; +import { sendCreateStreamEvent } from 'src/utilities/analytics/customEventAnalytics'; + +import type { CreateStreamPayload } from '@linode/api-v4'; +import type { + StreamAndDestinationFormType, + StreamFormType, +} from 'src/features/DataStream/Streams/StreamForm/types'; + +export const StreamCreate = () => { + const { mutateAsync: createStream } = useCreateStreamMutation(); + const navigate = useNavigate(); + + const form = useForm({ + defaultValues: { + stream: { + type: streamType.AuditLogs, + details: {}, + }, + destination: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + }, + mode: 'onBlur', + resolver: yupResolver(streamAndDestinationFormSchema), + }); + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/datastream/streams/create', + crumbOverrides: [ + { + label: 'DataStream', + linkTo: '/datastream/streams', + position: 1, + }, + ], + }, + removeCrumbX: 2, + title: 'Create Stream', + }; + + const onSubmit = () => { + const { + stream: { label, type, destinations, details }, + } = form.getValues(); + const payload: StreamFormType = { + label, + type, + destinations, + details: getStreamPayloadDetails(type, details), + }; + + createStream(payload as CreateStreamPayload).then(() => { + sendCreateStreamEvent('Stream Create Page'); + navigate({ to: '/datastream/streams' }); + }); + }; + + return ( + <> + + + + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx new file mode 100644 index 00000000000..41699fa8205 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx @@ -0,0 +1,84 @@ +import { + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import React from 'react'; +import { describe } from 'vitest'; + +import { destinationFactory, streamFactory } from 'src/factories/datastream'; +import { StreamEdit } from 'src/features/DataStream/Streams/StreamForm/StreamEdit'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +const loadingTestId = 'circle-progress'; +const streamId = 123; +const mockDestinations = [destinationFactory.build({ id: 1 })]; +const mockStream = streamFactory.build({ + id: streamId, + label: `Data Stream ${streamId}`, + destinations: mockDestinations, +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: vi.fn().mockReturnValue({ streamId: 123 }), + }; +}); + +describe('StreamEdit', () => { + const assertInputHasValue = (inputLabel: string, inputValue: string) => { + expect(screen.getByLabelText(inputLabel)).toHaveValue(inputValue); + }; + + it('should render edited stream when stream fetched properly', async () => { + server.use( + http.get(`*/monitor/streams/${streamId}`, () => { + return HttpResponse.json(mockStream); + }), + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(mockDestinations)); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } + + await waitFor(() => { + assertInputHasValue('Name', 'Data Stream 123'); + }); + assertInputHasValue('Stream Type', 'Audit Logs'); + await waitFor(() => { + assertInputHasValue('Destination Type', 'Linode Object Storage'); + }); + assertInputHasValue('Destination Name', 'Destination 1'); + + // Host: + expect(screen.getByText('3000')).toBeVisible(); + // Bucket: + expect(screen.getByText('Bucket Name')).toBeVisible(); + // Region: + await waitFor(() => { + expect(screen.getByText('US, Chicago, IL')).toBeVisible(); + }); + // Access Key ID: + expect(screen.getByTestId('access-key-id')).toHaveTextContent( + '*****************' + ); + // Secret Access Key: + expect(screen.getByTestId('secret-access-key')).toHaveTextContent( + '*****************' + ); + // Log Path: + expect(screen.getByText('file')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx new file mode 100644 index 00000000000..9a3f6bf846e --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx @@ -0,0 +1,131 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { destinationType, streamType } from '@linode/api-v4'; +import { useStreamQuery, useUpdateStreamMutation } from '@linode/queries'; +import { Box, CircleProgress, ErrorState } from '@linode/ui'; +import { streamAndDestinationFormSchema } from '@linode/validation'; +import { useNavigate, useParams } from '@tanstack/react-router'; +import * as React from 'react'; +import { useEffect } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { + LandingHeader, + type LandingHeaderProps, +} from 'src/components/LandingHeader'; +import { getStreamPayloadDetails } from 'src/features/DataStream/dataStreamUtils'; +import { StreamForm } from 'src/features/DataStream/Streams/StreamForm/StreamForm'; + +import type { UpdateStreamPayloadWithId } from '@linode/api-v4'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; + +export const StreamEdit = () => { + const navigate = useNavigate(); + const { streamId } = useParams({ + from: '/datastream/streams/$streamId/edit', + }); + const { mutateAsync: updateStream } = useUpdateStreamMutation(); + const { data: stream, isLoading, error } = useStreamQuery(Number(streamId)); + + const form = useForm({ + defaultValues: { + stream: { + type: streamType.AuditLogs, + details: {}, + }, + destination: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + }, + mode: 'onBlur', + resolver: yupResolver(streamAndDestinationFormSchema), + }); + + useEffect(() => { + if (stream) { + const details = + Object.keys(stream.details).length > 0 + ? { + is_auto_add_all_clusters_enabled: false, + cluster_ids: [], + ...stream.details, + } + : {}; + + form.reset({ + stream: { + ...stream, + details, + destinations: stream.destinations.map(({ id }) => id), + }, + destination: stream.destinations?.[0], + }); + } + }, [stream, form]); + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/datastream/streams/edit', + crumbOverrides: [ + { + label: 'DataStream', + linkTo: '/datastream/streams', + position: 1, + }, + ], + }, + removeCrumbX: 2, + title: 'Edit Stream', + }; + + const onSubmit = () => { + const { + stream: { label, type, destinations, details }, + } = form.getValues(); + + // TODO: DPS-33120 create destination call if new destination created + + const payload: UpdateStreamPayloadWithId = { + id: stream!.id, + label, + type: stream!.type, + status: stream!.status, + destinations: destinations as number[], // TODO: remove type assertion after DPS-33120 + details: getStreamPayloadDetails(type, details), + }; + + updateStream(payload).then(() => { + navigate({ to: '/datastream/streams' }); + }); + }; + + return ( + <> + + + {isLoading && ( + + + + )} + {error && ( + + )} + {!isLoading && !error && ( + + + + )} + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx new file mode 100644 index 00000000000..9ec617576de --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx @@ -0,0 +1,52 @@ +import { streamType } from '@linode/api-v4'; +import { Stack } from '@linode/ui'; +import Grid from '@mui/material/Grid'; +import * as React from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import { StreamFormSubmitBar } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar'; +import { StreamFormDelivery } from 'src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery'; + +import { StreamFormClusters } from './StreamFormClusters'; +import { StreamFormGeneralInfo } from './StreamFormGeneralInfo'; + +import type { FormMode } from 'src/features/DataStream/Shared/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; + +type StreamFormProps = { + mode: FormMode; + onSubmit: SubmitHandler; + streamId?: string; +}; + +export const StreamForm = (props: StreamFormProps) => { + const { mode, onSubmit, streamId } = props; + + const { control, handleSubmit } = + useFormContext(); + + const selectedStreamType = useWatch({ + control, + name: 'stream.type', + }); + + return ( +
+ + + + + {selectedStreamType === streamType.LKEAuditLogs && ( + + )} + + + + + + + +
+ ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.test.tsx similarity index 96% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.test.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.test.tsx index 9bb723fba9e..6ed13dd08f6 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.test.tsx @@ -5,15 +5,18 @@ import { describe, expect, it } from 'vitest'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; -import { StreamCreateClusters } from './StreamCreateClusters'; +import { StreamFormClusters } from './StreamFormClusters'; const renderComponentWithoutSelectedClusters = () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { stream: { - details: {}, + details: { + cluster_ids: [], + is_auto_add_all_clusters_enabled: false, + }, }, }, }, @@ -49,7 +52,7 @@ const expectCheckboxStateToBe = ( } }; -describe('StreamCreateClusters', () => { +describe('StreamFormClusters', () => { it('should render all clusters in table', async () => { renderComponentWithoutSelectedClusters(); @@ -159,12 +162,13 @@ describe('StreamCreateClusters', () => { describe('when form has already selected clusters', () => { it('should render table with properly selected clusters', async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { stream: { details: { cluster_ids: [3], + is_auto_add_all_clusters_enabled: false, }, }, }, diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.tsx similarity index 90% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.tsx index af894c9a200..b04eb9c9753 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.tsx @@ -1,5 +1,5 @@ import { Box, Checkbox, Notice, Paper, Typography } from '@linode/ui'; -import { usePrevious } from '@linode/utilities'; +import { isNotNullOrUndefined, usePrevious } from '@linode/utilities'; import React, { useEffect, useState } from 'react'; import type { ControllerRenderProps } from 'react-hook-form'; import { useWatch } from 'react-hook-form'; @@ -14,9 +14,9 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; -import { clusters } from 'src/features/DataStream/Streams/StreamCreate/StreamCreateClustersData'; +import { clusters } from 'src/features/DataStream/Streams/StreamForm/StreamFormClustersData'; -import type { CreateStreamAndDestinationForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; // TODO: remove type after fetching the clusters will be done export type Cluster = { @@ -28,9 +28,9 @@ export type Cluster = { type OrderByKeys = 'label' | 'logGeneration' | 'region'; -export const StreamCreateClusters = () => { +export const StreamFormClusters = () => { const { control, setValue, formState } = - useFormContext(); + useFormContext(); const [order, setOrder] = useState<'asc' | 'desc'>('asc'); const [orderBy, setOrderBy] = useState('label'); @@ -40,16 +40,31 @@ export const StreamCreateClusters = () => { .filter(({ logGeneration }) => logGeneration) .map(({ id }) => id); - const isAutoAddAllClustersEnabled = useWatch({ + const [isAutoAddAllClustersEnabled, clusterIds] = useWatch({ control, - name: 'stream.details.is_auto_add_all_clusters_enabled', + name: [ + 'stream.details.is_auto_add_all_clusters_enabled', + 'stream.details.cluster_ids', + ], }); const previousIsAutoAddAllClustersEnabled = usePrevious( isAutoAddAllClustersEnabled ); useEffect(() => { - if (isAutoAddAllClustersEnabled !== previousIsAutoAddAllClustersEnabled) { + setValue( + 'stream.details.cluster_ids', + isAutoAddAllClustersEnabled + ? idsWithLogGenerationEnabled + : clusterIds || [] + ); + }, []); + + useEffect(() => { + if ( + isNotNullOrUndefined(previousIsAutoAddAllClustersEnabled) && + isAutoAddAllClustersEnabled !== previousIsAutoAddAllClustersEnabled + ) { setValue( 'stream.details.cluster_ids', isAutoAddAllClustersEnabled ? idsWithLogGenerationEnabled : [] @@ -73,7 +88,7 @@ export const StreamCreateClusters = () => { const getTableContent = ( field: ControllerRenderProps< - CreateStreamAndDestinationForm, + StreamAndDestinationFormType, 'stream.details.cluster_ids' > ) => { diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClustersData.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClustersData.ts similarity index 92% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClustersData.ts rename to packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClustersData.ts index baaf487c7dd..246a5b189e6 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClustersData.ts +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClustersData.ts @@ -1,4 +1,4 @@ -import type { Cluster } from 'src/features/DataStream/Streams/StreamCreate/StreamCreateClusters'; +import type { Cluster } from 'src/features/DataStream/Streams/StreamForm/StreamFormClusters'; export const clusters: Cluster[] = [ { diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx new file mode 100644 index 00000000000..aeea11d63e9 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx @@ -0,0 +1,101 @@ +import { streamType } from '@linode/api-v4'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { StreamFormGeneralInfo } from './StreamFormGeneralInfo'; + +describe('StreamFormGeneralInfo', () => { + describe('when in create mode', () => { + it('should render Name input and allow to type text', async () => { + renderWithThemeAndHookFormContext({ + component: , + }); + + // Type test value inside the input + const nameInput = screen.getByPlaceholderText('Stream name...'); + await userEvent.type(nameInput, 'Test'); + + await waitFor(() => { + expect(nameInput.getAttribute('value')).toEqual('Test'); + }); + }); + + it('should render Stream type input and allow to select different options', async () => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + type: streamType.AuditLogs, + }, + }, + }, + }); + + const streamTypesAutocomplete = screen.getByRole('combobox'); + + expect(streamTypesAutocomplete).toHaveValue('Audit Logs'); + + // Open the dropdown + await userEvent.click(streamTypesAutocomplete); + + // Select the "Kubernetes Audit Logs" option + const kubernetesAuditLogs = await screen.findByText( + 'Kubernetes Audit Logs' + ); + await userEvent.click(kubernetesAuditLogs); + + await waitFor(() => { + expect(streamTypesAutocomplete).toHaveValue('Kubernetes Audit Logs'); + }); + }); + }); + + describe('when in edit mode and with streamId prop', () => { + const streamId = '123'; + it('should render ID', () => { + renderWithThemeAndHookFormContext({ + component: , + }); + + // ID: + expect(screen.getByText(streamId)).toBeVisible(); + }); + + it('should render Name input and allow to type text', async () => { + renderWithThemeAndHookFormContext({ + component: , + }); + + // Type test value inside the input + const nameInput = screen.getByPlaceholderText('Stream name...'); + await userEvent.type(nameInput, 'Test'); + + await waitFor(() => { + expect(nameInput.getAttribute('value')).toEqual('Test'); + }); + }); + + it('should render disabled Stream type input', async () => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + type: streamType.AuditLogs, + }, + }, + }, + }); + + const streamTypesAutocomplete = screen.getByRole('combobox'); + + expect(streamTypesAutocomplete).toBeDisabled(); + expect(streamTypesAutocomplete).toHaveValue('Audit Logs'); + }); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.tsx similarity index 65% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.tsx index e73ec41fe91..8ade80467c4 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.tsx @@ -3,22 +3,25 @@ import { Autocomplete, Paper, TextField, Typography } from '@linode/ui'; import React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; -import { type CreateStreamAndDestinationForm } from './types'; +import { + getStreamTypeOption, + isFormInEditMode, +} from 'src/features/DataStream/dataStreamUtils'; +import { LabelValue } from 'src/features/DataStream/Shared/LabelValue'; +import { streamTypeOptions } from 'src/features/DataStream/Shared/types'; -export const StreamCreateGeneralInfo = () => { - const { control, setValue } = - useFormContext(); +import type { StreamAndDestinationFormType } from './types'; +import type { FormMode } from 'src/features/DataStream/Shared/types'; - const streamTypeOptions = [ - { - value: streamType.AuditLogs, - label: 'Audit Logs', - }, - { - value: streamType.LKEAuditLogs, - label: 'Kubernetes Audit Logs', - }, - ]; +type StreamFormGeneralInfoProps = { + mode: FormMode; + streamId?: string; +}; + +export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { + const { mode, streamId } = props; + + const { control, setValue } = useFormContext(); const updateStreamDetails = (value: string) => { if (value === streamType.LKEAuditLogs) { @@ -31,6 +34,7 @@ export const StreamCreateGeneralInfo = () => { return ( General Information + {streamId && } { render={({ field, fieldState }) => ( { updateStreamDetails(value); }} options={streamTypeOptions} - value={streamTypeOptions.find(({ value }) => value === field.value)} + value={getStreamTypeOption(field.value)} /> )} rules={{ required: true }} diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/streamCreateLazyRoute.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute.ts similarity index 90% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/streamCreateLazyRoute.ts rename to packages/manager/src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute.ts index 0ec5b500ee8..eda795aea08 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/streamCreateLazyRoute.ts +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute.ts @@ -1,6 +1,6 @@ import { createLazyRoute } from '@tanstack/react-router'; -import { StreamCreate } from 'src/features/DataStream/Streams/StreamCreate/StreamCreate'; +import { StreamCreate } from 'src/features/DataStream/Streams/StreamForm/StreamCreate'; export const streamCreateLazyRoute = createLazyRoute( '/datastream/streams/create' diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts new file mode 100644 index 00000000000..a9ad91972cb --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { StreamEdit } from 'src/features/DataStream/Streams/StreamForm/StreamEdit'; + +export const streamEditLazyRoute = createLazyRoute( + '/datastream/streams/$streamId/edit' +)({ + component: StreamEdit, +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/types.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/types.ts similarity index 71% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/types.ts rename to packages/manager/src/features/DataStream/Streams/StreamForm/types.ts index c89de4c8bbf..ae29faa91ec 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/types.ts +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/types.ts @@ -1,12 +1,12 @@ import type { CreateStreamPayload } from '@linode/api-v4'; import type { CreateDestinationForm } from 'src/features/DataStream/Shared/types'; -export interface CreateStreamForm +export interface StreamFormType extends Omit { destinations: (number | undefined)[]; } -export interface CreateStreamAndDestinationForm { +export interface StreamAndDestinationFormType { destination: CreateDestinationForm; - stream: CreateStreamForm; + stream: StreamFormType; } diff --git a/packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx new file mode 100644 index 00000000000..025fa1636d6 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx @@ -0,0 +1,54 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { streamFactory } from 'src/factories/datastream'; +import { StreamTableRow } from 'src/features/DataStream/Streams/StreamTableRow'; +import { + mockMatchMedia, + renderWithTheme, + wrapWithTableBody, +} from 'src/utilities/testHelpers'; + +const fakeHandler = vi.fn(); + +describe('StreamTableRow', () => { + const stream = { ...streamFactory.build(), id: 1 }; + + it('should render a stream row', async () => { + mockMatchMedia(); + renderWithTheme( + wrapWithTableBody( + + ) + ); + + // Name: + screen.getByText('Data Stream 1'); + // Stream Type: + screen.getByText('Audit Logs'); + // Status: + screen.getByText('Enabled'); + // Destination Type: + screen.getByText('Linode Object Storage'); + // ID: + screen.getByText('1'); + // Creation Time: + screen.getByText(/2025-07-30/); + + const actionMenu = screen.getByLabelText( + `Action menu for Stream ${stream.label}` + ); + await userEvent.click(actionMenu); + + expect(screen.getByText('Edit')).toBeVisible(); + expect(screen.getByText('Disable')).toBeVisible(); + expect(screen.getByText('Delete')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx b/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx index 44d010c6f59..4a5c0f0cacb 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx @@ -5,30 +5,49 @@ import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { + getDestinationTypeOption, + getStreamTypeOption, +} from 'src/features/DataStream/dataStreamUtils'; +import { StreamActionMenu } from 'src/features/DataStream/Streams/StreamActionMenu'; +import type { Handlers as StreamHandlers } from './StreamActionMenu'; import type { Stream, StreamStatus } from '@linode/api-v4'; -interface StreamTableRowProps { +interface StreamTableRowProps extends StreamHandlers { stream: Stream; } export const StreamTableRow = React.memo((props: StreamTableRowProps) => { - const { stream } = props; + const { stream, onDelete, onDisableOrEnable, onEdit } = props; return ( {stream.label} + {getStreamTypeOption(stream.type)?.label} {humanizeStreamStatus(stream.status)} {stream.id} - {stream.destinations[0].label} + + {getDestinationTypeOption(stream.destinations[0]?.type)?.label} + + + + + + ); }); diff --git a/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx index c10cb9f70d3..58b69d54200 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx @@ -1,56 +1,94 @@ -import { waitForElementToBeRemoved, within } from '@testing-library/react'; +import { + screen, + waitForElementToBeRemoved, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { expect } from 'vitest'; +import { beforeEach, describe, expect } from 'vitest'; import { streamFactory } from 'src/factories/datastream'; import { StreamsLanding } from 'src/features/DataStream/Streams/StreamsLanding'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; const loadingTestId = 'circle-progress'; -describe('Streams Landing Table', () => { - it('should render streams landing tab header and table with items PaginationFooter', async () => { - server.use( - http.get('*/monitor/streams', () => { - return HttpResponse.json(makeResourcePage(streamFactory.buildList(30))); - }) - ); +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(() => vi.fn()), + useUpdateStreamMutation: vi.fn().mockReturnValue({ + mutateAsync: vi.fn(), + }), + useDeleteStreamMutation: vi.fn().mockReturnValue({ + mutateAsync: vi.fn(), + }), +})); - const { - getByText, - queryByTestId, - getAllByTestId, - getByPlaceholderText, - getByLabelText, - getByRole, - } = renderWithTheme(, { +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useUpdateStreamMutation: queryMocks.useUpdateStreamMutation, + useDeleteStreamMutation: queryMocks.useDeleteStreamMutation, + }; +}); + +const stream = streamFactory.build({ id: 1 }); +const streams = [stream, ...streamFactory.buildList(30)]; + +describe('Streams Landing Table', () => { + const renderComponentAndWaitForLoadingComplete = async () => { + renderWithTheme(, { initialRoute: '/datastream/streams', }); - const loadingElement = queryByTestId(loadingTestId); + const loadingElement = screen.queryByTestId(loadingTestId); if (loadingElement) { await waitForElementToBeRemoved(loadingElement); } + }; + + beforeEach(() => { + mockMatchMedia(); + }); + + it('should render streams landing tab header and table with items PaginationFooter', async () => { + server.use( + http.get('*/monitor/streams', () => { + return HttpResponse.json(makeResourcePage(streams)); + }) + ); + + await renderComponentAndWaitForLoadingComplete(); // search text input - getByPlaceholderText('Search for a Stream'); + screen.getByPlaceholderText('Search for a Stream'); // select - getByLabelText('Status'); + screen.getByLabelText('Status'); // button - getByText('Create Stream'); + screen.getByText('Create Stream'); // Table column headers - getByText('Name'); - within(getByRole('table')).getByText('Status'); - getByText('ID'); - getByText('Destination Type'); + screen.getByText('Name'); + screen.getByText('Stream Type'); + within(screen.getByRole('table')).getByText('Status'); + screen.getByText('ID'); + screen.getByText('Destination Type'); + screen.getByText('Creation Time'); // PaginationFooter - const paginationFooterSelectPageSizeInput = getAllByTestId( + const paginationFooterSelectPageSizeInput = screen.getAllByTestId( 'textfield-input' )[2] as HTMLInputElement; expect(paginationFooterSelectPageSizeInput.value).toBe('Show 25'); @@ -63,17 +101,109 @@ describe('Streams Landing Table', () => { }) ); - const { getByText, queryByTestId } = renderWithTheme(, { - initialRoute: '/datastream/streams', - }); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + await renderComponentAndWaitForLoadingComplete(); - getByText((text) => + screen.getByText((text) => text.includes('Create a data stream and configure delivery of cloud logs') ); }); + + const clickOnActionMenu = async () => { + const actionMenu = screen.getByLabelText( + `Action menu for Stream ${stream.label}` + ); + await userEvent.click(actionMenu); + }; + + const clickOnActionMenuItem = async (itemText: string) => { + await userEvent.click(screen.getByText(itemText)); + }; + + describe('given action menu', () => { + beforeEach(() => { + server.use( + http.get('*/monitor/streams', () => { + return HttpResponse.json(makeResourcePage(streams)); + }) + ); + }); + + describe('when Edit clicked', () => { + it('should navigate to edit page', async () => { + const mockNavigate = vi.fn(); + queryMocks.useNavigate.mockReturnValue(mockNavigate); + + await renderComponentAndWaitForLoadingComplete(); + + await clickOnActionMenu(); + await clickOnActionMenuItem('Edit'); + + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/datastream/streams/1/edit', + }); + }); + }); + + describe('when Disable clicked', () => { + it('should update stream with proper parameters', async () => { + const mockUpdateStreamMutation = vi.fn().mockResolvedValue({}); + queryMocks.useUpdateStreamMutation.mockReturnValue({ + mutateAsync: mockUpdateStreamMutation, + }); + + await renderComponentAndWaitForLoadingComplete(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Disable'); + + expect(mockUpdateStreamMutation).toHaveBeenCalledWith({ + id: 1, + status: 'inactive', + label: 'Data Stream 1', + destinations: [123], + details: {}, + type: 'audit_logs', + }); + }); + }); + + describe('when Enabled clicked', () => { + it('should update stream with proper parameters', async () => { + const mockUpdateStreamMutation = vi.fn().mockResolvedValue({}); + queryMocks.useUpdateStreamMutation.mockReturnValue({ + mutateAsync: mockUpdateStreamMutation, + }); + + stream.status = 'inactive'; + await renderComponentAndWaitForLoadingComplete(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Enable'); + + expect(mockUpdateStreamMutation).toHaveBeenCalledWith({ + id: 1, + status: 'active', + label: 'Data Stream 1', + destinations: [123], + details: {}, + type: 'audit_logs', + }); + }); + }); + + describe('when Delete clicked', () => { + it('should delete stream', async () => { + const mockDeleteStreamMutation = vi.fn().mockResolvedValue({}); + queryMocks.useDeleteStreamMutation.mockReturnValue({ + mutateAsync: mockDeleteStreamMutation, + }); + + await renderComponentAndWaitForLoadingComplete(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Delete'); + + expect(mockDeleteStreamMutation).toHaveBeenCalledWith({ + id: 1, + }); + }); + }); + }); }); diff --git a/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx b/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx index 39089819cd8..6d131412af3 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx @@ -1,8 +1,14 @@ -import { useStreamsQuery } from '@linode/queries'; +import { streamStatus } from '@linode/api-v4'; +import { + useDeleteStreamMutation, + useStreamsQuery, + useUpdateStreamMutation, +} from '@linode/queries'; import { CircleProgress, ErrorState, Hidden } from '@linode/ui'; import { TableBody, TableCell, TableHead, TableRow } from '@mui/material'; import Table from '@mui/material/Table'; import { useNavigate, useSearch } from '@tanstack/react-router'; +import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -18,9 +24,14 @@ import { StreamsLandingEmptyState } from 'src/features/DataStream/Streams/Stream import { StreamTableRow } from 'src/features/DataStream/Streams/StreamTableRow'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import type { Handlers as StreamHandlers } from './StreamActionMenu'; +import type { Stream } from '@linode/api-v4'; export const StreamsLanding = () => { const navigate = useNavigate(); + const streamsUrl = '/datastream/streams'; const search = useSearch({ from: '/datastream/streams', @@ -42,6 +53,9 @@ export const StreamsLanding = () => { preferenceKey: `streams-order`, }); + const { mutateAsync: updateStream } = useUpdateStreamMutation(); + const { mutateAsync: deleteStream } = useDeleteStreamMutation(); + const filter = { ['+order']: order, ['+order_by']: orderBy, @@ -106,6 +120,78 @@ export const StreamsLanding = () => { return ; } + const handleEdit = ({ id }: Stream) => { + navigate({ to: `/datastream/streams/${id}/edit` }); + }; + + const handleDelete = ({ id, label }: Stream) => { + deleteStream({ + id, + }) + .then(() => { + return enqueueSnackbar(`Stream ${label} deleted successfully`, { + variant: 'success', + }); + }) + .catch((error) => { + return enqueueSnackbar( + getAPIErrorOrDefault( + error, + `There was an issue deleting your stream` + )[0].reason, + { + variant: 'error', + } + ); + }); + }; + + const handleDisableOrEnable = ({ + id, + destinations, + details, + label, + type, + status, + }: Stream) => { + updateStream({ + id, + destinations: destinations.map(({ id: destinationId }) => destinationId), + details, + label, + type, + status: + status === streamStatus.Active + ? streamStatus.Inactive + : streamStatus.Active, + }) + .then(() => { + return enqueueSnackbar( + `Stream ${label} ${status === streamStatus.Active ? 'disabled' : 'enabled'}`, + { + variant: 'success', + } + ); + }) + .catch((error) => { + return enqueueSnackbar( + getAPIErrorOrDefault( + error, + `There was an issue ${status === streamStatus.Active ? 'disabling' : 'enabling'} your stream` + )[0].reason, + { + variant: 'error', + } + ); + }); + }; + + const handlers: StreamHandlers = { + onDisableOrEnable: handleDisableOrEnable, + onEdit: handleEdit, + onDelete: handleDelete, + }; + return ( <> { direction={order} handleClick={handleOrderChange} label="label" + sx={{ width: '30%' }} > Name + Stream Type { > ID - Destination Type + Destination Type + + { Creation Time + {streams?.data.map((stream) => ( - + ))} diff --git a/packages/manager/src/features/DataStream/dataStreamUtils.ts b/packages/manager/src/features/DataStream/dataStreamUtils.ts index 7caef631238..50111ba9186 100644 --- a/packages/manager/src/features/DataStream/dataStreamUtils.ts +++ b/packages/manager/src/features/DataStream/dataStreamUtils.ts @@ -1,8 +1,42 @@ -import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; +import { isEmpty, streamType } from '@linode/api-v4'; +import { omitProps } from '@linode/ui'; -import type { DestinationTypeOption } from 'src/features/DataStream/Shared/types'; +import { + destinationTypeOptions, + streamTypeOptions, +} from 'src/features/DataStream/Shared/types'; + +import type { StreamDetails, StreamType } from '@linode/api-v4'; +import type { + FormMode, + LabelValueOption, +} from 'src/features/DataStream/Shared/types'; export const getDestinationTypeOption = ( destinationTypeValue: string -): DestinationTypeOption | undefined => +): LabelValueOption | undefined => destinationTypeOptions.find(({ value }) => value === destinationTypeValue); + +export const getStreamTypeOption = ( + streamTypeValue: string +): LabelValueOption | undefined => + streamTypeOptions.find(({ value }) => value === streamTypeValue); + +export const isFormInEditMode = (mode: FormMode) => mode === 'edit'; + +export const getStreamPayloadDetails = ( + type: StreamType, + details: StreamDetails +): StreamDetails => { + let payloadDetails: StreamDetails = {}; + + if (!isEmpty(details) && type === streamType.LKEAuditLogs) { + if (details.is_auto_add_all_clusters_enabled) { + payloadDetails = omitProps(details, ['cluster_ids']); + } else { + payloadDetails = omitProps(details, ['is_auto_add_all_clusters_enabled']); + } + } + + return payloadDetails; +}; diff --git a/packages/manager/src/features/Events/factories/datastream.tsx b/packages/manager/src/features/Events/factories/datastream.tsx index 26f01242c16..ddcffb1fb59 100644 --- a/packages/manager/src/features/Events/factories/datastream.tsx +++ b/packages/manager/src/features/Events/factories/datastream.tsx @@ -13,6 +13,22 @@ export const stream: PartialEventMap<'stream'> = { ), }, + stream_delete: { + notification: (e) => ( + <> + Stream has been{' '} + deleted. + + ), + }, + stream_update: { + notification: (e) => ( + <> + Stream has been{' '} + updated. + + ), + }, }; export const destination: PartialEventMap<'destination'> = { diff --git a/packages/manager/src/mocks/presets/crud/datastream.ts b/packages/manager/src/mocks/presets/crud/datastream.ts index 8820ded8dd7..8fdde956d14 100644 --- a/packages/manager/src/mocks/presets/crud/datastream.ts +++ b/packages/manager/src/mocks/presets/crud/datastream.ts @@ -1,15 +1,24 @@ import { createDestinations, createStreams, + deleteStream, getDestinations, getStreams, + updateStream, } from 'src/mocks/presets/crud/handlers/datastream'; import type { MockPresetCrud } from 'src/mocks/types'; export const datastreamCrudPreset: MockPresetCrud = { group: { id: 'DataStream' }, - handlers: [getStreams, createStreams, getDestinations, createDestinations], + handlers: [ + getStreams, + createStreams, + deleteStream, + updateStream, + getDestinations, + createDestinations, + ], id: 'datastream:crud', label: 'Data Stream CRUD', }; diff --git a/packages/manager/src/mocks/presets/crud/handlers/datastream.ts b/packages/manager/src/mocks/presets/crud/handlers/datastream.ts index cf49a542407..52c24054c3b 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/datastream.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/datastream.ts @@ -92,6 +92,84 @@ export const createStreams = (mockState: MockState) => [ ), ]; +export const updateStream = (mockState: MockState) => [ + http.put( + '*/v4beta/monitor/streams/:id', + async ({ + params, + request, + }): Promise> => { + const id = Number(params.id); + const stream = await mswDB.get('streams', id); + + if (!stream) { + return makeNotFoundResponse(); + } + + const destinations = await mswDB.getAll('destinations'); + const payload = await request.clone().json(); + const updatedStream = { + ...stream, + ...payload, + destinations: payload['destinations'].map((destinationId: number) => + destinations?.find(({ id }) => id === destinationId) + ), + updated: DateTime.now().toISO(), + }; + + await mswDB.update('streams', id, updatedStream, mockState); + + queueEvents({ + event: { + action: 'stream_update', + entity: { + id: stream.id, + label: stream.label, + type: 'stream', + url: `/v4beta/monitor/streams/${stream.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(updatedStream); + } + ), +]; + +export const deleteStream = (mockState: MockState) => [ + http.delete( + '*/v4beta/monitor/streams/:id', + async ({ params }): Promise> => { + const id = Number(params.id); + const stream = await mswDB.get('streams', id); + + if (!stream) { + return makeNotFoundResponse(); + } + + await mswDB.delete('streams', id, mockState); + + queueEvents({ + event: { + action: 'stream_delete', + entity: { + id: stream.id, + label: stream.label, + type: 'domain', + url: `/v4beta/monitor/streams/${stream.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse({}); + } + ), +]; + export const getDestinations = () => [ http.get( '*/v4beta/monitor/streams/destinations', diff --git a/packages/manager/src/routes/datastream/index.ts b/packages/manager/src/routes/datastream/index.ts index 7f421ed2fdc..a8205708a23 100644 --- a/packages/manager/src/routes/datastream/index.ts +++ b/packages/manager/src/routes/datastream/index.ts @@ -43,10 +43,27 @@ const streamsCreateRoute = createRoute({ path: 'streams/create', }).lazy(() => import( - 'src/features/DataStream/Streams/StreamCreate/streamCreateLazyRoute' + 'src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute' ).then((m) => m.streamCreateLazyRoute) ); +const streamsEditRoute = createRoute({ + getParentRoute: () => dataStreamRoute, + params: { + parse: ({ streamId }: { streamId: string }) => ({ + streamId: Number(streamId), + }), + stringify: ({ streamId }: { streamId: number }) => ({ + streamId: String(streamId), + }), + }, + path: 'streams/$streamId/edit', +}).lazy(() => + import('src/features/DataStream/Streams/StreamForm/streamEditLazyRoute').then( + (m) => m.streamEditLazyRoute + ) +); + export interface DestinationSearchParams extends TableSearchParams { label?: string; } @@ -72,6 +89,6 @@ const destinationsCreateRoute = createRoute({ export const dataStreamRouteTree = dataStreamRoute.addChildren([ dataStreamLandingRoute, - streamsRoute.addChildren([streamsCreateRoute]), + streamsRoute.addChildren([streamsCreateRoute, streamsEditRoute]), destinationsRoute.addChildren([destinationsCreateRoute]), ]); diff --git a/packages/queries/.changeset/pr-12645-upcoming-features-1754489801598.md b/packages/queries/.changeset/pr-12645-upcoming-features-1754489801598.md new file mode 100644 index 00000000000..0f003c00343 --- /dev/null +++ b/packages/queries/.changeset/pr-12645-upcoming-features-1754489801598.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Upcoming Features +--- + +Add queries for Streams DELETE, PUT API endpoints ([#12645](https://github.com/linode/manager/pull/12645)) diff --git a/packages/queries/src/datastreams/datastream.ts b/packages/queries/src/datastreams/datastream.ts index 902c2be377d..4ab748b38d5 100644 --- a/packages/queries/src/datastreams/datastream.ts +++ b/packages/queries/src/datastreams/datastream.ts @@ -1,10 +1,12 @@ import { createDestination, createStream, + deleteStream, getDestination, getDestinations, getStream, getStreams, + updateStream, } from '@linode/api-v4'; import { profileQueries } from '@linode/queries'; import { getAll } from '@linode/utilities'; @@ -20,6 +22,7 @@ import type { Params, ResourcePage, Stream, + UpdateStreamPayloadWithId, } from '@linode/api-v4'; export const getAllDataStreams = ( @@ -83,6 +86,9 @@ export const useStreamsQuery = (params: Params = {}, filter: Filter = {}) => ...datastreamQueries.streams._ctx.paginated(params, filter), }); +export const useStreamQuery = (id: number) => + useQuery({ ...datastreamQueries.stream(id) }); + export const useCreateStreamMutation = () => { const queryClient = useQueryClient(); return useMutation({ @@ -107,6 +113,43 @@ export const useCreateStreamMutation = () => { }); }; +export const useUpdateStreamMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...data }) => updateStream(id, data), + onSuccess(stream) { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams.queryKey, + }); + + // Update stream in cache + queryClient.setQueryData( + datastreamQueries.stream(stream.id).queryKey, + stream, + ); + }, + }); +}; + +export const useDeleteStreamMutation = () => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], { id: number }>({ + mutationFn: ({ id }) => deleteStream(id), + onSuccess(_, { id }) { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams.queryKey, + }); + + // Remove stream from the cache + queryClient.removeQueries({ + queryKey: datastreamQueries.stream(id).queryKey, + }); + }, + }); +}; + export const useAllDestinationsQuery = ( params: Params = {}, filter: Filter = {}, diff --git a/packages/validation/src/datastream.schema.ts b/packages/validation/src/datastream.schema.ts index 14d21b97088..a7242c86388 100644 --- a/packages/validation/src/datastream.schema.ts +++ b/packages/validation/src/datastream.schema.ts @@ -125,34 +125,37 @@ const streamSchemaBase = object({ .max(maxLength, maxLengthMessage) .required('Stream name is required.'), status: mixed<'active' | 'inactive'>().oneOf(['active', 'inactive']), + type: string() + .oneOf(['audit_logs', 'lke_audit_logs']) + .required('Stream type is required.'), destinations: array().of(number().defined()).ensure().min(1).required(), - details: mixed | object>().when( - 'type', - { + details: mixed | object>() + .when('type', { is: 'lke_audit_logs', then: () => streamDetailsSchema.required(), otherwise: detailsShouldBeEmpty, - }, - ), + }) + .required(), }); -export const createStreamSchema = streamSchemaBase.shape({ - type: string() - .oneOf(['audit_logs', 'lke_audit_logs']) - .required('Stream type is required.'), +export const createStreamSchema = streamSchemaBase; + +export const updateStreamSchema = streamSchemaBase.shape({ + status: mixed<'active' | 'inactive'>() + .oneOf(['active', 'inactive']) + .required(), }); -export const createStreamAndDestinationFormSchema = object({ - stream: createStreamSchema.shape({ +export const streamAndDestinationFormSchema = object({ + stream: streamSchemaBase.shape({ destinations: array().of(number()).ensure().min(1).required(), - details: mixed | object>().when( - 'type', - { + details: mixed | object>() + .when('type', { is: 'lke_audit_logs', then: () => streamDetailsBase.required(), otherwise: detailsShouldBeEmpty, - }, - ), + }) + .required(), }), destination: createDestinationSchema.defined().when('stream.destinations', { is: (value: never[]) => value?.length === 1 && value[0] === undefined, From 9f6206f1d0e453b26fa8473eb69d3d4bad710c0b Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:03:10 -0400 Subject: [PATCH 05/73] upcoming: [M3-10486] - Update dual-stack labeling in VPC Create (#12746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Copy adjustments to the VPC Create flow as requested by Daniel ## How to test 🧪 ### Prerequisites (How to setup test environment) - Pull this PR and point to devcloud (ensure your account has vpc ipv6 customer tag) ### Verification steps (How to verify changes) - [ ] Ensure the VPC IPv6 feature flag is enabled and your account has the VPC Dual Stack account capability - [ ] Go to the VPC Create page - [ ] You should see the updated label changes `IP Stack` and `(dual-stack)` --- .../pr-12746-upcoming-features-1755808123912.md | 5 +++++ .../FormComponents/VPCTopSectionContent.test.tsx | 10 +++++----- .../VPCCreate/FormComponents/VPCTopSectionContent.tsx | 4 ++-- 3 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 packages/manager/.changeset/pr-12746-upcoming-features-1755808123912.md diff --git a/packages/manager/.changeset/pr-12746-upcoming-features-1755808123912.md b/packages/manager/.changeset/pr-12746-upcoming-features-1755808123912.md new file mode 100644 index 00000000000..390687173c5 --- /dev/null +++ b/packages/manager/.changeset/pr-12746-upcoming-features-1755808123912.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Update dual-stack labeling in VPC Create ([#12746](https://github.com/linode/manager/pull/12746)) diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx index 662c6484091..345739ffd01 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx @@ -37,10 +37,10 @@ describe('VPC Top Section form content', () => { expect(screen.getByText('VPC Label')).toBeVisible(); expect(screen.getByText('Description')).toBeVisible(); // @TODO VPC IPv6: Remove this check once VPC IPv6 is in GA - expect(screen.queryByText('Networking IP Stack')).not.toBeInTheDocument(); + expect(screen.queryByText('IP Stack')).not.toBeInTheDocument(); }); - it('renders a Networking IP Stack section with IPv4 pre-checked if the vpcIpv6 feature flag is enabled', async () => { + it('renders an IP Stack section with IPv4 pre-checked if the vpcIpv6 feature flag is enabled', async () => { const account = accountFactory.build({ capabilities: ['VPC Dual Stack'], }); @@ -66,7 +66,7 @@ describe('VPC Top Section form content', () => { }); await waitFor(() => { - expect(screen.getByText('Networking IP Stack')).toBeVisible(); + expect(screen.getByText('IP Stack')).toBeVisible(); }); const NetworkingIPStackRadios = screen.getAllByRole('radio'); @@ -100,7 +100,7 @@ describe('VPC Top Section form content', () => { }); await waitFor(() => { - expect(screen.getByText('Networking IP Stack')).toBeVisible(); + expect(screen.getByText('IP Stack')).toBeVisible(); }); const NetworkingIPStackRadios = screen.getAllByRole('radio'); @@ -140,7 +140,7 @@ describe('VPC Top Section form content', () => { }); await waitFor(() => { - expect(screen.getByText('Networking IP Stack')).toBeVisible(); + expect(screen.getByText('IP Stack')).toBeVisible(); }); const NetworkingIPStackRadios = screen.getAllByRole('radio'); diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx index 7ab330ae87b..bd0d864a009 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx @@ -138,7 +138,7 @@ export const VPCTopSectionContent = (props: Props) => { /> {isDualStackEnabled && ( - Networking IP Stack + IP Stack { sm: 12, xs: 12, }} - heading="IPv4 + IPv6 (Dual Stack)" + heading="IPv4 + IPv6 (dual-stack)" onClick={() => { field.onChange([ { From 64378dcfbf36f61b845c92bd2015ef4b75f1aa9d Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Fri, 22 Aug 2025 13:00:29 -0700 Subject: [PATCH 06/73] upcoming: [M3-10485] - Update dual-stack labeling in LKE cluster create (#12754) * Update copy for cluster create dual stack section * Update Cypress test * Added changeset: Update dual-stack labeling for LKE-E clusters in create cluster flow * Update unit test --- .../pr-12754-upcoming-features-1755878970596.md | 5 +++++ .../e2e/core/kubernetes/lke-enterprise-create.spec.ts | 2 +- .../CreateCluster/ClusterNetworkingPanel.test.tsx | 8 +++++--- .../Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx | 4 ++-- 4 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-12754-upcoming-features-1755878970596.md diff --git a/packages/manager/.changeset/pr-12754-upcoming-features-1755878970596.md b/packages/manager/.changeset/pr-12754-upcoming-features-1755878970596.md new file mode 100644 index 00000000000..9321423de71 --- /dev/null +++ b/packages/manager/.changeset/pr-12754-upcoming-features-1755878970596.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Update dual-stack labeling for LKE-E clusters in create cluster flow ([#12754](https://github.com/linode/manager/pull/12754)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts index bc84016b588..7f40094871a 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts @@ -171,7 +171,7 @@ describe('LKE Cluster Creation with LKE-E', () => { // Select either the IPv4 or IPv4 + IPv6 (dual-stack) IP Networking radio button cy.findByLabelText( - stackType === 'ipv4' ? 'IPv4' : 'IPv4 + IPv6' + stackType === 'ipv4' ? 'IPv4' : 'IPv4 + IPv6 (dual-stack)' ).click(); // Select a plan and add nodes diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.test.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.test.tsx index 7f2bd1e34ce..796f6b82dc5 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.test.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.test.tsx @@ -49,9 +49,9 @@ describe('ClusterNetworkingPanel', () => { }); // Confirm stack type section - expect(getByText('IP Version')).toBeVisible(); + expect(getByText('IP Stack')).toBeVisible(); expect(getByText('IPv4')).toBeVisible(); - expect(getByText('IPv4 + IPv6')).toBeVisible(); + expect(getByText('IPv4 + IPv6 (dual-stack)')).toBeVisible(); // Confirm VPC section expect(getByText('VPC')).toBeVisible(); @@ -71,7 +71,9 @@ describe('ClusterNetworkingPanel', () => { // Confirm stack type default expect(getByRole('radio', { name: 'IPv4' })).toBeChecked(); - expect(getByRole('radio', { name: 'IPv4 + IPv6' })).not.toBeChecked(); + expect( + getByRole('radio', { name: 'IPv4 + IPv6 (dual-stack)' }) + ).not.toBeChecked(); // Confirm VPC default expect( diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx index 5bdfa0639c5..de8be62216e 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx @@ -59,11 +59,11 @@ export const ClusterNetworkingPanel = (props: Props) => { onChange={(e) => field.onChange(e.target.value)} value={field.value ?? null} > - IP Version + IP Stack } label="IPv4" value="ipv4" /> } - label="IPv4 + IPv6" + label="IPv4 + IPv6 (dual-stack)" value="ipv4-ipv6" /> From 8e73ccb98851d8eb09023c652d36239dcb40230f Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:02:30 -0400 Subject: [PATCH 07/73] test: [M3-7763] - Account for parallelization in Cypress test result summary duration (#12765) * Report Cypress test duration of slowest runner * Added changeset: Fix Cypress test result summary duration accuracy --- .../pr-12765-tests-1756144753597.md | 5 ++++ .../cypress/support/plugins/junit-report.ts | 5 ++++ scripts/junit-summary/util/index.ts | 30 ++++++++++++++++--- 3 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 packages/manager/.changeset/pr-12765-tests-1756144753597.md diff --git a/packages/manager/.changeset/pr-12765-tests-1756144753597.md b/packages/manager/.changeset/pr-12765-tests-1756144753597.md new file mode 100644 index 00000000000..d701e3af658 --- /dev/null +++ b/packages/manager/.changeset/pr-12765-tests-1756144753597.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix Cypress test result summary duration accuracy ([#12765](https://github.com/linode/manager/pull/12765)) diff --git a/packages/manager/cypress/support/plugins/junit-report.ts b/packages/manager/cypress/support/plugins/junit-report.ts index 2249babea92..9eff60f4a4d 100644 --- a/packages/manager/cypress/support/plugins/junit-report.ts +++ b/packages/manager/cypress/support/plugins/junit-report.ts @@ -27,6 +27,8 @@ const getCommonJunitConfig = ( testSuite: string, config: Cypress.PluginConfigOptions ) => { + const runnerIndex = Number(config.env['CY_TEST_SPLIT_RUN_INDEX']) || 1; + if (config.env[envVarName]) { if (!config.reporterOptions) { config.reporterOptions = {}; @@ -38,6 +40,9 @@ const getCommonJunitConfig = ( testsuitesTitle: testSuiteName, jenkinsMode: true, suiteTitleSeparatedBy: '→', + properties: { + runner_index: runnerIndex, + }, }; } return config; diff --git a/scripts/junit-summary/util/index.ts b/scripts/junit-summary/util/index.ts index 945891272e6..5ce008ad66b 100644 --- a/scripts/junit-summary/util/index.ts +++ b/scripts/junit-summary/util/index.ts @@ -9,10 +9,32 @@ import type { TestResult } from '../results/test-result'; * @returns Length of time for all suites to run, in seconds. */ export const getTestLength = (suites: TestSuites[]): number => { - const unroundedLength = suites.reduce((acc: number, cur: TestSuites) => { - return acc + (cur.time ?? 0); - }, 0); - return Math.round(unroundedLength * 1000) / 1000; + const testDurations: {[key: number]: number} = suites.reduce((acc: {[key: number]: number}, cur: TestSuites) => { + const suite = cur.testsuite?.[0]; + if (!suite) { + return acc; + } + + const runnerIndex = (() => { + if (!suite.properties) { + return 1; + } + const indexProperty = suite.properties.find((property) => { + return property.name === 'runner_index'; + }); + + if (!indexProperty) { + return 1; + } + return Number(indexProperty.value); + })(); + + acc[runnerIndex] = (acc[runnerIndex] || 0) + (cur.time ?? 0); + return acc; + }, {}); + + const highestDuration = Math.max(...Object.values(testDurations)); + return Math.round(highestDuration * 1000) / 1000; }; /** From 5af88396d7eec03f2377c640271e81895f4eaebd Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Mon, 25 Aug 2025 15:15:13 -0700 Subject: [PATCH 08/73] test: [M3-10490] - Add error handling test coverage for LKE-E Phase 2 (BYO VPC, IP Stack) (#12755) * Add test spec for API error handling * Add test case for surfacing client-side validation on VPC field * Restructure test hierarchy and add test for field errors * Fix failures not caught when cherry picking from wrong branch * Added changeset: Add Cypress error handling test coverage for LKE-E Phase 2 (BYO VPC, IP Stack) * Fix test failures from merging in develop --- .../pr-12755-tests-1756140280158.md | 5 + .../kubernetes/lke-enterprise-create.spec.ts | 341 +++++++++++++----- .../CreateCluster/ClusterNetworkingPanel.tsx | 1 - 3 files changed, 262 insertions(+), 85 deletions(-) create mode 100644 packages/manager/.changeset/pr-12755-tests-1756140280158.md diff --git a/packages/manager/.changeset/pr-12755-tests-1756140280158.md b/packages/manager/.changeset/pr-12755-tests-1756140280158.md new file mode 100644 index 00000000000..cc1d3d3d048 --- /dev/null +++ b/packages/manager/.changeset/pr-12755-tests-1756140280158.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress error handling test coverage for LKE-E Phase 2 (BYO VPC, IP Stack) ([#12755](https://github.com/linode/manager/pull/12755)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts index 7f40094871a..78dc2c33408 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts @@ -15,6 +15,7 @@ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetLinodeTypes } from 'support/intercepts/linodes'; import { mockCreateCluster, + mockCreateClusterError, mockGetKubernetesVersions, mockGetLKEClusterTypes, mockGetTieredKubernetesVersions, @@ -31,35 +32,91 @@ import { vpcFactory, } from 'src/factories'; +const clusterLabel = randomLabel(); +const selectedVpcId = 1; +const selectedSubnetId = 1; + +const mockEnterpriseCluster = kubernetesClusterFactory.build({ + k8s_version: latestEnterpriseTierKubernetesVersion.id, + label: clusterLabel, + region: 'us-iad', + tier: 'enterprise', +}); + +const mockVpcs = [ + { + ...vpcFactory.build(), + id: selectedVpcId, + label: 'test-vpc', + region: 'us-iad', + subnets: [ + subnetFactory.build({ + id: selectedSubnetId, + label: 'subnet-a', + ipv4: '10.0.0.0/13', + }), + ], + }, +]; + describe('LKE Cluster Creation with LKE-E', () => { - describe('LKE-E Phase 2 Networking Configurations', () => { - const clusterLabel = randomLabel(); - const selectedVpcId = 1; - const selectedSubnetId = 1; - - const mockEnterpriseCluster = kubernetesClusterFactory.build({ - k8s_version: latestEnterpriseTierKubernetesVersion.id, - label: clusterLabel, - region: 'us-iad', - tier: 'enterprise', - }); + beforeEach(() => { + // TODO LKE-E: Remove feature flag mocks once we're in GA + mockAppendFeatureFlags({ + lkeEnterprise: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: true, + }, + }).as('getFeatureFlags'); + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); - const mockVpcs = [ - { - ...vpcFactory.build(), - id: selectedVpcId, - label: 'test-vpc', - region: 'us-iad', - subnets: [ - subnetFactory.build({ - id: selectedSubnetId, - label: 'subnet-a', - ipv4: '10.0.0.0/13', - }), + mockGetTieredKubernetesVersions('enterprise', [ + latestEnterpriseTierKubernetesVersion, + ]).as('getTieredKubernetesVersions'); + mockGetKubernetesVersions([latestKubernetesVersion]).as( + 'getKubernetesVersions' + ); + + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); + mockGetLKEClusterTypes(mockedLKEEnterprisePrices).as( + 'getLKEEnterpriseClusterTypes' + ); + mockCreateCluster(mockEnterpriseCluster).as('createCluster'); + + mockGetRegions([ + regionFactory.build({ + capabilities: [ + 'Linodes', + 'Kubernetes', + 'Kubernetes Enterprise', + 'VPCs', ], - }, - ]; + id: 'us-iad', + label: 'Washington, DC', + }), + ]).as('getRegions'); + + mockGetVPCs(mockVpcs).as('getVPCs'); + cy.visitWithLogin('/kubernetes/clusters'); + cy.wait(['@getAccount']); + + ui.button.findByTitle('Create Cluster').click(); + cy.url().should('endWith', '/kubernetes/create'); + cy.wait([ + '@getKubernetesVersions', + '@getTieredKubernetesVersions', + '@getLinodeTypes', + ]); + }); + + describe('LKE-E Phase 2 Networking Configurations', () => { // Accounts for the different combination of IP Networking and VPC/Subnet radio selections const possibleNetworkingConfigurations = [ { @@ -88,62 +145,6 @@ describe('LKE Cluster Creation with LKE-E', () => { }, ]; - beforeEach(() => { - // TODO LKE-E: Remove feature flag mocks once we're in GA - mockAppendFeatureFlags({ - lkeEnterprise: { - enabled: true, - la: true, - postLa: false, - phase2Mtc: true, - }, - }).as('getFeatureFlags'); - mockGetAccount( - accountFactory.build({ - capabilities: ['Kubernetes Enterprise'], - }) - ).as('getAccount'); - - mockGetTieredKubernetesVersions('enterprise', [ - latestEnterpriseTierKubernetesVersion, - ]).as('getTieredKubernetesVersions'); - mockGetKubernetesVersions([latestKubernetesVersion]).as( - 'getKubernetesVersions' - ); - - mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); - mockGetLKEClusterTypes(mockedLKEEnterprisePrices).as( - 'getLKEEnterpriseClusterTypes' - ); - mockCreateCluster(mockEnterpriseCluster).as('createCluster'); - - mockGetRegions([ - regionFactory.build({ - capabilities: [ - 'Linodes', - 'Kubernetes', - 'Kubernetes Enterprise', - 'VPCs', - ], - id: 'us-iad', - label: 'Washington, DC', - }), - ]).as('getRegions'); - - mockGetVPCs(mockVpcs).as('getVPCs'); - - cy.visitWithLogin('/kubernetes/clusters'); - cy.wait(['@getAccount']); - - ui.button.findByTitle('Create Cluster').click(); - cy.url().should('endWith', '/kubernetes/create'); - cy.wait([ - '@getKubernetesVersions', - '@getTieredKubernetesVersions', - '@getLinodeTypes', - ]); - }); - possibleNetworkingConfigurations.forEach( ({ description, isUsingOwnVPC, stackType }) => { it(`${description}`, () => { @@ -158,9 +159,7 @@ describe('LKE Cluster Creation with LKE-E', () => { // Select either the autogenerated or existing (BYO) VPC radio button if (isUsingOwnVPC) { - cy.findByTestId('isUsingOwnVpc').within(() => { - cy.findByLabelText('Use an existing VPC').click(); - }); + cy.findByLabelText('Use an existing VPC').click(); // Select the existing VPC and Subnet to use ui.autocomplete.findByLabel('VPC').click(); @@ -223,4 +222,178 @@ describe('LKE Cluster Creation with LKE-E', () => { } ); }); + + describe('LKE-E Cluster Error Handling', () => { + /* + * Surfaces an API errors on the page. + */ + it('surfaces API error when creating cluster with an invalid configuration', () => { + const mockErrorMessage = + 'There was a general error when creating your cluster.'; + + mockGetVPCs(mockVpcs).as('getVPCs'); + mockCreateClusterError(mockErrorMessage).as('createClusterError'); + + cy.findByLabelText('Cluster Label').type(clusterLabel); + cy.findByText('LKE Enterprise').click(); + cy.wait(['@getLKEEnterpriseClusterTypes', '@getRegions']); + + ui.regionSelect.find().clear().type('Washington, DC{enter}'); + cy.wait('@getVPCs'); + + cy.findByLabelText( + 'Automatically generate a VPC for this cluster' + ).click(); + cy.findByLabelText('IPv4 + IPv6 (dual-stack)').click(); + + // Bypass ACL validation + cy.get('input[name="acl-acknowledgement"]').check(); + + // Select a plan and add nodes + cy.findByText(clusterPlans[0].tab).should('be.visible').click(); + cy.findByText(clusterPlans[0].planName) + .should('be.visible') + .closest('tr') + .within(() => { + cy.get('[name="Quantity"]').should('be.visible').click(); + cy.focused().type(`{selectall}${clusterPlans[0].nodeCount}`); + + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + ui.button.findByTitle('Create Cluster').click(); + }); + + cy.wait('@createClusterError'); + cy.findByText(mockErrorMessage).should('be.visible'); + }); + + /** + * Surfaces field-level errors on the page. + */ + it('surfaces field-level errors on VPC fields', () => { + // Intercept the create cluster request and force an error response + cy.intercept('POST', '/v4beta/lke/clusters', { + statusCode: 400, + body: { + errors: [ + { + reason: 'There is an error configuring this VPC.', + field: 'vpc_id', + }, + { + reason: 'There is an error configuring this subnet.', + field: 'subnet_id', + }, + ], + }, + }).as('createClusterError'); + + cy.findByLabelText('Cluster Label').type(clusterLabel); + cy.findByText('LKE Enterprise').click(); + + // Select region, VPC, subnet, and IP stack + ui.regionSelect.find().clear().type('Washington, DC{enter}'); + cy.findByLabelText('Use an existing VPC').click(); + ui.autocomplete.findByLabel('VPC').click(); + cy.findByText('test-vpc').click(); + ui.autocomplete.findByLabel('Subnet').click(); + cy.findByText(/subnet-a/).click(); + cy.findByLabelText('IPv4 + IPv6 (dual-stack)').click(); + + // Bypass ACL validation + cy.get('input[name="acl-acknowledgement"]').check(); + + // Select a plan and add nodes + cy.findByText(clusterPlans[0].tab).should('be.visible').click(); + cy.findByText(clusterPlans[0].planName) + .should('be.visible') + .closest('tr') + .within(() => { + cy.get('[name="Quantity"]').should('be.visible').click(); + cy.focused().type(`{selectall}${clusterPlans[0].nodeCount}`); + + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Try to submit the form + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + ui.button.findByTitle('Create Cluster').click(); + }); + + // Confirm error messages display + cy.wait('@createClusterError'); + cy.findByText('There is an error configuring this VPC.').should( + 'be.visible' + ); + cy.findByText('There is an error configuring this subnet.').should( + 'be.visible' + ); + }); + + /* + * Surfaces client-side validation error for VPC selection. + */ + it('surfaces a client-side validation error when BYO VPC is selected but no VPC is chosen', () => { + mockGetVPCs(mockVpcs).as('getVPCs'); + const errorText = + 'You must either select a VPC or select automatic VPC generation.'; + + cy.findByLabelText('Cluster Label').type(clusterLabel); + cy.findByText('LKE Enterprise').click(); + cy.wait(['@getLKEEnterpriseClusterTypes', '@getRegions']); + + ui.regionSelect.find().clear().type('Washington, DC{enter}'); + cy.wait('@getVPCs'); + + // Bypass ACL validation + cy.get('input[name="acl-acknowledgement"]').check(); + + // Select a plan and add nodes + cy.findByText(clusterPlans[0].tab).should('be.visible').click(); + cy.findByText(clusterPlans[0].planName) + .should('be.visible') + .closest('tr') + .within(() => { + cy.get('[name="Quantity"]').should('be.visible').click(); + cy.focused().type(`{selectall}${clusterPlans[0].nodeCount}`); + + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Select the 'bring your own' VPC option + cy.findByLabelText('Use an existing VPC').click(); + + // Try to create the cluster without actually selecting a VPC + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + ui.button.findByTitle('Create Cluster').click(); + }); + + // Confirm error surfaces on the VPC field + cy.findByText(errorText).should('be.visible'); + + // Confirm switching to an autogenerated VPC clears the error + cy.findByLabelText( + 'Automatically generate a VPC for this cluster' + ).click(); + cy.findByText(errorText).should('not.exist'); + + // Confirm the error stays cleared when switching back to the existing VPC option + cy.findByLabelText('Use an existing VPC').click(); + cy.findByText(errorText).should('not.exist'); + }); + }); }); diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx index de8be62216e..9b316dcf2c1 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx @@ -83,7 +83,6 @@ export const ClusterNetworkingPanel = (props: Props) => { ) => { setIsUsingOwnVpc(e.target.value === 'yes'); From 0e5181962f02420f92d13e9d440c45d4a32bc7be Mon Sep 17 00:00:00 2001 From: Ankita Date: Tue, 26 Aug 2025 12:33:52 +0530 Subject: [PATCH 09/73] [DI-26971] - Add no region info msg for nodebalancer and firewall (#12759) * [DI-26971] - Add no region info msg for nodebalancer and firewall * [DI-26971] - Correct nodebalancer msg * [DI-26971] - Update msgs * [DI-26971] - Remove duplicate * [DI-26971] - Add changeset --------- Co-authored-by: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> --- ...r-12759-upcoming-features-1756119201066.md | 5 ++ .../features/CloudPulse/Utils/constants.ts | 5 +- .../shared/CloudPulseRegionSelect.test.tsx | 79 ++++++++++++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-12759-upcoming-features-1756119201066.md diff --git a/packages/manager/.changeset/pr-12759-upcoming-features-1756119201066.md b/packages/manager/.changeset/pr-12759-upcoming-features-1756119201066.md new file mode 100644 index 00000000000..f14982f18e2 --- /dev/null +++ b/packages/manager/.changeset/pr-12759-upcoming-features-1756119201066.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse - Metrics: Add/Update 'no region' info message for all services in `constants.ts` ([#12759](https://github.com/linode/manager/pull/12759)) diff --git a/packages/manager/src/features/CloudPulse/Utils/constants.ts b/packages/manager/src/features/CloudPulse/Utils/constants.ts index bac20ace791..032cb3bb59a 100644 --- a/packages/manager/src/features/CloudPulse/Utils/constants.ts +++ b/packages/manager/src/features/CloudPulse/Utils/constants.ts @@ -78,9 +78,12 @@ export const INTERFACE_IDS_LIMIT_ERROR_MESSAGE = 'Enter a maximum of 15 interface ID numbers'; export const INTERFACE_IDS_PLACEHOLDER_TEXT = 'e.g., 1234,5678'; + export const NO_REGION_MESSAGE: Record = { dbaas: 'No database clusters configured in any regions.', - linode: 'No linodes configured in any regions.', + linode: 'No Linodes configured in any regions.', + nodebalancer: 'No NodeBalancers configured in any regions.', + firewall: 'No firewalls configured in any Linode regions.', }; export const HELPER_TEXT: Record = { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx index 77a3e608ddb..5912a0521f3 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx @@ -1,5 +1,9 @@ import { capabilityServiceTypeMapping } from '@linode/api-v4'; -import { linodeFactory, regionFactory } from '@linode/utilities'; +import { + linodeFactory, + nodeBalancerFactory, + regionFactory, +} from '@linode/utilities'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; @@ -7,6 +11,7 @@ import * as React from 'react'; import { dashboardFactory, databaseInstanceFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; +import { NO_REGION_MESSAGE } from '../Utils/constants'; import { CloudPulseRegionSelect } from './CloudPulseRegionSelect'; import type { CloudPulseRegionSelectProps } from './CloudPulseRegionSelect'; @@ -208,4 +213,76 @@ describe('CloudPulseRegionSelect', () => { }) ).toBeNull(); }); + + it('should render a Region Select component with correct info message when no regions are available for dbaas service type', async () => { + const user = userEvent.setup(); + queryMocks.useResourcesQuery.mockReturnValue({ + data: databaseInstanceFactory.buildList(3, { + region: 'ap-west', + }), + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + await user.click(screen.getByRole('button', { name: 'Open' })); + expect(screen.getByText(NO_REGION_MESSAGE['dbaas'])).toBeVisible(); + }); + + it('should render a Region Select component with correct info message when no regions are available for linode service type', async () => { + const user = userEvent.setup(); + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodeFactory.buildList(3, { + region: 'ap-west', + }), + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + await user.click(screen.getByRole('button', { name: 'Open' })); + expect(screen.getByText(NO_REGION_MESSAGE['linode'])).toBeVisible(); + }); + + it('should render a Region Select component with correct info message when no regions are available for nodebalancer service type', async () => { + const user = userEvent.setup(); + queryMocks.useResourcesQuery.mockReturnValue({ + data: nodeBalancerFactory.buildList(3, { + region: 'ap-west', + }), + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + await user.click(screen.getByRole('button', { name: 'Open' })); + expect(screen.getByText(NO_REGION_MESSAGE['nodebalancer'])).toBeVisible(); + }); + + it('should render a Region Select component with correct info message when no regions are available for firewall service type', async () => { + const user = userEvent.setup(); + // There are no aclp supported regions for firewall service type as returned by useRegionsQuery above + renderWithTheme( + + ); + await user.click(screen.getByRole('button', { name: 'Open' })); + expect(screen.getByText(NO_REGION_MESSAGE['firewall'])).toBeVisible(); + }); }); From 24bc94f80bbdc39f0387f4728658ff12b5c5844e Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 26 Aug 2025 10:04:26 +0200 Subject: [PATCH 10/73] chore: [UIE-9124] Improve `usePermissions` hook type safety (#12732) * improve usePermissions type safety and cleanup accout landing routing * oops not ready to do this yet * Added changeset: Improve usePermissions hook type safety * cleanup * feedback @aaleksee-akamai * feedback @corya-akamai * feedback @aaleksee-akamai --- .../pr-12732-tech-stories-1755696277128.md | 5 +++++ .../features/EntityTransfers/RenderTransferRow.tsx | 5 +++-- .../TransfersPendingActionMenu.test.tsx | 8 +++++++- .../EntityTransfers/TransfersPendingActionMenu.tsx | 4 ++-- .../features/EntityTransfers/TransfersTable.tsx | 9 ++++++++- .../IAM/hooks/adapters/permissionAdapters.ts | 6 +++--- .../src/features/IAM/hooks/usePermissions.ts | 14 +++++++------- .../Linodes/LinodesLanding/LinodesLanding.tsx | 7 +++++-- .../Profile/OAuthClients/OAuthClientActionMenu.tsx | 7 ++++++- 9 files changed, 46 insertions(+), 19 deletions(-) create mode 100644 packages/manager/.changeset/pr-12732-tech-stories-1755696277128.md diff --git a/packages/manager/.changeset/pr-12732-tech-stories-1755696277128.md b/packages/manager/.changeset/pr-12732-tech-stories-1755696277128.md new file mode 100644 index 00000000000..4a78410f940 --- /dev/null +++ b/packages/manager/.changeset/pr-12732-tech-stories-1755696277128.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Improve usePermissions hook type safety ([#12732](https://github.com/linode/manager/pull/12732)) diff --git a/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx b/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx index 7b9bdbc6710..124b873abc9 100644 --- a/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx +++ b/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx @@ -18,7 +18,8 @@ import { } from './RenderTransferRow.styles'; import { TransfersPendingActionMenu } from './TransfersPendingActionMenu'; -import type { PermissionType, TransferEntities } from '@linode/api-v4'; +import type { TransfersPermissions } from './TransfersTable'; +import type { TransferEntities } from '@linode/api-v4'; interface Props { created: string; entities: TransferEntities; @@ -28,7 +29,7 @@ interface Props { entities: TransferEntities ) => void; handleTokenClick: (token: string, entities: TransferEntities) => void; - permissions?: Record; + permissions?: Record; status?: string; token: string; transferType?: 'pending' | 'received' | 'sent'; diff --git a/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.test.tsx b/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.test.tsx index 1c780b34de3..b04242a8b96 100644 --- a/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.test.tsx +++ b/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.test.tsx @@ -8,6 +8,8 @@ const queryMocks = vi.hoisted(() => ({ userPermissions: vi.fn(() => ({ data: { cancel_service_transfer: false, + accept_service_transfer: false, + create_service_transfer: false, }, })), })); @@ -35,7 +37,11 @@ describe('TransfersPendingActionMenu', () => { it('should enable "Cancel" button if the user has cancel_service_transfer permission', async () => { queryMocks.userPermissions.mockReturnValue({ - data: { cancel_service_transfer: true }, + data: { + cancel_service_transfer: true, + accept_service_transfer: false, + create_service_transfer: false, + }, }); const { getByRole } = renderWithTheme( diff --git a/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.tsx b/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.tsx index c1916b49afd..a91ed8b5457 100644 --- a/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.tsx +++ b/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.tsx @@ -3,12 +3,12 @@ import * as React from 'react'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import type { PermissionType } from '@linode/api-v4'; +import type { TransfersPermissions } from './TransfersTable'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; interface Props { onCancelClick: () => void; - permissions?: Partial>; + permissions?: Record; } export const TransfersPendingActionMenu = (props: Props) => { diff --git a/packages/manager/src/features/EntityTransfers/TransfersTable.tsx b/packages/manager/src/features/EntityTransfers/TransfersTable.tsx index fc55a31b867..f1ccffc65c7 100644 --- a/packages/manager/src/features/EntityTransfers/TransfersTable.tsx +++ b/packages/manager/src/features/EntityTransfers/TransfersTable.tsx @@ -22,6 +22,7 @@ import type { TransferEntities, } from '@linode/api-v4/lib/types'; +type PermissionsSubset = T; interface Props { error: APIError[] | null; handlePageChange: (v: number, showSpinner?: boolean | undefined) => void; @@ -29,12 +30,18 @@ interface Props { isLoading: boolean; page: number; pageSize: number; - permissions?: Record; + permissions?: Record; results: number; transfers?: EntityTransfer[]; transferType: 'pending' | 'received' | 'sent'; } +export type TransfersPermissions = PermissionsSubset< + | 'accept_service_transfer' + | 'cancel_service_transfer' + | 'create_service_transfer' +>; + export const TransfersTable = React.memo((props: Props) => { const { error, diff --git a/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts b/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts index b33d7c6c170..85efddb1d06 100644 --- a/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts +++ b/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts @@ -50,7 +50,7 @@ export const entityPermissionMapFrom = ( /** Convert the existing Grant model to the new IAM RBAC model. */ export const fromGrants = ( accessType: AccessType, - permissionsToCheck: PermissionType[], + permissionsToCheck: readonly PermissionType[], grants?: Grants, isRestricted?: boolean, entityId?: number @@ -97,7 +97,7 @@ export const fromGrants = ( export const toEntityPermissionMap = ( entities: EntityBase[] | undefined, entitiesPermissions: (PermissionType[] | undefined)[] | undefined, - permissionsToCheck: PermissionType[], + permissionsToCheck: readonly PermissionType[], isRestricted?: boolean ): EntityPermissionMap => { const entityPermissionsMap: EntityPermissionMap = {}; @@ -118,7 +118,7 @@ export const toEntityPermissionMap = ( /** Combines the permissions a user wants to check with the permissions returned from the backend */ export const toPermissionMap = ( - permissionsToCheck: PermissionType[], + permissionsToCheck: readonly PermissionType[], usersPermissions: PermissionType[], isRestricted?: boolean ): PermissionMap => { diff --git a/packages/manager/src/features/IAM/hooks/usePermissions.ts b/packages/manager/src/features/IAM/hooks/usePermissions.ts index 06e43dafb6a..6cd6fdbe73a 100644 --- a/packages/manager/src/features/IAM/hooks/usePermissions.ts +++ b/packages/manager/src/features/IAM/hooks/usePermissions.ts @@ -28,16 +28,16 @@ import type { } from '@linode/api-v4'; import type { UseQueryResult } from '@linode/queries'; -export type PermissionsResult = { - data: Record; +export type PermissionsResult = { + data: Record; } & Omit, 'data'>; -export const usePermissions = ( +export const usePermissions = ( accessType: AccessType, - permissionsToCheck: PermissionType[], + permissionsToCheck: T, entityId?: number, enabled: boolean = true -): PermissionsResult => { +): PermissionsResult => { const { isIAMEnabled } = useIsIAMEnabled(); const { data: userAccountPermissions, ...restAccountPermissions } = @@ -45,11 +45,11 @@ export const usePermissions = ( isIAMEnabled && accessType === 'account' && enabled ); - const { data: userEntityPermisssions, ...restEntityPermissions } = + const { data: userEntityPermissions, ...restEntityPermissions } = useUserEntityPermissions(accessType, entityId!, isIAMEnabled && enabled); const usersPermissions = - accessType === 'account' ? userAccountPermissions : userEntityPermisssions; + accessType === 'account' ? userAccountPermissions : userEntityPermissions; const { data: profile } = useProfile(); const { data: grants } = useGrants(!isIAMEnabled && enabled); diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx index 6d5342c218a..82c8e6027d2 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx @@ -39,7 +39,7 @@ import { LinodesLandingEmptyState } from './LinodesLandingEmptyState'; import { ListView } from './ListView'; import type { Action } from '../PowerActionsDialogOrDrawer'; -import type { Config } from '@linode/api-v4/lib/linodes/types'; +import type { Config, PermissionType } from '@linode/api-v4/lib/linodes/types'; import type { APIError } from '@linode/api-v4/lib/types'; import type { AnyRouter, @@ -77,6 +77,9 @@ export interface LinodeHandlers { onOpenResizeDialog: () => void; } +type PermissionsSubset = T; +type LinodesPermissions = PermissionsSubset<'create_linode'>; + export interface LinodesLandingProps { filteredLinodesLoading: boolean; handleRegionFilter: (regionFilter: RegionFilter) => void; @@ -92,7 +95,7 @@ export interface LinodesLandingProps { orderBy: string; sortedData: LinodeWithMaintenance[] | null; }; - permissions: Record; + permissions: Record; regionFilter: RegionFilter; search: SearchParamOptions['search']; someLinodesHaveScheduledMaintenance: boolean; diff --git a/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx b/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx index c201a319858..c3b5dee843a 100644 --- a/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx @@ -9,12 +9,17 @@ import type { PermissionType } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; +type PermissionsSubset = T; +type OAuthClientPermissions = PermissionsSubset< + 'delete_oauth_client' | 'reset_oauth_client_secret' | 'update_oauth_client' +>; + interface Props { label: string; onOpenDeleteDialog: () => void; onOpenEditDrawer: () => void; onOpenResetDialog: () => void; - permissions: Partial>; + permissions: Record; } export const OAuthClientActionMenu = (props: Props) => { From 2547827f2ea79588b0f0a578684caf277422a3d7 Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Tue, 26 Aug 2025 09:20:01 -0400 Subject: [PATCH 11/73] fix: [M3-10496] - Fix LKE jumping UI on HA Control Plane (#12768) * fix jumping * Added changeset: Jumping UI on LKE Create HA Control Plane when enabling Akamai App Platform * Update packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> * add margin left --------- Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> --- packages/manager/.changeset/pr-12768-fixed-1756151182727.md | 5 +++++ .../src/features/Kubernetes/CreateCluster/HAControlPlane.tsx | 1 + 2 files changed, 6 insertions(+) create mode 100644 packages/manager/.changeset/pr-12768-fixed-1756151182727.md diff --git a/packages/manager/.changeset/pr-12768-fixed-1756151182727.md b/packages/manager/.changeset/pr-12768-fixed-1756151182727.md new file mode 100644 index 00000000000..95287333dd0 --- /dev/null +++ b/packages/manager/.changeset/pr-12768-fixed-1756151182727.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Jumping UI on LKE Create HA Control Plane when enabling Akamai App Platform ([#12768](https://github.com/linode/manager/pull/12768)) diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx index ce6aafecf16..f6a8b3da7e4 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx @@ -104,6 +104,7 @@ export const HAControlPlane = (props: HAControlPlaneProps) => { {isAPLEnabled && ( Date: Wed, 27 Aug 2025 09:45:58 +0200 Subject: [PATCH 12/73] fix: [UI-8803] - IAM Cross browser AssignedRoles entities chips truncation (#12720) * fix some * improve ellipsis pattern * cleanup * cleanup * Added changeset: IAM - Cross browser AssignedRoles entities chips truncation --- .../pr-12720-fixed-1755606133614.md | 5 ++ .../IAM/Users/UserRoles/AssignedEntities.tsx | 47 ++++++++++++++----- 2 files changed, 40 insertions(+), 12 deletions(-) create mode 100644 packages/manager/.changeset/pr-12720-fixed-1755606133614.md diff --git a/packages/manager/.changeset/pr-12720-fixed-1755606133614.md b/packages/manager/.changeset/pr-12720-fixed-1755606133614.md new file mode 100644 index 00000000000..62505f37f74 --- /dev/null +++ b/packages/manager/.changeset/pr-12720-fixed-1755606133614.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM - Cross browser AssignedRoles entities chips truncation ([#12720](https://github.com/linode/manager/pull/12720)) diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx index 9bbf6fb060a..357a5bdc8e3 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx @@ -24,13 +24,15 @@ export const AssignedEntities = ({ useCalculateHiddenItems(role.entity_names!); const handleResize = React.useMemo( - () => debounce(() => calculateHiddenItems(), 100), + () => debounce(() => calculateHiddenItems(), 250), [calculateHiddenItems] ); React.useEffect(() => { - // Ensure calculateHiddenItems runs after layout stabilization on initial render - const rafId = requestAnimationFrame(() => calculateHiddenItems()); + // Double RAF for good measure - see https://stackoverflow.com/questions/44145740/how-does-double-requestanimationframe-work + const rafId = requestAnimationFrame(() => { + requestAnimationFrame(() => calculateHiddenItems()); + }); window.addEventListener('resize', handleResize); @@ -49,14 +51,27 @@ export const AssignedEntities = ({ [role.entity_names, role.entity_ids] ); + const isLastVisibleItem = React.useCallback( + (index: number) => { + return combinedEntities.length - numHiddenItems - 1 === index; + }, + [combinedEntities.length, numHiddenItems] + ); + const items = combinedEntities?.map( (entity: CombinedEntity, index: number) => ( -
{ itemRefs.current[index] = el; }} - style={{ display: 'inline-block', marginRight: 8 }} + sx={{ + display: 'inline', + marginRight: + numHiddenItems > 0 && isLastVisibleItem(index) + ? theme.tokens.spacing.S16 + : theme.tokens.spacing.S8, + }} > 0 && isLastVisibleItem(index) ? '"..."' : '""', + position: 'absolute', + top: 0, + right: -16, + width: 14, + }, }} /> -
+
) ); @@ -87,19 +111,18 @@ export const AssignedEntities = ({ sx={{ alignItems: 'center', display: 'flex', + position: 'relative', }} > -
{items} -
+
{numHiddenItems > 0 && ( Date: Wed, 27 Aug 2025 13:55:05 +0200 Subject: [PATCH 13/73] feat: [UIE-9055] - IAM RBAC: change docs links (#12743) * feat: [UIE-9055] - IAM RBAC: change docs links * Added changeset: IAM RBAC: change docs links to be relevant to content on page --- .../.changeset/pr-12743-changed-1755774240085.md | 5 +++++ packages/manager/src/features/IAM/IAMLanding.tsx | 4 ++-- .../manager/src/features/IAM/Shared/constants.ts | 9 +++++++++ .../src/features/IAM/Users/UserDetailsLanding.tsx | 12 ++++++++++-- 4 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 packages/manager/.changeset/pr-12743-changed-1755774240085.md diff --git a/packages/manager/.changeset/pr-12743-changed-1755774240085.md b/packages/manager/.changeset/pr-12743-changed-1755774240085.md new file mode 100644 index 00000000000..9907d32d705 --- /dev/null +++ b/packages/manager/.changeset/pr-12743-changed-1755774240085.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +IAM RBAC: change docs links to be relevant to content on page ([#12743](https://github.com/linode/manager/pull/12743)) diff --git a/packages/manager/src/features/IAM/IAMLanding.tsx b/packages/manager/src/features/IAM/IAMLanding.tsx index 2fb511dac4f..9e340bba786 100644 --- a/packages/manager/src/features/IAM/IAMLanding.tsx +++ b/packages/manager/src/features/IAM/IAMLanding.tsx @@ -8,7 +8,7 @@ import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import { useTabs } from 'src/hooks/useTabs'; -import { IAM_DOCS_LINK } from './Shared/constants'; +import { IAM_DOCS_LINK, ROLES_LEARN_MORE_LINK } from './Shared/constants'; export const IdentityAccessLanding = React.memo(() => { const location = useLocation(); @@ -29,7 +29,7 @@ export const IdentityAccessLanding = React.memo(() => { breadcrumbProps: { pathname: '/iam', }, - docsLink: IAM_DOCS_LINK, + docsLink: tabIndex === 0 ? IAM_DOCS_LINK : ROLES_LEARN_MORE_LINK, entity: 'Identity and Access', title: 'Identity and Access', }; diff --git a/packages/manager/src/features/IAM/Shared/constants.ts b/packages/manager/src/features/IAM/Shared/constants.ts index 0b5d18d52b3..195083ef74f 100644 --- a/packages/manager/src/features/IAM/Shared/constants.ts +++ b/packages/manager/src/features/IAM/Shared/constants.ts @@ -22,6 +22,15 @@ export const IAM_DOCS_LINK = export const ROLES_LEARN_MORE_LINK = 'https://techdocs.akamai.com/cloud-computing/docs/identity-access-cm-available-roles'; +export const USER_DETAILS_LINK = + 'https://techdocs.akamai.com/cloud-computing/docs/identity-access-cm-manage-access'; + +export const USER_ROLES_LINK = + 'https://techdocs.akamai.com/cloud-computing/docs/identity-access-cm-manage-access#check-and-update-users-role-assignment'; + +export const USER_ENTITIES_LINK = + 'https://techdocs.akamai.com/cloud-computing/docs/identity-access-cm-manage-access#check-and-update-users-entity-assignment'; + export const PAID_ENTITY_TYPES = [ 'database', 'linode', diff --git a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx index 26c7059e0a9..ffda5a30755 100644 --- a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx @@ -7,7 +7,12 @@ import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import { useTabs } from 'src/hooks/useTabs'; -import { IAM_DOCS_LINK, IAM_LABEL } from '../Shared/constants'; +import { + IAM_LABEL, + USER_DETAILS_LINK, + USER_ENTITIES_LINK, + USER_ROLES_LINK, +} from '../Shared/constants'; export const UserDetailsLanding = () => { const { username } = useParams({ from: '/iam/users/$username' }); @@ -26,6 +31,9 @@ export const UserDetailsLanding = () => { }, ]); + const docsLinks = [USER_DETAILS_LINK, USER_ROLES_LINK, USER_ENTITIES_LINK]; + const docsLink = docsLinks[tabIndex] ?? USER_DETAILS_LINK; + return ( <> { }, pathname: location.pathname, }} - docsLink={IAM_DOCS_LINK} + docsLink={docsLink} removeCrumbX={4} spacingBottom={4} title={username} From c71938cf71e40cb98d226ced5cd27294353f0ca1 Mon Sep 17 00:00:00 2001 From: dmcintyr-akamai Date: Wed, 27 Aug 2025 07:37:46 -0700 Subject: [PATCH 14/73] test: [M3-10433] - Test for empty string in numeric input validation (#12769) * numeric input validation test * verify toggle buttons are not disabled by error * Added changeset: Test for empty string in numeric input validation * test for full error msg --- .../pr-12769-tests-1756151657293.md | 5 ++ .../e2e/core/linodes/alerts-edit.spec.ts | 69 ++++++++++++++++--- 2 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 packages/manager/.changeset/pr-12769-tests-1756151657293.md diff --git a/packages/manager/.changeset/pr-12769-tests-1756151657293.md b/packages/manager/.changeset/pr-12769-tests-1756151657293.md new file mode 100644 index 00000000000..4f9660c98bc --- /dev/null +++ b/packages/manager/.changeset/pr-12769-tests-1756151657293.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Test for empty string in numeric input validation ([#12769](https://github.com/linode/manager/pull/12769)) diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts index 0bbe2c41235..944113a325b 100644 --- a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts @@ -119,7 +119,7 @@ describe('region enables alerts', function () { ); }); - it('Legacy alerts = 0, Beta alerts = [] => legacy disabled', function () { + xit('Legacy alerts = 0, Beta alerts = [] => legacy disabled', function () { const mockLinode = linodeFactory.build({ id: MOCK_LINODE_ID, label: randomLabel(), @@ -180,7 +180,7 @@ describe('region enables alerts', function () { }); }); - it('Legacy alerts > 0, Beta alerts = [] => legacy enabled. can upgrade to beta enabled', function () { + xit('Legacy alerts > 0, Beta alerts = [] => legacy enabled. can upgrade to beta enabled', function () { const mockLinode = linodeFactory.build({ id: MOCK_LINODE_ID, label: randomLabel(), @@ -243,7 +243,7 @@ describe('region enables alerts', function () { }); }); - it('Legacy alerts = 0, Beta alerts > 0, => beta enabled', function () { + xit('Legacy alerts = 0, Beta alerts > 0, => beta enabled', function () { const mockLinode = linodeFactory.build({ id: 2, label: randomLabel(), @@ -310,7 +310,7 @@ describe('region enables alerts', function () { }); }); - it('Legacy alerts > 0, Beta alerts > 0, => beta enabled. can downgrade to legacy enabled', function () { + xit('Legacy alerts > 0, Beta alerts > 0, => beta enabled. can downgrade to legacy enabled', function () { const mockLinode = linodeFactory.build({ id: MOCK_LINODE_ID, label: randomLabel(), @@ -383,7 +383,7 @@ describe('region enables alerts', function () { }); }); - it('in default beta mode, edits to beta alerts do not trigger confirmation modal', function () { + xit('in default beta mode, edits to beta alerts do not trigger confirmation modal', function () { const mockLinode = linodeFactory.build({ id: 2, label: randomLabel(), @@ -422,7 +422,7 @@ describe('region enables alerts', function () { ui.dialog.find().should('not.exist'); }); - it('in default legacy mode, edits to beta alerts trigger confirmation modal ', function () { + xit('in default legacy mode, edits to beta alerts trigger confirmation modal ', function () { const mockLinode = linodeFactory.build({ id: 2, label: randomLabel(), @@ -536,7 +536,7 @@ describe('region disables alerts. beta alerts not available regardless of linode mockGetRegions([mockDisabledRegion]).as('getRegions'); }); - it('Legacy alerts = 0, Beta alerts > 0, => legacy disabled', function () { + xit('Legacy alerts = 0, Beta alerts > 0, => legacy disabled', function () { const mockLinode = linodeFactory.build({ id: MOCK_LINODE_ID, label: randomLabel(), @@ -572,7 +572,7 @@ describe('region disables alerts. beta alerts not available regardless of linode }); }); - it('Legacy alerts > 0, Beta alerts = 0, => legacy enabled', function () { + xit('Legacy alerts > 0, Beta alerts = 0, => legacy enabled', function () { const mockLinode = linodeFactory.build({ id: MOCK_LINODE_ID, label: randomLabel(), @@ -604,4 +604,57 @@ describe('region disables alerts. beta alerts not available regardless of linode }); }); }); + + it('Deleting entire value in numeric input triggers validation error', function () { + const mockLinode = linodeFactory.build({ + id: MOCK_LINODE_ID, + label: randomLabel(), + region: this.mockDisabledRegion.id, + alerts: { ...mockEnabledLegacyAlerts }, + }); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + cy.visitWithLogin(`/linodes/${mockLinode.id}/alerts`); + cy.wait(['@getFeatureFlags', '@getRegions', '@getLinode']); + ui.tabList.findTabByTitle('Alerts').within(() => { + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + cy.get('[data-reach-tab-panels]') + .should('be.visible') + .within(() => { + const strNumericInputSelector = 'input[data-testid="textfield-input"]'; + // each data-qa-alerts-panel contains a toggle button and a numeric input + cy.get('[data-qa-alerts-panel="true"]').each((panel) => { + cy.wrap(panel).within(() => { + // toggle button is enabled + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'true') + .should('be.visible') + .should('be.enabled'); + cy.get('label[data-qa-alert]') + .invoke('attr', 'data-qa-alert') + .then((lbl) => { + cy.get(strNumericInputSelector).clear(); + cy.get(strNumericInputSelector).blur(); + // error appears in numeric input + cy.get('p[data-qa-textfield-error-text]') + .should('be.visible') + .then(($err) => { + // use the toggle button's label to get the full error msg + expect($err).to.contain(`${lbl} is required.`); + }); + // toggle button is not disabled by the error + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'true') + .should('be.enabled'); + cy.get(strNumericInputSelector).click(); + cy.get(strNumericInputSelector).type('1'); + // error is removed + cy.get('p[data-qa-textfield-error-text]').should('not.exist'); + }); + }); + }); + }); + }); }); From 9e91b2bc249d54760e982277d308634d6d8cbdda Mon Sep 17 00:00:00 2001 From: cliu-akamai <126020611+cliu-akamai@users.noreply.github.com> Date: Wed, 27 Aug 2025 12:36:10 -0400 Subject: [PATCH 15/73] test: [M3-9717] - Fix LKE Create Smoke Test Flake (#12738) * M3-9717 Fix LKE Create Smoke Test Flake * Added changeset: Fix LKE Create Smoke Test Flake --- .../manager/.changeset/pr-12738-tests-1755723265426.md | 5 +++++ .../cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-12738-tests-1755723265426.md diff --git a/packages/manager/.changeset/pr-12738-tests-1755723265426.md b/packages/manager/.changeset/pr-12738-tests-1755723265426.md new file mode 100644 index 00000000000..6ef446020cf --- /dev/null +++ b/packages/manager/.changeset/pr-12738-tests-1755723265426.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix LKE Create Smoke Test Flake ([#12738](https://github.com/linode/manager/pull/12738)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts index ad0d420eeaa..8589b150e9f 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts @@ -78,7 +78,12 @@ describe('LKE Create Cluster', () => { cy.findByLabelText('Cluster Label').click(); cy.focused().type(mockCluster.label); - ui.regionSelect.find().click().type(`${chooseRegion().label}{enter}`); + const lkeRegion = chooseRegion({ + capabilities: ['Kubernetes'], + }); + + ui.regionSelect.find().click().type(`${lkeRegion.label}`); + ui.regionSelect.findItemByRegionId(lkeRegion.id).click(); cy.findByLabelText('Kubernetes Version').should('be.visible').click(); cy.findByText('1.32').should('be.visible').click(); From 2ca8d17ebea0bd638e11d112fa67b68b950f464c Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Wed, 27 Aug 2025 09:50:53 -0700 Subject: [PATCH 16/73] test: [M3-10489] - Add Cypress integration test to confirm no LKE-E Phase 2 options visible to standard LKE cluster create flow (#12770) * Add test coverage for standard flow, no phase 2 options * Add changeset * Fix errors * Improve comment --- .../pr-12770-tests-1756162698530.md | 5 ++++ .../e2e/core/kubernetes/lke-create.spec.ts | 26 +++++++++++++++++-- .../CreateCluster/ClusterNetworkingPanel.tsx | 2 +- 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-12770-tests-1756162698530.md diff --git a/packages/manager/.changeset/pr-12770-tests-1756162698530.md b/packages/manager/.changeset/pr-12770-tests-1756162698530.md new file mode 100644 index 00000000000..80782cd7d0b --- /dev/null +++ b/packages/manager/.changeset/pr-12770-tests-1756162698530.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress test coverage for standard cluster creation with LKE-E phase2Mtc flag enabled ([#12770](https://github.com/linode/manager/pull/12770)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index dca95f300a8..3ecf54685fb 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -121,12 +121,18 @@ describe('LKE Cluster Creation', () => { beforeEach(() => { // Mock feature flag -- @TODO LKE-E: Remove feature flag once LKE-E is fully rolled out mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true, postLa: false }, + lkeEnterprise: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: true, + }, }).as('getFeatureFlags'); }); /* * - Confirms that users can create a cluster by completing the LKE create form. + * - Confirms that no IP Stack or VPC options are visible for standard tier clusters (LKE-E only). * - Confirms that LKE cluster is created. * - Confirms that user is redirected to new LKE cluster summary page. * - Confirms that correct information is shown on the LKE cluster summary page @@ -199,6 +205,15 @@ describe('LKE Cluster Creation', () => { cy.get('[data-testid="ha-radio-button-no"]').should('be.visible').click(); + // Confirms LKE-E Phase 2 IP Stack and VPC options do not display for a standard LKE cluster. + cy.findByText('IP Stack').should('not.exist'); + cy.findByText('IPv4', { exact: true }).should('not.exist'); + cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist'); + cy.findByText('Automatically generate a VPC for this cluster').should( + 'not.exist' + ); + cy.findByText('Use an existing VPC').should('not.exist'); + let monthPrice = 0; // Confirm the expected available plans display. @@ -269,12 +284,19 @@ describe('LKE Cluster Creation', () => { .click(); }); + // Confirm request payload does not include LKE-E-specific values. + cy.wait('@createCluster').then((intercept) => { + const payload = intercept.request.body; + expect(payload.stack_type).to.be.undefined; + expect(payload.vpc_id).to.be.undefined; + expect(payload.subnet_id).to.be.undefined; + }); + // Wait for LKE cluster to be created and confirm that we are redirected // to the cluster summary page. cy.wait([ '@getCluster', '@getClusterPools', - '@createCluster', '@getLKEClusterTypes', '@getDashboardUrl', '@getControlPlaneACL', diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx index 9b316dcf2c1..fc654c7e491 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx @@ -59,7 +59,7 @@ export const ClusterNetworkingPanel = (props: Props) => { onChange={(e) => field.onChange(e.target.value)} value={field.value ?? null} > - IP Stack + IP Stack } label="IPv4" value="ipv4" /> } From ea8c9da3a4a6eccce99c717b05a62be58368cda7 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Wed, 27 Aug 2025 11:18:18 -0700 Subject: [PATCH 17/73] test: [M3-10366] - Add Cypress LKE-E 'phase2Mtc' feature flag smoke tests (#12773) * Move shared utils to lke util file * Add smoke test file for LKE-Enterprise * Add coverage for LKE details flow; clean up * Delete old spec file * Add a TODO comment for M3-10365 * Make naming scheme and jsdocs comments consistent * Added changeset: Add Cypress LKE-E 'phase2Mtc' feature flag smoke tests * Move constant to the correct file * Clean up an unneeded mock * Mock requests that require Kubernetes Enterprise capability --- .../pr-12773-tests-1756303505092.md | 5 + .../kubernetes/smoke-lke-enterprise.spec.ts | 279 ++++++++++++++++++ ...c.ts => smoke-lke-standard-create.spec.ts} | 53 +--- .../manager/cypress/support/constants/lke.ts | 4 + packages/manager/cypress/support/util/lke.ts | 46 +++ 5 files changed, 340 insertions(+), 47 deletions(-) create mode 100644 packages/manager/.changeset/pr-12773-tests-1756303505092.md create mode 100644 packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts rename packages/manager/cypress/e2e/core/kubernetes/{smoke-lke-create.spec.ts => smoke-lke-standard-create.spec.ts} (78%) diff --git a/packages/manager/.changeset/pr-12773-tests-1756303505092.md b/packages/manager/.changeset/pr-12773-tests-1756303505092.md new file mode 100644 index 00000000000..924880e072b --- /dev/null +++ b/packages/manager/.changeset/pr-12773-tests-1756303505092.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress LKE-E 'phase2Mtc' feature flag smoke tests ([#12773](https://github.com/linode/manager/pull/12773)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts new file mode 100644 index 00000000000..01cc1e7ab25 --- /dev/null +++ b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts @@ -0,0 +1,279 @@ +/** + * Tests basic functionality for LKE-E feature-flagged work. + * TODO: M3-10365 - Add `postLa` smoke tests to this file. + * TODO: M3-8838 - Delete this spec file once LKE-E is released to GA. + */ + +import { regionFactory } from '@linode/utilities'; +import { + accountFactory, + kubernetesClusterFactory, + nodePoolFactory, + subnetFactory, + vpcFactory, +} from '@src/factories'; +import { + latestEnterpriseTierKubernetesVersion, + minimumNodeNotice, +} from 'support/constants/lke'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockCreateCluster, + mockGetCluster, + mockGetClusterPools, + mockGetTieredKubernetesVersions, +} from 'support/intercepts/lke'; +import { mockGetClusters } from 'support/intercepts/lke'; +import {} from 'support/intercepts/profile'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { mockGetVPC } from 'support/intercepts/vpc'; +import { ui } from 'support/ui'; +import { addNodes } from 'support/util/lke'; +import { randomLabel } from 'support/util/random'; + +const mockCluster = kubernetesClusterFactory.build({ + id: 1, + vpc_id: 123, + label: randomLabel(), + tier: 'enterprise', +}); + +const mockVPC = vpcFactory.build({ + id: 123, + label: 'lke-e-vpc', + subnets: [subnetFactory.build()], +}); + +const mockNodePools = [nodePoolFactory.build()]; + +// Mock a valid region for LKE-E to avoid test flake. +const mockRegions = [ + regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise', 'VPCs'], + id: 'us-iad', + label: 'Washington, DC', + }), +]; + +/** + * - Confirms VPC and IP Stack selections are shown with `phase2Mtc` feature flag is enabled. + * - Confirms VPC and IP Stack selections are not shown in create flow with `phase2Mtc` feature flag is disabled. + */ +describe('LKE-E Cluster Create', () => { + beforeEach(() => { + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + }); + + it('Simple Page Check - Phase 2 MTC Flag ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: true, + }, + }).as('getFeatureFlags'); + + mockCreateCluster(mockCluster).as('createCluster'); + mockGetTieredKubernetesVersions('enterprise', [ + latestEnterpriseTierKubernetesVersion, + ]).as('getTieredKubernetesVersions'); + mockGetRegions(mockRegions); + + cy.visitWithLogin('/kubernetes/create'); + cy.findByText('Add Node Pools').should('be.visible'); + + cy.findByLabelText('Cluster Label').click(); + cy.focused().type(mockCluster.label); + + cy.findByText('LKE Enterprise').click(); + + ui.regionSelect.find().click().type(`${mockRegions[0].label}`); + ui.regionSelect.findItemByRegionId(mockRegions[0].id).click(); + + cy.findByLabelText('Kubernetes Version').should('be.visible').click(); + cy.findByText(latestEnterpriseTierKubernetesVersion.id) + .should('be.visible') + .click(); + + // Confirms LKE-E Phase 2 IP Stack and VPC options display with the flag ON. + cy.findByText('IP Stack').should('be.visible'); + cy.findByText('IPv4', { exact: true }).should('be.visible'); + cy.findByText('IPv4 + IPv6 (dual-stack)').should('be.visible'); + cy.findByText('Automatically generate a VPC for this cluster').should( + 'be.visible' + ); + cy.findByText('Use an existing VPC').should('be.visible'); + + cy.findByText('Shared CPU').should('be.visible').click(); + addNodes('Linode 2 GB'); + + // Bypass ACL validation + cy.get('input[name="acl-acknowledgement"]').check(); + + // Confirm change is reflected in checkout bar. + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + cy.findByText('Linode 2 GB Plan').should('be.visible'); + cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible'); + + cy.get('[data-qa-notice="true"]').within(() => { + cy.findByText(minimumNodeNotice).should('be.visible'); + }); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createCluster'); + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockCluster.id}/summary` + ); + }); + + it('Simple Page Check - Phase 2 MTC Flag OFF', () => { + mockAppendFeatureFlags({ + lkeEnterprise: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: false, + }, + }).as('getFeatureFlags'); + + mockCreateCluster(mockCluster).as('createCluster'); + mockGetTieredKubernetesVersions('enterprise', [ + latestEnterpriseTierKubernetesVersion, + ]).as('getTieredKubernetesVersions'); + mockGetRegions(mockRegions); + + cy.visitWithLogin('/kubernetes/create'); + cy.findByText('Add Node Pools').should('be.visible'); + + cy.findByLabelText('Cluster Label').click(); + cy.focused().type(mockCluster.label); + + cy.findByText('LKE Enterprise').click(); + + ui.regionSelect.find().click().type(`${mockRegions[0].label}`); + ui.regionSelect.findItemByRegionId(mockRegions[0].id).click(); + + cy.findByLabelText('Kubernetes Version').should('be.visible').click(); + cy.findByText(latestEnterpriseTierKubernetesVersion.id) + .should('be.visible') + .click(); + + // Confirms LKE-E Phase 2 IP Stack and VPC options do not display with the flag OFF. + cy.findByText('IP Stack').should('not.exist'); + cy.findByText('IPv4', { exact: true }).should('not.exist'); + cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist'); + cy.findByText('Automatically generate a VPC for this cluster').should( + 'not.exist' + ); + cy.findByText('Use an existing VPC').should('not.exist'); + + cy.findByText('Shared CPU').should('be.visible').click(); + addNodes('Linode 2 GB'); + + // Bypass ACL validation + cy.get('input[name="acl-acknowledgement"]').check(); + + // Confirm change is reflected in checkout bar. + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + cy.findByText('Linode 2 GB Plan').should('be.visible'); + cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible'); + + cy.get('[data-qa-notice="true"]').within(() => { + cy.findByText(minimumNodeNotice).should('be.visible'); + }); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createCluster'); + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockCluster.id}/summary` + ); + }); +}); + +/** + * - Confirms cluster shows linked VPC and Node Pool VPC IP columns when `phase2Mtc` flag is enabled. + * - Confirms cluster's linked VPC and Node Pool VPC IP columns are hidden when `phase2Mtc` flag is disabled. + */ +describe('LKE-E Cluster Read', () => { + beforeEach(() => { + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + }); + + it('Simple Page Check - Phase 2 MTC Flag ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true, phase2Mtc: true }, + }).as('getFeatureFlags'); + + mockGetClusters([mockCluster]).as('getClusters'); + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + mockGetVPC(mockVPC).as('getVPC'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getVPC', '@getNodePools']); + + // Confirm linked VPC is present + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('exist'); + cy.findByTestId('assigned-lke-cluster-label').should( + 'contain.text', + mockVPC.label + ); + }); + + // Confirm VPC IP columns are present in the node table header + cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => { + cy.contains('th', 'VPC IPv4').should('be.visible'); + cy.contains('th', 'VPC IPv6').should('be.visible'); + }); + }); + + it('Simple Page Check - Phase 2 MTC Flag OFF', () => { + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true, phase2Mtc: false }, + }).as('getFeatureFlags'); + + mockGetClusters([mockCluster]).as('getClusters'); + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getNodePools']); + + // Confirm linked VPC is not present + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('not.exist'); + cy.findByTestId('assigned-lke-cluster-label').should('not.exist'); + }); + + // Confirm VPC IP columns are not present in the node table header + cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => { + cy.contains('th', 'VPC IPv4').should('not.exist'); + cy.contains('th', 'VPC IPv6').should('not.exist'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-standard-create.spec.ts similarity index 78% rename from packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts rename to packages/manager/cypress/e2e/core/kubernetes/smoke-lke-standard-create.spec.ts index 8589b150e9f..2dc7c4c1cd3 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-standard-create.spec.ts @@ -1,5 +1,10 @@ +/** + * Tests basic functionality for standard LKE creation. + */ + import { grantsFactory, profileFactory } from '@linode/utilities'; import { accountUserFactory, kubernetesClusterFactory } from '@src/factories'; +import { minimumNodeNotice } from 'support/constants/lke'; import { mockGetUser } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateCluster } from 'support/intercepts/lke'; @@ -8,56 +13,10 @@ import { mockGetProfileGrants, } from 'support/intercepts/profile'; import { ui } from 'support/ui'; +import { addNodes } from 'support/util/lke'; import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -/** - * Performs a click operation on Cypress subject a given number of times. - * - * @param subject - Cypress subject to click. - * @param count - Number of times to perform click. - * - * @returns Cypress chainable. - */ -const multipleClick = ( - subject: Cypress.Chainable, - count: number -): Cypress.Chainable => { - if (count == 1) { - return subject.click(); - } - return multipleClick(subject.click(), count - 1); -}; - -/** - * Adds a random-sized node pool of the given plan. - * - * @param plan Name of plan for which to add nodes. - */ -const addNodes = (plan: string) => { - const defaultNodes = 3; - const extraNodes = randomNumber(1, 5); - - cy.get(`[data-qa-plan-row="${plan}"`).within(() => { - multipleClick(cy.get('[data-testid="increment-button"]'), extraNodes); - multipleClick(cy.get('[data-testid="decrement-button"]'), extraNodes + 1); - - cy.get('[data-testid="textfield-input"]') - .invoke('val') - .should('eq', `${defaultNodes - 1}`); - - ui.button - .findByTitle('Add') - .should('be.visible') - .should('be.enabled') - .click(); - }); -}; - -// Warning that's shown when recommended minimum number of nodes is not met. -const minimumNodeNotice = - 'We recommend a minimum of 3 nodes in each Node Pool to avoid downtime during upgrades and maintenance.'; - describe('LKE Create Cluster', () => { beforeEach(() => { // Mock feature flag -- @TODO LKE-E: Remove feature flag once LKE-E is fully rolled out diff --git a/packages/manager/cypress/support/constants/lke.ts b/packages/manager/cypress/support/constants/lke.ts index e8ad5e735f6..0f36b0e1462 100644 --- a/packages/manager/cypress/support/constants/lke.ts +++ b/packages/manager/cypress/support/constants/lke.ts @@ -121,3 +121,7 @@ export const clusterPlans: LkePlanDescription[] = [ type: 'standard', }, ]; + +// Warning that's shown when recommended minimum number of nodes is not met. +export const minimumNodeNotice = + 'We recommend a minimum of 3 nodes in each Node Pool to avoid downtime during upgrades and maintenance.'; diff --git a/packages/manager/cypress/support/util/lke.ts b/packages/manager/cypress/support/util/lke.ts index 63005b76c96..cbb17398451 100644 --- a/packages/manager/cypress/support/util/lke.ts +++ b/packages/manager/cypress/support/util/lke.ts @@ -1,4 +1,7 @@ import { sortByVersion } from '@linode/utilities'; +import { ui } from 'support/ui'; + +import { randomNumber } from './random'; /** * Returns the string of the highest semantic version. @@ -16,3 +19,46 @@ export const getLatestKubernetesVersion = (versions: string[]) => { } return latestVersion; }; + +/** + * Performs a click operation on Cypress subject a given number of times. + * + * @param subject - Cypress subject to click. + * @param count - Number of times to perform click. + * + * @returns Cypress chainable. + */ +const multipleClick = ( + subject: Cypress.Chainable, + count: number +): Cypress.Chainable => { + if (count == 1) { + return subject.click(); + } + return multipleClick(subject.click(), count - 1); +}; + +/** + * Adds a random-sized node pool of the given plan. + * + * @param plan Name of plan for which to add nodes. + */ +export const addNodes = (plan: string) => { + const defaultNodes = 3; + const extraNodes = randomNumber(1, 5); + + cy.get(`[data-qa-plan-row="${plan}"`).within(() => { + multipleClick(cy.get('[data-testid="increment-button"]'), extraNodes); + multipleClick(cy.get('[data-testid="decrement-button"]'), extraNodes + 1); + + cy.get('[data-testid="textfield-input"]') + .invoke('val') + .should('eq', `${defaultNodes - 1}`); + + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); +}; From dd6636908e6e74503aaafb265cea949e2b585657 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 27 Aug 2025 14:49:58 -0400 Subject: [PATCH 18/73] feat: [M3-10324] - Add type-to-confirm to Images (#12740) * support type to confirm on Images * fix up cypress test and loading states * remove question mark from dialog title * add unit test for image row fix * fix type-safety issue * Added changeset: Type-to-confirm to Image deletion dialog --------- Co-authored-by: Banks Nussman --- .../pr-12740-added-1756313628688.md | 5 + .../core/images/machine-image-upload.spec.ts | 11 +- .../TypeToConfirmDialog.tsx | 7 +- .../ImagesLanding/DeleteImageDialog.tsx | 59 ++++++++ .../Images/ImagesLanding/ImageRow.test.tsx | 21 +++ .../Images/ImagesLanding/ImageRow.tsx | 1 + .../Images/ImagesLanding/ImagesLanding.tsx | 132 ++---------------- packages/queries/src/images/images.ts | 13 +- 8 files changed, 123 insertions(+), 126 deletions(-) create mode 100644 packages/manager/.changeset/pr-12740-added-1756313628688.md create mode 100644 packages/manager/src/features/Images/ImagesLanding/DeleteImageDialog.tsx diff --git a/packages/manager/.changeset/pr-12740-added-1756313628688.md b/packages/manager/.changeset/pr-12740-added-1756313628688.md new file mode 100644 index 00000000000..0354f392843 --- /dev/null +++ b/packages/manager/.changeset/pr-12740-added-1756313628688.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Type-to-confirm to Image deletion dialog ([#12740](https://github.com/linode/manager/pull/12740)) diff --git a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts index f1dac1ca995..e36d10bb960 100644 --- a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts +++ b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts @@ -16,6 +16,8 @@ import { apiMatcher } from 'support/util/intercepts'; import { randomLabel, randomPhrase } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { DISALLOWED_IMAGE_REGIONS } from 'src/constants'; + import type { EventStatus } from '@linode/api-v4'; import type { RecPartial } from 'factory.ts'; @@ -121,7 +123,7 @@ const uploadImage = (label: string) => { // See also BAC-862. const region = chooseRegion({ capabilities: ['Object Storage'], - exclude: ['au-mel', 'gb-lon', 'sg-sin-2'], + exclude: DISALLOWED_IMAGE_REGIONS, }); const upload = 'machine-images/test-image.gz'; cy.visitWithLogin('/images/create/upload'); @@ -238,8 +240,13 @@ describe('machine image', () => { .findByTitle(`Delete Image ${updatedLabel}`) .should('be.visible') .within(() => { + cy.findByLabelText('Image Label') + .should('be.visible') + .should('be.enabled') + .type(updatedLabel); + ui.buttonGroup - .findButtonByTitle('Delete Image') + .findButtonByTitle('Delete') .should('be.visible') .should('be.enabled') .click(); diff --git a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx index 36cad05e79e..9a4c36b1985 100644 --- a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx +++ b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx @@ -28,6 +28,7 @@ interface EntityInfo { | 'Bucket' | 'Database' | 'Domain' + | 'Image' | 'Kubernetes' | 'Linode' | 'Load Balancer' @@ -87,7 +88,7 @@ interface TypeToConfirmDialogProps { */ reversePrimaryButtonPosition?: boolean; /** Props for the secondary button */ - secondaryButtonProps?: Omit; + secondaryButtonProps?: ActionButtonsProps; } type CombinedProps = TypeToConfirmDialogProps & @@ -176,10 +177,10 @@ export const TypeToConfirmDialog = (props: CombinedProps) => { }; const cancelProps: ActionButtonsProps = { - ...secondaryButtonProps, 'data-testid': 'cancel', label: 'Cancel', onClick: () => onClose?.({}, 'escapeKeyDown'), + ...secondaryButtonProps, }; return { @@ -207,7 +208,7 @@ export const TypeToConfirmDialog = (props: CombinedProps) => { } const typeInstructions = - entity.action === 'cancellation' + entity.action === 'cancellation' && entity.type === 'AccountSetting' ? 'type your Username ' : `type the name of the ${entity.type} ${entity.subType || ''} `; diff --git a/packages/manager/src/features/Images/ImagesLanding/DeleteImageDialog.tsx b/packages/manager/src/features/Images/ImagesLanding/DeleteImageDialog.tsx new file mode 100644 index 00000000000..ca9c62d65dd --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/DeleteImageDialog.tsx @@ -0,0 +1,59 @@ +import { useDeleteImageMutation, useImageQuery } from '@linode/queries'; +import { useSnackbar } from 'notistack'; +import React from 'react'; + +import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; + +interface Props { + imageId: string | undefined; + onClose: () => void; + open: boolean; +} + +export const DeleteImageDialog = (props: Props) => { + const { imageId, open, onClose } = props; + const { enqueueSnackbar } = useSnackbar(); + + const { + data: image, + isLoading, + error, + } = useImageQuery(imageId ?? '', Boolean(imageId)); + + const { mutate: deleteImage, isPending } = useDeleteImageMutation({ + onSuccess() { + enqueueSnackbar('Image has been scheduled for deletion.', { + variant: 'info', + }); + onClose(); + }, + }); + + const isPendingUpload = image?.status === 'pending_upload'; + + return ( + deleteImage({ imageId: imageId ?? '' })} + onClose={onClose} + open={open} + secondaryButtonProps={{ + label: isPendingUpload ? 'Keep Image' : 'Cancel', + }} + title={ + isPendingUpload + ? 'Cancel Upload' + : `Delete Image ${image?.label ?? imageId}` + } + /> + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx index bdd27451cd9..46c43812b10 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx @@ -109,6 +109,27 @@ describe('Image Table Row', () => { ).toBeNull(); }); + it('should not show an unencrypted icon when an Image is still "pending_upload"', () => { + // The API does not populate the "distributed-sites" capability until the image is done creating. + // We must account for this because the image would show as "Unencrypted" while it is creating, + // then suddenly show as encrypted once it was done creating. We don't want that. + // Therefore, we decided we won't show the unencrypted icon until the image is done uploading to + // prevent confusion. + const image = imageFactory.build({ + capabilities: ['cloud-init'], + status: 'pending_upload', + type: 'manual', + }); + + const { queryByLabelText } = renderWithTheme( + wrapWithTableBody() + ); + + expect( + queryByLabelText('This image is not encrypted.', { exact: false }) + ).toBeNull(); + }); + it('should show N/A if Image does not have any regions', () => { const image = imageFactory.build({ regions: [] }); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx index 8c2e96b0925..aad89a214db 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx @@ -78,6 +78,7 @@ export const ImageRow = (props: Props) => { {type === 'manual' && status !== 'creating' && + status !== 'pending_upload' && !image.capabilities.includes('distributed-sites') && ( } diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 76856ace0d6..cd2843065d1 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -1,16 +1,9 @@ -import { - imageQueries, - useDeleteImageMutation, - useImageQuery, - useImagesQuery, -} from '@linode/queries'; +import { imageQueries, useImageQuery, useImagesQuery } from '@linode/queries'; import { getAPIFilterFromQuery } from '@linode/search'; import { - ActionsPanel, CircleProgress, Drawer, ErrorState, - Notice, Paper, Stack, Typography, @@ -18,11 +11,9 @@ import { import { Hidden } from '@linode/ui'; import { useQueryClient } from '@tanstack/react-query'; import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; -import { useSnackbar } from 'notistack'; import React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; @@ -45,7 +36,6 @@ import { isEventInProgressDiskImagize, } from 'src/queries/events/event.helpers'; import { useEventsInfiniteQuery } from 'src/queries/events/events'; -import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { AUTOMATIC_IMAGES_DEFAULT_ORDER, @@ -57,6 +47,7 @@ import { MANUAL_IMAGES_PREFERENCE_KEY, } from '../constants'; import { getEventsForImages } from '../utils'; +import { DeleteImageDialog } from './DeleteImageDialog'; import { EditImageDrawer } from './EditImageDrawer'; import { ManageImageReplicasForm } from './ImageRegions/ManageImageRegionsForm'; import { ImageRow } from './ImageRow'; @@ -64,7 +55,7 @@ import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; import { RebuildImageDrawer } from './RebuildImageDrawer'; import type { Handlers as ImageHandlers } from './ImagesActionMenu'; -import type { Filter, Image, ImageStatus } from '@linode/api-v4'; +import type { Filter, Image } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; import type { ImageAction } from 'src/routes/images'; @@ -84,37 +75,19 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -interface ImageDialogState { - error?: string; - status?: ImageStatus; - submitting: boolean; -} - -const defaultDialogState: ImageDialogState = { - error: undefined, - submitting: false, -}; - export const ImagesLanding = () => { const { classes } = useStyles(); - const { - action, - imageId: selectedImageId, - }: { action: ImageAction; imageId: string } = useParams({ - strict: false, + const params = useParams({ + from: '/images/$imageId/$action', + shouldThrow: false, }); const search = useSearch({ from: '/images' }); const { query } = search; const navigate = useNavigate(); - const { enqueueSnackbar } = useSnackbar(); const isCreateImageRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_images', }); const queryClient = useQueryClient(); - const [dialogState, setDialogState] = - React.useState(defaultDialogState); - const dialogStatus = - dialogState.status === 'pending_upload' ? 'cancel' : 'delete'; /** * At the time of writing: `label`, `tags`, `size`, `status`, `region` are filterable. @@ -258,8 +231,7 @@ export const ImagesLanding = () => { data: selectedImage, isLoading: isFetchingSelectedImage, error: selectedImageError, - } = useImageQuery(selectedImageId, !!selectedImageId); - const { mutateAsync: deleteImage } = useDeleteImageMutation(); + } = useImageQuery(params?.imageId ?? '', !!params?.imageId); const { events } = useEventsInfiniteQuery(); @@ -302,7 +274,6 @@ export const ImagesLanding = () => { }; const handleCloseDialog = () => { - setDialogState(defaultDialogState); navigate({ search: (prev) => prev, to: '/images' }); }; @@ -310,50 +281,6 @@ export const ImagesLanding = () => { actionHandler(image, 'manage-replicas'); }; - const handleDeleteImage = (image: Image) => { - if (!image.id) { - setDialogState((dialog) => ({ - ...dialog, - error: 'Image is not available.', - })); - } - - setDialogState((dialog) => ({ - ...dialog, - error: undefined, - submitting: true, - })); - - deleteImage({ imageId: image.id }) - .then(() => { - handleCloseDialog(); - /** - * request generated by the Pagey HOC. - * - * We're making a request here because the image is being - * optimistically deleted on the API side, so a GET to /images - * will not return the image scheduled for deletion. This request - * is ensuring the image is removed from the list, to prevent the user - * from taking any action on the Image. - */ - enqueueSnackbar('Image has been scheduled for deletion.', { - variant: 'info', - }); - }) - .catch((err) => { - const _error = getErrorStringOrDefault( - err, - 'There was an error deleting the image.' - ); - setDialogState({ - ...dialogState, - error: _error, - submitting: false, - }); - handleCloseDialog(); - }); - }; - const onCancelFailedClick = () => { queryClient.invalidateQueries({ queryKey: imageQueries.paginated._def, @@ -614,20 +541,20 @@ export const ImagesLanding = () => { imageError={selectedImageError} isFetching={isFetchingSelectedImage} onClose={handleCloseDialog} - open={action === 'edit'} + open={params?.action === 'edit'} /> { onClose={handleCloseDialog} /> - handleDeleteImage(selectedImage!), - }} - secondaryButtonProps={{ - 'data-testid': 'cancel', - label: dialogStatus === 'cancel' ? 'Keep Image' : 'Cancel', - onClick: handleCloseDialog, - }} - /> - } - entityError={selectedImageError} - isFetching={isFetchingSelectedImage} + - {dialogState.error && ( - - )} - - {dialogStatus === 'cancel' - ? 'Are you sure you want to cancel this Image upload?' - : 'Are you sure you want to delete this Image?'} - - + open={params?.action === 'delete'} + /> ); diff --git a/packages/queries/src/images/images.ts b/packages/queries/src/images/images.ts index 0664ebc5d3c..9001b26e5d1 100644 --- a/packages/queries/src/images/images.ts +++ b/packages/queries/src/images/images.ts @@ -30,7 +30,10 @@ import type { UploadImageResponse, } from '@linode/api-v4'; import type { EventHandlerData } from '@linode/queries'; -import type { UseQueryOptions } from '@tanstack/react-query'; +import type { + UseMutationOptions, + UseQueryOptions, +} from '@tanstack/react-query'; export const getAllImages = ( passedParams: Params = {}, @@ -133,11 +136,15 @@ export const useUpdateImageMutation = () => { }); }; -export const useDeleteImageMutation = () => { +export const useDeleteImageMutation = ( + options: UseMutationOptions<{}, APIError[], { imageId: string }>, +) => { const queryClient = useQueryClient(); return useMutation<{}, APIError[], { imageId: string }>({ mutationFn: ({ imageId }) => deleteImage(imageId), - onSuccess(_, variables) { + ...options, + onSuccess(response, variables, context) { + options.onSuccess?.(response, variables, context); queryClient.invalidateQueries({ queryKey: imageQueries.paginated._def, }); From 655c6b6f6585e00b6fa42e4ea49a5b5fd2c6e001 Mon Sep 17 00:00:00 2001 From: bill-akamai Date: Wed, 27 Aug 2025 15:21:29 -0500 Subject: [PATCH 19/73] fix:[M3-10481]- Fix Autocomplete undefined value issues - Part 1 (#12706) * Create debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Remove debug .yml file * Fix autocompletes * Update TwoStepRegion * Revert change to LinodeSelect.tsx * Use 'distributed-ALL' as default in TwoStepRegion.tsx * Add PowerActionsDialogOrDrawer.tsx * Added changeset: Replace undefined with null in Autocomplete values to fix MUI errors * Update MaintenancePolicySelect type --------- Co-authored-by: dmcintyr-akamai --- .../pr-12706-fixed-1755632991962.md | 5 +++++ .../components/region-select.spec.tsx | 22 +++++++++---------- .../MaintenancePolicySelect.tsx | 2 +- .../components/RegionSelect/RegionSelect.tsx | 2 +- .../RegionSelect/RegionSelect.types.ts | 2 +- .../shared/CloudPulseRegionSelect.tsx | 4 +++- .../DatabaseBackups/DatabaseBackups.tsx | 4 ++-- .../CreateCluster/CreateCluster.tsx | 2 +- .../AdditionalOptions/MaintenancePolicy.tsx | 2 +- .../Linodes/LinodeCreate/TwoStepRegion.tsx | 16 ++++++++------ .../Linodes/MigrateLinode/ConfigureForm.tsx | 2 +- .../Linodes/PowerActionsDialogOrDrawer.tsx | 7 +++--- .../BucketLanding/BucketRegions.tsx | 2 +- .../BucketLanding/ClusterSelect.tsx | 2 +- 14 files changed, 42 insertions(+), 32 deletions(-) create mode 100644 packages/manager/.changeset/pr-12706-fixed-1755632991962.md diff --git a/packages/manager/.changeset/pr-12706-fixed-1755632991962.md b/packages/manager/.changeset/pr-12706-fixed-1755632991962.md new file mode 100644 index 00000000000..93a72dd8c96 --- /dev/null +++ b/packages/manager/.changeset/pr-12706-fixed-1755632991962.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Replace undefined with null in Autocomplete values to fix MUI errors ([#12706](https://github.com/linode/manager/pull/12706)) diff --git a/packages/manager/cypress/component/components/region-select.spec.tsx b/packages/manager/cypress/component/components/region-select.spec.tsx index 91d5200a39c..5decbd16500 100644 --- a/packages/manager/cypress/component/components/region-select.spec.tsx +++ b/packages/manager/cypress/component/components/region-select.spec.tsx @@ -29,7 +29,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={[region]} - value={undefined} + value={null} /> ); @@ -58,7 +58,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={[region]} - value={undefined} + value={null} /> ); @@ -88,7 +88,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={[region]} - value={undefined} + value={null} /> ); @@ -118,7 +118,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={[region]} - value={undefined} + value={null} /> ); @@ -152,7 +152,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={regions} - value={undefined} + value={null} /> ); @@ -271,7 +271,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={regions} - value={undefined} + value={null} /> ); @@ -289,7 +289,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={spyFn} regions={regions} - value={undefined} + value={null} /> ); @@ -359,7 +359,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={regions} - value={undefined} + value={null} />, { dcGetWell: true, @@ -394,7 +394,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={regions} - value={undefined} + value={null} /> ); @@ -424,7 +424,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={regions} - value={undefined} + value={null} /> ); @@ -455,7 +455,7 @@ componentTests('RegionSelect', (mount) => { isGeckoLAEnabled={false} onChange={() => {}} regions={regions} - value={undefined} + value={null} /> ); checkComponentA11y(); diff --git a/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx b/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx index fabb0ecc050..6089a3b2677 100644 --- a/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx +++ b/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx @@ -28,7 +28,7 @@ interface MaintenancePolicySelectProps { hideDefaultChip?: boolean; onChange: (policy: MaintenancePolicy) => void; textFieldProps?: Partial; - value?: string; + value?: null | string; } export const MaintenancePolicySelect = ( diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 016fa3471c3..b89dbe71c46 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -68,7 +68,7 @@ export const RegionSelect = < }); const selectedRegion = value - ? regionOptions.find((r) => r.id === value) + ? (regionOptions.find((r) => r.id === value) ?? null) : null; const disabledRegions = regionOptions.reduce< diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index fc98211822a..ab38fcd89ea 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -62,7 +62,7 @@ export interface RegionSelectProps< /** * The ID of the selected region. */ - value: string | undefined; + value: null | string; width?: number; } diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index 36760f146d0..61d9412b992 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -167,7 +167,9 @@ export const CloudPulseRegionSelect = React.memo( placeholder={placeholder ?? 'Select a Region'} regions={supportedRegionsFromResources} value={ - supportedRegionsFromResources?.length ? selectedRegion : undefined + supportedRegionsFromResources?.length + ? (selectedRegion ?? null) + : null } /> ); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx index 85a68949b96..18508468a8b 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx @@ -222,7 +222,7 @@ export const DatabaseBackups = () => { option.value === value.value } label="" - onChange={(_, newTime) => setSelectedTime(newTime)} + onChange={(_, newTime) => setSelectedTime(newTime ?? null)} options={TIME_OPTIONS} placeholder="Choose a time" renderOption={(props, option) => { @@ -238,7 +238,7 @@ export const DatabaseBackups = () => { 'data-qa-time-select': true, }, }} - value={selectedTime} + value={selectedTime ?? null} /> diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 63382255dd2..5ea7c98df7e 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -527,7 +527,7 @@ export const CreateCluster = () => { ? 'Only regions that support LKE Enterprise clusters are listed.' : undefined } - value={selectedRegion?.id} + value={selectedRegion?.id || null} /> { : MAINTENANCE_POLICY_SELECT_REGION_TEXT : undefined, }} - value={field.value ?? undefined} + value={field.value ?? null} /> )} /> diff --git a/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx b/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx index 1b7eaa011c7..9b8289851a3 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx @@ -70,7 +70,7 @@ export const TwoStepRegion = (props: CombinedProps) => { const { disabled, disabledRegions, errorText, onChange, value } = props; const [regionFilter, setRegionFilter] = - React.useState('distributed'); + React.useState('distributed-ALL'); const { data: regions } = useRegionsQuery(); const createType = useGetLinodeCreateType(); @@ -120,7 +120,7 @@ export const TwoStepRegion = (props: CombinedProps) => { onChange={(e, region) => onChange(region)} regionFilter="core" regions={regions ?? []} - value={value} + value={value ?? null} /> @@ -131,8 +131,8 @@ export const TwoStepRegion = (props: CombinedProps) => { { if (selectedOption?.value) { @@ -140,9 +140,11 @@ export const TwoStepRegion = (props: CombinedProps) => { } }} options={GEOGRAPHICAL_AREA_OPTIONS} - value={GEOGRAPHICAL_AREA_OPTIONS.find( - (option) => option.value === regionFilter - )} + value={ + GEOGRAPHICAL_AREA_OPTIONS.find( + (option) => option.value === regionFilter + ) ?? null + } /> { onChange={(e, region) => onChange(region)} regionFilter={regionFilter} regions={regions ?? []} - value={value} + value={value ?? null} /> diff --git a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx index d443725d612..b998ebfcfe9 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx @@ -203,7 +203,7 @@ export const ConfigureForm = React.memo((props: Props) => { textFieldProps={{ helperText, }} - value={selectedRegion} + value={selectedRegion ?? null} /> {shouldDisplayPriceComparison && selectedRegion && ( { loading={configsLoading} onChange={(_, option) => setSelectConfigID(option?.value ?? null)} options={configOptions} - value={configOptions.find( - (option) => option.value === selectedConfigID - )} + value={ + configOptions.find((option) => option.value === selectedConfigID) ?? + null + } /> )} {props.action === 'Power Off' && ( diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx index 8c4481074a5..8f5efa402cd 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx @@ -50,7 +50,7 @@ export const BucketRegions = (props: Props) => { placeholder="Select a Region" regions={availableStorageRegions ?? []} required={required} - value={selectedRegion} + value={selectedRegion ?? null} /> ); }; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx index 81c8d3bfa60..bcd6d9e77ba 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx @@ -59,7 +59,7 @@ export const ClusterSelect: React.FC = (props) => { placeholder="Select a Region" regions={regionOptions ?? []} required={required} - value={selectedCluster ?? undefined} + value={selectedCluster ?? null} /> ); }; From 8c91ede504b25308b629ffd5ccaa1f738d5f4453 Mon Sep 17 00:00:00 2001 From: mduda-akamai Date: Thu, 28 Aug 2025 07:49:50 +0200 Subject: [PATCH 20/73] upcoming: [DPS-34041] Add actions in Destinations list (#12749) * upcoming: [DPS-34041] Add actions in Destinations list * upcoming: [DPS-34041] CR changes 1 --------- Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- ...r-12749-upcoming-features-1756101406751.md | 5 + packages/api-v4/src/account/types.ts | 2 + .../api-v4/src/datastream/destinations.ts | 41 ++++- packages/api-v4/src/datastream/types.ts | 7 + ...r-12749-upcoming-features-1756101457443.md | 5 + .../DestinationActionMenu.test.tsx | 29 ++++ .../Destinations/DestinationActionMenu.tsx | 40 +++++ .../DestinationCreate/DestinationCreate.tsx | 131 ---------------- .../DestinationCreate.test.tsx | 0 .../DestinationForm/DestinationCreate.tsx | 62 ++++++++ .../DestinationForm/DestinationEdit.test.tsx | 63 ++++++++ .../DestinationForm/DestinationEdit.tsx | 124 +++++++++++++++ .../DestinationForm/DestinationForm.tsx | 101 +++++++++++++ .../destinationCreateLazyRoute.ts | 2 +- .../destinationEditLazyRoute.ts | 9 ++ .../Destinations/DestinationTableRow.tsx | 13 +- .../Destinations/DestinationsLanding.test.tsx | 143 ++++++++++++++---- .../Destinations/DestinationsLanding.tsx | 45 +++++- .../src/features/DataStream/Shared/types.ts | 9 +- .../Streams/StreamActionMenu.test.tsx | 2 +- .../Delivery/StreamFormDelivery.tsx | 2 +- .../DataStream/Streams/StreamForm/types.ts | 4 +- .../Streams/StreamsLanding.test.tsx | 2 +- .../features/Events/factories/datastream.tsx | 16 ++ .../src/mocks/presets/crud/datastream.ts | 4 + .../mocks/presets/crud/handlers/datastream.ts | 90 +++++++++++ .../manager/src/routes/datastream/index.ts | 24 ++- ...r-12749-upcoming-features-1756101508851.md | 5 + .../queries/src/datastreams/datastream.ts | 73 ++++++++- packages/validation/src/datastream.schema.ts | 4 +- 30 files changed, 870 insertions(+), 187 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12749-upcoming-features-1756101406751.md create mode 100644 packages/manager/.changeset/pr-12749-upcoming-features-1756101457443.md create mode 100644 packages/manager/src/features/DataStream/Destinations/DestinationActionMenu.test.tsx create mode 100644 packages/manager/src/features/DataStream/Destinations/DestinationActionMenu.tsx delete mode 100644 packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx rename packages/manager/src/features/DataStream/Destinations/{DestinationCreate => DestinationForm}/DestinationCreate.test.tsx (100%) create mode 100644 packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.tsx create mode 100644 packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.test.tsx create mode 100644 packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.tsx create mode 100644 packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationForm.tsx rename packages/manager/src/features/DataStream/Destinations/{DestinationCreate => DestinationForm}/destinationCreateLazyRoute.ts (84%) create mode 100644 packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute.ts create mode 100644 packages/queries/.changeset/pr-12749-upcoming-features-1756101508851.md diff --git a/packages/api-v4/.changeset/pr-12749-upcoming-features-1756101406751.md b/packages/api-v4/.changeset/pr-12749-upcoming-features-1756101406751.md new file mode 100644 index 00000000000..b9e21d606fd --- /dev/null +++ b/packages/api-v4/.changeset/pr-12749-upcoming-features-1756101406751.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +DELETE, PUT API endpoints for Destinations ([#12749](https://github.com/linode/manager/pull/12749)) diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 079952d2d79..31b1c186491 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -344,6 +344,8 @@ export const EventActionKeys = [ 'database_migrate', 'database_upgrade', 'destination_create', + 'destination_delete', + 'destination_update', 'disk_create', 'disk_delete', 'disk_duplicate', diff --git a/packages/api-v4/src/datastream/destinations.ts b/packages/api-v4/src/datastream/destinations.ts index 66784f50ff3..c3c8b00a231 100644 --- a/packages/api-v4/src/datastream/destinations.ts +++ b/packages/api-v4/src/datastream/destinations.ts @@ -1,4 +1,4 @@ -import { createDestinationSchema } from '@linode/validation'; +import { destinationSchema } from '@linode/validation'; import { BETA_API_ROOT } from '../constants'; import Request, { @@ -10,7 +10,11 @@ import Request, { } from '../request'; import type { Filter, ResourcePage as Page, Params } from '../types'; -import type { CreateDestinationPayload, Destination } from './types'; +import type { + CreateDestinationPayload, + Destination, + UpdateDestinationPayload, +} from './types'; /** * Returns all the information about a specified Destination. @@ -45,7 +49,38 @@ export const getDestinations = (params?: Params, filter?: Filter) => */ export const createDestination = (data: CreateDestinationPayload) => Request( - setData(data, createDestinationSchema), + setData(data, destinationSchema), setURL(`${BETA_API_ROOT}/monitor/streams/destinations`), setMethod('POST'), ); + +/** + * Updates a Destination. + * + * @param destinationId { number } The ID of the Destination. + * @param data { object } Options for type, label, etc. + */ +export const updateDestination = ( + destinationId: number, + data: UpdateDestinationPayload, +) => + Request( + setData(data, destinationSchema), + setURL( + `${BETA_API_ROOT}/monitor/streams/destinations/${encodeURIComponent(destinationId)}`, + ), + setMethod('PUT'), + ); + +/** + * Deletes a Destination. + * + * @param destinationId { number } The ID of the Destination. + */ +export const deleteDestination = (destinationId: number) => + Request<{}>( + setURL( + `${BETA_API_ROOT}/monitor/streams/destinations/${encodeURIComponent(destinationId)}`, + ), + setMethod('DELETE'), + ); diff --git a/packages/api-v4/src/datastream/types.ts b/packages/api-v4/src/datastream/types.ts index 523509524e9..15b3129fdd3 100644 --- a/packages/api-v4/src/datastream/types.ts +++ b/packages/api-v4/src/datastream/types.ts @@ -126,3 +126,10 @@ export interface CreateDestinationPayload { label: string; type: DestinationType; } + +export type UpdateDestinationPayload = CreateDestinationPayload; + +export interface UpdateDestinationPayloadWithId + extends UpdateDestinationPayload { + id: number; +} diff --git a/packages/manager/.changeset/pr-12749-upcoming-features-1756101457443.md b/packages/manager/.changeset/pr-12749-upcoming-features-1756101457443.md new file mode 100644 index 00000000000..b00f3baaa55 --- /dev/null +++ b/packages/manager/.changeset/pr-12749-upcoming-features-1756101457443.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +DataStreams: add actions with handlers in Destinations list, add Edit Destination component ([#12749](https://github.com/linode/manager/pull/12749)) diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationActionMenu.test.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationActionMenu.test.tsx new file mode 100644 index 00000000000..492fec86b80 --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationActionMenu.test.tsx @@ -0,0 +1,29 @@ +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import * as React from 'react'; + +import { destinationFactory } from 'src/factories/datastream'; +import { DestinationActionMenu } from 'src/features/DataStream/Destinations/DestinationActionMenu'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +const fakeHandler = vi.fn(); + +describe('DestinationActionMenu', () => { + it('should include proper Stream actions', async () => { + renderWithTheme( + + ); + + const actionMenuButton = screen.queryByLabelText(/^Action menu for/)!; + + await userEvent.click(actionMenuButton); + + for (const action of ['Edit', 'Delete']) { + expect(screen.getByText(action)).toBeVisible(); + } + }); +}); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationActionMenu.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationActionMenu.tsx new file mode 100644 index 00000000000..b3d1bfea622 --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationActionMenu.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +import type { Destination } from '@linode/api-v4'; + +export interface DestinationHandlers { + onDelete: (destination: Destination) => void; + onEdit: (destination: Destination) => void; +} + +interface DestinationActionMenuProps extends DestinationHandlers { + destination: Destination; +} + +export const DestinationActionMenu = (props: DestinationActionMenuProps) => { + const { destination, onDelete, onEdit } = props; + + const menuActions = [ + { + onClick: () => { + onEdit(destination); + }, + title: 'Edit', + }, + { + onClick: () => { + onDelete(destination); + }, + title: 'Delete', + }, + ]; + + return ( + + ); +}; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx deleted file mode 100644 index 13a7f176309..00000000000 --- a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { yupResolver } from '@hookform/resolvers/yup'; -import { destinationType } from '@linode/api-v4'; -import { useCreateDestinationMutation } from '@linode/queries'; -import { Autocomplete, Box, Button, Paper, TextField } from '@linode/ui'; -import { createDestinationSchema } from '@linode/validation'; -import { useTheme } from '@mui/material/styles'; -import { useNavigate } from '@tanstack/react-router'; -import * as React from 'react'; -import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; - -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { LandingHeader } from 'src/components/LandingHeader'; -import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; -import { DestinationLinodeObjectStorageDetailsForm } from 'src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm'; -import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; - -import type { LandingHeaderProps } from 'src/components/LandingHeader'; -import type { CreateDestinationForm } from 'src/features/DataStream/Shared/types'; - -export const DestinationCreate = () => { - const theme = useTheme(); - const { mutateAsync: createDestination } = useCreateDestinationMutation(); - const navigate = useNavigate(); - - const landingHeaderProps: LandingHeaderProps = { - breadcrumbProps: { - pathname: '/datastream/destinations/create', - crumbOverrides: [ - { - label: 'DataStream', - linkTo: '/datastream/destinations', - position: 1, - }, - ], - }, - removeCrumbX: 2, - title: 'Create Destination', - }; - - const form = useForm({ - defaultValues: { - type: destinationType.LinodeObjectStorage, - details: { - region: '', - }, - }, - mode: 'onBlur', - resolver: yupResolver(createDestinationSchema), - }); - const { control, handleSubmit } = form; - - const selectedDestinationType = useWatch({ - control, - name: 'type', - }); - - const onSubmit = () => { - const payload = form.getValues(); - createDestination(payload).then(() => { - navigate({ to: '/datastream/destinations' }); - }); - }; - - return ( - <> - - - - -
- ( - { - field.onChange(value); - }} - options={destinationTypeOptions} - value={getDestinationTypeOption(field.value)} - /> - )} - rules={{ required: true }} - /> - ( - { - field.onChange(value); - }} - placeholder="Destination Name..." - value={field.value} - /> - )} - rules={{ required: true }} - /> - {selectedDestinationType === - destinationType.LinodeObjectStorage && ( - - )} - -
-
- - - - - ); -}; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.test.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.test.tsx similarity index 100% rename from packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.test.tsx rename to packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.test.tsx diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.tsx new file mode 100644 index 00000000000..1a49f6e87c4 --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.tsx @@ -0,0 +1,62 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { destinationType } from '@linode/api-v4'; +import { useCreateDestinationMutation } from '@linode/queries'; +import { destinationSchema } from '@linode/validation'; +import { useNavigate } from '@tanstack/react-router'; +import * as React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { DestinationForm } from 'src/features/DataStream/Destinations/DestinationForm/DestinationForm'; + +import type { LandingHeaderProps } from 'src/components/LandingHeader'; +import type { DestinationFormType } from 'src/features/DataStream/Shared/types'; + +export const DestinationCreate = () => { + const { mutateAsync: createDestination } = useCreateDestinationMutation(); + const navigate = useNavigate(); + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/datastream/destinations/create', + crumbOverrides: [ + { + label: 'DataStream', + linkTo: '/datastream/destinations', + position: 1, + }, + ], + }, + removeCrumbX: 2, + title: 'Create Destination', + }; + + const form = useForm({ + defaultValues: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + mode: 'onBlur', + resolver: yupResolver(destinationSchema), + }); + + const onSubmit = () => { + const payload = form.getValues(); + createDestination(payload).then(() => { + navigate({ to: '/datastream/destinations' }); + }); + }; + + return ( + <> + + + + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.test.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.test.tsx new file mode 100644 index 00000000000..f78db66ab0c --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.test.tsx @@ -0,0 +1,63 @@ +import { + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import React from 'react'; +import { describe } from 'vitest'; + +import { destinationFactory } from 'src/factories/datastream'; +import { DestinationEdit } from 'src/features/DataStream/Destinations/DestinationForm/DestinationEdit'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +const loadingTestId = 'circle-progress'; +const destinationId = 123; +const mockDestination = destinationFactory.build({ + id: destinationId, + label: `Destination ${destinationId}`, +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: vi.fn().mockReturnValue({ destinationId: 123 }), + }; +}); + +describe('DestinationEdit', () => { + const assertInputHasValue = (inputLabel: string, inputValue: string) => { + expect(screen.getByLabelText(inputLabel)).toHaveValue(inputValue); + }; + + it('should render edited destination when destination fetched properly', async () => { + server.use( + http.get(`*/monitor/streams/destinations/${destinationId}`, () => { + return HttpResponse.json(mockDestination); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } + + assertInputHasValue('Destination Type', 'Linode Object Storage'); + await waitFor(() => { + assertInputHasValue('Destination Name', 'Destination 123'); + }); + assertInputHasValue('Host', '3000'); + assertInputHasValue('Bucket', 'Bucket Name'); + await waitFor(() => { + assertInputHasValue('Region', 'US, Chicago, IL (us-ord)'); + }); + assertInputHasValue('Access Key ID', 'Access Id'); + assertInputHasValue('Secret Access Key', 'Access Secret'); + assertInputHasValue('Log Path Prefix', 'file'); + }); +}); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.tsx new file mode 100644 index 00000000000..21bc2480e39 --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.tsx @@ -0,0 +1,124 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { destinationType } from '@linode/api-v4'; +import { + useDestinationQuery, + useUpdateDestinationMutation, +} from '@linode/queries'; +import { Box, CircleProgress, ErrorState } from '@linode/ui'; +import { destinationSchema } from '@linode/validation'; +import { useNavigate, useParams } from '@tanstack/react-router'; +import { enqueueSnackbar } from 'notistack'; +import * as React from 'react'; +import { useEffect } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { DestinationForm } from 'src/features/DataStream/Destinations/DestinationForm/DestinationForm'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import type { LandingHeaderProps } from 'src/components/LandingHeader'; +import type { DestinationFormType } from 'src/features/DataStream/Shared/types'; + +export const DestinationEdit = () => { + const navigate = useNavigate(); + const { destinationId } = useParams({ + from: '/datastream/destinations/$destinationId/edit', + }); + const { mutateAsync: updateDestination } = useUpdateDestinationMutation(); + const { + data: destination, + isLoading, + error, + } = useDestinationQuery(Number(destinationId)); + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/datastream/destinations/edit', + crumbOverrides: [ + { + label: 'DataStream', + linkTo: '/datastream/destinations', + position: 1, + }, + ], + }, + removeCrumbX: 2, + title: 'Edit Destination', + }; + + const form = useForm({ + defaultValues: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + mode: 'onBlur', + resolver: yupResolver(destinationSchema), + }); + + useEffect(() => { + if (destination) { + form.reset({ + ...destination, + }); + } + }, [destination, form]); + + const onSubmit = () => { + const payload = { + id: destinationId, + ...form.getValues(), + }; + + updateDestination(payload) + .then(() => { + navigate({ to: '/datastream/destinations' }); + return enqueueSnackbar( + `Destination ${payload.label} edited successfully`, + { + variant: 'success', + } + ); + }) + .catch((error) => { + return enqueueSnackbar( + getAPIErrorOrDefault( + error, + `There was an issue editing your destination` + )[0].reason, + { + variant: 'error', + } + ); + }); + }; + + return ( + <> + + + {isLoading && ( + + + + )} + {error && ( + + )} + {!isLoading && !error && ( + + + + )} + + ); +}; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationForm.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationForm.tsx new file mode 100644 index 00000000000..c1288dc2c06 --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationForm.tsx @@ -0,0 +1,101 @@ +import { destinationType } from '@linode/api-v4'; +import { Autocomplete, Box, Button, Paper, TextField } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; +import * as React from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { useFormContext } from 'react-hook-form'; +import { Controller, useWatch } from 'react-hook-form'; + +import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; +import { DestinationLinodeObjectStorageDetailsForm } from 'src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm'; +import { LabelValue } from 'src/features/DataStream/Shared/LabelValue'; +import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; + +import type { + DestinationFormType, + FormMode, +} from 'src/features/DataStream/Shared/types'; + +type DestinationFormProps = { + destinationId?: string; + mode: FormMode; + onSubmit: SubmitHandler; +}; + +export const DestinationForm = (props: DestinationFormProps) => { + const { mode, onSubmit, destinationId } = props; + const theme = useTheme(); + + const { control, handleSubmit } = useFormContext(); + + const selectedDestinationType = useWatch({ + control, + name: 'type', + }); + + return ( + <> + +
+ {destinationId && ( + + )} + ( + { + field.onChange(value); + }} + options={destinationTypeOptions} + value={getDestinationTypeOption(field.value)} + /> + )} + rules={{ required: true }} + /> + ( + { + field.onChange(value); + }} + placeholder="Destination Name..." + value={field.value} + /> + )} + rules={{ required: true }} + /> + {selectedDestinationType === destinationType.LinodeObjectStorage && ( + + )} + +
+ + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/destinationCreateLazyRoute.ts b/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationCreateLazyRoute.ts similarity index 84% rename from packages/manager/src/features/DataStream/Destinations/DestinationCreate/destinationCreateLazyRoute.ts rename to packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationCreateLazyRoute.ts index 7f6ca4650f1..0f044abab07 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/destinationCreateLazyRoute.ts +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationCreateLazyRoute.ts @@ -1,6 +1,6 @@ import { createLazyRoute } from '@tanstack/react-router'; -import { DestinationCreate } from 'src/features/DataStream/Destinations/DestinationCreate/DestinationCreate'; +import { DestinationCreate } from 'src/features/DataStream/Destinations/DestinationForm/DestinationCreate'; export const destinationCreateLazyRoute = createLazyRoute( '/datastream/destinations/create' diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute.ts b/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute.ts new file mode 100644 index 00000000000..6d1ec02cf30 --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { DestinationEdit } from 'src/features/DataStream/Destinations/DestinationForm/DestinationEdit'; + +export const destinationEditLazyRoute = createLazyRoute( + '/datastream/destinations/$destinationId/edit' +)({ + component: DestinationEdit, +}); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationTableRow.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationTableRow.tsx index da462042c57..3f4c0f1ecfc 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationTableRow.tsx +++ b/packages/manager/src/features/DataStream/Destinations/DestinationTableRow.tsx @@ -5,16 +5,18 @@ import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; +import { DestinationActionMenu } from 'src/features/DataStream/Destinations/DestinationActionMenu'; +import type { DestinationHandlers } from './DestinationActionMenu'; import type { Destination } from '@linode/api-v4'; -interface DestinationTableRowProps { +interface DestinationTableRowProps extends DestinationHandlers { destination: Destination; } export const DestinationTableRow = React.memo( (props: DestinationTableRowProps) => { - const { destination } = props; + const { destination, onDelete, onEdit } = props; return ( @@ -31,6 +33,13 @@ export const DestinationTableRow = React.memo( + + + ); } diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx index b7e78477b20..1d16ac50963 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx +++ b/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx @@ -1,48 +1,81 @@ -import { waitForElementToBeRemoved } from '@testing-library/react'; +import { screen, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; +import { beforeEach, describe, expect } from 'vitest'; import { destinationFactory } from 'src/factories/datastream'; import { DestinationsLanding } from 'src/features/DataStream/Destinations/DestinationsLanding'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; const loadingTestId = 'circle-progress'; +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(() => vi.fn()), + useDeleteDestinationMutation: vi.fn().mockReturnValue({ + mutateAsync: vi.fn(), + }), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useDeleteDestinationMutation: queryMocks.useDeleteDestinationMutation, + }; +}); + +const destination = destinationFactory.build({ id: 1 }); +const destinations = [destination, ...destinationFactory.buildList(30)]; + describe('Destinations Landing Table', () => { + const renderComponentAndWaitForLoadingComplete = async () => { + renderWithTheme(, { + initialRoute: '/datastream/destinations', + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } + }; + + beforeEach(() => { + mockMatchMedia(); + }); + it('should render destinations landing tab header and table with items PaginationFooter', async () => { server.use( http.get('*/monitor/streams/destinations', () => { - return HttpResponse.json( - makeResourcePage(destinationFactory.buildList(30)) - ); + return HttpResponse.json(makeResourcePage(destinations)); }) ); - - const { getByText, queryByTestId, getAllByTestId, getByPlaceholderText } = - renderWithTheme(, { - initialRoute: '/datastream/destinations', - }); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + await renderComponentAndWaitForLoadingComplete(); // search text input - getByPlaceholderText('Search for a Destination'); + screen.getByPlaceholderText('Search for a Destination'); // button - getByText('Create Destination'); + screen.getByText('Create Destination'); // Table column headers - getByText('Name'); - getByText('Type'); - getByText('ID'); - getByText('Last Modified'); + screen.getByText('Name'); + screen.getByText('Type'); + screen.getByText('ID'); + screen.getByText('Creation Time'); + screen.getByText('Last Modified'); // PaginationFooter - const paginationFooterSelectPageSizeInput = getAllByTestId( + const paginationFooterSelectPageSizeInput = screen.getAllByTestId( 'textfield-input' )[1] as HTMLInputElement; expect(paginationFooterSelectPageSizeInput.value).toBe('Show 25'); @@ -55,18 +88,64 @@ describe('Destinations Landing Table', () => { }) ); - const { getByText, queryByTestId } = renderWithTheme( - , - { - initialRoute: '/datastream/destinations', - } + await renderComponentAndWaitForLoadingComplete(); + + screen.getByText((text) => + text.includes('Create a destination for cloud logs') ); + }); - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + const clickOnActionMenu = async () => { + const actionMenu = screen.getByLabelText( + `Action menu for Destination ${destination.label}` + ); + await userEvent.click(actionMenu); + }; + + const clickOnActionMenuItem = async (itemText: string) => { + await userEvent.click(screen.getByText(itemText)); + }; + + describe('given action menu', () => { + beforeEach(() => { + server.use( + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(destinations)); + }) + ); + }); - getByText((text) => text.includes('Create a destination for cloud logs')); + describe('when Edit clicked', () => { + it('should navigate to edit page', async () => { + const mockNavigate = vi.fn(); + queryMocks.useNavigate.mockReturnValue(mockNavigate); + + await renderComponentAndWaitForLoadingComplete(); + + await clickOnActionMenu(); + await clickOnActionMenuItem('Edit'); + + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/datastream/destinations/1/edit', + }); + }); + }); + + describe('when Delete clicked', () => { + it('should delete destination', async () => { + const mockDeleteDestinationMutation = vi.fn().mockResolvedValue({}); + queryMocks.useDeleteDestinationMutation.mockReturnValue({ + mutateAsync: mockDeleteDestinationMutation, + }); + + await renderComponentAndWaitForLoadingComplete(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Delete'); + + expect(mockDeleteDestinationMutation).toHaveBeenCalledWith({ + id: 1, + }); + }); + }); }); }); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx index f329d54c893..48ff5497690 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx +++ b/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx @@ -1,11 +1,16 @@ -import { useDestinationsQuery } from '@linode/queries'; +import { + useDeleteDestinationMutation, + useDestinationsQuery, +} from '@linode/queries'; import { CircleProgress, ErrorState, Hidden } from '@linode/ui'; import { TableBody, TableHead, TableRow } from '@mui/material'; import Table from '@mui/material/Table'; import { useNavigate, useSearch } from '@tanstack/react-router'; +import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { TableCell } from 'src/components/TableCell'; import { TableSortCell } from 'src/components/TableSortCell'; import { DESTINATIONS_TABLE_DEFAULT_ORDER, @@ -17,9 +22,14 @@ import { DestinationTableRow } from 'src/features/DataStream/Destinations/Destin import { DataStreamTabHeader } from 'src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import type { Destination } from '@linode/api-v4'; +import type { DestinationHandlers } from 'src/features/DataStream/Destinations/DestinationActionMenu'; export const DestinationsLanding = () => { const navigate = useNavigate(); + const { mutateAsync: deleteDestination } = useDeleteDestinationMutation(); const destinationsUrl = '/datastream/destinations'; const search = useSearch({ from: destinationsUrl, @@ -93,6 +103,37 @@ export const DestinationsLanding = () => { ); } + const handleEdit = ({ id }: Destination) => { + navigate({ to: `/datastream/destinations/${id}/edit` }); + }; + + const handleDelete = ({ id, label }: Destination) => { + deleteDestination({ + id, + }) + .then(() => { + return enqueueSnackbar(`Destination ${label} deleted successfully`, { + variant: 'success', + }); + }) + .catch((error) => { + return enqueueSnackbar( + getAPIErrorOrDefault( + error, + `There was an issue deleting your destination` + )[0].reason, + { + variant: 'error', + } + ); + }); + }; + + const handlers: DestinationHandlers = { + onEdit: handleEdit, + onDelete: handleDelete, + }; + return ( <> { > Last Modified + @@ -155,6 +197,7 @@ export const DestinationsLanding = () => { ))} diff --git a/packages/manager/src/features/DataStream/Shared/types.ts b/packages/manager/src/features/DataStream/Shared/types.ts index e0e4a08a142..cd5392286bd 100644 --- a/packages/manager/src/features/DataStream/Shared/types.ts +++ b/packages/manager/src/features/DataStream/Shared/types.ts @@ -1,6 +1,9 @@ import { destinationType, streamStatus, streamType } from '@linode/api-v4'; -import type { CreateDestinationPayload } from '@linode/api-v4'; +import type { + CreateDestinationPayload, + UpdateDestinationPayload, +} from '@linode/api-v4'; export type FormMode = 'create' | 'edit'; @@ -42,4 +45,6 @@ export const streamStatusOptions: LabelValueOption[] = [ }, ]; -export type CreateDestinationForm = CreateDestinationPayload; +export type DestinationFormType = + | CreateDestinationPayload + | UpdateDestinationPayload; diff --git a/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx index 58f84216c9a..806aa9549e0 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx @@ -10,7 +10,7 @@ import type { StreamStatus } from '@linode/api-v4'; const fakeHandler = vi.fn(); -describe('Stream action menu', () => { +describe('StreamActionMenu', () => { const renderComponent = (status: StreamStatus) => { renderWithTheme( { render={({ field, fieldState }) => ( { @@ -7,6 +7,6 @@ export interface StreamFormType } export interface StreamAndDestinationFormType { - destination: CreateDestinationForm; + destination: DestinationFormType; stream: StreamFormType; } diff --git a/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx index 58b69d54200..e14ef8f8872 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx @@ -166,7 +166,7 @@ describe('Streams Landing Table', () => { }); }); - describe('when Enabled clicked', () => { + describe('when Enable clicked', () => { it('should update stream with proper parameters', async () => { const mockUpdateStreamMutation = vi.fn().mockResolvedValue({}); queryMocks.useUpdateStreamMutation.mockReturnValue({ diff --git a/packages/manager/src/features/Events/factories/datastream.tsx b/packages/manager/src/features/Events/factories/datastream.tsx index ddcffb1fb59..691826b52a4 100644 --- a/packages/manager/src/features/Events/factories/datastream.tsx +++ b/packages/manager/src/features/Events/factories/datastream.tsx @@ -40,4 +40,20 @@ export const destination: PartialEventMap<'destination'> = { ), }, + destination_delete: { + notification: (e) => ( + <> + Destination has been{' '} + deleted. + + ), + }, + destination_update: { + notification: (e) => ( + <> + Destination has been{' '} + updated. + + ), + }, }; diff --git a/packages/manager/src/mocks/presets/crud/datastream.ts b/packages/manager/src/mocks/presets/crud/datastream.ts index 8fdde956d14..313d1721c1c 100644 --- a/packages/manager/src/mocks/presets/crud/datastream.ts +++ b/packages/manager/src/mocks/presets/crud/datastream.ts @@ -1,9 +1,11 @@ import { createDestinations, createStreams, + deleteDestination, deleteStream, getDestinations, getStreams, + updateDestination, updateStream, } from 'src/mocks/presets/crud/handlers/datastream'; @@ -18,6 +20,8 @@ export const datastreamCrudPreset: MockPresetCrud = { updateStream, getDestinations, createDestinations, + deleteDestination, + updateDestination, ], id: 'datastream:crud', label: 'Data Stream CRUD', diff --git a/packages/manager/src/mocks/presets/crud/handlers/datastream.ts b/packages/manager/src/mocks/presets/crud/handlers/datastream.ts index 52c24054c3b..74bde7244ca 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/datastream.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/datastream.ts @@ -5,6 +5,7 @@ import { destinationFactory, streamFactory } from 'src/factories/datastream'; import { mswDB } from 'src/mocks/indexedDB'; import { queueEvents } from 'src/mocks/utilities/events'; import { + makeErrorResponse, makeNotFoundResponse, makePaginatedResponse, makeResponse, @@ -242,3 +243,92 @@ export const createDestinations = (mockState: MockState) => [ } ), ]; + +export const updateDestination = (mockState: MockState) => [ + http.put( + '*/v4beta/monitor/streams/destinations/:id', + async ({ + params, + request, + }): Promise> => { + const id = Number(params.id); + const destination = await mswDB.get('destinations', id); + + if (!destination) { + return makeNotFoundResponse(); + } + + const payload = await request.clone().json(); + const [majorVersion, minorVersion] = destination.version.split('.'); + const updatedDestination = { + ...destination, + ...payload, + version: `${majorVersion}.${+minorVersion + 1}`, + updated: DateTime.now().toISO(), + }; + + await mswDB.update('destinations', id, updatedDestination, mockState); + + queueEvents({ + event: { + action: 'destination_update', + entity: { + id: destination.id, + label: destination.label, + type: 'stream', + url: `/v4beta/monitor/streams/${destination.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(updatedDestination); + } + ), +]; + +export const deleteDestination = (mockState: MockState) => [ + http.delete( + '*/v4beta/monitor/streams/destinations/:id', + async ({ params }): Promise> => { + const id = Number(params.id); + const destination = await mswDB.get('destinations', id); + const streams = await mswDB.getAll('streams'); + const currentlyAttachedDestinations = new Set( + streams?.flatMap(({ destinations }) => + destinations?.map(({ id }) => id) + ) + ); + + if (!destination) { + return makeNotFoundResponse(); + } + + if (currentlyAttachedDestinations.has(id)) { + return makeErrorResponse( + `Destination with id ${id} is attached to a stream and cannot be deleted`, + 409 + ); + } + + await mswDB.delete('destinations', id, mockState); + + queueEvents({ + event: { + action: 'destination_delete', + entity: { + id: destination.id, + label: destination.label, + type: 'domain', + url: `/v4beta/monitor/streams/${destination.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse({}); + } + ), +]; diff --git a/packages/manager/src/routes/datastream/index.ts b/packages/manager/src/routes/datastream/index.ts index a8205708a23..992795c7767 100644 --- a/packages/manager/src/routes/datastream/index.ts +++ b/packages/manager/src/routes/datastream/index.ts @@ -83,12 +83,32 @@ const destinationsCreateRoute = createRoute({ path: 'destinations/create', }).lazy(() => import( - 'src/features/DataStream/Destinations/DestinationCreate/destinationCreateLazyRoute' + 'src/features/DataStream/Destinations/DestinationForm/destinationCreateLazyRoute' ).then((m) => m.destinationCreateLazyRoute) ); +const destinationsEditRoute = createRoute({ + getParentRoute: () => dataStreamRoute, + params: { + parse: ({ destinationId }: { destinationId: string }) => ({ + destinationId: Number(destinationId), + }), + stringify: ({ destinationId }: { destinationId: number }) => ({ + destinationId: String(destinationId), + }), + }, + path: 'destinations/$destinationId/edit', +}).lazy(() => + import( + 'src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute' + ).then((m) => m.destinationEditLazyRoute) +); + export const dataStreamRouteTree = dataStreamRoute.addChildren([ dataStreamLandingRoute, streamsRoute.addChildren([streamsCreateRoute, streamsEditRoute]), - destinationsRoute.addChildren([destinationsCreateRoute]), + destinationsRoute.addChildren([ + destinationsCreateRoute, + destinationsEditRoute, + ]), ]); diff --git a/packages/queries/.changeset/pr-12749-upcoming-features-1756101508851.md b/packages/queries/.changeset/pr-12749-upcoming-features-1756101508851.md new file mode 100644 index 00000000000..3037ec5f274 --- /dev/null +++ b/packages/queries/.changeset/pr-12749-upcoming-features-1756101508851.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Upcoming Features +--- + +Add queries for Destinations DELETE, PUT API endpoints ([#12749](https://github.com/linode/manager/pull/12749)) diff --git a/packages/queries/src/datastreams/datastream.ts b/packages/queries/src/datastreams/datastream.ts index 4ab748b38d5..ea5d928b7c7 100644 --- a/packages/queries/src/datastreams/datastream.ts +++ b/packages/queries/src/datastreams/datastream.ts @@ -1,11 +1,13 @@ import { createDestination, createStream, + deleteDestination, deleteStream, getDestination, getDestinations, getStream, getStreams, + updateDestination, updateStream, } from '@linode/api-v4'; import { profileQueries } from '@linode/queries'; @@ -22,6 +24,7 @@ import type { Params, ResourcePage, Stream, + UpdateDestinationPayloadWithId, UpdateStreamPayloadWithId, } from '@linode/api-v4'; @@ -94,10 +97,13 @@ export const useCreateStreamMutation = () => { return useMutation({ mutationFn: createStream, onSuccess(stream) { - // Invalidate paginated lists + // Invalidate streams queryClient.invalidateQueries({ queryKey: datastreamQueries.streams._ctx.paginated._def, }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams._ctx.all._def, + }); // Set Stream in cache queryClient.setQueryData( @@ -118,9 +124,12 @@ export const useUpdateStreamMutation = () => { return useMutation({ mutationFn: ({ id, ...data }) => updateStream(id, data), onSuccess(stream) { - // Invalidate paginated lists + // Invalidate streams + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams._ctx.paginated._def, + }); queryClient.invalidateQueries({ - queryKey: datastreamQueries.streams.queryKey, + queryKey: datastreamQueries.streams._ctx.all._def, }); // Update stream in cache @@ -137,9 +146,12 @@ export const useDeleteStreamMutation = () => { return useMutation<{}, APIError[], { id: number }>({ mutationFn: ({ id }) => deleteStream(id), onSuccess(_, { id }) { - // Invalidate paginated lists + // Invalidate streams queryClient.invalidateQueries({ - queryKey: datastreamQueries.streams.queryKey, + queryKey: datastreamQueries.streams._ctx.paginated._def, + }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams._ctx.all._def, }); // Remove stream from the cache @@ -166,15 +178,21 @@ export const useDestinationsQuery = ( ...datastreamQueries.destinations._ctx.paginated(params, filter), }); +export const useDestinationQuery = (id: number) => + useQuery({ ...datastreamQueries.destination(id) }); + export const useCreateDestinationMutation = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: createDestination, onSuccess(destination) { - // Invalidate paginated lists + // Invalidate destinations queryClient.invalidateQueries({ queryKey: datastreamQueries.destinations._ctx.paginated._def, }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.destinations._ctx.all._def, + }); // Set Destination in cache queryClient.setQueryData( @@ -189,3 +207,46 @@ export const useCreateDestinationMutation = () => { }, }); }; + +export const useUpdateDestinationMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...data }) => updateDestination(id, data), + onSuccess(destination) { + // Invalidate destinations + queryClient.invalidateQueries({ + queryKey: datastreamQueries.destinations._ctx.paginated._def, + }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.destinations._ctx.all._def, + }); + + // Update destination in cache + queryClient.setQueryData( + datastreamQueries.destination(destination.id).queryKey, + destination, + ); + }, + }); +}; + +export const useDeleteDestinationMutation = () => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], { id: number }>({ + mutationFn: ({ id }) => deleteDestination(id), + onSuccess(_, { id }) { + // Invalidate destinations + queryClient.invalidateQueries({ + queryKey: datastreamQueries.destinations._ctx.paginated._def, + }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.destinations._ctx.all._def, + }); + + // Remove stream from the cache + queryClient.removeQueries({ + queryKey: datastreamQueries.destination(id).queryKey, + }); + }, + }); +}; diff --git a/packages/validation/src/datastream.schema.ts b/packages/validation/src/datastream.schema.ts index a7242c86388..5fafc0a1b02 100644 --- a/packages/validation/src/datastream.schema.ts +++ b/packages/validation/src/datastream.schema.ts @@ -74,7 +74,7 @@ const linodeObjectStorageDetailsSchema = object({ .required('Access Key Secret is required.'), }); -export const createDestinationSchema = object().shape({ +export const destinationSchema = object().shape({ label: string() .max(maxLength, maxLengthMessage) .required('Destination name is required.'), @@ -157,7 +157,7 @@ export const streamAndDestinationFormSchema = object({ }) .required(), }), - destination: createDestinationSchema.defined().when('stream.destinations', { + destination: destinationSchema.defined().when('stream.destinations', { is: (value: never[]) => value?.length === 1 && value[0] === undefined, then: (schema) => schema, otherwise: (schema) => From f7989deadc7dba45bf502c4d620b9af71fa1390e Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Thu, 28 Aug 2025 10:18:23 +0200 Subject: [PATCH 21/73] Change: [UIE-9059] - Volumes RBAC permissions (#12744) * adapters + volumes landing * Action menu * remaining instances * fix units * oops the mapping! * cleanup * remove wrong check * changesets * feedback @bnussman-akamai * first round of feedback * feedback 2 * missing grant hooks --- .../pr-12744-added-1756112277685.md | 5 ++ packages/api-v4/src/iam/types.ts | 28 ++++++++++- .../pr-12744-added-1756112241644.md | 5 ++ .../adapters/accountGrantsToPermissions.ts | 2 + .../IAM/hooks/adapters/permissionAdapters.ts | 45 +++++++++++------ .../adapters/volumeGrantsToPermissions.ts | 18 +++++++ .../LinodeStorage/LinodeVolumes.test.tsx | 10 ++++ .../LinodeStorage/LinodeVolumes.tsx | 20 +++++--- .../VolumesUpgradeBanner.test.tsx | 18 +++++++ .../Volumes/Drawers/AttachVolumeDrawer.tsx | 24 +++++---- .../Volumes/Drawers/CloneVolumeDrawer.tsx | 32 ++++++------ .../Volumes/Drawers/EditVolumeDrawer.tsx | 21 ++++---- .../Volumes/Drawers/ManageTagsDrawer.tsx | 21 ++++---- .../Volumes/Drawers/ResizeVolumeDrawer.tsx | 26 +++++----- .../VolumeDrawer/LinodeVolumeAttachForm.tsx | 31 +++++------- .../Drawers/VolumeDrawer/ModeSelection.tsx | 5 ++ .../src/features/Volumes/VolumeCreate.tsx | 26 +++++----- .../features/Volumes/VolumeTableRow.test.tsx | 23 +++++++++ .../Volumes/VolumesActionMenu.test.tsx | 23 +++++++++ .../features/Volumes/VolumesActionMenu.tsx | 50 ++++++++++++------- .../src/features/Volumes/VolumesLanding.tsx | 11 ++-- .../Volumes/VolumesLandingEmptyState.tsx | 9 ++-- 22 files changed, 307 insertions(+), 146 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12744-added-1756112277685.md create mode 100644 packages/manager/.changeset/pr-12744-added-1756112241644.md create mode 100644 packages/manager/src/features/IAM/hooks/adapters/volumeGrantsToPermissions.ts diff --git a/packages/api-v4/.changeset/pr-12744-added-1756112277685.md b/packages/api-v4/.changeset/pr-12744-added-1756112277685.md new file mode 100644 index 00000000000..0eb38767b9d --- /dev/null +++ b/packages/api-v4/.changeset/pr-12744-added-1756112277685.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +Volumes IAM RBAC permissions ([#12744](https://github.com/linode/manager/pull/12744)) diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 677cb9cafb5..aecb6c9cf65 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -102,7 +102,8 @@ export type AccountAdmin = | AccountBillingAdmin | AccountFirewallAdmin | AccountLinodeAdmin - | AccountOauthClientAdmin; + | AccountOauthClientAdmin + | AccountVolumeAdmin; /** Permissions associated with the "account_billing_admin" role. */ export type AccountBillingAdmin = @@ -141,6 +142,12 @@ export type AccountLinodeAdmin = AccountLinodeCreator | LinodeAdmin; /** Permissions associated with the "account_linode_creator" role. */ export type AccountLinodeCreator = 'create_linode'; +/** Permissions associated with the "account_volume_admin" role. */ +export type AccountVolumeAdmin = AccountVolumeCreator | VolumeAdmin; + +/** Permissions associated with the "account_volume_creator" role. */ +export type AccountVolumeCreator = 'create_volume'; + /** Permissions associated with the "account_maintenance_viewer" role. */ export type AccountMaintenanceViewer = 'list_maintenances'; @@ -207,7 +214,8 @@ export type AccountViewer = | AccountOauthClientViewer | AccountProfileViewer | FirewallViewer - | LinodeViewer; + | LinodeViewer + | VolumeViewer; /** Permissions associated with the "firewall_admin role. */ export type FirewallAdmin = @@ -287,6 +295,22 @@ export type LinodeViewer = | 'view_linode_network_transfer' | 'view_linode_stats'; +/** Permissions associated with the "volume_admin" role. */ +export type VolumeAdmin = 'delete_volume' | VolumeContributor; + +/** Permissions associated with the "volume_contributor" role. */ +export type VolumeContributor = + | 'attach_volume' + | 'clone_volume' + | 'delete_volume' + | 'detach_volume' + | 'resize_volume' + | 'update_volume' + | VolumeViewer; + +/** Permissions associated with the "volume_viewer" role. */ +export type VolumeViewer = 'view_volume'; + /** Facade roles represent the existing Grant model for entities that are not yet migrated to IAM */ export type AccountRoleFacade = | 'account_database_creator' diff --git a/packages/manager/.changeset/pr-12744-added-1756112241644.md b/packages/manager/.changeset/pr-12744-added-1756112241644.md new file mode 100644 index 00000000000..2149838a386 --- /dev/null +++ b/packages/manager/.changeset/pr-12744-added-1756112241644.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Volumes IAM RBAC permissions ([#12744](https://github.com/linode/manager/pull/12744)) diff --git a/packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts b/packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts index 66271e99d61..183b4470d20 100644 --- a/packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts +++ b/packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts @@ -67,6 +67,8 @@ export const accountGrantsToPermissions = ( create_firewall: unrestricted || globalGrants?.add_firewalls, // AccountLinodeAdmin create_linode: unrestricted || globalGrants?.add_linodes, + // AccountVolumeAdmin + create_volume: unrestricted || globalGrants?.add_volumes, // AccountOAuthClientAdmin create_oauth_client: true, update_oauth_client: true, diff --git a/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts b/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts index 85efddb1d06..e88331b74db 100644 --- a/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts +++ b/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts @@ -1,6 +1,7 @@ import { accountGrantsToPermissions } from './accountGrantsToPermissions'; import { firewallGrantsToPermissions } from './firewallGrantsToPermissions'; import { linodeGrantsToPermissions } from './linodeGrantsToPermissions'; +import { volumeGrantsToPermissions } from './volumeGrantsToPermissions'; import type { EntityBase } from '../usePermissions'; import type { @@ -24,23 +25,31 @@ export const entityPermissionMapFrom = ( const entityPermissionsMap: EntityPermissionMap = {}; if (grants) { grants[grantType]?.forEach((entity) => { + /** Entity Permissions Maps */ + const firewallPermissionsMap = firewallGrantsToPermissions( + entity?.permissions, + profile?.restricted + ) as PermissionMap; + const linodePermissionsMap = linodeGrantsToPermissions( + entity?.permissions, + profile?.restricted + ) as PermissionMap; + const volumePermissionsMap = volumeGrantsToPermissions( + entity?.permissions, + profile?.restricted + ) as PermissionMap; + + /** Add entity permissions to map */ switch (grantType) { case 'firewall': - // eslint-disable-next-line no-case-declarations - const firewallPermissionsMap = firewallGrantsToPermissions( - entity?.permissions, - profile?.restricted - ) as PermissionMap; entityPermissionsMap[entity.id] = firewallPermissionsMap; break; case 'linode': - // eslint-disable-next-line no-case-declarations - const linodePermissionsMap = linodeGrantsToPermissions( - entity?.permissions, - profile?.restricted - ) as PermissionMap; entityPermissionsMap[entity.id] = linodePermissionsMap; break; + case 'volume': + entityPermissionsMap[entity.id] = volumePermissionsMap; + break; } }); } @@ -55,8 +64,14 @@ export const fromGrants = ( isRestricted?: boolean, entityId?: number ): PermissionMap => { + /** Find the entity in the grants */ + const firewall = grants?.firewall.find((f) => f.id === entityId); + const linode = grants?.linode.find((f) => f.id === entityId); + const volume = grants?.volume.find((f) => f.id === entityId); + let usersPermissionsMap = {} as PermissionMap; + /** Convert the entity permissions to the new IAM RBAC model */ switch (accessType) { case 'account': usersPermissionsMap = accountGrantsToPermissions( @@ -65,21 +80,23 @@ export const fromGrants = ( ) as PermissionMap; break; case 'firewall': - // eslint-disable-next-line no-case-declarations - const firewall = grants?.firewall.find((f) => f.id === entityId); usersPermissionsMap = firewallGrantsToPermissions( firewall?.permissions, isRestricted ) as PermissionMap; break; case 'linode': - // eslint-disable-next-line no-case-declarations - const linode = grants?.linode.find((f) => f.id === entityId); usersPermissionsMap = linodeGrantsToPermissions( linode?.permissions, isRestricted ) as PermissionMap; break; + case 'volume': + usersPermissionsMap = volumeGrantsToPermissions( + volume?.permissions, + isRestricted + ) as PermissionMap; + break; default: throw new Error(`Unknown access type: ${accessType}`); } diff --git a/packages/manager/src/features/IAM/hooks/adapters/volumeGrantsToPermissions.ts b/packages/manager/src/features/IAM/hooks/adapters/volumeGrantsToPermissions.ts new file mode 100644 index 00000000000..cbb52de867b --- /dev/null +++ b/packages/manager/src/features/IAM/hooks/adapters/volumeGrantsToPermissions.ts @@ -0,0 +1,18 @@ +import type { GrantLevel, VolumeAdmin } from '@linode/api-v4'; + +/** Map the existing Grant model to the new IAM RBAC model. */ +export const volumeGrantsToPermissions = ( + grantLevel?: GrantLevel, + isRestricted?: boolean +): Record => { + const unrestricted = isRestricted === false; // explicit === false since the profile can be undefined + return { + attach_volume: unrestricted || grantLevel === 'read_write', + clone_volume: unrestricted || grantLevel === 'read_write', + delete_volume: unrestricted || grantLevel === 'read_write', + detach_volume: unrestricted || grantLevel === 'read_write', + resize_volume: unrestricted || grantLevel === 'read_write', + update_volume: unrestricted || grantLevel === 'read_write', + view_volume: unrestricted || grantLevel !== null, + }; +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.test.tsx index 5baadb980bd..374e91c96b8 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.test.tsx @@ -16,6 +16,7 @@ const queryMocks = vi.hoisted(() => ({ useNavigate: vi.fn(), useParams: vi.fn(), useSearch: vi.fn(), + usePermissions: vi.fn(), })); vi.mock('@tanstack/react-router', async () => { @@ -28,11 +29,20 @@ vi.mock('@tanstack/react-router', async () => { }; }); +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + describe('LinodeVolumes', async () => { beforeEach(() => { queryMocks.useNavigate.mockReturnValue(vi.fn()); queryMocks.useSearch.mockReturnValue({}); queryMocks.useParams.mockReturnValue({}); + queryMocks.usePermissions.mockReturnValue({}); }); const volumes = volumeFactory.buildList(3); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.tsx index 70225571e4f..c0e309cb62a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.tsx @@ -19,6 +19,7 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { DeleteVolumeDialog } from 'src/features/Volumes/Dialogs/DeleteVolumeDialog'; import { DetachVolumeDialog } from 'src/features/Volumes/Dialogs/DetachVolumeDialog'; import { CloneVolumeDrawer } from 'src/features/Volumes/Drawers/CloneVolumeDrawer'; @@ -28,7 +29,6 @@ import { ResizeVolumeDrawer } from 'src/features/Volumes/Drawers/ResizeVolumeDra import { VolumeDetailsDrawer } from 'src/features/Volumes/Drawers/VolumeDetailsDrawer'; import { LinodeVolumeAddDrawer } from 'src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeAddDrawer'; import { VolumeTableRow } from 'src/features/Volumes/VolumeTableRow'; -import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; @@ -41,12 +41,11 @@ export const LinodeVolumes = () => { const id = Number(linodeId); const { data: linode } = useLinodeQuery(id); - - const isLinodesGrantReadOnly = useIsResourceRestricted({ - grantLevel: 'read_only', - grantType: 'linode', - id, - }); + const { data: linodePermissions } = usePermissions( + 'linode', + ['update_linode'], + linode?.id + ); const { handleOrderChange, order, orderBy } = useOrderV2({ initialRoute: { @@ -207,8 +206,13 @@ export const LinodeVolumes = () => { Volumes diff --git a/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.test.tsx index dd3411a217c..1946105a39e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.test.tsx @@ -7,7 +7,25 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { VolumesUpgradeBanner } from './VolumesUpgradeBanner'; +const queryMocks = vi.hoisted(() => ({ + usePermissions: vi.fn(), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + describe('VolumesUpgradeBanner', () => { + beforeEach(() => { + queryMocks.usePermissions.mockReturnValue({ + update_volume: true, + }); + }); + it('should render if there is an upgradable volume', async () => { const volume = volumeFactory.build(); const notification = notificationFactory.build({ diff --git a/packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.tsx b/packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.tsx index 7779d00aee0..f441f69f92a 100644 --- a/packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.tsx @@ -1,4 +1,4 @@ -import { useAttachVolumeMutation, useGrants } from '@linode/queries'; +import { useAttachVolumeMutation } from '@linode/queries'; import { LinodeSelect } from '@linode/shared'; import { ActionsPanel, @@ -16,6 +16,7 @@ import { number, object } from 'yup'; import { BLOCK_STORAGE_ENCRYPTION_SETTING_IMMUTABLE_COPY } from 'src/components/Encryption/constants'; import { useIsBlockStorageEncryptionFeatureEnabled } from 'src/components/Encryption/utils'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useEventsPollingActions } from 'src/queries/events/events'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; @@ -46,7 +47,13 @@ export const AttachVolumeDrawer = React.memo((props: Props) => { const { checkForNewEvents } = useEventsPollingActions(); - const { data: grants } = useGrants(); + const { data: permissions } = usePermissions( + 'volume', + ['attach_volume'], + volume?.id + ); + + const canAttachVolume = permissions?.attach_volume; const { error, mutateAsync: attachVolume } = useAttachVolumeMutation(); @@ -86,11 +93,6 @@ export const AttachVolumeDrawer = React.memo((props: Props) => { overwrite: 'Overwrite', }; - const isReadOnly = - grants !== undefined && - grants.volume.find((grant) => grant.id === volume?.id)?.permissions === - 'read_only'; - const hasErrorFor = getAPIErrorFor( errorResources, error === null ? undefined : error @@ -107,7 +109,7 @@ export const AttachVolumeDrawer = React.memo((props: Props) => { title={`Attach Volume ${volume?.label}`} >
- {isReadOnly && ( + {!canAttachVolume && ( { {generalError && } { )} { { const { isFetching, onClose: _onClose, open, volume, volumeError } = props; + const { data: accountPermissions } = usePermissions('account', [ + 'create_volume', + ]); + const { data: volumePermissions } = usePermissions( + 'volume', + ['clone_volume'], + volume?.id + ); + const canCloneVolume = + volumePermissions?.clone_volume && accountPermissions?.create_volume; + const { mutateAsync: cloneVolume } = useCloneVolumeMutation(); const { checkForNewEvents } = useEventsPollingActions(); - const { data: grants } = useGrants(); const { data: types, isError, isLoading } = useVolumeTypesQuery(); const { isBlockStorageEncryptionFeatureEnabled } = useIsBlockStorageEncryptionFeatureEnabled(); - // Even if a restricted user has the ability to create Volumes, they - // can't clone a Volume they only have read only permission on. - const isReadOnly = - grants !== undefined && - grants.volume.find((grant) => grant.id === volume?.id)?.permissions === - 'read_only'; - const isInvalidPrice = !types || isError; const { @@ -104,7 +104,7 @@ export const CloneVolumeDrawer = (props: Props) => { title="Clone Volume" > - {isReadOnly && ( + {!canCloneVolume && ( { be available in {volume?.region}. { /> { const { isFetching, onClose: _onClose, open, volume, volumeError } = props; - const { data: grants } = useGrants(); + const { data: permissions } = usePermissions( + 'volume', + ['update_volume'], + volume?.id + ); + const canUpdateVolume = permissions?.update_volume; const { mutateAsync: updateVolume } = useUpdateVolumeMutation(); const { isBlockStorageEncryptionFeatureEnabled } = useIsBlockStorageEncryptionFeatureEnabled(); - const isReadOnly = - grants !== undefined && - grants.volume.find((grant) => grant.id === volume?.id)?.permissions === - 'read_only'; - const { dirty, errors, @@ -91,7 +92,7 @@ export const EditVolumeDrawer = (props: Props) => { title="Edit Volume" > - {isReadOnly && ( + {!canUpdateVolume && ( { {error && } { { const { isFetching, onClose: _onClose, open, volume, volumeError } = props; - const { data: grants } = useGrants(); + const { data: permissions } = usePermissions( + 'volume', + ['update_volume'], + volume?.id + ); + const canUpdateVolume = permissions?.update_volume; const { mutateAsync: updateVolume } = useUpdateVolumeMutation(); - const isReadOnly = - grants !== undefined && - grants.volume.find((grant) => grant.id === volume?.id)?.permissions === - 'read_only'; - const { control, formState: { errors, isDirty, isSubmitting }, @@ -76,7 +77,7 @@ export const ManageTagsDrawer = (props: Props) => { title="Manage Volume Tags" > - {isReadOnly && ( + {!canUpdateVolume && ( { name="tags" render={({ field, fieldState }) => ( @@ -104,7 +105,7 @@ export const ManageTagsDrawer = (props: Props) => { { const { isFetching, onClose: _onClose, open, volume, volumeError } = props; + const { data: permissions } = usePermissions( + 'volume', + ['resize_volume'], + volume?.id + ); + const canResizeVolume = permissions?.resize_volume; + const { mutateAsync: resizeVolume } = useResizeVolumeMutation(); const { checkForNewEvents } = useEventsPollingActions(); @@ -40,14 +44,8 @@ export const ResizeVolumeDrawer = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); - const { data: grants } = useGrants(); const { data: types, isError, isLoading } = useVolumeTypesQuery(); - const isReadOnly = - grants !== undefined && - grants.volume.find((grant) => grant.id === volume?.id)?.permissions === - 'read_only'; - const isInvalidPrice = !types || isError; const { @@ -102,7 +100,7 @@ export const ResizeVolumeDrawer = (props: Props) => { title="Resize Volume" > - {isReadOnly && ( + {!canResizeVolume && ( { )} {error && } { /> { const { linode, onClose, setClientLibraryCopyVisible } = props; - const { data: grants } = useGrants(); - const { enqueueSnackbar } = useSnackbar(); const { checkForNewEvents } = useEventsPollingActions(); - const linodeGrant = grants?.linode.find( - (grant: Grant) => grant.id === linode.id - ); - - const isReadOnly = linodeGrant?.permissions === 'read_only'; - const { mutateAsync: attachVolume } = useAttachVolumeMutation(); const { @@ -102,6 +90,13 @@ export const LinodeVolumeAttachForm = (props: Props) => { values.volume_id !== -1 ); + const { data: permissions } = usePermissions( + 'volume', + ['attach_volume'], + volume?.id + ); + const canAttachVolume = permissions?.attach_volume; + const linodeRequiresClientLibraryUpdate = volume?.encryption === 'enabled' && Boolean(!linode.capabilities?.includes('Block Storage Encryption')); @@ -113,7 +108,7 @@ export const LinodeVolumeAttachForm = (props: Props) => { return ( - {isReadOnly && ( + {!canAttachVolume && ( { )} {error && } { value={values.volume_id} /> { /> { + const { data: permissions } = usePermissions('account', ['create_volume']); + return ( { } data-qa-radio="Create and Attach Volume" + disabled={!permissions.create_volume} label="Create and Attach Volume" value="create" /> diff --git a/packages/manager/src/features/Volumes/VolumeCreate.tsx b/packages/manager/src/features/Volumes/VolumeCreate.tsx index ae1641de646..6d4bbf5d0e5 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.tsx @@ -1,7 +1,6 @@ import { useAccountAgreements, useCreateVolumeMutation, - useGrants, useLinodeQuery, useMutateAccountAgreements, useProfile, @@ -58,6 +57,7 @@ import { import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; import { reportAgreementSigningError } from 'src/utilities/reportAgreementSigningError'; +import { usePermissions } from '../IAM/hooks/usePermissions'; import { SIZE_FIELD_WIDTH } from './constants'; import { ConfigSelect } from './Drawers/VolumeDrawer/ConfigSelect'; import { SizeField } from './Drawers/VolumeDrawer/SizeField'; @@ -134,7 +134,8 @@ export const VolumeCreate = () => { const { data: types, isError, isLoading } = useVolumeTypesQuery(); const { data: profile } = useProfile(); - const { data: grants } = useGrants(); + + const { data: permissions } = usePermissions('account', ['create_volume']); const { data: regions } = useRegionsQuery(); const { isGeckoLAEnabled } = useIsGeckoEnabled( @@ -169,9 +170,6 @@ export const VolumeCreate = () => { ) .map((thisRegion) => thisRegion.id) ?? []; - const doesNotHavePermission = - profile?.restricted && !grants?.global.add_volumes; - const renderSelectTooltip = (tooltipText: string) => { return ( { const isInvalidPrice = !types || isError; + const canCreateVolume = permissions?.create_volume; + const disabled = Boolean( - doesNotHavePermission || + !canCreateVolume || (showGDPRCheckbox && !hasSignedAgreement) || isInvalidPrice ); @@ -348,7 +348,7 @@ export const VolumeCreate = () => { }} title="Create" /> - {doesNotHavePermission && ( + {!canCreateVolume && ( { { /> @@ -419,7 +419,7 @@ export const VolumeCreate = () => { { > { )} { ({ + usePermissions: vi.fn(), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + describe('Volume table row', () => { + beforeEach(() => { + queryMocks.usePermissions.mockReturnValue({ + update_volume: true, + attach_volume: true, + create_volume: true, + delete_volume: true, + resize_volume: true, + clone_volume: true, + }); + }); + it("should show the attached Linode's label if present", async () => { const { getByLabelText, getByTestId, getByText } = renderWithTheme( wrapWithTableBody( diff --git a/packages/manager/src/features/Volumes/VolumesActionMenu.test.tsx b/packages/manager/src/features/Volumes/VolumesActionMenu.test.tsx index b153f0c9e82..5b46eb149a8 100644 --- a/packages/manager/src/features/Volumes/VolumesActionMenu.test.tsx +++ b/packages/manager/src/features/Volumes/VolumesActionMenu.test.tsx @@ -26,7 +26,30 @@ const props: Props = { volume, }; +const queryMocks = vi.hoisted(() => ({ + usePermissions: vi.fn(), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + describe('Volume action menu', () => { + beforeEach(() => { + queryMocks.usePermissions.mockReturnValue({ + update_volume: true, + attach_volume: true, + create_volume: true, + delete_volume: true, + resize_volume: true, + clone_volume: true, + }); + }); + it('should include basic Volume actions', async () => { const { getByLabelText, getByText } = renderWithTheme( diff --git a/packages/manager/src/features/Volumes/VolumesActionMenu.tsx b/packages/manager/src/features/Volumes/VolumesActionMenu.tsx index fc797e971fd..bf2defa3ba6 100644 --- a/packages/manager/src/features/Volumes/VolumesActionMenu.tsx +++ b/packages/manager/src/features/Volumes/VolumesActionMenu.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import type { Volume } from '@linode/api-v4'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; @@ -30,11 +30,22 @@ export const VolumesActionMenu = (props: Props) => { const attached = volume.linode_id !== null; - const isVolumeReadOnly = useIsResourceRestricted({ - grantLevel: 'read_only', - grantType: 'volume', - id: volume.id, - }); + const { data: accountPermissions } = usePermissions('account', [ + 'create_volume', + ]); + const { data: volumePermissions } = usePermissions( + 'volume', + [ + 'delete_volume', + 'view_volume', + 'resize_volume', + 'clone_volume', + 'attach_volume', + 'detach_volume', + 'update_volume', + ], + volume.id + ); const actions: Action[] = [ { @@ -42,10 +53,10 @@ export const VolumesActionMenu = (props: Props) => { title: 'Show Config', }, { - disabled: isVolumeReadOnly, + disabled: !volumePermissions?.update_volume, onClick: handlers.handleEdit, title: 'Edit', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.update_volume ? getRestrictedResourceText({ action: 'edit', isSingular: true, @@ -54,15 +65,15 @@ export const VolumesActionMenu = (props: Props) => { : undefined, }, { - disabled: isVolumeReadOnly, + disabled: !volumePermissions?.update_volume, onClick: handlers.handleManageTags, title: 'Manage Tags', }, { - disabled: isVolumeReadOnly, + disabled: !volumePermissions?.resize_volume, onClick: handlers.handleResize, title: 'Resize', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.resize_volume ? getRestrictedResourceText({ action: 'resize', isSingular: true, @@ -71,10 +82,11 @@ export const VolumesActionMenu = (props: Props) => { : undefined, }, { - disabled: isVolumeReadOnly, + disabled: + !volumePermissions?.clone_volume || !accountPermissions?.create_volume, onClick: handlers.handleClone, title: 'Clone', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.clone_volume ? getRestrictedResourceText({ action: 'clone', isSingular: true, @@ -86,10 +98,10 @@ export const VolumesActionMenu = (props: Props) => { if (!attached && isVolumesLanding) { actions.push({ - disabled: isVolumeReadOnly, + disabled: !volumePermissions?.attach_volume, onClick: handlers.handleAttach, title: 'Attach', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.attach_volume ? getRestrictedResourceText({ action: 'attach', isSingular: true, @@ -99,10 +111,10 @@ export const VolumesActionMenu = (props: Props) => { }); } else { actions.push({ - disabled: isVolumeReadOnly, + disabled: !volumePermissions?.detach_volume, onClick: handlers.handleDetach, title: 'Detach', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.detach_volume ? getRestrictedResourceText({ action: 'detach', isSingular: true, @@ -113,10 +125,10 @@ export const VolumesActionMenu = (props: Props) => { } actions.push({ - disabled: isVolumeReadOnly || attached, + disabled: !volumePermissions?.delete_volume || attached, onClick: handlers.handleDelete, title: 'Delete', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.delete_volume ? getRestrictedResourceText({ action: 'delete', isSingular: true, diff --git a/packages/manager/src/features/Volumes/VolumesLanding.tsx b/packages/manager/src/features/Volumes/VolumesLanding.tsx index ccd400a75c6..45805258fd8 100644 --- a/packages/manager/src/features/Volumes/VolumesLanding.tsx +++ b/packages/manager/src/features/Volumes/VolumesLanding.tsx @@ -18,9 +18,9 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableSortCell } from 'src/components/TableSortCell'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { VOLUME_TABLE_DEFAULT_ORDER, VOLUME_TABLE_DEFAULT_ORDER_BY, @@ -50,6 +50,8 @@ export const VolumesLanding = () => { from: '/volumes/', shouldThrow: false, }); + const { data: permissions } = usePermissions('account', ['create_volume']); + const pagination = usePaginationV2({ currentRoute: '/volumes', preferenceKey: VOLUME_TABLE_PREFERENCE_KEY, @@ -58,9 +60,8 @@ export const VolumesLanding = () => { query: search?.query, }), }); - const isVolumeCreationRestricted = useRestrictedGlobalGrantCheck({ - globalGrantType: 'add_volumes', - }); + + const canCreateVolume = permissions?.create_volume; const { handleOrderChange, order, orderBy } = useOrderV2({ initialRoute: { @@ -169,7 +170,7 @@ export const VolumesLanding = () => { resourceType: 'Volumes', }), }} - disabledCreateButton={isVolumeCreationRestricted} + disabledCreateButton={!canCreateVolume} docsLink="https://techdocs.akamai.com/cloud-computing/docs/block-storage" entity="Volume" onButtonClick={() => navigate({ to: '/volumes/create' })} diff --git a/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx b/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx index 85d3ac47d11..13f35803b5b 100644 --- a/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx +++ b/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx @@ -4,8 +4,8 @@ import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { StyledBucketIcon } from 'src/features/ObjectStorage/BucketLanding/StylesBucketIcon'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { sendEvent } from 'src/utilities/analytics/utils'; import { @@ -17,10 +17,7 @@ import { export const VolumesLandingEmptyState = () => { const navigate = useNavigate(); - - const isRestricted = useRestrictedGlobalGrantCheck({ - globalGrantType: 'add_volumes', - }); + const { data: permissions } = usePermissions('account', ['create_volume']); return ( <> @@ -29,7 +26,7 @@ export const VolumesLandingEmptyState = () => { buttonProps={[ { children: 'Create Volume', - disabled: isRestricted, + disabled: !permissions?.create_volume, onClick: () => { sendEvent({ action: 'Click:button', From cfd40d826ec083140946eb85fbbd832753250819 Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Thu, 28 Aug 2025 09:17:03 -0400 Subject: [PATCH 22/73] tech-story: [M3-10364] - MSW CRUD: Add custom grants to profile preset to prevent type error when using restricted profile preset (#12756) * where are we going with this * something like this?? p1 * add back in service tool worker changes * combine profile and grants preset * Added changeset: MSW CRUD: Fix type error when using custom restricted profile preset and add grants preset support * rename things * more renaming * missed some renaming --- .../pr-12756-tech-stories-1756135837332.md | 5 + .../src/dev-tools/ServiceWorkerTool.tsx | 25 +- .../components/ExtraPresetOptions.tsx | 15 +- ...le.tsx => ExtraPresetProfileAndGrants.tsx} | 256 ++++++++++++++---- packages/manager/src/dev-tools/constants.ts | 2 + packages/manager/src/dev-tools/utils.ts | 22 ++ .../presets/extra/account/customProfile.ts | 33 --- .../extra/account/customProfileAndGrants.ts | 50 ++++ packages/manager/src/mocks/presets/index.ts | 4 +- packages/manager/src/mocks/types.ts | 6 +- 10 files changed, 320 insertions(+), 98 deletions(-) create mode 100644 packages/manager/.changeset/pr-12756-tech-stories-1756135837332.md rename packages/manager/src/dev-tools/components/{ExtraPresetProfile.tsx => ExtraPresetProfileAndGrants.tsx} (50%) delete mode 100644 packages/manager/src/mocks/presets/extra/account/customProfile.ts create mode 100644 packages/manager/src/mocks/presets/extra/account/customProfileAndGrants.ts diff --git a/packages/manager/.changeset/pr-12756-tech-stories-1756135837332.md b/packages/manager/.changeset/pr-12756-tech-stories-1756135837332.md new file mode 100644 index 00000000000..81e635f71b9 --- /dev/null +++ b/packages/manager/.changeset/pr-12756-tech-stories-1756135837332.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +MSW CRUD: Fix type error when using custom restricted profile preset and add grants preset support ([#12756](https://github.com/linode/manager/pull/12756)) diff --git a/packages/manager/src/dev-tools/ServiceWorkerTool.tsx b/packages/manager/src/dev-tools/ServiceWorkerTool.tsx index d617bf4f69d..c6195dbb5e7 100644 --- a/packages/manager/src/dev-tools/ServiceWorkerTool.tsx +++ b/packages/manager/src/dev-tools/ServiceWorkerTool.tsx @@ -13,6 +13,7 @@ import { getBaselinePreset, getCustomAccountData, getCustomEventsData, + getCustomGrantsData, getCustomMaintenanceData, getCustomNotificationsData, getCustomProfileData, @@ -26,6 +27,7 @@ import { saveBaselinePreset, saveCustomAccountData, saveCustomEventsData, + saveCustomGrantsData, saveCustomMaintenanceData, saveCustomNotificationsData, saveCustomProfileData, @@ -42,6 +44,7 @@ import type { Account, AccountMaintenance, Event, + Grants, Notification, PermissionType, Profile, @@ -92,6 +95,9 @@ export const ServiceWorkerTool = () => { const [customEventsData, setCustomEventsData] = React.useState< Event[] | null | undefined >(getCustomEventsData()); + const [customGrantsData, setCustomGrantsData] = React.useState< + Grants | null | undefined + >(getCustomGrantsData()); const [customMaintenanceData, setCustomMaintenanceData] = React.useState< AccountMaintenance[] | null | undefined >(getCustomMaintenanceData()); @@ -118,6 +124,7 @@ export const ServiceWorkerTool = () => { React.useEffect(() => { const currentAccountData = getCustomAccountData(); + const currentGrantsData = getCustomGrantsData(); const currentProfileData = getCustomProfileData(); const currentUserAccountPermissionsData = getCustomUserAccountPermissionsData(); @@ -128,6 +135,8 @@ export const ServiceWorkerTool = () => { const currentNotificationsData = getCustomNotificationsData(); const hasCustomAccountChanges = JSON.stringify(currentAccountData) !== JSON.stringify(customAccountData); + const hasCustomGrantsChanges = + JSON.stringify(currentGrantsData) !== JSON.stringify(customGrantsData); const hasCustomProfileChanges = JSON.stringify(currentProfileData) !== JSON.stringify(customProfileData); const hasCustomEventsChanges = @@ -148,6 +157,7 @@ export const ServiceWorkerTool = () => { if ( hasCustomAccountChanges || + hasCustomGrantsChanges || hasCustomProfileChanges || hasCustomEventsChanges || hasCustomMaintenanceChanges || @@ -164,6 +174,7 @@ export const ServiceWorkerTool = () => { customAccountData, customEventsData, customMaintenanceData, + customGrantsData, customNotificationsData, customProfileData, customUserAccountPermissionsData, @@ -183,8 +194,13 @@ export const ServiceWorkerTool = () => { saveCustomAccountData(customAccountData); } - if (extraPresets.includes('profile:custom') && customProfileData) { - saveCustomProfileData(customProfileData); + if (extraPresets.includes('profile-grants:custom')) { + if (customProfileData) { + saveCustomProfileData(customProfileData); + } + if (customGrantsData) { + saveCustomGrantsData(customGrantsData); + } } if (extraPresets.includes('events:custom') && customEventsData) { saveCustomEventsData(customEventsData); @@ -238,6 +254,7 @@ export const ServiceWorkerTool = () => { setSeedsCountMap(getSeedsCountMap()); setPresetsCountMap(getExtraPresetsMap()); setCustomAccountData(getCustomAccountData()); + setCustomGrantsData(getCustomGrantsData()); setCustomProfileData(getCustomProfileData()); setCustomEventsData(getCustomEventsData()); setCustomMaintenanceData(getCustomMaintenanceData()); @@ -261,6 +278,7 @@ export const ServiceWorkerTool = () => { setExtraPresets([]); setPresetsCountMap({}); setCustomAccountData(null); + setCustomGrantsData(null); setCustomProfileData(null); setCustomEventsData(null); setCustomMaintenanceData(null); @@ -275,6 +293,7 @@ export const ServiceWorkerTool = () => { saveExtraPresetsMap({}); saveCustomAccountData(null); saveCustomProfileData(null); + saveCustomGrantsData(null); saveCustomEventsData(null); saveCustomMaintenanceData(null); saveCustomNotificationsData(null); @@ -492,6 +511,7 @@ export const ServiceWorkerTool = () => { { handlers={extraPresets} onCustomAccountChange={setCustomAccountData} onCustomEventsChange={setCustomEventsData} + onCustomGrantsChange={setCustomGrantsData} onCustomMaintenanceChange={setCustomMaintenanceData} onCustomNotificationsChange={setCustomNotificationsData} onCustomProfileChange={setCustomProfileData} diff --git a/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx b/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx index db8e6857af8..025ebed2d24 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx @@ -9,7 +9,7 @@ import { ExtraPresetMaintenance } from './ExtraPresetMaintenance'; import { ExtraPresetNotifications } from './ExtraPresetNotifications'; import { ExtraPresetOptionCheckbox } from './ExtraPresetOptionCheckbox'; import { ExtraPresetOptionSelect } from './ExtraPresetOptionSelect'; -import { ExtraPresetProfile } from './ExtraPresetProfile'; +import { ExtraPresetProfileAndGrants } from './ExtraPresetProfileAndGrants'; import { ExtraPresetUserAccountPermissions } from './ExtraPresetUserAccountPermissions'; import { ExtraPresetUserEntityPermissions } from './ExtraPresetUserEntityPermissions'; @@ -17,6 +17,7 @@ import type { Account, AccountMaintenance, Event, + Grants, Notification, PermissionType, Profile, @@ -25,6 +26,7 @@ import type { export interface ExtraPresetOptionsProps { customAccountData?: Account | null; customEventsData?: Event[] | null; + customGrantsData?: Grants | null; customMaintenanceData?: AccountMaintenance[] | null; customNotificationsData?: Notification[] | null; customProfileData?: null | Profile; @@ -33,6 +35,7 @@ export interface ExtraPresetOptionsProps { handlers: string[]; onCustomAccountChange?: (data: Account | null | undefined) => void; onCustomEventsChange?: (data: Event[] | null | undefined) => void; + onCustomGrantsChange?: (data: Grants | null | undefined) => void; onCustomMaintenanceChange?: ( data: AccountMaintenance[] | null | undefined ) => void; @@ -59,6 +62,7 @@ export const ExtraPresetOptions = ({ customAccountData, customProfileData, customEventsData, + customGrantsData, customMaintenanceData, customNotificationsData, customUserAccountPermissionsData, @@ -67,6 +71,7 @@ export const ExtraPresetOptions = ({ onCustomAccountChange, onCustomProfileChange, onCustomEventsChange, + onCustomGrantsChange, onCustomMaintenanceChange, onCustomNotificationsChange, onCustomUserAccountPermissionsChange, @@ -120,11 +125,13 @@ export const ExtraPresetOptions = ({ onTogglePreset={onTogglePreset} /> )} - {currentGroupType === 'profile' && ( - )} diff --git a/packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx b/packages/manager/src/dev-tools/components/ExtraPresetProfileAndGrants.tsx similarity index 50% rename from packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx rename to packages/manager/src/dev-tools/components/ExtraPresetProfileAndGrants.tsx index a2183e6b800..696b43cabff 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetProfile.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetProfileAndGrants.tsx @@ -1,44 +1,57 @@ import { Dialog } from '@linode/ui'; -import { profileFactory } from '@linode/utilities'; +import { grantsFactory, profileFactory } from '@linode/utilities'; import * as React from 'react'; import { extraMockPresets } from 'src/mocks/presets'; -import { setCustomProfileData } from 'src/mocks/presets/extra/account/customProfile'; +import { + setCustomGrantsData, + setCustomProfileData, +} from 'src/mocks/presets/extra/account/customProfileAndGrants'; -import { saveCustomProfileData } from '../utils'; +import { saveCustomGrantsData, saveCustomProfileData } from '../utils'; import { JsonTextArea } from './JsonTextArea'; -import type { Profile } from '@linode/api-v4'; +import type { Grants, Profile } from '@linode/api-v4'; -const profilePreset = extraMockPresets.find((p) => p.id === 'profile:custom'); +const profilePreset = extraMockPresets.find( + (p) => p.id === 'profile-grants:custom' +); interface ExtraPresetProfileProps { + customGrantsData: Grants | null | undefined; customProfileData: null | Profile | undefined; handlers: string[]; - onFormChange?: (data: null | Profile | undefined) => void; + onFormChangeGrants?: (data: Grants | null | undefined) => void; + onFormChangeProfile?: (data: null | Profile | undefined) => void; onTogglePreset: ( e: React.ChangeEvent, presetId: string ) => void; } -export const ExtraPresetProfile = ({ +export const ExtraPresetProfileAndGrants = ({ + customGrantsData, customProfileData, handlers, - onFormChange, + onFormChangeGrants, + onFormChangeProfile, onTogglePreset, }: ExtraPresetProfileProps) => { - const isEnabled = handlers.includes('profile:custom'); - const [formData, setFormData] = React.useState(() => ({ + const isEnabled = handlers.includes('profile-grants:custom'); + const [profileFormData, setProfileFormData] = React.useState(() => ({ ...profileFactory.build({ restricted: false, }), ...customProfileData, })); + const [grantsFormData, setGrantsFormData] = React.useState(() => ({ + ...grantsFactory.build(), + ...customGrantsData, + })); const [isEditingCustomProfile, setIsEditingCustomProfile] = React.useState(false); - const handleInputChange = ( + const handleProfileInputChange = ( e: React.ChangeEvent< HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement > @@ -52,41 +65,77 @@ export const ExtraPresetProfile = ({ ].includes(name); const newValue = isRadioToggleField ? value === 'true' : value; - const newFormData = { - ...formData, + const newProfileFormData = { + ...profileFormData, [name]: newValue, }; - setFormData(newFormData); + setProfileFormData(newProfileFormData); if (isEnabled) { - onFormChange?.(newFormData); + onFormChangeProfile?.(newProfileFormData); + } + }; + + const handleGrantsInputChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + + try { + const newGrantsFormData = { + ...grantsFormData, + [name]: value, + }; + setGrantsFormData(newGrantsFormData); + + if (isEnabled) { + onFormChangeGrants?.(newGrantsFormData); + } + } catch (err) { + // eslint-disable-next-line no-console + console.error('Failed to parse JSON from input value:', value, err); } }; const handleTogglePreset = (e: React.ChangeEvent) => { if (!e.target.checked) { saveCustomProfileData(null); + saveCustomGrantsData(null); } else { - saveCustomProfileData(formData); + saveCustomProfileData(profileFormData); + saveCustomGrantsData(grantsFormData); } - onTogglePreset(e, 'profile:custom'); + onTogglePreset(e, 'profile-grants:custom'); }; React.useEffect(() => { if (!isEnabled) { - setFormData({ + setProfileFormData({ ...profileFactory.build(), }); setCustomProfileData(null); - } else if (isEnabled && customProfileData) { - setFormData((prev) => ({ - ...prev, - ...customProfileData, - })); - setCustomProfileData(customProfileData); + setGrantsFormData({ + ...grantsFactory.build(), + }); + setCustomGrantsData(null); + } else if (isEnabled) { + if (customProfileData) { + setProfileFormData((prev) => ({ + ...prev, + ...customProfileData, + })); + setCustomProfileData(customProfileData); + } + if (customGrantsData) { + setGrantsFormData((prev) => ({ + ...prev, + ...customGrantsData, + })); + setCustomGrantsData(customGrantsData); + } } - }, [isEnabled, customProfileData]); + }, [isEnabled, customProfileData, customGrantsData]); if (!profilePreset) { return null; @@ -120,20 +169,21 @@ export const ExtraPresetProfile = ({ setIsEditingCustomProfile(false)} open={isEditingCustomProfile} - title="Edit Custom Profile" + title="Edit Custom Profile and Grants" > setIsEditingCustomProfile(false)} > +

Profile

@@ -142,9 +192,9 @@ export const ExtraPresetProfile = ({ Email @@ -153,9 +203,9 @@ export const ExtraPresetProfile = ({ Verified Phone Number @@ -164,20 +214,20 @@ export const ExtraPresetProfile = ({ Email Notifications
@@ -207,9 +257,9 @@ export const ExtraPresetProfile = ({ Timezone @@ -218,19 +268,19 @@ export const ExtraPresetProfile = ({ Restricted
@@ -244,8 +294,8 @@ export const ExtraPresetProfile = ({ @@ -283,8 +333,8 @@ export const ExtraPresetProfile = ({ height={150} label="Referrals" name="referrals" - onChange={handleInputChange} - value={formData.referrals} + onChange={handleProfileInputChange} + value={profileFormData.referrals} /> @@ -292,8 +342,106 @@ export const ExtraPresetProfile = ({ height={80} label="Authorized Keys (one per line)" name="authorized_keys" - onChange={handleInputChange} - value={formData.authorized_keys} + onChange={handleProfileInputChange} + value={profileFormData.authorized_keys} + /> + +
+

Grants

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelAndTaintDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelAndTaintDrawer.tsx index f0f4495c81e..ba4b80211a3 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelAndTaintDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelAndTaintDrawer.tsx @@ -1,4 +1,3 @@ -import { useSpecificTypes } from '@linode/queries'; import { ActionsPanel, Button, @@ -13,8 +12,8 @@ import { FormProvider, useForm } from 'react-hook-form'; import { Link } from 'src/components/Link'; import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; -import { extendType } from 'src/utilities/extendType'; +import { useNodePoolDisplayLabel } from '../utils'; import { LabelInput } from './LabelInput'; import { LabelTable } from './LabelTable'; import { TaintInput } from './TaintInput'; @@ -37,11 +36,11 @@ interface LabelsAndTaintsFormFields { export const LabelAndTaintDrawer = (props: Props) => { const { clusterId, nodePool, onClose, open } = props; + const nodePoolLabel = useNodePoolDisplayLabel(nodePool, { suffix: 'Plan' }); + const [shouldShowLabelForm, setShouldShowLabelForm] = React.useState(false); const [shouldShowTaintForm, setShouldShowTaintForm] = React.useState(false); - const typesQuery = useSpecificTypes(nodePool?.type ? [nodePool.type] : []); - const { isPending, mutateAsync: updateNodePool } = useUpdateNodePoolMutation( clusterId, nodePool?.id ?? -1 @@ -109,15 +108,11 @@ export const LabelAndTaintDrawer = (props: Props) => { form.reset(); }; - const planType = typesQuery[0]?.data - ? extendType(typesQuery[0].data) - : undefined; - return ( {formState.errors.root?.message ? ( void; handleClickAutoscale: (poolId: number) => void; + handleClickConfigureNodePool: (poolId: number) => void; handleClickLabelsAndTaints: (poolId: number) => void; handleClickResize: (poolId: number) => void; isLkeClusterRestricted: boolean; isOnlyNodePool: boolean; + label: string; nodes: PoolNodeResponse[]; openDeletePoolDialog: (poolId: number) => void; openRecycleAllNodesDialog: (poolId: number) => void; @@ -39,7 +43,7 @@ interface Props { poolVersion: KubeNodePoolResponse['k8s_version']; statusFilter: StatusFilter; tags: string[]; - typeLabel: string; + type: string; } export const NodePool = (props: Props) => { @@ -53,6 +57,7 @@ export const NodePool = (props: Props) => { encryptionStatus, handleAccordionClick, handleClickAutoscale, + handleClickConfigureNodePool, handleClickLabelsAndTaints, handleClickResize, isLkeClusterRestricted, @@ -66,9 +71,13 @@ export const NodePool = (props: Props) => { poolVersion, statusFilter, tags, - typeLabel, + label, + type, } = props; + const { isLkeEnterprisePostLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); + const nodePoolLabel = useNodePoolDisplayLabel({ label, type }); + return ( { divider={} spacing={{ sm: 1.5, xs: 1 }} > - {typeLabel} + {nodePoolLabel} {pluralize('Node', 'Nodes', count)} @@ -102,6 +111,17 @@ export const NodePool = (props: Props) => { )} handleClickConfigureNodePool(poolId), + title: 'Configure Pool', + }, + ] + : []), { disabled: isLkeClusterRestricted, onClick: () => handleClickLabelsAndTaints(poolId), @@ -154,10 +174,10 @@ export const NodePool = (props: Props) => { clusterCreated={clusterCreated} clusterTier={clusterTier} isLkeClusterRestricted={isLkeClusterRestricted} + nodePoolType={type} nodes={nodes} openRecycleNodeDialog={openRecycleNodeDialog} statusFilter={statusFilter} - typeLabel={typeLabel} /> { clusterLabel, clusterRegionId, clusterTier, + clusterVersion, isLkeClusterRestricted, } = props; @@ -90,6 +91,8 @@ export const NodePoolsDisplay = (props: Props) => { const [selectedPoolId, setSelectedPoolId] = useState(-1); const selectedPool = pools?.find((pool) => pool.id === selectedPoolId); + const [isConfigureNodePoolDrawerOpen, setIsConfigureNodePoolDrawerOpen] = + useState(false); const [isDeleteNodePoolOpen, setIsDeleteNodePoolOpen] = useState(false); const [isLabelsAndTaintsDrawerOpen, setIsLabelsAndTaintsDrawerOpen] = useState(false); @@ -104,9 +107,6 @@ export const NodePoolsDisplay = (props: Props) => { const [numPoolsToDisplay, setNumPoolsToDisplay] = React.useState(5); const _pools = pools?.slice(0, numPoolsToDisplay); - const typesQuery = useSpecificTypes(_pools?.map((pool) => pool.type) ?? []); - const types = extendTypesQueryResult(typesQuery); - const [statusFilter, setStatusFilter] = React.useState('all'); const handleShowMore = () => { @@ -123,6 +123,11 @@ export const NodePoolsDisplay = (props: Props) => { setAddDrawerOpen(true); }; + const handleOpenConfigureNodePoolDrawer = (poolId: number) => { + setSelectedPoolId(poolId); + setIsConfigureNodePoolDrawerOpen(true); + }; + const handleOpenAutoscaleDrawer = (poolId: number) => { setSelectedPoolId(poolId); setIsAutoscaleDrawerOpen(true); @@ -267,14 +272,8 @@ export const NodePoolsDisplay = (props: Props) => { {poolsError && } {_pools?.map((thisPool) => { - const { count, disk_encryption, id, nodes, tags } = thisPool; - - const thisPoolType = types?.find( - (thisType) => thisType.id === thisPool.type - ); - - const typeLabel = thisPoolType?.formattedLabel ?? 'Unknown type'; - + const { count, disk_encryption, id, nodes, tags, label, type } = + thisPool; return ( { encryptionStatus={disk_encryption} handleAccordionClick={() => handleAccordionClick(id)} handleClickAutoscale={handleOpenAutoscaleDrawer} + handleClickConfigureNodePool={handleOpenConfigureNodePoolDrawer} handleClickLabelsAndTaints={handleOpenLabelsAndTaintsDrawer} handleClickResize={handleOpenResizeDrawer} isLkeClusterRestricted={isLkeClusterRestricted} isOnlyNodePool={pools?.length === 1} key={id} + label={label} nodes={nodes ?? []} openDeletePoolDialog={(id) => { setSelectedPoolId(id); @@ -304,7 +305,7 @@ export const NodePoolsDisplay = (props: Props) => { setSelectedPoolId(id); setIsRecycleAllPoolNodesOpen(true); }} - openRecycleNodeDialog={(nodeId, linodeLabel) => { + openRecycleNodeDialog={(nodeId) => { setSelectedNodeId(nodeId); setIsRecycleNodeOpen(true); }} @@ -313,7 +314,7 @@ export const NodePoolsDisplay = (props: Props) => { poolVersion={thisPool.k8s_version} statusFilter={statusFilter} tags={tags} - typeLabel={typeLabel} + type={type} /> ); })} @@ -331,6 +332,14 @@ export const NodePoolsDisplay = (props: Props) => { onClose={() => setAddDrawerOpen(false)} open={addDrawerOpen} /> + setIsConfigureNodePoolDrawerOpen(false)} + open={isConfigureNodePoolDrawerOpen} + /> void; - typeLabel: string; + type: string; } export const NodeRow = React.memo((props: NodeRowProps) => { @@ -43,10 +47,12 @@ export const NodeRow = React.memo((props: NodeRowProps) => { nodeId, nodeStatus, openRecycleNodeDialog, - typeLabel, + type, shouldShowVpcIPAddressColumns, } = props; + const { data: linodeType } = useTypeQuery(type); + const { data: ips, error: ipsError } = useLinodeIPsQuery( instanceId ?? -1, Boolean(instanceId) @@ -80,7 +86,7 @@ export const NodeRow = React.memo((props: NodeRowProps) => { ? 'active' : 'inactive'; - const labelText = label ?? typeLabel; + const labelText = label ?? linodeType?.label ?? type; const statusText = nodeStatus === 'not_ready' diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx index 844eb56ba00..f809a66aeb6 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx @@ -32,7 +32,7 @@ describe('NodeTable', () => { nodes, openRecycleNodeDialog: vi.fn(), statusFilter: 'all', - typeLabel: 'g6-standard-1', + nodePoolType: 'g6-standard-1', }; it('includes label, status, and IP columns', async () => { diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx index 9601333061c..346b287c3a6 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx @@ -27,21 +27,21 @@ export interface Props { clusterCreated: string; clusterTier: KubernetesTier; isLkeClusterRestricted: boolean; + nodePoolType: string; nodes: PoolNodeResponse[]; openRecycleNodeDialog: (nodeID: string, linodeLabel: string) => void; statusFilter: StatusFilter; - typeLabel: string; } export const NodeTable = React.memo((props: Props) => { const { clusterCreated, clusterTier, + nodePoolType, nodes, openRecycleNodeDialog, isLkeClusterRestricted, statusFilter, - typeLabel, } = props; const { data: profile } = useProfile(); @@ -215,7 +215,7 @@ export const NodeTable = React.memo((props: Props) => { shouldShowVpcIPAddressColumns={ shouldShowVpcIPAddressColumns } - typeLabel={typeLabel} + type={nodePoolType} /> ); })} diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx index 29e192b1803..30c58390be7 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx @@ -1,28 +1,24 @@ +import { linodeTypeFactory } from '@linode/utilities'; import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { nodePoolFactory, typeFactory } from 'src/factories'; +import { nodePoolFactory } from 'src/factories'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { ResizeNodePoolDrawer } from './ResizeNodePoolDrawer'; import type { Props } from './ResizeNodePoolDrawer'; +const type = linodeTypeFactory.build({ + id: 'fake-linode-type-id', + label: 'Linode 2GB', +}); const pool = nodePoolFactory.build({ - type: 'g6-standard-1', + type: type.id, }); const smallPool = nodePoolFactory.build({ count: 2 }); -vi.mock('@linode/queries', async () => { - const actual = await vi.importActual('@linode/queries'); - return { - ...actual, - useSpecificTypes: vi - .fn() - .mockReturnValue([{ data: typeFactory.build({ label: 'Linode 1 GB' }) }]), - }; -}); - const props: Props = { clusterTier: 'standard', kubernetesClusterId: 1, @@ -33,10 +29,31 @@ const props: Props = { }; describe('ResizeNodePoolDrawer', () => { - it("should render the pool's type and size", async () => { + // @TODO enable this test when we begin surfacing Node Pool `label` in the UI (ECE-353) + it.skip("should render a title containing the Node Pool's label when the node pool has a label", async () => { + const nodePool = nodePoolFactory.build({ label: 'my-mock-node-pool-1' }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Resize Pool: my-mock-node-pool-1')).toBeVisible(); + }); + + it("should render a title containing the Node Pool's type initially when the node pool does not have a label", () => { + const { getByText } = renderWithTheme(); + + expect(getByText('Resize Pool: fake-linode-type-id Plan')).toBeVisible(); + }); + + it("should render a title containing the Node Pool's type's label once the type data has loaded when the node pool does not have a label", async () => { + server.use( + http.get('*/v4*/linode/types/:id', () => HttpResponse.json(type)) + ); + const { findByText } = renderWithTheme(); - await findByText(/linode 1 GB/i); + expect(await findByText('Resize Pool: Linode 2 GB Plan')).toBeVisible(); }); it('should display a warning if the user tries to resize a node pool to < 3 nodes', async () => { diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx index df91b3347db..f575f526968 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx @@ -1,4 +1,4 @@ -import { useSpecificTypes } from '@linode/queries'; +import { useTypeQuery } from '@linode/queries'; import { ActionsPanel, CircleProgress, @@ -17,14 +17,13 @@ import { MAX_NODES_PER_POOL_STANDARD_TIER, } from 'src/features/Kubernetes/constants'; import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; -import { extendType } from 'src/utilities/extendType'; import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/dynamicPricing'; import { getKubernetesMonthlyPrice } from 'src/utilities/pricing/kubernetes'; import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { nodeWarning } from '../../constants'; -import { hasInvalidNodePoolPrice } from './utils'; +import { hasInvalidNodePoolPrice, useNodePoolDisplayLabel } from './utils'; import type { KubeNodePoolResponse, @@ -69,11 +68,12 @@ export const ResizeNodePoolDrawer = (props: Props) => { } = props; const { classes } = useStyles(); - const typesQuery = useSpecificTypes(nodePool?.type ? [nodePool.type] : []); - const isLoadingTypes = typesQuery[0]?.isLoading ?? false; - const planType = typesQuery[0]?.data - ? extendType(typesQuery[0].data) - : undefined; + const nodePoolLabel = useNodePoolDisplayLabel(nodePool, { suffix: 'Plan' }); + + const { data: planType, isLoading: isLoadingTypes } = useTypeQuery( + nodePool?.type ?? '', + Boolean(nodePool) + ); const { error, @@ -142,7 +142,7 @@ export const ResizeNodePoolDrawer = (props: Props) => { {isLoadingTypes ? ( diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts index ac3166ae4f3..6b2db97bbb5 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts @@ -1,4 +1,11 @@ -import { hasInvalidNodePoolPrice } from './utils'; +import { linodeTypeFactory } from '@linode/utilities'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { nodePoolFactory } from 'src/factories/kubernetesCluster'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { wrapWithTheme as wrapper } from 'src/utilities/testHelpers'; + +import { hasInvalidNodePoolPrice, useNodePoolDisplayLabel } from './utils'; describe('hasInvalidNodePoolPrice', () => { it('returns false if the prices are both zero, which is valid', () => { @@ -17,3 +24,69 @@ describe('hasInvalidNodePoolPrice', () => { expect(hasInvalidNodePoolPrice(null, null)).toBe(true); }); }); + +describe('useNodePoolDisplayLabel', () => { + // @TODO remove skip this when it's time to surface Node Pool labels in the UI (ECE-353) + it.skip("returns the node pools's label if it has one", () => { + const nodePool = nodePoolFactory.build({ label: 'my-node-pool-1' }); + + const { result } = renderHook(() => useNodePoolDisplayLabel(nodePool), { + wrapper, + }); + + expect(result.current).toBe('my-node-pool-1'); + }); + + it("returns the node pools's type ID initialy if it does not have an explicit label", () => { + const nodePool = nodePoolFactory.build({ + label: '', + type: 'g6-fake-type-1', + }); + + const { result } = renderHook(() => useNodePoolDisplayLabel(nodePool), { + wrapper, + }); + + expect(result.current).toBe('g6-fake-type-1'); + }); + + it('appends a suffix to the Linode type if one is provided', () => { + const nodePool = nodePoolFactory.build({ + label: '', + type: 'g6-fake-type-1', + }); + + const { result } = renderHook( + () => useNodePoolDisplayLabel(nodePool, { suffix: 'Plan' }), + { + wrapper, + } + ); + + expect(result.current).toBe('g6-fake-type-1 Plan'); + }); + + it("returns the node pools's type's `label` once it loads if it does not have an explicit label", async () => { + const type = linodeTypeFactory.build({ + id: 'g6-fake-type-1', + label: 'Fake Linode 2GB', + }); + + server.use( + http.get('*/v4*/linode/types/:id', () => HttpResponse.json(type)) + ); + + const nodePool = nodePoolFactory.build({ + label: '', + type: type.id, + }); + + const { result } = renderHook(() => useNodePoolDisplayLabel(nodePool), { + wrapper, + }); + + await waitFor(() => { + expect(result.current).toBe('Fake Linode 2 GB'); + }); + }); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts index 19ffd7cfe44..520f11934cc 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts @@ -1,5 +1,12 @@ +import { useTypeQuery } from '@linode/queries'; +import { formatStorageUnits } from '@linode/utilities'; + import type { NodeRow } from './NodeRow'; -import type { Linode, PoolNodeResponse } from '@linode/api-v4'; +import type { + KubeNodePoolResponse, + Linode, + PoolNodeResponse, +} from '@linode/api-v4'; /** * Checks whether prices are valid - 0 is valid, but undefined and null prices are invalid. @@ -37,3 +44,62 @@ export const nodeToRow = ( shouldShowVpcIPAddressColumns, }; }; + +interface NodePoolDisplayLabelOptions { + /** + * If set to `true`, the hook will only return the node pool's type's `id` or `label` + * and never its actual `label` + */ + ignoreNodePoolsLabel?: boolean; + /** + * Appends a suffix to the Node Pool's type `id` or `label` if it is returned + */ + suffix?: string; +} + +/** + * Given a Node Pool, this hook will return the Node Pool's display label. + * + * We use this helper rather than just using `label` on the Node Pool because the `label` + * field is optional was added later on to the API. For Node Pools without explicit labels, + * we identify them in the UI by their plan's label. + * + * @returns The Node Pool's label + */ +export const useNodePoolDisplayLabel = ( + nodePool: Pick | undefined, + options?: NodePoolDisplayLabelOptions +) => { + const { data: type } = useTypeQuery( + nodePool?.type ?? '', + Boolean(nodePool?.type) + ); + + if (!nodePool) { + return ''; + } + + // @TODO uncomment this when it's time to surface Node Pool labels in the UI (ECE-353) + // If the Node Pool has an explict label, return it. + // if (nodePool.label && !options?.ignoreNodePoolsLabel) { + // return nodePool.label; + // } + + // If the Node Pool's type is loaded, return that type's formatted label. + if (type) { + const typeLabel = formatStorageUnits(type.label); + + if (options?.suffix) { + return `${typeLabel} ${options.suffix}`; + } + + return typeLabel; + } + + // As a last resort, fallback to the Node Pool's type ID. + if (options?.suffix) { + return `${nodePool.type} ${options.suffix}`; + } + + return nodePool.type; +}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigDrawer.tsx index c0fef24aaa0..851a8200397 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigDrawer.tsx @@ -210,8 +210,7 @@ export const NodePoolConfigDrawer = (props: Props) => { )} - - {selectedTier === 'enterprise' && } + { +interface KubernetesVersionFieldOptions { + show: boolean; + versions: string[]; +} + +interface Props { + clusterTier: KubernetesTier; + firewallSelectOptions?: NodePoolFirewallSelectProps; + versionFieldOptions?: KubernetesVersionFieldOptions; +} + +export const NodePoolConfigOptions = (props: Props) => { + const { versionFieldOptions, clusterTier, firewallSelectOptions } = props; + const { isLkeEnterprisePostLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); const { control } = useFormContext(); + const versionOptions = + versionFieldOptions?.versions.map((version) => ({ + label: version, + })) ?? []; + + // @TODO uncomment and wire this up when we begin surfacing the Text Field for a Node Pool's `label` (ECE-353) + // const labelPlaceholder = useNodePoolDisplayLabel(nodePool, { + // ignoreNodePoolsLabel: true, + // }); + return ( - <> + + {/* + // @TODO allow users to edit Node Pool `label` and `tags` because the API supports it. (ECE-353) ( - ( + )} /> - - + ( + field.onChange(tags.map((tag) => tag.value))} + tagError={fieldState.error?.message} + value={ + field.value?.map((tag) => ({ label: tag, value: tag })) ?? [] + } + /> + )} + /> + */} + {/* LKE Enterprise cluster node pools have more configurability */} + {clusterTier === 'enterprise' && isLkeEnterprisePostLAFeatureEnabled && ( + <> + ( + + )} + /> + {versionFieldOptions?.show && ( + ( + field.onChange(version.label)} + options={versionOptions} + value={versionOptions.find( + (option) => option.label === field.value + )} + /> + )} + /> + )} + + + )} + ); }; diff --git a/packages/manager/src/features/Kubernetes/NodePoolFirewallSelect.tsx b/packages/manager/src/features/Kubernetes/NodePoolFirewallSelect.tsx index 4f39d6561d9..a7d73cffcbc 100644 --- a/packages/manager/src/features/Kubernetes/NodePoolFirewallSelect.tsx +++ b/packages/manager/src/features/Kubernetes/NodePoolFirewallSelect.tsx @@ -1,75 +1,142 @@ import { + FormControl, FormControlLabel, + FormHelperText, Radio, RadioGroup, Stack, - Typography, + TooltipIcon, } from '@linode/ui'; import React from 'react'; -import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { useController, useFormContext } from 'react-hook-form'; + +import { FormLabel } from 'src/components/FormLabel'; import { FirewallSelect } from '../Firewalls/components/FirewallSelect'; import type { CreateNodePoolData } from '@linode/api-v4'; -export const NodePoolFirewallSelect = () => { +export interface NodePoolFirewallSelectProps { + /** + * When standard LKE supports firewall, we will allow Firewalls to be add & removed + * Use this prop to allow/prevent Firwall from being removed on a Node Pool + */ + allowFirewallRemoval?: boolean; + /** + * An optional tooltip message that shows beside the "Use default firewall" radio label + */ + defaultFirewallRadioTooltip?: string; + /** + * Disables the "Use default firewall" option + */ + disableDefaultFirewallRadio?: boolean; +} + +export const NodePoolFirewallSelect = (props: NodePoolFirewallSelectProps) => { + const { + defaultFirewallRadioTooltip, + disableDefaultFirewallRadio, + allowFirewallRemoval, + } = props; const { control } = useFormContext(); - const watchedFirewallId = useWatch({ control, name: 'firewall_id' }); + const { field, fieldState, formState } = useController({ + control, + name: 'firewall_id', + rules: { + validate: (value) => { + if (isUsingOwnFirewall && value === null) { + if (disableDefaultFirewallRadio) { + return 'You must select a Firewall.'; + } + return 'You must either select a Firewall or select the default firewall.'; + } + return true; + }, + }, + }); const [isUsingOwnFirewall, setIsUsingOwnFirewall] = React.useState( - Boolean(watchedFirewallId) + Boolean(field.value) ); return ( - - ({ - font: theme.tokens.alias.Typography.Label.Bold.S, - })} - > - Firewall - - ) => { - setIsUsingOwnFirewall(e.target.value === 'yes'); - }} - value={isUsingOwnFirewall} - > - } - label="Use default firewall" - value="no" - /> - } - label="Select existing firewall" - value="yes" - /> - - {isUsingOwnFirewall && ( - ( - field.onChange(firewall?.id ?? null)} - placeholder="Select firewall" - value={field.value} - /> - )} - rules={{ - validate: (value) => { - if (isUsingOwnFirewall && !value) { - return 'You must either select a Firewall or select the default firewall.'; + + + + Firewall + + { + setIsUsingOwnFirewall(value === 'yes'); + + if (value === 'yes') { + // If the user chooses to use an existing firewall... + if (formState.defaultValues?.firewall_id) { + // If the Node Pool has a `firewall_id` set, restore that value (For the edit Node Pool flow) + field.onChange(formState.defaultValues?.firewall_id); + } else { + // Set `firewall_id` to `null` so that our validation forces the user to pick a firewall or pick the default backend-generated one + field.onChange(null); } - return true; - }, + } else { + field.onChange(formState.defaultValues?.firewall_id); + } + }} + value={isUsingOwnFirewall ? 'yes' : 'no'} + > + } + disabled={disableDefaultFirewallRadio} + label={ + <> + Use default firewall + {defaultFirewallRadioTooltip && ( + + )} + + } + value="no" + /> + } + label="Select existing firewall" + value="yes" + /> + + {!isUsingOwnFirewall && ( + + {fieldState.error?.message} + + )} + + {isUsingOwnFirewall && ( + { + if (firewall) { + field.onChange(firewall.id); + } else { + // `0` tells the backend to remove the firewall + field.onChange(0); + } }} + placeholder="Select firewall" + value={field.value} /> )} diff --git a/packages/manager/src/features/Kubernetes/NodePoolUpdateStrategySelect.tsx b/packages/manager/src/features/Kubernetes/NodePoolUpdateStrategySelect.tsx index fb181d04ecd..3d10e981f0e 100644 --- a/packages/manager/src/features/Kubernetes/NodePoolUpdateStrategySelect.tsx +++ b/packages/manager/src/features/Kubernetes/NodePoolUpdateStrategySelect.tsx @@ -4,19 +4,17 @@ import React from 'react'; import { UPDATE_STRATEGY_OPTIONS } from './constants'; interface Props { - label?: string; - noMarginTop?: boolean; onChange: (value: string | undefined) => void; value: string | undefined; } export const NodePoolUpdateStrategySelect = (props: Props) => { - const { onChange, value, noMarginTop, label } = props; + const { onChange, value } = props; return ( onChange(updateStrategy?.value)} options={UPDATE_STRATEGY_OPTIONS} placeholder="Select an Update Strategy" diff --git a/packages/manager/src/utilities/pricing/kubernetes.ts b/packages/manager/src/utilities/pricing/kubernetes.ts index 99c3cec1c4a..d1f7d7cab14 100644 --- a/packages/manager/src/utilities/pricing/kubernetes.ts +++ b/packages/manager/src/utilities/pricing/kubernetes.ts @@ -3,15 +3,15 @@ import { getLinodeRegionPrice } from './linodes'; import type { CreateNodePoolData, KubeNodePoolResponse, + LinodeType, Region, } from '@linode/api-v4/lib'; -import type { ExtendedType } from 'src/utilities/extendType'; interface MonthlyPriceOptions { count: number; region: Region['id'] | undefined; - type: ExtendedType | string; - types: ExtendedType[]; + type: LinodeType | string; + types: LinodeType[]; } interface TotalClusterPriceOptions { @@ -19,7 +19,7 @@ interface TotalClusterPriceOptions { highAvailabilityPrice?: number; pools: (CreateNodePoolData | KubeNodePoolResponse)[]; region: Region['id'] | undefined; - types: ExtendedType[]; + types: LinodeType[]; } /** @@ -35,7 +35,7 @@ export const getKubernetesMonthlyPrice = ({ if (!types || !type || !region) { return undefined; } - const thisType = types.find((t: ExtendedType) => t.id === type); + const thisType = types.find((t) => t.id === type); const monthlyPrice = getLinodeRegionPrice(thisType, region)?.monthly; diff --git a/packages/ui/src/foundations/themes/dark.ts b/packages/ui/src/foundations/themes/dark.ts index 5ab0647d149..fed43490946 100644 --- a/packages/ui/src/foundations/themes/dark.ts +++ b/packages/ui/src/foundations/themes/dark.ts @@ -708,7 +708,7 @@ export const darkTheme: ThemeOptions = { '&$disabled': { color: Component.Label.Text, }, - '&$error': { + '&.Mui-error': { color: Component.Label.Text, }, '&.Mui-focused': { diff --git a/packages/ui/src/foundations/themes/light.ts b/packages/ui/src/foundations/themes/light.ts index beb2b1a7fda..bfc9d61b32d 100644 --- a/packages/ui/src/foundations/themes/light.ts +++ b/packages/ui/src/foundations/themes/light.ts @@ -982,7 +982,7 @@ export const lightTheme: ThemeOptions = { color: Component.Label.Text, opacity: 0.5, }, - '&$error': { + '&.Mui-error': { color: Component.Label.Text, }, '&.Mui-focused': { From eab5e74d6fa92242fff09ef8c1fc5c67a3dc0fc7 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Mon, 1 Sep 2025 12:00:14 +0530 Subject: [PATCH 27/73] upcoming: [M3-10523] - Fix and extend ACLP-supported region Linode mock examples (#12747) * Correct ACLP-supported region Linode mocks * Add example 3 in the mocks * Add comments * Added changeset: Fix and extend ACLP-supported region Linode mock examples --- ...r-12747-upcoming-features-1755847964712.md | 5 ++ packages/manager/src/mocks/serverHandlers.ts | 60 ++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-12747-upcoming-features-1755847964712.md diff --git a/packages/manager/.changeset/pr-12747-upcoming-features-1755847964712.md b/packages/manager/.changeset/pr-12747-upcoming-features-1755847964712.md new file mode 100644 index 00000000000..71b21a163a3 --- /dev/null +++ b/packages/manager/.changeset/pr-12747-upcoming-features-1755847964712.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Fix and extend ACLP-supported region Linode mock examples ([#12747](https://github.com/linode/manager/pull/12747)) diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 6d7ce7a4f91..0ef1e70779b 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -813,6 +813,11 @@ export const handlers = [ region: 'us-east', id: 1005, }), + linodeFactory.build({ + label: 'aclp-supported-region-linode-3', + region: 'us-iad', + id: 1006, + }), ]; const linodes = [ ...mtcLinodes, @@ -939,19 +944,58 @@ export const handlers = [ }), ]; const linodeAclpSupportedRegionDetails = [ + /** Whether a Linode is ACLP-subscribed can be determined using the useIsLinodeAclpSubscribed hook. */ + + // 1. Example: ACLP-subscribed Linode in an ACLP-supported region (mock Linode ID: 1004) linodeFactory.build({ id, backups: { enabled: false }, label: 'aclp-supported-region-linode-1', region: 'us-iad', - alerts: { user: [100, 101], system: [200] }, + alerts: { + user: [21, 22, 23, 24, 25], + system: [19, 20], + cpu: 0, + io: 0, + network_in: 0, + network_out: 0, + transfer_quota: 0, + }, }), + // 2. Example: Linode not subscribed to ACLP in an ACLP-supported region (mock Linode ID: 1005) linodeFactory.build({ id, backups: { enabled: false }, label: 'aclp-supported-region-linode-2', region: 'us-east', - alerts: { user: [], system: [] }, + alerts: { + user: [], + system: [], + cpu: 10, + io: 10000, + network_in: 0, + network_out: 0, + transfer_quota: 80, + }, + }), + // 3. Example: Linode in an ACLP-supported region with NO enabled alerts (mock Linode ID: 1006) + // - Whether this Linode is ACLP-subscribed depends on the ACLP release stage: + // a. Beta stage: NOT subscribed to ACLP + // b. GA stage: Subscribed to ACLP + linodeFactory.build({ + id, + backups: { enabled: false }, + label: 'aclp-supported-region-linode-3', + region: 'us-iad', + alerts: { + user: [], + system: [], + cpu: 0, + io: 0, + network_in: 0, + network_out: 0, + transfer_quota: 0, + }, }), ]; const linodeNonMTCPlanInMTCSupportedRegionsDetail = linodeFactory.build({ @@ -986,6 +1030,8 @@ export const handlers = [ return linodeAclpSupportedRegionDetails[0]; case 1005: return linodeAclpSupportedRegionDetails[1]; + case 1006: + return linodeAclpSupportedRegionDetails[2]; default: return linodeDetail; } @@ -2733,11 +2779,19 @@ export const handlers = [ alertFactory.resetSequenceNumber(); return HttpResponse.json({ data: [ - ...alertFactory.buildList(20, { + ...alertFactory.buildList(18, { + rule_criteria: { + rules: alertRulesFactory.buildList(2), + }, + service_type: serviceType === 'dbaas' ? 'dbaas' : 'linode', + }), + // Mocked 2 alert definitions associated with mock Linode ID '1004' (aclp-supported-region-linode-1) + ...alertFactory.buildList(2, { rule_criteria: { rules: alertRulesFactory.buildList(2), }, service_type: serviceType === 'dbaas' ? 'dbaas' : 'linode', + entity_ids: ['1004'], }), ...alertFactory.buildList(6, { service_type: serviceType === 'dbaas' ? 'dbaas' : 'linode', From a67c9acec537d453e9393c37ae69d48ab3a0a11d Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Mon, 1 Sep 2025 13:08:20 +0200 Subject: [PATCH 28/73] feat: [UIE-9141] - IAM RBAC: add perm check for nodebalancer landing (#12780) * feat: [UIE-9141] - IAM RBAC: add perm check for nodebalancer * Added changeset: IAM RBAC: This PR implements IAM RBAC permissions for NodeBalancer * fix test and add changeset --- .../pr-12780-added-1756715574168.md | 5 ++ packages/api-v4/src/iam/types.ts | 78 +++++++++---------- .../pr-12780-added-1756375762995.md | 5 ++ .../adapters/accountGrantsToPermissions.ts | 2 + .../nodeBalancerGrantsToPermissions.ts | 31 ++++++++ .../IAM/hooks/adapters/permissionAdapters.ts | 15 ++++ .../NodeBalancerActionMenu.test.tsx | 43 ++++++++++ .../NodeBalancerActionMenu.tsx | 16 ++-- .../NodeBalancerTableRow.test.tsx | 14 ++++ .../NodeBalancersLanding.test.tsx | 46 ++++++++++- .../NodeBalancersLanding.tsx | 11 +-- .../NodeBalancersLandingEmptyState.test.tsx | 50 ++++++++---- .../NodeBalancersLandingEmptyState.tsx | 11 +-- 13 files changed, 252 insertions(+), 75 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12780-added-1756715574168.md create mode 100644 packages/manager/.changeset/pr-12780-added-1756375762995.md create mode 100644 packages/manager/src/features/IAM/hooks/adapters/nodeBalancerGrantsToPermissions.ts diff --git a/packages/api-v4/.changeset/pr-12780-added-1756715574168.md b/packages/api-v4/.changeset/pr-12780-added-1756715574168.md new file mode 100644 index 00000000000..c456e9734ab --- /dev/null +++ b/packages/api-v4/.changeset/pr-12780-added-1756715574168.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +NodeBalancers IAM RBAC permissions ([#12780](https://github.com/linode/manager/pull/12780)) diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index aecb6c9cf65..2af3868aa82 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -102,6 +102,7 @@ export type AccountAdmin = | AccountBillingAdmin | AccountFirewallAdmin | AccountLinodeAdmin + | AccountNodeBalancerAdmin | AccountOauthClientAdmin | AccountVolumeAdmin; @@ -142,6 +143,14 @@ export type AccountLinodeAdmin = AccountLinodeCreator | LinodeAdmin; /** Permissions associated with the "account_linode_creator" role. */ export type AccountLinodeCreator = 'create_linode'; +/** Permissions associated with the "account_nodebalancer_admin" role. */ +export type AccountNodeBalancerAdmin = + | AccountNodeBalancerCreator + | NodeBalancerAdmin; + +/** Permissions associated with the "account_nodebalancer_creator" role. */ +export type AccountNodeBalancerCreator = 'create_nodebalancer'; + /** Permissions associated with the "account_volume_admin" role. */ export type AccountVolumeAdmin = AccountVolumeCreator | VolumeAdmin; @@ -295,6 +304,35 @@ export type LinodeViewer = | 'view_linode_network_transfer' | 'view_linode_stats'; +/** Permissions associated with the "nodebalancer_admin" role. */ +// TODO: UIE-9154 - verify mapping for Nodebalancer as this is not migrated yet +export type NodeBalancerAdmin = + | 'delete_nodebalancer' + | 'delete_nodebalancer_config' + | 'delete_nodebalancer_config_node' + | NodeBalancerContributor; + +/** Permissions associated with the "nodebalancer_contributor" role. */ +export type NodeBalancerContributor = + | 'create_nodebalancer_config' + | 'create_nodebalancer_config_node' + | 'rebuild_nodebalancer_config' + | 'update_nodebalancer' + | 'update_nodebalancer_config' + | 'update_nodebalancer_config_node' + | 'update_nodebalancer_firewalls' + | NodeBalancerViewer; + +/** Permissions associated with the "nodebalancer_viewer" role. */ +export type NodeBalancerViewer = + | 'list_nodebalancer_config_nodes' + | 'list_nodebalancer_configs' + | 'list_nodebalancer_firewalls' + | 'view_nodebalancer' + | 'view_nodebalancer_config' + | 'view_nodebalancer_config_node' + | 'view_nodebalancer_statistics'; + /** Permissions associated with the "volume_admin" role. */ export type VolumeAdmin = 'delete_volume' | VolumeContributor; @@ -311,46 +349,6 @@ export type VolumeContributor = /** Permissions associated with the "volume_viewer" role. */ export type VolumeViewer = 'view_volume'; -/** Facade roles represent the existing Grant model for entities that are not yet migrated to IAM */ -export type AccountRoleFacade = - | 'account_database_creator' - | 'account_domain_creator' - | 'account_image_creator' - | 'account_ip_admin' - | 'account_ip_viewer' - | 'account_lkecluster_creator' - | 'account_longview_creator' - | 'account_longview_subscription_admin' - | 'account_nodebalancer_creator' - | 'account_placement_group_creator' - | 'account_stackscript_creator' - | 'account_vlan_admin' - | 'account_vlan_viewer' - | 'account_volume_creator' - | 'account_vpc_creator'; - -/** Facade roles represent the existing Grant model for entities that are not yet migrated to IAM */ -export type EntityRoleFacade = - | 'database_admin' - | 'database_viewer' - | 'domain_admin' - | 'domain_viewer' - | 'image_admin' - | 'image_viewer' - | 'lkecluster_admin' - | 'lkecluster_viewer' - | 'longview_admin' - | 'longview_viewer' - | 'nodebalancer_admin' - | 'nodebalancer_viewer' - | 'placement_group_admin' - | 'placement_group_viewer' - | 'stackscript_admin' - | 'stackscript_viewer' - | 'volume_admin' - | 'volume_viewer' - | 'vpc_admin'; - /** Union of all permissions */ export type PermissionType = AccountAdmin; diff --git a/packages/manager/.changeset/pr-12780-added-1756375762995.md b/packages/manager/.changeset/pr-12780-added-1756375762995.md new file mode 100644 index 00000000000..da9508d3d1a --- /dev/null +++ b/packages/manager/.changeset/pr-12780-added-1756375762995.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +IAM RBAC: Implements IAM RBAC permissions for NodeBalancer ([#12780](https://github.com/linode/manager/pull/12780)) diff --git a/packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts b/packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts index 183b4470d20..8e41485b15e 100644 --- a/packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts +++ b/packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts @@ -69,6 +69,8 @@ export const accountGrantsToPermissions = ( create_linode: unrestricted || globalGrants?.add_linodes, // AccountVolumeAdmin create_volume: unrestricted || globalGrants?.add_volumes, + // AccountNodeBalancerAdmin + create_nodebalancer: unrestricted || globalGrants?.add_nodebalancers, // AccountOAuthClientAdmin create_oauth_client: true, update_oauth_client: true, diff --git a/packages/manager/src/features/IAM/hooks/adapters/nodeBalancerGrantsToPermissions.ts b/packages/manager/src/features/IAM/hooks/adapters/nodeBalancerGrantsToPermissions.ts new file mode 100644 index 00000000000..65a33d807c2 --- /dev/null +++ b/packages/manager/src/features/IAM/hooks/adapters/nodeBalancerGrantsToPermissions.ts @@ -0,0 +1,31 @@ +import type { GrantLevel, NodeBalancerAdmin } from '@linode/api-v4'; + +/** Map the existing Grant model to the new IAM RBAC model. */ +export const nodeBalancerGrantsToPermissions = ( + grantLevel?: GrantLevel, + isRestricted?: boolean +): Record => { + const unrestricted = isRestricted === false; // explicit === false + return { + delete_nodebalancer: unrestricted || grantLevel === 'read_write', + delete_nodebalancer_config: unrestricted || grantLevel === 'read_write', + delete_nodebalancer_config_node: + unrestricted || grantLevel === 'read_write', + update_nodebalancer: unrestricted || grantLevel === 'read_write', + create_nodebalancer_config: unrestricted || grantLevel === 'read_write', + update_nodebalancer_config: unrestricted || grantLevel === 'read_write', + rebuild_nodebalancer_config: unrestricted || grantLevel === 'read_write', + create_nodebalancer_config_node: + unrestricted || grantLevel === 'read_write', + update_nodebalancer_config_node: + unrestricted || grantLevel === 'read_write', + update_nodebalancer_firewalls: unrestricted || grantLevel === 'read_write', + view_nodebalancer: unrestricted || grantLevel !== null, + list_nodebalancer_firewalls: unrestricted || grantLevel !== null, + view_nodebalancer_statistics: unrestricted || grantLevel !== null, + list_nodebalancer_configs: unrestricted || grantLevel !== null, + view_nodebalancer_config: unrestricted || grantLevel !== null, + list_nodebalancer_config_nodes: unrestricted || grantLevel !== null, + view_nodebalancer_config_node: unrestricted || grantLevel !== null, + }; +}; diff --git a/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts b/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts index e88331b74db..aee3d1918f1 100644 --- a/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts +++ b/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts @@ -1,6 +1,7 @@ import { accountGrantsToPermissions } from './accountGrantsToPermissions'; import { firewallGrantsToPermissions } from './firewallGrantsToPermissions'; import { linodeGrantsToPermissions } from './linodeGrantsToPermissions'; +import { nodeBalancerGrantsToPermissions } from './nodeBalancerGrantsToPermissions'; import { volumeGrantsToPermissions } from './volumeGrantsToPermissions'; import type { EntityBase } from '../usePermissions'; @@ -38,6 +39,10 @@ export const entityPermissionMapFrom = ( entity?.permissions, profile?.restricted ) as PermissionMap; + const nodebalancerPermissionsMap = nodeBalancerGrantsToPermissions( + entity?.permissions, + profile?.restricted + ) as PermissionMap; /** Add entity permissions to map */ switch (grantType) { @@ -47,6 +52,9 @@ export const entityPermissionMapFrom = ( case 'linode': entityPermissionsMap[entity.id] = linodePermissionsMap; break; + case 'nodebalancer': + entityPermissionsMap[entity.id] = nodebalancerPermissionsMap; + break; case 'volume': entityPermissionsMap[entity.id] = volumePermissionsMap; break; @@ -68,6 +76,7 @@ export const fromGrants = ( const firewall = grants?.firewall.find((f) => f.id === entityId); const linode = grants?.linode.find((f) => f.id === entityId); const volume = grants?.volume.find((f) => f.id === entityId); + const nodebalancer = grants?.nodebalancer.find((f) => f.id === entityId); let usersPermissionsMap = {} as PermissionMap; @@ -91,6 +100,12 @@ export const fromGrants = ( isRestricted ) as PermissionMap; break; + case 'nodebalancer': + usersPermissionsMap = nodeBalancerGrantsToPermissions( + nodebalancer?.permissions, + isRestricted + ) as PermissionMap; + break; case 'volume': usersPermissionsMap = volumeGrantsToPermissions( volume?.permissions, diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx index 0de7d906e43..8de0fbc6cf6 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx @@ -9,6 +9,15 @@ const navigate = vi.fn(); const queryMocks = vi.hoisted(() => ({ useNavigate: vi.fn(() => navigate), useRouter: vi.fn(() => vi.fn()), + userPermissions: vi.fn(() => ({ + data: { + delete_nodebalancer: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, })); vi.mock('@tanstack/react-router', async () => { @@ -41,7 +50,41 @@ describe('NodeBalancerActionMenu', () => { expect(getByText('Delete')).toBeVisible(); }); + it('should disable "Delete" if the user does not have permissions', async () => { + const { getAllByRole, getByText } = renderWithTheme( + + ); + const actionBtn = getAllByRole('button')[0]; + expect(actionBtn).toBeInTheDocument(); + await userEvent.click(actionBtn); + + const deleteBtn = getByText('Delete'); + expect(deleteBtn).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should enable "Delete" if the user has permissions', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + delete_nodebalancer: true, + }, + }); + const { getAllByRole, getByText } = renderWithTheme( + + ); + const actionBtn = getAllByRole('button')[0]; + expect(actionBtn).toBeInTheDocument(); + await userEvent.click(actionBtn); + + const deleteBtn = getByText('Delete'); + expect(deleteBtn).not.toHaveAttribute('aria-disabled', 'true'); + }); + it('triggers the action to delete the NodeBalancer', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + delete_nodebalancer: true, + }, + }); const { getByText } = renderWithTheme( ); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx index b473e8497ed..120b38918da 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx @@ -6,7 +6,7 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useIsNodebalancerVPCEnabled } from '../utils'; @@ -24,11 +24,11 @@ export const NodeBalancerActionMenu = (props: Props) => { const { nodeBalancerId } = props; - const isNodeBalancerReadOnly = useIsResourceRestricted({ - grantLevel: 'read_only', - grantType: 'nodebalancer', - id: nodeBalancerId, - }); + const { data: permissions } = usePermissions( + 'nodebalancer', + ['delete_nodebalancer'], + nodeBalancerId + ); const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled(); @@ -56,7 +56,7 @@ export const NodeBalancerActionMenu = (props: Props) => { title: 'Settings', }, { - disabled: isNodeBalancerReadOnly, + disabled: !permissions.delete_nodebalancer, onClick: () => { navigate({ params: { @@ -66,7 +66,7 @@ export const NodeBalancerActionMenu = (props: Props) => { }); }, title: 'Delete', - tooltip: isNodeBalancerReadOnly + tooltip: !permissions.delete_nodebalancer ? getRestrictedResourceText({ action: 'delete', resourceType: 'NodeBalancers', diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx index 35f1b35eba9..110368fcc8b 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx @@ -11,6 +11,15 @@ import { NodeBalancerTableRow } from './NodeBalancerTableRow'; const navigate = vi.fn(); const queryMocks = vi.hoisted(() => ({ useNavigate: vi.fn(() => navigate), + userPermissions: vi.fn(() => ({ + data: { + delete_nodebalancer: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, })); vi.mock('@tanstack/react-router', async () => { @@ -56,6 +65,11 @@ describe('NodeBalancerTableRow', () => { }); it('deletes the NodeBalancer', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + delete_nodebalancer: true, + }, + }); const { getByText } = renderWithTheme(); const deleteButton = getByText('Delete'); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx index 16187a749a8..72934f64d7a 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx @@ -1,5 +1,5 @@ import { nodeBalancerFactory } from '@linode/utilities'; -import { waitForElementToBeRemoved } from '@testing-library/react'; +import { waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import * as React from 'react'; import { makeResourcePage } from 'src/mocks/serverHandlers'; @@ -12,6 +12,15 @@ const queryMocks = vi.hoisted(() => ({ useMatch: vi.fn().mockReturnValue({}), useNavigate: vi.fn(() => vi.fn()), useParams: vi.fn().mockReturnValue({}), + userPermissions: vi.fn(() => ({ + data: { + create_nodebalancer: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, })); vi.mock('@tanstack/react-router', async () => { @@ -83,4 +92,39 @@ describe('NodeBalancersLanding', () => { expect(getByText('IP Address')).toBeVisible(); expect(getByText('Region')).toBeVisible(); }); + + it('should disable the "Create NodeBalancer" button if the user does not have permission', async () => { + const { getByRole } = renderWithTheme(); + + await waitFor(() => { + const createNodeBalancerBtn = getByRole('button', { + name: 'Create NodeBalancer', + }); + + expect(createNodeBalancerBtn).toBeInTheDocument(); + expect(createNodeBalancerBtn).toHaveAttribute('aria-disabled', 'true'); + }); + }); + + it('should enable the "Create NodeBalancer" button if the user has permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + create_nodebalancer: true, + }, + }); + + const { getByRole } = renderWithTheme(); + + await waitFor(() => { + const createNodeBalancerBtn = getByRole('button', { + name: 'Create NodeBalancer', + }); + + expect(createNodeBalancerBtn).toBeInTheDocument(); + expect(createNodeBalancerBtn).not.toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx index 63e62b7d023..5b88ac4b80c 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx @@ -15,9 +15,9 @@ import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell/TableSortCell'; import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { NodeBalancerDeleteDialog } from '../NodeBalancerDeleteDialog'; import { useIsNodebalancerVPCEnabled } from '../utils'; @@ -35,9 +35,10 @@ export const NodeBalancersLanding = () => { initialPage: 1, preferenceKey, }); - const isRestricted = useRestrictedGlobalGrantCheck({ - globalGrantType: 'add_nodebalancers', - }); + + const { data: permissions } = usePermissions('account', [ + 'create_nodebalancer', + ]); const { handleOrderChange, order, orderBy } = useOrderV2({ initialRoute: { @@ -101,7 +102,7 @@ export const NodeBalancersLanding = () => { resourceType: 'NodeBalancers', }), }} - disabledCreateButton={isRestricted} + disabledCreateButton={!permissions.create_nodebalancer} docsLink="https://techdocs.akamai.com/cloud-computing/docs/getting-started-with-nodebalancers" entity="NodeBalancer" onButtonClick={() => navigate({ to: '/nodebalancers/create' })} diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx index b04b3f64eaf..2da3b76b5cc 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx @@ -1,13 +1,21 @@ import { waitFor } from '@testing-library/react'; import * as React from 'react'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { NodeBalancerLandingEmptyState } from './NodeBalancersLandingEmptyState'; const queryMocks = vi.hoisted(() => ({ useNavigate: vi.fn(() => vi.fn()), + userPermissions: vi.fn(() => ({ + data: { + create_nodebalancer: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, })); vi.mock('@tanstack/react-router', async () => { @@ -18,29 +26,39 @@ vi.mock('@tanstack/react-router', async () => { }; }); -vi.mock('src/hooks/useRestrictedGlobalGrantCheck'); - // Note: An integration test confirming the helper text and enabled Create NodeBalancer button already exists, so we're just checking for a disabled create button here describe('NodeBalancersLandingEmptyState', () => { - afterEach(() => { - vi.resetAllMocks(); + it('should disable the "Create NodeBalancer" button if the user does not have permission', async () => { + const { getByRole } = renderWithTheme(); + + await waitFor(() => { + const createNodeBalancerBtn = getByRole('button', { + name: 'Create NodeBalancer', + }); + + expect(createNodeBalancerBtn).toBeInTheDocument(); + expect(createNodeBalancerBtn).toHaveAttribute('aria-disabled', 'true'); + }); }); - it('disables the Create NodeBalancer button if user does not have permissions to create a NodeBalancer', async () => { - // disables the create button - vi.mocked(useRestrictedGlobalGrantCheck).mockReturnValue(true); + it('should enable the "Create NodeBalancer" button if the user has permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + create_nodebalancer: true, + }, + }); - const { getByText } = renderWithTheme(); + const { getByRole } = renderWithTheme(); await waitFor(() => { - const createNodeBalancerButton = getByText('Create NodeBalancer').closest( - 'button' - ); + const createNodeBalancerBtn = getByRole('button', { + name: 'Create NodeBalancer', + }); - expect(createNodeBalancerButton).toBeDisabled(); - expect(createNodeBalancerButton).toHaveAttribute( - 'data-qa-tooltip', - "You don't have permissions to create NodeBalancers. Please contact your account administrator to request the necessary permissions." + expect(createNodeBalancerBtn).toBeInTheDocument(); + expect(createNodeBalancerBtn).not.toHaveAttribute( + 'aria-disabled', + 'true' ); }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx index 1f22b671b48..c826cbfa688 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx @@ -5,7 +5,7 @@ import NetworkIcon from 'src/assets/icons/entityIcons/networking.svg'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { sendEvent } from 'src/utilities/analytics/utils'; import { @@ -17,9 +17,10 @@ import { export const NodeBalancerLandingEmptyState = () => { const navigate = useNavigate(); - const isRestricted = useRestrictedGlobalGrantCheck({ - globalGrantType: 'add_nodebalancers', - }); + + const { data: permissions } = usePermissions('account', [ + 'create_nodebalancer', + ]); return ( @@ -28,7 +29,7 @@ export const NodeBalancerLandingEmptyState = () => { buttonProps={[ { children: 'Create NodeBalancer', - disabled: isRestricted, + disabled: !permissions.create_nodebalancer, onClick: () => { sendEvent({ action: 'Click:button', From c9a55b6971052c5f758085de55f1fc8bcf7368f4 Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:50:35 -0400 Subject: [PATCH 29/73] upcoming: [M3-10532] - Disable legacy interface selection in Linode Interface UI when creating Linode from backups (#12772) * disable legacy interfaces when creat ing from backup * tooltip for vpc legacy flow * minor updates * something like this? * changeset * whoops didn't save + add test * tooltip when disabled * update tooltip * no selection when disabled * add test case * this feels really weird ngl * address feedback * this seems more legit * remove stray console --- ...r-12772-upcoming-features-1756221031061.md | 5 +++ .../LinodeCreate/Networking/InterfaceType.tsx | 39 +++++++++++++++++-- .../Tabs/utils/useGetLinodeCreateType.ts | 4 ++ .../features/Linodes/LinodeCreate/VPC/VPC.tsx | 15 ++++++- .../features/Linodes/LinodeCreate/index.tsx | 9 ++++- .../Linodes/LinodeCreate/utilities.test.tsx | 32 +++++++++++++++ .../Linodes/LinodeCreate/utilities.ts | 8 ++-- 7 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 packages/manager/.changeset/pr-12772-upcoming-features-1756221031061.md diff --git a/packages/manager/.changeset/pr-12772-upcoming-features-1756221031061.md b/packages/manager/.changeset/pr-12772-upcoming-features-1756221031061.md new file mode 100644 index 00000000000..0567ca5288e --- /dev/null +++ b/packages/manager/.changeset/pr-12772-upcoming-features-1756221031061.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Disable legacy interface selection for Linode Interfaces when creating a Linode from backups ([#12772](https://github.com/linode/manager/pull/12772)) diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceType.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceType.tsx index 9ea728a96bb..62dc33ee27b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceType.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceType.tsx @@ -1,5 +1,6 @@ import { firewallQueries, useQueryClient } from '@linode/queries'; import { + Box, FormControl, Radio, RadioGroup, @@ -9,11 +10,12 @@ import { import { Grid } from '@mui/material'; import { useSnackbar } from 'notistack'; import React from 'react'; -import { useController, useFormContext } from 'react-hook-form'; +import { useController, useFormContext, useWatch } from 'react-hook-form'; import { FormLabel } from 'src/components/FormLabel'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; +import { useGetLinodeCreateType } from '../Tabs/utils/useGetLinodeCreateType'; import { getDefaultFirewallForInterfacePurpose } from './utilities'; import type { LinodeCreateFormValues } from '../utilities'; @@ -57,6 +59,16 @@ export const InterfaceType = ({ index }: Props) => { name: `linodeInterfaces.${index}.purpose`, }); + const interfaceGeneration = useWatch({ + control, + name: 'interface_generation', + }); + + const createType = useGetLinodeCreateType(); + const isCreatingFromBackup = createType === 'Backups'; + + const disabled = isCreatingFromBackup && interfaceGeneration !== 'linode'; + const onChange = async (value: InterfacePurpose) => { // Change the interface purpose (Public, VPC, VLAN) field.onChange(value); @@ -96,7 +108,20 @@ export const InterfaceType = ({ index }: Props) => { return ( - Network Connection + + Network Connection + {disabled && ( + + )} + The default interface used by this Linode to route network traffic. Additional interfaces can be added after the Linode is created. @@ -109,7 +134,8 @@ export const InterfaceType = ({ index }: Props) => { {interfaceTypes.map((interfaceType) => ( { key={interfaceType.purpose} onClick={() => onChange(interfaceType.purpose)} renderIcon={() => ( - + )} renderVariant={() => ( { const { pathname } = useLocation() as { pathname: LinkProps['to'] }; + return getLinodeCreateType(pathname); +}; + +export const getLinodeCreateType = (pathname: LinkProps['to']) => { switch (pathname) { case '/linodes/create/backups': return 'Backups'; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx index fb40be0ac3f..8ae41de6039 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx @@ -71,6 +71,8 @@ export const VPC = () => { : 'Assign this Linode to an existing VPC.'; const createType = useGetLinodeCreateType(); + const isCreatingFromBackup = createType === 'Backups'; + const disabled = !regionSupportsVPCs || isCreatingFromBackup; const vpcFormEventOptions: LinodeCreateFormEventOptions = { createType: createType ?? 'OS', @@ -82,7 +84,16 @@ export const VPC = () => { return ( - VPC + + VPC + {isCreatingFromBackup && ( + + )} + {copy}{' '} { name="interfaces.0.vpc_id" render={({ field, fieldState }) => ( { handleTabChange(index); if (index !== tabIndex) { + const newTab = tabs[index]; + const newLinodeCreateType = getLinodeCreateType(newTab.to); // Get the default values for the new tab and reset the form - defaultValues(linodeCreateType, search, queryClient, { + defaultValues(newLinodeCreateType, search, queryClient, { isLinodeInterfacesEnabled, isVMHostMaintenanceEnabled, }).then(form.reset); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx index 10b3f74db37..9184714117a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx @@ -70,6 +70,38 @@ describe('getLinodeCreatePayload', () => { placement_group: undefined, }); }); + + it('should remove interface from the payload if using legacy interfaces with the new UI and the linode is being created from backups', () => { + const values = { + ...createLinodeRequestFactory.build({ + interface_generation: 'legacy_config', + backup_id: 1, + }), + linodeInterfaces: [{ purpose: 'public', public: {} }], + } as LinodeCreateFormValues; + + expect( + getLinodeCreatePayload(values, { + isShowingNewNetworkingUI: true, + }).interfaces + ).toEqual(undefined); + }); + + it('should not remove interface from the payload when using new interfaces and creating from a backup', () => { + const values = { + ...createLinodeRequestFactory.build({ + interface_generation: 'linode', + backup_id: 1, + }), + linodeInterfaces: [{ purpose: 'public', public: {} }], + } as LinodeCreateFormValues; + + expect( + getLinodeCreatePayload(values, { + isShowingNewNetworkingUI: true, + }).interfaces + ).toEqual([{ public: {}, vpc: null, vlan: null }]); + }); }); describe('getInterfacesPayload', () => { diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts index 9eafb8457d2..204c157d91b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts @@ -98,9 +98,11 @@ export const getLinodeCreatePayload = ( ); values.firewall_id = undefined; } else { - values.interfaces = formValues.linodeInterfaces.map( - getLegacyInterfaceFromLinodeInterface - ); + values.interfaces = formValues.backup_id + ? undefined + : formValues.linodeInterfaces.map( + getLegacyInterfaceFromLinodeInterface + ); } } else { values.interfaces = getInterfacesPayload( From 19cbc5b84e709e8c25726c23c910db35d3085df3 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 2 Sep 2025 21:57:33 +0200 Subject: [PATCH 30/73] [UIE-9148] - IAM / RBAC - Support permission segmentation for LA (#12764) * Do the deed * hook logic * fix and improve test * fix MaintenancePolicy permissions * improve hook logic based on feedback * feedback @hana-akamai * revert API changes * changesets * tests + improved & cleaned up logic --- .../pr-12764-fixed-1756302991843.md | 5 ++ packages/api-v4/src/iam/types.ts | 2 +- .../pr-12764-changed-1756303053101.md | 5 ++ .../src/features/IAM/Shared/utilities.ts | 2 +- .../features/IAM/hooks/usePermissions.test.ts | 90 ++++++++++++++++++- .../src/features/IAM/hooks/usePermissions.ts | 59 +++++++++--- 6 files changed, 148 insertions(+), 15 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12764-fixed-1756302991843.md create mode 100644 packages/manager/.changeset/pr-12764-changed-1756303053101.md diff --git a/packages/api-v4/.changeset/pr-12764-fixed-1756302991843.md b/packages/api-v4/.changeset/pr-12764-fixed-1756302991843.md new file mode 100644 index 00000000000..b3b7f9d5ce8 --- /dev/null +++ b/packages/api-v4/.changeset/pr-12764-fixed-1756302991843.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Fixed +--- + +Wrong import path for EntityType ([#12764](https://github.com/linode/manager/pull/12764)) diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 2af3868aa82..fdf1a6bba56 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -1,4 +1,4 @@ -import type { EntityType } from 'src/entities/types'; +import type { EntityType } from '../entities/types'; export type AccountType = 'account'; diff --git a/packages/manager/.changeset/pr-12764-changed-1756303053101.md b/packages/manager/.changeset/pr-12764-changed-1756303053101.md new file mode 100644 index 00000000000..b34feab9106 --- /dev/null +++ b/packages/manager/.changeset/pr-12764-changed-1756303053101.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Support IAM/RBAC permission segmentation for BETA/LA features ([#12764](https://github.com/linode/manager/pull/12764)) diff --git a/packages/manager/src/features/IAM/Shared/utilities.ts b/packages/manager/src/features/IAM/Shared/utilities.ts index 61aed47fc32..d5092b1a1e5 100644 --- a/packages/manager/src/features/IAM/Shared/utilities.ts +++ b/packages/manager/src/features/IAM/Shared/utilities.ts @@ -513,7 +513,7 @@ export const mergeAssignedRolesIntoExistingRoles = ( selectedPlusExistingRoles.entity_access.push({ id: e.value, roles: [r.role?.value as EntityRoleType], - type: r.role?.entity_type, + type: r.role?.entity_type as AccessType, }); } }); diff --git a/packages/manager/src/features/IAM/hooks/usePermissions.test.ts b/packages/manager/src/features/IAM/hooks/usePermissions.test.ts index e0b19f6028b..1fed21f3720 100644 --- a/packages/manager/src/features/IAM/hooks/usePermissions.test.ts +++ b/packages/manager/src/features/IAM/hooks/usePermissions.test.ts @@ -23,13 +23,22 @@ vi.mock(import('@linode/queries'), async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - useIsIAMEnabled: queryMocks.useIsIAMEnabled, useUserAccountPermissions: queryMocks.useUserAccountPermissions, useUserEntityPermissions: queryMocks.useUserEntityPermissions, useGrants: queryMocks.useGrants, }; }); +vi.mock('src/features/IAM/hooks/useIsIAMEnabled', async () => { + const actual = await vi.importActual( + 'src/features/IAM/hooks/useIsIAMEnabled' + ); + return { + ...actual, + useIsIAMEnabled: queryMocks.useIsIAMEnabled, + }; +}); + vi.mock('./adapters', () => ({ fromGrants: vi.fn( ( @@ -127,4 +136,83 @@ describe('usePermissions', () => { false ); }); + + it('returns correct map when IAM beta is false', () => { + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: false, + }); + const flags = { iam: { beta: false, enabled: true } }; + + renderHook(() => usePermissions('account', ['create_linode']), { + wrapper: (ui) => wrapWithTheme(ui, { flags }), + }); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(false); + expect(queryMocks.useUserEntityPermissions).toHaveBeenCalledWith( + 'account', + undefined, + true + ); + }); + + it('returns correct map when beta is true and neither the access type nor the permissions are in the limited availability scope', () => { + const flags = { iam: { beta: true, enabled: true } }; + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: true, + }); + + renderHook(() => usePermissions('linode', ['update_linode'], 123), { + wrapper: (ui) => wrapWithTheme(ui, { flags }), + }); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(false); + expect(queryMocks.useUserAccountPermissions).toHaveBeenCalledWith(false); + expect(queryMocks.useUserEntityPermissions).toHaveBeenCalledWith( + 'linode', + 123, + true + ); + }); + + it('returns correct map when beta is true and the access type is in the limited availability scope', () => { + const flags = { iam: { beta: true, enabled: true } }; + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: true, + }); + + renderHook(() => usePermissions('volume', ['resize_volume'], 123), { + wrapper: (ui) => wrapWithTheme(ui, { flags }), + }); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(true); + expect(queryMocks.useUserAccountPermissions).toHaveBeenCalledWith(false); + expect(queryMocks.useUserEntityPermissions).toHaveBeenCalledWith( + 'volume', + 123, + false + ); + }); + + it('returns correct map when beta is true and one of the permissions is in the limited availability scope', () => { + const flags = { iam: { beta: true, enabled: true } }; + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: true, + }); + + renderHook(() => usePermissions('account', ['create_volume']), { + wrapper: (ui) => wrapWithTheme(ui, { flags }), + }); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(true); + expect(queryMocks.useUserAccountPermissions).toHaveBeenCalledWith(false); + expect(queryMocks.useUserEntityPermissions).toHaveBeenCalledWith( + 'account', + undefined, + false + ); + }); }); diff --git a/packages/manager/src/features/IAM/hooks/usePermissions.ts b/packages/manager/src/features/IAM/hooks/usePermissions.ts index 6cd6fdbe73a..155cbf37bd1 100644 --- a/packages/manager/src/features/IAM/hooks/usePermissions.ts +++ b/packages/manager/src/features/IAM/hooks/usePermissions.ts @@ -1,8 +1,4 @@ -import { - type AccessType, - getUserEntityPermissions, - type PermissionType, -} from '@linode/api-v4'; +import { getUserEntityPermissions } from '@linode/api-v4'; import { useGrants, useProfile, @@ -20,14 +16,26 @@ import { import { useIsIAMEnabled } from './useIsIAMEnabled'; import type { + AccessType, + AccountAdmin, AccountEntity, APIError, EntityType, GrantType, + PermissionType, Profile, } from '@linode/api-v4'; import type { UseQueryResult } from '@linode/queries'; +const BETA_ACCESS_TYPE_SCOPE: AccessType[] = ['account', 'linode', 'firewall']; +const LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE = [ + 'create_image', + 'upload_image', + 'create_vpc', + 'create_volume', + 'create_nodebalancer', +]; + export type PermissionsResult = { data: Record; } & Omit, 'data'>; @@ -38,23 +46,50 @@ export const usePermissions = ( entityId?: number, enabled: boolean = true ): PermissionsResult => { - const { isIAMEnabled } = useIsIAMEnabled(); + const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); + const { data: profile } = useProfile(); + + /** + * BETA and LA features should use the new permission model. + * However, beta features are limited to a subset of AccessTypes and account permissions. + * - Use Beta Permissions if: + * - The feature is beta + * - The access type is in the BETA_ACCESS_TYPE_SCOPE + * - The account permission is not in the LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE + * - Use LA Permissions if: + * - The feature is not beta + */ + const useBetaPermissions = + isIAMEnabled && + isIAMBeta && + BETA_ACCESS_TYPE_SCOPE.includes(accessType) && + LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE.some( + (blacklistedPermission) => + permissionsToCheck.includes(blacklistedPermission as AccountAdmin) // some of the account admin in the blacklist have not been added yet + ) === false; + const useLAPermissions = isIAMEnabled && !isIAMBeta; + const shouldUsePermissionMap = useBetaPermissions || useLAPermissions; + + const { data: grants } = useGrants( + (!isIAMEnabled || !shouldUsePermissionMap) && enabled + ); const { data: userAccountPermissions, ...restAccountPermissions } = useUserAccountPermissions( - isIAMEnabled && accessType === 'account' && enabled + shouldUsePermissionMap && accessType === 'account' && enabled ); const { data: userEntityPermissions, ...restEntityPermissions } = - useUserEntityPermissions(accessType, entityId!, isIAMEnabled && enabled); + useUserEntityPermissions( + accessType, + entityId!, + shouldUsePermissionMap && enabled + ); const usersPermissions = accessType === 'account' ? userAccountPermissions : userEntityPermissions; - const { data: profile } = useProfile(); - const { data: grants } = useGrants(!isIAMEnabled && enabled); - - const permissionMap = isIAMEnabled + const permissionMap = shouldUsePermissionMap ? toPermissionMap( permissionsToCheck, usersPermissions!, From 01d3ed8f593942c3893f7210979c2f005c011576 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Tue, 2 Sep 2025 17:30:08 -0400 Subject: [PATCH 31/73] test: [M3-10568] - Fix failing `linode-storage.spec.ts` delete test following API release (#12794) * Fix failing test following API release, fix test docs and improve test name * Added changeset: Fix disk deletion test following API release changes --- .../pr-12794-tests-1756839262706.md | 5 +++++ .../e2e/core/linodes/linode-storage.spec.ts | 19 +++++++++---------- 2 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 packages/manager/.changeset/pr-12794-tests-1756839262706.md diff --git a/packages/manager/.changeset/pr-12794-tests-1756839262706.md b/packages/manager/.changeset/pr-12794-tests-1756839262706.md new file mode 100644 index 00000000000..87c44817071 --- /dev/null +++ b/packages/manager/.changeset/pr-12794-tests-1756839262706.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix disk deletion test following API release changes ([#12794](https://github.com/linode/manager/pull/12794)) diff --git a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts index 7cf8942b4af..8814b12c700 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts @@ -160,13 +160,12 @@ describe('linode storage tab', () => { }); /* - * - Confirms UI flow end-to-end when a user deletes a Linode disk. - * - Confirms that user can successfully delete a disk from a Linode. - * - Confirms that Cloud Manager UI automatically updates to reflect deleted disk. - * TODO: Disk cannot be deleted if disk_encryption is 'enabled' - * TODO: edit result of this test if/when behavior of backend is updated. uncertain what expected behavior is for this disk config + * - Confirms UI flow end-to-end when a user attempts to delete a Linode disk with encryption enabled. + * - Confirms that disk deletion fails and toast notification appears. */ - it('delete disk fails', () => { + // TODO: Disk cannot be deleted if disk_encryption is 'enabled' + // TODO: edit result of this test if/when behavior of backend is updated. uncertain what expected behavior is for this disk config + it('delete disk fails when Linode uses disk encryption', () => { const diskName = randomLabel(); cy.defer(() => createTestLinode({ @@ -214,9 +213,11 @@ describe('linode storage tab', () => { }); /* - * - Same test as above, but uses different linode config for disk_encryption + * - Confirms UI flow end-to-end when a user deletes a Linode disk. + * - Confirms that disk is deleted successfully + * - Confirms that UI updates to reflect the deleted disk. */ - it('delete disk succeeds', () => { + it('deletes a disk', () => { const diskName = randomLabel(); cy.defer(() => createTestLinode({ @@ -244,9 +245,7 @@ describe('linode storage tab', () => { deleteDisk(diskName); cy.wait('@deleteDisk').its('response.statusCode').should('eq', 200); - cy.findByText('Deleting', { exact: false }).should('be.visible'); ui.button.findByTitle('Add a Disk').should('be.enabled'); - ui.toast.assertMessage( `Disk ${diskName} on Linode ${linode.label} has been deleted.` ); From 5efca8ed4108deb8ab432d15b4aa650dcc7dab39 Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Wed, 3 Sep 2025 11:01:23 +0200 Subject: [PATCH 32/73] new: [STORIF-80] Volume summary page created. (#12757) * new: [STORIF-80] Volume summary page created. * Added changeset: Volume details page * Update packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailBody.tsx Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> --------- Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> --- .../pr-12757-added-1756385787321.md | 5 + .../restricted-user-details-pages.spec.ts | 2 +- .../src/components/TagCell.stories.tsx | 2 + .../src/components/TagCell/TagCell.test.tsx | 10 +- .../src/components/TagCell/TagCell.tsx | 13 +- .../manager/src/dev-tools/FeatureFlagTool.tsx | 1 + packages/manager/src/featureFlags.ts | 1 + .../Domains/DomainDetail/DomainDetail.tsx | 1 + .../KubeEntityDetailFooter.tsx | 1 + .../NodePoolsDisplay/NodePoolFooter.tsx | 1 + .../Linodes/LinodeEntityDetailFooter.tsx | 1 + .../NodeBalancerSummary/SummaryPanel.tsx | 1 + .../Volumes/VolumeDetails/VolumeDetails.tsx | 64 ++++++++ .../VolumeEntityDetail.tsx | 23 +++ .../VolumeEntityDetailBody.tsx | 147 ++++++++++++++++++ .../VolumeEntityDetailFooter.tsx | 28 ++++ .../VolumeEntityDetailHeader.tsx | 29 ++++ .../VolumeDetails/volumeLandingLazyRoute.ts | 7 + .../src/features/Volumes/VolumeTableRow.tsx | 49 ++++-- packages/manager/src/routes/volumes/index.ts | 19 +++ 20 files changed, 382 insertions(+), 23 deletions(-) create mode 100644 packages/manager/.changeset/pr-12757-added-1756385787321.md create mode 100644 packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx create mode 100644 packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetail.tsx create mode 100644 packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailBody.tsx create mode 100644 packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailFooter.tsx create mode 100644 packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailHeader.tsx create mode 100644 packages/manager/src/features/Volumes/VolumeDetails/volumeLandingLazyRoute.ts diff --git a/packages/manager/.changeset/pr-12757-added-1756385787321.md b/packages/manager/.changeset/pr-12757-added-1756385787321.md new file mode 100644 index 00000000000..01e13b4372b --- /dev/null +++ b/packages/manager/.changeset/pr-12757-added-1756385787321.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Volume details page ([#12757](https://github.com/linode/manager/pull/12757)) diff --git a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts index 6e7a65ed47a..09712fbff0f 100644 --- a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts +++ b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts @@ -187,7 +187,7 @@ describe('restricted user details pages', () => { .and('be.disabled') .trigger('mouseover'); ui.tooltip.findByText( - 'You must be an unrestricted User in order to add or modify tags on Linodes.' + 'You must be an unrestricted User in order to add or modify tags on a Linode.' ); }); diff --git a/packages/manager/src/components/TagCell.stories.tsx b/packages/manager/src/components/TagCell.stories.tsx index 65e74b84845..6aef31c30d1 100644 --- a/packages/manager/src/components/TagCell.stories.tsx +++ b/packages/manager/src/components/TagCell.stories.tsx @@ -21,6 +21,7 @@ export const PanelView: StoryObj = { = { { it('should display the tooltip if disabled and tooltipText is true', async () => { const { getByTestId } = renderWithTheme( - + ); const disabledButton = getByTestId('button'); expect(disabledButton).toBeInTheDocument(); @@ -33,7 +39,7 @@ describe('TagCell Component', () => { expect( screen.getByText( - 'You must be an unrestricted User in order to add or modify tags on Linodes.' + 'You must be an unrestricted User in order to add or modify tags on a Linode.' ) ).toBeVisible(); }); diff --git a/packages/manager/src/components/TagCell/TagCell.tsx b/packages/manager/src/components/TagCell/TagCell.tsx index d912c7e92a9..4dd8d5a7084 100644 --- a/packages/manager/src/components/TagCell/TagCell.tsx +++ b/packages/manager/src/components/TagCell/TagCell.tsx @@ -24,6 +24,11 @@ export interface TagCellProps { */ disabled?: boolean; + /** + * Entity name to display on the tooltip when the "Add Button" is disabled. + */ + entity?: string; + /** * An optional label to display in the overflow drawer header. */ @@ -68,7 +73,7 @@ const checkOverflow = (el: HTMLElement) => { }; export const TagCell = (props: TagCellProps) => { - const { disabled, sx, tags, updateTags, view } = props; + const { disabled, sx, tags, updateTags, view, entity } = props; const [addingTag, setAddingTag] = React.useState(false); const [loading, setLoading] = React.useState(false); @@ -98,11 +103,7 @@ export const TagCell = (props: TagCellProps) => { onClick={() => setAddingTag(true)} panel={props.panel} title="Add a tag" - tooltipText={`${ - disabled - ? 'You must be an unrestricted User in order to add or modify tags on Linodes.' - : '' - }`} + tooltipText={`${disabled ? `You must be an unrestricted User in order to add or modify tags on a ${entity}.` : ''}`} > Add a tag diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 3331ec02c96..4a2d90d9755 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -61,6 +61,7 @@ const options: { flag: keyof Flags; label: string }[] = [ flag: 'vmHostMaintenance', label: 'VM Host Maintenance Policy', }, + { flag: 'volumeSummaryPage', label: 'Volume Summary Page' }, { flag: 'vpcIpv6', label: 'VPC IPv6' }, ]; diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 3b21b03401e..9bcab347af9 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -189,6 +189,7 @@ export interface Flags { tpaProviders: Provider[]; udp: boolean; vmHostMaintenance: VMHostMaintenanceFlag; + volumeSummaryPage: boolean; vpcIpv6: boolean; } diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx index 27d2eaf56a1..a950718f3ea 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx @@ -144,6 +144,7 @@ export const DomainDetail = () => { { > { { // but for the sake of the user experience, we choose to disable the "Add a tag" button in the UI because // restricted users can't see account tags using GET /v4/tags disabled={!permissions.is_account_admin} + entity="Linode" entityLabel={linodeLabel} sx={{ width: '100%', diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx index af29a8d91f9..00acc92ff86 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx @@ -266,6 +266,7 @@ export const SummaryPanel = () => { updateNodeBalancer({ tags })} view="panel" diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx new file mode 100644 index 00000000000..c90166078ae --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx @@ -0,0 +1,64 @@ +import { useVolumeQuery } from '@linode/queries'; +import { CircleProgress, ErrorState } from '@linode/ui'; +import { useNavigate, useParams } from '@tanstack/react-router'; +import * as React from 'react'; + +import { LandingHeader } from 'src/components/LandingHeader'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +import { TabPanels } from 'src/components/Tabs/TabPanels'; +import { Tabs } from 'src/components/Tabs/Tabs'; +import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; +import { useFlags } from 'src/hooks/useFlags'; +import { useTabs } from 'src/hooks/useTabs'; + +import { VolumeEntityDetail } from './VolumeEntityDetails/VolumeEntityDetail'; + +export const VolumeDetails = () => { + const navigate = useNavigate(); + const { volumeSummaryPage } = useFlags(); + const { volumeId } = useParams({ from: '/volumes/$volumeId' }); + const { data: volume, isLoading, error } = useVolumeQuery(volumeId); + const { tabs, handleTabChange, tabIndex, getTabIndex } = useTabs([ + { + to: '/volumes/$volumeId/summary', + title: 'Summary', + }, + ]); + + if (!volumeSummaryPage || error) { + return ; + } + + if (isLoading || !volume) { + return ; + } + + if (location.pathname === `/volumes/${volumeId}`) { + navigate({ to: `/volumes/${volumeId}/summary` }); + } + + return ( + <> + + + + + }> + + + + + + + + + ); +}; diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetail.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetail.tsx new file mode 100644 index 00000000000..ccbabeda79d --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetail.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; + +import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; + +import { VolumeEntityDetailBody } from './VolumeEntityDetailBody'; +import { VolumeEntityDetailFooter } from './VolumeEntityDetailFooter'; +import { VolumeEntityDetailHeader } from './VolumeEntityDetailHeader'; + +import type { Volume } from '@linode/api-v4'; + +interface Props { + volume: Volume; +} + +export const VolumeEntityDetail = ({ volume }: Props) => { + return ( + } + footer={} + header={} + /> + ); +}; diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailBody.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailBody.tsx new file mode 100644 index 00000000000..d4d9c9e24c2 --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailBody.tsx @@ -0,0 +1,147 @@ +import { useProfile, useRegionsQuery } from '@linode/queries'; +import { Box, Typography } from '@linode/ui'; +import { getFormattedStatus } from '@linode/utilities'; +import Grid from '@mui/material/Grid'; +import { useTheme } from '@mui/material/styles'; +import React from 'react'; + +import Lock from 'src/assets/icons/lock.svg'; +import Unlock from 'src/assets/icons/unlock.svg'; +import { Link } from 'src/components/Link'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { formatDate } from 'src/utilities/formatDate'; + +import { volumeStatusIconMap } from '../../utils'; + +import type { Volume } from '@linode/api-v4'; + +interface Props { + volume: Volume; +} + +export const VolumeEntityDetailBody = ({ volume }: Props) => { + const theme = useTheme(); + const { data: profile } = useProfile(); + const { data: regions } = useRegionsQuery(); + + const regionLabel = + regions?.find((region) => region.id === volume.region)?.label ?? + volume.region; + + return ( + + + + Status + + + ({ font: theme.font.bold })}> + {getFormattedStatus(volume.status)} + + + + + Size + ({ font: theme.font.bold })}> + {volume.size} GB + + + + Created + ({ font: theme.font.bold })}> + {formatDate(volume.created, { + timezone: profile?.timezone, + })} + + + + + + + Volume ID + ({ font: theme.font.bold })}> + {volume.id} + + + + Region + ({ font: theme.font.bold })}> + {regionLabel} + + + + Volume Label + ({ font: theme.font.bold })}> + {volume.label} + + + + + + + Attached To + ({ font: theme.font.bold })}> + {volume.linode_id !== null ? ( + + {volume.linode_label} + + ) : ( + 'Unattached' + )} + + + + + Encryption + + {volume.encryption === 'enabled' ? ( + <> + + ({ font: theme.font.bold })}> + Encrypted + + + ) : ( + <> + + ({ font: theme.font.bold })}> + Not Encrypted + + + )} + + + + + ); +}; diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailFooter.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailFooter.tsx new file mode 100644 index 00000000000..eb974fe1c4b --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailFooter.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { TagCell } from 'src/components/TagCell/TagCell'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; + +interface Props { + tags: string[]; +} + +export const VolumeEntityDetailFooter = ({ tags }: Props) => { + const isReadOnlyAccountAccess = useRestrictedGlobalGrantCheck({ + globalGrantType: 'account_access', + permittedGrantLevel: 'read_write', + }); + + return ( + Promise.resolve()} + view="inline" + /> + ); +}; diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailHeader.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailHeader.tsx new file mode 100644 index 00000000000..1810b7f7c38 --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailHeader.tsx @@ -0,0 +1,29 @@ +import { Box } from '@linode/ui'; +import { Typography } from '@linode/ui'; +import React from 'react'; + +import { EntityHeader } from 'src/components/EntityHeader/EntityHeader'; + +import type { Volume } from '@linode/api-v4'; + +interface Props { + volume: Volume; +} + +export const VolumeEntityDetailHeader = ({ volume }: Props) => { + return ( + + ({ + display: 'flex', + alignItems: 'center', + padding: `${theme.spacingFunction(6)} 0 ${theme.spacingFunction(6)} ${theme.spacingFunction(16)}`, + })} + > + ({ font: theme.font.bold })}> + Summary + + + + ); +}; diff --git a/packages/manager/src/features/Volumes/VolumeDetails/volumeLandingLazyRoute.ts b/packages/manager/src/features/Volumes/VolumeDetails/volumeLandingLazyRoute.ts new file mode 100644 index 00000000000..9b2a5bf5ed1 --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeDetails/volumeLandingLazyRoute.ts @@ -0,0 +1,7 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { VolumeDetails } from './VolumeDetails'; + +export const volumeDetailsLazyRoute = createLazyRoute('/volumes/$volumeId')({ + component: VolumeDetails, +}); diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.tsx index 069aac9d68c..6266476908b 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.tsx @@ -10,6 +10,7 @@ import { Link } from 'src/components/Link'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { useFlags } from 'src/hooks/useFlags'; import { useInProgressEvents } from 'src/queries/events/events'; import { HighPerformanceVolumeIcon } from '../Linodes/HighPerformanceVolumeIcon'; @@ -53,6 +54,7 @@ export const VolumeTableRow = React.memo((props: Props) => { const { data: regions } = useRegionsQuery(); const { data: notifications } = useNotificationsQuery(); const { data: inProgressEvents } = useInProgressEvents(); + const { volumeSummaryPage } = useFlags(); const isVolumesLanding = !isDetailsPageRow; @@ -124,20 +126,39 @@ export const VolumeTableRow = React.memo((props: Props) => { wrap: 'nowrap', }} > - ({ - alignItems: 'center', - display: 'flex', - gap: theme.spacing(), - })} - > - {volume.label} - {linodeCapabilities && ( - - )} - + {volumeSummaryPage ? ( + + ({ + alignItems: 'center', + display: 'flex', + gap: theme.spacingFunction(8), + })} + > + {volume.label} + {linodeCapabilities && ( + + )} + + + ) : ( + ({ + alignItems: 'center', + display: 'flex', + gap: theme.spacingFunction(8), + })} + > + {volume.label} + {linodeCapabilities && ( + + )} + + )} {isEligibleForUpgradeToNVMe && ( volumesRoute, + parseParams: (params) => ({ + volumeId: Number(params.volumeId), + }), + // validateSearch: (search: VolumesSearchParams) => search, + path: '$volumeId', +}).lazy(() => + import('src/features/Volumes/VolumeDetails/volumeLandingLazyRoute').then( + (m) => m.volumeDetailsLazyRoute + ) +); + +const volumeDetailsSummaryRoute = createRoute({ + getParentRoute: () => volumeDetailsRoute, + path: 'summary', +}); + const volumesIndexRoute = createRoute({ getParentRoute: () => volumesRoute, path: '/', @@ -96,4 +114,5 @@ export const volumesRouteTree = volumesRoute.addChildren([ volumesIndexRoute.addChildren([volumeActionRoute]), volumesCreateRoute, volumesCatchAllRoute, + volumeDetailsRoute.addChildren([volumeDetailsSummaryRoute]), ]); From ea2d8cbf5bbbfa1b8768d8c7f241f4098b3ee5f4 Mon Sep 17 00:00:00 2001 From: Ankita Date: Wed, 3 Sep 2025 15:01:42 +0530 Subject: [PATCH 33/73] [DI-26876] - Add missing props and enhance utils for firewalls contextual view (#12760) * [DI-26876] - Add missing props and enahnce utils for firewalls contextual view * [DI-26876] - revert * [DI-26876] - dont send undefined in metrics call in contextual view for optional filters * [DI-26876] - keep checks at the final processing fn * [DI-26876] - Pr comment * [DI-26876] - Add changeset * [DI-26876] - remove temporary changes --------- Co-authored-by: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> --- .../.changeset/pr-12760-upcoming-features-1756124216048.md | 5 +++++ .../Dashboard/CloudPulseDashboardWithFilters.tsx | 6 ++++++ .../manager/src/features/CloudPulse/Utils/FilterBuilder.ts | 1 + .../CloudPulse/Utils/ReusableDashboardFilterUtils.ts | 2 ++ .../CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx | 2 +- .../features/CloudPulse/shared/CloudPulseRegionSelect.tsx | 3 +++ packages/manager/src/mocks/serverHandlers.ts | 7 +++++-- 7 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-12760-upcoming-features-1756124216048.md diff --git a/packages/manager/.changeset/pr-12760-upcoming-features-1756124216048.md b/packages/manager/.changeset/pr-12760-upcoming-features-1756124216048.md new file mode 100644 index 00000000000..48fd51f12f8 --- /dev/null +++ b/packages/manager/.changeset/pr-12760-upcoming-features-1756124216048.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse - Metrics: Add missing props and enhance utils for firewalls contextual view ([#12760](https://github.com/linode/manager/pull/12760)) diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx index e7df0173e00..1cfdc379444 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx @@ -10,6 +10,7 @@ import { CloudPulseDashboardSelect } from '../shared/CloudPulseDashboardSelect'; import { CloudPulseDateTimeRangePicker } from '../shared/CloudPulseDateTimeRangePicker'; import { CloudPulseErrorPlaceholder } from '../shared/CloudPulseErrorPlaceholder'; import { convertToGmt } from '../Utils/CloudPulseDateTimePickerUtils'; +import { LINODE_REGION } from '../Utils/constants'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; import { checkIfFilterBuilderNeeded, @@ -189,6 +190,11 @@ export const CloudPulseDashboardWithFilters = React.memo( resource, timeDuration, })} + linodeRegion={ + filterData.id[LINODE_REGION] + ? (filterData.id[LINODE_REGION] as string) + : undefined + } /> ) : ( renderPlaceHolder('Select filters to visualize metrics.') diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index ff4448f5dd4..0998166afc6 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -518,6 +518,7 @@ export const constructAdditionalRequestFilters = ( for (const filter of additionalFilters) { if ( filter && + filter.filterValue && (!Array.isArray(filter.filterValue) || filter.filterValue.length > 0) // Check for empty array ) { // push to the filters diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts index 10c2c59258f..8c9d9181d9d 100644 --- a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts @@ -110,6 +110,7 @@ export const checkIfFilterNeededInMetricsCall = ( const { filterKey: configFilterKey, isFilterable, + isMetricsFilter, neededInViews, } = configuration; @@ -117,6 +118,7 @@ export const checkIfFilterNeededInMetricsCall = ( // Indicates if this filter should be included in the metrics call configFilterKey === filterKey && Boolean(isFilterable) && + !isMetricsFilter && neededInViews.includes(CloudPulseAvailableViews.service) ); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index 9ee7727b24b..9dc771e5c81 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -304,7 +304,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( isServiceAnalyticsIntegration, preferences, dependentFilters: resource_ids?.length - ? { [RESOURCE_ID]: resource_ids } + ? { [RESOURCE_ID]: resource_ids.map(String) } : dependentFilterReference.current, shouldDisable: isError || isLoading, }, diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index 61d9412b992..56af1d7420e 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -81,6 +81,9 @@ export const CloudPulseRegionSelect = React.memo( const [selectedRegion, setSelectedRegion] = React.useState(); React.useEffect(() => { + if (!savePreferences) { + return; // early exit if savePreferences is false + } if (disabled && !selectedRegion) { return; // no need to do anything } diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 0ef1e70779b..20f4b24f161 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1159,6 +1159,9 @@ export const handlers = [ label: 'Linode-123', }), }), + firewallEntityfactory.build({ + type: 'linode', + }), ], }), ]; @@ -3309,7 +3312,7 @@ export const handlers = [ dashboardLabel = 'NodeBalancer Service I/O Statistics'; } else if (id === '4') { serviceType = 'firewall'; - dashboardLabel = 'Linode Service I/O Statistics'; + dashboardLabel = 'Firewall Service I/O Statistics'; } else { serviceType = 'linode'; dashboardLabel = 'Linode Service I/O Statistics'; @@ -3317,7 +3320,7 @@ export const handlers = [ const response = { created: '2024-04-29T17:09:29', - id: params.id, + id: Number(params.id), label: dashboardLabel, service_type: serviceType, type: 'standard', From 2f77692e510bc94cbd225007363626da098cad13 Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:31:40 +0530 Subject: [PATCH 34/73] upcoming: [DI-26469] - TextField character limit validations in Alerts and Metrics (#12771) * fix: [DI-26469] - TextField 100 character limit validations in Alerts and Metrics * add changeset --------- Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Co-authored-by: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> --- ...r-12771-upcoming-features-1756215443845.md | 5 +++ .../DimensionFilterValue/ValueSchemas.ts | 36 ++++++++----------- .../DimensionFilterValue/constants.ts | 4 +++ .../features/CloudPulse/Utils/constants.ts | 5 +-- .../features/CloudPulse/Utils/utils.test.ts | 30 +++++++++++----- .../src/features/CloudPulse/Utils/utils.ts | 21 +++++------ 6 files changed, 56 insertions(+), 45 deletions(-) create mode 100644 packages/manager/.changeset/pr-12771-upcoming-features-1756215443845.md diff --git a/packages/manager/.changeset/pr-12771-upcoming-features-1756215443845.md b/packages/manager/.changeset/pr-12771-upcoming-features-1756215443845.md new file mode 100644 index 00000000000..06bfaa03cb8 --- /dev/null +++ b/packages/manager/.changeset/pr-12771-upcoming-features-1756215443845.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +ACLP-Metrics,Alerts: enforce validation for 100 characters for TextField components ([#12771](https://github.com/linode/manager/pull/12771)) diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts index 26e9c868b7a..00c4e54cc25 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts @@ -6,7 +6,6 @@ import { PORTS_HELPER_TEXT, PORTS_LEADING_COMMA_ERROR_MESSAGE, PORTS_LEADING_ZERO_ERROR_MESSAGE, - PORTS_LIMIT_ERROR_MESSAGE, PORTS_RANGE_ERROR_MESSAGE, } from 'src/features/CloudPulse/Utils/constants'; @@ -19,16 +18,16 @@ import { PORTS_TRAILING_COMMA_ERROR_MESSAGE, } from '../../../constants'; +const LENGTH_ERROR_MESSAGE = 'Value must be 100 characters or less.'; const fieldErrorMessage = 'This field is required.'; const DECIMAL_PORT_REGEX = /^[1-9]\d{0,4}$/; const LEADING_ZERO_PORT_REGEX = /^0\d+/; const CONFIG_NUMBER_REGEX = /^\d+$/; // Validation schema for a single input port -const singlePortSchema = string().test( - 'validate-single-port', - PORT_HELPER_TEXT, - function (value) { +const singlePortSchema = string() + .max(100, LENGTH_ERROR_MESSAGE) + .test('validate-single-port', PORT_HELPER_TEXT, function (value) { if (!value || typeof value !== 'string') { return this.createError({ message: fieldErrorMessage }); } @@ -38,7 +37,6 @@ const singlePortSchema = string().test( message: PORTS_LEADING_ZERO_ERROR_MESSAGE, }); } - if (!DECIMAL_PORT_REGEX.test(value)) { return this.createError({ message: PORTS_RANGE_ERROR_MESSAGE }); } @@ -48,14 +46,12 @@ const singlePortSchema = string().test( } return true; - } -); + }); // Validation schema for a multiple comma-separated ports -const commaSeparatedPortListSchema = string().test( - 'validate-port-list', - PORTS_HELPER_TEXT, - function (value) { +const commaSeparatedPortListSchema = string() + .max(100, LENGTH_ERROR_MESSAGE) + .test('validate-port-list', PORTS_HELPER_TEXT, function (value) { if (!value || typeof value !== 'string') { return this.createError({ message: fieldErrorMessage }); } @@ -87,11 +83,6 @@ const commaSeparatedPortListSchema = string().test( const ports = rawSegments.map((p) => p.trim()); - if (ports.length > 15) { - return this.createError({ - message: PORTS_LIMIT_ERROR_MESSAGE, - }); - } for (const port of ports) { const trimmedPort = port.trim(); @@ -111,10 +102,9 @@ const commaSeparatedPortListSchema = string().test( } return true; - } -); + }); const singleConfigSchema = string() - .max(100, 'Value must be 100 characters or less.') + .max(100, LENGTH_ERROR_MESSAGE) .test( 'validate-single-config-schema', CONFIG_ERROR_MESSAGE, @@ -131,7 +121,7 @@ const singleConfigSchema = string() ); const multipleConfigSchema = string() - .max(100, 'Value must be 100 characters or less.') + .max(100, LENGTH_ERROR_MESSAGE) .test( 'validate-multi-config-schema', CONFIGS_ERROR_MESSAGE, @@ -204,6 +194,8 @@ export const getDimensionFilterValueSchema = ({ operator === 'in' ? multipleConfigSchema : singleConfigSchema; return configSchema.concat(baseValueSchema); } - + if (['endswith', 'startswith'].includes(operator)) { + return baseValueSchema.concat(string().max(100, LENGTH_ERROR_MESSAGE)); + } return baseValueSchema; }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts index 2a9304ff064..c112a2bf212 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts @@ -148,12 +148,16 @@ export const valueFieldConfig: ValueFieldConfigMap = { eq_neq: { type: 'textfield', inputType: 'number', + min: 0, + max: Number.MAX_SAFE_INTEGER, placeholder: CONFIG_ID_PLACEHOLDER_TEXT, helperText: CONFIG_ERROR_MESSAGE, }, startswith_endswith: { type: 'textfield', inputType: 'number', + min: 0, + max: Number.MAX_SAFE_INTEGER, placeholder: CONFIG_ID_PLACEHOLDER_TEXT, helperText: CONFIG_ERROR_MESSAGE, }, diff --git a/packages/manager/src/features/CloudPulse/Utils/constants.ts b/packages/manager/src/features/CloudPulse/Utils/constants.ts index 032cb3bb59a..786a4795805 100644 --- a/packages/manager/src/features/CloudPulse/Utils/constants.ts +++ b/packages/manager/src/features/CloudPulse/Utils/constants.ts @@ -58,7 +58,8 @@ export const PORTS_CONSECUTIVE_COMMAS_ERROR_MESSAGE = export const PORTS_LEADING_COMMA_ERROR_MESSAGE = 'First character must be an integer.'; -export const PORTS_LIMIT_ERROR_MESSAGE = 'Enter a maximum of 15 port numbers'; +export const PORTS_LIMIT_ERROR_MESSAGE = + 'Port list must be 100 characters or less.'; export const PORTS_PLACEHOLDER_TEXT = 'e.g., 80,443,3000'; @@ -75,7 +76,7 @@ export const INTERFACE_IDS_LEADING_COMMA_ERROR_MESSAGE = 'First character must be an integer.'; export const INTERFACE_IDS_LIMIT_ERROR_MESSAGE = - 'Enter a maximum of 15 interface ID numbers'; + 'Interface IDs list must be 100 characters or less.'; export const INTERFACE_IDS_PLACEHOLDER_TEXT = 'e.g., 1234,5678'; diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts index 2ca235bbeba..3a01bc2b14d 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts @@ -64,10 +64,17 @@ describe('arePortsValid', () => { expect(arePortsValid('abc')).toBe(PORTS_ERROR_MESSAGE); }); - it('should return invalid for more than 15 ports', () => { - const ports = Array.from({ length: 16 }, (_, i) => i + 1).join(','); - const result = arePortsValid(ports); - expect(result).toBe(PORTS_LIMIT_ERROR_MESSAGE); + it('should return invalid for input length more than 100 characters', () => { + expect( + arePortsValid( + '12345,23456,34567,45678,56789,123,456,789,1111,2222,3333,4444,5555,6666,7777,8888,9999,12,34,56,1055' + ) + ).toBe(undefined); + expect( + arePortsValid( + '12345,23456,34567,45678,56789,123,456,789,1111,2222,3333,4444,5555,6666,7777,8888,9999,12,34,56,10455' + ) + ).toBe(PORTS_LIMIT_ERROR_MESSAGE); }); }); @@ -93,10 +100,17 @@ describe('areValidInterfaceIds', () => { expect(areValidInterfaceIds('abc')).toBe(INTERFACE_IDS_ERROR_MESSAGE); }); - it('should return invalid for more than 15 interface ids', () => { - const interfaceIds = Array.from({ length: 16 }, (_, i) => i + 1).join(','); - const result = areValidInterfaceIds(interfaceIds); - expect(result).toBe(INTERFACE_IDS_LIMIT_ERROR_MESSAGE); + it('should return invalid for input length more than 100 characters', () => { + expect( + areValidInterfaceIds( + '12345,23456,34567,45678,56789,123,456,789,1111,2222,3333,4444,5555,6666,7777,8888,9999,12,34,56,1455' + ) + ).toBe(undefined); + expect( + areValidInterfaceIds( + '12345,23456,34567,45678,56789,123,456,789,1111,2222,3333,4444,5555,6666,7777,8888,9999,12,34,56,14055' + ) + ).toBe(INTERFACE_IDS_LIMIT_ERROR_MESSAGE); }); }); diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index e19a3cbd0e8..8916d7fedce 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -305,6 +305,10 @@ export const arePortsValid = (ports: string): string | undefined => { return undefined; } + if (ports.length > 100) { + return PORTS_LIMIT_ERROR_MESSAGE; + } + if (ports.startsWith(',')) { return PORTS_LEADING_COMMA_ERROR_MESSAGE; } @@ -318,18 +322,12 @@ export const arePortsValid = (ports: string): string | undefined => { } const portList = ports.split(','); - let portLimitCount = 0; for (const port of portList) { const result = isValidPort(port); if (result !== undefined) { return result; } - portLimitCount++; - } - - if (portLimitCount > 15) { - return PORTS_LIMIT_ERROR_MESSAGE; } return undefined; @@ -347,6 +345,10 @@ export const areValidInterfaceIds = ( return undefined; } + if (interfaceIds.length > 100) { + return INTERFACE_IDS_LIMIT_ERROR_MESSAGE; + } + if (interfaceIds.startsWith(',')) { return INTERFACE_IDS_LEADING_COMMA_ERROR_MESSAGE; } @@ -358,13 +360,6 @@ export const areValidInterfaceIds = ( return INTERFACE_IDS_ERROR_MESSAGE; } - const interfaceIdList = interfaceIds.split(','); - const interfaceIdLimitCount = interfaceIdList.length; - - if (interfaceIdLimitCount > 15) { - return INTERFACE_IDS_LIMIT_ERROR_MESSAGE; - } - return undefined; }; From edf8cd7d1e1645019fd93d34f768a2686df77de7 Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Wed, 3 Sep 2025 16:26:44 +0530 Subject: [PATCH 35/73] upcoming: [DI-26718] - change aggregation function label (#12787) * upcoming: [DI-26718] - change aggregation function label * add changeset --- .../.changeset/pr-12787-changed-1756744243536.md | 5 +++++ .../e2e/core/cloudpulse/create-user-alert.spec.ts | 6 +++--- .../e2e/core/cloudpulse/edit-user-alert.spec.ts | 8 ++++---- packages/manager/cypress/support/constants/alert.ts | 6 +++--- .../Alerts/AlertsDetail/AlertDetailCriteria.test.tsx | 2 +- .../Alerts/CreateAlert/Criteria/Metric.test.tsx | 10 ++++------ .../src/features/CloudPulse/Alerts/constants.ts | 12 ++++++------ 7 files changed, 26 insertions(+), 23 deletions(-) create mode 100644 packages/manager/.changeset/pr-12787-changed-1756744243536.md diff --git a/packages/manager/.changeset/pr-12787-changed-1756744243536.md b/packages/manager/.changeset/pr-12787-changed-1756744243536.md new file mode 100644 index 00000000000..ab022b566c0 --- /dev/null +++ b/packages/manager/.changeset/pr-12787-changed-1756744243536.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Aggregation Function labels from Average,Minimum,Maximum to Avg,Min,Max in ACLP-Alerting service ([#12787](https://github.com/linode/manager/pull/12787)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts index 82b1f5fd4a4..f5cfa16fe10 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts @@ -120,7 +120,7 @@ const mockAlerts = alertFactory.build({ * Fills metric details in the form. * @param ruleIndex - The index of the rule to fill. * @param dataField - The metric's data field (e.g., "CPU Utilization"). - * @param aggregationType - The aggregation type (e.g., "Average"). + * @param aggregationType - The aggregation type (e.g., "Avg"). * @param operator - The operator (e.g., ">=", "=="). * @param threshold - The threshold value for the metric. */ @@ -242,7 +242,7 @@ describe('Create Alert', () => { // Fill metric details for the first rule const cpuUsageMetricDetails = { - aggregationType: 'Average', + aggregationType: 'Avg', dataField: 'CPU Utilization', operator: '=', ruleIndex: 0, @@ -288,7 +288,7 @@ describe('Create Alert', () => { // Fill metric details for the second rule const memoryUsageMetricDetails = { - aggregationType: 'Average', + aggregationType: 'Avg', dataField: 'Memory Usage', operator: '=', ruleIndex: 1, diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts index 3ab96ff6578..c50f8955077 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts @@ -205,7 +205,7 @@ describe('Integration Tests for Edit Alert', () => { // Assert rule values 1 assertRuleValues(0, { - aggregationType: 'Average', + aggregationType: 'Avg', dataField: 'CPU Utilization', operator: '=', threshold: '1000', @@ -213,7 +213,7 @@ describe('Integration Tests for Edit Alert', () => { // Assert rule values 2 assertRuleValues(1, { - aggregationType: 'Average', + aggregationType: 'Avg', dataField: 'Memory Usage', operator: '=', threshold: '1000', @@ -295,8 +295,8 @@ describe('Integration Tests for Edit Alert', () => { cy.get('[data-testid="rule_criteria.rules.0-id"]').within(() => { ui.autocomplete.findByLabel('Data Field').type('Disk I/O'); ui.autocompletePopper.findByTitle('Disk I/O').click(); - ui.autocomplete.findByLabel('Aggregation Type').type('Minimum'); - ui.autocompletePopper.findByTitle('Minimum').click(); + ui.autocomplete.findByLabel('Aggregation Type').type('Min'); + ui.autocompletePopper.findByTitle('Min').click(); ui.autocomplete.findByLabel('Operator').type('>'); ui.autocompletePopper.findByTitle('>').click(); cy.get('[data-qa-threshold]').should('be.visible').clear(); diff --git a/packages/manager/cypress/support/constants/alert.ts b/packages/manager/cypress/support/constants/alert.ts index 2628cb1a6ff..398ff892ccf 100644 --- a/packages/manager/cypress/support/constants/alert.ts +++ b/packages/manager/cypress/support/constants/alert.ts @@ -32,10 +32,10 @@ export const severityMap: Record = { }; export const aggregationTypeMap: Record = { - avg: 'Average', + avg: 'Avg', count: 'Count', - max: 'Maximum', - min: 'Minimum', + max: 'Max', + min: 'Min', sum: 'Sum', }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.test.tsx index 45b03435318..cac99f6afe8 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.test.tsx @@ -32,7 +32,7 @@ describe('AlertDetailCriteria component tests', () => { expect(getAllByText('Metric Threshold:').length).toBe(rules.length); expect(getAllByText('Dimension Filter:').length).toBe(rules.length); expect(getByText('Criteria')).toBeInTheDocument(); - expect(getAllByText('Average').length).toBe(2); + expect(getAllByText('Avg').length).toBe(2); expect(getAllByText('CPU Usage').length).toBe(2); expect(getAllByText('bytes').length).toBe(2); expect(getAllByText(metricOperatorTypeMap['gt']).length).toBe(2); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx index df1cb808223..54915174f56 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx @@ -147,19 +147,17 @@ describe('Metric component tests', () => { user.click(aggregationTypeInput); expect( - await container.findByRole('option', { name: 'Minimum' }) + await container.findByRole('option', { name: 'Min' }) ).toBeInTheDocument(); - expect( - container.getByRole('option', { name: 'Average' }) - ).toBeInTheDocument(); + expect(container.getByRole('option', { name: 'Avg' })).toBeInTheDocument(); - const option = await container.findByRole('option', { name: 'Average' }); + const option = await container.findByRole('option', { name: 'Avg' }); await user.click(option); expect( within(aggregationTypeContainer).getByRole('combobox') - ).toHaveAttribute('value', 'Average'); + ).toHaveAttribute('value', 'Avg'); }); it('should render the Operator component', async () => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index d2c8aa7b743..83b5ff3afbd 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -61,15 +61,15 @@ export const metricOperatorOptions: Item[] = [ export const metricAggregationOptions: Item[] = [ { - label: 'Average', + label: 'Avg', value: 'avg', }, { - label: 'Minimum', + label: 'Min', value: 'min', }, { - label: 'Maximum', + label: 'Max', value: 'max', }, { @@ -145,10 +145,10 @@ export const metricOperatorTypeMap: Record = { lte: '<=', }; export const aggregationTypeMap: Record = { - avg: 'Average', + avg: 'Avg', count: 'Count', - max: 'Maximum', - min: 'Minimum', + max: 'Max', + min: 'Min', sum: 'Sum', }; export const dimensionOperatorTypeMap: Record< From 88686268f10b85f2434deb2c7a3fdc07230f8fe5 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Wed, 3 Sep 2025 16:15:57 +0200 Subject: [PATCH 36/73] fix: [M3-10570] - Profile preferences across sessions (#12795) * update useMutatePreferences * Added changeset: Profile preferences across sessions * feedback * fix more section --- .../.changeset/pr-12795-fixed-1756842256177.md | 5 +++++ .../src/components/PrimaryNav/PrimaryNav.tsx | 18 +++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-12795-fixed-1756842256177.md diff --git a/packages/manager/.changeset/pr-12795-fixed-1756842256177.md b/packages/manager/.changeset/pr-12795-fixed-1756842256177.md new file mode 100644 index 00000000000..fa1f24de8b2 --- /dev/null +++ b/packages/manager/.changeset/pr-12795-fixed-1756842256177.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Profile preferences across sessions ([#12795](https://github.com/linode/manager/pull/12795)) diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 9fb20619667..716f55f73ca 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -113,11 +113,17 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); - const { data: collapsedSideNavPreference } = usePreferences( + const { + data: collapsedSideNavPreference, + error: preferencesError, + isLoading: preferencesLoading, + } = usePreferences( (preferences) => preferences?.collapsedSideNavProductFamilies ); - const collapsedAccordions = collapsedSideNavPreference ?? [1, 2, 3, 4, 5, 6]; // by default, we collapse all categories if no preference is set; + const collapsedAccordions = collapsedSideNavPreference ?? [ + 1, 2, 3, 4, 5, 6, 7, + ]; // by default, we collapse all categories if no preference is set; const { mutateAsync: updatePreferences } = useMutatePreferences(); @@ -332,7 +338,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { ); const accordionClicked = (index: number) => { - let updatedCollapsedAccordions: number[] = [0, 1, 2, 3, 4, 5]; + let updatedCollapsedAccordions: number[] = [1, 2, 3, 4, 5, 6, 7]; if (collapsedAccordions.includes(index)) { updatedCollapsedAccordions = collapsedAccordions.filter( (accIndex) => accIndex !== index @@ -398,6 +404,10 @@ export const PrimaryNav = (props: PrimaryNavProps) => { // When a user lands on a page and does not have any preference set, // we want to expand the accordion that contains the active link for convenience and discoverability React.useEffect(() => { + if (preferencesLoading || preferencesError) { + return; + } + if (collapsedSideNavPreference) { return; } @@ -421,6 +431,8 @@ export const PrimaryNav = (props: PrimaryNavProps) => { location.search, productFamilyLinkGroups, collapsedSideNavPreference, + preferencesLoading, + preferencesError, ]); let activeProductFamily = ''; From 0f818e24275a4eb3fec1ac7863f085685461014d Mon Sep 17 00:00:00 2001 From: John Callahan <114753608+jcallahan-akamai@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:56:39 -0400 Subject: [PATCH 37/73] refactor: [M3-10574] - Update jspdf (#12797) * Update jspdf * Added changeset: Updated jspdf to 3.0.2 --- .../pr-12797-changed-1756847396468.md | 5 ++ packages/manager/package.json | 4 +- pnpm-lock.yaml | 61 +++++++++++-------- 3 files changed, 42 insertions(+), 28 deletions(-) create mode 100644 packages/manager/.changeset/pr-12797-changed-1756847396468.md diff --git a/packages/manager/.changeset/pr-12797-changed-1756847396468.md b/packages/manager/.changeset/pr-12797-changed-1756847396468.md new file mode 100644 index 00000000000..30c915177b8 --- /dev/null +++ b/packages/manager/.changeset/pr-12797-changed-1756847396468.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Updated jspdf to 3.0.2 ([#12797](https://github.com/linode/manager/pull/12797)) diff --git a/packages/manager/package.json b/packages/manager/package.json index ae508f7b6ca..46d66e89c11 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -59,7 +59,7 @@ "he": "^1.2.0", "immer": "^9.0.6", "ipaddr.js": "^1.9.1", - "jspdf": "^3.0.1", + "jspdf": "^3.0.2", "jspdf-autotable": "^5.0.2", "launchdarkly-react-client-sdk": "3.0.10", "libphonenumber-js": "^1.10.6", @@ -189,4 +189,4 @@ "Firefox ESR", "not dead" ] -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8efe6c60cf..018a069cd0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,11 +252,11 @@ importers: specifier: ^1.9.1 version: 1.9.1 jspdf: - specifier: ^3.0.1 - version: 3.0.1 + specifier: ^3.0.2 + version: 3.0.2 jspdf-autotable: specifier: ^5.0.2 - version: 5.0.2(jspdf@3.0.1) + version: 5.0.2(jspdf@3.0.2) launchdarkly-react-client-sdk: specifier: 3.0.10 version: 3.0.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -443,7 +443,7 @@ importers: version: 3.7.2(vite@6.3.4(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vitest/coverage-v8': specifier: ^3.1.2 - version: 3.1.2(vitest@3.1.2) + version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@20.17.6)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vueless/storybook-dark-mode': specifier: ^9.0.5 version: 9.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -2266,6 +2266,9 @@ packages: '@types/novnc__novnc@1.5.0': resolution: {integrity: sha512-9DrDJK1hUT6Cbp4t03IsU/DsR6ndnIrDgZVrzITvspldHQ7n81F3wUDfq89zmPM3wg4GErH11IQa0QuTgLMf+w==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -2664,11 +2667,6 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} - atob@2.1.2: - resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==} - engines: {node: '>= 4.5.0'} - hasBin: true - attr-accept@2.2.5: resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} engines: {node: '>=4'} @@ -2754,11 +2752,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - btoa@1.2.1: - resolution: {integrity: sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==} - engines: {node: '>= 0.4.0'} - hasBin: true - buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -3593,6 +3586,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} + fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -3983,6 +3979,9 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4261,8 +4260,8 @@ packages: peerDependencies: jspdf: ^2 || ^3 - jspdf@3.0.1: - resolution: {integrity: sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==} + jspdf@3.0.2: + resolution: {integrity: sha512-G0fQDJ5fAm6UW78HG6lNXyq09l0PrA1rpNY5i+ly17Zb1fMMFSmS+3lw4cnrAPGyouv2Y0ylujbY2Ieq3DSlKA==} jsprim@2.0.2: resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} @@ -4742,6 +4741,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -7582,6 +7584,8 @@ snapshots: '@types/novnc__novnc@1.5.0': {} + '@types/pako@2.0.4': {} + '@types/parse-json@4.0.2': {} '@types/paypal-checkout-components@4.0.8': {} @@ -7826,7 +7830,7 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - '@vitest/coverage-v8@3.1.2(vitest@3.1.2)': + '@vitest/coverage-v8@3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@20.17.6)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -8104,8 +8108,6 @@ snapshots: at-least-node@1.0.0: {} - atob@2.1.2: {} - attr-accept@2.2.5: {} available-typed-arrays@1.0.7: @@ -8205,8 +8207,6 @@ snapshots: node-releases: 2.0.18 update-browserslist-db: 1.1.1(browserslist@4.24.2) - btoa@1.2.1: {} - buffer-crc32@0.2.13: {} buffer-from@1.1.2: {} @@ -9210,6 +9210,12 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-png@6.4.0: + dependencies: + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.1.0 + fastq@1.17.1: dependencies: reusify: 1.0.4 @@ -9618,6 +9624,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + iobuffer@5.4.0: {} + ipaddr.js@1.9.1: {} ipaddr.js@2.2.0: {} @@ -9880,15 +9888,14 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jspdf-autotable@5.0.2(jspdf@3.0.1): + jspdf-autotable@5.0.2(jspdf@3.0.2): dependencies: - jspdf: 3.0.1 + jspdf: 3.0.2 - jspdf@3.0.1: + jspdf@3.0.2: dependencies: '@babel/runtime': 7.27.1 - atob: 2.1.2 - btoa: 1.2.1 + fast-png: 6.4.0 fflate: 0.8.2 optionalDependencies: canvg: 3.0.11 @@ -10464,6 +10471,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@2.1.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 From a0b8ac5dd922da24bf890f155a2d335d0473d785 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 3 Sep 2025 12:01:33 -0400 Subject: [PATCH 38/73] upcoming: [M3-10379] - Allow Firewall to be configured in the Add Node Pool Drawer (#12793) * initial work to add more config options to add node pool drawer * save changes * remove unnessesary changes * Added changeset: Add Firewall option to the Add Node Pool Drawer for LKE Enterprise Kubernetes Clusters * Added changeset: General Node Pool schema `nodePoolSchema` * Added changeset: Node Pool schemas `CreateNodePoolSchema` and `EditNodePoolSchema` * Added changeset: Update `CreateNodePoolData` to satisfy @linode/validation's `CreateNodePoolSchema`'s type * add some basic unit testing * organize unit tests --------- Co-authored-by: Banks Nussman --- .../pr-12793-changed-1756835875675.md | 5 + packages/api-v4/src/kubernetes/nodePools.ts | 6 +- packages/api-v4/src/kubernetes/types.ts | 10 +- ...r-12793-upcoming-features-1756835231450.md | 5 + .../AddNodePoolDrawer.test.tsx | 152 ++++++++++++- .../NodePoolsDisplay/AddNodePoolDrawer.tsx | 215 ++++++++---------- .../KubernetesPlanSelectionTable.tsx | 5 +- .../NodePoolConfigOptions.tsx | 2 +- .../pr-12793-added-1756835813761.md | 5 + .../pr-12793-removed-1756835773560.md | 5 + packages/validation/src/kubernetes.schema.ts | 98 +++++--- 11 files changed, 329 insertions(+), 179 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12793-changed-1756835875675.md create mode 100644 packages/manager/.changeset/pr-12793-upcoming-features-1756835231450.md create mode 100644 packages/validation/.changeset/pr-12793-added-1756835813761.md create mode 100644 packages/validation/.changeset/pr-12793-removed-1756835773560.md diff --git a/packages/api-v4/.changeset/pr-12793-changed-1756835875675.md b/packages/api-v4/.changeset/pr-12793-changed-1756835875675.md new file mode 100644 index 00000000000..ac4f658e5b5 --- /dev/null +++ b/packages/api-v4/.changeset/pr-12793-changed-1756835875675.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Update `CreateNodePoolData` to satisfy @linode/validation's `CreateNodePoolSchema`'s type ([#12793](https://github.com/linode/manager/pull/12793)) diff --git a/packages/api-v4/src/kubernetes/nodePools.ts b/packages/api-v4/src/kubernetes/nodePools.ts index f9a9d0f547d..faf8895e28c 100644 --- a/packages/api-v4/src/kubernetes/nodePools.ts +++ b/packages/api-v4/src/kubernetes/nodePools.ts @@ -1,4 +1,4 @@ -import { nodePoolSchema } from '@linode/validation/lib/kubernetes.schema'; +import { CreateNodePoolSchema, EditNodePoolSchema } from '@linode/validation'; import { API_ROOT, BETA_API_ROOT } from '../constants'; import Request, { @@ -61,7 +61,7 @@ export const createNodePool = (clusterID: number, data: CreateNodePoolData) => setURL( `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/pools`, ), - setData(data, nodePoolSchema), + setData(data, CreateNodePoolSchema), ); /** @@ -81,7 +81,7 @@ export const updateNodePool = ( clusterID, )}/pools/${encodeURIComponent(nodePoolID)}`, ), - setData(data, nodePoolSchema), + setData(data, EditNodePoolSchema), ); /** diff --git a/packages/api-v4/src/kubernetes/types.ts b/packages/api-v4/src/kubernetes/types.ts index c6bfcb75d39..f0181314853 100644 --- a/packages/api-v4/src/kubernetes/types.ts +++ b/packages/api-v4/src/kubernetes/types.ts @@ -110,7 +110,7 @@ export interface CreateNodePoolData { * * @note Only supported on LKE Enterprise clusters */ - firewall_id?: number; + firewall_id?: null | number; /** * The LKE version that the node pool should use. * @@ -127,12 +127,12 @@ export interface CreateNodePoolData { /** * Key-value pairs added as labels to nodes in the node pool. */ - labels?: Label; - tags?: string[]; + labels?: Label | null; + tags?: null | string[]; /** * Kubernetes taints to add to node pool nodes. */ - taints?: Taint[]; + taints?: null | Taint[]; /** * The Linode Type for all of the nodes in the Node Pool. */ @@ -144,7 +144,7 @@ export interface CreateNodePoolData { * @note Only supported on LKE Enterprise clusters * @default on_recycle */ - update_strategy?: NodePoolUpdateStrategy; + update_strategy?: NodePoolUpdateStrategy | null; } export type UpdateNodePoolData = Partial; diff --git a/packages/manager/.changeset/pr-12793-upcoming-features-1756835231450.md b/packages/manager/.changeset/pr-12793-upcoming-features-1756835231450.md new file mode 100644 index 00000000000..ce64d1ebeeb --- /dev/null +++ b/packages/manager/.changeset/pr-12793-upcoming-features-1756835231450.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Firewall option to the Add Node Pool Drawer for LKE Enterprise Kubernetes Clusters ([#12793](https://github.com/linode/manager/pull/12793)) diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.test.tsx index bb7e2988ca8..c7df8c0d6f0 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.test.tsx @@ -1,5 +1,11 @@ +import { linodeTypeFactory } from '@linode/utilities'; +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; +import { accountFactory, firewallFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AddNodePoolDrawer } from './AddNodePoolDrawer'; @@ -16,23 +22,145 @@ const props: Props = { }; describe('AddNodePoolDrawer', () => { - it('should render plan heading', async () => { - const { findByText } = renderWithTheme(); + describe('Plans', () => { + it('should render plan heading', async () => { + const { findByText } = renderWithTheme(); - await findByText('Dedicated CPU'); - }); + await findByText('Dedicated CPU'); + }); + + it('should display the GPU tab for standard clusters', async () => { + const { findByText } = renderWithTheme(); + + expect(await findByText('GPU')).toBeInTheDocument(); + }); - it('should display the GPU tab for standard clusters', async () => { - const { findByText } = renderWithTheme(); + it('should not display the GPU tab for enterprise clusters', async () => { + const { queryByText } = renderWithTheme( + + ); - expect(await findByText('GPU')).toBeInTheDocument(); + expect(queryByText('GPU')).toBeNull(); + }); }); - it('should not display the GPU tab for enterprise clusters', async () => { - const { queryByText } = renderWithTheme( - - ); + describe('Firewall', () => { + // LKE-E Post LA must be enabled for the Firewall option to show up + const flags = { + lkeEnterprise: { + enabled: true, + ga: false, + la: true, + postLa: true, + phase2Mtc: false, + }, + }; + + it('should not display "Firewall" as an option by default', async () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Firewall')).toBeNull(); + }); + + it('should display "Firewall" as an option for enterprise clusters if the postLA flag is on and the account has the capability', async () => { + const account = accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }); + + server.use( + http.get('*/v4*/account', () => { + return HttpResponse.json(account); + }), + http.get('*/v4*/linode/types', () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { findByText, getByLabelText } = renderWithTheme( + , + { flags } + ); + + expect(await findByText('Firewall')).toBeVisible(); + + const defaultOption = getByLabelText('Use default firewall'); + const existingFirewallOption = getByLabelText('Select existing firewall'); + + expect(defaultOption).toBeInTheDocument(); + expect(existingFirewallOption).toBeInTheDocument(); + + expect(defaultOption).toBeEnabled(); + expect(existingFirewallOption).toBeEnabled(); + }); + + it('should allow the user to pick an existing firewall for enterprise clusters if the postLA flag is on and the account has the capability', async () => { + const account = accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }); + const firewall = firewallFactory.build({ id: 12 }); + const type = linodeTypeFactory.build({ + label: 'Linode 4GB', + class: 'dedicated', + }); + + const onCreatePool = vi.fn(); + + server.use( + http.get('*/v4*/account', () => { + return HttpResponse.json(account); + }), + http.get('*/v4*/linode/types', () => { + return HttpResponse.json(makeResourcePage([type])); + }), + http.get('*/v4*/networking/firewalls', () => { + return HttpResponse.json(makeResourcePage([firewall])); + }), + http.post( + '*/v4*/lke/clusters/:clusterId/pools', + async ({ request }) => { + const data = await request.json(); + onCreatePool(data); + return HttpResponse.json(data); + } + ) + ); + + const { findByText, findByLabelText, getByRole, getByPlaceholderText } = + renderWithTheme( + , + { flags } + ); + + expect(await findByText('Linode 4 GB')).toBeVisible(); + + await userEvent.click(getByRole('button', { name: 'Add 1' })); + await userEvent.click(getByRole('button', { name: 'Add 1' })); + await userEvent.click(getByRole('button', { name: 'Add 1' })); + + const existingFirewallOption = await findByLabelText( + 'Select existing firewall' + ); + + await userEvent.click(existingFirewallOption); + + const firewallSelect = getByPlaceholderText('Select firewall'); + + await userEvent.click(firewallSelect); + + await userEvent.click(await findByText(firewall.label)); + + await userEvent.click(getByRole('button', { name: 'Add pool' })); - expect(queryByText('GPU')).toBeNull(); + await waitFor(() => { + expect(onCreatePool).toHaveBeenCalledWith({ + firewall_id: 12, + count: 3, + type: type.id, + update_strategy: 'on_recycle', + }); + }); + }); }); }); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx index cd598c37634..c7cbc89b2ec 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx @@ -1,3 +1,8 @@ +import { + type CreateNodePoolData, + type KubernetesTier, + type Region, +} from '@linode/api-v4'; import { useAllTypes, useRegionsQuery } from '@linode/queries'; import { Box, Button, Drawer, Notice, Stack, Typography } from '@linode/ui'; import { @@ -7,10 +12,9 @@ import { scrollErrorIntoView, } from '@linode/utilities'; import React from 'react'; -import { Controller, useForm, useWatch } from 'react-hook-form'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { ErrorMessage } from 'src/components/ErrorMessage'; -// import { FirewallSelect } from 'src/features/Firewalls/components/FirewallSelect'; import { ADD_NODE_POOLS_DESCRIPTION, ADD_NODE_POOLS_ENTERPRISE_DESCRIPTION, @@ -25,16 +29,9 @@ import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { PremiumCPUPlanNotice } from '../../CreateCluster/PremiumCPUPlanNotice'; import { KubernetesPlansPanel } from '../../KubernetesPlansPanel/KubernetesPlansPanel'; -import { useIsLkeEnterpriseEnabled } from '../../kubeUtils'; -import { NodePoolUpdateStrategySelect } from '../../NodePoolUpdateStrategySelect'; +import { NodePoolConfigOptions } from '../../KubernetesPlansPanel/NodePoolConfigOptions'; import { hasInvalidNodePoolPrice } from './utils'; -import type { - CreateNodePoolData, - KubernetesTier, - Region, -} from '@linode/api-v4'; - export interface Props { clusterId: number; clusterLabel: string; @@ -54,14 +51,13 @@ export const AddNodePoolDrawer = (props: Props) => { open, } = props; - const { isLkeEnterprisePostLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); const { data: regions, isLoading: isRegionsLoading } = useRegionsQuery(); const { data: types, isLoading: isTypesLoading } = useAllTypes(open); const { - error, isPending, mutateAsync: createPool, + error, } = useCreateNodePoolMutation(clusterId); // Only want to use current types here and filter out nanodes @@ -93,7 +89,7 @@ export const AddNodePoolDrawer = (props: Props) => { type && count && isNumber(pricePerNode) ? count * pricePerNode : undefined; const hasInvalidPrice = hasInvalidNodePoolPrice(pricePerNode, totalPrice); - const shouldShowPricingInfo = type && count > 0; + const shouldShowPricingInfo = Boolean(type) && count > 0; React.useEffect(() => { if (open) { @@ -145,119 +141,100 @@ export const AddNodePoolDrawer = (props: Props) => { open={open} slotProps={{ paper: { - sx: { maxWidth: '790px !important' }, + sx: { maxWidth: '810px !important' }, }, }} title={`Add a Node Pool: ${clusterLabel}`} wide > - {form.formState.errors.root?.message && ( - - - - )} -
- { - if (plan === type) { - return count; - } - return 0; - }} - hasSelectedRegion={hasSelectedRegion} - isPlanPanelDisabled={isPlanPanelDisabled} - isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan} - isSubmitting={isPending} - notice={} - onSelect={(type) => form.setValue('type', type)} - regionsData={regions ?? []} - resetValues={() => form.reset()} - selectedId={type} - selectedRegionId={clusterRegionId} - selectedTier={clusterTier} - types={extendedTypes} - updatePlanCount={updatePlanCount} - /> - {count > 0 && count < 3 && ( - - )} - {hasInvalidPrice && shouldShowPricingInfo && ( - - )} - {isLkeEnterprisePostLAFeatureEnabled && - clusterTier === 'enterprise' && ( - - Configuration - ( - - )} + + + + {form.formState.errors.root?.message && ( + + + + )} + { + if (plan === type) { + return count; + } + return 0; + }} + hasSelectedRegion={hasSelectedRegion} + isPlanPanelDisabled={isPlanPanelDisabled} + isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan} + isSubmitting={isPending} + notice={ + + } + onSelect={(type) => form.setValue('type', type)} + regionsData={regions ?? []} + resetValues={() => { + form.setValue('type', ''); + form.setValue('count', 0); + }} + selectedId={type} + selectedRegionId={clusterRegionId} + selectedTier={clusterTier} + types={extendedTypes} + updatePlanCount={updatePlanCount} + /> + {count > 0 && count < 3 && ( + - {/* - ( - - field.onChange(firewall?.id ?? null) - } - value={field.value ?? null} - /> - )} + )} + {hasInvalidPrice && shouldShowPricingInfo && ( + - */} - - )} - - {shouldShowPricingInfo && ( - - This pool will add{' '} - - ${renderMonthlyPriceToCorrectDecimalPlace(totalPrice)}/month ( - {pluralize('node', 'nodes', count)} at $ - {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)} - /month) - {' '} - to this cluster. - - )} - - - + )} + + + {shouldShowPricingInfo && ( + + This pool will add{' '} + + ${renderMonthlyPriceToCorrectDecimalPlace(totalPrice)}/month + ({pluralize('node', 'nodes', count)} at $ + {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)} + /month) + {' '} + to this cluster. + + )} + + + + +
); }; diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx index 12b682e555d..10052d81f98 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx @@ -38,10 +38,7 @@ export const KubernetesPlanSelectionTable = ( } = props; return ( - +
{tableCells.map(({ cellName, center, noWrap, testId }) => { diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigOptions.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigOptions.tsx index 84a4b12eddb..0fd1fa3c68a 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigOptions.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigOptions.tsx @@ -79,7 +79,7 @@ export const NodePoolConfigOptions = (props: Props) => { render={({ field }) => ( )} /> diff --git a/packages/validation/.changeset/pr-12793-added-1756835813761.md b/packages/validation/.changeset/pr-12793-added-1756835813761.md new file mode 100644 index 00000000000..afa8074ad6d --- /dev/null +++ b/packages/validation/.changeset/pr-12793-added-1756835813761.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Added +--- + +Node Pool schemas `CreateNodePoolSchema` and `EditNodePoolSchema` ([#12793](https://github.com/linode/manager/pull/12793)) diff --git a/packages/validation/.changeset/pr-12793-removed-1756835773560.md b/packages/validation/.changeset/pr-12793-removed-1756835773560.md new file mode 100644 index 00000000000..495ede1479c --- /dev/null +++ b/packages/validation/.changeset/pr-12793-removed-1756835773560.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Removed +--- + +General Node Pool schema `nodePoolSchema` ([#12793](https://github.com/linode/manager/pull/12793)) diff --git a/packages/validation/src/kubernetes.schema.ts b/packages/validation/src/kubernetes.schema.ts index 2eabbcb528e..218c9a5f63c 100644 --- a/packages/validation/src/kubernetes.schema.ts +++ b/packages/validation/src/kubernetes.schema.ts @@ -2,10 +2,70 @@ import { array, boolean, number, object, string } from 'yup'; import { validateIP } from './firewalls.schema'; -export const nodePoolSchema = object({ +// Starts and ends with a letter or number and contains letters, numbers, hyphens, dots, and underscores +const alphaNumericValidCharactersRegex = + /^[a-zA-Z0-9]([a-zA-Z0-9-._]*[a-zA-Z0-9])?$/; + +export const kubernetesTaintSchema = object({ + key: string() + .required('Key is required.') + .test( + 'valid-key', + 'Key must start with a letter or number and may contain letters, numbers, hyphens, dots, and underscores, up to 253 characters.', + (value) => { + return ( + alphaNumericValidCharactersRegex.test(value) || + dnsKeyRegex.test(value) + ); + }, + ) + .max(253, 'Key must be between 1 and 253 characters.') + .min(1, 'Key must be between 1 and 253 characters.'), + value: string() + .matches( + alphaNumericValidCharactersRegex, + 'Value must start with a letter or number and may contain letters, numbers, hyphens, dots, and underscores, up to 63 characters.', + ) + .max(63, 'Value must be between 0 and 63 characters.') + .notOneOf( + ['kubernetes.io', 'linode.com'], + 'Value cannot be "kubernetes.io" or "linode.com".', + ) + .notRequired(), +}); + +const NodePoolDiskSchema = object({ + size: number().required(), + type: string() + .oneOf(['raw', 'ext4'] as const) + .required(), +}); + +const AutoscaleSettingsSchema = object({ + enabled: boolean().required(), + max: number().required(), + min: number().required(), +}); + +export const CreateNodePoolSchema = object({ + autoscaler: AutoscaleSettingsSchema.notRequired().default(undefined), + type: string().required('Type is required.'), + count: number().required(), + tags: array(string().defined()).notRequired(), + disks: array(NodePoolDiskSchema).notRequired(), + update_strategy: string() + .oneOf(['rolling_update', 'on_recycle'] as const) + .notRequired(), + k8_version: string().notRequired(), + firewall_id: number().notRequired(), + labels: object().notRequired(), + taints: array(kubernetesTaintSchema).notRequired(), +}); + +export const EditNodePoolSchema = object({ type: string(), count: number(), - upgrade_strategy: string(), + update_strategy: string(), k8_version: string(), firewall_id: number(), }); @@ -58,7 +118,7 @@ export const createKubeClusterSchema = object({ region: string().required('Region is required.'), k8s_version: string().required('Kubernetes version is required.'), node_pools: array() - .of(nodePoolSchema) + .of(CreateNodePoolSchema) .min(1, 'Please add at least one node pool.'), }); @@ -105,10 +165,6 @@ export const kubernetesEnterpriseControlPlaneACLPayloadSchema = object({ ), }); -// Starts and ends with a letter or number and contains letters, numbers, hyphens, dots, and underscores -const alphaNumericValidCharactersRegex = - /^[a-zA-Z0-9]([a-zA-Z0-9-._]*[a-zA-Z0-9])?$/; - // DNS subdomain key (example.com/my-app) const dnsKeyRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-._/]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/; @@ -170,31 +226,3 @@ export const kubernetesLabelSchema = object().test({ message: 'Labels must be valid key-value pairs.', test: validateKubernetesLabel, }); - -export const kubernetesTaintSchema = object({ - key: string() - .required('Key is required.') - .test( - 'valid-key', - 'Key must start with a letter or number and may contain letters, numbers, hyphens, dots, and underscores, up to 253 characters.', - (value) => { - return ( - alphaNumericValidCharactersRegex.test(value) || - dnsKeyRegex.test(value) - ); - }, - ) - .max(253, 'Key must be between 1 and 253 characters.') - .min(1, 'Key must be between 1 and 253 characters.'), - value: string() - .matches( - alphaNumericValidCharactersRegex, - 'Value must start with a letter or number and may contain letters, numbers, hyphens, dots, and underscores, up to 63 characters.', - ) - .max(63, 'Value must be between 0 and 63 characters.') - .notOneOf( - ['kubernetes.io', 'linode.com'], - 'Value cannot be "kubernetes.io" or "linode.com".', - ) - .notRequired(), -}); From bd5cd86e6c86e3a59b5011c91b510f285da5bd5e Mon Sep 17 00:00:00 2001 From: bill-akamai Date: Wed, 3 Sep 2025 11:16:23 -0500 Subject: [PATCH 39/73] change: [M3-10395] - Pendo Tag selector support: Create Linode > Linode Plan tabs (#12806) * Add tab title to data-pendo-id * Added changeset: Add data-pendo-id attribute to TabbedPanel for Linode Plan tab tracking --- .../manager/.changeset/pr-12806-changed-1756914783800.md | 5 +++++ packages/manager/src/components/TabbedPanel/TabbedPanel.tsx | 1 + 2 files changed, 6 insertions(+) create mode 100644 packages/manager/.changeset/pr-12806-changed-1756914783800.md diff --git a/packages/manager/.changeset/pr-12806-changed-1756914783800.md b/packages/manager/.changeset/pr-12806-changed-1756914783800.md new file mode 100644 index 00000000000..b4d8c0e9a3d --- /dev/null +++ b/packages/manager/.changeset/pr-12806-changed-1756914783800.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Add data-pendo-id attribute to TabbedPanel for Linode Plan tab tracking ([#12806](https://github.com/linode/manager/pull/12806)) diff --git a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx index 3060be39440..a882a95e0c4 100644 --- a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx +++ b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx @@ -100,6 +100,7 @@ const TabbedPanel = React.memo((props: TabbedPanelProps) => { {tabs.map((tab, idx) => ( From f1ea40aa7e5b3dbfdaf1bd58b4beeec2001e5cf9 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 3 Sep 2025 11:31:50 -0500 Subject: [PATCH 40/73] upcoming: [M3-10529] - UX feedback: Change /settings to /account-settings and profile/settings to profile/preferences (#12785) * Change settings route to account-settings * Change rounte 'profile/settings' to 'profile/preferences' * Update PrimaryNav.test.tsx * Update Cypress tests to account for account settings route change * Update packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * Remove redundant document titles * PR feedback - @coliu-akamai * Added changeset: UX feedback: Change /settings to /account-settings and profile/settings to profile/preferences --------- Co-authored-by: Joe D'Amore Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- ...r-12785-upcoming-features-1756910726492.md | 5 ++ .../smoke-linode-landing-table.spec.ts | 2 +- .../manager/cypress/support/ui/constants.ts | 2 +- .../MaskableText/MaskableTextArea.tsx | 14 +++- .../components/PrimaryNav/PrimaryNav.test.tsx | 7 +- .../src/components/PrimaryNav/PrimaryNav.tsx | 6 +- .../TypeToConfirm/TypeToConfirm.tsx | 15 +++- .../src/features/Account/GlobalSettings.tsx | 2 - .../AccountSettingsLanding.tsx} | 6 +- .../accountSettingsLandingLazyRoute.ts | 9 +++ .../src/features/Billing/BillingDetail.tsx | 2 - .../EntityTransfersLanding.tsx | 2 - .../LoginHistory/LoginHistoryLanding.tsx | 2 - .../Maintenance/MaintenanceLanding.tsx | 2 - .../manager/src/features/Profile/Profile.tsx | 12 ++- .../features/Profile/Settings/Settings.tsx | 16 +++- .../Profile/Settings/settingsLazyRoute.ts | 8 ++ .../src/features/Quotas/QuotasLanding.tsx | 2 - .../Settings/settingsLandingLazyRoute.ts | 7 -- .../TopMenu/UserMenu/UserMenuPopover.tsx | 78 ++++++++++--------- .../UsersAndGrants/UsersAndGrants.tsx | 2 - packages/manager/src/routes/account/index.ts | 2 +- .../AccountSettingsRoute.tsx} | 2 +- .../src/routes/accountSettings/index.ts | 42 ++++++++++ packages/manager/src/routes/index.tsx | 4 +- packages/manager/src/routes/profile/index.ts | 34 +++++++- packages/manager/src/routes/settings/index.ts | 42 ---------- 27 files changed, 204 insertions(+), 123 deletions(-) create mode 100644 packages/manager/.changeset/pr-12785-upcoming-features-1756910726492.md rename packages/manager/src/features/{Settings/SettingsLanding.tsx => AccountSettings/AccountSettingsLanding.tsx} (89%) create mode 100644 packages/manager/src/features/AccountSettings/accountSettingsLandingLazyRoute.ts delete mode 100644 packages/manager/src/features/Settings/settingsLandingLazyRoute.ts rename packages/manager/src/routes/{settings/SettingsRoute.tsx => accountSettings/AccountSettingsRoute.tsx} (90%) create mode 100644 packages/manager/src/routes/accountSettings/index.ts delete mode 100644 packages/manager/src/routes/settings/index.ts diff --git a/packages/manager/.changeset/pr-12785-upcoming-features-1756910726492.md b/packages/manager/.changeset/pr-12785-upcoming-features-1756910726492.md new file mode 100644 index 00000000000..d2cc072c239 --- /dev/null +++ b/packages/manager/.changeset/pr-12785-upcoming-features-1756910726492.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +UX feedback: Change /settings to /account-settings and profile/settings to profile/preferences ([#12785](https://github.com/linode/manager/pull/12785)) diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index 6be3c0eb1b8..d02b019ee9a 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts @@ -105,7 +105,7 @@ describe('linode landing checks', () => { cy.findByTestId('menu-item-Marketplace').should('be.visible'); cy.findByTestId('menu-item-Billing').scrollIntoView(); cy.findByTestId('menu-item-Billing').should('be.visible'); - cy.findByTestId('menu-item-Settings').should('be.visible'); + cy.findByTestId('menu-item-Account Settings').should('be.visible'); cy.findByTestId('menu-item-Help & Support').should('be.visible'); }); diff --git a/packages/manager/cypress/support/ui/constants.ts b/packages/manager/cypress/support/ui/constants.ts index a39dd3183e1..3ca596c5182 100644 --- a/packages/manager/cypress/support/ui/constants.ts +++ b/packages/manager/cypress/support/ui/constants.ts @@ -258,6 +258,6 @@ export const pages: Page[] = [ }, ], name: 'Settings', - url: `/settings`, + url: '/account-settings', }, ]; diff --git a/packages/manager/src/components/MaskableText/MaskableTextArea.tsx b/packages/manager/src/components/MaskableText/MaskableTextArea.tsx index abbeb85697a..5075bc68dc9 100644 --- a/packages/manager/src/components/MaskableText/MaskableTextArea.tsx +++ b/packages/manager/src/components/MaskableText/MaskableTextArea.tsx @@ -1,6 +1,8 @@ import { Typography } from '@linode/ui'; import React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; + import { Link } from '../Link'; /** @@ -8,11 +10,21 @@ import { Link } from '../Link'; * Example: Billing Contact info, rather than masking many individual fields */ export const MaskableTextAreaCopy = () => { + const { iamRbacPrimaryNavChanges } = useFlags(); return ( This data is sensitive and hidden for privacy. To unmask all sensitive data by default, go to{' '} - profile settings. + + profile settings + + . ); }; diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx index 64e6aafb3cf..c560e1d0815 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx @@ -375,7 +375,7 @@ describe('PrimaryNav', () => { screen.getByRole('link', { name: 'Maintenance' }) ).toBeInTheDocument(); expect( - screen.getByRole('link', { name: 'Settings' }) + screen.getByRole('link', { name: 'Account Settings' }) ).toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Account' })).toBeNull(); }); @@ -438,14 +438,15 @@ describe('PrimaryNav', () => { screen.queryByRole('link', { name: 'Service Transfers' }) ).toBeNull(); expect(screen.queryByRole('link', { name: 'Maintenance' })).toBeNull(); - expect(screen.queryByRole('link', { name: 'Settings' })).toBeNull(); expect( screen.queryByRole('link', { name: 'Account' }) ).toBeInTheDocument(); expect( screen.queryByRole('link', { name: 'Identity & Access' }) ).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'Settings' })).toBeNull(); + expect( + screen.queryByRole('link', { name: 'Account Settings' }) + ).toBeNull(); }); }); }); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 716f55f73ca..2a714cf4761 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -33,6 +33,7 @@ import type { PrimaryLink as PrimaryLinkType } from './PrimaryLink'; export type NavEntity = | 'Account' + | 'Account Settings' | 'Alerts' | 'Betas' | 'Billing' @@ -59,7 +60,6 @@ export type NavEntity = | 'Placement Groups' | 'Quotas' | 'Service Transfers' - | 'Settings' | 'StackScripts' | 'Users & Grants' | 'Volumes' @@ -313,8 +313,8 @@ export const PrimaryNav = (props: PrimaryNavProps) => { to: '/maintenance', }, { - display: 'Settings', - to: '/settings', + display: 'Account Settings', + to: '/account-settings', }, ], name: 'Administration', diff --git a/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx b/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx index 7ff7ca9704c..29bb8314cee 100644 --- a/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx +++ b/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx @@ -5,6 +5,7 @@ import type { JSX } from 'react'; import { FormGroup } from 'src/components/FormGroup'; import { Link } from 'src/components/Link'; +import { useFlags } from 'src/hooks/useFlags'; import type { TextFieldProps, TypographyProps } from '@linode/ui'; import type { Theme } from '@mui/material'; @@ -52,6 +53,8 @@ export const TypeToConfirm = (props: TypeToConfirmProps) => { (preferences) => preferences?.type_to_confirm ?? true ); + const { iamRbacPrimaryNavChanges } = useFlags(); + /* There is an edge case where preferences?.type_to_confirm is undefined when the user has not yet set a preference as seen in /profile/settings?preferenceEditor=true. @@ -115,7 +118,17 @@ export const TypeToConfirm = (props: TypeToConfirmProps) => { sx={{ marginTop: 1 }} > To {disableOrEnable} type-to-confirm, go to the Type-to-Confirm - section of My Settings. + section of{' '} + + {iamRbacPrimaryNavChanges ? 'Preferences' : 'My Settings'} + + . ) : null} diff --git a/packages/manager/src/features/Account/GlobalSettings.tsx b/packages/manager/src/features/Account/GlobalSettings.tsx index fb65d666557..fda507e3324 100644 --- a/packages/manager/src/features/Account/GlobalSettings.tsx +++ b/packages/manager/src/features/Account/GlobalSettings.tsx @@ -7,7 +7,6 @@ import { CircleProgress, ErrorState, Stack } from '@linode/ui'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; @@ -87,7 +86,6 @@ const GlobalSettings = () => { return (
- {isVMHostMaintenanceEnabled && } {isLinodeInterfacesEnabled && } diff --git a/packages/manager/src/features/Settings/SettingsLanding.tsx b/packages/manager/src/features/AccountSettings/AccountSettingsLanding.tsx similarity index 89% rename from packages/manager/src/features/Settings/SettingsLanding.tsx rename to packages/manager/src/features/AccountSettings/AccountSettingsLanding.tsx index 0523eb58e79..8e854a57be0 100644 --- a/packages/manager/src/features/Settings/SettingsLanding.tsx +++ b/packages/manager/src/features/AccountSettings/AccountSettingsLanding.tsx @@ -11,7 +11,7 @@ import GlobalSettings from '../Account/GlobalSettings'; import type { LandingHeaderProps } from 'src/components/LandingHeader'; -export const SettingsLanding = () => { +export const AccountSettingsLanding = () => { const flags = useFlags(); const location = useLocation(); @@ -23,14 +23,14 @@ export const SettingsLanding = () => { } const landingHeaderProps: LandingHeaderProps = { - title: 'Settings', + title: 'Account Settings', }; return ( <> - + diff --git a/packages/manager/src/features/AccountSettings/accountSettingsLandingLazyRoute.ts b/packages/manager/src/features/AccountSettings/accountSettingsLandingLazyRoute.ts new file mode 100644 index 00000000000..ee49d58a8d7 --- /dev/null +++ b/packages/manager/src/features/AccountSettings/accountSettingsLandingLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { AccountSettingsLanding } from './AccountSettingsLanding'; + +export const accountSettingsLandingLazyRoute = createLazyRoute( + '/account-settings' +)({ + component: AccountSettingsLanding, +}); diff --git a/packages/manager/src/features/Billing/BillingDetail.tsx b/packages/manager/src/features/Billing/BillingDetail.tsx index db2bd9a2a32..c063ead1276 100644 --- a/packages/manager/src/features/Billing/BillingDetail.tsx +++ b/packages/manager/src/features/Billing/BillingDetail.tsx @@ -10,7 +10,6 @@ import { styled } from '@mui/material/styles'; import { PayPalScriptProvider } from '@paypal/react-paypal-js'; import * as React from 'react'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { PAYPAL_CLIENT_ID } from 'src/constants'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -56,7 +55,6 @@ export const BillingDetail = () => { return ( - { return (
- { <> - diff --git a/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx b/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx index 7c4b14afe93..cd78b4716bd 100644 --- a/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx +++ b/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx @@ -1,7 +1,6 @@ import { Navigate, useLocation } from '@tanstack/react-router'; import * as React from 'react'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; @@ -30,7 +29,6 @@ export const MaintenanceLanding = () => { <> - diff --git a/packages/manager/src/features/Profile/Profile.tsx b/packages/manager/src/features/Profile/Profile.tsx index cab8bb3b3ea..1825a7b8929 100644 --- a/packages/manager/src/features/Profile/Profile.tsx +++ b/packages/manager/src/features/Profile/Profile.tsx @@ -8,11 +8,13 @@ import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; +import { useFlags } from 'src/hooks/useFlags'; import { useTabs } from 'src/hooks/useTabs'; export const Profile = () => { const location = useLocation(); const navigate = useNavigate(); + const { iamRbacPrimaryNavChanges } = useFlags(); const { tabs, handleTabChange, tabIndex } = useTabs([ { @@ -40,12 +42,14 @@ export const Profile = () => { title: 'OAuth Apps', }, { - to: `/profile/referrals`, - title: 'Referrals', + to: iamRbacPrimaryNavChanges + ? `/profile/preferences` + : `/profile/referrals`, + title: iamRbacPrimaryNavChanges ? 'Preferences' : 'Referrals', }, { - to: `/profile/settings`, - title: 'My Settings', + to: iamRbacPrimaryNavChanges ? `/profile/referrals` : `/profile/settings`, + title: iamRbacPrimaryNavChanges ? 'Referrals' : 'My Settings', }, ]); diff --git a/packages/manager/src/features/Profile/Settings/Settings.tsx b/packages/manager/src/features/Profile/Settings/Settings.tsx index fe8b9575367..6174d15904e 100644 --- a/packages/manager/src/features/Profile/Settings/Settings.tsx +++ b/packages/manager/src/features/Profile/Settings/Settings.tsx @@ -3,6 +3,7 @@ import { useNavigate, useSearch } from '@tanstack/react-router'; import React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { useFlags } from 'src/hooks/useFlags'; import { MaskSensitiveData } from './MaskSensitiveData'; import { Notifications } from './Notifications'; @@ -13,20 +14,29 @@ import { TypeToConfirm } from './TypeToConfirm'; export const ProfileSettings = () => { const navigate = useNavigate(); - const { preferenceEditor } = useSearch({ from: '/profile/settings' }); + const { iamRbacPrimaryNavChanges } = useFlags(); + const { preferenceEditor } = useSearch({ + from: iamRbacPrimaryNavChanges + ? '/profile/preferences' + : '/profile/settings', + }); const isPreferenceEditorOpen = !!preferenceEditor; const handleClosePreferenceEditor = () => { navigate({ - to: '/profile/settings', + to: iamRbacPrimaryNavChanges + ? '/profile/preferences' + : '/profile/settings', search: { preferenceEditor: undefined }, }); }; return ( <> - + diff --git a/packages/manager/src/features/Profile/Settings/settingsLazyRoute.ts b/packages/manager/src/features/Profile/Settings/settingsLazyRoute.ts index ccb73884b46..33e561dae36 100644 --- a/packages/manager/src/features/Profile/Settings/settingsLazyRoute.ts +++ b/packages/manager/src/features/Profile/Settings/settingsLazyRoute.ts @@ -5,3 +5,11 @@ import { ProfileSettings } from './Settings'; export const settingsLazyRoute = createLazyRoute('/profile/settings')({ component: ProfileSettings, }); + +/** + * @todo As part of the IAM Primary Nav flag (iamRbacPrimaryNavChanges) cleanup, /profile/settings will be removed. + * Adding the lazy route in this file will also require the necessary cleanup work, such as renaming the file and removing settingsLazyRoute(/profile/settings), as part of the flag cleanup. + */ +export const preferencesLazyRoute = createLazyRoute('/profile/preferences')({ + component: ProfileSettings, +}); diff --git a/packages/manager/src/features/Quotas/QuotasLanding.tsx b/packages/manager/src/features/Quotas/QuotasLanding.tsx index f0aa7d4f89b..eafc6f2237f 100644 --- a/packages/manager/src/features/Quotas/QuotasLanding.tsx +++ b/packages/manager/src/features/Quotas/QuotasLanding.tsx @@ -1,7 +1,6 @@ import { Navigate, useLocation } from '@tanstack/react-router'; import * as React from 'react'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; @@ -36,7 +35,6 @@ export const QuotasLanding = () => { <> - diff --git a/packages/manager/src/features/Settings/settingsLandingLazyRoute.ts b/packages/manager/src/features/Settings/settingsLandingLazyRoute.ts deleted file mode 100644 index e73ddbfed13..00000000000 --- a/packages/manager/src/features/Settings/settingsLandingLazyRoute.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createLazyRoute } from '@tanstack/react-router'; - -import { SettingsLanding } from './SettingsLanding'; - -export const settingsLandingLazyRoute = createLazyRoute('/settings')({ - component: SettingsLanding, -}); diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx index 3b5a841a146..aed316a400e 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx @@ -32,28 +32,10 @@ interface MenuLink { to: string; } -const profileLinks: MenuLink[] = [ - { - display: 'Display', - to: '/profile/display', - }, - { display: 'Login & Authentication', to: '/profile/auth' }, - { display: 'SSH Keys', to: '/profile/keys' }, - { display: 'LISH Console Settings', to: '/profile/lish' }, - { - display: 'API Tokens', - to: '/profile/tokens', - }, - { display: 'OAuth Apps', to: '/profile/clients' }, - { display: 'Referrals', to: '/profile/referrals' }, - { display: 'My Settings', to: '/profile/settings' }, - { display: 'Log Out', to: '/logout' }, -]; - export const UserMenuPopover = (props: UserMenuPopoverProps) => { const { anchorEl, isDrawerOpen, onClose, onDrawerOpen } = props; const sessionContext = React.useContext(switchAccountSessionContext); - const flags = useFlags(); + const { iamRbacPrimaryNavChanges, limitsEvolution } = useFlags(); const theme = useTheme(); const { data: account } = useAccount(); @@ -73,6 +55,32 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { const open = Boolean(anchorEl); const id = open ? 'user-menu-popover' : undefined; + const profileLinks: MenuLink[] = [ + { + display: 'Display', + to: '/profile/display', + }, + { display: 'Login & Authentication', to: '/profile/auth' }, + { display: 'SSH Keys', to: '/profile/keys' }, + { display: 'LISH Console Settings', to: '/profile/lish' }, + { + display: 'API Tokens', + to: '/profile/tokens', + }, + { display: 'OAuth Apps', to: '/profile/clients' }, + { + display: iamRbacPrimaryNavChanges ? 'Preferences' : 'Referrals', + to: iamRbacPrimaryNavChanges + ? '/profile/preferences' + : '/profile/referrals', + }, + { + display: iamRbacPrimaryNavChanges ? 'Referrals' : 'My Settings', + to: iamRbacPrimaryNavChanges ? '/profile/referrals' : '/profile/settings', + }, + { display: 'Log Out', to: '/logout' }, + ]; + // Used for fetching parent profile and account data by making a request with the parent's token. const proxyHeaders = isProxyUser ? { @@ -93,50 +101,50 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { () => [ { display: 'Billing', - to: flags?.iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', + to: iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', }, { display: - flags?.iamRbacPrimaryNavChanges && isIAMEnabled + iamRbacPrimaryNavChanges && isIAMEnabled ? 'Identity & Access' : 'Users & Grants', to: - flags?.iamRbacPrimaryNavChanges && isIAMEnabled + iamRbacPrimaryNavChanges && isIAMEnabled ? '/iam' - : flags?.iamRbacPrimaryNavChanges && !isIAMEnabled + : iamRbacPrimaryNavChanges && !isIAMEnabled ? '/users' : '/account/users', - isBeta: flags?.iamRbacPrimaryNavChanges && isIAMEnabled, + isBeta: iamRbacPrimaryNavChanges && isIAMEnabled, }, { display: 'Quotas', - hide: !flags.limitsEvolution?.enabled, - to: flags?.iamRbacPrimaryNavChanges ? '/quotas' : '/account/quotas', + hide: !limitsEvolution?.enabled, + to: iamRbacPrimaryNavChanges ? '/quotas' : '/account/quotas', }, { display: 'Login History', - to: flags?.iamRbacPrimaryNavChanges + to: iamRbacPrimaryNavChanges ? '/login-history' : '/account/login-history', }, { display: 'Service Transfers', - to: flags?.iamRbacPrimaryNavChanges + to: iamRbacPrimaryNavChanges ? '/service-transfers' : '/account/service-transfers', }, { display: 'Maintenance', - to: flags?.iamRbacPrimaryNavChanges - ? '/maintenance' - : '/account/maintenance', + to: iamRbacPrimaryNavChanges ? '/maintenance' : '/account/maintenance', }, { - display: 'Settings', - to: flags?.iamRbacPrimaryNavChanges ? '/settings' : '/account/settings', + display: iamRbacPrimaryNavChanges ? 'Account Settings' : 'Settings', + to: iamRbacPrimaryNavChanges + ? '/account-settings' + : '/account/settings', }, ], - [isIAMEnabled, flags] + [isIAMEnabled, iamRbacPrimaryNavChanges, limitsEvolution] ); const renderLink = (link: MenuLink) => { @@ -246,7 +254,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { - {flags?.iamRbacPrimaryNavChanges ? 'Administration' : 'Account'} + {iamRbacPrimaryNavChanges ? 'Administration' : 'Account'} { <> - diff --git a/packages/manager/src/routes/account/index.ts b/packages/manager/src/routes/account/index.ts index aec45d398cb..8a4b97e3bae 100644 --- a/packages/manager/src/routes/account/index.ts +++ b/packages/manager/src/routes/account/index.ts @@ -142,7 +142,7 @@ const accountSettingsRoute = createRoute({ beforeLoad: ({ context }) => { if (context?.flags?.iamRbacPrimaryNavChanges) { throw redirect({ - to: `/settings`, + to: `/account-settings`, replace: true, }); } diff --git a/packages/manager/src/routes/settings/SettingsRoute.tsx b/packages/manager/src/routes/accountSettings/AccountSettingsRoute.tsx similarity index 90% rename from packages/manager/src/routes/settings/SettingsRoute.tsx rename to packages/manager/src/routes/accountSettings/AccountSettingsRoute.tsx index 14e0369843e..58ff4bf6410 100644 --- a/packages/manager/src/routes/settings/SettingsRoute.tsx +++ b/packages/manager/src/routes/accountSettings/AccountSettingsRoute.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; -export const SettingsRoute = () => { +export const AccountSettingsRoute = () => { return ( }> diff --git a/packages/manager/src/routes/accountSettings/index.ts b/packages/manager/src/routes/accountSettings/index.ts new file mode 100644 index 00000000000..8b398567130 --- /dev/null +++ b/packages/manager/src/routes/accountSettings/index.ts @@ -0,0 +1,42 @@ +import { createRoute, redirect } from '@tanstack/react-router'; + +import { rootRoute } from '../root'; +import { AccountSettingsRoute } from './AccountSettingsRoute'; + +const accountSettingsRoute = createRoute({ + component: AccountSettingsRoute, + getParentRoute: () => rootRoute, + path: 'account-settings', +}); + +// Catch all route for account-settings page +const accountSettingsCatchAllRoute = createRoute({ + getParentRoute: () => accountSettingsRoute, + path: '/$invalidPath', + beforeLoad: () => { + throw redirect({ to: '/account-settings' }); + }, +}); + +// Index route: /account-settings (main settings content) +const accountSettingsIndexRoute = createRoute({ + getParentRoute: () => accountSettingsRoute, + path: '/', + beforeLoad: ({ context }) => { + if (!context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/account/settings`, + replace: true, + }); + } + }, +}).lazy(() => + import('src/features/AccountSettings/accountSettingsLandingLazyRoute').then( + (m) => m.accountSettingsLandingLazyRoute + ) +); + +export const accountSettingsRouteTree = accountSettingsRoute.addChildren([ + accountSettingsIndexRoute, + accountSettingsCatchAllRoute, +]); diff --git a/packages/manager/src/routes/index.tsx b/packages/manager/src/routes/index.tsx index 6fe0a84eba3..ef2b52210b0 100644 --- a/packages/manager/src/routes/index.tsx +++ b/packages/manager/src/routes/index.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { ErrorComponent } from 'src/features/ErrorBoundary/ErrorComponent'; import { accountRouteTree } from './account'; +import { accountSettingsRouteTree } from './accountSettings'; import { cloudPulseAlertsRouteTree } from './alerts'; import { cancelLandingRoute, @@ -37,7 +38,6 @@ import { quotasRouteTree } from './quotas'; import { rootRoute } from './root'; import { searchRouteTree } from './search'; import { serviceTransfersRouteTree } from './serviceTransfers'; -import { settingsRouteTree } from './settings'; import { stackScriptsRouteTree } from './stackscripts'; import { supportRouteTree } from './support'; import { usersAndGrantsRouteTree } from './usersAndGrants'; @@ -56,6 +56,7 @@ const indexRoute = createRoute({ export const routeTree = rootRoute.addChildren([ indexRoute, + accountSettingsRouteTree, cancelLandingRoute, loginAsCustomerCallbackRoute, logoutRoute, @@ -85,7 +86,6 @@ export const routeTree = rootRoute.addChildren([ quotasRouteTree, searchRouteTree, serviceTransfersRouteTree, - settingsRouteTree, stackScriptsRouteTree, supportRouteTree, usersAndGrantsRouteTree, diff --git a/packages/manager/src/routes/profile/index.ts b/packages/manager/src/routes/profile/index.ts index 681c2a52a0d..879b6950833 100644 --- a/packages/manager/src/routes/profile/index.ts +++ b/packages/manager/src/routes/profile/index.ts @@ -1,4 +1,4 @@ -import { createRoute } from '@tanstack/react-router'; +import { createRoute, redirect } from '@tanstack/react-router'; import { rootRoute } from '../root'; import { ProfileRoute } from './ProfileRoute'; @@ -92,7 +92,38 @@ const profileReferralsRoute = createRoute({ ) ); +/** + * The new route /profile/preferences aligns with the Profile tab, which has been renamed to Preferences (My Settings). + * After the transition, and as part of the cleanup, we will be removing /profile/settings (profileSettingsRoute). + */ + +const profilePreferencesRoute = createRoute({ + beforeLoad: ({ context }) => { + if (!context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/profile/settings`, + replace: true, + }); + } + }, + getParentRoute: () => profileRoute, + path: 'preferences', + validateSearch: (search: ProfileSettingsSearchParams) => search, +}).lazy(() => + import('src/features/Profile/Settings/settingsLazyRoute').then( + (m) => m.preferencesLazyRoute + ) +); + const profileSettingsRoute = createRoute({ + beforeLoad: ({ context }) => { + if (context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/profile/preferences`, + replace: true, + }); + } + }, getParentRoute: () => profileRoute, path: 'settings', validateSearch: (search: ProfileSettingsSearchParams) => search, @@ -109,6 +140,7 @@ export const profileRouteTree = profileRoute.addChildren([ profileLishSettingsRoute, profileAPITokensRoute, profileOAuthClientsRoute, + profilePreferencesRoute, profileReferralsRoute, profileSettingsRoute, ]); diff --git a/packages/manager/src/routes/settings/index.ts b/packages/manager/src/routes/settings/index.ts deleted file mode 100644 index 47cf6c5aca6..00000000000 --- a/packages/manager/src/routes/settings/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createRoute, redirect } from '@tanstack/react-router'; - -import { rootRoute } from '../root'; -import { SettingsRoute } from './SettingsRoute'; - -const settingsRoute = createRoute({ - component: SettingsRoute, - getParentRoute: () => rootRoute, - path: 'settings', -}); - -// Catch all route for settings page -const settingsCatchAllRoute = createRoute({ - getParentRoute: () => settingsRoute, - path: '/$invalidPath', - beforeLoad: () => { - throw redirect({ to: '/settings' }); - }, -}); - -// Index route: /settings (main settings content) -const settingsIndexRoute = createRoute({ - getParentRoute: () => settingsRoute, - path: '/', - beforeLoad: ({ context }) => { - if (!context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/account/settings`, - replace: true, - }); - } - }, -}).lazy(() => - import('src/features/Settings/settingsLandingLazyRoute').then( - (m) => m.settingsLandingLazyRoute - ) -); - -export const settingsRouteTree = settingsRoute.addChildren([ - settingsIndexRoute, - settingsCatchAllRoute, -]); From 9e489689c32d217617f9023e38f1c7822ca9d642 Mon Sep 17 00:00:00 2001 From: dmcintyr-akamai Date: Wed, 3 Sep 2025 12:33:00 -0400 Subject: [PATCH 41/73] tests [M3-10513] Fix for flakey test in alerts-listing-page.spec.ts (#12736) * limit cypress scope to fix test * Added changeset: Fix for flakey test in alerts-listing-page.spec.ts --- .../pr-12736-tests-1755708307206.md | 5 +++ .../cloudpulse/alerts-listing-page.spec.ts | 31 ++++++++++--------- 2 files changed, 22 insertions(+), 14 deletions(-) create mode 100644 packages/manager/.changeset/pr-12736-tests-1755708307206.md diff --git a/packages/manager/.changeset/pr-12736-tests-1755708307206.md b/packages/manager/.changeset/pr-12736-tests-1755708307206.md new file mode 100644 index 00000000000..d65593fead1 --- /dev/null +++ b/packages/manager/.changeset/pr-12736-tests-1755708307206.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix for flakey test in alerts-listing-page.spec.ts ([#12736](https://github.com/linode/manager/pull/12736)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts index 485dd160cb7..7c1a0643c1f 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts @@ -260,24 +260,27 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { }); it('should validate UI elements and alert details', () => { - // Validate navigation links and buttons - cy.findByText('Alerts').should('be.visible'); + // filter to main content area to avoid confusion w/ 'Alerts' nav link in left sidebar + cy.get('main').within(() => { + // Validate breadcrumb and buttons + cy.findByText('Alerts', { exact: false }).should('be.visible'); - cy.findByText('Definitions') - .should('be.visible') - .and('have.attr', 'href', alertDefinitionsUrl); - ui.buttonGroup.findButtonByTitle('Create Alert').should('be.visible'); + cy.findByText('Definitions') + .should('be.visible') + .and('have.attr', 'href', alertDefinitionsUrl); + ui.buttonGroup.findButtonByTitle('Create Alert').should('be.visible'); - // Validate table headers - cy.get('[data-qa="alert-table"]').within(() => { - expectedHeaders.forEach((header) => { - cy.findByText(header).should('have.text', header); + // Validate table headers + cy.get('[data-qa="alert-table"]').within(() => { + expectedHeaders.forEach((header) => { + cy.findByText(header).should('have.text', header); + }); }); - }); - // Validate alert details - mockAlerts.forEach((alert) => { - validateAlertDetails(alert); + // Validate alert details + mockAlerts.forEach((alert) => { + validateAlertDetails(alert); + }); }); }); From 107fb3386e6fa4ce30ff131d32ee17051f016f79 Mon Sep 17 00:00:00 2001 From: dmcintyr-akamai Date: Wed, 3 Sep 2025 13:08:21 -0400 Subject: [PATCH 42/73] test [M3 9641]: Tests for Host & VM Maintenance Linode details page changes (#12786) * initial commit * Added changeset: Tests for Host & VM Maintenance in Linode create page * remove deprecated tooltip * initial commit * failure test case * Added changeset: Tests for Host & VM Maintenance Linode details page changes * Delete packages/manager/.changeset/pr-12734-tests-1755697767741.md duplicate changeset file * file from wrong pr * refactoring after review * more edits after review --- .../pr-12786-tests-1756396419263.md | 5 + .../e2e/core/linodes/alerts-edit.spec.ts | 10 +- ...inode-settings-vm-host-maintenance.spec.ts | 188 ++++++++++++++++++ .../cypress/support/intercepts/linodes.ts | 45 ++++- 4 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-12786-tests-1756396419263.md create mode 100644 packages/manager/cypress/e2e/core/linodes/linode-settings-vm-host-maintenance.spec.ts diff --git a/packages/manager/.changeset/pr-12786-tests-1756396419263.md b/packages/manager/.changeset/pr-12786-tests-1756396419263.md new file mode 100644 index 00000000000..a9a23c0f979 --- /dev/null +++ b/packages/manager/.changeset/pr-12786-tests-1756396419263.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Tests for Host & VM Maintenance Linode details page changes ([#12786](https://github.com/linode/manager/pull/12786)) diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts index 944113a325b..d2b7e3267a4 100644 --- a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts @@ -2,8 +2,8 @@ import { linodeFactory, regionFactory } from '@linode/utilities'; import { mockGetAlertDefinition } from 'support/intercepts/cloudpulse'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { + interceptUpdateLinode, mockGetLinodeDetails, - mockUpdateLinode, } from 'support/intercepts/linodes'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; @@ -234,7 +234,7 @@ describe('region enables alerts', function () { // toggles in table are on but can be turned off assertLinodeAlertsEnabled(this.alertDefinitions); - mockUpdateLinode(mockLinode.id).as('updateLinode'); + interceptUpdateLinode(mockLinode.id).as('updateLinode'); ui.button .findByTitle('Save') .should('be.visible') @@ -366,7 +366,7 @@ describe('region enables alerts', function () { .click({ multiple: true }); ui.toggle.find().should('have.attr', 'data-qa-toggle', 'false'); - mockUpdateLinode(mockLinode.id).as('updateLinode'); + interceptUpdateLinode(mockLinode.id).as('updateLinode'); cy.scrollTo('bottom'); // save changes ui.button @@ -411,7 +411,7 @@ describe('region enables alerts', function () { // toggles in table are on but can be turned off assertLinodeAlertsEnabled(this.alertDefinitions); - mockUpdateLinode(mockLinode.id).as('updateLinode'); + interceptUpdateLinode(mockLinode.id).as('updateLinode'); ui.button .findByTitle('Save') .should('be.visible') @@ -492,7 +492,7 @@ describe('region enables alerts', function () { }); }); - mockUpdateLinode(mockLinode.id).as('updateLinode'); + interceptUpdateLinode(mockLinode.id).as('updateLinode'); ui.button .findByTitle('Save') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/linodes/linode-settings-vm-host-maintenance.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-settings-vm-host-maintenance.spec.ts new file mode 100644 index 00000000000..416f1829b9f --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/linode-settings-vm-host-maintenance.spec.ts @@ -0,0 +1,188 @@ +import { linodeFactory, regionFactory } from '@linode/utilities'; +import { mockGetAccountSettings } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetLinodeDetails, + mockGetLinodeDisks, + mockUpdateLinode, + mockUpdateLinodeError, +} from 'support/intercepts/linodes'; +import { mockGetMaintenancePolicies } from 'support/intercepts/maintenance'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { randomLabel, randomNumber } from 'support/util/random'; + +import { accountSettingsFactory } from 'src/factories'; +import { maintenancePolicyFactory } from 'src/factories/maintenancePolicy'; + +import type { Disk } from '@linode/api-v4'; + +const mockEnabledRegion = regionFactory.build({ + capabilities: ['Linodes', 'Maintenance Policy'], +}); +const mockDisabledRegion = regionFactory.build({ + capabilities: ['Linodes'], +}); +const mockMaintenancePolicyMigrate = maintenancePolicyFactory.build({ + slug: 'linode/migrate', + label: 'Migrate', + type: 'linode_migrate', +}); +const mockMaintenancePolicyPowerOnOff = maintenancePolicyFactory.build({ + slug: 'linode/power_off_on', + label: 'Power Off / Power On', + type: 'linode_power_off_on', +}); +const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockEnabledRegion.id, +}); + +describe('vmHostMaintenance feature flag', () => { + beforeEach(() => { + mockGetAccountSettings( + accountSettingsFactory.build({ + maintenance_policy: 'linode/power_off_on', + }) + ).as('getAccountSettings'); + mockGetRegions([mockEnabledRegion, mockDisabledRegion]).as('getRegions'); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + const mockDisk: Disk = { + created: '2020-08-21T17:26:14', + filesystem: 'ext4', + id: 44311273, + label: 'Debian 10 Disk', + size: 81408, + status: 'ready', + updated: '2020-08-21T17:26:30', + }; + mockGetLinodeDisks(mockLinode.id, [mockDisk]).as('getDisks'); + mockGetMaintenancePolicies([ + mockMaintenancePolicyMigrate, + mockMaintenancePolicyPowerOnOff, + ]).as('getMaintenancePolicies'); + // cy.wrap(mockMaintenancePolicyPowerOnOff).as('mockMaintenancePolicyPowerOnOff'); + }); + + it('VM host maintenance setting is editable when vmHostMaintenance feature flag is enabled. Mocked success.', () => { + mockAppendFeatureFlags({ + vmHostMaintenance: { + enabled: true, + }, + }).as('getFeatureFlags'); + cy.visitWithLogin(`/linodes/${mockLinode.id}/settings`); + cy.wait([ + '@getAccountSettings', + '@getFeatureFlags', + '@getMaintenancePolicies', + '@getDisks', + ]); + mockUpdateLinode(mockLinode.id, { + ...mockLinode, + maintenance_policy: mockMaintenancePolicyPowerOnOff.slug, + }).as('updateLinode'); + + cy.contains('Host Maintenance Policy').should('be.visible'); + cy.contains('Maintenance Policy').should('be.visible'); + ui.autocomplete.findByLabel('Maintenance Policy').as('maintenanceInput'); + cy.get('@maintenanceInput') + .should('be.visible') + .should('have.value', mockMaintenancePolicyMigrate.label); + cy.get('@maintenanceInput') + .closest('form') + .within(() => { + // save button for the host maintenance setting is disabled before edits + ui.button.findByTitle('Save').should('be.disabled'); + // make edit + cy.get('@maintenanceInput').click(); + cy.focused().type(`${mockMaintenancePolicyPowerOnOff.label}`); + ui.autocompletePopper + .findByTitle(mockMaintenancePolicyPowerOnOff.label) + .should('be.visible') + .click(); + // save button is enabled after edit + ui.button.findByTitle('Save').should('be.enabled').click(); + }); + + // POST payload should include maintenance_policy + cy.wait('@updateLinode').then((intercept) => { + expect(intercept.request.body['maintenance_policy']).to.eq( + mockMaintenancePolicyPowerOnOff.slug + ); + }); + + // toast notification + ui.toast.assertMessage('Host Maintenance Policy settings updated.'); + }); + + it('VM host maintenance setting is editable when vmHostMaintenance feature flag is enabled. Mocked failure.', () => { + mockAppendFeatureFlags({ + vmHostMaintenance: { + enabled: true, + }, + }).as('getFeatureFlags'); + cy.visitWithLogin(`/linodes/${mockLinode.id}/settings`); + cy.wait([ + '@getAccountSettings', + '@getFeatureFlags', + '@getMaintenancePolicies', + '@getDisks', + ]); + const linodeError = { + statusCode: 400, + errorMessage: 'Linode update failed', + }; + mockUpdateLinodeError( + mockLinode.id, + linodeError.errorMessage, + linodeError.statusCode + ); + + cy.contains('Host Maintenance Policy').should('be.visible'); + cy.contains('Maintenance Policy').should('be.visible'); + ui.autocomplete.findByLabel('Maintenance Policy').as('maintenanceInput'); + cy.get('@maintenanceInput') + .should('be.visible') + .should('have.value', mockMaintenancePolicyMigrate.label); + cy.get('@maintenanceInput') + .closest('form') + .within(() => { + // save button for the host maintenance setting is disabled before edits + ui.button.findByTitle('Save').should('be.disabled'); + // make edit + cy.get('@maintenanceInput').click(); + cy.focused().type(`${mockMaintenancePolicyPowerOnOff.label}`); + ui.autocompletePopper + .findByTitle(mockMaintenancePolicyPowerOnOff.label) + .should('be.visible') + .click(); + // save button is enabled after edit + ui.button.findByTitle('Save').should('be.enabled').click(); + }); + + cy.get('[data-qa-textfield-error-text="Maintenance Policy"]') + .should('be.visible') + .should('have.text', linodeError.errorMessage); + cy.get('[aria-errormessage]').should('be.visible'); + }); + + it('Maintenance policy setting is absent when feature flag is disabled', () => { + mockAppendFeatureFlags({ + vmHostMaintenance: { + enabled: false, + }, + }).as('getFeatureFlags'); + cy.visitWithLogin(`/linodes/${mockLinode.id}/settings`); + cy.wait(['@getAccountSettings', '@getFeatureFlags', '@getDisks']); + + // "Host Maintenance Policy" section is not present + cy.get('[data-reach-tab-panels]') + .should('be.visible') + .within(() => { + cy.findByText('Host Maintenance Policy').should('not.exist'); + cy.findByText('Maintenance Policy').should('not.exist'); + cy.get('[data-qa-panel="Host Maintenance Policy"]').should('not.exist'); + }); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index 60c387bcccc..28516154615 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -806,6 +806,49 @@ export const mockGetLinodeStatsError = ( * * @returns Cypress chainable. */ -export const mockUpdateLinode = (linodeId: number): Cypress.Chainable => { +export const interceptUpdateLinode = ( + linodeId: number +): Cypress.Chainable => { return cy.intercept('PUT', apiMatcher(`linode/instances/${linodeId}`)); }; + +/** + * Intercepts PUT request to edit details of a linode + * + * @param linodeId - ID of Linode for intercepted request. + * @param updatedLinode - a mock linode object + * + * @returns Cypress chainable. + */ +export const mockUpdateLinode = ( + linodeId: number, + updatedLinode: Linode +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`linode/instances/${linodeId}`), + updatedLinode + ); +}; + +/** + * Intercepts PUT request to edit details of a linode and mocks an error response. + * + * @param linodeId - ID of Linode for intercepted request. + * @param updatedLinode - a mock linode object + * @param errorMessage - Error message to be included in the mocked HTTP response. + * @param statusCode - HTTP status code for mocked error response. Default is `400`. + * + * @returns Cypress chainable. + */ +export const mockUpdateLinodeError = ( + linodeId: number, + errorMessage: string, + statusCode: number = 400 +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`linode/instances/${linodeId}`), + makeErrorResponse(errorMessage, statusCode) + ); +}; From 43ef130b27c1ac88a1b7905a9e9d34963413b2ec Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 3 Sep 2025 13:31:24 -0500 Subject: [PATCH 43/73] fix: [M3-10567] - The Make a Payment and Add Payment drawers does not close when the browser back button is clicked (#12796) * Fix: Close Make a payment and Add payment drawers when browser back button is clicked * Added changeset: The Make a Payment and Add Payment drawers does not close when the browser back button is clicked * Update packages/manager/.changeset/pr-12796-fixed-1756845120661.md Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> * PR feedback - Billing related test failures --------- Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> --- packages/manager/.changeset/pr-12796-fixed-1756845120661.md | 5 +++++ .../Billing/BillingPanels/BillingSummary/BillingSummary.tsx | 3 +++ .../BillingPanels/PaymentInfoPanel/PaymentInformation.tsx | 2 ++ 3 files changed, 10 insertions(+) create mode 100644 packages/manager/.changeset/pr-12796-fixed-1756845120661.md diff --git a/packages/manager/.changeset/pr-12796-fixed-1756845120661.md b/packages/manager/.changeset/pr-12796-fixed-1756845120661.md new file mode 100644 index 00000000000..23590350072 --- /dev/null +++ b/packages/manager/.changeset/pr-12796-fixed-1756845120661.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Make a Payment and Add Payment drawers not closing when browser back button is clicked ([#12796](https://github.com/linode/manager/pull/12796)) diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx index 54d55cf4372..c6c827abbfc 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx @@ -90,6 +90,9 @@ export const BillingSummary = (props: BillingSummaryProps) => { React.useEffect(() => { if (!makePaymentRouteMatch) { + if (paymentDrawerOpen) { + closePaymentDrawer(); + } return; } diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx index aec62112818..c7919c408bb 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx @@ -98,6 +98,8 @@ const PaymentInformation = (props: Props) => { React.useEffect(() => { if (addPaymentMethodRouteMatch) { openAddDrawer(); + } else { + closeAddDrawer(); } }, [addPaymentMethodRouteMatch, openAddDrawer]); From 52dbd319adfac171482599bf355d188d4166134f Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:16:57 -0400 Subject: [PATCH 44/73] feat: [M3-9853] - Update Linode Config and Rescue Dialog to support new volume count limit (#12791) * being updating types * confirm values sent to backend (sdi) * add some more slots for testing clean this all up fr later * move stuff to constants file * match api limit on our end oops * allow extra devices to be omitted from payload * changeset * switch to type map instead * fix failing test * rescue updates part 1 * should try to remove cast * optionality feedback @bnussman-akamai * Update packages/utilities/src/helpers/createDevicesFromStrings.ts Co-authored-by: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> * address feedback simple warn @jaalah-akamai --------- Co-authored-by: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> --- .../pr-12791-added-1756819884245.md | 5 + packages/api-v4/src/linodes/types.ts | 79 ++++++++-- .../pr-12791-added-1756819851078.md | 5 + .../LinodeConfigs/LinodeConfigDialog.tsx | 34 ++--- .../LinodeConfigs/LinodeConfigs.test.tsx | 2 +- .../LinodesDetail/LinodeConfigs/constants.ts | 135 ++++++++++++++++++ .../LinodeRescue/DeviceSelection.tsx | 4 +- .../LinodeRescue/StandardRescueDialog.tsx | 50 ++++--- .../pr-12791-changed-1756914177012.md | 5 + .../helpers/createDevicesFromStrings.test.ts | 30 +--- .../src/helpers/createDevicesFromStrings.ts | 89 ++++++++++-- .../pr-12791-added-1756819913640.md | 5 + packages/validation/src/linodes.schema.ts | 60 +++++++- 13 files changed, 394 insertions(+), 109 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12791-added-1756819884245.md create mode 100644 packages/manager/.changeset/pr-12791-added-1756819851078.md create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.ts create mode 100644 packages/utilities/.changeset/pr-12791-changed-1756914177012.md create mode 100644 packages/validation/.changeset/pr-12791-added-1756819913640.md diff --git a/packages/api-v4/.changeset/pr-12791-added-1756819884245.md b/packages/api-v4/.changeset/pr-12791-added-1756819884245.md new file mode 100644 index 00000000000..a070853b8db --- /dev/null +++ b/packages/api-v4/.changeset/pr-12791-added-1756819884245.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +Additional device slots to `Devices` type to match new API limits ([#12791](https://github.com/linode/manager/pull/12791)) diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 48005cd73d2..114a7dce89d 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -388,15 +388,73 @@ export interface VolumeDevice { volume_id: null | number; } +export type ConfigDevice = DiskDevice | null | VolumeDevice; + export interface Devices { - sda: DiskDevice | null | VolumeDevice; - sdb: DiskDevice | null | VolumeDevice; - sdc: DiskDevice | null | VolumeDevice; - sdd: DiskDevice | null | VolumeDevice; - sde: DiskDevice | null | VolumeDevice; - sdf: DiskDevice | null | VolumeDevice; - sdg: DiskDevice | null | VolumeDevice; - sdh: DiskDevice | null | VolumeDevice; + sda?: ConfigDevice; + sdaa?: ConfigDevice; + sdab?: ConfigDevice; + sdac?: ConfigDevice; + sdad?: ConfigDevice; + sdae?: ConfigDevice; + sdaf?: ConfigDevice; + sdag?: ConfigDevice; + sdah?: ConfigDevice; + sdai?: ConfigDevice; + sdaj?: ConfigDevice; + sdak?: ConfigDevice; + sdal?: ConfigDevice; + sdam?: ConfigDevice; + sdan?: ConfigDevice; + sdao?: ConfigDevice; + sdap?: ConfigDevice; + sdaq?: ConfigDevice; + sdar?: ConfigDevice; + sdas?: ConfigDevice; + sdat?: ConfigDevice; + sdau?: ConfigDevice; + sdav?: ConfigDevice; + sdaw?: ConfigDevice; + sdax?: ConfigDevice; + sday?: ConfigDevice; + sdaz?: ConfigDevice; + sdb?: ConfigDevice; + sdba?: ConfigDevice; + sdbb?: ConfigDevice; + sdbc?: ConfigDevice; + sdbd?: ConfigDevice; + sdbe?: ConfigDevice; + sdbf?: ConfigDevice; + sdbg?: ConfigDevice; + sdbh?: ConfigDevice; + sdbi?: ConfigDevice; + sdbj?: ConfigDevice; + sdbk?: ConfigDevice; + sdbl?: ConfigDevice; + sdc?: ConfigDevice; + sdd?: ConfigDevice; + sde?: ConfigDevice; + sdf?: ConfigDevice; + sdg?: ConfigDevice; + sdh?: ConfigDevice; + sdi?: ConfigDevice; + sdj?: ConfigDevice; + sdk?: ConfigDevice; + sdl?: ConfigDevice; + sdm?: ConfigDevice; + sdn?: ConfigDevice; + sdo?: ConfigDevice; + sdp?: ConfigDevice; + sdq?: ConfigDevice; + sdr?: ConfigDevice; + sds?: ConfigDevice; + sdt?: ConfigDevice; + sdu?: ConfigDevice; + sdv?: ConfigDevice; + sdw?: ConfigDevice; + sdx?: ConfigDevice; + sdy?: ConfigDevice; + sdz?: ConfigDevice; } export type KernelArchitecture = 'i386' | 'x86_64'; @@ -678,10 +736,7 @@ export interface MigrateLinodeRequest { region: string; } -export type RescueRequestObject = Pick< - Devices, - 'sda' | 'sdb' | 'sdc' | 'sdd' | 'sde' | 'sdf' | 'sdg' ->; +export type RescueRequestObject = Omit; export interface LinodeCloneData { backups_enabled?: boolean | null; diff --git a/packages/manager/.changeset/pr-12791-added-1756819851078.md b/packages/manager/.changeset/pr-12791-added-1756819851078.md new file mode 100644 index 00000000000..e8c40cf7b3c --- /dev/null +++ b/packages/manager/.changeset/pr-12791-added-1756819851078.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Additional device slots to Linode Config and Rescue Dialog to match new API limits ([#12791](https://github.com/linode/manager/pull/12791)) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx index 9364ebf9614..5eb28665008 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx @@ -65,6 +65,7 @@ import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; import { InterfaceSelect } from '../LinodeSettings/InterfaceSelect'; import { KernelSelect } from '../LinodeSettings/KernelSelect'; import { getSelectedDeviceOption } from '../utilities'; +import { deviceSlots, pathsOptions, pathsOptionsLabels } from './constants'; import { StyledDivider, StyledFormControl, @@ -175,17 +176,6 @@ const defaultLegacyInterfaceFieldValues: EditableFields = { interfaces: defaultInterfaceList, }; -const pathsOptions = [ - { label: '/dev/sda', value: '/dev/sda' }, - { label: '/dev/sdb', value: '/dev/sdb' }, - { label: '/dev/sdc', value: '/dev/sdc' }, - { label: '/dev/sdd', value: '/dev/sdd' }, - { label: '/dev/sde', value: '/dev/sde' }, - { label: '/dev/sdf', value: '/dev/sdf' }, - { label: '/dev/sdg', value: '/dev/sdg' }, - { label: '/dev/sdh', value: '/dev/sdh' }, -]; - const interfacesToState = (interfaces?: Interface[] | null) => { if (!interfaces || interfaces.length === 0) { return defaultInterfaceList; @@ -243,7 +233,6 @@ const interfacesToPayload = (interfaces?: ExtendedInterface[] | null) => { return filteredInterfaces as Interface[]; }; -const deviceSlots = ['sda', 'sdb', 'sdc', 'sdd', 'sde', 'sdf', 'sdg', 'sdh']; const deviceCounterDefault = 1; // DiskID reserved on the back-end to indicate Finnix. @@ -254,6 +243,12 @@ export const LinodeConfigDialog = (props: Props) => { const { config, linodeId, onClose, open } = props; const { data: linode } = useLinodeQuery(linodeId, open); + const availableMemory = linode?.specs.memory ?? 0; + if (availableMemory < 0) { + // eslint-disable-next-line no-console + console.warn('Invalid memory value:', availableMemory); + } + const deviceLimit = Math.max(8, Math.min(availableMemory / 1024, 64)); const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); @@ -908,7 +903,7 @@ export const LinodeConfigDialog = (props: Props) => { values.devices?.[slot as keyof DevicesAsStrings] ?? '' } onChange={handleDevicesChanges} - slots={deviceSlots} + slots={deviceSlots.slice(0, deviceLimit)} /> { - - - ); -}; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.test.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.test.tsx similarity index 100% rename from packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.test.tsx rename to packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.test.tsx diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.tsx new file mode 100644 index 00000000000..1a49f6e87c4 --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationCreate.tsx @@ -0,0 +1,62 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { destinationType } from '@linode/api-v4'; +import { useCreateDestinationMutation } from '@linode/queries'; +import { destinationSchema } from '@linode/validation'; +import { useNavigate } from '@tanstack/react-router'; +import * as React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { DestinationForm } from 'src/features/DataStream/Destinations/DestinationForm/DestinationForm'; + +import type { LandingHeaderProps } from 'src/components/LandingHeader'; +import type { DestinationFormType } from 'src/features/DataStream/Shared/types'; + +export const DestinationCreate = () => { + const { mutateAsync: createDestination } = useCreateDestinationMutation(); + const navigate = useNavigate(); + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/datastream/destinations/create', + crumbOverrides: [ + { + label: 'DataStream', + linkTo: '/datastream/destinations', + position: 1, + }, + ], + }, + removeCrumbX: 2, + title: 'Create Destination', + }; + + const form = useForm({ + defaultValues: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + mode: 'onBlur', + resolver: yupResolver(destinationSchema), + }); + + const onSubmit = () => { + const payload = form.getValues(); + createDestination(payload).then(() => { + navigate({ to: '/datastream/destinations' }); + }); + }; + + return ( + <> + + + + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.test.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.test.tsx new file mode 100644 index 00000000000..f78db66ab0c --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.test.tsx @@ -0,0 +1,63 @@ +import { + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import React from 'react'; +import { describe } from 'vitest'; + +import { destinationFactory } from 'src/factories/datastream'; +import { DestinationEdit } from 'src/features/DataStream/Destinations/DestinationForm/DestinationEdit'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +const loadingTestId = 'circle-progress'; +const destinationId = 123; +const mockDestination = destinationFactory.build({ + id: destinationId, + label: `Destination ${destinationId}`, +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: vi.fn().mockReturnValue({ destinationId: 123 }), + }; +}); + +describe('DestinationEdit', () => { + const assertInputHasValue = (inputLabel: string, inputValue: string) => { + expect(screen.getByLabelText(inputLabel)).toHaveValue(inputValue); + }; + + it('should render edited destination when destination fetched properly', async () => { + server.use( + http.get(`*/monitor/streams/destinations/${destinationId}`, () => { + return HttpResponse.json(mockDestination); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } + + assertInputHasValue('Destination Type', 'Linode Object Storage'); + await waitFor(() => { + assertInputHasValue('Destination Name', 'Destination 123'); + }); + assertInputHasValue('Host', '3000'); + assertInputHasValue('Bucket', 'Bucket Name'); + await waitFor(() => { + assertInputHasValue('Region', 'US, Chicago, IL (us-ord)'); + }); + assertInputHasValue('Access Key ID', 'Access Id'); + assertInputHasValue('Secret Access Key', 'Access Secret'); + assertInputHasValue('Log Path Prefix', 'file'); + }); +}); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.tsx new file mode 100644 index 00000000000..21bc2480e39 --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationEdit.tsx @@ -0,0 +1,124 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { destinationType } from '@linode/api-v4'; +import { + useDestinationQuery, + useUpdateDestinationMutation, +} from '@linode/queries'; +import { Box, CircleProgress, ErrorState } from '@linode/ui'; +import { destinationSchema } from '@linode/validation'; +import { useNavigate, useParams } from '@tanstack/react-router'; +import { enqueueSnackbar } from 'notistack'; +import * as React from 'react'; +import { useEffect } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { DestinationForm } from 'src/features/DataStream/Destinations/DestinationForm/DestinationForm'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import type { LandingHeaderProps } from 'src/components/LandingHeader'; +import type { DestinationFormType } from 'src/features/DataStream/Shared/types'; + +export const DestinationEdit = () => { + const navigate = useNavigate(); + const { destinationId } = useParams({ + from: '/datastream/destinations/$destinationId/edit', + }); + const { mutateAsync: updateDestination } = useUpdateDestinationMutation(); + const { + data: destination, + isLoading, + error, + } = useDestinationQuery(Number(destinationId)); + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/datastream/destinations/edit', + crumbOverrides: [ + { + label: 'DataStream', + linkTo: '/datastream/destinations', + position: 1, + }, + ], + }, + removeCrumbX: 2, + title: 'Edit Destination', + }; + + const form = useForm({ + defaultValues: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + mode: 'onBlur', + resolver: yupResolver(destinationSchema), + }); + + useEffect(() => { + if (destination) { + form.reset({ + ...destination, + }); + } + }, [destination, form]); + + const onSubmit = () => { + const payload = { + id: destinationId, + ...form.getValues(), + }; + + updateDestination(payload) + .then(() => { + navigate({ to: '/datastream/destinations' }); + return enqueueSnackbar( + `Destination ${payload.label} edited successfully`, + { + variant: 'success', + } + ); + }) + .catch((error) => { + return enqueueSnackbar( + getAPIErrorOrDefault( + error, + `There was an issue editing your destination` + )[0].reason, + { + variant: 'error', + } + ); + }); + }; + + return ( + <> + + + {isLoading && ( + + + + )} + {error && ( + + )} + {!isLoading && !error && ( + + + + )} + + ); +}; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationForm.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationForm.tsx new file mode 100644 index 00000000000..c1288dc2c06 --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/DestinationForm.tsx @@ -0,0 +1,101 @@ +import { destinationType } from '@linode/api-v4'; +import { Autocomplete, Box, Button, Paper, TextField } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; +import * as React from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { useFormContext } from 'react-hook-form'; +import { Controller, useWatch } from 'react-hook-form'; + +import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; +import { DestinationLinodeObjectStorageDetailsForm } from 'src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm'; +import { LabelValue } from 'src/features/DataStream/Shared/LabelValue'; +import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; + +import type { + DestinationFormType, + FormMode, +} from 'src/features/DataStream/Shared/types'; + +type DestinationFormProps = { + destinationId?: string; + mode: FormMode; + onSubmit: SubmitHandler; +}; + +export const DestinationForm = (props: DestinationFormProps) => { + const { mode, onSubmit, destinationId } = props; + const theme = useTheme(); + + const { control, handleSubmit } = useFormContext(); + + const selectedDestinationType = useWatch({ + control, + name: 'type', + }); + + return ( + <> + +
+ {destinationId && ( + + )} + ( + { + field.onChange(value); + }} + options={destinationTypeOptions} + value={getDestinationTypeOption(field.value)} + /> + )} + rules={{ required: true }} + /> + ( + { + field.onChange(value); + }} + placeholder="Destination Name..." + value={field.value} + /> + )} + rules={{ required: true }} + /> + {selectedDestinationType === destinationType.LinodeObjectStorage && ( + + )} + +
+ + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/destinationCreateLazyRoute.ts b/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationCreateLazyRoute.ts similarity index 84% rename from packages/manager/src/features/DataStream/Destinations/DestinationCreate/destinationCreateLazyRoute.ts rename to packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationCreateLazyRoute.ts index 7f6ca4650f1..0f044abab07 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/destinationCreateLazyRoute.ts +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationCreateLazyRoute.ts @@ -1,6 +1,6 @@ import { createLazyRoute } from '@tanstack/react-router'; -import { DestinationCreate } from 'src/features/DataStream/Destinations/DestinationCreate/DestinationCreate'; +import { DestinationCreate } from 'src/features/DataStream/Destinations/DestinationForm/DestinationCreate'; export const destinationCreateLazyRoute = createLazyRoute( '/datastream/destinations/create' diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute.ts b/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute.ts new file mode 100644 index 00000000000..6d1ec02cf30 --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { DestinationEdit } from 'src/features/DataStream/Destinations/DestinationForm/DestinationEdit'; + +export const destinationEditLazyRoute = createLazyRoute( + '/datastream/destinations/$destinationId/edit' +)({ + component: DestinationEdit, +}); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationTableRow.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationTableRow.tsx index da462042c57..3f4c0f1ecfc 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationTableRow.tsx +++ b/packages/manager/src/features/DataStream/Destinations/DestinationTableRow.tsx @@ -5,16 +5,18 @@ import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; +import { DestinationActionMenu } from 'src/features/DataStream/Destinations/DestinationActionMenu'; +import type { DestinationHandlers } from './DestinationActionMenu'; import type { Destination } from '@linode/api-v4'; -interface DestinationTableRowProps { +interface DestinationTableRowProps extends DestinationHandlers { destination: Destination; } export const DestinationTableRow = React.memo( (props: DestinationTableRowProps) => { - const { destination } = props; + const { destination, onDelete, onEdit } = props; return ( @@ -31,6 +33,13 @@ export const DestinationTableRow = React.memo( + + + ); } diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx index b7e78477b20..1d16ac50963 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx +++ b/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.test.tsx @@ -1,48 +1,81 @@ -import { waitForElementToBeRemoved } from '@testing-library/react'; +import { screen, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; +import { beforeEach, describe, expect } from 'vitest'; import { destinationFactory } from 'src/factories/datastream'; import { DestinationsLanding } from 'src/features/DataStream/Destinations/DestinationsLanding'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; const loadingTestId = 'circle-progress'; +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(() => vi.fn()), + useDeleteDestinationMutation: vi.fn().mockReturnValue({ + mutateAsync: vi.fn(), + }), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useDeleteDestinationMutation: queryMocks.useDeleteDestinationMutation, + }; +}); + +const destination = destinationFactory.build({ id: 1 }); +const destinations = [destination, ...destinationFactory.buildList(30)]; + describe('Destinations Landing Table', () => { + const renderComponentAndWaitForLoadingComplete = async () => { + renderWithTheme(, { + initialRoute: '/datastream/destinations', + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } + }; + + beforeEach(() => { + mockMatchMedia(); + }); + it('should render destinations landing tab header and table with items PaginationFooter', async () => { server.use( http.get('*/monitor/streams/destinations', () => { - return HttpResponse.json( - makeResourcePage(destinationFactory.buildList(30)) - ); + return HttpResponse.json(makeResourcePage(destinations)); }) ); - - const { getByText, queryByTestId, getAllByTestId, getByPlaceholderText } = - renderWithTheme(, { - initialRoute: '/datastream/destinations', - }); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + await renderComponentAndWaitForLoadingComplete(); // search text input - getByPlaceholderText('Search for a Destination'); + screen.getByPlaceholderText('Search for a Destination'); // button - getByText('Create Destination'); + screen.getByText('Create Destination'); // Table column headers - getByText('Name'); - getByText('Type'); - getByText('ID'); - getByText('Last Modified'); + screen.getByText('Name'); + screen.getByText('Type'); + screen.getByText('ID'); + screen.getByText('Creation Time'); + screen.getByText('Last Modified'); // PaginationFooter - const paginationFooterSelectPageSizeInput = getAllByTestId( + const paginationFooterSelectPageSizeInput = screen.getAllByTestId( 'textfield-input' )[1] as HTMLInputElement; expect(paginationFooterSelectPageSizeInput.value).toBe('Show 25'); @@ -55,18 +88,64 @@ describe('Destinations Landing Table', () => { }) ); - const { getByText, queryByTestId } = renderWithTheme( - , - { - initialRoute: '/datastream/destinations', - } + await renderComponentAndWaitForLoadingComplete(); + + screen.getByText((text) => + text.includes('Create a destination for cloud logs') ); + }); - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + const clickOnActionMenu = async () => { + const actionMenu = screen.getByLabelText( + `Action menu for Destination ${destination.label}` + ); + await userEvent.click(actionMenu); + }; + + const clickOnActionMenuItem = async (itemText: string) => { + await userEvent.click(screen.getByText(itemText)); + }; + + describe('given action menu', () => { + beforeEach(() => { + server.use( + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(destinations)); + }) + ); + }); - getByText((text) => text.includes('Create a destination for cloud logs')); + describe('when Edit clicked', () => { + it('should navigate to edit page', async () => { + const mockNavigate = vi.fn(); + queryMocks.useNavigate.mockReturnValue(mockNavigate); + + await renderComponentAndWaitForLoadingComplete(); + + await clickOnActionMenu(); + await clickOnActionMenuItem('Edit'); + + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/datastream/destinations/1/edit', + }); + }); + }); + + describe('when Delete clicked', () => { + it('should delete destination', async () => { + const mockDeleteDestinationMutation = vi.fn().mockResolvedValue({}); + queryMocks.useDeleteDestinationMutation.mockReturnValue({ + mutateAsync: mockDeleteDestinationMutation, + }); + + await renderComponentAndWaitForLoadingComplete(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Delete'); + + expect(mockDeleteDestinationMutation).toHaveBeenCalledWith({ + id: 1, + }); + }); + }); }); }); diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx index f329d54c893..48ff5497690 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx +++ b/packages/manager/src/features/DataStream/Destinations/DestinationsLanding.tsx @@ -1,11 +1,16 @@ -import { useDestinationsQuery } from '@linode/queries'; +import { + useDeleteDestinationMutation, + useDestinationsQuery, +} from '@linode/queries'; import { CircleProgress, ErrorState, Hidden } from '@linode/ui'; import { TableBody, TableHead, TableRow } from '@mui/material'; import Table from '@mui/material/Table'; import { useNavigate, useSearch } from '@tanstack/react-router'; +import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { TableCell } from 'src/components/TableCell'; import { TableSortCell } from 'src/components/TableSortCell'; import { DESTINATIONS_TABLE_DEFAULT_ORDER, @@ -17,9 +22,14 @@ import { DestinationTableRow } from 'src/features/DataStream/Destinations/Destin import { DataStreamTabHeader } from 'src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import type { Destination } from '@linode/api-v4'; +import type { DestinationHandlers } from 'src/features/DataStream/Destinations/DestinationActionMenu'; export const DestinationsLanding = () => { const navigate = useNavigate(); + const { mutateAsync: deleteDestination } = useDeleteDestinationMutation(); const destinationsUrl = '/datastream/destinations'; const search = useSearch({ from: destinationsUrl, @@ -93,6 +103,37 @@ export const DestinationsLanding = () => { ); } + const handleEdit = ({ id }: Destination) => { + navigate({ to: `/datastream/destinations/${id}/edit` }); + }; + + const handleDelete = ({ id, label }: Destination) => { + deleteDestination({ + id, + }) + .then(() => { + return enqueueSnackbar(`Destination ${label} deleted successfully`, { + variant: 'success', + }); + }) + .catch((error) => { + return enqueueSnackbar( + getAPIErrorOrDefault( + error, + `There was an issue deleting your destination` + )[0].reason, + { + variant: 'error', + } + ); + }); + }; + + const handlers: DestinationHandlers = { + onEdit: handleEdit, + onDelete: handleDelete, + }; + return ( <> { > Last Modified + @@ -155,6 +197,7 @@ export const DestinationsLanding = () => { ))} diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationDetail.tsx b/packages/manager/src/features/DataStream/Shared/LabelValue.tsx similarity index 52% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationDetail.tsx rename to packages/manager/src/features/DataStream/Shared/LabelValue.tsx index 5e4fb9a05a6..ddb57224006 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationDetail.tsx +++ b/packages/manager/src/features/DataStream/Shared/LabelValue.tsx @@ -2,30 +2,37 @@ import { Box, Typography } from '@linode/ui'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; -type DestinationDetailProps = { +type LabelValueProps = { + compact?: boolean; + 'data-testid'?: string; label: string; value: string; }; -export const DestinationDetail = (props: DestinationDetailProps) => { - const { label, value } = props; +export const LabelValue = (props: LabelValueProps) => { + const { compact = false, label, value, 'data-testid': dataTestId } = props; const theme = useTheme(); return ( - - {label}: - {value} + + + {label}: + + {value} ); }; -const StyledLabel = styled(Typography, { - label: 'StyledLabel', -})(({ theme }) => ({ - font: theme.font.bold, - width: 160, -})); - const StyledValue = styled(Box, { label: 'StyledValue', })(({ theme }) => ({ diff --git a/packages/manager/src/features/DataStream/Shared/types.ts b/packages/manager/src/features/DataStream/Shared/types.ts index 57d8fb68233..cd5392286bd 100644 --- a/packages/manager/src/features/DataStream/Shared/types.ts +++ b/packages/manager/src/features/DataStream/Shared/types.ts @@ -1,18 +1,18 @@ -import { destinationType, streamStatus } from '@linode/api-v4'; +import { destinationType, streamStatus, streamType } from '@linode/api-v4'; -import type { CreateDestinationPayload } from '@linode/api-v4'; +import type { + CreateDestinationPayload, + UpdateDestinationPayload, +} from '@linode/api-v4'; -export interface DestinationTypeOption { - label: string; - value: string; -} +export type FormMode = 'create' | 'edit'; export interface LabelValueOption { label: string; value: string; } -export const destinationTypeOptions: DestinationTypeOption[] = [ +export const destinationTypeOptions: LabelValueOption[] = [ { value: destinationType.CustomHttps, label: 'Custom HTTPS', @@ -23,7 +23,18 @@ export const destinationTypeOptions: DestinationTypeOption[] = [ }, ]; -export const streamStatusOptions = [ +export const streamTypeOptions: LabelValueOption[] = [ + { + value: streamType.AuditLogs, + label: 'Audit Logs', + }, + { + value: streamType.LKEAuditLogs, + label: 'Kubernetes Audit Logs', + }, +]; + +export const streamStatusOptions: LabelValueOption[] = [ { value: streamStatus.Active, label: 'Enabled', @@ -34,4 +45,6 @@ export const streamStatusOptions = [ }, ]; -export type CreateDestinationForm = CreateDestinationPayload; +export type DestinationFormType = + | CreateDestinationPayload + | UpdateDestinationPayload; diff --git a/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx new file mode 100644 index 00000000000..806aa9549e0 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx @@ -0,0 +1,52 @@ +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import * as React from 'react'; + +import { streamFactory } from 'src/factories/datastream'; +import { StreamActionMenu } from 'src/features/DataStream/Streams/StreamActionMenu'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import type { StreamStatus } from '@linode/api-v4'; + +const fakeHandler = vi.fn(); + +describe('StreamActionMenu', () => { + const renderComponent = (status: StreamStatus) => { + renderWithTheme( + + ); + }; + + describe('when stream is active', () => { + it('should include proper Stream actions', async () => { + renderComponent('active'); + + const actionMenuButton = screen.queryByLabelText(/^Action menu for/)!; + + await userEvent.click(actionMenuButton); + + for (const action of ['Edit', 'Disable', 'Delete']) { + expect(screen.getByText(action)).toBeVisible(); + } + }); + }); + + describe('when stream is inactive', () => { + it('should include proper Stream actions', async () => { + renderComponent('inactive'); + + const actionMenuButton = screen.queryByLabelText(/^Action menu for/)!; + + await userEvent.click(actionMenuButton); + + for (const action of ['Edit', 'Enable', 'Delete']) { + expect(screen.getByText(action)).toBeVisible(); + } + }); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx new file mode 100644 index 00000000000..a5d061c54cf --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx @@ -0,0 +1,46 @@ +import { type Stream, streamStatus } from '@linode/api-v4'; +import * as React from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +export interface Handlers { + onDelete: (stream: Stream) => void; + onDisableOrEnable: (stream: Stream) => void; + onEdit: (stream: Stream) => void; +} + +interface StreamActionMenuProps extends Handlers { + stream: Stream; +} + +export const StreamActionMenu = (props: StreamActionMenuProps) => { + const { stream, onDelete, onDisableOrEnable, onEdit } = props; + + const menuActions = [ + { + onClick: () => { + onEdit(stream); + }, + title: 'Edit', + }, + { + onClick: () => { + onDisableOrEnable(stream); + }, + title: stream.status === streamStatus.Active ? 'Disable' : 'Enable', + }, + { + onClick: () => { + onDelete(stream); + }, + title: 'Delete', + }, + ]; + + return ( + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx deleted file mode 100644 index cc366229d01..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { destinationType } from '@linode/api-v4'; -import { screen } from '@testing-library/react'; -import React from 'react'; -import { describe, expect } from 'vitest'; - -import { StreamCreateSubmitBar } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar'; -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; - -describe('StreamCreateSubmitBar', () => { - const createStream = () => {}; - - const renderComponent = () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - destination: { type: destinationType.LinodeObjectStorage }, - }, - }, - }); - }; - - it('should render checkout bar with enabled checkout button', async () => { - renderComponent(); - const submitButton = screen.getByText('Create Stream'); - - expect(submitButton).toBeEnabled(); - }); - - it('should render Delivery summary with destination type', () => { - renderComponent(); - const deliveryTitle = screen.getByText('Delivery'); - const deliveryType = screen.getByText('Linode Object Storage'); - - expect(deliveryTitle).toBeInTheDocument(); - expect(deliveryType).toBeInTheDocument(); - }); -}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx deleted file mode 100644 index 275a4dfb64c..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useRegionsQuery } from '@linode/queries'; -import React from 'react'; - -import { DestinationDetail } from 'src/features/DataStream/Streams/StreamCreate/Delivery/DestinationDetail'; - -import type { LinodeObjectStorageDetails } from '@linode/api-v4'; - -export const DestinationLinodeObjectStorageDetailsSummary = ( - props: LinodeObjectStorageDetails -) => { - const { bucket_name, host, region, path } = props; - const { data: regions } = useRegionsQuery(); - - const regionValue = regions?.find(({ id }) => id === region)?.label || region; - - return ( - <> - - - - - - - - ); -}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx deleted file mode 100644 index 3e689d3025a..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { yupResolver } from '@hookform/resolvers/yup'; -import { destinationType, streamType } from '@linode/api-v4'; -import { useCreateStreamMutation } from '@linode/queries'; -import { omitProps, Stack } from '@linode/ui'; -import { createStreamAndDestinationFormSchema } from '@linode/validation'; -import Grid from '@mui/material/Grid'; -import { useNavigate } from '@tanstack/react-router'; -import * as React from 'react'; -import { FormProvider, useForm, useWatch } from 'react-hook-form'; - -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { LandingHeader } from 'src/components/LandingHeader'; -import { StreamCreateSubmitBar } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar'; -import { StreamCreateDelivery } from 'src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery'; -import { sendCreateStreamEvent } from 'src/utilities/analytics/customEventAnalytics'; - -import { StreamCreateClusters } from './StreamCreateClusters'; -import { StreamCreateGeneralInfo } from './StreamCreateGeneralInfo'; - -import type { CreateStreamPayload } from '@linode/api-v4'; -import type { LandingHeaderProps } from 'src/components/LandingHeader'; -import type { - CreateStreamAndDestinationForm, - CreateStreamForm, -} from 'src/features/DataStream/Streams/StreamCreate/types'; - -export const StreamCreate = () => { - const { mutateAsync: createStream } = useCreateStreamMutation(); - const navigate = useNavigate(); - - const form = useForm({ - defaultValues: { - stream: { - type: streamType.AuditLogs, - details: {}, - }, - destination: { - type: destinationType.LinodeObjectStorage, - details: { - region: '', - }, - }, - }, - mode: 'onBlur', - resolver: yupResolver(createStreamAndDestinationFormSchema), - }); - - const { control, handleSubmit } = form; - const selectedStreamType = useWatch({ - control, - name: 'stream.type', - }); - - const landingHeaderProps: LandingHeaderProps = { - breadcrumbProps: { - pathname: '/datastream/streams/create', - crumbOverrides: [ - { - label: 'DataStream', - linkTo: '/datastream/streams', - position: 1, - }, - ], - }, - removeCrumbX: 2, - title: 'Create Stream', - }; - - const onSubmit = () => { - const { - stream: { label, type, destinations, details }, - } = form.getValues(); - const payload: CreateStreamForm = { - label, - type, - destinations, - details, - }; - - if (type === streamType.LKEAuditLogs && details) { - if (details.is_auto_add_all_clusters_enabled) { - payload.details = omitProps(details, ['cluster_ids']); - } else { - payload.details = omitProps(details, [ - 'is_auto_add_all_clusters_enabled', - ]); - } - } - - createStream(payload as CreateStreamPayload).then(() => { - sendCreateStreamEvent('Stream Create Page'); - navigate({ to: '/datastream/streams' }); - }); - }; - - return ( - <> - - - -
- - - - - {selectedStreamType === streamType.LKEAuditLogs && ( - - )} - - - - - - - - -
- - ); -}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.test.tsx deleted file mode 100644 index dacb024ab50..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { streamType } from '@linode/api-v4'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; - -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; - -import { StreamCreateGeneralInfo } from './StreamCreateGeneralInfo'; - -describe('StreamCreateGeneralInfo', () => { - it('should render Name input and allow to type text', async () => { - renderWithThemeAndHookFormContext({ - component: , - }); - - // Type test value inside the input - const nameInput = screen.getByPlaceholderText('Stream name...'); - await userEvent.type(nameInput, 'Test'); - - await waitFor(() => { - expect(nameInput.getAttribute('value')).toEqual('Test'); - }); - }); - - it('should render Stream type input and allow to select different options', async () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - stream: { - type: streamType.AuditLogs, - }, - }, - }, - }); - - const streamTypesAutocomplete = screen.getByRole('combobox'); - - expect(streamTypesAutocomplete).toHaveValue('Audit Logs'); - - // Open the dropdown - await userEvent.click(streamTypesAutocomplete); - - // Select the "Kubernetes Audit Logs" option - const kubernetesAuditLogs = await screen.findByText( - 'Kubernetes Audit Logs' - ); - await userEvent.click(kubernetesAuditLogs); - - await waitFor(() => { - expect(streamTypesAutocomplete).toHaveValue('Kubernetes Audit Logs'); - }); - }); -}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/types.ts b/packages/manager/src/features/DataStream/Streams/StreamCreate/types.ts deleted file mode 100644 index c89de4c8bbf..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { CreateStreamPayload } from '@linode/api-v4'; -import type { CreateDestinationForm } from 'src/features/DataStream/Shared/types'; - -export interface CreateStreamForm - extends Omit { - destinations: (number | undefined)[]; -} - -export interface CreateStreamAndDestinationForm { - destination: CreateDestinationForm; - stream: CreateStreamForm; -} diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles.ts similarity index 100% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles.ts rename to packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles.ts diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx similarity index 85% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.test.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx index 4665e078caa..7eefd4a16b1 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx @@ -5,20 +5,20 @@ import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { describe, expect } from 'vitest'; -import { StreamCreateCheckoutBar } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar'; -import { StreamCreateGeneralInfo } from 'src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo'; +import { StreamFormCheckoutBar } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar'; +import { StreamFormGeneralInfo } from 'src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo'; import { renderWithTheme, renderWithThemeAndHookFormContext, } from 'src/utilities/testHelpers'; -describe('StreamCreateCheckoutBar', () => { +describe('StreamFormCheckoutBar', () => { const getDeliveryPriceContext = () => screen.getByText(/\/unit/i).textContent; const createStream = () => {}; const renderComponent = () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { destination: { @@ -61,8 +61,8 @@ describe('StreamCreateCheckoutBar', () => { return (
- - + +
); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx similarity index 86% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx index c48bcaa3c3e..cc4dab35826 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx @@ -5,17 +5,17 @@ import { useFormContext, useWatch } from 'react-hook-form'; import { CheckoutBar } from 'src/components/CheckoutBar/CheckoutBar'; import { displayPrice } from 'src/components/DisplayPrice'; import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; -import { StyledHeader } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles'; +import { StyledHeader } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles'; -import type { CreateStreamAndDestinationForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; export interface Props { createStream: () => void; } -export const StreamCreateCheckoutBar = (props: Props) => { +export const StreamFormCheckoutBar = (props: Props) => { const { createStream } = props; - const { control } = useFormContext(); + const { control } = useFormContext(); const destinationType = useWatch({ control, name: 'destination.type' }); const formValues = useWatch({ control, diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx new file mode 100644 index 00000000000..df6554a590b --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx @@ -0,0 +1,60 @@ +import { destinationType, streamType } from '@linode/api-v4'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { StreamFormSubmitBar } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import type { FormMode } from 'src/features/DataStream/Shared/types'; + +describe('StreamFormSubmitBar', () => { + const createStream = () => {}; + + const renderComponent = (mode: FormMode) => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + type: streamType.AuditLogs, + details: {}, + }, + destination: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + }, + }, + }); + }; + + describe('when in create mode', () => { + it('should render checkout bar with enabled Create Stream button', async () => { + renderComponent('create'); + const submitButton = screen.getByText('Create Stream'); + + expect(submitButton).toBeEnabled(); + }); + }); + + describe('when in edit mode', () => { + it('should render checkout bar with enabled Edit Stream button', async () => { + renderComponent('edit'); + const submitButton = screen.getByText('Edit Stream'); + + expect(submitButton).toBeEnabled(); + }); + }); + + it('should render Delivery summary with destination type', () => { + renderComponent('create'); + const deliveryTitle = screen.getByText('Delivery'); + const deliveryType = screen.getByText('Linode Object Storage'); + + expect(deliveryTitle).toBeInTheDocument(); + expect(deliveryType).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.tsx similarity index 62% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.tsx index 9c009208eb8..74d5eb83e2f 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.tsx @@ -2,19 +2,24 @@ import { Box, Button, Divider, Paper, Stack, Typography } from '@linode/ui'; import * as React from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; -import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; -import { StyledHeader } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles'; +import { + getDestinationTypeOption, + isFormInEditMode, +} from 'src/features/DataStream/dataStreamUtils'; +import { StyledHeader } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles'; -import type { CreateStreamAndDestinationForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { FormMode } from 'src/features/DataStream/Shared/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; -type StreamCreateSidebarProps = { - createStream: () => void; +type StreamFormSubmitBarProps = { + mode: FormMode; + onSubmit: () => void; }; -export const StreamCreateSubmitBar = (props: StreamCreateSidebarProps) => { - const { createStream } = props; +export const StreamFormSubmitBar = (props: StreamFormSubmitBarProps) => { + const { onSubmit, mode } = props; - const { control } = useFormContext(); + const { control } = useFormContext(); const destinationType = useWatch({ control, name: 'destination.type' }); return ( @@ -32,7 +37,7 @@ export const StreamCreateSubmitBar = (props: StreamCreateSidebarProps) => {
diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx similarity index 78% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx index cff5103b0ed..b66fe12a369 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx @@ -1,5 +1,5 @@ import { regionFactory } from '@linode/utilities'; -import { screen, waitFor, within } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import React from 'react'; import { makeResourcePage } from 'src/mocks/serverHandlers'; @@ -33,26 +33,23 @@ describe('DestinationLinodeObjectStorageDetailsSummary', () => { ); + // Host: expect(screen.getByText('test host')).toBeVisible(); - + // Bucket: expect(screen.getByText('test bucket')).toBeVisible(); - + // Log Path: expect(screen.getByText('test/path')).toBeVisible(); - + // Region: await waitFor(() => { expect(screen.getByText('US, Chicago, IL')).toBeVisible(); }); - - expect( - within(screen.getByText('Access Key ID:').closest('div')!).getByText( - '*****************' - ) - ).toBeInTheDocument(); - - expect( - within(screen.getByText('Secret Access Key:').closest('div')!).getByText( - '*****************' - ) - ).toBeInTheDocument(); + // Access Key ID: + expect(screen.getByTestId('access-key-id')).toHaveTextContent( + '*****************' + ); + // Secret Access Key: + expect(screen.getByTestId('secret-access-key')).toHaveTextContent( + '*****************' + ); }); }); diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx new file mode 100644 index 00000000000..f031a4997ef --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx @@ -0,0 +1,34 @@ +import { useRegionsQuery } from '@linode/queries'; +import React from 'react'; + +import { LabelValue } from 'src/features/DataStream/Shared/LabelValue'; + +import type { LinodeObjectStorageDetails } from '@linode/api-v4'; + +export const DestinationLinodeObjectStorageDetailsSummary = ( + props: LinodeObjectStorageDetails +) => { + const { bucket_name, host, region, path } = props; + const { data: regions } = useRegionsQuery(); + + const regionValue = regions?.find(({ id }) => id === region)?.label || region; + + return ( + <> + + + + + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx similarity index 96% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.test.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx index 5e58470ad1a..71e0ed84dca 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx @@ -9,13 +9,13 @@ import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; -import { StreamCreateDelivery } from './StreamCreateDelivery'; +import { StreamFormDelivery } from './StreamFormDelivery'; const loadingTestId = 'circle-progress'; const mockDestinations = destinationFactory.buildList(5); -describe('StreamCreateDelivery', () => { +describe('StreamFormDelivery', () => { beforeEach(async () => { server.use( http.get('*/monitor/streams/destinations', () => { @@ -26,7 +26,7 @@ describe('StreamCreateDelivery', () => { it('should render disabled Destination Type input with proper selection', async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { destination: { @@ -50,7 +50,7 @@ describe('StreamCreateDelivery', () => { it('should render Destination Name input and allow to select an existing option', async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { destination: { @@ -81,7 +81,7 @@ describe('StreamCreateDelivery', () => { const renderComponentAndAddNewDestinationName = async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { destination: { diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx similarity index 85% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx index 223a7bb8fb7..d4d7cbf4f24 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -16,13 +16,13 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; import { DestinationLinodeObjectStorageDetailsForm } from 'src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm'; import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; -import { DestinationLinodeObjectStorageDetailsSummary } from 'src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary'; +import { DestinationLinodeObjectStorageDetailsSummary } from 'src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary'; import type { DestinationType, LinodeObjectStorageDetails, } from '@linode/api-v4'; -import type { CreateStreamAndDestinationForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; type DestinationName = { create?: boolean; @@ -40,15 +40,12 @@ const controlPaths = { region: 'destination.details.region', }; -export const StreamCreateDelivery = () => { +export const StreamFormDelivery = () => { const theme = useTheme(); - const { control, setValue } = - useFormContext(); + const { control, setValue } = useFormContext(); const [showDestinationForm, setShowDestinationForm] = React.useState(false); - const [showExistingDestination, setShowExistingDestination] = - React.useState(false); const { data: destinations, isLoading, error } = useAllDestinationsQuery(); const destinationNameOptions: DestinationName[] = (destinations || []).map( @@ -79,7 +76,7 @@ export const StreamCreateDelivery = () => { render={({ field, fieldState }) => ( { label="Destination Name" onBlur={field.onBlur} onChange={(_, newValue) => { - const selectedExistingDestination = !!( - newValue?.label && newValue?.id + setValue( + 'stream.destinations', + newValue?.id ? [newValue?.id] : [] ); - if (selectedExistingDestination) { - setValue('stream.destinations', [newValue?.id as number]); - } field.onChange(newValue?.label || newValue); - setValue('stream.destinations', [newValue?.id as number]); setShowDestinationForm(!!newValue?.create); - setShowExistingDestination(selectedExistingDestination); }} options={destinationNameOptions.filter( ({ type }) => type === selectedDestinationType @@ -158,7 +151,7 @@ export const StreamCreateDelivery = () => { controlPaths={controlPaths} /> )} - {showExistingDestination && ( + {!!selectedDestinations?.length && ( id === selectedDestinations[0] diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx new file mode 100644 index 00000000000..c5fa4ee0007 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx @@ -0,0 +1,86 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { destinationType, streamType } from '@linode/api-v4'; +import { useCreateStreamMutation } from '@linode/queries'; +import { streamAndDestinationFormSchema } from '@linode/validation'; +import { useNavigate } from '@tanstack/react-router'; +import * as React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { + LandingHeader, + type LandingHeaderProps, +} from 'src/components/LandingHeader'; +import { getStreamPayloadDetails } from 'src/features/DataStream/dataStreamUtils'; +import { StreamForm } from 'src/features/DataStream/Streams/StreamForm/StreamForm'; +import { sendCreateStreamEvent } from 'src/utilities/analytics/customEventAnalytics'; + +import type { CreateStreamPayload } from '@linode/api-v4'; +import type { + StreamAndDestinationFormType, + StreamFormType, +} from 'src/features/DataStream/Streams/StreamForm/types'; + +export const StreamCreate = () => { + const { mutateAsync: createStream } = useCreateStreamMutation(); + const navigate = useNavigate(); + + const form = useForm({ + defaultValues: { + stream: { + type: streamType.AuditLogs, + details: {}, + }, + destination: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + }, + mode: 'onBlur', + resolver: yupResolver(streamAndDestinationFormSchema), + }); + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/datastream/streams/create', + crumbOverrides: [ + { + label: 'DataStream', + linkTo: '/datastream/streams', + position: 1, + }, + ], + }, + removeCrumbX: 2, + title: 'Create Stream', + }; + + const onSubmit = () => { + const { + stream: { label, type, destinations, details }, + } = form.getValues(); + const payload: StreamFormType = { + label, + type, + destinations, + details: getStreamPayloadDetails(type, details), + }; + + createStream(payload as CreateStreamPayload).then(() => { + sendCreateStreamEvent('Stream Create Page'); + navigate({ to: '/datastream/streams' }); + }); + }; + + return ( + <> + + + + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx new file mode 100644 index 00000000000..41699fa8205 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx @@ -0,0 +1,84 @@ +import { + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import React from 'react'; +import { describe } from 'vitest'; + +import { destinationFactory, streamFactory } from 'src/factories/datastream'; +import { StreamEdit } from 'src/features/DataStream/Streams/StreamForm/StreamEdit'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +const loadingTestId = 'circle-progress'; +const streamId = 123; +const mockDestinations = [destinationFactory.build({ id: 1 })]; +const mockStream = streamFactory.build({ + id: streamId, + label: `Data Stream ${streamId}`, + destinations: mockDestinations, +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: vi.fn().mockReturnValue({ streamId: 123 }), + }; +}); + +describe('StreamEdit', () => { + const assertInputHasValue = (inputLabel: string, inputValue: string) => { + expect(screen.getByLabelText(inputLabel)).toHaveValue(inputValue); + }; + + it('should render edited stream when stream fetched properly', async () => { + server.use( + http.get(`*/monitor/streams/${streamId}`, () => { + return HttpResponse.json(mockStream); + }), + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(mockDestinations)); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } + + await waitFor(() => { + assertInputHasValue('Name', 'Data Stream 123'); + }); + assertInputHasValue('Stream Type', 'Audit Logs'); + await waitFor(() => { + assertInputHasValue('Destination Type', 'Linode Object Storage'); + }); + assertInputHasValue('Destination Name', 'Destination 1'); + + // Host: + expect(screen.getByText('3000')).toBeVisible(); + // Bucket: + expect(screen.getByText('Bucket Name')).toBeVisible(); + // Region: + await waitFor(() => { + expect(screen.getByText('US, Chicago, IL')).toBeVisible(); + }); + // Access Key ID: + expect(screen.getByTestId('access-key-id')).toHaveTextContent( + '*****************' + ); + // Secret Access Key: + expect(screen.getByTestId('secret-access-key')).toHaveTextContent( + '*****************' + ); + // Log Path: + expect(screen.getByText('file')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx new file mode 100644 index 00000000000..9a3f6bf846e --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx @@ -0,0 +1,131 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { destinationType, streamType } from '@linode/api-v4'; +import { useStreamQuery, useUpdateStreamMutation } from '@linode/queries'; +import { Box, CircleProgress, ErrorState } from '@linode/ui'; +import { streamAndDestinationFormSchema } from '@linode/validation'; +import { useNavigate, useParams } from '@tanstack/react-router'; +import * as React from 'react'; +import { useEffect } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { + LandingHeader, + type LandingHeaderProps, +} from 'src/components/LandingHeader'; +import { getStreamPayloadDetails } from 'src/features/DataStream/dataStreamUtils'; +import { StreamForm } from 'src/features/DataStream/Streams/StreamForm/StreamForm'; + +import type { UpdateStreamPayloadWithId } from '@linode/api-v4'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; + +export const StreamEdit = () => { + const navigate = useNavigate(); + const { streamId } = useParams({ + from: '/datastream/streams/$streamId/edit', + }); + const { mutateAsync: updateStream } = useUpdateStreamMutation(); + const { data: stream, isLoading, error } = useStreamQuery(Number(streamId)); + + const form = useForm({ + defaultValues: { + stream: { + type: streamType.AuditLogs, + details: {}, + }, + destination: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + }, + mode: 'onBlur', + resolver: yupResolver(streamAndDestinationFormSchema), + }); + + useEffect(() => { + if (stream) { + const details = + Object.keys(stream.details).length > 0 + ? { + is_auto_add_all_clusters_enabled: false, + cluster_ids: [], + ...stream.details, + } + : {}; + + form.reset({ + stream: { + ...stream, + details, + destinations: stream.destinations.map(({ id }) => id), + }, + destination: stream.destinations?.[0], + }); + } + }, [stream, form]); + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/datastream/streams/edit', + crumbOverrides: [ + { + label: 'DataStream', + linkTo: '/datastream/streams', + position: 1, + }, + ], + }, + removeCrumbX: 2, + title: 'Edit Stream', + }; + + const onSubmit = () => { + const { + stream: { label, type, destinations, details }, + } = form.getValues(); + + // TODO: DPS-33120 create destination call if new destination created + + const payload: UpdateStreamPayloadWithId = { + id: stream!.id, + label, + type: stream!.type, + status: stream!.status, + destinations: destinations as number[], // TODO: remove type assertion after DPS-33120 + details: getStreamPayloadDetails(type, details), + }; + + updateStream(payload).then(() => { + navigate({ to: '/datastream/streams' }); + }); + }; + + return ( + <> + + + {isLoading && ( + + + + )} + {error && ( + + )} + {!isLoading && !error && ( + + + + )} + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx new file mode 100644 index 00000000000..9ec617576de --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx @@ -0,0 +1,52 @@ +import { streamType } from '@linode/api-v4'; +import { Stack } from '@linode/ui'; +import Grid from '@mui/material/Grid'; +import * as React from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import { StreamFormSubmitBar } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar'; +import { StreamFormDelivery } from 'src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery'; + +import { StreamFormClusters } from './StreamFormClusters'; +import { StreamFormGeneralInfo } from './StreamFormGeneralInfo'; + +import type { FormMode } from 'src/features/DataStream/Shared/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; + +type StreamFormProps = { + mode: FormMode; + onSubmit: SubmitHandler; + streamId?: string; +}; + +export const StreamForm = (props: StreamFormProps) => { + const { mode, onSubmit, streamId } = props; + + const { control, handleSubmit } = + useFormContext(); + + const selectedStreamType = useWatch({ + control, + name: 'stream.type', + }); + + return ( +
+ + + + + {selectedStreamType === streamType.LKEAuditLogs && ( + + )} + + + + + + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.test.tsx similarity index 96% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.test.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.test.tsx index 9bb723fba9e..6ed13dd08f6 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.test.tsx @@ -5,15 +5,18 @@ import { describe, expect, it } from 'vitest'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; -import { StreamCreateClusters } from './StreamCreateClusters'; +import { StreamFormClusters } from './StreamFormClusters'; const renderComponentWithoutSelectedClusters = () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { stream: { - details: {}, + details: { + cluster_ids: [], + is_auto_add_all_clusters_enabled: false, + }, }, }, }, @@ -49,7 +52,7 @@ const expectCheckboxStateToBe = ( } }; -describe('StreamCreateClusters', () => { +describe('StreamFormClusters', () => { it('should render all clusters in table', async () => { renderComponentWithoutSelectedClusters(); @@ -159,12 +162,13 @@ describe('StreamCreateClusters', () => { describe('when form has already selected clusters', () => { it('should render table with properly selected clusters', async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { stream: { details: { cluster_ids: [3], + is_auto_add_all_clusters_enabled: false, }, }, }, diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.tsx similarity index 90% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.tsx index af894c9a200..b04eb9c9753 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.tsx @@ -1,5 +1,5 @@ import { Box, Checkbox, Notice, Paper, Typography } from '@linode/ui'; -import { usePrevious } from '@linode/utilities'; +import { isNotNullOrUndefined, usePrevious } from '@linode/utilities'; import React, { useEffect, useState } from 'react'; import type { ControllerRenderProps } from 'react-hook-form'; import { useWatch } from 'react-hook-form'; @@ -14,9 +14,9 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; -import { clusters } from 'src/features/DataStream/Streams/StreamCreate/StreamCreateClustersData'; +import { clusters } from 'src/features/DataStream/Streams/StreamForm/StreamFormClustersData'; -import type { CreateStreamAndDestinationForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; // TODO: remove type after fetching the clusters will be done export type Cluster = { @@ -28,9 +28,9 @@ export type Cluster = { type OrderByKeys = 'label' | 'logGeneration' | 'region'; -export const StreamCreateClusters = () => { +export const StreamFormClusters = () => { const { control, setValue, formState } = - useFormContext(); + useFormContext(); const [order, setOrder] = useState<'asc' | 'desc'>('asc'); const [orderBy, setOrderBy] = useState('label'); @@ -40,16 +40,31 @@ export const StreamCreateClusters = () => { .filter(({ logGeneration }) => logGeneration) .map(({ id }) => id); - const isAutoAddAllClustersEnabled = useWatch({ + const [isAutoAddAllClustersEnabled, clusterIds] = useWatch({ control, - name: 'stream.details.is_auto_add_all_clusters_enabled', + name: [ + 'stream.details.is_auto_add_all_clusters_enabled', + 'stream.details.cluster_ids', + ], }); const previousIsAutoAddAllClustersEnabled = usePrevious( isAutoAddAllClustersEnabled ); useEffect(() => { - if (isAutoAddAllClustersEnabled !== previousIsAutoAddAllClustersEnabled) { + setValue( + 'stream.details.cluster_ids', + isAutoAddAllClustersEnabled + ? idsWithLogGenerationEnabled + : clusterIds || [] + ); + }, []); + + useEffect(() => { + if ( + isNotNullOrUndefined(previousIsAutoAddAllClustersEnabled) && + isAutoAddAllClustersEnabled !== previousIsAutoAddAllClustersEnabled + ) { setValue( 'stream.details.cluster_ids', isAutoAddAllClustersEnabled ? idsWithLogGenerationEnabled : [] @@ -73,7 +88,7 @@ export const StreamCreateClusters = () => { const getTableContent = ( field: ControllerRenderProps< - CreateStreamAndDestinationForm, + StreamAndDestinationFormType, 'stream.details.cluster_ids' > ) => { diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClustersData.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClustersData.ts similarity index 92% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClustersData.ts rename to packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClustersData.ts index baaf487c7dd..246a5b189e6 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClustersData.ts +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClustersData.ts @@ -1,4 +1,4 @@ -import type { Cluster } from 'src/features/DataStream/Streams/StreamCreate/StreamCreateClusters'; +import type { Cluster } from 'src/features/DataStream/Streams/StreamForm/StreamFormClusters'; export const clusters: Cluster[] = [ { diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx new file mode 100644 index 00000000000..aeea11d63e9 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx @@ -0,0 +1,101 @@ +import { streamType } from '@linode/api-v4'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { StreamFormGeneralInfo } from './StreamFormGeneralInfo'; + +describe('StreamFormGeneralInfo', () => { + describe('when in create mode', () => { + it('should render Name input and allow to type text', async () => { + renderWithThemeAndHookFormContext({ + component: , + }); + + // Type test value inside the input + const nameInput = screen.getByPlaceholderText('Stream name...'); + await userEvent.type(nameInput, 'Test'); + + await waitFor(() => { + expect(nameInput.getAttribute('value')).toEqual('Test'); + }); + }); + + it('should render Stream type input and allow to select different options', async () => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + type: streamType.AuditLogs, + }, + }, + }, + }); + + const streamTypesAutocomplete = screen.getByRole('combobox'); + + expect(streamTypesAutocomplete).toHaveValue('Audit Logs'); + + // Open the dropdown + await userEvent.click(streamTypesAutocomplete); + + // Select the "Kubernetes Audit Logs" option + const kubernetesAuditLogs = await screen.findByText( + 'Kubernetes Audit Logs' + ); + await userEvent.click(kubernetesAuditLogs); + + await waitFor(() => { + expect(streamTypesAutocomplete).toHaveValue('Kubernetes Audit Logs'); + }); + }); + }); + + describe('when in edit mode and with streamId prop', () => { + const streamId = '123'; + it('should render ID', () => { + renderWithThemeAndHookFormContext({ + component: , + }); + + // ID: + expect(screen.getByText(streamId)).toBeVisible(); + }); + + it('should render Name input and allow to type text', async () => { + renderWithThemeAndHookFormContext({ + component: , + }); + + // Type test value inside the input + const nameInput = screen.getByPlaceholderText('Stream name...'); + await userEvent.type(nameInput, 'Test'); + + await waitFor(() => { + expect(nameInput.getAttribute('value')).toEqual('Test'); + }); + }); + + it('should render disabled Stream type input', async () => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + type: streamType.AuditLogs, + }, + }, + }, + }); + + const streamTypesAutocomplete = screen.getByRole('combobox'); + + expect(streamTypesAutocomplete).toBeDisabled(); + expect(streamTypesAutocomplete).toHaveValue('Audit Logs'); + }); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.tsx similarity index 65% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.tsx index e73ec41fe91..8ade80467c4 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.tsx @@ -3,22 +3,25 @@ import { Autocomplete, Paper, TextField, Typography } from '@linode/ui'; import React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; -import { type CreateStreamAndDestinationForm } from './types'; +import { + getStreamTypeOption, + isFormInEditMode, +} from 'src/features/DataStream/dataStreamUtils'; +import { LabelValue } from 'src/features/DataStream/Shared/LabelValue'; +import { streamTypeOptions } from 'src/features/DataStream/Shared/types'; -export const StreamCreateGeneralInfo = () => { - const { control, setValue } = - useFormContext(); +import type { StreamAndDestinationFormType } from './types'; +import type { FormMode } from 'src/features/DataStream/Shared/types'; - const streamTypeOptions = [ - { - value: streamType.AuditLogs, - label: 'Audit Logs', - }, - { - value: streamType.LKEAuditLogs, - label: 'Kubernetes Audit Logs', - }, - ]; +type StreamFormGeneralInfoProps = { + mode: FormMode; + streamId?: string; +}; + +export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { + const { mode, streamId } = props; + + const { control, setValue } = useFormContext(); const updateStreamDetails = (value: string) => { if (value === streamType.LKEAuditLogs) { @@ -31,6 +34,7 @@ export const StreamCreateGeneralInfo = () => { return ( General Information + {streamId && } { render={({ field, fieldState }) => ( { updateStreamDetails(value); }} options={streamTypeOptions} - value={streamTypeOptions.find(({ value }) => value === field.value)} + value={getStreamTypeOption(field.value)} /> )} rules={{ required: true }} diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/streamCreateLazyRoute.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute.ts similarity index 90% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/streamCreateLazyRoute.ts rename to packages/manager/src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute.ts index 0ec5b500ee8..eda795aea08 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/streamCreateLazyRoute.ts +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute.ts @@ -1,6 +1,6 @@ import { createLazyRoute } from '@tanstack/react-router'; -import { StreamCreate } from 'src/features/DataStream/Streams/StreamCreate/StreamCreate'; +import { StreamCreate } from 'src/features/DataStream/Streams/StreamForm/StreamCreate'; export const streamCreateLazyRoute = createLazyRoute( '/datastream/streams/create' diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts new file mode 100644 index 00000000000..a9ad91972cb --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { StreamEdit } from 'src/features/DataStream/Streams/StreamForm/StreamEdit'; + +export const streamEditLazyRoute = createLazyRoute( + '/datastream/streams/$streamId/edit' +)({ + component: StreamEdit, +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/types.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/types.ts new file mode 100644 index 00000000000..f028763eb85 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/types.ts @@ -0,0 +1,12 @@ +import type { CreateStreamPayload } from '@linode/api-v4'; +import type { DestinationFormType } from 'src/features/DataStream/Shared/types'; + +export interface StreamFormType + extends Omit { + destinations: (number | undefined)[]; +} + +export interface StreamAndDestinationFormType { + destination: DestinationFormType; + stream: StreamFormType; +} diff --git a/packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx new file mode 100644 index 00000000000..025fa1636d6 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx @@ -0,0 +1,54 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { streamFactory } from 'src/factories/datastream'; +import { StreamTableRow } from 'src/features/DataStream/Streams/StreamTableRow'; +import { + mockMatchMedia, + renderWithTheme, + wrapWithTableBody, +} from 'src/utilities/testHelpers'; + +const fakeHandler = vi.fn(); + +describe('StreamTableRow', () => { + const stream = { ...streamFactory.build(), id: 1 }; + + it('should render a stream row', async () => { + mockMatchMedia(); + renderWithTheme( + wrapWithTableBody( + + ) + ); + + // Name: + screen.getByText('Data Stream 1'); + // Stream Type: + screen.getByText('Audit Logs'); + // Status: + screen.getByText('Enabled'); + // Destination Type: + screen.getByText('Linode Object Storage'); + // ID: + screen.getByText('1'); + // Creation Time: + screen.getByText(/2025-07-30/); + + const actionMenu = screen.getByLabelText( + `Action menu for Stream ${stream.label}` + ); + await userEvent.click(actionMenu); + + expect(screen.getByText('Edit')).toBeVisible(); + expect(screen.getByText('Disable')).toBeVisible(); + expect(screen.getByText('Delete')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx b/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx index 44d010c6f59..4a5c0f0cacb 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx @@ -5,30 +5,49 @@ import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { + getDestinationTypeOption, + getStreamTypeOption, +} from 'src/features/DataStream/dataStreamUtils'; +import { StreamActionMenu } from 'src/features/DataStream/Streams/StreamActionMenu'; +import type { Handlers as StreamHandlers } from './StreamActionMenu'; import type { Stream, StreamStatus } from '@linode/api-v4'; -interface StreamTableRowProps { +interface StreamTableRowProps extends StreamHandlers { stream: Stream; } export const StreamTableRow = React.memo((props: StreamTableRowProps) => { - const { stream } = props; + const { stream, onDelete, onDisableOrEnable, onEdit } = props; return ( {stream.label} + {getStreamTypeOption(stream.type)?.label} {humanizeStreamStatus(stream.status)} {stream.id} - {stream.destinations[0].label} + + {getDestinationTypeOption(stream.destinations[0]?.type)?.label} + + + + + + ); }); diff --git a/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx index c10cb9f70d3..e14ef8f8872 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx @@ -1,56 +1,94 @@ -import { waitForElementToBeRemoved, within } from '@testing-library/react'; +import { + screen, + waitForElementToBeRemoved, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { expect } from 'vitest'; +import { beforeEach, describe, expect } from 'vitest'; import { streamFactory } from 'src/factories/datastream'; import { StreamsLanding } from 'src/features/DataStream/Streams/StreamsLanding'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; const loadingTestId = 'circle-progress'; -describe('Streams Landing Table', () => { - it('should render streams landing tab header and table with items PaginationFooter', async () => { - server.use( - http.get('*/monitor/streams', () => { - return HttpResponse.json(makeResourcePage(streamFactory.buildList(30))); - }) - ); +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(() => vi.fn()), + useUpdateStreamMutation: vi.fn().mockReturnValue({ + mutateAsync: vi.fn(), + }), + useDeleteStreamMutation: vi.fn().mockReturnValue({ + mutateAsync: vi.fn(), + }), +})); - const { - getByText, - queryByTestId, - getAllByTestId, - getByPlaceholderText, - getByLabelText, - getByRole, - } = renderWithTheme(, { +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useUpdateStreamMutation: queryMocks.useUpdateStreamMutation, + useDeleteStreamMutation: queryMocks.useDeleteStreamMutation, + }; +}); + +const stream = streamFactory.build({ id: 1 }); +const streams = [stream, ...streamFactory.buildList(30)]; + +describe('Streams Landing Table', () => { + const renderComponentAndWaitForLoadingComplete = async () => { + renderWithTheme(, { initialRoute: '/datastream/streams', }); - const loadingElement = queryByTestId(loadingTestId); + const loadingElement = screen.queryByTestId(loadingTestId); if (loadingElement) { await waitForElementToBeRemoved(loadingElement); } + }; + + beforeEach(() => { + mockMatchMedia(); + }); + + it('should render streams landing tab header and table with items PaginationFooter', async () => { + server.use( + http.get('*/monitor/streams', () => { + return HttpResponse.json(makeResourcePage(streams)); + }) + ); + + await renderComponentAndWaitForLoadingComplete(); // search text input - getByPlaceholderText('Search for a Stream'); + screen.getByPlaceholderText('Search for a Stream'); // select - getByLabelText('Status'); + screen.getByLabelText('Status'); // button - getByText('Create Stream'); + screen.getByText('Create Stream'); // Table column headers - getByText('Name'); - within(getByRole('table')).getByText('Status'); - getByText('ID'); - getByText('Destination Type'); + screen.getByText('Name'); + screen.getByText('Stream Type'); + within(screen.getByRole('table')).getByText('Status'); + screen.getByText('ID'); + screen.getByText('Destination Type'); + screen.getByText('Creation Time'); // PaginationFooter - const paginationFooterSelectPageSizeInput = getAllByTestId( + const paginationFooterSelectPageSizeInput = screen.getAllByTestId( 'textfield-input' )[2] as HTMLInputElement; expect(paginationFooterSelectPageSizeInput.value).toBe('Show 25'); @@ -63,17 +101,109 @@ describe('Streams Landing Table', () => { }) ); - const { getByText, queryByTestId } = renderWithTheme(, { - initialRoute: '/datastream/streams', - }); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + await renderComponentAndWaitForLoadingComplete(); - getByText((text) => + screen.getByText((text) => text.includes('Create a data stream and configure delivery of cloud logs') ); }); + + const clickOnActionMenu = async () => { + const actionMenu = screen.getByLabelText( + `Action menu for Stream ${stream.label}` + ); + await userEvent.click(actionMenu); + }; + + const clickOnActionMenuItem = async (itemText: string) => { + await userEvent.click(screen.getByText(itemText)); + }; + + describe('given action menu', () => { + beforeEach(() => { + server.use( + http.get('*/monitor/streams', () => { + return HttpResponse.json(makeResourcePage(streams)); + }) + ); + }); + + describe('when Edit clicked', () => { + it('should navigate to edit page', async () => { + const mockNavigate = vi.fn(); + queryMocks.useNavigate.mockReturnValue(mockNavigate); + + await renderComponentAndWaitForLoadingComplete(); + + await clickOnActionMenu(); + await clickOnActionMenuItem('Edit'); + + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/datastream/streams/1/edit', + }); + }); + }); + + describe('when Disable clicked', () => { + it('should update stream with proper parameters', async () => { + const mockUpdateStreamMutation = vi.fn().mockResolvedValue({}); + queryMocks.useUpdateStreamMutation.mockReturnValue({ + mutateAsync: mockUpdateStreamMutation, + }); + + await renderComponentAndWaitForLoadingComplete(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Disable'); + + expect(mockUpdateStreamMutation).toHaveBeenCalledWith({ + id: 1, + status: 'inactive', + label: 'Data Stream 1', + destinations: [123], + details: {}, + type: 'audit_logs', + }); + }); + }); + + describe('when Enable clicked', () => { + it('should update stream with proper parameters', async () => { + const mockUpdateStreamMutation = vi.fn().mockResolvedValue({}); + queryMocks.useUpdateStreamMutation.mockReturnValue({ + mutateAsync: mockUpdateStreamMutation, + }); + + stream.status = 'inactive'; + await renderComponentAndWaitForLoadingComplete(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Enable'); + + expect(mockUpdateStreamMutation).toHaveBeenCalledWith({ + id: 1, + status: 'active', + label: 'Data Stream 1', + destinations: [123], + details: {}, + type: 'audit_logs', + }); + }); + }); + + describe('when Delete clicked', () => { + it('should delete stream', async () => { + const mockDeleteStreamMutation = vi.fn().mockResolvedValue({}); + queryMocks.useDeleteStreamMutation.mockReturnValue({ + mutateAsync: mockDeleteStreamMutation, + }); + + await renderComponentAndWaitForLoadingComplete(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Delete'); + + expect(mockDeleteStreamMutation).toHaveBeenCalledWith({ + id: 1, + }); + }); + }); + }); }); diff --git a/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx b/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx index 39089819cd8..6d131412af3 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx @@ -1,8 +1,14 @@ -import { useStreamsQuery } from '@linode/queries'; +import { streamStatus } from '@linode/api-v4'; +import { + useDeleteStreamMutation, + useStreamsQuery, + useUpdateStreamMutation, +} from '@linode/queries'; import { CircleProgress, ErrorState, Hidden } from '@linode/ui'; import { TableBody, TableCell, TableHead, TableRow } from '@mui/material'; import Table from '@mui/material/Table'; import { useNavigate, useSearch } from '@tanstack/react-router'; +import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -18,9 +24,14 @@ import { StreamsLandingEmptyState } from 'src/features/DataStream/Streams/Stream import { StreamTableRow } from 'src/features/DataStream/Streams/StreamTableRow'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import type { Handlers as StreamHandlers } from './StreamActionMenu'; +import type { Stream } from '@linode/api-v4'; export const StreamsLanding = () => { const navigate = useNavigate(); + const streamsUrl = '/datastream/streams'; const search = useSearch({ from: '/datastream/streams', @@ -42,6 +53,9 @@ export const StreamsLanding = () => { preferenceKey: `streams-order`, }); + const { mutateAsync: updateStream } = useUpdateStreamMutation(); + const { mutateAsync: deleteStream } = useDeleteStreamMutation(); + const filter = { ['+order']: order, ['+order_by']: orderBy, @@ -106,6 +120,78 @@ export const StreamsLanding = () => { return ; } + const handleEdit = ({ id }: Stream) => { + navigate({ to: `/datastream/streams/${id}/edit` }); + }; + + const handleDelete = ({ id, label }: Stream) => { + deleteStream({ + id, + }) + .then(() => { + return enqueueSnackbar(`Stream ${label} deleted successfully`, { + variant: 'success', + }); + }) + .catch((error) => { + return enqueueSnackbar( + getAPIErrorOrDefault( + error, + `There was an issue deleting your stream` + )[0].reason, + { + variant: 'error', + } + ); + }); + }; + + const handleDisableOrEnable = ({ + id, + destinations, + details, + label, + type, + status, + }: Stream) => { + updateStream({ + id, + destinations: destinations.map(({ id: destinationId }) => destinationId), + details, + label, + type, + status: + status === streamStatus.Active + ? streamStatus.Inactive + : streamStatus.Active, + }) + .then(() => { + return enqueueSnackbar( + `Stream ${label} ${status === streamStatus.Active ? 'disabled' : 'enabled'}`, + { + variant: 'success', + } + ); + }) + .catch((error) => { + return enqueueSnackbar( + getAPIErrorOrDefault( + error, + `There was an issue ${status === streamStatus.Active ? 'disabling' : 'enabling'} your stream` + )[0].reason, + { + variant: 'error', + } + ); + }); + }; + + const handlers: StreamHandlers = { + onDisableOrEnable: handleDisableOrEnable, + onEdit: handleEdit, + onDelete: handleDelete, + }; + return ( <> { direction={order} handleClick={handleOrderChange} label="label" + sx={{ width: '30%' }} > Name + Stream Type { > ID - Destination Type + Destination Type + + { Creation Time + {streams?.data.map((stream) => ( - + ))}
diff --git a/packages/manager/src/features/DataStream/dataStreamUtils.ts b/packages/manager/src/features/DataStream/dataStreamUtils.ts index 7caef631238..50111ba9186 100644 --- a/packages/manager/src/features/DataStream/dataStreamUtils.ts +++ b/packages/manager/src/features/DataStream/dataStreamUtils.ts @@ -1,8 +1,42 @@ -import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; +import { isEmpty, streamType } from '@linode/api-v4'; +import { omitProps } from '@linode/ui'; -import type { DestinationTypeOption } from 'src/features/DataStream/Shared/types'; +import { + destinationTypeOptions, + streamTypeOptions, +} from 'src/features/DataStream/Shared/types'; + +import type { StreamDetails, StreamType } from '@linode/api-v4'; +import type { + FormMode, + LabelValueOption, +} from 'src/features/DataStream/Shared/types'; export const getDestinationTypeOption = ( destinationTypeValue: string -): DestinationTypeOption | undefined => +): LabelValueOption | undefined => destinationTypeOptions.find(({ value }) => value === destinationTypeValue); + +export const getStreamTypeOption = ( + streamTypeValue: string +): LabelValueOption | undefined => + streamTypeOptions.find(({ value }) => value === streamTypeValue); + +export const isFormInEditMode = (mode: FormMode) => mode === 'edit'; + +export const getStreamPayloadDetails = ( + type: StreamType, + details: StreamDetails +): StreamDetails => { + let payloadDetails: StreamDetails = {}; + + if (!isEmpty(details) && type === streamType.LKEAuditLogs) { + if (details.is_auto_add_all_clusters_enabled) { + payloadDetails = omitProps(details, ['cluster_ids']); + } else { + payloadDetails = omitProps(details, ['is_auto_add_all_clusters_enabled']); + } + } + + return payloadDetails; +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx index 85a68949b96..18508468a8b 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx @@ -222,7 +222,7 @@ export const DatabaseBackups = () => { option.value === value.value } label="" - onChange={(_, newTime) => setSelectedTime(newTime)} + onChange={(_, newTime) => setSelectedTime(newTime ?? null)} options={TIME_OPTIONS} placeholder="Choose a time" renderOption={(props, option) => { @@ -238,7 +238,7 @@ export const DatabaseBackups = () => { 'data-qa-time-select': true, }, }} - value={selectedTime} + value={selectedTime ?? null} /> diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx index 27d2eaf56a1..a950718f3ea 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx @@ -144,6 +144,7 @@ export const DomainDetail = () => { { return (
- void; handleTokenClick: (token: string, entities: TransferEntities) => void; - permissions?: Record; + permissions?: Record; status?: string; token: string; transferType?: 'pending' | 'received' | 'sent'; diff --git a/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.test.tsx b/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.test.tsx index 1c780b34de3..b04242a8b96 100644 --- a/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.test.tsx +++ b/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.test.tsx @@ -8,6 +8,8 @@ const queryMocks = vi.hoisted(() => ({ userPermissions: vi.fn(() => ({ data: { cancel_service_transfer: false, + accept_service_transfer: false, + create_service_transfer: false, }, })), })); @@ -35,7 +37,11 @@ describe('TransfersPendingActionMenu', () => { it('should enable "Cancel" button if the user has cancel_service_transfer permission', async () => { queryMocks.userPermissions.mockReturnValue({ - data: { cancel_service_transfer: true }, + data: { + cancel_service_transfer: true, + accept_service_transfer: false, + create_service_transfer: false, + }, }); const { getByRole } = renderWithTheme( diff --git a/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.tsx b/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.tsx index c1916b49afd..a91ed8b5457 100644 --- a/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.tsx +++ b/packages/manager/src/features/EntityTransfers/TransfersPendingActionMenu.tsx @@ -3,12 +3,12 @@ import * as React from 'react'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import type { PermissionType } from '@linode/api-v4'; +import type { TransfersPermissions } from './TransfersTable'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; interface Props { onCancelClick: () => void; - permissions?: Partial>; + permissions?: Record; } export const TransfersPendingActionMenu = (props: Props) => { diff --git a/packages/manager/src/features/EntityTransfers/TransfersTable.tsx b/packages/manager/src/features/EntityTransfers/TransfersTable.tsx index fc55a31b867..f1ccffc65c7 100644 --- a/packages/manager/src/features/EntityTransfers/TransfersTable.tsx +++ b/packages/manager/src/features/EntityTransfers/TransfersTable.tsx @@ -22,6 +22,7 @@ import type { TransferEntities, } from '@linode/api-v4/lib/types'; +type PermissionsSubset = T; interface Props { error: APIError[] | null; handlePageChange: (v: number, showSpinner?: boolean | undefined) => void; @@ -29,12 +30,18 @@ interface Props { isLoading: boolean; page: number; pageSize: number; - permissions?: Record; + permissions?: Record; results: number; transfers?: EntityTransfer[]; transferType: 'pending' | 'received' | 'sent'; } +export type TransfersPermissions = PermissionsSubset< + | 'accept_service_transfer' + | 'cancel_service_transfer' + | 'create_service_transfer' +>; + export const TransfersTable = React.memo((props: Props) => { const { error, diff --git a/packages/manager/src/features/Events/factories/datastream.tsx b/packages/manager/src/features/Events/factories/datastream.tsx index 26f01242c16..691826b52a4 100644 --- a/packages/manager/src/features/Events/factories/datastream.tsx +++ b/packages/manager/src/features/Events/factories/datastream.tsx @@ -13,6 +13,22 @@ export const stream: PartialEventMap<'stream'> = { ), }, + stream_delete: { + notification: (e) => ( + <> + Stream has been{' '} + deleted. + + ), + }, + stream_update: { + notification: (e) => ( + <> + Stream has been{' '} + updated. + + ), + }, }; export const destination: PartialEventMap<'destination'> = { @@ -24,4 +40,20 @@ export const destination: PartialEventMap<'destination'> = { ), }, + destination_delete: { + notification: (e) => ( + <> + Destination has been{' '} + deleted. + + ), + }, + destination_update: { + notification: (e) => ( + <> + Destination has been{' '} + updated. + + ), + }, }; diff --git a/packages/manager/src/features/IAM/IAMLanding.tsx b/packages/manager/src/features/IAM/IAMLanding.tsx index 2fb511dac4f..9e340bba786 100644 --- a/packages/manager/src/features/IAM/IAMLanding.tsx +++ b/packages/manager/src/features/IAM/IAMLanding.tsx @@ -8,7 +8,7 @@ import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import { useTabs } from 'src/hooks/useTabs'; -import { IAM_DOCS_LINK } from './Shared/constants'; +import { IAM_DOCS_LINK, ROLES_LEARN_MORE_LINK } from './Shared/constants'; export const IdentityAccessLanding = React.memo(() => { const location = useLocation(); @@ -29,7 +29,7 @@ export const IdentityAccessLanding = React.memo(() => { breadcrumbProps: { pathname: '/iam', }, - docsLink: IAM_DOCS_LINK, + docsLink: tabIndex === 0 ? IAM_DOCS_LINK : ROLES_LEARN_MORE_LINK, entity: 'Identity and Access', title: 'Identity and Access', }; diff --git a/packages/manager/src/features/IAM/Shared/constants.ts b/packages/manager/src/features/IAM/Shared/constants.ts index 0b5d18d52b3..195083ef74f 100644 --- a/packages/manager/src/features/IAM/Shared/constants.ts +++ b/packages/manager/src/features/IAM/Shared/constants.ts @@ -22,6 +22,15 @@ export const IAM_DOCS_LINK = export const ROLES_LEARN_MORE_LINK = 'https://techdocs.akamai.com/cloud-computing/docs/identity-access-cm-available-roles'; +export const USER_DETAILS_LINK = + 'https://techdocs.akamai.com/cloud-computing/docs/identity-access-cm-manage-access'; + +export const USER_ROLES_LINK = + 'https://techdocs.akamai.com/cloud-computing/docs/identity-access-cm-manage-access#check-and-update-users-role-assignment'; + +export const USER_ENTITIES_LINK = + 'https://techdocs.akamai.com/cloud-computing/docs/identity-access-cm-manage-access#check-and-update-users-entity-assignment'; + export const PAID_ENTITY_TYPES = [ 'database', 'linode', diff --git a/packages/manager/src/features/IAM/Shared/utilities.ts b/packages/manager/src/features/IAM/Shared/utilities.ts index 61aed47fc32..d5092b1a1e5 100644 --- a/packages/manager/src/features/IAM/Shared/utilities.ts +++ b/packages/manager/src/features/IAM/Shared/utilities.ts @@ -513,7 +513,7 @@ export const mergeAssignedRolesIntoExistingRoles = ( selectedPlusExistingRoles.entity_access.push({ id: e.value, roles: [r.role?.value as EntityRoleType], - type: r.role?.entity_type, + type: r.role?.entity_type as AccessType, }); } }); diff --git a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx index 26c7059e0a9..ffda5a30755 100644 --- a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx @@ -7,7 +7,12 @@ import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import { useTabs } from 'src/hooks/useTabs'; -import { IAM_DOCS_LINK, IAM_LABEL } from '../Shared/constants'; +import { + IAM_LABEL, + USER_DETAILS_LINK, + USER_ENTITIES_LINK, + USER_ROLES_LINK, +} from '../Shared/constants'; export const UserDetailsLanding = () => { const { username } = useParams({ from: '/iam/users/$username' }); @@ -26,6 +31,9 @@ export const UserDetailsLanding = () => { }, ]); + const docsLinks = [USER_DETAILS_LINK, USER_ROLES_LINK, USER_ENTITIES_LINK]; + const docsLink = docsLinks[tabIndex] ?? USER_DETAILS_LINK; + return ( <> { }, pathname: location.pathname, }} - docsLink={IAM_DOCS_LINK} + docsLink={docsLink} removeCrumbX={4} spacingBottom={4} title={username} diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx index 9bbf6fb060a..357a5bdc8e3 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx @@ -24,13 +24,15 @@ export const AssignedEntities = ({ useCalculateHiddenItems(role.entity_names!); const handleResize = React.useMemo( - () => debounce(() => calculateHiddenItems(), 100), + () => debounce(() => calculateHiddenItems(), 250), [calculateHiddenItems] ); React.useEffect(() => { - // Ensure calculateHiddenItems runs after layout stabilization on initial render - const rafId = requestAnimationFrame(() => calculateHiddenItems()); + // Double RAF for good measure - see https://stackoverflow.com/questions/44145740/how-does-double-requestanimationframe-work + const rafId = requestAnimationFrame(() => { + requestAnimationFrame(() => calculateHiddenItems()); + }); window.addEventListener('resize', handleResize); @@ -49,14 +51,27 @@ export const AssignedEntities = ({ [role.entity_names, role.entity_ids] ); + const isLastVisibleItem = React.useCallback( + (index: number) => { + return combinedEntities.length - numHiddenItems - 1 === index; + }, + [combinedEntities.length, numHiddenItems] + ); + const items = combinedEntities?.map( (entity: CombinedEntity, index: number) => ( -
{ itemRefs.current[index] = el; }} - style={{ display: 'inline-block', marginRight: 8 }} + sx={{ + display: 'inline', + marginRight: + numHiddenItems > 0 && isLastVisibleItem(index) + ? theme.tokens.spacing.S16 + : theme.tokens.spacing.S8, + }} > 0 && isLastVisibleItem(index) ? '"..."' : '""', + position: 'absolute', + top: 0, + right: -16, + width: 14, + }, }} /> -
+ ) ); @@ -87,19 +111,18 @@ export const AssignedEntities = ({ sx={{ alignItems: 'center', display: 'flex', + position: 'relative', }} > -
{items} -
+ {numHiddenItems > 0 && ( => { + const unrestricted = isRestricted === false; // explicit === false + return { + delete_nodebalancer: unrestricted || grantLevel === 'read_write', + delete_nodebalancer_config: unrestricted || grantLevel === 'read_write', + delete_nodebalancer_config_node: + unrestricted || grantLevel === 'read_write', + update_nodebalancer: unrestricted || grantLevel === 'read_write', + create_nodebalancer_config: unrestricted || grantLevel === 'read_write', + update_nodebalancer_config: unrestricted || grantLevel === 'read_write', + rebuild_nodebalancer_config: unrestricted || grantLevel === 'read_write', + create_nodebalancer_config_node: + unrestricted || grantLevel === 'read_write', + update_nodebalancer_config_node: + unrestricted || grantLevel === 'read_write', + update_nodebalancer_firewalls: unrestricted || grantLevel === 'read_write', + view_nodebalancer: unrestricted || grantLevel !== null, + list_nodebalancer_firewalls: unrestricted || grantLevel !== null, + view_nodebalancer_statistics: unrestricted || grantLevel !== null, + list_nodebalancer_configs: unrestricted || grantLevel !== null, + view_nodebalancer_config: unrestricted || grantLevel !== null, + list_nodebalancer_config_nodes: unrestricted || grantLevel !== null, + view_nodebalancer_config_node: unrestricted || grantLevel !== null, + }; +}; diff --git a/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts b/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts index b33d7c6c170..aee3d1918f1 100644 --- a/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts +++ b/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts @@ -1,6 +1,8 @@ import { accountGrantsToPermissions } from './accountGrantsToPermissions'; import { firewallGrantsToPermissions } from './firewallGrantsToPermissions'; import { linodeGrantsToPermissions } from './linodeGrantsToPermissions'; +import { nodeBalancerGrantsToPermissions } from './nodeBalancerGrantsToPermissions'; +import { volumeGrantsToPermissions } from './volumeGrantsToPermissions'; import type { EntityBase } from '../usePermissions'; import type { @@ -24,23 +26,38 @@ export const entityPermissionMapFrom = ( const entityPermissionsMap: EntityPermissionMap = {}; if (grants) { grants[grantType]?.forEach((entity) => { + /** Entity Permissions Maps */ + const firewallPermissionsMap = firewallGrantsToPermissions( + entity?.permissions, + profile?.restricted + ) as PermissionMap; + const linodePermissionsMap = linodeGrantsToPermissions( + entity?.permissions, + profile?.restricted + ) as PermissionMap; + const volumePermissionsMap = volumeGrantsToPermissions( + entity?.permissions, + profile?.restricted + ) as PermissionMap; + const nodebalancerPermissionsMap = nodeBalancerGrantsToPermissions( + entity?.permissions, + profile?.restricted + ) as PermissionMap; + + /** Add entity permissions to map */ switch (grantType) { case 'firewall': - // eslint-disable-next-line no-case-declarations - const firewallPermissionsMap = firewallGrantsToPermissions( - entity?.permissions, - profile?.restricted - ) as PermissionMap; entityPermissionsMap[entity.id] = firewallPermissionsMap; break; case 'linode': - // eslint-disable-next-line no-case-declarations - const linodePermissionsMap = linodeGrantsToPermissions( - entity?.permissions, - profile?.restricted - ) as PermissionMap; entityPermissionsMap[entity.id] = linodePermissionsMap; break; + case 'nodebalancer': + entityPermissionsMap[entity.id] = nodebalancerPermissionsMap; + break; + case 'volume': + entityPermissionsMap[entity.id] = volumePermissionsMap; + break; } }); } @@ -50,13 +67,20 @@ export const entityPermissionMapFrom = ( /** Convert the existing Grant model to the new IAM RBAC model. */ export const fromGrants = ( accessType: AccessType, - permissionsToCheck: PermissionType[], + permissionsToCheck: readonly PermissionType[], grants?: Grants, isRestricted?: boolean, entityId?: number ): PermissionMap => { + /** Find the entity in the grants */ + const firewall = grants?.firewall.find((f) => f.id === entityId); + const linode = grants?.linode.find((f) => f.id === entityId); + const volume = grants?.volume.find((f) => f.id === entityId); + const nodebalancer = grants?.nodebalancer.find((f) => f.id === entityId); + let usersPermissionsMap = {} as PermissionMap; + /** Convert the entity permissions to the new IAM RBAC model */ switch (accessType) { case 'account': usersPermissionsMap = accountGrantsToPermissions( @@ -65,21 +89,29 @@ export const fromGrants = ( ) as PermissionMap; break; case 'firewall': - // eslint-disable-next-line no-case-declarations - const firewall = grants?.firewall.find((f) => f.id === entityId); usersPermissionsMap = firewallGrantsToPermissions( firewall?.permissions, isRestricted ) as PermissionMap; break; case 'linode': - // eslint-disable-next-line no-case-declarations - const linode = grants?.linode.find((f) => f.id === entityId); usersPermissionsMap = linodeGrantsToPermissions( linode?.permissions, isRestricted ) as PermissionMap; break; + case 'nodebalancer': + usersPermissionsMap = nodeBalancerGrantsToPermissions( + nodebalancer?.permissions, + isRestricted + ) as PermissionMap; + break; + case 'volume': + usersPermissionsMap = volumeGrantsToPermissions( + volume?.permissions, + isRestricted + ) as PermissionMap; + break; default: throw new Error(`Unknown access type: ${accessType}`); } @@ -97,7 +129,7 @@ export const fromGrants = ( export const toEntityPermissionMap = ( entities: EntityBase[] | undefined, entitiesPermissions: (PermissionType[] | undefined)[] | undefined, - permissionsToCheck: PermissionType[], + permissionsToCheck: readonly PermissionType[], isRestricted?: boolean ): EntityPermissionMap => { const entityPermissionsMap: EntityPermissionMap = {}; @@ -118,7 +150,7 @@ export const toEntityPermissionMap = ( /** Combines the permissions a user wants to check with the permissions returned from the backend */ export const toPermissionMap = ( - permissionsToCheck: PermissionType[], + permissionsToCheck: readonly PermissionType[], usersPermissions: PermissionType[], isRestricted?: boolean ): PermissionMap => { diff --git a/packages/manager/src/features/IAM/hooks/adapters/volumeGrantsToPermissions.ts b/packages/manager/src/features/IAM/hooks/adapters/volumeGrantsToPermissions.ts new file mode 100644 index 00000000000..cbb52de867b --- /dev/null +++ b/packages/manager/src/features/IAM/hooks/adapters/volumeGrantsToPermissions.ts @@ -0,0 +1,18 @@ +import type { GrantLevel, VolumeAdmin } from '@linode/api-v4'; + +/** Map the existing Grant model to the new IAM RBAC model. */ +export const volumeGrantsToPermissions = ( + grantLevel?: GrantLevel, + isRestricted?: boolean +): Record => { + const unrestricted = isRestricted === false; // explicit === false since the profile can be undefined + return { + attach_volume: unrestricted || grantLevel === 'read_write', + clone_volume: unrestricted || grantLevel === 'read_write', + delete_volume: unrestricted || grantLevel === 'read_write', + detach_volume: unrestricted || grantLevel === 'read_write', + resize_volume: unrestricted || grantLevel === 'read_write', + update_volume: unrestricted || grantLevel === 'read_write', + view_volume: unrestricted || grantLevel !== null, + }; +}; diff --git a/packages/manager/src/features/IAM/hooks/usePermissions.test.ts b/packages/manager/src/features/IAM/hooks/usePermissions.test.ts index e0b19f6028b..1fed21f3720 100644 --- a/packages/manager/src/features/IAM/hooks/usePermissions.test.ts +++ b/packages/manager/src/features/IAM/hooks/usePermissions.test.ts @@ -23,13 +23,22 @@ vi.mock(import('@linode/queries'), async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - useIsIAMEnabled: queryMocks.useIsIAMEnabled, useUserAccountPermissions: queryMocks.useUserAccountPermissions, useUserEntityPermissions: queryMocks.useUserEntityPermissions, useGrants: queryMocks.useGrants, }; }); +vi.mock('src/features/IAM/hooks/useIsIAMEnabled', async () => { + const actual = await vi.importActual( + 'src/features/IAM/hooks/useIsIAMEnabled' + ); + return { + ...actual, + useIsIAMEnabled: queryMocks.useIsIAMEnabled, + }; +}); + vi.mock('./adapters', () => ({ fromGrants: vi.fn( ( @@ -127,4 +136,83 @@ describe('usePermissions', () => { false ); }); + + it('returns correct map when IAM beta is false', () => { + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: false, + }); + const flags = { iam: { beta: false, enabled: true } }; + + renderHook(() => usePermissions('account', ['create_linode']), { + wrapper: (ui) => wrapWithTheme(ui, { flags }), + }); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(false); + expect(queryMocks.useUserEntityPermissions).toHaveBeenCalledWith( + 'account', + undefined, + true + ); + }); + + it('returns correct map when beta is true and neither the access type nor the permissions are in the limited availability scope', () => { + const flags = { iam: { beta: true, enabled: true } }; + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: true, + }); + + renderHook(() => usePermissions('linode', ['update_linode'], 123), { + wrapper: (ui) => wrapWithTheme(ui, { flags }), + }); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(false); + expect(queryMocks.useUserAccountPermissions).toHaveBeenCalledWith(false); + expect(queryMocks.useUserEntityPermissions).toHaveBeenCalledWith( + 'linode', + 123, + true + ); + }); + + it('returns correct map when beta is true and the access type is in the limited availability scope', () => { + const flags = { iam: { beta: true, enabled: true } }; + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: true, + }); + + renderHook(() => usePermissions('volume', ['resize_volume'], 123), { + wrapper: (ui) => wrapWithTheme(ui, { flags }), + }); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(true); + expect(queryMocks.useUserAccountPermissions).toHaveBeenCalledWith(false); + expect(queryMocks.useUserEntityPermissions).toHaveBeenCalledWith( + 'volume', + 123, + false + ); + }); + + it('returns correct map when beta is true and one of the permissions is in the limited availability scope', () => { + const flags = { iam: { beta: true, enabled: true } }; + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: true, + }); + + renderHook(() => usePermissions('account', ['create_volume']), { + wrapper: (ui) => wrapWithTheme(ui, { flags }), + }); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(true); + expect(queryMocks.useUserAccountPermissions).toHaveBeenCalledWith(false); + expect(queryMocks.useUserEntityPermissions).toHaveBeenCalledWith( + 'account', + undefined, + false + ); + }); }); diff --git a/packages/manager/src/features/IAM/hooks/usePermissions.ts b/packages/manager/src/features/IAM/hooks/usePermissions.ts index 06e43dafb6a..155cbf37bd1 100644 --- a/packages/manager/src/features/IAM/hooks/usePermissions.ts +++ b/packages/manager/src/features/IAM/hooks/usePermissions.ts @@ -1,8 +1,4 @@ -import { - type AccessType, - getUserEntityPermissions, - type PermissionType, -} from '@linode/api-v4'; +import { getUserEntityPermissions } from '@linode/api-v4'; import { useGrants, useProfile, @@ -20,41 +16,80 @@ import { import { useIsIAMEnabled } from './useIsIAMEnabled'; import type { + AccessType, + AccountAdmin, AccountEntity, APIError, EntityType, GrantType, + PermissionType, Profile, } from '@linode/api-v4'; import type { UseQueryResult } from '@linode/queries'; -export type PermissionsResult = { - data: Record; +const BETA_ACCESS_TYPE_SCOPE: AccessType[] = ['account', 'linode', 'firewall']; +const LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE = [ + 'create_image', + 'upload_image', + 'create_vpc', + 'create_volume', + 'create_nodebalancer', +]; + +export type PermissionsResult = { + data: Record; } & Omit, 'data'>; -export const usePermissions = ( +export const usePermissions = ( accessType: AccessType, - permissionsToCheck: PermissionType[], + permissionsToCheck: T, entityId?: number, enabled: boolean = true -): PermissionsResult => { - const { isIAMEnabled } = useIsIAMEnabled(); +): PermissionsResult => { + const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); + const { data: profile } = useProfile(); + + /** + * BETA and LA features should use the new permission model. + * However, beta features are limited to a subset of AccessTypes and account permissions. + * - Use Beta Permissions if: + * - The feature is beta + * - The access type is in the BETA_ACCESS_TYPE_SCOPE + * - The account permission is not in the LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE + * - Use LA Permissions if: + * - The feature is not beta + */ + const useBetaPermissions = + isIAMEnabled && + isIAMBeta && + BETA_ACCESS_TYPE_SCOPE.includes(accessType) && + LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE.some( + (blacklistedPermission) => + permissionsToCheck.includes(blacklistedPermission as AccountAdmin) // some of the account admin in the blacklist have not been added yet + ) === false; + const useLAPermissions = isIAMEnabled && !isIAMBeta; + const shouldUsePermissionMap = useBetaPermissions || useLAPermissions; + + const { data: grants } = useGrants( + (!isIAMEnabled || !shouldUsePermissionMap) && enabled + ); const { data: userAccountPermissions, ...restAccountPermissions } = useUserAccountPermissions( - isIAMEnabled && accessType === 'account' && enabled + shouldUsePermissionMap && accessType === 'account' && enabled ); - const { data: userEntityPermisssions, ...restEntityPermissions } = - useUserEntityPermissions(accessType, entityId!, isIAMEnabled && enabled); + const { data: userEntityPermissions, ...restEntityPermissions } = + useUserEntityPermissions( + accessType, + entityId!, + shouldUsePermissionMap && enabled + ); const usersPermissions = - accessType === 'account' ? userAccountPermissions : userEntityPermisssions; - - const { data: profile } = useProfile(); - const { data: grants } = useGrants(!isIAMEnabled && enabled); + accessType === 'account' ? userAccountPermissions : userEntityPermissions; - const permissionMap = isIAMEnabled + const permissionMap = shouldUsePermissionMap ? toPermissionMap( permissionsToCheck, usersPermissions!, diff --git a/packages/manager/src/features/Images/ImagesLanding/DeleteImageDialog.tsx b/packages/manager/src/features/Images/ImagesLanding/DeleteImageDialog.tsx new file mode 100644 index 00000000000..ca9c62d65dd --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/DeleteImageDialog.tsx @@ -0,0 +1,59 @@ +import { useDeleteImageMutation, useImageQuery } from '@linode/queries'; +import { useSnackbar } from 'notistack'; +import React from 'react'; + +import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; + +interface Props { + imageId: string | undefined; + onClose: () => void; + open: boolean; +} + +export const DeleteImageDialog = (props: Props) => { + const { imageId, open, onClose } = props; + const { enqueueSnackbar } = useSnackbar(); + + const { + data: image, + isLoading, + error, + } = useImageQuery(imageId ?? '', Boolean(imageId)); + + const { mutate: deleteImage, isPending } = useDeleteImageMutation({ + onSuccess() { + enqueueSnackbar('Image has been scheduled for deletion.', { + variant: 'info', + }); + onClose(); + }, + }); + + const isPendingUpload = image?.status === 'pending_upload'; + + return ( + deleteImage({ imageId: imageId ?? '' })} + onClose={onClose} + open={open} + secondaryButtonProps={{ + label: isPendingUpload ? 'Keep Image' : 'Cancel', + }} + title={ + isPendingUpload + ? 'Cancel Upload' + : `Delete Image ${image?.label ?? imageId}` + } + /> + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx index bdd27451cd9..46c43812b10 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx @@ -109,6 +109,27 @@ describe('Image Table Row', () => { ).toBeNull(); }); + it('should not show an unencrypted icon when an Image is still "pending_upload"', () => { + // The API does not populate the "distributed-sites" capability until the image is done creating. + // We must account for this because the image would show as "Unencrypted" while it is creating, + // then suddenly show as encrypted once it was done creating. We don't want that. + // Therefore, we decided we won't show the unencrypted icon until the image is done uploading to + // prevent confusion. + const image = imageFactory.build({ + capabilities: ['cloud-init'], + status: 'pending_upload', + type: 'manual', + }); + + const { queryByLabelText } = renderWithTheme( + wrapWithTableBody() + ); + + expect( + queryByLabelText('This image is not encrypted.', { exact: false }) + ).toBeNull(); + }); + it('should show N/A if Image does not have any regions', () => { const image = imageFactory.build({ regions: [] }); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx index 8c2e96b0925..aad89a214db 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx @@ -78,6 +78,7 @@ export const ImageRow = (props: Props) => { {type === 'manual' && status !== 'creating' && + status !== 'pending_upload' && !image.capabilities.includes('distributed-sites') && ( } diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 76856ace0d6..cd2843065d1 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -1,16 +1,9 @@ -import { - imageQueries, - useDeleteImageMutation, - useImageQuery, - useImagesQuery, -} from '@linode/queries'; +import { imageQueries, useImageQuery, useImagesQuery } from '@linode/queries'; import { getAPIFilterFromQuery } from '@linode/search'; import { - ActionsPanel, CircleProgress, Drawer, ErrorState, - Notice, Paper, Stack, Typography, @@ -18,11 +11,9 @@ import { import { Hidden } from '@linode/ui'; import { useQueryClient } from '@tanstack/react-query'; import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; -import { useSnackbar } from 'notistack'; import React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; @@ -45,7 +36,6 @@ import { isEventInProgressDiskImagize, } from 'src/queries/events/event.helpers'; import { useEventsInfiniteQuery } from 'src/queries/events/events'; -import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { AUTOMATIC_IMAGES_DEFAULT_ORDER, @@ -57,6 +47,7 @@ import { MANUAL_IMAGES_PREFERENCE_KEY, } from '../constants'; import { getEventsForImages } from '../utils'; +import { DeleteImageDialog } from './DeleteImageDialog'; import { EditImageDrawer } from './EditImageDrawer'; import { ManageImageReplicasForm } from './ImageRegions/ManageImageRegionsForm'; import { ImageRow } from './ImageRow'; @@ -64,7 +55,7 @@ import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; import { RebuildImageDrawer } from './RebuildImageDrawer'; import type { Handlers as ImageHandlers } from './ImagesActionMenu'; -import type { Filter, Image, ImageStatus } from '@linode/api-v4'; +import type { Filter, Image } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; import type { ImageAction } from 'src/routes/images'; @@ -84,37 +75,19 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -interface ImageDialogState { - error?: string; - status?: ImageStatus; - submitting: boolean; -} - -const defaultDialogState: ImageDialogState = { - error: undefined, - submitting: false, -}; - export const ImagesLanding = () => { const { classes } = useStyles(); - const { - action, - imageId: selectedImageId, - }: { action: ImageAction; imageId: string } = useParams({ - strict: false, + const params = useParams({ + from: '/images/$imageId/$action', + shouldThrow: false, }); const search = useSearch({ from: '/images' }); const { query } = search; const navigate = useNavigate(); - const { enqueueSnackbar } = useSnackbar(); const isCreateImageRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_images', }); const queryClient = useQueryClient(); - const [dialogState, setDialogState] = - React.useState(defaultDialogState); - const dialogStatus = - dialogState.status === 'pending_upload' ? 'cancel' : 'delete'; /** * At the time of writing: `label`, `tags`, `size`, `status`, `region` are filterable. @@ -258,8 +231,7 @@ export const ImagesLanding = () => { data: selectedImage, isLoading: isFetchingSelectedImage, error: selectedImageError, - } = useImageQuery(selectedImageId, !!selectedImageId); - const { mutateAsync: deleteImage } = useDeleteImageMutation(); + } = useImageQuery(params?.imageId ?? '', !!params?.imageId); const { events } = useEventsInfiniteQuery(); @@ -302,7 +274,6 @@ export const ImagesLanding = () => { }; const handleCloseDialog = () => { - setDialogState(defaultDialogState); navigate({ search: (prev) => prev, to: '/images' }); }; @@ -310,50 +281,6 @@ export const ImagesLanding = () => { actionHandler(image, 'manage-replicas'); }; - const handleDeleteImage = (image: Image) => { - if (!image.id) { - setDialogState((dialog) => ({ - ...dialog, - error: 'Image is not available.', - })); - } - - setDialogState((dialog) => ({ - ...dialog, - error: undefined, - submitting: true, - })); - - deleteImage({ imageId: image.id }) - .then(() => { - handleCloseDialog(); - /** - * request generated by the Pagey HOC. - * - * We're making a request here because the image is being - * optimistically deleted on the API side, so a GET to /images - * will not return the image scheduled for deletion. This request - * is ensuring the image is removed from the list, to prevent the user - * from taking any action on the Image. - */ - enqueueSnackbar('Image has been scheduled for deletion.', { - variant: 'info', - }); - }) - .catch((err) => { - const _error = getErrorStringOrDefault( - err, - 'There was an error deleting the image.' - ); - setDialogState({ - ...dialogState, - error: _error, - submitting: false, - }); - handleCloseDialog(); - }); - }; - const onCancelFailedClick = () => { queryClient.invalidateQueries({ queryKey: imageQueries.paginated._def, @@ -614,20 +541,20 @@ export const ImagesLanding = () => { imageError={selectedImageError} isFetching={isFetchingSelectedImage} onClose={handleCloseDialog} - open={action === 'edit'} + open={params?.action === 'edit'} /> { onClose={handleCloseDialog} /> - handleDeleteImage(selectedImage!), - }} - secondaryButtonProps={{ - 'data-testid': 'cancel', - label: dialogStatus === 'cancel' ? 'Keep Image' : 'Cancel', - onClick: handleCloseDialog, - }} - /> - } - entityError={selectedImageError} - isFetching={isFetchingSelectedImage} + - {dialogState.error && ( - - )} - - {dialogStatus === 'cancel' - ? 'Are you sure you want to cancel this Image upload?' - : 'Are you sure you want to delete this Image?'} - - + open={params?.action === 'delete'} + /> ); diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.test.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.test.tsx index 7f2bd1e34ce..796f6b82dc5 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.test.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.test.tsx @@ -49,9 +49,9 @@ describe('ClusterNetworkingPanel', () => { }); // Confirm stack type section - expect(getByText('IP Version')).toBeVisible(); + expect(getByText('IP Stack')).toBeVisible(); expect(getByText('IPv4')).toBeVisible(); - expect(getByText('IPv4 + IPv6')).toBeVisible(); + expect(getByText('IPv4 + IPv6 (dual-stack)')).toBeVisible(); // Confirm VPC section expect(getByText('VPC')).toBeVisible(); @@ -71,7 +71,9 @@ describe('ClusterNetworkingPanel', () => { // Confirm stack type default expect(getByRole('radio', { name: 'IPv4' })).toBeChecked(); - expect(getByRole('radio', { name: 'IPv4 + IPv6' })).not.toBeChecked(); + expect( + getByRole('radio', { name: 'IPv4 + IPv6 (dual-stack)' }) + ).not.toBeChecked(); // Confirm VPC default expect( diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx index 5bdfa0639c5..fc654c7e491 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx @@ -59,11 +59,11 @@ export const ClusterNetworkingPanel = (props: Props) => { onChange={(e) => field.onChange(e.target.value)} value={field.value ?? null} > - IP Version + IP Stack } label="IPv4" value="ipv4" /> } - label="IPv4 + IPv6" + label="IPv4 + IPv6 (dual-stack)" value="ipv4-ipv6" /> @@ -83,7 +83,6 @@ export const ClusterNetworkingPanel = (props: Props) => { ) => { setIsUsingOwnVpc(e.target.value === 'yes'); diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 63382255dd2..5ea7c98df7e 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -527,7 +527,7 @@ export const CreateCluster = () => { ? 'Only regions that support LKE Enterprise clusters are listed.' : undefined } - value={selectedRegion?.id} + value={selectedRegion?.id || null} /> { {isAPLEnabled && ( { > Cluster ID:{' '} - {clusterId} + {isLkeEnterprisePhase2FeatureEnabled && vpc && ( { {vpc?.label ?? `${vpcId}`}   - {vpcId && vpc?.label ? `(ID: ${vpcId})` : undefined} + {vpcId && vpc?.label && ( + + (ID: ) + + )} )} @@ -212,6 +217,7 @@ export const KubeEntityDetailFooter = React.memo((props: FooterProps) => { > { clusterLabel={cluster.label} clusterRegionId={cluster.region} clusterTier={cluster.tier ?? 'standard'} + clusterVersion={cluster.k8s_version} isLkeClusterRestricted={isClusterReadOnly} /> diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.test.tsx index bb7e2988ca8..c7df8c0d6f0 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.test.tsx @@ -1,5 +1,11 @@ +import { linodeTypeFactory } from '@linode/utilities'; +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; +import { accountFactory, firewallFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AddNodePoolDrawer } from './AddNodePoolDrawer'; @@ -16,23 +22,145 @@ const props: Props = { }; describe('AddNodePoolDrawer', () => { - it('should render plan heading', async () => { - const { findByText } = renderWithTheme(); + describe('Plans', () => { + it('should render plan heading', async () => { + const { findByText } = renderWithTheme(); - await findByText('Dedicated CPU'); - }); + await findByText('Dedicated CPU'); + }); + + it('should display the GPU tab for standard clusters', async () => { + const { findByText } = renderWithTheme(); + + expect(await findByText('GPU')).toBeInTheDocument(); + }); - it('should display the GPU tab for standard clusters', async () => { - const { findByText } = renderWithTheme(); + it('should not display the GPU tab for enterprise clusters', async () => { + const { queryByText } = renderWithTheme( + + ); - expect(await findByText('GPU')).toBeInTheDocument(); + expect(queryByText('GPU')).toBeNull(); + }); }); - it('should not display the GPU tab for enterprise clusters', async () => { - const { queryByText } = renderWithTheme( - - ); + describe('Firewall', () => { + // LKE-E Post LA must be enabled for the Firewall option to show up + const flags = { + lkeEnterprise: { + enabled: true, + ga: false, + la: true, + postLa: true, + phase2Mtc: false, + }, + }; + + it('should not display "Firewall" as an option by default', async () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Firewall')).toBeNull(); + }); + + it('should display "Firewall" as an option for enterprise clusters if the postLA flag is on and the account has the capability', async () => { + const account = accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }); + + server.use( + http.get('*/v4*/account', () => { + return HttpResponse.json(account); + }), + http.get('*/v4*/linode/types', () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { findByText, getByLabelText } = renderWithTheme( + , + { flags } + ); + + expect(await findByText('Firewall')).toBeVisible(); + + const defaultOption = getByLabelText('Use default firewall'); + const existingFirewallOption = getByLabelText('Select existing firewall'); + + expect(defaultOption).toBeInTheDocument(); + expect(existingFirewallOption).toBeInTheDocument(); + + expect(defaultOption).toBeEnabled(); + expect(existingFirewallOption).toBeEnabled(); + }); + + it('should allow the user to pick an existing firewall for enterprise clusters if the postLA flag is on and the account has the capability', async () => { + const account = accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }); + const firewall = firewallFactory.build({ id: 12 }); + const type = linodeTypeFactory.build({ + label: 'Linode 4GB', + class: 'dedicated', + }); + + const onCreatePool = vi.fn(); + + server.use( + http.get('*/v4*/account', () => { + return HttpResponse.json(account); + }), + http.get('*/v4*/linode/types', () => { + return HttpResponse.json(makeResourcePage([type])); + }), + http.get('*/v4*/networking/firewalls', () => { + return HttpResponse.json(makeResourcePage([firewall])); + }), + http.post( + '*/v4*/lke/clusters/:clusterId/pools', + async ({ request }) => { + const data = await request.json(); + onCreatePool(data); + return HttpResponse.json(data); + } + ) + ); + + const { findByText, findByLabelText, getByRole, getByPlaceholderText } = + renderWithTheme( + , + { flags } + ); + + expect(await findByText('Linode 4 GB')).toBeVisible(); + + await userEvent.click(getByRole('button', { name: 'Add 1' })); + await userEvent.click(getByRole('button', { name: 'Add 1' })); + await userEvent.click(getByRole('button', { name: 'Add 1' })); + + const existingFirewallOption = await findByLabelText( + 'Select existing firewall' + ); + + await userEvent.click(existingFirewallOption); + + const firewallSelect = getByPlaceholderText('Select firewall'); + + await userEvent.click(firewallSelect); + + await userEvent.click(await findByText(firewall.label)); + + await userEvent.click(getByRole('button', { name: 'Add pool' })); - expect(queryByText('GPU')).toBeNull(); + await waitFor(() => { + expect(onCreatePool).toHaveBeenCalledWith({ + firewall_id: 12, + count: 3, + type: type.id, + update_strategy: 'on_recycle', + }); + }); + }); }); }); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx index 55dfe7ddeb2..c7cbc89b2ec 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx @@ -1,3 +1,8 @@ +import { + type CreateNodePoolData, + type KubernetesTier, + type Region, +} from '@linode/api-v4'; import { useAllTypes, useRegionsQuery } from '@linode/queries'; import { Box, Button, Drawer, Notice, Stack, Typography } from '@linode/ui'; import { @@ -7,10 +12,9 @@ import { scrollErrorIntoView, } from '@linode/utilities'; import React from 'react'; -import { Controller, useForm, useWatch } from 'react-hook-form'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { ErrorMessage } from 'src/components/ErrorMessage'; -// import { FirewallSelect } from 'src/features/Firewalls/components/FirewallSelect'; import { ADD_NODE_POOLS_DESCRIPTION, ADD_NODE_POOLS_ENTERPRISE_DESCRIPTION, @@ -25,16 +29,9 @@ import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { PremiumCPUPlanNotice } from '../../CreateCluster/PremiumCPUPlanNotice'; import { KubernetesPlansPanel } from '../../KubernetesPlansPanel/KubernetesPlansPanel'; -import { useIsLkeEnterpriseEnabled } from '../../kubeUtils'; -import { NodePoolUpdateStrategySelect } from '../../NodePoolUpdateStrategySelect'; +import { NodePoolConfigOptions } from '../../KubernetesPlansPanel/NodePoolConfigOptions'; import { hasInvalidNodePoolPrice } from './utils'; -import type { - CreateNodePoolData, - KubernetesTier, - Region, -} from '@linode/api-v4'; - export interface Props { clusterId: number; clusterLabel: string; @@ -54,14 +51,13 @@ export const AddNodePoolDrawer = (props: Props) => { open, } = props; - const { isLkeEnterprisePostLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); const { data: regions, isLoading: isRegionsLoading } = useRegionsQuery(); const { data: types, isLoading: isTypesLoading } = useAllTypes(open); const { - error, isPending, mutateAsync: createPool, + error, } = useCreateNodePoolMutation(clusterId); // Only want to use current types here and filter out nanodes @@ -93,7 +89,7 @@ export const AddNodePoolDrawer = (props: Props) => { type && count && isNumber(pricePerNode) ? count * pricePerNode : undefined; const hasInvalidPrice = hasInvalidNodePoolPrice(pricePerNode, totalPrice); - const shouldShowPricingInfo = type && count > 0; + const shouldShowPricingInfo = Boolean(type) && count > 0; React.useEffect(() => { if (open) { @@ -145,121 +141,100 @@ export const AddNodePoolDrawer = (props: Props) => { open={open} slotProps={{ paper: { - sx: { maxWidth: '790px !important' }, + sx: { maxWidth: '810px !important' }, }, }} title={`Add a Node Pool: ${clusterLabel}`} wide > - {form.formState.errors.root?.message && ( - - - - )} -
- { - if (plan === type) { - return count; - } - return 0; - }} - hasSelectedRegion={hasSelectedRegion} - isPlanPanelDisabled={isPlanPanelDisabled} - isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan} - isSubmitting={isPending} - notice={} - onSelect={(type) => form.setValue('type', type)} - regionsData={regions ?? []} - resetValues={() => form.reset()} - selectedId={type} - selectedRegionId={clusterRegionId} - selectedTier={clusterTier} - types={extendedTypes} - updatePlanCount={updatePlanCount} - /> - {count > 0 && count < 3 && ( - - )} - {hasInvalidPrice && shouldShowPricingInfo && ( - - )} - {isLkeEnterprisePostLAFeatureEnabled && - clusterTier === 'enterprise' && ( - - Configuration - ( - - )} + + + + {form.formState.errors.root?.message && ( + + + + )} + { + if (plan === type) { + return count; + } + return 0; + }} + hasSelectedRegion={hasSelectedRegion} + isPlanPanelDisabled={isPlanPanelDisabled} + isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan} + isSubmitting={isPending} + notice={ + + } + onSelect={(type) => form.setValue('type', type)} + regionsData={regions ?? []} + resetValues={() => { + form.setValue('type', ''); + form.setValue('count', 0); + }} + selectedId={type} + selectedRegionId={clusterRegionId} + selectedTier={clusterTier} + types={extendedTypes} + updatePlanCount={updatePlanCount} + /> + {count > 0 && count < 3 && ( + - {/* - ( - - field.onChange(firewall?.id ?? null) - } - value={field.value ?? null} - /> - )} + )} + {hasInvalidPrice && shouldShowPricingInfo && ( + - */} - - )} - - {shouldShowPricingInfo && ( - - This pool will add{' '} - - ${renderMonthlyPriceToCorrectDecimalPlace(totalPrice)}/month ( - {pluralize('node', 'nodes', count)} at $ - {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)} - /month) - {' '} - to this cluster. - - )} - - - + )} + + + {shouldShowPricingInfo && ( + + This pool will add{' '} + + ${renderMonthlyPriceToCorrectDecimalPlace(totalPrice)}/month + ({pluralize('node', 'nodes', count)} at $ + {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)} + /month) + {' '} + to this cluster. + + )} + + + + + ); }; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscaleNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscaleNodePoolDrawer.tsx index 4739d66e2df..cd6520888f3 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscaleNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscaleNodePoolDrawer.tsx @@ -1,4 +1,3 @@ -import { useSpecificTypes } from '@linode/queries'; import { ActionsPanel, Box, @@ -18,12 +17,12 @@ import { makeStyles } from 'tss-react/mui'; import { EnhancedNumberInput } from 'src/components/EnhancedNumberInput/EnhancedNumberInput'; import { Link } from 'src/components/Link'; import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; -import { extendType } from 'src/utilities/extendType'; import { MAX_NODES_PER_POOL_ENTERPRISE_TIER, MAX_NODES_PER_POOL_STANDARD_TIER, } from '../../constants'; +import { useNodePoolDisplayLabel } from './utils'; import type { AutoscaleSettings, @@ -88,6 +87,7 @@ export const AutoscaleNodePoolDrawer = (props: Props) => { open, } = props; const autoscaler = nodePool?.autoscaler; + const nodePoolLabel = useNodePoolDisplayLabel(nodePool, { suffix: 'Plan' }); const { classes, cx } = useStyles(); const { enqueueSnackbar } = useSnackbar(); @@ -96,12 +96,6 @@ export const AutoscaleNodePoolDrawer = (props: Props) => { nodePool?.id ?? -1 ); - const typesQuery = useSpecificTypes(nodePool?.type ? [nodePool.type] : []); - - const planType = typesQuery[0]?.data - ? extendType(typesQuery[0].data) - : undefined; - const { clearErrors, control, @@ -189,7 +183,7 @@ export const AutoscaleNodePoolDrawer = (props: Props) => { {errors.root?.message ? ( diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolDrawer.tsx new file mode 100644 index 00000000000..0f29fe72f34 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolDrawer.tsx @@ -0,0 +1,40 @@ +import { Drawer } from '@linode/ui'; +import React from 'react'; + +import { useNodePoolDisplayLabel } from '../utils'; +import { ConfigureNodePoolForm } from './ConfigureNodePoolForm'; + +import type { KubeNodePoolResponse, KubernetesCluster } from '@linode/api-v4'; + +interface Props { + clusterId: KubernetesCluster['id']; + clusterTier: KubernetesCluster['tier']; + clusterVersion: KubernetesCluster['k8s_version']; + nodePool: KubeNodePoolResponse | undefined; + onClose: () => void; + open: boolean; +} + +export const ConfigureNodePoolDrawer = (props: Props) => { + const { nodePool, onClose, clusterId, open, clusterTier, clusterVersion } = + props; + const nodePoolLabel = useNodePoolDisplayLabel(nodePool, { suffix: 'Plan' }); + + return ( + + {nodePool && ( + + )} + + ); +}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolDrawer.utils.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolDrawer.utils.ts new file mode 100644 index 00000000000..af734e20bff --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolDrawer.utils.ts @@ -0,0 +1,24 @@ +import type { KubeNodePoolResponse, KubernetesCluster } from '@linode/api-v4'; + +interface NodePoolVersionOptions { + clusterVersion: KubernetesCluster['k8s_version']; + nodePoolVersion: KubeNodePoolResponse['k8s_version']; +} + +/** + * This function returns Autocomplete `options` for possible Node Pool versions. + * + * The only valid k8s_version options for a Node Pool are + * - The Node Pool's current version + * - The Cluster's k8s_version + */ +export function getNodePoolVersionOptions(options: NodePoolVersionOptions) { + // The only valid versions are the Node Pool's version and the Cluster's version + const versions = [options.nodePoolVersion, options.clusterVersion]; + + // Filter out undefined versions. In some cases, Node Pool's `k8s_version` may be undefined + const definedVersions = versions.filter((version) => version !== undefined); + + // Get unique versions because the Node Pool's version and the cluster's version can be the same + return Array.from(new Set(definedVersions)); +} diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.test.tsx new file mode 100644 index 00000000000..cfb639fbf48 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.test.tsx @@ -0,0 +1,224 @@ +import { linodeTypeFactory } from '@linode/utilities'; +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { accountFactory, nodePoolFactory } from 'src/factories'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { getNodePoolVersionOptions } from './ConfigureNodePoolDrawer.utils'; +import { ConfigureNodePoolForm } from './ConfigureNodePoolForm'; + +const flags = { + lkeEnterprise: { + postLa: true, + enabled: true, + la: true, + ga: false, + phase2Mtc: false, + }, +}; + +describe('ConfigureNodePoolForm', () => { + // @todo Enable this test when we allow users to edit their Node Pool's label in the UI (ECE-353) + it.skip("renders a label field containing the Node Pool's label", () => { + const nodePool = nodePoolFactory.build({ label: 'my-node-pool-1' }); + + const { getByLabelText } = renderWithTheme( + + ); + + const labelTextField = getByLabelText('Label'); + + expect(labelTextField).toBeEnabled(); + expect(labelTextField).toBeVisible(); + expect(labelTextField).toHaveDisplayValue('my-node-pool-1'); + }); + + // @todo Enable this test when we allow users to edit their Node Pool's label in the UI (ECE-353) + it.skip("uses the Node Pool's type as the label field's placeholder if the node pool does not have an explicit label", async () => { + const type = linodeTypeFactory.build({ label: 'Fake Linode 2GB' }); + const nodePool = nodePoolFactory.build({ label: '', type: type.id }); + + server.use( + http.get(`*/v4*/linode/types/${type.id}`, () => HttpResponse.json(type)) + ); + + const { findByPlaceholderText } = renderWithTheme( + + ); + + expect(await findByPlaceholderText('Fake Linode 2 GB')).toBeVisible(); + }); + + it('renders an Update Strategy select if the cluster is enterprise, the account has the capability, and the postLa feature flag is enabled', async () => { + const nodePool = nodePoolFactory.build({ + update_strategy: 'rolling_update', + }); + const account = accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }); + + server.use(http.get('*/v4*/account', () => HttpResponse.json(account))); + + const { findByLabelText } = renderWithTheme( + , + { flags } + ); + + const updateStrategyField = await findByLabelText('Update Strategy'); + + expect(updateStrategyField).toBeEnabled(); + expect(updateStrategyField).toBeVisible(); + expect(updateStrategyField).toHaveDisplayValue('Rolling Updates'); + }); + + it('renders an Kubernetes Version select if the cluster is enterprise, the account has the capability, and the postLa feature flag is enabled', async () => { + const nodePool = nodePoolFactory.build({ + k8s_version: 'v1.31.8+lke5', + }); + const account = accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }); + + server.use(http.get('*/v4*/account', () => HttpResponse.json(account))); + + const { getByRole, findByLabelText } = renderWithTheme( + , + { flags } + ); + + const kubernetesVersionField = await findByLabelText('Kubernetes Version'); + + expect(kubernetesVersionField).toBeEnabled(); + expect(kubernetesVersionField).toBeVisible(); + expect(kubernetesVersionField).toHaveDisplayValue('v1.31.8+lke5'); + + // Open the version select + await userEvent.click(kubernetesVersionField); + + // Verify the Node Pool's version and the cluster's version show as version options + expect(getByRole('option', { name: 'v1.31.8+lke5' })).toBeVisible(); + expect(getByRole('option', { name: 'v1.31.8+lke6' })).toBeVisible(); + }); + + it('makes a PUT request to /v4beta/lke/clusters/:id/pools/:id and calls onDone when the form is saved', async () => { + const clusterId = 1; + const nodePool = nodePoolFactory.build({ k8s_version: 'v1.31.8+lke5' }); + const onDone = vi.fn(); + const onUpdateNodePool = vi.fn(); + const account = accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }); + + server.use( + http.get('*/v4*/account', () => HttpResponse.json(account)), + http.put( + `*/v4*/lke/clusters/${clusterId}/pools/${nodePool.id}`, + async ({ request }) => { + onUpdateNodePool(await request.json()); + return HttpResponse.json(nodePool); + } + ) + ); + + const { getByRole, findByLabelText } = renderWithTheme( + , + { + flags, + } + ); + + const saveButton = getByRole('button', { name: 'Save' }); + + // The save button should be disabled until the user makes a change + expect(saveButton).toBeDisabled(); + + // Must await because we must load and check /v4/account 's capabilities to know if LKE-E should be enabled + await userEvent.click(await findByLabelText('Kubernetes Version')); + + // Select the cluster's newer version + await userEvent.click(getByRole('option', { name: 'v1.31.8+lke6' })); + + // The save button should be enabled now that the user changed the Node Pool's label + expect(saveButton).toBeEnabled(); + + await userEvent.click(saveButton); + + // Verify the onDone prop was called + await waitFor(() => { + expect(onDone).toHaveBeenCalled(); + }); + + // Verify the PUT request happend with the expected payload + expect(onUpdateNodePool).toHaveBeenCalledWith({ + k8s_version: 'v1.31.8+lke6', + }); + }); + + it("calls onDone when 'Cancel' is clicked", async () => { + const nodePool = nodePoolFactory.build(); + const onDone = vi.fn(); + + const { getByRole } = renderWithTheme( + + ); + + await userEvent.click(getByRole('button', { name: 'Cancel' })); + + expect(onDone).toHaveBeenCalled(); + }); +}); + +describe('getNodePoolVersionOptions', () => { + it('Returns Autocomplete options given the required params ', () => { + expect( + getNodePoolVersionOptions({ + clusterVersion: 'v1.0.0', + nodePoolVersion: 'v0.0.9', + }) + ).toStrictEqual(['v0.0.9', 'v1.0.0']); + }); + + it('only returns one option if the versions are the same', () => { + expect( + getNodePoolVersionOptions({ + clusterVersion: 'v0.0.9', + nodePoolVersion: 'v0.0.9', + }) + ).toStrictEqual(['v0.0.9']); + }); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.tsx new file mode 100644 index 00000000000..e450fdfef2e --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.tsx @@ -0,0 +1,118 @@ +import { Button, Notice, Stack } from '@linode/ui'; +import { useSnackbar } from 'notistack'; +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { NodePoolConfigOptions } from 'src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigOptions'; +import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; + +import { getNodePoolVersionOptions } from './ConfigureNodePoolDrawer.utils'; + +import type { + KubeNodePoolResponse, + KubernetesCluster, + UpdateNodePoolData, +} from '@linode/api-v4'; + +interface Props { + /** + * The ID of the LKE cluster + */ + clusterId: KubernetesCluster['id']; + /** + * The tier of the LKE cluster + */ + clusterTier: KubernetesCluster['tier']; + /** + * The version of the LKE cluster + */ + clusterVersion: KubernetesCluster['k8s_version']; + /** + * The Node Pool to configure + */ + nodePool: KubeNodePoolResponse; + /** + * A function that will be called when the user saves or cancels + */ + onDone?: () => void; +} + +export const ConfigureNodePoolForm = (props: Props) => { + const { clusterId, onDone, nodePool, clusterVersion, clusterTier } = props; + const { enqueueSnackbar } = useSnackbar(); + + const form = useForm({ + defaultValues: { + // @TODO allow users to edit Node Pool `label` and `tags` because the API supports it. (ECE-353) + // label: nodePool.label, + // tags: nodePool.tags, + firewall_id: nodePool.firewall_id, + update_strategy: nodePool.update_strategy, + k8s_version: nodePool.k8s_version, + }, + }); + + const { mutateAsync: updateNodePool } = useUpdateNodePoolMutation( + clusterId, + nodePool.id + ); + + const versions = getNodePoolVersionOptions({ + clusterVersion, + nodePoolVersion: nodePool.k8s_version, + }); + + const onSubmit = async (values: UpdateNodePoolData) => { + try { + await updateNodePool(values); + enqueueSnackbar('Node Pool configuration successfully updated. ', { + variant: 'success', + }); + onDone?.(); + } catch (errors) { + for (const error of errors) { + form.setError(error.field ?? 'root', { message: error.reason }); + } + } + }; + + return ( + +
+ + {form.formState.errors.root?.message && ( + + )} + + + + + + +
+
+ ); +}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelAndTaintDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelAndTaintDrawer.tsx index f0f4495c81e..ba4b80211a3 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelAndTaintDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/LabelsAndTaints/LabelAndTaintDrawer.tsx @@ -1,4 +1,3 @@ -import { useSpecificTypes } from '@linode/queries'; import { ActionsPanel, Button, @@ -13,8 +12,8 @@ import { FormProvider, useForm } from 'react-hook-form'; import { Link } from 'src/components/Link'; import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; -import { extendType } from 'src/utilities/extendType'; +import { useNodePoolDisplayLabel } from '../utils'; import { LabelInput } from './LabelInput'; import { LabelTable } from './LabelTable'; import { TaintInput } from './TaintInput'; @@ -37,11 +36,11 @@ interface LabelsAndTaintsFormFields { export const LabelAndTaintDrawer = (props: Props) => { const { clusterId, nodePool, onClose, open } = props; + const nodePoolLabel = useNodePoolDisplayLabel(nodePool, { suffix: 'Plan' }); + const [shouldShowLabelForm, setShouldShowLabelForm] = React.useState(false); const [shouldShowTaintForm, setShouldShowTaintForm] = React.useState(false); - const typesQuery = useSpecificTypes(nodePool?.type ? [nodePool.type] : []); - const { isPending, mutateAsync: updateNodePool } = useUpdateNodePoolMutation( clusterId, nodePool?.id ?? -1 @@ -109,15 +108,11 @@ export const LabelAndTaintDrawer = (props: Props) => { form.reset(); }; - const planType = typesQuery[0]?.data - ? extendType(typesQuery[0].data) - : undefined; - return ( {formState.errors.root?.message ? ( void; handleClickAutoscale: (poolId: number) => void; + handleClickConfigureNodePool: (poolId: number) => void; handleClickLabelsAndTaints: (poolId: number) => void; handleClickResize: (poolId: number) => void; isLkeClusterRestricted: boolean; isOnlyNodePool: boolean; + label: string; nodes: PoolNodeResponse[]; openDeletePoolDialog: (poolId: number) => void; openRecycleAllNodesDialog: (poolId: number) => void; openRecycleNodeDialog: (nodeID: string, linodeLabel: string) => void; + poolFirewallId: KubeNodePoolResponse['firewall_id']; poolId: number; poolVersion: KubeNodePoolResponse['k8s_version']; statusFilter: StatusFilter; tags: string[]; - typeLabel: string; + type: string; } export const NodePool = (props: Props) => { @@ -52,6 +57,7 @@ export const NodePool = (props: Props) => { encryptionStatus, handleAccordionClick, handleClickAutoscale, + handleClickConfigureNodePool, handleClickLabelsAndTaints, handleClickResize, isLkeClusterRestricted, @@ -61,12 +67,17 @@ export const NodePool = (props: Props) => { openRecycleAllNodesDialog, openRecycleNodeDialog, poolId, + poolFirewallId, poolVersion, statusFilter, tags, - typeLabel, + label, + type, } = props; + const { isLkeEnterprisePostLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); + const nodePoolLabel = useNodePoolDisplayLabel({ label, type }); + return ( { divider={} spacing={{ sm: 1.5, xs: 1 }} > - {typeLabel} + {nodePoolLabel} {pluralize('Node', 'Nodes', count)} @@ -100,6 +111,17 @@ export const NodePool = (props: Props) => { )} handleClickConfigureNodePool(poolId), + title: 'Configure Pool', + }, + ] + : []), { disabled: isLkeClusterRestricted, onClick: () => handleClickLabelsAndTaints(poolId), @@ -152,16 +174,17 @@ export const NodePool = (props: Props) => { clusterCreated={clusterCreated} clusterTier={clusterTier} isLkeClusterRestricted={isLkeClusterRestricted} + nodePoolType={type} nodes={nodes} openRecycleNodeDialog={openRecycleNodeDialog} statusFilter={statusFilter} - typeLabel={typeLabel} /> { tags: [], poolId: 1, poolVersion: undefined, + poolFirewallId: undefined, isLkeClusterRestricted: false, }; it('shows the Pool ID', async () => { const { getByText } = renderWithTheme(); - expect(getByText('Pool ID')).toBeVisible(); + expect(getByText('Pool ID:')).toBeVisible(); expect(getByText(props.poolId)).toBeVisible(); }); @@ -47,7 +48,7 @@ describe('Node Pool Footer', () => { /> ); - expect(getByText('Version')).toBeVisible(); + expect(getByText('Version:')).toBeVisible(); expect(getByText('v1.31.8+lke5')).toBeVisible(); }); @@ -60,10 +61,67 @@ describe('Node Pool Footer', () => { /> ); - expect(queryByText('Version')).not.toBeInTheDocument(); + expect(queryByText('Version:')).not.toBeInTheDocument(); expect(queryByText('v1.31.8+lke5')).not.toBeInTheDocument(); }); + it("shows the node pool's firewall for an LKE Enterprise cluster", async () => { + server.use( + http.get('*/firewalls/*', () => { + return HttpResponse.json( + firewallFactory.build({ id: 123, label: 'my-lke-e-firewall' }) + ); + }) + ); + const { getByText, findByText } = renderWithTheme( + + ); + + expect(getByText('Firewall:')).toBeVisible(); + expect( + await findByText('my-lke-e-firewall', { exact: false }) + ).toBeVisible(); + expect(getByText('123')).toBeVisible(); + }); + + it("does not show the node pool's firewall when undefined for a LKE Enterprise cluster ", async () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Firewall:')).not.toBeInTheDocument(); + }); + + // This check handles the current API behavior for a default firewall (0). TODO: remove this once LKE-7686 is fixed. + it("does not show the node pool's firewall when 0 for a LKE Enterprise cluster ", async () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Firewall:')).not.toBeInTheDocument(); + }); + + it("does not show the node pool's firewall for a standard LKE cluster", async () => { + server.use( + http.get('*/firewalls/*', () => { + return HttpResponse.json( + firewallFactory.build({ id: 123, label: 'my-lke-e-firewall' }) + ); + }) + ); + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Firewall:')).not.toBeInTheDocument(); + expect(queryByText('my-lke-e-firewall')).not.toBeInTheDocument(); + expect(queryByText('123')).not.toBeInTheDocument(); + }); + it('does not display the encryption status of the pool if the account lacks the capability or the feature flag is off', async () => { const { queryByText } = renderWithTheme(, { flags: { linodeDiskEncryption: false }, diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolFooter.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolFooter.tsx index e41b34f7ffb..917e0637269 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolFooter.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolFooter.tsx @@ -1,9 +1,11 @@ +import { useFirewallQuery } from '@linode/queries'; import { Box, Divider, Stack, Typography } from '@linode/ui'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { useIsDiskEncryptionFeatureEnabled } from 'src/components/Encryption/utils'; +import { Link } from 'src/components/Link'; import { TagCell } from 'src/components/TagCell/TagCell'; import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; @@ -21,6 +23,7 @@ export interface Props { clusterTier: KubernetesTier; encryptionStatus: EncryptionStatus; isLkeClusterRestricted: boolean; + poolFirewallId: KubeNodePoolResponse['firewall_id']; poolId: number; poolVersion: KubeNodePoolResponse['k8s_version']; tags: string[]; @@ -33,6 +36,7 @@ export const NodePoolFooter = (props: Props) => { encryptionStatus, isLkeClusterRestricted, poolId, + poolFirewallId, tags, clusterId, } = props; @@ -44,6 +48,11 @@ export const NodePoolFooter = (props: Props) => { poolId ); + const { data: firewall } = useFirewallQuery( + poolFirewallId ?? -1, + Boolean(poolFirewallId) + ); + const { isDiskEncryptionFeatureEnabled } = useIsDiskEncryptionFeatureEnabled(); @@ -65,17 +74,34 @@ export const NodePoolFooter = (props: Props) => { divider={ } - flexWrap={{ sm: 'unset', xs: 'wrap' }} + flexWrap="wrap" + maxWidth="100%" rowGap={1} > - Pool ID + Pool ID: {clusterTier === 'enterprise' && poolVersion && ( - Version {poolVersion} + Version: {poolVersion} )} + {clusterTier === 'enterprise' && + poolFirewallId && + poolFirewallId > 0 && ( // This check handles the current API behavior for a default firewall (0). TODO: remove this once LKE-7686 is fixed. + + Firewall:{' '} + + {firewall?.label ?? poolFirewallId} + {' '} + {firewall?.label && ( + + (ID:{' '} + ) + + )} + + )} {isDiskEncryptionFeatureEnabled && ( )} @@ -83,6 +109,8 @@ export const NodePoolFooter = (props: Props) => {
{ clusterLabel, clusterRegionId, clusterTier, + clusterVersion, isLkeClusterRestricted, } = props; @@ -90,6 +91,8 @@ export const NodePoolsDisplay = (props: Props) => { const [selectedPoolId, setSelectedPoolId] = useState(-1); const selectedPool = pools?.find((pool) => pool.id === selectedPoolId); + const [isConfigureNodePoolDrawerOpen, setIsConfigureNodePoolDrawerOpen] = + useState(false); const [isDeleteNodePoolOpen, setIsDeleteNodePoolOpen] = useState(false); const [isLabelsAndTaintsDrawerOpen, setIsLabelsAndTaintsDrawerOpen] = useState(false); @@ -104,9 +107,6 @@ export const NodePoolsDisplay = (props: Props) => { const [numPoolsToDisplay, setNumPoolsToDisplay] = React.useState(5); const _pools = pools?.slice(0, numPoolsToDisplay); - const typesQuery = useSpecificTypes(_pools?.map((pool) => pool.type) ?? []); - const types = extendTypesQueryResult(typesQuery); - const [statusFilter, setStatusFilter] = React.useState('all'); const handleShowMore = () => { @@ -123,6 +123,11 @@ export const NodePoolsDisplay = (props: Props) => { setAddDrawerOpen(true); }; + const handleOpenConfigureNodePoolDrawer = (poolId: number) => { + setSelectedPoolId(poolId); + setIsConfigureNodePoolDrawerOpen(true); + }; + const handleOpenAutoscaleDrawer = (poolId: number) => { setSelectedPoolId(poolId); setIsAutoscaleDrawerOpen(true); @@ -267,14 +272,8 @@ export const NodePoolsDisplay = (props: Props) => { {poolsError && } {_pools?.map((thisPool) => { - const { count, disk_encryption, id, nodes, tags } = thisPool; - - const thisPoolType = types?.find( - (thisType) => thisType.id === thisPool.type - ); - - const typeLabel = thisPoolType?.formattedLabel ?? 'Unknown type'; - + const { count, disk_encryption, id, nodes, tags, label, type } = + thisPool; return ( { encryptionStatus={disk_encryption} handleAccordionClick={() => handleAccordionClick(id)} handleClickAutoscale={handleOpenAutoscaleDrawer} + handleClickConfigureNodePool={handleOpenConfigureNodePoolDrawer} handleClickLabelsAndTaints={handleOpenLabelsAndTaintsDrawer} handleClickResize={handleOpenResizeDrawer} isLkeClusterRestricted={isLkeClusterRestricted} isOnlyNodePool={pools?.length === 1} key={id} + label={label} nodes={nodes ?? []} openDeletePoolDialog={(id) => { setSelectedPoolId(id); @@ -304,15 +305,16 @@ export const NodePoolsDisplay = (props: Props) => { setSelectedPoolId(id); setIsRecycleAllPoolNodesOpen(true); }} - openRecycleNodeDialog={(nodeId, linodeLabel) => { + openRecycleNodeDialog={(nodeId) => { setSelectedNodeId(nodeId); setIsRecycleNodeOpen(true); }} + poolFirewallId={thisPool.firewall_id} poolId={thisPool.id} poolVersion={thisPool.k8s_version} statusFilter={statusFilter} tags={tags} - typeLabel={typeLabel} + type={type} /> ); })} @@ -330,6 +332,14 @@ export const NodePoolsDisplay = (props: Props) => { onClose={() => setAddDrawerOpen(false)} open={addDrawerOpen} /> + setIsConfigureNodePoolDrawerOpen(false)} + open={isConfigureNodePoolDrawerOpen} + /> void; - typeLabel: string; + type: string; } export const NodeRow = React.memo((props: NodeRowProps) => { @@ -43,10 +47,12 @@ export const NodeRow = React.memo((props: NodeRowProps) => { nodeId, nodeStatus, openRecycleNodeDialog, - typeLabel, + type, shouldShowVpcIPAddressColumns, } = props; + const { data: linodeType } = useTypeQuery(type); + const { data: ips, error: ipsError } = useLinodeIPsQuery( instanceId ?? -1, Boolean(instanceId) @@ -80,7 +86,7 @@ export const NodeRow = React.memo((props: NodeRowProps) => { ? 'active' : 'inactive'; - const labelText = label ?? typeLabel; + const labelText = label ?? linodeType?.label ?? type; const statusText = nodeStatus === 'not_ready' diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts index 8c99fb5f63f..e76774ca7e8 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts @@ -9,6 +9,7 @@ export const NodePoolTableFooter = styled(Box, { justifyContent: 'space-between', alignItems: 'center', columnGap: theme.spacingFunction(32), + flexWrap: 'wrap', rowGap: theme.spacingFunction(8), paddingTop: theme.spacingFunction(8), paddingButtom: theme.spacingFunction(8), diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx index 844eb56ba00..f809a66aeb6 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx @@ -32,7 +32,7 @@ describe('NodeTable', () => { nodes, openRecycleNodeDialog: vi.fn(), statusFilter: 'all', - typeLabel: 'g6-standard-1', + nodePoolType: 'g6-standard-1', }; it('includes label, status, and IP columns', async () => { diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx index 9601333061c..346b287c3a6 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx @@ -27,21 +27,21 @@ export interface Props { clusterCreated: string; clusterTier: KubernetesTier; isLkeClusterRestricted: boolean; + nodePoolType: string; nodes: PoolNodeResponse[]; openRecycleNodeDialog: (nodeID: string, linodeLabel: string) => void; statusFilter: StatusFilter; - typeLabel: string; } export const NodeTable = React.memo((props: Props) => { const { clusterCreated, clusterTier, + nodePoolType, nodes, openRecycleNodeDialog, isLkeClusterRestricted, statusFilter, - typeLabel, } = props; const { data: profile } = useProfile(); @@ -215,7 +215,7 @@ export const NodeTable = React.memo((props: Props) => { shouldShowVpcIPAddressColumns={ shouldShowVpcIPAddressColumns } - typeLabel={typeLabel} + type={nodePoolType} /> ); })} diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx index 29e192b1803..30c58390be7 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx @@ -1,28 +1,24 @@ +import { linodeTypeFactory } from '@linode/utilities'; import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { nodePoolFactory, typeFactory } from 'src/factories'; +import { nodePoolFactory } from 'src/factories'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { ResizeNodePoolDrawer } from './ResizeNodePoolDrawer'; import type { Props } from './ResizeNodePoolDrawer'; +const type = linodeTypeFactory.build({ + id: 'fake-linode-type-id', + label: 'Linode 2GB', +}); const pool = nodePoolFactory.build({ - type: 'g6-standard-1', + type: type.id, }); const smallPool = nodePoolFactory.build({ count: 2 }); -vi.mock('@linode/queries', async () => { - const actual = await vi.importActual('@linode/queries'); - return { - ...actual, - useSpecificTypes: vi - .fn() - .mockReturnValue([{ data: typeFactory.build({ label: 'Linode 1 GB' }) }]), - }; -}); - const props: Props = { clusterTier: 'standard', kubernetesClusterId: 1, @@ -33,10 +29,31 @@ const props: Props = { }; describe('ResizeNodePoolDrawer', () => { - it("should render the pool's type and size", async () => { + // @TODO enable this test when we begin surfacing Node Pool `label` in the UI (ECE-353) + it.skip("should render a title containing the Node Pool's label when the node pool has a label", async () => { + const nodePool = nodePoolFactory.build({ label: 'my-mock-node-pool-1' }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Resize Pool: my-mock-node-pool-1')).toBeVisible(); + }); + + it("should render a title containing the Node Pool's type initially when the node pool does not have a label", () => { + const { getByText } = renderWithTheme(); + + expect(getByText('Resize Pool: fake-linode-type-id Plan')).toBeVisible(); + }); + + it("should render a title containing the Node Pool's type's label once the type data has loaded when the node pool does not have a label", async () => { + server.use( + http.get('*/v4*/linode/types/:id', () => HttpResponse.json(type)) + ); + const { findByText } = renderWithTheme(); - await findByText(/linode 1 GB/i); + expect(await findByText('Resize Pool: Linode 2 GB Plan')).toBeVisible(); }); it('should display a warning if the user tries to resize a node pool to < 3 nodes', async () => { diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx index df91b3347db..f575f526968 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx @@ -1,4 +1,4 @@ -import { useSpecificTypes } from '@linode/queries'; +import { useTypeQuery } from '@linode/queries'; import { ActionsPanel, CircleProgress, @@ -17,14 +17,13 @@ import { MAX_NODES_PER_POOL_STANDARD_TIER, } from 'src/features/Kubernetes/constants'; import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; -import { extendType } from 'src/utilities/extendType'; import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/dynamicPricing'; import { getKubernetesMonthlyPrice } from 'src/utilities/pricing/kubernetes'; import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { nodeWarning } from '../../constants'; -import { hasInvalidNodePoolPrice } from './utils'; +import { hasInvalidNodePoolPrice, useNodePoolDisplayLabel } from './utils'; import type { KubeNodePoolResponse, @@ -69,11 +68,12 @@ export const ResizeNodePoolDrawer = (props: Props) => { } = props; const { classes } = useStyles(); - const typesQuery = useSpecificTypes(nodePool?.type ? [nodePool.type] : []); - const isLoadingTypes = typesQuery[0]?.isLoading ?? false; - const planType = typesQuery[0]?.data - ? extendType(typesQuery[0].data) - : undefined; + const nodePoolLabel = useNodePoolDisplayLabel(nodePool, { suffix: 'Plan' }); + + const { data: planType, isLoading: isLoadingTypes } = useTypeQuery( + nodePool?.type ?? '', + Boolean(nodePool) + ); const { error, @@ -142,7 +142,7 @@ export const ResizeNodePoolDrawer = (props: Props) => { {isLoadingTypes ? ( diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts index ac3166ae4f3..6b2db97bbb5 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.test.ts @@ -1,4 +1,11 @@ -import { hasInvalidNodePoolPrice } from './utils'; +import { linodeTypeFactory } from '@linode/utilities'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { nodePoolFactory } from 'src/factories/kubernetesCluster'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { wrapWithTheme as wrapper } from 'src/utilities/testHelpers'; + +import { hasInvalidNodePoolPrice, useNodePoolDisplayLabel } from './utils'; describe('hasInvalidNodePoolPrice', () => { it('returns false if the prices are both zero, which is valid', () => { @@ -17,3 +24,69 @@ describe('hasInvalidNodePoolPrice', () => { expect(hasInvalidNodePoolPrice(null, null)).toBe(true); }); }); + +describe('useNodePoolDisplayLabel', () => { + // @TODO remove skip this when it's time to surface Node Pool labels in the UI (ECE-353) + it.skip("returns the node pools's label if it has one", () => { + const nodePool = nodePoolFactory.build({ label: 'my-node-pool-1' }); + + const { result } = renderHook(() => useNodePoolDisplayLabel(nodePool), { + wrapper, + }); + + expect(result.current).toBe('my-node-pool-1'); + }); + + it("returns the node pools's type ID initialy if it does not have an explicit label", () => { + const nodePool = nodePoolFactory.build({ + label: '', + type: 'g6-fake-type-1', + }); + + const { result } = renderHook(() => useNodePoolDisplayLabel(nodePool), { + wrapper, + }); + + expect(result.current).toBe('g6-fake-type-1'); + }); + + it('appends a suffix to the Linode type if one is provided', () => { + const nodePool = nodePoolFactory.build({ + label: '', + type: 'g6-fake-type-1', + }); + + const { result } = renderHook( + () => useNodePoolDisplayLabel(nodePool, { suffix: 'Plan' }), + { + wrapper, + } + ); + + expect(result.current).toBe('g6-fake-type-1 Plan'); + }); + + it("returns the node pools's type's `label` once it loads if it does not have an explicit label", async () => { + const type = linodeTypeFactory.build({ + id: 'g6-fake-type-1', + label: 'Fake Linode 2GB', + }); + + server.use( + http.get('*/v4*/linode/types/:id', () => HttpResponse.json(type)) + ); + + const nodePool = nodePoolFactory.build({ + label: '', + type: type.id, + }); + + const { result } = renderHook(() => useNodePoolDisplayLabel(nodePool), { + wrapper, + }); + + await waitFor(() => { + expect(result.current).toBe('Fake Linode 2 GB'); + }); + }); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts index 19ffd7cfe44..520f11934cc 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts @@ -1,5 +1,12 @@ +import { useTypeQuery } from '@linode/queries'; +import { formatStorageUnits } from '@linode/utilities'; + import type { NodeRow } from './NodeRow'; -import type { Linode, PoolNodeResponse } from '@linode/api-v4'; +import type { + KubeNodePoolResponse, + Linode, + PoolNodeResponse, +} from '@linode/api-v4'; /** * Checks whether prices are valid - 0 is valid, but undefined and null prices are invalid. @@ -37,3 +44,62 @@ export const nodeToRow = ( shouldShowVpcIPAddressColumns, }; }; + +interface NodePoolDisplayLabelOptions { + /** + * If set to `true`, the hook will only return the node pool's type's `id` or `label` + * and never its actual `label` + */ + ignoreNodePoolsLabel?: boolean; + /** + * Appends a suffix to the Node Pool's type `id` or `label` if it is returned + */ + suffix?: string; +} + +/** + * Given a Node Pool, this hook will return the Node Pool's display label. + * + * We use this helper rather than just using `label` on the Node Pool because the `label` + * field is optional was added later on to the API. For Node Pools without explicit labels, + * we identify them in the UI by their plan's label. + * + * @returns The Node Pool's label + */ +export const useNodePoolDisplayLabel = ( + nodePool: Pick | undefined, + options?: NodePoolDisplayLabelOptions +) => { + const { data: type } = useTypeQuery( + nodePool?.type ?? '', + Boolean(nodePool?.type) + ); + + if (!nodePool) { + return ''; + } + + // @TODO uncomment this when it's time to surface Node Pool labels in the UI (ECE-353) + // If the Node Pool has an explict label, return it. + // if (nodePool.label && !options?.ignoreNodePoolsLabel) { + // return nodePool.label; + // } + + // If the Node Pool's type is loaded, return that type's formatted label. + if (type) { + const typeLabel = formatStorageUnits(type.label); + + if (options?.suffix) { + return `${typeLabel} ${options.suffix}`; + } + + return typeLabel; + } + + // As a last resort, fallback to the Node Pool's type ID. + if (options?.suffix) { + return `${nodePool.type} ${options.suffix}`; + } + + return nodePool.type; +}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx index 12b682e555d..10052d81f98 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx @@ -38,10 +38,7 @@ export const KubernetesPlanSelectionTable = ( } = props; return ( - +
{tableCells.map(({ cellName, center, noWrap, testId }) => { diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigDrawer.tsx index c0fef24aaa0..851a8200397 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigDrawer.tsx @@ -210,8 +210,7 @@ export const NodePoolConfigDrawer = (props: Props) => { )} - - {selectedTier === 'enterprise' && } + { +interface KubernetesVersionFieldOptions { + show: boolean; + versions: string[]; +} + +interface Props { + clusterTier: KubernetesTier; + firewallSelectOptions?: NodePoolFirewallSelectProps; + versionFieldOptions?: KubernetesVersionFieldOptions; +} + +export const NodePoolConfigOptions = (props: Props) => { + const { versionFieldOptions, clusterTier, firewallSelectOptions } = props; + const { isLkeEnterprisePostLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); const { control } = useFormContext(); + const versionOptions = + versionFieldOptions?.versions.map((version) => ({ + label: version, + })) ?? []; + + // @TODO uncomment and wire this up when we begin surfacing the Text Field for a Node Pool's `label` (ECE-353) + // const labelPlaceholder = useNodePoolDisplayLabel(nodePool, { + // ignoreNodePoolsLabel: true, + // }); + return ( - <> + + {/* + // @TODO allow users to edit Node Pool `label` and `tags` because the API supports it. (ECE-353) ( - ( + )} /> - - + ( + field.onChange(tags.map((tag) => tag.value))} + tagError={fieldState.error?.message} + value={ + field.value?.map((tag) => ({ label: tag, value: tag })) ?? [] + } + /> + )} + /> + */} + {/* LKE Enterprise cluster node pools have more configurability */} + {clusterTier === 'enterprise' && isLkeEnterprisePostLAFeatureEnabled && ( + <> + ( + + )} + /> + {versionFieldOptions?.show && ( + ( + field.onChange(version.label)} + options={versionOptions} + value={versionOptions.find( + (option) => option.label === field.value + )} + /> + )} + /> + )} + + + )} + ); }; diff --git a/packages/manager/src/features/Kubernetes/NodePoolFirewallSelect.tsx b/packages/manager/src/features/Kubernetes/NodePoolFirewallSelect.tsx index 4f39d6561d9..a7d73cffcbc 100644 --- a/packages/manager/src/features/Kubernetes/NodePoolFirewallSelect.tsx +++ b/packages/manager/src/features/Kubernetes/NodePoolFirewallSelect.tsx @@ -1,75 +1,142 @@ import { + FormControl, FormControlLabel, + FormHelperText, Radio, RadioGroup, Stack, - Typography, + TooltipIcon, } from '@linode/ui'; import React from 'react'; -import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { useController, useFormContext } from 'react-hook-form'; + +import { FormLabel } from 'src/components/FormLabel'; import { FirewallSelect } from '../Firewalls/components/FirewallSelect'; import type { CreateNodePoolData } from '@linode/api-v4'; -export const NodePoolFirewallSelect = () => { +export interface NodePoolFirewallSelectProps { + /** + * When standard LKE supports firewall, we will allow Firewalls to be add & removed + * Use this prop to allow/prevent Firwall from being removed on a Node Pool + */ + allowFirewallRemoval?: boolean; + /** + * An optional tooltip message that shows beside the "Use default firewall" radio label + */ + defaultFirewallRadioTooltip?: string; + /** + * Disables the "Use default firewall" option + */ + disableDefaultFirewallRadio?: boolean; +} + +export const NodePoolFirewallSelect = (props: NodePoolFirewallSelectProps) => { + const { + defaultFirewallRadioTooltip, + disableDefaultFirewallRadio, + allowFirewallRemoval, + } = props; const { control } = useFormContext(); - const watchedFirewallId = useWatch({ control, name: 'firewall_id' }); + const { field, fieldState, formState } = useController({ + control, + name: 'firewall_id', + rules: { + validate: (value) => { + if (isUsingOwnFirewall && value === null) { + if (disableDefaultFirewallRadio) { + return 'You must select a Firewall.'; + } + return 'You must either select a Firewall or select the default firewall.'; + } + return true; + }, + }, + }); const [isUsingOwnFirewall, setIsUsingOwnFirewall] = React.useState( - Boolean(watchedFirewallId) + Boolean(field.value) ); return ( - - ({ - font: theme.tokens.alias.Typography.Label.Bold.S, - })} - > - Firewall - - ) => { - setIsUsingOwnFirewall(e.target.value === 'yes'); - }} - value={isUsingOwnFirewall} - > - } - label="Use default firewall" - value="no" - /> - } - label="Select existing firewall" - value="yes" - /> - - {isUsingOwnFirewall && ( - ( - field.onChange(firewall?.id ?? null)} - placeholder="Select firewall" - value={field.value} - /> - )} - rules={{ - validate: (value) => { - if (isUsingOwnFirewall && !value) { - return 'You must either select a Firewall or select the default firewall.'; + + + + Firewall + + { + setIsUsingOwnFirewall(value === 'yes'); + + if (value === 'yes') { + // If the user chooses to use an existing firewall... + if (formState.defaultValues?.firewall_id) { + // If the Node Pool has a `firewall_id` set, restore that value (For the edit Node Pool flow) + field.onChange(formState.defaultValues?.firewall_id); + } else { + // Set `firewall_id` to `null` so that our validation forces the user to pick a firewall or pick the default backend-generated one + field.onChange(null); } - return true; - }, + } else { + field.onChange(formState.defaultValues?.firewall_id); + } + }} + value={isUsingOwnFirewall ? 'yes' : 'no'} + > + } + disabled={disableDefaultFirewallRadio} + label={ + <> + Use default firewall + {defaultFirewallRadioTooltip && ( + + )} + + } + value="no" + /> + } + label="Select existing firewall" + value="yes" + /> + + {!isUsingOwnFirewall && ( + + {fieldState.error?.message} + + )} + + {isUsingOwnFirewall && ( + { + if (firewall) { + field.onChange(firewall.id); + } else { + // `0` tells the backend to remove the firewall + field.onChange(0); + } }} + placeholder="Select firewall" + value={field.value} /> )} diff --git a/packages/manager/src/features/Kubernetes/NodePoolUpdateStrategySelect.tsx b/packages/manager/src/features/Kubernetes/NodePoolUpdateStrategySelect.tsx index fb181d04ecd..3d10e981f0e 100644 --- a/packages/manager/src/features/Kubernetes/NodePoolUpdateStrategySelect.tsx +++ b/packages/manager/src/features/Kubernetes/NodePoolUpdateStrategySelect.tsx @@ -4,19 +4,17 @@ import React from 'react'; import { UPDATE_STRATEGY_OPTIONS } from './constants'; interface Props { - label?: string; - noMarginTop?: boolean; onChange: (value: string | undefined) => void; value: string | undefined; } export const NodePoolUpdateStrategySelect = (props: Props) => { - const { onChange, value, noMarginTop, label } = props; + const { onChange, value } = props; return ( onChange(updateStrategy?.value)} options={UPDATE_STRATEGY_OPTIONS} placeholder="Select an Update Strategy" diff --git a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx index 4cf2120d0fa..4bdd304b90d 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx @@ -88,7 +88,7 @@ export const MaintenancePolicy = () => { : MAINTENANCE_POLICY_SELECT_REGION_TEXT : undefined, }} - value={field.value ?? undefined} + value={field.value ?? null} /> )} /> diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceType.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceType.tsx index 9ea728a96bb..62dc33ee27b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceType.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceType.tsx @@ -1,5 +1,6 @@ import { firewallQueries, useQueryClient } from '@linode/queries'; import { + Box, FormControl, Radio, RadioGroup, @@ -9,11 +10,12 @@ import { import { Grid } from '@mui/material'; import { useSnackbar } from 'notistack'; import React from 'react'; -import { useController, useFormContext } from 'react-hook-form'; +import { useController, useFormContext, useWatch } from 'react-hook-form'; import { FormLabel } from 'src/components/FormLabel'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; +import { useGetLinodeCreateType } from '../Tabs/utils/useGetLinodeCreateType'; import { getDefaultFirewallForInterfacePurpose } from './utilities'; import type { LinodeCreateFormValues } from '../utilities'; @@ -57,6 +59,16 @@ export const InterfaceType = ({ index }: Props) => { name: `linodeInterfaces.${index}.purpose`, }); + const interfaceGeneration = useWatch({ + control, + name: 'interface_generation', + }); + + const createType = useGetLinodeCreateType(); + const isCreatingFromBackup = createType === 'Backups'; + + const disabled = isCreatingFromBackup && interfaceGeneration !== 'linode'; + const onChange = async (value: InterfacePurpose) => { // Change the interface purpose (Public, VPC, VLAN) field.onChange(value); @@ -96,7 +108,20 @@ export const InterfaceType = ({ index }: Props) => { return ( - Network Connection + + Network Connection + {disabled && ( + + )} + The default interface used by this Linode to route network traffic. Additional interfaces can be added after the Linode is created. @@ -109,7 +134,8 @@ export const InterfaceType = ({ index }: Props) => { {interfaceTypes.map((interfaceType) => ( { key={interfaceType.purpose} onClick={() => onChange(interfaceType.purpose)} renderIcon={() => ( - + )} renderVariant={() => ( { const showTwoStepRegion = isGeckoLAEnabled && isDistributedRegionSupported(createType ?? 'OS'); - const onChange = async (region: RegionType) => { + const onChange = async (region: null | RegionType) => { const values = getValues(); - - field.onChange(region.id); + field.onChange(region?.id); if (values.hasSignedEUAgreement) { // Reset the EU agreement checkbox if they checked it so they have to re-agree when they change regions @@ -114,14 +113,14 @@ export const Region = React.memo(() => { if ( values.metadata?.user_data && - !region.capabilities.includes('Metadata') + !region?.capabilities.includes('Metadata') ) { // Clear metadata only if the new region does not support it setValue('metadata.user_data', null); } // Handle maintenance policy based on region capabilities - if (region.capabilities.includes('Maintenance Policy')) { + if (region?.capabilities.includes('Maintenance Policy')) { // If the region supports maintenance policy, set it to the default value // or keep the current value if it's already set if (!values.maintenance_policy) { @@ -140,20 +139,20 @@ export const Region = React.memo(() => { // Because distributed regions do not support some features, // we must disable those features here. Keep in mind, we should // prevent the user from enabling these features in their respective components. - if (region.site_type === 'distributed') { + if (region?.site_type === 'distributed') { setValue('backups_enabled', false); setValue('private_ip', false); } if (isDiskEncryptionFeatureEnabled) { - if (region.site_type === 'distributed') { + if (region?.site_type === 'distributed') { // If a distributed region is selected, make sure we don't send disk_encryption in the payload. setValue('disk_encryption', undefined); } else { // Enable disk encryption by default if the region supports it const defaultDiskEncryptionValue = - region.capabilities.includes('Disk Encryption') || - region.capabilities.includes('LA Disk Encryption') + region?.capabilities.includes('Disk Encryption') || + region?.capabilities.includes('LA Disk Encryption') ? 'enabled' : undefined; @@ -161,7 +160,7 @@ export const Region = React.memo(() => { } } - if (!isLabelFieldDirty) { + if (!isLabelFieldDirty && region) { // Auto-generate the Linode label because the region is included in the generated label const label = await getGeneratedLinodeLabel({ queryClient, diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType.ts index aafbbbe43a3..bf7e308d7a3 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType.ts @@ -28,6 +28,10 @@ export const linodesCreateTypes = Array.from(linodesCreateTypesMap.keys()); export const useGetLinodeCreateType = () => { const { pathname } = useLocation() as { pathname: LinkProps['to'] }; + return getLinodeCreateType(pathname); +}; + +export const getLinodeCreateType = (pathname: LinkProps['to']) => { switch (pathname) { case '/linodes/create/backups': return 'Backups'; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx b/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx index 1b7eaa011c7..13fa7e9915e 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx @@ -61,7 +61,7 @@ const GEOGRAPHICAL_AREA_OPTIONS: GeographicalAreaOption[] = [ ]; interface Props { - onChange: (region: RegionType) => void; + onChange: (region: null | RegionType) => void; } type CombinedProps = Props & Omit, 'onChange'>; @@ -69,8 +69,10 @@ type CombinedProps = Props & Omit, 'onChange'>; export const TwoStepRegion = (props: CombinedProps) => { const { disabled, disabledRegions, errorText, onChange, value } = props; + const [tabIndex, setTabIndex] = React.useState(0); + const [regionFilter, setRegionFilter] = - React.useState('distributed'); + React.useState('distributed-ALL'); const { data: regions } = useRegionsQuery(); const createType = useGetLinodeCreateType(); @@ -97,7 +99,15 @@ export const TwoStepRegion = (props: CombinedProps) => { } /> - + { + if (index !== tabIndex) { + setTabIndex(index); + // M3-9469: Reset region selection when switching between site types + onChange(null); + } + }} + > Core Distributed @@ -120,7 +130,7 @@ export const TwoStepRegion = (props: CombinedProps) => { onChange={(e, region) => onChange(region)} regionFilter="core" regions={regions ?? []} - value={value} + value={value ?? null} /> @@ -131,8 +141,8 @@ export const TwoStepRegion = (props: CombinedProps) => { { if (selectedOption?.value) { @@ -140,9 +150,11 @@ export const TwoStepRegion = (props: CombinedProps) => { } }} options={GEOGRAPHICAL_AREA_OPTIONS} - value={GEOGRAPHICAL_AREA_OPTIONS.find( - (option) => option.value === regionFilter - )} + value={ + GEOGRAPHICAL_AREA_OPTIONS.find( + (option) => option.value === regionFilter + ) ?? null + } /> { onChange={(e, region) => onChange(region)} regionFilter={regionFilter} regions={regions ?? []} - value={value} + value={value ?? null} /> diff --git a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx index fb40be0ac3f..8ae41de6039 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx @@ -71,6 +71,8 @@ export const VPC = () => { : 'Assign this Linode to an existing VPC.'; const createType = useGetLinodeCreateType(); + const isCreatingFromBackup = createType === 'Backups'; + const disabled = !regionSupportsVPCs || isCreatingFromBackup; const vpcFormEventOptions: LinodeCreateFormEventOptions = { createType: createType ?? 'OS', @@ -82,7 +84,16 @@ export const VPC = () => { return ( - VPC + + VPC + {isCreatingFromBackup && ( + + )} + {copy}{' '} { name="interfaces.0.vpc_id" render={({ field, fieldState }) => ( { handleTabChange(index); if (index !== tabIndex) { + const newTab = tabs[index]; + const newLinodeCreateType = getLinodeCreateType(newTab.to); // Get the default values for the new tab and reset the form - defaultValues(linodeCreateType, search, queryClient, { + defaultValues(newLinodeCreateType, search, queryClient, { isLinodeInterfacesEnabled, isVMHostMaintenanceEnabled, }).then(form.reset); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx index 10b3f74db37..9184714117a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx @@ -70,6 +70,38 @@ describe('getLinodeCreatePayload', () => { placement_group: undefined, }); }); + + it('should remove interface from the payload if using legacy interfaces with the new UI and the linode is being created from backups', () => { + const values = { + ...createLinodeRequestFactory.build({ + interface_generation: 'legacy_config', + backup_id: 1, + }), + linodeInterfaces: [{ purpose: 'public', public: {} }], + } as LinodeCreateFormValues; + + expect( + getLinodeCreatePayload(values, { + isShowingNewNetworkingUI: true, + }).interfaces + ).toEqual(undefined); + }); + + it('should not remove interface from the payload when using new interfaces and creating from a backup', () => { + const values = { + ...createLinodeRequestFactory.build({ + interface_generation: 'linode', + backup_id: 1, + }), + linodeInterfaces: [{ purpose: 'public', public: {} }], + } as LinodeCreateFormValues; + + expect( + getLinodeCreatePayload(values, { + isShowingNewNetworkingUI: true, + }).interfaces + ).toEqual([{ public: {}, vpc: null, vlan: null }]); + }); }); describe('getInterfacesPayload', () => { diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts index 9eafb8457d2..204c157d91b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts @@ -98,9 +98,11 @@ export const getLinodeCreatePayload = ( ); values.firewall_id = undefined; } else { - values.interfaces = formValues.linodeInterfaces.map( - getLegacyInterfaceFromLinodeInterface - ); + values.interfaces = formValues.backup_id + ? undefined + : formValues.linodeInterfaces.map( + getLegacyInterfaceFromLinodeInterface + ); } } else { values.interfaces = getInterfacesPayload( diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx index ed4e4aeb4fc..c9323412be5 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx @@ -147,6 +147,7 @@ export const LinodeEntityDetailFooter = React.memo((props: FooterProps) => { // but for the sake of the user experience, we choose to disable the "Add a tag" button in the UI because // restricted users can't see account tags using GET /v4/tags disabled={!permissions.is_account_admin} + entity="Linode" entityLabel={linodeLabel} sx={{ width: '100%', diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx index 418de59a277..c870d9c0c3b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx @@ -93,7 +93,6 @@ describe('LinodeConfigDialog', () => { it('should return a with NATTED_PUBLIC_IP_HELPER_TEXT under the appropriate conditions', () => { const valueReturned = unrecommendedConfigNoticeSelector({ _interface: vpcInterface, - isLKEEnterpriseCluster: false, primaryInterfaceIndex: editableFields.interfaces.findIndex( (element) => element.primary === true ), @@ -114,7 +113,6 @@ describe('LinodeConfigDialog', () => { const valueReturned = unrecommendedConfigNoticeSelector({ _interface: vpcInterfaceWithoutNAT, - isLKEEnterpriseCluster: false, primaryInterfaceIndex: editableFields.interfaces.findIndex( (element) => element.primary === true ), @@ -140,7 +138,6 @@ describe('LinodeConfigDialog', () => { const valueReturned = unrecommendedConfigNoticeSelector({ _interface: vpcInterfacePrimaryWithoutNAT, - isLKEEnterpriseCluster: false, primaryInterfaceIndex: editableFieldsWithSingleInterface.interfaces.findIndex( (element) => element.primary === true @@ -162,7 +159,6 @@ describe('LinodeConfigDialog', () => { const valueReturned = unrecommendedConfigNoticeSelector({ _interface: publicInterface, - isLKEEnterpriseCluster: false, primaryInterfaceIndex: editableFieldsWithoutVPCInterface.interfaces.findIndex( (element) => element.primary === true diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx index 9364ebf9614..b7b1982cd61 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx @@ -44,10 +44,6 @@ import type { JSX } from 'react'; import { FormLabel } from 'src/components/FormLabel'; import { Link } from 'src/components/Link'; -import { - useIsLkeEnterpriseEnabled, - useKubernetesBetaEndpoint, -} from 'src/features/Kubernetes/kubeUtils'; import { DeviceSelection } from 'src/features/Linodes/LinodesDetail/LinodeRescue/DeviceSelection'; import { titlecase } from 'src/features/Linodes/presentation'; import { @@ -55,7 +51,6 @@ import { NATTED_PUBLIC_IP_HELPER_TEXT, NOT_NATTED_HELPER_TEXT, } from 'src/features/VPCs/constants'; -import { useKubernetesClusterQuery } from 'src/queries/kubernetes'; import { handleFieldErrors, handleGeneralErrors, @@ -65,6 +60,7 @@ import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; import { InterfaceSelect } from '../LinodeSettings/InterfaceSelect'; import { KernelSelect } from '../LinodeSettings/KernelSelect'; import { getSelectedDeviceOption } from '../utilities'; +import { deviceSlots, pathsOptions, pathsOptionsLabels } from './constants'; import { StyledDivider, StyledFormControl, @@ -175,17 +171,6 @@ const defaultLegacyInterfaceFieldValues: EditableFields = { interfaces: defaultInterfaceList, }; -const pathsOptions = [ - { label: '/dev/sda', value: '/dev/sda' }, - { label: '/dev/sdb', value: '/dev/sdb' }, - { label: '/dev/sdc', value: '/dev/sdc' }, - { label: '/dev/sdd', value: '/dev/sdd' }, - { label: '/dev/sde', value: '/dev/sde' }, - { label: '/dev/sdf', value: '/dev/sdf' }, - { label: '/dev/sdg', value: '/dev/sdg' }, - { label: '/dev/sdh', value: '/dev/sdh' }, -]; - const interfacesToState = (interfaces?: Interface[] | null) => { if (!interfaces || interfaces.length === 0) { return defaultInterfaceList; @@ -243,7 +228,6 @@ const interfacesToPayload = (interfaces?: ExtendedInterface[] | null) => { return filteredInterfaces as Interface[]; }; -const deviceSlots = ['sda', 'sdb', 'sdc', 'sdd', 'sde', 'sdf', 'sdg', 'sdh']; const deviceCounterDefault = 1; // DiskID reserved on the back-end to indicate Finnix. @@ -254,23 +238,15 @@ export const LinodeConfigDialog = (props: Props) => { const { config, linodeId, onClose, open } = props; const { data: linode } = useLinodeQuery(linodeId, open); + const availableMemory = linode?.specs.memory ?? 0; + if (availableMemory < 0) { + // eslint-disable-next-line no-console + console.warn('Invalid memory value:', availableMemory); + } + const deviceLimit = Math.max(8, Math.min(availableMemory / 1024, 64)); const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); - const { isLkeEnterpriseLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); - - const { isAPLAvailabilityLoading, isUsingBetaEndpoint } = - useKubernetesBetaEndpoint(); - - const { data: cluster } = useKubernetesClusterQuery({ - enabled: - isLkeEnterpriseLAFeatureEnabled && - Boolean(linode?.lke_cluster_id) && - !isAPLAvailabilityLoading, - id: linode?.lke_cluster_id ?? -1, - isUsingBetaEndpoint, - }); - const { enqueueSnackbar } = useSnackbar(); const virtModeCaptionId = React.useId(); @@ -908,7 +884,7 @@ export const LinodeConfigDialog = (props: Props) => { values.devices?.[slot as keyof DevicesAsStrings] ?? '' } onChange={handleDevicesChanges} - slots={deviceSlots} + slots={deviceSlots.slice(0, deviceLimit)} /> { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.test.tsx index dd3411a217c..1946105a39e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.test.tsx @@ -7,7 +7,25 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { VolumesUpgradeBanner } from './VolumesUpgradeBanner'; +const queryMocks = vi.hoisted(() => ({ + usePermissions: vi.fn(), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + describe('VolumesUpgradeBanner', () => { + beforeEach(() => { + queryMocks.usePermissions.mockReturnValue({ + update_volume: true, + }); + }); + it('should render if there is an upgradable volume', async () => { const volume = volumeFactory.build(); const notification = notificationFactory.build({ diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx index 6d5342c218a..82c8e6027d2 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx @@ -39,7 +39,7 @@ import { LinodesLandingEmptyState } from './LinodesLandingEmptyState'; import { ListView } from './ListView'; import type { Action } from '../PowerActionsDialogOrDrawer'; -import type { Config } from '@linode/api-v4/lib/linodes/types'; +import type { Config, PermissionType } from '@linode/api-v4/lib/linodes/types'; import type { APIError } from '@linode/api-v4/lib/types'; import type { AnyRouter, @@ -77,6 +77,9 @@ export interface LinodeHandlers { onOpenResizeDialog: () => void; } +type PermissionsSubset = T; +type LinodesPermissions = PermissionsSubset<'create_linode'>; + export interface LinodesLandingProps { filteredLinodesLoading: boolean; handleRegionFilter: (regionFilter: RegionFilter) => void; @@ -92,7 +95,7 @@ export interface LinodesLandingProps { orderBy: string; sortedData: LinodeWithMaintenance[] | null; }; - permissions: Record; + permissions: Record; regionFilter: RegionFilter; search: SearchParamOptions['search']; someLinodesHaveScheduledMaintenance: boolean; diff --git a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx index d443725d612..b998ebfcfe9 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx @@ -203,7 +203,7 @@ export const ConfigureForm = React.memo((props: Props) => { textFieldProps={{ helperText, }} - value={selectedRegion} + value={selectedRegion ?? null} /> {shouldDisplayPriceComparison && selectedRegion && ( { loading={configsLoading} onChange={(_, option) => setSelectConfigID(option?.value ?? null)} options={configOptions} - value={configOptions.find( - (option) => option.value === selectedConfigID - )} + value={ + configOptions.find((option) => option.value === selectedConfigID) ?? + null + } /> )} {props.action === 'Power Off' && ( diff --git a/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx b/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx index 69ffc219779..f7e8cdc5ffc 100644 --- a/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx +++ b/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx @@ -1,7 +1,6 @@ import { Navigate, useLocation } from '@tanstack/react-router'; import * as React from 'react'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; @@ -30,7 +29,6 @@ export const LoginHistoryLanding = () => { <> - diff --git a/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx b/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx index 7c4b14afe93..cd78b4716bd 100644 --- a/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx +++ b/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx @@ -1,7 +1,6 @@ import { Navigate, useLocation } from '@tanstack/react-router'; import * as React from 'react'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; @@ -30,7 +29,6 @@ export const MaintenanceLanding = () => { <> - diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx index cefd263bc45..6f6850b45d8 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx @@ -13,7 +13,7 @@ import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useTabs } from 'src/hooks/useTabs'; import { getErrorMap } from 'src/utilities/errorUtils'; @@ -38,11 +38,11 @@ export const NodeBalancerDetail = () => { isLoading, } = useNodeBalancerQuery(Number(id), Boolean(id)); - const isNodeBalancerReadOnly = useIsResourceRestricted({ - grantLevel: 'read_only', - grantType: 'nodebalancer', - id: nodebalancer?.id, - }); + const { data: permissions } = usePermissions( + 'nodebalancer', + ['update_nodebalancer'], + nodebalancer?.id + ); const { handleTabChange, tabIndex, tabs } = useTabs([ { @@ -90,13 +90,14 @@ export const NodeBalancerDetail = () => { }, pathname: `/nodebalancers/${nodebalancer.label}`, }} + disabledBreadcrumbEditButton={!permissions.update_nodebalancer} docsLabel="Docs" docsLink="https://techdocs.akamai.com/cloud-computing/docs/getting-started-with-nodebalancers" spacingBottom={4} title={nodebalancer.label} /> {errorMap.none && } - {isNodeBalancerReadOnly && ( + {!permissions.update_nodebalancer && ( ({ .fn() .mockReturnValue({ data: undefined }), useParams: vi.fn().mockReturnValue({}), + userPermissions: vi.fn(() => ({ + data: { + update_nodebalancer: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, })); vi.mock('@tanstack/react-router', async () => { @@ -188,4 +197,31 @@ describe('SummaryPanel', () => { ); }); }); + + it('should disable "Add a tag" if user does not have permission', () => { + const { getByText } = renderWithTheme(, { + flags: { nodebalancerVpc: true }, + }); + + // Tags panel + expect(getByText('Tags')).toBeVisible(); + expect(getByText('Add a tag')).toBeVisible(); + expect(getByText('Add a tag')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should enable "Add a tag" if user has permission', () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + update_nodebalancer: true, + }, + }); + const { getByText } = renderWithTheme(, { + flags: { nodebalancerVpc: true }, + }); + + // Tags panel + expect(getByText('Tags')).toBeVisible(); + expect(getByText('Add a tag')).toBeVisible(); + expect(getByText('Add a tag')).not.toHaveAttribute('aria-disabled', 'true'); + }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx index af29a8d91f9..e2003220d02 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx @@ -15,9 +15,9 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; import { TagCell } from 'src/components/TagCell/TagCell'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useKubernetesBetaEndpoint } from 'src/features/Kubernetes/kubeUtils'; import { IPAddress } from 'src/features/Linodes/LinodesLanding/IPAddress'; -import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useKubernetesClusterQuery } from 'src/queries/kubernetes'; import { useIsNodebalancerVPCEnabled } from '../../utils'; @@ -42,11 +42,11 @@ export const SummaryPanel = () => { ); const displayFirewallLink = !!attachedFirewallData?.data?.length; - const isNodeBalancerReadOnly = useIsResourceRestricted({ - grantLevel: 'read_only', - grantType: 'nodebalancer', - id: nodebalancer?.id, - }); + const { data: permissions } = usePermissions( + 'nodebalancer', + ['update_nodebalancer'], + nodebalancer?.id + ); const flags = useIsNodebalancerVPCEnabled(); @@ -265,7 +265,8 @@ export const SummaryPanel = () => { Tags updateNodeBalancer({ tags })} view="panel" diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx index 0de7d906e43..8de0fbc6cf6 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx @@ -9,6 +9,15 @@ const navigate = vi.fn(); const queryMocks = vi.hoisted(() => ({ useNavigate: vi.fn(() => navigate), useRouter: vi.fn(() => vi.fn()), + userPermissions: vi.fn(() => ({ + data: { + delete_nodebalancer: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, })); vi.mock('@tanstack/react-router', async () => { @@ -41,7 +50,41 @@ describe('NodeBalancerActionMenu', () => { expect(getByText('Delete')).toBeVisible(); }); + it('should disable "Delete" if the user does not have permissions', async () => { + const { getAllByRole, getByText } = renderWithTheme( + + ); + const actionBtn = getAllByRole('button')[0]; + expect(actionBtn).toBeInTheDocument(); + await userEvent.click(actionBtn); + + const deleteBtn = getByText('Delete'); + expect(deleteBtn).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should enable "Delete" if the user has permissions', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + delete_nodebalancer: true, + }, + }); + const { getAllByRole, getByText } = renderWithTheme( + + ); + const actionBtn = getAllByRole('button')[0]; + expect(actionBtn).toBeInTheDocument(); + await userEvent.click(actionBtn); + + const deleteBtn = getByText('Delete'); + expect(deleteBtn).not.toHaveAttribute('aria-disabled', 'true'); + }); + it('triggers the action to delete the NodeBalancer', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + delete_nodebalancer: true, + }, + }); const { getByText } = renderWithTheme( ); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx index b473e8497ed..120b38918da 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx @@ -6,7 +6,7 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useIsNodebalancerVPCEnabled } from '../utils'; @@ -24,11 +24,11 @@ export const NodeBalancerActionMenu = (props: Props) => { const { nodeBalancerId } = props; - const isNodeBalancerReadOnly = useIsResourceRestricted({ - grantLevel: 'read_only', - grantType: 'nodebalancer', - id: nodeBalancerId, - }); + const { data: permissions } = usePermissions( + 'nodebalancer', + ['delete_nodebalancer'], + nodeBalancerId + ); const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled(); @@ -56,7 +56,7 @@ export const NodeBalancerActionMenu = (props: Props) => { title: 'Settings', }, { - disabled: isNodeBalancerReadOnly, + disabled: !permissions.delete_nodebalancer, onClick: () => { navigate({ params: { @@ -66,7 +66,7 @@ export const NodeBalancerActionMenu = (props: Props) => { }); }, title: 'Delete', - tooltip: isNodeBalancerReadOnly + tooltip: !permissions.delete_nodebalancer ? getRestrictedResourceText({ action: 'delete', resourceType: 'NodeBalancers', diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx index 35f1b35eba9..110368fcc8b 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx @@ -11,6 +11,15 @@ import { NodeBalancerTableRow } from './NodeBalancerTableRow'; const navigate = vi.fn(); const queryMocks = vi.hoisted(() => ({ useNavigate: vi.fn(() => navigate), + userPermissions: vi.fn(() => ({ + data: { + delete_nodebalancer: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, })); vi.mock('@tanstack/react-router', async () => { @@ -56,6 +65,11 @@ describe('NodeBalancerTableRow', () => { }); it('deletes the NodeBalancer', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + delete_nodebalancer: true, + }, + }); const { getByText } = renderWithTheme(); const deleteButton = getByText('Delete'); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx index 16187a749a8..72934f64d7a 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.test.tsx @@ -1,5 +1,5 @@ import { nodeBalancerFactory } from '@linode/utilities'; -import { waitForElementToBeRemoved } from '@testing-library/react'; +import { waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import * as React from 'react'; import { makeResourcePage } from 'src/mocks/serverHandlers'; @@ -12,6 +12,15 @@ const queryMocks = vi.hoisted(() => ({ useMatch: vi.fn().mockReturnValue({}), useNavigate: vi.fn(() => vi.fn()), useParams: vi.fn().mockReturnValue({}), + userPermissions: vi.fn(() => ({ + data: { + create_nodebalancer: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, })); vi.mock('@tanstack/react-router', async () => { @@ -83,4 +92,39 @@ describe('NodeBalancersLanding', () => { expect(getByText('IP Address')).toBeVisible(); expect(getByText('Region')).toBeVisible(); }); + + it('should disable the "Create NodeBalancer" button if the user does not have permission', async () => { + const { getByRole } = renderWithTheme(); + + await waitFor(() => { + const createNodeBalancerBtn = getByRole('button', { + name: 'Create NodeBalancer', + }); + + expect(createNodeBalancerBtn).toBeInTheDocument(); + expect(createNodeBalancerBtn).toHaveAttribute('aria-disabled', 'true'); + }); + }); + + it('should enable the "Create NodeBalancer" button if the user has permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + create_nodebalancer: true, + }, + }); + + const { getByRole } = renderWithTheme(); + + await waitFor(() => { + const createNodeBalancerBtn = getByRole('button', { + name: 'Create NodeBalancer', + }); + + expect(createNodeBalancerBtn).toBeInTheDocument(); + expect(createNodeBalancerBtn).not.toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx index 63e62b7d023..5b88ac4b80c 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx @@ -15,9 +15,9 @@ import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell/TableSortCell'; import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { NodeBalancerDeleteDialog } from '../NodeBalancerDeleteDialog'; import { useIsNodebalancerVPCEnabled } from '../utils'; @@ -35,9 +35,10 @@ export const NodeBalancersLanding = () => { initialPage: 1, preferenceKey, }); - const isRestricted = useRestrictedGlobalGrantCheck({ - globalGrantType: 'add_nodebalancers', - }); + + const { data: permissions } = usePermissions('account', [ + 'create_nodebalancer', + ]); const { handleOrderChange, order, orderBy } = useOrderV2({ initialRoute: { @@ -101,7 +102,7 @@ export const NodeBalancersLanding = () => { resourceType: 'NodeBalancers', }), }} - disabledCreateButton={isRestricted} + disabledCreateButton={!permissions.create_nodebalancer} docsLink="https://techdocs.akamai.com/cloud-computing/docs/getting-started-with-nodebalancers" entity="NodeBalancer" onButtonClick={() => navigate({ to: '/nodebalancers/create' })} diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx index b04b3f64eaf..2da3b76b5cc 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.test.tsx @@ -1,13 +1,21 @@ import { waitFor } from '@testing-library/react'; import * as React from 'react'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { NodeBalancerLandingEmptyState } from './NodeBalancersLandingEmptyState'; const queryMocks = vi.hoisted(() => ({ useNavigate: vi.fn(() => vi.fn()), + userPermissions: vi.fn(() => ({ + data: { + create_nodebalancer: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, })); vi.mock('@tanstack/react-router', async () => { @@ -18,29 +26,39 @@ vi.mock('@tanstack/react-router', async () => { }; }); -vi.mock('src/hooks/useRestrictedGlobalGrantCheck'); - // Note: An integration test confirming the helper text and enabled Create NodeBalancer button already exists, so we're just checking for a disabled create button here describe('NodeBalancersLandingEmptyState', () => { - afterEach(() => { - vi.resetAllMocks(); + it('should disable the "Create NodeBalancer" button if the user does not have permission', async () => { + const { getByRole } = renderWithTheme(); + + await waitFor(() => { + const createNodeBalancerBtn = getByRole('button', { + name: 'Create NodeBalancer', + }); + + expect(createNodeBalancerBtn).toBeInTheDocument(); + expect(createNodeBalancerBtn).toHaveAttribute('aria-disabled', 'true'); + }); }); - it('disables the Create NodeBalancer button if user does not have permissions to create a NodeBalancer', async () => { - // disables the create button - vi.mocked(useRestrictedGlobalGrantCheck).mockReturnValue(true); + it('should enable the "Create NodeBalancer" button if the user has permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + create_nodebalancer: true, + }, + }); - const { getByText } = renderWithTheme(); + const { getByRole } = renderWithTheme(); await waitFor(() => { - const createNodeBalancerButton = getByText('Create NodeBalancer').closest( - 'button' - ); + const createNodeBalancerBtn = getByRole('button', { + name: 'Create NodeBalancer', + }); - expect(createNodeBalancerButton).toBeDisabled(); - expect(createNodeBalancerButton).toHaveAttribute( - 'data-qa-tooltip', - "You don't have permissions to create NodeBalancers. Please contact your account administrator to request the necessary permissions." + expect(createNodeBalancerBtn).toBeInTheDocument(); + expect(createNodeBalancerBtn).not.toHaveAttribute( + 'aria-disabled', + 'true' ); }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx index 1f22b671b48..c826cbfa688 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx @@ -5,7 +5,7 @@ import NetworkIcon from 'src/assets/icons/entityIcons/networking.svg'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { sendEvent } from 'src/utilities/analytics/utils'; import { @@ -17,9 +17,10 @@ import { export const NodeBalancerLandingEmptyState = () => { const navigate = useNavigate(); - const isRestricted = useRestrictedGlobalGrantCheck({ - globalGrantType: 'add_nodebalancers', - }); + + const { data: permissions } = usePermissions('account', [ + 'create_nodebalancer', + ]); return ( @@ -28,7 +29,7 @@ export const NodeBalancerLandingEmptyState = () => { buttonProps={[ { children: 'Create NodeBalancer', - disabled: isRestricted, + disabled: !permissions.create_nodebalancer, onClick: () => { sendEvent({ action: 'Click:button', diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx index 44bbd5898f5..e624b21443f 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx @@ -5,6 +5,7 @@ import { } from '@linode/api-v4/lib/object-storage'; import { useAccountSettings } from '@linode/queries'; import { useErrors, useOpenClose } from '@linode/utilities'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -53,6 +54,7 @@ export const AccessKeyLanding = (props: Props) => { openAccessDrawer, } = props; + const navigate = useNavigate(); const pagination = usePaginationV2({ currentRoute: '/object-storage/access-keys', initialPage: 1, @@ -88,6 +90,25 @@ export const AccessKeyLanding = (props: Props) => { const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); + // Redirect to base access keys route if current page has no data + // TODO: Remove this implementation and replace `usePagination` with `usePaginate` hook. See [M3-10442] + React.useEffect(() => { + const currentPage = Number(pagination.page); + + // Only redirect if we have data, no results, and we're not on page 1 + if ( + !isLoading && + data && + (data.results === 0 || data.data.length === 0) && + currentPage > 1 + ) { + navigate({ + to: '/object-storage/access-keys', + search: { page: undefined, pageSize: undefined }, + }); + } + }, [data, isLoading, pagination.page, navigate]); + const handleCreateKey = ( values: CreateObjectStorageKeyPayload, { diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx index 8c4481074a5..8f5efa402cd 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx @@ -50,7 +50,7 @@ export const BucketRegions = (props: Props) => { placeholder="Select a Region" regions={availableStorageRegions ?? []} required={required} - value={selectedRegion} + value={selectedRegion ?? null} /> ); }; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx index 81c8d3bfa60..bcd6d9e77ba 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx @@ -59,7 +59,7 @@ export const ClusterSelect: React.FC = (props) => { placeholder="Select a Region" regions={regionOptions ?? []} required={required} - value={selectedCluster ?? undefined} + value={selectedCluster ?? null} /> ); }; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.test.tsx index 7df7e2b3781..4017cc573b9 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.test.tsx @@ -1,8 +1,6 @@ import { fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { placementGroupFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { PlacementGroupsCreateDrawer } from './PlacementGroupsCreateDrawer'; @@ -110,37 +108,4 @@ describe('PlacementGroupsCreateDrawer', () => { }); }); }); - - it('should display an error message if the region has reached capacity', async () => { - /** - * Note: this unit test assumes regions are mocked from the MSW's serverHandles.ts - * and that us-west has special limits - */ - queryMocks.useAllPlacementGroupsQuery.mockReturnValue({ - data: [placementGroupFactory.build({ region: 'us-west' })], - }); - const regionWithoutCapacity = 'US, Fremont, CA (us-west)'; - - const { findByText, getByPlaceholderText, getByRole } = renderWithTheme( - - ); - - const regionSelect = getByPlaceholderText('Select a Region'); - - await userEvent.click(regionSelect); - - const regionWithNoCapacityOption = await findByText(regionWithoutCapacity); - - await userEvent.click(regionWithNoCapacityOption); - - const tooltip = getByRole('tooltip'); - - await waitFor(() => { - expect(tooltip.textContent).toContain( - 'You’ve reached the limit of placement groups you can create in this region.' - ); - }); - - expect(tooltip).toBeVisible(); - }); }); diff --git a/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx b/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx index c201a319858..c3b5dee843a 100644 --- a/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx @@ -9,12 +9,17 @@ import type { PermissionType } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; +type PermissionsSubset = T; +type OAuthClientPermissions = PermissionsSubset< + 'delete_oauth_client' | 'reset_oauth_client_secret' | 'update_oauth_client' +>; + interface Props { label: string; onOpenDeleteDialog: () => void; onOpenEditDrawer: () => void; onOpenResetDialog: () => void; - permissions: Partial>; + permissions: Record; } export const OAuthClientActionMenu = (props: Props) => { diff --git a/packages/manager/src/features/Profile/Profile.tsx b/packages/manager/src/features/Profile/Profile.tsx index cab8bb3b3ea..1825a7b8929 100644 --- a/packages/manager/src/features/Profile/Profile.tsx +++ b/packages/manager/src/features/Profile/Profile.tsx @@ -8,11 +8,13 @@ import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; +import { useFlags } from 'src/hooks/useFlags'; import { useTabs } from 'src/hooks/useTabs'; export const Profile = () => { const location = useLocation(); const navigate = useNavigate(); + const { iamRbacPrimaryNavChanges } = useFlags(); const { tabs, handleTabChange, tabIndex } = useTabs([ { @@ -40,12 +42,14 @@ export const Profile = () => { title: 'OAuth Apps', }, { - to: `/profile/referrals`, - title: 'Referrals', + to: iamRbacPrimaryNavChanges + ? `/profile/preferences` + : `/profile/referrals`, + title: iamRbacPrimaryNavChanges ? 'Preferences' : 'Referrals', }, { - to: `/profile/settings`, - title: 'My Settings', + to: iamRbacPrimaryNavChanges ? `/profile/referrals` : `/profile/settings`, + title: iamRbacPrimaryNavChanges ? 'Referrals' : 'My Settings', }, ]); diff --git a/packages/manager/src/features/Profile/Settings/Settings.tsx b/packages/manager/src/features/Profile/Settings/Settings.tsx index fe8b9575367..6174d15904e 100644 --- a/packages/manager/src/features/Profile/Settings/Settings.tsx +++ b/packages/manager/src/features/Profile/Settings/Settings.tsx @@ -3,6 +3,7 @@ import { useNavigate, useSearch } from '@tanstack/react-router'; import React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { useFlags } from 'src/hooks/useFlags'; import { MaskSensitiveData } from './MaskSensitiveData'; import { Notifications } from './Notifications'; @@ -13,20 +14,29 @@ import { TypeToConfirm } from './TypeToConfirm'; export const ProfileSettings = () => { const navigate = useNavigate(); - const { preferenceEditor } = useSearch({ from: '/profile/settings' }); + const { iamRbacPrimaryNavChanges } = useFlags(); + const { preferenceEditor } = useSearch({ + from: iamRbacPrimaryNavChanges + ? '/profile/preferences' + : '/profile/settings', + }); const isPreferenceEditorOpen = !!preferenceEditor; const handleClosePreferenceEditor = () => { navigate({ - to: '/profile/settings', + to: iamRbacPrimaryNavChanges + ? '/profile/preferences' + : '/profile/settings', search: { preferenceEditor: undefined }, }); }; return ( <> - + diff --git a/packages/manager/src/features/Profile/Settings/settingsLazyRoute.ts b/packages/manager/src/features/Profile/Settings/settingsLazyRoute.ts index ccb73884b46..33e561dae36 100644 --- a/packages/manager/src/features/Profile/Settings/settingsLazyRoute.ts +++ b/packages/manager/src/features/Profile/Settings/settingsLazyRoute.ts @@ -5,3 +5,11 @@ import { ProfileSettings } from './Settings'; export const settingsLazyRoute = createLazyRoute('/profile/settings')({ component: ProfileSettings, }); + +/** + * @todo As part of the IAM Primary Nav flag (iamRbacPrimaryNavChanges) cleanup, /profile/settings will be removed. + * Adding the lazy route in this file will also require the necessary cleanup work, such as renaming the file and removing settingsLazyRoute(/profile/settings), as part of the flag cleanup. + */ +export const preferencesLazyRoute = createLazyRoute('/profile/preferences')({ + component: ProfileSettings, +}); diff --git a/packages/manager/src/features/Quotas/QuotasLanding.tsx b/packages/manager/src/features/Quotas/QuotasLanding.tsx index f0aa7d4f89b..eafc6f2237f 100644 --- a/packages/manager/src/features/Quotas/QuotasLanding.tsx +++ b/packages/manager/src/features/Quotas/QuotasLanding.tsx @@ -1,7 +1,6 @@ import { Navigate, useLocation } from '@tanstack/react-router'; import * as React from 'react'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; import { PlatformMaintenanceBanner } from 'src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; @@ -36,7 +35,6 @@ export const QuotasLanding = () => { <> - diff --git a/packages/manager/src/features/Settings/settingsLandingLazyRoute.ts b/packages/manager/src/features/Settings/settingsLandingLazyRoute.ts deleted file mode 100644 index e73ddbfed13..00000000000 --- a/packages/manager/src/features/Settings/settingsLandingLazyRoute.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createLazyRoute } from '@tanstack/react-router'; - -import { SettingsLanding } from './SettingsLanding'; - -export const settingsLandingLazyRoute = createLazyRoute('/settings')({ - component: SettingsLanding, -}); diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx index 3b5a841a146..aed316a400e 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx @@ -32,28 +32,10 @@ interface MenuLink { to: string; } -const profileLinks: MenuLink[] = [ - { - display: 'Display', - to: '/profile/display', - }, - { display: 'Login & Authentication', to: '/profile/auth' }, - { display: 'SSH Keys', to: '/profile/keys' }, - { display: 'LISH Console Settings', to: '/profile/lish' }, - { - display: 'API Tokens', - to: '/profile/tokens', - }, - { display: 'OAuth Apps', to: '/profile/clients' }, - { display: 'Referrals', to: '/profile/referrals' }, - { display: 'My Settings', to: '/profile/settings' }, - { display: 'Log Out', to: '/logout' }, -]; - export const UserMenuPopover = (props: UserMenuPopoverProps) => { const { anchorEl, isDrawerOpen, onClose, onDrawerOpen } = props; const sessionContext = React.useContext(switchAccountSessionContext); - const flags = useFlags(); + const { iamRbacPrimaryNavChanges, limitsEvolution } = useFlags(); const theme = useTheme(); const { data: account } = useAccount(); @@ -73,6 +55,32 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { const open = Boolean(anchorEl); const id = open ? 'user-menu-popover' : undefined; + const profileLinks: MenuLink[] = [ + { + display: 'Display', + to: '/profile/display', + }, + { display: 'Login & Authentication', to: '/profile/auth' }, + { display: 'SSH Keys', to: '/profile/keys' }, + { display: 'LISH Console Settings', to: '/profile/lish' }, + { + display: 'API Tokens', + to: '/profile/tokens', + }, + { display: 'OAuth Apps', to: '/profile/clients' }, + { + display: iamRbacPrimaryNavChanges ? 'Preferences' : 'Referrals', + to: iamRbacPrimaryNavChanges + ? '/profile/preferences' + : '/profile/referrals', + }, + { + display: iamRbacPrimaryNavChanges ? 'Referrals' : 'My Settings', + to: iamRbacPrimaryNavChanges ? '/profile/referrals' : '/profile/settings', + }, + { display: 'Log Out', to: '/logout' }, + ]; + // Used for fetching parent profile and account data by making a request with the parent's token. const proxyHeaders = isProxyUser ? { @@ -93,50 +101,50 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { () => [ { display: 'Billing', - to: flags?.iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', + to: iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', }, { display: - flags?.iamRbacPrimaryNavChanges && isIAMEnabled + iamRbacPrimaryNavChanges && isIAMEnabled ? 'Identity & Access' : 'Users & Grants', to: - flags?.iamRbacPrimaryNavChanges && isIAMEnabled + iamRbacPrimaryNavChanges && isIAMEnabled ? '/iam' - : flags?.iamRbacPrimaryNavChanges && !isIAMEnabled + : iamRbacPrimaryNavChanges && !isIAMEnabled ? '/users' : '/account/users', - isBeta: flags?.iamRbacPrimaryNavChanges && isIAMEnabled, + isBeta: iamRbacPrimaryNavChanges && isIAMEnabled, }, { display: 'Quotas', - hide: !flags.limitsEvolution?.enabled, - to: flags?.iamRbacPrimaryNavChanges ? '/quotas' : '/account/quotas', + hide: !limitsEvolution?.enabled, + to: iamRbacPrimaryNavChanges ? '/quotas' : '/account/quotas', }, { display: 'Login History', - to: flags?.iamRbacPrimaryNavChanges + to: iamRbacPrimaryNavChanges ? '/login-history' : '/account/login-history', }, { display: 'Service Transfers', - to: flags?.iamRbacPrimaryNavChanges + to: iamRbacPrimaryNavChanges ? '/service-transfers' : '/account/service-transfers', }, { display: 'Maintenance', - to: flags?.iamRbacPrimaryNavChanges - ? '/maintenance' - : '/account/maintenance', + to: iamRbacPrimaryNavChanges ? '/maintenance' : '/account/maintenance', }, { - display: 'Settings', - to: flags?.iamRbacPrimaryNavChanges ? '/settings' : '/account/settings', + display: iamRbacPrimaryNavChanges ? 'Account Settings' : 'Settings', + to: iamRbacPrimaryNavChanges + ? '/account-settings' + : '/account/settings', }, ], - [isIAMEnabled, flags] + [isIAMEnabled, iamRbacPrimaryNavChanges, limitsEvolution] ); const renderLink = (link: MenuLink) => { @@ -246,7 +254,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { - {flags?.iamRbacPrimaryNavChanges ? 'Administration' : 'Account'} + {iamRbacPrimaryNavChanges ? 'Administration' : 'Account'} { <> - diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx index 662c6484091..345739ffd01 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx @@ -37,10 +37,10 @@ describe('VPC Top Section form content', () => { expect(screen.getByText('VPC Label')).toBeVisible(); expect(screen.getByText('Description')).toBeVisible(); // @TODO VPC IPv6: Remove this check once VPC IPv6 is in GA - expect(screen.queryByText('Networking IP Stack')).not.toBeInTheDocument(); + expect(screen.queryByText('IP Stack')).not.toBeInTheDocument(); }); - it('renders a Networking IP Stack section with IPv4 pre-checked if the vpcIpv6 feature flag is enabled', async () => { + it('renders an IP Stack section with IPv4 pre-checked if the vpcIpv6 feature flag is enabled', async () => { const account = accountFactory.build({ capabilities: ['VPC Dual Stack'], }); @@ -66,7 +66,7 @@ describe('VPC Top Section form content', () => { }); await waitFor(() => { - expect(screen.getByText('Networking IP Stack')).toBeVisible(); + expect(screen.getByText('IP Stack')).toBeVisible(); }); const NetworkingIPStackRadios = screen.getAllByRole('radio'); @@ -100,7 +100,7 @@ describe('VPC Top Section form content', () => { }); await waitFor(() => { - expect(screen.getByText('Networking IP Stack')).toBeVisible(); + expect(screen.getByText('IP Stack')).toBeVisible(); }); const NetworkingIPStackRadios = screen.getAllByRole('radio'); @@ -140,7 +140,7 @@ describe('VPC Top Section form content', () => { }); await waitFor(() => { - expect(screen.getByText('Networking IP Stack')).toBeVisible(); + expect(screen.getByText('IP Stack')).toBeVisible(); }); const NetworkingIPStackRadios = screen.getAllByRole('radio'); diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx index 7ab330ae87b..bd0d864a009 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx @@ -138,7 +138,7 @@ export const VPCTopSectionContent = (props: Props) => { /> {isDualStackEnabled && ( - Networking IP Stack + IP Stack { sm: 12, xs: 12, }} - heading="IPv4 + IPv6 (Dual Stack)" + heading="IPv4 + IPv6 (dual-stack)" onClick={() => { field.onChange([ { diff --git a/packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.tsx b/packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.tsx index 7779d00aee0..f441f69f92a 100644 --- a/packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.tsx @@ -1,4 +1,4 @@ -import { useAttachVolumeMutation, useGrants } from '@linode/queries'; +import { useAttachVolumeMutation } from '@linode/queries'; import { LinodeSelect } from '@linode/shared'; import { ActionsPanel, @@ -16,6 +16,7 @@ import { number, object } from 'yup'; import { BLOCK_STORAGE_ENCRYPTION_SETTING_IMMUTABLE_COPY } from 'src/components/Encryption/constants'; import { useIsBlockStorageEncryptionFeatureEnabled } from 'src/components/Encryption/utils'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useEventsPollingActions } from 'src/queries/events/events'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; @@ -46,7 +47,13 @@ export const AttachVolumeDrawer = React.memo((props: Props) => { const { checkForNewEvents } = useEventsPollingActions(); - const { data: grants } = useGrants(); + const { data: permissions } = usePermissions( + 'volume', + ['attach_volume'], + volume?.id + ); + + const canAttachVolume = permissions?.attach_volume; const { error, mutateAsync: attachVolume } = useAttachVolumeMutation(); @@ -86,11 +93,6 @@ export const AttachVolumeDrawer = React.memo((props: Props) => { overwrite: 'Overwrite', }; - const isReadOnly = - grants !== undefined && - grants.volume.find((grant) => grant.id === volume?.id)?.permissions === - 'read_only'; - const hasErrorFor = getAPIErrorFor( errorResources, error === null ? undefined : error @@ -107,7 +109,7 @@ export const AttachVolumeDrawer = React.memo((props: Props) => { title={`Attach Volume ${volume?.label}`} >
- {isReadOnly && ( + {!canAttachVolume && ( { {generalError && } { )} { { const { isFetching, onClose: _onClose, open, volume, volumeError } = props; + const { data: accountPermissions } = usePermissions('account', [ + 'create_volume', + ]); + const { data: volumePermissions } = usePermissions( + 'volume', + ['clone_volume'], + volume?.id + ); + const canCloneVolume = + volumePermissions?.clone_volume && accountPermissions?.create_volume; + const { mutateAsync: cloneVolume } = useCloneVolumeMutation(); const { checkForNewEvents } = useEventsPollingActions(); - const { data: grants } = useGrants(); const { data: types, isError, isLoading } = useVolumeTypesQuery(); const { isBlockStorageEncryptionFeatureEnabled } = useIsBlockStorageEncryptionFeatureEnabled(); - // Even if a restricted user has the ability to create Volumes, they - // can't clone a Volume they only have read only permission on. - const isReadOnly = - grants !== undefined && - grants.volume.find((grant) => grant.id === volume?.id)?.permissions === - 'read_only'; - const isInvalidPrice = !types || isError; const { @@ -104,7 +104,7 @@ export const CloneVolumeDrawer = (props: Props) => { title="Clone Volume" > - {isReadOnly && ( + {!canCloneVolume && ( { be available in {volume?.region}.
{ /> { const { isFetching, onClose: _onClose, open, volume, volumeError } = props; - const { data: grants } = useGrants(); + const { data: permissions } = usePermissions( + 'volume', + ['update_volume'], + volume?.id + ); + const canUpdateVolume = permissions?.update_volume; const { mutateAsync: updateVolume } = useUpdateVolumeMutation(); const { isBlockStorageEncryptionFeatureEnabled } = useIsBlockStorageEncryptionFeatureEnabled(); - const isReadOnly = - grants !== undefined && - grants.volume.find((grant) => grant.id === volume?.id)?.permissions === - 'read_only'; - const { dirty, errors, @@ -91,7 +92,7 @@ export const EditVolumeDrawer = (props: Props) => { title="Edit Volume" > - {isReadOnly && ( + {!canUpdateVolume && ( { {error && } { { const { isFetching, onClose: _onClose, open, volume, volumeError } = props; - const { data: grants } = useGrants(); + const { data: permissions } = usePermissions( + 'volume', + ['update_volume'], + volume?.id + ); + const canUpdateVolume = permissions?.update_volume; const { mutateAsync: updateVolume } = useUpdateVolumeMutation(); - const isReadOnly = - grants !== undefined && - grants.volume.find((grant) => grant.id === volume?.id)?.permissions === - 'read_only'; - const { control, formState: { errors, isDirty, isSubmitting }, @@ -76,7 +77,7 @@ export const ManageTagsDrawer = (props: Props) => { title="Manage Volume Tags" > - {isReadOnly && ( + {!canUpdateVolume && ( { name="tags" render={({ field, fieldState }) => ( @@ -104,7 +105,7 @@ export const ManageTagsDrawer = (props: Props) => { { const { isFetching, onClose: _onClose, open, volume, volumeError } = props; + const { data: permissions } = usePermissions( + 'volume', + ['resize_volume'], + volume?.id + ); + const canResizeVolume = permissions?.resize_volume; + const { mutateAsync: resizeVolume } = useResizeVolumeMutation(); const { checkForNewEvents } = useEventsPollingActions(); @@ -40,14 +44,8 @@ export const ResizeVolumeDrawer = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); - const { data: grants } = useGrants(); const { data: types, isError, isLoading } = useVolumeTypesQuery(); - const isReadOnly = - grants !== undefined && - grants.volume.find((grant) => grant.id === volume?.id)?.permissions === - 'read_only'; - const isInvalidPrice = !types || isError; const { @@ -102,7 +100,7 @@ export const ResizeVolumeDrawer = (props: Props) => { title="Resize Volume" > - {isReadOnly && ( + {!canResizeVolume && ( { )} {error && } { /> { const { linode, onClose, setClientLibraryCopyVisible } = props; - const { data: grants } = useGrants(); - const { enqueueSnackbar } = useSnackbar(); const { checkForNewEvents } = useEventsPollingActions(); - const linodeGrant = grants?.linode.find( - (grant: Grant) => grant.id === linode.id - ); - - const isReadOnly = linodeGrant?.permissions === 'read_only'; - const { mutateAsync: attachVolume } = useAttachVolumeMutation(); const { @@ -102,6 +90,13 @@ export const LinodeVolumeAttachForm = (props: Props) => { values.volume_id !== -1 ); + const { data: permissions } = usePermissions( + 'volume', + ['attach_volume'], + volume?.id + ); + const canAttachVolume = permissions?.attach_volume; + const linodeRequiresClientLibraryUpdate = volume?.encryption === 'enabled' && Boolean(!linode.capabilities?.includes('Block Storage Encryption')); @@ -113,7 +108,7 @@ export const LinodeVolumeAttachForm = (props: Props) => { return ( - {isReadOnly && ( + {!canAttachVolume && ( { )} {error && } { value={values.volume_id} /> { /> { + const { data: permissions } = usePermissions('account', ['create_volume']); + return ( { } data-qa-radio="Create and Attach Volume" + disabled={!permissions.create_volume} label="Create and Attach Volume" value="create" /> diff --git a/packages/manager/src/features/Volumes/VolumeCreate.tsx b/packages/manager/src/features/Volumes/VolumeCreate.tsx index ae1641de646..6d4bbf5d0e5 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.tsx @@ -1,7 +1,6 @@ import { useAccountAgreements, useCreateVolumeMutation, - useGrants, useLinodeQuery, useMutateAccountAgreements, useProfile, @@ -58,6 +57,7 @@ import { import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; import { reportAgreementSigningError } from 'src/utilities/reportAgreementSigningError'; +import { usePermissions } from '../IAM/hooks/usePermissions'; import { SIZE_FIELD_WIDTH } from './constants'; import { ConfigSelect } from './Drawers/VolumeDrawer/ConfigSelect'; import { SizeField } from './Drawers/VolumeDrawer/SizeField'; @@ -134,7 +134,8 @@ export const VolumeCreate = () => { const { data: types, isError, isLoading } = useVolumeTypesQuery(); const { data: profile } = useProfile(); - const { data: grants } = useGrants(); + + const { data: permissions } = usePermissions('account', ['create_volume']); const { data: regions } = useRegionsQuery(); const { isGeckoLAEnabled } = useIsGeckoEnabled( @@ -169,9 +170,6 @@ export const VolumeCreate = () => { ) .map((thisRegion) => thisRegion.id) ?? []; - const doesNotHavePermission = - profile?.restricted && !grants?.global.add_volumes; - const renderSelectTooltip = (tooltipText: string) => { return ( { const isInvalidPrice = !types || isError; + const canCreateVolume = permissions?.create_volume; + const disabled = Boolean( - doesNotHavePermission || + !canCreateVolume || (showGDPRCheckbox && !hasSignedAgreement) || isInvalidPrice ); @@ -348,7 +348,7 @@ export const VolumeCreate = () => { }} title="Create" /> - {doesNotHavePermission && ( + {!canCreateVolume && ( { { /> @@ -419,7 +419,7 @@ export const VolumeCreate = () => { { > { )}
{ { + const navigate = useNavigate(); + const { volumeSummaryPage } = useFlags(); + const { volumeId } = useParams({ from: '/volumes/$volumeId' }); + const { data: volume, isLoading, error } = useVolumeQuery(volumeId); + const { tabs, handleTabChange, tabIndex, getTabIndex } = useTabs([ + { + to: '/volumes/$volumeId/summary', + title: 'Summary', + }, + ]); + + if (!volumeSummaryPage || error) { + return ; + } + + if (isLoading || !volume) { + return ; + } + + if (location.pathname === `/volumes/${volumeId}`) { + navigate({ to: `/volumes/${volumeId}/summary` }); + } + + return ( + <> + + + + + }> + + + + + + + + + ); +}; diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetail.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetail.tsx new file mode 100644 index 00000000000..ccbabeda79d --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetail.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; + +import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; + +import { VolumeEntityDetailBody } from './VolumeEntityDetailBody'; +import { VolumeEntityDetailFooter } from './VolumeEntityDetailFooter'; +import { VolumeEntityDetailHeader } from './VolumeEntityDetailHeader'; + +import type { Volume } from '@linode/api-v4'; + +interface Props { + volume: Volume; +} + +export const VolumeEntityDetail = ({ volume }: Props) => { + return ( + } + footer={} + header={} + /> + ); +}; diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailBody.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailBody.tsx new file mode 100644 index 00000000000..d4d9c9e24c2 --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailBody.tsx @@ -0,0 +1,147 @@ +import { useProfile, useRegionsQuery } from '@linode/queries'; +import { Box, Typography } from '@linode/ui'; +import { getFormattedStatus } from '@linode/utilities'; +import Grid from '@mui/material/Grid'; +import { useTheme } from '@mui/material/styles'; +import React from 'react'; + +import Lock from 'src/assets/icons/lock.svg'; +import Unlock from 'src/assets/icons/unlock.svg'; +import { Link } from 'src/components/Link'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { formatDate } from 'src/utilities/formatDate'; + +import { volumeStatusIconMap } from '../../utils'; + +import type { Volume } from '@linode/api-v4'; + +interface Props { + volume: Volume; +} + +export const VolumeEntityDetailBody = ({ volume }: Props) => { + const theme = useTheme(); + const { data: profile } = useProfile(); + const { data: regions } = useRegionsQuery(); + + const regionLabel = + regions?.find((region) => region.id === volume.region)?.label ?? + volume.region; + + return ( + + + + Status + + + ({ font: theme.font.bold })}> + {getFormattedStatus(volume.status)} + + + + + Size + ({ font: theme.font.bold })}> + {volume.size} GB + + + + Created + ({ font: theme.font.bold })}> + {formatDate(volume.created, { + timezone: profile?.timezone, + })} + + + + + + + Volume ID + ({ font: theme.font.bold })}> + {volume.id} + + + + Region + ({ font: theme.font.bold })}> + {regionLabel} + + + + Volume Label + ({ font: theme.font.bold })}> + {volume.label} + + + + + + + Attached To + ({ font: theme.font.bold })}> + {volume.linode_id !== null ? ( + + {volume.linode_label} + + ) : ( + 'Unattached' + )} + + + + + Encryption + + {volume.encryption === 'enabled' ? ( + <> + + ({ font: theme.font.bold })}> + Encrypted + + + ) : ( + <> + + ({ font: theme.font.bold })}> + Not Encrypted + + + )} + + + + + ); +}; diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailFooter.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailFooter.tsx new file mode 100644 index 00000000000..eb974fe1c4b --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailFooter.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { TagCell } from 'src/components/TagCell/TagCell'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; + +interface Props { + tags: string[]; +} + +export const VolumeEntityDetailFooter = ({ tags }: Props) => { + const isReadOnlyAccountAccess = useRestrictedGlobalGrantCheck({ + globalGrantType: 'account_access', + permittedGrantLevel: 'read_write', + }); + + return ( + Promise.resolve()} + view="inline" + /> + ); +}; diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailHeader.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailHeader.tsx new file mode 100644 index 00000000000..1810b7f7c38 --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailHeader.tsx @@ -0,0 +1,29 @@ +import { Box } from '@linode/ui'; +import { Typography } from '@linode/ui'; +import React from 'react'; + +import { EntityHeader } from 'src/components/EntityHeader/EntityHeader'; + +import type { Volume } from '@linode/api-v4'; + +interface Props { + volume: Volume; +} + +export const VolumeEntityDetailHeader = ({ volume }: Props) => { + return ( + + ({ + display: 'flex', + alignItems: 'center', + padding: `${theme.spacingFunction(6)} 0 ${theme.spacingFunction(6)} ${theme.spacingFunction(16)}`, + })} + > + ({ font: theme.font.bold })}> + Summary + + + + ); +}; diff --git a/packages/manager/src/features/Volumes/VolumeDetails/volumeLandingLazyRoute.ts b/packages/manager/src/features/Volumes/VolumeDetails/volumeLandingLazyRoute.ts new file mode 100644 index 00000000000..9b2a5bf5ed1 --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumeDetails/volumeLandingLazyRoute.ts @@ -0,0 +1,7 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { VolumeDetails } from './VolumeDetails'; + +export const volumeDetailsLazyRoute = createLazyRoute('/volumes/$volumeId')({ + component: VolumeDetails, +}); diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx index 9042d4d5b95..0cf318243ab 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx @@ -34,7 +34,30 @@ const handlers: ActionHandlers = { handleUpgrade: vi.fn(), }; +const queryMocks = vi.hoisted(() => ({ + usePermissions: vi.fn(), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + describe('Volume table row', () => { + beforeEach(() => { + queryMocks.usePermissions.mockReturnValue({ + update_volume: true, + attach_volume: true, + create_volume: true, + delete_volume: true, + resize_volume: true, + clone_volume: true, + }); + }); + it("should show the attached Linode's label if present", async () => { const { getByLabelText, getByTestId, getByText } = renderWithTheme( wrapWithTableBody( diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.tsx index 069aac9d68c..6266476908b 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.tsx @@ -10,6 +10,7 @@ import { Link } from 'src/components/Link'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { useFlags } from 'src/hooks/useFlags'; import { useInProgressEvents } from 'src/queries/events/events'; import { HighPerformanceVolumeIcon } from '../Linodes/HighPerformanceVolumeIcon'; @@ -53,6 +54,7 @@ export const VolumeTableRow = React.memo((props: Props) => { const { data: regions } = useRegionsQuery(); const { data: notifications } = useNotificationsQuery(); const { data: inProgressEvents } = useInProgressEvents(); + const { volumeSummaryPage } = useFlags(); const isVolumesLanding = !isDetailsPageRow; @@ -124,20 +126,39 @@ export const VolumeTableRow = React.memo((props: Props) => { wrap: 'nowrap', }} > - ({ - alignItems: 'center', - display: 'flex', - gap: theme.spacing(), - })} - > - {volume.label} - {linodeCapabilities && ( - - )} - + {volumeSummaryPage ? ( + + ({ + alignItems: 'center', + display: 'flex', + gap: theme.spacingFunction(8), + })} + > + {volume.label} + {linodeCapabilities && ( + + )} + + + ) : ( + ({ + alignItems: 'center', + display: 'flex', + gap: theme.spacingFunction(8), + })} + > + {volume.label} + {linodeCapabilities && ( + + )} + + )} {isEligibleForUpgradeToNVMe && ( ({ + usePermissions: vi.fn(), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + describe('Volume action menu', () => { + beforeEach(() => { + queryMocks.usePermissions.mockReturnValue({ + update_volume: true, + attach_volume: true, + create_volume: true, + delete_volume: true, + resize_volume: true, + clone_volume: true, + }); + }); + it('should include basic Volume actions', async () => { const { getByLabelText, getByText } = renderWithTheme( diff --git a/packages/manager/src/features/Volumes/VolumesActionMenu.tsx b/packages/manager/src/features/Volumes/VolumesActionMenu.tsx index fc797e971fd..bf2defa3ba6 100644 --- a/packages/manager/src/features/Volumes/VolumesActionMenu.tsx +++ b/packages/manager/src/features/Volumes/VolumesActionMenu.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import type { Volume } from '@linode/api-v4'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; @@ -30,11 +30,22 @@ export const VolumesActionMenu = (props: Props) => { const attached = volume.linode_id !== null; - const isVolumeReadOnly = useIsResourceRestricted({ - grantLevel: 'read_only', - grantType: 'volume', - id: volume.id, - }); + const { data: accountPermissions } = usePermissions('account', [ + 'create_volume', + ]); + const { data: volumePermissions } = usePermissions( + 'volume', + [ + 'delete_volume', + 'view_volume', + 'resize_volume', + 'clone_volume', + 'attach_volume', + 'detach_volume', + 'update_volume', + ], + volume.id + ); const actions: Action[] = [ { @@ -42,10 +53,10 @@ export const VolumesActionMenu = (props: Props) => { title: 'Show Config', }, { - disabled: isVolumeReadOnly, + disabled: !volumePermissions?.update_volume, onClick: handlers.handleEdit, title: 'Edit', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.update_volume ? getRestrictedResourceText({ action: 'edit', isSingular: true, @@ -54,15 +65,15 @@ export const VolumesActionMenu = (props: Props) => { : undefined, }, { - disabled: isVolumeReadOnly, + disabled: !volumePermissions?.update_volume, onClick: handlers.handleManageTags, title: 'Manage Tags', }, { - disabled: isVolumeReadOnly, + disabled: !volumePermissions?.resize_volume, onClick: handlers.handleResize, title: 'Resize', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.resize_volume ? getRestrictedResourceText({ action: 'resize', isSingular: true, @@ -71,10 +82,11 @@ export const VolumesActionMenu = (props: Props) => { : undefined, }, { - disabled: isVolumeReadOnly, + disabled: + !volumePermissions?.clone_volume || !accountPermissions?.create_volume, onClick: handlers.handleClone, title: 'Clone', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.clone_volume ? getRestrictedResourceText({ action: 'clone', isSingular: true, @@ -86,10 +98,10 @@ export const VolumesActionMenu = (props: Props) => { if (!attached && isVolumesLanding) { actions.push({ - disabled: isVolumeReadOnly, + disabled: !volumePermissions?.attach_volume, onClick: handlers.handleAttach, title: 'Attach', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.attach_volume ? getRestrictedResourceText({ action: 'attach', isSingular: true, @@ -99,10 +111,10 @@ export const VolumesActionMenu = (props: Props) => { }); } else { actions.push({ - disabled: isVolumeReadOnly, + disabled: !volumePermissions?.detach_volume, onClick: handlers.handleDetach, title: 'Detach', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.detach_volume ? getRestrictedResourceText({ action: 'detach', isSingular: true, @@ -113,10 +125,10 @@ export const VolumesActionMenu = (props: Props) => { } actions.push({ - disabled: isVolumeReadOnly || attached, + disabled: !volumePermissions?.delete_volume || attached, onClick: handlers.handleDelete, title: 'Delete', - tooltip: isVolumeReadOnly + tooltip: !volumePermissions?.delete_volume ? getRestrictedResourceText({ action: 'delete', isSingular: true, diff --git a/packages/manager/src/features/Volumes/VolumesLanding.tsx b/packages/manager/src/features/Volumes/VolumesLanding.tsx index ccd400a75c6..45805258fd8 100644 --- a/packages/manager/src/features/Volumes/VolumesLanding.tsx +++ b/packages/manager/src/features/Volumes/VolumesLanding.tsx @@ -18,9 +18,9 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableSortCell } from 'src/components/TableSortCell'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { VOLUME_TABLE_DEFAULT_ORDER, VOLUME_TABLE_DEFAULT_ORDER_BY, @@ -50,6 +50,8 @@ export const VolumesLanding = () => { from: '/volumes/', shouldThrow: false, }); + const { data: permissions } = usePermissions('account', ['create_volume']); + const pagination = usePaginationV2({ currentRoute: '/volumes', preferenceKey: VOLUME_TABLE_PREFERENCE_KEY, @@ -58,9 +60,8 @@ export const VolumesLanding = () => { query: search?.query, }), }); - const isVolumeCreationRestricted = useRestrictedGlobalGrantCheck({ - globalGrantType: 'add_volumes', - }); + + const canCreateVolume = permissions?.create_volume; const { handleOrderChange, order, orderBy } = useOrderV2({ initialRoute: { @@ -169,7 +170,7 @@ export const VolumesLanding = () => { resourceType: 'Volumes', }), }} - disabledCreateButton={isVolumeCreationRestricted} + disabledCreateButton={!canCreateVolume} docsLink="https://techdocs.akamai.com/cloud-computing/docs/block-storage" entity="Volume" onButtonClick={() => navigate({ to: '/volumes/create' })} diff --git a/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx b/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx index 85d3ac47d11..13f35803b5b 100644 --- a/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx +++ b/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx @@ -4,8 +4,8 @@ import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { StyledBucketIcon } from 'src/features/ObjectStorage/BucketLanding/StylesBucketIcon'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { sendEvent } from 'src/utilities/analytics/utils'; import { @@ -17,10 +17,7 @@ import { export const VolumesLandingEmptyState = () => { const navigate = useNavigate(); - - const isRestricted = useRestrictedGlobalGrantCheck({ - globalGrantType: 'add_volumes', - }); + const { data: permissions } = usePermissions('account', ['create_volume']); return ( <> @@ -29,7 +26,7 @@ export const VolumesLandingEmptyState = () => { buttonProps={[ { children: 'Create Volume', - disabled: isRestricted, + disabled: !permissions?.create_volume, onClick: () => { sendEvent({ action: 'Click:button', diff --git a/packages/manager/src/mocks/presets/crud/datastream.ts b/packages/manager/src/mocks/presets/crud/datastream.ts index 8820ded8dd7..313d1721c1c 100644 --- a/packages/manager/src/mocks/presets/crud/datastream.ts +++ b/packages/manager/src/mocks/presets/crud/datastream.ts @@ -1,15 +1,28 @@ import { createDestinations, createStreams, + deleteDestination, + deleteStream, getDestinations, getStreams, + updateDestination, + updateStream, } from 'src/mocks/presets/crud/handlers/datastream'; import type { MockPresetCrud } from 'src/mocks/types'; export const datastreamCrudPreset: MockPresetCrud = { group: { id: 'DataStream' }, - handlers: [getStreams, createStreams, getDestinations, createDestinations], + handlers: [ + getStreams, + createStreams, + deleteStream, + updateStream, + getDestinations, + createDestinations, + deleteDestination, + updateDestination, + ], id: 'datastream:crud', label: 'Data Stream CRUD', }; diff --git a/packages/manager/src/mocks/presets/crud/handlers/datastream.ts b/packages/manager/src/mocks/presets/crud/handlers/datastream.ts index cf49a542407..74bde7244ca 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/datastream.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/datastream.ts @@ -5,6 +5,7 @@ import { destinationFactory, streamFactory } from 'src/factories/datastream'; import { mswDB } from 'src/mocks/indexedDB'; import { queueEvents } from 'src/mocks/utilities/events'; import { + makeErrorResponse, makeNotFoundResponse, makePaginatedResponse, makeResponse, @@ -92,6 +93,84 @@ export const createStreams = (mockState: MockState) => [ ), ]; +export const updateStream = (mockState: MockState) => [ + http.put( + '*/v4beta/monitor/streams/:id', + async ({ + params, + request, + }): Promise> => { + const id = Number(params.id); + const stream = await mswDB.get('streams', id); + + if (!stream) { + return makeNotFoundResponse(); + } + + const destinations = await mswDB.getAll('destinations'); + const payload = await request.clone().json(); + const updatedStream = { + ...stream, + ...payload, + destinations: payload['destinations'].map((destinationId: number) => + destinations?.find(({ id }) => id === destinationId) + ), + updated: DateTime.now().toISO(), + }; + + await mswDB.update('streams', id, updatedStream, mockState); + + queueEvents({ + event: { + action: 'stream_update', + entity: { + id: stream.id, + label: stream.label, + type: 'stream', + url: `/v4beta/monitor/streams/${stream.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(updatedStream); + } + ), +]; + +export const deleteStream = (mockState: MockState) => [ + http.delete( + '*/v4beta/monitor/streams/:id', + async ({ params }): Promise> => { + const id = Number(params.id); + const stream = await mswDB.get('streams', id); + + if (!stream) { + return makeNotFoundResponse(); + } + + await mswDB.delete('streams', id, mockState); + + queueEvents({ + event: { + action: 'stream_delete', + entity: { + id: stream.id, + label: stream.label, + type: 'domain', + url: `/v4beta/monitor/streams/${stream.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse({}); + } + ), +]; + export const getDestinations = () => [ http.get( '*/v4beta/monitor/streams/destinations', @@ -164,3 +243,92 @@ export const createDestinations = (mockState: MockState) => [ } ), ]; + +export const updateDestination = (mockState: MockState) => [ + http.put( + '*/v4beta/monitor/streams/destinations/:id', + async ({ + params, + request, + }): Promise> => { + const id = Number(params.id); + const destination = await mswDB.get('destinations', id); + + if (!destination) { + return makeNotFoundResponse(); + } + + const payload = await request.clone().json(); + const [majorVersion, minorVersion] = destination.version.split('.'); + const updatedDestination = { + ...destination, + ...payload, + version: `${majorVersion}.${+minorVersion + 1}`, + updated: DateTime.now().toISO(), + }; + + await mswDB.update('destinations', id, updatedDestination, mockState); + + queueEvents({ + event: { + action: 'destination_update', + entity: { + id: destination.id, + label: destination.label, + type: 'stream', + url: `/v4beta/monitor/streams/${destination.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(updatedDestination); + } + ), +]; + +export const deleteDestination = (mockState: MockState) => [ + http.delete( + '*/v4beta/monitor/streams/destinations/:id', + async ({ params }): Promise> => { + const id = Number(params.id); + const destination = await mswDB.get('destinations', id); + const streams = await mswDB.getAll('streams'); + const currentlyAttachedDestinations = new Set( + streams?.flatMap(({ destinations }) => + destinations?.map(({ id }) => id) + ) + ); + + if (!destination) { + return makeNotFoundResponse(); + } + + if (currentlyAttachedDestinations.has(id)) { + return makeErrorResponse( + `Destination with id ${id} is attached to a stream and cannot be deleted`, + 409 + ); + } + + await mswDB.delete('destinations', id, mockState); + + queueEvents({ + event: { + action: 'destination_delete', + entity: { + id: destination.id, + label: destination.label, + type: 'domain', + url: `/v4beta/monitor/streams/${destination.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse({}); + } + ), +]; diff --git a/packages/manager/src/mocks/presets/extra/account/customProfile.ts b/packages/manager/src/mocks/presets/extra/account/customProfile.ts deleted file mode 100644 index 10651dc1b8a..00000000000 --- a/packages/manager/src/mocks/presets/extra/account/customProfile.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { profileFactory } from '@linode/utilities'; -import { http } from 'msw'; - -import { makeResponse } from 'src/mocks/utilities/response'; - -import type { Profile } from '@linode/api-v4'; -import type { MockPresetExtra } from 'src/mocks/types'; - -let customProfileData: null | Profile = null; - -export const setCustomProfileData = (data: null | Profile) => { - customProfileData = data; -}; - -const mockCustomProfile = () => { - return [ - http.get('*/v4*/profile', async () => { - return makeResponse( - customProfileData - ? { ...profileFactory.build(), ...customProfileData } - : profileFactory.build() - ); - }), - ]; -}; - -export const customProfilePreset: MockPresetExtra = { - desc: 'Custom Profile', - group: { id: 'Profile', type: 'profile' }, - handlers: [mockCustomProfile], - id: 'profile:custom', - label: 'Custom Profile', -}; diff --git a/packages/manager/src/mocks/presets/extra/account/customProfileAndGrants.ts b/packages/manager/src/mocks/presets/extra/account/customProfileAndGrants.ts new file mode 100644 index 00000000000..d4dab180794 --- /dev/null +++ b/packages/manager/src/mocks/presets/extra/account/customProfileAndGrants.ts @@ -0,0 +1,50 @@ +import { grantsFactory, profileFactory } from '@linode/utilities'; +import { http } from 'msw'; + +import { makeResponse } from 'src/mocks/utilities/response'; + +import type { Grants, Profile } from '@linode/api-v4'; +import type { MockPresetExtra } from 'src/mocks/types'; + +let customProfileData: null | Profile = null; +let customGrantsData: Grants | null = null; + +export const setCustomProfileData = (data: null | Profile) => { + customProfileData = data; +}; + +export const setCustomGrantsData = (data: Grants | null) => { + customGrantsData = data; +}; + +const mockCustomProfile = () => { + return [ + http.get('*/v4*/profile', async () => { + return makeResponse( + customProfileData + ? { ...profileFactory.build(), ...customProfileData } + : profileFactory.build() + ); + }), + ]; +}; + +const mockCustomGrants = () => { + return [ + http.get('*/v4*/grants', async () => { + return makeResponse( + customGrantsData + ? { ...grantsFactory.build(), ...customGrantsData } + : grantsFactory.build() + ); + }), + ]; +}; + +export const customProfileAndGrantsPreset: MockPresetExtra = { + desc: 'Custom Profile and Grants', + group: { id: 'Profile & Grants', type: 'profile & grants' }, + handlers: [mockCustomProfile, mockCustomGrants], + id: 'profile-grants:custom', + label: 'Custom Profile and Grants', +}; diff --git a/packages/manager/src/mocks/presets/index.ts b/packages/manager/src/mocks/presets/index.ts index 8a1d91d40b1..20dd84566c8 100644 --- a/packages/manager/src/mocks/presets/index.ts +++ b/packages/manager/src/mocks/presets/index.ts @@ -9,7 +9,7 @@ import { customAccountPreset } from './extra/account/customAccount'; import { customEventsPreset } from './extra/account/customEvents'; import { customMaintenancePreset } from './extra/account/customMaintenance'; import { customNotificationsPreset } from './extra/account/customNotifications'; -import { customProfilePreset } from './extra/account/customProfile'; +import { customProfileAndGrantsPreset } from './extra/account/customProfileAndGrants'; import { managedDisabledPreset } from './extra/account/managedDisabled'; import { managedEnabledPreset } from './extra/account/managedEnabled'; import { apiResponseTimePreset } from './extra/api/api'; @@ -45,7 +45,7 @@ export const baselineMockPresets: MockPresetBaseline[] = [ export const extraMockPresets: MockPresetExtra[] = [ apiResponseTimePreset, customAccountPreset, - customProfilePreset, + customProfileAndGrantsPreset, customEventsPreset, customUserAccountPermissionsPreset, customUserEntityPermissionsPreset, diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 6d7ce7a4f91..233c360405e 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -58,6 +58,8 @@ import { firewallDeviceFactory, firewallEntityfactory, firewallFactory, + firewallMetricDefinitionsResponse, + firewallMetricRulesFactory, imageFactory, incidentResponseFactory, invoiceFactory, @@ -739,6 +741,9 @@ export const handlers = [ }), http.get('*/linode/instances', async ({ request }) => { linodeFactory.resetSequenceNumber(); + const linodesWithFirewalls = linodeFactory.buildList(10, { + region: 'ap-west', + }); const metadataLinodeWithCompatibleImage = linodeFactory.build({ image: 'metadata-test-image', label: 'metadata-test-image', @@ -813,8 +818,19 @@ export const handlers = [ region: 'us-east', id: 1005, }), + linodeFactory.build({ + label: 'aclp-supported-region-linode-3', + region: 'us-iad', + id: 1006, + }), ]; + const linodeFirewall = linodeFactory.build({ + region: 'ap-west', + label: 'Linode-firewall-test', + id: 90909, + }); const linodes = [ + ...linodesWithFirewalls, ...mtcLinodes, ...aclpSupportedRegionLinodes, nonMTCPlanInMTCSupportedRegionsLinode, @@ -867,6 +883,7 @@ export const handlers = [ }), eventLinode, multipleIPLinode, + linodeFirewall, ]; if (request.headers.get('x-filter')) { @@ -877,20 +894,63 @@ export const handlers = [ let filteredLinodes = linodes; // Default to the original linodes in case no filters are applied - // filter the linodes based on id or region if (andFilters?.length) { - filteredLinodes = filteredLinodes.filter((linode) => { - const filteredById = andFilters.every( - (filter: { id: number }) => filter.id === linode.id - ); - const filteredByRegion = andFilters.every( - (filter: { region: string }) => filter.region === linode.region - ); + // Check if this is a combined filter structure (multiple filter groups with +or arrays) + const hasCombinedFilter = andFilters.some( + (filterGroup: any) => + filterGroup['+or'] && Array.isArray(filterGroup['+or']) + ); - return filteredById || filteredByRegion; - }); + if (hasCombinedFilter) { + // Handle combined filter structure for CloudPulse alerts + filteredLinodes = filteredLinodes.filter((linode) => { + return andFilters.every((filterGroup: any) => { + // Handle id filter group + if (filterGroup['+or'] && Array.isArray(filterGroup['+or'])) { + const idFilters = filterGroup['+or'].filter( + (f) => f.id !== undefined + ); + const regionFilters = filterGroup['+or'].filter( + (f) => f.region !== undefined + ); + + // Check if linode matches any id in the id filter group + const matchesId = + idFilters.length === 0 || + idFilters.some((f) => Number(f.id) === linode.id); + + // Check if linode matches any region in the region filter group + const matchesRegion = + regionFilters.length === 0 || + regionFilters.some((f) => f.region === linode.region); + + return matchesId && matchesRegion; + } + + return false; + }); + }); + } else { + // Handle legacy andFilters for other use cases + filteredLinodes = filteredLinodes.filter((linode) => { + const filteredById = andFilters.every( + (filter: { id: number }) => filter.id === linode.id + ); + const filteredByRegion = andFilters.every( + (filter: { region: string }) => filter.region === linode.region + ); + + return filteredById || filteredByRegion; + }); + } } + // The legacy id/region filtering logic has been removed here because it + // duplicated the work done above and incorrectly trimmed results when a + // newer "combined" filter structure (an array of "+or" groups inside + // "+and") was supplied. For legacy consumers the filtering is handled + // in the `else` branch above (lines ~922–934). + // after the linodes are filtered based on region, filter the region-filtered linodes based on selected tags if any if (orFilters?.length) { filteredLinodes = filteredLinodes.filter((linode) => { @@ -939,19 +999,58 @@ export const handlers = [ }), ]; const linodeAclpSupportedRegionDetails = [ + /** Whether a Linode is ACLP-subscribed can be determined using the useIsLinodeAclpSubscribed hook. */ + + // 1. Example: ACLP-subscribed Linode in an ACLP-supported region (mock Linode ID: 1004) linodeFactory.build({ id, backups: { enabled: false }, label: 'aclp-supported-region-linode-1', region: 'us-iad', - alerts: { user: [100, 101], system: [200] }, + alerts: { + user: [21, 22, 23, 24, 25], + system: [19, 20], + cpu: 0, + io: 0, + network_in: 0, + network_out: 0, + transfer_quota: 0, + }, }), + // 2. Example: Linode not subscribed to ACLP in an ACLP-supported region (mock Linode ID: 1005) linodeFactory.build({ id, backups: { enabled: false }, label: 'aclp-supported-region-linode-2', region: 'us-east', - alerts: { user: [], system: [] }, + alerts: { + user: [], + system: [], + cpu: 10, + io: 10000, + network_in: 0, + network_out: 0, + transfer_quota: 80, + }, + }), + // 3. Example: Linode in an ACLP-supported region with NO enabled alerts (mock Linode ID: 1006) + // - Whether this Linode is ACLP-subscribed depends on the ACLP release stage: + // a. Beta stage: NOT subscribed to ACLP + // b. GA stage: Subscribed to ACLP + linodeFactory.build({ + id, + backups: { enabled: false }, + label: 'aclp-supported-region-linode-3', + region: 'us-iad', + alerts: { + user: [], + system: [], + cpu: 0, + io: 0, + network_in: 0, + network_out: 0, + transfer_quota: 0, + }, }), ]; const linodeNonMTCPlanInMTCSupportedRegionsDetail = linodeFactory.build({ @@ -986,6 +1085,8 @@ export const handlers = [ return linodeAclpSupportedRegionDetails[0]; case 1005: return linodeAclpSupportedRegionDetails[1]; + case 1006: + return linodeAclpSupportedRegionDetails[2]; default: return linodeDetail; } @@ -1113,6 +1214,12 @@ export const handlers = [ label: 'Linode-123', }), }), + firewallEntityfactory.build({ + type: 'linode', + label: 'Linode-firewall-test', + parent_entity: null, + id: 90909, + }), ], }), ]; @@ -2733,11 +2840,19 @@ export const handlers = [ alertFactory.resetSequenceNumber(); return HttpResponse.json({ data: [ - ...alertFactory.buildList(20, { + ...alertFactory.buildList(18, { + rule_criteria: { + rules: alertRulesFactory.buildList(2), + }, + service_type: serviceType === 'dbaas' ? 'dbaas' : 'linode', + }), + // Mocked 2 alert definitions associated with mock Linode ID '1004' (aclp-supported-region-linode-1) + ...alertFactory.buildList(2, { rule_criteria: { rules: alertRulesFactory.buildList(2), }, service_type: serviceType === 'dbaas' ? 'dbaas' : 'linode', + entity_ids: ['1004'], }), ...alertFactory.buildList(6, { service_type: serviceType === 'dbaas' ? 'dbaas' : 'linode', @@ -2808,12 +2923,35 @@ export const handlers = [ type: 'user', updated_by: 'user1', }), + alertFactory.build({ + id: 999, + label: 'Firewall - testing', + service_type: 'firewall', + type: 'user', + created_by: 'user1', + rule_criteria: { + rules: [firewallMetricRulesFactory.build()], + }, + }), ]; return HttpResponse.json(makeResourcePage(alerts)); }), http.get( '*/monitor/services/:serviceType/alert-definitions/:id', ({ params }) => { + if (params.id === '999' && params.serviceType === 'firewall') { + return HttpResponse.json( + alertFactory.build({ + id: 999, + label: 'Firewall - testing', + service_type: 'firewall', + type: 'user', + rule_criteria: { + rules: [firewallMetricRulesFactory.build()], + }, + }) + ); + } if (params.id !== undefined) { return HttpResponse.json( alertFactory.build({ @@ -2828,6 +2966,7 @@ export const handlers = [ }, service_type: params.serviceType === 'linode' ? 'linode' : 'dbaas', type: 'user', + scope: pickRandom(['account', 'region', 'entity']), }) ); } @@ -2837,6 +2976,19 @@ export const handlers = [ http.put( '*/monitor/services/:serviceType/alert-definitions/:id', ({ params, request }) => { + if (params.id === '999' && params.serviceType === 'firewall') { + return HttpResponse.json( + alertFactory.build({ + id: 999, + label: 'Firewall - testing', + service_type: 'firewall', + type: 'user', + rule_criteria: { + rules: [firewallMetricRulesFactory.build()], + }, + }) + ); + } const body: any = request.json(); return HttpResponse.json( alertFactory.build({ @@ -3228,6 +3380,9 @@ export const handlers = [ }, ], }; + if (params.serviceType === 'firewall') { + return HttpResponse.json({ data: firewallMetricDefinitionsResponse }); + } if (params.serviceType === 'nodebalancer') { return HttpResponse.json(nodebalancerMetricsResponse); } @@ -3255,7 +3410,7 @@ export const handlers = [ dashboardLabel = 'NodeBalancer Service I/O Statistics'; } else if (id === '4') { serviceType = 'firewall'; - dashboardLabel = 'Linode Service I/O Statistics'; + dashboardLabel = 'Firewall Service I/O Statistics'; } else { serviceType = 'linode'; dashboardLabel = 'Linode Service I/O Statistics'; @@ -3263,7 +3418,7 @@ export const handlers = [ const response = { created: '2024-04-29T17:09:29', - id: params.id, + id: Number(params.id), label: dashboardLabel, service_type: serviceType, type: 'standard', diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts index d365c38aa20..6c57084afb5 100644 --- a/packages/manager/src/mocks/types.ts +++ b/packages/manager/src/mocks/types.ts @@ -78,7 +78,7 @@ export type MockPresetExtraGroupId = | 'Maintenance' | 'Managed' | 'Notifications' - | 'Profile' + | 'Profile & Grants' | 'Regions' | 'User Permissions'; @@ -88,7 +88,7 @@ export type MockPresetExtraGroupType = | 'events' | 'maintenance' | 'notifications' - | 'profile' + | 'profile & grants' | 'select' | 'userPermissions'; @@ -102,7 +102,7 @@ export type MockPresetExtraId = | 'limits:lke-limits' | 'maintenance:custom' | 'notifications:custom' - | 'profile:custom' + | 'profile-grants:custom' | 'regions:core-and-distributed' | 'regions:core-only' | 'regions:legacy' diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts index 1a1bdac1fc2..8b78ef91fa8 100644 --- a/packages/manager/src/queries/cloudpulse/alerts.ts +++ b/packages/manager/src/queries/cloudpulse/alerts.ts @@ -252,10 +252,44 @@ export const useServiceAlertsMutation = ( mutationFn: (payload: CloudPulseAlertsPayload) => { return updateServiceAlerts(serviceType, entityId, payload); }, - onSuccess() { + onSuccess(_, payload) { + const allAlerts = queryClient.getQueryData( + queryFactory.alerts._ctx.all().queryKey + ); + + // Get alerts previously enabled for this entity + const oldEnabledAlertIds = + allAlerts + ?.filter((alert) => alert.entity_ids.includes(entityId)) + .map((alert) => alert.id) || []; + + // Combine enabled user and system alert IDs from payload + const newEnabledAlertIds = [ + ...(payload.user ?? []), + ...(payload.system ?? []), + ]; + + // Get unique list of all enabled alert IDs for cache invalidation + const alertIdsToInvalidate = Array.from( + new Set([...oldEnabledAlertIds, ...newEnabledAlertIds]) + ); + queryClient.invalidateQueries({ queryKey: queryFactory.resources(serviceType).queryKey, }); + + queryClient.invalidateQueries({ + queryKey: queryFactory.alerts._ctx.all().queryKey, + }); + + alertIdsToInvalidate.forEach((alertId) => { + queryClient.invalidateQueries({ + queryKey: queryFactory.alerts._ctx.alertByServiceTypeAndId( + serviceType, + String(alertId) + ).queryKey, + }); + }); }, }); }; diff --git a/packages/manager/src/routes/account/index.ts b/packages/manager/src/routes/account/index.ts index aec45d398cb..8a4b97e3bae 100644 --- a/packages/manager/src/routes/account/index.ts +++ b/packages/manager/src/routes/account/index.ts @@ -142,7 +142,7 @@ const accountSettingsRoute = createRoute({ beforeLoad: ({ context }) => { if (context?.flags?.iamRbacPrimaryNavChanges) { throw redirect({ - to: `/settings`, + to: `/account-settings`, replace: true, }); } diff --git a/packages/manager/src/routes/settings/SettingsRoute.tsx b/packages/manager/src/routes/accountSettings/AccountSettingsRoute.tsx similarity index 90% rename from packages/manager/src/routes/settings/SettingsRoute.tsx rename to packages/manager/src/routes/accountSettings/AccountSettingsRoute.tsx index 14e0369843e..58ff4bf6410 100644 --- a/packages/manager/src/routes/settings/SettingsRoute.tsx +++ b/packages/manager/src/routes/accountSettings/AccountSettingsRoute.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; -export const SettingsRoute = () => { +export const AccountSettingsRoute = () => { return ( }> diff --git a/packages/manager/src/routes/accountSettings/index.ts b/packages/manager/src/routes/accountSettings/index.ts new file mode 100644 index 00000000000..8b398567130 --- /dev/null +++ b/packages/manager/src/routes/accountSettings/index.ts @@ -0,0 +1,42 @@ +import { createRoute, redirect } from '@tanstack/react-router'; + +import { rootRoute } from '../root'; +import { AccountSettingsRoute } from './AccountSettingsRoute'; + +const accountSettingsRoute = createRoute({ + component: AccountSettingsRoute, + getParentRoute: () => rootRoute, + path: 'account-settings', +}); + +// Catch all route for account-settings page +const accountSettingsCatchAllRoute = createRoute({ + getParentRoute: () => accountSettingsRoute, + path: '/$invalidPath', + beforeLoad: () => { + throw redirect({ to: '/account-settings' }); + }, +}); + +// Index route: /account-settings (main settings content) +const accountSettingsIndexRoute = createRoute({ + getParentRoute: () => accountSettingsRoute, + path: '/', + beforeLoad: ({ context }) => { + if (!context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/account/settings`, + replace: true, + }); + } + }, +}).lazy(() => + import('src/features/AccountSettings/accountSettingsLandingLazyRoute').then( + (m) => m.accountSettingsLandingLazyRoute + ) +); + +export const accountSettingsRouteTree = accountSettingsRoute.addChildren([ + accountSettingsIndexRoute, + accountSettingsCatchAllRoute, +]); diff --git a/packages/manager/src/routes/datastream/index.ts b/packages/manager/src/routes/datastream/index.ts index 7f421ed2fdc..992795c7767 100644 --- a/packages/manager/src/routes/datastream/index.ts +++ b/packages/manager/src/routes/datastream/index.ts @@ -43,10 +43,27 @@ const streamsCreateRoute = createRoute({ path: 'streams/create', }).lazy(() => import( - 'src/features/DataStream/Streams/StreamCreate/streamCreateLazyRoute' + 'src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute' ).then((m) => m.streamCreateLazyRoute) ); +const streamsEditRoute = createRoute({ + getParentRoute: () => dataStreamRoute, + params: { + parse: ({ streamId }: { streamId: string }) => ({ + streamId: Number(streamId), + }), + stringify: ({ streamId }: { streamId: number }) => ({ + streamId: String(streamId), + }), + }, + path: 'streams/$streamId/edit', +}).lazy(() => + import('src/features/DataStream/Streams/StreamForm/streamEditLazyRoute').then( + (m) => m.streamEditLazyRoute + ) +); + export interface DestinationSearchParams extends TableSearchParams { label?: string; } @@ -66,12 +83,32 @@ const destinationsCreateRoute = createRoute({ path: 'destinations/create', }).lazy(() => import( - 'src/features/DataStream/Destinations/DestinationCreate/destinationCreateLazyRoute' + 'src/features/DataStream/Destinations/DestinationForm/destinationCreateLazyRoute' ).then((m) => m.destinationCreateLazyRoute) ); +const destinationsEditRoute = createRoute({ + getParentRoute: () => dataStreamRoute, + params: { + parse: ({ destinationId }: { destinationId: string }) => ({ + destinationId: Number(destinationId), + }), + stringify: ({ destinationId }: { destinationId: number }) => ({ + destinationId: String(destinationId), + }), + }, + path: 'destinations/$destinationId/edit', +}).lazy(() => + import( + 'src/features/DataStream/Destinations/DestinationForm/destinationEditLazyRoute' + ).then((m) => m.destinationEditLazyRoute) +); + export const dataStreamRouteTree = dataStreamRoute.addChildren([ dataStreamLandingRoute, - streamsRoute.addChildren([streamsCreateRoute]), - destinationsRoute.addChildren([destinationsCreateRoute]), + streamsRoute.addChildren([streamsCreateRoute, streamsEditRoute]), + destinationsRoute.addChildren([ + destinationsCreateRoute, + destinationsEditRoute, + ]), ]); diff --git a/packages/manager/src/routes/index.tsx b/packages/manager/src/routes/index.tsx index 6fe0a84eba3..ef2b52210b0 100644 --- a/packages/manager/src/routes/index.tsx +++ b/packages/manager/src/routes/index.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { ErrorComponent } from 'src/features/ErrorBoundary/ErrorComponent'; import { accountRouteTree } from './account'; +import { accountSettingsRouteTree } from './accountSettings'; import { cloudPulseAlertsRouteTree } from './alerts'; import { cancelLandingRoute, @@ -37,7 +38,6 @@ import { quotasRouteTree } from './quotas'; import { rootRoute } from './root'; import { searchRouteTree } from './search'; import { serviceTransfersRouteTree } from './serviceTransfers'; -import { settingsRouteTree } from './settings'; import { stackScriptsRouteTree } from './stackscripts'; import { supportRouteTree } from './support'; import { usersAndGrantsRouteTree } from './usersAndGrants'; @@ -56,6 +56,7 @@ const indexRoute = createRoute({ export const routeTree = rootRoute.addChildren([ indexRoute, + accountSettingsRouteTree, cancelLandingRoute, loginAsCustomerCallbackRoute, logoutRoute, @@ -85,7 +86,6 @@ export const routeTree = rootRoute.addChildren([ quotasRouteTree, searchRouteTree, serviceTransfersRouteTree, - settingsRouteTree, stackScriptsRouteTree, supportRouteTree, usersAndGrantsRouteTree, diff --git a/packages/manager/src/routes/profile/index.ts b/packages/manager/src/routes/profile/index.ts index 681c2a52a0d..879b6950833 100644 --- a/packages/manager/src/routes/profile/index.ts +++ b/packages/manager/src/routes/profile/index.ts @@ -1,4 +1,4 @@ -import { createRoute } from '@tanstack/react-router'; +import { createRoute, redirect } from '@tanstack/react-router'; import { rootRoute } from '../root'; import { ProfileRoute } from './ProfileRoute'; @@ -92,7 +92,38 @@ const profileReferralsRoute = createRoute({ ) ); +/** + * The new route /profile/preferences aligns with the Profile tab, which has been renamed to Preferences (My Settings). + * After the transition, and as part of the cleanup, we will be removing /profile/settings (profileSettingsRoute). + */ + +const profilePreferencesRoute = createRoute({ + beforeLoad: ({ context }) => { + if (!context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/profile/settings`, + replace: true, + }); + } + }, + getParentRoute: () => profileRoute, + path: 'preferences', + validateSearch: (search: ProfileSettingsSearchParams) => search, +}).lazy(() => + import('src/features/Profile/Settings/settingsLazyRoute').then( + (m) => m.preferencesLazyRoute + ) +); + const profileSettingsRoute = createRoute({ + beforeLoad: ({ context }) => { + if (context?.flags?.iamRbacPrimaryNavChanges) { + throw redirect({ + to: `/profile/preferences`, + replace: true, + }); + } + }, getParentRoute: () => profileRoute, path: 'settings', validateSearch: (search: ProfileSettingsSearchParams) => search, @@ -109,6 +140,7 @@ export const profileRouteTree = profileRoute.addChildren([ profileLishSettingsRoute, profileAPITokensRoute, profileOAuthClientsRoute, + profilePreferencesRoute, profileReferralsRoute, profileSettingsRoute, ]); diff --git a/packages/manager/src/routes/settings/index.ts b/packages/manager/src/routes/settings/index.ts deleted file mode 100644 index 47cf6c5aca6..00000000000 --- a/packages/manager/src/routes/settings/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createRoute, redirect } from '@tanstack/react-router'; - -import { rootRoute } from '../root'; -import { SettingsRoute } from './SettingsRoute'; - -const settingsRoute = createRoute({ - component: SettingsRoute, - getParentRoute: () => rootRoute, - path: 'settings', -}); - -// Catch all route for settings page -const settingsCatchAllRoute = createRoute({ - getParentRoute: () => settingsRoute, - path: '/$invalidPath', - beforeLoad: () => { - throw redirect({ to: '/settings' }); - }, -}); - -// Index route: /settings (main settings content) -const settingsIndexRoute = createRoute({ - getParentRoute: () => settingsRoute, - path: '/', - beforeLoad: ({ context }) => { - if (!context?.flags?.iamRbacPrimaryNavChanges) { - throw redirect({ - to: `/account/settings`, - replace: true, - }); - } - }, -}).lazy(() => - import('src/features/Settings/settingsLandingLazyRoute').then( - (m) => m.settingsLandingLazyRoute - ) -); - -export const settingsRouteTree = settingsRoute.addChildren([ - settingsIndexRoute, - settingsCatchAllRoute, -]); diff --git a/packages/manager/src/routes/volumes/index.ts b/packages/manager/src/routes/volumes/index.ts index 14cee80fd5a..1209a9622e2 100644 --- a/packages/manager/src/routes/volumes/index.ts +++ b/packages/manager/src/routes/volumes/index.ts @@ -29,6 +29,24 @@ const volumesRoute = createRoute({ path: 'volumes', }); +const volumeDetailsRoute = createRoute({ + getParentRoute: () => volumesRoute, + parseParams: (params) => ({ + volumeId: Number(params.volumeId), + }), + // validateSearch: (search: VolumesSearchParams) => search, + path: '$volumeId', +}).lazy(() => + import('src/features/Volumes/VolumeDetails/volumeLandingLazyRoute').then( + (m) => m.volumeDetailsLazyRoute + ) +); + +const volumeDetailsSummaryRoute = createRoute({ + getParentRoute: () => volumeDetailsRoute, + path: 'summary', +}); + const volumesIndexRoute = createRoute({ getParentRoute: () => volumesRoute, path: '/', @@ -96,4 +114,5 @@ export const volumesRouteTree = volumesRoute.addChildren([ volumesIndexRoute.addChildren([volumeActionRoute]), volumesCreateRoute, volumesCatchAllRoute, + volumeDetailsRoute.addChildren([volumeDetailsSummaryRoute]), ]); diff --git a/packages/manager/src/testSetup.ts b/packages/manager/src/testSetup.ts index a34d2363bd6..7b221df7d12 100644 --- a/packages/manager/src/testSetup.ts +++ b/packages/manager/src/testSetup.ts @@ -61,6 +61,13 @@ global.ResizeObserver = class ResizeObserver { unobserve() {} }; +// @ts-expect-error Mock IntersectionObserver for tests +global.IntersectionObserver = class IntersectionObserver { + disconnect() {} + observe() {} + unobserve() {} +}; + /** *************************************** * Custom matchers & matchers overrides diff --git a/packages/manager/src/utilities/pricing/kubernetes.ts b/packages/manager/src/utilities/pricing/kubernetes.ts index 99c3cec1c4a..d1f7d7cab14 100644 --- a/packages/manager/src/utilities/pricing/kubernetes.ts +++ b/packages/manager/src/utilities/pricing/kubernetes.ts @@ -3,15 +3,15 @@ import { getLinodeRegionPrice } from './linodes'; import type { CreateNodePoolData, KubeNodePoolResponse, + LinodeType, Region, } from '@linode/api-v4/lib'; -import type { ExtendedType } from 'src/utilities/extendType'; interface MonthlyPriceOptions { count: number; region: Region['id'] | undefined; - type: ExtendedType | string; - types: ExtendedType[]; + type: LinodeType | string; + types: LinodeType[]; } interface TotalClusterPriceOptions { @@ -19,7 +19,7 @@ interface TotalClusterPriceOptions { highAvailabilityPrice?: number; pools: (CreateNodePoolData | KubeNodePoolResponse)[]; region: Region['id'] | undefined; - types: ExtendedType[]; + types: LinodeType[]; } /** @@ -35,7 +35,7 @@ export const getKubernetesMonthlyPrice = ({ if (!types || !type || !region) { return undefined; } - const thisType = types.find((t: ExtendedType) => t.id === type); + const thisType = types.find((t) => t.id === type); const monthlyPrice = getLinodeRegionPrice(thisType, region)?.monthly; diff --git a/packages/queries/CHANGELOG.md b/packages/queries/CHANGELOG.md index e2246edc104..bcaed67d166 100644 --- a/packages/queries/CHANGELOG.md +++ b/packages/queries/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2025-09-09] - v0.13.0 + +### Upcoming Features: + +- Add queries for Streams DELETE, PUT API endpoints ([#12645](https://github.com/linode/manager/pull/12645)) +- Add queries for Destinations DELETE, PUT API endpoints ([#12749](https://github.com/linode/manager/pull/12749)) + ## [2025-08-26] - v0.12.0 ### Added: diff --git a/packages/queries/package.json b/packages/queries/package.json index df342c9da8b..ffcf3041600 100644 --- a/packages/queries/package.json +++ b/packages/queries/package.json @@ -1,6 +1,6 @@ { "name": "@linode/queries", - "version": "0.12.0", + "version": "0.13.0", "description": "Linode Utility functions library", "main": "src/index.js", "module": "src/index.ts", @@ -43,4 +43,4 @@ "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6" } -} +} \ No newline at end of file diff --git a/packages/queries/src/datastreams/datastream.ts b/packages/queries/src/datastreams/datastream.ts index 902c2be377d..ea5d928b7c7 100644 --- a/packages/queries/src/datastreams/datastream.ts +++ b/packages/queries/src/datastreams/datastream.ts @@ -1,10 +1,14 @@ import { createDestination, createStream, + deleteDestination, + deleteStream, getDestination, getDestinations, getStream, getStreams, + updateDestination, + updateStream, } from '@linode/api-v4'; import { profileQueries } from '@linode/queries'; import { getAll } from '@linode/utilities'; @@ -20,6 +24,8 @@ import type { Params, ResourcePage, Stream, + UpdateDestinationPayloadWithId, + UpdateStreamPayloadWithId, } from '@linode/api-v4'; export const getAllDataStreams = ( @@ -83,15 +89,21 @@ export const useStreamsQuery = (params: Params = {}, filter: Filter = {}) => ...datastreamQueries.streams._ctx.paginated(params, filter), }); +export const useStreamQuery = (id: number) => + useQuery({ ...datastreamQueries.stream(id) }); + export const useCreateStreamMutation = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: createStream, onSuccess(stream) { - // Invalidate paginated lists + // Invalidate streams queryClient.invalidateQueries({ queryKey: datastreamQueries.streams._ctx.paginated._def, }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams._ctx.all._def, + }); // Set Stream in cache queryClient.setQueryData( @@ -107,6 +119,49 @@ export const useCreateStreamMutation = () => { }); }; +export const useUpdateStreamMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...data }) => updateStream(id, data), + onSuccess(stream) { + // Invalidate streams + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams._ctx.paginated._def, + }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams._ctx.all._def, + }); + + // Update stream in cache + queryClient.setQueryData( + datastreamQueries.stream(stream.id).queryKey, + stream, + ); + }, + }); +}; + +export const useDeleteStreamMutation = () => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], { id: number }>({ + mutationFn: ({ id }) => deleteStream(id), + onSuccess(_, { id }) { + // Invalidate streams + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams._ctx.paginated._def, + }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams._ctx.all._def, + }); + + // Remove stream from the cache + queryClient.removeQueries({ + queryKey: datastreamQueries.stream(id).queryKey, + }); + }, + }); +}; + export const useAllDestinationsQuery = ( params: Params = {}, filter: Filter = {}, @@ -123,15 +178,21 @@ export const useDestinationsQuery = ( ...datastreamQueries.destinations._ctx.paginated(params, filter), }); +export const useDestinationQuery = (id: number) => + useQuery({ ...datastreamQueries.destination(id) }); + export const useCreateDestinationMutation = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: createDestination, onSuccess(destination) { - // Invalidate paginated lists + // Invalidate destinations queryClient.invalidateQueries({ queryKey: datastreamQueries.destinations._ctx.paginated._def, }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.destinations._ctx.all._def, + }); // Set Destination in cache queryClient.setQueryData( @@ -146,3 +207,46 @@ export const useCreateDestinationMutation = () => { }, }); }; + +export const useUpdateDestinationMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...data }) => updateDestination(id, data), + onSuccess(destination) { + // Invalidate destinations + queryClient.invalidateQueries({ + queryKey: datastreamQueries.destinations._ctx.paginated._def, + }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.destinations._ctx.all._def, + }); + + // Update destination in cache + queryClient.setQueryData( + datastreamQueries.destination(destination.id).queryKey, + destination, + ); + }, + }); +}; + +export const useDeleteDestinationMutation = () => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], { id: number }>({ + mutationFn: ({ id }) => deleteDestination(id), + onSuccess(_, { id }) { + // Invalidate destinations + queryClient.invalidateQueries({ + queryKey: datastreamQueries.destinations._ctx.paginated._def, + }); + queryClient.invalidateQueries({ + queryKey: datastreamQueries.destinations._ctx.all._def, + }); + + // Remove stream from the cache + queryClient.removeQueries({ + queryKey: datastreamQueries.destination(id).queryKey, + }); + }, + }); +}; diff --git a/packages/queries/src/images/images.ts b/packages/queries/src/images/images.ts index 0664ebc5d3c..9001b26e5d1 100644 --- a/packages/queries/src/images/images.ts +++ b/packages/queries/src/images/images.ts @@ -30,7 +30,10 @@ import type { UploadImageResponse, } from '@linode/api-v4'; import type { EventHandlerData } from '@linode/queries'; -import type { UseQueryOptions } from '@tanstack/react-query'; +import type { + UseMutationOptions, + UseQueryOptions, +} from '@tanstack/react-query'; export const getAllImages = ( passedParams: Params = {}, @@ -133,11 +136,15 @@ export const useUpdateImageMutation = () => { }); }; -export const useDeleteImageMutation = () => { +export const useDeleteImageMutation = ( + options: UseMutationOptions<{}, APIError[], { imageId: string }>, +) => { const queryClient = useQueryClient(); return useMutation<{}, APIError[], { imageId: string }>({ mutationFn: ({ imageId }) => deleteImage(imageId), - onSuccess(_, variables) { + ...options, + onSuccess(response, variables, context) { + options.onSuccess?.(response, variables, context); queryClient.invalidateQueries({ queryKey: imageQueries.paginated._def, }); diff --git a/packages/shared/CHANGELOG.md b/packages/shared/CHANGELOG.md index bb733a4dfc4..dc146b6d71c 100644 --- a/packages/shared/CHANGELOG.md +++ b/packages/shared/CHANGELOG.md @@ -1,5 +1,10 @@ -## [2025-08-12] - v0.7.0 +## [2025-09-09] - v0.8.0 + +### Tests: + +- Add Mock IntersectionObserver in testSetup.ts ([#12777](https://github.com/linode/manager/pull/12777)) +## [2025-08-12] - v0.7.0 ### Changed: @@ -7,14 +12,12 @@ ## [2025-07-29] - v0.6.0 - ### Fixed: - `LinodeSelect` not filtering by the `optionsFilter` when `options` was passed as props ([#12529](https://github.com/linode/manager/pull/12529)) ## [2025-07-15] - v0.5.0 - ### Upcoming Features: - Add `useIsLinodeAclpSubscribed` hook and unit tests ([#12479](https://github.com/linode/manager/pull/12479)) diff --git a/packages/shared/package.json b/packages/shared/package.json index efabcbf6772..781bde21a8d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@linode/shared", - "version": "0.7.0", + "version": "0.8.0", "description": "Linode shared feature component library", "main": "src/index.ts", "module": "src/index.ts", @@ -49,4 +49,4 @@ "@types/react-dom": "^19.1.6", "vite-plugin-svgr": "^3.2.0" } -} +} \ No newline at end of file diff --git a/packages/shared/testSetup.ts b/packages/shared/testSetup.ts index 141cd45f9a4..f28e2d3a90e 100644 --- a/packages/shared/testSetup.ts +++ b/packages/shared/testSetup.ts @@ -7,3 +7,10 @@ expect.extend(matchers); afterEach(() => { cleanup(); }); + +// @ts-expect-error Mock IntersectionObserver for tests +global.IntersectionObserver = class IntersectionObserver { + disconnect() {} + observe() {} + unobserve() {} +}; diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 4bbe8ecdf7f..2c46fbd15ca 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,5 +1,10 @@ -## [2025-08-26] - v0.19.0 +## [2025-09-09] - v0.20.0 + +### Fixed: +- Disabled list option tooltip behavior in selects ([#12777](https://github.com/linode/manager/pull/12777)) + +## [2025-08-26] - v0.19.0 ### Changed: @@ -7,7 +12,6 @@ ## [2025-08-12] - v0.18.0 - ### Changed: - Use gap for TableSortLabel spacing of text and icons ([#12512](https://github.com/linode/manager/pull/12512)) @@ -20,21 +24,18 @@ ## [2025-07-29] - v0.17.0 - ### Changed: - Textfield styles and color to match ADS ([#12496](https://github.com/linode/manager/pull/12496)) -- Add qa-ids to `DateTimeRangePicker.tsx` and `TimeZoneSelect.tsx` files, update `Presets.tsx` to calculate date according to selected timezone ([#12497](https://github.com/linode/manager/pull/12497)) +- Add qa-ids to `DateTimeRangePicker.tsx` and `TimeZoneSelect.tsx` files, update `Presets.tsx` to calculate date according to selected timezone ([#12497](https://github.com/linode/manager/pull/12497)) - Use gap for TableSortLabel spacing of text and icons ([#12512](https://github.com/linode/manager/pull/12512)) ### Fixed: - `TextField` not respecting `inputProps.id` and `InputProps.id` ([#12502](https://github.com/linode/manager/pull/12502)) - ## [2025-07-15] - v0.16.0 - ### Added: - Add `null` as type option for `headingChip` ([#12460](https://github.com/linode/manager/pull/12460)) @@ -48,7 +49,6 @@ ## [2025-07-01] - v0.15.0 - ### Changed: - Add `Toggle` design tokens and update styles to match Akamai Design System ([#12303](https://github.com/linode/manager/pull/12303)) @@ -71,7 +71,6 @@ ## [2025-06-17] - v0.14.0 - ### Changed: - Add `Select` design tokens and update styles to match Akamai Design System ([#12124](https://github.com/linode/manager/pull/12124)) diff --git a/packages/ui/package.json b/packages/ui/package.json index 0824f8dfc31..1cd2bdc0433 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@linode/ui", "author": "Linode", "description": "Linode UI component library", - "version": "0.19.0", + "version": "0.20.0", "type": "module", "main": "src/index.ts", "module": "src/index.ts", @@ -56,4 +56,4 @@ "@types/react-dom": "^19.1.6", "vite-plugin-svgr": "^3.2.0" } -} +} \ No newline at end of file diff --git a/packages/ui/src/components/ListItemOption/ListItemOption.tsx b/packages/ui/src/components/ListItemOption/ListItemOption.tsx index 7e6dad29638..c755d463271 100644 --- a/packages/ui/src/components/ListItemOption/ListItemOption.tsx +++ b/packages/ui/src/components/ListItemOption/ListItemOption.tsx @@ -44,7 +44,8 @@ export const ListItemOption = ({ const disabledReason = disabledOptions?.reason; // Used to control the Tooltip - const [isFocused, setIsFocused] = useState(false); + const [isDisabledItemFocused, setIsDisabledItemFocused] = useState(false); + const [isDisabledItemInView, setIsDisabledItemInView] = useState(false); const listItemRef = useRef(null); useEffect(() => { @@ -53,24 +54,36 @@ export const ListItemOption = ({ return; } if (!isOptionDisabled) { - // We don't need to setup the mutation observer for options that are enabled. They won't have a tooltip + // We don't need to setup the observers for options that are enabled. They won't have a tooltip return; } - const observer = new MutationObserver(() => { + const intersectionObserver = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setIsDisabledItemInView(true); + } else { + setIsDisabledItemInView(false); + } + }); + intersectionObserver.observe(listItemRef.current); + + const mutationObserver = new MutationObserver(() => { const className = listItemRef.current?.className; const hasFocusedClass = className?.includes('Mui-focused') ?? false; if (hasFocusedClass) { - setIsFocused(true); + setIsDisabledItemFocused(true); } else if (!hasFocusedClass) { - setIsFocused(false); + setIsDisabledItemFocused(false); } }); - observer.observe(listItemRef.current, { attributeFilter: ['class'] }); + mutationObserver.observe(listItemRef.current, { + attributeFilter: ['class'], + }); return () => { - observer.disconnect(); + mutationObserver.disconnect(); + intersectionObserver.disconnect(); }; }, [isOptionDisabled]); @@ -109,7 +122,7 @@ export const ListItemOption = ({ if (isOptionDisabled) { return ( { describe('createRescueDevicesPostObject', () => { it('Returns the minimum requirement.', () => { const result = createDevicesFromStrings({}); - const expected = { - sda: null, - sdb: null, - sdc: null, - sdd: null, - sde: null, - sdf: null, - sdg: null, - sdh: null, - }; + const expected = {}; expect(result).toEqual(expected); }); @@ -26,13 +17,7 @@ describe('LinodeRescue', () => { }); const expected = { sda: { disk_id: 123 }, - sdb: null, - sdc: null, sdd: { disk_id: 456 }, - sde: null, - sdf: null, - sdg: null, - sdh: null, }; expect(result).toEqual(expected); @@ -44,14 +29,8 @@ describe('LinodeRescue', () => { sde: 'volume-456', }); const expected = { - sda: null, sdb: { volume_id: 123 }, - sdc: null, - sdd: null, sde: { volume_id: 456 }, - sdf: null, - sdg: null, - sdh: null, }; expect(result).toEqual(expected); @@ -63,14 +42,7 @@ describe('LinodeRescue', () => { sdd: 'disk-456', }); const expected = { - sda: null, - sdb: null, - sdc: null, sdd: { disk_id: 456 }, - sde: null, - sdf: null, - sdg: null, - sdh: null, }; expect(result).toEqual(expected); }); diff --git a/packages/utilities/src/helpers/createDevicesFromStrings.ts b/packages/utilities/src/helpers/createDevicesFromStrings.ts index 66e214b08d5..c7dcb252a32 100644 --- a/packages/utilities/src/helpers/createDevicesFromStrings.ts +++ b/packages/utilities/src/helpers/createDevicesFromStrings.ts @@ -4,23 +4,28 @@ type DiskRecord = Record<'disk_id', number>; type VolumeRecord = Record<'volume_id', number>; -export interface DevicesAsStrings { - sda?: string; - sdb?: string; - sdc?: string; - sdd?: string; - sde?: string; - sdf?: string; - sdg?: string; - sdh?: string; -} +/** + * Maps the Devices type to have optional string values instead of device objects. + * This allows us to work with string representations like "volume-123" or "disk-456" + * before converting them to the proper API format. + */ +type StringTypeMap = { + [key in keyof T]?: string; +}; + +export type DevicesAsStrings = StringTypeMap; /** - * The `value` should be formatted as volume-123, disk-123, etc., + * Creates a device record from a string representation. + * + * Device slots are optional and may not exist + * in all contexts, so empty slots can be represented as `undefined`. */ -const createTypeRecord = (value?: string): DiskRecord | null | VolumeRecord => { - if (value === null || value === undefined || value === 'none') { - return null; +const createTypeRecord = ( + value?: string, +): DiskRecord | null | undefined | VolumeRecord => { + if (value === undefined || value === null || value === 'none') { + return undefined; } // Given: volume-123 @@ -47,4 +52,60 @@ export const createDevicesFromStrings = ( sdf: createTypeRecord(devices.sdf), sdg: createTypeRecord(devices.sdg), sdh: createTypeRecord(devices.sdh), + sdi: createTypeRecord(devices.sdi), + sdj: createTypeRecord(devices.sdj), + sdk: createTypeRecord(devices.sdk), + sdl: createTypeRecord(devices.sdl), + sdm: createTypeRecord(devices.sdm), + sdn: createTypeRecord(devices.sdn), + sdo: createTypeRecord(devices.sdo), + sdp: createTypeRecord(devices.sdp), + sdq: createTypeRecord(devices.sdq), + sdr: createTypeRecord(devices.sdr), + sds: createTypeRecord(devices.sds), + sdt: createTypeRecord(devices.sdt), + sdu: createTypeRecord(devices.sdu), + sdv: createTypeRecord(devices.sdv), + sdw: createTypeRecord(devices.sdw), + sdx: createTypeRecord(devices.sdx), + sdy: createTypeRecord(devices.sdy), + sdz: createTypeRecord(devices.sdz), + sdaa: createTypeRecord(devices.sdaa), + sdab: createTypeRecord(devices.sdab), + sdac: createTypeRecord(devices.sdac), + sdad: createTypeRecord(devices.sdad), + sdae: createTypeRecord(devices.sdae), + sdaf: createTypeRecord(devices.sdaf), + sdag: createTypeRecord(devices.sdag), + sdah: createTypeRecord(devices.sdah), + sdai: createTypeRecord(devices.sdai), + sdaj: createTypeRecord(devices.sdaj), + sdak: createTypeRecord(devices.sdak), + sdal: createTypeRecord(devices.sdal), + sdam: createTypeRecord(devices.sdam), + sdan: createTypeRecord(devices.sdan), + sdao: createTypeRecord(devices.sdao), + sdap: createTypeRecord(devices.sdap), + sdaq: createTypeRecord(devices.sdaq), + sdar: createTypeRecord(devices.sdar), + sdas: createTypeRecord(devices.sdas), + sdat: createTypeRecord(devices.sdat), + sdau: createTypeRecord(devices.sdau), + sdav: createTypeRecord(devices.sdav), + sdaw: createTypeRecord(devices.sdaw), + sdax: createTypeRecord(devices.sdax), + sday: createTypeRecord(devices.sday), + sdaz: createTypeRecord(devices.sdaz), + sdba: createTypeRecord(devices.sdba), + sdbb: createTypeRecord(devices.sdbb), + sdbc: createTypeRecord(devices.sdbc), + sdbd: createTypeRecord(devices.sdbd), + sdbe: createTypeRecord(devices.sdbe), + sdbf: createTypeRecord(devices.sdbf), + sdbg: createTypeRecord(devices.sdbg), + sdbh: createTypeRecord(devices.sdbh), + sdbi: createTypeRecord(devices.sdbi), + sdbj: createTypeRecord(devices.sdbj), + sdbk: createTypeRecord(devices.sdbk), + sdbl: createTypeRecord(devices.sdbl), }); diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index b0d3e30ad83..c768baa5664 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,3 +1,14 @@ +## [2025-09-09] - v0.74.0 + +### Added: + +- Additional device slots to `devices` schema ([#12791](https://github.com/linode/manager/pull/12791)) +- Node Pool schemas `CreateNodePoolSchema` and `EditNodePoolSchema` ([#12793](https://github.com/linode/manager/pull/12793)) + +### Removed: + +- General Node Pool schema `nodePoolSchema` ([#12793](https://github.com/linode/manager/pull/12793)) + ## [2025-08-26] - v0.73.0 ### Changed: diff --git a/packages/validation/package.json b/packages/validation/package.json index 9db74739062..fd68f42351b 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.73.0", + "version": "0.74.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", @@ -61,4 +61,4 @@ }, "author": "Linode LLC", "license": "Apache-2.0" -} +} \ No newline at end of file diff --git a/packages/validation/src/datastream.schema.ts b/packages/validation/src/datastream.schema.ts index 14d21b97088..5fafc0a1b02 100644 --- a/packages/validation/src/datastream.schema.ts +++ b/packages/validation/src/datastream.schema.ts @@ -74,7 +74,7 @@ const linodeObjectStorageDetailsSchema = object({ .required('Access Key Secret is required.'), }); -export const createDestinationSchema = object().shape({ +export const destinationSchema = object().shape({ label: string() .max(maxLength, maxLengthMessage) .required('Destination name is required.'), @@ -125,36 +125,39 @@ const streamSchemaBase = object({ .max(maxLength, maxLengthMessage) .required('Stream name is required.'), status: mixed<'active' | 'inactive'>().oneOf(['active', 'inactive']), + type: string() + .oneOf(['audit_logs', 'lke_audit_logs']) + .required('Stream type is required.'), destinations: array().of(number().defined()).ensure().min(1).required(), - details: mixed | object>().when( - 'type', - { + details: mixed | object>() + .when('type', { is: 'lke_audit_logs', then: () => streamDetailsSchema.required(), otherwise: detailsShouldBeEmpty, - }, - ), + }) + .required(), }); -export const createStreamSchema = streamSchemaBase.shape({ - type: string() - .oneOf(['audit_logs', 'lke_audit_logs']) - .required('Stream type is required.'), +export const createStreamSchema = streamSchemaBase; + +export const updateStreamSchema = streamSchemaBase.shape({ + status: mixed<'active' | 'inactive'>() + .oneOf(['active', 'inactive']) + .required(), }); -export const createStreamAndDestinationFormSchema = object({ - stream: createStreamSchema.shape({ +export const streamAndDestinationFormSchema = object({ + stream: streamSchemaBase.shape({ destinations: array().of(number()).ensure().min(1).required(), - details: mixed | object>().when( - 'type', - { + details: mixed | object>() + .when('type', { is: 'lke_audit_logs', then: () => streamDetailsBase.required(), otherwise: detailsShouldBeEmpty, - }, - ), + }) + .required(), }), - destination: createDestinationSchema.defined().when('stream.destinations', { + destination: destinationSchema.defined().when('stream.destinations', { is: (value: never[]) => value?.length === 1 && value[0] === undefined, then: (schema) => schema, otherwise: (schema) => diff --git a/packages/validation/src/kubernetes.schema.ts b/packages/validation/src/kubernetes.schema.ts index 2eabbcb528e..218c9a5f63c 100644 --- a/packages/validation/src/kubernetes.schema.ts +++ b/packages/validation/src/kubernetes.schema.ts @@ -2,10 +2,70 @@ import { array, boolean, number, object, string } from 'yup'; import { validateIP } from './firewalls.schema'; -export const nodePoolSchema = object({ +// Starts and ends with a letter or number and contains letters, numbers, hyphens, dots, and underscores +const alphaNumericValidCharactersRegex = + /^[a-zA-Z0-9]([a-zA-Z0-9-._]*[a-zA-Z0-9])?$/; + +export const kubernetesTaintSchema = object({ + key: string() + .required('Key is required.') + .test( + 'valid-key', + 'Key must start with a letter or number and may contain letters, numbers, hyphens, dots, and underscores, up to 253 characters.', + (value) => { + return ( + alphaNumericValidCharactersRegex.test(value) || + dnsKeyRegex.test(value) + ); + }, + ) + .max(253, 'Key must be between 1 and 253 characters.') + .min(1, 'Key must be between 1 and 253 characters.'), + value: string() + .matches( + alphaNumericValidCharactersRegex, + 'Value must start with a letter or number and may contain letters, numbers, hyphens, dots, and underscores, up to 63 characters.', + ) + .max(63, 'Value must be between 0 and 63 characters.') + .notOneOf( + ['kubernetes.io', 'linode.com'], + 'Value cannot be "kubernetes.io" or "linode.com".', + ) + .notRequired(), +}); + +const NodePoolDiskSchema = object({ + size: number().required(), + type: string() + .oneOf(['raw', 'ext4'] as const) + .required(), +}); + +const AutoscaleSettingsSchema = object({ + enabled: boolean().required(), + max: number().required(), + min: number().required(), +}); + +export const CreateNodePoolSchema = object({ + autoscaler: AutoscaleSettingsSchema.notRequired().default(undefined), + type: string().required('Type is required.'), + count: number().required(), + tags: array(string().defined()).notRequired(), + disks: array(NodePoolDiskSchema).notRequired(), + update_strategy: string() + .oneOf(['rolling_update', 'on_recycle'] as const) + .notRequired(), + k8_version: string().notRequired(), + firewall_id: number().notRequired(), + labels: object().notRequired(), + taints: array(kubernetesTaintSchema).notRequired(), +}); + +export const EditNodePoolSchema = object({ type: string(), count: number(), - upgrade_strategy: string(), + update_strategy: string(), k8_version: string(), firewall_id: number(), }); @@ -58,7 +118,7 @@ export const createKubeClusterSchema = object({ region: string().required('Region is required.'), k8s_version: string().required('Kubernetes version is required.'), node_pools: array() - .of(nodePoolSchema) + .of(CreateNodePoolSchema) .min(1, 'Please add at least one node pool.'), }); @@ -105,10 +165,6 @@ export const kubernetesEnterpriseControlPlaneACLPayloadSchema = object({ ), }); -// Starts and ends with a letter or number and contains letters, numbers, hyphens, dots, and underscores -const alphaNumericValidCharactersRegex = - /^[a-zA-Z0-9]([a-zA-Z0-9-._]*[a-zA-Z0-9])?$/; - // DNS subdomain key (example.com/my-app) const dnsKeyRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-._/]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/; @@ -170,31 +226,3 @@ export const kubernetesLabelSchema = object().test({ message: 'Labels must be valid key-value pairs.', test: validateKubernetesLabel, }); - -export const kubernetesTaintSchema = object({ - key: string() - .required('Key is required.') - .test( - 'valid-key', - 'Key must start with a letter or number and may contain letters, numbers, hyphens, dots, and underscores, up to 253 characters.', - (value) => { - return ( - alphaNumericValidCharactersRegex.test(value) || - dnsKeyRegex.test(value) - ); - }, - ) - .max(253, 'Key must be between 1 and 253 characters.') - .min(1, 'Key must be between 1 and 253 characters.'), - value: string() - .matches( - alphaNumericValidCharactersRegex, - 'Value must start with a letter or number and may contain letters, numbers, hyphens, dots, and underscores, up to 63 characters.', - ) - .max(63, 'Value must be between 0 and 63 characters.') - .notOneOf( - ['kubernetes.io', 'linode.com'], - 'Value cannot be "kubernetes.io" or "linode.com".', - ) - .notRequired(), -}); diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index 70c9d9fb1b7..97583ba9a6b 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -457,17 +457,75 @@ export const CreateSnapshotSchema = object({ const device = object({ disk_id: number().nullable(), volume_id: number().nullable(), -}).nullable(); +}) + .nullable() + .notRequired(); const devices = object({ sda: device, + sdaa: device, + sdab: device, + sdac: device, + sdad: device, + sdae: device, + sdaf: device, + sdag: device, + sdah: device, + sdai: device, + sdaj: device, + sdak: device, + sdal: device, + sdam: device, + sdan: device, + sdao: device, + sdap: device, + sdaq: device, + sdar: device, + sdas: device, + sdat: device, + sdau: device, + sdav: device, + sdaw: device, + sdax: device, + sday: device, + sdaz: device, sdb: device, + sdba: device, + sdbb: device, + sdbc: device, + sdbd: device, + sdbe: device, + sdbf: device, + sdbg: device, + sdbh: device, + sdbi: device, + sdbj: device, + sdbk: device, + sdbl: device, sdc: device, sdd: device, sde: device, sdf: device, sdg: device, sdh: device, + sdi: device, + sdj: device, + sdk: device, + sdl: device, + sdm: device, + sdn: device, + sdo: device, + sdp: device, + sdq: device, + sdr: device, + sds: device, + sdt: device, + sdu: device, + sdv: device, + sdw: device, + sdx: device, + sdy: device, + sdz: device, }); const helpers = object({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8efe6c60cf..018a069cd0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,11 +252,11 @@ importers: specifier: ^1.9.1 version: 1.9.1 jspdf: - specifier: ^3.0.1 - version: 3.0.1 + specifier: ^3.0.2 + version: 3.0.2 jspdf-autotable: specifier: ^5.0.2 - version: 5.0.2(jspdf@3.0.1) + version: 5.0.2(jspdf@3.0.2) launchdarkly-react-client-sdk: specifier: 3.0.10 version: 3.0.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -443,7 +443,7 @@ importers: version: 3.7.2(vite@6.3.4(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vitest/coverage-v8': specifier: ^3.1.2 - version: 3.1.2(vitest@3.1.2) + version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@20.17.6)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vueless/storybook-dark-mode': specifier: ^9.0.5 version: 9.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -2266,6 +2266,9 @@ packages: '@types/novnc__novnc@1.5.0': resolution: {integrity: sha512-9DrDJK1hUT6Cbp4t03IsU/DsR6ndnIrDgZVrzITvspldHQ7n81F3wUDfq89zmPM3wg4GErH11IQa0QuTgLMf+w==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -2664,11 +2667,6 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} - atob@2.1.2: - resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==} - engines: {node: '>= 4.5.0'} - hasBin: true - attr-accept@2.2.5: resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} engines: {node: '>=4'} @@ -2754,11 +2752,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - btoa@1.2.1: - resolution: {integrity: sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==} - engines: {node: '>= 0.4.0'} - hasBin: true - buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -3593,6 +3586,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} + fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -3983,6 +3979,9 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4261,8 +4260,8 @@ packages: peerDependencies: jspdf: ^2 || ^3 - jspdf@3.0.1: - resolution: {integrity: sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==} + jspdf@3.0.2: + resolution: {integrity: sha512-G0fQDJ5fAm6UW78HG6lNXyq09l0PrA1rpNY5i+ly17Zb1fMMFSmS+3lw4cnrAPGyouv2Y0ylujbY2Ieq3DSlKA==} jsprim@2.0.2: resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} @@ -4742,6 +4741,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -7582,6 +7584,8 @@ snapshots: '@types/novnc__novnc@1.5.0': {} + '@types/pako@2.0.4': {} + '@types/parse-json@4.0.2': {} '@types/paypal-checkout-components@4.0.8': {} @@ -7826,7 +7830,7 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - '@vitest/coverage-v8@3.1.2(vitest@3.1.2)': + '@vitest/coverage-v8@3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@20.17.6)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -8104,8 +8108,6 @@ snapshots: at-least-node@1.0.0: {} - atob@2.1.2: {} - attr-accept@2.2.5: {} available-typed-arrays@1.0.7: @@ -8205,8 +8207,6 @@ snapshots: node-releases: 2.0.18 update-browserslist-db: 1.1.1(browserslist@4.24.2) - btoa@1.2.1: {} - buffer-crc32@0.2.13: {} buffer-from@1.1.2: {} @@ -9210,6 +9210,12 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-png@6.4.0: + dependencies: + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.1.0 + fastq@1.17.1: dependencies: reusify: 1.0.4 @@ -9618,6 +9624,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + iobuffer@5.4.0: {} + ipaddr.js@1.9.1: {} ipaddr.js@2.2.0: {} @@ -9880,15 +9888,14 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jspdf-autotable@5.0.2(jspdf@3.0.1): + jspdf-autotable@5.0.2(jspdf@3.0.2): dependencies: - jspdf: 3.0.1 + jspdf: 3.0.2 - jspdf@3.0.1: + jspdf@3.0.2: dependencies: '@babel/runtime': 7.27.1 - atob: 2.1.2 - btoa: 1.2.1 + fast-png: 6.4.0 fflate: 0.8.2 optionalDependencies: canvg: 3.0.11 @@ -10464,6 +10471,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@2.1.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 diff --git a/scripts/junit-summary/util/index.ts b/scripts/junit-summary/util/index.ts index 945891272e6..5ce008ad66b 100644 --- a/scripts/junit-summary/util/index.ts +++ b/scripts/junit-summary/util/index.ts @@ -9,10 +9,32 @@ import type { TestResult } from '../results/test-result'; * @returns Length of time for all suites to run, in seconds. */ export const getTestLength = (suites: TestSuites[]): number => { - const unroundedLength = suites.reduce((acc: number, cur: TestSuites) => { - return acc + (cur.time ?? 0); - }, 0); - return Math.round(unroundedLength * 1000) / 1000; + const testDurations: {[key: number]: number} = suites.reduce((acc: {[key: number]: number}, cur: TestSuites) => { + const suite = cur.testsuite?.[0]; + if (!suite) { + return acc; + } + + const runnerIndex = (() => { + if (!suite.properties) { + return 1; + } + const indexProperty = suite.properties.find((property) => { + return property.name === 'runner_index'; + }); + + if (!indexProperty) { + return 1; + } + return Number(indexProperty.value); + })(); + + acc[runnerIndex] = (acc[runnerIndex] || 0) + (cur.time ?? 0); + return acc; + }, {}); + + const highestDuration = Math.max(...Object.values(testDurations)); + return Math.round(highestDuration * 1000) / 1000; }; /** From 17583039e348ed538b80acecfcd4bdbcd8131f18 Mon Sep 17 00:00:00 2001 From: bill-akamai Date: Fri, 5 Sep 2025 14:29:18 -0500 Subject: [PATCH 59/73] change: [M3-10593] - Update Self-Hosted Pendo Agent to Support data-pendo-id Attribute (#12828) * Update Pendo agent script for staging and prod * Update changelog --- packages/manager/CHANGELOG.md | 1 + .../manager/public/pendo/pendo-staging.js | 235 ++++++++--------- packages/manager/public/pendo/pendo.js | 247 +++++++++--------- 3 files changed, 243 insertions(+), 240 deletions(-) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 1b0205fdb8f..4c02215ab43 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Support IAM/RBAC permission segmentation for BETA/LA features ([#12764](https://github.com/linode/manager/pull/12764)) - Aggregation Function labels from Average,Minimum,Maximum to Avg,Min,Max in ACLP-Alerting service ([#12787](https://github.com/linode/manager/pull/12787)) - Add data-pendo-id attribute to TabbedPanel for Linode Plan tab tracking ([#12806](https://github.com/linode/manager/pull/12806)) +- Update self-hosted Pendo agent script to support data-pendo-id attribute ([#12828](https://github.com/linode/manager/pull/12828)) ### Fixed: diff --git a/packages/manager/public/pendo/pendo-staging.js b/packages/manager/public/pendo/pendo-staging.js index 6703205b634..0b327092f3e 100644 --- a/packages/manager/public/pendo/pendo-staging.js +++ b/packages/manager/public/pendo/pendo-staging.js @@ -1,121 +1,122 @@ // Pendo Agent Wrapper // Copyright 2025 Pendo.io, Inc. // Environment: staging -// Agent Version: 2.285.2 -// Installed: 2025-07-18T19:07:45Z +// Agent Version: 2.291.3 +// Installed: 2025-09-05T18:20:46Z (function (PendoConfig) { -/* -@license https://agent.pendo.io/licenses -*/ -!function(D,G,E){{var H="undefined"!=typeof PendoConfig?PendoConfig:{};z="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".split("");var z,j,o,W,J,q,K,V,$={uint8ToBase64:function(e){var t,n,i,r=e.length%3,o="";for(t=0,i=e.length-r;t>18&63]+z[e>>12&63]+z[e>>6&63]+z[63&e]}(n);switch(r){case 1:n=e[e.length-1],o=(o+=z[n>>2])+z[n<<4&63];break;case 2:n=(e[e.length-2]<<8)+e[e.length-1],o=(o=(o+=z[n>>10])+z[n>>4&63])+z[n<<2&63]}return o}},Ut="undefined"!=typeof globalThis?globalThis:void 0!==D?D:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{};function Z(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e["default"]:e}function Y(e){e?(K[0]=K[16]=K[1]=K[2]=K[3]=K[4]=K[5]=K[6]=K[7]=K[8]=K[9]=K[10]=K[11]=K[12]=K[13]=K[14]=K[15]=0,this.blocks=K):this.blocks=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],this.h0=1732584193,this.h1=4023233417,this.h2=2562383102,this.h3=271733878,this.h4=3285377520,this.block=this.start=this.bytes=this.hBytes=0,this.finalized=this.hashed=!1,this.first=!0}a=Be={exports:{}},e=!(j="object"==typeof D?D:{}).JS_SHA1_NO_COMMON_JS&&a.exports,o="0123456789abcdef".split(""),W=[-2147483648,8388608,32768,128],J=[24,16,8,0],q=["hex","array","digest","arrayBuffer"],K=[],V=function(t){return function(e){return new Y(!0).update(e)[t]()}},Y.prototype.update=function(e){if(!this.finalized){for(var t,n,i="string"!=typeof e,r=0,o=(e=i&&e.constructor===j.ArrayBuffer?new Uint8Array(e):e).length||0,a=this.blocks;r>2]|=e[r]<>2]|=t<>2]|=(192|t>>6)<>2]|=(224|t>>12)<>2]|=(240|t>>18)<>2]|=(128|t>>12&63)<>2]|=(128|t>>6&63)<>2]|=(128|63&t)<>2]|=W[3&t],this.block=e[16],56<=t&&(this.hashed||this.hash(),e[0]=this.block,e[16]=e[1]=e[2]=e[3]=e[4]=e[5]=e[6]=e[7]=e[8]=e[9]=e[10]=e[11]=e[12]=e[13]=e[14]=e[15]=0),e[14]=this.hBytes<<3|this.bytes>>>29,e[15]=this.bytes<<3,this.hash())},Y.prototype.hash=function(){for(var e,t=this.h0,n=this.h1,i=this.h2,r=this.h3,o=this.h4,a=this.blocks,s=16;s<80;++s)e=a[s-3]^a[s-8]^a[s-14]^a[s-16],a[s]=e<<1|e>>>31;for(s=0;s<20;s+=5)t=(e=(n=(e=(i=(e=(r=(e=(o=(e=t<<5|t>>>27)+(n&i|~n&r)+o+1518500249+a[s]<<0)<<5|o>>>27)+(t&(n=n<<30|n>>>2)|~t&i)+r+1518500249+a[s+1]<<0)<<5|r>>>27)+(o&(t=t<<30|t>>>2)|~o&n)+i+1518500249+a[s+2]<<0)<<5|i>>>27)+(r&(o=o<<30|o>>>2)|~r&t)+n+1518500249+a[s+3]<<0)<<5|n>>>27)+(i&(r=r<<30|r>>>2)|~i&o)+t+1518500249+a[s+4]<<0,i=i<<30|i>>>2;for(;s<40;s+=5)t=(e=(n=(e=(i=(e=(r=(e=(o=(e=t<<5|t>>>27)+(n^i^r)+o+1859775393+a[s]<<0)<<5|o>>>27)+(t^(n=n<<30|n>>>2)^i)+r+1859775393+a[s+1]<<0)<<5|r>>>27)+(o^(t=t<<30|t>>>2)^n)+i+1859775393+a[s+2]<<0)<<5|i>>>27)+(r^(o=o<<30|o>>>2)^t)+n+1859775393+a[s+3]<<0)<<5|n>>>27)+(i^(r=r<<30|r>>>2)^o)+t+1859775393+a[s+4]<<0,i=i<<30|i>>>2;for(;s<60;s+=5)t=(e=(n=(e=(i=(e=(r=(e=(o=(e=t<<5|t>>>27)+(n&i|n&r|i&r)+o-1894007588+a[s]<<0)<<5|o>>>27)+(t&(n=n<<30|n>>>2)|t&i|n&i)+r-1894007588+a[s+1]<<0)<<5|r>>>27)+(o&(t=t<<30|t>>>2)|o&n|t&n)+i-1894007588+a[s+2]<<0)<<5|i>>>27)+(r&(o=o<<30|o>>>2)|r&t|o&t)+n-1894007588+a[s+3]<<0)<<5|n>>>27)+(i&(r=r<<30|r>>>2)|i&o|r&o)+t-1894007588+a[s+4]<<0,i=i<<30|i>>>2;for(;s<80;s+=5)t=(e=(n=(e=(i=(e=(r=(e=(o=(e=t<<5|t>>>27)+(n^i^r)+o-899497514+a[s]<<0)<<5|o>>>27)+(t^(n=n<<30|n>>>2)^i)+r-899497514+a[s+1]<<0)<<5|r>>>27)+(o^(t=t<<30|t>>>2)^n)+i-899497514+a[s+2]<<0)<<5|i>>>27)+(r^(o=o<<30|o>>>2)^t)+n-899497514+a[s+3]<<0)<<5|n>>>27)+(i^(r=r<<30|r>>>2)^o)+t-899497514+a[s+4]<<0,i=i<<30|i>>>2;this.h0=this.h0+t<<0,this.h1=this.h1+n<<0,this.h2=this.h2+i<<0,this.h3=this.h3+r<<0,this.h4=this.h4+o<<0},Y.prototype.toString=Y.prototype.hex=function(){this.finalize();var e=this.h0,t=this.h1,n=this.h2,i=this.h3,r=this.h4;return o[e>>28&15]+o[e>>24&15]+o[e>>20&15]+o[e>>16&15]+o[e>>12&15]+o[e>>8&15]+o[e>>4&15]+o[15&e]+o[t>>28&15]+o[t>>24&15]+o[t>>20&15]+o[t>>16&15]+o[t>>12&15]+o[t>>8&15]+o[t>>4&15]+o[15&t]+o[n>>28&15]+o[n>>24&15]+o[n>>20&15]+o[n>>16&15]+o[n>>12&15]+o[n>>8&15]+o[n>>4&15]+o[15&n]+o[i>>28&15]+o[i>>24&15]+o[i>>20&15]+o[i>>16&15]+o[i>>12&15]+o[i>>8&15]+o[i>>4&15]+o[15&i]+o[r>>28&15]+o[r>>24&15]+o[r>>20&15]+o[r>>16&15]+o[r>>12&15]+o[r>>8&15]+o[r>>4&15]+o[15&r]},Y.prototype.array=Y.prototype.digest=function(){this.finalize();var e=this.h0,t=this.h1,n=this.h2,i=this.h3,r=this.h4;return[e>>24&255,e>>16&255,e>>8&255,255&e,t>>24&255,t>>16&255,t>>8&255,255&t,n>>24&255,n>>16&255,n>>8&255,255&n,i>>24&255,i>>16&255,i>>8&255,255&i,r>>24&255,r>>16&255,r>>8&255,255&r]},Y.prototype.arrayBuffer=function(){this.finalize();var e=new ArrayBuffer(20),t=new DataView(e);return t.setUint32(0,this.h0),t.setUint32(4,this.h1),t.setUint32(8,this.h2),t.setUint32(12,this.h3),t.setUint32(16,this.h4),e},Ue=function(){var t=V("hex");t.create=function(){return new Y},t.update=function(e){return t.create().update(e)};for(var e=0;ee,createHTML:e=>e};function Q(e){return t||(t=e.trustedTypesPolicy||(D.trustedTypes&&"function"==typeof D.trustedTypes.createPolicy?D.trustedTypes.createPolicy("pendo",q0):q0),e.trustedTypesPolicy=t),t}var I,ee="stagingServerHashes",te={};function ne(e){return e.loadAsModule}function ie(e){return"staging"===e.environmentName}function re(e){return"extension"===e.installType}function oe(t=[],n){var i=/^https:\/\/[\w\-.]*cdn[\w\-.]*\.(pendo-dev\.com|pendo\.io)\/agent\/static\/([\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12}|PENDO_API_KEY)\/pendo\.js$/g;for(let e=0;e":">",'"':""","'":"'","`":"`"},Ge=De(t),t=De(Ee(t)),Ue=y.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g},Be=/(.)^/,He={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},ze=/\\|'|\r|\n|\u2028|\u2029/g;function je(e){return"\\"+He[e]}var We=/^\s*(\w|\$)+\s*$/;var Je=0;function qe(e,t,n,i,r){return i instanceof t?(i=Te(e.prototype),o(t=e.apply(i,r))?t:i):e.apply(n,r)}var _=c(function(r,o){var a=_.placeholder,s=function(){for(var e=0,t=o.length,n=Array(t),i=0;iu(h,"name"),sources:{SNIPPET_SRC:d,PENDO_CONFIG_SRC:c,GLOBAL_SRC:l,DEFAULT_SRC:p},validate(t){t.groupCollapsed("Validate Config options"),r(S(),function(e){t.log(String(e.active)),0=e},isMobileUserAgent:x.memoize(function(){return/Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(Oe())}),isChromeExtension:a},je=function(){return!isNaN(_e)&&11!=_e&&"CSS1Compat"!==G.compatMode},We=function(e,t){var n,i=e.height,r=e.width;return"top"==e.arrowPosition||"bottom"==e.arrowPosition?(n=0,"top"==e.arrowPosition?(e.top=t.top+t.height,n=-1,e.arrow.top=3,_e<=9&&(e.arrow.top=6)):"bottom"==e.arrowPosition&&(e.top=t.top-(i+I.TOOLTIP_ARROW_SIZE),e.arrow.top=i-I.TOOLTIP_ARROW_SIZE,10==_e?e.arrow.top--:_e<=9&&(e.arrow.top+=4),n=1),"left"==e.arrow.hbias?(e.left=t.left+t.width/2-(10+2*I.TOOLTIP_ARROW_SIZE),e.arrow.left=10+I.TOOLTIP_ARROW_SIZE):"right"==e.arrow.hbias?(e.left=t.left-r+t.width/2+(10+2*I.TOOLTIP_ARROW_SIZE),e.arrow.left=r-3*I.TOOLTIP_ARROW_SIZE-10):(e.left=t.left+t.width/2-r/2,e.arrow.left=r/2-I.TOOLTIP_ARROW_SIZE),e.arrow.border.top=e.arrow.top+n,e.arrow.border.left=e.arrow.left):("left"==e.arrow.hbias?(e.left=t.left+t.width,e.arrow.left=1,e.arrow.left+=5,e.arrow.border.left=e.arrow.left-1):"right"==e.arrow.hbias&&(e.left=Math.max(0,t.left-r-I.TOOLTIP_ARROW_SIZE),e.arrow.left=r-I.TOOLTIP_ARROW_SIZE-1,e.arrow.left+=5,e.arrow.border.left=e.arrow.left+1),e.top=t.top+t.height/2-i/2,e.arrow.top=i/2-I.TOOLTIP_ARROW_SIZE,e.arrow.border.top=e.arrow.top),e},Je="prod",qe="https://app.pendo.io",Ke="cdn.pendo.io",Ve="agent/releases/2.285.2",Ue="https://app.pendo.io",$e="2.285.2_prod",Be="2.285.2",Ze="xhr",Ye=function(){return je()?$e+"+quirksmode":$e};function Xe(){return-1!==Je.indexOf("prod")}var Qe=/^\s+|\s+$/g;function et(e){for(var t=[],n=0;n>6,128|63&i):i<55296||57344<=i?t.push(224|i>>12,128|i>>6&63,128|63&i):(n++,i=65536+((1023&i)<<10|1023&e.charCodeAt(n)),t.push(240|i>>18,128|i>>12&63,128|i>>6&63,128|63&i))}return t}var tt=(tt=String.prototype.trim)||function(){return this.replace(Qe,"")},e={exports:{}},nt=(!function(){var X=void 0,Q=!0,o=this;function e(e,t){var n,i=e.split("."),r=o;i[0]in r||!r.execScript||r.execScript("var "+i[0]);for(;i.length&&(n=i.shift());)i.length||t===X?r=r[n]||(r[n]={}):r[n]=t}var ee="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array;function te(e,t){if(this.index="number"==typeof t?t:0,this.e=0,this.buffer=e instanceof(ee?Uint8Array:Array)?e:new(ee?Uint8Array:Array)(32768),2*this.buffer.length<=this.index)throw Error("invalid index");this.buffer.length<=this.index&&u(this)}function u(e){var t,n=e.buffer,i=n.length,r=new(ee?Uint8Array:Array)(i<<1);if(ee)r.set(n);else for(t=0;t>>8&255]<<16|d[e>>>16&255]<<8|d[e>>>24&255])>>32-t:d[e]>>8-t),t+a<8)s=s<>t-i-1&1,8==++a&&(a=0,r[o++]=d[s],s=0,o===r.length)&&(r=u(this));r[o]=s,this.buffer=r,this.e=a,this.index=o},te.prototype.finish=function(){var e=this.buffer,t=this.index;return 0>>1;a;a>>>=1)i=i<<1|1&a,--r;t[n]=(i<>>0}var d=t;function c(e){this.buffer=new(ee?Uint16Array:Array)(2*e),this.length=0}function s(e,t){this.d=ne,this.i=0,this.input=ee&&e instanceof Array?new Uint8Array(e):e,this.c=0,t&&(t.lazy&&(this.i=t.lazy),"number"==typeof t.compressionType&&(this.d=t.compressionType),t.outputBuffer&&(this.a=ee&&t.outputBuffer instanceof Array?new Uint8Array(t.outputBuffer):t.outputBuffer),"number"==typeof t.outputIndex)&&(this.c=t.outputIndex),this.a||(this.a=new(ee?Uint8Array:Array)(32768))}c.prototype.getParent=function(e){return 2*((e-2)/4|0)},c.prototype.push=function(e,t){var n,i,r=this.buffer,o=this.length;for(r[this.length++]=t,r[this.length++]=e;0r[n]);)i=r[o],r[o]=r[n],r[n]=i,i=r[o+1],r[o+1]=r[n+1],r[n+1]=i,o=n;return this.length},c.prototype.pop=function(){var e,t,n,i=this.buffer,r=i[0],o=i[1];for(this.length-=2,i[0]=i[this.length],i[1]=i[this.length+1],n=0;!((t=2*n+2)>=this.length)&&(t+2i[t]&&(t+=2),i[t]>i[n]);)e=i[n],i[n]=i[t],i[t]=e,e=i[n+1],i[n+1]=i[t+1],i[t+1]=e,n=t;return{index:o,value:r,length:this.length}};for(var ne=2,l={NONE:0,h:1,g:ne,n:3},ie=[],p=0;p<288;p++)switch(Q){case p<=143:ie.push([p+48,8]);break;case p<=255:ie.push([p-144+400,9]);break;case p<=279:ie.push([p-256,7]);break;case p<=287:ie.push([p-280+192,8]);break;default:throw"invalid literal: "+p}function y(e,t){this.length=e,this.k=t}s.prototype.f=function(){var e,t,F,n=this.input;switch(this.d){case 0:for(t=0,F=n.length;t>>8&255,a[s++]=255&D,a[s++]=D>>>8&255,ee)a.set(i,s),s+=i.length,a=a.subarray(0,s);else{for(o=0,G=i.length;o>16&255,a[s++]=u>>24,Q){case 1===o:n=[0,o-1,0];break;case 2===o:n=[1,o-2,0];break;case 3===o:n=[2,o-3,0];break;case 4===o:n=[3,o-4,0];break;case o<=6:n=[4,o-5,1];break;case o<=8:n=[5,o-7,1];break;case o<=12:n=[6,o-9,2];break;case o<=16:n=[7,o-13,2];break;case o<=24:n=[8,o-17,3];break;case o<=32:n=[9,o-25,3];break;case o<=48:n=[10,o-33,4];break;case o<=64:n=[11,o-49,4];break;case o<=96:n=[12,o-65,5];break;case o<=128:n=[13,o-97,5];break;case o<=192:n=[14,o-129,6];break;case o<=256:n=[15,o-193,6];break;case o<=384:n=[16,o-257,7];break;case o<=512:n=[17,o-385,7];break;case o<=768:n=[18,o-513,8];break;case o<=1024:n=[19,o-769,8];break;case o<=1536:n=[20,o-1025,9];break;case o<=2048:n=[21,o-1537,9];break;case o<=3072:n=[22,o-2049,10];break;case o<=4096:n=[23,o-3073,10];break;case o<=6144:n=[24,o-4097,11];break;case o<=8192:n=[25,o-6145,11];break;case o<=12288:n=[26,o-8193,12];break;case o<=16384:n=[27,o-12289,12];break;case o<=24576:n=[28,o-16385,13];break;case o<=32768:n=[29,o-24577,13];break;default:throw"invalid distance"}for(u=n,a[s++]=u[0],a[+s]=u[1],a[5]=u[2],i=0,r=a.length;i2*u[r-1]+d[r]&&(u[r]=2*u[r-1]+d[r]),l[r]=Array(u[r]),p[r]=Array(u[r]);for(i=0;ie[i]?(l[r][o]=a,p[r][o]=n,s+=2):(l[r][o]=e[i],p[r][o]=i,++i);h[r]=0,1===d[r]&&function m(e){var t=p[e][h[e]];t===n?(m(e+1),m(e+1)):--c[t],++h[e]}(r)}return c}(i,i.length,t),o=0,a=n.length;o>>=1;return i}function f(e,t){this.input=e,this.a=new(ee?Uint8Array:Array)(32768),this.d=S.g;var n,i={};for(n in(t?"number"==typeof t.compressionType:(t={},0))&&(this.d=t.compressionType),t)i[n]=t[n];i.outputBuffer=this.a,this.j=new s(this.input,i)}var g,m,v,b,S=l,E=(f.prototype.f=function(){var e,t,n=0,i=this.a,r=Math.LOG2E*Math.log(32768)-8<<4|8;switch(i[n++]=r,8,this.d){case S.NONE:t=0;break;case S.h:t=1;break;case S.g:t=2;break;default:throw Error("unsupported compression type")}i[+n]=(e=t<<6|0)|31-(256*r+e)%31;var o=this.input;if("string"==typeof o){for(var a=o.split(""),s=0,u=a.length;s>>0;o=a}for(var d,c=1,l=0,p=o.length,h=0;0>>0,this.j.c=2,n=(i=this.j.f()).length,ee&&((i=new Uint8Array(i.buffer)).length<=n+4&&(this.a=new Uint8Array(i.length+4),this.a.set(i),i=this.a),i=i.subarray(0,n+4)),i[n++]=r>>24&255,i[n++]=r>>16&255,i[n++]=r>>8&255,i[+n]=255&r,i},e("Zlib.Deflate",f),e("Zlib.Deflate.compress",function(e,t){return new f(e,t).f()}),e("Zlib.Deflate.prototype.compress",f.prototype.f),{NONE:S.NONE,FIXED:S.h,DYNAMIC:S.g});if(Object.keys)g=Object.keys(E);else for(m in g=[],v=0,E)g[v++]=m;for(v=0,b=g.length;v>>8^r[255&(t^e[n])];for(o=i>>3;o--;n+=8)t=(t=(t=(t=(t=(t=(t=(t=t>>>8^r[255&(t^e[n])])>>>8^r[255&(t^e[n+1])])>>>8^r[255&(t^e[n+2])])>>>8^r[255&(t^e[n+3])])>>>8^r[255&(t^e[n+4])])>>>8^r[255&(t^e[n+5])])>>>8^r[255&(t^e[n+6])])>>>8^r[255&(t^e[n+7])];return(4294967295^t)>>>0},d:function(e,t){return(a.a[255&(e^t)]^e>>>8)>>>0},b:[0,1996959894,3993919788,2567524794,124634137,1886057615,3915621685,2657392035,249268274,2044508324,3772115230,2547177864,162941995,2125561021,3887607047,2428444049,498536548,1789927666,4089016648,2227061214,450548861,1843258603,4107580753,2211677639,325883990,1684777152,4251122042,2321926636,335633487,1661365465,4195302755,2366115317,997073096,1281953886,3579855332,2724688242,1006888145,1258607687,3524101629,2768942443,901097722,1119000684,3686517206,2898065728,853044451,1172266101,3705015759,2882616665,651767980,1373503546,3369554304,3218104598,565507253,1454621731,3485111705,3099436303,671266974,1594198024,3322730930,2970347812,795835527,1483230225,3244367275,3060149565,1994146192,31158534,2563907772,4023717930,1907459465,112637215,2680153253,3904427059,2013776290,251722036,2517215374,3775830040,2137656763,141376813,2439277719,3865271297,1802195444,476864866,2238001368,4066508878,1812370925,453092731,2181625025,4111451223,1706088902,314042704,2344532202,4240017532,1658658271,366619977,2362670323,4224994405,1303535960,984961486,2747007092,3569037538,1256170817,1037604311,2765210733,3554079995,1131014506,879679996,2909243462,3663771856,1141124467,855842277,2852801631,3708648649,1342533948,654459306,3188396048,3373015174,1466479909,544179635,3110523913,3462522015,1591671054,702138776,2966460450,3352799412,1504918807,783551873,3082640443,3233442989,3988292384,2596254646,62317068,1957810842,3939845945,2647816111,81470997,1943803523,3814918930,2489596804,225274430,2053790376,3826175755,2466906013,167816743,2097651377,4027552580,2265490386,503444072,1762050814,4150417245,2154129355,426522225,1852507879,4275313526,2312317920,282753626,1742555852,4189708143,2394877945,397917763,1622183637,3604390888,2714866558,953729732,1340076626,3518719985,2797360999,1068828381,1219638859,3624741850,2936675148,906185462,1090812512,3747672003,2825379669,829329135,1181335161,3412177804,3160834842,628085408,1382605366,3423369109,3138078467,570562233,1426400815,3317316542,2998733608,733239954,1555261956,3268935591,3050360625,752459403,1541320221,2607071920,3965973030,1969922972,40735498,2617837225,3943577151,1913087877,83908371,2512341634,3803740692,2075208622,213261112,2463272603,3855990285,2094854071,198958881,2262029012,4057260610,1759359992,534414190,2176718541,4139329115,1873836001,414664567,2282248934,4279200368,1711684554,285281116,2405801727,4167216745,1634467795,376229701,2685067896,3608007406,1308918612,956543938,2808555105,3495958263,1231636301,1047427035,2932959818,3654703836,1088359270,936918e3,2847714899,3736837829,1202900863,817233897,3183342108,3401237130,1404277552,615818150,3134207493,3453421203,1423857449,601450431,3009837614,3294710456,1567103746,711928724,3020668471,3272380065,1510334235,755167117]};a.a="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array?new Uint32Array(a.b):a.b,e("Zlib.CRC32",a),e("Zlib.CRC32.calc",a.c),e("Zlib.CRC32.update",a.update)}.call(a.exports);var ot={CRC32:Z(a.exports).Zlib.CRC32},at=function(e,n){var i;return 200<=(n=n||0)?e:x.isArray(e)?x.map(e,function(e){return at(e,n+1)}):!x.isObject(e)||x.isDate(e)||x.isRegExp(e)||x.isElement(e)?x.isString(e)?x.escape(e):e:(i={},x.each(e,function(e,t){i[t]=at(e,n+1)}),i)},st=function(e){e=et(e);return $.uint8ToBase64(e)},ut=function(e){if(void 0!==e)return e=et(e=x.isString(e)?e:JSON.stringify(e)),ot.CRC32.calc(e,0,e.length)};function dt(e){return e[Math.floor(Math.random()*e.length)]}function ct(e){for(var t="abcdefghijklmnopqrstuvwxyz",n="",i=(t+t.toUpperCase()+"1234567890").split(""),r=0;rt+"="+e)),e}hashCode(){return this.toString()}}(a=yt=yt||{}).Debug="debug",a.Info="info",a.Warn="warn",a.Error="error",a.Critical="critical";class V0 extends class{constructor(){this.listeners={}}addEventListener(e,t){let n=this.listeners[e];n||(n=[],this.listeners[e]=n),x.findIndex(n,e=>t===e)<0&&n.push(t)}removeEventListener(e,t){var n,i=this.listeners[e];i&&0<=(n=x.findIndex(i,e=>t===e))&&(i.splice(n,1),i.length||delete this.listeners[e])}dispatchEvent(t){var e=this.listeners[t.type];e&&x.each(e,e=>{e(t)})}}{write(e,t,n){this.dispatchEvent(new K0(t,e,n))}writeError(e,t,n){let i,r;x.isString(e)?(i=e,r={message:i}):(r=e,i=r.message),n&&n.error&&(r=n.error,delete n.error);e=new K0(t,i,n);e.error=r,this.dispatchEvent(e)}debug(e,t){this.write(e,yt.Debug,t)}info(e,t){this.write(e,yt.Info,t)}warn(e,t){this.writeError(e,yt.Warn,t)}error(e,t){this.writeError(e,yt.Error,t)}critical(e,t){this.writeError(e,yt.Critical,t)}}const B=new V0;function Et(e){if(e)return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}const $0=function(){var e=f.get("storage.allowKeys")||"*";return x.isArray(e)?x.indexBy(e):e};function It(t,n,i){return function(){try{return n.apply(t,arguments)}catch(e){return i}}}function xt(n){x.forEach(x.keys(n),function(e){try{/^_?pendo_/.test(e)&&n.removeItem(e)}catch(t){}})}function Ct(e){var t=x.noop,t={getItem:()=>null,setItem:t,removeItem:t,clearPendo:t};try{var n=e();return n?{getItem:It(n,n.getItem,null),setItem:It(n,n.setItem),removeItem:It(n,n.removeItem),clearPendo:x.partial(xt,n)}:t}catch(i){return t}}var _t,Tt=Ct(function(){return D.localStorage}),At=Ct(function(){return D.sessionStorage}),Rt={},Ot=!0,kt=function(){return f.get("localStorageOnly")},Lt=function(){return!!f.get("disableCookies")};function Nt(e){Ot=e}var Mt=function(e){var t=Lt()||kt()?Rt[e]:G.cookie;return(e=new RegExp("(^|; )"+e+"=([^;]*)").exec(t))?wt(e[2]):null},Pt=function(e,t,n,i){var r,o;!Ot||f.get("preventCookieRefresh")&&Mt(e)===t||(o=X0(n),(r=new Date).setTime(r.getTime()+o),o=e+"="+St(t)+(n?";expires="+r.toUTCString():"")+"; path=/"+("https:"===G.location.protocol||i?";secure":"")+"; SameSite=Strict",_t&&(o+=";domain="+_t),Lt()||kt()?Rt[e]=o:G.cookie=o)};function Ft(e){_t?Pt(e,""):G.cookie=e+"=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}var Dt=function(e,t){return`_pendo_${e}.`+(t||I.apiKey)},Bt=function(e,t){return Mt(Dt(e,t))};const Z0=864e5,Y0=100*Z0,X0=(e=Y0)=>{var t=f.get("maxCookieTTLDays"),t=tn,getSession:()=>t}}(),qt=(D.Promise,e=function(){var t=Gt;function d(e){return Boolean(e&&"undefined"!=typeof e.length)}function i(){}function a(e){if(!(this instanceof a))throw new TypeError("Promises must be constructed via new");if("function"!=typeof e)throw new TypeError("not a function");this._state=0,this._handled=!1,this._value=E,this._deferreds=[],l(e,this)}function r(i,r){for(;3===i._state;)i=i._value;0===i._state?i._deferreds.push(r):(i._handled=!0,a._immediateFn(function(){var e,t=1===i._state?r.onFulfilled:r.onRejected;if(null===t)(1===i._state?o:s)(r.promise,i._value);else{try{e=t(i._value)}catch(n){return void s(r.promise,n)}o(r.promise,e)}}))}function o(e,t){try{if(t===e)throw new TypeError("A promise cannot be resolved with itself.");if(t&&("object"==typeof t||"function"==typeof t)){var n=t.then;if(t instanceof a)return e._state=3,e._value=t,void u(e);if("function"==typeof n)return void l((i=n,r=t,function(){i.apply(r,arguments)}),e)}e._state=1,e._value=t,u(e)}catch(o){s(e,o)}var i,r}function s(e,t){e._state=2,e._value=t,u(e)}function u(e){2===e._state&&0===e._deferreds.length&&a._immediateFn(function(){e._handled||a._unhandledRejectionFn(e._value)});for(var t=0,n=e._deferreds.length;t+~]|"+c+")"+c+"*"),Ri=new RegExp(c+"|>"),Oi=new RegExp(xi),ki=new RegExp("^"+e+"$"),Li={ID:new RegExp("^#("+e+")"),CLASS:new RegExp("^\\.("+e+")"),TAG:new RegExp("^("+e+"|[*])"),ATTR:new RegExp("^"+Yv),PSEUDO:new RegExp("^"+xi),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+c+"*(even|odd|(([+-]|)(\\d*)n|)"+c+"*(?:([+-]|)"+c+"*(\\d+)|))"+c+"*\\)|)","i"),bool:new RegExp("^(?:"+Ii+")$","i"),needsContext:new RegExp("^"+c+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+c+"*((?:-\\d)?\\d*)"+c+"*\\)|)(?=[^-]|$)","i")},Ni=/HTML$/i,Mi=/^(?:input|select|textarea|button)$/i,Pi=/^h\d$/i,Fi=/^[^{]+\{\s*\[native \w/,Di=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,Gi=/[+~]/,Ui=new RegExp("\\\\[\\da-fA-F]{1,6}"+c+"?|\\\\([^\\r\\n\\f])","g"),Bi=function(e,t){e="0x"+e.slice(1)-65536;return t||(e<0?String.fromCharCode(65536+e):String.fromCharCode(e>>10|55296,1023&e|56320))},Hi=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,zi=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},ji=function(){ei()},Wi=tr(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{wi.apply(Kv=Si.call(di.childNodes),di.childNodes),Kv[di.childNodes.length].nodeType}catch(W0){wi={apply:Kv.length?function(e,t){yi.apply(e,Si.call(t))}:function(e,t){for(var n=e.length,i=0;e[n++]=t[i++];);e.length=n-1}}}function T(e,t,n,i){var r,o,a,s,u,d,c=t&&t.ownerDocument,l=t?t.nodeType:9;if(n=n||[],"string"!=typeof e||!e||1!==l&&9!==l&&11!==l)return n;if(!i&&(ei(t),t=t||_,ni)){if(11!==l&&(s=Di.exec(e)))if(r=s[1]){if(9===l){if(!(d=t.getElementById(r)))return n;if(d.id===r)return n.push(d),n}else if(c&&(d=c.getElementById(r))&&ai(t,d)&&d.id===r)return n.push(d),n}else{if(s[2])return wi.apply(n,t.getElementsByTagName(e)),n;if((r=s[3])&&S.getElementsByClassName&&t.getElementsByClassName)return wi.apply(n,t.getElementsByClassName(r)),n}if(S.qsa&&!gi[e+" "]&&(!ii||!ii.test(e))&&(1!==l||"object"!==t.nodeName.toLowerCase())){if(d=e,c=t,1===l&&(Ri.test(e)||Ai.test(e))){for((c=Gi.test(e)&&Xi(t.parentNode)||t)===t&&S.scope||((a=t.getAttribute("id"))?a=a.replace(Hi,zi):t.setAttribute("id",a=ui)),o=(u=Vn(e)).length;o--;)u[o]=(a?"#"+a:":scope")+" "+er(u[o]);d=u.join(",")}try{return wi.apply(n,c.querySelectorAll(d)),n}catch(p){gi(e,!0)}finally{a===ui&&t.removeAttribute("id")}}}return Zn(e.replace(_i,"$1"),t,n,i)}function Ji(){var n=[];function i(e,t){return n.push(e+" ")>C.cacheLength&&delete i[n.shift()],i[e+" "]=t}return i}function qi(e){return e[ui]=!0,e}function Ki(e){var t=_.createElement("fieldset");try{return!!e(t)}catch(W0){return!1}finally{t.parentNode&&t.parentNode.removeChild(t)}}function Vi(e,t){for(var n=e.split("|"),i=n.length;i--;)C.attrHandle[n[i]]=t}function $i(e,t){var n=t&&e,i=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(i)return i;if(n)for(;n=n.nextSibling;)if(n===t)return-1;return e?1:-1}function Zi(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&Wi(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function Yi(a){return qi(function(o){return o=+o,qi(function(e,t){for(var n,i=a([],e.length,o),r=i.length;r--;)e[n=i[r]]&&(e[n]=!(t[n]=e[n]))})})}function Xi(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(Jn in S=T.support={},Kn=T.isXML=function(e){var t=e.namespaceURI,e=(e.ownerDocument||e).documentElement;return!Ni.test(t||e&&e.nodeName||"HTML")},ei=T.setDocument=function(e){var e=e?e.ownerDocument||e:di;return e!=_&&9===e.nodeType&&e.documentElement&&(ti=(_=e).documentElement,ni=!Kn(_),di!=_&&(e=_.defaultView)&&e.top!==e&&(e.addEventListener?e.addEventListener("unload",ji,!1):e.attachEvent&&e.attachEvent("onunload",ji)),S.scope=Ki(function(e){return ti.appendChild(e).appendChild(_.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),S.attributes=Ki(function(e){return e.className="i",!e.getAttribute("className")}),S.getElementsByTagName=Ki(function(e){return e.appendChild(_.createComment("")),!e.getElementsByTagName("*").length}),S.getElementsByClassName=!!_.getElementsByClassName,S.getById=Ki(function(e){return ti.appendChild(e).id=ui,!_.getElementsByName||!_.getElementsByName(ui).length}),S.getById?(C.filter.ID=function(e){var t=e.replace(Ui,Bi);return function(e){return e.getAttribute("id")===t}},C.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&ni)return(e=t.getElementById(e))?[e]:[]}):(C.filter.ID=function(e){var t=e.replace(Ui,Bi);return function(e){e="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return e&&e.value===t}},C.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&ni){var n,i,r,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];for(r=t.getElementsByName(e),i=0;o=r[i++];)if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),C.find.TAG=S.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):S.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,i=[],r=0,o=t.getElementsByTagName(e);if("*"!==e)return o;for(;n=o[r++];)1===n.nodeType&&i.push(n);return i},C.find.CLASS=S.getElementsByClassName&&function(e,t){return"undefined"!=typeof t.getElementsByClassName&&ni?t.getElementsByClassName(e):S.qsa&&ni?t.querySelectorAll("."+e):void 0},ri=[],ii=[],(S.qsa=!!_.querySelectorAll)&&(Ki(function(e){var t;ti.appendChild(e).innerHTML=Q().createHTML(""),e.querySelectorAll("[msallowcapture^='']").length&&ii.push("[*^$]="+c+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||ii.push("\\["+c+"*(?:value|"+Ii+")"),e.querySelectorAll("[id~="+ui+"-]").length||ii.push("~="),(t=_.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||ii.push("\\["+c+"*name"+c+"*="+c+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||ii.push(":checked"),e.querySelectorAll("a#"+ui+"+*").length||ii.push(".#.+[+~]"),e.querySelectorAll("\\\f"),ii.push("[\\r\\n\\f]")}),Ki(function(e){e.innerHTML=Q().createHTML("");var t=_.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&ii.push("name"+c+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&ii.push(":enabled",":disabled"),ti.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&ii.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),ii.push(",.*:")})),(S.matchesSelector=Fi.test(oi=ti.matches||ti.webkitMatchesSelector||ti.mozMatchesSelector||ti.oMatchesSelector||ti.msMatchesSelector))&&Ki(function(e){S.disconnectedMatch=oi.call(e,"*"),oi.call(e,"[s!='']:x"),ri.push("!=",xi)}),ii=ii.length&&new RegExp(ii.join("|")),ri=ri.length&&new RegExp(ri.join("|")),e=!!ti.compareDocumentPosition,ai=e||ti.contains?function(e,t){var n=9===e.nodeType?e.documentElement:e,t=t&&t.parentNode;return e===t||!(!t||1!==t.nodeType||!(n.contains?n.contains(t):e.compareDocumentPosition&&16&e.compareDocumentPosition(t)))}:function(e,t){if(t)for(;t=t.parentNode;)if(t===e)return!0;return!1},mi=e?function(e,t){var n;return e===t?(Qn=!0,0):(n=!e.compareDocumentPosition-!t.compareDocumentPosition)||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!S.sortDetached&&t.compareDocumentPosition(e)===n?e==_||e.ownerDocument==di&&ai(di,e)?-1:t==_||t.ownerDocument==di&&ai(di,t)?1:Xn?Ei(Xn,e)-Ei(Xn,t):0:4&n?-1:1)}:function(e,t){if(e===t)return Qn=!0,0;var n,i=0,r=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!r||!o)return e==_?-1:t==_?1:r?-1:o?1:Xn?Ei(Xn,e)-Ei(Xn,t):0;if(r===o)return $i(e,t);for(n=e;n=n.parentNode;)a.unshift(n);for(n=t;n=n.parentNode;)s.unshift(n);for(;a[i]===s[i];)i++;return i?$i(a[i],s[i]):a[i]==di?-1:s[i]==di?1:0}),_},T.matches=function(e,t){return T(e,null,null,t)},T.matchesSelector=function(e,t){if(ei(e),S.matchesSelector&&ni&&!gi[t+" "]&&(!ri||!ri.test(t))&&(!ii||!ii.test(t)))try{var n=oi.call(e,t);if(n||S.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(W0){gi(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(Ui,Bi),e[3]=(e[3]||e[4]||e[5]||"").replace(Ui,Bi),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||T.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&T.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return Li.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&Oi.test(n)&&(t=(t=Vn(n,!0))&&n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(Ui,Bi).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=pi[e+" "];return t||(t=new RegExp("(^|"+c+")"+e+"("+c+"|$)"))&&pi(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(t,n,i){return function(e){e=T.attr(e,t);return null==e?"!="===n:!n||(e+="","="===n?e===i:"!="===n?e!==i:"^="===n?i&&0===e.indexOf(i):"*="===n?i&&-1"),"#"===e.firstChild.getAttribute("href")})||Vi("type|href|height|width",function(e,t,n){if(!n)return e.getAttribute(t,"type"===t.toLowerCase()?1:2)}),S.attributes&&Ki(function(e){return e.innerHTML=Q().createHTML(""),e.firstChild.setAttribute("value",""),""===e.firstChild.getAttribute("value")})||Vi("value",function(e,t,n){if(!n&&"input"===e.nodeName.toLowerCase())return e.defaultValue}),Ki(function(e){return null==e.getAttribute("disabled")})||Vi(Ii,function(e,t,n){if(!n)return!0===e[t]?t.toLowerCase():(n=e.getAttributeNode(t))&&n.specified?n.value:null});var or=si.Sizzle;T.noConflict=function(){return si.Sizzle===T&&(si.Sizzle=or),T},d.exports?d.exports=T:si.Sizzle=T,ar=Z(a.exports),(sr=x.extend(function(){return sr.Sizzle.apply(this,arguments)},ar)).reset=function(){sr.Sizzle=ar,sr.matchesSelector=ar.matchesSelector,sr.matches=ar.matches},sr.intercept=function(e,t="Sizzle"){sr[t]=x.wrap(sr[t],e)},sr.reset();var ar,sr,ur=sr;function A(e,t){var n,i=this;if(e&&e instanceof A)return e;if(!(i instanceof A))return new A(e,t);if(e)if(e.nodeType)n=[e];else if(r=/^<(\w+)\/?>$/.exec(e))n=[G.createElement(r[1])];else if(/^<[\w\W]+>$/.test(e)){var r=G.createElement("div");r.innerHTML=e,n=x.toArray(r.childNodes)}else if(x.isString(e)){t instanceof A&&(t=0{fr(e,t,n,i)}):x.noop}function fr(e,t,n,i){e&&t&&n&&(i=i||!1,e.removeEventListener?ht("removeEventListener",e).call(e,t,n,i):e.detachEvent&&e.detachEvent("on"+t,n))}var gr=function(e){var t=zn.getComposedPath(e);return t&&0{A.event.remove(t,e.type,e.handler,e.capture)}},dispatch(r,o){var e,t,a;r&&(e=(cr.get(r,"captureEvents")||{})[o.type]||[],t=(cr.get(r,"bubbleEvents")||{})[o.type]||[],(t=e.concat(t)).length)&&!(a=cr.get(o)).ignore&&(a.handled=a.handled||{},x.each(t.slice(),function(e){var t=!!e.capture===pr(o),n=(n=o,x.isNumber(n.eventPhase)&&2===n.eventPhase),t=!t&&!n;if(!(gr(o)!==r&&t||a.handled[e.id])){a.handled[e.id]=!0;try{(!be(e.selector)||0{clearTimeout(Sr)}}var kr=function(e){var t;try{t=_r()}catch(n){}return t},Lr=[],Nr=!1,Mr=null;function Pr(){var t=xr().href;Mr!=t&&(Mr=t,x.map(Lr,function(e){e(t)}))}var Fr="queryStringWhitelist";function Dr(e){var t=f.get("sanitizeUrl");return x.isFunction(t)?t(e):e}function Gr(e){e=e||xr().href;var t=f.get("annotateUrl");if(t)if(x.isFunction(t))try{var n,i,r,o=t();return o&&(x.isObject(o)||x.isArray(o))?(n=o.exclude,i=o.include,r=o.fragment,delete o.fragment,(n&&x.isArray(n)||i&&(x.isArray(i)||x.isObject(i)))&&(n&&(e=Br(e,null,n,!0)),o=i||{}),vr.urlFor(e,o,r)):e}catch(a){B.error("customer-provided `annotateUrl` function threw an exception",{error:a})}else B.error("customer-provided `annotateUrl` must be of type: function");return e}function Ur(e){var t,n;return!e||(t=e.indexOf("?"))<0?"":(n=e.indexOf("#"))<0?e.substring(t):n{i[e]=t})}),n.push(hr(D,"popstate",Pr))),ze.supportsHashChange()&&n.push(hr(D,"hashchange",Pr)),!1!==t&&ze.supportsHashChange()||n.push(Or()),Nr=!0),Lr.push(e),()=>{x.each(n,function(e){e()}),Lr.length=0,Nr=!1}},get:kr,externalizeURL:function(e,t,n){n=n||f.get(Fr);return Dr(Br(e,t,n=x.isFunction(n)?n():n,!1))},startPoller:Or,getWindowLocation:xr,clear:function(){Lr=[]},isElectron:Er,electronUserDirectory:function(){return D.process.env.PWD||""},electronAppName:function(){return D.process.env.npm_package_name||""},electronUserHomeDirectory:function(){return D.process.env.HOME||""},electronResourcesPath:function(){return D.process.resourcesPath||""}};function Jr(){var e=f.getLocalConfig("dataHost");return e||((e=f.getHostedConfig("dataHost"))?-1===e.indexOf("://")?"https://"+e:e:qe)}function qr(){Hr=Jr()}function Kr(){var e=f.get("contentHost")||f.get("assetHost")||Ke;return e=e&&-1===e.indexOf("://")?"https://"+e:e}function Vr(e){var t=Kr(),n=0<=(n=t).indexOf("localhost")||0<=n.indexOf("local.pendo.io")?e.replace(".min.js",".js"):e;return t+"/"+(Ve?Ve+"/":"")+n}function $r(){var e=f.get("allowPartnerAnalyticsForwarding",!1)&&f.get("adoptAnalyticsForwarding",!1);return f.get("trainingPartner",!1)||e}var Zr=3,Yr=1,Xr=9,Qr=11,eo=4;function O(e){var t;if((t=e)&&t.nodeType===Yr)try{return D.getComputedStyle?getComputedStyle(e):e.currentStyle||void 0}catch(n){}}function to(e,t){var n;return!(!e||!x.isFunction(e.getPropertyValue))&&(n=[e.getPropertyValue("transform")],void 0!==t&&x.isString(t)&&n.push(e.getPropertyValue("-"+t.toLowerCase()+"-transform")),x.any(n,function(e){return e&&"none"!==e}))}function no(e){var t=(e=e||D).document.documentElement;return e.pageYOffset||t.scrollTop}function io(e){var t=(e=e||D).document.documentElement;return e.pageXOffset||t.scrollLeft}function ro(e){return x.isNumber(e)?e:0}function oo(e,t){e=e.offsetParent;return t=t||D,e=e&&e.parentElement===t.document.documentElement&&!so(e)?null:e}function ao(e){return to(O(e),ke)&&isNaN(_e)}function so(e){if(e)return(e=O(e))&&(x.contains(["relative","absolute","fixed"],e.position)||to(e,ke))}function uo(e,t,n){if(!e)return{width:0,height:0};n=n||D;var t=so(t)?t:oo(t,n),i=t?co(t):{top:0,left:0},e=co(e),i={top:e.top-i.top,left:e.left-i.left,width:e.width,height:e.height};return t?(t!==n.document.scrollingElement&&(i.top+=ro(t.scrollTop),i.left+=ro(t.scrollLeft)),i.top-=ro(t.clientTop),i.left-=ro(t.clientLeft)):(i.top+=no(n),i.left+=io(n)),i.bottom=i.top+i.height,i.right=i.left+i.width,i}function co(e){var t;return e?e.getBoundingClientRect?{top:(t=e.getBoundingClientRect()).top,left:t.left,bottom:t.bottom,right:t.right,width:t.width||Math.abs(t.right-t.left),height:t.height||Math.abs(t.bottom-t.top)}:{top:0,left:0,width:e.offsetWidth,height:e.offsetHeight,right:e.offsetWidth,bottom:e.offsetHeight}:{width:0,height:0}}var lo=void 0===(e=f.get("pendoCore"))||e,po=function(e,t,n){e=Hr+"/data/"+e+"/"+t,t=x.map(n,function(e,t){return t+"="+e});return 0=n&&e.left>=i&&e.top+e.height<=n+t.height&&e.left+e.width<=i+t.width};function No(t){return x.each(["left","top","width","height"],function(e){t[e]=Math.round(t[e])}),t}function Mo(e,t=D){var n;return function(e){var t,n=e;for(;n;){if(!(t=O(n)))return;if("fixed"===t.position)return!isNaN(_e)||!jo(n);n=n.parentNode}return}(e)?((n=co(e)).fixed=!0,No(n)):No(uo(e,Go(t.document),t))}var Po=function(e){e&&e.parentNode&&e.parentNode.removeChild(e)},Fo=x.compose(function(e){return Array.prototype.slice.call(e)},function(e,t){try{return ur(e,t)}catch(n){return fo("error using sizzle: "+n),t.getElementsByTagName(e)}}),Do=function(e,t){try{return t.children.length+t.offsetHeight+t.offsetWidth-(e.children.length+e.offsetHeight+e.offsetWidth)}catch(n){return B.info("error interrogating body elements: "+n),fo("error picking best body:"+n),0}},Go=function(e){e=e||G;try{var t=Fo("body",e);return t&&1=t.bottom||e.bottom<=t.top||e.left>=t.right||e.right<=t.left)};function jo(e){for(var t=e&&e.parentNode;t;){if(to(O(t),ke))return 1;t=t.parentNode}}var Wo=function(e,t,n){t=t||/(auto|scroll|hidden)/;var i,r=(n=n||D).document.documentElement;if(Bo(e))for(i=e;i;)if(zn.isElementShadowRoot(i))i=i.host;else{if(i===r)return null;if(!(o=O(i)))return null;var o,a=o.position;if(i!==e&&t.test(o.overflow+o.overflowY+o.overflowX))return i.parentNode!==r||(o=O(r))&&!x.contains([o.overflow,o.overflowY,o.overflowX],"visible")?i:null;if("absolute"===a||"fixed"===a&&jo(i))i=oo(i);else{if("fixed"===a)return null;i=i.assignedSlot||i.parentNode}}return null};function Jo(e,t){e=O(e);return t=t||/(auto|scroll|hidden)/,!e||"inline"===e.display?qo.NONE:t.test(e.overflowY)&&t.test(e.overflowX)?qo.BOTH:t.test(e.overflowY)?qo.Y:t.test(e.overflowX)?qo.X:t.test(e.overflow)?qo.BOTH:qo.NONE}var qo={X:"x",Y:"y",BOTH:"both",NONE:"none"};function Ko(e){return e&&e.nodeName&&"body"===e.nodeName.toLowerCase()&&Bo(e)}function Vo(e){var t=G.createElement("script"),n=G.head||G.getElementsByTagName("head")[0]||G.body;t.type="text/javascript",e.src?t.src=e.src:t.text=e.text||e.textContent||e.innerHTML||"",n.appendChild(t),n.removeChild(t)}function $o(e){if(e){if(x.isFunction(e.getRootNode))return e.getRootNode();if(null!=e.ownerDocument)return e.ownerDocument}return G}function Zo(e,t,n){const i=[];var r=$o(G.documentElement);let o=$o(Wo(t)),a=0;for(;o!==r&&a<20;)i.push(e(o,"scroll",n,!0)),o=$o(Wo(o)),a++;return()=>{x.each(x.compact(i),function(e){e()}),i.length=0}}const aw=['a[href]:not([disabled]):not([tabindex="-1"])','button:not([disabled]):not([tabindex="-1"])','textarea:not([disabled]):not([tabindex="-1"])','input:not([disabled]):not([tabindex="-1"])','select:not([disabled]):not([tabindex="-1"])','[tabindex]:not([tabindex="-1"])',"iframe"].join(", ");function Yo(e,t,n){var i=Ho(t),t=Jo(t,n);if(t!==qo.BOTH||zo(e,i)){if(t===qo.Y){if(e.top>=i.bottom)return;if(e.bottom<=i.top)return}if(t===qo.X){if(e.left>=i.right)return;if(e.right<=i.left)return}return 1}}function Xo(e){if(e){if(Ko(e))return 1;var t=Ho(e);if(0!==t.width&&0!==t.height){var n=O(e);if(!n||"hidden"!==n.visibility){for(var i=e;i&&n;){if("none"===n.display)return;if(parseFloat(n.opacity)<=0)return;n=O(i=i.parentNode)}return 1}}}}function Qo(e,t){if(!Xo(e))return!1;if(!Ko(e)){for(var n=Ho(e),i=Wo(e,t=t||/hidden/),r=null;i&&i!==G&&i!==r;){if(!Yo(n,i,t))return!1;i=Wo(r=i,t)}if(e.getBoundingClientRect){var e=e.getBoundingClientRect(),o=e.right,e=e.bottom;if(n.fixed||(o+=io(),e+=no()),o<=0||e<=0)return!1}}return!0}function ea(e){var t,n,i,r,o,a=/(auto|scroll)/,s=/(auto|scroll|hidden)/,u=Ho(e),d=Wo(e,s);if(!Xo(e))return!1;for(;d;){if(t=Ho(d),(o=Jo(d,a))!==qo.NONE&&(i=n=0,o!==qo.Y&&o!==qo.BOTH||(u.bottom>t.bottom&&(n+=u.bottom-t.bottom,u.top-=n,u.bottom-=n),u.topt.right&&(i+=u.right-t.right,u.left-=i,u.right-=i),u.leftn.bottom&&(i+=t.bottom-n.bottom,t.top-=i,t.bottom-=i),t.topn.right&&(r+=t.right-n.right,t.left-=r,t.right-=r),t.left{},ze.MutationObserver){const n=new(ht("MutationObserver"))((e,t)=>{this.signal()});n.observe(e,t),this._teardown=()=>n.disconnect}else{const i=Gt(()=>{this.signal()},500);this._teardown=()=>{clearTimeout(i)}}}signal(){x.each(this.listeners,e=>{e.get()})}addObservers(...e){this.listeners=[].concat(this.listeners,e)}teardown(){this._teardown()}}Kv=function(){function e(e){this._object=e}return e.prototype.deref=function(){return this._object},e};var na="function"==typeof(d=D.WeakRef)&&/native/.test(d)?d:Kv();function ia(e,t){var n,i;return e.tagName&&-1<["textarea","input"].indexOf(e.tagName.toLowerCase())?(n=e.value,i=t,n.length<=i?n:sa(n.substring(0,i))):ra(e,t)}function ra(e,t=128){var n,i="",r=e.nodeType;if(r===Zr||r===eo)return e.nodeValue;if((n=e).tagName&&"textarea"!=n.tagName.toLowerCase()&&(r===Yr||r===Xr||r===Qr)){if(!e.childNodes)return i;for(var o,a=0;a{t.addEventListener(e,e=>this.onEvent(e))}),this.elRef=new na(t)}return t}getText(e=1024){return ia(this.get(),e)}addEventListener(e,t){var n=this.get();this.events.indexOf(e)<0&&(this.events.push(e),n)&&n.addEventListener(e,e=>this.onEvent(e)),this.listeners[e]=this.listeners[e]||[],this.listeners[e].push(t)}onEvent(t){var e=t.type;x.each(this.listeners[e],e=>e(t))}teardown(t=this.get()){t&&x.each(this.events,e=>t.removeEventListener(e,this.onEvent))}}function ua(t){if(!t)return!1;if(t===D.location.origin)return!0;if(t===Jr())return!0;if(t===Kr())return!0;var e=[/^https:\/\/(app|via|adopt)(\.eu|\.us|\.gov|\.jpn|\.hsbc|\.au)?\.pendo\.io$/,/^https:\/\/((adopt\.)?us1\.)?(app|via|adopt)\.pendo\.io$/,/^https:\/\/([0-9]{8}t[0-9]{4}-dot-)pendo-(io|eu|us1|govramp|jp-prod|hsbc|au)\.appspot\.com$/,/^https:\/\/hotfix-(ops|app)-([0-9]+-dot-)pendo-(io|eu|us1|govramp|jp-prod|hsbc|au)\.appspot\.com$/,/^https:\/\/pendo-(io|eu|us1|govramp|jp-prod|hsbc|au)-static\.storage\.googleapis\.com$/,/^https:\/\/(us1\.)?cdn(\.eu|\.jpn|\.gov|\.hsbc|\.au)?\.pendo\.io$/],n=(Xe()||(e=e.concat([/^https:\/\/([a-zA-Z0-9-]+\.)*pendo-dev\.com$/,/^https:\/\/([a-zA-Z0-9-]+-dot-)?pendo-(dev|test|io|us1|govramp|jp-prod|hsbc|au|batman|magic|atlas|wildlings|ionchef|mobile-guides|mobile-hummus|mobile-fbi|mobile-plat|eu|eu-dev|apollo|security|perfserf|freeze|armada|voc|mcfly|calypso|dap|scrum-ops|ml|helix|uat)\.appspot\.com$/,/^https:\/\/via\.pendo\.local:\d{4}$/,/^https:\/\/adopt\.pendo\.local:\d{4}$/,/^https:\/\/local\.pendo\.io:\d{4}$/,new RegExp("^https://pendo-"+Je+"-static\\.storage\\.googleapis\\.com$")])),f.get("adoptHost"));if(n&&t==="https://"+n)return!0;return!!x.contains(f.get("allowedOriginServers",[]),t)||x.any(e,function(e){return e.test(t)})}function da(e){var t;if(x.isString(e))return t=(t=Ur(e).substring(1))&&t.length?jr(t):{},e=x.last(x.first(e.split("?")).split("/")).split("."),{filename:x.first(e),extension:e.slice(1).join("."),query:t}}function ca(e,t){var n;f.get("guideValidation")&&ze.sri&&(n=da(t),t=x.find(["sha512","sha384","sha256"],function(e){return!!n.query[e]}))&&(e.integrity=t+"-"+(t=n.query[t],x.isString?t.replace(/-/g,"+").replace(/_/g,"/"):t),e.setAttribute("crossorigin","anonymous"))}x.extend(A,{data:cr,event:mr,removeNode:Po,getClass:_o,hasClass:Eo,addClass:function(e,t){var n;"string"==typeof e?(n=A(e),x.map(n,function(e){Io(e,t)})):Io(e,t)},removeClass:function(e,t){var n;"string"==typeof e?(n=A(e),x.map(n,function(e){xo(e,t)})):xo(e,t)},getBody:Go,getComputedStyle:O,getClientRect:Ho,intersectRect:zo,getScrollParent:Wo,isElementVisible:Qo,Observer:sw,Element:uw,scrollIntoView:ta,getRootNode:$o}),x.extend(A.prototype,mr.$,Yv.$);var la,pa=function(e){var t=0===f.get("allowedOriginServers",[]).length,n=$r();return!(!t&&!n&&(t=Je,n=te,!/prod/.test(t)||se(n)))||ua(e)},ha=function(e,t,n=!1){try{var i,r="text/css",o="text/javascript";if(x.isString(e)&&(e={url:e}),!pa((d=e.url,new Dn(d).origin)))throw new Error;e.type=e.type||/\.css/.test(e.url)?r:o;var a=null,s=G.getElementsByTagName("head")[0]||G.getElementsByTagName("body")[0];if(e.type===r){var u=G.createElement("link");u.type=r,u.rel="stylesheet",u.href=e.url,ca(u,e.url),a=u}else{if(pt())return(i=G.createElement("script")).addEventListener("load",function(){t(),Po(i)}),i.type=o,i.src=Q(I).createScriptURL(e.url),ca(i,e.url),G.body.appendChild(i),{};(i=G.createElement("script")).type=o,i["async"]=!0,i.src=Q(I).createScriptURL(e.url),ca(i,e.url),a=i,t=x.wrap(t,function(e,t){A.removeNode(i),t?n&&e(t):e.apply(this,x.toArray(arguments).slice(1))})}return s.appendChild(a),fa(a,e.url,t),a}catch(c){return{}}var d},fa=function(e,t,n){var i=!1;be(n)&&(e.onload=function(){!0!==i&&(i=!0,n(null,t))},e.onerror=function(){!0!==i&&(i=!0,n(new Error("Failed to load script"),t))},e.onreadystatechange=function(){i||e.readyState&&"loaded"!=e.readyState&&"complete"!=e.readyState||(i=!0,n(null,t))},"link"===e.tagName.toLowerCase())&&(Gt(function(){var e;i||((e=new Image).onload=e.onerror=function(){!0!==i&&(i=!0,n(null,t))},e.src=t)},500),Gt(function(){i||fo("Failed to load "+t+" within 10 seconds")},1e4))},ga=function(e){var t=JSON.parse(e.data),n=e.origin;B.debug(I.app_name+": Message: "+JSON.stringify(t)+" from "+n),Oa(e.source,{status:"success",msg:"ack",originator:"messageLogger"},n)},ma=function(e){ba(Ea)(va(e))},va=function(e){if(e.data)try{var t="string"==typeof e.data?JSON.parse(e.data):e.data,n=e.origin,i=e.source;if(!t.action&&!t.mutation){if(t.type&&"string"==typeof t.type)return{data:t,origin:n,source:i};B.debug("Invalid Message: Missing 'type' in data format")}}catch(r){}};function ba(t){return function(e){if(e&&ua(e.origin))return t.apply(this,arguments)}}var ya={disconnect(e){},module:function(e){Ra(e.moduleURL)},debug:function(e){Na(ga)}},wa=function(e,t){ya[e]=t},Sa=function(e){delete ya[e]},Ea=function(e){var t;e&&(t=e.data)&&be(ya[t.type])&&ya[t.type](t,e)},Ia={},xa=function(e){if(Ia[e]={},"undefined"!=typeof CKEDITOR)try{CKEDITOR.config.customConfig=""}catch(t){}},Ca=function(e){return be(Ia[e])},_a=function(e){if(Ia)for(var t in Ia)if(0<=t.indexOf(e))return t;return null},Ta=[],Aa=function(){var e;Ta.length<1||(e=Ta.shift(),Ca(e))||ha(e,function(){xa(e),Aa()})},Ra=function(e){!function(e){var t={"/js/lib/ckeditor/ckeditor.js":1};x.each(["depres.js","tether.js","sortable.js","selection.js","selection.css","html2canvas.js","ckeditor/ckeditor.js"],function(e){t["/modules/pendo.designer/plugins/"+e]=1,t["/engage-app-ui/assets/classic-designer/plugins/"+e]=1});try{var n=new Dn(e);return ua(n.origin)&&t[n.pathname]}catch(i){B.debug("Invalid module URL: "+e)}}(e)||(Ta.push(e),1e.codePointAt(0))))):JSON.parse(atob(e.split(".")[1]))}catch(n){return null}var t}function Qa(e,t){return t=t?t+": ":"",e.jwt||e.signingKeyName?e.jwt&&!e.signingKeyName?(B.debug(t+"The jwt is supplied but missing signingKeyName."),!1):e.signingKeyName&&!e.jwt?(B.debug(t+"The signingKeyName is supplied but missing jwt."),!1):(e=e.jwt,!(!x.isString(e)||!/^[a-zA-Z0-9\-_]+?\.[a-zA-Z0-9\-_]+?\.([a-zA-Z0-9\-_]+)?$/.test(e))||(B.debug(t+"The jwt is invalid."),!1)):(B.debug(t+"Missing jwt and signingKeyName."),!1)}es=null;var es,ts={set:function(e){es=JSON.parse(JSON.stringify(e||{}))},get:function(){return null!==es?es:{}},getJwtOptions:function(e,t){var n;return t=t||"",!!f.get("enableSignedMetadata")&&(n=Qa(e,t),f.get("requireSignedMetadata")&&!n?(B.debug("Pendo will not "+t+"."),!1):n?Xa(e.jwt):void B.debug("JWT is enabled but not being used, falling back to unsigned metadata."))}};class dw{constructor(e,t=100){this.queue=[],this.unloads=new Set,this.pending=new Set,this.failures=new Map,this.sendFn=e,this.maxFailures=t}isEmpty(){return this.queue.length<=0}stop(){this.queue.length=0,this.unloads.clear(),this.pending.clear(),this.failures.clear(),this.stopped=!0,clearTimeout(this.timer),delete this.timer}start(){this.stopped=!1}push(...e){this.queue.push(...e),this.next()}next(){var e;if(this.queue.length&&this.pending.size<1)return e=this.queue[0],this.send(e,!1,!0)}incrementFailure(e){var t=(this.failures.get(e)||0)+1;return this.failures.set(e,t),t}pass(e,t=!0){this.unloads["delete"](e),this.pending["delete"](e),this.failures.clear();e=this.queue.indexOf(e);0<=e&&this.queue.splice(e,1),!this.stopped&&t&&this.next()}fail(e,t=!0){this.unloads["delete"](e),this.pending["delete"](e);var n=this.incrementFailure(e);!this.stopped&&t&&(n>=this.maxFailures&&(this.onTimeout&&this.onTimeout(),this.pass(e,!1)),this.retryLater(1e3*Math.pow(2,Math.min(n-1,6))))}retryLater(e){this.timer=Gt(()=>{delete this.timer,this.next()},e)}failed(){return 0this.pass(e,n),()=>this.fail(e,n))}drain(e,t=!0){if(this.queue.push(...e),this.failed())return qt.reject();var n=[];for(const i of this.queue)this.pending.has(i)?this.retryPending&&t&&!this.unloads.has(i)&&(this.incrementFailure(i),n.push(this.send(i,t,!1))):n.push(this.send(i,t,!1));return qt.all(n)}}const cw="unsentEvents";class lw{constructor(){this.events={}}send(t,n){var i=this.events[t];if(i){let e=i.shift();for(;e;)n.push(e),e=i.shift();delete this.events[t]}}push(e,t){var n=this.events[e]||[];n.push(t),this.events[e]=n}read(e){e.registry.addLocal(cw),this.events=JSON.parse(e.read(cw)||"{}"),e.clear(cw)}write(e){0t.upper)return o;i+=n}if(!(0t.lower)return o;i+=n}return-1}function ys(){var e=ts.get();return x.isEmpty(e)?0:e.jwt.length+e.signingKeyName.length}function ws(e,t){var n;if(0!==e.length)return e.JZB||(e.JZB=I.squeezeAndCompress(e.slice()),e.JZB.length<=Ba)||1===e.length?t(e):(n=e.length/2,ws(e.slice(0,n),t),void ws(e.slice(n),t))}function Ss(e,t){So()&&t(e)}function Es(){return function(e,t){1===e.length&&e.JZB.length>Ba?(B.debug("Couldn't write event"),fo("Single item is: "+e.JZB.length+". Dropping."),go(e.JZB)):t(e)}}function Is(e,t){return po(t.beacon+".gif",e,x.extend({v:$e,ct:v(),jzb:t.JZB},t.params,t.auth))}function xs(e,t){return po(t.beacon+".gif",e,x.extend({v:$e,ct:v(),s:t.JZB.length},t.params))}function Cs(i){return function(e,t){e.params=x.extend({},e.params,i.params),e.beacon=i.beacon,e.eventLength=e.JZB.length;var n=ts.get();x.isEmpty(n)||(e.auth=n,e.eventLength+=n.jwt.length,e.eventLength+=n.signingKeyName.length),t(e)}}function _s(e,t){var n=$r(),i=x.first(e),i=x.get(i,"account_id");n&&i&&(e.params=x.extend({},e.params,{acc:st(i)})),t(e)}function Ts(e,t){var n=x.first(e),n=x.get(n,"props.source");n&&(e.params=x.extend({},e.params,{source:n})),t(e)}function As(e){return JSON.stringify(x.extend({events:e.JZB},e.auth))}function Rs(e){return e.status<200||300<=e.status?b.reject(new Error(`received status code ${e.status}: `+e.statusText)):b.resolve()}function Os(e,t){return vo(Is(e,t)).then(Rs)}function ks(e,t){return fetch(xs(e,t),{method:"POST",keepalive:!0,body:As(t),headers:{"Content-Type":"application/json"}}).then(Rs)}function Ls(e,t){var n=As(t);return bo(xs(e,t),n)?b.resolve():b.reject()}function Ns(n){return function(e,t){return t.JZB?t.eventLength<=Ua&&!f.get("sendEventsWithPostOnly")?n.preferFetch&&!t.auth&&vo.supported()?Os(e,t):t.auth?vr({method:"GET",url:Is(e,t)}):mo(Is(e,t)):n.allowPost&&t.eventLength<=Ba?vo.supported()?ks(e,t):bo.supported()?Ls(e,t):vr({method:"POST",url:xs(e,e=t),data:As(e),headers:{"Content-Type":"application/json"}}):b.resolve():b.resolve()}}function Ms(n){return function(e,t){if(t.JZB){if(t.eventLength<=Ua&&!f.get("sendEventsWithPostOnly",!1)){if(!t.auth&&vo.supported())return Os(e,t);if(ze.msie<=11)return vr({method:"GET",url:Is(e,t),sync:!0})}if(t.eventLength<=Ba&&n.allowPost){if(vo.supported())return ks(e,t);if(bo.supported())return Ls(e,t);if(ze.msie<=11)return vr({method:"POST",url:xs(e,e=t),data:As(e),sync:!0,headers:{"Content-Type":"application/json"}})}}return b.resolve()}}function Ps(e,t){e.length=0;var n,i={};for(n in e)e.hasOwnProperty(n)&&(i[n]=e[n]);t(i)}function Fs(e){return ls(Ss,fs,ws,(t=e.shorten,t=x.defaults(t||{},{fields:[],siloMaxLength:Ba}),function(n,e){var i;1===n.length&&n.JZB.length>t.siloMaxLength&&(i=n[0],B.debug("Max length exceeded for an event"),x.each(t.fields,function(e){var t=i[e];t&&2e3e.isEmpty())}stop(){x.each(this.queues,e=>e.stop())}push(){const t=x.toArray(arguments);x.each(this.queues,e=>e.push.apply(e,t))}drain(){const t=x.toArray(arguments);return b.all(x.map(this.queues,e=>e.drain.apply(e,t)))}}function Ds(o,a,s){a=a||Ns(o),s=s||Ms(o);e=o;var e=x.isFunction(e.apiKey)?[].concat(e.apiKey()):[].concat(e.apiKey),e=x.map(e,(i,r)=>{var e=new dw(function(e,t,n){return n&&(e.params=x.extend({},e.params,{rt:n})),o.localStorageUnload&&t?(0===r&&ns.push(o.beacon,e),b.resolve()):(t?s:a)(i,e)});return e.onTimeout=function(){y.commit("monitoring/incrementCounter",o.beacon+"GifFailures")},e.retryPending=!0,e}),e=new pw(e);return ns.send(o.beacon,e),e}function Gs(e,t){var n=f.get("analytics.excludeEvents");0<=x.indexOf(n,e.type)||t(e)}class hw{constructor(e){this.locks={},this.cache=e.cache||[],this.silos=e.silos||[],this.packageSilos=e.packageSilos,this.processSilos=e.processSilos,this.sendQueue=Ds(e)}pause(e=1e4){var t=x.uniqueId();const n=this["locks"];n[t]=1;var i=()=>{n[t]&&(clearTimeout(r),delete n[t],this.flush())},r=Gt(i,e);return i}push(e){this.packageSilos(e,e=>{this.silos.push(e)})}clear(){this.cache.length=0,this.silos.length=0,this.sendQueue.stop()}flush({unload:e=!1,hidden:t=!1}={}){var{cache:n,silos:i}=this;if((0!==n.length||0!==i.length||!this.sendQueue.isEmpty())&&x.isEmpty(this.locks)){i.push(n.slice()),n.length=0;n=i.slice();i.length=0;const r=[];x.each(n,function(e){this.processSilos(e,function(e){r.push(e)})},this),e||t?this.sendQueue.drain(r,e):this.sendQueue.push(...r)}}}function Us(e){var i,r,t=Fs(e),n=ls((r=e.beacon,function(e,t){var n=f.get("excludeNonGuideAnalytics");"ptm"===r&&n||t(e)}),Gs,ps,hs(e.cache),(i={overhead:ys,lower:f.get("sendEventsWithPostOnly")?Ba:Ua,upper:Ba,compressionRatio:[.5*rs,.75*rs,rs]},function(e,t){for(var n=bs(e,i);0<=n;)t(e.splice(0,Math.max(n,1))),n=bs(e,i)}));return new hw(x.extend({processSilos:t,packageSilos:n},e))}var Bs=Fn(function(e){var t,n,i;if((e=e||R.get())&&e!==Bs.lastUrl)return Bs.lastUrl=e,t=-1,Ma()||ka()&&(i=La(),Oa(i,{type:"load",url:location.toString()},"*")),B.debug("sending load event for url "+e),t={load_time:t="undefined"!=typeof performance&&x.isFunction(performance.getEntriesByType)&&!x.isEmpty(performance.getEntriesByType("navigation"))?(i=performance.getEntriesByType("navigation")[0]).loadEventStart-i.fetchStart:t},ka()&&(t.is_frame=!0),"*"!==(n=$0())&&(t.allowed_storage_keys=x.keys(n)),os("load",t,e),Va(),m.urlChanged.trigger(),!0});function Hs(e){return"hidden"===e.visibilityState}Bs.reset=function(){Bs.lastUrl=null};const fw="visibilitychange",gw="pagehide",mw="unload";function zs(){this.serializers=x.toArray(arguments)}function js(e,t){return e.tag=zn.isElementShadowRoot(t)?"#shadow-root":t.nodeName||"",e}function Ws(e){return be(e)?""+e:""}function Js(e,t){return e.id=Ws(t.id),e}function qs(e,t){return e.cls=Ws(A.getClass(t)),e}x.extend(zs.prototype,{add(e){this.serializers.push(e)},remove(e){e=x.indexOf(this.serializers,e);0<=e&&this.serializers.splice(e,1)},serialize(n,i){return n?(i=i||n,x.reduce(this.serializers,function(e,t){return t.call(this,e,n,i)},{},this)):{}}});var Ks=256,Vs=64,$s={a:{events:["click"],attr:["href"]},button:{events:["click"],attr:["value","name"]},img:{events:["click"],attr:["src","alt"]},select:{events:["mouseup"],attr:["name","type","selectedIndex"]},textarea:{events:["mouseup"],attr:["name"]},'input[type="submit"]':{events:["click"],attr:["name","type","value"]},'input[type="button"]':{events:["click"],attr:["name","type","value"]},'input[type="radio"]':{events:["click"],attr:["name","type"]},'input[type="checkbox"]':{events:["click"],attr:["name","type"]},'input[type="password"]':{events:["click"],attr:["name","type"]},'input[type="text"]':{events:["click"],attr:["name","type"]}},Zs=function(e,t,n){var i;return e&&e.nodeName?"img"==(i=e.nodeName.toLowerCase())&&"src"==t||"a"==i&&"href"==t?(i=e.getAttribute(t),Dr((i=i)&&0===i.indexOf("data:")?(B.debug("Embedded data provided in URI."),i.substring(0,i.indexOf(","))):i+"")):(i=t,e=(t=e).getAttribute?t.getAttribute(i):t[i],(!n||typeof e===n)&&e?x.isString(e)?tt.call(e).substring(0,Ks):e:null):null};function Ys(t){var e,n,i;return x.isRegExp(t)&&x.isFunction(t.test)?function(e){return t.test(e)}:x.isArray(t)?(e=x.map(x.filter(t,x.isObject),function(e){var t;return e.regexp?(t=(t=/\/([a-z]*)$/.exec(e.value))&&t[1]||"",new RegExp(e.value.replace(/^\//,"").replace(/\/[a-z]*$/,""),t)):new RegExp("^"+e.value+"$","i")}),function(t){return x.any(e,function(e){return e.test(t)})}):x.isObject(t)&&t.regexp?(n=(n=/\/([a-z]*)$/.exec(t.value))&&n[1]||"",i=new RegExp(t.value.replace(/^\//,"").replace(/\/[a-z]*$/,""),n),function(e){return i.test(e)}):x.constant(!1)}function Xs(e,t,n,i){try{var r,o=x.indexBy(t),a=x.filter(x.filter(e,function(e){return n(e.nodeName)||o[e.nodeName]}),function(e){return!i(e.nodeName)});return a.length<=Vs?x.pluck(a,"nodeName"):(r=x.groupBy(e,function(e){return o[e.nodeName]?"defaults":x.isString(e.value)&&e.value.length>Ks?"large":"small"}),x.pluck([].concat(x.sortBy(r.defaults,"nodeName")).concat(x.sortBy(r.small,"nodeName")).concat(x.sortBy(r.large,"nodeName")).slice(0,Vs),"nodeName"))}catch(s){return B.error("Error collecting DOM Node attributes: "+s),[]}}function Qs(t,n){var e=Ys(f.get("htmlAttributes")),i=Ys(f.get("htmlAttributeBlacklist")),r=(i("title")||(t.title=Zs(n,"title","string")),(t.tag||"").toLowerCase()),r=("input"===r&&(r+='[type="'+n.type+'"]'),t.attrs={},Xs(n.attributes,$s[r]&&$s[r].attr,e,i));return x.each(r,function(e){t.attrs[e.toLowerCase()]=Zs(n,e)}),t}function eu(e,t){var n;return t.parentNode&&t.parentNode.childNodes&&(n=x.chain(t.parentNode.childNodes),e.myIndex=n.indexOf(t).value(),e.childIndex=n.filter(function(e){return e.nodeType==Yr}).indexOf(t).value()),e}function tu(i,e){var r;return f.get("siblingSelectors")&&e.previousElementSibling&&(r="_pendo_sibling_",this.remove(tu),e=this.serialize(e.previousElementSibling),this.add(tu),i.attrs=i.attrs||{},x.each(e,function(e,t){var n={cls:"class",txt:"pendo_text"}[t]||t;x.isEmpty(e)||(x.isObject(e)?x.each(e,function(e,t){e&&!x.isEmpty(e)&&(i.attrs[r+n+"_"+t]=e)}):i.attrs[r+n]=e)})),i}var nu=new zs(js,Js,qs,Qs,eu,tu),iu=function(e){return"BODY"===e.nodeName&&e===Go()||null===e.parentNode&&!zn.isElementShadowRoot(e)},ru="pendo-ignore",ou="pendo-analytics-ignore",au=function(e){var t={},n=t,i=e,r=!1;if(!e)return t;do{var o=i,a=nu.serialize(o,e)}while(r||!lt(a.cls,ru)&&!lt(a.cls,ou)||(r=!0),n.parentElem=a,n=a,(i=zn.getParent(o))&&!iu(o));return r&&(t.parentElem.ignore=!0),t.parentElem},su=["","left","right","middle"],uu=[["button",function(e){return e.which||e.button},function(){return!0},function(e,t){return su[t]}],["altKey",e=function(e,t){return e[t]},a=function(e){return e},a],["ctrlKey",e,a,a],["metaKey",e,a,a],["shiftKey",e,a,a]],du={click:function(e,t){for(var n=[],i=0;i{lu.cancel()}),t.push(hr(G,"change",lu,!0)));var n,i=f.get("interceptElementRemoval")||f.get("syntheticClicks.elementRemoval"),r=f.get("syntheticClicks.targetChanged"),r=(t.push(function(t,e,n,i){var r,o,a=[],s=ze.hasEvent("pointerdown"),u=s?"pointerdown":"mousedown",s=s?"pointerup":"mouseup",d=[],c={cloneEvent:function(e){e=A.event.clone(e);return e.type="click",e.from=u,e.bubbles=!0,e},down:function(e){o=!1,e&&(r=c.cloneEvent(e),n)&&c.intercept(e)},up:function(e){o=!1,e&&r&&i&&gr(r)!==gr(e)&&(o=!0,t(r))},click:function(e){r=null,o&&A.data.set(e,"ignore",!0),o=!1,n&&c.unwrap()},intercept:function(e){e=function(e){var t=[];for(;e&&!iu(e);)t.push(e),e=e.parentNode;return t}(gr(e));x.each(e,function(e){e=hu(e,c.remove);a.push(e)})},remove:function(){r&&(t(r),r=null),c.unwrap()},unwrap:function(){0{x.each(n,function(e){e()})})),t.push(function(e,t){if(t){const n=[];return n.push(hr(G,fw,()=>{Hs(G)&&e(!0,!1)})),n.push(hr(D,gw,x.partial(e,!1,!0))),()=>x.each(n,function(e){e()})}return hr(D,mw,x.partial(e,!0,!0))}(function(e,t){t&&m.appUnloaded.trigger(),e&&m.appHidden.trigger()},f.get("preventUnloadListener"))),f.get("interceptStopPropagation",!0)),i=f.get("interceptPreventDefault",!0);return r&&t.push(fu(D.Event,e)),i&&t.push(gu(D.Event,["touchend"])),()=>{x.each(t,function(e){e()})}};function hu(n,i){var e=["remove","removeChild"];try{if(!n)return x.noop;x.each(e,function(e){var t=n[e];if(!t)return x.noop;n[e]=x.wrap(t,function(e){return i&&i(),e.apply(this,x.toArray(arguments).slice(1))}),n[e]._pendoUnwrap=function(){if(!n)return x.noop;n[e]=t,delete n[e]._pendoUnwrap}})}catch(t){B.critical("ERROR in interceptRemove",{error:t})}return function(){x.each(e,function(e){if(!n[e])return x.noop;e=n[e]._pendoUnwrap;x.isFunction(e)&&e()})}}function fu(n,e){var t=["stopPropagation","stopImmediatePropagation"];try{if(!n||!n.prototype)return x.noop;var i=x.indexBy(e);x.each(t,function(e){var t=n.prototype[e];t&&(n.prototype[e]=x.wrap(t,function(e){var t=e.apply(this,arguments);return i[this.type]&&(A.data.set(this,"stopped",!0),A.event.trigger(this)),t}),n.prototype[e]._pendoUnwrap=function(){n.prototype[e]=t,delete n.prototype[e]._pendoUnwrap})})}catch(r){B.critical("ERROR in interceptStopPropagation",{error:r})}return function(){x.each(t,function(e){e=n.prototype[e]._pendoUnwrap;x.isFunction(e)&&e()})}}function gu(t,e){try{if(!t||!t.prototype)return x.noop;var i=x.indexBy(e),n=t.prototype.preventDefault;if(!n)return x.noop;t.prototype.preventDefault=x.wrap(n,function(e){var t,n=e.apply(this,arguments);return i[this.type]&&((t=A.event.clone(this)).type="click",t.from=this.type,t.bubbles=!0,t.eventPhase=lr,A.event.trigger(t)),n}),t.prototype.preventDefault._pendoUnwrap=function(){t.prototype.preventDefault=n,delete t.prototype.preventDefault._pendoUnwrap}}catch(r){B.critical("ERROR in interceptPreventDefault",{error:r})}return function(){var e=t.prototype.preventDefault._pendoUnwrap;x.isFunction(e)&&e()}}function l(e,t,n,i){return e&&t&&n?(i&&!ze.addEventListener&&(i=!1),A.event.add(e,{type:t,handler:n,capture:i})):x.noop}function mu(e,t,n,i){e&&t&&n&&(i&&!ze.addEventListener&&(i=!1),A.event.remove(e,t,n,i))}var vu=function(e){A.data.set(e,"pendoStopped",!0),e.stopPropagation?e.stopPropagation():e.cancelBubble=!0,e.preventDefault?e.preventDefault():e.returnValue=!1},bu=function(e,t){return"complete"!==(t=t||D).document.readyState?hr(t,"load",e):(e(),x.noop)},k=[],yu=[],wu={};let h={};function Su(){return k}function Eu(){return Iu(k)}function Iu(e){return x.filter(e,function(e){return!e.isFrameProxy})}function xu(e){x.isArray(e)?(k=e,m.guideListChanged.trigger({guideIds:x.pluck(e,"id")})):B.info("bad guide array input to `setActiveGuides`")}var Cu=function(){let n=[];return{addGuide:e=>{var t;x.isEmpty(e)||(n=n.concat(e),x.each(n,e=>e.hide&&e.hide()),e=k,(t=x.difference(e,n)).length