From 1f189b919e0dd31594b7dfb10f1c266250b63964 Mon Sep 17 00:00:00 2001 From: Caleb Ellis Date: Fri, 6 Nov 2020 09:41:24 +1000 Subject: [PATCH] feat(ui): add analytics to all machine action forms --- ui/.betterer.results | 140 ++++++++------- ui/src/app/base/types.ts | 6 + .../CommissionForm/CommissionForm.test.tsx | 59 ++++++- .../CommissionForm/CommissionForm.tsx | 15 +- .../DeployForm/DeployForm.test.tsx | 69 +++++++- .../DeployForm/DeployForm.tsx | 21 ++- .../UserDataField/UserDataField.test.tsx | 15 +- .../FieldlessForm/FieldlessForm.js | 15 +- .../FieldlessForm/FieldlessForm.test.js | 162 ++++++++++++++++-- .../MarkBrokenForm/MarkBrokenForm.test.tsx | 39 ++++- .../MarkBrokenForm/MarkBrokenForm.tsx | 15 +- .../OverrideTestForm/OverrideTestForm.js | 15 +- .../SetPoolForm/SetPoolForm.js | 15 +- .../SetPoolForm/SetPoolForm.test.js | 49 +++++- .../SetZoneForm/SetZoneForm.js | 15 +- .../SetZoneForm/SetZoneForm.test.js | 43 ++++- .../ActionFormWrapper/TagForm/TagForm.js | 13 +- .../ActionFormWrapper/TagForm/TagForm.test.js | 45 ++++- .../TestForm/TestForm.test.tsx | 48 +++++- .../ActionFormWrapper/TestForm/TestForm.tsx | 13 +- ui/src/app/machines/hooks.tsx | 19 +- .../TestResults/TestResults.tsx | 2 +- ui/src/testing/factories/state.ts | 1 + 23 files changed, 693 insertions(+), 141 deletions(-) diff --git a/ui/.betterer.results b/ui/.betterer.results index 7eb68d235e..a27cfbc3ea 100644 --- a/ui/.betterer.results +++ b/ui/.betterer.results @@ -7,7 +7,7 @@ exports[`stricter compilation`] = { [162, 4, 36, "Object is possibly \'null\'.", "1039669632"] ], "src/app/App.tsx:3872899616": [ - [21, 7, 25, "Could not find a declaration file for module \'@maas-ui/maas-ui-shared\'. \'/Users/kit/src/canonical/local/maas-ui/shared/dist/index.js\' implicitly has an \'any\' type.\\n Try \`npm install @types/maas-ui__maas-ui-shared\` if it exists or add a new declaration (.d.ts) file containing \`declare module \'@maas-ui/maas-ui-shared\';\`", "1778274862"], + [21, 7, 25, "Could not find a declaration file for module \'@maas-ui/maas-ui-shared\'. \'/home/caleb/Projects/maas-ui/shared/dist/index.js\' implicitly has an \'any\' type.\\n Try \`npm install @types/maas-ui__maas-ui-shared\` if it exists or add a new declaration (.d.ts) file containing \`declare module \'@maas-ui/maas-ui-shared\';\`", "1778274862"], [188, 17, 17, "Object is possibly \'null\'.", "2133029343"], [193, 7, 7, "Variable \'content\' is used before being assigned.", "3716929964"] ], @@ -44,7 +44,7 @@ exports[`stricter compilation`] = { [75, 4, 5, "Argument of type \'boolean | undefined\' is not assignable to parameter of type \'boolean\'.\\n Type \'undefined\' is not assignable to type \'boolean\'.", "195688512"] ], "src/app/base/components/LegacyLink/LegacyLink.tsx:2706551295": [ - [4, 52, 25, "Could not find a declaration file for module \'@maas-ui/maas-ui-shared\'. \'/Users/kit/src/canonical/local/maas-ui/shared/dist/index.js\' implicitly has an \'any\' type.\\n Try \`npm install @types/maas-ui__maas-ui-shared\` if it exists or add a new declaration (.d.ts) file containing \`declare module \'@maas-ui/maas-ui-shared\';\`", "1778274862"] + [4, 52, 25, "Could not find a declaration file for module \'@maas-ui/maas-ui-shared\'. \'/home/caleb/Projects/maas-ui/shared/dist/index.js\' implicitly has an \'any\' type.\\n Try \`npm install @types/maas-ui__maas-ui-shared\` if it exists or add a new declaration (.d.ts) file containing \`declare module \'@maas-ui/maas-ui-shared\';\`", "1778274862"] ], "src/app/base/components/NotificationGroup/Notification/Notification.tsx:122297593": [ [26, 26, 12, "Argument of type \'Notification | null\' is not assignable to parameter of type \'Notification\'.\\n Type \'null\' is not assignable to type \'Notification\'.\\n Type \'null\' is not assignable to type \'Model\'.", "148512008"], @@ -86,7 +86,7 @@ exports[`stricter compilation`] = { [214, 7, 11, "Property \'placeholder\' is missing in type \'{ disabledTags: { id: number; name: string; }[]; initialSelected: { id: number; name: string; }[]; tags: { id: number; name: string; }[]; }\' but required in type \'Props\'.", "3766634306"] ], "src/app/base/components/TagSelector/TagSelector.tsx:2755544058": [ - [1, 18, 51, "Could not find a declaration file for module \'@canonical/react-components/dist/components/Field\'. \'/Users/kit/src/canonical/local/maas-ui/node_modules/@canonical/react-components/dist/components/Field/index.js\' implicitly has an \'any\' type.\\n Try \`npm install @types/canonical__react-components\` if it exists or add a new declaration (.d.ts) file containing \`declare module \'@canonical/react-components/dist/components/Field\';\`", "1535046059"], + [1, 18, 51, "Could not find a declaration file for module \'@canonical/react-components/dist/components/Field\'. \'/home/caleb/Projects/maas-ui/node_modules/@canonical/react-components/dist/components/Field/index.js\' implicitly has an \'any\' type.\\n Try \`npm install @types/canonical__react-components\` if it exists or add a new declaration (.d.ts) file containing \`declare module \'@canonical/react-components/dist/components/Field\';\`", "1535046059"], [37, 2, 12, "Binding element \'allowNewTags\' implicitly has an \'any\' type.", "3979358209"], [38, 2, 6, "Binding element \'filter\' implicitly has an \'any\' type.", "1355726373"], [39, 2, 12, "Binding element \'selectedTags\' implicitly has an \'any\' type.", "2698915821"], @@ -246,36 +246,42 @@ exports[`stricter compilation`] = { [130, 2, 656, "Type \'Element | null\' is not assignable to type \'Element\'.\\n Type \'null\' is not assignable to type \'Element\'.", "2065603965"], [144, 36, 11, "Property \'setSelected\' does not exist on type \'{ fetch: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { model: any; method: string; }>; create: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { ...; }>; update: ActionCreatorWithPreparedPayload<...>; delete: ActionCreatorWithPreparedPayload<......\'.", "1023496814"] ], - "src/app/machines/components/ActionFormWrapper/CommissionForm/CommissionForm.test.tsx:2858846500": [ - [106, 6, 66, "Cannot invoke an object which is possibly \'undefined\'.", "4166470712"], - [110, 10, 15, "Argument of type \'{ enableSSH: boolean; skipBMCConfig: boolean; skipNetworking: boolean; skipStorage: boolean; updateFirmware: boolean; configureHBA: boolean; testingScripts: Scripts[]; commissioningScripts: Scripts[]; scriptInputs: { ...; }; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'enableSSH\' does not exist in type \'FormEvent<{}>\'.", "2907345984"], - [201, 6, 66, "Cannot invoke an object which is possibly \'undefined\'.", "4166470712"], - [205, 10, 15, "Argument of type \'{ enableSSH: boolean; skipBMCConfig: boolean; skipNetworking: boolean; skipStorage: boolean; updateFirmware: boolean; configureHBA: boolean; testingScripts: Scripts[]; commissioningScripts: Scripts[]; scriptInputs: { ...; }; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'enableSSH\' does not exist in type \'FormEvent<{}>\'.", "2907345984"] - ], - "src/app/machines/components/ActionFormWrapper/CommissionForm/CommissionForm.tsx:1892825711": [ - [121, 6, 8, "Type \'(values: CommissionFormValues) => void\' is not assignable to type \'(...args: unknown[]) => void\'.\\n Types of parameters \'values\' and \'args\' are incompatible.\\n Type \'unknown\' is not assignable to type \'CommissionFormValues\'.", "1301647696"], - [135, 27, 10, "Property \'commission\' does not exist on type \'{ fetch: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { model: any; method: string; }>; create: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { ...; }>; update: ActionCreatorWithPreparedPayload<...>; delete: ActionCreatorWithPreparedPayload<......\'.", "2592181864"], - [155, 42, 25, "Argument of type \'(Scripts | undefined)[]\' is not assignable to parameter of type \'Scripts[]\'.\\n Type \'Scripts | undefined\' is not assignable to type \'Scripts\'.\\n Type \'undefined\' is not assignable to type \'Scripts\'.\\n Type \'undefined\' is not assignable to type \'Model\'.", "4025185665"] - ], - "src/app/machines/components/ActionFormWrapper/DeployForm/DeployForm.test.tsx:1194028480": [ - [139, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], - [140, 8, 17, "Argument of type \'{ oSystem: string; release: string; kernel: string; installKVM: boolean; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'oSystem\' does not exist in type \'FormEvent<{}>\'.", "4147515896"], - [210, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], - [211, 8, 17, "Argument of type \'{ oSystem: string; release: string; kernel: string; installKVM: boolean; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'oSystem\' does not exist in type \'FormEvent<{}>\'.", "4147515896"], - [257, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], - [258, 8, 21, "Argument of type \'{ includeUserData: boolean; installKVM: boolean; kernel: string; oSystem: string; release: string; userData: string; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'includeUserData\' does not exist in type \'FormEvent<{}>\'.", "4050162228"], - [306, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], - [307, 8, 22, "Argument of type \'{ includeUserData: boolean; installKVM: boolean; kernel: string; oSystem: string; release: string; userData: string; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'includeUserData\' does not exist in type \'FormEvent<{}>\'.", "486161855"], - [355, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], - [356, 8, 21, "Argument of type \'{ includeUserData: boolean; installKVM: boolean; kernel: string; oSystem: string; release: string; userData: string; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'includeUserData\' does not exist in type \'FormEvent<{}>\'.", "4050162228"] - ], - "src/app/machines/components/ActionFormWrapper/DeployForm/DeployForm.tsx:2382076300": [ - [97, 6, 8, "Type \'(values: DeployFormValues) => void\' is not assignable to type \'(...args: unknown[]) => void\'.\\n Types of parameters \'values\' and \'args\' are incompatible.\\n Type \'unknown\' is not assignable to type \'DeployFormValues\'.", "1301647696"], - [115, 34, 6, "Property \'deploy\' does not exist on type \'{ fetch: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { model: any; method: string; }>; create: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { ...; }>; update: ActionCreatorWithPreparedPayload<...>; delete: ActionCreatorWithPreparedPayload<......\'.", "1121484462"] - ], - "src/app/machines/components/ActionFormWrapper/DeployForm/DeployFormFields/UserDataField/UserDataField.test.tsx:2053903631": [ - [116, 10, 19, "Element implicitly has an \'any\' type because expression of type \'string\' can\'t be used to index type \'{}\'.\\n No index signature with a parameter of type \'string\' was found on type \'{}\'.", "2609332470"], - [150, 12, 10, "Type \'{ processing: boolean; setProcessing: Mock; setSelectedAction: Mock; }\' is not assignable to type \'IntrinsicAttributes & Pick & Pick any>; }>, never> & Pick<...>\'.\\n Property \'processing\' does not exist on type \'IntrinsicAttributes & Pick & Pick any>; }>, never> & Pick<...>\'.", "3107157038"] + "src/app/machines/components/ActionFormWrapper/CommissionForm/CommissionForm.test.tsx:968510342": [ + [107, 6, 66, "Cannot invoke an object which is possibly \'undefined\'.", "4166470712"], + [111, 10, 15, "Argument of type \'{ enableSSH: boolean; skipBMCConfig: boolean; skipNetworking: boolean; skipStorage: boolean; updateFirmware: boolean; configureHBA: boolean; testingScripts: Scripts[]; commissioningScripts: Scripts[]; scriptInputs: { ...; }; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'enableSSH\' does not exist in type \'FormEvent<{}>\'.", "2907345984"], + [202, 6, 66, "Cannot invoke an object which is possibly \'undefined\'.", "4166470712"], + [206, 10, 15, "Argument of type \'{ enableSSH: boolean; skipBMCConfig: boolean; skipNetworking: boolean; skipStorage: boolean; updateFirmware: boolean; configureHBA: boolean; testingScripts: Scripts[]; commissioningScripts: Scripts[]; scriptInputs: { ...; }; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'enableSSH\' does not exist in type \'FormEvent<{}>\'.", "2907345984"], + [255, 40, 16, "Cannot assign to \'useSendAnalytics\' because it is a read-only property.", "3235495692"], + [270, 6, 66, "Cannot invoke an object which is possibly \'undefined\'.", "4166470712"], + [274, 10, 15, "Argument of type \'{ enableSSH: boolean; skipBMCConfig: boolean; skipNetworking: boolean; skipStorage: boolean; updateFirmware: boolean; configureHBA: boolean; testingScripts: Scripts[]; commissioningScripts: Scripts[]; scriptInputs: { ...; }; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'enableSSH\' does not exist in type \'FormEvent<{}>\'.", "2907345984"] + ], + "src/app/machines/components/ActionFormWrapper/CommissionForm/CommissionForm.tsx:2088034249": [ + [125, 6, 8, "Type \'(values: CommissionFormValues) => void\' is not assignable to type \'(...args: unknown[]) => void\'.\\n Types of parameters \'values\' and \'args\' are incompatible.\\n Type \'unknown\' is not assignable to type \'CommissionFormValues\'.", "1301647696"], + [139, 27, 10, "Property \'commission\' does not exist on type \'{ fetch: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { model: any; method: string; }>; create: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { ...; }>; update: ActionCreatorWithPreparedPayload<...>; delete: ActionCreatorWithPreparedPayload<......\'.", "2592181864"], + [164, 42, 25, "Argument of type \'(Scripts | undefined)[]\' is not assignable to parameter of type \'Scripts[]\'.\\n Type \'Scripts | undefined\' is not assignable to type \'Scripts\'.\\n Type \'undefined\' is not assignable to type \'Scripts\'.\\n Type \'undefined\' is not assignable to type \'Model\'.", "4025185665"] + ], + "src/app/machines/components/ActionFormWrapper/DeployForm/DeployForm.test.tsx:1155304200": [ + [149, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], + [150, 8, 17, "Argument of type \'{ oSystem: string; release: string; kernel: string; installKVM: boolean; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'oSystem\' does not exist in type \'FormEvent<{}>\'.", "4147515896"], + [220, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], + [221, 8, 17, "Argument of type \'{ oSystem: string; release: string; kernel: string; installKVM: boolean; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'oSystem\' does not exist in type \'FormEvent<{}>\'.", "4147515896"], + [267, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], + [268, 8, 21, "Argument of type \'{ includeUserData: boolean; installKVM: boolean; kernel: string; oSystem: string; release: string; userData: string; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'includeUserData\' does not exist in type \'FormEvent<{}>\'.", "4050162228"], + [316, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], + [317, 8, 22, "Argument of type \'{ includeUserData: boolean; installKVM: boolean; kernel: string; oSystem: string; release: string; userData: string; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'includeUserData\' does not exist in type \'FormEvent<{}>\'.", "486161855"], + [354, 40, 16, "Cannot assign to \'useSendAnalytics\' because it is a read-only property.", "3235495692"], + [369, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], + [370, 8, 21, "Argument of type \'{ includeUserData: boolean; installKVM: boolean; kernel: string; oSystem: string; release: string; userData: string; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'includeUserData\' does not exist in type \'FormEvent<{}>\'.", "4050162228"], + [392, 40, 16, "Cannot assign to \'useSendAnalytics\' because it is a read-only property.", "3235495692"], + [407, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], + [408, 8, 21, "Argument of type \'{ includeUserData: boolean; installKVM: boolean; kernel: string; oSystem: string; release: string; userData: string; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'includeUserData\' does not exist in type \'FormEvent<{}>\'.", "4050162228"] + ], + "src/app/machines/components/ActionFormWrapper/DeployForm/DeployForm.tsx:480578381": [ + [100, 6, 8, "Type \'(values: DeployFormValues) => void\' is not assignable to type \'(...args: unknown[]) => void\'.\\n Types of parameters \'values\' and \'args\' are incompatible.\\n Type \'unknown\' is not assignable to type \'DeployFormValues\'.", "1301647696"], + [124, 34, 6, "Property \'deploy\' does not exist on type \'{ fetch: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { model: any; method: string; }>; create: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { ...; }>; update: ActionCreatorWithPreparedPayload<...>; delete: ActionCreatorWithPreparedPayload<......\'.", "1121484462"] + ], + "src/app/machines/components/ActionFormWrapper/DeployForm/DeployFormFields/UserDataField/UserDataField.test.tsx:3711365735": [ + [119, 10, 19, "Element implicitly has an \'any\' type because expression of type \'string\' can\'t be used to index type \'{}\'.\\n No index signature with a parameter of type \'string\' was found on type \'{}\'.", "2609332470"] ], "src/app/machines/components/ActionFormWrapper/DeployForm/DeployFormFields/UserDataField/UserDataField.tsx:3781897231": [ [19, 27, 4, "Binding element \'file\' implicitly has an \'any\' type.", "2087597251"], @@ -286,29 +292,35 @@ exports[`stricter compilation`] = { [67, 4, 14, "Type \'([file]: [any]) => void\' is not assignable to type \'(files: T[], event: DropEvent) => void\'.\\n Types of parameters \'__0\' and \'files\' are incompatible.\\n Type \'T[]\' is not assignable to type \'[any]\'.\\n Target requires 1 element(s) but source may have fewer.", "3837758380"], [100, 34, 5, "Type \'null\' is not assignable to type \'CSSProperties | undefined\'.", "195056594"] ], - "src/app/machines/components/ActionFormWrapper/MarkBrokenForm/MarkBrokenForm.test.tsx:2328625110": [ - [63, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], - [64, 8, 29, "Argument of type \'{ comment: string; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'comment\' does not exist in type \'FormEvent<{}>\'.", "940323754"], - [118, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], - [119, 8, 11, "Argument of type \'{ comment: string; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'comment\' does not exist in type \'FormEvent<{}>\'.", "942845580"], - [163, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], - [164, 8, 29, "Argument of type \'{ comment: string; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'comment\' does not exist in type \'FormEvent<{}>\'.", "940323754"] - ], - "src/app/machines/components/ActionFormWrapper/MarkBrokenForm/MarkBrokenForm.tsx:4230109469": [ - [50, 6, 8, "Type \'(values: MarkBrokenFormValues) => void\' is not assignable to type \'(...args: unknown[]) => void\'.\\n Types of parameters \'values\' and \'args\' are incompatible.\\n Type \'unknown\' is not assignable to type \'MarkBrokenFormValues\'.", "1301647696"], - [53, 27, 10, "Property \'markBroken\' does not exist on type \'{ fetch: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { model: any; method: string; }>; create: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { ...; }>; update: ActionCreatorWithPreparedPayload<...>; delete: ActionCreatorWithPreparedPayload<......\'.", "3004692879"] - ], - "src/app/machines/components/ActionFormWrapper/TestForm/TestForm.test.tsx:3774525721": [ + "src/app/machines/components/ActionFormWrapper/MarkBrokenForm/MarkBrokenForm.test.tsx:257246983": [ + [65, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], + [66, 8, 29, "Argument of type \'{ comment: string; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'comment\' does not exist in type \'FormEvent<{}>\'.", "940323754"], + [120, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], + [121, 8, 11, "Argument of type \'{ comment: string; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'comment\' does not exist in type \'FormEvent<{}>\'.", "942845580"], + [165, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], + [166, 8, 29, "Argument of type \'{ comment: string; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'comment\' does not exist in type \'FormEvent<{}>\'.", "940323754"], + [194, 40, 16, "Cannot assign to \'useSendAnalytics\' because it is a read-only property.", "3235495692"], + [209, 6, 39, "Cannot invoke an object which is possibly \'undefined\'.", "2019447570"], + [210, 8, 11, "Argument of type \'{ comment: string; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'comment\' does not exist in type \'FormEvent<{}>\'.", "942845580"] + ], + "src/app/machines/components/ActionFormWrapper/MarkBrokenForm/MarkBrokenForm.tsx:574041947": [ + [54, 6, 8, "Type \'(values: MarkBrokenFormValues) => void\' is not assignable to type \'(...args: unknown[]) => void\'.\\n Types of parameters \'values\' and \'args\' are incompatible.\\n Type \'unknown\' is not assignable to type \'MarkBrokenFormValues\'.", "1301647696"], + [57, 27, 10, "Property \'markBroken\' does not exist on type \'{ fetch: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { model: any; method: string; }>; create: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { ...; }>; update: ActionCreatorWithPreparedPayload<...>; delete: ActionCreatorWithPreparedPayload<......\'.", "3004692879"] + ], + "src/app/machines/components/ActionFormWrapper/TestForm/TestForm.test.tsx:1661971984": [ [95, 6, 66, "Cannot invoke an object which is possibly \'undefined\'.", "4166470712"], [99, 10, 15, "Argument of type \'{ enableSSH: boolean; scripts: Scripts[]; scriptInputs: { \\"internet-connectivity\\": string; }; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'enableSSH\' does not exist in type \'FormEvent<{}>\'.", "2907345984"], [216, 6, 66, "Cannot invoke an object which is possibly \'undefined\'.", "4166470712"], - [220, 10, 15, "Argument of type \'{ enableSSH: boolean; scripts: Scripts[]; scriptInputs: { \\"internet-connectivity\\": string; }; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'enableSSH\' does not exist in type \'FormEvent<{}>\'.", "2907345984"] + [220, 10, 15, "Argument of type \'{ enableSSH: boolean; scripts: Scripts[]; scriptInputs: { \\"internet-connectivity\\": string; }; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'enableSSH\' does not exist in type \'FormEvent<{}>\'.", "2907345984"], + [258, 40, 16, "Cannot assign to \'useSendAnalytics\' because it is a read-only property.", "3235495692"], + [273, 6, 66, "Cannot invoke an object which is possibly \'undefined\'.", "4166470712"], + [277, 10, 15, "Argument of type \'{ enableSSH: boolean; scripts: Scripts[]; scriptInputs: { \\"internet-connectivity\\": string; }; }\' is not assignable to parameter of type \'FormEvent<{}>\'.\\n Object literal may only specify known properties, and \'enableSSH\' does not exist in type \'FormEvent<{}>\'.", "2907345984"] ], - "src/app/machines/components/ActionFormWrapper/TestForm/TestForm.tsx:739278699": [ - [76, 6, 25, "Element implicitly has an \'any\' type because expression of type \'string\' can\'t be used to index type \'{}\'.\\n No index signature with a parameter of type \'string\' was found on type \'{}\'.", "311081487"], - [99, 6, 8, "Type \'(values: FormValues) => void\' is not assignable to type \'(...args: unknown[]) => void\'.\\n Types of parameters \'values\' and \'args\' are incompatible.\\n Type \'unknown\' is not assignable to type \'FormValues\'.", "1301647696"], - [103, 27, 4, "Property \'test\' does not exist on type \'{ fetch: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { model: any; method: string; }>; create: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { ...; }>; update: ActionCreatorWithPreparedPayload<...>; delete: ActionCreatorWithPreparedPayload<......\'.", "2087956275"], - [116, 22, 11, "Type \'({ displayName: string; id: number; apply_configured_networking: boolean; default: boolean; description: string; destructive: boolean; for_hardware: string[]; hardware_type_name: \\"Node\\" | \\"CPU\\" | \\"Memory\\" | \\"Storage\\" | \\"Network\\"; ... 15 more ...; type: number; } | undefined)[]\' is not assignable to type \'ScriptsDisplay[]\'.\\n Type \'{ displayName: string; id: number; apply_configured_networking: boolean; default: boolean; description: string; destructive: boolean; for_hardware: string[]; hardware_type_name: \\"Node\\" | \\"CPU\\" | \\"Memory\\" | \\"Storage\\" | \\"Network\\"; ... 15 more ...; type: number; } | undefined\' is not assignable to type \'ScriptsDisplay\'.\\n Type \'undefined\' is not assignable to type \'ScriptsDisplay\'.\\n Type \'undefined\' is not assignable to type \'Model\'.", "611253867"] + "src/app/machines/components/ActionFormWrapper/TestForm/TestForm.tsx:2845063949": [ + [82, 6, 25, "Element implicitly has an \'any\' type because expression of type \'string\' can\'t be used to index type \'{}\'.\\n No index signature with a parameter of type \'string\' was found on type \'{}\'.", "311081487"], + [105, 6, 8, "Type \'(values: FormValues) => void\' is not assignable to type \'(...args: unknown[]) => void\'.\\n Types of parameters \'values\' and \'args\' are incompatible.\\n Type \'unknown\' is not assignable to type \'FormValues\'.", "1301647696"], + [109, 27, 4, "Property \'test\' does not exist on type \'{ fetch: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { model: any; method: string; }>; create: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { ...; }>; update: ActionCreatorWithPreparedPayload<...>; delete: ActionCreatorWithPreparedPayload<......\'.", "2087956275"], + [127, 22, 11, "Type \'({ displayName: string; id: number; apply_configured_networking: boolean; default: boolean; description: string; destructive: boolean; for_hardware: string[]; hardware_type_name: \\"Node\\" | \\"CPU\\" | \\"Memory\\" | \\"Storage\\" | \\"Network\\"; ... 15 more ...; type: number; } | undefined)[]\' is not assignable to type \'ScriptsDisplay[]\'.\\n Type \'{ displayName: string; id: number; apply_configured_networking: boolean; default: boolean; description: string; destructive: boolean; for_hardware: string[]; hardware_type_name: \\"Node\\" | \\"CPU\\" | \\"Memory\\" | \\"Storage\\" | \\"Network\\"; ... 15 more ...; type: number; } | undefined\' is not assignable to type \'ScriptsDisplay\'.\\n Type \'undefined\' is not assignable to type \'ScriptsDisplay\'.\\n Type \'undefined\' is not assignable to type \'Model\'.", "611253867"] ], "src/app/machines/components/TakeActionMenu/TakeActionMenu.test.tsx:2594365837": [ [77, 8, 4, "Type \'\\"lifecycle1\\"\' is not assignable to type \'\\"on\\" | \\"off\\" | \\"abort\\" | \\"acquire\\" | \\"check-power\\" | \\"commission\\" | \\"delete\\" | \\"deploy\\" | \\"exit-rescue-mode\\" | \\"lock\\" | \\"mark-broken\\" | \\"mark-fixed\\" | \\"override-failed-testing\\" | ... 11 more ... | undefined\'.", "2087876002"], @@ -331,11 +343,11 @@ exports[`stricter compilation`] = { [86, 6, 7, "Type \'false | \\"Select machines below to perform an action.\\"\' is not assignable to type \'string | undefined\'.\\n Type \'false\' is not assignable to type \'string | undefined\'.", "1236122734"], [97, 10, 16, "Argument of type \'(BaseMachine | MachineDetails | undefined)[]\' is not assignable to parameter of type \'Machine[]\'.", "4020685210"] ], - "src/app/machines/hooks.tsx:3635236711": [ - [45, 4, 76, "Element implicitly has an \'any\' type because expression of type \'string\' can\'t be used to index type \'{ aborting: Selector; abortingSelected: Selector; acquiring: Selector; ... 54 more ...; saving: (state: RootState) => boolean; }\'.\\n No index signature with a parameter of type \'string\' was found on type \'{ aborting: Selector; abortingSelected: Selector; acquiring: Selector; ... 54 more ...; saving: (state: RootState) => boolean; }\'.", "1657451879"], - [45, 37, 6, "Object is possibly \'undefined\'.", "1314712411"], - [45, 56, 6, "Object is possibly \'undefined\'.", "1314712411"], - [49, 11, 16, "Type \'(BaseMachine | MachineDetails | undefined)[]\' is not assignable to type \'Machine[]\'.\\n Type \'BaseMachine | MachineDetails | undefined\' is not assignable to type \'Machine\'.\\n Type \'undefined\' is not assignable to type \'Machine\'.", "2366246550"] + "src/app/machines/hooks.tsx:330909216": [ + [53, 4, 76, "Element implicitly has an \'any\' type because expression of type \'string\' can\'t be used to index type \'{ aborting: Selector; abortingSelected: Selector; acquiring: Selector; ... 54 more ...; saving: (state: RootState) => boolean; }\'.\\n No index signature with a parameter of type \'string\' was found on type \'{ aborting: Selector; abortingSelected: Selector; acquiring: Selector; ... 54 more ...; saving: (state: RootState) => boolean; }\'.", "1657451879"], + [53, 37, 6, "Object is possibly \'undefined\'.", "1314712411"], + [53, 56, 6, "Object is possibly \'undefined\'.", "1314712411"], + [64, 4, 16, "Type \'(BaseMachine | MachineDetails | undefined)[]\' is not assignable to type \'Machine[]\'.\\n Type \'BaseMachine | MachineDetails | undefined\' is not assignable to type \'Machine\'.\\n Type \'undefined\' is not assignable to type \'Machine\'.", "2366246550"] ], "src/app/machines/views/MachineDetails/MachineDetails.tsx:3514974960": [ [27, 28, 3, "Property \'get\' does not exist on type \'{ fetch: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { model: any; method: string; }>; create: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { ...; }>; update: ActionCreatorWithPreparedPayload<...>; delete: ActionCreatorWithPreparedPayload<......\'.", "193411891"], @@ -352,7 +364,7 @@ exports[`stricter compilation`] = { "src/app/machines/views/MachineDetails/MachineSummary/NumaCard/NumaCard.test.tsx:2502469861": [ [33, 27, 10, "Property \'numa_nodes\' does not exist on type \'Machine\'.\\n Property \'numa_nodes\' does not exist on type \'BaseMachine\'.", "3857696382"] ], - "src/app/machines/views/MachineDetails/MachineSummary/TestResults/TestResults.tsx:4182062532": [ + "src/app/machines/views/MachineDetails/MachineSummary/TestResults/TestResults.tsx:2985488462": [ [18, 18, 36, "Element implicitly has an \'any\' type because expression of type \'string\' can\'t be used to index type \'MachineDetails\'.\\n No index signature with a parameter of type \'string\' was found on type \'MachineDetails\'.", "830072625"], [37, 9, 36, "Element implicitly has an \'any\' type because expression of type \'string\' can\'t be used to index type \'MachineDetails\'.\\n No index signature with a parameter of type \'string\' was found on type \'MachineDetails\'.", "830072625"], [52, 15, 36, "Element implicitly has an \'any\' type because expression of type \'string\' can\'t be used to index type \'MachineDetails\'.\\n No index signature with a parameter of type \'string\' was found on type \'MachineDetails\'.", "830072625"], @@ -511,18 +523,18 @@ exports[`stricter compilation`] = { "src/testing/factories/notification.ts:2660496186": [ [10, 2, 5, "Type \'null\' is not assignable to type \'string | ArrayFactory | AttributeFunction | Factory | DerivedFunction\'.", "179146135"] ], - "src/testing/factories/state.ts:3687536065": [ - [232, 2, 4, "Type \'null\' is not assignable to type \'OSInfo | ArrayFactory | AttributeFunction | Factory | DerivedFunction\'.", "2087377941"], - [311, 2, 6, "Type \'null\' is not assignable to type \'string | ArrayFactory | AttributeFunction | Factory | DerivedFunction, string>\'.", "2158674347"], - [313, 2, 4, "Type \'null\' is not assignable to type \'string | ArrayFactory | AttributeFunction | Factory | DerivedFunction, string>\'.", "2087809207"], - [318, 2, 6, "Type \'null\' is not assignable to type \'ArrayFactory | \\"PUSH\\" | \\"POP\\" | \\"REPLACE\\" | AttributeFunction | Factory | DerivedFunction, Action>\'.", "1314712411"] + "src/testing/factories/state.ts:1886719305": [ + [233, 2, 4, "Type \'null\' is not assignable to type \'OSInfo | ArrayFactory | AttributeFunction | Factory | DerivedFunction\'.", "2087377941"], + [312, 2, 6, "Type \'null\' is not assignable to type \'string | ArrayFactory | AttributeFunction | Factory | DerivedFunction, string>\'.", "2158674347"], + [314, 2, 4, "Type \'null\' is not assignable to type \'string | ArrayFactory | AttributeFunction | Factory | DerivedFunction, string>\'.", "2087809207"], + [319, 2, 6, "Type \'null\' is not assignable to type \'ArrayFactory | \\"PUSH\\" | \\"POP\\" | \\"REPLACE\\" | AttributeFunction | Factory | DerivedFunction, Action>\'.", "1314712411"] ] }` }; exports[`no TSFixMe types`] = { value: `{ - "src/app/base/types.ts:2567213908": [ + "src/app/base/types.ts:1483624430": [ [0, 11, 8, "RegExp match", "1152173309"] ], "src/app/store/auth/selectors.ts:1742453516": [ diff --git a/ui/src/app/base/types.ts b/ui/src/app/base/types.ts index 9dc63f4efb..1c88ab6140 100644 --- a/ui/src/app/base/types.ts +++ b/ui/src/app/base/types.ts @@ -8,3 +8,9 @@ export type Sort = { export type RouteParams = { id: string; }; + +export type AnalyticsEvent = { + action: string; + category: string; + label: string; +}; diff --git a/ui/src/app/machines/components/ActionFormWrapper/CommissionForm/CommissionForm.test.tsx b/ui/src/app/machines/components/ActionFormWrapper/CommissionForm/CommissionForm.test.tsx index 4842110bad..05d4fc9988 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/CommissionForm/CommissionForm.test.tsx +++ b/ui/src/app/machines/components/ActionFormWrapper/CommissionForm/CommissionForm.test.tsx @@ -6,17 +6,18 @@ import configureStore from "redux-mock-store"; import React from "react"; import type { RootState } from "app/store/root/types"; +import * as hooks from "app/base/hooks"; import { - scripts as scriptsFactory, - machineAction as machineActionFactory, - machine as machineFactory, generalState as generalStateFactory, + machine as machineFactory, + machineAction as machineActionFactory, + machineActionsState as machineActionsStateFactory, machineState as machineStateFactory, - scriptsState as scriptsStateFactory, - rootState as rootStateFactory, machineStatus as machineStatusFactory, machineStatuses as machineStatusesFactory, - machineActionsState as machineActionsStateFactory, + rootState as rootStateFactory, + scripts as scriptsFactory, + scriptsState as scriptsStateFactory, } from "testing/factories"; import CommissionForm from "./CommissionForm"; @@ -31,7 +32,7 @@ describe("CommissionForm", () => { data: [ machineActionFactory({ name: "commission", - sentence: "commission", + title: "Commission", }), ], }), @@ -247,4 +248,48 @@ describe("CommissionForm", () => { }, ]); }); + + it("sends an analytics event on submit", () => { + const state = { ...initialState }; + state.machine.selected = ["abc123"]; + const mockSendAnalytics = jest.fn(); + const mockUseSendAnalytics = (hooks.useSendAnalytics = jest.fn( + () => mockSendAnalytics + )); + const store = mockStore(state); + const wrapper = mount( + + + + + + ); + + act(() => + wrapper + .find("Formik") + .props() + .onSubmit({ + enableSSH: true, + skipBMCConfig: true, + skipNetworking: true, + skipStorage: true, + updateFirmware: true, + configureHBA: true, + testingScripts: [state.scripts.items[0]], + commissioningScripts: [state.scripts.items[1]], + scriptInputs: { testingScript0: { url: "www.url.com" } }, + }) + ); + + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Commission", + ]); + mockUseSendAnalytics.mockRestore(); + }); }); diff --git a/ui/src/app/machines/components/ActionFormWrapper/CommissionForm/CommissionForm.tsx b/ui/src/app/machines/components/ActionFormWrapper/CommissionForm/CommissionForm.tsx index 671cac1577..018b889cc3 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/CommissionForm/CommissionForm.tsx +++ b/ui/src/app/machines/components/ActionFormWrapper/CommissionForm/CommissionForm.tsx @@ -7,6 +7,7 @@ import { machine as machineActions, scripts as scriptActions, } from "app/base/actions"; +import { useSendAnalytics } from "app/base/hooks"; import { useMachineActionForm } from "app/machines/hooks"; import { simpleSortByKey } from "app/utils"; import machineSelectors from "app/store/machine/selectors"; @@ -73,9 +74,12 @@ export const CommissionForm = ({ setSelectedAction }: Props): JSX.Element => { ); const urlScripts = useSelector(scriptSelectors.testingWithUrl); const testingScripts = useSelector(scriptSelectors.testing); - const { machinesToAction, processingCount } = useMachineActionForm( - "commission" - ); + const sendAnalytics = useSendAnalytics(); + const { + actionFormAnalytics, + machinesToAction, + processingCount, + } = useMachineActionForm("commission"); const preselectedTestingScripts = [ testingScripts.find((script) => script.name === "smartctl-validate"), @@ -147,6 +151,11 @@ export const CommissionForm = ({ setSelectedAction }: Props): JSX.Element => { ) ); }); + sendAnalytics( + actionFormAnalytics.category, + actionFormAnalytics.action, + actionFormAnalytics.label + ); }} processingCount={processingCount} selectedCount={machinesToAction.length} diff --git a/ui/src/app/machines/components/ActionFormWrapper/DeployForm/DeployForm.test.tsx b/ui/src/app/machines/components/ActionFormWrapper/DeployForm/DeployForm.test.tsx index e20f1b6fe3..708b73499c 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/DeployForm/DeployForm.test.tsx +++ b/ui/src/app/machines/components/ActionFormWrapper/DeployForm/DeployForm.test.tsx @@ -13,11 +13,13 @@ import { configState as configStateFactory, defaultMinHweKernelState as defaultMinHweKerelStateFactory, generalState as generalStateFactory, - osInfoState as osInfoStateFactory, - rootState as rootStateFactory, machine as machineFactory, + machineAction as machineActionFactory, + machineActionsState as machineActionsStateFactory, machineState as machineStateFactory, machineStatus as machineStatusFactory, + osInfoState as osInfoStateFactory, + rootState as rootStateFactory, } from "testing/factories"; const mockStore = configureStore(); @@ -48,6 +50,14 @@ describe("DeployForm", () => { data: "ga-18.04", loaded: true, }), + machineActions: machineActionsStateFactory({ + data: [ + machineActionFactory({ + name: "deploy", + title: "Deploy", + }), + ], + }), osInfo: osInfoStateFactory({ data: { osystems: [ @@ -338,10 +348,13 @@ describe("DeployForm", () => { ]); }); - it("sends an event if cloud-init is set", () => { + it("sends an analytics event without cloud-init user data set", () => { const state = { ...initialState }; state.machine.selected = ["abc123"]; - const useSendMock = jest.spyOn(hooks, "useSendAnalytics"); + const mockSendAnalytics = jest.fn(); + const mockUseSendAnalytics = (hooks.useSendAnalytics = jest.fn( + () => mockSendAnalytics + )); const store = mockStore(state); const wrapper = mount( @@ -352,6 +365,45 @@ describe("DeployForm", () => { ); + + act(() => + wrapper.find("Formik").props().onSubmit({ + includeUserData: true, + installKVM: false, + kernel: "", + oSystem: "ubuntu", + release: "bionic", + userData: "", + }) + ); + + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Deploy", + ]); + mockUseSendAnalytics.mockRestore(); + }); + + it("sends an analytics event with cloud-init user data set", () => { + const state = { ...initialState }; + state.machine.selected = ["abc123"]; + const mockSendAnalytics = jest.fn(); + const mockUseSendAnalytics = (hooks.useSendAnalytics = jest.fn( + () => mockSendAnalytics + )); + const store = mockStore(state); + const wrapper = mount( + + + + + + ); + act(() => wrapper.find("Formik").props().onSubmit({ includeUserData: true, @@ -362,6 +414,13 @@ describe("DeployForm", () => { userData: "test script", }) ); - expect(useSendMock).toHaveBeenCalled(); + + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form with cloud-init user data", + "Deploy", + ]); + mockUseSendAnalytics.mockRestore(); }); }); diff --git a/ui/src/app/machines/components/ActionFormWrapper/DeployForm/DeployForm.tsx b/ui/src/app/machines/components/ActionFormWrapper/DeployForm/DeployForm.tsx index 163f4253e8..d5f128514b 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/DeployForm/DeployForm.tsx +++ b/ui/src/app/machines/components/ActionFormWrapper/DeployForm/DeployForm.tsx @@ -52,7 +52,12 @@ export const DeployForm = ({ setSelectedAction }: Props): JSX.Element => { generalSelectors.defaultMinHweKernel.loaded ); const osInfoLoaded = useSelector(generalSelectors.osInfo.loaded); - const { machinesToAction, processingCount } = useMachineActionForm("deploy"); + const sendAnalytics = useSendAnalytics(); + const { + actionFormAnalytics, + machinesToAction, + processingCount, + } = useMachineActionForm("deploy"); useEffect(() => { dispatch(generalActions.fetchDefaultMinHweKernel()); @@ -77,8 +82,6 @@ export const DeployForm = ({ setSelectedAction }: Props): JSX.Element => { initialRelease = default_release; } - const sendAnalytics = useSendAnalytics(); - return ( { }; if (hasUserData) { sendAnalytics( - "Machine list deploy form", - "Has cloud-init config", - "Cloud-init user data" + actionFormAnalytics.category, + "Submit action form with cloud-init user data", + actionFormAnalytics.label + ); + } else { + sendAnalytics( + actionFormAnalytics.category, + actionFormAnalytics.action, + actionFormAnalytics.label ); } machinesToAction.forEach((machine) => { diff --git a/ui/src/app/machines/components/ActionFormWrapper/DeployForm/DeployFormFields/UserDataField/UserDataField.test.tsx b/ui/src/app/machines/components/ActionFormWrapper/DeployForm/DeployFormFields/UserDataField/UserDataField.test.tsx index c8ab4a67de..4da9d94f05 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/DeployForm/DeployFormFields/UserDataField/UserDataField.test.tsx +++ b/ui/src/app/machines/components/ActionFormWrapper/DeployForm/DeployFormFields/UserDataField/UserDataField.test.tsx @@ -5,7 +5,10 @@ import { MemoryRouter } from "react-router-dom"; import React from "react"; import { Provider } from "react-redux"; -import { machine as machineFactory } from "testing/factories"; +import { + generalState as generalStateFactory, + machine as machineFactory, +} from "testing/factories"; import { TSFixMe } from "app/base/types"; import DeployForm from "../../DeployForm"; @@ -61,7 +64,7 @@ describe("DeployFormFields", () => { loaded: true, loading: false, }, - general: { + general: generalStateFactory({ defaultMinHweKernel: { data: "", errors: {}, @@ -106,7 +109,7 @@ describe("DeployFormFields", () => { loaded: true, loading: false, }, - }, + }), machine: { errors: {}, loading: false, @@ -147,11 +150,7 @@ describe("DeployFormFields", () => { wrapper = mount( - + ); diff --git a/ui/src/app/machines/components/ActionFormWrapper/FieldlessForm/FieldlessForm.js b/ui/src/app/machines/components/ActionFormWrapper/FieldlessForm/FieldlessForm.js index 0f4964347e..d72ce67e66 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/FieldlessForm/FieldlessForm.js +++ b/ui/src/app/machines/components/ActionFormWrapper/FieldlessForm/FieldlessForm.js @@ -4,6 +4,7 @@ import React from "react"; import { kebabToCamelCase } from "app/utils"; import { machine as machineActions } from "app/base/actions"; +import { useSendAnalytics } from "app/base/hooks"; import { useMachineActionForm } from "app/machines/hooks"; import machineSelectors from "app/store/machine/selectors"; import ActionForm from "app/base/components/ActionForm"; @@ -29,9 +30,12 @@ const fieldlessActions = [ export const FieldlessForm = ({ selectedAction, setSelectedAction }) => { const dispatch = useDispatch(); const errors = useSelector(machineSelectors.errors); - const { machinesToAction, processingCount } = useMachineActionForm( - selectedAction.name - ); + const sendAnalytics = useSendAnalytics(); + const { + actionFormAnalytics, + machinesToAction, + processingCount, + } = useMachineActionForm(selectedAction.name); return ( { machinesToAction.forEach((machine) => { dispatch(machineActions[actionMethod](machine.system_id)); }); + sendAnalytics( + actionFormAnalytics.category, + actionFormAnalytics.action, + actionFormAnalytics.label + ); } }} processingCount={processingCount} diff --git a/ui/src/app/machines/components/ActionFormWrapper/FieldlessForm/FieldlessForm.test.js b/ui/src/app/machines/components/ActionFormWrapper/FieldlessForm/FieldlessForm.test.js index 1126863e19..49652b61e0 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/FieldlessForm/FieldlessForm.test.js +++ b/ui/src/app/machines/components/ActionFormWrapper/FieldlessForm/FieldlessForm.test.js @@ -6,9 +6,12 @@ import configureStore from "redux-mock-store"; import React from "react"; import FieldlessForm from "./FieldlessForm"; +import * as hooks from "app/base/hooks"; import { generalState as generalStateFactory, machine as machineFactory, + machineAction as machineActionFactory, + machineActionsState as machineActionsStateFactory, machineState as machineStateFactory, rootState as rootStateFactory, } from "testing/factories"; @@ -17,8 +20,14 @@ const mockStore = configureStore(); describe("FieldlessForm", () => { let initialState; + let mockSendAnalytics; + let mockUseSendAnalytics; beforeEach(() => { + mockSendAnalytics = jest.fn(); + mockUseSendAnalytics = hooks.useSendAnalytics = jest.fn( + () => mockSendAnalytics + ); initialState = rootStateFactory({ machine: machineStateFactory({ loaded: true, @@ -33,25 +42,32 @@ describe("FieldlessForm", () => { }, }), general: generalStateFactory({ - machineActions: { + machineActions: machineActionsStateFactory({ data: [ - { name: "abort", sentence: "abort" }, - { name: "acquire", sentence: "acquire" }, - { name: "delete", sentence: "delete" }, - { name: "exit-rescue-mode", sentence: "exit-rescue-mode" }, - { name: "lock", sentence: "lock" }, - { name: "mark-fixed", sentence: "mark-fixed" }, - { name: "off", sentence: "off" }, - { name: "on", sentence: "on" }, - { name: "release", sentence: "release" }, - { name: "rescue-mode", sentence: "rescue-mode" }, - { name: "unlock", sentence: "unlock" }, + machineActionFactory({ name: "abort", title: "Abort" }), + machineActionFactory({ name: "acquire", title: "Acquire" }), + machineActionFactory({ name: "delete", title: "Delete" }), + machineActionFactory({ + name: "exit-rescue-mode", + title: "Exit rescue mode", + }), + machineActionFactory({ name: "lock", title: "Lock" }), + machineActionFactory({ name: "mark-fixed", title: "Mark fixed" }), + machineActionFactory({ name: "off", title: "Power off" }), + machineActionFactory({ name: "on", title: "Power on" }), + machineActionFactory({ name: "release", title: "Release" }), + machineActionFactory({ name: "rescue-mode", title: "Rescue mode" }), + machineActionFactory({ name: "unlock", title: "Unlock" }), ], - }, + }), }), }); }); + afterEach(() => { + mockUseSendAnalytics.mockRestore(); + }); + it("renders", () => { const state = { ...initialState }; const store = mockStore(state); @@ -152,6 +168,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Abort", + ]); }); it("can dispatch abort action from details view", () => { @@ -197,6 +219,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine details", + "Submit action form", + "Abort", + ]); }); it("can dispatch acquire action on selected machines", () => { @@ -236,6 +264,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Acquire", + ]); }); it("can dispatch acquire action from details view", () => { @@ -282,6 +316,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine details", + "Submit action form", + "Acquire", + ]); }); it("can dispatch delete action on selected machines", () => { @@ -321,6 +361,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Delete", + ]); }); it("can dispatch delete action from details view", () => { @@ -367,6 +413,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine details", + "Submit action form", + "Delete", + ]); }); it("can dispatch exit rescue mode action on selected machines", () => { @@ -410,6 +462,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Exit rescue mode", + ]); }); it("can dispatch exit rescue mode action from details view", () => { @@ -458,6 +516,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine details", + "Submit action form", + "Exit rescue mode", + ]); }); it("can dispatch lock action on selected machines", () => { @@ -497,6 +561,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Lock", + ]); }); it("can dispatch lock action from details view", () => { @@ -543,6 +613,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine details", + "Submit action form", + "Lock", + ]); }); it("can dispatch mark fixed action on selected machines", () => { @@ -582,6 +658,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Mark fixed", + ]); }); it("can dispatch mark fixed action from details view", () => { @@ -628,6 +710,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine details", + "Submit action form", + "Mark fixed", + ]); }); it("can dispatch power off action on selected machines", () => { @@ -667,6 +755,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Power off", + ]); }); it("can dispatch power off action from details view", () => { @@ -713,6 +807,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine details", + "Submit action form", + "Power off", + ]); }); it("can dispatch power on action on selected machines", () => { @@ -752,6 +852,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Power on", + ]); }); it("can dispatch power on action from details view", () => { @@ -798,6 +904,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine details", + "Submit action form", + "Power on", + ]); }); it("can dispatch release action on selected machines", () => { @@ -837,6 +949,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Release", + ]); }); it("can dispatch release action from details view", () => { @@ -883,6 +1001,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine details", + "Submit action form", + "Release", + ]); }); it("can dispatch unlock action on selected machines", () => { @@ -922,6 +1046,12 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Unlock", + ]); }); it("can dispatch unlock action from details view", () => { @@ -968,5 +1098,11 @@ describe("FieldlessForm", () => { }, }, ]); + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine details", + "Submit action form", + "Unlock", + ]); }); }); diff --git a/ui/src/app/machines/components/ActionFormWrapper/MarkBrokenForm/MarkBrokenForm.test.tsx b/ui/src/app/machines/components/ActionFormWrapper/MarkBrokenForm/MarkBrokenForm.test.tsx index f40c997eb7..aa430602c4 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/MarkBrokenForm/MarkBrokenForm.test.tsx +++ b/ui/src/app/machines/components/ActionFormWrapper/MarkBrokenForm/MarkBrokenForm.test.tsx @@ -6,11 +6,13 @@ import configureStore from "redux-mock-store"; import React from "react"; import MarkBrokenForm from "./MarkBrokenForm"; +import * as hooks from "app/base/hooks"; import { RootState } from "app/store/root/types"; import { generalState as generalStateFactory, rootState as rootStateFactory, machine as machineFactory, + machineAction as machineActionFactory, machineActionsState as machineActionsStateFactory, machineState as machineStateFactory, machineStatus as machineStatusFactory, @@ -25,12 +27,12 @@ describe("MarkBrokenForm", () => { general: generalStateFactory({ machineActions: machineActionsStateFactory({ data: [ - { + machineActionFactory({ name: "mark-broken", title: "Mark broken", sentence: "marked broken", type: "testing", - }, + }), ], }), }), @@ -185,4 +187,37 @@ describe("MarkBrokenForm", () => { }, ]); }); + + it("sends an analytics event on submit", () => { + const state = { ...initialState }; + state.machine.selected = ["abc123"]; + const mockSendAnalytics = jest.fn(); + const mockUseSendAnalytics = (hooks.useSendAnalytics = jest.fn( + () => mockSendAnalytics + )); + const store = mockStore(state); + const wrapper = mount( + + + + + + ); + + act(() => + wrapper.find("Formik").props().onSubmit({ + comment: "", + }) + ); + + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Mark broken", + ]); + mockUseSendAnalytics.mockRestore(); + }); }); diff --git a/ui/src/app/machines/components/ActionFormWrapper/MarkBrokenForm/MarkBrokenForm.tsx b/ui/src/app/machines/components/ActionFormWrapper/MarkBrokenForm/MarkBrokenForm.tsx index 637c5b3efb..1a314e0391 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/MarkBrokenForm/MarkBrokenForm.tsx +++ b/ui/src/app/machines/components/ActionFormWrapper/MarkBrokenForm/MarkBrokenForm.tsx @@ -5,6 +5,7 @@ import React, { useEffect } from "react"; import { machine as machineActions } from "app/base/actions"; import ActionForm from "app/base/components/ActionForm"; +import { useSendAnalytics } from "app/base/hooks"; import { useMachineActionForm } from "app/machines/hooks"; import type { MachineAction } from "app/store/general/types"; import machineSelectors from "app/store/machine/selectors"; @@ -26,9 +27,12 @@ export const MarkBrokenForm = ({ setSelectedAction }: Props): JSX.Element => { const dispatch = useDispatch(); const machineErrors = useSelector(machineSelectors.errors); const errors = Object.keys(machineErrors).length > 0 ? machineErrors : null; - const { machinesToAction, processingCount } = useMachineActionForm( - "mark-broken" - ); + const sendAnalytics = useSendAnalytics(); + const { + actionFormAnalytics, + machinesToAction, + processingCount, + } = useMachineActionForm("mark-broken"); useEffect( () => () => { @@ -54,6 +58,11 @@ export const MarkBrokenForm = ({ setSelectedAction }: Props): JSX.Element => { machineActions.markBroken(machine.system_id, values.comment) ); }); + sendAnalytics( + actionFormAnalytics.category, + actionFormAnalytics.action, + actionFormAnalytics.label + ); }} processingCount={processingCount} selectedCount={machinesToAction.length} diff --git a/ui/src/app/machines/components/ActionFormWrapper/OverrideTestForm/OverrideTestForm.js b/ui/src/app/machines/components/ActionFormWrapper/OverrideTestForm/OverrideTestForm.js index 8fa699a0e0..c8c3806980 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/OverrideTestForm/OverrideTestForm.js +++ b/ui/src/app/machines/components/ActionFormWrapper/OverrideTestForm/OverrideTestForm.js @@ -7,6 +7,7 @@ import { useDispatch, useSelector } from "react-redux"; import * as Yup from "yup"; import { machine as machineActions } from "app/base/actions"; +import { useSendAnalytics } from "app/base/hooks"; import { useMachineActionForm } from "app/machines/hooks"; import machineSelectors from "app/store/machine/selectors"; import scriptresultsSelectors from "app/store/scriptresults/selectors"; @@ -61,9 +62,12 @@ export const OverrideTestForm = ({ setSelectedAction }) => { const dispatch = useDispatch(); const errors = useSelector(machineSelectors.errors); const scriptResultsLoaded = useSelector(scriptresultsSelectors.loaded); - const { machinesToAction, processingCount } = useMachineActionForm( - "override-failed-testing" - ); + const sendAnalytics = useSendAnalytics(); + const { + actionFormAnalytics, + machinesToAction, + processingCount, + } = useMachineActionForm("override-failed-testing"); const machineIDs = machinesToAction.map((machine) => machine.system_id); const failedScriptResults = useSelector((state) => machineSelectors.failedScriptResults(state, machineIDs) @@ -105,6 +109,11 @@ export const OverrideTestForm = ({ setSelectedAction }) => { } }); } + sendAnalytics( + actionFormAnalytics.category, + actionFormAnalytics.action, + actionFormAnalytics.label + ); }} processingCount={processingCount} selectedCount={machinesToAction.length} diff --git a/ui/src/app/machines/components/ActionFormWrapper/SetPoolForm/SetPoolForm.js b/ui/src/app/machines/components/ActionFormWrapper/SetPoolForm/SetPoolForm.js index f9fcbbb8c3..49d87856c8 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/SetPoolForm/SetPoolForm.js +++ b/ui/src/app/machines/components/ActionFormWrapper/SetPoolForm/SetPoolForm.js @@ -4,6 +4,7 @@ import PropTypes from "prop-types"; import React, { useEffect, useState } from "react"; import { machine as machineActions } from "app/base/actions"; +import { useSendAnalytics } from "app/base/hooks"; import { useMachineActionForm } from "app/machines/hooks"; import machineSelectors from "app/store/machine/selectors"; import { actions as resourcePoolActions } from "app/store/resourcepool"; @@ -30,9 +31,12 @@ export const SetPoolForm = ({ setSelectedAction }) => { const resourcePoolsLoaded = useSelector(resourcePoolSelectors.loaded); const errors = Object.keys(machineErrors).length > 0 ? machineErrors : poolErrors; - const { machinesToAction, processingCount } = useMachineActionForm( - "set-pool" - ); + const sendAnalytics = useSendAnalytics(); + const { + actionFormAnalytics, + machinesToAction, + processingCount, + } = useMachineActionForm("set-pool"); useEffect(() => { dispatch(resourcePoolActions.fetch()); @@ -68,6 +72,11 @@ export const SetPoolForm = ({ setSelectedAction }) => { }); } } + sendAnalytics( + actionFormAnalytics.category, + actionFormAnalytics.action, + actionFormAnalytics.label + ); // Store the values in case there are errors and the form needs to be // displayed again. setInitialValues(values); diff --git a/ui/src/app/machines/components/ActionFormWrapper/SetPoolForm/SetPoolForm.test.js b/ui/src/app/machines/components/ActionFormWrapper/SetPoolForm/SetPoolForm.test.js index 3f385840d7..e98324ac69 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/SetPoolForm/SetPoolForm.test.js +++ b/ui/src/app/machines/components/ActionFormWrapper/SetPoolForm/SetPoolForm.test.js @@ -6,8 +6,11 @@ import configureStore from "redux-mock-store"; import React from "react"; import SetPoolForm from "./SetPoolForm"; +import * as hooks from "app/base/hooks"; import { machine as machineFactory, + machineAction as machineActionFactory, + machineActionsState as machineActionsStateFactory, machineState as machineStateFactory, generalState as generalStateFactory, resourcePool as resourcePoolFactory, @@ -22,9 +25,15 @@ describe("SetPoolForm", () => { beforeEach(() => { state = rootStateFactory({ general: generalStateFactory({ - machineActions: { - data: [{ name: "set-pool", sentence: "change those pools" }], - }, + machineActions: machineActionsStateFactory({ + data: [ + machineActionFactory({ + name: "set-pool", + sentence: "change those pools", + title: "Set pool", + }), + ], + }), }), machine: machineStateFactory({ errors: {}, @@ -177,4 +186,38 @@ describe("SetPoolForm", () => { }, ]); }); + + it("sends an analytics event on submit", () => { + state.machine.selected = ["abc123"]; + const mockSendAnalytics = jest.fn(); + const mockUseSendAnalytics = (hooks.useSendAnalytics = jest.fn( + () => mockSendAnalytics + )); + const store = mockStore(state); + const wrapper = mount( + + + + + + ); + + act(() => + wrapper.find("Formik").props().onSubmit({ + poolSelection: "select", + name: "pool-1", + description: "", + }) + ); + + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Set pool", + ]); + mockUseSendAnalytics.mockRestore(); + }); }); diff --git a/ui/src/app/machines/components/ActionFormWrapper/SetZoneForm/SetZoneForm.js b/ui/src/app/machines/components/ActionFormWrapper/SetZoneForm/SetZoneForm.js index 324088755a..f4b3ba24c2 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/SetZoneForm/SetZoneForm.js +++ b/ui/src/app/machines/components/ActionFormWrapper/SetZoneForm/SetZoneForm.js @@ -5,6 +5,7 @@ import PropTypes from "prop-types"; import React, { useEffect } from "react"; import { machine as machineActions } from "app/base/actions"; +import { useSendAnalytics } from "app/base/hooks"; import { useMachineActionForm } from "app/machines/hooks"; import machineSelectors from "app/store/machine/selectors"; import { actions as zoneActions } from "app/store/zone"; @@ -21,9 +22,12 @@ export const SetZoneForm = ({ setSelectedAction }) => { const errors = useSelector(machineSelectors.errors); const zones = useSelector(zoneSelectors.all); const zonesLoaded = useSelector(zoneSelectors.loaded); - const { machinesToAction, processingCount } = useMachineActionForm( - "set-zone" - ); + const sendAnalytics = useSendAnalytics(); + const { + actionFormAnalytics, + machinesToAction, + processingCount, + } = useMachineActionForm("set-zone"); useEffect(() => { dispatch(zoneActions.fetch()); @@ -52,6 +56,11 @@ export const SetZoneForm = ({ setSelectedAction }) => { machinesToAction.forEach((machine) => { dispatch(machineActions.setZone(machine.system_id, zone.id)); }); + sendAnalytics( + actionFormAnalytics.category, + actionFormAnalytics.action, + actionFormAnalytics.label + ); }} processingCount={processingCount} selectedCount={machinesToAction.length} diff --git a/ui/src/app/machines/components/ActionFormWrapper/SetZoneForm/SetZoneForm.test.js b/ui/src/app/machines/components/ActionFormWrapper/SetZoneForm/SetZoneForm.test.js index 1fa6362b5f..6326908766 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/SetZoneForm/SetZoneForm.test.js +++ b/ui/src/app/machines/components/ActionFormWrapper/SetZoneForm/SetZoneForm.test.js @@ -6,10 +6,13 @@ import configureStore from "redux-mock-store"; import React from "react"; import SetZoneForm from "./SetZoneForm"; +import * as hooks from "app/base/hooks"; import { + generalState as generalStateFactory, machine as machineFactory, + machineAction as machineActionFactory, + machineActionsState as machineActionsStateFactory, machineState as machineStateFactory, - generalState as generalStateFactory, rootState as rootStateFactory, zone as zoneFactory, zoneState as zoneStateFactory, @@ -22,9 +25,9 @@ describe("SetZoneForm", () => { beforeEach(() => { state = rootStateFactory({ general: generalStateFactory({ - machineActions: { - data: [{ name: "set-zone", sentence: "set-zone" }], - }, + machineActions: machineActionsStateFactory({ + data: [machineActionFactory({ name: "set-zone", title: "Set zone" })], + }), }), machine: machineStateFactory({ errors: {}, @@ -154,4 +157,36 @@ describe("SetZoneForm", () => { }, ]); }); + + it("sends an analytics event on submit", () => { + state.machine.selected = ["abc123"]; + const mockSendAnalytics = jest.fn(); + const mockUseSendAnalytics = (hooks.useSendAnalytics = jest.fn( + () => mockSendAnalytics + )); + const store = mockStore(state); + const wrapper = mount( + + + + + + ); + + act(() => + wrapper.find("Formik").props().onSubmit({ + zone: "zone-1", + }) + ); + + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Set zone", + ]); + mockUseSendAnalytics.mockRestore(); + }); }); diff --git a/ui/src/app/machines/components/ActionFormWrapper/TagForm/TagForm.js b/ui/src/app/machines/components/ActionFormWrapper/TagForm/TagForm.js index c15cad1931..73e89e3292 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/TagForm/TagForm.js +++ b/ui/src/app/machines/components/ActionFormWrapper/TagForm/TagForm.js @@ -4,6 +4,7 @@ import { useDispatch, useSelector } from "react-redux"; import * as Yup from "yup"; import { machine as machineActions } from "app/base/actions"; +import { useSendAnalytics } from "app/base/hooks"; import { useMachineActionForm } from "app/machines/hooks"; import machineSelectors from "app/store/machine/selectors"; import { actions as tagActions } from "app/store/tag"; @@ -28,7 +29,12 @@ export const TagForm = ({ setSelectedAction }) => { const [initialValues, setInitialValues] = useState({ tags: [] }); const errors = useSelector(machineSelectors.errors); const tagsLoaded = useSelector(tagSelectors.loaded); - const { machinesToAction, processingCount } = useMachineActionForm("tag"); + const sendAnalytics = useSendAnalytics(); + const { + actionFormAnalytics, + machinesToAction, + processingCount, + } = useMachineActionForm("tag"); const formErrors = { ...errors }; if (formErrors && formErrors.name) { @@ -55,6 +61,11 @@ export const TagForm = ({ setSelectedAction }) => { dispatch(machineActions.tag(machine.system_id, values.tags)); }); } + sendAnalytics( + actionFormAnalytics.category, + actionFormAnalytics.action, + actionFormAnalytics.label + ); setInitialValues(values); }} processingCount={processingCount} diff --git a/ui/src/app/machines/components/ActionFormWrapper/TagForm/TagForm.test.js b/ui/src/app/machines/components/ActionFormWrapper/TagForm/TagForm.test.js index 89aff55a44..28ab3f10b2 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/TagForm/TagForm.test.js +++ b/ui/src/app/machines/components/ActionFormWrapper/TagForm/TagForm.test.js @@ -6,9 +6,12 @@ import configureStore from "redux-mock-store"; import React from "react"; import TagForm from "./TagForm"; +import * as hooks from "app/base/hooks"; import { generalState as generalStateFactory, machine as machineFactory, + machineAction as machineActionFactory, + machineActionsState as machineActionsStateFactory, machineState as machineStateFactory, rootState as rootStateFactory, tagState as tagStateFactory, @@ -22,9 +25,9 @@ describe("TagForm", () => { beforeEach(() => { initialState = rootStateFactory({ general: generalStateFactory({ - machineActions: { - data: [{ name: "tag", sentence: "tag" }], - }, + machineActions: machineActionsStateFactory({ + data: [machineActionFactory({ name: "tag", title: "Tag" })], + }), }), machine: machineStateFactory({ loaded: true, @@ -170,4 +173,40 @@ describe("TagForm", () => { }, ]); }); + + it("sends an analytics event on submit", () => { + const state = { ...initialState }; + state.machine.selected = ["abc123"]; + const mockSendAnalytics = jest.fn(); + const mockUseSendAnalytics = (hooks.useSendAnalytics = jest.fn( + () => mockSendAnalytics + )); + const store = mockStore(state); + const wrapper = mount( + + + + + + ); + + act(() => + wrapper + .find("Formik") + .props() + .onSubmit({ + tags: [{ name: "tag1" }, { name: "tag2" }], + }) + ); + + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Tag", + ]); + mockUseSendAnalytics.mockRestore(); + }); }); diff --git a/ui/src/app/machines/components/ActionFormWrapper/TestForm/TestForm.test.tsx b/ui/src/app/machines/components/ActionFormWrapper/TestForm/TestForm.test.tsx index 50ab36e3bb..7ce671bf0e 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/TestForm/TestForm.test.tsx +++ b/ui/src/app/machines/components/ActionFormWrapper/TestForm/TestForm.test.tsx @@ -7,14 +7,16 @@ import React from "react"; import TestForm from "./TestForm"; import { HardwareType } from "app/base/enum"; +import * as hooks from "app/base/hooks"; import { generalState as generalStateFactory, machine as machineFactory, - scripts as scriptsFactory, + machineAction as machineActionFactory, machineActionsState as machineActionsStateFactory, machineState as machineStateFactory, machineStatus as machineStatusFactory, rootState as rootStateFactory, + scripts as scriptsFactory, scriptsState as scriptsStateFactory, } from "testing/factories"; import { ScriptType } from "testing/factories/scripts"; @@ -30,9 +32,7 @@ describe("TestForm", () => { initialState = rootStateFactory({ general: generalStateFactory({ machineActions: machineActionsStateFactory({ - data: [ - { name: "test", sentence: "test", type: "test", title: "test" }, - ], + data: [machineActionFactory({ name: "test", title: "Test" })], }), }), machine: machineStateFactory({ @@ -251,4 +251,44 @@ describe("TestForm", () => { }, ]); }); + + it("sends an analytics event on submit", () => { + const state = { ...initialState }; + state.machine.selected = ["abc123"]; + const mockSendAnalytics = jest.fn(); + const mockUseSendAnalytics = (hooks.useSendAnalytics = jest.fn( + () => mockSendAnalytics + )); + const store = mockStore(state); + const wrapper = mount( + + + + + + ); + + act(() => + wrapper + .find("Formik") + .props() + .onSubmit({ + enableSSH: true, + scripts: state.scripts.items, + scriptInputs: { + "internet-connectivity": "https://connectivity-check.ubuntu.com", + }, + }) + ); + + expect(mockSendAnalytics).toHaveBeenCalled(); + expect(mockSendAnalytics.mock.calls[0]).toEqual([ + "Machine list", + "Submit action form", + "Test", + ]); + mockUseSendAnalytics.mockRestore(); + }); }); diff --git a/ui/src/app/machines/components/ActionFormWrapper/TestForm/TestForm.tsx b/ui/src/app/machines/components/ActionFormWrapper/TestForm/TestForm.tsx index c44efd4fa7..82e25c5abf 100644 --- a/ui/src/app/machines/components/ActionFormWrapper/TestForm/TestForm.tsx +++ b/ui/src/app/machines/components/ActionFormWrapper/TestForm/TestForm.tsx @@ -8,6 +8,7 @@ import { machine as machineActions, scripts as scriptActions, } from "app/base/actions"; +import { useSendAnalytics } from "app/base/hooks"; import ActionForm from "app/base/components/ActionForm"; import { HardwareType } from "app/base/enum"; import { useMachineActionForm } from "app/machines/hooks"; @@ -53,7 +54,12 @@ export const TestForm = ({ const scripts = useSelector(scriptSelectors.testing); const scriptsLoaded = useSelector(scriptSelectors.loaded); const urlScripts = useSelector(scriptSelectors.testingWithUrl); - const { machinesToAction, processingCount } = useMachineActionForm("test"); + const sendAnalytics = useSendAnalytics(); + const { + actionFormAnalytics, + machinesToAction, + processingCount, + } = useMachineActionForm("test"); const formattedScripts = scripts.map((script) => ({ ...script, @@ -109,6 +115,11 @@ export const TestForm = ({ ) ); }); + sendAnalytics( + actionFormAnalytics.category, + actionFormAnalytics.action, + actionFormAnalytics.label + ); }} processingCount={processingCount} selectedCount={machinesToAction.length} diff --git a/ui/src/app/machines/hooks.tsx b/ui/src/app/machines/hooks.tsx index 51c6947bc3..fa582834a1 100644 --- a/ui/src/app/machines/hooks.tsx +++ b/ui/src/app/machines/hooks.tsx @@ -1,10 +1,13 @@ import { useCallback } from "react"; import { useSelector } from "react-redux"; +import { AnalyticsEvent } from "app/base/types"; import { ACTIONS } from "app/base/reducers/machine/machine"; +import generalSelectors from "app/store/general/selectors"; import type { MachineActionName } from "app/store/general/types"; import machineSelectors from "app/store/machine/selectors"; import type { Machine } from "app/store/machine/types"; +import type { RootState } from "app/store/root/types"; /** * Create a callback for toggling the menu @@ -34,11 +37,16 @@ export const useToggleMenu = ( export const useMachineActionForm = ( actionName: MachineActionName ): { + actionFormAnalytics: AnalyticsEvent; machinesToAction: Machine[]; processingCount: number; } => { const activeMachine = useSelector(machineSelectors.active); const selectedMachines = useSelector(machineSelectors.selected); + const actionTitle = + useSelector((state: RootState) => + generalSelectors.machineActions.getByName(state, actionName) + )?.title || actionName; const action = ACTIONS.find((action) => action.name === actionName); // If in the machine details view, the machine is not in selected state so // instead we use the regular selector. @@ -46,6 +54,15 @@ export const useMachineActionForm = ( machineSelectors[activeMachine ? action.status : `${action.status}Selected`] ) as Machine[]; const machinesToAction = activeMachine ? [activeMachine] : selectedMachines; + const actionFormAnalytics = { + action: "Submit action form", + category: activeMachine ? "Machine details" : "Machine list", + label: actionTitle.replace("...", ""), + }; - return { machinesToAction, processingCount: processingMachines.length }; + return { + actionFormAnalytics, + machinesToAction, + processingCount: processingMachines.length, + }; }; diff --git a/ui/src/app/machines/views/MachineDetails/MachineSummary/TestResults/TestResults.tsx b/ui/src/app/machines/views/MachineDetails/MachineSummary/TestResults/TestResults.tsx index fd0cba8424..7088420de7 100644 --- a/ui/src/app/machines/views/MachineDetails/MachineSummary/TestResults/TestResults.tsx +++ b/ui/src/app/machines/views/MachineDetails/MachineSummary/TestResults/TestResults.tsx @@ -44,7 +44,7 @@ const TestResults = ({ onClick={() => sendAnalytics( "Machine details", - "Storage tests passed link", + `${capitaliseFirst(scriptType)} tests passed link`, "Machine summary tab" ) } diff --git a/ui/src/testing/factories/state.ts b/ui/src/testing/factories/state.ts index a74f8edc58..85400293a8 100644 --- a/ui/src/testing/factories/state.ts +++ b/ui/src/testing/factories/state.ts @@ -221,6 +221,7 @@ export const knownArchitecturesState = define({ export const machineActionsState = define({ ...defaultGeneralState, + data: () => [], }); export const navigationOptionsState = define({