diff --git a/packages/grafana-data/src/utils/arrayUtils.test.ts b/packages/grafana-data/src/utils/arrayUtils.test.ts index b41c3e728b7..132580a6415 100644 --- a/packages/grafana-data/src/utils/arrayUtils.test.ts +++ b/packages/grafana-data/src/utils/arrayUtils.test.ts @@ -1,6 +1,6 @@ import { SortOrder } from '@grafana/schema'; -import { sortValues } from './arrayUtils'; +import { insertAfterImmutably, insertBeforeImmutably, sortValues } from './arrayUtils'; describe('arrayUtils', () => { describe('sortValues', () => { @@ -30,4 +30,52 @@ describe('arrayUtils', () => { expect(sorted).toEqual(expected); }); }); + + describe('insertBeforeImmutably', () => { + const original = [1, 2, 3]; + + it.each` + item | index | expected + ${4} | ${1} | ${[1, 4, 2, 3]} + ${4} | ${2} | ${[1, 2, 4, 3]} + ${0} | ${0} | ${[0, 1, 2, 3]} + `('add $item before $index', ({ item, index, expected }) => { + const output = insertBeforeImmutably(original, item, index); + expect(output).toStrictEqual(expected); + }); + + it('should throw when out of bounds', () => { + expect(() => { + insertBeforeImmutably([], 1, -1); + }).toThrow(); + + expect(() => { + insertBeforeImmutably([], 1, 3); + }).toThrow(); + }); + }); + + describe('insertAfterImmutably', () => { + const original = [1, 2, 3]; + + it.each` + item | index | expected + ${4} | ${1} | ${[1, 2, 4, 3]} + ${4} | ${0} | ${[1, 4, 2, 3]} + ${4} | ${2} | ${[1, 2, 3, 4]} + `('add $item after $index', ({ item, index, expected }) => { + const output = insertAfterImmutably(original, item, index); + expect(output).toStrictEqual(expected); + }); + + it('should throw when out of bounds', () => { + expect(() => { + insertAfterImmutably([], 1, -1); + }).toThrow(); + + expect(() => { + insertAfterImmutably([], 1, 3); + }).toThrow(); + }); + }); }); diff --git a/packages/grafana-data/src/utils/arrayUtils.ts b/packages/grafana-data/src/utils/arrayUtils.ts index fe56786fedc..843c4d01e95 100644 --- a/packages/grafana-data/src/utils/arrayUtils.ts +++ b/packages/grafana-data/src/utils/arrayUtils.ts @@ -7,6 +7,30 @@ export function moveItemImmutably(arr: T[], from: number, to: number) { return clone; } +/** @internal */ +export function insertBeforeImmutably(array: T[], item: T, index: number): T[] { + if (index < 0 || index > array.length) { + throw new Error('Index out of bounds'); + } + + const clone = [...array]; + clone.splice(index, 0, item); + + return clone; +} + +/** @internal */ +export function insertAfterImmutably(array: T[], item: T, index: number): T[] { + if (index < 0 || index > array.length) { + throw new Error('Index out of bounds'); + } + + const clone = [...array]; + clone.splice(index + 1, 0, item); + + return clone; +} + /** * Given a sort order and a value, return a function that can be used to sort values * Null/undefined/empty string values are always sorted to the end regardless of the sort order provided diff --git a/public/app/features/alerting/unified/NotificationPolicies.tsx b/public/app/features/alerting/unified/NotificationPolicies.tsx index 16017ecc8e1..c12bfb6217b 100644 --- a/public/app/features/alerting/unified/NotificationPolicies.tsx +++ b/public/app/features/alerting/unified/NotificationPolicies.tsx @@ -36,7 +36,12 @@ import { useRouteGroupsMatcher } from './useRouteGroupsMatcher'; import { addUniqueIdentifierToRoute } from './utils/amroutes'; import { computeInheritedTree } from './utils/notification-policies'; import { initialAsyncRequestState } from './utils/redux'; -import { addRouteToParentRoute, mergePartialAmRouteWithRouteTree, omitRouteFromRouteTree } from './utils/routeTree'; +import { + InsertPosition, + addRouteToReferenceRoute, + mergePartialAmRouteWithRouteTree, + omitRouteFromRouteTree, +} from './utils/routeTree'; enum ActiveTab { NotificationPolicies = 'notification_policies', @@ -132,12 +137,18 @@ const AmRoutes = () => { updateRouteTree(newRouteTree); } - function handleAdd(partialRoute: Partial, parentRoute: RouteWithID) { + function handleAdd(partialRoute: Partial, referenceRoute: RouteWithID, insertPosition: InsertPosition) { if (!rootRoute) { return; } - const newRouteTree = addRouteToParentRoute(selectedAlertmanager ?? '', partialRoute, parentRoute, rootRoute); + const newRouteTree = addRouteToReferenceRoute( + selectedAlertmanager ?? '', + partialRoute, + referenceRoute, + rootRoute, + insertPosition + ); updateRouteTree(newRouteTree); } diff --git a/public/app/features/alerting/unified/components/notification-policies/Modals.tsx b/public/app/features/alerting/unified/components/notification-policies/Modals.tsx index 9985b7261f3..43338fdbdcf 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Modals.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Modals.tsx @@ -12,6 +12,7 @@ import { import { FormAmRoute } from '../../types/amroutes'; import { MatcherFormatter } from '../../utils/matchers'; +import { InsertPosition } from '../../utils/routeTree'; import { AlertGroup } from '../alert-groups/AlertGroup'; import { useGetAmRouteReceiverWithGrafanaAppTypes } from '../receivers/grafanaAppReceivers/grafanaApp'; @@ -21,24 +22,28 @@ import { AmRoutesExpandedForm } from './EditNotificationPolicyForm'; import { Matchers } from './Matchers'; type ModalHook = [JSX.Element, (item: T) => void, () => void]; +type AddModalHook = [JSX.Element, (item: T, position: InsertPosition) => void, () => void]; type EditModalHook = [JSX.Element, (item: RouteWithID, isDefaultRoute?: boolean) => void, () => void]; const useAddPolicyModal = ( receivers: Receiver[] = [], - handleAdd: (route: Partial, parentRoute: RouteWithID) => void, + handleAdd: (route: Partial, referenceRoute: RouteWithID, position: InsertPosition) => void, loading: boolean -): ModalHook => { +): AddModalHook => { const [showModal, setShowModal] = useState(false); - const [parentRoute, setParentRoute] = useState(); + const [insertPosition, setInsertPosition] = useState(undefined); + const [referenceRoute, setReferenceRoute] = useState(); const AmRouteReceivers = useGetAmRouteReceiverWithGrafanaAppTypes(receivers); const handleDismiss = useCallback(() => { - setParentRoute(undefined); + setReferenceRoute(undefined); + setInsertPosition(undefined); setShowModal(false); }, []); - const handleShow = useCallback((parentRoute: RouteWithID) => { - setParentRoute(parentRoute); + const handleShow = useCallback((referenceRoute: RouteWithID, position: InsertPosition) => { + setReferenceRoute(referenceRoute); + setInsertPosition(position); setShowModal(true); }, []); @@ -57,9 +62,13 @@ const useAddPolicyModal = ( { + if (referenceRoute && insertPosition) { + handleAdd(newRoute, referenceRoute, insertPosition); + } }} - onSubmit={(newRoute) => parentRoute && handleAdd(newRoute, parentRoute)} actionButtons={ + {isDefaultPolicy ? ( + + ) : ( + + onAddPolicy(currentRoute, 'above')} + /> + onAddPolicy(currentRoute, 'below')} + /> + + onAddPolicy(currentRoute, 'child')} + /> + + } + > + + + )} )} @@ -302,7 +337,7 @@ const Policy = (props: PolicyComponentProps) => {
- {renderChildPolicies && ( + {showPolicyChildren && ( <> {pageOfChildren.map((child) => { const childInheritedProperties = getInheritedProperties(currentRoute, child, inheritedProperties); @@ -353,24 +388,6 @@ const Policy = (props: PolicyComponentProps) => { ); }; -/** - * This function returns if the policy should be collapsible or not. - * Add here conditions for policies that should be collapsible. - */ -function useShouldPolicyBeCollapsible(route: RouteWithID): boolean { - const childrenCount = route.routes?.length ?? 0; - const [isSupportedToSeeAutogeneratedChunk, isAllowedToSeeAutogeneratedChunk] = useAlertmanagerAbility( - AlertmanagerAction.ViewAutogeneratedPolicyTree - ); - const isAutoGeneratedRoot = - childrenCount > 0 && - isSupportedToSeeAutogeneratedChunk && - isAllowedToSeeAutogeneratedChunk && - isAutoGeneratedRootAndSimplifiedEnabled(route); - // let's add here more conditions for policies that should be collapsible - - return isAutoGeneratedRoot; -} interface MetadataRowProps { matchingInstancesPreview: { groupsMap?: Map; enabled: boolean }; diff --git a/public/app/features/alerting/unified/utils/__snapshots__/routeTree.test.ts.snap b/public/app/features/alerting/unified/utils/__snapshots__/routeTree.test.ts.snap new file mode 100644 index 00000000000..bd1b648b406 --- /dev/null +++ b/public/app/features/alerting/unified/utils/__snapshots__/routeTree.test.ts.snap @@ -0,0 +1,87 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`addRouteToReferenceRoute should be able to add above 1`] = ` +{ + "id": "route-1", + "routes": [ + { + "id": "route-2", + }, + { + "continue": undefined, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, + "match": undefined, + "match_re": undefined, + "matchers": undefined, + "mute_time_intervals": undefined, + "object_matchers": undefined, + "receiver": "new-route", + "repeat_interval": undefined, + "routes": undefined, + }, + { + "id": "route-3", + }, + ], +} +`; + +exports[`addRouteToReferenceRoute should be able to add as child 1`] = ` +{ + "id": "route-1", + "routes": [ + { + "id": "route-2", + }, + { + "id": "route-3", + "routes": [ + { + "continue": undefined, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, + "match": undefined, + "match_re": undefined, + "matchers": undefined, + "mute_time_intervals": undefined, + "object_matchers": undefined, + "receiver": "new-route", + "repeat_interval": undefined, + "routes": undefined, + }, + ], + }, + ], +} +`; + +exports[`addRouteToReferenceRoute should be able to add below 1`] = ` +{ + "id": "route-1", + "routes": [ + { + "id": "route-2", + }, + { + "id": "route-3", + }, + { + "continue": undefined, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, + "match": undefined, + "match_re": undefined, + "matchers": undefined, + "mute_time_intervals": undefined, + "object_matchers": undefined, + "receiver": "new-route", + "repeat_interval": undefined, + "routes": undefined, + }, + ], +} +`; diff --git a/public/app/features/alerting/unified/utils/routeTree.test.ts b/public/app/features/alerting/unified/utils/routeTree.test.ts new file mode 100644 index 00000000000..f13f239ab37 --- /dev/null +++ b/public/app/features/alerting/unified/utils/routeTree.test.ts @@ -0,0 +1,87 @@ +import { RouteWithID } from 'app/plugins/datasource/alertmanager/types'; + +import { FormAmRoute } from '../types/amroutes'; + +import { GRAFANA_DATASOURCE_NAME } from './datasource'; +import { addRouteToReferenceRoute, findRouteInTree, omitRouteFromRouteTree } from './routeTree'; + +describe('findRouteInTree', () => { + it('should find the correct route', () => { + const needle: RouteWithID = { id: 'route-2' }; + + const root: RouteWithID = { + id: 'route-0', + routes: [{ id: 'route-1' }, needle, { id: 'route-3', routes: [{ id: 'route-4' }] }], + }; + + expect(findRouteInTree(root, { id: 'route-2' })).toStrictEqual([needle, root, 1]); + }); + + it('should return undefined for unknown route', () => { + const root: RouteWithID = { + id: 'route-0', + routes: [{ id: 'route-1' }], + }; + + expect(findRouteInTree(root, { id: 'none' })).toStrictEqual([undefined, undefined, undefined]); + }); +}); + +describe('addRouteToReferenceRoute', () => { + const targetRoute = { id: 'route-3' }; + const root: RouteWithID = { + id: 'route-1', + routes: [{ id: 'route-2' }, targetRoute], + }; + + const newRoute: Partial = { + id: 'new-route', + receiver: 'new-route', + }; + + it('should be able to add above', () => { + expect(addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRoute, root, 'above')).toMatchSnapshot(); + }); + + it('should be able to add below', () => { + expect(addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRoute, root, 'below')).toMatchSnapshot(); + }); + + it('should be able to add as child', () => { + expect(addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRoute, root, 'child')).toMatchSnapshot(); + }); + + it('should throw if target route does not exist', () => { + expect(() => + addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, { id: 'unknown' }, root, 'child') + ).toThrow(); + }); +}); + +describe('omitRouteFromRouteTree', () => { + it('should omit route from tree', () => { + const tree: RouteWithID = { + id: 'route-1', + receiver: 'root', + routes: [ + { id: 'route-2', receiver: 'receiver-2' }, + { id: 'route-3', receiver: 'receiver-3' }, + ], + }; + + expect(omitRouteFromRouteTree({ id: 'route-2' }, tree)).toStrictEqual({ + receiver: 'root', + routes: [{ receiver: 'receiver-3', routes: undefined }], + }); + }); + + it('should throw when removing root route from tree', () => { + const tree: RouteWithID = { + id: 'route-1', + }; + + expect(() => { + omitRouteFromRouteTree(tree, { id: 'route-1' }); + }).toThrow(); + }); +}); diff --git a/public/app/features/alerting/unified/utils/routeTree.ts b/public/app/features/alerting/unified/utils/routeTree.ts index 032c9a579be..458a9d77043 100644 --- a/public/app/features/alerting/unified/utils/routeTree.ts +++ b/public/app/features/alerting/unified/utils/routeTree.ts @@ -2,8 +2,10 @@ * Various helper functions to modify (immutably) the route tree, aka "notification policies" */ +import { produce } from 'immer'; import { omit } from 'lodash'; +import { insertAfterImmutably, insertBeforeImmutably } from '@grafana/data/src/utils/arrayUtils'; import { Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { FormAmRoute } from '../types/amroutes'; @@ -74,44 +76,78 @@ export const omitRouteFromRouteTree = (findRoute: RouteWithID, routeTree: RouteW return findAndOmit(routeTree); }; +export type InsertPosition = 'above' | 'below' | 'child'; + // add a new route to a parent route -export const addRouteToParentRoute = ( +export const addRouteToReferenceRoute = ( alertManagerSourceName: string, partialFormRoute: Partial, - parentRoute: RouteWithID, - routeTree: RouteWithID + referenceRoute: RouteWithID, + routeTree: RouteWithID, + position: InsertPosition ): Route => { const newRoute = formAmRouteToAmRoute(alertManagerSourceName, partialFormRoute, routeTree); - function findAndAdd(currentRoute: RouteWithID): RouteWithID { - if (currentRoute.id === parentRoute.id) { - return { - ...currentRoute, - // TODO fix this typescript exception, it's... complicated - // @ts-ignore - routes: currentRoute.routes?.concat(newRoute), - }; + return produce(routeTree, (draftTree) => { + const [routeInTree, parentRoute, positionInParent] = findRouteInTree(draftTree, referenceRoute); + + if (routeInTree === undefined || parentRoute === undefined || positionInParent === undefined) { + throw new Error(`could not find reference route "${referenceRoute.id}" in tree`); } - return { - ...currentRoute, - routes: currentRoute.routes?.map(findAndAdd), - }; - } + // if user wants to insert new child policy, append to the bottom of children + if (position === 'child') { + if (routeInTree.routes) { + routeInTree.routes.push(newRoute); + } else { + routeInTree.routes = [newRoute]; + } + } - function findAndOmitId(currentRoute: RouteWithID): Route { - return omit( - { - ...currentRoute, - routes: currentRoute.routes?.map(findAndOmitId), - }, - 'id' - ); - } + // insert new policy before / above the referenceRoute + if (position === 'above') { + parentRoute.routes = insertBeforeImmutably(parentRoute.routes ?? [], newRoute, positionInParent); + } - return findAndOmitId(findAndAdd(routeTree)); + // insert new policy after / below the referenceRoute + if (position === 'below') { + parentRoute.routes = insertAfterImmutably(parentRoute.routes ?? [], newRoute, positionInParent); + } + }); }; +type RouteMatch = Route | undefined; + +export function findRouteInTree( + routeTree: RouteWithID, + referenceRoute: RouteWithID +): [matchingRoute: RouteMatch, parentRoute: RouteMatch, positionInParent: number | undefined] { + let matchingRoute: RouteMatch; + let matchingRouteParent: RouteMatch; + let matchingRoutePositionInParent: number | undefined; + + // recurse through the tree to find the matching route, its parent and the position of the route in the parent + function findRouteInTree(currentRoute: RouteWithID, index: number, parentRoute: RouteWithID) { + if (matchingRoute) { + return; + } + + if (currentRoute.id === referenceRoute.id) { + matchingRoute = currentRoute; + matchingRouteParent = parentRoute; + matchingRoutePositionInParent = index; + } + + if (currentRoute.routes) { + currentRoute.routes.forEach((route, index) => findRouteInTree(route, index, currentRoute)); + } + } + + findRouteInTree(routeTree, 0, routeTree); + + return [matchingRoute, matchingRouteParent, matchingRoutePositionInParent]; +} + export function findExistingRoute(id: string, routeTree: RouteWithID): RouteWithID | undefined { return routeTree.id === id ? routeTree : routeTree.routes?.find((route) => findExistingRoute(id, route)); }