diff --git a/package.json b/package.json index d28c4b37ef0..875e610b018 100644 --- a/package.json +++ b/package.json @@ -70,4 +70,4 @@ "node": "18.14.1" }, "dependencies": {} -} +} \ No newline at end of file diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index c9711ef28bb..03da879534f 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,12 @@ +## [2023-11-13] - v0.105.0 + + +### Upcoming Features: + +- Add `UpdateConfigurationPayload` ([#9853](https://github.com/linode/manager/pull/9853)) +- Add `getAccountAvailabilities` and `getAccountAvailability` methods for DC Get Well initiative ([#9860](https://github.com/linode/manager/pull/9860)) +- Add `getRegionAvailabilities` and `getRegionAvailability` endpoints and related types for Sold Out Plans initiative ([#9878](https://github.com/linode/manager/pull/9878)) + ## [2023-10-30] - v0.104.0 ### Upcoming Features: diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 20411c535ef..40b7618db27 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.104.0", + "version": "0.105.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/api-v4/src/account/account.ts b/packages/api-v4/src/account/account.ts index a606bb8c141..fb585984394 100644 --- a/packages/api-v4/src/account/account.ts +++ b/packages/api-v4/src/account/account.ts @@ -3,15 +3,23 @@ import { UpdateAccountSettingsSchema, } from '@linode/validation/lib/account.schema'; import { API_ROOT, BETA_API_ROOT } from '../constants'; -import Request, { setData, setMethod, setURL } from '../request'; +import Request, { + setData, + setMethod, + setURL, + setParams, + setXFilter, +} from '../request'; import { Account, + AccountAvailability, AccountSettings, CancelAccount, CancelAccountPayload, Agreements, RegionalNetworkUtilization, } from './types'; +import { Filter, ResourcePage as Page, Params } from '../types'; /** * getAccountInfo @@ -99,6 +107,34 @@ export const getAccountAgreements = () => setMethod('GET') ); +/** + * getAccountAvailabilities + * + * Gets the account's entity availability for each region. Specifically + * tells which entities the account does not have capability for in each region. + * + */ +export const getAccountAvailabilities = (params?: Params, filter?: Filter) => + Request>( + setURL(`${API_ROOT}/account/availability`), + setMethod('GET'), + setParams(params), + setXFilter(filter) + ); + +/** + * getAccountAvailability + * + * Gets the account's entity availability for given region. Specifically + * tells which entities the account does not have capability for in given region. + * + */ +export const getAccountAvailability = (regionId: string) => + Request( + setURL(`${API_ROOT}/account/availability/${encodeURIComponent(regionId)}`), + setMethod('GET') + ); + /** * signAgreement * diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index e3ab5fdf759..e691e362903 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -1,5 +1,5 @@ import { APIWarning } from '../types'; -import type { Region } from '../regions'; +import type { Capabilities, Region } from '../regions'; export interface User { username: string; @@ -69,6 +69,11 @@ export type AccountCapability = | 'Vlans' | 'VPCs'; +export interface AccountAvailability { + id: string; // will be ID of region + unavailable: Capabilities[]; +} + export interface AccountSettings { managed: boolean; longview_subscription: string | null; diff --git a/packages/api-v4/src/aglb/configurations.ts b/packages/api-v4/src/aglb/configurations.ts index dd647471946..e98a31e6967 100644 --- a/packages/api-v4/src/aglb/configurations.ts +++ b/packages/api-v4/src/aglb/configurations.ts @@ -7,7 +7,12 @@ import Request, { } from '../request'; import { Filter, Params, ResourcePage } from '../types'; import { BETA_API_ROOT } from '../constants'; -import type { Configuration, ConfigurationPayload } from './types'; +import type { + Configuration, + ConfigurationPayload, + UpdateConfigurationPayload, +} from './types'; +import { UpdateConfigurationSchema } from '@linode/validation'; /** * getLoadbalancerConfigurations @@ -75,7 +80,7 @@ export const createLoadbalancerConfiguration = ( export const updateLoadbalancerConfiguration = ( loadbalancerId: number, configurationId: number, - data: Partial + data: UpdateConfigurationPayload ) => Request( setURL( @@ -83,7 +88,7 @@ export const updateLoadbalancerConfiguration = ( loadbalancerId )}/configurations/${encodeURIComponent(configurationId)}` ), - setData(data), + setData(data, UpdateConfigurationSchema), setMethod('PUT') ); diff --git a/packages/api-v4/src/aglb/loadbalancers.ts b/packages/api-v4/src/aglb/loadbalancers.ts index fc7446a3a9f..3182985d647 100644 --- a/packages/api-v4/src/aglb/loadbalancers.ts +++ b/packages/api-v4/src/aglb/loadbalancers.ts @@ -8,10 +8,12 @@ import Request, { import { BETA_API_ROOT } from '../constants'; import { Filter, Params, ResourcePage } from '../types'; import type { + CreateBasicLoadbalancerPayload, CreateLoadbalancerPayload, Loadbalancer, UpdateLoadbalancerPayload, } from './types'; +import { CreateBasicLoadbalancerSchema } from '@linode/validation'; /** * getLoadbalancers @@ -49,6 +51,18 @@ export const createLoadbalancer = (data: CreateLoadbalancerPayload) => setMethod('POST') ); +/** + * createBasicLoadbalancer + * + * Creates an unconfigured Akamai Global Load Balancer + */ +export const createBasicLoadbalancer = (data: CreateBasicLoadbalancerPayload) => + Request( + setURL(`${BETA_API_ROOT}/aglb`), + setData(data, CreateBasicLoadbalancerSchema), + setMethod('POST') + ); + /** * updateLoadbalancer * diff --git a/packages/api-v4/src/aglb/types.ts b/packages/api-v4/src/aglb/types.ts index 156685e9d68..525678e92f0 100644 --- a/packages/api-v4/src/aglb/types.ts +++ b/packages/api-v4/src/aglb/types.ts @@ -17,6 +17,13 @@ export interface CreateLoadbalancerPayload { configurations?: ConfigurationPayload[]; } +/** + * TODO: AGLB - remove when we move to full creation flow + */ +export interface CreateBasicLoadbalancerPayload { + label: string; +} + export interface UpdateLoadbalancerPayload { label?: string; regions?: string[]; @@ -104,6 +111,14 @@ export interface Configuration { routes: { id: number; label: string }[]; } +export type UpdateConfigurationPayload = Partial<{ + label: string; + port: number; + protocol: Protocol; + certificates: CertificateConfig[]; + routes: number[]; +}>; + export interface CertificateConfig { hostname: string; id: number; diff --git a/packages/api-v4/src/regions/regions.ts b/packages/api-v4/src/regions/regions.ts index a2aaa0a0dd4..736e0623d34 100644 --- a/packages/api-v4/src/regions/regions.ts +++ b/packages/api-v4/src/regions/regions.ts @@ -1,7 +1,7 @@ import { API_ROOT } from '../constants'; -import Request, { setMethod, setParams, setURL } from '../request'; -import { Params, ResourcePage as Page } from '../types'; -import { Region } from './types'; +import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; +import { Filter, Params, ResourcePage as Page } from '../types'; +import { Region, RegionAvailability } from './types'; /** * getRegions @@ -27,13 +27,39 @@ export const getRegions = (params?: Params) => * * Return detailed information about a particular region. * - * @param regionID { string } The region to be retrieved. + * @param regionId { string } The region to be retrieved. * */ -export const getRegion = (regionID: string) => +export const getRegion = (regionId: string) => Request( - setURL(`${API_ROOT}/regions/${encodeURIComponent(regionID)}`), + setURL(`${API_ROOT}/regions/${encodeURIComponent(regionId)}`), setMethod('GET') ); export { Region }; + +/** + * getRegionAvailabilities + * + * Returns the availability status for all Linode plans for all regions. + */ +export const getRegionAvailabilities = (params?: Params, filter?: Filter) => + Request>( + setURL(`${API_ROOT}/regions/availability`), + setMethod('GET'), + setParams(params), + setXFilter(filter) + ); + +/** + * getRegionAvailability + * + * Return the availability status of Linode plans for the given region. + * + * @param regionId { string } The region to get the availabilities for + */ +export const getRegionAvailability = (regionId: string) => + Request( + setURL(`${API_ROOT}/regions/${encodeURIComponent(regionId)}/availability`), + setMethod('GET') + ); diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index e9bab3df5bf..b5893bce3a0 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -28,3 +28,9 @@ export interface Region { status: RegionStatus; resolvers: DNSResolvers; } + +export interface RegionAvailability { + available: boolean; + plan: string; + region: string; +} diff --git a/packages/manager/.eslintrc.js b/packages/manager/.eslintrc.js index 340efd3f998..1aefa4734d9 100644 --- a/packages/manager/.eslintrc.js +++ b/packages/manager/.eslintrc.js @@ -109,8 +109,10 @@ module.exports = { 'scanjs-rules', 'xss', 'perfectionist', + '@linode/eslint-plugin-cloud-manager', ], rules: { + '@linode/cloud-manager/no-custom-fontWeight': 'error', '@typescript-eslint/camelcase': 'off', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', @@ -126,8 +128,8 @@ module.exports = { '@typescript-eslint/no-use-before-define': 'off', 'array-callback-return': 'error', 'comma-dangle': 'off', // Prettier and TS both handle and check for this one - curly: 'warn', // radix: Codacy considers it as an error, i put it here to fix it before push + curly: 'warn', // See: https://www.w3.org/TR/graphics-aria-1.0/ 'jsx-a11y/aria-role': [ 'error', diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index d0af3479fe5..88f79fb213b 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,65 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2023-11-13] - v1.107.0 + + +### Changed: + +- Logic governing inclusion of public interfaces in Linode Create payload ([#9834](https://github.com/linode/manager/pull/9834)) +- Improve layout of breadcrumb for support tickets ([#9855](https://github.com/linode/manager/pull/9855)) +- Logic governing display of Network Interfaces/Networking section in Linode Config dialog ([#9868](https://github.com/linode/manager/pull/9868)) +- Temporarily remove region sorting on DBaaS landing page ([#9861](https://github.com/linode/manager/pull/9861)) + + +### Fixed: + +- Linodes Landing flickering ([#9836](https://github.com/linode/manager/pull/9836)) +- Faux-bold font rendering ([#9843](https://github.com/linode/manager/pull/9843)) +- Incorrect docs links for Main Concept and Simplex Marketplace apps ([#9854](https://github.com/linode/manager/pull/9854)) +- Select Backup grid layout ([#9862](https://github.com/linode/manager/pull/9862)) + +### Tech Stories: + +- `Tag` Component v7 story migration ([#9840](https://github.com/linode/manager/pull/9840)) +- `BetaChip` Component v7 story migration ([#9864](https://github.com/linode/manager/pull/9864)) +- MUI Migration - `SRC > Components > Crumbs` ([#9841](https://github.com/linode/manager/pull/9841)) +- Clean up app entrypoint render logic ([#9844](https://github.com/linode/manager/pull/9844)) +- Fix Safari LaunchDarkly MSW Errors ([#9863](https://github.com/linode/manager/pull/9863)) + +### Tests: + +- Add DBaaS test coverage for disk metrics ([#9833](https://github.com/linode/manager/pull/9833)) +- Improve Cypress rescue and rebuild test stability ([#9867](https://github.com/linode/manager/pull/9867)) +- Upgrade Cypress to v13.x ([#9874](https://github.com/linode/manager/pull/9874)) +- Add integration tests for AGLB certificate edit flow ([#9880](https://github.com/linode/manager/pull/9880)) +- Add integration tests for AGLB certificate delete flow ([#9846](https://github.com/linode/manager/pull/9846)) + + +### Upcoming Features: + +- Fix Unassign multiple Linodes from Subnet ([#9820](https://github.com/linode/manager/pull/9820)) +- `RemovableSelectionsList` default maximum height and overflow scroll ([#9827](https://github.com/linode/manager/pull/9827)) +- VPC UX feedback ([#9832](https://github.com/linode/manager/pull/9832)) +- Remove temporary code for surfacing VPC interface errors and fix formatting of error in Linode Config dialog ([#9839](https://github.com/linode/manager/pull/9839)) +- Refine payload in subnet "Assign Linodes" drawer ([#9845](https://github.com/linode/manager/pull/9845)) +- Add Create VPC drawer to Linode Create flow and update Create Firewall button width ([#9847](https://github.com/linode/manager/pull/9847)) +- Only unassign linodes in the 'Linodes to be Unassigned from Subnet' list for Subnet Unassign Drawer ([#9851](https://github.com/linode/manager/pull/9851)) +- Clear subnet errors in Linode Create flow and VPC label errors in VPC Edit flow upon input change ([#9857](https://github.com/linode/manager/pull/9857)) +- Fix IPv4 checkboxes for VPC interfaces in Linode Config dialog ([#9865](https://github.com/linode/manager/pull/9865)) +- Fix incorrectly displayed error text in Linode Edit/Add config flow and prevent subnet section from incorrectly clearing in Linode Edit/Add Config and Linode Create flow ([#9866](https://github.com/linode/manager/pull/9866)) +- Linode Details: VPC Subnets Not Associated with VPC IP Address Are Displayed ([#9872](https://github.com/linode/manager/pull/9872)) +- Add VPC BETA Feedback link to VPC landing and detail pages ([#9879](https://github.com/linode/manager/pull/9879)) +- Add `dcGetWell` feature flag ([#9859](https://github.com/linode/manager/pull/9859)) +- Add RQ queries and mock data for DC Get Well ([#9860](https://github.com/linode/manager/pull/9860)) +- Add RQ queries and mock data for Sold Out Plans ([#9878](https://github.com/linode/manager/pull/9878)) +- Add basic AGLB create page and feature flag ([#9856](https://github.com/linode/manager/pull/9856)) +- Add AGLB create page with Actions buttons ([#9825](https://github.com/linode/manager/pull/9825)) +- Manage state in Create Load Balancer flow ([#9848](https://github.com/linode/manager/pull/9848)) +- AGLB Configurations Add Route Drawer and other refinements ([#9853](https://github.com/linode/manager/pull/9853)) +- Add missing label field validation in AGLB Edit Certificate drawer ([#9880](https://github.com/linode/manager/pull/9880)) + + ## [2023-10-30] - v1.106.0 ### Added: diff --git a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts index 2aaee4bd0d7..a2b15006d23 100644 --- a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts @@ -186,6 +186,12 @@ describe('Update database clusters', () => { cy.get('[data-qa-cluster-config]').within(() => { cy.findByText(configuration.region.label).should('be.visible'); + cy.findByText(database.used_disk_size_gb + ' GB').should( + 'be.visible' + ); + cy.findByText(database.total_disk_size_gb + ' GB').should( + 'be.visible' + ); }); cy.get('[data-qa-connection-details]').within(() => { diff --git a/packages/manager/cypress/e2e/core/general/account-activation.spec.ts b/packages/manager/cypress/e2e/core/general/account-activation.spec.ts new file mode 100644 index 00000000000..c6ab1764182 --- /dev/null +++ b/packages/manager/cypress/e2e/core/general/account-activation.spec.ts @@ -0,0 +1,27 @@ +import { apiMatcher } from 'support/util/intercepts'; + +describe('account activation', () => { + /** + * The API will return 403 with the body below for most endpoint except `/v4/profile`. + * + * { "errors": [ { "reason": "Your account must be activated before you can use this endpoint" } ] } + */ + it('should render an activation landing page if the customer is not activated', () => { + cy.intercept('GET', apiMatcher('*'), { + statusCode: 403, + body: { + errors: [ + { + reason: + 'Your account must be activated before you can use this endpoint', + }, + ], + }, + }); + + cy.visitWithLogin('/'); + + cy.findByText('Your account is currently being reviewed.'); + cy.findByText('open a support ticket', { exact: false }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index d44049ef2aa..81cc21759b2 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -134,6 +134,7 @@ describe('create linode', () => { * - Confirms DC-specific pricing UI flow works as expected during Linode creation. * - Confirms that pricing notice is shown in "Region" section. * - Confirms that notice is shown when selecting a region with a different price structure. + * - Confirms that backups pricing is correct when selecting a region with a different price structure. */ it('shows DC-specific pricing information during create flow', () => { const rootpass = randomString(32); @@ -147,6 +148,19 @@ describe('create linode', () => { type: dcPricingMockLinodeTypes[0].id, }); + const currentPrice = dcPricingMockLinodeTypes[0].region_prices.find( + (regionPrice) => regionPrice.id === initialRegion.id + ); + const currentBackupPrice = dcPricingMockLinodeTypes[0].addons.backups.region_prices.find( + (regionPrice) => regionPrice.id === initialRegion.id + ); + const newPrice = dcPricingMockLinodeTypes[1].region_prices.find( + (linodeType) => linodeType.id === newRegion.id + ); + const newBackupPrice = dcPricingMockLinodeTypes[1].addons.backups.region_prices.find( + (regionPrice) => regionPrice.id === newRegion.id + ); + mockAppendFeatureFlags({ dcSpecificPricing: makeFeatureFlagData(true), }).as('getFeatureFlags'); @@ -175,15 +189,28 @@ describe('create linode', () => { dcPricingPlanPlaceholder ); - // Confirm that the checkout summary at the bottom of the page reflects the correct price. + // Check the 'Backups' add on + cy.get('[data-testid="backups"]').should('be.visible').click(); + containsClick(selectRegionString).type(`${initialRegion.label} {enter}`); fbtClick('Shared CPU'); getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + // Confirm that the backup prices are displayed as expected. + cy.get('[data-qa-add-ons="true"]') + .eq(1) + .within(() => { + cy.findByText(`$${currentBackupPrice.monthly}`).should('be.visible'); + cy.findByText('per month').should('be.visible'); + }); + // Confirm that the checkout summary at the bottom of the page reflects the correct price. cy.get('[data-qa-summary="true"]').within(() => { - const currentPrice = dcPricingMockLinodeTypes[0].region_prices.find( - (regionPrice) => regionPrice.id === initialRegion.id + cy.findByText(`$${currentPrice.monthly.toFixed(2)}/month`).should( + 'be.visible' + ); + cy.findByText('Backups').should('be.visible'); + cy.findByText(`$${currentBackupPrice.monthly.toFixed(2)}/month`).should( + 'be.visible' ); - cy.findByText(`$${currentPrice.monthly}0/month`).should('be.visible'); }); // Confirms that a notice is shown in the "Region" section of the Linode Create form informing the user of tiered pricing @@ -194,15 +221,25 @@ describe('create linode', () => { // .should('be.visible') // .should('have.attr', 'href', dcPricingDocsUrl); - // Confirms that the summary updates to reflect price changes if the user changes their region and plan selection. containsClick(initialRegion.label).type(`${newRegion.label} {enter}`); fbtClick('Shared CPU'); getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + // Confirm that the backup prices are displayed as expected. + cy.get('[data-qa-add-ons="true"]') + .eq(1) + .within(() => { + cy.findByText(`$${newBackupPrice.monthly}`).should('be.visible'); + cy.findByText('per month').should('be.visible'); + }); + // Confirms that the summary updates to reflect price changes if the user changes their region and plan selection. cy.get('[data-qa-summary="true"]').within(() => { - const currentPrice = dcPricingMockLinodeTypes[1].region_prices.find( - (regionPrice) => regionPrice.id === newRegion.id + cy.findByText(`$${newPrice.monthly.toFixed(2)}/month`).should( + 'be.visible' + ); + cy.findByText('Backups').should('be.visible'); + cy.findByText(`$${newBackupPrice.monthly.toFixed(2)}/month`).should( + 'be.visible' ); - cy.findByText(`$${currentPrice.monthly}0/month`).should('be.visible'); }); getClick('#linode-label').clear().type(linodeLabel); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts index dcb632df4b2..95933ad1bfa 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -12,7 +12,7 @@ import { import { createLinodeAndGetConfig, createAndBootLinode, -} from 'support/util/linode-utils'; +} from 'support/util/linodes'; import type { Config, Linode } from '@linode/api-v4'; diff --git a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts index e3e36622146..e2f318e4735 100644 --- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts @@ -1,17 +1,29 @@ -import { createLinode } from 'support/api/linodes'; -import { containsClick, getClick } from 'support/helpers'; -import { apiMatcher } from 'support/util/intercepts'; +import { createLinode, CreateLinodeRequest, Linode } from '@linode/api-v4'; import { ui } from 'support/ui'; import { randomString, randomLabel } from 'support/util/random'; import { authenticate } from 'support/api/authentication'; import { createStackScript } from '@linode/api-v4/lib'; import { interceptGetStackScripts } from 'support/intercepts/stackscripts'; -import { createLinodeRequestFactory } from '@src/factories'; +import { createLinodeRequestFactory, linodeFactory } from '@src/factories'; import { cleanUp } from 'support/util/cleanup'; +import { chooseRegion } from 'support/util/regions'; +import { + interceptRebuildLinode, + mockGetLinodeDetails, + mockRebuildLinodeError, +} from 'support/intercepts/linodes'; +/** + * Creates a Linode and StackScript. + * + * @param stackScriptRequestPayload - StackScript create request payload. + * @param linodeRequestPayload - Linode create request payload. + * + * @returns Promise that resolves when Linode and StackScript are created. + */ const createStackScriptAndLinode = async ( - stackScriptRequestPayload, - linodeRequestPayload + stackScriptRequestPayload: any, + linodeRequestPayload: CreateLinodeRequest ) => { return Promise.all([ createStackScript(stackScriptRequestPayload), @@ -19,102 +31,196 @@ const createStackScriptAndLinode = async ( ]); }; -const checkPasswordComplexity = (rootPassword: string) => { - const weakPassword = '123'; - const fairPassword = 'Akamai123'; +/** + * Opens the Rebuild Linode dialog by selecting it from the Linode's action menu. + * + * Assumes that the user has first navigated to the Linode's details page. + * + * @param linodeLabel - Label of the Linode being rebuilt. + */ +const openRebuildDialog = (linodeLabel: string) => { + ui.actionMenu + .findByTitle(`Action menu for Linode ${linodeLabel}`) + .should('be.visible') + .click(); - // weak or fair root password cannot rebuild the linode - cy.get('[id="root-password"]').clear().type(weakPassword); - ui.button.findByTitle('Rebuild Linode').should('be.enabled').click(); - cy.contains('Password does not meet complexity requirements.'); + ui.actionMenuItem.findByTitle('Rebuild').should('be.visible').click(); +}; - cy.get('[id="root-password"]').clear().type(fairPassword); - ui.button.findByTitle('Rebuild Linode').should('be.enabled').click(); - cy.contains('Password does not meet complexity requirements.'); +/** + * Finds the Rebuild Linode dialog. + * + * @param linodeLabel - Label of the Linode being rebuilt. + * + * @returns Cypress chainable. + */ +const findRebuildDialog = (linodeLabel: string) => { + return ui.dialog + .findByTitle(`Rebuild Linode ${linodeLabel}`) + .should('be.visible'); +}; - // Only strong password is allowed to rebuild the linode - cy.get('[id="root-password"]').type(rootPassword); - ui.button.findByTitle('Rebuild Linode').should('be.enabled').click(); +/** + * Enters a password into the "Root Password" field and confirms it is rated a given strength. + * + * @param desiredPassword - Password whose strength should be tested. + * @param passwordStrength - Expected strength for `desiredPassword`. + */ +const assertPasswordComplexity = ( + desiredPassword: string, + passwordStrength: 'Weak' | 'Fair' | 'Good' +) => { + cy.findByLabelText('Root Password') + .should('be.visible') + .clear() + .type(desiredPassword); + + cy.contains(`Strength: ${passwordStrength}`).should('be.visible'); }; +/** + * Submits rebuild dialog. + */ +const submitRebuild = () => { + ui.button + .findByTitle('Rebuild Linode') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); +}; + +// Error message that is displayed when desired password is not strong enough. +const passwordComplexityError = + 'Password does not meet complexity requirements.'; + authenticate(); describe('rebuild linode', () => { const image = 'Alpine 3.18'; const rootPassword = randomString(16); before(() => { - cleanUp(['linodes', 'stackscripts']); + cleanUp(['lke-clusters', 'linodes', 'stackscripts', 'images']); }); + /* + * - Confirms that Linode can be rebuilt using an image. + * - Confirms that password complexity + */ it('rebuilds a linode from Image', () => { - createLinode().then((linode) => { - cy.visitWithLogin(`/linodes`); - cy.get(`[data-qa-linode="${linode.label}"]`) - .should('be.visible') - .within(() => { - cy.findByText('Running'); - }); + const weakPassword = 'abc123'; + const fairPassword = 'Akamai123'; - cy.intercept( - 'POST', - apiMatcher(`linode/instances/${linode.id}/rebuild`) - ).as('linodeRebuild'); - cy.visitWithLogin(`/linodes/${linode.id}?rebuild=true`); - cy.get('[data-qa-enhanced-select="From Image"]').within(() => { - containsClick('From Image').type('From Image{enter}'); - }); - cy.get('[data-qa-enhanced-select="Choose an image"]').within(() => { - containsClick('Choose an image').type(`${image}{enter}`); - }); - cy.get('[id="linode-label"]').type(linode.label); + const linodeCreatePayload = createLinodeRequestFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }); - checkPasswordComplexity(rootPassword); + cy.defer(createLinode(linodeCreatePayload), 'creating Linode').then( + (linode: Linode) => { + interceptRebuildLinode(linode.id).as('linodeRebuild'); - cy.wait('@linodeRebuild'); - cy.contains('REBUILDING').should('be.visible'); - }); + cy.visitWithLogin(`/linodes/${linode.id}`); + cy.findByText('RUNNING').should('be.visible'); + + openRebuildDialog(linode.label); + findRebuildDialog(linode.label).within(() => { + // "From Image" should be selected by default; no need to change the value. + ui.select.findByText('From Image').should('be.visible'); + + ui.select + .findByText('Choose an image') + .should('be.visible') + .click() + .type(`${image}{enter}`); + + // Type to confirm. + cy.findByLabelText('Linode Label').type(linode.label); + + // checkPasswordComplexity(rootPassword); + assertPasswordComplexity(weakPassword, 'Weak'); + submitRebuild(); + cy.findByText(passwordComplexityError).should('be.visible'); + + assertPasswordComplexity(fairPassword, 'Fair'); + submitRebuild(); + cy.findByText(passwordComplexityError).should('be.visible'); + + assertPasswordComplexity(rootPassword, 'Good'); + submitRebuild(); + cy.findByText(passwordComplexityError).should('not.exist'); + }); + + cy.wait('@linodeRebuild'); + cy.contains('REBUILDING').should('be.visible'); + } + ); }); + /* + * - Confirms that a Linode can be rebuilt using a Community StackScript. + */ it('rebuilds a linode from Community StackScript', () => { const stackScriptId = '443929'; const stackScriptName = 'OpenLiteSpeed-WordPress'; const image = 'AlmaLinux 9'; - createLinode().then((linode) => { - cy.visitWithLogin(`/linodes`); - cy.get(`[data-qa-linode="${linode.label}"]`) - .should('be.visible') - .within(() => { - cy.findByText('Running'); - }); + const linodeCreatePayload = createLinodeRequestFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }); - cy.intercept( - 'POST', - apiMatcher(`linode/instances/${linode.id}/rebuild`) - ).as('linodeRebuild'); - cy.visitWithLogin(`/linodes/${linode.id}?rebuild=true`); - interceptGetStackScripts().as('getStackScripts'); - cy.get('[data-qa-enhanced-select="From Image"]').within(() => { - containsClick('From Image').type('From Community StackScript{enter}'); - }); - cy.wait('@getStackScripts'); - // Search the corresponding community stack script - cy.get('[id="search-by-label,-username,-or-description"]') - .click() - .type(`${stackScriptName}{enter}`); - getClick(`[id="${stackScriptId}"][type="radio"]`); - cy.get('[data-qa-enhanced-select="Choose an image"]').within(() => { - containsClick('Choose an image').type(`${image}{enter}`); - }); - cy.get('[id="linode-label"]').type(linode.label); + cy.defer(createLinode(linodeCreatePayload), 'creating Linode').then( + (linode: Linode) => { + interceptRebuildLinode(linode.id).as('linodeRebuild'); + interceptGetStackScripts().as('getStackScripts'); + cy.visitWithLogin(`/linodes/${linode.id}`); + cy.findByText('RUNNING').should('be.visible'); - checkPasswordComplexity(rootPassword); + openRebuildDialog(linode.label); + findRebuildDialog(linode.label).within(() => { + ui.select.findByText('From Image').click(); - cy.wait('@linodeRebuild'); - cy.contains('REBUILDING').should('be.visible'); - }); + ui.select + .findItemByText('From Community StackScript') + .should('be.visible') + .click(); + + cy.wait('@getStackScripts'); + cy.findByLabelText('Search by Label, Username, or Description') + .should('be.visible') + .type(`${stackScriptName}`); + + cy.wait('@getStackScripts'); + cy.findByLabelText('List of StackScripts').within(() => { + cy.get(`[id="${stackScriptId}"][type="radio"]`).click(); + }); + + ui.select + .findByText('Choose an image') + .scrollIntoView() + .should('be.visible') + .click(); + + ui.select.findItemByText(image).should('be.visible').click(); + + cy.findByLabelText('Linode Label') + .should('be.visible') + .type(linode.label); + + assertPasswordComplexity(rootPassword, 'Good'); + submitRebuild(); + }); + + cy.wait('@linodeRebuild'); + cy.contains('REBUILDING').should('be.visible'); + } + ); }); + /* + * - Confirms that a Linode can be rebuilt using an Account StackScript. + */ it('rebuilds a linode from Account StackScript', () => { const image = 'Alpine'; const region = 'us-east'; @@ -145,57 +251,84 @@ describe('rebuild linode', () => { cy.defer( createStackScriptAndLinode(stackScriptRequest, linodeRequest), 'creating stackScript and linode' - ).then(([stackScript, linodeBody]) => { - const linode = linodeBody.body; - cy.visitWithLogin(`/linodes`); - cy.get(`[data-qa-linode="${linode.label}"]`) - .should('be.visible') - .within(() => { - cy.findByText('Running'); + ).then(([stackScript, linode]) => { + interceptRebuildLinode(linode.id).as('linodeRebuild'); + cy.visitWithLogin(`/linodes/${linode.id}`); + cy.findByText('RUNNING').should('be.visible'); + + openRebuildDialog(linode.label); + findRebuildDialog(linode.label).within(() => { + ui.select.findByText('From Image').should('be.visible').click(); + + ui.select + .findItemByText('From Account StackScript') + .should('be.visible') + .click(); + + cy.findByLabelText('Search by Label, Username, or Description') + .should('be.visible') + .type(`${stackScript.label}`); + + cy.findByLabelText('List of StackScripts').within(() => { + cy.get(`[id="${stackScript.id}"][type="radio"]`).click(); }); - cy.intercept( - 'POST', - apiMatcher(`linode/instances/${linode.id}/rebuild`) - ).as('linodeRebuild'); - cy.visitWithLogin(`/linodes/${linode.id}?rebuild=true`); - cy.get('[data-qa-enhanced-select="From Image"]').within(() => { - containsClick('From Image').type('From Account StackScript{enter}'); - }); - cy.get('[id="search-by-label,-username,-or-description"]') - .click() - .type(`${stackScript.label}{enter}`); - getClick(`[id="${stackScript.id}"][type="radio"]`); - cy.get('[data-qa-enhanced-select="Choose an image"]').within(() => { - containsClick('Choose an image').type(`${image}{enter}`); - }); - cy.get('[id="linode-label"]').type(linode.label); + ui.select + .findByText('Choose an image') + .scrollIntoView() + .should('be.visible') + .click(); + + ui.select.findItemByText(image).should('be.visible').click(); - checkPasswordComplexity(rootPassword); + cy.findByLabelText('Linode Label') + .should('be.visible') + .type(linode.label); + + assertPasswordComplexity(rootPassword, 'Good'); + submitRebuild(); + }); cy.wait('@linodeRebuild'); cy.contains('REBUILDING').should('be.visible'); }); }); + /* + * - Confirms UI error flow when attempting to rebuild a Linode that is provisioning. + * - Confirms that API error message is displayed in the rebuild dialog. + */ it('cannot rebuild a provisioning linode', () => { - createLinode().then((linode) => { - cy.intercept( - 'POST', - apiMatcher(`linode/instances/${linode.id}/rebuild`) - ).as('linodeRebuild'); - cy.visitWithLogin(`/linodes/${linode.id}?rebuild=true`); - cy.get('[data-qa-enhanced-select="From Image"]').within(() => { - containsClick('From Image').type('From Image{enter}'); - }); - cy.get('[data-qa-enhanced-select="Choose an image"]').within(() => { - containsClick('Choose an image').type(`${image}{enter}`); - }); - cy.get('[id="linode-label"]').type(linode.label); - cy.get('[id="root-password"]').type(rootPassword); - ui.button.findByTitle('Rebuild Linode').should('be.enabled').click(); - cy.wait('@linodeRebuild'); - cy.contains('Linode busy.'); + const mockLinode = linodeFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + status: 'provisioning', + }); + + const mockErrorMessage = 'Linode busy.'; + + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + mockRebuildLinodeError(mockLinode.id, mockErrorMessage).as('rebuildLinode'); + + cy.visitWithLogin(`/linodes/${mockLinode.id}?rebuild=true`); + findRebuildDialog(mockLinode.label).within(() => { + ui.select.findByText('From Image').should('be.visible'); + ui.select + .findByText('Choose an image') + .should('be.visible') + .click() + .type(`${image}{enter}`); + + assertPasswordComplexity(rootPassword, 'Good'); + + cy.findByLabelText('Linode Label') + .should('be.visible') + .click() + .type(mockLinode.label); + + submitRebuild(); + cy.wait('@rebuildLinode'); + cy.findByText(mockErrorMessage); }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts index 977299cb285..e5416609233 100644 --- a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts @@ -1,14 +1,17 @@ -import { createLinode, Linode } from '@linode/api-v4'; -import { createLinodeRequestFactory } from '@src/factories'; +import type { Linode } from '@linode/api-v4'; +import { createLinode } from '@linode/api-v4'; +import { createLinodeRequestFactory, linodeFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { interceptGetLinodeDetails, interceptRebootLinodeIntoRescueMode, + mockGetLinodeDetails, + mockRebootLinodeIntoRescueModeError, } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { createAndBootLinode } from '../../../support/util/linode-utils'; // Submits the Rescue Linode dialog, initiating reboot into rescue mode. const rebootInRescueMode = () => { @@ -21,6 +24,10 @@ const rebootInRescueMode = () => { authenticate(); describe('Rescue Linodes', () => { + before(() => { + cleanUp(['linodes', 'lke-clusters']); + }); + /* * - Creates a Linode, waits for it to boot, and reboots it into rescue mode. * - Confirms that rescue mode API requests succeed. @@ -28,18 +35,33 @@ describe('Rescue Linodes', () => { * - Confirms that toast appears confirming successful reboot into rescue mode. */ it('Can reboot a Linode into rescue mode', () => { - cy.defer(createAndBootLinode(), 'creating and booting Linode').then( + const linodePayload = createLinodeRequestFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }); + + cy.defer(createLinode(linodePayload), 'creating Linode').then( (linode: Linode) => { - // mock 200 response interceptGetLinodeDetails(linode.id).as('getLinode'); interceptRebootLinodeIntoRescueMode(linode.id).as( 'rebootLinodeRescueMode' ); - const rescueUrl = `/linodes/${linode.id}/?rescue=true`; + const rescueUrl = `/linodes/${linode.id}`; cy.visitWithLogin(rescueUrl); cy.wait('@getLinode'); + // Wait for Linode to boot. + cy.findByText('RUNNING').should('be.visible'); + + // Open rescue dialog using action menu.. + ui.actionMenu + .findByTitle(`Action menu for Linode ${linode.label}`) + .should('be.visible') + .click(); + + ui.actionMenuItem.findByTitle('Rescue').should('be.visible').click(); + ui.dialog .findByTitle(`Rescue Linode ${linode.label}`) .should('be.visible') @@ -47,7 +69,7 @@ describe('Rescue Linodes', () => { rebootInRescueMode(); }); - // Check mocked response and make sure UI responded correctly. + // Check intercepted response and make sure UI responded correctly. cy.wait('@rebootLinodeRescueMode') .its('response.statusCode') .should('eq', 200); @@ -59,41 +81,29 @@ describe('Rescue Linodes', () => { }); /* - * - Creates a Linode and immediately attempts to reboot it into rescue mode. - * - Confirms that an error message appears in the UI explaining that the Linode is busy. + * - Confirms UI error flow when user rescues a Linode that is provisioning. + * - Confirms that API error message is displayed in the rescue dialog. */ it('Cannot reboot a provisioning Linode into rescue mode', () => { - const linodeRequest = createLinodeRequestFactory.build({ + const mockLinode = linodeFactory.build({ label: randomLabel(), region: chooseRegion().id, + status: 'provisioning', }); - cy.defer(createLinode(linodeRequest), 'creating Linode').then( - (linode: Linode) => { - interceptGetLinodeDetails(linode.id).as('getLinode'); - interceptRebootLinodeIntoRescueMode(linode.id).as( - 'rebootLinodeRescueMode' - ); - - const rescueUrl = `/linodes/${linode.id}?rescue=true`; - - cy.visitWithLogin(rescueUrl); - cy.wait('@getLinode'); - - ui.dialog - .findByTitle(`Rescue Linode ${linode.label}`) - .should('be.visible') - .within(() => { - rebootInRescueMode(); - - // Wait for API request and confirm that error message appears in dialog. - cy.wait('@rebootLinodeRescueMode') - .its('response.statusCode') - .should('eq', 400); - - cy.findByText('Linode busy.').should('be.visible'); - }); - } + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + mockRebootLinodeIntoRescueModeError(mockLinode.id, 'Linode busy.').as( + 'rescueLinode' ); + + cy.visitWithLogin(`/linodes/${mockLinode.id}?rescue=true`); + ui.dialog + .findByTitle(`Rescue Linode ${mockLinode.label}`) + .should('be.visible') + .within(() => { + rebootInRescueMode(); + cy.wait('@rescueLinode'); + cy.findByText('Linode busy.').should('be.visible'); + }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index dd46377a225..160a1a77218 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts @@ -15,6 +15,11 @@ import { apiMatcher } from 'support/util/intercepts'; import { chooseRegion, getRegionById } from 'support/util/regions'; import { authenticate } from 'support/api/authentication'; import { mockGetLinodes } from 'support/intercepts/linodes'; +import { userPreferencesFactory } from '@src/factories'; +import { + mockGetUserPreferences, + mockUpdateUserPreferences, +} from 'support/intercepts/profile'; const mockLinodes = new Array(5).fill(null).map( (_item: null, index: number): Linode => { @@ -360,12 +365,27 @@ describe('linode landing checks', () => { }); it('checks summary view for linode table', () => { + const mockPreferencesListView = userPreferencesFactory.build({ + linodes_view_style: 'list', + }); + + const mockPreferencesSummaryView = { + ...mockPreferencesListView, + linodes_view_style: 'grid', + }; + mockGetLinodes(mockLinodes).as('getLinodes'); + mockGetUserPreferences(mockPreferencesListView).as('getUserPreferences'); + mockUpdateUserPreferences(mockPreferencesSummaryView).as( + 'updateUserPreferences' + ); + cy.visitWithLogin('/linodes'); - cy.wait('@getLinodes'); + cy.wait(['@getLinodes', '@getUserPreferences']); // Check 'Summary View' button works as expected that can be visiable, enabled and clickable getVisible('[aria-label="Toggle display"]').should('be.enabled').click(); + cy.wait('@updateUserPreferences'); mockLinodes.forEach((linode) => { cy.findByText(linode.label) diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-certificates.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-certificates.spec.ts index f943313d483..ddd7bfe51e5 100644 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-certificates.spec.ts +++ b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-certificates.spec.ts @@ -7,23 +7,106 @@ import { mockGetFeatureFlagClientstream, } from 'support/intercepts/feature-flags'; import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { loadbalancerFactory, certificateFactory } from '@src/factories'; +import { + loadbalancerFactory, + certificateFactory, + mockCertificate, +} from '@src/factories'; import { ui } from 'support/ui'; -import { randomLabel, randomString } from 'support/util/random'; +import { randomItem, randomLabel, randomString } from 'support/util/random'; import { + mockDeleteLoadBalancerCertificate, + mockDeleteLoadBalancerCertificateError, mockGetLoadBalancer, mockGetLoadBalancerCertificates, + mockUpdateLoadBalancerCertificate, mockUploadLoadBalancerCertificate, } from 'support/intercepts/load-balancers'; +import { Loadbalancer, Certificate } from '@linode/api-v4/types'; + +/** + * Deletes the TLS / Service Target certificate in the AGLB landing page. + * + * @param loadBalancer - The load balancer that contains the certificate to be deleted. + * @param certificatesDeleteBefore - The array of certificates to be displayed before deleting. + * @param certificatesDeleteAfter - The array of certificates to be displayed after deleting. + * + * Asserts that the landing page has updated to reflect the changes. + */ +const deleteCertificate = ( + loadBalancer: Loadbalancer, + certificatesDeleteBefore: Certificate[], + certificatesDeleteAfter: Certificate[] +) => { + mockAppendFeatureFlags({ + aglb: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + mockGetLoadBalancer(loadBalancer).as('getLoadBalancer'); + mockGetLoadBalancerCertificates(loadBalancer.id, certificatesDeleteBefore).as( + 'getCertificates' + ); + + cy.visitWithLogin(`/loadbalancers/${loadBalancer.id}/certificates`); + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getCertificates', + ]); + + // Delete a TLS/Service Target certificate. + const certificateToDeleteLabel = certificatesDeleteBefore[0].label; + ui.actionMenu + .findByTitle(`Action Menu for certificate ${certificateToDeleteLabel}`) + .should('be.visible') + .click(); + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + + mockDeleteLoadBalancerCertificate( + loadBalancer.id, + certificatesDeleteBefore[0].id + ).as('deleteCertificate'); + + mockGetLoadBalancerCertificates(loadBalancer.id, certificatesDeleteAfter).as( + 'getCertificates' + ); + + ui.dialog + .findByTitle(`Delete Certificate ${certificateToDeleteLabel}?`) + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@deleteCertificate', '@getCertificates']); + + // Confirm that the deleted certificate is removed from the table with expected info. + cy.findByText(certificateToDeleteLabel).should('not.exist'); + + if (certificatesDeleteAfter.length === 0) { + // Confirm that Cloud Manager allows users to delete the last certificate, and display empty state gracefully. + cy.findByText('No items to display.').should('be.visible'); + } +}; describe('Akamai Global Load Balancer certificates page', () => { + let mockLoadBalancer: Loadbalancer; + + before(() => { + mockLoadBalancer = loadbalancerFactory.build(); + }); + /* * - Confirms Load Balancer certificate upload UI flow using mocked API requests. * - Confirms that TLS and Service Target certificates can be uploaded. * - Confirms that certificates table update to reflects uploaded certificates. */ it('can upload a TLS certificate', () => { - const mockLoadBalancer = loadbalancerFactory.build(); const mockLoadBalancerCertTls = certificateFactory.build({ label: randomLabel(), type: 'downstream', @@ -94,8 +177,8 @@ describe('Akamai Global Load Balancer certificates page', () => { // Confirm that new certificate is listed in the table with expected info. cy.findByText(mockLoadBalancerCertTls.label).should('be.visible'); }); + it('can upload a service target certificate', () => { - const mockLoadBalancer = loadbalancerFactory.build(); const mockLoadBalancerCertServiceTarget = certificateFactory.build({ label: randomLabel(), type: 'ca', @@ -162,4 +245,333 @@ describe('Akamai Global Load Balancer certificates page', () => { // Confirm that both new certificates are listed in the table with expected info. cy.findByText(mockLoadBalancerCertServiceTarget.label).should('be.visible'); }); + + /* + * - Confirms Load Balancer certificate edit UI flow using mocked API requests. + * - Confirms that TLS and Service Target certificates can be edited. + * - Confirms that certificates table updates to reflect edited certificates. + */ + it('can update a TLS certificate', () => { + const mockLoadBalancer = loadbalancerFactory.build(); + const mockLoadBalancerCertTls = certificateFactory.build({ + label: randomLabel(), + type: 'downstream', + certificate: mockCertificate.trim(), + }); + const mockNewLoadBalancerCertTls = certificateFactory.build({ + label: 'my-updated-tls-cert', + certificate: 'mock-new-cert', + key: 'mock-new-key', + type: 'downstream', + }); + + mockAppendFeatureFlags({ + aglb: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); + mockGetLoadBalancerCertificates( + mockLoadBalancer.id, + mockLoadBalancerCertTls + ).as('getCertificates'); + + cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}/certificates`); + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getCertificates', + ]); + + // Edit a TLS certificate. + ui.actionMenu + .findByTitle( + `Action Menu for certificate ${mockLoadBalancerCertTls.label}` + ) + .should('be.visible') + .click(); + ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); + + mockUpdateLoadBalancerCertificate( + mockLoadBalancer.id, + mockLoadBalancerCertTls + ).as('updateCertificate'); + + mockGetLoadBalancerCertificates(mockLoadBalancer.id, [ + mockNewLoadBalancerCertTls, + ]).as('getCertificates'); + + ui.drawer + .findByTitle(`Edit ${mockLoadBalancerCertTls.label}`) + .should('be.visible') + .within(() => { + // Confirm that drawer displays certificate data or indicates where data is redacted for security. + cy.findByLabelText('Certificate Label') + .should('be.visible') + .should('have.value', mockLoadBalancerCertTls.label); + + cy.findByLabelText('TLS Certificate') + .should('be.visible') + .should('have.value', mockLoadBalancerCertTls.certificate); + + cy.findByLabelText('Private Key') + .should('be.visible') + .should('have.value', '') + .invoke('attr', 'placeholder') + .should('contain', 'Private key is redacted for security.'); + + // Attempt to submit an incorrect form without a label or a new cert key. + cy.findByLabelText('Certificate Label').clear(); + cy.findByLabelText('TLS Certificate').clear().type('my-new-cert'); + + ui.buttonGroup + .findButtonByTitle('Update Certificate') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that validation errors appear when drawer is not filled out correctly. + cy.findAllByText('Label must not be empty.').should('be.visible'); + cy.findAllByText('Private Key is required').should('be.visible'); + + // Fix errors. + cy.findByLabelText('Certificate Label') + .click() + .type(mockNewLoadBalancerCertTls.label); + + cy.findByLabelText('TLS Certificate') + .click() + .type(mockNewLoadBalancerCertTls.certificate); + + cy.findByLabelText('Private Key') + .click() + .type(mockNewLoadBalancerCertTls.key); + + ui.buttonGroup + .findButtonByTitle('Update Certificate') + .scrollIntoView() + .click(); + }); + + cy.wait(['@updateCertificate', '@getCertificates']); + + // Confirm that new certificate is listed in the table with expected info. + cy.findByText(mockNewLoadBalancerCertTls.label).should('be.visible'); + }); + + it('can update a service target certificate', () => { + const mockLoadBalancer = loadbalancerFactory.build(); + const mockLoadBalancerCertServiceTarget = certificateFactory.build({ + label: randomLabel(), + type: 'ca', + certificate: mockCertificate.trim(), + }); + const mockNewLoadBalancerCertServiceTarget = certificateFactory.build({ + label: 'my-updated-ca-cert', + certificate: 'mock-new-cert', + type: 'ca', + }); + + mockAppendFeatureFlags({ + aglb: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); + mockGetLoadBalancerCertificates( + mockLoadBalancer.id, + mockLoadBalancerCertServiceTarget + ).as('getCertificates'); + + cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}/certificates`); + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getCertificates', + ]); + + // Edit a CA certificate. + ui.actionMenu + .findByTitle( + `Action Menu for certificate ${mockLoadBalancerCertServiceTarget.label}` + ) + .should('be.visible') + .click(); + ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); + + mockUpdateLoadBalancerCertificate( + mockLoadBalancer.id, + mockLoadBalancerCertServiceTarget + ).as('updateCertificate'); + + mockGetLoadBalancerCertificates(mockLoadBalancer.id, [ + mockNewLoadBalancerCertServiceTarget, + ]).as('getCertificates'); + + ui.drawer + .findByTitle(`Edit ${mockLoadBalancerCertServiceTarget.label}`) + .should('be.visible') + .within(() => { + // Confirm that drawer displays certificate data or indicates where data is redacted for security. + cy.findByLabelText('Certificate Label') + .should('be.visible') + .should('have.value', mockLoadBalancerCertServiceTarget.label); + + cy.findByLabelText('Server Certificate') + .should('be.visible') + .should('have.value', mockLoadBalancerCertServiceTarget.certificate); + + cy.findByLabelText('Private Key').should('not.exist'); + + // Attempt to submit an incorrect form without a label. + cy.findByLabelText('Certificate Label').clear(); + + ui.buttonGroup + .findButtonByTitle('Update Certificate') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that validation error appears when drawer is not filled out correctly. + cy.findAllByText('Label must not be empty.').should('be.visible'); + + // Fix error. + cy.findByLabelText('Certificate Label') + .click() + .type(mockNewLoadBalancerCertServiceTarget.label); + + // Update certificate. + cy.findByLabelText('Server Certificate') + .click() + .type(mockNewLoadBalancerCertServiceTarget.certificate); + + ui.buttonGroup + .findButtonByTitle('Update Certificate') + .scrollIntoView() + .click(); + }); + + cy.wait(['@updateCertificate', '@getCertificates']); + + // Confirm that new certificate is listed in the table with expected info. + cy.findByText(mockNewLoadBalancerCertServiceTarget.label).should( + 'be.visible' + ); + }); + + /* + * - Confirms Load Balancer certificate delete UI flow using mocked API requests. + * - Confirms that TLS and Service Target certificates can be deleted. + * - Confirms that certificates table update to reflects deleted certificates. + * - Confirms that the last certificate can be deleted. + */ + it('can delete a TLS certificate', () => { + const mockLoadBalancerCertsTls = certificateFactory.buildList(5, { + type: 'downstream', + }); + const mockLoadBalancerAfterDeleteCertsTls = mockLoadBalancerCertsTls.slice( + 1 + ); + + deleteCertificate( + mockLoadBalancer, + mockLoadBalancerCertsTls, + mockLoadBalancerAfterDeleteCertsTls + ); + }); + + it('can delete a service target certificate', () => { + const mockLoadBalancerCertsTls = certificateFactory.buildList(5, { + type: 'ca', + }); + const mockLoadBalancerAfterDeleteCertsTls = mockLoadBalancerCertsTls.slice( + 1 + ); + + deleteCertificate( + mockLoadBalancer, + mockLoadBalancerCertsTls, + mockLoadBalancerAfterDeleteCertsTls + ); + }); + + it('can delete the last certificate', () => { + const mockLoadBalancerCertsTls = certificateFactory.buildList(1, { + type: randomItem(['ca', 'downstream']), + }); + const mockLoadBalancerAfterDeleteCertsTls = mockLoadBalancerCertsTls.slice( + 1 + ); + + deleteCertificate( + mockLoadBalancer, + mockLoadBalancerCertsTls, + mockLoadBalancerAfterDeleteCertsTls + ); + }); + + it('can handle server errors gracefully when failing to delete the certificate', () => { + const mockLoadBalancerCertsTls = certificateFactory.buildList(1, { + type: randomItem(['ca', 'downstream']), + }); + const mockLoadBalancerAfterDeleteCertsTls = mockLoadBalancerCertsTls.slice( + 1 + ); + + mockAppendFeatureFlags({ + aglb: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); + mockGetLoadBalancerCertificates( + mockLoadBalancer.id, + mockLoadBalancerCertsTls + ).as('getCertificates'); + + cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}/certificates`); + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getCertificates', + ]); + + // Delete a TLS/Service Target certificate. + const certificateToDeleteLabel = mockLoadBalancerCertsTls[0].label; + ui.actionMenu + .findByTitle(`Action Menu for certificate ${certificateToDeleteLabel}`) + .should('be.visible') + .click(); + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + + mockDeleteLoadBalancerCertificateError( + mockLoadBalancer.id, + mockLoadBalancerCertsTls[0].id + ).as('deleteCertificateError'); + + ui.dialog + .findByTitle(`Delete Certificate ${certificateToDeleteLabel}?`) + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@deleteCertificateError'); + + ui.dialog + .findByTitle(`Delete Certificate ${certificateToDeleteLabel}?`) + .should('be.visible') + .within(() => { + // Confirm that an error message shows up in the dialog + cy.findByText( + 'An error occurred while deleting Load Balancer certificate.' + ).should('be.visible'); + }); + }); }); diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-configurations.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-configurations.spec.ts new file mode 100644 index 00000000000..8fc87120c34 --- /dev/null +++ b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-configurations.spec.ts @@ -0,0 +1,38 @@ +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { loadbalancerFactory, configurationFactory } from '@src/factories'; +import { + mockGetLoadBalancer, + mockGetLoadBalancerConfigurations, +} from 'support/intercepts/load-balancers'; + +describe('Akamai Global Load Balancer configurations page', () => { + it('renders configurations', () => { + const loadbalancer = loadbalancerFactory.build(); + const configurations = configurationFactory.buildList(5); + + mockAppendFeatureFlags({ + aglb: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); + mockGetLoadBalancerConfigurations(loadbalancer.id, configurations).as( + 'getConfigurations' + ); + + cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/configurations`); + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getConfigurations', + ]); + + for (const configuration of configurations) { + cy.findByText(configuration.label).should('be.visible'); + } + }); +}); diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts index d0e78d7ff77..3bd441c7a18 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts @@ -16,6 +16,7 @@ const eventActions: RecPartial[] = [ 'disk_duplicate', 'disk_resize', 'disk_update', + 'database_low_disk_space', 'entity_transfer_accept', 'entity_transfer_cancel', 'entity_transfer_create', diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts index d91f38034de..cc8341ef251 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts @@ -117,7 +117,7 @@ describe('VPC details page', () => { // Confirm that user is redirected to VPC landing page. cy.url().should('endWith', '/vpcs'); - cy.findByText('Create a private and isolated network.'); + cy.findByText('Create a private and isolated network'); }); /** diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts index 40639e974f7..855eb7ff1b5 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts @@ -12,6 +12,7 @@ import { vpcFactory } from '@src/factories'; import { ui } from 'support/ui'; import { randomLabel, randomPhrase } from 'support/util/random'; import { chooseRegion, getRegionById } from 'support/util/regions'; +import { VPC_LABEL } from 'src/features/VPCs/constants'; // TODO Remove feature flag mocks when feature flag is removed from codebase. describe('VPC landing page', () => { @@ -65,10 +66,8 @@ describe('VPC landing page', () => { cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPCs']); // Confirm that empty state is shown and that each section is present. - cy.findByText('VPCs').should('be.visible'); - cy.findByText('Create a private and isolated network.').should( - 'be.visible' - ); + cy.findByText(VPC_LABEL).should('be.visible'); + cy.findByText('Create a private and isolated network').should('be.visible'); cy.findByText('Getting Started Guides').should('be.visible'); cy.findByText('Video Playlist').should('be.visible'); @@ -268,9 +267,7 @@ describe('VPC landing page', () => { cy.wait(['@deleteVPC', '@getVPCs']); ui.toast.assertMessage('VPC deleted successfully.'); cy.findByText(mockVPCs[1].label).should('not.exist'); - cy.findByText('Create a private and isolated network.').should( - 'be.visible' - ); + cy.findByText('Create a private and isolated network').should('be.visible'); }); /* diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index 4a873a8333a..bdbc6440f35 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -7,6 +7,7 @@ import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; import type { Disk, Linode, LinodeType, Volume } from '@linode/api-v4/types'; +import { makeErrorResponse } from 'support/util/errors'; /** * Intercepts POST request to create a Linode. @@ -135,6 +136,43 @@ export const mockGetLinodeVolumes = ( ); }; +/** + * Intercepts POST request to rebuild a Linode. + * + * @param linodeId - ID of Linode for intercepted request. + * + * @returns Cypress chainable. + */ +export const interceptRebuildLinode = ( + linodeId: number +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`linode/instances/${linodeId}/rebuild`) + ); +}; + +/** + * Intercepts POST request to rebuild a Linode and mocks an error response. + * + * @param linodeId - ID of Linode for intercepted request. + * @param errorMessage - Error message to be included in the mocked HTTP response. + * @param statusCode - HTTP status code for mocked error response. Default is `400`. + * + * @returns Cypress chainable. + */ +export const mockRebuildLinodeError = ( + linodeId: number, + errorMessage: string, + statusCode: number = 400 +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`linode/instances/${linodeId}/rebuild`), + makeErrorResponse(errorMessage, statusCode) + ); +}; + /** * Intercepts POST request to reboot a Linode. * @@ -167,6 +205,27 @@ export const interceptRebootLinodeIntoRescueMode = ( ); }; +/** + * Intercepts POST request to reboot a Linode into rescue mode and mocks error response. + * + * @param linodeId - ID of Linode to reboot into rescue mode. + * @param errorMessage - Error message to be included in the mocked HTTP response. + * @param statusCode - HTTP status code for mocked error response. Default is `400`. + * + * @returns Cypress chainable. + */ +export const mockRebootLinodeIntoRescueModeError = ( + linodeId: number, + errorMessage: string, + statusCode: number = 400 +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`linode/instances/${linodeId}/rescue`), + makeErrorResponse(errorMessage, statusCode) + ); +}; + /** * Intercepts GET request to retrieve a Linode's Disks and mocks response. * diff --git a/packages/manager/cypress/support/intercepts/load-balancers.ts b/packages/manager/cypress/support/intercepts/load-balancers.ts index ce028c70a86..c79b257c2db 100644 --- a/packages/manager/cypress/support/intercepts/load-balancers.ts +++ b/packages/manager/cypress/support/intercepts/load-balancers.ts @@ -97,6 +97,66 @@ export const mockUploadLoadBalancerCertificate = ( ); }; +/** + * Intercepts DELETE request to delete an AGLB load balancer certificate and mocks a success response. + * + * @param loadBalancerId - ID of load balancer for which to delete certificates. + * @param certificateId - ID of certificate for which to remove. + * + * @returns Cypress chainable. + */ +export const mockDeleteLoadBalancerCertificate = ( + loadBalancerId: number, + certificateId: number +) => { + return cy.intercept( + 'DELETE', + apiMatcher(`/aglb/${loadBalancerId}/certificates/${certificateId}`), + makeResponse() + ); +}; + +/** + * Intercepts GET request to retrieve AGLB service targets and mocks HTTP 500 error response. + * + * @param loadBalancerId - ID of load balancer for which to delete certificates. + * @param certificateId - ID of certificate for which to remove. + * @param message - Optional error message with which to respond. + * + * @returns Cypress chainable. + */ +export const mockDeleteLoadBalancerCertificateError = ( + loadBalancerId: number, + certificateId: number, + message?: string +) => { + const defaultMessage = + 'An error occurred while deleting Load Balancer certificate.'; + return cy.intercept( + 'DELETE', + apiMatcher(`/aglb/${loadBalancerId}/certificates/${certificateId}`), + makeErrorResponse(message ?? defaultMessage, 500) + ); +}; + +/** + * Intercepts PUT request to update an AGLB load balancer certificate and mocks a success response. + * + * @param loadBalancerId - ID of load balancer for which to mock certificates. + * + * @returns Cypress chainable. + */ +export const mockUpdateLoadBalancerCertificate = ( + loadBalancerId: number, + certificate: Certificate +) => { + return cy.intercept( + 'PUT', + apiMatcher(`/aglb/${loadBalancerId}/certificates/${certificate.id}`), + makeResponse(certificate) + ); +}; + /** * Intercepts GET request to retrieve AGLB service targets and mocks response. * diff --git a/packages/manager/cypress/support/intercepts/profile.ts b/packages/manager/cypress/support/intercepts/profile.ts index cafd8793182..6a83e58ef51 100644 --- a/packages/manager/cypress/support/intercepts/profile.ts +++ b/packages/manager/cypress/support/intercepts/profile.ts @@ -62,6 +62,19 @@ export const mockGetUserPreferences = ( return cy.intercept('GET', apiMatcher('profile/preferences'), preferences); }; +/** + * Intercepts PUT request to update user preferences and mocks response. + * + * @param preferences - Updated user preferences with which to respond. + * + * @returns Cypress chainable. + */ +export const mockUpdateUserPreferences = ( + preferences: UserPreferences +): Cypress.Chainable => { + return cy.intercept('PUT', apiMatcher('profile/preferences'), preferences); +}; + /** * Intercepts POST request to opt out of SMS verification and mocks response. * diff --git a/packages/manager/cypress/support/ui/select.ts b/packages/manager/cypress/support/ui/select.ts index 2c46f877dfe..a65be2d618c 100644 --- a/packages/manager/cypress/support/ui/select.ts +++ b/packages/manager/cypress/support/ui/select.ts @@ -4,6 +4,19 @@ import { getRegionById, getRegionByLabel } from 'support/util/regions'; * UI helpers for Enhanced Select component. */ export const select = { + /** + * Finds an Enhanced Select by its text value. + * + * @param selectText - Select component inner text. + * + * @returns Cypress chainable. + */ + findByText: (selectText: string) => { + return cy + .get(`[data-qa-enhanced-select="${selectText}"]`) + .findByText(selectText); + }, + /** * Finds a Select menu item by its `data-qa-option` ID. * diff --git a/packages/manager/cypress/support/util/linode-utils.ts b/packages/manager/cypress/support/util/linodes.ts similarity index 86% rename from packages/manager/cypress/support/util/linode-utils.ts rename to packages/manager/cypress/support/util/linodes.ts index a2d617e1012..e94a8263402 100644 --- a/packages/manager/cypress/support/util/linode-utils.ts +++ b/packages/manager/cypress/support/util/linodes.ts @@ -7,16 +7,15 @@ import { chooseRegion } from 'support/util/regions'; import type { Config, Linode, LinodeConfigCreationData } from '@linode/api-v4'; -const linodeRequest = createLinodeRequestFactory.build({ - label: randomLabel(), - region: chooseRegion().id, -}); - /** * Creates a Linode and waits for it to be in "running" state. */ export const createAndBootLinode = async (): Promise => { - const linode = await createLinode(linodeRequest); + const createPayload = createLinodeRequestFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }); + const linode = await createLinode(createPayload); await pollLinodeStatus( linode.id, @@ -40,8 +39,12 @@ export const createLinodeAndGetConfig = async ({ linodeConfigRequestOverride?: Partial; waitForLinodeToBeRunning?: boolean; }): Promise<[Linode, Config]> => { + const createPayload = createLinodeRequestFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }); const linode = await createLinode({ - ...linodeRequest, + ...createPayload, ...linodeConfigRequestOverride, }); diff --git a/packages/manager/package.json b/packages/manager/package.json index 3a700cfc49c..001a6b6019d 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.106.0", + "version": "1.107.0", "private": true, "bugs": { "url": "https://github.com/Linode/manager/issues" @@ -108,19 +108,20 @@ ] }, "devDependencies": { - "@storybook/addon-actions": "~7.0.24", - "@storybook/addon-controls": "~7.0.24", - "@storybook/addon-docs": "~7.0.24", - "@storybook/addon-measure": "~7.0.24", - "@storybook/addon-viewport": "~7.0.24", - "@storybook/addons": "~7.0.24", - "@storybook/client-api": "~7.0.24", - "@storybook/react": "~7.0.24", - "@storybook/react-vite": "^7.0.24", - "@storybook/theming": "~7.0.24", + "@linode/eslint-plugin-cloud-manager": "^0.0.3", + "@storybook/addon-actions": "~7.5.2", + "@storybook/addon-controls": "~7.5.2", + "@storybook/addon-docs": "~7.5.2", + "@storybook/addon-measure": "~7.5.2", + "@storybook/addon-viewport": "~7.5.2", + "@storybook/addons": "~7.5.2", + "@storybook/client-api": "~7.5.2", + "@storybook/react": "~7.5.2", + "@storybook/react-vite": "^7.5.2", + "@storybook/theming": "~7.5.2", "@swc/core": "^1.3.1", "@swc/jest": "^0.2.22", - "@testing-library/cypress": "^9.0.0", + "@testing-library/cypress": "^10.0.0", "@testing-library/jest-dom": "~5.11.3", "@testing-library/react": "~10.4.9", "@testing-library/react-hooks": "~3.4.1", @@ -165,10 +166,10 @@ "chai-string": "^1.5.0", "chalk": "^5.2.0", "css-mediaquery": "^0.1.2", - "cypress": "^12.17.3", + "cypress": "^13.4.0", "cypress-axe": "^1.0.0", "cypress-file-upload": "^5.0.7", - "cypress-real-events": "^1.7.0", + "cypress-real-events": "^1.11.0", "cypress-vite": "^1.4.2", "dotenv": "^16.0.3", "enzyme": "^3.10.0", @@ -186,7 +187,7 @@ "eslint-plugin-react-hooks": "^3.0.0", "eslint-plugin-scanjs-rules": "^0.2.1", "eslint-plugin-sonarjs": "^0.5.0", - "eslint-plugin-storybook": "^0.6.12", + "eslint-plugin-storybook": "^0.6.15", "eslint-plugin-testing-library": "^3.1.2", "eslint-plugin-xss": "^0.1.10", "factory.ts": "^0.5.1", @@ -197,13 +198,13 @@ "jest-sonar-reporter": "^2.0.0", "jest_workaround": "^0.1.10", "lint-staged": "^13.2.2", - "msw": "~0.20.5", + "msw": "~1.3.2", "prettier": "~2.2.1", "redux-mock-store": "^1.5.3", "reselect-tools": "^0.0.7", "serve": "^14.0.1", - "storybook": "~7.0.24", - "storybook-dark-mode": "^3.0.0", + "storybook": "~7.5.2", + "storybook-dark-mode": "^3.0.1", "vite": "^4.4.11", "vite-plugin-svgr": "^3.2.0" }, diff --git a/packages/manager/public/fonts/fonts.css b/packages/manager/public/fonts/fonts.css index 56f2e3a9d89..6939d146fb8 100644 --- a/packages/manager/public/fonts/fonts.css +++ b/packages/manager/public/fonts/fonts.css @@ -11,19 +11,6 @@ text-rendering: optimizeLegibility; } -/* Webfont: Lato-SemiBold */ -@font-face { - font-family: 'LatoWebSemibold'; - src: url('fonts/Lato-Semibold.eot'); /* IE9 Compat Modes */ - src: url('fonts/Lato-Semibold.eot?#iefix') format('embedded-opentype'), - /* IE6-IE8 */ url('fonts/Lato-Semibold.woff2') format('woff2'), - /* Modern Browsers */ url('fonts/Lato-Semibold.woff') format('woff'), - /* Modern Browsers */ url('fonts/Lato-Semibold.ttf') format('truetype'); - font-style: normal; - font-weight: normal; - text-rendering: optimizeLegibility; -} - /* Webfont: Lato-Regular */ @font-face { font-family: 'LatoWeb'; diff --git a/packages/manager/public/mockServiceWorker.js b/packages/manager/public/mockServiceWorker.js index 9704362b77a..a3dbcdd184b 100644 --- a/packages/manager/public/mockServiceWorker.js +++ b/packages/manager/public/mockServiceWorker.js @@ -1,228 +1,308 @@ +/* eslint-disable */ +/* tslint:disable */ + /** - * Mock Service Worker. + * Mock Service Worker (1.3.2). * @see https://github.com/mswjs/msw * - Please do NOT modify this file. * - Please do NOT serve this file on production. */ -/* eslint-disable */ -/* tslint:disable */ -const INTEGRITY_CHECKSUM = 'ca2c3cd7453d8c614e2c19db63ede1a1'; -const bypassHeaderName = 'x-msw-bypass'; +const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id -let clients = {}; + if (!clientId || !self.clients) { + return + } -self.addEventListener('install', function() { - return self.skipWaiting(); -}); + const client = await self.clients.get(clientId) -self.addEventListener('activate', async function(event) { - return self.clients.claim(); -}); + if (!client) { + return + } -self.addEventListener('message', async function(event) { - const clientId = event.source.id; - const client = await event.currentTarget.clients.get(clientId); - const allClients = await self.clients.matchAll(); - const allClientIds = allClients.map(client => client.id); + const allClients = await self.clients.matchAll({ + type: 'window', + }) switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + case 'INTEGRITY_CHECK_REQUEST': { sendToClient(client, { type: 'INTEGRITY_CHECK_RESPONSE', - payload: INTEGRITY_CHECKSUM - }); - break; + payload: INTEGRITY_CHECKSUM, + }) + break } case 'MOCK_ACTIVATE': { - clients = ensureKeys(allClientIds, clients); - clients[clientId] = true; + activeClientIds.add(clientId) sendToClient(client, { type: 'MOCKING_ENABLED', - payload: true - }); - break; + payload: true, + }) + break } case 'MOCK_DEACTIVATE': { - clients = ensureKeys(allClientIds, clients); - clients[clientId] = false; - break; + activeClientIds.delete(clientId) + break } case 'CLIENT_CLOSED': { - const remainingClients = allClients.filter(client => { - return client.id !== clientId; - }); + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) // Unregister itself when there are no more clients if (remainingClients.length === 0) { - self.registration.unregister(); + self.registration.unregister() } - break; + break } } -}); +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass launch darkly + if (request.url.includes("launchdarkly.com")) { + return + } + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } -self.addEventListener('fetch', async function(event) { - const { clientId, request } = event; - const requestClone = request.clone(); - const getOriginalResponse = () => fetch(requestClone); + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { - return; + return } - event.respondWith( - new Promise(async (resolve, reject) => { - const client = await event.target.clients.get(clientId); - - if ( - // Bypass mocking when no clients active - !client || - // Bypass mocking if the current client has mocking disabled - !clients[clientId] || - // Bypass mocking for navigation requests - request.mode === 'navigate' - ) { - return resolve(getOriginalResponse()); - } - - // Bypass requests with the explicit bypass header - if (requestClone.headers.get(bypassHeaderName) === 'true') { - const modifiedHeaders = serializeHeaders(requestClone.headers); - // Remove the bypass header to comply with the CORS preflight check - delete modifiedHeaders[bypassHeaderName]; + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } - const originalRequest = new Request(requestClone, { - headers: new Headers(modifiedHeaders) - }); + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2) - return resolve(fetch(originalRequest)); + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return } - const reqHeaders = serializeHeaders(request.headers); - const body = await request.text(); - - const rawClientMessage = await sendToClient(client, { - type: 'REQUEST', - payload: { - url: request.url, - method: request.method, - headers: reqHeaders, - cache: request.cache, - mode: request.mode, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body, - bodyUsed: request.bodyUsed, - keepalive: request.keepalive - } - }); - - const clientMessage = rawClientMessage; - - switch (clientMessage.type) { - case 'MOCK_SUCCESS': { - setTimeout( - resolve.bind(this, createResponse(clientMessage)), - clientMessage.payload.delay - ); - break; - } - - case 'MOCK_NOT_FOUND': { - return resolve(getOriginalResponse()); - } - - case 'NETWORK_ERROR': { - const { name, message } = clientMessage.payload; - const networkError = new Error(message); - networkError.name = name; - - // Rejecting a request Promise emulates a network error. - return reject(networkError); - } - - case 'INTERNAL_ERROR': { - const parsedBody = JSON.parse(clientMessage.payload.body); - - console.error( - `\ -[MSW] Request handler function for "%s %s" has thrown the following exception: - -${parsedBody.errorType}: ${parsedBody.message} -(see more detailed error stack trace in the mocked response body) - -This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error. -If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses\ - `, - request.method, - request.url - ); - - return resolve(createResponse(clientMessage)); - } - } - }).catch(error => { + // At this point, any exception indicates an issue with the original request/response. console.error( - '[MSW] Failed to mock a "%s" request to "%s": %s', + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, request.method, request.url, - error - ); + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' }) - ); -}); - -function serializeHeaders(headers) { - const reqHeaders = {}; - headers.forEach((value, name) => { - reqHeaders[name] = reqHeaders[name] - ? [].concat(reqHeaders[name]).concat(value) - : value; - }); - return reqHeaders; + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + const clonedRequest = request.clone() + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()) + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass'] + + return fetch(clonedRequest, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'MOCK_NOT_FOUND': { + return passthrough() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.data + const networkError = new Error(message) + networkError.name = name + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError + } + } + + return passthrough() } function sendToClient(client, message) { return new Promise((resolve, reject) => { - const channel = new MessageChannel(); + const channel = new MessageChannel() - channel.port1.onmessage = event => { + channel.port1.onmessage = (event) => { if (event.data && event.data.error) { - reject(event.data.error); - } else { - resolve(event.data); + return reject(event.data.error) } - }; - client.postMessage(JSON.stringify(message), [channel.port2]); - }); -} + resolve(event.data) + } -function createResponse(clientMessage) { - return new Response(clientMessage.payload.body, { - ...clientMessage.payload, - headers: clientMessage.payload.headers - }); + client.postMessage(message, [channel.port2]) + }) } -function ensureKeys(keys, obj) { - return Object.keys(obj).reduce((acc, key) => { - if (keys.includes(key)) { - acc[key] = obj[key]; - } +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs) + }) +} - return acc; - }, {}); +async function respondWithMock(response) { + await sleep(response.delay) + return new Response(response.body, response) } diff --git a/packages/manager/src/App.tsx b/packages/manager/src/App.tsx index 47e7ac976c0..9d71807abe9 100644 --- a/packages/manager/src/App.tsx +++ b/packages/manager/src/App.tsx @@ -2,7 +2,6 @@ import '@reach/tabs/styles.css'; import { ErrorBoundary } from '@sentry/react'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; import { @@ -14,13 +13,12 @@ import withFeatureFlagProvider from 'src/containers/withFeatureFlagProvider.cont import { EventWithStore, events$ } from 'src/events'; import TheApplicationIsOnFire from 'src/features/TheApplicationIsOnFire'; -import GoTo from './GoTo'; -import IdentifyUser from './IdentifyUser'; -import MainContent from './MainContent'; +import { GoTo } from './GoTo'; +import { MainContent } from './MainContent'; +import { SplashScreen } from './components/SplashScreen'; import { ADOBE_ANALYTICS_URL, NUM_ADOBE_SCRIPTS } from './constants'; import { reportException } from './exceptionReporting'; -import { useAuthentication } from './hooks/useAuthentication'; -import { useFeatureFlagsLoad } from './hooks/useFeatureFlagLoad'; +import { useInitialRequests } from './hooks/useInitialRequests'; import { loadScript } from './hooks/useScript'; import { oauthClientsEventHandler } from './queries/accountOAuth'; import { databaseEventsHandler } from './queries/databases'; @@ -37,7 +35,7 @@ import { sshKeyEventHandler } from './queries/profile'; import { supportTicketEventHandler } from './queries/support'; import { tokenEventHandler } from './queries/tokens'; import { volumeEventsHandler } from './queries/volumes'; -import { ApplicationState } from './store'; +import { useSetupFeatureFlags } from './useSetupFeatureFlags'; import { getNextThemeValue } from './utilities/theme'; import { isOSMac } from './utilities/userAgent'; @@ -52,11 +50,9 @@ const BaseApp = withDocumentTitleProvider( const { data: preferences } = usePreferences(); const { mutateAsync: updateUserPreferences } = useMutatePreferences(); - const { featureFlagsLoading } = useFeatureFlagsLoad(); - const appIsLoading = useSelector( - (state: ApplicationState) => state.initialLoad.appIsLoading - ); - const { loggedInAsCustomer } = useAuthentication(); + const { isLoading } = useInitialRequests(); + + const { areFeatureFlagsLoading } = useSetupFeatureFlags(); const { enqueueSnackbar } = useSnackbar(); @@ -263,10 +259,14 @@ const BaseApp = withDocumentTitleProvider( }; }, [handleMigrationEvent]); + if (isLoading || areFeatureFlagsLoading) { + return ; + } + return ( }> {/** Accessibility helper */} - + Skip to main content - setGoToOpen(false)} /> + setGoToOpen(false)} open={goToOpen} /> {/** Update the LD client with the user's id as soon as we know it */} - - {featureFlagsLoading ? null : ( - - )} + ); }) diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx index c66b88a8684..32af40cb471 100644 --- a/packages/manager/src/GoTo.tsx +++ b/packages/manager/src/GoTo.tsx @@ -58,7 +58,7 @@ interface Props { open: boolean; } -const GoTo = (props: Props) => { +export const GoTo = React.memo((props: Props) => { const classes = useStyles(); const routerHistory = useHistory(); const { _hasAccountAccess, _isManagedAccount } = useAccountManagement(); @@ -195,6 +195,4 @@ const GoTo = (props: Props) => { ); -}; - -export default React.memo(GoTo); +}); diff --git a/packages/manager/src/MainContent.test.tsx b/packages/manager/src/MainContent.test.ts similarity index 55% rename from packages/manager/src/MainContent.test.tsx rename to packages/manager/src/MainContent.test.ts index 298d7762e32..676fc7a38d6 100644 --- a/packages/manager/src/MainContent.test.tsx +++ b/packages/manager/src/MainContent.test.ts @@ -1,16 +1,7 @@ -import { render, waitFor } from '@testing-library/react'; -import * as React from 'react'; - -import { rest, server } from 'src/mocks/testServer'; -import { wrapWithTheme } from 'src/utilities/testHelpers'; - -import MainContent, { +import { checkFlagsForMainContentBanner, checkPreferencesForBannerDismissal, } from './MainContent'; -import { queryClientFactory } from './queries/base'; - -const queryClient = queryClientFactory(); const mainContentBanner = { key: 'Test Text Key', @@ -52,31 +43,3 @@ describe('checkPreferencesForBannerDismissal', () => { expect(checkPreferencesForBannerDismissal({}, 'key1')).toBe(false); }); }); - -describe('Databases menu item for a restricted user', () => { - it('should not render the menu item', async () => { - server.use( - rest.get('*/account', (req, res, ctx) => { - return res(ctx.json({})); - }) - ); - const { getByText } = render( - wrapWithTheme( - , - { - flags: { databases: false }, - queryClient, - } - ) - ); - - await waitFor(() => { - let el; - try { - el = getByText('Databases'); - } catch (e) { - expect(el).not.toBeDefined(); - } - }); - }); -}); diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index 4109e1c5739..dab1dd692da 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -3,7 +3,6 @@ import { Theme } from '@mui/material/styles'; import { isEmpty } from 'ramda'; import * as React from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; -import { compose } from 'recompose'; import { makeStyles } from 'tss-react/mui'; import Logo from 'src/assets/logo/akamai-logo.svg'; @@ -11,12 +10,8 @@ import { Box } from 'src/components/Box'; import { MainContentBanner } from 'src/components/MainContentBanner'; import { MaintenanceScreen } from 'src/components/MaintenanceScreen'; import { NotFound } from 'src/components/NotFound'; -import { PreferenceToggle } from 'src/components/PreferenceToggle/PreferenceToggle'; import { SideMenu } from 'src/components/SideMenu'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; -import withGlobalErrors, { - Props as GlobalErrorProps, -} from 'src/containers/globalErrors.container'; import { useDialogContext } from 'src/context/useDialogContext'; import { Footer } from 'src/features/Footer'; import { GlobalNotifications } from 'src/features/GlobalNotifications/GlobalNotifications'; @@ -29,15 +24,14 @@ import { TopMenu } from 'src/features/TopMenu/TopMenu'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { useDatabaseEnginesQuery } from 'src/queries/databases'; -import { usePreferences } from 'src/queries/preferences'; +import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; import { ManagerPreferences } from 'src/types/ManagerPreferences'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { ENABLE_MAINTENANCE_MODE } from './constants'; import { complianceUpdateContext } from './context/complianceUpdateContext'; import { FlagSet } from './featureFlags'; - -import type { PreferenceToggleProps } from 'src/components/PreferenceToggle/PreferenceToggle'; +import { useGlobalErrors } from './hooks/useGlobalErrors'; const useStyles = makeStyles()((theme: Theme) => ({ activationWrapper: { @@ -98,10 +92,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, width: '100%', }, - hidden: { - display: 'none', - overflow: 'hidden', - }, logo: { '& > g': { fill: theme.color.black, @@ -128,13 +118,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -interface Props { - appIsLoading: boolean; - isLoggedInAsCustomer: boolean; -} - -type CombinedProps = Props & GlobalErrorProps; - const Account = React.lazy(() => import('src/features/Account').then((module) => ({ default: module.Account, @@ -151,7 +134,11 @@ const Images = React.lazy(() => import('src/features/Images')); const Kubernetes = React.lazy(() => import('src/features/Kubernetes')); const ObjectStorage = React.lazy(() => import('src/features/ObjectStorage')); const Profile = React.lazy(() => import('src/features/Profile/Profile')); -const LoadBalancers = React.lazy(() => import('src/features/LoadBalancers')); +const LoadBalancers = React.lazy(() => + import('src/features/LoadBalancers').then((module) => ({ + default: module.LoadBalancers, + })) +); const NodeBalancers = React.lazy( () => import('src/features/NodeBalancers/NodeBalancers') ); @@ -189,10 +176,13 @@ const Databases = React.lazy(() => import('src/features/Databases')); const BetaRoutes = React.lazy(() => import('src/features/Betas')); const VPC = React.lazy(() => import('src/features/VPCs')); -const MainContent = (props: CombinedProps) => { +export const MainContent = () => { const { classes, cx } = useStyles(); const flags = useFlags(); const { data: preferences } = usePreferences(); + const { mutateAsync: updatePreferences } = useMutatePreferences(); + + const globalErrors = useGlobalErrors(); const NotificationProvider = notificationContext.Provider; const contextValue = useNotificationContext(); @@ -249,7 +239,7 @@ const MainContent = (props: CombinedProps) => { * * So in this case, we'll show something more user-friendly */ - if (props.globalErrors.account_unactivated) { + if (globalErrors.account_unactivated) { return (
@@ -283,139 +273,118 @@ const MainContent = (props: CombinedProps) => { } // If the API is in maintenance mode, return a Maintenance screen - if (props.globalErrors.api_maintenance_mode || ENABLE_MAINTENANCE_MODE) { + if (globalErrors.api_maintenance_mode || ENABLE_MAINTENANCE_MODE) { return ; } + const desktopMenuIsOpen = preferences?.desktop_sidebar_open ?? false; + + const desktopMenuToggle = () => { + updatePreferences({ + desktop_sidebar_open: !preferences?.desktop_sidebar_open, + }); + }; + /** * otherwise just show the rest of the app. */ return ( - ); }); diff --git a/packages/manager/src/components/Breadcrumb/README.md b/packages/manager/src/components/Breadcrumb/README.md deleted file mode 100644 index f7de19527b2..00000000000 --- a/packages/manager/src/components/Breadcrumb/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# Breadcrumb - -The purpose of the Breadcrumb component is to add a versatile and dynamic solution for the manager navigation patterns. - -## Description - -The component utilizes the prop.location provided by RouteComponentProps to build the breadCrumb (based on `location.pathname`). It provides a few props for customizing the output. The last item of the crumbs is removed by default in order to pick either a custom one, an editable text component, or the default crumb we slice back in. - -### Props - -- `pathname` (string, required). Should be the props.location provided by react-router-dom. -- `labelTitle` (string, optional). Customize the last crumb text. -- `labelOptions` (LabelProps, optional). Provide options to customize the label overridden above. See labelOptions Props below. -- `removeCrumbX` (number, optional). Remove a crumb by specifying its actual position in the array/url. -- `crumbOverrides` (CrumbOverridesProps[], optional). The override for a crumb works by the position (required) of the crumb (index + 1). Just provide the actual position of the crumb in the array. We can either override the label or link or both. Omitted values will inherit the path default. It is an array, so we can replace as many as needed. -- `onEditHandlers` (EditableProps, optional). Provide an editable text field for the last crumb (ex: linodes and nodeBalancers details). - -### labelOptions Props - -- `linkTo` (string, optional). Make label a link. -- `prefixComponent` (JSX.Element | null, optional). Provides a prefix component. ex: user detail avatar. -- `prefixStyle` (CSSProperties, optional), customize the styles for the `prefixComponent`. -- `suffixComponent`: (JSX.Element | null, optional). Provides a suffix component. ex: ticket detail status chip. -- `subtitle`: (string, optional). Provide a subtitle to the label. ex: ticket detail submission information. -- `noCap`: (boolean, optional). Override the default capitalization for the label. - -## Usage - -The only required prop is `pathname`. Since we need it to be a string passed from RouteComponentProps, we do need to import the props along with `withRouter` for the export. ex: - -```jsx -import { RouteComponentProps, withRouter } from 'react-router-dom'; - -type Props = RouteComponentProps<{}>; - -class MyComponent extends React.Component { - render() { - return ; - } -} - -export default withRouter(MyComponent); -``` - -You can otherwise refer to the storybook examples to implement the breadcrumb as needed. diff --git a/packages/manager/src/components/Breadcrumb/types.ts b/packages/manager/src/components/Breadcrumb/types.ts index 72bc5d03dd3..b3daee93837 100644 --- a/packages/manager/src/components/Breadcrumb/types.ts +++ b/packages/manager/src/components/Breadcrumb/types.ts @@ -5,7 +5,6 @@ export interface LabelProps { noCap?: boolean; prefixComponent?: JSX.Element | null; prefixStyle?: CSSProperties; - subtitle?: string; suffixComponent?: JSX.Element | null; } diff --git a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx index 5fac4667a1a..40ad820d84f 100644 --- a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx +++ b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx @@ -1,6 +1,6 @@ +import { useTheme } from '@mui/material'; import { Theme, styled } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; import * as React from 'react'; import { Grid } from '../Grid'; diff --git a/packages/manager/src/components/CheckoutSummary/SummaryItem.tsx b/packages/manager/src/components/CheckoutSummary/SummaryItem.tsx index 9b194ce3e8a..096b2b3c47a 100644 --- a/packages/manager/src/components/CheckoutSummary/SummaryItem.tsx +++ b/packages/manager/src/components/CheckoutSummary/SummaryItem.tsx @@ -10,7 +10,12 @@ export const SummaryItem = ({ details, title }: Props) => { {title ? ( <> - + ({ + fontFamily: theme.font.bold, + })} + component="span" + > {title} {' '} diff --git a/packages/manager/src/components/DocsLink/DocsLink.tsx b/packages/manager/src/components/DocsLink/DocsLink.tsx index d3f11c2c8b8..405a483f46c 100644 --- a/packages/manager/src/components/DocsLink/DocsLink.tsx +++ b/packages/manager/src/components/DocsLink/DocsLink.tsx @@ -17,6 +17,8 @@ export interface DocsLinkProps { label?: string; /** A callback function when the link is clicked */ onClick?: () => void; + /* */ + icon?: JSX.Element; } /** @@ -25,7 +27,7 @@ export interface DocsLinkProps { * - Consider displaying the title of a key guide or product document as the link instead of the generic “Docs”. */ export const DocsLink = (props: DocsLinkProps) => { - const { analyticsLabel, href, label, onClick } = props; + const { analyticsLabel, href, label, onClick, icon } = props; return ( { className="docsButton" to={href} > - + {icon ?? } {label ?? 'Docs'} ); diff --git a/packages/manager/src/components/DynamicPriceNotice.tsx b/packages/manager/src/components/DynamicPriceNotice.tsx index 661736b2f9e..900159d5bdd 100644 --- a/packages/manager/src/components/DynamicPriceNotice.tsx +++ b/packages/manager/src/components/DynamicPriceNotice.tsx @@ -1,3 +1,4 @@ +import { useTheme } from '@mui/material'; import React from 'react'; import { useRegionsQuery } from 'src/queries/regions'; @@ -13,6 +14,7 @@ interface Props extends NoticeProps { export const DynamicPriceNotice = (props: Props) => { const { data: regions } = useRegionsQuery(); const { region, ...rest } = props; + const theme = useTheme(); const regionLabel = regions?.find((r) => r.id === region)?.label ?? region; @@ -24,7 +26,7 @@ export const DynamicPriceNotice = (props: Props) => { variant="warning" {...rest} > - + Prices for plans, products, and services in {regionLabel} may vary from other regions.{' '} Learn more. diff --git a/packages/manager/src/components/EnhancedSelect/EnhancedSelect.css b/packages/manager/src/components/EnhancedSelect/EnhancedSelect.css index b34996386e3..d1cf5bfb9d2 100644 --- a/packages/manager/src/components/EnhancedSelect/EnhancedSelect.css +++ b/packages/manager/src/components/EnhancedSelect/EnhancedSelect.css @@ -6,7 +6,6 @@ font-size: 1rem; box-sizing: content-box; transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1), color .2s cubic-bezier(0.4, 0, 0.2, 1); - font-weight: 400; font-family: "LatoWeb", sans-serif; line-height: 1.2em; white-space: initial; diff --git a/packages/manager/src/components/LandingHeader/LandingHeader.tsx b/packages/manager/src/components/LandingHeader/LandingHeader.tsx index 09da70cb8ae..37dbf1aaa00 100644 --- a/packages/manager/src/components/LandingHeader/LandingHeader.tsx +++ b/packages/manager/src/components/LandingHeader/LandingHeader.tsx @@ -2,6 +2,7 @@ import Grid from '@mui/material/Unstable_Grid2'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; +import BetaFeedbackIcon from 'src/assets/icons/icon-feedback.svg'; import { Breadcrumb, BreadcrumbProps, @@ -11,6 +12,7 @@ import { DocsLink } from 'src/components/DocsLink/DocsLink'; export interface LandingHeaderProps { analyticsLabel?: string; + betaFeedbackLink?: string; breadcrumbDataAttrs?: { [key: string]: boolean }; breadcrumbProps?: BreadcrumbProps; buttonDataAttrs?: { [key: string]: boolean | string }; @@ -35,6 +37,7 @@ export interface LandingHeaderProps { */ export const LandingHeader = ({ analyticsLabel, + betaFeedbackLink, breadcrumbDataAttrs, breadcrumbProps, buttonDataAttrs, @@ -86,6 +89,19 @@ export const LandingHeader = ({ {!shouldHideDocsAndCreateButtons && ( + {betaFeedbackLink && ( + + } + /> + + )} {docsLink ? ( ({ color: theme.palette.text.primary, }, color: theme.color.headline, + fontFamily: theme.font.bold, fontSize: '1rem', - fontWeight: 'bold', }, })); diff --git a/packages/manager/src/components/Notice/Notice.test.tsx b/packages/manager/src/components/Notice/Notice.test.tsx index 652004b957f..4078b3d7ac1 100644 --- a/packages/manager/src/components/Notice/Notice.test.tsx +++ b/packages/manager/src/components/Notice/Notice.test.tsx @@ -89,11 +89,11 @@ describe('Notice Component', () => { const { container } = renderWithTheme( ); const typography = container.querySelector('.noticeText'); - expect(typography).toHaveStyle('font-weight: bold'); + expect(typography).toHaveStyle('font-family: monospace'); }); }); diff --git a/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx b/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx index de1dd66f538..b279ec36f83 100644 --- a/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx +++ b/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx @@ -1,10 +1,4 @@ -import { path } from 'ramda'; -import * as React from 'react'; - import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; -import { isNullOrUndefined } from 'src/utilities/nullOrUndefined'; - -type PreferenceValue = boolean | number | string; export interface PreferenceToggleProps { preference: T; @@ -18,171 +12,43 @@ interface RenderChildrenProps { type RenderChildren = (props: RenderChildrenProps) => JSX.Element; -interface Props { +interface Props { children: RenderChildren; initialSetCallbackFn?: (value: T) => void; - localStorageKey?: string; preferenceKey: string; preferenceOptions: [T, T]; toggleCallbackFn?: (value: T) => void; - toggleCallbackFnDebounced?: (value: T) => void; value?: T; } -export const PreferenceToggle = (props: Props) => { +export const PreferenceToggle = (props: Props) => { const { children, preferenceKey, preferenceOptions, toggleCallbackFn, - toggleCallbackFnDebounced, value, } = props; - /** will be undefined and render-block children unless otherwise specified */ - const [currentlySetPreference, setPreference] = React.useState( - value - ); - const [lastUpdated, setLastUpdated] = React.useState(0); - - const { - data: preferences, - error: preferencesError, - refetch: refetchUserPreferences, - } = usePreferences(); + const { data: preferences } = usePreferences(); const { mutateAsync: updateUserPreferences } = useMutatePreferences(); - React.useEffect(() => { - /** - * This useEffect is strictly for when the app first loads - * whether we have a preference error or preference data - */ - - /** - * if for whatever reason we failed to get the preferences data - * just fallback to some default (the first in the list of options). - * - * Do NOT try and PUT to the API - we don't want to overwrite other unrelated preferences - */ - if ( - isNullOrUndefined(currentlySetPreference) && - !!preferencesError && - lastUpdated === 0 - ) { - /** - * get the first set of options - */ - const preferenceToSet = preferenceOptions[0]; - setPreference(preferenceToSet); - - if (props.initialSetCallbackFn) { - props.initialSetCallbackFn(preferenceToSet); - } - } - - /** - * In the case of when we successfully retrieved preferences for the FIRST time, - * set the state to what we got from the server. If the preference - * doesn't exist yet in this user's payload, set defaults in local state. - */ - if ( - isNullOrUndefined(currentlySetPreference) && - !!preferences && - lastUpdated === 0 - ) { - const preferenceFromAPI = path([preferenceKey], preferences); - - /** - * this is the first time the user is setting the user preference - * - * if the API value is null or undefined, default to the first value that was passed to this component from props. - */ - const preferenceToSet = isNullOrUndefined(preferenceFromAPI) - ? preferenceOptions[0] - : preferenceFromAPI; - - setPreference(preferenceToSet); - - /** run callback function if passed one */ - if (props.initialSetCallbackFn) { - props.initialSetCallbackFn(preferenceToSet); - } - } - }, [preferences, preferencesError]); - - React.useEffect(() => { - /** - * we only want to update local state if we already have something set in local state - * setting the initial state is the responsibility of the first useEffect - */ - if (!isNullOrUndefined(currentlySetPreference)) { - const debouncedErrorUpdate = setTimeout(() => { - /** - * we have a preference error, so first GET the preferences - * before trying to PUT them. - * - * Don't update anything if the GET fails - */ - if (!!preferencesError && lastUpdated !== 0) { - /** invoke our callback prop if we have one */ - if ( - toggleCallbackFnDebounced && - !isNullOrUndefined(currentlySetPreference) - ) { - toggleCallbackFnDebounced(currentlySetPreference); - } - refetchUserPreferences() - .then((response) => { - updateUserPreferences({ - ...response.data, - [preferenceKey]: currentlySetPreference, - }).catch(() => /** swallow the error */ null); - }) - .catch(() => /** swallow the error */ null); - } else if ( - !!preferences && - !isNullOrUndefined(currentlySetPreference) && - lastUpdated !== 0 - ) { - /** - * PUT to /preferences on every toggle, debounced. - */ - updateUserPreferences({ - [preferenceKey]: currentlySetPreference, - }).catch(() => /** swallow the error */ null); - - /** invoke our callback prop if we have one */ - if ( - toggleCallbackFnDebounced && - !isNullOrUndefined(currentlySetPreference) - ) { - toggleCallbackFnDebounced(currentlySetPreference); - } - } else if (lastUpdated === 0) { - /** - * this is the case where the app has just been mounted and the preferences are - * being set in local state for the first time - */ - setLastUpdated(Date.now()); - } - }, 500); - - return () => clearTimeout(debouncedErrorUpdate); - } - - return () => null; - }, [currentlySetPreference]); - const togglePreference = () => { - /** first set local state to the opposite option */ - const newPreferenceToSet = - currentlySetPreference === preferenceOptions[0] - ? preferenceOptions[1] - : preferenceOptions[0]; + let newPreferenceToSet: T; + + if (preferences?.[preferenceKey] === undefined) { + // Because we default to preferenceOptions[0], toggling with no preference should pick preferenceOptions[1] + newPreferenceToSet = preferenceOptions[1]; + } else if (preferences[preferenceKey] === preferenceOptions[0]) { + newPreferenceToSet = preferenceOptions[1]; + } else { + newPreferenceToSet = preferenceOptions[0]; + } - /** set the preference in local state */ - setPreference(newPreferenceToSet); + updateUserPreferences({ + [preferenceKey]: newPreferenceToSet, + }).catch(() => /** swallow the error */ null); /** invoke our callback prop if we have one */ if (toggleCallbackFn) { @@ -192,18 +58,8 @@ export const PreferenceToggle = (props: Props) => { return newPreferenceToSet; }; - /** - * render-block the children. We can prevent - * render-blocking by passing a default value as a prop - * - * So if you want to handle local state outside of this component, - * you can do so and pass the value explicitly with the _value_ prop - */ - if (isNullOrUndefined(currentlySetPreference)) { - return null; - } - - return typeof children === 'function' - ? children({ preference: currentlySetPreference, togglePreference }) - : null; + return children({ + preference: value ?? preferences?.[preferenceKey] ?? preferenceOptions[0], + togglePreference, + }); }; diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts new file mode 100644 index 00000000000..392807ae9d1 --- /dev/null +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts @@ -0,0 +1,90 @@ +import { styled } from '@mui/material/styles'; + +import { Box } from 'src/components/Box'; +import { List } from 'src/components/List'; +import { ListItem } from 'src/components/ListItem'; +import { omittedProps } from 'src/utilities/omittedProps'; + +import { Typography } from '../Typography'; + +import type { RemovableSelectionsListProps } from './RemovableSelectionsList'; + +export const StyledNoAssignedLinodesBox = styled(Box, { + label: 'StyledNoAssignedLinodesBox', + shouldForwardProp: omittedProps(['maxWidth']), +})(({ maxWidth, theme }) => ({ + background: theme.name === 'light' ? theme.bg.main : theme.bg.app, + display: 'flex', + flexDirection: 'column', + height: '52px', + justifyContent: 'center', + maxWidth: maxWidth ? `${maxWidth}px` : '416px', + paddingLeft: theme.spacing(2), + width: '100%', +})); + +export const SelectedOptionsHeader = styled(Typography, { + label: 'SelectedOptionsHeader', +})(({ theme }) => ({ + color: theme.color.headline, + fontFamily: theme.font.bold, + marginBottom: theme.spacing(2), +})); + +export const SelectedOptionsList = styled(List, { + label: 'SelectedOptionsList', + shouldForwardProp: omittedProps(['isRemovable']), +})<{ isRemovable?: boolean }>(({ isRemovable, theme }) => ({ + background: theme.name === 'light' ? theme.bg.main : theme.bg.app, + padding: !isRemovable ? `${theme.spacing(2)} 0` : '5px 0', + width: '100%', +})); + +export const SelectedOptionsListItem = styled(ListItem, { + label: 'SelectedOptionsListItem', +})(() => ({ + justifyContent: 'space-between', + paddingBottom: 0, + paddingRight: 4, + paddingTop: 0, +})); + +export const StyledLabel = styled('span', { label: 'StyledLabel' })( + ({ theme }) => ({ + color: theme.color.label, + fontSize: '14px', + }) +); + +type StyledBoxShadowWrapperBoxProps = Pick< + RemovableSelectionsListProps, + 'maxHeight' | 'maxWidth' +>; + +export const StyledBoxShadowWrapper = styled(Box, { + label: 'StyledBoxShadowWrapper', + shouldForwardProp: omittedProps(['displayShadow']), +})<{ displayShadow: boolean; maxWidth: number }>( + ({ displayShadow, maxWidth, theme }) => ({ + '&:after': { + bottom: 0, + content: '""', + height: '15px', + position: 'absolute', + width: '100%', + ...(displayShadow && { + boxShadow: `${theme.color.boxShadow} 0px -15px 10px -10px inset`, + }), + }, + maxWidth: `${maxWidth}px`, + position: 'relative', + }) +); + +export const StyledScrollBox = styled(Box, { + label: 'StyledScrollBox', +})(({ maxHeight, maxWidth }) => ({ + maxHeight: `${maxHeight}px`, + maxWidth: `${maxWidth}px`, + overflow: 'auto', +})); diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.test.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.test.tsx index 916b7c96175..26cb3be5bca 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.test.tsx +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.test.tsx @@ -81,4 +81,12 @@ describe('Removable Selections List', () => { fireEvent.click(removeButton); expect(props.onRemove).toHaveBeenCalled(); }); + + it('should not display the remove button for a list item', () => { + const screen = renderWithTheme( + + ); + const removeButton = screen.queryByLabelText(`remove my-linode-1`); + expect(removeButton).not.toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx index f97bb62af5c..706d4ada54a 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx @@ -1,12 +1,17 @@ import Close from '@mui/icons-material/Close'; -import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { Box } from 'src/components/Box'; import { IconButton } from 'src/components/IconButton'; -import { List } from 'src/components/List'; -import { ListItem } from 'src/components/ListItem'; -import { omittedProps } from 'src/utilities/omittedProps'; + +import { + SelectedOptionsHeader, + SelectedOptionsList, + SelectedOptionsListItem, + StyledBoxShadowWrapper, + StyledLabel, + StyledNoAssignedLinodesBox, + StyledScrollBox, +} from './RemovableSelectionsList.style'; export type RemovableItem = { id: number; @@ -16,7 +21,7 @@ export type RemovableItem = { // Trying to type them as 'unknown' led to type errors. } & { [key: string]: any }; -interface Props { +export interface RemovableSelectionsListProps { /** * The descriptive text to display above the list */ @@ -26,11 +31,11 @@ interface Props { */ isRemovable?: boolean; /** - * The maxHeight of the list component, in px + * The maxHeight of the list component, in px. The default max height is 427px. */ maxHeight?: number; /** - * The maxWidth of the list component, in px + * The maxWidth of the list component, in px. The default max width is 416px. */ maxWidth?: number; /** @@ -53,18 +58,30 @@ interface Props { selectionData: RemovableItem[]; } -export const RemovableSelectionsList = (props: Props) => { +export const RemovableSelectionsList = ( + props: RemovableSelectionsListProps +) => { const { headerText, isRemovable = true, - maxHeight, - maxWidth, + maxHeight = 427, + maxWidth = 416, noDataText, onRemove, preferredDataLabel, selectionData, } = props; + // used to determine when to display a box-shadow to indicate scrollability + const listRef = React.useRef(null); + const [listHeight, setListHeight] = React.useState(0); + + React.useEffect(() => { + if (listRef.current) { + setListHeight(listRef.current.clientHeight); + } + }, [selectionData]); + const handleOnClick = (selection: RemovableItem) => { onRemove(selection); }; @@ -73,37 +90,38 @@ export const RemovableSelectionsList = (props: Props) => { <> {headerText} {selectionData.length > 0 ? ( - maxHeight} + maxWidth={maxWidth} > - {selectionData.map((selection) => ( - - - {preferredDataLabel - ? selection[preferredDataLabel] - : selection.label} - - {isRemovable && ( - + + {selectionData.map((selection) => ( + + + {preferredDataLabel ? selection[preferredDataLabel] - : selection.label - }`} - disableRipple - onClick={() => handleOnClick(selection)} - size="medium" - > - - - )} - - ))} - + : selection.label} + + {isRemovable && ( + handleOnClick(selection)} + size="medium" + > + + + )} + + ))} + + + ) : ( {noDataText} @@ -112,51 +130,3 @@ export const RemovableSelectionsList = (props: Props) => { ); }; - -const StyledNoAssignedLinodesBox = styled(Box, { - label: 'StyledNoAssignedLinodesBox', - shouldForwardProp: omittedProps(['maxWidth']), -})(({ maxWidth, theme }) => ({ - background: theme.name === 'light' ? theme.bg.main : theme.bg.app, - display: 'flex', - flexDirection: 'column', - height: '52px', - justifyContent: 'center', - maxWidth: maxWidth ? `${maxWidth}px` : '416px', - paddingLeft: theme.spacing(2), - width: '100%', -})); - -const SelectedOptionsHeader = styled('h4', { - label: 'SelectedOptionsHeader', -})(({ theme }) => ({ - color: theme.color.headline, - fontFamily: theme.font.bold, - fontSize: '14px', - textTransform: 'initial', -})); - -const SelectedOptionsList = styled(List, { - label: 'SelectedOptionsList', - shouldForwardProp: omittedProps(['isRemovable']), -})<{ isRemovable?: boolean }>(({ isRemovable, theme }) => ({ - background: theme.name === 'light' ? theme.bg.main : theme.bg.app, - overflow: 'auto', - padding: !isRemovable ? '16px 0' : '5px 0', - width: '100%', -})); - -const SelectedOptionsListItem = styled(ListItem, { - label: 'SelectedOptionsListItem', -})(() => ({ - justifyContent: 'space-between', - paddingBottom: 0, - paddingRight: 4, - paddingTop: 0, -})); - -const StyledLabel = styled('span', { label: 'StyledLabel' })(({ theme }) => ({ - color: theme.color.label, - fontFamily: theme.font.semiBold, - fontSize: '14px', -})); diff --git a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx index ceeb04e7dea..9d69c29f55b 100644 --- a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx +++ b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx @@ -1,8 +1,10 @@ import { Firewall } from '@linode/api-v4'; -import { Stack } from 'src/components/Stack'; +import { styled } from '@mui/material/styles'; import * as React from 'react'; +import { Box } from 'src/components/Box'; import { Paper } from 'src/components/Paper'; +import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { CreateFirewallDrawer } from 'src/features/Firewalls/FirewallLanding/CreateFirewallDrawer'; import { useFirewallsQuery } from 'src/queries/firewalls'; @@ -71,12 +73,11 @@ export const SelectFirewallPanel = (props: Props) => { placeholder={'None'} value={selectedFirewall} /> - - Create Firewall - + + + Create Firewall + + setIsDrawerOpen(false)} @@ -87,3 +88,11 @@ export const SelectFirewallPanel = (props: Props) => { ); }; + +export const StyledLinkButtonBox = styled(Box, { + label: 'StyledLinkButtonBox', +})({ + display: 'flex', + justifyContent: 'flex-start', + marginTop: '12px', +}); diff --git a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx index 83f2ef3d3bc..f1ff5ab5d2a 100644 --- a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx +++ b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx @@ -1,4 +1,5 @@ import { Region } from '@linode/api-v4/lib/regions'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { useLocation } from 'react-router-dom'; @@ -50,6 +51,7 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { const location = useLocation(); const flags = useFlags(); + const theme = useTheme(); const params = getQueryParamsFromQueryString(location.search); const isCloning = /clone/i.test(params.type); @@ -132,7 +134,7 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { spacingTop={8} variant="warning" > - + {CROSS_DATA_CENTER_CLONE_WARNING} @@ -152,7 +154,7 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { spacingTop={12} variant="warning" > - + {DIFFERENT_PRICE_STRUCTURE_WARNING}{' '} Learn more. diff --git a/packages/manager/src/components/ShowMore/ShowMore.tsx b/packages/manager/src/components/ShowMore/ShowMore.tsx index 7c8d41682ba..db2ead1512f 100644 --- a/packages/manager/src/components/ShowMore/ShowMore.tsx +++ b/packages/manager/src/components/ShowMore/ShowMore.tsx @@ -75,7 +75,7 @@ const StyledChip = styled(Chip)(({ theme }) => ({ color: 'white', }, backgroundColor: theme.bg.lightBlue1, - fontWeight: 500, + fontFamily: theme.font.bold, lineHeight: 1, marginLeft: theme.spacing(0.5), paddingLeft: 2, diff --git a/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.stories.mdx b/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.stories.mdx index 68e5de2e604..6091eabb85f 100644 --- a/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.stories.mdx +++ b/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.stories.mdx @@ -9,7 +9,11 @@ import { ShowMoreExpansion } from './ShowMoreExpansion'; # Show More Expansion -export const Template = (args) => ; +export const Template = (args) => ( + + {args.children} + +); { + React.useEffect(() => { + // @TODO: The utilility cases a scrollbar to show in the browser, fix it. + srSpeak('Loading Linode Cloud Manager', 'polite'); + }, []); + + return ( + + + + ); +}; diff --git a/packages/manager/src/components/SplashScreen/SplashScreen.tsx b/packages/manager/src/components/SplashScreen/SplashScreen.tsx deleted file mode 100644 index 2b062e6252e..00000000000 --- a/packages/manager/src/components/SplashScreen/SplashScreen.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { styled } from '@mui/material/styles'; -import * as React from 'react'; -import { connect } from 'react-redux'; -import { compose } from 'recompose'; - -import { CircleProgress } from 'src/components/CircleProgress'; -import { useFeatureFlagsLoad } from 'src/hooks/useFeatureFlagLoad'; -import { MapState } from 'src/store/types'; -import { srSpeak } from 'src/utilities/accessibility'; - -const SplashScreen = (props: StateProps) => { - React.useEffect(() => { - srSpeak('Loading Linode Cloud Manager', 'polite'); - }, []); - - const { featureFlagsLoading } = useFeatureFlagsLoad(); - - return props.appIsLoading || featureFlagsLoading ? ( - - - - ) : null; -}; - -interface StateProps { - appIsLoading: boolean; -} - -const mapStateToProps: MapState = (state) => ({ - appIsLoading: state.initialLoad.appIsLoading, -}); - -const connected = connect(mapStateToProps); - -export default compose(connected, React.memo)(SplashScreen); - -const StyledDiv = styled('div')(({ theme }) => ({ - alignItems: 'center', - backgroundColor: theme.bg.main, - display: 'flex', - height: '100vh', - justifyContent: 'center', - left: 0, - position: 'fixed', - top: 0, - width: '100vw', - zIndex: 100, -})); diff --git a/packages/manager/src/components/SplashScreen/index.ts b/packages/manager/src/components/SplashScreen/index.ts deleted file mode 100644 index 9a83fef2531..00000000000 --- a/packages/manager/src/components/SplashScreen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './SplashScreen'; diff --git a/packages/manager/src/components/StatusIcon/StatusIcon.stories.mdx b/packages/manager/src/components/StatusIcon/StatusIcon.stories.mdx deleted file mode 100644 index 188b9a0b515..00000000000 --- a/packages/manager/src/components/StatusIcon/StatusIcon.stories.mdx +++ /dev/null @@ -1,29 +0,0 @@ -import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs'; -import { StatusIcon } from './StatusIcon'; - - - -export const StatusIconTemplate = (args) => ; - -# Status Icons - - - - {StatusIconTemplate.bind({})} - - - - diff --git a/packages/manager/src/components/StatusIcon/StatusIcon.stories.tsx b/packages/manager/src/components/StatusIcon/StatusIcon.stories.tsx new file mode 100644 index 00000000000..efa2956860c --- /dev/null +++ b/packages/manager/src/components/StatusIcon/StatusIcon.stories.tsx @@ -0,0 +1,24 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { StatusIcon } from './StatusIcon'; + +const meta: Meta = { + component: StatusIcon, + title: 'Components/StatusIcon', +}; + +type Story = StoryObj; + +export const Default: Story = { + args: { + pulse: false, + status: 'active', + }, + parameters: { + controls: { include: ['pulse', 'status', 'ariaLabel'] }, + }, + render: (args) => , +}; + +export default meta; diff --git a/packages/manager/src/components/StatusIcon/StatusIcon.tsx b/packages/manager/src/components/StatusIcon/StatusIcon.tsx index 78bd4ddea51..fab623fe350 100644 --- a/packages/manager/src/components/StatusIcon/StatusIcon.tsx +++ b/packages/manager/src/components/StatusIcon/StatusIcon.tsx @@ -8,12 +8,23 @@ import { Box, BoxProps } from '../Box'; export type Status = 'active' | 'error' | 'inactive' | 'other'; export interface StatusProps extends BoxProps { + /** + * Optional property can override the value of the default aria label for status. + * This is useful when the status is not descriptive enough. + */ + ariaLabel?: string; + /** + * When true, displays the icon with a pulsing animation. + */ pulse?: boolean; + /** + * Status of the icon. + */ status: Status; } -const StatusIcon = React.memo((props: StatusProps) => { - const { pulse, status, ...rest } = props; +export const StatusIcon = React.memo((props: StatusProps) => { + const { ariaLabel, pulse, status, ...rest } = props; const shouldPulse = pulse === undefined @@ -21,11 +32,16 @@ const StatusIcon = React.memo((props: StatusProps) => { !['active', 'error', 'inactive'].includes(status) : pulse; - return ; + return ( + + ); }); -export { StatusIcon }; - const StyledDiv = styled(Box, { shouldForwardProp: omittedProps(['pulse', 'status']), })(({ theme, ...props }) => ({ diff --git a/packages/manager/src/components/TableCell/TableCell.tsx b/packages/manager/src/components/TableCell/TableCell.tsx index 16b29c884d9..e9d02a6cff8 100644 --- a/packages/manager/src/components/TableCell/TableCell.tsx +++ b/packages/manager/src/components/TableCell/TableCell.tsx @@ -55,11 +55,11 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, '& button, & button:focus': { color: theme.color.headline, - fontWeight: 'normal', + fontFamily: theme.font.normal, }, color: theme.color.headline, cursor: 'pointer', - fontWeight: 'normal', + fontFamily: theme.font.normal, }, status: { alignItems: 'center', diff --git a/packages/manager/src/components/Tag/Tag.stories.mdx b/packages/manager/src/components/Tag/Tag.stories.mdx deleted file mode 100644 index 90bb3adc178..00000000000 --- a/packages/manager/src/components/Tag/Tag.stories.mdx +++ /dev/null @@ -1,63 +0,0 @@ -import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs'; -import { Tag } from './Tag'; - - - -# Tag - -export const Template = (args) => ; - - - void' }, - }, - }, - }} - > - {Template.bind({})} - - - - diff --git a/packages/manager/src/components/Tag/Tag.stories.tsx b/packages/manager/src/components/Tag/Tag.stories.tsx new file mode 100644 index 00000000000..1a30037ecae --- /dev/null +++ b/packages/manager/src/components/Tag/Tag.stories.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { Tag } from './Tag'; + +import type { TagProps } from './Tag'; +import type { Meta, StoryObj } from '@storybook/react'; + +export const Default: StoryObj = { + render: (args: TagProps) => , +}; + +export const WithMaxLength: StoryObj = { + render: (args: TagProps) => ( + + ), +}; + +const meta: Meta = { + args: { + colorVariant: 'lightBlue', + label: 'Tag', + }, + component: Tag, + title: 'Components/Chip/Tag', +}; +export default meta; diff --git a/packages/manager/src/components/Tag/Tag.styles.ts b/packages/manager/src/components/Tag/Tag.styles.ts new file mode 100644 index 00000000000..3010a22d869 --- /dev/null +++ b/packages/manager/src/components/Tag/Tag.styles.ts @@ -0,0 +1,101 @@ +import { styled } from '@mui/material/styles'; + +import { Chip } from 'src/components/Chip'; +import { omittedProps } from 'src/utilities/omittedProps'; + +import { StyledLinkButton } from '../Button/StyledLinkButton'; + +import type { TagProps } from './Tag'; + +export const StyledChip = styled(Chip, { + shouldForwardProp: omittedProps(['colorVariant', 'closeMenu', 'maxLength']), +})(({ theme, ...props }) => ({ + '& .MuiChip-label': { + '&:hover': { + borderBottomRightRadius: props.onDelete && 0, + borderTopRightRadius: props.onDelete && 0, + }, + borderRadius: 4, + color: theme.name === 'light' ? '#3a3f46' : '#fff', + fontWeight: 'normal', + maxWidth: 350, + padding: '7px 10px', + }, + // Targets first span (tag label) + '& > span': { + borderBottomRightRadius: 0, + borderRadius: 3, + borderTopRightRadius: 0, + padding: '7px 10px', + }, + '&:focus': { + ['& .StyledDeleteButton']: { + color: theme.color.tagIcon, + }, + backgroundColor: theme.color.tagButton, + }, + // Overrides MUI chip default styles so these appear as separate elements. + '&:hover': { + ['& .StyledDeleteButton']: { + color: theme.color.tagIcon, + }, + backgroundColor: theme.color.tagButton, + }, + fontSize: '0.875rem', + height: 30, + padding: 0, + ...(props.colorVariant === 'blue' && { + '& > span': { + '&:hover, &:focus': { + backgroundColor: theme.palette.primary.main, + color: 'white', + }, + color: 'white', + }, + + backgroundColor: theme.palette.primary.main, + }), + ...(props.colorVariant === 'lightBlue' && { + '& > span': { + '&:focus': { + backgroundColor: theme.color.tagButton, + color: theme.color.black, + }, + '&:hover': { + backgroundColor: theme.palette.primary.main, + color: 'white', + }, + }, + backgroundColor: theme.color.tagButton, + }), +})); + +export const StyledDeleteButton = styled(StyledLinkButton, { + label: 'StyledDeleteButton', +})(({ theme }) => ({ + '& svg': { + borderRadius: 0, + color: theme.color.tagIcon, + height: 15, + width: 15, + }, + '&:focus': { + backgroundColor: theme.bg.lightBlue1, + color: theme.color.black, + }, + '&:hover': { + '& svg': { + color: 'white', + }, + backgroundColor: theme.palette.primary.main, + color: 'white', + }, + borderBottomRightRadius: 3, + borderLeft: `1px solid ${theme.name === 'light' ? '#fff' : '#2e3238'}`, + borderRadius: 0, + borderTopRightRadius: 3, + height: 30, + margin: 0, + minWidth: 30, + padding: theme.spacing(), +})); diff --git a/packages/manager/src/components/Tag/Tag.test.tsx b/packages/manager/src/components/Tag/Tag.test.tsx new file mode 100644 index 00000000000..ef56e911da7 --- /dev/null +++ b/packages/manager/src/components/Tag/Tag.test.tsx @@ -0,0 +1,46 @@ +import { fireEvent } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { Tag, TagProps } from './Tag'; + +describe('Tag Component', () => { + const defaultProps: TagProps = { + colorVariant: 'lightBlue', + label: 'Test Label', + }; + + it('renders correctly with required props', () => { + const { getByRole, getByText } = renderWithTheme(); + const tagElement = getByText('Test Label'); + const searchButton = getByRole('button'); + + expect(tagElement).toBeInTheDocument(); + expect(searchButton).toHaveAttribute( + 'aria-label', + `Search for Tag 'Test Label'` + ); + }); + + it('truncates the label if maxLength is provided', () => { + const { getByText } = renderWithTheme( + + ); + const tagElement = getByText('Lo...'); + expect(tagElement).toBeInTheDocument(); + }); + + it('calls closeMenu when clicked', () => { + const closeMenuMock = jest.fn(); + + const { getByText } = renderWithTheme( + + ); + + const tagElement = getByText('Test Label'); + fireEvent.click(tagElement); + + expect(closeMenuMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/components/Tag/Tag.tsx b/packages/manager/src/components/Tag/Tag.tsx index 92e40ef9774..ba0bc40ca92 100644 --- a/packages/manager/src/components/Tag/Tag.tsx +++ b/packages/manager/src/components/Tag/Tag.tsx @@ -1,33 +1,63 @@ import Close from '@mui/icons-material/Close'; -import { styled } from '@mui/material/styles'; -import { omit } from 'lodash'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { Chip, ChipProps } from 'src/components/Chip'; import { truncateEnd } from 'src/utilities/truncate'; +import { StyledChip, StyledDeleteButton } from './Tag.styles'; + +import type { ChipProps } from 'src/components/Chip'; + type Variants = 'blue' | 'lightBlue'; export interface TagProps extends ChipProps { - asSuggestion?: boolean; + /** + * Callback fired when the delete icon is clicked. + */ closeMenu?: any; + /** + * The variant to use. + */ colorVariant: Variants; + /** + * The component used for the root node. Either a string representation of an HTML element or a component. + */ component?: React.ElementType; + /** + * The content of the label. + */ label: string; + /** + * The maximum length of the tag label. If the label exceeds this length, it will be truncated. + * Must be greater than 4. + */ maxLength?: number; } +/** + * This component is an abstraction of the Chip component. + * It is used to display deletable tags in the Linode Manager. + * It contains two elements: + * - a label, linking to its corresponding tag search result page + * - an optional delete icon + */ export const Tag = (props: TagProps) => { - const { className, label, maxLength, ...chipProps } = props; + const { + className, + closeMenu, + component = 'div', + label, + maxLength, + ...chipProps + } = props; const history = useHistory(); const handleClick = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - if (props.asSuggestion) { - props.closeMenu(); + if (closeMenu) { + closeMenu(); } history.push(`/search/?query=tag:${label}`); }; @@ -35,15 +65,13 @@ export const Tag = (props: TagProps) => { // If maxLength is set, truncate display to that length. const _label = maxLength ? truncateEnd(label, maxLength) : label; - const tagProps = omit(props, ['asSuggestion', 'closeMenu']); - return ( @@ -51,10 +79,10 @@ export const Tag = (props: TagProps) => { ) : undefined } - aria-label={`Search for Tag "${label}"`} + aria-label={`Search for Tag '${label}'`} className={className} clickable - component="div" + component={component} data-qa-tag={label} label={_label} onClick={handleClick} @@ -62,94 +90,3 @@ export const Tag = (props: TagProps) => { /> ); }; - -const StyledChip = styled(Chip, { - shouldForwardProp: (prop) => prop !== 'colorVariant', -})(({ theme, ...props }) => ({ - '& .MuiChip-label': { - '&:hover': { - borderBottomRightRadius: props.onDelete && 0, - borderTopRightRadius: props.onDelete && 0, - }, - borderRadius: 4, - color: theme.name === 'light' ? '#3a3f46' : '#9caec9', - maxWidth: 350, - padding: '7px 10px', - }, - // Targets first span (tag label) - '& > span': { - borderBottomRightRadius: 0, - borderRadius: 3, - borderTopRightRadius: 0, - padding: '7px 10px', - }, - '&:focus': { - ['& .StyledDeleteButton']: { - color: theme.color.tagIcon, - }, - backgroundColor: theme.color.tagButton, - }, - // Overrides MUI chip default styles so these appear as separate elements. - '&:hover': { - ['& .StyledDeleteButton']: { - color: theme.color.tagIcon, - }, - backgroundColor: theme.color.tagButton, - }, - fontSize: '0.875rem', - height: 30, - padding: 0, - ...(props.colorVariant === 'blue' && { - '& > span': { - color: 'white', - }, - '&:hover, &:focus': { - backgroundColor: theme.palette.primary.main, - }, - backgroundColor: theme.palette.primary.main, - }), - ...(props.colorVariant === 'lightBlue' && { - '& > span': { - '&:focus': { - backgroundColor: theme.color.tagButton, - color: theme.color.black, - }, - '&:hover': { - backgroundColor: theme.palette.primary.main, - color: 'white', - }, - }, - backgroundColor: theme.color.tagButton, - }), -})); - -const StyledDeleteButton = styled('button', { label: 'StyledDeleteButton' })( - ({ theme }) => ({ - ...theme.applyLinkStyles, - '& svg': { - borderRadius: 0, - color: theme.color.tagIcon, - height: 15, - width: 15, - }, - '&:focus': { - backgroundColor: theme.bg.lightBlue1, - color: theme.color.black, - }, - '&:hover': { - '& svg': { - color: 'white', - }, - backgroundColor: theme.palette.primary.main, - color: 'white', - }, - borderBottomRightRadius: 3, - borderLeft: `1px solid ${theme.name === 'light' ? '#fff' : '#2e3238'}`, - borderRadius: 0, - borderTopRightRadius: 3, - height: 30, - margin: 0, - minWidth: 30, - padding: theme.spacing(), - }) -); diff --git a/packages/manager/src/components/TagCell/TagCell.tsx b/packages/manager/src/components/TagCell/TagCell.tsx index 66ef66a552d..217fb603e7b 100644 --- a/packages/manager/src/components/TagCell/TagCell.tsx +++ b/packages/manager/src/components/TagCell/TagCell.tsx @@ -199,9 +199,8 @@ const StyledAddTagButton = styled('button')(({ theme }) => ({ color: theme.textColors.linkActiveLight, cursor: 'pointer', display: 'flex', - fontFamily: theme.font.normal, + fontFamily: theme.font.bold, fontSize: 14, - fontWeight: 'bold', height: 30, paddingLeft: 10, paddingRight: 10, diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.tsx index 032c56e85c8..023b878a449 100644 --- a/packages/manager/src/components/TagsPanel/TagsPanel.tsx +++ b/packages/manager/src/components/TagsPanel/TagsPanel.tsx @@ -40,9 +40,8 @@ const useStyles = makeStyles()((theme: Theme) => ({ color: theme.textColors.linkActiveLight, cursor: 'pointer', display: 'flex', - fontFamily: theme.font.normal, + fontFamily: theme.font.bold, fontSize: '0.875rem', - fontWeight: 'bold', justifyContent: 'center', padding: '7px 10px', whiteSpace: 'nowrap', diff --git a/packages/manager/src/components/Typography.ts b/packages/manager/src/components/Typography.tsx similarity index 100% rename from packages/manager/src/components/Typography.ts rename to packages/manager/src/components/Typography.tsx diff --git a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.styles.ts b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.styles.ts index 03eb7eff8c1..4d8bc9d619c 100644 --- a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.styles.ts +++ b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.styles.ts @@ -44,10 +44,10 @@ export const CustomStepIcon = styled(StepIcon, { label: 'StyledCircleIcon' })( export const StyledColorlibConnector = styled(StepConnector, { label: 'StyledColorlibConnector', -})(() => ({ +})(({ theme }) => ({ '& .MuiStepConnector-line': { borderColor: '#eaeaf0', borderLeftWidth: '3px', - minHeight: '28px', + minHeight: theme.spacing(2), }, })); diff --git a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx index 7efed690d24..e76f6732885 100644 --- a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx +++ b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx @@ -7,9 +7,12 @@ import { } from '@mui/material'; import Box from '@mui/material/Box'; import { Theme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { useTheme } from '@mui/styles'; import React, { useState } from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { convertToKebabCase } from 'src/utilities/convertToKebobCase'; import { CustomStepIcon, @@ -23,7 +26,7 @@ type VerticalLinearStep = { label: string; }; -interface VerticalLinearStepperProps { +export interface VerticalLinearStepperProps { steps: VerticalLinearStep[]; } @@ -31,6 +34,9 @@ export const VerticalLinearStepper = ({ steps, }: VerticalLinearStepperProps) => { const [activeStep, setActiveStep] = useState(0); + const theme = useTheme(); + + const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); const handleNext = () => { setActiveStep((prevActiveStep) => prevActiveStep + 1); @@ -45,9 +51,8 @@ export const VerticalLinearStepper = ({ sx={(theme: Theme) => ({ backgroundColor: theme.bg.bgPaper, display: 'flex', - margin: 'auto', - maxWidth: 800, - p: `${theme.spacing(2)}`, + flexDirection: matchesSmDown ? 'column' : 'row', + p: matchesSmDown ? `${theme.spacing(2)}px 0px` : `${theme.spacing(2)}`, })} > {/* Left Column - Vertical Steps */} @@ -101,7 +106,16 @@ export const VerticalLinearStepper = ({ {steps.map(({ content, handler, label }, index) => ( {index === activeStep ? ( - + ({ bgcolor: theme.bg.app, @@ -116,9 +130,13 @@ export const VerticalLinearStepper = ({ primaryButtonProps={ index !== 2 ? { - 'data-testid': steps[ - index + 1 - ]?.label.toLocaleLowerCase(), + /** Generate a 'data-testid' attribute value based on the label of the next step. + * 1. toLocaleLowerCase(): Converts the label to lowercase for consistency. + * 2. replace(/\s/g, ''): Removes spaces from the label to create a valid test ID. + */ + 'data-testid': convertToKebabCase( + steps[index + 1]?.label + ), label: `Next: ${steps[index + 1]?.label}`, onClick: () => { handleNext(); diff --git a/packages/manager/src/dev-tools/EnvironmentToggleTool.tsx b/packages/manager/src/dev-tools/EnvironmentToggleTool.tsx index bfe6c2924f9..d7713089e56 100644 --- a/packages/manager/src/dev-tools/EnvironmentToggleTool.tsx +++ b/packages/manager/src/dev-tools/EnvironmentToggleTool.tsx @@ -48,7 +48,7 @@ const options = getOptions(import.meta.env); // This component works by setting local storage values that override the API_ROOT, LOGIN_ROOT, // and CLIENT_ID environment variables, giving client-side control over the environment. -const EnvironmentToggleTool: React.FC<{}> = () => { +export const EnvironmentToggleTool = () => { const [selectedOption, setSelectedOption] = React.useState(0); const localStorageEnv = storage.devToolsEnv.get(); @@ -97,5 +97,3 @@ const EnvironmentToggleTool: React.FC<{}> = () => { ); }; - -export default React.memo(EnvironmentToggleTool); diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 70aabf86cf9..20897fab9eb 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -15,14 +15,16 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'metadata', label: 'Metadata' }, { flag: 'vpc', label: 'VPC' }, { flag: 'aglb', label: 'AGLB' }, + { flag: 'aglbFullCreateFlow', label: 'AGLB Full Create Flow' }, { flag: 'unifiedMigrations', label: 'Unified Migrations' }, + { flag: 'dcGetWell', label: 'DC Get Well' }, { flag: 'dcSpecificPricing', label: 'DC-Specific Pricing' }, { flag: 'objDcSpecificPricing', label: 'OBJ Storage DC-Specific Pricing' }, { flag: 'selfServeBetas', label: 'Self Serve Betas' }, { flag: 'soldOutTokyo', label: 'Sold Out Tokyo' }, ]; -const FeatureFlagTool: React.FC<{}> = () => { +export const FeatureFlagTool = withFeatureFlagProvider(() => { const dispatch: Dispatch = useDispatch(); const flags = useFlags(); @@ -76,6 +78,4 @@ const FeatureFlagTool: React.FC<{}> = () => { ); -}; - -export default withFeatureFlagProvider(FeatureFlagTool); +}); diff --git a/packages/manager/src/dev-tools/MockDataTool.tsx b/packages/manager/src/dev-tools/MockDataTool.tsx index 0d7db3860f8..eab11ae37d8 100644 --- a/packages/manager/src/dev-tools/MockDataTool.tsx +++ b/packages/manager/src/dev-tools/MockDataTool.tsx @@ -1,65 +1,17 @@ import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; -import ServiceWorkerTool from './ServiceWorkerTool'; -import { MockData, mockDataController } from './mockDataController'; - -const options: { key: keyof MockData; label: string }[] = [ - { key: 'linode', label: 'Linodes' }, - { key: 'nodeBalancer', label: 'NodeBalancers' }, - { key: 'domain', label: 'Domains' }, - { key: 'volume', label: 'Volumes' }, -]; - -const MockDataTool: React.FC<{}> = () => { - // Keep track of mockData state locally to this component, so it can be referenced during render. - const [localMockData, setLocalMockData] = React.useState( - mockDataController.mockData - ); - - const handleInputChange = (key: keyof MockData, quantity: number) => { - const newMockData: MockData = { [key]: { mocked: true, quantity } }; - mockDataController.updateMockData(newMockData); - }; - - React.useEffect(() => { - // Subscribe to mockData changes so this components local copy can be updated. - const token = mockDataController.subscribe((newMockData) => { - setLocalMockData(newMockData); - }); - return () => mockDataController.unsubscribe(token); - }, []); - - // @todo: The MockData interface has a `template` field, which could be used to allow entry of - // specific fields, like label, region, etc. (via or even JSON entry?) +import { ServiceWorkerTool } from './ServiceWorkerTool'; +export const MockDataTool = () => { return (

Mock Data

- - {options.map((thisOption) => { - return ( -
- - - handleInputChange(thisOption.key, Number(e.target.value)) - } - min="0" - type="number" - value={localMockData[thisOption.key]?.quantity ?? 0} - /> -
- ); - })} -
); }; - -export default MockDataTool; diff --git a/packages/manager/src/dev-tools/ServiceWorkerTool.tsx b/packages/manager/src/dev-tools/ServiceWorkerTool.tsx index b08f487266c..38190d8ccf0 100644 --- a/packages/manager/src/dev-tools/ServiceWorkerTool.tsx +++ b/packages/manager/src/dev-tools/ServiceWorkerTool.tsx @@ -1,43 +1,28 @@ import * as React from 'react'; -import { worker } from '../mocks/testBrowser'; +const LOCAL_STORAGE_KEY = 'msw'; -export const ServiceWorkerTool: React.FC<{}> = (_) => { - const _workerActive = - localStorage.getItem('devTools/mock-service-worker-enabled') ?? 'disabled'; - const workerActive = _workerActive === 'enabled'; +export const isMSWEnabled = + localStorage.getItem(LOCAL_STORAGE_KEY) === 'enabled'; - React.useEffect(() => { - if (workerActive) { - worker.start(); - } else { - worker.stop(); - } - }, [workerActive]); - - const handleToggleWorker = (e: React.ChangeEvent) => { - const checked = e.target.checked; - localStorage.setItem( - 'devTools/mock-service-worker-enabled', - checked ? 'enabled' : 'disabled' - ); - window.location.reload(); - }; +export const setMSWEnabled = (enabled: boolean) => { + localStorage.setItem(LOCAL_STORAGE_KEY, enabled ? 'enabled' : 'disabled'); + window.location.reload(); +}; +export const ServiceWorkerTool = () => { return ( <> Mock Service Worker: - {workerActive ? 'Enabled' : 'Disabled'} + {isMSWEnabled ? 'Enabled' : 'Disabled'} handleToggleWorker(e)} + checked={isMSWEnabled} + onChange={(e) => setMSWEnabled(e.target.checked)} style={{ margin: 0 }} type="checkbox" /> ); }; - -export default ServiceWorkerTool; diff --git a/packages/manager/src/dev-tools/dev-tools.tsx b/packages/manager/src/dev-tools/dev-tools.tsx index bf7900c3da2..d24f54ad37b 100644 --- a/packages/manager/src/dev-tools/dev-tools.tsx +++ b/packages/manager/src/dev-tools/dev-tools.tsx @@ -6,14 +6,12 @@ import { Provider } from 'react-redux'; import { ENABLE_DEV_TOOLS, isProductionBuild } from 'src/constants'; import { ApplicationStore } from 'src/store'; -import EnvironmentToggleTool from './EnvironmentToggleTool'; -import FeatureFlagTool from './FeatureFlagTool'; -import MockDataTool from './MockDataTool'; +import { EnvironmentToggleTool } from './EnvironmentToggleTool'; +import { FeatureFlagTool } from './FeatureFlagTool'; +import { MockDataTool } from './MockDataTool'; import './dev-tools.css'; function install(store: ApplicationStore) { - (window as any).devToolsEnabled = true; - function DevTools() { return (
diff --git a/packages/manager/src/dev-tools/load.ts b/packages/manager/src/dev-tools/load.ts index 3bb024d0866..9b0703ff810 100644 --- a/packages/manager/src/dev-tools/load.ts +++ b/packages/manager/src/dev-tools/load.ts @@ -1,13 +1,23 @@ import { ENABLE_DEV_TOOLS } from 'src/constants'; import { ApplicationStore } from 'src/store'; +import { isMSWEnabled } from './ServiceWorkerTool'; + /** * Use this to dynamicly import our custom dev-tools ONLY when they * are needed. * @param store Redux store to control */ export async function loadDevTools(store: ApplicationStore) { - await import('./dev-tools').then((devTools) => devTools.install(store)); + const devTools = await import('./dev-tools'); + + if (isMSWEnabled) { + const { worker } = await import('../mocks/testBrowser'); + + await worker.start({ onUnhandledRequest: 'bypass' }); + } + + devTools.install(store); } /** diff --git a/packages/manager/src/dev-tools/mockDataController.ts b/packages/manager/src/dev-tools/mockDataController.ts deleted file mode 100644 index 4f266f2ea31..00000000000 --- a/packages/manager/src/dev-tools/mockDataController.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Domain } from '@linode/api-v4/lib/domains/types'; -import { Linode } from '@linode/api-v4/lib/linodes/types'; -import { NodeBalancer } from '@linode/api-v4/lib/nodebalancers/types'; -import { Volume } from '@linode/api-v4/lib/volumes/types'; -import { v4 } from 'uuid'; - -// Simple pub/sub class to keep track of mock data for the local dev tools. This allows subscription -// from the mock service worker, so the handlers can be updated whenever the mock data changes. -export type MockDataType = 'domain' | 'linode' | 'nodeBalancer' | 'volume'; - -export interface MockDataEntity { - mocked: boolean; - quantity: number; - template?: Partial; -} - -export interface MockData { - domain?: MockDataEntity; - linode?: MockDataEntity; - nodeBalancer?: MockDataEntity; - volume?: MockDataEntity; -} - -export type SubscribeFunction = (mockData: MockData) => void; - -export class MockDataController { - constructor() { - this.subscribers = {}; - this.mockData = {}; - } - private notifySubscribers() { - Object.values(this.subscribers).forEach((thisSubscriber) => { - thisSubscriber(this.mockData); - }); - } - - subscribe(fn: SubscribeFunction) { - const id = v4(); - this.subscribers[id] = fn; - return id; - } - - unsubscribe(token: string) { - delete this.subscribers[token]; - } - - updateMockData(newMockData: MockData) { - this.mockData = { ...this.mockData, ...newMockData }; - this.notifySubscribers(); - } - - mockData: MockData; - - subscribers: Record; -} - -export const mockDataController = new MockDataController(); diff --git a/packages/manager/src/factories/accountAvailability.ts b/packages/manager/src/factories/accountAvailability.ts new file mode 100644 index 00000000000..3ce664a088f --- /dev/null +++ b/packages/manager/src/factories/accountAvailability.ts @@ -0,0 +1,14 @@ +import { AccountAvailability } from '@linode/api-v4'; +import * as Factory from 'factory.ts'; + +import { pickRandom } from 'src/utilities/random'; + +export const accountAvailabilityFactory = Factory.Sync.makeFactory( + { + id: pickRandom(['us-mia', 'ap-south', 'ap-northeast']), + unavailable: pickRandom([ + ['Block Storage'], + ['Linodes', 'Block Storage', 'Kubernetes', 'NodeBalancers'], + ]), + } +); diff --git a/packages/manager/src/factories/aglb.ts b/packages/manager/src/factories/aglb.ts index eb438a7d04d..ba4022defb5 100644 --- a/packages/manager/src/factories/aglb.ts +++ b/packages/manager/src/factories/aglb.ts @@ -258,7 +258,7 @@ export const createRouteFactory = Factory.Sync.makeFactory({ // ************************* export const serviceTargetFactory = Factory.Sync.makeFactory({ - ca_certificate: 'my-cms-certificate', + ca_certificate: 'certificate-0', endpoints: [ { ip: '192.168.0.100', @@ -276,7 +276,7 @@ export const serviceTargetFactory = Factory.Sync.makeFactory({ unhealthy_threshold: 5, }, id: Factory.each((i) => i), - label: Factory.each((i) => `images-backend-aws-${i}`), + label: Factory.each((i) => `service-target-${i}`), load_balancing_policy: 'round_robin', }); diff --git a/packages/manager/src/factories/index.ts b/packages/manager/src/factories/index.ts index 1d27e268b33..e2b9be33241 100644 --- a/packages/manager/src/factories/index.ts +++ b/packages/manager/src/factories/index.ts @@ -1,4 +1,5 @@ export * from './account'; +export * from './accountAvailability'; export * from './accountSettings'; export * from './accountMaintenance'; export * from './accountOAuth'; diff --git a/packages/manager/src/factories/regions.ts b/packages/manager/src/factories/regions.ts index 8e0dd022843..8d3475e9dc1 100644 --- a/packages/manager/src/factories/regions.ts +++ b/packages/manager/src/factories/regions.ts @@ -1,6 +1,12 @@ -import { DNSResolvers, Region } from '@linode/api-v4/lib/regions/types'; +import { + DNSResolvers, + Region, + RegionAvailability, +} from '@linode/api-v4/lib/regions/types'; import * as Factory from 'factory.ts'; +import { pickRandom } from 'src/utilities/random'; + export const resolverFactory = Factory.Sync.makeFactory({ ipv4: '1.1.1.1', ipv6: '2600:3c03::', @@ -34,3 +40,15 @@ export const regionWithDynamicPricingFactory = Factory.Sync.makeFactory( status: 'ok', } ); + +export const regionAvailabilityFactory = Factory.Sync.makeFactory( + { + available: false, + // TODO SOLD OUT PLANS - Remove this comment once the API is changed: Note that the mock data below doesn't match what the API + // currently returns for plans; however, the API will be changing soon to match the below labels (ex: g7-premium-#, g1-gpu-rtx-6000-#) + plan: Factory.each((id) => + pickRandom([`g7-premium-${id}`, `g1-gpu-rtx6000-${id}`]) + ), + region: Factory.each((id) => `us-${id}`), + } +); diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index b8e039d1ff7..027a49547cf 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -41,9 +41,11 @@ type OneClickApp = Record; export interface Flags { aglb: boolean; + aglbFullCreateFlow: boolean; apiMaintenance: APIMaintenance; databaseBeta: boolean; databases: boolean; + dcGetWell: boolean; dcSpecificPricing: boolean; ipv6Sharing: boolean; kubernetesDashboardAvailability: boolean; diff --git a/packages/manager/src/features/Billing/BillingDetail.tsx b/packages/manager/src/features/Billing/BillingDetail.tsx index 5e4db934311..0ecb0b0624f 100644 --- a/packages/manager/src/features/Billing/BillingDetail.tsx +++ b/packages/manager/src/features/Billing/BillingDetail.tsx @@ -110,9 +110,8 @@ export const BillingActionButton = styled(Button)(({ theme }) => ({ textDecoration: 'underline', }, color: theme.textColors.linkActiveLight, - fontFamily: theme.font.normal, + fontFamily: theme.font.bold, fontSize: '.875rem', - fontWeight: 700, minHeight: 'unset', minWidth: 'auto', padding: 0, diff --git a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx index bee29c8d8b1..890eb4ea5ba 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx @@ -21,7 +21,7 @@ import AddAccessControlDrawer from './AddAccessControlDrawer'; const useStyles = makeStyles()((theme: Theme) => ({ addAccessControlBtn: { - minWidth: 214, + minWidth: 225, [theme.breakpoints.down('md')]: { alignSelf: 'flex-start', marginBottom: '1rem', diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupTableRow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupTableRow.tsx index 1f179a3e338..5de90fb3b78 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupTableRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupTableRow.tsx @@ -1,11 +1,10 @@ import { DatabaseBackup } from '@linode/api-v4/lib/databases'; import * as React from 'react'; +import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { useProfile } from 'src/queries/profile'; import { parseAPIDate } from 'src/utilities/date'; -import { formatDate } from 'src/utilities/formatDate'; import DatabaseBackupActionMenu from './DatabaseBackupActionMenu'; @@ -14,17 +13,13 @@ interface Props { onRestore: (id: number) => void; } -const BackupTableRow: React.FC = ({ backup, onRestore }) => { +export const BackupTableRow = ({ backup, onRestore }: Props) => { const { created, id } = backup; - const { data: profile } = useProfile(); - return ( - {formatDate(created, { - timezone: profile?.timezone, - })} + {parseAPIDate(created).toRelative()} @@ -33,5 +28,3 @@ const BackupTableRow: React.FC = ({ backup, onRestore }) => { ); }; - -export default BackupTableRow; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx index 38a6c1c5dc2..3753848e790 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.test.tsx @@ -2,7 +2,11 @@ import { waitForElementToBeRemoved } from '@testing-library/react'; import * as React from 'react'; import { QueryClient } from 'react-query'; -import { databaseBackupFactory, databaseFactory } from 'src/factories'; +import { + databaseBackupFactory, + databaseFactory, + profileFactory, +} from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { rest, server } from 'src/mocks/testServer'; import { formatDate } from 'src/utilities/formatDate'; @@ -33,13 +37,12 @@ describe('Database Backups', () => { // Mock the Database because the Backups Details page requires it to be loaded server.use( + rest.get('*/profile', (req, res, ctx) => { + return res(ctx.json(profileFactory.build({ timezone: 'utc' }))); + }), rest.get('*/databases/:engine/instances/:id', (req, res, ctx) => { return res(ctx.json(databaseFactory.build())); - }) - ); - - // Mock a list of 7 backups - server.use( + }), rest.get('*/databases/:engine/instances/:id/backups', (req, res, ctx) => { return res(ctx.json(makeResourcePage(backups))); }) @@ -57,7 +60,9 @@ describe('Database Backups', () => { for (const backup of backups) { // Check to see if all 7 backups are rendered - expect(getByText(formatDate(backup.created))).toBeInTheDocument(); + expect( + getByText(formatDate(backup.created, { timezone: 'utc' })) + ).toBeInTheDocument(); } }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx index 8fced026345..79b8915a2e3 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx @@ -19,8 +19,8 @@ import { useDatabaseQuery, } from 'src/queries/databases'; -import DatabaseBackupTableRow from './DatabaseBackupTableRow'; import RestoreFromBackupDialog from './RestoreFromBackupDialog'; +import { BackupTableRow } from './DatabaseBackupTableRow'; export const DatabaseBackups = () => { const { databaseId, engine } = useParams<{ @@ -85,7 +85,7 @@ export const DatabaseBackups = () => { return backups.data .sort(sorter) .map((backup) => ( - ({ cursor: 'default', }, color: theme.palette.primary.main, - fontFamily: theme.font.normal, + fontFamily: theme.font.bold, fontSize: '0.875rem', - fontWeight: theme.typography.fontWeightRegular, lineHeight: '1.125rem', marginLeft: theme.spacing(), minHeight: 'auto', @@ -56,11 +56,11 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, connectionDetailsCtn: { '& p': { - '& span': { - fontWeight: 'bold', - }, lineHeight: '1.5rem', }, + '& span': { + fontFamily: theme.font.bold, + }, background: theme.bg.bgAccessRow, border: `1px solid ${theme.name === 'light' ? '#ccc' : '#222'}`, padding: '8px 15px', @@ -100,8 +100,8 @@ const useStyles = makeStyles()((theme: Theme) => ({ marginLeft: 22, }, provisioningText: { + fontFamily: theme.font.normal, fontStyle: 'italic', - fontWeight: 'lighter !important' as 'lighter', }, showBtn: { color: theme.palette.primary.main, @@ -131,6 +131,7 @@ const mongoHostHelperCopy = export const DatabaseSummaryConnectionDetails = (props: Props) => { const { database } = props; const { classes } = useStyles(); + const theme = useTheme(); const { enqueueSnackbar } = useSnackbar(); const [showCredentials, setShowPassword] = React.useState(false); @@ -283,7 +284,7 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { <> host ={' '} - + {database.hosts?.primary} {' '} @@ -333,7 +334,9 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { marginTop: 0, }} > - {hostname} + + {hostname} + { replica set ={' '} - + {database.replica_set} diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseActionMenu.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseActionMenu.tsx index f2aa9005a72..ef09ed73fcb 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseActionMenu.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseActionMenu.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx index 159eac886f7..447d0f189d7 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx @@ -9,6 +9,7 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; @@ -111,14 +112,8 @@ const DatabaseLanding = () => { Engine - - Region - + {/* TODO add back TableSortCell once API is updated to support sort by Region */} + Region - {!autoassignIPv4WithinVPC && ( - handleVPCIPv4Change(e.target.value)} - required={!autoassignIPv4WithinVPC} - value={vpcIPv4AddressOfLinode} + errorText={subnetError} + isClearable={false} + label="Subnet" + options={subnetDropdownOptions} + placeholder="Select Subnet" /> - )} - ({ - marginLeft: '2px', - marginTop: !autoassignIPv4WithinVPC ? theme.spacing() : 0, - })} - alignItems="center" - display="flex" - > - - } - label={ - - - Assign a public IPv4 address for this Linode - - ({ + marginLeft: '2px', + paddingTop: theme.spacing(), + })} + alignItems="center" + display="flex" + flexDirection="row" + > + - - } - /> + } + label={ + + + Auto-assign a VPC IPv4 address for this Linode in the + VPC + + + + } + data-testid="vpc-ipv4-checkbox" + /> + + {!autoassignIPv4WithinVPC && ( + handleVPCIPv4Change(e.target.value)} + required={!autoassignIPv4WithinVPC} + value={vpcIPv4AddressOfLinode} + /> + )} + ({ + marginLeft: '2px', + marginTop: !autoassignIPv4WithinVPC ? theme.spacing() : 0, + })} + alignItems="center" + display="flex" + > + + } + label={ + + + Assign a public IPv4 address for this Linode + + + + } + /> + {assignPublicIPv4Address && publicIPv4Error && ( ({ @@ -313,10 +326,18 @@ export const VPCPanel = (props: VPCPanelProps) => { {publicIPv4Error} )} - - - )} - - + + )} + + + {isVPCCreateDrawerOpen && ( + handleSelectVPC(vpcId)} + onClose={() => setIsVPCCreateDrawerOpen(false)} + open={isVPCCreateDrawerOpen} + selectedRegion={region} + /> + )} + ); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.test.tsx index 81540879a34..808a720cb6b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.test.tsx @@ -1,3 +1,4 @@ +import { Settings } from 'luxon'; import * as React from 'react'; import { profileFactory } from 'src/factories'; @@ -27,7 +28,7 @@ describe('ScheduleSettings', () => { ); }); - it('renders with the linode schedule taking into account the user timezone', async () => { + it('renders with the linode schedule taking into account the user timezone (UTC)', async () => { server.use( rest.get('*/linode/instances/1', (req, res, ctx) => { return res( @@ -37,7 +38,42 @@ describe('ScheduleSettings', () => { enabled: true, schedule: { day: 'Monday', - window: 'W4', + window: 'W10', + }, + }, + id: 1, + }) + ) + ); + }), + rest.get('*/profile', (req, res, ctx) => { + return res(ctx.json(profileFactory.build({ timezone: 'utc' }))); + }) + ); + + const { findByText } = renderWithTheme( + + ); + + await findByText('Monday'); + + // W10 indicates that your backups should be taken between 10:00 and 12:00 + await findByText('10:00 - 12:00'); + + await findByText('Time displayed in utc'); + }); + + it('renders with the linode schedule taking into account the user timezone (America/New_York) (EDT)', async () => { + server.use( + rest.get('*/linode/instances/1', (req, res, ctx) => { + return res( + ctx.json( + linodeFactory.build({ + backups: { + enabled: true, + schedule: { + day: 'Wednesday', + window: 'W10', }, }, id: 1, @@ -52,13 +88,60 @@ describe('ScheduleSettings', () => { }) ); + // Mock that today's date is May 25th, 2018 so that it is daylight savings time + Settings.now = () => new Date(2018, 4, 25).valueOf(); + const { findByText } = renderWithTheme( ); - await findByText('Monday'); - await findByText('00:00 - 02:00'); + await findByText('Wednesday'); + + // W10 indicates that your backups should be taken between 10:00 and 12:00 UTC. + // W10 in America/New_York during daylight savings is 06:00 - 08:00 EDT (UTC-4). + await findByText(`06:00 - 08:00`); + + await findByText('Time displayed in America/New York'); + }); + + it('renders with the linode schedule taking into account the user timezone (America/New_York) (EST)', async () => { + server.use( + rest.get('*/linode/instances/1', (req, res, ctx) => { + return res( + ctx.json( + linodeFactory.build({ + backups: { + enabled: true, + schedule: { + day: 'Wednesday', + window: 'W10', + }, + }, + id: 1, + }) + ) + ); + }), + rest.get('*/profile', (req, res, ctx) => { + return res( + ctx.json(profileFactory.build({ timezone: 'America/New_York' })) + ); + }) + ); + + // Mock that today's date is Nov 7th, 2023 so that it is *not* daylight savings time + Settings.now = () => new Date(2023, 11, 7).valueOf(); + + const { findByText } = renderWithTheme( + + ); + + await findByText('Wednesday'); + + // W10 indicates that your backups should be taken between 10:00 and 12:00 UTC. + // W10 in America/New_York when it is not daylight savings is 05:00 - 07:00 EST (UTC-5). + await findByText(`05:00 - 07:00`); - await findByText('America/New York', { exact: false }); + await findByText('Time displayed in America/New York'); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx index 5f559e1bad7..dd12c2fa8ee 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx @@ -1,7 +1,7 @@ import { Config } from '@linode/api-v4/lib/linodes'; import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import { splitAt } from 'ramda'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx index c45b7275fa9..066dcaddff8 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx @@ -281,8 +281,6 @@ export const LinodeConfigDialog = (props: Props) => { thisRegion.capabilities.includes('VPCs') ); - const showVlans = regionHasVLANS; - // @TODO VPC: remove once VPC is fully rolled out const vpcEnabled = isFeatureEnabled( 'VPCs', @@ -425,20 +423,9 @@ export const LinodeConfigDialog = (props: Props) => { }); }; - // @TODO VPC: Remove this override and surface the field errors appropriately - // once API fixes interface index bug for ipv4.vpc & ipv4.nat_1_1 errors - const overrideFieldForIPv4 = (error: APIError[]) => { - error.forEach((err) => { - if (err.field && ['ipv4.nat_1_1', 'ipv4.vpc'].includes(err.field)) { - err.field = 'interfaces'; - } - }); - }; - formik.setSubmitting(false); overrideFieldForDevices(error); - overrideFieldForIPv4(error); handleFieldErrors(formik.setErrors, error); @@ -952,85 +939,86 @@ export const LinodeConfigDialog = (props: Props) => { - {showVlans ? ( - - - - {vpcEnabled ? 'Networking' : 'Network Interfaces'} - - + + + {vpcEnabled ? 'Networking' : 'Network Interfaces'} + + + + {formik.errors.interfaces && ( + + )} + {vpcEnabled && ( + <> + - - - )} - {values.interfaces.map((thisInterface, idx) => { - return ( - - handleInterfaceChange(idx, newInterface) - } - ipamAddress={thisInterface.ipam_address} - key={`eth${idx}-interface`} - label={thisInterface.label} - purpose={thisInterface.purpose} - readOnly={isReadOnly} - region={linode?.region} - slotNumber={idx} - subnetId={thisInterface.subnet_id} - vpcIPv4={thisInterface.ipv4?.vpc} - vpcId={thisInterface.vpc_id} - /> - ); - })} - - ) : null} + ); + })} + Filesystem/Boot Helpers diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx index 0fb57f4b4af..3b5eabfaa40 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx @@ -134,7 +134,7 @@ const LinodeConfigs = () => { @@ -143,7 +143,7 @@ const LinodeConfigs = () => { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx index 67fb90fc99b..719170446ca 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx @@ -306,7 +306,7 @@ const IPSharingPanel = (props: Props) => { {flags.ipv6Sharing ? ( - + Warning: Converting a statically routed IPv6 range to a shared range will break existing IPv6 connectivity unless each Linode that shares the range has @@ -336,7 +336,7 @@ const IPSharingPanel = (props: Props) => { width: '100%', }} > - + IP Addresses diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx index 024346b4111..a1897950faa 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx @@ -1,7 +1,7 @@ import { IPAddress, IPRange } from '@linode/api-v4/lib/networking'; import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import { isEmpty } from 'ramda'; import * as React from 'react'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.test.tsx new file mode 100644 index 00000000000..0de63d58fff --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.test.tsx @@ -0,0 +1,63 @@ +import { waitFor } from '@testing-library/react'; +import * as React from 'react'; + +import { InterfacePurpose } from '@linode/api-v4'; +import { InterfaceSelect } from './InterfaceSelect'; + +import { queryClientFactory } from 'src/queries/base'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +const queryClient = queryClientFactory(); + +beforeAll(() => mockMatchMedia()); +afterEach(() => { + queryClient.clear(); +}); + +const unavailableInRegionTextTestId = 'unavailable-in-region-text'; + +describe('InterfaceSelect', () => { + const props = { + fromAddonsPanel: false, + handleChange: jest.fn(), + ipamAddress: null, + label: null, + readOnly: false, + region: 'us-east', + slotNumber: 0, + regionHasVLANs: true, + errors: {}, + }; + + it('should display helper text regarding VPCs not being available in the region in the Linode Add/Edit Config dialog if applicable', async () => { + const _props = { + ...props, + purpose: 'vpc' as InterfacePurpose, + regionHasVPCs: false, + }; + + const { queryByTestId } = renderWithTheme(, { + flags: { vpc: true }, + }); + + await waitFor(() => { + expect(queryByTestId(unavailableInRegionTextTestId)).toBeInTheDocument(); + }); + }); + + it('should display helper text regarding VLANs not being available in the region in the Linode Add/Edit Config dialog if applicable', async () => { + const _props = { + ...props, + purpose: 'vlan' as InterfacePurpose, + regionHasVLANs: false, + }; + + const { queryByTestId } = renderWithTheme(, { + flags: { vpc: false }, + }); + + await waitFor(() => { + expect(queryByTestId(unavailableInRegionTextTestId)).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx index 8e73548edae..0ca6633a984 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx @@ -2,7 +2,6 @@ import { InterfacePayload, InterfacePurpose, } from '@linode/api-v4/lib/linodes/types'; -import { Stack } from 'src/components/Stack'; import Grid from '@mui/material/Unstable_Grid2'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; @@ -10,6 +9,7 @@ import * as React from 'react'; import { Divider } from 'src/components/Divider'; import Select, { Item } from 'src/components/EnhancedSelect/Select'; +import { Stack } from 'src/components/Stack'; import { TextField } from 'src/components/TextField'; import { VPCPanel } from 'src/features/Linodes/LinodesCreate/VPCPanel'; import { useFlags } from 'src/hooks/useFlags'; @@ -17,8 +17,9 @@ import { useAccount } from 'src/queries/account'; import { useVlansQuery } from 'src/queries/vlans'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { sendLinodeCreateDocsEvent } from 'src/utilities/analytics'; +import { Typography } from 'src/components/Typography'; -export interface Props { +interface Props { fromAddonsPanel?: boolean; handleChange: (updatedInterface: ExtendedInterface) => void; ipamAddress?: null | string; @@ -27,6 +28,8 @@ export interface Props { readOnly: boolean; region?: string; slotNumber: number; + regionHasVLANs?: boolean; + regionHasVPCs?: boolean; } interface VPCStateErrors { @@ -40,6 +43,7 @@ interface VPCStateErrors { interface VPCState { errors: VPCStateErrors; + nattedIPv4Address?: string; subnetId?: null | number; vpcIPv4?: string; vpcId?: null | number; @@ -61,6 +65,7 @@ export const InterfaceSelect = (props: CombinedProps) => { handleChange, ipamAddress, label, + nattedIPv4Address, purpose, readOnly, region, @@ -68,6 +73,8 @@ export const InterfaceSelect = (props: CombinedProps) => { subnetId, vpcIPv4, vpcId, + regionHasVLANs, + regionHasVPCs, } = props; const theme = useTheme(); @@ -102,8 +109,12 @@ export const InterfaceSelect = (props: CombinedProps) => { vlanOptions.push({ label: newVlan, value: newVlan }); } - const [autoAssignVPCIPv4, setAutoAssignVPCIPv4] = React.useState(true); - const [autoAssignLinodeIPv4, setAutoAssignLinodeIPv4] = React.useState(false); + const [autoAssignVPCIPv4, setAutoAssignVPCIPv4] = React.useState( + !Boolean(vpcIPv4) + ); + const [autoAssignLinodeIPv4, setAutoAssignLinodeIPv4] = React.useState( + Boolean(nattedIPv4Address) + ); const handlePurposeChange = (selected: Item) => { const purpose = selected.value; @@ -124,22 +135,28 @@ export const InterfaceSelect = (props: CombinedProps) => { purpose, }); - const handleVPCLabelChange = (selectedVPCId: number) => - handleChange({ - ipam_address: null, - ipv4: { - vpc: autoAssignVPCIPv4 ? undefined : vpcIPv4, - }, - label: null, - purpose, - subnet_id: undefined, - vpc_id: selectedVPCId, - }); + const handleVPCLabelChange = (selectedVPCId: number) => { + // Only clear VPC related fields if VPC selection changes + if (selectedVPCId !== vpcId) { + handleChange({ + ipam_address: null, + ipv4: { + nat_1_1: autoAssignLinodeIPv4 ? 'any' : undefined, + vpc: autoAssignVPCIPv4 ? undefined : vpcIPv4, + }, + label: null, + purpose, + subnet_id: undefined, + vpc_id: selectedVPCId, + }); + } + }; const handleSubnetChange = (selectedSubnetId: number) => handleChange({ ipam_address: null, ipv4: { + nat_1_1: autoAssignLinodeIPv4 ? 'any' : undefined, vpc: autoAssignVPCIPv4 ? undefined : vpcIPv4, }, label: null, @@ -311,13 +328,30 @@ export const InterfaceSelect = (props: CombinedProps) => { ); }; + const displayUnavailableInRegionTextVPC = + purpose === 'vpc' && regionHasVPCs === false; + const displayUnavailableInRegionTextVLAN = + purpose === 'vlan' && regionHasVLANs === false; + + const unavailableInRegionHelperTextJSX = + !displayUnavailableInRegionTextVPC && + !displayUnavailableInRegionTextVLAN ? null : ( + + {displayUnavailableInRegionTextVPC ? 'VPC ' : 'VLAN '}{' '} + {unavailableInRegionHelperTextSegment} + + ); + return ( {fromAddonsPanel ? null : ( + formik.setFieldValue('protocol', value) + } textFieldProps={{ - labelTooltipText: 'TODO', + labelTooltipText: 'TODO: AGLB', }} - value={ - protocolOptions.find( - (option) => option.value === formik.values.protocol - ) ?? null - } + value={protocolOptions.find( + (option) => option.value === formik.values.protocol + )} + disableClearable errorText={errorMap.protocol} - isClearable={false} label="Protocol" - onChange={({ value }) => formik.setFieldValue('protocol', value)} options={protocolOptions} - styles={{ container: () => ({ width: 'unset' }) }} /> - - - - TLS Certificates - - - - + + TLS Certificates + { Routes - + + + setRoutesTableQuery(e.target.value)} + placeholder="Filter" + /> + + - setIsDeleteDialogOpen(true), + + + + + { + formik.setFieldValue('routes', [...formik.values.routes, route]); }} + configuration={configuration} + loadbalancerId={loadbalancerId} + onClose={() => setIsAddRouteDrawerOpen(false)} + open={isAddRouteDrawerOpen} /> { const { data, + error, fetchNextPage, hasNextPage, isFetchingNextPage, @@ -22,6 +24,10 @@ export const LoadBalancerConfigurations = () => { return ; } + if (error) { + return ; + } + const configurations = data?.pages.flatMap((page) => page.data); return ( diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerDetail.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerDetail.tsx index d4e00db41d1..769749e17aa 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerDetail.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerDetail.tsx @@ -32,8 +32,8 @@ const LoadBalancerServiceTargets = React.lazy(() => })) ); const LoadBalancerRoutes = React.lazy(() => - import('./Routes/RoutesTable').then((module) => ({ - default: module.RoutesTable, + import('./LoadBalancerRoutes').then((module) => ({ + default: module.LoadBalancerRoutes, })) ); @@ -49,7 +49,7 @@ const LoadBalancerSettings = React.lazy(() => })) ); -const LoadBalancerDetailLanding = () => { +export const LoadBalancerDetail = () => { const { loadbalancerId } = useParams<{ loadbalancerId: string }>(); const location = useLocation(); const { path, url } = useRouteMatch(); @@ -60,32 +60,26 @@ const LoadBalancerDetailLanding = () => { const tabs = [ { - component: LoadBalancerSummary, path: 'summary', title: 'Summary', }, { - component: LoadBalancerConfigurations, path: 'configurations', title: 'Configurations', }, { - component: LoadBalancerRoutes, path: 'routes', title: 'Routes', }, { - component: LoadBalancerServiceTargets, path: 'service-targets', title: 'Service Targets', }, { - component: LoadBalancerCertificates, path: 'certificates', title: 'Certificates', }, { - component: LoadBalancerSettings, path: 'settings', title: 'Settings', }, @@ -118,13 +112,20 @@ const LoadBalancerDetailLanding = () => { /> }> - {tabs.map((tab) => ( - - ))} + + + + + @@ -132,5 +133,3 @@ const LoadBalancerDetailLanding = () => { ); }; - -export default LoadBalancerDetailLanding; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerRegions.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerRegions.tsx new file mode 100644 index 00000000000..731e1f40cb2 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerRegions.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { Country } from 'src/components/EnhancedSelect/variants/RegionSelect/utils'; +import { Flag } from 'src/components/Flag'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; + +export const regions = [ + { country: 'us', id: 'us-iad', label: 'Washington, DC' }, + { country: 'us', id: 'us-lax', label: 'Los Angeles, CA' }, + { country: 'fr', id: 'fr-par', label: 'Paris, FR' }, + { country: 'jp', id: 'jp-osa', label: 'Osaka, JP' }, + { country: 'au', id: 'ap-southeast', label: 'Sydney, AU' }, +]; + +export const LoadBalancerRegions = () => { + return ( + + {regions.map((region) => ( + + } /> + {`${region.label} (${region.id})`} + + ))} + + ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerRoutes.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerRoutes.tsx new file mode 100644 index 00000000000..fc2c1724d63 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerRoutes.tsx @@ -0,0 +1,71 @@ +import CloseIcon from '@mui/icons-material/Close'; +import React, { useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import { Button } from 'src/components/Button/Button'; +import { IconButton } from 'src/components/IconButton'; +import { InputAdornment } from 'src/components/InputAdornment'; +import { Stack } from 'src/components/Stack'; +import { TextField } from 'src/components/TextField'; + +import { CreateRouteDrawer } from './Routes/CreateRouteDrawer'; +import { RoutesTable } from './Routes/RoutesTable'; + +import type { Filter } from '@linode/api-v4'; + +export const LoadBalancerRoutes = () => { + const { loadbalancerId } = useParams<{ loadbalancerId: string }>(); + const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false); + const [query, setQuery] = useState(); + + const filter: Filter = query ? { label: { '+contains': query } } : {}; + + return ( + <> + + + setQuery('')} + size="small" + sx={{ padding: 'unset' }} + > + + + + ), + }} + hideLabel + label="Filter" + onChange={(e) => setQuery(e.target.value)} + placeholder="Filter" + style={{ minWidth: '320px' }} + value={query} + /> + + + + setIsCreateDrawerOpen(false)} + open={isCreateDrawerOpen} + /> + + ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerServiceTargets.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerServiceTargets.tsx index 9101eb5651d..91383f89403 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerServiceTargets.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerServiceTargets.tsx @@ -8,7 +8,6 @@ import { ActionMenu } from 'src/components/ActionMenu'; import { Button } from 'src/components/Button/Button'; import { CircleProgress } from 'src/components/CircleProgress'; import { InputAdornment } from 'src/components/InputAdornment'; -import { Link } from 'src/components/Link'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { Stack } from 'src/components/Stack'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; @@ -106,7 +105,7 @@ export const LoadBalancerServiceTargets = () => { > { size="small" sx={{ padding: 'unset' }} > - + ), @@ -154,6 +150,16 @@ export const LoadBalancerServiceTargets = () => { Health Checks + + + ID + + @@ -162,9 +168,7 @@ export const LoadBalancerServiceTargets = () => { {data?.results === 0 && } {data?.data.map((serviceTarget) => ( - - {serviceTarget.label} - + {serviceTarget.label} @@ -187,6 +191,9 @@ export const LoadBalancerServiceTargets = () => { {serviceTarget.healthcheck.interval !== 0 ? 'Yes' : 'No'} + + {serviceTarget.id} + { onClick: () => handleEditServiceTarget(serviceTarget), title: 'Edit', }, - { onClick: () => null, title: 'Clone Service Target' }, { onClick: () => handleDeleteServiceTarget(serviceTarget), title: 'Delete', diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerSettings.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerSettings.tsx index 65f19a9076b..e605458b1e6 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerSettings.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerSettings.tsx @@ -1,7 +1,8 @@ -import { Stack } from 'src/components/Stack'; import React from 'react'; import { useParams } from 'react-router-dom'; +import { Stack } from 'src/components/Stack'; + import { Delete } from './Settings/Delete'; import { Label } from './Settings/Label'; import { Region } from './Settings/Region'; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerSummary.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerSummary.tsx index 3f19583fc48..0b8416a6ec8 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerSummary.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerSummary.tsx @@ -58,6 +58,10 @@ export const LoadBalancerSummary = () => { ), }, + { + title: 'Load Balancer ID', + value: {loadbalancer?.id}, + }, ]; return ( diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/AddRouteDrawer.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/AddRouteDrawer.tsx new file mode 100644 index 00000000000..04214869c61 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/AddRouteDrawer.tsx @@ -0,0 +1,179 @@ +import { CreateRouteSchema } from '@linode/validation'; +import { useFormik } from 'formik'; +import React, { useState } from 'react'; +import { number, object } from 'yup'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Drawer } from 'src/components/Drawer'; +import { FormControlLabel } from 'src/components/FormControlLabel'; +import { Notice } from 'src/components/Notice/Notice'; +import { Radio } from 'src/components/Radio/Radio'; +import { RadioGroup } from 'src/components/RadioGroup'; +import { TextField } from 'src/components/TextField'; +import { useLoadBalancerRouteCreateMutation } from 'src/queries/aglb/routes'; +import { getFormikErrorsFromAPIErrors } from 'src/utilities/formikErrorUtils'; + +import { RouteSelect } from './RouteSelect'; +import { getRouteProtocolFromConfiguration } from './utils'; + +import type { Configuration, CreateRoutePayload } from '@linode/api-v4'; + +export interface Props { + configuration: Configuration; + loadbalancerId: number; + onAdd: (routeId: number) => void; + onClose: () => void; + open: boolean; +} + +type Mode = 'existing' | 'new'; + +export const AddRouteDrawer = (props: Props) => { + const { configuration, loadbalancerId, onAdd, onClose, open } = props; + + const [mode, setMode] = useState('existing'); + + const routeProtocol = getRouteProtocolFromConfiguration( + configuration + ).toLocaleUpperCase(); + + return ( + + setMode(value as Mode)} value={mode}> + } + label={`Create New ${routeProtocol} Route`} + value="new" + /> + } + label="Add Existing Route" + value="existing" + /> + + {mode === 'existing' ? ( + + ) : ( + + )} + + ); +}; + +interface AddExistingRouteFormProps { + loadbalancerId: number; + onAdd: (routeId: number) => void; + onClose: () => void; +} + +const AddExistingRouteForm = (props: AddExistingRouteFormProps) => { + const { loadbalancerId, onAdd, onClose } = props; + + const formik = useFormik<{ route: null | number }>({ + initialValues: { + route: null, + }, + onSubmit({ route }) { + onAdd(route!); + onClose(); + }, + validationSchema: object({ + route: number().required().typeError('Route is required.'), + }), + }); + + return ( +
+ formik.setFieldValue('route', route?.id ?? null)} + value={formik.values.route ?? -1} + /> + + + ); +}; + +interface AddNewRouteFormProps { + configuration: Configuration; + loadbalancerId: number; + onAdd: (routeId: number) => void; + onClose: () => void; +} + +const AddNewRouteForm = (props: AddNewRouteFormProps) => { + const { configuration, loadbalancerId, onAdd, onClose } = props; + + const { + error, + isLoading, + mutateAsync: createRoute, + } = useLoadBalancerRouteCreateMutation(loadbalancerId); + + const formik = useFormik({ + initialValues: { + label: '', + protocol: getRouteProtocolFromConfiguration(configuration), + rules: [], + }, + async onSubmit(values, helpers) { + try { + const route = await createRoute(values); + onAdd(route.id); + onClose(); + } catch (error) { + helpers.setErrors(getFormikErrorsFromAPIErrors(error)); + } + }, + validationSchema: CreateRouteSchema, + }); + + const generalErrors = error + ?.filter((e) => !e.field || e.field !== 'label') + .map((e) => e.reason) + .join(','); + + return ( +
+ {generalErrors && } + + + + ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RouteSelect.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RouteSelect.tsx new file mode 100644 index 00000000000..099da8236e7 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RouteSelect.tsx @@ -0,0 +1,87 @@ +import React from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { useLoadBalancerRoutesInfiniteQuery } from 'src/queries/aglb/routes'; + +import type { Filter, Route } from '@linode/api-v4'; + +interface Props { + /** + * Error text to display as helper text under the TextField. Useful for validation errors. + */ + errorText?: string; + /** + * The TextField label + * @default Route + */ + label?: string; + /** + * The id of the Load Balancer you want to show certificates for + */ + loadbalancerId: number; + /** + * Called when the value of the Select changes + */ + onChange: (certificate: Route | null) => void; + /** + * The id of the selected Route + */ + value: number; +} + +export const RouteSelect = (props: Props) => { + const { errorText, label, loadbalancerId, onChange, value } = props; + + const [inputValue, setInputValue] = React.useState(''); + + const filter: Filter = {}; + + if (inputValue) { + filter['label'] = { '+contains': inputValue }; + } + + const { + data, + error, + fetchNextPage, + hasNextPage, + isLoading, + } = useLoadBalancerRoutesInfiniteQuery(loadbalancerId, filter); + + const routes = data?.pages.flatMap((page) => page.data); + + const selectedRoute = routes?.find((cert) => cert.id === value) ?? null; + + const onScroll = (event: React.SyntheticEvent) => { + const listboxNode = event.currentTarget; + if ( + listboxNode.scrollTop + listboxNode.clientHeight >= + listboxNode.scrollHeight && + hasNextPage + ) { + fetchNextPage(); + } + }; + + return ( + { + if (reason === 'input') { + setInputValue(value); + } + }} + errorText={error?.[0].reason ?? errorText} + inputValue={selectedRoute ? selectedRoute.label : inputValue} + label={label ?? 'Route'} + loading={isLoading} + noMarginTop + onChange={(e, value) => onChange(value)} + options={routes ?? []} + placeholder="Select a Route" + value={selectedRoute} + /> + ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RoutesTable.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RoutesTable.tsx index f5b7671e9ab..18b50a1e4f3 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RoutesTable.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RoutesTable.tsx @@ -1,30 +1,24 @@ -import CloseIcon from '@mui/icons-material/Close'; -import { Hidden, IconButton } from '@mui/material'; -import { Stack } from 'src/components/Stack'; +import { Hidden } from '@mui/material'; import React, { useState } from 'react'; import { useParams } from 'react-router-dom'; import { ActionMenu } from 'src/components/ActionMenu'; -import { Button } from 'src/components/Button/Button'; import { CircleProgress } from 'src/components/CircleProgress'; import { CollapsibleTable, TableItem, } from 'src/components/CollapsibleTable/CollapsibleTable'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import { InputAdornment } from 'src/components/InputAdornment'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; -import { TextField } from 'src/components/TextField'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useLoadBalancerRoutesQuery } from 'src/queries/aglb/routes'; import { RulesTable } from '../RulesTable'; -import { CreateRouteDrawer } from './CreateRouteDrawer'; import { DeleteRouteDialog } from './DeleteRouteDialog'; import { DeleteRuleDialog } from './DeleteRuleDialog'; import { EditRouteDrawer } from './EditRouteDrawer'; @@ -36,14 +30,15 @@ const PREFERENCE_KEY = 'loadbalancer-routes'; interface Props { configuredRoutes?: Configuration['routes']; + filter?: Filter; } -export const RoutesTable = ({ configuredRoutes }: Props) => { +export const RoutesTable = (props: Props) => { + const { configuredRoutes, filter: additionalFilter } = props; + const { loadbalancerId } = useParams<{ loadbalancerId: string }>(); - const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false); const [isEditDrawerOpen, setIsEditDrawerOpen] = useState(false); const [isAddRuleDrawerOpen, setIsAddRuleDrawerOpen] = useState(false); - const [query, setQuery] = useState(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteRuleDialogOpen, setIsDeleteRuleDialogOpen] = useState(false); const [selectedRouteId, setSelectedRouteId] = useState(); @@ -64,11 +59,6 @@ export const RoutesTable = ({ configuredRoutes }: Props) => { ['+order_by']: orderBy, }; - // If the user types in a search query, API filter by the label. - if (query) { - filter['label'] = { '+contains': query }; - } - /** * If configuredRoutes is passed, it filters the configured routes form API * Otherwise, it fetches routes without filter in the routes table. @@ -83,7 +73,7 @@ export const RoutesTable = ({ configuredRoutes }: Props) => { page: pagination.page, page_size: pagination.pageSize, }, - filter + { ...filter, ...additionalFilter } ); const selectedRoute = routes?.data?.find( @@ -128,27 +118,23 @@ export const RoutesTable = ({ configuredRoutes }: Props) => { return routes?.data?.map((route) => { const OuterTableCells = ( <> - + {route.rules.length} - {route.protocol.toLocaleUpperCase()}{' '} + {route.protocol.toLocaleUpperCase()} + + + {route.id} - {/** - * TODO: AGLB: The Add Rule behavior should be implemented in future AGLB tickets. - */} onAddRule(route)} /> - {/** - * TODO: AGLB: The Action menu behavior should be implemented in future AGLB tickets. - */} onEditRoute(route), title: 'Edit' }, - { onClick: () => null, title: 'Clone Route' }, { onClick: () => onDeleteRoute(route), title: 'Delete' }, ]} ariaLabel={`Action Menu for Route ${route.label}`} @@ -185,7 +171,7 @@ export const RoutesTable = ({ configuredRoutes }: Props) => { > Route Label - + Rules @@ -198,56 +184,22 @@ export const RoutesTable = ({ configuredRoutes }: Props) => { Protocol + + + ID + +
); return ( <> - - - setQuery('')} - size="small" - sx={{ padding: 'unset' }} - > - - - - ), - }} - hideLabel - label="Filter" - onChange={(e) => setQuery(e.target.value)} - placeholder="Filter" - style={{ minWidth: '320px' }} - value={query} - /> - {/** - * TODO: AGLB: The Create Route behavior should be implemented in future AGLB tickets. - */} - - } @@ -276,11 +228,6 @@ export const RoutesTable = ({ configuredRoutes }: Props) => { open={isEditDrawerOpen} route={selectedRoute} /> - setIsCreateDrawerOpen(false)} - open={isCreateDrawerOpen} - /> setIsDeleteDialogOpen(false)} diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/utils.ts b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/utils.ts index 86c764b9f80..649617bdeba 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/utils.ts +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/utils.ts @@ -1,7 +1,14 @@ /* eslint-disable perfectionist/sort-objects */ -import type { MatchField, Rule, RulePayload } from '@linode/api-v4'; import { capitalize } from 'src/utilities/capitalize'; +import type { + Configuration, + MatchField, + Route, + Rule, + RulePayload, +} from '@linode/api-v4'; + export const matchFieldMap: Record = { header: 'HTTP Header', host: 'Host', @@ -73,3 +80,18 @@ export const timeUnitOptions = Object.keys(timeUnitFactorMap).map( ); export const defaultTTL = timeUnitFactorMap['hour'] * 8; + +/** + * Routes can be `http` or `tcp`. + * Configurations can be `http`, `https`, or `tcp`. + * + * Use this function to get the corresponding *route* protocol from a *configuration* + */ +export function getRouteProtocolFromConfiguration( + configuration: Configuration +): Route['protocol'] { + if (configuration.protocol === 'http' || configuration.protocol === 'https') { + return 'http'; + } + return 'tcp'; +} diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/RulesTable.styles.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/RulesTable.styles.tsx index 4b822684787..667e813d632 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/RulesTable.styles.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/RulesTable.styles.tsx @@ -30,8 +30,8 @@ export const StyledInnerBox = styled(Box, { label: 'StyledInnerBox' })( ({ theme }) => ({ backgroundColor: theme.bg.tableHeader, color: theme.textColors.tableHeader, + fontFamily: theme.font.bold, fontSize: '.875rem', - fontWeight: 'bold', height: '46px', }) ); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Settings/Region.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Settings/Region.tsx index c4b3f924317..4fe59db5990 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Settings/Region.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Settings/Region.tsx @@ -1,23 +1,24 @@ -import { Stack } from 'src/components/Stack'; import { useFormik } from 'formik'; import React from 'react'; -import { Box } from 'src/components/Box'; -import { Button } from 'src/components/Button/Button'; +import { BetaChip } from 'src/components/BetaChip/BetaChip'; import { Paper } from 'src/components/Paper'; +import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { useLoadBalancerMutation, useLoadBalancerQuery, } from 'src/queries/aglb/loadbalancers'; +import { LoadBalancerRegions } from '../LoadBalancerRegions'; + interface Props { loadbalancerId: number; } export const Region = ({ loadbalancerId }: Props) => { const { data: loadbalancer } = useLoadBalancerQuery(loadbalancerId); - const { isLoading, mutateAsync } = useLoadBalancerMutation(loadbalancerId); + const { mutateAsync } = useLoadBalancerMutation(loadbalancerId); const formik = useFormik({ enableReinitialize: true, @@ -34,12 +35,11 @@ export const Region = ({ loadbalancerId }: Props) => {
Regions - Select regions for your Load Balancer. - - - + + Load Balancer + regions can not be changed during beta. + +
diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/LoadBalancerLanding.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/LoadBalancerLanding.tsx index 22266f51b3a..0952097ee58 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/LoadBalancerLanding.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/LoadBalancerLanding.tsx @@ -26,7 +26,7 @@ import { LoadBalancerRow } from './LoadBalancerRow'; const LOADBALANCER_CREATE_ROUTE = 'loadbalancers/create'; const preferenceKey = 'loadbalancers'; -const LoadBalancerLanding = () => { +export const LoadBalancerLanding = () => { const history = useHistory(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); @@ -156,5 +156,3 @@ const LoadBalancerLanding = () => { ); }; - -export default LoadBalancerLanding; diff --git a/packages/manager/src/features/LoadBalancers/index.tsx b/packages/manager/src/features/LoadBalancers/index.tsx index aaa3d9cfc24..fa417a7dbef 100644 --- a/packages/manager/src/features/LoadBalancers/index.tsx +++ b/packages/manager/src/features/LoadBalancers/index.tsx @@ -3,23 +3,49 @@ import { Route, Switch } from 'react-router-dom'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { useFlags } from 'src/hooks/useFlags'; -const LoadBalancerLanding = React.lazy( - () => import('./LoadBalancerLanding/LoadBalancerLanding') +const LoadBalancerLanding = React.lazy(() => + import('./LoadBalancerLanding/LoadBalancerLanding').then((module) => ({ + default: module.LoadBalancerLanding, + })) ); -const LoadBalancerDetail = React.lazy( - () => import('./LoadBalancerDetail/LoadBalancerDetail') + +const LoadBalancerDetail = React.lazy(() => + import('./LoadBalancerDetail/LoadBalancerDetail').then((module) => ({ + default: module.LoadBalancerDetail, + })) +); + +const LoadBalancerCreate = React.lazy(() => + import('./LoadBalancerCreate/LoadBalancerCreate').then((module) => ({ + default: module.LoadBalancerCreate, + })) ); -const LoadBalancerCreate = React.lazy( - () => import('./LoadBalancerCreate/LoadBalancerCreate') + +const LoadBalancerBasicCreate = React.lazy(() => + import('./LoadBalancerCreate/LoadBalancerBasicCreate').then((module) => ({ + default: module.LoadBalancerBasicCreate, + })) ); -const LoadBalancer = () => { +export const LoadBalancers = () => { + const flags = useFlags(); return ( }> - + {/** + * TODO: AGLB - remove alternative create flow + */} + { ); }; - -export default LoadBalancer; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.styles.ts b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.styles.ts index 3d465e8f4dc..17b396e98b1 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.styles.ts +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.styles.ts @@ -1,9 +1,11 @@ import { styled } from '@mui/material/styles'; import { Link } from 'react-router-dom'; -export const StyledLink = styled(Link, { label: 'StyledLink' })({ - fontSize: 16, - fontWeight: 'bold', - position: 'relative', - top: 3, -}); +export const StyledLink = styled(Link, { label: 'StyledLink' })( + ({ theme }) => ({ + fontFamily: theme.font.bold, + fontSize: 16, + position: 'relative', + top: 3, + }) +); diff --git a/packages/manager/src/features/Managed/Credentials/CredentialActionMenu.tsx b/packages/manager/src/features/Managed/Credentials/CredentialActionMenu.tsx index 04cab62d328..40a88af1771 100644 --- a/packages/manager/src/features/Managed/Credentials/CredentialActionMenu.tsx +++ b/packages/manager/src/features/Managed/Credentials/CredentialActionMenu.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { ActionMenu, Action } from 'src/components/ActionMenu'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index 37be0c2a2b5..ae40e9e4a7e 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import { append, clone, diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx index 21c36e91bc2..dd5e2e85c8e 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; diff --git a/packages/manager/src/features/NotificationCenter/NotificationSection.tsx b/packages/manager/src/features/NotificationCenter/NotificationSection.tsx index 0baab5212a5..621dc5655e5 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationSection.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationSection.tsx @@ -32,8 +32,8 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, alignItems: 'center', display: 'flex', + fontFamily: theme.font.bold, fontSize: 14, - fontWeight: 'bold', paddingTop: theme.spacing(), }, })); @@ -170,11 +170,11 @@ const ContentBody = React.memo((props: BodyProps) => { {content.length > count ? ( ({ color: 'primary.main', - fontWeight: 'bold', + fontFamily: theme.font.bold, textDecoration: 'none !important', - }} + })} aria-label={`Display all ${content.length} items`} data-test-id="showMoreButton" onClick={() => setShowAll(!showAll)} diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderActionMenu.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/FolderActionMenu.tsx index 35044326b53..27259e0c091 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderActionMenu.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/FolderActionMenu.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { ActionMenu, Action } from 'src/components/ActionMenu'; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectActionMenu.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectActionMenu.tsx index 6949b6cc140..ee01f16219c 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectActionMenu.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectActionMenu.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { ActionMenu, Action } from 'src/components/ActionMenu'; diff --git a/packages/manager/src/features/OneClickApps/oneClickApps.ts b/packages/manager/src/features/OneClickApps/oneClickApps.ts index 08345a499a2..d60d7ae5631 100644 --- a/packages/manager/src/features/OneClickApps/oneClickApps.ts +++ b/packages/manager/src/features/OneClickApps/oneClickApps.ts @@ -1136,12 +1136,12 @@ export const oneClickApps: OCA[] = [ related_guides: [ { href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/mcffmpegplugins/', - title: 'Deploy MainConcept FFmpeg Plugins through the Linode Marketplace', + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-ffmpeg-plugins/', + title: + 'Deploy MainConcept FFmpeg Plugins through the Linode Marketplace', }, ], - summary: - 'MainConcept FFmpeg Plugins are advanced video encoding tools.', + summary: 'MainConcept FFmpeg Plugins are advanced video encoding tools.', website: 'https://www.mainconcept.com/ffmpeg', }, { @@ -1158,12 +1158,11 @@ export const oneClickApps: OCA[] = [ related_guides: [ { href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/mcliveencoder/', + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-live-encoder/', title: 'Deploy MainConcept Live Encoder through the Linode Marketplace', }, ], - summary: - 'MainConcept Live Encoder is a real time video encoding engine.', + summary: 'MainConcept Live Encoder is a real time video encoding engine.', website: 'https://www.mainconcept.com/live-encoder', }, { @@ -1180,8 +1179,9 @@ export const oneClickApps: OCA[] = [ related_guides: [ { href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/mcp2avcultratranscoder/', - title: 'Deploy MainConcept P2 AVC Ultra Transcoder through the Linode Marketplace', + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-p2-avc-ultra/', + title: + 'Deploy MainConcept P2 AVC Ultra Transcoder through the Linode Marketplace', }, ], summary: @@ -1202,8 +1202,9 @@ export const oneClickApps: OCA[] = [ related_guides: [ { href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/mcp2xavc/', - title: 'Deploy MainConcept XAVC Transcoder through the Linode Marketplace', + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-xavc-transcoder/', + title: + 'Deploy MainConcept XAVC Transcoder through the Linode Marketplace', }, ], summary: @@ -1224,8 +1225,9 @@ export const oneClickApps: OCA[] = [ related_guides: [ { href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/mcp2xdcam/', - title: 'Deploy MainConcept XDCAM Transcoder through the Linode Marketplace', + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-xdcam-transcoder/', + title: + 'Deploy MainConcept XDCAM Transcoder through the Linode Marketplace', }, ], summary: @@ -2250,8 +2252,7 @@ export const oneClickApps: OCA[] = [ website: 'https://docs.splunk.com/Documentation/Splunk', }, { - alt_description: - 'A private by design messaging platform.', + alt_description: 'A private by design messaging platform.', alt_name: 'Anonymous messaging platform.', categories: ['Productivity'], colors: { @@ -2264,7 +2265,7 @@ export const oneClickApps: OCA[] = [ related_guides: [ { href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/simplexchat/', + 'https://www.linode.com/docs/products/tools/marketplace/guides/simplex/', title: 'Deploy SimpleX chat through the Linode Marketplace', }, ], diff --git a/packages/manager/src/features/Profile/APITokens/APITokenMenu.tsx b/packages/manager/src/features/Profile/APITokens/APITokenMenu.tsx index cd9bb11bd9a..0f9ae41f728 100644 --- a/packages/manager/src/features/Profile/APITokens/APITokenMenu.tsx +++ b/packages/manager/src/features/Profile/APITokens/APITokenMenu.tsx @@ -1,7 +1,7 @@ import { Token } from '@linode/api-v4/lib/profile'; import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { ActionMenu, Action } from 'src/components/ActionMenu'; diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.styles.ts b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.styles.ts index 8d1dce833f2..4a05ca75936 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.styles.ts +++ b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.styles.ts @@ -85,9 +85,7 @@ export const StyledLabel = styled(Typography, { label: 'StyledLabel', })(({ theme }) => ({ color: theme.name === 'light' ? '#555' : '#c9cacb', - fontFamily: 'LatoWebBold', fontSize: '.875rem', - fontWeight: 400, lineHeight: '1', marginBottom: '8px', marginTop: theme.spacing(2), diff --git a/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx b/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx index 354b1509f24..65841d984a4 100644 --- a/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { ActionMenu, Action } from 'src/components/ActionMenu'; diff --git a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx index 1160c2004d3..4afd1a31f1e 100644 --- a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; diff --git a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.test.tsx b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.test.tsx index 6bb1c386f3a..bd91cd7da9a 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.test.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.test.tsx @@ -7,13 +7,13 @@ import { } from 'src/factories/support'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { rest, server } from 'src/mocks/testServer'; -import { wrapWithTheme } from 'src/utilities/testHelpers'; +import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; import { SupportTicketDetail } from './SupportTicketDetail'; describe('Support Ticket Detail', () => { it('should display a loading spinner', () => { - render(wrapWithTheme()); + renderWithTheme(); expect(screen.getByTestId('circle-progress')).toBeInTheDocument(); }); @@ -22,7 +22,7 @@ describe('Support Ticket Detail', () => { rest.get('*/support/tickets/:ticketId', (req, res, ctx) => { const ticket = supportTicketFactory.build({ description: 'TEST Support Ticket body', - id: req.params.ticketId, + id: Number(req.params.ticketId), status: 'open', summary: '#0: TEST Support Ticket', }); @@ -36,19 +36,37 @@ describe('Support Ticket Detail', () => { expect(await findByText(/TEST Support Ticket body/i)).toBeInTheDocument(); }); - it("should display a 'new' icon and 'updated by' messaging", async () => { + it("should display a 'new' status and 'updated by' messaging", async () => { server.use( rest.get('*/support/tickets/:ticketId', (req, res, ctx) => { const ticket = supportTicketFactory.build({ - id: req.params.ticketId, + id: Number(req.params.ticketId), status: 'new', updated_by: 'test-account', }); return res(ctx.json(ticket)); }) ); - render(wrapWithTheme()); - expect(await screen.findByText(/new/)).toBeInTheDocument(); + renderWithTheme(); + expect(await screen.findByText(/New/)).toBeInTheDocument(); + expect( + await screen.findByText(/updated by test-account/i) + ).toBeInTheDocument(); + }); + + it("should display an 'open' status and 'updated by' messaging", async () => { + server.use( + rest.get('*/support/tickets/:ticketId', (req, res, ctx) => { + const ticket = supportTicketFactory.build({ + id: Number(req.params.ticketId), + status: 'open', + updated_by: 'test-account', + }); + return res(ctx.json(ticket)); + }) + ); + renderWithTheme(); + expect(await screen.findByText(/Open/)).toBeInTheDocument(); expect( await screen.findByText(/updated by test-account/i) ).toBeInTheDocument(); @@ -58,19 +76,46 @@ describe('Support Ticket Detail', () => { server.use( rest.get('*/support/tickets/:ticketId', (req, res, ctx) => { const ticket = supportTicketFactory.build({ - id: req.params.ticketId, + id: Number(req.params.ticketId), status: 'closed', }); return res(ctx.json(ticket)); }) ); - render(wrapWithTheme()); - expect(await screen.findByText(/closed/)).toBeInTheDocument(); + renderWithTheme(); + expect(await screen.findByText('Closed')).toBeInTheDocument(); expect( await screen.findByText(/closed by test-account/i) ).toBeInTheDocument(); }); + it('should display an entity in the status details if the ticket has one', async () => { + const mockEntity = { + id: 1, + label: 'my-linode-entity', + type: 'linode', + url: '/', + }; + server.use( + rest.get('*/support/tickets/:ticketId', (req, res, ctx) => { + const ticket = supportTicketFactory.build({ + entity: mockEntity, + id: Number(req.params.ticketId), + }); + return res(ctx.json(ticket)); + }) + ); + renderWithTheme(); + const entity = await screen.findByText(mockEntity.label, { exact: false }); + const entityTextLink = entity.closest('a'); + + expect(entity).toBeInTheDocument(); + expect(entityTextLink).toBeInTheDocument(); + expect(entityTextLink?.getAttribute('aria-label')).toContain( + mockEntity.label + ); + }); + it('should display replies', async () => { server.use( rest.get('*/support/tickets/:ticketId/replies', (req, res, ctx) => { @@ -83,14 +128,14 @@ describe('Support Ticket Detail', () => { rest.get('*/support/tickets/:ticketId', (req, res, ctx) => { const ticket = supportTicketFactory.build({ description: 'this ticket should have a reply on it', - id: req.params.ticketId, + id: Number(req.params.ticketId), status: 'open', summary: 'My Linode is broken :(', }); return res(ctx.json(ticket)); }) ); - render(wrapWithTheme()); + renderWithTheme(); expect( await screen.findByText( 'Hi, this is lindoe support! OMG, sorry your Linode is broken!' diff --git a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx index c30b3730892..2cc136d468a 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx @@ -1,59 +1,26 @@ import { SupportReply } from '@linode/api-v4/lib/support'; -import { Stack } from 'src/components/Stack'; import Grid from '@mui/material/Unstable_Grid2'; -import { Theme } from '@mui/material/styles'; import { isEmpty } from 'ramda'; import * as React from 'react'; -import { Link, useHistory, useLocation, useParams } from 'react-router-dom'; +import { useHistory, useLocation, useParams } from 'react-router-dom'; import { Waypoint } from 'react-waypoint'; -import { makeStyles } from 'tss-react/mui'; -import { Chip } from 'src/components/Chip'; import { CircleProgress } from 'src/components/CircleProgress'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { EntityIcon } from 'src/components/EntityIcon/EntityIcon'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; -import { Notice } from 'src/components/Notice/Notice'; -import { Typography } from 'src/components/Typography'; import { useProfile } from 'src/queries/profile'; import { useInfiniteSupportTicketRepliesQuery, useSupportTicketQuery, } from 'src/queries/support'; -import { capitalize } from 'src/utilities/capitalize'; -import { formatDate } from 'src/utilities/formatDate'; -import { getLinkTargets } from 'src/utilities/getEventsActionLink'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; import { ExpandableTicketPanel } from '../ExpandableTicketPanel'; import TicketAttachmentList from '../TicketAttachmentList'; import AttachmentError from './AttachmentError'; import { ReplyContainer } from './TabbedReply/ReplyContainer'; - -import type { EntityVariants } from 'src/components/EntityIcon/EntityIcon'; - -const useStyles = makeStyles()((theme: Theme) => ({ - closed: { - backgroundColor: theme.color.red, - }, - open: { - backgroundColor: theme.color.green, - }, - status: { - color: theme.color.white, - marginLeft: theme.spacing(1), - marginTop: 5, - }, - ticketLabel: { - position: 'relative', - top: -3, - }, - title: { - alignItems: 'center', - display: 'flex', - }, -})); +import { TicketStatus } from './TicketStatus'; export interface AttachmentError { error: string; @@ -66,8 +33,6 @@ export const SupportTicketDetail = () => { const { ticketId } = useParams<{ ticketId: string }>(); const id = Number(ticketId); - const { classes, cx } = useStyles(); - const attachmentErrors = history.location.state?.attachmentErrors; const { data: profile } = useProfile(); @@ -95,47 +60,6 @@ export const SupportTicketDetail = () => { return null; } - const formattedDate = formatDate(ticket.updated, { - timezone: profile?.timezone, - }); - - const status = ticket.status === 'closed' ? 'Closed' : 'Last updated'; - - const renderEntityLabelWithIcon = () => { - const entity = ticket?.entity; - - if (!entity) { - return null; - } - - const target = getLinkTargets(entity); - - return ( - - - - - This ticket is associated with your {capitalize(entity.type)}{' '} - {target ? {entity.label} : entity.label} - - - - ); - }; - - const _Chip = () => ( - - ); - const ticketTitle = sanitizeHTML({ disallowedTagsMode: 'discard', sanitizingTier: 'none', @@ -162,14 +86,11 @@ export const SupportTicketDetail = () => { position: 2, }, ], - labelOptions: { - subtitle: `${status} by ${ticket.updated_by} at ${formattedDate}`, - suffixComponent: <_Chip />, - }, pathname: location.pathname, }} title={ticketTitle} /> + {/* If a user attached files when creating the ticket and was redirected here, display those errors. */} {attachmentErrors !== undefined && @@ -182,8 +103,6 @@ export const SupportTicketDetail = () => { /> ))} - {ticket.entity && renderEntityLabelWithIcon()} - {/* If the ticket isn't blank, display it, followed by replies (if any). */} diff --git a/packages/manager/src/features/Support/SupportTicketDetail/TicketStatus.tsx b/packages/manager/src/features/Support/SupportTicketDetail/TicketStatus.tsx new file mode 100644 index 00000000000..8d82d055a47 --- /dev/null +++ b/packages/manager/src/features/Support/SupportTicketDetail/TicketStatus.tsx @@ -0,0 +1,84 @@ +import { SupportTicket } from '@linode/api-v4/lib/support/types'; +import { styled } from '@mui/material/styles'; +import React from 'react'; + +import { Link } from 'src/components/Link'; +import { Stack } from 'src/components/Stack'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { Typography } from 'src/components/Typography'; +import { useProfile } from 'src/queries/profile'; +import { capitalize } from 'src/utilities/capitalize'; +import { formatDate } from 'src/utilities/formatDate'; +import { getLinkTargets } from 'src/utilities/getEventsActionLink'; + +type Props = Pick< + SupportTicket, + 'entity' | 'status' | 'updated' | 'updated_by' +>; + +export const TicketStatus = (props: Props) => { + const { entity, status, updated, updated_by } = props; + + const { data: profile } = useProfile(); + + const formattedDate = formatDate(updated, { + timezone: profile?.timezone, + }); + const statusUpdateText = status === 'closed' ? 'Closed' : 'Last updated'; + + const renderEntityLabel = () => { + if (!entity) { + return null; + } + + const target = getLinkTargets(entity); + + return ( + + | Regarding:{' '} + {target ? {entity.label} : entity.label} + + ); + }; + + return ( + ({ + flexFlow: 'row wrap', + marginBottom: theme.spacing(3), + [theme.breakpoints.down('md')]: { + marginLeft: theme.spacing(1), + }, + })} + > + + + {capitalize(status)} + +   + + | {statusUpdateText} by {updated_by} at {formattedDate} + +   + {renderEntityLabel()} + + ); +}; + +const StyledStatusIcon = styled(StatusIcon, { + label: 'StyledStatusIcon', +})(({ theme, ...props }) => ({ + alignSelf: 'center', + ...(props.status === 'inactive' && + theme.name === 'light' && { + backgroundColor: theme.color.grey3, + }), +})); diff --git a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.test.tsx b/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.test.tsx index 44529a312ef..eb7d193439d 100644 --- a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.test.tsx +++ b/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.test.tsx @@ -66,13 +66,9 @@ describe('AddNewMenu', () => { }); test('does not render hidden menu item - databases', () => { - const mockedUseFlags = jest.fn().mockReturnValue({ databases: false }); - jest.mock('src/hooks/useFlags', () => ({ - __esModule: true, - useFlags: mockedUseFlags, - })); - - const { getByText, queryByText } = renderWithTheme(); + const { getByText, queryByText } = renderWithTheme(, { + flags: { databases: false }, + }); const createButton = getByText('Create'); fireEvent.click(createButton); const hiddenMenuItem = queryByText('Create Database'); diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchSuggestion.styles.ts b/packages/manager/src/features/TopMenu/SearchBar/SearchSuggestion.styles.ts index 51f64a28083..ae369de459a 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchSuggestion.styles.ts +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchSuggestion.styles.ts @@ -47,8 +47,8 @@ export const StyledSuggestionTitle = styled('div', { label: 'StyledSuggestionTitle', })(({ theme }) => ({ color: theme.palette.text.primary, + fontFamily: theme.font.bold, fontSize: '1rem', - fontWeight: 600, wordBreak: 'break-all', })); diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchSuggestion.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchSuggestion.tsx index 5498c2592e7..43d84821037 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchSuggestion.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchSuggestion.tsx @@ -74,7 +74,6 @@ export const SearchSuggestion = (props: SearchSuggestionProps) => { } return tags.map((tag: string) => (