mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: add algorithm to detect what strategy changes would be overwritten by applying a CR (#5963)
This change adds an algorithm with tests for detecting what changes would be overwritten by applying a CR. Test cases: - It compares strategies regardless of order of keys in the objects. This ensures that two strategies with the same content but different order of keys are compared correctly. - It treats `undefined` or missing segments in old config as equal to `[]` in change - It treats `undefined` or missing strategy variants in old config and change as equal to `[]` - It lists changes in a sorted list with the correct values - It ignores object order on nested objects. Similar to the first point, this does order-insensitive comparison for nested objects (such as params and constraints).
This commit is contained in:
		
							parent
							
								
									b00909db3f
								
							
						
					
					
						commit
						c69137a1ee
					
				| @ -2,7 +2,10 @@ | |||||||
|   "name": "unleash-frontend-local", |   "name": "unleash-frontend-local", | ||||||
|   "version": "0.0.0", |   "version": "0.0.0", | ||||||
|   "private": true, |   "private": true, | ||||||
|   "files": ["index.js", "build"], |   "files": [ | ||||||
|  |     "index.js", | ||||||
|  |     "build" | ||||||
|  |   ], | ||||||
|   "engines": { |   "engines": { | ||||||
|     "node": ">=18" |     "node": ">=18" | ||||||
|   }, |   }, | ||||||
| @ -50,6 +53,7 @@ | |||||||
|     "@types/deep-diff": "1.0.5", |     "@types/deep-diff": "1.0.5", | ||||||
|     "@types/jest": "29.5.11", |     "@types/jest": "29.5.11", | ||||||
|     "@types/lodash.clonedeep": "4.5.9", |     "@types/lodash.clonedeep": "4.5.9", | ||||||
|  |     "@types/lodash.isequal": "^4.5.8", | ||||||
|     "@types/lodash.mapvalues": "^4.6.9", |     "@types/lodash.mapvalues": "^4.6.9", | ||||||
|     "@types/lodash.omit": "4.5.9", |     "@types/lodash.omit": "4.5.9", | ||||||
|     "@types/node": "18.19.6", |     "@types/node": "18.19.6", | ||||||
| @ -83,6 +87,7 @@ | |||||||
|     "immer": "9.0.21", |     "immer": "9.0.21", | ||||||
|     "jsdom": "23.1.0", |     "jsdom": "23.1.0", | ||||||
|     "lodash.clonedeep": "4.5.0", |     "lodash.clonedeep": "4.5.0", | ||||||
|  |     "lodash.isequal": "^4.5.0", | ||||||
|     "lodash.mapvalues": "^4.6.0", |     "lodash.mapvalues": "^4.6.0", | ||||||
|     "lodash.omit": "4.5.0", |     "lodash.omit": "4.5.0", | ||||||
|     "mermaid": "^9.3.0", |     "mermaid": "^9.3.0", | ||||||
| @ -138,7 +143,11 @@ | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   "browserslist": { |   "browserslist": { | ||||||
|     "production": [">0.2%", "not dead", "not op_mini all"], |     "production": [ | ||||||
|  |       ">0.2%", | ||||||
|  |       "not dead", | ||||||
|  |       "not op_mini all" | ||||||
|  |     ], | ||||||
|     "development": [ |     "development": [ | ||||||
|       "last 1 chrome version", |       "last 1 chrome version", | ||||||
|       "last 1 firefox version", |       "last 1 firefox version", | ||||||
|  | |||||||
| @ -0,0 +1,290 @@ | |||||||
|  | import { IChangeRequestUpdateStrategy } from 'component/changeRequest/changeRequest.types'; | ||||||
|  | import { IFeatureStrategy } from 'interfaces/strategy'; | ||||||
|  | import { getChangesThatWouldBeOverwritten } from './strategy-change-diff-calculation'; | ||||||
|  | 
 | ||||||
|  | describe('Strategy change conflict detection', () => { | ||||||
|  |     const existingStrategy: IFeatureStrategy = { | ||||||
|  |         name: 'flexibleRollout', | ||||||
|  |         constraints: [], | ||||||
|  |         variants: [], | ||||||
|  |         parameters: { | ||||||
|  |             groupId: 'aaa', | ||||||
|  |             rollout: '71', | ||||||
|  |             stickiness: 'default', | ||||||
|  |         }, | ||||||
|  |         sortOrder: 0, | ||||||
|  |         id: '31572930-2db7-461f-813b-3eedc200cb33', | ||||||
|  |         title: '', | ||||||
|  |         disabled: false, | ||||||
|  |         segments: [], | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const snapshot: IFeatureStrategy = { | ||||||
|  |         id: '31572930-2db7-461f-813b-3eedc200cb33', | ||||||
|  |         name: 'flexibleRollout', | ||||||
|  |         title: '', | ||||||
|  |         disabled: false, | ||||||
|  |         segments: [], | ||||||
|  |         variants: [], | ||||||
|  |         sortOrder: 0, | ||||||
|  |         parameters: { | ||||||
|  |             groupId: 'aaa', | ||||||
|  |             rollout: '71', | ||||||
|  |             stickiness: 'default', | ||||||
|  |         }, | ||||||
|  |         constraints: [], | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const change: IChangeRequestUpdateStrategy = { | ||||||
|  |         id: 39, | ||||||
|  |         action: 'updateStrategy' as const, | ||||||
|  |         payload: { | ||||||
|  |             id: '31572930-2db7-461f-813b-3eedc200cb33', | ||||||
|  |             name: 'flexibleRollout', | ||||||
|  |             title: '', | ||||||
|  |             disabled: false, | ||||||
|  |             segments: [], | ||||||
|  |             snapshot, | ||||||
|  |             variants: [], | ||||||
|  |             parameters: { | ||||||
|  |                 groupId: 'aaa', | ||||||
|  |                 rollout: '38', | ||||||
|  |                 stickiness: 'default', | ||||||
|  |             }, | ||||||
|  |             constraints: [], | ||||||
|  |         }, | ||||||
|  |         createdAt: new Date('2024-01-18T07:58:36.314Z'), | ||||||
|  |         createdBy: { | ||||||
|  |             id: 1, | ||||||
|  |             username: 'admin', | ||||||
|  |             imageUrl: | ||||||
|  |                 'https://gravatar.com/avatar/8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918?s=42&d=retro&r=g', | ||||||
|  |         }, | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     test('It compares strategies regardless of order of keys in the objects', () => { | ||||||
|  |         const result = getChangesThatWouldBeOverwritten( | ||||||
|  |             existingStrategy, | ||||||
|  |             change, | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         expect(result).toBeNull(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     test('It treats `undefined` or missing segments in old config as equal to `[]` in change', () => { | ||||||
|  |         const resultUndefined = getChangesThatWouldBeOverwritten( | ||||||
|  |             { | ||||||
|  |                 ...existingStrategy, | ||||||
|  |                 segments: undefined, | ||||||
|  |             }, | ||||||
|  |             change, | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         expect(resultUndefined).toBeNull(); | ||||||
|  | 
 | ||||||
|  |         const { segments, ...withoutSegments } = existingStrategy; | ||||||
|  |         const resultMissing = getChangesThatWouldBeOverwritten( | ||||||
|  |             withoutSegments, | ||||||
|  |             change, | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         expect(resultMissing).toBeNull(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     test('It treats `undefined` or missing strategy variants in old config and change as equal to `[]`', () => { | ||||||
|  |         const undefinedVariantsExistingStrategy = { | ||||||
|  |             ...existingStrategy, | ||||||
|  |             variants: undefined, | ||||||
|  |         }; | ||||||
|  |         const { variants: _variants, ...missingVariantsExistingStrategy } = | ||||||
|  |             existingStrategy; | ||||||
|  | 
 | ||||||
|  |         const { variants: _snapshotVariants, ...snapshot } = | ||||||
|  |             change.payload.snapshot!; | ||||||
|  | 
 | ||||||
|  |         const undefinedVariantsInSnapshot = { | ||||||
|  |             ...change, | ||||||
|  |             payload: { | ||||||
|  |                 ...change.payload, | ||||||
|  |                 snapshot: { | ||||||
|  |                     ...snapshot, | ||||||
|  |                     variants: undefined, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         const missingVariantsInSnapshot = { | ||||||
|  |             ...change, | ||||||
|  |             payload: { | ||||||
|  |                 ...change.payload, | ||||||
|  |                 snapshot: snapshot, | ||||||
|  |             }, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         // for all combinations, check that there are no changes
 | ||||||
|  |         const cases = [ | ||||||
|  |             undefinedVariantsExistingStrategy, | ||||||
|  |             missingVariantsExistingStrategy, | ||||||
|  |         ].flatMap((existing) => | ||||||
|  |             [undefinedVariantsInSnapshot, missingVariantsInSnapshot].map( | ||||||
|  |                 (changeValue) => | ||||||
|  |                     getChangesThatWouldBeOverwritten(existing, changeValue), | ||||||
|  |             ), | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         expect(cases.every((result) => result === null)).toBeTruthy(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     test('It lists changes in a sorted list with the correct values', () => { | ||||||
|  |         const withChanges: IFeatureStrategy = { | ||||||
|  |             name: 'flexibleRollout', | ||||||
|  |             title: 'custom title', | ||||||
|  |             constraints: [ | ||||||
|  |                 { | ||||||
|  |                     values: ['blah'], | ||||||
|  |                     inverted: false, | ||||||
|  |                     operator: 'IN' as const, | ||||||
|  |                     contextName: 'appName', | ||||||
|  |                     caseInsensitive: false, | ||||||
|  |                 }, | ||||||
|  |             ], | ||||||
|  |             variants: [ | ||||||
|  |                 { | ||||||
|  |                     name: 'variant1', | ||||||
|  |                     weight: 1000, | ||||||
|  |                     payload: { | ||||||
|  |                         type: 'string', | ||||||
|  |                         value: 'beaty', | ||||||
|  |                     }, | ||||||
|  |                     stickiness: 'userId', | ||||||
|  |                     weightType: 'variable' as const, | ||||||
|  |                 }, | ||||||
|  |             ], | ||||||
|  |             parameters: { | ||||||
|  |                 groupId: 'aab', | ||||||
|  |                 rollout: '39', | ||||||
|  |                 stickiness: 'userId', | ||||||
|  |             }, | ||||||
|  |             sortOrder: 1, | ||||||
|  |             id: '31572930-2db7-461f-813b-3eedc200cb33', | ||||||
|  |             disabled: true, | ||||||
|  |             segments: [3], | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         const result = getChangesThatWouldBeOverwritten(withChanges, change); | ||||||
|  | 
 | ||||||
|  |         const { id, name, ...changedProperties } = withChanges; | ||||||
|  | 
 | ||||||
|  |         const expectedOutput = Object.entries(changedProperties).map( | ||||||
|  |             ([property, oldValue]) => ({ | ||||||
|  |                 property, | ||||||
|  |                 oldValue, | ||||||
|  |                 newValue: | ||||||
|  |                     change.payload.snapshot![ | ||||||
|  |                         property as keyof IFeatureStrategy | ||||||
|  |                     ], | ||||||
|  |             }), | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         expectedOutput.sort((a, b) => a.property.localeCompare(b.property)); | ||||||
|  |         expect(result).toStrictEqual(expectedOutput); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     test('it ignores object order on nested objects', () => { | ||||||
|  |         const existingStrategyMod: IFeatureStrategy = { | ||||||
|  |             ...existingStrategy, | ||||||
|  |             constraints: [ | ||||||
|  |                 { | ||||||
|  |                     values: ['blah'], | ||||||
|  |                     inverted: false, | ||||||
|  |                     operator: 'IN' as const, | ||||||
|  |                     contextName: 'appName', | ||||||
|  |                     caseInsensitive: false, | ||||||
|  |                 }, | ||||||
|  |             ], | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         const constraintChange: IChangeRequestUpdateStrategy = { | ||||||
|  |             ...change, | ||||||
|  |             payload: { | ||||||
|  |                 ...change.payload, | ||||||
|  |                 // @ts-expect-error Some of the properties that may be
 | ||||||
|  |                 // undefined, we know exist in the value
 | ||||||
|  |                 snapshot: { | ||||||
|  |                     ...change.payload.snapshot, | ||||||
|  |                     constraints: [ | ||||||
|  |                         { | ||||||
|  |                             caseInsensitive: false, | ||||||
|  |                             contextName: 'appName', | ||||||
|  |                             inverted: false, | ||||||
|  |                             operator: 'IN' as const, | ||||||
|  |                             values: ['blah'], | ||||||
|  |                         }, | ||||||
|  |                     ], | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         expect( | ||||||
|  |             getChangesThatWouldBeOverwritten( | ||||||
|  |                 existingStrategyMod, | ||||||
|  |                 constraintChange, | ||||||
|  |             ), | ||||||
|  |         ).toBeNull(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     test('Any properties in the existing strategy that do not exist in the snapshot are also detected', () => { | ||||||
|  |         const { variants: _snapshotVariants, ...snapshot } = | ||||||
|  |             change.payload.snapshot!; | ||||||
|  | 
 | ||||||
|  |         const existingStrategyWithVariants = { | ||||||
|  |             ...existingStrategy, | ||||||
|  |             variants: [ | ||||||
|  |                 { | ||||||
|  |                     name: 'variant1', | ||||||
|  |                     weight: 1000, | ||||||
|  |                     payload: { | ||||||
|  |                         type: 'string', | ||||||
|  |                         value: 'beaty', | ||||||
|  |                     }, | ||||||
|  |                     stickiness: 'userId', | ||||||
|  |                     weightType: 'variable' as const, | ||||||
|  |                 }, | ||||||
|  |             ], | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         const result = getChangesThatWouldBeOverwritten( | ||||||
|  |             existingStrategyWithVariants, | ||||||
|  |             { | ||||||
|  |                 ...change, | ||||||
|  |                 payload: { | ||||||
|  |                     ...change.payload, | ||||||
|  |                     snapshot, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         expect(result).toStrictEqual([ | ||||||
|  |             { | ||||||
|  |                 property: 'variants', | ||||||
|  |                 oldValue: existingStrategyWithVariants.variants, | ||||||
|  |                 newValue: undefined, | ||||||
|  |             }, | ||||||
|  |         ]); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     test('it returns null if the existing strategy is undefined', () => { | ||||||
|  |         const result = getChangesThatWouldBeOverwritten(undefined, change); | ||||||
|  | 
 | ||||||
|  |         expect(result).toBeNull(); | ||||||
|  |     }); | ||||||
|  |     test('it returns null if the snapshot is missing', () => { | ||||||
|  |         const { snapshot, ...payload } = change.payload; | ||||||
|  |         const result = getChangesThatWouldBeOverwritten(existingStrategy, { | ||||||
|  |             ...change, | ||||||
|  |             payload, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         expect(result).toBeNull(); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
| @ -0,0 +1,79 @@ | |||||||
|  | import { IChangeRequestUpdateStrategy } from 'component/changeRequest/changeRequest.types'; | ||||||
|  | import { IFeatureStrategy } from 'interfaces/strategy'; | ||||||
|  | import isEqual from 'lodash.isequal'; | ||||||
|  | 
 | ||||||
|  | const hasJsonDiff = (object: unknown, objectToCompare: unknown) => | ||||||
|  |     JSON.stringify(object) !== JSON.stringify(objectToCompare); | ||||||
|  | 
 | ||||||
|  | type DataToOverwrite<Prop extends keyof IFeatureStrategy> = { | ||||||
|  |     property: Prop; | ||||||
|  |     oldValue: IFeatureStrategy[Prop]; | ||||||
|  |     newValue: IFeatureStrategy[Prop]; | ||||||
|  | }; | ||||||
|  | type ChangesThatWouldBeOverwritten = DataToOverwrite<keyof IFeatureStrategy>[]; | ||||||
|  | 
 | ||||||
|  | export const getChangesThatWouldBeOverwritten = ( | ||||||
|  |     currentStrategyConfig: IFeatureStrategy | undefined, | ||||||
|  |     change: IChangeRequestUpdateStrategy, | ||||||
|  | ): ChangesThatWouldBeOverwritten | null => { | ||||||
|  |     const { snapshot } = change.payload; | ||||||
|  |     if (snapshot && currentStrategyConfig) { | ||||||
|  |         const hasChanged = (a: unknown, b: unknown) => { | ||||||
|  |             if (typeof a === 'object') { | ||||||
|  |                 return !isEqual(a, b); | ||||||
|  |             } | ||||||
|  |             return hasJsonDiff(a, b); | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         // compare each property in the snapshot. The property order
 | ||||||
|  |         // might differ, so using JSON.stringify to compare them
 | ||||||
|  |         // doesn't work.
 | ||||||
|  |         const changes: ChangesThatWouldBeOverwritten = Object.entries( | ||||||
|  |             currentStrategyConfig, | ||||||
|  |         ) | ||||||
|  |             .map(([key, currentValue]: [string, unknown]) => { | ||||||
|  |                 const snapshotValue = snapshot[key as keyof IFeatureStrategy]; | ||||||
|  | 
 | ||||||
|  |                 // compare, assuming that order never changes
 | ||||||
|  |                 if (key === 'segments') { | ||||||
|  |                     // segments can be undefined on the original
 | ||||||
|  |                     // object, but that doesn't mean it has changed
 | ||||||
|  |                     if (hasJsonDiff(snapshotValue, currentValue ?? [])) { | ||||||
|  |                         return { | ||||||
|  |                             property: key as keyof IFeatureStrategy, | ||||||
|  |                             oldValue: currentValue, | ||||||
|  |                             newValue: snapshotValue, | ||||||
|  |                         }; | ||||||
|  |                     } | ||||||
|  |                 } else if (key === 'variants') { | ||||||
|  |                     // strategy variants might not be defined, so use
 | ||||||
|  |                     // fallback values
 | ||||||
|  |                     if (hasJsonDiff(snapshotValue ?? [], currentValue ?? [])) { | ||||||
|  |                         return { | ||||||
|  |                             property: key as keyof IFeatureStrategy, | ||||||
|  |                             oldValue: currentValue, | ||||||
|  |                             newValue: snapshotValue, | ||||||
|  |                         }; | ||||||
|  |                     } | ||||||
|  |                 } else if (hasChanged(snapshotValue, currentValue)) { | ||||||
|  |                     return { | ||||||
|  |                         property: key as keyof IFeatureStrategy, | ||||||
|  |                         oldValue: currentValue, | ||||||
|  |                         newValue: snapshotValue, | ||||||
|  |                     }; | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  |             .filter( | ||||||
|  |                 (change): change is DataToOverwrite<keyof IFeatureStrategy> => | ||||||
|  |                     Boolean(change), | ||||||
|  |             ); | ||||||
|  | 
 | ||||||
|  |         if (changes.length) { | ||||||
|  |             // we have changes that would be overwritten
 | ||||||
|  |             changes.sort((a, b) => a.property.localeCompare(b.property)); | ||||||
|  |             return changes; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return null; | ||||||
|  | }; | ||||||
| @ -228,6 +228,7 @@ export type ChangeRequestAddStrategy = Pick< | |||||||
| 
 | 
 | ||||||
| export type ChangeRequestEditStrategy = ChangeRequestAddStrategy & { | export type ChangeRequestEditStrategy = ChangeRequestAddStrategy & { | ||||||
|     id: string; |     id: string; | ||||||
|  |     snapshot?: IFeatureStrategy; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| type ChangeRequestDeleteStrategy = { | type ChangeRequestDeleteStrategy = { | ||||||
|  | |||||||
| @ -2127,6 +2127,13 @@ | |||||||
|   dependencies: |   dependencies: | ||||||
|     "@types/lodash" "*" |     "@types/lodash" "*" | ||||||
| 
 | 
 | ||||||
|  | "@types/lodash.isequal@^4.5.8": | ||||||
|  |   version "4.5.8" | ||||||
|  |   resolved "https://registry.yarnpkg.com/@types/lodash.isequal/-/lodash.isequal-4.5.8.tgz#b30bb6ff6a5f6c19b3daf389d649ac7f7a250499" | ||||||
|  |   integrity sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA== | ||||||
|  |   dependencies: | ||||||
|  |     "@types/lodash" "*" | ||||||
|  | 
 | ||||||
| "@types/lodash.mapvalues@^4.6.9": | "@types/lodash.mapvalues@^4.6.9": | ||||||
|   version "4.6.9" |   version "4.6.9" | ||||||
|   resolved "https://registry.yarnpkg.com/@types/lodash.mapvalues/-/lodash.mapvalues-4.6.9.tgz#1edb4b1d299db332166b474221b06058b34030a7" |   resolved "https://registry.yarnpkg.com/@types/lodash.mapvalues/-/lodash.mapvalues-4.6.9.tgz#1edb4b1d299db332166b474221b06058b34030a7" | ||||||
| @ -5289,6 +5296,11 @@ lodash.isempty@^4.4.0: | |||||||
|   resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e" |   resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e" | ||||||
|   integrity sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg== |   integrity sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg== | ||||||
| 
 | 
 | ||||||
|  | lodash.isequal@^4.5.0: | ||||||
|  |   version "4.5.0" | ||||||
|  |   resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" | ||||||
|  |   integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== | ||||||
|  | 
 | ||||||
| lodash.mapvalues@^4.6.0: | lodash.mapvalues@^4.6.0: | ||||||
|   version "4.6.0" |   version "4.6.0" | ||||||
|   resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c" |   resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c" | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user