From 3e6175fbb80cb2d08f2c0f43feac9698617edefe Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Wed, 20 Sep 2023 11:41:41 -0400 Subject: [PATCH 01/16] fix: [fix-M3-7083] initial coomit: save work --- packages/manager/src/featureFlags.ts | 8 ++-- .../TabbedContent/FromAppsContent.tsx | 11 +++--- .../features/OneClickApps/AppDetailDrawer.tsx | 15 +++----- .../src/features/OneClickApps/oneClickApps.ts | 38 +------------------ .../src/features/OneClickApps/types.ts | 37 ++++++++++++++++++ .../src/features/OneClickApps/utils.ts | 25 ++++++++++++ 6 files changed, 77 insertions(+), 57 deletions(-) create mode 100644 packages/manager/src/features/OneClickApps/types.ts create mode 100644 packages/manager/src/features/OneClickApps/utils.ts diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 68b8fb5746c..4b9e511f5fb 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -1,8 +1,6 @@ -import { TPAProvider } from '@linode/api-v4/lib/profile'; - -import { NoticeVariant } from 'src/components/Notice/Notice'; - -import { Doc } from './features/OneClickApps/oneClickApps'; +import type { Doc } from './features/OneClickApps/types'; +import type { TPAProvider } from '@linode/api-v4/lib/profile'; +import type { NoticeVariant } from 'src/components/Notice/Notice'; // These flags should correspond with active features flags in LD diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx index 4bf6bdb3568..a4c5ca37262 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx @@ -10,15 +10,12 @@ import { Box } from 'src/components/Box'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import Select, { Item } from 'src/components/EnhancedSelect'; import { ImageSelect } from 'src/components/ImageSelect/ImageSelect'; -import { Typography } from 'src/components/Typography'; import { Paper } from 'src/components/Paper'; +import { Typography } from 'src/components/Typography'; import { APP_ROOT } from 'src/constants'; import { ImageEmptyState } from 'src/features/Linodes/LinodesCreate/TabbedContent/ImageEmptyState'; import { AppDetailDrawer } from 'src/features/OneClickApps'; -import { - AppCategory, - oneClickApps, -} from 'src/features/OneClickApps/oneClickApps'; +import { oneClickApps } from 'src/features/OneClickApps/oneClickApps'; import UserDefinedFieldsPanel from 'src/features/StackScripts/UserDefinedFieldsPanel'; import { sendMarketplaceSearchEvent } from 'src/utilities/analytics'; import getAPIErrorsFor from 'src/utilities/getAPIErrorFor'; @@ -30,8 +27,10 @@ import { StackScriptFormStateHandlers, WithTypesRegionsAndImages, } from '../types'; -import { filterUDFErrors } from './formUtilities'; import { StyledGrid } from './CommonTabbedContent.styles'; +import { filterUDFErrors } from './formUtilities'; + +import type { AppCategory } from 'src/features/OneClickApps/types'; const appCategories = [ 'Control Panels', diff --git a/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx b/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx index ffdade1f3ed..0cca2968c6c 100644 --- a/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx +++ b/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx @@ -8,11 +8,13 @@ import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { Link } from 'src/components/Link'; import { Typography } from 'src/components/Typography'; -import { OCA } from 'src/features/OneClickApps/oneClickApps'; import { useFlags } from 'src/hooks/useFlags'; import { sanitizeHTML } from 'src/utilities/sanitize-html'; import { oneClickApps } from './oneClickApps'; +import { mapStackScriptLabelToOneClickAppName } from './utils'; + +import type { OCA } from './types'; const useStyles = makeStyles((theme: Theme) => ({ appName: { @@ -92,14 +94,9 @@ export const AppDetailDrawer: React.FunctionComponent = (props) => { }; React.useEffect(() => { - const app = oneClickApps.find((app) => { - const cleanedStackScriptLabel = stackScriptLabel - .replace(/[^\w\s\/$*+-?&.:()]/gi, '') - .trim(); - - const cleanedAppName = app.name.replace('®', ''); - - return cleanedStackScriptLabel === cleanedAppName; + const app = mapStackScriptLabelToOneClickAppName({ + oneClickApps, + stackScriptLabel, }); if (!app) { diff --git a/packages/manager/src/features/OneClickApps/oneClickApps.ts b/packages/manager/src/features/OneClickApps/oneClickApps.ts index 7f2a99ce654..5ecbc5a87a5 100644 --- a/packages/manager/src/features/OneClickApps/oneClickApps.ts +++ b/packages/manager/src/features/OneClickApps/oneClickApps.ts @@ -1,40 +1,4 @@ -export interface OCA { - alt_description: string; - alt_name: string; - categories: AppCategory[]; - colors: Colors; - description: string; - href?: string; - logo_url: string; - name: string; - related_guides?: Doc[]; - summary: string; - tips?: string[]; - website?: string; -} - -export interface Doc { - href: string; - title: string; -} - -export interface Colors { - end: string; - start: string; -} - -export type AppCategory = - | 'App Creators' - | 'Control Panels' - | 'Databases' - | 'Development' - | 'Games' - | 'Media and Entertainment' - | 'Monitoring' - | 'Productivity' - | 'Security' - | 'Stacks' - | 'Website'; +import type { OCA } from './types'; export const oneClickApps: OCA[] = [ { diff --git a/packages/manager/src/features/OneClickApps/types.ts b/packages/manager/src/features/OneClickApps/types.ts new file mode 100644 index 00000000000..5e5bf88d501 --- /dev/null +++ b/packages/manager/src/features/OneClickApps/types.ts @@ -0,0 +1,37 @@ +export interface OCA { + alt_description: string; + alt_name: string; + categories: AppCategory[]; + colors: Colors; + description: string; + href?: string; + logo_url: string; + name: string; + related_guides?: Doc[]; + summary: string; + tips?: string[]; + website?: string; +} + +export interface Doc { + href: string; + title: string; +} + +export interface Colors { + end: string; + start: string; +} + +export type AppCategory = + | 'App Creators' + | 'Control Panels' + | 'Databases' + | 'Development' + | 'Games' + | 'Media and Entertainment' + | 'Monitoring' + | 'Productivity' + | 'Security' + | 'Stacks' + | 'Website'; diff --git a/packages/manager/src/features/OneClickApps/utils.ts b/packages/manager/src/features/OneClickApps/utils.ts new file mode 100644 index 00000000000..ba0da601ee6 --- /dev/null +++ b/packages/manager/src/features/OneClickApps/utils.ts @@ -0,0 +1,25 @@ +import type { OCA } from './types'; + +interface Options { + oneClickApps: OCA[]; + stackScriptLabel: string; +} + +/** + * Given a StackScript label, return the corresponding One-Click App name + * @param oneClickApps + * @param stackScriptLabel + * @returns + */ +export const mapStackScriptLabelToOneClickAppName = ({ + oneClickApps, + stackScriptLabel, +}: Options): OCA | undefined => { + return oneClickApps.find((app) => { + const cleanedStackScriptLabel = stackScriptLabel.replace(/\W/g, '').trim(); + + const cleanedAppName = app.name.replace('®', '').replace(/\W/g, ''); + + return cleanedStackScriptLabel === cleanedAppName; + }); +}; From 9b7d47c2c26aaf3a649698820c8e6ace3104c77d Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Thu, 21 Sep 2023 19:44:27 -0400 Subject: [PATCH 02/16] Make utils and introduce new e2e --- .../core/oneClickApps/one-click-apps.spec.ts | 66 ++++++++++++++++ .../support/intercepts/feature-flags.ts | 7 ++ .../manager/src/factories/stackscripts.ts | 20 ++++- .../LinodesCreate/LinodeCreateContainer.tsx | 25 ++---- .../Linodes/LinodesCreate/utilities.test.ts | 76 +++++++++++++++++++ .../Linodes/LinodesCreate/utilities.tsx | 48 +++++++++++- .../features/OneClickApps/AppDetailDrawer.tsx | 4 +- .../src/features/OneClickApps/utils.test.ts | 38 ++++++++++ .../src/features/OneClickApps/utils.ts | 2 +- 9 files changed, 259 insertions(+), 27 deletions(-) create mode 100644 packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts create mode 100644 packages/manager/src/features/OneClickApps/utils.test.ts diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts new file mode 100644 index 00000000000..abf4633f491 --- /dev/null +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -0,0 +1,66 @@ +import { createLinode } from 'support/api/linodes'; +import { containsVisible } from 'support/helpers'; +import { ui } from 'support/ui'; +import { authenticate } from 'support/api/authentication'; +import { cleanUp } from 'support/util/cleanup'; +import { interceptRebootLinode } from 'support/intercepts/linodes'; +import { interceptGetStackScripts } from 'support/intercepts/stackscripts'; +import { + interceptDeleteLinodeConfig, + interceptCreateLinodeConfigs, + interceptUpdateLinodeConfigs, +} from 'support/intercepts/configs'; +import { + createLinodeAndGetConfig, + createAndBootLinode, +} from 'support/util/linode-utils'; +import { interceptFeatureFlags } from 'support/intercepts/feature-flags'; +import { baseApps } from '../../../../src/features/StackScripts/stackScriptUtils'; + +import type { Config, Linode, StackScript } from '@linode/api-v4'; +import { OCA } from '@src/features/OneClickApps/types'; +import { FlagSet } from '@src/featureFlags'; + +authenticate(); + +describe('OneClick Apps (OCA)', () => { + beforeEach(() => { + cleanUp(['linodes']); + }); + + it('Lists all the OneClick Apps', () => { + interceptGetStackScripts().as('getStackScripts'); + interceptFeatureFlags().as('getFeatureFlags'); + + cy.visitWithLogin(`/linodes/create?type=One-Click`); + + let flags: FlagSet = {}; + cy.wait('@getFeatureFlags').then((xhr) => { + flags = xhr.response?.body ?? {}; + + cy.wait('@getStackScripts').then((xhr) => { + const stackScripts: StackScript[] = xhr.response?.body.data ?? []; + const newApps = flags['one-click-apps']?.value; + + const allowedApps = Object.keys({ + ...baseApps, + ...newApps, + }); + const filteredApps = stackScripts.filter((app) => { + return ( + !app.label.match(/helpers/i) && allowedApps.includes(String(app.id)) + ); + }); + + console.log('filteredApps TEST', filteredApps); + + cy.findByTestId('one-click-apps-container').within(() => { + filteredApps.forEach((stackScript) => { + // console.log(stackScript); + // containsVisible(stackScript.label); + }); + }); + }); + }); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/feature-flags.ts b/packages/manager/cypress/support/intercepts/feature-flags.ts index 9fdf442a267..5c256f74ad4 100644 --- a/packages/manager/cypress/support/intercepts/feature-flags.ts +++ b/packages/manager/cypress/support/intercepts/feature-flags.ts @@ -14,6 +14,13 @@ const launchDarklyUrlPattern = const launchDarklyClientstreamPattern = 'https://clientstream.launchdarkly.com/eval/*/*'; +/** + * Intercepts GET request to feature flag URL. + */ +export const interceptFeatureFlags = () => { + return cy.intercept('GET', launchDarklyUrlPattern); +}; + /** * Intercepts GET request to feature flag clientstream URL and mocks the response. * diff --git a/packages/manager/src/factories/stackscripts.ts b/packages/manager/src/factories/stackscripts.ts index b0a82603ff9..cff3dd2f045 100644 --- a/packages/manager/src/factories/stackscripts.ts +++ b/packages/manager/src/factories/stackscripts.ts @@ -1,8 +1,10 @@ -import { +import * as Factory from 'factory.ts'; + +import type { StackScript, UserDefinedField, } from '@linode/api-v4/lib/stackscripts/types'; -import * as Factory from 'factory.ts'; +import type { OCA } from 'src/features/OneClickApps/types'; export const stackScriptFactory = Factory.Sync.makeFactory({ created: '2010-12-31T23:59:58', @@ -24,6 +26,20 @@ export const stackScriptFactory = Factory.Sync.makeFactory({ username: 'Linode', }); +export const oneClickAppFactory = Factory.Sync.makeFactory({ + alt_description: 'A test app', + alt_name: 'Test App', + categories: ['App Creators'], + colors: { + end: '#000000', + start: '#000000', + }, + description: 'A test app', + logo_url: 'https://www.linode.com/media/images/logos/one-click/test.svg', + name: 'Test App', + summary: 'A test app', +}); + export const userDefinedFieldFactory = Factory.Sync.makeFactory( { label: Factory.each((i) => `Field${i}`), diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx index f1cf0dabd4c..e8ca82e253c 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -68,7 +68,7 @@ import { validatePassword } from 'src/utilities/validatePassword'; import LinodeCreate from './LinodeCreate'; import { deriveDefaultLabel } from './deriveDefaultLabel'; import { HandleSubmit, Info, LinodeCreateValidation, TypeInfo } from './types'; -import { getRegionIDFromLinodeID } from './utilities'; +import { filterOneClickApps, getRegionIDFromLinodeID } from './utilities'; import type { CreateLinodeRequest, @@ -175,13 +175,6 @@ const getDisabledClasses = (regionID: string, regions: Region[] = []) => { return disabledClasses; }; -const trimOneClickFromLabel = (script: StackScript) => { - return { - ...script, - label: script.label.replace('One-Click', ''), - }; -}; - const nonImageCreateTypes = ['fromStackScript', 'fromBackup', 'fromLinode']; const isNonDefaultImageType = (prevType: string, type: string) => { @@ -194,7 +187,6 @@ class LinodeCreateContainer extends React.PureComponent { componentDidMount() { // Allowed apps include the base set of original apps + anything LD tells us to show const newApps = this.props.flags.oneClickApps || []; - const allowedApps = Object.keys({ ...baseApps, ...newApps }); if (nonImageCreateTypes.includes(this.props.createType)) { // If we're navigating directly to e.g. the clone page, don't select an image by default this.setState({ selectedImageID: undefined }); @@ -204,17 +196,12 @@ class LinodeCreateContainer extends React.PureComponent { this.props.queryClient .fetchQuery('stackscripts-oca-all', () => getAllOCAsRequest()) .then((res: StackScript[]) => { - // Don't display One-Click Helpers to the user - // Filter out any apps that we don't have info for - const filteredApps = res.filter((script) => { - return ( - !script.label.match(/helpers/i) && - allowedApps.includes(String(script.id)) - ); + const trimmedApps = filterOneClickApps({ + baseApps, + newApps, + queryResults: res, }); - const trimmedApps = filteredApps.map((stackscript) => - trimOneClickFromLabel(stackscript) - ); + this.setState({ appInstances: trimmedApps, appInstancesLoading: false, diff --git a/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts b/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts index ccd8624bca3..6356d3cc19d 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts +++ b/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts @@ -1,14 +1,19 @@ import { extendedTypes } from 'src/__data__/ExtendedType'; import { linode1, linode2 } from 'src/__data__/linodes'; import { imageFactory, normalizeEntities } from 'src/factories'; +import { stackScriptFactory } from 'src/factories/stackscripts'; import { extendLinodes, + filterOneClickApps, formatLinodeSubheading, getMonthlyAndHourlyNodePricing, getRegionIDFromLinodeID, + trimOneClickFromLabel, } from './utilities'; +import type { StackScript } from '@linode/api-v4'; + const linodeImage = imageFactory.build({ id: 'linode/debian10', label: 'Debian 10', @@ -71,3 +76,74 @@ describe('Marketplace cluster pricing', () => { }); }); }); + +describe('trimOneClickFromLabel', () => { + const stackScript = stackScriptFactory.build({ + label: 'MongoDB Cluster One-Click', + }); + + it('should remove "One-Click" from the label', () => { + expect(trimOneClickFromLabel(stackScript)).toStrictEqual({ + ...stackScript, + label: 'MongoDB Cluster ', + }); + }); +}); + +describe('filterOneClickApps', () => { + const baseApps = { + 1: 'Base App 1', + 2: 'Base App 2', + 3: 'Base App 3', + 4: 'Base App 4', + }; + const newApps = { + 5: 'New App 1', + 6: 'New App 2', + 7: 'New App 3', + 8: 'New App 4', + }; + + const stackScript = stackScriptFactory.build(); + + // id: 1,2,3,4 + const queryResultsWithHelpers: StackScript[] = [ + ...stackScriptFactory.buildList(3), + { ...stackScript, id: 4, label: 'StackScript Helpers' }, + ]; + // id: 5,6,7,8 + const queryResultsWithoutHelpers: StackScript[] = stackScriptFactory.buildList( + 4 + ); + + it('filters OneClickApps and trims labels, excluding StackScripts with Helpers', () => { + // feeding 4 Ids (1,2,3,4) getting 3 back + const filteredOCAsWithHelpersLabel = filterOneClickApps({ + baseApps, + newApps, + queryResults: queryResultsWithHelpers, + }); + expect(filteredOCAsWithHelpersLabel.length).toBe(3); + + // feeding 4 Ids (5,6,7,8) getting 4 back + const filteredOCAsWithoutHelpersLabel = filterOneClickApps({ + baseApps, + newApps, + queryResults: queryResultsWithoutHelpers, + }); + + expect(filteredOCAsWithoutHelpersLabel.length).toBe(4); + }); + + it('handles empty queryResults', () => { + const emptyQueryResults: StackScript[] = []; + const filteredOCAs = filterOneClickApps({ + baseApps, + newApps, + queryResults: emptyQueryResults, + }); + + // Expect an empty array when queryResults is empty + expect(filteredOCAs).toEqual([]); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/utilities.tsx b/packages/manager/src/features/Linodes/LinodesCreate/utilities.tsx index e9d3027643b..855a1a1d9f3 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/utilities.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/utilities.tsx @@ -1,6 +1,3 @@ -import { Image } from '@linode/api-v4/lib/images'; -import { Linode } from '@linode/api-v4/lib/linodes'; -import { Region } from '@linode/api-v4/lib/regions'; import * as React from 'react'; import { Link } from 'src/components/Link'; @@ -10,6 +7,8 @@ import { ExtendedType } from 'src/utilities/extendType'; import { ExtendedLinode } from './types'; +import type { Image, Linode, Region, StackScript } from '@linode/api-v4/lib'; + /** * adds a heading and subheading key to the Linode */ @@ -120,3 +119,46 @@ export const regionSupportsMetadata = ( ?.capabilities.includes('Metadata') ?? false ); }; + +/** + * This function is used to remove the "One-Click" text from the label of an OCA StackScript. + * @param stackScript + * @returns StackScript + */ +export const trimOneClickFromLabel = (stackScript: StackScript) => { + return { + ...stackScript, + label: stackScript.label.replace('One-Click', ''), + }; +}; + +interface FilteredOCAs { + baseApps: Record; + newApps: Record | never[]; + queryResults: StackScript[]; +} + +/** + * This function is used to filter StackScripts OCAs. + * @param baseApps // The base apps that are always displayed (static) + * @param newApps // The new apps defined in the OneClickApps feature flag + * @param queryResults // The results of the query for StackScripts + * @returns an array of OCA StackScripts + */ +export const filterOneClickApps = ({ + baseApps, + newApps, + queryResults, +}: FilteredOCAs) => { + const allowedApps = Object.keys({ ...baseApps, ...newApps }); + // Don't display One-Click Helpers to the user + // Filter out any apps that we don't have info for + const filteredApps: StackScript[] = queryResults.filter( + (app: StackScript) => { + return ( + !app.label.match(/helpers/i) && allowedApps.includes(String(app.id)) + ); + } + ); + return filteredApps.map((app) => trimOneClickFromLabel(app)); +}; diff --git a/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx b/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx index 0cca2968c6c..677218e07f3 100644 --- a/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx +++ b/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx @@ -12,7 +12,7 @@ import { useFlags } from 'src/hooks/useFlags'; import { sanitizeHTML } from 'src/utilities/sanitize-html'; import { oneClickApps } from './oneClickApps'; -import { mapStackScriptLabelToOneClickAppName } from './utils'; +import { mapStackScriptLabelToOCA } from './utils'; import type { OCA } from './types'; @@ -94,7 +94,7 @@ export const AppDetailDrawer: React.FunctionComponent = (props) => { }; React.useEffect(() => { - const app = mapStackScriptLabelToOneClickAppName({ + const app = mapStackScriptLabelToOCA({ oneClickApps, stackScriptLabel, }); diff --git a/packages/manager/src/features/OneClickApps/utils.test.ts b/packages/manager/src/features/OneClickApps/utils.test.ts new file mode 100644 index 00000000000..5f75625008e --- /dev/null +++ b/packages/manager/src/features/OneClickApps/utils.test.ts @@ -0,0 +1,38 @@ +import { oneClickAppFactory } from 'src/factories/stackscripts'; + +import { mapStackScriptLabelToOCA } from './utils'; + +describe('mapStackScriptLabelToOneClickAppName', () => { + const onClickApp = oneClickAppFactory.build(); + + it('should return undefined if no match is found', () => { + const result = mapStackScriptLabelToOCA({ + oneClickApps: [], + stackScriptLabel: '', + }); + + expect(result).toBeUndefined(); + }); + + it('should return the matching app', () => { + const result = mapStackScriptLabelToOCA({ + oneClickApps: [onClickApp], + stackScriptLabel: 'TestApp', + }); + + expect(result).toBeDefined(); + }); + + it('should return the matching app when the StackScript label contains unexpected characters', () => { + const onClickAppWithUnexpectedCharacters = oneClickAppFactory.build({ + name: 'Test @App ®', + }); + + const result = mapStackScriptLabelToOCA({ + oneClickApps: [onClickAppWithUnexpectedCharacters], + stackScriptLabel: 'TestApp', + }); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/manager/src/features/OneClickApps/utils.ts b/packages/manager/src/features/OneClickApps/utils.ts index ba0da601ee6..8a4c013c0f3 100644 --- a/packages/manager/src/features/OneClickApps/utils.ts +++ b/packages/manager/src/features/OneClickApps/utils.ts @@ -11,7 +11,7 @@ interface Options { * @param stackScriptLabel * @returns */ -export const mapStackScriptLabelToOneClickAppName = ({ +export const mapStackScriptLabelToOCA = ({ oneClickApps, stackScriptLabel, }: Options): OCA | undefined => { From e3699d05cf00be69c2cd8d385ed689a459d78d65 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Fri, 22 Sep 2023 09:24:07 -0400 Subject: [PATCH 03/16] Asserting apps --- .../core/oneClickApps/one-click-apps.spec.ts | 25 ++++++++++--------- .../Linodes/LinodesCreate/AppPanelSection.tsx | 14 +++-------- .../Linodes/LinodesCreate/SelectAppPanel.tsx | 5 +++- .../Linodes/LinodesCreate/utilities.tsx | 23 +++++++++++++++++ 4 files changed, 43 insertions(+), 24 deletions(-) diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index abf4633f491..830610f53d7 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -14,6 +14,10 @@ import { createLinodeAndGetConfig, createAndBootLinode, } from 'support/util/linode-utils'; +import { + filterOneClickApps, + handleAppLabel, +} from '../../../../src/features/Linodes/LinodesCreate/utilities'; import { interceptFeatureFlags } from 'support/intercepts/feature-flags'; import { baseApps } from '../../../../src/features/StackScripts/stackScriptUtils'; @@ -42,22 +46,19 @@ describe('OneClick Apps (OCA)', () => { const stackScripts: StackScript[] = xhr.response?.body.data ?? []; const newApps = flags['one-click-apps']?.value; - const allowedApps = Object.keys({ - ...baseApps, - ...newApps, - }); - const filteredApps = stackScripts.filter((app) => { - return ( - !app.label.match(/helpers/i) && allowedApps.includes(String(app.id)) - ); + const trimmedApps = filterOneClickApps({ + baseApps, + newApps, + queryResults: stackScripts, }); - console.log('filteredApps TEST', filteredApps); + console.log('trimmedApps', trimmedApps); cy.findByTestId('one-click-apps-container').within(() => { - filteredApps.forEach((stackScript) => { - // console.log(stackScript); - // containsVisible(stackScript.label); + trimmedApps.forEach((stackScript) => { + const { label } = handleAppLabel(stackScript); + + cy.get(`[data-qa-select-card-heading="${label}"]`).should('exist'); }); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/AppPanelSection.tsx b/packages/manager/src/features/Linodes/LinodesCreate/AppPanelSection.tsx index 1aca1c9f462..05d4a923fb4 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/AppPanelSection.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/AppPanelSection.tsx @@ -1,7 +1,6 @@ import { StackScript, UserDefinedField } from '@linode/api-v4/lib/stackscripts'; import Grid from '@mui/material/Unstable_Grid2'; import { styled } from '@mui/material/styles'; -import { decode } from 'he'; import * as React from 'react'; import { Chip } from 'src/components/Chip'; @@ -9,6 +8,8 @@ import { Divider } from 'src/components/Divider'; import { Typography } from 'src/components/Typography'; import { SelectionCardWrapper } from 'src/features/Linodes/LinodesCreate/SelectionCardWrapper'; +import { handleAppLabel } from './utilities'; + interface Props { apps: StackScript[]; disabled: boolean; @@ -49,16 +50,7 @@ export const AppPanelSection = (props: Props) => { {apps.length > 0 ? ( {apps.map((eachApp) => { - const decodedLabel = decode(eachApp.label); - const isCluster = - decodedLabel.endsWith('Cluster ') && - eachApp.user_defined_fields.some( - (field) => field.name === 'cluster_size' - ); - - const label = isCluster - ? decodedLabel.split(' Cluster')[0] - : decodedLabel; + const { decodedLabel, isCluster, label } = handleAppLabel(eachApp); return ( { const isFilteringOrSearching = isFiltering || isSearching; return ( - + {error && } {!isFilteringOrSearching ? ( trimOneClickFromLabel(app)); }; + +/** + * This function is used to + * - decode the label of a StackScript + * - remove the "Cluster" text from the label of a StackScript since it'll turn into a chip. + * @param app // The StackScript + * @returns the decoded label of the StackScript + */ +export const handleAppLabel = (app: StackScript) => { + const decodedLabel = decode(app.label); + const isCluster = + decodedLabel.endsWith('Cluster ') && + app.user_defined_fields.some((field) => field.name === 'cluster_size'); + + const label = isCluster ? decodedLabel.split(' Cluster')[0] : decodedLabel; + + return { + decodedLabel, + isCluster, + label, + }; +}; From 68a889f6c18409ed7daba7d87e2ac2e146d0c632 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Fri, 22 Sep 2023 11:25:00 -0400 Subject: [PATCH 04/16] Refactor hard coded new apps --- .../e2e/core/oneClickApps/one-click-apps.spec.ts | 2 +- .../Linodes/LinodesCreate/LinodeCreate.tsx | 6 ++++-- .../Linodes/LinodesCreate/SelectAppPanel.tsx | 14 ++++++++------ .../TabbedContent/FromAppsContent.tsx | 4 ++++ 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index 830610f53d7..9900010c532 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -32,7 +32,7 @@ describe('OneClick Apps (OCA)', () => { cleanUp(['linodes']); }); - it('Lists all the OneClick Apps', () => { + it('Lists all the OneClick Apps, by category, and ensure their respective drawer open', () => { interceptGetStackScripts().as('getStackScripts'); interceptFeatureFlags().as('getFeatureFlags'); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index be139873b82..8dba674a1e7 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -32,11 +32,11 @@ import { WithTypesProps } from 'src/containers/types.container'; import { FeatureFlagConsumerProps } from 'src/containers/withFeatureFlagConsumer.container'; import { WithLinodesProps } from 'src/containers/withLinodes.container'; import EUAgreementCheckbox from 'src/features/Account/Agreements/EUAgreementCheckbox'; -import { regionSupportsMetadata } from 'src/features/Linodes/LinodesCreate/utilities'; import { getMonthlyAndHourlyNodePricing, utoa, } from 'src/features/Linodes/LinodesCreate/utilities'; +import { regionSupportsMetadata } from 'src/features/Linodes/LinodesCreate/utilities'; import { SMTPRestrictionText } from 'src/features/Linodes/SMTPRestrictionText'; import { getCommunityStackscripts, @@ -56,6 +56,7 @@ import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature import { getErrorMap } from 'src/utilities/errorUtils'; import { extendType } from 'src/utilities/extendType'; import { filterCurrentTypes } from 'src/utilities/filterCurrentLinodeTypes'; +import { getMonthlyBackupsPrice } from 'src/utilities/pricing/backups'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; import { AddonsPanel } from './AddonsPanel'; @@ -88,7 +89,6 @@ import { } from './types'; import type { Tab } from 'src/components/TabLinkList/TabLinkList'; -import { getMonthlyBackupsPrice } from 'src/utilities/pricing/backups'; export interface LinodeCreateProps { checkValidation: LinodeCreateValidation; @@ -220,6 +220,7 @@ export class LinodeCreate extends React.PureComponent< const { accountBackupsEnabled, errors, + flags, formIsSubmitting, handleAgreementChange, handleShowApiAwarenessModal, @@ -404,6 +405,7 @@ export class LinodeCreate extends React.PureComponent< // error={hasErrorFor.image} accountBackupsEnabled={accountBackupsEnabled} errors={errors} + flags={flags} imagesData={imagesData!} regionsData={regionsData!} typesData={typesData!} diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectAppPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectAppPanel.tsx index 6c829e832d3..f7dad5c3cf9 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectAppPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectAppPanel.tsx @@ -1,4 +1,3 @@ -import { UserDefinedField } from '@linode/api-v4/lib/stackscripts'; import { Theme, styled } from '@mui/material/styles'; import * as React from 'react'; import { compose } from 'recompose'; @@ -11,11 +10,15 @@ import { AppPanelSection } from 'src/features/Linodes/LinodesCreate/AppPanelSect import { getQueryParamFromQueryString } from 'src/utilities/queryParams'; import { Panel } from './Panel'; -import { AppsData } from './types'; + +import type { AppsData } from './types'; +import type { UserDefinedField } from '@linode/api-v4/lib/stackscripts'; +import type { FlagSet } from 'src/featureFlags'; interface Props extends AppsData { disabled: boolean; error?: string; + flags: FlagSet; handleClick: ( id: number, label: string, @@ -51,6 +54,7 @@ class SelectAppPanel extends React.PureComponent { appInstancesLoading, disabled, error, + flags, handleClick, isFiltering, isSearching, @@ -81,11 +85,9 @@ class SelectAppPanel extends React.PureComponent { return null; } + const newAppsIds = Object.keys(flags.oneClickApps || {}); const newApps = appInstances.filter((app) => { - return [ - 'hashicorp nomad clients cluster', - 'hashicorp nomad cluster', - ].includes(app.label.toLowerCase().trim()); + return newAppsIds.includes(app.id.toString()); }); const popularApps = appInstances.slice(0, 10); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx index a4c5ca37262..568e207e430 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx @@ -30,6 +30,7 @@ import { import { StyledGrid } from './CommonTabbedContent.styles'; import { filterUDFErrors } from './formUtilities'; +import type { FlagSet } from 'src/featureFlags'; import type { AppCategory } from 'src/features/OneClickApps/types'; const appCategories = [ @@ -64,6 +65,7 @@ const errorResources = { }; interface Props { + flags: FlagSet; setNumberOfNodesForAppCluster: (num: number) => void; } @@ -143,6 +145,7 @@ export class FromAppsContent extends React.Component { availableStackScriptImages: compatibleImages, availableUserDefinedFields: userDefinedFields, errors, + flags, selectedImageID, selectedStackScriptID, selectedStackScriptLabel, @@ -238,6 +241,7 @@ export class FromAppsContent extends React.Component { appInstancesLoading={appInstancesLoading} disabled={userCannotCreateLinode} error={hasErrorFor('stackscript_id')} + flags={flags} handleClick={handleSelectStackScript} isFiltering={isFiltering} isSearching={isSearching} From a4d75c75bcdcdc12a24e220eb3fba098cae31d10 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Fri, 22 Sep 2023 14:37:35 -0400 Subject: [PATCH 05/16] Assert matching drawers --- .../core/oneClickApps/one-click-apps.spec.ts | 20 +++++++---- .../Linodes/LinodesCreate/utilities.test.ts | 33 +++++++++++++++++++ .../src/features/OneClickApps/utils.ts | 2 +- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index 9900010c532..719789755fd 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -19,11 +19,12 @@ import { handleAppLabel, } from '../../../../src/features/Linodes/LinodesCreate/utilities'; import { interceptFeatureFlags } from 'support/intercepts/feature-flags'; +import { mapStackScriptLabelToOCA } from '../../../../src/features/OneClickApps/utils'; import { baseApps } from '../../../../src/features/StackScripts/stackScriptUtils'; - +import { oneClickApps } from '../../../../src/features/OneClickApps/OneClickApps'; import type { Config, Linode, StackScript } from '@linode/api-v4'; import { OCA } from '@src/features/OneClickApps/types'; -import { FlagSet } from '@src/featureFlags'; +import type { FlagSet } from '@src/featureFlags'; authenticate(); @@ -46,19 +47,26 @@ describe('OneClick Apps (OCA)', () => { const stackScripts: StackScript[] = xhr.response?.body.data ?? []; const newApps = flags['one-click-apps']?.value; - const trimmedApps = filterOneClickApps({ + const trimmedApps: StackScript[] = filterOneClickApps({ baseApps, newApps, queryResults: stackScripts, }); - console.log('trimmedApps', trimmedApps); - cy.findByTestId('one-click-apps-container').within(() => { trimmedApps.forEach((stackScript) => { - const { label } = handleAppLabel(stackScript); + const { decodedLabel, label } = handleAppLabel(stackScript); + // Check that every OCA is listed with the correct label cy.get(`[data-qa-select-card-heading="${label}"]`).should('exist'); + + // Check that every OCA has a drawer match + expect( + mapStackScriptLabelToOCA({ + oneClickApps, + stackScriptLabel: decodedLabel, + }) + ).to.not.be.undefined; }); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts b/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts index 6356d3cc19d..2e0a0b2e75b 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts +++ b/packages/manager/src/features/Linodes/LinodesCreate/utilities.test.ts @@ -9,6 +9,7 @@ import { formatLinodeSubheading, getMonthlyAndHourlyNodePricing, getRegionIDFromLinodeID, + handleAppLabel, trimOneClickFromLabel, } from './utilities'; @@ -147,3 +148,35 @@ describe('filterOneClickApps', () => { expect(filteredOCAs).toEqual([]); }); }); + +describe('handleAppLabel', () => { + jest.mock('he', () => ({ + decode: jest.fn(), + })); + + it('should decode the label and remove "Cluster" when cluster_size is present', () => { + const stackScript = stackScriptFactory.build({ + label: 'My StackScript Cluster ', + user_defined_fields: [{ name: 'cluster_size' }], + }); + + const result = handleAppLabel(stackScript); + + expect(result.decodedLabel).toBe('My StackScript Cluster '); + expect(result.isCluster).toBe(true); + expect(result.label).toBe('My StackScript'); + }); + + it('should decode the label without removing "Cluster" when cluster_size is not present', () => { + const stackScript = stackScriptFactory.build({ + label: 'My StackScript® Cluster ', + user_defined_fields: [], + }); + + const result = handleAppLabel(stackScript); + + expect(result.decodedLabel).toBe('My StackScript® Cluster '); + expect(result.isCluster).toBe(false); + expect(result.label).toBe('My StackScript® Cluster '); + }); +}); diff --git a/packages/manager/src/features/OneClickApps/utils.ts b/packages/manager/src/features/OneClickApps/utils.ts index 8a4c013c0f3..41d7da3b819 100644 --- a/packages/manager/src/features/OneClickApps/utils.ts +++ b/packages/manager/src/features/OneClickApps/utils.ts @@ -9,7 +9,7 @@ interface Options { * Given a StackScript label, return the corresponding One-Click App name * @param oneClickApps * @param stackScriptLabel - * @returns + * @returns {string} */ export const mapStackScriptLabelToOCA = ({ oneClickApps, From fd058c0d4b8340addd9b0c95000173f95e0bddea Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Fri, 22 Sep 2023 16:13:36 -0400 Subject: [PATCH 06/16] Assert drawer content --- .../core/oneClickApps/one-click-apps.spec.ts | 23 ++++++++++++++++++- .../LinodesCreate/SelectionCardWrapper.tsx | 1 + .../features/OneClickApps/AppDetailDrawer.tsx | 4 ++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index 719789755fd..d4aa7986d93 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -29,7 +29,7 @@ import type { FlagSet } from '@src/featureFlags'; authenticate(); describe('OneClick Apps (OCA)', () => { - beforeEach(() => { + before(() => { cleanUp(['linodes']); }); @@ -69,6 +69,27 @@ describe('OneClick Apps (OCA)', () => { ).to.not.be.undefined; }); }); + + // Check one of the OCA drawers + const candidate = trimmedApps[0].label; + const stackScriptCandidate = cy + .get(`[data-qa-selection-card-info="${candidate}"]`) + .first(); + stackScriptCandidate.should('exist').click(); + + const app: OCA | undefined = mapStackScriptLabelToOCA({ + oneClickApps, + stackScriptLabel: candidate, + }); + + ui.drawer + .findByTitle(trimmedApps[0].label) + .should('be.visible') + .within(() => { + containsVisible(app?.description); + containsVisible(app?.summary); + containsVisible(app?.website); + }); }); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectionCardWrapper.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectionCardWrapper.tsx index a7132c7f0b6..eb659144193 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectionCardWrapper.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectionCardWrapper.tsx @@ -77,6 +77,7 @@ export const SelectionCardWrapper = (props: Props) => { = (props) => { @@ -149,6 +152,7 @@ export const AppDetailDrawer: React.FunctionComponent = (props) => { __html: sanitizeHTML(selectedApp.name), }} className={classes.appName} + data-qa-drawer-title={stackScriptLabel} data-testid="app-name" variant="h2" /> From 99b44a1e55dc0d82d4f960eeb3af79a1cd7ad0b9 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Mon, 25 Sep 2023 09:19:04 -0400 Subject: [PATCH 07/16] Save work --- .../e2e/core/oneClickApps/one-click-apps.spec.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index d4aa7986d93..663e6236217 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -53,6 +53,7 @@ describe('OneClick Apps (OCA)', () => { queryResults: stackScripts, }); + // Check the content of the OCA listing cy.findByTestId('one-click-apps-container').within(() => { trimmedApps.forEach((stackScript) => { const { decodedLabel, label } = handleAppLabel(stackScript); @@ -61,6 +62,9 @@ describe('OneClick Apps (OCA)', () => { cy.get(`[data-qa-select-card-heading="${label}"]`).should('exist'); // Check that every OCA has a drawer match + // This validates the regex in `mapStackScriptLabelToOCA` + // and ensures every app listed has a corresponding populated drawer + // This is only true for the apps defined in `oneClickApps.ts` expect( mapStackScriptLabelToOCA({ oneClickApps, @@ -70,7 +74,7 @@ describe('OneClick Apps (OCA)', () => { }); }); - // Check one of the OCA drawers + // Check drawer content for one OCA candidate const candidate = trimmedApps[0].label; const stackScriptCandidate = cy .get(`[data-qa-selection-card-info="${candidate}"]`) @@ -90,6 +94,10 @@ describe('OneClick Apps (OCA)', () => { containsVisible(app?.summary); containsVisible(app?.website); }); + ui.drawerCloseButton.find().click(); + ui.drawer.find().should('not.exist'); + + // Check the filtering of the apps }); }); }); From 30ae9f6623050cd6e53e793006ffc1e36418756d Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Mon, 25 Sep 2023 12:04:40 -0400 Subject: [PATCH 08/16] Assert filtering --- .../e2e/core/oneClickApps/one-click-apps.spec.ts | 14 ++++++++++++++ .../src/features/OneClickApps/AppDetailDrawer.tsx | 1 + 2 files changed, 15 insertions(+) diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index 663e6236217..ff7a1f93548 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -98,6 +98,20 @@ describe('OneClick Apps (OCA)', () => { ui.drawer.find().should('not.exist'); // Check the filtering of the apps + cy.scrollTo(0, 0); + const initialNumberOfApps = trimmedApps.length; + cy.findByPlaceholderText('Search for app name') + .should('exist') + .type(candidate); + cy.findByTestId('one-click-apps-container').within(() => { + cy.get('[data-qa-selection-card="true"]').should( + 'have.length.below', + initialNumberOfApps + ); + cy.get(`[data-qa-selection-card-info="${candidate}"]`).should( + 'be.visible' + ); + }); }); }); }); diff --git a/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx b/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx index f0a1141b127..c3b0642edb3 100644 --- a/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx +++ b/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx @@ -124,6 +124,7 @@ export const AppDetailDrawer: React.FunctionComponent = (props) => {