diff --git a/docs/development-guide/13-coding-standards.md b/docs/development-guide/13-coding-standards.md index 582d3944b24..76928a24558 100644 --- a/docs/development-guide/13-coding-standards.md +++ b/docs/development-guide/13-coding-standards.md @@ -158,39 +158,3 @@ For this reason, extending types with interfaces/extends is suggested over creat ``` Source: [TypeScript Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#preferring-interfaces-over-intersections) - -## Adobe Analytics - -### Writing a Custom Event - -Custom events live (mostly) in `src/utilities/analytics.ts`. Try to write and export custom events in this file if possible, and import them in the component(s) where they are used. - -```tsx -// Component.tsx {file(s) where the event is called, for quick reference} -// OtherComponent.tsx - -sendDescriptiveNameEvent () => { - category: '{Descriptive/Page Name}', - action: '{interaction such as Click, Hover, Focus}:{input type such as button, link, toggle, checkbox, text field} e.g. Click:link', - label: '{string associated with the event; e.g event label} (optional)', - value: '{number associated with the event; e.g. size of a volume} (optional)', -} -``` - -When adding a new custom event, coordinating with UX on the event's `category`, `action`, `label`, and `value` props values ensures consistency across our data. - -Avoid including pipes (`|`) as delimiters in any of the event properties. They are used in Adobe Analytics to separate fields. - -Avoid creating custom events that collect such as search queries, entity names, or other forms of user input. Custom events can still be fired on these actions with a generic `label` or no label at all. - -Examples - -- `sendMarketplaceSearchEvent` fires when selecting a category from the dropdown (`label` is predefined) and clicking the search field (a generic `label` is used). -- `sendBucketCreateEvent` sends the region of the bucket, but does not send the bucket label. - -### Locally Testing Page Views & Custom Events and/or Troubleshooting - -1. Set the `REACT_APP_ADOBE_ANALYTICS_URL` environment variable in `.env`. -2. Use the browser tools Network tab, filter requests by "adobe", and check that successful network requests have been made to load the launch script and its extensions. -3. In the browser console, type `_satellite.setDebug(true)`. -4. Refresh the page. You should see Adobe debug log output in the console. Each page view change or custom event that fires should be visible in the logs. diff --git a/docs/tooling/analytics.md b/docs/tooling/analytics.md new file mode 100644 index 00000000000..eef9ee3b951 --- /dev/null +++ b/docs/tooling/analytics.md @@ -0,0 +1,42 @@ +# Adobe Analytics + +Cloud Manager uses Adobe Analytics to capture page view and custom event statistics. To view analytics, Cloud Manager developers must follow internal processes to request access to Adobe Analytics dashboards. + +## Writing a Custom Event + +Custom events live (mostly) in `src/utilities/analytics.ts`. Try to write and export custom events in this file if possible, and import them in the component(s) where they are used. + +A custom event will take this shape: +```tsx +// Component.tsx {file(s) where the event is called, for quick reference} +// OtherComponent.tsx + +sendDescriptiveNameEvent () => { + category: '{Descriptive/Page/Flow Name}', + action: '{interaction such as Click, Hover, Focus}:{input type such as button, link, toggle, checkbox, text field} e.g. Click:link', + label: '{string associated with the event; e.g event label} (optional)', + value: '{number associated with the event; e.g. size of a volume} (optional)', + data: '{stringified object of additional key-value pairs; e.g. "{isLinodePoweredOff: true}"} (optional)' +} +``` + +When adding a new custom event, coordinate with UX on the event's `category`, `action`, `label`, and `value` variables to ensure consistency across our data. + +`data` is an additional variable we use to capture information associated with an event that cannot easily or clearly be represented via the other variables; for example, boolean key-value pair(s). To add an additional property to `data`, it should first be added as an optional property and typed in the `CustomAnalyticsData` interface. + +Avoid including pipes (`|`) as delimiters in any of the event properties. They are used in Adobe Analytics to separate fields. + +Avoid creating custom events that collect such as search queries, entity names, or other forms of user input. Custom events can still be fired on these actions with a generic `label` or no label at all. + +Examples + +- `sendMarketplaceSearchEvent` fires when selecting a category from the dropdown (`label` is predefined) and clicking the search field (a generic `label` is used). +- `sendBucketCreateEvent` sends the region of the bucket, but does not send the bucket label. + +## Locally Testing Page Views & Custom Events and/or Troubleshooting + +1. Set the `REACT_APP_ADOBE_ANALYTICS_URL` environment variable in `.env`. +2. Use the browser tools Network tab, filter requests by "adobe", and check that successful network requests have been made to load the launch script and its extensions. +3. In the browser console, type `_satellite.setDebug(true)`. +4. Refresh the page. You should see Adobe debug log output in the console. Each page view change or custom event that fires should be visible in the logs. +5. When viewing dashboards in Adobe Analytics, it may take ~1 hour for analytics data to update. Once this happens, locally fired events will be visible in the dev dashboard. diff --git a/package.json b/package.json index 7a52c671374..4c7b5804b7d 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,6 @@ "node-fetch": "^2.6.7", "ua-parser-js": "^0.7.33", "immer": "^9.0.6", - "lodash": "^4.17.21", "glob-parent": "^5.1.2", "hosted-git-info": "^5.0.0", "yaml": "^2.3.0", diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 2eadb3daf3f..76fa30da115 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,18 @@ +## [2024-04-29] - v0.115.0 + + +### Added: + +- New endpoint for `volumes/types` ([#10376](https://github.com/linode/manager/pull/10376)) + +### Changed: + +- Allow `stackscript_id` to be `null` in `CreateLinodeRequest` ([#10367](https://github.com/linode/manager/pull/10367)) + +### Upcoming Features: + +- Add interface for linode migrate flow with placement groups ([#10339](https://github.com/linode/manager/pull/10339)) + ## [2024-04-15] - v0.114.0 ### Added: diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 5c4358e0126..1acbfc6043b 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.114.0", + "version": "0.115.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/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 2b39c104c81..55a76fd3359 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -363,7 +363,7 @@ export interface CreateLinodeRequest { * * This field cannot be used when deploying from a Backup or a Private Image. */ - stackscript_id?: number; + stackscript_id?: number | null; /** * A Backup ID from another Linode’s available backups. * @@ -451,6 +451,14 @@ export interface CreateLinodeRequest { placement_group?: CreateLinodePlacementGroupPayload; } +export interface MigrateLinodeRequest { + placement_group?: { + id: number; + compliant_only?: boolean; + }; + region: string; +} + export type RescueRequestObject = Pick< Devices, 'sda' | 'sdb' | 'sdc' | 'sdd' | 'sde' | 'sdf' | 'sdg' diff --git a/packages/api-v4/src/volumes/volumes.ts b/packages/api-v4/src/volumes/volumes.ts index ba63629032d..d489fd1bb57 100644 --- a/packages/api-v4/src/volumes/volumes.ts +++ b/packages/api-v4/src/volumes/volumes.ts @@ -12,7 +12,7 @@ import Request, { setURL, setXFilter, } from '../request'; -import { Filter, Params, ResourcePage as Page } from '../types'; +import { Filter, Params, ResourcePage as Page, PriceType } from '../types'; import { AttachVolumePayload, CloneVolumePayload, @@ -48,6 +48,19 @@ export const getVolumes = (params?: Params, filters?: Filter) => setXFilter(filters) ); +/** + * getVolumeTypes + * + * Return a paginated list of available Volume types, which contains pricing information. + * This endpoint does not require authentication. + */ +export const getVolumeTypes = (params?: Params) => + Request>( + setURL(`${API_ROOT}/volumes/types`), + setMethod('GET'), + setParams(params) + ); + /** * attachVolume * diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 0f720f45036..14683a31986 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,68 @@ 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/). +## [2024-04-29] - v1.118.0 + + +### Added: + +- April Marketplace apps and SVGs ([#10382](https://github.com/linode/manager/pull/10382)) + +### Changed: + +- Improve the UX of Access Token & Access Key drawers ([#10338](https://github.com/linode/manager/pull/10338)) +- RegionSelect disabled option API updates ([#10373](https://github.com/linode/manager/pull/10373)) +- Dynamic pricing with `volumes/types` endpoint ([#10376](https://github.com/linode/manager/pull/10376)) +- Top Menu clean up and refactor ([#10383](https://github.com/linode/manager/pull/10383)) +- PlanSelection availability updates and consolidation ([#10387](https://github.com/linode/manager/pull/10387)) +- Shift wording from 'limited availability' to 'limited deployment availability' ([#10394](https://github.com/linode/manager/pull/10394)) +- Gecko Beta copy updates ([#10400](https://github.com/linode/manager/pull/10400)) + +### Fixed: + +- Charts Y-axis values are trimmed when scale is increased ([#10330](https://github.com/linode/manager/pull/10330)) +- Chrome bug related to outdated CSS vendor prefixes ([#10380](https://github.com/linode/manager/pull/10380)) +- Clickable disabled smaller plans in Resizing Tab ([#10381](https://github.com/linode/manager/pull/10381)) +- New OBJ Buckets do not appear when created before initial fetch completes ([#10388](https://github.com/linode/manager/pull/10388)) + +### Removed: + +- Deprecated Marketplace apps ([#10382](https://github.com/linode/manager/pull/10382)) + +### Tech Stories: + +- Refactor and streamline VPC queries ([#10322](https://github.com/linode/manager/pull/10322)) +- Update documentation on Adobe Analytics to cover data property ([#10365](https://github.com/linode/manager/pull/10365)) +- Add isFeatureEnabledV2 to check for feature flag AND account capability ([#10371](https://github.com/linode/manager/pull/10371)) +- Replace sanitize-html with dompurify ([#10378](https://github.com/linode/manager/pull/10378)) +- Remove lodash (global import) as a package dependency ([#10386](https://github.com/linode/manager/pull/10386)) +- Add Gravatar Analytics ([#10389](https://github.com/linode/manager/pull/10389)) + +### Tests: + +- Add tests for Parent/Child Users & Grants page ([#10240](https://github.com/linode/manager/pull/10240)) +- Add new Cypress tests for Longview landing page ([#10321](https://github.com/linode/manager/pull/10321)) +- Add VM Placement Group landing page empty state UI test ([#10350](https://github.com/linode/manager/pull/10350)) +- Fix `machine-image-upload.spec.ts` e2e test flake ([#10370](https://github.com/linode/manager/pull/10370)) +- Update latest kernel version to fix `linode-config.spec.ts` ([#10391](https://github.com/linode/manager/pull/10391)) +- Fix hanging account switching test ([#10396](https://github.com/linode/manager/pull/10396)) + +### Upcoming Features: + +- Add Placement Groups to Linode Migrate flow ([#10339](https://github.com/linode/manager/pull/10339)) +- Add text copy for Placement Group region limits in PlacementGroupsCreateDrawer ([#10355](https://github.com/linode/manager/pull/10355)) +- Invalidate Placement Group queries on Linode create & delete mutations ([#10366](https://github.com/linode/manager/pull/10366)) +- Update the Placement Groups SVG icon ([#10379](https://github.com/linode/manager/pull/10379)) +- Fix & Improve Placement Groups feature restriction ([#10372](https://github.com/linode/manager/pull/10372)) +- Linode Create Refactor: + - VPC (#10354) + - StackScripts (#10367) + - Validation (#10374) + - User Defined Fields ([#10395](https://github.com/linode/manager/pull/10395)) +- Update gecko feature flag to object ([#10363](https://github.com/linode/manager/pull/10363)) +- Show the selected regions as chips in the AccessKeyDrawer ([#10375](https://github.com/linode/manager/pull/10375)) +- Add feature flag for Linode Disk Encryption (LDE) ([#10402](https://github.com/linode/manager/pull/10402)) + ## [2024-04-15] - v1.117.0 ### Added: diff --git a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts index 7e5f0b0f9c6..5f616fe4de6 100644 --- a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts +++ b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts @@ -68,7 +68,34 @@ describe('Personal access tokens', () => { .findByTitle('Add Personal Access Token') .should('be.visible') .within(() => { - // Attempt to submit form without specifying a label + // Confirm submit button is disabled without specifying scopes. + ui.buttonGroup + .findButtonByTitle('Create Token') + .scrollIntoView() + .should('be.visible') + .should('be.disabled'); + + // Select just one scope. + cy.get('[data-qa-row="Account"]').within(() => { + cy.get('[type="radio"]').first().click(); + }); + + // Confirm submit button is still disabled without specifying ALL scopes. + ui.buttonGroup + .findButtonByTitle('Create Token') + .scrollIntoView() + .should('be.visible') + .should('be.disabled'); + + // Specify ALL scopes by selecting the "No Access" Select All radio button. + cy.get('[data-qa-perm-no-access-radio]').click(); + cy.get('[data-qa-perm-no-access-radio]').should( + 'have.attr', + 'data-qa-radio', + 'true' + ); + + // Confirm submit button is enabled; attempt to submit form without specifying a label. ui.buttonGroup .findButtonByTitle('Create Token') .scrollIntoView() @@ -76,6 +103,7 @@ describe('Personal access tokens', () => { .should('be.enabled') .click(); + // Confirm validation error. cy.findByText('Label must be between 1 and 100 characters.') .scrollIntoView() .should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts index e7cc216163a..e67d5695332 100644 --- a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts @@ -4,14 +4,12 @@ import { accountUserFactory } from '@src/factories/accountUsers'; import { grantsFactory } from '@src/factories/grants'; import { userPermissionsGrants } from 'support/constants/user-permissions'; import { - mockAddUser, mockGetUser, mockGetUserGrants, mockGetUserGrantsUnrestrictedAccess, mockGetUsers, mockUpdateUser, mockUpdateUserGrants, - mockDeleteUser, } from 'support/intercepts/account'; import { mockAppendFeatureFlags, @@ -22,9 +20,8 @@ import { ui } from 'support/ui'; import { shuffleArray } from 'support/util/arrays'; import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; -import { PARENT_USER } from 'src/features/Account/constants'; -// Message shown when user has unrestricted account acess. +// Message shown when user has unrestricted account access. const unrestrictedAccessMessage = 'This user has unrestricted access to the account.'; @@ -482,7 +479,11 @@ describe('User permission management', () => { }); }); - it('disables Read-Write and defaults to Read Only Billing Access for child account users with Parent/Child feature flag', () => { + /** + * Confirm the User Permissions flow for a child account. + * Confirm that child accounts default to "Read Only" Billing Access and have disabled "Read Write". + */ + it('tests the user permissions for a child account', () => { const mockProfile = profileFactory.build({ username: 'unrestricted-child-user', }); @@ -514,20 +515,6 @@ describe('User permission management', () => { mockGetUserGrants(mockActiveUser.username, mockUserGrants); mockGetProfile(mockProfile); - // Navigate to Users & Grants page, find mock restricted user, click its "User Permissions" button. - cy.visitWithLogin('/account/users'); - cy.wait('@getUsers'); - cy.findByText(mockRestrictedUser.username) - .should('be.visible') - .closest('tr') - .within(() => { - ui.button - .findByTitle('User Permissions') - .should('be.visible') - .should('be.enabled') - .click(); - }); - cy.visitWithLogin( `/account/users/${mockRestrictedUser.username}/permissions` ); @@ -561,14 +548,11 @@ describe('User permission management', () => { }); /** - * Confirm the Users & Grants and User Permissions pages flow for a child account viewing a proxy user. - * Confirm that "Parent User Settings" and "User Settings" sections are present on the Users & Grants page. - * Confirm that proxy accounts are listed under "Parent User Settings". - * Confirm that clicking the "Manage Access" button navigates to the proxy user's User Permissions page at /account/users/:user/permissions. + * Confirm the User Permissions flow for a child account viewing a proxy user. * Confirm that no "Profile" tab is present on the proxy user's User Permissions page. * Confirm that proxy accounts default to "Read Write" Billing Access and have disabled "Read Only" and "None" options. */ - it('tests the users landing and user permissions flow for a child account viewing a proxy user ', () => { + it('tests the user permissions for a child account viewing a proxy user', () => { const mockChildProfile = profileFactory.build({ username: 'proxy-user', user_type: 'child', @@ -602,33 +586,10 @@ describe('User permission management', () => { mockGetUser(mockRestrictedProxyUser); mockGetUserGrants(mockRestrictedProxyUser.username, mockUserGrants); - // Navigate to Users & Grants page and confirm "Parent User Settings" and "User Settings" sections are visible. - cy.visitWithLogin('/account/users'); - cy.wait('@getUsers'); - cy.findByText(`${PARENT_USER} Settings`).should('be.visible'); - cy.findByText('User Settings').should('be.visible'); - - // Find mock restricted proxy user under "Parent User Settings", click its "Manage Access" button. - cy.findByLabelText('List of Parent Users') - .should('be.visible') - .within(() => { - cy.findByText(mockRestrictedProxyUser.username) - .should('be.visible') - .closest('tr') - .within(() => { - ui.button - .findByTitle('Manage Access') - .should('be.visible') - .should('be.enabled') - .click(); - }); - }); - - // Confirm button navigates to the proxy user's User Permissions page at /account/users/:user/permissions. - cy.url().should( - 'endWith', + cy.visitWithLogin( `/account/users/${mockRestrictedProxyUser.username}/permissions` ); + cy.wait(['@getClientStream', '@getFeatureFlags']); cy.findByText('Parent User Permissions', { exact: false }).should( @@ -660,354 +621,4 @@ describe('User permission management', () => { .should('have.attr', 'disabled'); }); }); - - it('can add users with full access', () => { - const mockUser = accountUserFactory.build({ - username: randomLabel(), - restricted: false, - }); - - const username = randomLabel(); - const newUser = accountUserFactory.build({ - username: username, - email: `${username}@test.com`, - restricted: false, - }); - - mockGetUsers([mockUser]).as('getUsers'); - mockGetUser(mockUser); - mockGetUserGrantsUnrestrictedAccess(mockUser.username); - mockAddUser(newUser).as('addUser'); - - // Navigate to Users & Grants page, find mock user, click its "User Permissions" button. - cy.visitWithLogin('/account/users'); - cy.wait('@getUsers'); - - // Confirm that the "Users & Grants" page initially lists the main user - cy.findByText(mockUser.username).should('be.visible'); - - mockGetUsers([mockUser, newUser]).as('getUsers'); - - // "Add a User" button shows up and is clickable - cy.findByText('Add a User') - .should('be.visible') - .should('be.enabled') - .click(); - - // "Add a User" drawer shows up - ui.drawer - .findByTitle('Add a User') - .should('be.visible') - .within(() => { - cy.findByText('Username').click().type(`${newUser.username}{enter}`); - cy.findByText('Email') - .click() - .type(`${newUser.username}@test.com{enter}`); - ui.buttonGroup - .findButtonByTitle('Cancel') - .should('be.visible') - .should('be.enabled') - .click(); - }); - // the drawer has been closed - cy.findByText('Add a User').should('not.exist'); - // cancel button will not add a new user - cy.findByText(newUser.username).should('not.exist'); - - cy.findByText('Add a User') - .should('be.visible') - .should('be.enabled') - .click(); - - // "x" button will not add a new user - ui.drawer - .findByTitle('Add a User') - .should('be.visible') - .within(() => { - cy.findByText('Username').click().type(`${newUser.username}{enter}`); - cy.findByText('Email') - .click() - .type(`${newUser.username}@test.com{enter}`); - ui.buttonGroup - .findButtonByTitle('Cancel') - .should('be.visible') - .should('be.enabled') - .click(); - }); - // the drawer has been closed - cy.findByText('Add a User').should('not.exist'); - // no new user is added - cy.findByText(newUser.username).should('not.exist'); - - // new user should be added and shown in the user list - cy.findByText('Add a User') - .should('be.visible') - .should('be.enabled') - .click(); - - // confirm to add a new user - ui.drawer - .findByTitle('Add a User') - .should('be.visible') - .within(() => { - // an inline error message will be displayed when username or email is not specified - ui.buttonGroup - .findButtonByTitle('Add User') - .should('be.visible') - .should('be.enabled') - .click(); - cy.findByText('Username is required.').should('be.visible'); - cy.findByText('Email address is required.').should('be.visible'); - - // type username - cy.findByText('Username').click().type(`${newUser.username}{enter}`); - - // an inline error message will be displayed when the email address is invalid - cy.findByText('Email').click().type(`not_valid_email_address{enter}`); - ui.buttonGroup - .findButtonByTitle('Add User') - .should('be.visible') - .should('be.enabled') - .click(); - cy.findByText('Must be a valid Email address.').should('be.visible'); - - // type email address - cy.get('[id="email"]') - .click() - .clear() - .type(`${newUser.username}@test.com{enter}`); - - ui.buttonGroup - .findButtonByTitle('Add User') - .should('be.visible') - .should('be.enabled') - .click(); - }); - // Cloud Manager passes "restricted: false" in the request payload - cy.wait('@addUser').then((intercept) => { - expect(intercept.request.body['restricted']).to.equal(newUser.restricted); - }); - cy.wait('@getUsers'); - - // the new user is displayed in the user list - cy.findByText(newUser.username).should('be.visible'); - - // no redirect occurs - cy.url().should('endWith', '/users'); - }); - - it('can add users with restricted access', () => { - const mockUser = accountUserFactory.build({ - username: randomLabel(), - restricted: false, - }); - - const username = randomLabel(); - const newUser = accountUserFactory.build({ - username: username, - email: `${username}@test.com`, - restricted: true, - }); - - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - mockGetUsers([mockUser]).as('getUsers'); - mockGetUser(mockUser); - mockGetUserGrantsUnrestrictedAccess(mockUser.username); - mockAddUser(newUser).as('addUser'); - - // Navigate to Users & Grants page, find mock user, click its "User Permissions" button. - cy.visitWithLogin('/account/users'); - cy.wait('@getUsers'); - - // Confirm that the "Users & Grants" page initially lists the main user - cy.findByText(mockUser.username).should('be.visible'); - - mockGetUsers([mockUser, newUser]).as('getUsers'); - - // "Add a User" button shows up and is clickable - cy.findByText('Add a User') - .should('be.visible') - .should('be.enabled') - .click(); - - // "Add a User" drawer shows up - ui.drawer - .findByTitle('Add a User') - .should('be.visible') - .within(() => { - cy.findByText('Username').click().type(`${newUser.username}{enter}`); - cy.findByText('Email') - .click() - .type(`${newUser.username}@test.com{enter}`); - ui.buttonGroup - .findButtonByTitle('Cancel') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // "x" or cancel button will not add a new user - cy.findByText(newUser.username).should('not.exist'); - - // new user should be added and shown in the user list - cy.findByText('Add a User') - .should('be.visible') - .should('be.enabled') - .click(); - - // confirm to add a new user - ui.drawer - .findByTitle('Add a User') - .should('be.visible') - .within(() => { - // an inline error message will be displayed when username or email is not specified - ui.buttonGroup - .findButtonByTitle('Add User') - .should('be.visible') - .should('be.enabled') - .click(); - cy.findByText('Username is required.').should('be.visible'); - cy.findByText('Email address is required.').should('be.visible'); - - // type username - cy.findByText('Username').click().type(`${newUser.username}{enter}`); - - // an inline error message will be displayed when the email address is invalid - cy.findByText('Email').click().type(`not_valid_email_address{enter}`); - ui.buttonGroup - .findButtonByTitle('Add User') - .should('be.visible') - .should('be.enabled') - .click(); - cy.findByText('Must be a valid Email address.').should('be.visible'); - - // type email address - cy.get('[id="email"]') - .click() - .clear() - .type(`${newUser.username}@test.com{enter}`); - - // toggle to disable full access - cy.get('[data-qa-create-restricted="true"]') - .should('be.visible') - .click(); - - ui.buttonGroup - .findButtonByTitle('Add User') - .should('be.visible') - .should('be.enabled') - .click(); - }); - // Cloud Manager passes "restricted: true" in the request payload - cy.wait('@addUser').then((intercept) => { - expect(intercept.request.body['restricted']).to.equal(newUser.restricted); - }); - cy.wait('@getUsers'); - cy.wait(['@getClientStream', '@getFeatureFlags']); - - // the new user is displayed in the user list - cy.findByText(newUser.username).should('be.visible'); - - // redirects to the new user's "User Permissions" page - cy.url().should('endWith', `/users/${newUser.username}/permissions`); - }); - - it('can delete users', () => { - const mockUser = accountUserFactory.build({ - username: randomLabel(), - restricted: false, - }); - - const username = randomLabel(); - const additionalUser = accountUserFactory.build({ - username: username, - email: `${username}@test.com`, - restricted: false, - }); - - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - mockGetUsers([mockUser, additionalUser]).as('getUsers'); - mockGetUser(mockUser); - mockGetUserGrantsUnrestrictedAccess(mockUser.username); - mockGetUserGrantsUnrestrictedAccess(additionalUser.username); - mockDeleteUser(additionalUser.username).as('deleteUser'); - - // Navigate to Users & Grants page, find mock user, click its "User Permissions" button. - cy.visitWithLogin('/account/users'); - cy.wait('@getUsers'); - - mockGetUsers([mockUser]).as('getUsers'); - - // Confirm that the "Users & Grants" page initially lists the main and additional users - cy.findByText(mockUser.username).should('be.visible'); - cy.findByText(additionalUser.username) - .should('be.visible') - .closest('tr') - .within(() => { - ui.button - .findByTitle('Delete') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // the "Confirm Deletion" dialog opens - ui.dialog.findByTitle('Confirm Deletion').within(() => { - ui.button.findByTitle('Cancel').should('be.visible').click(); - }); - // click the "Cancel" button will do nothing - cy.findByText(mockUser.username).should('be.visible'); - cy.findByText(additionalUser.username).should('be.visible'); - - // clicking the "x" button will dismiss the dialog and do nothing - cy.findByText(additionalUser.username) - .should('be.visible') - .closest('tr') - .within(() => { - ui.button - .findByTitle('Delete') - .should('be.visible') - .should('be.enabled') - .click(); - }); - ui.dialog.findByTitle('Confirm Deletion').within(() => { - cy.get('[data-testid="CloseIcon"]').should('be.visible').click(); - }); - cy.findByText(mockUser.username).should('be.visible'); - cy.findByText(additionalUser.username).should('be.visible'); - - // delete the user - cy.findByText(additionalUser.username) - .should('be.visible') - .closest('tr') - .within(() => { - ui.button - .findByTitle('Delete') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // the "Confirm Deletion" dialog opens - ui.dialog.findByTitle('Confirm Deletion').within(() => { - ui.button.findByTitle('Delete').should('be.visible').click(); - }); - cy.wait(['@deleteUser', '@getUsers']); - - // the user is deleted - ui.toast.assertMessage( - `User ${additionalUser.username} has been deleted successfully.` - ); - cy.findByText(additionalUser.username).should('not.exist'); - }); }); diff --git a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts new file mode 100644 index 00000000000..e6c79be4da4 --- /dev/null +++ b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts @@ -0,0 +1,670 @@ +import { profileFactory } from '@src/factories'; +import { accountUserFactory } from '@src/factories/accountUsers'; +import { grantsFactory } from '@src/factories/grants'; +import type { Profile } from '@linode/api-v4'; +import { + mockAddUser, + mockGetUser, + mockGetUserGrants, + mockGetUserGrantsUnrestrictedAccess, + mockGetUsers, + mockDeleteUser, +} from 'support/intercepts/account'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; +import { ui } from 'support/ui'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { randomLabel } from 'support/util/random'; +import { PARENT_USER } from 'src/features/Account/constants'; + +/** + * Initialize test users before tests + * + * @param mockProfile - Account profile. + * @param enableChildAccountAccess - Child account access switch. + * + * @returns User array. + */ +const initTestUsers = (profile: Profile, enableChildAccountAccess: boolean) => { + const mockProfile = profile; + + const mockRestrictedParentWithoutChildAccountAccess = accountUserFactory.build( + { + username: 'restricted-parent-user-without-child-account-access', + restricted: true, + user_type: 'parent', + } + ); + + const mockRestrictedParentWithChildAccountAccess = accountUserFactory.build({ + username: 'restricted-parent-user-with-child-account-access', + restricted: true, + user_type: 'parent', + }); + + const mockUsers = [ + mockRestrictedParentWithoutChildAccountAccess, + mockRestrictedParentWithChildAccountAccess, + ]; + + const mockParentNoAccountAccessGrants = grantsFactory.build({ + global: { child_account_access: false }, + }); + + const mockParentWithAccountAccessGrants = grantsFactory.build({ + global: { child_account_access: true }, + }); + + const mockProfileGrants = grantsFactory.build({ + global: { child_account_access: enableChildAccountAccess }, + }); + + // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. + mockAppendFeatureFlags({ + parentChildAccountAccess: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Initially mock user with unrestricted account access. + mockGetUsers(mockUsers).as('getUsers'); + mockGetUser(mockRestrictedParentWithoutChildAccountAccess); + mockGetUserGrants( + mockRestrictedParentWithoutChildAccountAccess.username, + mockParentNoAccountAccessGrants + ); + mockGetUser(mockRestrictedParentWithChildAccountAccess); + mockGetUserGrants( + mockRestrictedParentWithChildAccountAccess.username, + mockParentWithAccountAccessGrants + ); + mockGetProfileGrants(mockProfileGrants); + mockGetProfile(mockProfile); + + return mockUsers; +}; + +describe('Users landing page', () => { + /* + * Confirm the visibility and status of the "Child account access" column for the following users: + * - Unrestricted parent user (Enabled) + * - Restricted parent user with child_account_access grant set to false (Disabled) + * - Restricted parent user with child_account_access grant set to true (Enabled) + * Confirm that a "Child account access" column is present in the users table for parent users, but not for other types (default, proxy, and child)) + * Confirm the column reflects the status of child account access for the corresponding users + */ + it('shows "Child account access" column for unrestricted parent users and shows restricted parent users who have the correct grant status', () => { + const mockProfile = profileFactory.build({ + username: 'unrestricted-parent-user', + restricted: false, + user_type: 'parent', + }); + + const mockUsers = initTestUsers(mockProfile, true); + + // Navigate to Users & Grants page. + cy.visitWithLogin('/account/users'); + cy.wait('@getUsers'); + + // Confirm that "Child account access" column is present + cy.findByText('Child Account Access').should('be.visible'); + mockUsers.forEach((user) => { + cy.get(`[aria-label="User ${user.username}"]`) + .should('be.visible') + .within(() => { + if ( + user.username === + 'restricted-parent-user-without-child-account-access' + ) { + // The status should be "Disabled" for the user without "child_account_access" grant + cy.findByText('Disabled').should('be.visible'); + } else { + // The status should be "Enabled" for the user without "child_account_access" grant + cy.findByText('Enabled').should('be.visible'); + } + }); + }); + }); + + it('shows "Child account access" column for restricted parent users with child_account_access grant set to true', () => { + const mockProfile = profileFactory.build({ + username: 'restricted-parent-user', + restricted: true, + user_type: 'parent', + }); + + initTestUsers(mockProfile, true); + + // Navigate to Users & Grants page. + cy.visitWithLogin('/account/users'); + + // Confirm that "Child account access" column is present + cy.findByText('Child Account Access').should('be.visible'); + }); + + it('hides "Child account access" column for restricted parent users with child_account_access grant set to false', () => { + const mockProfile = profileFactory.build({ + username: 'restricted-parent-user', + restricted: true, + user_type: 'parent', + }); + + initTestUsers(mockProfile, false); + + // Navigate to Users & Grants page. + cy.visitWithLogin('/account/users'); + + // Confirm that "Child account access" column is not present + cy.findByText('Child Account Access').should('not.exist'); + }); + + it('hides "Child account access" column for default users', () => { + const mockProfile = profileFactory.build({ + username: 'default-user', + restricted: false, + }); + + initTestUsers(mockProfile, false); + + // Navigate to Users & Grants page. + cy.visitWithLogin('/account/users'); + cy.wait('@getUsers'); + + // Confirm that "Child account access" column is not present + cy.findByText('Child Account Access').should('not.exist'); + }); + + it('hides "Child account access" column for proxy users', () => { + const mockProfile = profileFactory.build({ + username: 'proxy-user', + restricted: false, + user_type: 'proxy', + }); + + initTestUsers(mockProfile, false); + + // Navigate to Users & Grants page. + cy.visitWithLogin('/account/users'); + cy.wait('@getUsers'); + + // Confirm that "Child account access" column is not present + cy.findByText('Child Account Access').should('not.exist'); + }); + + it('hides "Child account access" column for child users', () => { + const mockProfile = profileFactory.build({ + username: 'child-user', + restricted: false, + user_type: 'child', + }); + + initTestUsers(mockProfile, false); + + // Navigate to Users & Grants page. + cy.visitWithLogin('/account/users'); + cy.wait('@getUsers'); + + // Confirm that "Child account access" column is not present + cy.findByText('Child Account Access').should('not.exist'); + }); + + /* + * Confirm that "Parent User Settings" section is not present for parent users + */ + it('hides "Parent User Settings" section for parent users', () => { + const mockProfile = profileFactory.build({ + username: 'unrestricted-parent-user', + restricted: false, + user_type: 'parent', + }); + + const mockUser = accountUserFactory.build({ + username: 'unrestricted-user', + restricted: false, + }); + + // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. + mockAppendFeatureFlags({ + parentChildAccountAccess: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Initially mock user with unrestricted account access. + mockGetUsers([mockUser]).as('getUsers'); + mockGetUser(mockUser); + mockGetUserGrantsUnrestrictedAccess(mockUser.username).as('getUserGrants'); + mockGetProfile(mockProfile); + + // Navigate to Users & Grants page. + cy.visitWithLogin('/account/users'); + cy.wait('@getUsers'); + + // Confirm the "Parent User Settings" and "User Settings" sections are not present. + cy.findByText(`${PARENT_USER} Settings`).should('not.exist'); + cy.findByText('User Settings').should('not.exist'); + }); + + /** + * Confirm the Users & Grants and User Permissions pages flow for a child account viewing a proxy user. + * Confirm that "Parent User Settings" and "User Settings" sections are present on the Users & Grants page. + * Confirm that proxy accounts are listed under "Parent User Settings". + * Confirm that clicking the "Manage Access" button navigates to the proxy user's User Permissions page at /account/users/:user/permissions. + */ + it('tests the users landing flow for a child account viewing a proxy user', () => { + const mockChildProfile = profileFactory.build({ + username: 'child-user', + user_type: 'child', + }); + + const mockChildUser = accountUserFactory.build({ + restricted: false, + user_type: 'child', + }); + + const mockRestrictedProxyUser = accountUserFactory.build({ + restricted: true, + user_type: 'proxy', + username: 'restricted-proxy-user', + }); + + const mockUserGrants = grantsFactory.build({ + global: { account_access: 'read_write' }, + }); + + // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. + mockAppendFeatureFlags({ + parentChildAccountAccess: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetUsers([mockRestrictedProxyUser]).as('getUsers'); + mockGetUser(mockChildUser); + mockGetUserGrants(mockChildUser.username, mockUserGrants); + mockGetProfile(mockChildProfile); + mockGetUser(mockRestrictedProxyUser); + mockGetUserGrants(mockRestrictedProxyUser.username, mockUserGrants); + + // Navigate to Users & Grants page and confirm "Parent User Settings" and "User Settings" sections are visible. + cy.visitWithLogin('/account/users'); + cy.wait('@getUsers'); + cy.findByText(`${PARENT_USER} Settings`).should('be.visible'); + cy.findByText('User Settings').should('be.visible'); + + // Find mock restricted proxy user under "Parent User Settings", click its "Manage Access" button. + cy.findByLabelText('List of Parent Users') + .should('be.visible') + .within(() => { + cy.findByText(mockRestrictedProxyUser.username) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Manage Access') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + // Confirm button navigates to the proxy user's User Permissions page at /account/users/:user/permissions. + cy.url().should( + 'endWith', + `/account/users/${mockRestrictedProxyUser.username}/permissions` + ); + }); + + it('can add users with full access', () => { + const mockUser = accountUserFactory.build({ + username: randomLabel(), + restricted: false, + }); + + const username = randomLabel(); + const newUser = accountUserFactory.build({ + username: username, + email: `${username}@test.com`, + restricted: false, + }); + + mockGetUsers([mockUser]).as('getUsers'); + mockGetUser(mockUser); + mockGetUserGrantsUnrestrictedAccess(mockUser.username); + mockAddUser(newUser).as('addUser'); + + // Navigate to Users & Grants page, find mock user, click its "User Permissions" button. + cy.visitWithLogin('/account/users'); + cy.wait('@getUsers'); + + // Confirm that the "Users & Grants" page initially lists the main user + cy.findByText(mockUser.username).should('be.visible'); + + mockGetUsers([mockUser, newUser]).as('getUsers'); + + // "Add a User" button shows up and is clickable + cy.findByText('Add a User') + .should('be.visible') + .should('be.enabled') + .click(); + + // "Add a User" drawer shows up + ui.drawer + .findByTitle('Add a User') + .should('be.visible') + .within(() => { + cy.findByText('Username').click().type(`${newUser.username}{enter}`); + cy.findByText('Email') + .click() + .type(`${newUser.username}@test.com{enter}`); + ui.buttonGroup + .findButtonByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + // the drawer has been closed + cy.findByText('Add a User').should('not.exist'); + // cancel button will not add a new user + cy.findByText(newUser.username).should('not.exist'); + + cy.findByText('Add a User') + .should('be.visible') + .should('be.enabled') + .click(); + + // "x" button will not add a new user + ui.drawer + .findByTitle('Add a User') + .should('be.visible') + .within(() => { + cy.findByText('Username').click().type(`${newUser.username}{enter}`); + cy.findByText('Email') + .click() + .type(`${newUser.username}@test.com{enter}`); + ui.buttonGroup + .findButtonByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + // the drawer has been closed + cy.findByText('Add a User').should('not.exist'); + // no new user is added + cy.findByText(newUser.username).should('not.exist'); + + // new user should be added and shown in the user list + cy.findByText('Add a User') + .should('be.visible') + .should('be.enabled') + .click(); + + // confirm to add a new user + ui.drawer + .findByTitle('Add a User') + .should('be.visible') + .within(() => { + // an inline error message will be displayed when username or email is not specified + ui.buttonGroup + .findButtonByTitle('Add User') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Username is required.').should('be.visible'); + cy.findByText('Email address is required.').should('be.visible'); + + // type username + cy.findByText('Username').click().type(`${newUser.username}{enter}`); + + // an inline error message will be displayed when the email address is invalid + cy.findByText('Email').click().type(`not_valid_email_address{enter}`); + ui.buttonGroup + .findButtonByTitle('Add User') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Must be a valid Email address.').should('be.visible'); + + // type email address + cy.get('[id="email"]') + .click() + .clear() + .type(`${newUser.username}@test.com{enter}`); + + ui.buttonGroup + .findButtonByTitle('Add User') + .should('be.visible') + .should('be.enabled') + .click(); + }); + // Cloud Manager passes "restricted: false" in the request payload + cy.wait('@addUser').then((intercept) => { + expect(intercept.request.body['restricted']).to.equal(newUser.restricted); + }); + cy.wait('@getUsers'); + + // the new user is displayed in the user list + cy.findByText(newUser.username).should('be.visible'); + + // no redirect occurs + cy.url().should('endWith', '/users'); + }); + + it('can add users with restricted access', () => { + const mockUser = accountUserFactory.build({ + username: randomLabel(), + restricted: false, + }); + + const username = randomLabel(); + const newUser = accountUserFactory.build({ + username: username, + email: `${username}@test.com`, + restricted: true, + }); + + // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. + mockAppendFeatureFlags({ + parentChildAccountAccess: makeFeatureFlagData(false), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetUsers([mockUser]).as('getUsers'); + mockGetUser(mockUser); + mockGetUserGrantsUnrestrictedAccess(mockUser.username); + mockAddUser(newUser).as('addUser'); + + // Navigate to Users & Grants page, find mock user, click its "User Permissions" button. + cy.visitWithLogin('/account/users'); + cy.wait('@getUsers'); + + // Confirm that the "Users & Grants" page initially lists the main user + cy.findByText(mockUser.username).should('be.visible'); + + mockGetUsers([mockUser, newUser]).as('getUsers'); + + // "Add a User" button shows up and is clickable + cy.findByText('Add a User') + .should('be.visible') + .should('be.enabled') + .click(); + + // "Add a User" drawer shows up + ui.drawer + .findByTitle('Add a User') + .should('be.visible') + .within(() => { + cy.findByText('Username').click().type(`${newUser.username}{enter}`); + cy.findByText('Email') + .click() + .type(`${newUser.username}@test.com{enter}`); + ui.buttonGroup + .findButtonByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // "x" or cancel button will not add a new user + cy.findByText(newUser.username).should('not.exist'); + + // new user should be added and shown in the user list + cy.findByText('Add a User') + .should('be.visible') + .should('be.enabled') + .click(); + + // confirm to add a new user + ui.drawer + .findByTitle('Add a User') + .should('be.visible') + .within(() => { + // an inline error message will be displayed when username or email is not specified + ui.buttonGroup + .findButtonByTitle('Add User') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Username is required.').should('be.visible'); + cy.findByText('Email address is required.').should('be.visible'); + + // type username + cy.findByText('Username').click().type(`${newUser.username}{enter}`); + + // an inline error message will be displayed when the email address is invalid + cy.findByText('Email').click().type(`not_valid_email_address{enter}`); + ui.buttonGroup + .findButtonByTitle('Add User') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Must be a valid Email address.').should('be.visible'); + + // type email address + cy.get('[id="email"]') + .click() + .clear() + .type(`${newUser.username}@test.com{enter}`); + + // toggle to disable full access + cy.get('[data-qa-create-restricted="true"]') + .should('be.visible') + .click(); + + ui.buttonGroup + .findButtonByTitle('Add User') + .should('be.visible') + .should('be.enabled') + .click(); + }); + // Cloud Manager passes "restricted: true" in the request payload + cy.wait('@addUser').then((intercept) => { + expect(intercept.request.body['restricted']).to.equal(newUser.restricted); + }); + cy.wait('@getUsers'); + cy.wait(['@getClientStream', '@getFeatureFlags']); + + // the new user is displayed in the user list + cy.findByText(newUser.username).should('be.visible'); + + // redirects to the new user's "User Permissions" page + cy.url().should('endWith', `/users/${newUser.username}/permissions`); + }); + + it('can delete users', () => { + const mockUser = accountUserFactory.build({ + username: randomLabel(), + restricted: false, + }); + + const username = randomLabel(); + const additionalUser = accountUserFactory.build({ + username: username, + email: `${username}@test.com`, + restricted: false, + }); + + // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. + mockAppendFeatureFlags({ + parentChildAccountAccess: makeFeatureFlagData(false), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetUsers([mockUser, additionalUser]).as('getUsers'); + mockGetUser(mockUser); + mockGetUserGrantsUnrestrictedAccess(mockUser.username); + mockGetUserGrantsUnrestrictedAccess(additionalUser.username); + mockDeleteUser(additionalUser.username).as('deleteUser'); + + // Navigate to Users & Grants page, find mock user, click its "User Permissions" button. + cy.visitWithLogin('/account/users'); + cy.wait('@getUsers'); + + mockGetUsers([mockUser]).as('getUsers'); + + // Confirm that the "Users & Grants" page initially lists the main and additional users + cy.findByText(mockUser.username).should('be.visible'); + cy.findByText(additionalUser.username) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // the "Confirm Deletion" dialog opens + ui.dialog.findByTitle('Confirm Deletion').within(() => { + ui.button.findByTitle('Cancel').should('be.visible').click(); + }); + // click the "Cancel" button will do nothing + cy.findByText(mockUser.username).should('be.visible'); + cy.findByText(additionalUser.username).should('be.visible'); + + // clicking the "x" button will dismiss the dialog and do nothing + cy.findByText(additionalUser.username) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + ui.dialog.findByTitle('Confirm Deletion').within(() => { + cy.get('[data-testid="CloseIcon"]').should('be.visible').click(); + }); + cy.findByText(mockUser.username).should('be.visible'); + cy.findByText(additionalUser.username).should('be.visible'); + + // delete the user + cy.findByText(additionalUser.username) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // the "Confirm Deletion" dialog opens + ui.dialog.findByTitle('Confirm Deletion').within(() => { + ui.button.findByTitle('Delete').should('be.visible').click(); + }); + cy.wait(['@deleteUser', '@getUsers']); + + // the user is deleted + ui.toast.assertMessage( + `User ${additionalUser.username} has been deleted successfully.` + ); + cy.findByText(additionalUser.username).should('not.exist'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts index bd921824f7d..687a54abb80 100644 --- a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts +++ b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts @@ -8,7 +8,6 @@ import { authenticate } from 'support/api/authentication'; import { fbtVisible, getClick } from 'support/helpers'; import { mockDeleteImage, - mockGetAllImages, mockGetCustomImages, mockUpdateImage, } from 'support/intercepts/images'; @@ -249,7 +248,7 @@ describe('machine image', () => { cy.wait('@imageUpload').then((xhr) => { const imageId = xhr.response?.body.image.id; assertProcessing(label, imageId); - mockGetAllImages([ + mockGetCustomImages([ imageFactory.build({ label, id: imageId, status: 'available' }), ]).as('getImages'); eventIntercept(label, imageId, status); 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 b3f8fe6d3cc..a7d7a8c2fb9 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -80,6 +80,10 @@ describe('create linode', () => { /* * Region select test. + * + * TODO: Cypress + * Move this to cypress component testing once the setup is complete - see https://github.com/linode/manager/pull/10134 + * * - Confirms that region select dropdown is visible and interactive. * - Confirms that region select dropdown is populated with expected regions. * - Confirms that region select dropdown is sorted alphabetically by region, with North America first. 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 a78931ce9b4..141495ccfdf 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -8,7 +8,7 @@ import { mockGetFeatureFlagClientstream, } from 'support/intercepts/feature-flags'; import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; +import { mockGetVPC } from 'support/intercepts/vpc'; import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; import { getRegionById } from 'support/util/regions'; import { mockGetVLANs } from 'support/intercepts/vlans'; @@ -38,7 +38,7 @@ import { } from '@src/factories'; import { randomNumber, randomLabel } from 'support/util/random'; -import type { Config, Linode, VLAN, VPC, Disk, Region } from '@linode/api-v4'; +import type { Config, Linode, VLAN, Disk, Region } from '@linode/api-v4'; authenticate(); @@ -69,7 +69,6 @@ describe('Linode Config', () => { }, ]; const mockVLANs: VLAN[] = VLANFactory.buildList(2); - const mockVPCs: VPC[] = vpcFactory.buildList(5); before(() => { mockConfig.interfaces.splice(2, 1); @@ -111,7 +110,7 @@ describe('Linode Config', () => { cy.findByLabelText('List of Configurations').within(() => { cy.get('tr').should('have.length', 2); containsVisible( - `${linode.id}-test-config – Latest 64 bit (6.2.9-x86_64-linode160)` + `${linode.id}-test-config – Latest 64 bit (6.7.9-x86_64-linode163)` ); containsVisible('eth0 – Public Internet'); }); @@ -135,7 +134,6 @@ describe('Linode Config', () => { }).as('getFeatureFlags'); mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetVPCs(mockVPCs).as('getVPCs'); mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); mockGetLinodeConfigs(mockLinode.id, []); mockGetVPC(mockVPC).as('getVPC'); @@ -146,7 +144,6 @@ describe('Linode Config', () => { '@getClientStream', '@getFeatureFlags', '@getLinode', - '@getVPCs', '@getDisks', '@getVolumes', ]); @@ -289,7 +286,6 @@ describe('Linode Config', () => { }).as('getFeatureFlags'); mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetVPCs(mockVPCs).as('getVPCs'); mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getConfig'); mockGetVPC(mockVPC).as('getVPC'); @@ -301,7 +297,6 @@ describe('Linode Config', () => { '@getFeatureFlags', '@getLinode', '@getConfig', - '@getVPCs', '@getDisks', '@getVolumes', ]); @@ -472,7 +467,7 @@ describe('Linode Config', () => { cy.findByLabelText('List of Configurations').within(() => { cy.get('tr').should('have.length', 2); containsVisible( - `sharable-configuration – Latest 64 bit (6.2.9-x86_64-linode160)` + `sharable-configuration – Latest 64 bit (6.7.9-x86_64-linode163)` ); containsVisible('eth0 – Public Internet'); }); diff --git a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts new file mode 100644 index 00000000000..afcf629e103 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -0,0 +1,342 @@ +// TODO: Cypress +// Move this to cypress component testing once the setup is complete - see https://github.com/linode/manager/pull/10134 +import { fbtClick } from 'support/helpers'; +import { ui } from 'support/ui'; +import { + regionFactory, + regionAvailabilityFactory, + linodeTypeFactory, +} from '@src/factories'; +import { authenticate } from 'support/api/authentication'; +import { + mockGetRegions, + mockGetRegionAvailability, +} from 'support/intercepts/regions'; +import { mockGetLinodeTypes } from 'support/intercepts/linodes'; + +const mockRegions = [ + regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes'], + id: 'us-east', + label: 'Newark, NJ', + }), +]; + +const mockDedicatedLinodeTypes = [ + linodeTypeFactory.build({ + id: 'dedicated-1', + label: 'dedicated-1', + class: 'dedicated', + }), + linodeTypeFactory.build({ + id: 'dedicated-2', + label: 'dedicated-2', + class: 'dedicated', + }), + linodeTypeFactory.build({ + id: 'dedicated-3', + label: 'dedicated-3', + class: 'dedicated', + }), +]; + +const mockSharedLinodeTypes = [ + linodeTypeFactory.build({ + id: 'shared-1', + label: 'shared-1', + class: 'standard', + }), + linodeTypeFactory.build({ + id: 'shared-2', + label: 'shared-2', + class: 'standard', + }), + linodeTypeFactory.build({ + id: 'shared-3', + label: 'shared-3', + class: 'standard', + }), +]; + +const mockHighMemoryLinodeTypes = [ + linodeTypeFactory.build({ + id: 'highmem-1', + label: 'highmem-1', + class: 'highmem', + }), +]; + +const mockGPUType = [ + linodeTypeFactory.build({ + id: 'gpu-1', + label: 'gpu-1', + class: 'gpu', + }), +]; + +const mockLinodeTypes = [ + ...mockDedicatedLinodeTypes, + ...mockHighMemoryLinodeTypes, + ...mockSharedLinodeTypes, + ...mockGPUType, +]; + +const mockRegionAvailability = [ + regionAvailabilityFactory.build({ + plan: 'dedicated-3', + available: false, + region: 'us-east', + }), + regionAvailabilityFactory.build({ + plan: 'highmem-1', + available: false, + region: 'us-east', + }), +]; + +const linodePlansPanel = '[data-qa-tp="Linode Plan"]'; +const k8PlansPanel = '[data-qa-tp="Add Node Pools"]'; +const planSelectionTable = 'List of Linode Plans'; + +const notices = { + limitedAvailability: '[data-testid="limited-availability"]', + unavailable: '[data-testid="notice-error"]', +}; + +authenticate(); +describe('displays linode plans panel based on availability', () => { + before(() => { + mockGetRegions(mockRegions).as('getRegions'); + mockGetLinodeTypes(mockLinodeTypes).as('getLinodeTypes'); + mockGetRegionAvailability(mockRegions[0].id, mockRegionAvailability).as( + 'getRegionAvailability' + ); + }); + + it('displays the proper plans based on the region and types', () => { + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getRegions', '@getLinodeTypes']); + + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(mockRegions[0].label).click(); + + cy.wait(['@getRegionAvailability']); + + // Dedicated CPU tab + // Should be selected/open by default + // Should have the limited availability notice + // Should contain 4 plans (5 rows including the header row) + // Should have 2 plans disabled + // Should have tooltips for the disabled plans (not more than half disabled plans in the panel) + cy.get(linodePlansPanel).within(() => { + cy.findAllByRole('alert').should('have.length', 1); + cy.get(notices.limitedAvailability).should('be.visible'); + + cy.findByRole('table', { name: planSelectionTable }).within(() => { + cy.findAllByRole('row').should('have.length', 5); + cy.get('[id="dedicated-1"]').should('be.enabled'); + cy.get('[id="dedicated-2"]').should('be.enabled'); + cy.get('[id="dedicated-3"]').should('be.disabled'); + cy.get('[id="g6-dedicated-64"]').should('be.disabled'); + cy.findAllByTestId('limited-availability').should('have.length', 2); + }); + }); + + // Shared CPU tab + // Should have no notices + // Should contain 3 plans (4 rows including the header row) + // Should have 0 disabled plan + // Should have no tooltip for the disabled plan + fbtClick('Shared CPU'); + cy.get(linodePlansPanel).within(() => { + cy.findAllByRole('alert').should('have.length', 0); + + cy.findByRole('table', { name: planSelectionTable }).within(() => { + cy.findAllByRole('row').should('have.length', 4); + cy.get('[id="shared-1"]').should('be.enabled'); + cy.get('[id="shared-2"]').should('be.enabled'); + cy.get('[id="shared-3"]').should('be.enabled'); + cy.findAllByTestId('limited-availability').should('have.length', 0); + }); + }); + + // High Memory tab + // Should have the limited availability notice + // Should contain 1 plan (2 rows including the header row) + // Should have one disabled plan + // Should have tooltip for the disabled plan (more than half disabled plans in the panel, but only one plan) + fbtClick('High Memory'); + cy.get(linodePlansPanel).within(() => { + cy.findAllByRole('alert').should('have.length', 1); + cy.get(notices.limitedAvailability).should('be.visible'); + + cy.findByRole('table', { name: planSelectionTable }).within(() => { + cy.findAllByRole('row').should('have.length', 2); + cy.get('[id="highmem-1"]').should('be.disabled'); + cy.findAllByTestId('limited-availability').should('have.length', 1); + }); + }); + + // GPU tab + // Should have the unavailable notice + // Should contain 1 plan (2 rows including the header row) + // Should have its panel disabled + // Should not have tooltip for the disabled plan (not needed on disabled panels) + fbtClick('GPU'); + cy.get(linodePlansPanel).within(() => { + cy.findAllByRole('alert').should('have.length', 1); + cy.get(notices.unavailable).should('be.visible'); + + cy.findByRole('table', { name: planSelectionTable }).within(() => { + cy.findAllByRole('row').should('have.length', 2); + cy.get('[id="gpu-1"]').should('be.disabled'); + cy.findAllByTestId('limited-availability').should('have.length', 0); + }); + }); + + // Premium CPU + // Should have the unavailable notice + // Only present since we manually inject the 512 plan for it + // Should contain 1 plan (2 rows including the header row) + // Should have its whole panel disabled + // Should not have tooltip for the disabled plan (not needed on disabled panels) + fbtClick('Premium CPU'); + cy.get(linodePlansPanel).within(() => { + cy.findAllByRole('alert').should('have.length', 1); + cy.get(notices.unavailable).should('be.visible'); + + cy.findByRole('table', { name: planSelectionTable }).within(() => { + cy.findAllByRole('row').should('have.length', 2); + cy.get('[id="g7-premium-64"]').should('be.disabled'); + cy.findAllByTestId('limited-availability').should('have.length', 0); + }); + }); + }); +}); + +describe('displays kubernetes plans panel based on availability', () => { + before(() => { + mockGetRegions(mockRegions).as('getRegions'); + mockGetLinodeTypes(mockLinodeTypes).as('getLinodeTypes'); + mockGetRegionAvailability(mockRegions[0].id, mockRegionAvailability).as( + 'getRegionAvailability' + ); + }); + + it('displays the proper plans based on the region and types', () => { + cy.visitWithLogin('/kubernetes/create'); + cy.wait(['@getRegions', '@getLinodeTypes']); + + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(mockRegions[0].label).click(); + + cy.wait(['@getRegionAvailability']); + + // Dedicated CPU tab + // Should be selected/open by default + // Should have the limited availability notice + // Should contain 4 plans (5 rows including the header row) + // Should have 2 plans disabled + // Should have tooltips for the disabled plans (not more than half disabled plans in the panel) + // All inputs for a row should be enabled if row is enabled (only testing one row in suite) + // All inputs for a disabled row should be disabled (only testing one row in suite) + cy.get(k8PlansPanel).within(() => { + cy.findAllByRole('alert').should('have.length', 1); + cy.get(notices.limitedAvailability).should('be.visible'); + + cy.findByRole('table', { name: planSelectionTable }).within(() => { + cy.findAllByRole('row').should('have.length', 5); + cy.get('[data-qa-plan-row="dedicated-1"]').should( + 'not.have.attr', + 'disabled' + ); + cy.get('[data-qa-plan-row="dedicated-2"]').should( + 'not.have.attr', + 'disabled' + ); + cy.get('[data-qa-plan-row="dedicated-3"]').should( + 'have.attr', + 'disabled' + ); + cy.get('[data-qa-plan-row="Dedicated 512 GB"]').should( + 'have.attr', + 'disabled' + ); + cy.get('[data-qa-plan-row="dedicated-3"]').within(() => { + cy.get('[data-testid="decrement-button"]').should('be.disabled'); + cy.get('[data-testid="increment-button"]').should('be.disabled'); + cy.findByRole('button', { name: 'Add' }).should('be.disabled'); + }); + cy.findAllByTestId('limited-availability').should('have.length', 2); + }); + }); + + // Shared CPU tab + // Should have no notices + // Should contain 3 plans (4 rows including the header row) + // Should have 1 disabled plan + // Should have tooltip for the disabled plan (not more than half disabled plans in the panel) + fbtClick('Shared CPU'); + cy.get(k8PlansPanel).within(() => { + cy.findAllByRole('alert').should('have.length', 0); + + cy.findByRole('table', { name: planSelectionTable }).within(() => { + cy.findAllByRole('row').should('have.length', 4); + cy.get('[data-qa-plan-row="shared-1"]').should( + 'not.have.attr', + 'disabled' + ); + cy.get('[data-qa-plan-row="shared-2"]').should( + 'not.have.attr', + 'disabled' + ); + cy.get('[data-qa-plan-row="shared-3"]').should( + 'not.have.attr', + 'disabled' + ); + cy.findAllByTestId('limited-availability').should('have.length', 0); + }); + }); + + // High Memory tab + // Should have the limited availability notice + // Should contain 1 plan (2 rows including the header row) + // Should have one disabled plan + // Should have tooltip for the disabled plan (more than half disabled plans in the panel, but only one plan) + fbtClick('High Memory'); + cy.get(k8PlansPanel).within(() => { + cy.findAllByRole('alert').should('have.length', 1); + cy.get(notices.limitedAvailability).should('be.visible'); + + cy.findByRole('table', { name: planSelectionTable }).within(() => { + cy.findAllByRole('row').should('have.length', 2); + cy.get('[data-qa-plan-row="highmem-1"]').should( + 'have.attr', + 'disabled' + ); + cy.findAllByTestId('limited-availability').should('have.length', 1); + }); + }); + + // Premium CPU + // Should have the unavailable notice + // Only present since we manually inject the 512 plan for it + // Should contain 1 plan (2 rows including the header row) + // Should have its whole panel disabled + // Should not have tooltip for the disabled plan (not needed on disabled panels) + fbtClick('Premium CPU'); + cy.get(k8PlansPanel).within(() => { + cy.findAllByRole('alert').should('have.length', 1); + cy.get(notices.unavailable).should('be.visible'); + + cy.findByRole('table', { name: planSelectionTable }).within(() => { + cy.findAllByRole('row').should('have.length', 2); + cy.get('[data-qa-plan-row="Premium 512 GB"]').should( + 'have.attr', + 'disabled' + ); + cy.findAllByTestId('limited-availability').should('have.length', 0); + }); + }); + }); +}); 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 868cf7e0c64..f25d7ac4ef1 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 @@ -122,7 +122,7 @@ describe('linode landing checks', () => { // Cypress cannot work with other tabs and windows, so we can't test // that this takes the user to the expected place since it has no `href` // attribute. - cy.findByLabelText('Linode Cloud Community (opens in new tab)') + cy.findByLabelText('Linode Cloud Community - link opens in a new tab') .should('be.visible') .should('be.enabled'); diff --git a/packages/manager/cypress/e2e/core/longview/longview.spec.ts b/packages/manager/cypress/e2e/core/longview/longview.spec.ts index 14641f24ed9..d45259e8a6a 100644 --- a/packages/manager/cypress/e2e/core/longview/longview.spec.ts +++ b/packages/manager/cypress/e2e/core/longview/longview.spec.ts @@ -1,14 +1,22 @@ import type { Linode, LongviewClient } from '@linode/api-v4'; import { createLongviewClient } from '@linode/api-v4'; +import { longviewResponseFactory, longviewClientFactory } from 'src/factories'; +import { LongviewResponse } from 'src/features/Longview/request.types'; import { authenticate } from 'support/api/authentication'; import { longviewInstallTimeout, longviewStatusTimeout, + longviewEmptyStateMessage, + longviewAddClientButtonText, } from 'support/constants/longview'; import { interceptFetchLongviewStatus, interceptGetLongviewClients, + mockGetLongviewClients, + mockFetchLongviewStatus, + mockCreateLongviewClient, } from 'support/intercepts/longview'; +import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { createAndBootLinode } from 'support/util/linodes'; import { randomLabel, randomString } from 'support/util/random'; @@ -176,4 +184,44 @@ describe('longview', () => { }); }); }); + + /* + * - Confirms that the landing page empty state message is displayed when no Longview clients are present. + * - Confirms that UI updates to show the new client when creating one. + */ + it('displays empty state message when no clients are present and shows the new client when creating one', () => { + const client: LongviewClient = longviewClientFactory.build(); + const status: LongviewResponse = longviewResponseFactory.build(); + mockGetLongviewClients([]).as('getLongviewClients'); + mockCreateLongviewClient(client).as('createLongviewClient'); + mockFetchLongviewStatus(status).as('fetchLongviewStatus'); + + cy.visitWithLogin('/longview'); + cy.wait('@getLongviewClients'); + + // Confirms that a landing page empty state message is displayed + cy.findByText(longviewEmptyStateMessage).should('be.visible'); + cy.findByText(longviewAddClientButtonText).should('be.visible'); + + ui.button + .findByTitle(longviewAddClientButtonText) + .should('be.visible') + .should('be.enabled') + .click(); + cy.wait('@createLongviewClient'); + + // Confirms that UI updates to show the new client when creating one. + cy.findByText(`${client.label}`).should('be.visible'); + cy.get(`[data-qa-longview-client="${client.id}"]`) + .should('be.visible') + .within(() => { + cy.findByText('Waiting for data...').should('not.exist'); + cy.findByText('CPU').should('be.visible'); + cy.findByText('RAM').should('be.visible'); + cy.findByText('Swap').should('be.visible'); + cy.findByText('Load').should('be.visible'); + cy.findByText('Network').should('be.visible'); + cy.findByText('Storage').should('be.visible'); + }); + }); }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts index 56383377eb5..37841935c51 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts @@ -342,9 +342,14 @@ describe('object storage access keys smoke tests', () => { .click() .type('{esc}'); - // Enable "Limited Access" toggle for access key, and select access rules. + // Enable "Limited Access" toggle for access key and confirm Create button is disabled. cy.findByText('Limited Access').should('be.visible').click(); + ui.buttonGroup + .findButtonByTitle('Create Access Key') + .should('be.disabled'); + + // Select access rules for all buckets to enable Create button. mockBuckets.forEach((mockBucket) => { cy.findByText(mockBucket.label) .should('be.visible') @@ -483,28 +488,14 @@ describe('object storage access keys smoke tests', () => { .click() .type(`${mockUpdatedRegion.label}{enter}{esc}`); - cy.get('[data-qa-selection-list]') + cy.contains(mockUpdatedRegion.label) .should('be.visible') - .within(() => { - // Confirm both regions are selected and present in selection list. - mockRegions.forEach((mockRegion) => { - cy.findByText(`${mockRegion.label} (${mockRegion.id})`).should( - 'be.visible' - ); - }); + .and('exist'); - // Deselect initial region and confirm it's removed from list. - cy.findByLabelText( - `remove ${mockInitialRegion.label} (${mockInitialRegion.id})` - ) - .should('be.visible') - .should('be.enabled') - .click(); - - cy.findByText( - `${mockInitialRegion.label} (${mockInitialRegion.id})` - ).should('not.exist'); - }); + // Directly find the close button within the chip + cy.findByTestId(`${mockUpdatedRegion.id}`) + .findByTestId('CloseIcon') + .click(); mockUpdateAccessKey(mockUpdatedAccessKey).as('updateAccessKey'); mockGetAccessKeys([mockUpdatedAccessKey]); diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index ff6f27f3d3a..5386f787eef 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -72,21 +72,23 @@ describe('OneClick Apps (OCA)', () => { }); }); - // Check drawer content for one OCA candidate - const candidate = trimmedApps[0].label; + // Check drawer content for one OCA candidate. + const candidateApp = trimmedApps[0]; + const candidateLabel = handleAppLabel(trimmedApps[0]).label; + const stackScriptCandidate = cy - .get(`[data-qa-selection-card-info="${candidate}"]`) + .get(`[data-qa-selection-card-info="${candidateLabel}"]`) .first(); stackScriptCandidate.should('exist').click(); const app: OCA | undefined = mapStackScriptLabelToOCA({ oneClickApps, - stackScriptLabel: candidate, + stackScriptLabel: candidateApp.label, }); if (!app) { throw new Error( - `Failed to map StackScript label '${candidate}' to a One-Click App` + `Failed to map StackScript label '${candidateLabel}' to a One-Click App` ); } @@ -106,13 +108,13 @@ describe('OneClick Apps (OCA)', () => { const initialNumberOfApps = trimmedApps.length; cy.findByPlaceholderText('Search for app name') .should('exist') - .type(candidate); + .type(candidateLabel); cy.findByTestId('one-click-apps-container').within(() => { cy.get('[data-qa-selection-card="true"]').should( 'have.length.below', initialNumberOfApps ); - cy.get(`[data-qa-selection-card-info="${candidate}"]`).should( + cy.get(`[data-qa-selection-card-info="${candidateLabel}"]`).should( 'be.visible' ); }); diff --git a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts index 28db3eca210..cb1300418d2 100644 --- a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts @@ -179,6 +179,7 @@ describe('Parent/Child account switching', () => { interceptGetInvoices().as('getInvoices'); cy.visitWithLogin('/account/billing'); + cy.trackPageVisit().as('pageVisit'); cy.wait(['@getPayments', '@getInvoices', '@getPaymentMethods']); // Confirm that "Switch Account" button is present, then click it. @@ -220,6 +221,7 @@ describe('Parent/Child account switching', () => { }); cy.wait('@switchAccount'); + cy.expectNewPageVisit('@pageVisit'); // Confirm that Cloud Manager updates local storage authentication values. // Satisfy TypeScript using non-null assertions since we know what the mock data contains. @@ -253,6 +255,7 @@ describe('Parent/Child account switching', () => { mockGetUser(mockParentUser); cy.visitWithLogin('/'); + cy.trackPageVisit().as('pageVisit'); // Confirm that Parent account username and company name are shown in user // menu button, then click the button. @@ -301,6 +304,7 @@ describe('Parent/Child account switching', () => { }); cy.wait('@switchAccount'); + cy.expectNewPageVisit('@pageVisit'); // Confirm that Cloud Manager updates local storage authentication values. // Satisfy TypeScript using non-null assertions since we know what the mock data contains. @@ -334,7 +338,7 @@ describe('Parent/Child account switching', () => { * - Confirms that Parent account information is displayed in user menu button after switch. * - Confirms that Cloud updates local storage auth values upon account switch. */ - it.skip('can switch from Proxy user back to Parent account user from Billing page', () => { + it('can switch from Proxy user back to Parent account user from Billing page', () => { const mockParentToken = randomString(32); const mockParentExpiration = DateTime.now().plus({ minutes: 15 }).toISO(); @@ -357,6 +361,10 @@ describe('Parent/Child account switching', () => { }, }); + // Track the initial page visit so that we can later assert that Cloud has + // reloaded upon switching accounts. + cy.trackPageVisit().as('pageVisit'); + // Wait for page to finish loading before proceeding with account switch. cy.wait(['@getPayments', '@getPaymentMethods', '@getInvoices']); @@ -398,15 +406,15 @@ describe('Parent/Child account switching', () => { .click(); }); - // Allow page to load before asserting user menu, ensuring no app crash, etc. + cy.expectNewPageVisit('@pageVisit'); cy.wait(['@getInvoices', '@getPayments', '@getPaymentMethods']); + assertAuthLocalStorage(mockParentToken, mockParentExpiration, '*'); + assertUserMenuButton( mockParentProfile.username, mockParentAccount.company ); - - assertAuthLocalStorage(mockParentToken, mockParentExpiration, '*'); }); }); @@ -442,6 +450,8 @@ describe('Parent/Child account switching', () => { }, }); + cy.trackPageVisit().as('pageVisit'); + // Wait for page to finish loading before proceeding with account switch. cy.wait(['@getPayments', '@getPaymentMethods', '@getInvoices']); @@ -489,6 +499,7 @@ describe('Parent/Child account switching', () => { // Allow page to load before asserting user menu, ensuring no app crash, etc. cy.wait('@switchAccount'); + cy.expectNewPageVisit('@pageVisit'); cy.wait(['@getInvoices', '@getPayments', '@getPaymentMethods']); assertUserMenuButton( diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts new file mode 100644 index 00000000000..7709b82b298 --- /dev/null +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts @@ -0,0 +1,49 @@ +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockGetPlacementGroups } from 'support/intercepts/vm-placement'; +import { ui } from 'support/ui'; +import { accountFactory } from 'src/factories'; + +import type { Flags } from 'src/featureFlags'; +import { mockGetAccount } from 'support/intercepts/account'; + +const mockAccount = accountFactory.build(); + +describe('VM Placement landing page', () => { + // Mock the VM Placement Groups feature flag to be enabled for each test in this block. + beforeEach(() => { + mockAppendFeatureFlags({ + placementGroups: makeFeatureFlagData({ + beta: true, + enabled: true, + }), + }); + mockGetFeatureFlagClientstream(); + mockGetAccount(mockAccount).as('getAccount'); + }); + + /** + * - Confirms landing page empty state is shown when there are no Placement Groups. + * - Confirms that clicking "Create Placement Groups" opens PG create drawer. + */ + it('displays empty state when there are no Placement Groups', () => { + mockGetPlacementGroups([]).as('getPlacementGroups'); + cy.visitWithLogin('/placement-groups'); + cy.wait('@getPlacementGroups'); + + ui.heading.find().within(() => { + cy.findByText('Placement Groups').should('be.visible'); + }); + + ui.button + .findByTitle('Create Placement Groups') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer.findByTitle('Create Placement Group').should('be.visible'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts index 7e181043ad1..31bffd13a22 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts @@ -1,5 +1,9 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { volumeFactory, linodeFactory } from '@src/factories'; +import { + volumeFactory, + linodeFactory, + volumeTypeFactory, +} from '@src/factories'; import { mockGetLinodes, mockGetLinodeDetails, @@ -9,10 +13,17 @@ import { mockCreateVolume, mockGetVolumes, mockDetachVolume, + mockGetVolumeTypesError, + mockGetVolumeTypes, } from 'support/intercepts/volumes'; import { randomLabel, randomNumber } from 'support/util/random'; import { ui } from 'support/ui'; +import { + PRICES_RELOAD_ERROR_NOTICE_TEXT, + UNKNOWN_PRICE, +} from 'src/utilities/pricing/constants'; + const region = 'Newark, NJ'; /** @@ -70,9 +81,11 @@ const localStorageOverrides = { describe('volumes', () => { it('creates a volume without linode from volumes page', () => { const mockVolume = volumeFactory.build({ label: randomLabel() }); + const mockVolumeTypes = volumeTypeFactory.buildList(1); mockGetVolumes([]).as('getVolumes'); mockCreateVolume(mockVolume).as('createVolume'); + mockGetVolumeTypes(mockVolumeTypes).as('getVolumeTypes'); cy.visitWithLogin('/volumes', { preferenceOverrides, @@ -83,6 +96,8 @@ describe('volumes', () => { cy.url().should('endWith', 'volumes/create'); + cy.wait('@getVolumeTypes'); + ui.button.findByTitle('Create Volume').should('be.visible').click(); cy.findByText('Label is required.').should('be.visible'); @@ -165,7 +180,7 @@ describe('volumes', () => { cy.findByText('1 Volume').should('be.visible'); }); - it('Detaches attached volume', () => { + it('detaches attached volume', () => { const mockLinode = linodeFactory.build({ label: randomLabel() }); const mockAttachedVolume = volumeFactory.build({ label: randomLabel(), @@ -210,4 +225,86 @@ describe('volumes', () => { cy.wait('@detachVolume').its('response.statusCode').should('eq', 200); ui.toast.assertMessage('Volume detachment started'); }); + + it('does not allow creation of a volume with invalid pricing from volumes landing', () => { + const mockVolume = volumeFactory.build({ label: randomLabel() }); + + mockGetVolumes([]).as('getVolumes'); + mockCreateVolume(mockVolume).as('createVolume'); + // Mock an error response to the /types endpoint so prices cannot be calculated. + mockGetVolumeTypesError().as('getVolumeTypesError'); + + cy.visitWithLogin('/volumes', { + preferenceOverrides, + localStorageOverrides, + }); + + ui.button.findByTitle('Create Volume').should('be.visible').click(); + + cy.url().should('endWith', 'volumes/create'); + + ui.regionSelect.find().click().type('newark{enter}'); + + cy.wait(['@getVolumeTypesError']); + + // Confirm that unknown pricing placeholder text displays, create button is disabled, and error tooltip displays. + cy.findByText(`$${UNKNOWN_PRICE}/month`).should('be.visible'); + ui.button + .findByTitle('Create Volume') + .should('be.visible') + .should('be.disabled') + .trigger('mouseover'); + ui.tooltip.findByText(PRICES_RELOAD_ERROR_NOTICE_TEXT).should('be.visible'); + }); + + it('does not allow creation of a volume with invalid pricing from linode details', () => { + const mockLinode = linodeFactory.build({ + label: randomLabel(), + id: randomNumber(), + }); + const newVolume = volumeFactory.build({ + label: randomLabel(), + }); + + mockCreateVolume(newVolume).as('createVolume'); + mockGetLinodes([mockLinode]).as('getLinodes'); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinodeDetail'); + mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); + // Mock an error response to the /types endpoint so prices cannot be calculated. + mockGetVolumeTypesError().as('getVolumeTypesError'); + + cy.visitWithLogin('/linodes', { + preferenceOverrides, + localStorageOverrides, + }); + + // Visit a Linode's details page. + cy.wait('@getLinodes'); + cy.findByText(mockLinode.label).should('be.visible').click(); + cy.wait(['@getVolumes', '@getLinodeDetail']); + + // Open the Create Volume drawer. + cy.findByText('Storage').should('be.visible').click(); + ui.button.findByTitle('Create Volume').should('be.visible').click(); + cy.wait(['@getVolumeTypesError']); + + mockGetLinodeVolumes(mockLinode.id, [newVolume]).as('getVolumes'); + ui.drawer + .findByTitle(`Create Volume for ${mockLinode.label}`) + .should('be.visible') + .within(() => { + cy.findByText('Create and Attach Volume').should('be.visible').click(); + + // Confirm that unknown pricing placeholder text displays, create button is disabled, and error tooltip displays. + cy.contains(`$${UNKNOWN_PRICE}/mo`).should('be.visible'); + ui.button + .findByTitle('Create Volume') + .should('be.visible') + .should('be.disabled') + .trigger('mouseover'); + ui.tooltip + .findByText(PRICES_RELOAD_ERROR_NOTICE_TEXT) + .should('be.visible'); + }); + }); }); diff --git a/packages/manager/cypress/support/constants/longview.ts b/packages/manager/cypress/support/constants/longview.ts index 29b14802ce7..781b40c0949 100644 --- a/packages/manager/cypress/support/constants/longview.ts +++ b/packages/manager/cypress/support/constants/longview.ts @@ -11,3 +11,14 @@ export const longviewInstallTimeout = 255000; * Equates to 1 minute. */ export const longviewStatusTimeout = 60000; + +/** + * Message that will be displayed when no clients are present. + */ +export const longviewEmptyStateMessage = + 'You have no Longview clients configured.'; + +/** + * Button text to add a new Longview client. + */ +export const longviewAddClientButtonText = 'Click here to add one.'; diff --git a/packages/manager/cypress/support/e2e.ts b/packages/manager/cypress/support/e2e.ts index 8d337f7ac4d..dc2dfc94cdf 100644 --- a/packages/manager/cypress/support/e2e.ts +++ b/packages/manager/cypress/support/e2e.ts @@ -21,6 +21,7 @@ import 'cypress-real-events/support'; import './setup/defer-command'; import './setup/login-command'; +import './setup/page-visit-tracking-commands'; chai.use(chaiString); chai.use(function (chai, utils) { diff --git a/packages/manager/cypress/support/index.d.ts b/packages/manager/cypress/support/index.d.ts index 2397637c236..1439322a740 100644 --- a/packages/manager/cypress/support/index.d.ts +++ b/packages/manager/cypress/support/index.d.ts @@ -33,6 +33,36 @@ declare global { cypressOptions?: Partial ): Chainable; + /** + * Assigns a random page visit ID to the current page. + * + * Used to determine whether navigation has occurred later. + * + * @example + * // After initial call to `cy.visit()` or `cy.visitWithLogin()`: + * cy.trackPageVisit().as('pageVisit'); + * // Later in the tests, to assert that navigation has occurred: + * cy.expectNewPageVisit('@pageVisit'); + * + * @returns Cypress chainable that yields the random page visit ID. + */ + trackPageVisit(): Chainable; + + /** + * Asserts that a browser page visit (e.g. navigation or reload) has occurred. + * + * @example + * // After initial call to `cy.visit()` or `cy.visitWithLogin()`: + * cy.trackPageVisit().as('pageVisit'); + * // Later in the tests, to assert that navigation has occurred: + * cy.expectNewPageVisit('@pageVisit'); + * + * @param alias - Alias to the current page load ID. + * + * @returns Cypress chainable. + */ + expectNewPageVisit(alias: string): Chainable<>; + /** * Internal Cypress command to retrieve test state. * diff --git a/packages/manager/cypress/support/intercepts/longview.ts b/packages/manager/cypress/support/intercepts/longview.ts index 62909f39b82..d0cb2c742fc 100644 --- a/packages/manager/cypress/support/intercepts/longview.ts +++ b/packages/manager/cypress/support/intercepts/longview.ts @@ -1,4 +1,8 @@ import { apiMatcher } from 'support/util/intercepts'; +import { paginateResponse } from 'support/util/paginate'; +import { makeResponse } from 'support/util/response'; +import { LongviewClient } from '@linode/api-v4'; +import { LongviewResponse } from 'src/features/Longview/request.types'; /** * Intercepts request to retrieve Longview status for a Longview client. @@ -9,6 +13,21 @@ export const interceptFetchLongviewStatus = (): Cypress.Chainable => { return cy.intercept('POST', 'https://longview.linode.com/fetch'); }; +/** + * Mocks request to retrieve Longview status for a Longview client. + * + * @returns Cypress chainable. + */ +export const mockFetchLongviewStatus = ( + status: LongviewResponse +): Cypress.Chainable => { + return cy.intercept( + 'POST', + 'https://longview.linode.com/fetch', + makeResponse(status) + ); +}; + /** * Intercepts GET request to fetch Longview clients. * @@ -17,3 +36,33 @@ export const interceptFetchLongviewStatus = (): Cypress.Chainable => { export const interceptGetLongviewClients = (): Cypress.Chainable => { return cy.intercept('GET', apiMatcher('longview/clients*')); }; + +/** + * Mocks GET request to fetch Longview clients. + * + * @returns Cypress chainable. + */ +export const mockGetLongviewClients = ( + clients: LongviewClient[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('longview/clients*'), + paginateResponse(clients) + ); +}; + +/** + * Mocks request to create a Longview client. + * + * @returns Cypress chainable. + */ +export const mockCreateLongviewClient = ( + client: LongviewClient +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher('longview/clients*'), + makeResponse(client) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/regions.ts b/packages/manager/cypress/support/intercepts/regions.ts index f5b5d6a78ed..b921ba63bcf 100644 --- a/packages/manager/cypress/support/intercepts/regions.ts +++ b/packages/manager/cypress/support/intercepts/regions.ts @@ -2,7 +2,7 @@ * @file Cypress intercept and mock utilities for Linode regions. */ -import { Region } from '@linode/api-v4'; +import { Region, RegionAvailability } from '@linode/api-v4'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; @@ -16,3 +16,19 @@ import { paginateResponse } from 'support/util/paginate'; export const mockGetRegions = (regions: Region[]): Cypress.Chainable => { return cy.intercept('GET', apiMatcher('regions*'), paginateResponse(regions)); }; + +/** + * Intercepts GET request to fetch regions availability and mocks response. + * + * @returns Cypress chainable. + */ +export const mockGetRegionAvailability = ( + regionId: Region['id'], + regionAvailability: RegionAvailability[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`regions/${regionId}/availability`), + regionAvailability + ); +}; diff --git a/packages/manager/cypress/support/intercepts/vm-placement.ts b/packages/manager/cypress/support/intercepts/vm-placement.ts new file mode 100644 index 00000000000..11bc8b8a4c4 --- /dev/null +++ b/packages/manager/cypress/support/intercepts/vm-placement.ts @@ -0,0 +1,21 @@ +import { apiMatcher } from 'support/util/intercepts'; +import { paginateResponse } from 'support/util/paginate'; + +import type { PlacementGroup } from '@linode/api-v4'; + +/** + * Intercepts GET request to fetch Placement Groups and mocks response. + * + * @param placementGroups - Array of Placement Group objects with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetPlacementGroups = ( + placementGroups: PlacementGroup[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('placement/groups*'), + paginateResponse([]) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/volumes.ts b/packages/manager/cypress/support/intercepts/volumes.ts index 536da877b34..e0eb89ca056 100644 --- a/packages/manager/cypress/support/intercepts/volumes.ts +++ b/packages/manager/cypress/support/intercepts/volumes.ts @@ -2,10 +2,12 @@ * @files Cypress intercepts and mocks for Volume API requests. */ +import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; +import { makeResponse } from 'support/util/response'; -import type { Volume } from '@linode/api-v4'; +import type { PriceType, Volume } from '@linode/api-v4'; /** * Intercepts GET request to fetch Volumes and mocks response. @@ -122,3 +124,33 @@ export const interceptDeleteVolume = ( export const mockMigrateVolumes = (): Cypress.Chainable => { return cy.intercept('POST', apiMatcher(`volumes/migrate`), {}); }; + +/** + * Intercepts GET request to fetch Volumes Types and mocks response. + * + * @returns Cypress chainable. + */ +export const mockGetVolumeTypes = ( + volumeTypes: PriceType[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('volumes/types*'), + paginateResponse(volumeTypes) + ); +}; + +/** + * Intercepts GET request to fetch Volumes Types and mocks an error response. + * + * @returns Cypress chainable. + */ +export const mockGetVolumeTypesError = (): Cypress.Chainable => { + const errorResponse = makeErrorResponse('', 500); + + return cy.intercept( + 'GET', + apiMatcher('volumes/types*'), + makeResponse(errorResponse) + ); +}; diff --git a/packages/manager/cypress/support/setup/page-visit-tracking-commands.ts b/packages/manager/cypress/support/setup/page-visit-tracking-commands.ts new file mode 100644 index 00000000000..7c03d1751df --- /dev/null +++ b/packages/manager/cypress/support/setup/page-visit-tracking-commands.ts @@ -0,0 +1,51 @@ +import { randomNumber } from 'support/util/random'; + +/** + * Assigns a random page visit ID to the current page. + * + * Used to determine whether navigation has occurred later. + * + * @returns Cypress chainable that yields the random page visit ID. + */ +Cypress.Commands.add('trackPageVisit', { prevSubject: false }, () => { + const pageLoadId = randomNumber(100000, 999999); + + cy.window({ log: false }).then((window) => { + window['cypress-visit-id'] = pageLoadId; + }); + + cy.log(`Tracking page visit with ID ${pageLoadId}`); + + return cy.wrap(pageLoadId, { log: false }); +}); + +/** + * Asserts that a browser page visit (e.g. via navigation or reload) has occurred. + * + * @param alias - Alias to the current page load ID. + * + * @returns Cypress chainable. + */ +Cypress.Commands.add( + 'expectNewPageVisit', + { prevSubject: false }, + (alias: string) => { + return cy + .get(alias, { log: false }) + .then((pageLoadId) => { + const log = Cypress.log({ + autoEnd: false, + end: false, + message: `Expecting window not to have page visit ID ${pageLoadId}`, + name: 'expectNewPageVisit', + }); + + cy.window({ log: false }).should( + 'not.have.prop', + 'cypress-visit-id', + pageLoadId + ); + log.end(); + }); + } +); diff --git a/packages/manager/package.json b/packages/manager/package.json index 91bdabc2d93..d77097f6651 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.117.0", + "version": "1.118.0", "private": true, "type": "module", "bugs": { @@ -16,6 +16,7 @@ "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", + "@hookform/resolvers": "2.9.11", "@linode/api-v4": "*", "@linode/validation": "*", "@lukemorales/query-key-factory": "^1.3.4", @@ -32,6 +33,7 @@ "chart.js": "~2.9.4", "copy-to-clipboard": "^3.0.8", "country-region-data": "^1.4.5", + "dompurify": "^3.1.0", "flag-icons": "^6.6.5", "font-logos": "^0.18.0", "formik": "~2.1.3", @@ -43,7 +45,9 @@ "jspdf-autotable": "^3.5.14", "launchdarkly-react-client-sdk": "3.0.10", "libphonenumber-js": "^1.10.6", - "lodash": "^4.17.21", + "lodash.clonedeep": "^4.5.0", + "lodash.curry": "^4.1.1", + "lodash.set": "^4.3.2", "logic-query-parser": "^0.0.5", "luxon": "3.4.4", "markdown-it": "^12.3.2", @@ -70,7 +74,6 @@ "redux": "^4.0.4", "redux-thunk": "^2.3.0", "reselect": "^4.0.0", - "sanitize-html": "^2.12.1", "search-string": "^3.1.0", "throttle-debounce": "^2.0.0", "tss-react": "^4.8.2", @@ -132,10 +135,14 @@ "@types/chai-string": "^1.4.5", "@types/chart.js": "^2.9.21", "@types/css-mediaquery": "^0.1.1", + "@types/dompurify": "^3.0.5", "@types/he": "^1.1.0", "@types/highlight.js": "~10.1.0", "@types/jsdom": "^21.1.4", "@types/jspdf": "^1.3.3", + "@types/lodash.clonedeep": "^4.5.9", + "@types/lodash.curry": "^4.1.9", + "@types/lodash.set": "^4.3.9", "@types/luxon": "3.4.2", "@types/markdown-it": "^10.0.2", "@types/md5": "^2.1.32", @@ -153,7 +160,6 @@ "@types/react-select": "^3.0.11", "@types/recompose": "^0.30.0", "@types/redux-mock-store": "^1.0.1", - "@types/sanitize-html": "^2.9.1", "@types/throttle-debounce": "^1.0.0", "@types/uuid": "^3.4.3", "@types/yup": "^0.29.13", diff --git a/packages/manager/public/assets/ark@1x.svg b/packages/manager/public/assets/ark@1x.svg deleted file mode 100644 index a693aaa86b5..00000000000 --- a/packages/manager/public/assets/ark@1x.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - Ark - Created with Sketch. - - - - \ No newline at end of file diff --git a/packages/manager/public/assets/bitninja.svg b/packages/manager/public/assets/bitninja.svg deleted file mode 100755 index 7720a32688d..00000000000 --- a/packages/manager/public/assets/bitninja.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/manager/public/assets/csgo2.svg b/packages/manager/public/assets/csgo2.svg deleted file mode 100644 index 73a1cd898b6..00000000000 --- a/packages/manager/public/assets/csgo2.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - CSGO - Created with Sketch. - - - - \ No newline at end of file diff --git a/packages/manager/public/assets/glusterfs.svg b/packages/manager/public/assets/glusterfs.svg new file mode 100755 index 00000000000..04b5b724757 --- /dev/null +++ b/packages/manager/public/assets/glusterfs.svg @@ -0,0 +1,3022 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/public/assets/grafana.svg b/packages/manager/public/assets/grafana.svg deleted file mode 100644 index 27d62a58f40..00000000000 --- a/packages/manager/public/assets/grafana.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/manager/public/assets/magicspam.svg b/packages/manager/public/assets/magicspam.svg deleted file mode 100755 index b57adab3b4d..00000000000 --- a/packages/manager/public/assets/magicspam.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/manager/public/assets/prometheus.svg b/packages/manager/public/assets/prometheus.svg deleted file mode 100644 index 11ced54ad4e..00000000000 --- a/packages/manager/public/assets/prometheus.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/manager/public/assets/rust.svg b/packages/manager/public/assets/rust.svg deleted file mode 100644 index 6fa8d078cf6..00000000000 --- a/packages/manager/public/assets/rust.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - Rust - Created with Sketch. - - - - \ No newline at end of file diff --git a/packages/manager/public/assets/terraria.svg b/packages/manager/public/assets/terraria.svg deleted file mode 100644 index f72d59b7652..00000000000 --- a/packages/manager/public/assets/terraria.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - Terraria - Created with Sketch. - - - - \ No newline at end of file diff --git a/packages/manager/public/assets/tf2.svg b/packages/manager/public/assets/tf2.svg deleted file mode 100644 index a86957cf73d..00000000000 --- a/packages/manager/public/assets/tf2.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - TeamFortress - Created with Sketch. - - - - \ No newline at end of file diff --git a/packages/manager/public/assets/valheim.svg b/packages/manager/public/assets/valheim.svg deleted file mode 100644 index e8fee5cb4e4..00000000000 --- a/packages/manager/public/assets/valheim.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/manager/public/assets/virtualmin.svg b/packages/manager/public/assets/virtualmin.svg deleted file mode 100644 index dfe0118d61a..00000000000 --- a/packages/manager/public/assets/virtualmin.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/manager/public/assets/webmin.svg b/packages/manager/public/assets/webmin.svg deleted file mode 100644 index fa3dea4dacf..00000000000 --- a/packages/manager/public/assets/webmin.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/manager/public/assets/white/ark@1x.svg b/packages/manager/public/assets/white/ark@1x.svg deleted file mode 100644 index a63a2d33b6f..00000000000 --- a/packages/manager/public/assets/white/ark@1x.svg +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/packages/manager/public/assets/white/bitninja.svg b/packages/manager/public/assets/white/bitninja.svg deleted file mode 100644 index fd0b45c49d5..00000000000 --- a/packages/manager/public/assets/white/bitninja.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - diff --git a/packages/manager/public/assets/white/csgo2.svg b/packages/manager/public/assets/white/csgo2.svg deleted file mode 100644 index 9ca25186d18..00000000000 --- a/packages/manager/public/assets/white/csgo2.svg +++ /dev/null @@ -1,35 +0,0 @@ - - - - - diff --git a/packages/manager/public/assets/white/glusterfs.svg b/packages/manager/public/assets/white/glusterfs.svg new file mode 100755 index 00000000000..77f635c485c --- /dev/null +++ b/packages/manager/public/assets/white/glusterfs.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/public/assets/white/grafana.svg b/packages/manager/public/assets/white/grafana.svg deleted file mode 100644 index fb80e69b7a6..00000000000 --- a/packages/manager/public/assets/white/grafana.svg +++ /dev/null @@ -1,41 +0,0 @@ - - - - - diff --git a/packages/manager/public/assets/white/magicspam.svg b/packages/manager/public/assets/white/magicspam.svg deleted file mode 100644 index 25b1fe4e307..00000000000 --- a/packages/manager/public/assets/white/magicspam.svg +++ /dev/null @@ -1,584 +0,0 @@ - -image/svg+xml \ No newline at end of file diff --git a/packages/manager/public/assets/white/prometheus.svg b/packages/manager/public/assets/white/prometheus.svg deleted file mode 100644 index f7a95f09d93..00000000000 --- a/packages/manager/public/assets/white/prometheus.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - \ No newline at end of file diff --git a/packages/manager/public/assets/white/rust.svg b/packages/manager/public/assets/white/rust.svg deleted file mode 100644 index ae99747c2cb..00000000000 --- a/packages/manager/public/assets/white/rust.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - -Rust - - - - - - - - - - - - \ No newline at end of file diff --git a/packages/manager/public/assets/white/terraria.svg b/packages/manager/public/assets/white/terraria.svg deleted file mode 100644 index 0f8b1f07cef..00000000000 --- a/packages/manager/public/assets/white/terraria.svg +++ /dev/null @@ -1,62 +0,0 @@ - - - - - \ No newline at end of file diff --git a/packages/manager/public/assets/white/tf2.svg b/packages/manager/public/assets/white/tf2.svg deleted file mode 100644 index 9482094315a..00000000000 --- a/packages/manager/public/assets/white/tf2.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - -TF2 - - - - - - \ No newline at end of file diff --git a/packages/manager/public/assets/white/valheim.svg b/packages/manager/public/assets/white/valheim.svg deleted file mode 100644 index 29b0d91fcdb..00000000000 --- a/packages/manager/public/assets/white/valheim.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/manager/public/assets/white/virtualmin.svg b/packages/manager/public/assets/white/virtualmin.svg deleted file mode 100644 index 0f3bb890847..00000000000 --- a/packages/manager/public/assets/white/virtualmin.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/manager/public/assets/white/webmin.svg b/packages/manager/public/assets/white/webmin.svg deleted file mode 100644 index 15528f35c65..00000000000 --- a/packages/manager/public/assets/white/webmin.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx index a682323fcb8..fd55ff3a646 100644 --- a/packages/manager/src/GoTo.tsx +++ b/packages/manager/src/GoTo.tsx @@ -7,6 +7,7 @@ import { makeStyles } from 'tss-react/mui'; import EnhancedSelect, { Item } from 'src/components/EnhancedSelect/Select'; import { useIsACLBEnabled } from './features/LoadBalancers/utils'; +import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useAccountManagement } from './hooks/useAccountManagement'; import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener'; @@ -60,6 +61,7 @@ export const GoTo = React.memo(() => { const { _hasAccountAccess, _isManagedAccount } = useAccountManagement(); const { isACLBEnabled } = useIsACLBEnabled(); + const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { goToOpen, setGoToOpen } = useGlobalKeyboardListener(); const onClose = () => { @@ -113,6 +115,11 @@ export const GoTo = React.memo(() => { display: 'Images', href: '/images', }, + { + display: 'Placement Groups', + hide: !isPlacementGroupsEnabled, + href: '/placement-groups', + }, { display: 'Domains', href: '/domains', @@ -149,7 +156,12 @@ export const GoTo = React.memo(() => { href: '/profile/display', }, ], - [_hasAccountAccess, _isManagedAccount, isACLBEnabled] + [ + _hasAccountAccess, + _isManagedAccount, + isACLBEnabled, + isPlacementGroupsEnabled, + ] ); const options: Item[] = React.useMemo( diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index 46bd8cf1a07..c42231b0688 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -1,5 +1,5 @@ -import Grid from '@mui/material/Unstable_Grid2'; import { Theme } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import { isEmpty } from 'ramda'; import * as React from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; @@ -10,8 +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 { SIDEBAR_WIDTH } from 'src/components/PrimaryNav/SideMenu'; import { SideMenu } from 'src/components/PrimaryNav/SideMenu'; +import { SIDEBAR_WIDTH } from 'src/components/PrimaryNav/SideMenu'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { useDialogContext } from 'src/context/useDialogContext'; import { Footer } from 'src/features/Footer'; @@ -33,6 +33,7 @@ import { complianceUpdateContext } from './context/complianceUpdateContext'; import { switchAccountSessionContext } from './context/switchAccountSessionContext'; import { FlagSet } from './featureFlags'; import { useIsACLBEnabled } from './features/LoadBalancers/utils'; +import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useGlobalErrors } from './hooks/useGlobalErrors'; const useStyles = makeStyles()((theme: Theme) => ({ @@ -226,6 +227,7 @@ export const MainContent = () => { (checkRestrictedUser && !enginesLoading && !enginesError); const { isACLBEnabled } = useIsACLBEnabled(); + const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const defaultRoot = _isManagedAccount ? '/managed' : '/linodes'; @@ -337,10 +339,12 @@ export const MainContent = () => { }> - + {isPlacementGroupsEnabled && ( + + )} {isACLBEnabled && ( diff --git a/packages/manager/src/__data__/regionsData.ts b/packages/manager/src/__data__/regionsData.ts index e5d0a0d6afd..2a37e1b20a4 100644 --- a/packages/manager/src/__data__/regionsData.ts +++ b/packages/manager/src/__data__/regionsData.ts @@ -10,6 +10,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Block Storage Migrations', 'Managed Databases', ], @@ -37,6 +38,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Block Storage Migrations', 'Managed Databases', 'Placement Group', @@ -65,6 +67,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Block Storage Migrations', 'Managed Databases', ], @@ -93,6 +96,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Managed Databases', 'Metadata', 'Premium Plans', @@ -123,6 +127,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Managed Databases', 'Metadata', 'Premium Plans', @@ -152,6 +157,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Managed Databases', 'Metadata', 'Premium Plans', @@ -181,6 +187,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Metadata', 'Premium Plans', 'Placement Group', @@ -210,6 +217,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Metadata', 'Premium Plans', 'Placement Group', @@ -239,6 +247,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Metadata', 'Premium Plans', ], @@ -267,6 +276,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Metadata', 'Premium Plans', ], @@ -295,6 +305,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Metadata', 'Premium Plans', ], @@ -323,6 +334,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Metadata', 'Premium Plans', 'Placement Group', @@ -352,6 +364,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Metadata', 'Premium Plans', ], @@ -380,6 +393,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Metadata', 'Premium Plans', 'Placement Group', @@ -409,6 +423,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Metadata', 'Premium Plans', ], @@ -437,6 +452,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Metadata', 'Premium Plans', 'Placement Group', @@ -465,6 +481,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Block Storage Migrations', 'Managed Databases', 'Placement Group', @@ -522,6 +539,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Block Storage Migrations', 'Managed Databases', 'Placement Group', @@ -553,6 +571,7 @@ export const regions: Region[] = [ 'Cloud Firewall', 'Bare Metal', 'Vlans', + 'VPCs', 'Block Storage Migrations', 'Managed Databases', 'Placement Group', @@ -581,6 +600,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Block Storage Migrations', 'Managed Databases', ], @@ -610,6 +630,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Block Storage Migrations', 'Managed Databases', ], @@ -639,6 +660,7 @@ export const regions: Region[] = [ 'Kubernetes', 'Cloud Firewall', 'Vlans', + 'VPCs', 'Block Storage Migrations', 'Managed Databases', ], diff --git a/packages/manager/src/assets/icons/entityIcons/edge-server.svg b/packages/manager/src/assets/icons/entityIcons/edge-region.svg similarity index 95% rename from packages/manager/src/assets/icons/entityIcons/edge-server.svg rename to packages/manager/src/assets/icons/entityIcons/edge-region.svg index 1ca05241491..6a8889a6581 100644 --- a/packages/manager/src/assets/icons/entityIcons/edge-server.svg +++ b/packages/manager/src/assets/icons/entityIcons/edge-region.svg @@ -1,6 +1,6 @@ -edge-server +edge-region diff --git a/packages/manager/src/assets/icons/entityIcons/placement-groups.svg b/packages/manager/src/assets/icons/entityIcons/placement-groups.svg index 5ee28e5f41a..799f4092e66 100644 --- a/packages/manager/src/assets/icons/entityIcons/placement-groups.svg +++ b/packages/manager/src/assets/icons/entityIcons/placement-groups.svg @@ -1,6 +1,5 @@ - - - - - + + + + diff --git a/packages/manager/src/components/AreaChart/AreaChart.tsx b/packages/manager/src/components/AreaChart/AreaChart.tsx index a64ed6c25ed..29ed0c35d23 100644 --- a/packages/manager/src/components/AreaChart/AreaChart.tsx +++ b/packages/manager/src/components/AreaChart/AreaChart.tsx @@ -21,7 +21,11 @@ import { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay'; import { Paper } from 'src/components/Paper'; import { StyledBottomLegend } from 'src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel'; -import { tooltipLabelFormatter, tooltipValueFormatter } from './utils'; +import { + humanizeLargeData, + tooltipLabelFormatter, + tooltipValueFormatter, +} from './utils'; interface AreaProps { color: string; @@ -45,16 +49,6 @@ interface AreaChartProps { xAxis: XAxisProps; } -const humanizeLargeData = (value: number) => { - if (value >= 1000000) { - return value / 1000000 + 'M'; - } - if (value >= 1000) { - return value / 1000 + 'K'; - } - return `${value}`; -}; - export const AreaChart = (props: AreaChartProps) => { const { areas, diff --git a/packages/manager/src/components/AreaChart/utils.test.ts b/packages/manager/src/components/AreaChart/utils.test.ts index 6bcfe2fb5a7..f52f176ead7 100644 --- a/packages/manager/src/components/AreaChart/utils.test.ts +++ b/packages/manager/src/components/AreaChart/utils.test.ts @@ -1,9 +1,14 @@ +import { determinePower } from 'src/utilities/unitConversions'; + import { getAccessibleTimestamp, + humanizeLargeData, tooltipLabelFormatter, tooltipValueFormatter, } from './utils'; +import type { StorageSymbol } from 'src/utilities/unitConversions'; + const timestamp = 1704204000000; describe('getAccessibleTimestamp', () => { @@ -29,3 +34,40 @@ describe('tooltipValueFormatter', () => { expect(tooltipValueFormatter(0.000234, '%')).toBe('0%'); }); }); + +describe('humanizeLargeData', () => { + it('should return the value as an abbreviated string if the value is >= 1000', () => { + expect(humanizeLargeData(999)).toBe('999'); + expect(humanizeLargeData(1125)).toBe('1.1K'); + expect(humanizeLargeData(231434)).toBe('231.4K'); + expect(humanizeLargeData(1010000)).toBe('1M'); + expect(humanizeLargeData(12345678900)).toBe('12.3B'); + expect(humanizeLargeData(1543212345678)).toBe('1.5T'); + }); +}); + +describe('determinePower', () => { + it('should return the correct power', () => { + const storageUnits: StorageSymbol[] = ['bytes', 'KB', 'MB', 'GB', 'TB']; + expect( + determinePower(5187703696998400, storageUnits, { + maxUnit: 'TB', + }) + ).toBe(4); + expect( + determinePower(95509904120.832, storageUnits, { + maxUnit: 'TB', + }) + ).toBe(3); + expect( + determinePower(64823296, storageUnits, { + maxUnit: 'TB', + }) + ).toBe(2); + expect( + determinePower(1024, storageUnits, { + maxUnit: 'TB', + }) + ).toBe(1); + }); +}); diff --git a/packages/manager/src/components/AreaChart/utils.ts b/packages/manager/src/components/AreaChart/utils.ts index 5a3bce4764b..b83a9c0c879 100644 --- a/packages/manager/src/components/AreaChart/utils.ts +++ b/packages/manager/src/components/AreaChart/utils.ts @@ -17,6 +17,22 @@ export const tooltipLabelFormatter = (timestamp: number, timezone: string) => export const tooltipValueFormatter = (value: number, unit: string) => `${roundTo(value)}${unit}`; +export const humanizeLargeData = (value: number) => { + if (value >= 1000000000000) { + return +(value / 1000000000000).toFixed(1) + 'T'; + } + if (value >= 1000000000) { + return +(value / 1000000000).toFixed(1) + 'B'; + } + if (value >= 1000000) { + return +(value / 1000000).toFixed(1) + 'M'; + } + if (value >= 1000) { + return +(value / 1000).toFixed(1) + 'K'; + } + return `${value}`; +}; + export const timeData: LinodeNetworkTimeData[] = [ { 'Public Outbound Traffic': 5.434939999999999, diff --git a/packages/manager/src/components/CopyableAndDownloadableTextField.tsx b/packages/manager/src/components/CopyableAndDownloadableTextField.tsx index 7c8d76592f4..17f89731429 100644 --- a/packages/manager/src/components/CopyableAndDownloadableTextField.tsx +++ b/packages/manager/src/components/CopyableAndDownloadableTextField.tsx @@ -1,5 +1,4 @@ import { Theme } from '@mui/material/styles'; -import snakeCase from 'lodash/snakeCase'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -59,3 +58,15 @@ export const CopyableAndDownloadableTextField = (props: Props) => { /> ); }; + +const snakeCase = (str: string | undefined): string => { + if (!str) { + return ''; + } + + return str + .replace(/\W+/g, ' ') + .split(/ |\B(?=[A-Z])/) + .map((word) => word.toLowerCase()) + .join('_'); +}; diff --git a/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx b/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx index 942a9f0794a..20a294d8303 100644 --- a/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx +++ b/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx @@ -42,7 +42,7 @@ const StyledTextField = styled(TextField)(({ theme }) => ({ }, '.removeDisabledStyles': { '& .MuiInput-input': { - '-webkit-text-fill-color': 'unset !important', + WebkitTextFillColor: 'unset !important', borderColor: theme.name === 'light' ? '#ccc' : '#222', color: theme.name === 'light' diff --git a/packages/manager/src/components/Currency/Currency.tsx b/packages/manager/src/components/Currency/Currency.tsx index f91d1bf7103..f1ad0259632 100644 --- a/packages/manager/src/components/Currency/Currency.tsx +++ b/packages/manager/src/components/Currency/Currency.tsx @@ -1,6 +1,7 @@ -import { isNumber } from 'lodash'; import * as React from 'react'; +import { isNumber } from 'src/utilities/isNumber'; + interface CurrencyFormatterProps { /** * Additional data attributes to pass in. For example, a data-testid diff --git a/packages/manager/src/components/DetailsPanel/DetailsPanel.tsx b/packages/manager/src/components/DetailsPanel/DetailsPanel.tsx index 0f93f1b77ee..e1e09ef6645 100644 --- a/packages/manager/src/components/DetailsPanel/DetailsPanel.tsx +++ b/packages/manager/src/components/DetailsPanel/DetailsPanel.tsx @@ -7,7 +7,7 @@ import { TagsInput, TagsInputProps } from 'src/components/TagsInput/TagsInput'; import { TextField, TextFieldProps } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { PlacementGroupsDetailPanel } from 'src/features/PlacementGroups/PlacementGroupsDetailPanel'; -import { useFlags } from 'src/hooks/useFlags'; +import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; import type { PlacementGroup } from '@linode/api-v4'; @@ -28,9 +28,7 @@ export const DetailsPanel = (props: DetailsPanelProps) => { tagsInputProps, } = props; const theme = useTheme(); - const flags = useFlags(); - - const showPlacementGroups = Boolean(flags.placementGroups?.enabled); + const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); return ( { /> {tagsInputProps && } - - {showPlacementGroups && ( + {isPlacementGroupsEnabled && ( { - const { className, email, height, width } = props; + const { + captureAnalytics, + className, + email, + height = DEFAULT_AVATAR_SIZE, + width = DEFAULT_AVATAR_SIZE, + } = props; + const url = getGravatarUrl(email); + React.useEffect(() => { + if (captureAnalytics) { + checkForGravatarAndSendEvent(url); + } + }, []); + return ( - - + ); }; -const StyledAvatar = styled(Avatar, { - label: 'StyledAvatar', -})>(({ height, width }) => ({ - height: height || 28, - width: width || 28, -})); +/** + * Given a Gravatar URL, this function waits for Adobe Analytics + * to load (if it is not already loaded) and captures an Analytics + * event saying whether or not the user has a Gravatar. + * + * Make sure the URL passed has `?d=404` + */ +async function checkForGravatarAndSendEvent(url: string) { + try { + await waitForAdobeAnalyticsToBeLoaded(); + + const response = await fetch(url); + + if (response.status === 200) { + sendHasGravatarEvent(true); + } + if (response.status === 404) { + sendHasGravatarEvent(false); + } + } catch (error) { + // Analytics didn't load or the fetch to Gravatar + // failed. Event won't be logged. + } +} diff --git a/packages/manager/src/components/GravatarByUsername.tsx b/packages/manager/src/components/GravatarByUsername.tsx index 77dbfd1599e..509a78eabc4 100644 --- a/packages/manager/src/components/GravatarByUsername.tsx +++ b/packages/manager/src/components/GravatarByUsername.tsx @@ -1,11 +1,12 @@ import Avatar from '@mui/material/Avatar'; -import { styled } from '@mui/material/styles'; import * as React from 'react'; import UserIcon from 'src/assets/icons/account.svg'; import { useAccountUser } from 'src/queries/account/users'; import { getGravatarUrl } from 'src/utilities/gravatar'; +import { DEFAULT_AVATAR_SIZE } from './GravatarByEmail'; + interface Props { className?: string; username: null | string; @@ -17,20 +18,13 @@ export const GravatarByUsername = (props: Props) => { const url = user?.email ? getGravatarUrl(user.email) : undefined; return ( - - + ); }; - -const StyledAvatar = styled(Avatar, { - label: 'StyledGravatarByUsername', -})(() => ({ - borderRadius: '50%', - height: 28, - width: 28, -})); diff --git a/packages/manager/src/components/HighlightedMarkdown/HighlightedMarkdown.tsx b/packages/manager/src/components/HighlightedMarkdown/HighlightedMarkdown.tsx index 42b7fa6a23b..8ebd0dd9c49 100644 --- a/packages/manager/src/components/HighlightedMarkdown/HighlightedMarkdown.tsx +++ b/packages/manager/src/components/HighlightedMarkdown/HighlightedMarkdown.tsx @@ -15,7 +15,7 @@ import { unsafe_MarkdownIt } from 'src/utilities/markdown'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; import { useColorMode } from 'src/utilities/theme'; -import type { IOptions } from 'sanitize-html'; +import type { SanitizeOptions } from 'src/utilities/sanitizeHTML'; hljs.registerLanguage('apache', apache); hljs.registerLanguage('bash', bash); @@ -35,7 +35,7 @@ export type SupportedLanguage = export interface HighlightedMarkdownProps { className?: string; language?: SupportedLanguage; - sanitizeOptions?: IOptions; + sanitizeOptions?: SanitizeOptions; textOrMarkdown: string; } @@ -88,8 +88,7 @@ export const HighlightedMarkdown = (props: HighlightedMarkdownProps) => { const unsafe_parsedMarkdown = unsafe_MarkdownIt.render(textOrMarkdown); const sanitizedHtml = sanitizeHTML({ - // eslint-disable-next-line xss/no-mixed-html - options: sanitizeOptions, + sanitizeOptions, sanitizingTier: 'flexible', text: unsafe_parsedMarkdown, }); diff --git a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.test.tsx b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.test.tsx index a18f808dabb..5e3862d1684 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.test.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.test.tsx @@ -65,12 +65,7 @@ describe('ImageSelectv2', () => { await userEvent.click(imageOption); - expect(onChange).toHaveBeenCalledWith( - expect.anything(), - image, - 'selectOption', - expect.anything() - ); + expect(onChange).toHaveBeenCalledWith(image); }); it('should correctly initialize with a default value', async () => { diff --git a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx index c40255eda3e..363ad62d11e 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx @@ -15,7 +15,22 @@ import type { EnhancedAutocompleteProps } from 'src/components/Autocomplete/Auto export type ImageSelectVariant = 'all' | 'private' | 'public'; interface Props - extends Omit>, 'value'> { + extends Omit< + Partial>, + 'onChange' | 'value' + > { + /** + * Optional filter function applied to the options. + */ + filter?: (image: Image) => boolean; + /** + * Called when the value is changed + */ + onChange?: (image: Image | null) => void; + /** + * If there is only one avaiblable option, selected it by default. + */ + selectIfOnlyOneOption?: boolean; /** * The ID of the selected image */ @@ -30,7 +45,7 @@ interface Props } export const ImageSelectv2 = (props: Props) => { - const { variant, ...rest } = props; + const { filter, onChange, selectIfOnlyOneOption, variant, ...rest } = props; const { data: images, error, isLoading } = useAllImagesQuery( {}, @@ -40,8 +55,18 @@ export const ImageSelectv2 = (props: Props) => { // We can't filter out Kubernetes images using the API so we filter them here const options = getFilteredImagesForImageSelect(images, variant); + const filteredOptions = filter ? options?.filter(filter) : options; + const value = images?.find((i) => i.id === props.value); + if ( + filteredOptions?.length === 1 && + props.onChange && + selectIfOnlyOneOption + ) { + props.onChange(filteredOptions[0]); + } + return ( ( @@ -52,12 +77,22 @@ export const ImageSelectv2 = (props: Props) => { listItemProps={props} /> )} + clearOnBlur groupBy={(option) => option.vendor ?? 'My Images'} label="Images" loading={isLoading} - options={options ?? []} + options={filteredOptions ?? []} placeholder="Choose an image" {...rest} + disableClearable={ + rest.disableClearable ?? + (selectIfOnlyOneOption && filteredOptions?.length === 1) + } + onChange={(e, image) => { + if (onChange) { + onChange(image); + } + }} errorText={rest.errorText ?? error?.[0].reason} value={value ?? null} /> diff --git a/packages/manager/src/components/ImageSelectv2/utilities.ts b/packages/manager/src/components/ImageSelectv2/utilities.ts index 23510e3aafc..fadd4bf42cf 100644 --- a/packages/manager/src/components/ImageSelectv2/utilities.ts +++ b/packages/manager/src/components/ImageSelectv2/utilities.ts @@ -33,7 +33,7 @@ export const getFilteredImagesForImageSelect = ( images: Image[] | undefined, variant: ImageSelectVariant | undefined ) => { - return variant === 'public' - ? images?.filter((image) => !image.id.includes('kube')) - : images; + return variant === 'private' + ? images + : images?.filter((image) => !image.id.includes('kube')); }; diff --git a/packages/manager/src/components/LineGraph/LineGraph.tsx b/packages/manager/src/components/LineGraph/LineGraph.tsx index e28951180a3..701ce6acbf6 100644 --- a/packages/manager/src/components/LineGraph/LineGraph.tsx +++ b/packages/manager/src/components/LineGraph/LineGraph.tsx @@ -11,6 +11,7 @@ import { import { curry } from 'ramda'; import * as React from 'react'; +import { humanizeLargeData } from 'src/components/AreaChart/utils'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; @@ -115,16 +116,6 @@ const lineOptions: ChartDataSets = { pointRadius: 0, }; -const humanizeLargeData = (value: number) => { - if (value >= 1000000) { - return value / 1000000 + 'M'; - } - if (value >= 1000) { - return value / 1000 + 'K'; - } - return value; -}; - /** * **Chart.js** is the charting tool we use for analytics shown on the Linode detail page * - Keep charts compact @@ -186,6 +177,11 @@ export const LineGraph = (props: LineGraphProps) => { ) => { const finalChartOptions: ChartOptions = { animation: { duration: 0 }, + layout: { + padding: { + left: 8, + }, + }, legend: { display: _nativeLegend, position: _nativeLegend ? 'bottom' : undefined, diff --git a/packages/manager/src/components/Link.tsx b/packages/manager/src/components/Link.tsx index bbdb7fc9957..ef2b8dfcd21 100644 --- a/packages/manager/src/components/Link.tsx +++ b/packages/manager/src/components/Link.tsx @@ -1,5 +1,4 @@ import { sanitizeUrl } from '@braintree/sanitize-url'; -import { omit } from 'lodash'; import * as React from 'react'; import { Link as RouterLink } from 'react-router-dom'; @@ -10,6 +9,7 @@ import { flattenChildrenIntoAriaLabel, opensInNewTab, } from 'src/utilities/link'; +import { omitProps } from 'src/utilities/omittedProps'; import type { LinkProps as _LinkProps } from 'react-router-dom'; @@ -69,78 +69,82 @@ export interface LinkProps extends _LinkProps { * - External links provide by default "noopener noreferrer" attributes to prevent security vulnerabilities. * - ExternalLink component provides by default "aria-label" attributes to improve accessibility. */ -export const Link = (props: LinkProps) => { - const { - accessibleAriaLabel, - children, - className, - external, - forceCopyColor, - hideIcon, - onClick, - to, - } = props; - const { classes, cx } = useStyles(); - const sanitizedUrl = () => sanitizeUrl(to); - const shouldOpenInNewTab = opensInNewTab(sanitizedUrl()); - const childrenAsAriaLabel = flattenChildrenIntoAriaLabel(children); - const externalNotice = '- link opens in a new tab'; - const ariaLabel = accessibleAriaLabel - ? `${accessibleAriaLabel} ${shouldOpenInNewTab ? externalNotice : ''}` - : `${childrenAsAriaLabel} ${shouldOpenInNewTab ? externalNotice : ''}`; +export const Link = React.forwardRef( + (props, ref) => { + const { + accessibleAriaLabel, + children, + className, + external, + forceCopyColor, + hideIcon, + onClick, + to, + } = props; + const { classes, cx } = useStyles(); + const sanitizedUrl = () => sanitizeUrl(to); + const shouldOpenInNewTab = opensInNewTab(sanitizedUrl()); + const childrenAsAriaLabel = flattenChildrenIntoAriaLabel(children); + const externalNotice = '- link opens in a new tab'; + const ariaLabel = accessibleAriaLabel + ? `${accessibleAriaLabel} ${shouldOpenInNewTab ? externalNotice : ''}` + : `${childrenAsAriaLabel} ${shouldOpenInNewTab ? externalNotice : ''}`; - if (childrenContainsNoText(children) && !accessibleAriaLabel) { - // eslint-disable-next-line no-console - console.error( - 'Link component must have text content to be accessible to screen readers. Please provide an accessibleAriaLabel prop or text content.' - ); - } + if (childrenContainsNoText(children) && !accessibleAriaLabel) { + // eslint-disable-next-line no-console + console.error( + 'Link component must have text content to be accessible to screen readers. Please provide an accessibleAriaLabel prop or text content.' + ); + } - const routerLinkProps = omit(props, [ - 'accessibleAriaLabel', - 'external', - 'forceCopyColor', - ]); + const routerLinkProps = omitProps(props, [ + 'accessibleAriaLabel', + 'external', + 'forceCopyColor', + ]); - return shouldOpenInNewTab ? ( - - {children} - {external && !hideIcon && ( - + {children} + {external && !hideIcon && ( + + + + )} + + ) : ( + - - - )} - - ) : ( - - ); -}; + }, + className + )} + ref={ref} + /> + ); + } +); diff --git a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.test.tsx b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.test.tsx index 8e4eb9587c7..5ca36284321 100644 --- a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.test.tsx +++ b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.test.tsx @@ -97,7 +97,7 @@ describe('PlacementGroupSelect', () => { /> ); - const select = getByPlaceholderText('Select a Placement Group'); + const select = getByPlaceholderText('None'); expect(select).toBeEnabled(); fireEvent.focus(select); diff --git a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx index 81f45c9104c..8ac18bd25ad 100644 --- a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx +++ b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx @@ -115,7 +115,7 @@ export const PlacementGroupsSelect = (props: PlacementGroupsSelectProps) => { loading={isLoading || loading} onBlur={onBlur} options={placementGroupsOptions ?? []} - placeholder="Select a Placement Group" + placeholder="None" sx={sx} value={selection} {...textFieldProps} diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 631a36b6991..c55ac7ecf0b 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -26,6 +26,7 @@ import { BetaChip } from 'src/components/BetaChip/BetaChip'; import { Box } from 'src/components/Box'; import { Divider } from 'src/components/Divider'; import { useIsACLBEnabled } from 'src/features/LoadBalancers/utils'; +import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { usePrefetch } from 'src/hooks/usePreFetch'; @@ -35,7 +36,7 @@ import { useObjectStorageClusters, } from 'src/queries/objectStorage'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { useStackScriptsOCA } from 'src/queries/stackscripts'; +import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import useStyles from './PrimaryNav.styles'; @@ -115,7 +116,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { data: oneClickApps, error: oneClickAppsError, isLoading: oneClickAppsLoading, - } = useStackScriptsOCA(enableMarketplacePrefetch); + } = useMarketplaceAppsQuery(enableMarketplacePrefetch); const { data: clusters, @@ -170,6 +171,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { (checkRestrictedUser && !enginesLoading && !enginesError); const { isACLBEnabled } = useIsACLBEnabled(); + const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const prefetchObjectStorage = () => { if (!enableObjectPrefetch) { @@ -245,7 +247,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { { betaChipClassName: 'beta-chip-placement-groups', display: 'Placement Groups', - hide: !flags.placementGroups?.enabled, + hide: !isPlacementGroupsEnabled, href: '/placement-groups', icon: , isBeta: flags.placementGroups?.beta, @@ -322,6 +324,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { allowMarketplacePrefetch, flags.databaseBeta, isACLBEnabled, + isPlacementGroupsEnabled, flags.placementGroups, ] ); diff --git a/packages/manager/src/components/PrimaryNav/SideMenu.stories.tsx b/packages/manager/src/components/PrimaryNav/SideMenu.stories.tsx index 5bc651dc6f4..2bba6ff0fa6 100644 --- a/packages/manager/src/components/PrimaryNav/SideMenu.stories.tsx +++ b/packages/manager/src/components/PrimaryNav/SideMenu.stories.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { Box } from 'src/components/Box'; import { Hidden } from 'src/components/Hidden'; import { IconButton } from 'src/components/IconButton'; -import { TopMenuIcon } from 'src/features/TopMenu/TopMenuIcon'; +import { TopMenuTooltip } from 'src/features/TopMenu/TopMenuTooltip'; import { SideMenu } from './SideMenu'; @@ -36,7 +36,7 @@ export const Default: StoryObj = { - + = { > - + - + = { > - + diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index 0057809ea12..2c69a74d0d5 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -1,11 +1,18 @@ +import CloseIcon from '@mui/icons-material/Close'; import React, { useEffect, useMemo, useState } from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { StyledListItem } from 'src/components/Autocomplete/Autocomplete.styles'; +import { Box } from 'src/components/Box'; +import { Chip } from 'src/components/Chip'; +import { Flag } from 'src/components/Flag'; import { useAllAccountAvailabilitiesQuery } from 'src/queries/account/availability'; import { RegionOption } from './RegionOption'; -import { StyledAutocompleteContainer } from './RegionSelect.styles'; +import { + StyledAutocompleteContainer, + StyledFlagContainer, +} from './RegionSelect.styles'; import { getRegionOptions, getSelectedRegionsByIds, @@ -16,6 +23,32 @@ import type { RegionSelectOption, } from './RegionSelect.types'; +interface LabelComponentProps { + selection: RegionSelectOption; +} + +const SelectedRegion = ({ selection }: LabelComponentProps) => { + return ( + + ({ + marginRight: theme.spacing(1 / 2), + transform: 'scale(0.8)', + })} + > + + + {selection.label} + + ); +}; + export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { const { SelectedRegionsList, @@ -26,7 +59,6 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { helperText, isClearable, label, - onBlur, placeholder, regions, required, @@ -51,8 +83,6 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { const handleRegionChange = (selection: RegionSelectOption[]) => { setSelectedRegions(selection); - const selectedIds = selection.map((region) => region.value); - handleSelection(selectedIds); }; useEffect(() => { @@ -91,6 +121,9 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { <> + Boolean(option.disabledProps?.disabled) + } groupBy={(option: RegionSelectOption) => { return option?.data?.region; }} @@ -101,6 +134,10 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { onChange={(_, selectedOption) => handleRegionChange(selectedOption as RegionSelectOption[]) } + onClose={() => { + const selectedIds = selectedRegions.map((region) => region.value); + handleSelection(selectedIds); + }} renderOption={(props, option, { selected }) => { if (!option.data) { // Render options like "Select All / Deselect All " @@ -117,6 +154,18 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { /> ); }} + renderTags={(tagValue, getTagProps) => { + return tagValue.map((option, index) => ( + } + key={index} + label={} + onDelete={() => handleRemoveOption(option.value)} + /> + )); + }} sx={(theme) => ({ [theme.breakpoints.up('md')]: { width: '416px', @@ -126,6 +175,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { InputProps: { required, }, + placeholder: selectedRegions.length > 0 ? '' : placeholder, tooltipText: helperText, }} autoHighlight @@ -134,15 +184,12 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { disableClearable={!isClearable} disabled={disabled} errorText={errorText} - getOptionDisabled={(option: RegionSelectOption) => option.unavailable} label={label ?? 'Regions'} loading={accountAvailabilityLoading} multiple noOptionsText="No results" - onBlur={onBlur} options={options} placeholder={placeholder ?? 'Select Regions'} - renderTags={() => null} value={selectedRegions} /> diff --git a/packages/manager/src/components/RegionSelect/RegionOption.tsx b/packages/manager/src/components/RegionSelect/RegionOption.tsx index 7186b1cc6c3..3e8918c70aa 100644 --- a/packages/manager/src/components/RegionSelect/RegionOption.tsx +++ b/packages/manager/src/components/RegionSelect/RegionOption.tsx @@ -1,10 +1,9 @@ import { visuallyHidden } from '@mui/utils'; import React from 'react'; -import EdgeServer from 'src/assets/icons/entityIcons/edge-server.svg'; +import EdgeRegion from 'src/assets/icons/entityIcons/edge-region.svg'; import { Box } from 'src/components/Box'; import { Flag } from 'src/components/Flag'; -import { Link } from 'src/components/Link'; import { Tooltip } from 'src/components/Tooltip'; import { TooltipIcon } from 'src/components/TooltipIcon'; @@ -19,94 +18,79 @@ import { RegionSelectOption } from './RegionSelect.types'; import type { ListItemComponentsPropsOverrides } from '@mui/material/ListItem'; type Props = { - displayEdgeServerIcon?: boolean; + displayEdgeRegionIcon?: boolean; option: RegionSelectOption; props: React.HTMLAttributes; selected?: boolean; }; export const RegionOption = ({ - displayEdgeServerIcon, + displayEdgeRegionIcon, option, props, selected, }: Props) => { - const isDisabledMenuItem = option.unavailable; + const { className, onClick } = props; + const { data, disabledProps, label, value } = option; + const isRegionDisabled = Boolean(disabledProps?.disabled); + const isRegionDisabledReason = disabledProps?.reason; return ( - There may be limited capacity in this region.{' '} - - Learn more - - . - - ) : ( - '' - ) + isRegionDisabled && isRegionDisabledReason ? isRegionDisabledReason : '' } - disableFocusListener={!isDisabledMenuItem} - disableHoverListener={!isDisabledMenuItem} - disableTouchListener={!isDisabledMenuItem} + disableFocusListener={!isRegionDisabled} + disableHoverListener={!isRegionDisabled} + disableTouchListener={!isRegionDisabled} enterDelay={200} enterNextDelay={200} enterTouchDelay={200} - key={option.value} + key={value} > - isDisabledMenuItem - ? e.preventDefault() - : props.onClick - ? props.onClick(e) - : null + isRegionDisabled ? e.preventDefault() : onClick ? onClick(e) : null } aria-disabled={undefined} + className={isRegionDisabled ? `${className} Mui-disabled` : className} > <> - + - {option.label} - {displayEdgeServerIcon && ( + {label} + {displayEdgeRegionIcon && ( -  (This region is an Edge site.) +  (This region is an edge region.) )} - {isDisabledMenuItem && ( - - Disabled option - There may be limited capacity in this region. - Learn more at - https://www.linode.com/global-infrastructure/availability. - + {isRegionDisabled && isRegionDisabledReason && ( + {isRegionDisabledReason} )} {selected && } - {displayEdgeServerIcon && ( + {displayEdgeRegionIcon && ( } + icon={} status="other" sxTooltipIcon={sxEdgeIcon} - text="This region is an edge site." + text="This region is an edge region." /> )} diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts index d4a72010e19..f155d0a96e2 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts @@ -2,6 +2,7 @@ import DoneIcon from '@mui/icons-material/Done'; import { styled } from '@mui/material/styles'; import { Box } from 'src/components/Box'; +import { Chip } from 'src/components/Chip'; import { ListItem } from 'src/components/ListItem'; export const StyledAutocompleteContainer = styled(Box, { @@ -108,3 +109,20 @@ export const SelectedIcon = styled(DoneIcon, { visibility: visible ? 'visible' : 'hidden', width: 17, })); + +export const StyledChip = styled(Chip)(() => ({ + '& .MuiChip-deleteIcon': { + '& svg': { + borderRadius: '50%', + }, + padding: 0, + }, + '& .MuiChip-deleteIcon.MuiSvgIcon-root': { + '&:hover': { + backgroundColor: '#fff', + color: '#3683dc', + }, + backgroundColor: '#3683dc', + color: '#fff', + }, +})); diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx index 43491f83564..079579855fb 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx @@ -22,6 +22,7 @@ describe('RegionSelect', () => { regions, required: false, selectedId: '', + tooltipText: '', width: 100, }; @@ -38,10 +39,10 @@ describe('RegionSelect', () => { }); it('should render a Select component with the correct helper text', () => { - const { getByLabelText } = renderWithTheme( + const { getByText } = renderWithTheme( ); - expect(getByLabelText('helper text')).toBeInTheDocument(); + expect(getByText('helper text')).toBeInTheDocument(); }); it('should render a Select component with the correct error text', () => { diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 1fb00e9384a..71a8451ab38 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -1,7 +1,7 @@ import { Typography } from '@mui/material'; import * as React from 'react'; -import EdgeServer from 'src/assets/icons/entityIcons/edge-server.svg'; +import EdgeRegion from 'src/assets/icons/entityIcons/edge-region.svg'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Flag } from 'src/components/Flag'; import { Link } from 'src/components/Link'; @@ -26,7 +26,7 @@ import type { * A specific select for regions. * * The RegionSelect automatically filters regions based on capability using its `currentCapability` prop. For example, if - * `currentCapability="VPCs"`, only regions that support VPCs will appear in the RegionSelect dropdown. Edge regions are filtered based on the `hideEdgeServers` prop. + * `currentCapability="VPCs"`, only regions that support VPCs will appear in the RegionSelect dropdown. Edge regions are filtered based on the `regionFilter` prop. * There is no need to pre-filter regions when passing them to the RegionSelect. See the description of `currentCapability` prop for more information. * * We do not display the selected check mark for single selects. @@ -36,6 +36,7 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { currentCapability, disabled, errorText, + handleDisabledRegion, handleSelection, helperText, isClearable, @@ -45,6 +46,7 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { required, selectedId, showEdgeIconHelperText, + tooltipText, width, } = props; @@ -84,15 +86,25 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { getRegionOptions({ accountAvailabilityData: accountAvailability, currentCapability, + handleDisabledRegion, regionFilter, regions, }), - [accountAvailability, currentCapability, regions, regionFilter] + [ + accountAvailability, + currentCapability, + handleDisabledRegion, + regions, + regionFilter, + ] ); return ( + Boolean(option.disabledProps?.disabled) + } isOptionEqualToValue={( option: RegionSelectOption, { value }: RegionSelectOption @@ -109,7 +121,7 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { renderOption={(props, option) => { return ( { }, })} textFieldProps={{ + ...props.textFieldProps, InputProps: { endAdornment: regionFilter !== 'core' && selectedRegion?.site_type === 'edge' && ( } + icon={} status="other" sxTooltipIcon={sxEdgeIcon} - text="This region is an Edge server." + text="This region is an edge region." /> ), required, @@ -141,7 +154,7 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { ), }, - tooltipText: helperText, + tooltipText, }} autoHighlight clearOnBlur @@ -149,8 +162,8 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { disableClearable={!isClearable} disabled={disabled} errorText={errorText} - getOptionDisabled={(option: RegionSelectOption) => option.unavailable} groupBy={(option: RegionSelectOption) => option.data.region} + helperText={helperText} label={label ?? 'Region'} loading={accountAvailabilityLoading} loadingText="Loading regions..." @@ -161,14 +174,14 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { /> {showEdgeIconHelperText && ( // @TODO Gecko Beta: Add docs link - + {' '} - Indicates an Edge server region.{' '} - + Indicates an edge region.{' '} + Learn more . diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index df0ad7059e0..e8a32a92190 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -14,9 +14,13 @@ export interface RegionSelectOption { country: Country; region: string; }; + disabledProps?: { + disabled: boolean; + reason?: JSX.Element | string; + tooltipWidth?: number; + }; label: string; site_type: RegionSite; - unavailable: boolean; value: string; } @@ -33,6 +37,9 @@ export interface RegionSelectProps * See `ImageUpload.tsx` for an example of a RegionSelect with an undefined `currentCapability` - there is no capability associated with Images yet. */ currentCapability: Capabilities | undefined; + handleDisabledRegion?: ( + region: Region + ) => RegionSelectOption['disabledProps']; handleSelection: (id: string) => void; helperText?: string; isClearable?: boolean; @@ -42,6 +49,7 @@ export interface RegionSelectProps required?: boolean; selectedId: null | string; showEdgeIconHelperText?: boolean; + tooltipText?: string; width?: number; } @@ -63,12 +71,16 @@ export interface RegionMultiSelectProps required?: boolean; selectedIds: string[]; sortRegionOptions?: (a: RegionSelectOption, b: RegionSelectOption) => number; + tooltipText?: string; width?: number; } export interface RegionOptionAvailability { accountAvailabilityData: AccountAvailability[] | undefined; currentCapability: Capabilities | undefined; + handleDisabledRegion?: ( + region: Region + ) => RegionSelectOption['disabledProps']; } export interface GetRegionOptions extends RegionOptionAvailability { diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx index 95f52bf560c..8bdf1472598 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx @@ -1,10 +1,10 @@ import { accountAvailabilityFactory, regionFactory } from 'src/factories'; import { - getRegionOptionAvailability, getRegionOptions, getSelectedRegionById, getSelectedRegionsByIds, + isRegionOptionUnavailable, } from './RegionSelect.utils'; import type { RegionSelectOption } from './RegionSelect.types'; @@ -62,23 +62,29 @@ const expectedRegions: RegionSelectOption[] = [ country: 'us', region: 'North America', }, + disabledProps: { + disabled: false, + }, label: 'US Location (us-1)', site_type: 'core', - unavailable: false, value: 'us-1', }, { data: { country: 'ca', region: 'North America' }, + disabledProps: { + disabled: false, + }, label: 'CA Location (ca-1)', site_type: 'core', - unavailable: false, value: 'ca-1', }, { data: { country: 'jp', region: 'Asia' }, + disabledProps: { + disabled: false, + }, label: 'JP Location (jp-1)', site_type: 'core', - unavailable: false, value: 'jp-1', }, ]; @@ -86,16 +92,20 @@ const expectedRegions: RegionSelectOption[] = [ const expectedEdgeRegions = [ { data: { country: 'us', region: 'North America' }, + disabledProps: { + disabled: false, + }, label: 'Gecko Edge Test (us-edge-1)', site_type: 'edge', - unavailable: false, value: 'us-edge-1', }, { data: { country: 'us', region: 'North America' }, + disabledProps: { + disabled: false, + }, label: 'Gecko Edge Test 2 (us-edge-2)', site_type: 'edge', - unavailable: false, value: 'us-edge-2', }, ]; @@ -179,6 +189,46 @@ describe('getRegionOptions', () => { expect(result).toEqual(expectedRegionsWithEdge); }); + + it('should have its option disabled if the region is unavailable', () => { + const _regions = [ + ...regions, + regionFactory.build({ + capabilities: ['Linodes'], + country: 'us', + id: 'ap-south', + label: 'US Location 2', + }), + ]; + + const result: RegionSelectOption[] = getRegionOptions({ + accountAvailabilityData, + currentCapability: 'Linodes', + regions: _regions, + }); + + const unavailableRegion = result.find( + (region) => region.value === 'ap-south' + ); + + expect(unavailableRegion?.disabledProps?.disabled).toBe(true); + }); + + it('should have its option disabled if `handleDisabledRegion` is passed', () => { + const result: RegionSelectOption[] = getRegionOptions({ + accountAvailabilityData, + currentCapability: 'Linodes', + handleDisabledRegion: (region) => ({ + ...region, + disabled: true, + }), + regions, + }); + + const unavailableRegion = result.find((region) => region.value === 'us-1'); + + expect(unavailableRegion?.disabledProps?.disabled).toBe(true); + }); }); describe('getSelectedRegionById', () => { @@ -200,7 +250,6 @@ describe('getSelectedRegionById', () => { }, label: 'US Location (us-1)', site_type: 'core', - unavailable: false, value: 'us-1', }; @@ -223,7 +272,7 @@ describe('getSelectedRegionById', () => { describe('getRegionOptionAvailability', () => { it('should return true if the region is not available', () => { - const result = getRegionOptionAvailability({ + const result = isRegionOptionUnavailable({ accountAvailabilityData, currentCapability: 'Linodes', region: regionFactory.build({ @@ -235,7 +284,7 @@ describe('getRegionOptionAvailability', () => { }); it('should return false if the region is available', () => { - const result = getRegionOptionAvailability({ + const result = isRegionOptionUnavailable({ accountAvailabilityData, currentCapability: 'Linodes', region: regionFactory.build({ @@ -266,7 +315,6 @@ describe('getSelectedRegionsByIds', () => { }, label: 'US Location (us-1)', site_type: 'core', - unavailable: false, value: 'us-1', }, { @@ -276,7 +324,6 @@ describe('getSelectedRegionsByIds', () => { }, label: 'CA Location (ca-1)', site_type: 'core', - unavailable: false, value: 'ca-1', }, ]; @@ -302,7 +349,6 @@ describe('getSelectedRegionsByIds', () => { }, label: 'US Location (us-1)', site_type: 'core', - unavailable: false, value: 'us-1', }, ]; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.ts b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx similarity index 86% rename from packages/manager/src/components/RegionSelect/RegionSelect.utils.ts rename to packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx index 89d25b4f99c..7c9d84b3b1a 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx @@ -20,6 +20,7 @@ const NORTH_AMERICA = CONTINENT_CODE_TO_CONTINENT.NA; /** * Returns an array of OptionType objects for use in the RegionSelect component. + * Handles the disabled state of each region based on the user's account availability or an optional custom handler. * Regions are sorted alphabetically by region, with North America first. * * @returns An array of RegionSelectOption objects @@ -27,6 +28,7 @@ const NORTH_AMERICA = CONTINENT_CODE_TO_CONTINENT.NA; export const getRegionOptions = ({ accountAvailabilityData, currentCapability, + handleDisabledRegion, regionFilter, regions, }: GetRegionOptions): RegionSelectOption[] => { @@ -42,22 +44,40 @@ export const getRegionOptions = ({ ) : filteredRegionsByCapability; + const isRegionUnavailable = (region: Region) => + isRegionOptionUnavailable({ + accountAvailabilityData, + currentCapability, + region, + }); + return filteredRegionsByCapabilityAndSiteType .map((region: Region) => { const group = getRegionCountryGroup(region); + // The region availability is the first check we run, regardless of the handleDisabledRegion function. + // This check always runs, and if the region is unavailable, the region will be disabled. + const disabledProps = isRegionUnavailable(region) + ? { + disabled: true, + reason: + 'This region is currently unavailable. For help, open a support ticket.', + tooltipWidth: 250, + } + : handleDisabledRegion?.(region)?.disabled + ? handleDisabledRegion(region) + : { + disabled: false, + }; + return { data: { country: region.country, region: group, }, + disabledProps, label: `${region.label} (${region.id})`, site_type: region.site_type, - unavailable: getRegionOptionAvailability({ - accountAvailabilityData, - currentCapability, - region, - }), value: region.id, }; }) @@ -107,8 +127,6 @@ export const getRegionOptions = ({ * @returns an RegionSelectOption object for the currently selected region. */ export const getSelectedRegionById = ({ - accountAvailabilityData, - currentCapability, regions, selectedRegionId, }: GetSelectedRegionById): RegionSelectOption | undefined => { @@ -127,11 +145,6 @@ export const getSelectedRegionById = ({ }, label: `${selectedRegion.label} (${selectedRegion.id})`, site_type: selectedRegion.site_type, - unavailable: getRegionOptionAvailability({ - accountAvailabilityData, - currentCapability, - region: selectedRegion, - }), value: selectedRegion.id, }; }; @@ -141,7 +154,7 @@ export const getSelectedRegionById = ({ * * @returns a boolean indicating whether the region is available to the user. */ -export const getRegionOptionAvailability = ({ +export const isRegionOptionUnavailable = ({ accountAvailabilityData, currentCapability, region, diff --git a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx index 8bea877b099..50c5c476d44 100644 --- a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx +++ b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx @@ -24,10 +24,12 @@ import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; import { Box } from '../Box'; import { DocsLink } from '../DocsLink/DocsLink'; import { Link } from '../Link'; +import { RegionSelectProps } from '../RegionSelect/RegionSelect.types'; import type { LinodeCreateType } from 'src/features/Linodes/LinodesCreate/types'; interface SelectRegionPanelProps { + RegionSelectProps?: Partial; currentCapability: Capabilities; disabled?: boolean; error?: string; @@ -42,6 +44,7 @@ interface SelectRegionPanelProps { export const SelectRegionPanel = (props: SelectRegionPanelProps) => { const { + RegionSelectProps, currentCapability, disabled, error, @@ -78,7 +81,8 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { }); const hideEdgeRegions = - !flags.gecko || + !flags.gecko2?.enabled || + flags.gecko2?.ga || !getIsLinodeCreateTypeEdgeSupported(params.type as LinodeCreateType); const showEdgeIconHelperText = Boolean( @@ -140,6 +144,7 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { regions={regions ?? []} selectedId={selectedId || null} showEdgeIconHelperText={showEdgeIconHelperText} + {...RegionSelectProps} /> {showClonePriceWarning && ( { ...rest } = props; - const tableProps = omit(rest, [ + const tableProps = omitProps({ ...props, ...rest }, [ + 'colCount', + 'rowCount', 'noBorder', 'noOverflow', 'rowHoverState', diff --git a/packages/manager/src/components/TableRow/TableRow.styles.ts b/packages/manager/src/components/TableRow/TableRow.styles.ts index 7a63344b489..1cfdb721e5a 100644 --- a/packages/manager/src/components/TableRow/TableRow.styles.ts +++ b/packages/manager/src/components/TableRow/TableRow.styles.ts @@ -1,5 +1,5 @@ -import { default as _TableRow } from '@mui/material/TableRow'; import { styled } from '@mui/material/styles'; +import { default as _TableRow } from '@mui/material/TableRow'; import { omittedProps } from 'src/utilities/omittedProps'; @@ -63,14 +63,10 @@ export const StyledTableRow = styled(_TableRow, { backgroundColor: theme.bg.lightBlue1, }), ...(props.disabled && { - '& td': { + '& td:not(.hasTooltip *), & td:has(.hasTooltip):not(.MuiRadio-root)': { color: - theme.palette.mode === 'dark' - ? theme.color.grey6 - : theme.color.disabledText, + theme.palette.mode === 'dark' ? theme.color.grey6 : theme.color.grey1, }, - backgroundColor: - theme.palette.mode === 'dark' ? '#32363c' : 'rgba(247, 247, 247, 0.25)', }), })); diff --git a/packages/manager/src/components/VLANSelect.tsx b/packages/manager/src/components/VLANSelect.tsx index 230dfc3a8ee..99d5fe82996 100644 --- a/packages/manager/src/components/VLANSelect.tsx +++ b/packages/manager/src/components/VLANSelect.tsx @@ -21,6 +21,10 @@ interface Props { * Default API filter */ filter?: Filter; + /** + * Called when the field is blurred + */ + onBlur?: () => void; /** * Is called when a VLAN is selected */ @@ -42,7 +46,7 @@ interface Props { * - Allows VLAN creation */ export const VLANSelect = (props: Props) => { - const { disabled, errorText, filter, onChange, sx, value } = props; + const { disabled, errorText, filter, onBlur, onChange, sx, value } = props; const [open, setOpen] = React.useState(false); const [inputValue, setInputValue] = useState(''); @@ -62,7 +66,7 @@ export const VLANSelect = (props: Props) => { const vlans = data?.pages.flatMap((page) => page.data) ?? []; - const newVlanPlacehodler = { + const newVlanPlaceholder = { cidr_block: '', created: '', id: 0, @@ -74,7 +78,7 @@ export const VLANSelect = (props: Props) => { const hasVLANWithExactLabel = vlans.some((vlan) => vlan.label === inputValue); if (!isFetching && inputValue && !hasVLANWithExactLabel) { - vlans.push(newVlanPlacehodler); + vlans.push(newVlanPlaceholder); } const selectedVLAN = vlans?.find((option) => option.label === value) ?? null; @@ -94,7 +98,7 @@ export const VLANSelect = (props: Props) => { }, }} getOptionLabel={(option) => - option === newVlanPlacehodler ? `Create "${inputValue}"` : option.label + option === newVlanPlaceholder ? `Create "${inputValue}"` : option.label } isOptionEqualToValue={(option1, options2) => option1.label === options2.label @@ -103,7 +107,7 @@ export const VLANSelect = (props: Props) => { if (onChange) { onChange(value?.label ?? null); } - if (value !== newVlanPlacehodler) { + if (value !== newVlanPlaceholder) { setInputValue(''); } }} @@ -124,6 +128,7 @@ export const VLANSelect = (props: Props) => { label="VLAN" loading={isFetching} noOptionsText="You have no VLANs in this region. Type to create one." + onBlur={onBlur} open={open} options={vlans} placeholder="Create or select a VLAN" diff --git a/packages/manager/src/components/VPCSelect.test.tsx b/packages/manager/src/components/VPCSelect.test.tsx new file mode 100644 index 00000000000..8a5c69e674a --- /dev/null +++ b/packages/manager/src/components/VPCSelect.test.tsx @@ -0,0 +1,37 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { vpcFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { VPCSelect } from './VPCSelect'; + +describe('VPCSelect', () => { + it('should render a label', () => { + const { getByLabelText } = renderWithTheme(); + + expect(getByLabelText('VPC')).toBeVisible(); + }); + + it('should render VPCs returned by the API', async () => { + const vpcs = vpcFactory.buildList(5); + + server.use( + http.get('*/v4beta/vpcs', () => { + return HttpResponse.json(makeResourcePage(vpcs)); + }) + ); + + const { getByPlaceholderText, getByText } = renderWithTheme( + + ); + + await userEvent.click(getByPlaceholderText('Select an option')); + + for (const vpc of vpcs) { + expect(getByText(vpc.label)).toBeVisible(); + } + }); +}); diff --git a/packages/manager/src/components/VPCSelect.tsx b/packages/manager/src/components/VPCSelect.tsx new file mode 100644 index 00000000000..9e4da5df9d0 --- /dev/null +++ b/packages/manager/src/components/VPCSelect.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { useVPCsQuery } from 'src/queries/vpcs/vpcs'; + +import { Autocomplete } from './Autocomplete/Autocomplete'; + +import type { EnhancedAutocompleteProps } from './Autocomplete/Autocomplete'; +import type { Filter, VPC } from '@linode/api-v4'; + +interface Props extends Partial, 'value'>> { + /** + * An optional API filter to filter the VPC options + */ + filter?: Filter; + /** + * The ID of the selected VPC + */ + value: null | number | undefined; +} + +export const VPCSelect = (props: Props) => { + const { filter, value, ...rest } = props; + + const { data, isFetching } = useVPCsQuery({}, filter ?? {}); + + const selectedVPC = data?.data.find((vpc) => vpc.id === value) ?? null; + + return ( + + ); +}; diff --git a/packages/manager/src/constants.ts b/packages/manager/src/constants.ts index fd0592be7a6..7fc18183fd3 100644 --- a/packages/manager/src/constants.ts +++ b/packages/manager/src/constants.ts @@ -167,7 +167,14 @@ export const allowedHTMLTagsFlexible: string[] = [ 'tr', ]; -export const allowedHTMLAttr = ['href', 'lang', 'title', 'align']; +export const allowedHTMLAttr = [ + 'href', + 'lang', + 'title', + 'align', + 'class', + 'rel', +]; /** * MBps rate for intra DC migrations (AKA Mutations) diff --git a/packages/manager/src/containers/withMarketplaceApps.ts b/packages/manager/src/containers/withMarketplaceApps.ts index 0dda04baf9f..f5040df7241 100644 --- a/packages/manager/src/containers/withMarketplaceApps.ts +++ b/packages/manager/src/containers/withMarketplaceApps.ts @@ -4,7 +4,7 @@ import { useLocation } from 'react-router-dom'; import { baseApps } from 'src/features/StackScripts/stackScriptUtils'; import { useFlags } from 'src/hooks/useFlags'; -import { useStackScriptsOCA } from 'src/queries/stackscripts'; +import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; import { getQueryParamFromQueryString } from 'src/utilities/queryParams'; const trimOneClickFromLabel = (script: StackScript) => { @@ -31,7 +31,7 @@ export const withMarketplaceApps = ( // Only enable the query when the user is on the Marketplace page const enabled = type === 'One-Click'; - const { data, error, isLoading } = useStackScriptsOCA(enabled); + const { data, error, isLoading } = useMarketplaceAppsQuery(enabled); const newApps = flags.oneClickApps || []; const allowedApps = Object.keys({ ...baseApps, ...newApps }); diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 846b6f391a1..856320406f0 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -21,16 +21,17 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'aclb', label: 'ACLB' }, { flag: 'aclbFullCreateFlow', label: 'ACLB Full Create Flow' }, { flag: 'disableLargestGbPlans', label: 'Disable Largest GB Plans' }, - { flag: 'linodeCloneUiChanges', label: 'Linode Clone UI Changes' }, - { flag: 'gecko', label: 'Gecko' }, - { flag: 'parentChildAccountAccess', label: 'Parent/Child Account' }, - { flag: 'selfServeBetas', label: 'Self Serve Betas' }, { flag: 'firewallNodebalancer', label: 'Firewall NodeBalancer' }, - { flag: 'recharts', label: 'Recharts' }, + { flag: 'gecko2', label: 'Gecko' }, + { flag: 'linodeCloneUiChanges', label: 'Linode Clone UI Changes' }, + { flag: 'linodeCreateRefactor', label: 'Linode Create v2' }, + { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, + { flag: 'parentChildAccountAccess', label: 'Parent/Child Account' }, { flag: 'placementGroups', label: 'Placement Groups' }, + { flag: 'recharts', label: 'Recharts' }, + { flag: 'selfServeBetas', label: 'Self Serve Betas' }, { flag: 'supportTicketSeverity', label: 'Support Ticket Severity' }, - { flag: 'linodeCreateRefactor', label: 'Linode Create v2' }, ]; export const FeatureFlagTool = withFeatureFlagProvider(() => { diff --git a/packages/manager/src/factories/account.ts b/packages/manager/src/factories/account.ts index 17662c422de..79f8866b75d 100644 --- a/packages/manager/src/factories/account.ts +++ b/packages/manager/src/factories/account.ts @@ -46,6 +46,7 @@ export const accountFactory = Factory.Sync.makeFactory({ 'LKE HA Control Planes', 'Machine Images', 'Managed Databases', + 'Placement Group', ], city: 'Colorado', company: Factory.each((i) => `company-${i}`), diff --git a/packages/manager/src/factories/types.ts b/packages/manager/src/factories/types.ts index 50afa4873ef..0665214a7ce 100644 --- a/packages/manager/src/factories/types.ts +++ b/packages/manager/src/factories/types.ts @@ -137,3 +137,25 @@ export const nodeBalancerTypeFactory = Factory.Sync.makeFactory({ ], transfer: 0, }); + +export const volumeTypeFactory = Factory.Sync.makeFactory({ + id: 'volume', + label: 'Volume', + price: { + hourly: 0.00015, + monthly: 0.1, + }, + region_prices: [ + { + hourly: 0.00018, + id: 'id-cgk', + monthly: 0.12, + }, + { + hourly: 0.00021, + id: 'br-gru', + monthly: 0.14, + }, + ], + transfer: 0, +}); diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 4b6ceeeebde..bc34486419a 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -43,6 +43,11 @@ interface PlacementGroupsFlag { enabled: boolean; } +interface GeckoFlag { + enabled: boolean; + ga: boolean; +} + type OneClickApp = Record; export interface Flags { @@ -54,11 +59,13 @@ export interface Flags { databases: boolean; disableLargestGbPlans: boolean; firewallNodebalancer: boolean; - gecko: boolean; + gecko: boolean; // @TODO gecko: delete this after next release + gecko2: GeckoFlag; ipv6Sharing: boolean; linodeCloneUiChanges: boolean; linodeCreateRefactor: boolean; linodeCreateWithFirewall: boolean; + linodeDiskEncryption: boolean; mainContentBanner: MainContentBanner; metadata: boolean; objMultiCluster: boolean; diff --git a/packages/manager/src/features/Backups/BackupDrawer.tsx b/packages/manager/src/features/Backups/BackupDrawer.tsx index 28386a3f7e5..55cf416029c 100644 --- a/packages/manager/src/features/Backups/BackupDrawer.tsx +++ b/packages/manager/src/features/Backups/BackupDrawer.tsx @@ -1,6 +1,4 @@ import { styled } from '@mui/material'; -import { Stack } from 'src/components/Stack'; -import { isNumber } from 'lodash'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -10,6 +8,7 @@ import { DisplayPrice } from 'src/components/DisplayPrice'; import { Drawer } from 'src/components/Drawer'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; +import { Stack } from 'src/components/Stack'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; @@ -24,6 +23,7 @@ import { } from 'src/queries/account/settings'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { useAllTypes } from 'src/queries/types'; +import { isNumber } from 'src/utilities/isNumber'; import { pluralize } from 'src/utilities/pluralize'; import { getTotalBackupsPrice } from 'src/utilities/pricing/backups'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx index 8d50cbb217e..820807f5139 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx @@ -121,4 +121,49 @@ describe('database resize', () => { getByText(`Resize Database Cluster ${database.label}?`); }); }); + + describe('should be disabled smaller plans', () => { + const database = databaseFactory.build({ + type: 'g6-dedicated-8', + }); + it('disabled smaller plans', async () => { + // Mock database types + const dedicatedTypes = [ + databaseTypeFactory.build({ + class: 'dedicated', + disk: 81920, + id: 'g6-dedicated-2', + label: 'Dedicated 4 GB', + memory: 4096, + }), + databaseTypeFactory.build({ + class: 'dedicated', + disk: 163840, + id: 'g6-dedicated-4', + label: 'Dedicated 8 GB', + memory: 8192, + }), + databaseTypeFactory.build({ + class: 'dedicated', + disk: 327680, + id: 'g6-dedicated-8', + label: `Linode 16 GB`, + memory: 16384, + }), + ]; + server.use( + http.get('*/databases/types', () => { + return HttpResponse.json(makeResourcePage([...dedicatedTypes])); + }) + ); + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + expect( + document.getElementById('g6-dedicated-4')?.hasAttribute('disabled') + ); + }); + }); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx index 094db3a7339..a7d9e2b3550 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx @@ -203,9 +203,13 @@ export const DatabaseResize = ({ database }: Props) => { const currentPlan = displayTypes?.find((type) => type.id === database.type); - const disabledPlans = displayTypes?.filter( - (type) => type.disk < (currentPlan ? currentPlan.disk : 0) + const currentPlanDisk = currentPlan ? currentPlan.disk : 0; + const disabledPlans = displayTypes?.filter((type) => + type.class === 'dedicated' + ? type.disk < currentPlanDisk + : type.disk <= currentPlanDisk ); + if (typesLoading) { return ; } diff --git a/packages/manager/src/features/Events/EventRow.tsx b/packages/manager/src/features/Events/EventRow.tsx index fe0998122e3..804af2d74c1 100644 --- a/packages/manager/src/features/Events/EventRow.tsx +++ b/packages/manager/src/features/Events/EventRow.tsx @@ -81,7 +81,7 @@ export const Row = (props: RowProps) => { { }; const errorNotice = () => { - let errorMsg = sanitize(localError || '', { - allowedAttributes: {}, - allowedTags: [], // Disallow all HTML tags, - }); + let errorMsg = sanitizeHTML({ + sanitizeOptions: { + ALLOWED_ATTR: [], + ALLOWED_TAGS: [], // Disallow all HTML tags, + }, + sanitizingTier: 'strict', + text: localError || '', + }).toString(); // match something like: Linode (ID ) const linode = /Linode (.+?) \(ID ([^\)]+)\)/i.exec(errorMsg); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx index 17061da3771..37e3993a09f 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx @@ -1,10 +1,9 @@ import { NodeBalancer } from '@linode/api-v4'; import { useTheme } from '@mui/material'; +import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useQueryClient } from '@tanstack/react-query'; import { useParams } from 'react-router-dom'; -import sanitize from 'sanitize-html'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; @@ -22,6 +21,7 @@ import { queryKey } from 'src/queries/nodebalancers'; import { useGrants, useProfile } from 'src/queries/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getEntityIdsByPermission } from 'src/utilities/grants'; +import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; interface Props { helperText: string; @@ -103,10 +103,14 @@ export const AddNodebalancerDrawer = (props: Props) => { }; const errorNotice = () => { - let errorMsg = sanitize(localError || '', { - allowedAttributes: {}, - allowedTags: [], // Disallow all HTML tags, - }); + let errorMsg = sanitizeHTML({ + sanitizeOptions: { + ALLOWED_ATTR: [], + ALLOWED_TAGS: [], // Disallow all HTML tags, + }, + sanitizingTier: 'strict', + text: localError || '', + }).toString(); // match something like: NodeBalancer (ID ) const nodebalancer = /NodeBalancer (.+?) \(ID ([^\)]+)\)/i.exec(errorMsg); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx index f34cc4228c9..9ccd1979c3c 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx @@ -1,5 +1,4 @@ import { Theme } from '@mui/material/styles'; -import { isNumber } from 'lodash'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -12,6 +11,7 @@ import { useCreateNodePoolMutation } from 'src/queries/kubernetes'; import { useAllTypes } from 'src/queries/types'; import { extendType } from 'src/utilities/extendType'; import { filterCurrentTypes } from 'src/utilities/filterCurrentLinodeTypes'; +import { isNumber } from 'src/utilities/isNumber'; import { plansNoticesUtils } from 'src/utilities/planNotices'; import { pluralize } from 'src/utilities/pluralize'; import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx index 5f44f7681cd..f097ae6fd17 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx @@ -12,6 +12,7 @@ import { Typography } from 'src/components/Typography'; import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; import { useSpecificTypes } from 'src/queries/types'; import { extendType } from 'src/utilities/extendType'; +import { isNumber } from 'src/utilities/isNumber'; import { pluralize } from 'src/utilities/pluralize'; import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/dynamicPricing'; @@ -20,7 +21,6 @@ import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { nodeWarning } from '../../kubeUtils'; import { hasInvalidNodePoolPrice } from './utils'; -import { isNumber } from 'lodash'; const useStyles = makeStyles()((theme: Theme) => ({ helperText: { diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.test.tsx index 0000dc08685..072a1e2c32b 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.test.tsx @@ -23,11 +23,14 @@ const plans: TypeWithAvailability[] = [ ]; const props: KubernetesPlanContainerProps = { + allDisabledPlans: [], getTypeCount: vi.fn(), + hasMajorityOfPlansDisabled: false, onSelect: vi.fn(), plans, selectedRegionId: undefined, updatePlanCount: vi.fn(), + wholePanelIsDisabled: false, }; beforeAll(() => mockMatchMedia()); diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx index d8c302f7f5b..3fec0db7943 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx @@ -25,55 +25,78 @@ const tableCells = [ { cellName: 'Quantity', center: false, noWrap: false, testId: 'quantity' }, ]; +type AllDisabledPlans = TypeWithAvailability & { + isDisabled512GbPlan: boolean; + isLimitedAvailabilityPlan: boolean; +}; + export interface KubernetesPlanContainerProps { - disabled?: boolean; + allDisabledPlans: AllDisabledPlans[]; getTypeCount: (planId: string) => number; + hasMajorityOfPlansDisabled: boolean; onAdd?: (key: string, value: number) => void; onSelect: (key: string) => void; plans: TypeWithAvailability[]; selectedId?: string; selectedRegionId?: string; updatePlanCount: (planId: string, newCount: number) => void; + wholePanelIsDisabled: boolean; } export const KubernetesPlanContainer = ( props: KubernetesPlanContainerProps ) => { const { - disabled, + allDisabledPlans, getTypeCount, + hasMajorityOfPlansDisabled, onAdd, onSelect, plans, selectedId, selectedRegionId, updatePlanCount, + wholePanelIsDisabled, } = props; const shouldDisplayNoRegionSelectedMessage = !selectedRegionId; const renderPlanSelection = React.useCallback(() => { return plans.map((plan, id) => { + const isPlanDisabled = allDisabledPlans.some( + (disabledPlan) => disabledPlan.id === plan.id + ); + const currentDisabledPlan = allDisabledPlans.find( + (disabledPlan) => disabledPlan.id === plan.id + ); + const currentDisabledPlanStatus = currentDisabledPlan && { + isDisabled512GbPlan: currentDisabledPlan.isDisabled512GbPlan, + isLimitedAvailabilityPlan: + currentDisabledPlan.isLimitedAvailabilityPlan, + }; + return ( ); }); }, [ - disabled, + allDisabledPlans, + wholePanelIsDisabled, + hasMajorityOfPlansDisabled, getTypeCount, onAdd, onSelect, diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelection.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelection.test.tsx index 90756625355..62a5fe039b9 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelection.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelection.test.tsx @@ -32,14 +32,20 @@ const typeWithAvailability: TypeWithAvailability = { }; const props: KubernetesPlanSelectionProps = { + disabledStatus: { + isDisabled512GbPlan: false, + isLimitedAvailabilityPlan: false, + }, getTypeCount: vi.fn(), + hasMajorityOfPlansDisabled: false, idx: 0, - isLimitedAvailabilityPlan: false, onAdd: vi.fn(), onSelect: vi.fn(), + planIsDisabled: false, selectedRegionId: 'us-east', type: typeWithAvailability, updatePlanCount: vi.fn(), + wholePanelIsDisabled: false, }; describe('KubernetesPlanSelection (table, desktop view)', () => { @@ -128,10 +134,13 @@ describe('KubernetesPlanSelection (table, desktop view)', () => { wrapWithTableBody( , - { flags: { disableLargestGbPlans: true } } + /> ) ); @@ -182,45 +191,4 @@ describe('KubernetesPlanSelection (cards, mobile view)', () => { getByText(`${regionHourlyPrice}/hr`, { exact: false }) ).toBeInTheDocument(); }); - - it('shows limited availability messaging', async () => { - const { getByRole, getByTestId, getByText } = renderWithTheme( - - ); - - const selectionCard = getByTestId('selection-card'); - fireEvent.mouseOver(selectionCard); - - await waitFor(() => { - expect(getByRole('tooltip')).toBeInTheDocument(); - }); - - expect(getByText(LIMITED_AVAILABILITY_TEXT)).toBeVisible(); - }); - - it('is disabled for 512 GB plans', () => { - const bigPlanType: TypeWithAvailability = { - ...extendedTypeFactory.build({ - heading: 'Dedicated 512 GB', - label: 'Dedicated 512GB', - }), - isLimitedAvailabilityPlan: false, - }; - - const { getByTestId } = renderWithTheme( - , - { flags: { disableLargestGbPlans: true } } - ); - - const selectionCard = getByTestId('selection-card'); - expect(selectionCard).toBeDisabled(); - }); }); diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelection.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelection.tsx index b5d39d34f5c..9f98526aea4 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelection.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelection.tsx @@ -13,10 +13,9 @@ import { Hidden } from 'src/components/Hidden'; import { IconButton } from 'src/components/IconButton'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; import { Tooltip } from 'src/components/Tooltip'; import { LIMITED_AVAILABILITY_TEXT } from 'src/features/components/PlansPanel/constants'; -import { StyledDisabledTableRow } from 'src/features/components/PlansPanel/PlansPanel.styles'; -import { useFlags } from 'src/hooks/useFlags'; import { PRICE_ERROR_TOOLTIP_TEXT, UNKNOWN_PRICE, @@ -28,42 +27,44 @@ import { convertMegabytesTo } from 'src/utilities/unitConversions'; import type { TypeWithAvailability } from 'src/features/components/PlansPanel/types'; export interface KubernetesPlanSelectionProps { - disabled?: boolean; + disabledStatus: + | { + isDisabled512GbPlan: boolean; + isLimitedAvailabilityPlan: boolean; + } + | undefined; getTypeCount: (planId: string) => number; + hasMajorityOfPlansDisabled: boolean; idx: number; - isLimitedAvailabilityPlan: boolean; onAdd?: (key: string, value: number) => void; onSelect: (key: string) => void; + planIsDisabled: boolean; selectedId?: string; selectedRegionId?: Region['id']; type: TypeWithAvailability; updatePlanCount: (planId: string, newCount: number) => void; + wholePanelIsDisabled: boolean; } export const KubernetesPlanSelection = ( props: KubernetesPlanSelectionProps ) => { const { - disabled, + disabledStatus, getTypeCount, + hasMajorityOfPlansDisabled, idx, - isLimitedAvailabilityPlan, onAdd, onSelect, + planIsDisabled, selectedId, selectedRegionId, type, updatePlanCount, + wholePanelIsDisabled, } = props; - const flags = useFlags(); - - // Determine if the plan should be disabled solely due to being a 512GB plan - const disabled512GbPlan = - type.label.includes('512GB') && - Boolean(flags.disableLargestGbPlans) && - !disabled; - const isDisabled = disabled || isLimitedAvailabilityPlan || disabled512GbPlan; + const isDisabled = planIsDisabled || wholePanelIsDisabled; const count = getTypeCount(type.id); const price: PriceObject | undefined = getLinodeRegionPrice( type, @@ -88,13 +89,8 @@ export const KubernetesPlanSelection = ( /> {onAdd && ( + + + ); + } + + return ( + + + + + + + StackScript + + + + + + {stackscripts?.map((stackscript) => ( + { + setValue('image', null); + setValue( + 'stackscript_data', + getDefaultUDFData(stackscript.user_defined_fields) + ); + field.onChange(stackscript.id); + }} + isSelected={field.value === stackscript.id} + key={stackscript.id} + onOpenDetails={() => setSelectedStackScriptId(stackscript.id)} + stackscript={stackscript} + /> + ))} + {error && } + {isLoading && } + {(isFetchingNextPage || hasNextPage) && ( + + )} + +
+ {hasNextPage && fetchNextPage()} />} + setSelectedStackScriptId(undefined)} + open={selectedStackScriptId !== undefined} + /> +
+ ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.test.tsx new file mode 100644 index 00000000000..e3180ef5566 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.test.tsx @@ -0,0 +1,109 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { stackScriptFactory } from 'src/factories'; +import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; + +import { StackScriptSelectionRow } from './StackScriptSelectionRow'; + +describe('StackScriptSelectionRow', () => { + it('render a stackscript label and username', () => { + const stackscript = stackScriptFactory.build(); + + const { getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(getByText(stackscript.username, { exact: false })).toBeVisible(); + expect(getByText(stackscript.label, { exact: false })).toBeVisible(); + }); + + it('render a checked Radio if isSelected is true', () => { + const stackscript = stackScriptFactory.build(); + + const { getByLabelText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + const radio = getByLabelText(stackscript.label, { exact: false }); + + expect(radio).toBeChecked(); + }); + + it('render an unchecked Radio if isSelected is false', () => { + const stackscript = stackScriptFactory.build(); + + const { getByLabelText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + const radio = getByLabelText(stackscript.label, { exact: false }); + + expect(radio).not.toBeChecked(); + }); + + it('should call onSelect when a stackscript is clicked', async () => { + const stackscript = stackScriptFactory.build(); + const onSelect = vi.fn(); + + const { getByLabelText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + const radio = getByLabelText(stackscript.label, { exact: false }); + + await userEvent.click(radio); + + expect(onSelect).toHaveBeenCalled(); + }); + + it('should call onOpenDetails when a stackscript details button is clicked', async () => { + const stackscript = stackScriptFactory.build(); + const onOpenDetails = vi.fn(); + + const { getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + const detailsButton = getByText('Show Details'); + + await userEvent.click(detailsButton); + + expect(onOpenDetails).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx new file mode 100644 index 00000000000..b81d62f824e --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx @@ -0,0 +1,69 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React from 'react'; + +import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; +import { Radio } from 'src/components/Radio/Radio'; +import { Stack } from 'src/components/Stack'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { Typography } from 'src/components/Typography'; +import { truncate } from 'src/utilities/truncate'; + +import type { StackScript } from '@linode/api-v4'; + +interface Props { + disabled?: boolean; + isSelected: boolean; + onOpenDetails: () => void; + onSelect?: () => void; + stackscript: StackScript; +} + +export const StackScriptSelectionRow = (props: Props) => { + const { disabled, isSelected, onOpenDetails, onSelect, stackscript } = props; + + // Never show LKE StackScripts. We try to hide these from the user, even though they + // are returned by the API. + if (stackscript.username.startsWith('lke-service-account-')) { + return null; + } + + return ( + + + + + + + + + + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.test.tsx new file mode 100644 index 00000000000..c81b02dc195 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { StackScripts } from './StackScripts'; + +describe('StackScripts', () => { + it('should render a StackScript section', () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByText('Account StackScripts')).toBeVisible(); + expect(getByText('Community StackScripts')).toBeVisible(); + }); + + it('should render an Image section', () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByText('Select an Image')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx new file mode 100644 index 00000000000..3df32c14ba2 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Stack } from 'src/components/Stack'; + +import { StackScriptImages } from './StackScriptImages'; +import { StackScriptSelection } from './StackScriptSelection'; +import { UserDefinedFields } from './UserDefinedFields/UserDefinedFields'; + +export const StackScripts = () => { + return ( + + + + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.test.tsx new file mode 100644 index 00000000000..d9fd7068fa0 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.test.tsx @@ -0,0 +1,113 @@ +import { UserDefinedField } from '@linode/api-v4'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { UserDefinedFieldInput } from './UserDefinedFieldInput'; + +describe('UserDefinedFieldInput', () => { + it('should render a TextField for a required UDF', () => { + const udf: UserDefinedField = { + label: 'Username', + name: 'username', + }; + + const { getByLabelText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByLabelText('Username (required)')).toBeVisible(); + }); + + it('should render a TextField for an optional UDF', () => { + const udf: UserDefinedField = { + default: 'admin', + label: 'Username', + name: 'username', + }; + + const { getByLabelText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByLabelText('Username')).toBeVisible(); + }); + + it('should render helper text', () => { + const udf: UserDefinedField = { + example: 'This is a username', + label: 'Username', + name: 'username', + }; + + const { getByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByText('This is a username')).toBeVisible(); + }); + + it('should render a radio when oneOf is defined with 4 or less items', () => { + const udf: UserDefinedField = { + label: 'Protocol', + name: 'protocol', + oneof: 'tcp,http,imap,ftp', + }; + + const { getByLabelText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByLabelText('tcp')).toHaveRole('radio'); + expect(getByLabelText('http')).toHaveRole('radio'); + expect(getByLabelText('imap')).toHaveRole('radio'); + expect(getByLabelText('ftp')).toHaveRole('radio'); + }); + + it('should render a select when oneOf is defined with > 4 items', async () => { + const udf: UserDefinedField = { + default: 'tcp', + label: 'Protocol', + name: 'protocol', + oneof: 'tcp,http,imap,ftp,telnet', + }; + + const { getByLabelText, getByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + const select = getByLabelText('Protocol'); + + await userEvent.click(select); + + expect(getByText('tcp')).toBeVisible(); + expect(getByText('http')).toBeVisible(); + expect(getByText('imap')).toBeVisible(); + expect(getByText('ftp')).toBeVisible(); + expect(getByText('telnet')).toBeVisible(); + }); + + it('should render a multi-select when manyOf is defined', async () => { + const udf: UserDefinedField = { + default: 'tcp', + label: 'Protocol', + manyof: 'tcp,http,imap,ftp,telnet', + name: 'protocol', + }; + + const { getByLabelText, getByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + const select = getByLabelText('Protocol'); + + await userEvent.click(select); + + expect(getByText('tcp')).toBeVisible(); + expect(getByText('http')).toBeVisible(); + expect(getByText('imap')).toBeVisible(); + expect(getByText('ftp')).toBeVisible(); + expect(getByText('telnet')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.tsx new file mode 100644 index 00000000000..b61b0f8aa94 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { useController, useFormContext } from 'react-hook-form'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Divider } from 'src/components/Divider'; +import { FormControl } from 'src/components/FormControl'; +import { FormControlLabel } from 'src/components/FormControlLabel'; +import { FormLabel } from 'src/components/FormLabel'; +import { Link } from 'src/components/Link'; +import PasswordInput from 'src/components/PasswordInput/PasswordInput'; +import { Radio } from 'src/components/Radio/Radio'; +import { RadioGroup } from 'src/components/RadioGroup'; +import { Stack } from 'src/components/Stack'; +import { TextField } from 'src/components/TextField'; +import { Typography } from 'src/components/Typography'; + +import { + getIsUDFHeader, + getIsUDFMultiSelect, + getIsUDFPasswordField, + getIsUDFRequired, + getIsUDFSingleSelect, +} from './utilities'; + +import type { CreateLinodeRequest, UserDefinedField } from '@linode/api-v4'; + +interface Props { + userDefinedField: UserDefinedField; +} + +export const UserDefinedFieldInput = ({ userDefinedField }: Props) => { + const isRequired = getIsUDFRequired(userDefinedField); + + const { control, formState } = useFormContext(); + + const { field } = useController({ + control, + name: `stackscript_data.${userDefinedField.name}`, + }); + + const error = formState.errors?.[userDefinedField.name]?.message?.replace( + 'the UDF', + '' + ); + + if (getIsUDFHeader(userDefinedField)) { + return ( + + + {userDefinedField.label} + + ); + } + + if (getIsUDFMultiSelect(userDefinedField)) { + const options = userDefinedField + .manyof!.split(',') + .map((option) => ({ label: option })); + + return ( + field.onChange(options.join(','))} + options={options} + value={field.value?.split(',') ?? []} + /> + ); + } + + if (getIsUDFSingleSelect(userDefinedField)) { + const options = userDefinedField + .oneof!.split(',') + .map((option) => ({ label: option })); + + if (options.length > 4) { + return ( + field.onChange(option.label)} + options={options} + value={options.find((option) => option.label === field.value)} + /> + ); + } + + return ( + + + {userDefinedField.label} + + + {options.map((option) => ( + } + key={option.label} + label={option.label} + onChange={() => field.onChange(option.label)} + /> + ))} + + + ); + } + + if (getIsUDFPasswordField(userDefinedField)) { + const isTokenPassword = userDefinedField.name === 'token_password'; + return ( + + {' '} + To create an API token, go to{' '} + your profile. + + ) : undefined + } + errorText={error} + label={userDefinedField.label} + noMarginTop + onChange={(e) => field.onChange(e.target.value)} + placeholder={isTokenPassword ? 'Enter your token' : 'Enter a password.'} + required={isRequired} + tooltipInteractive={isTokenPassword} + value={field.value ?? ''} + /> + ); + } + + return ( + field.onChange(e.target.value)} + required={isRequired} + value={field.value ?? ''} + /> + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.test.tsx new file mode 100644 index 00000000000..35097a438ed --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import { stackScriptFactory } from 'src/factories'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { UserDefinedFields } from './UserDefinedFields'; + +import type { CreateLinodeRequest } from '@linode/api-v4'; + +describe('UserDefinedFields', () => { + it('should render UDFs for the selected StackScript', async () => { + const stackscript = stackScriptFactory.build({ + user_defined_fields: [ + { + label: 'Server Username', + name: 'username', + }, + { + label: 'Server Password', + name: 'password', + }, + { + label: 'Protocol', + name: 'protocol', + oneof: 'tcp', + }, + { + label: 'User Type', + manyof: 'admin,mod,normal', + name: 'user_type', + }, + { + default: 'password123', + label: 'Admin Password', + name: 'admin_password', + }, + ], + }); + + server.use( + http.get('*/linode/stackscripts/:id', () => { + return HttpResponse.json(stackscript); + }) + ); + + const { + findByLabelText, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { stackscript_id: stackscript.id }, + }, + }); + + for (const udf of stackscript.user_defined_fields) { + // eslint-disable-next-line no-await-in-loop + await findByLabelText(udf.label, { exact: false }); + } + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.tsx new file mode 100644 index 00000000000..bbb5c03c393 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.tsx @@ -0,0 +1,79 @@ +import { CreateLinodeRequest } from '@linode/api-v4'; +import React from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import { Box } from 'src/components/Box'; +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { ShowMoreExpansion } from 'src/components/ShowMoreExpansion'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { useStackScriptQuery } from 'src/queries/stackscripts'; + +import { UserDefinedFieldInput } from './UserDefinedFieldInput'; +import { separateUDFsByRequiredStatus } from './utilities'; + +export const UserDefinedFields = () => { + const stackscriptId = useWatch({ + name: 'stackscript_id', + }); + + const { formState } = useFormContext(); + + const hasStackscriptSelected = + stackscriptId !== null && stackscriptId !== undefined; + + const { data: stackscript } = useStackScriptQuery( + stackscriptId ?? -1, + hasStackscriptSelected + ); + + const userDefinedFields = stackscript?.user_defined_fields; + + const [requiredUDFs, optionalUDFs] = separateUDFsByRequiredStatus( + userDefinedFields + ); + + if (!stackscript || userDefinedFields?.length === 0) { + return null; + } + + return ( + + + {stackscript.label} Setup + {formState.errors.stackscript_data && ( + + )} + + {requiredUDFs.map((field) => ( + + ))} + + + {optionalUDFs.length !== 0 && ( + + + + These fields are additional configuration options and are not + required for creation. + + + {optionalUDFs.map((field) => ( + + ))} + + + + )} + + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.test.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.test.ts new file mode 100644 index 00000000000..e772afa65bb --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.test.ts @@ -0,0 +1,156 @@ +import { + getDefaultUDFData, + getIsUDFHeader, + getIsUDFMultiSelect, + getIsUDFPasswordField, + getIsUDFSingleSelect, + separateUDFsByRequiredStatus, +} from './utilities'; + +import type { UserDefinedField } from '@linode/api-v4'; + +describe('separateUDFsByRequiredStatus', () => { + it('should seperate udfs by required', () => { + const requiredUserDefinedField: UserDefinedField = { + label: 'Server Username', + name: 'username', + }; + + const optionalUserDefinedField: UserDefinedField = { + default: 'password', + label: 'Server Password', + name: 'password', + }; + + expect( + separateUDFsByRequiredStatus([ + optionalUserDefinedField, + requiredUserDefinedField, + ]) + ).toStrictEqual([[requiredUserDefinedField], [optionalUserDefinedField]]); + }); +}); + +describe('getDefaultUDFData', () => { + it('should return key-value pairs for values with defaults', () => { + const udfs: UserDefinedField[] = [ + { + default: 'admin', + label: 'Server Username', + name: 'username', + }, + { + label: 'Server Password', + name: 'password', + }, + ]; + + expect(getDefaultUDFData(udfs)).toStrictEqual({ + username: 'admin', + }); + }); +}); + +describe('getIsUDFHeader', () => { + it('should return true if UDF has header with value yes', () => { + const udf: UserDefinedField = { + header: 'yes', + label: 'Server Username', + name: 'username', + }; + + expect(getIsUDFHeader(udf)).toBe(true); + }); + + it('should return false if UDF has header with value no', () => { + const udf: UserDefinedField = { + header: 'no', + label: 'Server Username', + name: 'username', + }; + + expect(getIsUDFHeader(udf)).toBe(false); + }); + + it('should return false if UDF has no header value', () => { + const udf: UserDefinedField = { + label: 'Server Username', + name: 'username', + }; + + expect(getIsUDFHeader(udf)).toBe(false); + }); +}); + +describe('getIsUDFMultiSelect', () => { + it('should return true if UDF has a manyof value', () => { + const udf: UserDefinedField = { + label: 'Server Username', + manyof: 'php,js,go', + name: 'username', + }; + + expect(getIsUDFMultiSelect(udf)).toBe(true); + }); + + it('should return false if UDF has no manyof value', () => { + const udf: UserDefinedField = { + label: 'Server Username', + name: 'username', + }; + + expect(getIsUDFMultiSelect(udf)).toBe(false); + }); +}); + +describe('getIsUDFSingleSelect', () => { + it('should return true if UDF has a oneOf value', () => { + const udf: UserDefinedField = { + label: 'Server Username', + name: 'username', + oneof: 'php,js,go', + }; + + expect(getIsUDFSingleSelect(udf)).toBe(true); + }); + + it('should return false if UDF has no oneOf value', () => { + const udf: UserDefinedField = { + label: 'Server Username', + manyof: 'omg', + name: 'username', + }; + + expect(getIsUDFSingleSelect(udf)).toBe(false); + }); +}); + +describe('getIsUDFPasswordField', () => { + it('should return true if UDF has password in the name (any capitalization)', () => { + const udf: UserDefinedField = { + label: 'Server Username', + name: 'PasSwOrd', + }; + + expect(getIsUDFPasswordField(udf)).toBe(true); + }); + + it('should return true if UDF has password in the name', () => { + const udf: UserDefinedField = { + label: 'Server Username', + name: 'password', + }; + + expect(getIsUDFPasswordField(udf)).toBe(true); + }); + + it('should return false if UDF does not have password in the name', () => { + const udf: UserDefinedField = { + label: 'Server Username', + manyof: 'omg', + name: 'username', + }; + + expect(getIsUDFPasswordField(udf)).toBe(false); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.ts new file mode 100644 index 00000000000..c72c4f43fbc --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.ts @@ -0,0 +1,67 @@ +import type { UserDefinedField } from '@linode/api-v4'; + +/** + * Used to separate required UDFs from non-required ones + * + * @returns nested array [[...requiredUDFs], [...nonRequiredUDFs]] + */ +export const separateUDFsByRequiredStatus = (udfs: UserDefinedField[] = []) => { + return udfs.reduce( + (accum, udf) => { + if (getIsUDFRequired(udf)) { + return [[...accum[0], udf], accum[1]]; + } else { + return [accum[0], [...accum[1], udf]]; + } + }, + [[], []] + ); +}; + +/** + * @returns true if a User Defined Field should be considered required + */ +export const getIsUDFRequired = (udf: UserDefinedField) => + !udf.hasOwnProperty('default') || udf.hasOwnProperty('required'); + +/** + * Given an array of User Defined Fields, this returns an object of + * key-value pairs based on the default values. + */ +export const getDefaultUDFData = ( + userDefinedFields: UserDefinedField[] +): Record => + userDefinedFields.reduce((accum, eachField) => { + if (eachField.default) { + accum[eachField.name] = eachField.default; + } + return accum; + }, {}); + +/** + * @returns true if a user defined field should be treated as a password + */ +export const getIsUDFPasswordField = (udf: UserDefinedField) => { + return udf.name.toLowerCase().includes('password'); +}; + +/** + * @returns true if a user defined field should be treated as a single select + */ +export const getIsUDFSingleSelect = (udf: UserDefinedField) => { + return !!udf.oneof; +}; + +/** + * @returns true if a user defined field should be treated as a multi-select + */ +export const getIsUDFMultiSelect = (udf: UserDefinedField) => { + return !!udf.manyof; +}; + +/** + * @returns true if a user defined field should be treated as a header + */ +export const getIsUDFHeader = (udf: UserDefinedField) => { + return udf.header?.toLowerCase() === 'yes'; +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.test.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.test.ts new file mode 100644 index 00000000000..6a3831ee9b2 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.test.ts @@ -0,0 +1,17 @@ +import { getStackScriptTabIndex } from './utilities'; + +describe('getStackScriptTabIndex', () => { + it('should return 0 for Account', () => { + expect(getStackScriptTabIndex('Account')).toBe(0); + }); + it('should return 1 for Community', () => { + expect(getStackScriptTabIndex('Community')).toBe(1); + }); + it('should return 0 for an unexpected value', () => { + // @ts-expect-error intentionally passing an unexpected value + expect(getStackScriptTabIndex('hey')).toBe(0); + }); + it('should return 0 for undefined (default to first tab)', () => { + expect(getStackScriptTabIndex(undefined)).toBe(0); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.ts new file mode 100644 index 00000000000..41bb6d1c721 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.ts @@ -0,0 +1,41 @@ +export type StackScriptTabType = 'Account' | 'Community'; + +export const tabs = ['Account', 'Community'] as const; + +/** + * Returns the index of the currently selected StackScripts tab + * + * @param tab the current tab. Currently, this value comes from 'subtype' query param on the Linode Create flow. + * @returns the index of the selected tab + */ +export const getStackScriptTabIndex = (tab: StackScriptTabType | undefined) => { + if (tab === undefined) { + return 0; + } + + const tabIndex = tabs.indexOf(tab); + + if (tabIndex === -1) { + return 0; + } + + return tabIndex; +}; + +/** + * API filter for fetching community StackScripts + * + * We omit some usernames so that Marketplace StackScripts don't show up. + */ +export const communityStackScriptFilter = { + '+and': [ + { username: { '+neq': 'linode' } }, + { username: { '+neq': 'linode-stackscripts' } }, + ], + mine: false, +}; + +/** + * API filter for fetching account StackScripts + */ +export const accountStackScriptFilter = { mine: true }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx index 982c57bb7b1..e570a4f0013 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx @@ -80,22 +80,24 @@ export const UserData = () => { ( + onBlur={(e) => { + field.onBlur(); checkFormat({ hasInputValueChanged: false, userData: e.target.value, - }) - } + }); + }} onChange={(e) => { + field.onChange(e); checkFormat({ hasInputValueChanged: true, userData: e.target.value, }); - field.onChange(e); }} disabled={isLinodeCreateRestricted} errorText={fieldState.error?.message} expand + inputRef={field.ref} label="User Data" labelTooltipText="Compatible formats include cloud-config data and executable scripts." multiline diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx index 6dc1ee78999..1cae85da5ed 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx @@ -86,8 +86,9 @@ export const VLAN = () => { disabled={disabled} errorText={fieldState.error?.message} filter={{ region: regionId }} + onBlur={field.onBlur} onChange={field.onChange} - sx={{ minWidth: 300 }} + sx={{ width: 300 }} value={field.value ?? null} /> )} @@ -100,13 +101,15 @@ export const VLAN = () => { tooltipText={ 'IPAM address must use IP/netmask format, e.g. 192.0.2.0/24.' } + containerProps={{ maxWidth: 335 }} disabled={disabled} errorText={fieldState.error?.message} + inputRef={field.ref} label="IPAM Address" + onBlur={field.onBlur} onChange={field.onChange} optional placeholder="192.0.2.0/24" - sx={{ maxWidth: 300 }} value={field.value ?? ''} /> )} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.test.tsx new file mode 100644 index 00000000000..eb193d24815 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.test.tsx @@ -0,0 +1,142 @@ +import React from 'react'; + +import { regionFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { VPC } from './VPC'; + +import type { CreateLinodeRequest } from '@linode/api-v4'; + +describe('VPC', () => { + it('renders a heading', () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + const heading = getByText('VPC'); + + expect(heading).toBeVisible(); + expect(heading.tagName).toBe('H2'); + }); + + it('disables the VPC select if no region is selected', () => { + const { getByLabelText } = renderWithThemeAndHookFormContext({ + component: , + }); + + const vpcSelect = getByLabelText('Assign VPC'); + + expect(vpcSelect).toBeVisible(); + expect(vpcSelect).toBeDisabled(); + }); + + it('renders a warning if the selected region does not support VPC', async () => { + const region = regionFactory.build({ capabilities: [] }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + + const { + findByText, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { defaultValues: { region: region.id } }, + }); + + await findByText('VPC is not available in the selected region.'); + }); + + it('renders a subnet select if a VPC is selected', async () => { + const { + getByLabelText, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + interfaces: [{ vpc_id: 4 }, {}, {}], + region: 'fake-region', + }, + }, + }); + + expect(getByLabelText('Subnet')).toBeVisible(); + }); + + it('renders VPC IPv4, NAT checkboxes, and IP Ranges inputs when a subnet is selected', async () => { + const { + getByLabelText, + getByText, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + interfaces: [{ subnet_id: 5, vpc_id: 4 }, {}, {}], + region: 'fake-region', + }, + }, + }); + + expect( + getByLabelText( + 'Auto-assign a VPC IPv4 address for this Linode in the VPC' + ) + ).toBeInTheDocument(); + + expect( + getByLabelText('Assign a public IPv4 address for this Linode') + ).toBeInTheDocument(); + + expect(getByText('Assign additional IPv4 ranges')).toBeInTheDocument(); + }); + + it('should check the VPC IPv4 if a "ipv4.vpc" is null/undefined', async () => { + const { + getByLabelText, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + interfaces: [ + { ipv4: { vpc: undefined }, subnet_id: 5, vpc_id: 4 }, + {}, + {}, + ], + region: 'fake-region', + }, + }, + }); + + expect( + getByLabelText( + 'Auto-assign a VPC IPv4 address for this Linode in the VPC' + ) + ).toBeChecked(); + }); + + it('should uncheck the VPC IPv4 if a "ipv4.vpc" is a string value and show the VPC IP TextField', async () => { + const { + getByLabelText, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + interfaces: [{ ipv4: { vpc: '' }, subnet_id: 5, vpc_id: 4 }, {}, {}], + region: 'fake-region', + }, + }, + }); + + expect( + getByLabelText( + 'Auto-assign a VPC IPv4 address for this Linode in the VPC' + ) + ).not.toBeChecked(); + + expect(getByLabelText('VPC IPv4 (required)')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx new file mode 100644 index 00000000000..6938cf80562 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx @@ -0,0 +1,272 @@ +import React, { useState } from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Box } from 'src/components/Box'; +import { Checkbox } from 'src/components/Checkbox'; +import { Divider } from 'src/components/Divider'; +import { FormControlLabel } from 'src/components/FormControlLabel'; +import { Link } from 'src/components/Link'; +import { LinkButton } from 'src/components/LinkButton'; +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { Stack } from 'src/components/Stack'; +import { TextField } from 'src/components/TextField'; +import { TooltipIcon } from 'src/components/TooltipIcon'; +import { Typography } from 'src/components/Typography'; +import { VPCSelect } from 'src/components/VPCSelect'; +import { VPC_AUTO_ASSIGN_IPV4_TOOLTIP } from 'src/features/VPCs/constants'; +import { inputMaxWidth } from 'src/foundations/themes/light'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import { useVPCQuery, useVPCsQuery } from 'src/queries/vpcs/vpcs'; +import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature'; + +import { REGION_CAVEAT_HELPER_TEXT } from '../../LinodesCreate/constants'; +import { VPCCreateDrawer } from '../../LinodesCreate/VPCCreateDrawer'; +import { VPCRanges } from './VPCRanges'; + +import type { CreateLinodeRequest } from '@linode/api-v4'; + +export const VPC = () => { + const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false); + + const { + control, + formState, + setValue, + } = useFormContext(); + + const { data: regions } = useRegionsQuery(); + + const [ + regionId, + selectedVPCId, + selectedSubnetId, + linodeVPCIPAddress, + ] = useWatch({ + control, + name: [ + 'region', + 'interfaces.0.vpc_id', + 'interfaces.0.subnet_id', + 'interfaces.0.ipv4.vpc', + ], + }); + + const regionSupportsVPCs = doesRegionSupportFeature( + regionId, + regions ?? [], + 'VPCs' + ); + + const { data: selectedVPC } = useVPCQuery( + selectedVPCId ?? -1, + Boolean(selectedVPCId) + ); + + // This is here only to determine which copy to show... + const { data } = useVPCsQuery({}, { region: regionId }, regionSupportsVPCs); + + const copy = + data?.results === 0 + ? 'Allow Linode to communicate in an isolated environment.' + : 'Assign this Linode to an existing VPC.'; + + return ( + + + VPC + + {copy}{' '} + + Learn more. + + + + ( + ({ + [theme.breakpoints.up('sm')]: { minWidth: inputMaxWidth }, + }), + tooltipText: REGION_CAVEAT_HELPER_TEXT, + }} + disabled={!regionSupportsVPCs} + errorText={fieldState.error?.message} + filter={{ region: regionId }} + label="Assign VPC" + noMarginTop + onChange={(e, vpc) => field.onChange(vpc?.id ?? null)} + placeholder="None" + value={field.value ?? null} + /> + )} + control={control} + name="interfaces.0.vpc_id" + /> + {regionId && regionSupportsVPCs && ( + + setIsCreateDrawerOpen(true)}> + Create VPC + + + )} + {selectedVPCId && ( + <> + ( + + `${subnet.label} (${subnet.ipv4})` + } + textFieldProps={{ + inputRef: field.ref, + }} + value={ + selectedVPC?.subnets.find( + (subnet) => subnet.id === field.value + ) ?? null + } + errorText={fieldState.error?.message} + label="Subnet" + noMarginTop + onBlur={field.onBlur} + onChange={(e, subnet) => field.onChange(subnet?.id ?? null)} + options={selectedVPC?.subnets ?? []} + placeholder="Select Subnet" + /> + )} + control={control} + name="interfaces.0.subnet_id" + /> + {selectedSubnetId && ( + <> + + ( + + + + Auto-assign a VPC IPv4 address for this Linode + in the VPC + + + + } + onChange={(e, checked) => + // If "Auto-assign" is checked, set the VPC IP to null + // so that it gets auto-assigned. Otherwise, set it to + // an empty string so that the TextField renders and a + // user can enter one. + field.onChange(checked ? null : '') + } + control={} + /> + + )} + control={control} + name="interfaces.0.ipv4.vpc" + /> + {linodeVPCIPAddress !== null && + linodeVPCIPAddress !== undefined && ( + ( + + )} + control={control} + name="interfaces.0.ipv4.vpc" + /> + )} + ( + + + Assign a public IPv4 address for this Linode + + + + } + onChange={(e, checked) => + field.onChange(checked ? 'any' : null) + } + checked={field.value === 'any'} + control={} + sx={{ mt: 0 }} + /> + )} + control={control} + name="interfaces.0.ipv4.nat_1_1" + /> + + + theme.font.bold}> + Assign additional IPv4 ranges + + {formState.errors.interfaces?.[1]?.ip_ranges?.message && ( + + )} + + Assign additional IPv4 address ranges that the VPC can use + to reach services running on this Linode.{' '} + + Learn more + + . + + {formState.errors.interfaces?.[0]?.ip_ranges?.message && ( + + )} + + + )} + + )} + + + setValue('interfaces.0.vpc_id', vpcId)} + onClose={() => setIsCreateDrawerOpen(false)} + open={isCreateDrawerOpen} + selectedRegion={regionId} + /> + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPCRanges.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPCRanges.test.tsx new file mode 100644 index 00000000000..dbef2ec3c52 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPCRanges.test.tsx @@ -0,0 +1,82 @@ +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { VPCRanges } from './VPCRanges'; + +describe('VPCRanges', () => { + it('renders IP ranges from form data', () => { + const { + getByDisplayValue, + getByLabelText, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + interfaces: [ + { ip_ranges: ['192.168.1.1/24'], subnet_id: 5, vpc_id: 4 }, + {}, + {}, + ], + region: 'fake-region', + }, + }, + }); + + expect(getByDisplayValue('192.168.1.1/24')).toBeVisible(); + expect(getByLabelText('Remove IP Range 0')).toBeVisible(); + }); + + it('can add an IP range', async () => { + const { + getByPlaceholderText, + getByText, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + interfaces: [{ ip_ranges: [], subnet_id: 5, vpc_id: 4 }, {}, {}], + region: 'fake-region', + }, + }, + }); + + const addButton = getByText('Add IPv4 Range'); + + expect(addButton).toBeVisible(); + expect(addButton).toBeEnabled(); + + await userEvent.click(addButton); + + expect(getByPlaceholderText('10.0.0.0/24')).toBeVisible(); + }); + + it('can remove an IP range', async () => { + const { + getByLabelText, + queryByDisplayValue, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + interfaces: [ + { ip_ranges: ['192.168.1.1/24'], subnet_id: 5, vpc_id: 4 }, + {}, + {}, + ], + region: 'fake-region', + }, + }, + }); + + const removeButton = getByLabelText('Remove IP Range 0'); + + expect(removeButton).toBeVisible(); + expect(removeButton).toBeEnabled(); + + await userEvent.click(removeButton); + + expect(queryByDisplayValue('192.168.1.1/24')).toBeNull(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPCRanges.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPCRanges.tsx new file mode 100644 index 00000000000..fedb35aa619 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPCRanges.tsx @@ -0,0 +1,61 @@ +import CloseIcon from '@mui/icons-material/Close'; +import React from 'react'; +import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; + +import { Box } from 'src/components/Box'; +import { IconButton } from 'src/components/IconButton'; +import { LinkButton } from 'src/components/LinkButton'; +import { Stack } from 'src/components/Stack'; +import { TextField } from 'src/components/TextField'; + +import type { CreateLinodeRequest } from '@linode/api-v4'; + +export const VPCRanges = () => { + const { control } = useFormContext(); + + const { append, fields, remove } = useFieldArray({ + control, + name: 'interfaces.0.ip_ranges', + }); + + return ( + + {fields.map((field, index) => ( + + ( + + )} + control={control} + name={`interfaces.0.ip_ranges.${index}`} + /> + remove(index)} + sx={{ padding: 0.75 }} + > + + + + ))} + + append('')}>Add IPv4 Range + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index 59675e6923a..a0b609a9237 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { useHistory } from 'react-router-dom'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { Stack } from 'src/components/Stack'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; @@ -22,21 +23,30 @@ import { Region } from './Region'; import { Summary } from './Summary'; import { Distributions } from './Tabs/Distributions'; import { Images } from './Tabs/Images'; +import { StackScripts } from './Tabs/StackScripts/StackScripts'; import { UserData } from './UserData/UserData'; import { defaultValues, + defaultValuesMap, getLinodeCreatePayload, getTabIndex, + resolver, tabs, useLinodeCreateQueryParams, } from './utilities'; import { VLAN } from './VLAN'; +import { VPC } from './VPC/VPC'; import type { CreateLinodeRequest } from '@linode/api-v4'; import type { SubmitHandler } from 'react-hook-form'; export const LinodeCreatev2 = () => { - const methods = useForm({ defaultValues }); + const methods = useForm({ + defaultValues, + mode: 'onBlur', + resolver, + }); + const history = useHistory(); const { mutateAsync: createLinode } = useCreateLinodeMutation(); @@ -59,12 +69,21 @@ export const LinodeCreatev2 = () => { } }; - const { params, updateParams } = useLinodeCreateQueryParams(); + const { params, setParams } = useLinodeCreateQueryParams(); const currentTabIndex = getTabIndex(params.type); + const onTabChange = (index: number) => { + const newTab = tabs[index]; + // Update tab "type" query param. (This changes the selected tab) + setParams({ type: newTab }); + // Reset the form values + methods.reset(defaultValuesMap[newTab]); + }; + return ( + {
- updateParams({ type: tabs[index] })} - > + Distributions Marketplace @@ -90,7 +106,9 @@ export const LinodeCreatev2 = () => { Marketplace - StackScripts + + + @@ -102,6 +120,7 @@ export const LinodeCreatev2 = () => {
+ diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.test.tsx index 7027a99ad1d..b08528c0f79 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.test.tsx @@ -24,7 +24,7 @@ describe('getLinodeCreatePayload', () => { it('should return a basic payload', () => { const values = createLinodeRequestFactory.build(); - expect(getLinodeCreatePayload(values)).toStrictEqual(values); + expect(getLinodeCreatePayload(values)).toEqual(values); }); it('should base64 encode metadata', () => { @@ -32,48 +32,271 @@ describe('getLinodeCreatePayload', () => { metadata: { user_data: userData }, }); - expect(getLinodeCreatePayload(values)).toStrictEqual({ + expect(getLinodeCreatePayload(values)).toEqual({ ...values, metadata: { user_data: base64UserData }, }); }); + + it('should remove placement_group from the payload if no id exists', () => { + const values = createLinodeRequestFactory.build({ + placement_group: {}, + }); + + expect(getLinodeCreatePayload(values)).toEqual({ + ...values, + placement_group: undefined, + }); + }); }); describe('getInterfacesPayload', () => { - it('should remove a VLAN from the payload if the label is empty', () => { + it('should return undefined when there is no vpc or vlan', () => { expect( - getInterfacesPayload([ - { - ipam_address: '', - label: '', - purpose: 'vlan', - }, - ]) - ).toStrictEqual([]); + getInterfacesPayload( + [ + { + ipam_address: '', + label: '', + purpose: 'vpc', + }, + { + ipam_address: '', + label: '', + purpose: 'vlan', + }, + { + ipam_address: '', + label: '', + purpose: 'public', + }, + ], + false + ) + ).toStrictEqual(undefined); }); - it('should remove a VPC from the payload if the id is not set', () => { + it('should return a public interface and a VLAN interface when a VLAN is selected', () => { expect( - getInterfacesPayload([ - { - ipam_address: '', - label: '', - purpose: 'vpc', - vpc_id: null, - }, - ]) - ).toStrictEqual([]); + getInterfacesPayload( + [ + { + ipam_address: '', + label: '', + purpose: 'vpc', + }, + { + ipam_address: '', + label: 'my-vlan', + purpose: 'vlan', + }, + { + ipam_address: '', + label: '', + purpose: 'public', + }, + ], + false + ) + ).toStrictEqual([ + { + ipam_address: '', + label: '', + purpose: 'public', + }, + { + ipam_address: '', + label: 'my-vlan', + purpose: 'vlan', + }, + ]); }); - it('should return undefined if there is only a public interface', () => { + it('should return a public interface and a VLAN interface when a VLAN is selected with a private IP', () => { expect( - getInterfacesPayload([ - { - ipam_address: '', - label: '', - purpose: 'public', - }, - ]) - ).toStrictEqual(undefined); + getInterfacesPayload( + [ + { + ipam_address: '', + label: '', + purpose: 'vpc', + }, + { + ipam_address: '', + label: 'my-vlan', + purpose: 'vlan', + }, + { + ipam_address: '', + label: '', + purpose: 'public', + }, + ], + true + ) + ).toStrictEqual([ + { + ipam_address: '', + label: '', + purpose: 'public', + }, + { + ipam_address: '', + label: 'my-vlan', + purpose: 'vlan', + }, + ]); + }); + + it('should return a VPC interface if only a VPC is selected', () => { + expect( + getInterfacesPayload( + [ + { + ipam_address: '', + label: '', + purpose: 'vpc', + vpc_id: 5, + }, + { + ipam_address: '', + label: '', + purpose: 'vlan', + }, + { + ipam_address: '', + label: '', + purpose: 'public', + }, + ], + false + ) + ).toStrictEqual([ + { + ipam_address: '', + label: '', + purpose: 'vpc', + vpc_id: 5, + }, + ]); + }); + + it('should return a VPC interface and a public interface if a VPC is selected and Private IP is enabled', () => { + expect( + getInterfacesPayload( + [ + { + ipam_address: '', + label: '', + purpose: 'vpc', + vpc_id: 5, + }, + { + ipam_address: '', + label: '', + purpose: 'vlan', + }, + { + ipam_address: '', + label: '', + purpose: 'public', + }, + ], + true + ) + ).toStrictEqual([ + { + ipam_address: '', + label: '', + purpose: 'vpc', + vpc_id: 5, + }, + { + ipam_address: '', + label: '', + purpose: 'public', + }, + ]); + }); + + it('should return a VPC interface and a VLAN interface when both are specified (no private IP)', () => { + expect( + getInterfacesPayload( + [ + { + ipam_address: '', + label: '', + purpose: 'vpc', + vpc_id: 5, + }, + { + ipam_address: '', + label: 'my-vlan', + purpose: 'vlan', + }, + { + ipam_address: '', + label: '', + purpose: 'public', + }, + ], + false + ) + ).toStrictEqual([ + { + ipam_address: '', + label: '', + purpose: 'vpc', + vpc_id: 5, + }, + { + ipam_address: '', + label: 'my-vlan', + purpose: 'vlan', + }, + ]); + }); + + it('should return a VPC, VLAN, and Public interface if a VPC is selcted, a VLAN is selected, and Private IP is enabled', () => { + expect( + getInterfacesPayload( + [ + { + ipam_address: '', + label: '', + purpose: 'vpc', + vpc_id: 5, + }, + { + ipam_address: '', + label: 'my-vlan', + purpose: 'vlan', + }, + { + ipam_address: '', + label: '', + purpose: 'public', + }, + ], + true + ) + ).toStrictEqual([ + { + ipam_address: '', + label: '', + purpose: 'vpc', + vpc_id: 5, + }, + { + ipam_address: '', + label: 'my-vlan', + purpose: 'vlan', + }, + { + ipam_address: '', + label: '', + purpose: 'public', + }, + ]); }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index 01da7ce0b23..eef2a322192 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -1,3 +1,6 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { getStackScript } from '@linode/api-v4'; +import { CreateLinodeSchema } from '@linode/validation'; import { useHistory } from 'react-router-dom'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; @@ -5,12 +8,17 @@ import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; import { utoa } from '../LinodesCreate/utilities'; import type { LinodeCreateType } from '../LinodesCreate/types'; +import type { StackScriptTabType } from './Tabs/StackScripts/utilities'; import type { CreateLinodeRequest, InterfacePayload } from '@linode/api-v4'; +import type { Resolver } from 'react-hook-form'; /** * This interface is used to type the query params on the Linode Create flow. */ interface LinodeCreateQueryParams { + imageID: string | undefined; + stackScriptID: string | undefined; + subtype: StackScriptTabType | undefined; type: LinodeCreateType | undefined; } @@ -24,16 +32,42 @@ export const useLinodeCreateQueryParams = () => { const rawParams = getQueryParamsFromQueryString(history.location.search); + /** + * Updates query params + */ const updateParams = (params: Partial) => { - const newParams = new URLSearchParams({ ...rawParams, ...params }); + const newParams = new URLSearchParams(rawParams); + + for (const key in params) { + if (!params[key]) { + newParams.delete(key); + } else { + newParams.set(key, params[key]); + } + } + + history.push({ search: newParams.toString() }); + }; + + /** + * Replaces query params with the provided values + */ + const setParams = (params: Partial) => { + const newParams = new URLSearchParams(params); + history.push({ search: newParams.toString() }); }; const params = { + imageID: rawParams.imageID as string | undefined, + stackScriptID: rawParams.stackScriptID + ? Number(rawParams.stackScriptID) + : undefined, + subtype: rawParams.subtype as StackScriptTabType | undefined, type: rawParams.type as LinodeCreateType | undefined, - } as LinodeCreateQueryParams; + }; - return { params, updateParams }; + return { params, setParams, updateParams }; }; /** @@ -74,74 +108,194 @@ export const tabs: LinodeCreateType[] = [ export const getLinodeCreatePayload = ( payload: CreateLinodeRequest ): CreateLinodeRequest => { - if (payload.metadata?.user_data) { - payload.metadata.user_data = utoa(payload.metadata.user_data); + const values = { ...payload }; + if (values.metadata?.user_data) { + values.metadata.user_data = utoa(values.metadata.user_data); } - if (!payload.metadata?.user_data) { - payload.metadata = undefined; + if (!values.metadata?.user_data) { + values.metadata = undefined; } - payload.interfaces = getInterfacesPayload(payload.interfaces); + if (values.placement_group?.id === undefined) { + values.placement_group = undefined; + } + + values.interfaces = getInterfacesPayload( + values.interfaces, + Boolean(values.private_ip) + ); - return payload; + return values; }; /** - * Performans transformation and ordering on the Linode Create "interfaces" form data. + * Transforms and orders the Linode Create "interfaces" form data. * - * We need this so we can put interfaces in the correct order and omit unused iterfaces. + * We need this so we can put interfaces in the correct order and omit unused interfaces. * * @param interfaces raw interfaces from the Linode create flow form * @returns a transformed interfaces array in the correct order and with the expected values for the API */ export const getInterfacesPayload = ( - interfaces: InterfacePayload[] | undefined + interfaces: InterfacePayload[] | undefined, + hasPrivateIP: boolean | undefined ): InterfacePayload[] | undefined => { if (!interfaces) { return undefined; } - interfaces = interfaces.filter((i) => { - if (i.purpose === 'vpc' && !i.vpc_id) { - // If no vpc was selected, clear remove it from the interfaces array - return false; - } - if (i.purpose === 'vlan' && !i.label) { - // If no VLAN label is specificed, remove it from the interfaces array - return false; - } - return true; - }); + const vpcInterface = interfaces[0]; + const vlanInterface = interfaces[1]; + const publicInterface = interfaces[2]; - if (interfaces.length === 1 && interfaces[0].purpose === 'public') { - // If there is only 1 interface, and it is the public interface, return undefined. - // The API will default to adding a public interface and this makes the payload cleaner. - return undefined; + const hasVPC = Boolean(vpcInterface.vpc_id); + const hasVLAN = Boolean(vlanInterface.label); + + if (hasVPC && hasVLAN && hasPrivateIP) { + return [vpcInterface, vlanInterface, publicInterface]; + } + + if (hasVLAN && hasVPC) { + return [vpcInterface, vlanInterface]; + } + + if (hasVPC && hasPrivateIP) { + return [vpcInterface, publicInterface]; + } + + if (hasVLAN) { + return [publicInterface, vlanInterface]; + } + + if (hasVPC) { + return [vpcInterface]; } - return interfaces; + // If no special case is met, don't send `interfaces` in the Linode + // create payload. This will cause the API to default to giving the Linode + // public communication. + return undefined; +}; + +const defaultVPCInterface = { + ipam_address: '', + label: '', + purpose: 'vpc', +} as const; + +const defaultVLANInterface = { + ipam_address: '', + label: '', + purpose: 'vlan', +} as const; + +const defaultPublicInterface = { + ipam_address: '', + label: '', + purpose: 'public', +} as const; + +/** + * This function initializes the Linode Create flow form + * when the form mounts. + * + * The default values are dependent on the query params present. + */ +export const defaultValues = async (): Promise => { + const queryParams = getQueryParamsFromQueryString(window.location.search); + + const stackScriptID = queryParams.stackScriptID + ? Number(queryParams.stackScriptID) + : undefined; + + const stackscript = stackScriptID + ? await getStackScript(stackScriptID) + : null; + + const imageID = queryParams.imageID; + + return { + image: stackScriptID ? imageID : imageID ?? 'linode/debian11', + interfaces: [ + defaultVPCInterface, + defaultVLANInterface, + defaultPublicInterface, + ], + region: '', + stackscript_data: stackscript?.user_defined_fields, + stackscript_id: stackScriptID, + type: '', + }; }; -export const defaultValues: CreateLinodeRequest = { +const defaultValuesForImages = { + interfaces: [ + defaultVPCInterface, + defaultVLANInterface, + defaultPublicInterface, + ], + region: '', + type: '', +}; + +const defaultValuesForDistributions = { image: 'linode/debian11', interfaces: [ - { - ipam_address: '', - label: '', - purpose: 'public', - }, - { - ipam_address: '', - label: '', - purpose: 'vlan', - }, - { - ipam_address: '', - label: '', - purpose: 'vpc', - }, + defaultVPCInterface, + defaultVLANInterface, + defaultPublicInterface, + ], + region: '', + type: '', +}; + +const defaultValuesForStackScripts = { + image: undefined, + interfaces: [ + defaultVPCInterface, + defaultVLANInterface, + defaultPublicInterface, ], region: '', + stackscript_id: null, type: '', }; + +/** + * A map that conatins default values for each Tab of the Linode Create flow. + */ +export const defaultValuesMap: Record = { + Backups: defaultValuesForImages, + 'Clone Linode': defaultValuesForImages, + Distributions: defaultValuesForDistributions, + Images: defaultValuesForImages, + 'One-Click': defaultValuesForImages, + StackScripts: defaultValuesForStackScripts, +}; + +/** + * Provides dynamic validation to the Linode Create form. + * + * Unfortunately, we have to wrap `yupResolver` so that we can transform the payload + * using `getLinodeCreatePayload` before validation happens. + */ +export const resolver: Resolver = async ( + values, + context, + options +) => { + const transformedValues = getLinodeCreatePayload(values); + + const { errors } = await yupResolver( + CreateLinodeSchema, + {}, + { rawValues: true } + )(transformedValues, context, options); + + if (errors) { + return { errors, values }; + } + + return { errors: {}, values }; +}; diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx index e013af49b09..54c368bab8d 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx @@ -9,7 +9,6 @@ import { subnetFactory, vpcFactory, } from 'src/factories'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { queryClientFactory } from 'src/queries/base'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; @@ -54,8 +53,8 @@ describe('Linode Entity Detail', () => { return HttpResponse.json(account); }), - http.get('*/vpcs', () => { - return HttpResponse.json(makeResourcePage([vpc])); + http.get('*/vpcs/:vpcId', () => { + return HttpResponse.json(vpc); }) ); @@ -80,15 +79,11 @@ describe('Linode Entity Detail', () => { linodes: [subnetAssignedLinodeDataFactory.build({ id: linode.id })], }); - const _vpcs = vpcFactory.buildList(3); - const vpcs = [ - ..._vpcs, - vpcFactory.build({ label: 'test-vpc', subnets: [subnet] }), - ]; + const vpc = vpcFactory.build({ label: 'test-vpc', subnets: [subnet] }); server.use( - http.get('*/vpcs', () => { - return HttpResponse.json(makeResourcePage(vpcs)); + http.get('*/vpcs/:vpcId', () => { + return HttpResponse.json(vpc); }) ); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index c83a7f7f317..2d04cad246c 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -7,7 +7,7 @@ import { } from '@linode/api-v4/lib/linodes'; import { Tag } from '@linode/api-v4/lib/tags/types'; import Grid from '@mui/material/Unstable_Grid2'; -import cloneDeep from 'lodash/cloneDeep'; +import cloneDeep from 'lodash.clonedeep'; import * as React from 'react'; import { MapDispatchToProps, connect } from 'react-redux'; import { RouteComponentProps } from 'react-router-dom'; @@ -474,7 +474,7 @@ export class LinodeCreate extends React.PureComponent< (imageIsCloudInitCompatible || linodeIsCloudInitCompatible); const isEdgeRegionSelected = Boolean( - flags.gecko && + flags.gecko2?.enabled && getIsEdgeRegion(regionsData, this.props.selectedRegionID ?? '') ); @@ -861,10 +861,7 @@ export class LinodeCreate extends React.PureComponent< image: this.props.selectedImageID, label: this.props.label, placement_group: - this.props.flags.placementGroups?.enabled && - placement_group_payload.id !== -1 - ? placement_group_payload - : undefined, + placement_group_payload.id !== -1 ? placement_group_payload : undefined, private_ip: this.props.privateIPEnabled, region: this.props.selectedRegionID ?? '', root_pass: this.props.password, diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx index 18be49dc2f1..99a72ee06b7 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -54,7 +54,7 @@ import withAgreements, { } from 'src/features/Account/Agreements/withAgreements'; import { hasPlacementGroupReachedCapacity } from 'src/features/PlacementGroups/utils'; import { reportAgreementSigningError } from 'src/queries/account/agreements'; -import { vpcQueryKey } from 'src/queries/vpcs'; +import { vpcQueries } from 'src/queries/vpcs/vpcs'; import { CreateTypes } from 'src/store/linodeCreate/linodeCreate.actions'; import { MapState } from 'src/store/types'; import { @@ -911,7 +911,11 @@ class LinodeCreateContainer extends React.PureComponent { this.state.selectedVPCId !== undefined && this.state.selectedVPCId !== -1 ) { - this.props.queryClient.invalidateQueries([vpcQueryKey, 'paginated']); + this.props.queryClient.invalidateQueries(vpcQueries.all.queryKey); + this.props.queryClient.invalidateQueries(vpcQueries.paginated._def); + this.props.queryClient.invalidateQueries( + vpcQueries.vpc(this.state.selectedVPCId).queryKey + ); } /** send the user to the Linode detail page */ diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx index 0b5c3fb8a9c..64f953fc780 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx @@ -1,8 +1,7 @@ import { Image } from '@linode/api-v4/lib/images'; import { StackScript, UserDefinedField } from '@linode/api-v4/lib/stackscripts'; import { styled } from '@mui/material/styles'; -import compact from 'lodash/compact'; -import curry from 'lodash/curry'; +import curry from 'lodash.curry'; import { assocPath } from 'ramda'; import * as React from 'react'; @@ -18,6 +17,7 @@ import { AppDetailDrawer } from 'src/features/OneClickApps'; import { oneClickApps } from 'src/features/OneClickApps/oneClickApps'; import UserDefinedFieldsPanel from 'src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel'; import { sendMarketplaceSearchEvent } from 'src/utilities/analytics'; +import { compact } from 'src/utilities/compact'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; import SelectAppPanel from '../SelectAppPanel'; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx index 84e32a98f12..21931f4e159 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx @@ -80,7 +80,7 @@ export const FromLinodeContent = (props: CombinedProps) => { (linode) => !getIsEdgeRegion(regionsData, linode.region) // Hide linodes that are in an edge region ); - const filteredLinodes = flags.gecko + const filteredLinodes = flags.gecko2?.enabled ? filterEdgeLinodes(linodesData) : linodesData; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx index 2c6c834a228..1877b64d4f4 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx @@ -17,7 +17,7 @@ import { Typography } from 'src/components/Typography'; import { VPC_AUTO_ASSIGN_IPV4_TOOLTIP } from 'src/features/VPCs/constants'; import { AssignIPRanges } from 'src/features/VPCs/VPCDetail/AssignIPRanges'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { useVPCsQuery } from 'src/queries/vpcs'; +import { useAllVPCsQuery } from 'src/queries/vpcs/vpcs'; import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; @@ -91,7 +91,7 @@ export const VPCPanel = (props: VPCPanelProps) => { false ); - const { data: vpcData, error, isLoading } = useVPCsQuery({}, {}); + const { data: vpcsData, error, isLoading } = useAllVPCsQuery(); React.useEffect(() => { if (subnetError || vpcIPv4Error) { @@ -99,7 +99,7 @@ export const VPCPanel = (props: VPCPanelProps) => { } }, [subnetError, vpcIPv4Error]); - const vpcs = vpcData?.data ?? []; + const vpcs = vpcsData ?? []; const vpcDropdownOptions: Item[] = vpcs.reduce((accumulator, vpc) => { return vpc.region === region diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/InterfaceListItem.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/InterfaceListItem.tsx index 97bf7e74c23..32659f7420d 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/InterfaceListItem.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/InterfaceListItem.tsx @@ -1,7 +1,7 @@ import { Interface } from '@linode/api-v4/lib/linodes'; import React from 'react'; -import { useVPCQuery } from 'src/queries/vpcs'; +import { useVPCQuery } from 'src/queries/vpcs/vpcs'; interface Props { idx: number; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx index ceaaad2151c..9f934c965fd 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx @@ -50,7 +50,7 @@ import { import { useRegionsQuery } from 'src/queries/regions/regions'; import { vlanQueries } from 'src/queries/vlans'; import { useAllVolumesQuery } from 'src/queries/volumes'; -import { vpcQueryKey } from 'src/queries/vpcs'; +import { vpcQueries } from 'src/queries/vpcs/vpcs'; import { DevicesAsStrings, createDevicesFromStrings, @@ -397,12 +397,14 @@ export const LinodeConfigDialog = (props: Props) => { } // Ensure VPC query data is up-to-date - if ( - configData.interfaces?.some( - (thisInterface) => thisInterface.purpose === 'vpc' - ) - ) { - queryClient.invalidateQueries([vpcQueryKey]); + const vpcId = configData.interfaces?.find( + (thisInterface) => thisInterface.purpose === 'vpc' + )?.vpc_id; + + if (vpcId) { + queryClient.invalidateQueries(vpcQueries.all.queryKey); + queryClient.invalidateQueries(vpcQueries.paginated._def); + queryClient.invalidateQueries(vpcQueries.vpc(vpcId).queryKey); } enqueueSnackbar( diff --git a/packages/manager/src/features/Linodes/LinodesLanding/DeleteLinodeDialog.tsx b/packages/manager/src/features/Linodes/LinodesLanding/DeleteLinodeDialog.tsx index 9a54a2acfd4..dbcdc1f58a2 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/DeleteLinodeDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/DeleteLinodeDialog.tsx @@ -10,7 +10,7 @@ import { useDeleteLinodeMutation, useLinodeQuery, } from 'src/queries/linodes/linodes'; -import { subnetQueryKey, vpcQueryKey } from 'src/queries/vpcs'; +import { vpcQueries } from 'src/queries/vpcs/vpcs'; import { getVPCsFromLinodeConfigs } from './utils'; @@ -55,16 +55,14 @@ export const DeleteLinodeDialog = (props: Props) => { // @TODO VPC: potentially revisit using the linodeEventsHandler in linode/events.ts to invalidate queries rather than here // See PR #9814 for more details if (vpcIds.length > 0) { - queryClient.invalidateQueries([vpcQueryKey, 'paginated']); + queryClient.invalidateQueries(vpcQueries.all.queryKey); + queryClient.invalidateQueries(vpcQueries.paginated._def); // invalidate data for specific vpcs this linode is assigned to vpcIds.forEach((vpcId) => { - queryClient.invalidateQueries([vpcQueryKey, 'vpc', vpcId]); - queryClient.invalidateQueries([ - vpcQueryKey, - 'vpc', - vpcId, - subnetQueryKey, - ]); + queryClient.invalidateQueries(vpcQueries.vpc(vpcId).queryKey); + queryClient.invalidateQueries( + vpcQueries.vpc(vpcId)._ctx.subnets.queryKey + ); }); } onClose(); diff --git a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx index 9027280e7bc..a7e1f3de72e 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx @@ -1,10 +1,10 @@ -import { tail } from 'lodash'; import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { ShowMore } from 'src/components/ShowMore/ShowMore'; import { PublicIpsUnassignedTooltip } from 'src/features/Linodes/PublicIpsUnassignedTooltip'; import { privateIPRegex } from 'src/utilities/ipUtils'; +import { tail } from 'src/utilities/tail'; import { StyledCopyTooltip, diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx index 7f2dfb358ec..5a2c1ecc1ed 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx @@ -11,6 +11,10 @@ import OrderBy from 'src/components/OrderBy'; import { PreferenceToggle } from 'src/components/PreferenceToggle/PreferenceToggle'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; +import { + WithFeatureFlagProps, + withFeatureFlags, +} from 'src/containers/flags.container'; import { WithProfileProps, withProfile, @@ -87,12 +91,12 @@ export interface LinodesLandingProps { someLinodesHaveScheduledMaintenance: boolean; } -interface ListLinodesProps - extends LinodesLandingProps, - RouteProps, - WithProfileProps {} +type CombinedProps = LinodesLandingProps & + RouteProps & + WithFeatureFlagProps & + WithProfileProps; -class ListLinodes extends React.Component { +class ListLinodes extends React.Component { render() { const { grants, @@ -447,9 +451,10 @@ const sendGroupByAnalytic = (value: boolean) => { sendGroupByTagEnabledEvent(eventCategory, value); }; -export const enhanced = compose( +export const enhanced = compose( withRouter, - withProfile + withProfile, + withFeatureFlags ); export default enhanced(ListLinodes); diff --git a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.test.tsx b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.test.tsx index 8ad60375310..aee1c6df8c9 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.test.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.test.tsx @@ -2,7 +2,7 @@ import { waitFor } from '@testing-library/react'; import React from 'react'; import { typeFactory } from 'src/factories/types'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { ConfigureForm } from './ConfigureForm'; @@ -48,6 +48,7 @@ const mockLinodeType = typeFactory.build({ }); const handleSelectRegion = vi.fn(); +const handlePlacementGroupChange = vi.fn(); const currentPriceLabel = 'Current Price'; const newPriceLabel = 'New Price'; const currentPricePanel = 'current-price-panel'; @@ -57,6 +58,7 @@ describe('ConfigureForm component with price comparison', () => { const props = { backupEnabled: true, currentRegion: 'us-east', + handlePlacementGroupChange, handleSelectRegion, linodeType: 'g6-standard-1', selectedRegion: '', @@ -165,4 +167,24 @@ describe('ConfigureForm component with price comparison', () => { ); }); }); + + it('should render the PlacementGroupsSelect component when a new region is selected', async () => { + const wrapper = renderWithTheme( + , + { + flags: { placementGroups: { beta: true, enabled: true } }, + } + ); + + // Verify that the PlacementGroupsSelect component is rendered + await waitFor(() => { + expect( + wrapper.getByTestId('placement-groups-select') + ).toBeInTheDocument(); + }); + }); }); diff --git a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx index 5895485233a..02fbdb40eaa 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; -import EdgeServer from 'src/assets/icons/entityIcons/edge-server.svg'; +import EdgeRegion from 'src/assets/icons/entityIcons/edge-region.svg'; import { Flag } from 'src/components/Flag'; import { Notice } from 'src/components/Notice/Notice'; +import { PlacementGroupsSelect } from 'src/components/PlacementGroupsSelect/PlacementGroupsSelect'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { sxEdgeIcon } from 'src/components/RegionSelect/RegionSelect.styles'; import { TooltipIcon } from 'src/components/TooltipIcon'; @@ -28,12 +29,13 @@ import { import { MigrationPricing } from './MigrationPricing'; import type { MigrationPricingProps } from './MigrationPricing'; -import type { Linode, PriceObject } from '@linode/api-v4'; +import type { Linode, PlacementGroup, PriceObject } from '@linode/api-v4'; interface Props { backupEnabled: Linode['backups']['enabled']; currentRegion: string; errorText?: string; + handlePlacementGroupChange: (selected: PlacementGroup | null) => void; handleSelectRegion: (id: string) => void; helperText?: string; linodeType: Linode['type']; @@ -47,6 +49,7 @@ export const ConfigureForm = React.memo((props: Props) => { backupEnabled, currentRegion, errorText, + handlePlacementGroupChange, handleSelectRegion, helperText, linodeType, @@ -54,15 +57,51 @@ export const ConfigureForm = React.memo((props: Props) => { } = props; const flags = useFlags(); + const showPlacementGroups = Boolean(flags.placementGroups?.enabled); const { data: regions } = useRegionsQuery(); + const { data: currentLinodeType } = useTypeQuery( linodeType || '', Boolean(linodeType) ); + + const [ + selectedPlacementGroup, + setSelectedPlacementGroup, + ] = React.useState(null); + + React.useEffect(() => { + handlePlacementGroupSelection(null); + }, [selectedRegion]); + const currentActualRegion = regions?.find((r) => r.id === currentRegion); + + const newRegion = regions?.find( + (thisRegion) => thisRegion.id === selectedRegion + ); + + const placementGroupSelectLabel = selectedRegion + ? `Placement Groups in ${newRegion?.label} (${newRegion?.id}) (optional)` + : 'Placement Group'; + + const hasRegionPlacementGroupCapability = Boolean( + newRegion?.capabilities.includes('Placement Group') + ); + + const isPlacementGroupSelectDisabled = + !newRegion || !hasRegionPlacementGroupCapability; + + const handlePlacementGroupSelection = ( + placementGroup: PlacementGroup | null + ) => { + setSelectedPlacementGroup(placementGroup); + handlePlacementGroupChange(placementGroup); + }; + const country = regions?.find((thisRegion) => thisRegion.id == currentRegion)?.country ?? 'us'; + const shouldDisplayPriceComparison = Boolean( selectedRegion && isLinodeTypeDifferentPriceInSelectedRegion({ @@ -118,10 +157,10 @@ export const ConfigureForm = React.memo((props: Props) => { }`} {linodeIsInEdgeRegion && ( } + icon={} status="other" sxTooltipIcon={sxEdgeIcon} - text="This region is an Edge server." + text="This region is an edge region." /> )} @@ -131,9 +170,11 @@ export const ConfigureForm = React.memo((props: Props) => { /> )} - eachRegion.id !== currentRegion @@ -146,7 +187,6 @@ export const ConfigureForm = React.memo((props: Props) => { errorText={errorText} handleSelection={handleSelectRegion} label="New Region" - regionFilter={flags.gecko && linodeIsInEdgeRegion ? 'edge' : 'core'} selectedId={selectedRegion} /> {shouldDisplayPriceComparison && selectedRegion && ( @@ -154,6 +194,24 @@ export const ConfigureForm = React.memo((props: Props) => { {...panelPrice(selectedRegion, selectedRegionPrice, 'new')} /> )} + {showPlacementGroups && ( + { + handlePlacementGroupSelection(placementGroup); + }} + textFieldProps={{ + tooltipText: hasRegionPlacementGroupCapability + ? '' + : 'Placement Groups are not available in this region.', + }} + disabled={isPlacementGroupSelectDisabled} + key={selectedRegion} + label={placementGroupSelectLabel} + noOptionsMessage="There are no Placement Groups in this region." + selectedPlacementGroup={selectedPlacementGroup} + selectedRegion={newRegion} + /> + )} {!currentRegionPrice && selectedRegion && ( diff --git a/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx b/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx index 9a83b193458..c854b62dfb0 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx @@ -44,6 +44,8 @@ import { addUsedDiskSpace } from '../LinodesDetail/LinodeStorage/LinodeDisks'; import { CautionNotice } from './CautionNotice'; import { ConfigureForm } from './ConfigureForm'; +import type { PlacementGroup } from '@linode/api-v4'; + interface Props { linodeId: number | undefined; onClose: () => void; @@ -99,8 +101,13 @@ export const MigrateLinode = React.memo((props: Props) => { const [selectedRegion, handleSelectRegion] = React.useState( null ); + const [ + placementGroupSelection, + setPlacementGroupSelection, + ] = React.useState(); const [hasConfirmed, setConfirmed] = React.useState(false); + const [hasSignedAgreement, setHasSignedAgreement] = React.useState( false ); @@ -151,8 +158,8 @@ export const MigrateLinode = React.memo((props: Props) => { ); const edgeRegionWarning = - flags.gecko && linodeIsInEdgeRegion - ? 'Edge sites may only be migrated to other Edge sites.' + flags.gecko2?.enabled && linodeIsInEdgeRegion + ? 'Edge regions may only be migrated to other edge regions.' : undefined; if (!linode) { @@ -168,7 +175,12 @@ export const MigrateLinode = React.memo((props: Props) => { return; } + const placementGroupPayload = placementGroupSelection?.id + ? { id: placementGroupSelection.id } + : undefined; + return migrateLinode({ + placement_group: placementGroupPayload, region: selectedRegion, }).then(() => { checkForNewEvents(); @@ -245,6 +257,7 @@ export const MigrateLinode = React.memo((props: Props) => { { return shouldShowPrice ? ( {currentPanel ? 'Current' : 'New'} Price { const _convertData = React.useCallback(convertData, [data, start, end]); const { error, read, swap, write } = React.useMemo( - () => - processDiskData( - pathOr({}, ['Disk'], data), - pathOr('kvm', ['SysInfo', 'type'], data) - ), + () => processDiskData(data.Disk ?? {}, data.SysInfo?.type ?? 'kvm'), [data.Disk, data.SysInfo] ); @@ -145,14 +140,11 @@ export const processDiskData = ( if (thisDisk.isswap === 1) { // For swap, Classic combines reads and writes into a single metric // Note: we are assuming only one disk will have isswap === 1 - acc.swap = appendStats( - pathOr([], ['reads'], thisDisk), - pathOr([], ['writes'], thisDisk) - ); + acc.swap = appendStats(thisDisk.reads ?? [], thisDisk.writes ?? []); } else { // Not a swap, add reads and writes to running total - acc.read = appendStats(acc.read, pathOr([], ['reads'], thisDisk)); - acc.write = appendStats(acc.write, pathOr([], ['writes'], thisDisk)); + acc.read = appendStats(acc.read, thisDisk.reads ?? []); + acc.write = appendStats(acc.write, thisDisk.writes ?? []); } return acc; }, diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/MemoryGraph.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/MemoryGraph.tsx index 898880ca737..fedc337cb96 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/MemoryGraph.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/MemoryGraph.tsx @@ -1,5 +1,4 @@ import { useTheme } from '@mui/material/styles'; -import { pathOr } from 'ramda'; import * as React from 'react'; import { LongviewLineGraph } from 'src/components/LongviewLineGraph/LongviewLineGraph'; @@ -40,14 +39,10 @@ export const MemoryGraph = (props: GraphProps) => { const _convertData = React.useCallback(convertData, [data, start, end]); - const buffers = pathOr([], ['Memory', 'real', 'buffers'], data); - const cache = pathOr([], ['Memory', 'real', 'cache'], data); - const used = getUsedMemory( - pathOr([], ['Memory', 'real', 'used'], data), - cache, - buffers - ); - const swap = pathOr([], ['Memory', 'swap', 'used'], data); + const buffers = data.Memory?.real.buffers ?? []; + const cache = data.Memory?.real.cache ?? []; + const used = getUsedMemory(data.Memory?.real.used ?? [], cache, buffers); + const swap = data.Memory?.swap.used ?? []; // Determine the unit based on the largest value const unit = React.useMemo(() => getMaxUnit([buffers, cache, used, swap]), [ @@ -114,9 +109,9 @@ export const getUsedMemory = (used: Stat[], cache: Stat[], buffers: Stat[]) => { const totalLength = used.length; let i = 0; for (i; i < totalLength; i++) { - const _used = pathOr({}, [i], used); - const _cache = pathOr(0, [i, 'y'], cache); - const _buffers = pathOr(0, [i, 'y'], buffers); + const _used = used[i] ?? {}; + const _cache = cache[i].y ?? 0; + const _buffers = buffers[i].y ?? 0; const calculatedUsed = generateUsedMemory(_used.y, _buffers, _cache); result.push({ // Time will be converted to ms in convertData diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/NetworkGraph.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/NetworkGraph.tsx index 9c473f5165d..7caddcb745b 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/NetworkGraph.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/NetworkGraph.tsx @@ -1,5 +1,4 @@ import { useTheme } from '@mui/material/styles'; -import { pathOr } from 'ramda'; import * as React from 'react'; import { LongviewLineGraph } from 'src/components/LongviewLineGraph/LongviewLineGraph'; @@ -34,7 +33,7 @@ export const NetworkGraph = React.memo((props: GraphProps) => { ); const networkData = React.useMemo( - () => sumNetwork(pathOr({}, ['Interface'], data.Network)), + () => sumNetwork(data.Network?.Interface ?? {}), [data.Network] ); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx index de564901c07..90eef3a557e 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx @@ -35,7 +35,7 @@ export const RenderEvent = React.memo((props: RenderEventProps) => {
void; onChange: (value: string[]) => void; required?: boolean; selectedRegion: string[]; @@ -23,7 +20,7 @@ const sortRegionOptions = (a: RegionSelectOption, b: RegionSelectOption) => { }; export const AccessKeyRegions = (props: Props) => { - const { disabled, error, onBlur, onChange, required, selectedRegion } = props; + const { disabled, error, onChange, required, selectedRegion } = props; const { data: regions, error: regionsError } = useRegionsQuery(); @@ -35,13 +32,11 @@ export const AccessKeyRegions = (props: Props) => { handleSelection={(ids) => { onChange(ids); }} - SelectedRegionsList={SelectedRegionsList} currentCapability="Object Storage" disabled={disabled} errorText={errorText} isClearable={false} label="Regions" - onBlur={onBlur} placeholder="Select Regions or type to search" regions={regions ?? []} required={required} diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessTable.styles.ts b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessTable.styles.ts index 75cb52d94f2..9ed82557843 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessTable.styles.ts +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessTable.styles.ts @@ -39,6 +39,14 @@ export const StyledRadioRow = styled(TableRow, { }), })); +export const StyledSelectAllRadioRow = styled(StyledRadioRow, { + label: 'StyledSelectAllRadioRow', +})(({ theme }) => ({ + '& td ': { + borderBottom: `2px solid ${theme.color.grey2}`, + }, +})); + export const StyledRadioCell = styled(TableCell, { label: 'StyledRadioCell', })(() => ({ diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/BucketPermissionsTable.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/BucketPermissionsTable.tsx index d12f31e14cc..6e28583aa49 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/BucketPermissionsTable.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/BucketPermissionsTable.tsx @@ -17,6 +17,7 @@ import { StyledClusterCell, StyledRadioCell, StyledRadioRow, + StyledSelectAllRadioRow, StyledTableRoot, } from './AccessTable.styles'; @@ -76,8 +77,9 @@ export const BucketPermissionsTable = React.memo((props: Props) => { }; const allScopesEqual = (accessType: AccessType) => { - return bucket_access.every( - (thisScope) => thisScope.permissions === accessType + return ( + bucket_access.length > 0 && + bucket_access.every((thisScope) => thisScope.permissions === accessType) ); }; @@ -105,7 +107,7 @@ export const BucketPermissionsTable = React.memo((props: Props) => { {mode === 'creating' && ( - + Select All @@ -151,7 +153,7 @@ export const BucketPermissionsTable = React.memo((props: Props) => { value="read-write" /> - + )} {bucket_access.length === 0 ? ( { + permissions: AccessType | null; +} + export interface FormState { bucket_access: Scope[] | null; label: string; @@ -66,8 +75,8 @@ export interface FormState { */ export const sortByRegion = (regionLookup: { [key: string]: Region }) => ( - a: Scope, - b: Scope + a: DisplayedAccessKeyScope, + b: DisplayedAccessKeyScope ) => { if (!a.region || !b.region) { return 0; @@ -82,12 +91,12 @@ export const sortByRegion = (regionLookup: { [key: string]: Region }) => ( export const getDefaultScopes = ( buckets: ObjectStorageBucket[], regionLookup: { [key: string]: Region } = {} -): Scope[] => +): DisplayedAccessKeyScope[] => buckets .map((thisBucket) => ({ bucket_name: thisBucket.label, cluster: thisBucket.cluster, - permissions: 'none' as AccessType, + permissions: null, region: thisBucket.region, })) .sort(sortByRegion(regionLookup)); @@ -163,13 +172,15 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { // If the user hasn't toggled the Limited Access button, // don't include any bucket_access information in the payload. - // If any/all values are 'none', don't include them in the response. + // If any/all permissions are 'none' or null, don't include them in the response. const access = values.bucket_access ?? []; const payload = limitedAccessChecked ? { ...values, bucket_access: access.filter( - (thisAccess) => thisAccess.permissions !== 'none' + (thisAccess: DisplayedAccessKeyScope) => + thisAccess.permissions !== 'none' && + thisAccess.permissions !== null ), } : { ...values, bucket_access: null }; @@ -183,7 +194,6 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { } }, validateOnBlur: true, - validateOnChange: false, validationSchema: createMode ? createObjectStorageKeysSchema : updateObjectStorageKeysSchema, @@ -194,8 +204,10 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { isRestrictedUser || (mode !== 'creating' && objectStorageKey && - objectStorageKey?.regions?.length > 0 && - !hasLabelOrRegionsChanged(formik.values, objectStorageKey)); + !hasLabelOrRegionsChanged(formik.values, objectStorageKey)) || + (mode === 'creating' && + limitedAccessChecked && + !hasAccessBeenSelectedForAllBuckets(formik.values.bucket_access)); const beforeSubmit = () => { confirmObjectStorage( @@ -279,8 +291,8 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { { value={formik.values.label} /> { - const bucketsInRegions = buckets?.filter( - (bucket) => - bucket.region && formik.values.regions.includes(bucket.region) - ); - - formik.setFieldValue( - 'bucket_access', - getDefaultScopes(bucketsInRegions, regionsLookup) - ); - formik.validateField('regions'); - }} + error={ + formik.touched.regions + ? (formik.errors.regions as string) + : undefined + } onChange={(values) => { const bucketsInRegions = buckets?.filter( (bucket) => bucket.region && values.includes(bucket.region) @@ -310,9 +315,9 @@ export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { getDefaultScopes(bucketsInRegions, regionsLookup) ); formik.setFieldValue('regions', values); + formik.setFieldTouched('regions', true, true); }} disabled={isRestrictedUser} - error={formik.errors.regions as string} name="regions" required selectedRegion={formik.values.regions} diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.test.ts b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.test.ts index 2acbd6e940c..7eed687e51b 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.test.ts +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.test.ts @@ -1,7 +1,11 @@ -import { ObjectStorageKey } from '@linode/api-v4/lib/object-storage'; +import { + generateUpdatePayload, + hasAccessBeenSelectedForAllBuckets, + hasLabelOrRegionsChanged, +} from './utils'; -import { FormState } from './OMC_AccessKeyDrawer'; -import { generateUpdatePayload, hasLabelOrRegionsChanged } from './utils'; +import type { DisplayedAccessKeyScope, FormState } from './OMC_AccessKeyDrawer'; +import type { ObjectStorageKey } from '@linode/api-v4/lib/object-storage'; describe('generateUpdatePayload', () => { const initialValues: FormState = { @@ -96,3 +100,42 @@ describe('hasLabelOrRegionsChanged', () => { ).toBe(true); }); }); + +describe('hasAccessBeenSelectedForAllBuckets', () => { + const bucketWithoutAccessSelected: DisplayedAccessKeyScope = { + bucket_name: 'obj-bucket-1', + cluster: 'us-lax-1', + permissions: null, + region: 'us-lax', + }; + + const bucketWithAccessSelected: DisplayedAccessKeyScope = { + bucket_name: 'obj-bucket-1', + cluster: 'us-lax-1', + permissions: 'read_only', + region: 'us-lax', + }; + + it('returns false if any buckets have permission set to null', () => { + const bucket_access = [ + bucketWithAccessSelected, + bucketWithoutAccessSelected, + ]; + expect(hasAccessBeenSelectedForAllBuckets(bucket_access)).toBe(false); + }); + + it('returns true if all buckets have permission set to a value other than null', () => { + const bucket_access = [bucketWithAccessSelected, bucketWithAccessSelected]; + expect(hasAccessBeenSelectedForAllBuckets(bucket_access)).toBe(true); + }); + + it('returns true if buckets are null', () => { + const bucket_access = null; + expect(hasAccessBeenSelectedForAllBuckets(bucket_access)).toBe(true); + }); + + it('returns true if there are no buckets', () => { + const bucket_access: DisplayedAccessKeyScope[] = []; + expect(hasAccessBeenSelectedForAllBuckets(bucket_access)).toBe(true); + }); +}); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts index 8008022a36a..e299c3ee27b 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts @@ -1,13 +1,12 @@ -import { ObjectStorageKey } from '@linode/api-v4/lib/object-storage'; - import { areArraysEqual } from 'src/utilities/areArraysEqual'; import { sortByString } from 'src/utilities/sort-by'; -import { FormState } from './OMC_AccessKeyDrawer'; +import type { DisplayedAccessKeyScope, FormState } from './OMC_AccessKeyDrawer'; +import type { ObjectStorageKey } from '@linode/api-v4/lib/object-storage'; type UpdatePayload = - | { label: FormState['label']; regions: FormState['regions'] } | { label: FormState['label'] } + | { label: FormState['label']; regions: FormState['regions'] } | { regions: FormState['regions'] } | {}; @@ -71,3 +70,19 @@ export const hasLabelOrRegionsChanged = ( return labelChanged || regionsChanged; }; + +/** + * Determines whether the selection of access key scopes has been made for every bucket, + * since by default, the displayed permissions are set to null. + * + * @param bucketAccess - The array of bucket objects. + * @returns {boolean} True if all buckets have permissions set to none/read_only/read_write or if there are no buckets, false otherwise. + */ +export const hasAccessBeenSelectedForAllBuckets = ( + bucketAccess: DisplayedAccessKeyScope[] | null +): boolean => { + if (!bucketAccess) { + return true; + } + return bucketAccess.every((bucket) => bucket.permissions !== null); +}; diff --git a/packages/manager/src/features/OneClickApps/oneClickApps.ts b/packages/manager/src/features/OneClickApps/oneClickApps.ts index 199a16f7fb0..f5cb1bea442 100644 --- a/packages/manager/src/features/OneClickApps/oneClickApps.ts +++ b/packages/manager/src/features/OneClickApps/oneClickApps.ts @@ -579,25 +579,26 @@ export const oneClickApps: OCA[] = [ website: 'https://gopaddle.io/', }, { - alt_description: 'Monitoring server.', - alt_name: 'Monitoring visualization dashboards', - categories: ['Monitoring'], + alt_description: 'Open source, highly available, shared filesystem.', + alt_name: 'GlusterFS', + categories: ['Development'], colors: { - end: 'f9b716', - start: '606060', + end: '784900', + start: 'D4AC5C', }, - description: `Grafana gives you the ability to create, monitor, store, and share metrics with your team to keep tabs on your infrastructure.`, - logo_url: 'grafana.svg', - name: 'Grafana', + description: + 'GlusterFS is an open source, software scalable network filesystem. This app deploys three GlusterFS servers and three GlusterFS clients.', + logo_url: 'glusterfs.svg', + name: 'GlusterFS Cluster', related_guides: [ { href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/grafana/', - title: 'Deploy Grafana through the Linode Marketplace', + 'https://www.linode.com/docs/products/tools/marketplace/guides/glusterfs-cluster/', + title: 'Deploy GlusterFS Cluster through the Linode Marketplace', }, ], - summary: `An open source analytics and monitoring solution with a focus on accessibility for metric visualization.`, - website: 'https://grafana.com/', + summary: 'Open source network filesystem.', + website: 'https://www.gluster.org/', }, { alt_description: 'Markdown-based website CMS.', @@ -862,6 +863,28 @@ export const oneClickApps: OCA[] = [ summary: 'Free, open source video conferencing and communication platform.', website: 'https://jitsi.org/', }, + { + alt_description: + 'Open source video conferencing cluster, alternative to Zoom.', + alt_name: 'Video chat and video conferencing cluster', + categories: ['Media and Entertainment'], + colors: { + end: '949699', + start: '1d76ba', + }, + description: `Secure, stable, and free alternative to popular video conferencing services. This app deploys four networked Jitsi nodes.`, + logo_url: 'jitsi.svg', + name: 'Jitsi Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/jitsi-cluster/', + title: 'Deploy Jitsi Cluster through the Linode Marketplace', + }, + ], + summary: 'Free, open source video conferencing and communication platform.', + website: 'https://jitsi.org/', + }, { alt_description: 'Secure website CMS.', alt_name: 'CMS: content management system', @@ -1839,28 +1862,6 @@ export const oneClickApps: OCA[] = [ summary: 'Enterprise open source VPN.', website: 'https://docs.pritunl.com/docs', }, - { - alt_description: 'Monitoring server.', - alt_name: 'Server monitoring', - categories: ['Monitoring'], - colors: { - end: 'e27c37', - start: 'e6522c', - }, - description: `Prometheus is a powerful monitoring software tool that collects metrics from configurable data points at given intervals, evaluates rule expressions, and can trigger alerts if some condition is observed.`, - logo_url: 'prometheus.svg', - name: 'Prometheus', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/prometheus/', - title: 'Deploy Prometheus through the Linode Marketplace', - }, - ], - summary: - 'Gain metrics and receive alerts with this open-source monitoring tool.', - website: 'https://prometheus.io/', - }, { alt_description: 'Monitoring server.', alt_name: 'Server monitoring and visualization', @@ -2011,27 +2012,6 @@ export const oneClickApps: OCA[] = [ summary: `Ruby on Rails is a web framework that allows web designers and developers to implement dynamic, fully featured web applications.`, website: 'https://rubyonrails.org/', }, - { - alt_description: 'Open world survival game.', - alt_name: 'Survival game', - categories: ['Games'], - colors: { - end: 'cd412b', - start: '050508', - }, - description: `In Rust, you must work with or against other players to ensure your own survival. Players are able to steal, lie, cheat, or trick each other. Build a shelter, hunt animals for food, craft weapons and armor, and much more. Hosting your own Rust server allows you to customize settings and curate the number of players in the world.`, - logo_url: 'rust.svg', - name: 'Rust', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/rust/', - title: 'Deploy Rust through the Linode Marketplace', - }, - ], - summary: `A free-for-all battle for survival in a harsh open-world environment. In Rust, you can do anything--but so can everyone else.`, - website: 'https://rust.facepunch.com/', - }, { alt_description: 'Database low-code/no-code application builder.', alt_name: 'Low-code application builder', @@ -2249,28 +2229,6 @@ export const oneClickApps: OCA[] = [ 'A powerful, user-friendly Virtual Private Network (VPN) server application that supports multiple VPN protocols.', website: 'https://www.utunnel.io/linode-vpn-server.html', }, - { - alt_description: 'Open world surival crafting game.', - alt_name: 'Viking survival game', - categories: ['Games'], - colors: { - end: 'bb470d', - start: '081e2b', - }, - description: `In the relatively peaceful place called Valheim, traveling farther comes with a price: more enemies and greater challenges to stay alive. Experience a punishing combat system, intense boss battles, and a complex building system to construct Viking warships and more.`, - logo_url: 'valheim.svg', - name: 'Valheim', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/valheim/', - title: 'Deploy Valheim through the Linode Marketplace', - }, - ], - summary: - 'Explore, build, and conquer in the popular open-world Viking survival game.', - website: 'https://www.valheimgame.com/', - }, { alt_description: 'Time series database and database monitoring/metrics.', alt_name: 'Database monitoring', @@ -2293,27 +2251,6 @@ export const oneClickApps: OCA[] = [ 'Free and open source time series database (TSDB) and monitoring solution.', website: 'https://victoriametrics.com/', }, - { - alt_description: 'Domain management and website control panel.', - alt_name: 'Website control panel', - categories: ['Control Panels'], - colors: { - end: '005ebd', - start: '58cc00', - }, - description: `Streamline domain management, included as part of Webmin. Choose between the standard free version or upgrade to their premium service to access more features.`, - logo_url: 'virtualmin.svg', - name: 'Virtualmin', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/virtualmin/', - title: 'Deploy Virtualmin through the Linode Marketplace', - }, - ], - summary: 'Domain hosting and website control panel.', - website: 'https://www.virtualmin.com/', - }, { alt_description: 'Fancy development text editor.', alt_name: 'Text editor', @@ -2378,27 +2315,6 @@ export const oneClickApps: OCA[] = [ summary: 'Free open source security monitoring solution.', website: 'https://documentation.wazuh.com/current/index.html', }, - { - alt_description: 'Unix management in the browser.', - alt_name: 'Linux & UNIX management', - categories: ['Control Panels'], - colors: { - end: '3a70c4', - start: '6ca034', - }, - description: `Web interface for Unix to optimize system management, both from the console and remotely.`, - logo_url: 'webmin.svg', - name: 'Webmin', - related_guides: [ - { - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/webmin/', - title: 'Deploy Webmin through the Linode Marketplace', - }, - ], - summary: 'Unix management in your browser.', - website: 'http://www.webmin.com/', - }, { alt_description: 'Control panel to deploy and manage LAMP stack applications.', diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.test.tsx index 15ef1043cf1..2ac7e4f4637 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, waitFor } from '@testing-library/react'; import * as React from 'react'; +import { placementGroupFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { PlacementGroupsCreateDrawer } from './PlacementGroupsCreateDrawer'; @@ -13,6 +14,7 @@ const commonProps = { }; const queryMocks = vi.hoisted(() => ({ + useAllPlacementGroupsQuery: vi.fn().mockReturnValue({}), useCreatePlacementGroup: vi.fn().mockReturnValue({ mutateAsync: vi.fn().mockResolvedValue({}), reset: vi.fn(), @@ -23,6 +25,7 @@ vi.mock('src/queries/placementGroups', async () => { const actual = await vi.importActual('src/queries/placementGroups'); return { ...actual, + useAllPlacementGroupsQuery: queryMocks.useAllPlacementGroupsQuery, useCreatePlacementGroup: queryMocks.useCreatePlacementGroup, }; }); @@ -63,7 +66,12 @@ describe('PlacementGroupsCreateDrawer', () => { + />, + { + MemoryRouter: { + initialEntries: ['/linodes/create'], + }, + } ); await waitFor(() => { @@ -108,22 +116,12 @@ describe('PlacementGroupsCreateDrawer', () => { }); it('should display an error message if the region has reached capacity', async () => { + queryMocks.useAllPlacementGroupsQuery.mockReturnValue({ + data: [placementGroupFactory.build({ region: 'us-west' })], + }); const regionWithoutCapacity = 'Fremont, CA (us-west)'; const { getByPlaceholderText, getByText } = renderWithTheme( - + ); const regionSelect = getByPlaceholderText('Select a Region'); @@ -132,12 +130,15 @@ describe('PlacementGroupsCreateDrawer', () => { target: { value: regionWithoutCapacity }, }); await waitFor(() => { - const selectedRegionOption = getByText(regionWithoutCapacity); - fireEvent.click(selectedRegionOption); + expect(getByText(regionWithoutCapacity)).toBeInTheDocument(); }); await waitFor(() => { - expect(getByText('This region has reached capacity')).toBeInTheDocument(); + expect( + getByText( + 'You’ve reached the limit of placement groups you can create in this region.' + ) + ).toBeInTheDocument(); }); }); }); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx index 1d81bf0a482..ea16d453604 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx @@ -2,6 +2,7 @@ import { createPlacementGroupSchema } from '@linode/validation'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; +import { useLocation } from 'react-router-dom'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { DescriptionList } from 'src/components/DescriptionList/DescriptionList'; @@ -11,9 +12,13 @@ import { Notice } from 'src/components/Notice/Notice'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { Stack } from 'src/components/Stack'; import { TextField } from 'src/components/TextField'; +import { Typography } from 'src/components/Typography'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useFormValidateOnChange } from 'src/hooks/useFormValidateOnChange'; -import { useCreatePlacementGroup } from 'src/queries/placementGroups'; +import { + useAllPlacementGroupsQuery, + useCreatePlacementGroup, +} from 'src/queries/placementGroups'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { getFormikErrorsFromAPIErrors } from 'src/utilities/formikErrorUtils'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; @@ -30,7 +35,6 @@ export const PlacementGroupsCreateDrawer = ( props: PlacementGroupsCreateDrawerProps ) => { const { - allPlacementGroups, disabledPlacementGroupCreateButton, onClose, onPlacementGroupCreate, @@ -38,37 +42,24 @@ export const PlacementGroupsCreateDrawer = ( selectedRegionId, } = props; const { data: regions } = useRegionsQuery(); + const { data: allPlacementGroups } = useAllPlacementGroupsQuery(); const { error, mutateAsync } = useCreatePlacementGroup(); const { enqueueSnackbar } = useSnackbar(); const { hasFormBeenSubmitted, setHasFormBeenSubmitted, } = useFormValidateOnChange(); - const [ - hasRegionReachedPGCapacity, - setHasRegionReachedPGCapacity, - ] = React.useState(false); - const selectedRegionFromProps = regions?.find( - (r) => r.id === selectedRegionId - ); + const location = useLocation(); + const displayRegionHeaderText = location.pathname.includes('/linodes/create'); const handleRegionSelect = (region: Region['id']) => { - const selectedRegion = regions?.find((r) => r.id === region); - setFieldValue('region', region); - setHasRegionReachedPGCapacity( - hasRegionReachedPlacementGroupCapacity({ - allPlacementGroups, - region: selectedRegion, - }) - ); }; const handleResetForm = () => { resetForm(); setHasFormBeenSubmitted(false); - setHasRegionReachedPGCapacity(false); }; const handleDrawerClose = () => { @@ -127,6 +118,13 @@ export const PlacementGroupsCreateDrawer = ( const generalError = error?.find((e) => !e.field)?.reason; + const selectedRegion = React.useMemo( + () => regions?.find((region) => region.id == values.region), + [regions, values.region] + ); + + const pgRegionLimitHelperText = `The maximum number of placement groups in this region is: ${selectedRegion?.placement_group_limits?.maximum_pgs_per_customer}`; + return ( {generalError && } - {selectedRegionFromProps && ( + {selectedRegion && displayRegionHeaderText && ( { + const isRegionAtCapacity = hasRegionReachedPlacementGroupCapacity( + { + allPlacementGroups, + region, + } + ); + + return { + disabled: isRegionAtCapacity, + reason: ( + <> + + You’ve reached the limit of placement groups you can + create in this region. + + + The maximum number of placement groups in this region + is:{' '} + {region.placement_group_limits.maximum_pgs_per_customer} + + + ), + tooltipWidth: 300, + }; + }} handleSelection={(selection) => { handleRegionSelect(selection); }} currentCapability="Placement Group" - helperText="Only regions supporting Placement Groups are listed." + helperText={values.region && pgRegionLimitHelperText} regions={regions ?? []} selectedId={selectedRegionId ?? values.region} + tooltipText="Only regions supporting Placement Groups are listed." /> )} { - const flags = useFlags(); const { id } = useParams<{ id: string }>(); const placementGroupId = +id; @@ -31,10 +29,7 @@ export const PlacementGroupsDetail = () => { data: placementGroup, error: placementGroupError, isLoading, - } = usePlacementGroupQuery( - placementGroupId, - Boolean(flags.placementGroups?.enabled) - ); + } = usePlacementGroupQuery(placementGroupId); const { data: linodes, isFetching: isFetchingLinodes } = useAllLinodesQuery( {}, { diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx index 7198356f67c..fc3802b1b27 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx @@ -127,7 +127,7 @@ describe('PlacementGroupsDetailPanel', () => { /> ); - const select = getByPlaceholderText('Select a Placement Group'); + const select = getByPlaceholderText('None'); expect(select).toBeEnabled(); expect( getByRole('button', { name: /create placement group/i }) diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx index ea4a1db2303..3e1d8fb1478 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx @@ -29,10 +29,12 @@ export const PlacementGroupsDetailPanel = (props: Props) => { const { handlePlacementGroupChange, selectedRegionId } = props; const { data: allPlacementGroups } = useAllPlacementGroupsQuery(); const { data: regions } = useRegionsQuery(); + const [ isCreatePlacementGroupDrawerOpen, setIsCreatePlacementGroupDrawerOpen, ] = React.useState(false); + const [ selectedPlacementGroup, setSelectedPlacementGroup, @@ -150,7 +152,6 @@ export const PlacementGroupsDetailPanel = (props: Props) => { )} setIsCreatePlacementGroupDrawerOpen(false)} onPlacementGroupCreate={handlePlacementGroupCreated} diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx index d5187a39c5c..3afc7fa6abb 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx @@ -139,7 +139,6 @@ export const PlacementGroupsLanding = React.memo(() => { openCreatePlacementGroupDrawer={handleCreatePlacementGroup} /> { pageSize={pagination.pageSize} /> void; diff --git a/packages/manager/src/features/PlacementGroups/utils.test.ts b/packages/manager/src/features/PlacementGroups/utils.test.ts index ac6c9df18f4..28d652fdd30 100644 --- a/packages/manager/src/features/PlacementGroups/utils.test.ts +++ b/packages/manager/src/features/PlacementGroups/utils.test.ts @@ -1,3 +1,5 @@ +import { renderHook } from '@testing-library/react'; + import { linodeFactory, placementGroupFactory, @@ -11,8 +13,30 @@ import { getPlacementGroupLinodes, hasPlacementGroupReachedCapacity, hasRegionReachedPlacementGroupCapacity, + useIsPlacementGroupsEnabled, } from './utils'; +const queryMocks = vi.hoisted(() => ({ + useAccount: vi.fn().mockReturnValue({}), + useFlags: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/account/account', () => { + const actual = vi.importActual('src/queries/account/account'); + return { + ...actual, + useAccount: queryMocks.useAccount, + }; +}); + +vi.mock('src/hooks/useFlags', () => { + const actual = vi.importActual('src/hooks/useFlags'); + return { + ...actual, + useFlags: queryMocks.useFlags, + }; +}); + const initialLinodeData = [ { is_compliant: true, @@ -201,3 +225,58 @@ describe('getPlacementGroupLinodes', () => { expect(getPlacementGroupLinodes(placementGroup, linodes)).toBeUndefined(); }); }); + +describe('useIsPlacementGroupsEnabled', () => { + it('returns true if the feature flag is enabled and the account has the Placement Group capability', () => { + queryMocks.useFlags.mockReturnValue({ + placementGroups: { + enabled: true, + }, + }); + queryMocks.useAccount.mockReturnValue({ + data: { + capabilities: ['Placement Group'], + }, + }); + + const { result } = renderHook(() => useIsPlacementGroupsEnabled()); + expect(result.current).toStrictEqual({ + isPlacementGroupsEnabled: true, + }); + }); + + it('returns false if the feature flag is disabled', () => { + queryMocks.useFlags.mockReturnValue({ + placementGroups: { + enabled: false, + }, + }); + queryMocks.useAccount.mockReturnValue({ + data: { + capabilities: ['Placement Group'], + }, + }); + + const { result } = renderHook(() => useIsPlacementGroupsEnabled()); + expect(result.current).toStrictEqual({ + isPlacementGroupsEnabled: false, + }); + }); + it('returns false if the account does not have the Placement Group capability', () => { + queryMocks.useFlags.mockReturnValue({ + placementGroups: { + enabled: true, + }, + }); + queryMocks.useAccount.mockReturnValue({ + data: { + capabilities: [], + }, + }); + + const { result } = renderHook(() => useIsPlacementGroupsEnabled()); + expect(result.current).toStrictEqual({ + isPlacementGroupsEnabled: false, + }); + }); +}); diff --git a/packages/manager/src/features/PlacementGroups/utils.ts b/packages/manager/src/features/PlacementGroups/utils.ts index aab8f427b2e..f7de317351c 100644 --- a/packages/manager/src/features/PlacementGroups/utils.ts +++ b/packages/manager/src/features/PlacementGroups/utils.ts @@ -1,5 +1,8 @@ import { AFFINITY_TYPES } from '@linode/api-v4/lib/placement-groups'; +import { useFlags } from 'src/hooks/useFlags'; +import { useAccount } from 'src/queries/account/account'; + import type { AffinityEnforcement, CreatePlacementGroupPayload, @@ -71,7 +74,7 @@ export const hasRegionReachedPlacementGroupCapacity = ({ allPlacementGroups, region, }: HasRegionReachedPlacementGroupCapacityOptions): boolean => { - if (!region || !allPlacementGroups) { + if (!region?.placement_group_limits || !allPlacementGroups) { return false; } @@ -118,3 +121,30 @@ export const getLinodesFromAllPlacementGroups = ( return Array.from(new Set(linodeIds)); }; + +/** + * Hook to determine if the Placement Group feature should be visible to the user. + * Dased on the user's account capability and the feature flag. + * + * @returns {boolean} - Whether the Placement Group feature is enabled for the current user. + */ +export const useIsPlacementGroupsEnabled = (): { + isPlacementGroupsEnabled: boolean; +} => { + const { data: account, error } = useAccount(); + const flags = useFlags(); + + if (error || !flags) { + return { isPlacementGroupsEnabled: false }; + } + + const hasAccountCapability = account?.capabilities?.includes( + 'Placement Group' + ); + const isFeatureFlagEnabled = flags.placementGroups?.enabled; + const isPlacementGroupsEnabled = Boolean( + hasAccountCapability && isFeatureFlagEnabled + ); + + return { isPlacementGroupsEnabled }; +}; diff --git a/packages/manager/src/features/Profile/APITokens/APITokenDrawer.styles.ts b/packages/manager/src/features/Profile/APITokens/APITokenDrawer.styles.ts index ba6ea7d4945..ae51ad4834c 100644 --- a/packages/manager/src/features/Profile/APITokens/APITokenDrawer.styles.ts +++ b/packages/manager/src/features/Profile/APITokens/APITokenDrawer.styles.ts @@ -12,7 +12,8 @@ export const StyledPermsTable = styled(Table, { export const StyledSelectCell = styled(TableCell, { label: 'StyledSelectCell', -})(() => ({ +})(({ theme }) => ({ + borderBottom: `2px solid ${theme.color.grey2}`, fontFamily: 'LatoWebBold', // we keep this bold at all times fontSize: '.9rem', })); @@ -35,3 +36,9 @@ export const StyledPermissionsCell = styled(TableCell, { }, width: '23%', })); + +export const StyledSelectAllPermissionsCell = styled(StyledPermissionsCell, { + label: 'StyledSelectAllPermissionsCell', +})(({ theme }) => ({ + borderBottom: `2px solid ${theme.color.grey2}`, +})); diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx index 33bc4a3f8d4..0d9ece4ab70 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx @@ -54,9 +54,10 @@ describe('Create API Token Drawer', () => { const expiry = getByText(/Expiry/); expect(expiry).toBeVisible(); + // Submit button will be disabled until scope selection is made. const submitBtn = getByTestId('create-button'); expect(submitBtn).toBeVisible(); - expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true'); + expect(submitBtn).toHaveAttribute('aria-disabled', 'true'); const cancelBtn = getByText(/Cancel/); expect(cancelBtn).not.toHaveAttribute('aria-disabled', 'true'); @@ -72,26 +73,44 @@ describe('Create API Token Drawer', () => { }) ); - const { getByTestId, getByText } = renderWithTheme( + const { getByLabelText, getByTestId, getByText } = renderWithTheme( ); const labelField = getByTestId('textfield-input'); await userEvent.type(labelField, 'my-test-token'); - const submit = getByText('Create Token'); - await userEvent.click(submit); + + const selectAllNoAccessPermRadioButton = getByLabelText( + 'Select no access for all' + ); + const submitBtn = getByText('Create Token'); + + expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true'); + await userEvent.click(selectAllNoAccessPermRadioButton); + await userEvent.click(submitBtn); await waitFor(() => expect(props.showSecret).toBeCalledWith('secret-value') ); }); - it('Should default to None for all scopes', () => { + it('Should default to no selection for all scopes', () => { const { getByLabelText } = renderWithTheme( ); - const selectAllNonePermRadioButton = getByLabelText('Select none for all'); - expect(selectAllNonePermRadioButton).toBeChecked(); + const selectAllNoAccessPermRadioButton = getByLabelText( + 'Select no access for all' + ); + const selectAllReadOnlyPermRadioButton = getByLabelText( + 'Select read-only for all' + ); + const selectAllReadWritePermRadioButton = getByLabelText( + 'Select read/write for all' + ); + + expect(selectAllNoAccessPermRadioButton).not.toBeChecked(); + expect(selectAllReadOnlyPermRadioButton).not.toBeChecked(); + expect(selectAllReadWritePermRadioButton).not.toBeChecked(); }); it('Should default to 6 months for expiration', () => { @@ -157,7 +176,7 @@ describe('Create API Token Drawer', () => { ); const vpcPermRadioButtons = getAllByTestId('perm-vpc-radio'); - const vpcNonePermRadioButton = vpcPermRadioButtons[0].firstChild; + const vpcNoAccessPermRadioButton = vpcPermRadioButtons[0].firstChild; const vpcReadOnlyPermRadioButton = vpcPermRadioButtons[1].firstChild; const selectAllReadOnlyPermRadioButton = getByLabelText( @@ -166,7 +185,7 @@ describe('Create API Token Drawer', () => { await userEvent.click(selectAllReadOnlyPermRadioButton); expect(selectAllReadOnlyPermRadioButton).toBeChecked(); - expect(vpcNonePermRadioButton).toBeChecked(); + expect(vpcNoAccessPermRadioButton).toBeChecked(); expect(vpcReadOnlyPermRadioButton).not.toBeChecked(); expect(vpcReadOnlyPermRadioButton).toBeDisabled(); }); diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx index 33b4e1d7902..e2eacbe7679 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx @@ -27,12 +27,14 @@ import { StyledAccessCell, StyledPermissionsCell, StyledPermsTable, + StyledSelectAllPermissionsCell, StyledSelectCell, } from './APITokenDrawer.styles'; import { Permission, allScopesAreTheSame, basePermNameMap, + hasAccessBeenSelectedForAllScopes, permTuplesToScopeString, scopeStringToPermTuples, } from './utils'; @@ -97,7 +99,7 @@ export const CreateAPITokenDrawer = (props: Props) => { const initialValues = { expiry: expiryTups[0][1], label: '', - scopes: scopeStringToPermTuples(''), + scopes: scopeStringToPermTuples('', true), }; const { data: profile } = useProfile(); @@ -234,8 +236,8 @@ export const CreateAPITokenDrawer = (props: Props) => { Access - - None + + No Access Read Only @@ -250,20 +252,26 @@ export const CreateAPITokenDrawer = (props: Props) => { Select All - + - - + + { onChange={handleSelectAllScopes} value="1" /> - - + + { onChange={handleSelectAllScopes} value="2" /> - + {allPermissions.map((scopeTup) => { if ( @@ -359,6 +370,7 @@ export const CreateAPITokenDrawer = (props: Props) => { form.handleSubmit(), diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx index 103561e81e2..d526fa0fe7d 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx @@ -4,8 +4,8 @@ import { appTokenFactory } from 'src/factories'; import { profileFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { ViewAPITokenDrawer } from './ViewAPITokenDrawer'; import { basePerms } from './utils'; +import { ViewAPITokenDrawer } from './ViewAPITokenDrawer'; import type { UserType } from '@linode/api-v4'; @@ -63,7 +63,7 @@ describe('View API Token Drawer', () => { } }); - it('should show all permissions as none with no scopes', () => { + it('should show all permissions as No Access with no scopes', () => { // We want to show all perms for this test, even perms specific to Parent/Child accounts. queryMocks.useProfile.mockReturnValue({ data: profileFactory.build({ user_type: 'parent' }), @@ -81,7 +81,7 @@ describe('View API Token Drawer', () => { } }); - it('only account has read/write, all others are none', () => { + it('only account has read/write, all others are no access', () => { // We want to show all perms for this test, even perms specific to Parent/Child accounts. queryMocks.useProfile.mockReturnValue({ data: profileFactory.build({ user_type: 'parent' }), diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx index 4d50c5309a3..87aa61b8be6 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx @@ -53,8 +53,8 @@ export const ViewAPITokenDrawer = (props: Props) => { Access - - None + + No Access Read Only @@ -81,7 +81,10 @@ export const ViewAPITokenDrawer = (props: Props) => { {basePermNameMap[scopeTup[0]]} - + { }); }); }); + +describe('hasAccessBeenSelectedForAllScopes', () => { + const defaultScopes: Permission[] = [ + ['account', -1], + ['child_account', -1], + ['databases', -1], + ['domains', -1], + ['events', -1], + ['firewall', -1], + ['images', -1], + ['ips', -1], + ['linodes', -1], + ['lke', -1], + ['longview', -1], + ['nodebalancers', -1], + ['object_storage', -1], + ['stackscripts', -1], + ['volumes', -1], + ['vpc', -1], + ]; + + const missingSelectionScopes: Permission[] = [ + ['account', -1], + ['child_account', -1], + ['databases', -1], + ['domains', -1], + ['events', -1], + ['firewall', -1], + ['images', -1], + ['ips', -1], + ['linodes', -1], + ['lke', -1], + ['longview', -1], + ['nodebalancers', -1], + ['object_storage', -1], + ['stackscripts', -1], + ['volumes', -1], + ['vpc', 0], + ]; + + const allSelectedScopes: Permission[] = [ + ['account', 1], + ['child_account', 0], + ['databases', 0], + ['domains', 0], + ['events', 0], + ['firewall', 0], + ['images', 0], + ['ips', 0], + ['linodes', 2], + ['lke', 0], + ['longview', 0], + ['nodebalancers', 0], + ['object_storage', 0], + ['stackscripts', 0], + ['volumes', 0], + ['vpc', 0], + ]; + + it('should return false if scopes are all set to a default of no selection', () => { + expect(hasAccessBeenSelectedForAllScopes(defaultScopes)).toBe(false); + }); + it('should return false if at least one scope does not have a selection', () => { + expect(hasAccessBeenSelectedForAllScopes(missingSelectionScopes)).toBe( + false + ); + }); + it('should return true if all scopes have a valid selection', () => { + expect(hasAccessBeenSelectedForAllScopes(allSelectedScopes)).toBe(true); + }); +}); diff --git a/packages/manager/src/features/Profile/APITokens/utils.ts b/packages/manager/src/features/Profile/APITokens/utils.ts index 5aad811b893..2e6cf9ce334 100644 --- a/packages/manager/src/features/Profile/APITokens/utils.ts +++ b/packages/manager/src/features/Profile/APITokens/utils.ts @@ -56,8 +56,16 @@ export const levelMap = { view: 1, }; -const defaultScopeMap = (perms: typeof basePerms): Record => - perms.reduce((obj, key) => ({ ...obj, [key]: 0 }), {}); +const NO_SCOPE_SELECTION = -1; + +const defaultScopeMap = ( + perms: typeof basePerms, + isCreateFlow?: boolean +): Record => + perms.reduce( + (obj, key) => ({ ...obj, [key]: isCreateFlow ? NO_SCOPE_SELECTION : 0 }), + {} + ); /** * This function accepts scopes strings as given by the API, which have the following format: @@ -85,7 +93,10 @@ const defaultScopeMap = (perms: typeof basePerms): Record => * Each permission level gives a user access to all lower permission levels. */ const permRegex = new RegExp(/[, ]/); -export const scopeStringToPermTuples = (scopes: string): Permission[] => { +export const scopeStringToPermTuples = ( + scopes: string, + isCreateFlow?: boolean +): Permission[] => { if (scopes === '*') { return basePerms.map((perm) => [perm, 2] as Permission); } @@ -96,7 +107,7 @@ export const scopeStringToPermTuples = (scopes: string): Permission[] => { ...map, [perm]: levelMap[level], }; - }, defaultScopeMap(basePerms)); + }, defaultScopeMap(basePerms, isCreateFlow)); /** * So there are deprecated permission types that have been folded into a parent permission. So @@ -239,3 +250,22 @@ Omit => { return filteredPermNameMap; }; + +/** + * Determines whether a selection has been made for every scope, since by default, the scope permissions are set to null. + * + * @param scopeTuples - The array of scope tuples. + * @returns {boolean} True if all scopes have permissions set to none/read_only/read_write, false otherwise. + */ +export const hasAccessBeenSelectedForAllScopes = ( + scopeTuples: Permission[] +): boolean => { + const validAccessLevels = [ + levelMap['none'], + levelMap['read_only'], + levelMap['read_write'], + ]; + return scopeTuples.every((scopeTuple) => + validAccessLevels.includes(scopeTuple[1]) + ); +}; diff --git a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx index 24ae80d11d3..6407613ecdc 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx @@ -17,6 +17,7 @@ import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; import { useNotificationsQuery } from 'src/queries/account/notifications'; import { useMutateProfile, useProfile } from 'src/queries/profile'; import { ApplicationState } from 'src/store'; +import { sendManageGravatarEvent } from 'src/utilities/analytics'; import { TimezoneForm } from './TimezoneForm'; @@ -111,7 +112,11 @@ export const DisplaySettings = () => { Create, upload, and manage your globally recognized avatar from a single place with Gravatar. - + sendManageGravatarEvent()} + to="https://en.gravatar.com/" + > Manage photo
diff --git a/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx b/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx index b8a5ad9267f..96c21a99b31 100644 --- a/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx +++ b/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx @@ -10,6 +10,7 @@ import { Notice } from 'src/components/Notice/Notice'; import { RenderGuard } from 'src/components/RenderGuard'; import { ShowMoreExpansion } from 'src/components/ShowMoreExpansion'; import { Typography } from 'src/components/Typography'; +import { getIsUDFHeader, getIsUDFMultiSelect, getIsUDFSingleSelect, getIsUDFPasswordField, separateUDFsByRequiredStatus } from 'src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities'; import { AppInfo } from '../../Linodes/LinodesCreate/AppInfo'; import UserDefinedMultiSelect from './FieldTypes/UserDefinedMultiSelect'; @@ -38,7 +39,7 @@ const renderField = ( // if the 'default' key is returned from the API, the field is optional const isOptional = field.hasOwnProperty('default'); - if (isHeader(field)) { + if (getIsUDFHeader(field)) { return ( @@ -47,7 +48,7 @@ const renderField = ( ); } - if (isMultiSelect(field)) { + if (getIsUDFMultiSelect(field)) { return ( ); } - if (isOneSelect(field)) { + if (getIsUDFSingleSelect(field)) { return ( ); } - if (isPasswordField(field.name)) { + if (getIsUDFPasswordField(field)) { const isTokenPassword = field.name === 'token_password'; return ( @@ -238,44 +239,4 @@ const getError = (field: UserDefinedField, errors?: APIError[]) => { return error ? error.reason.replace('the UDF', '') : undefined; }; -const isPasswordField = (udfName: string) => { - return udfName.toLowerCase().includes('password'); -}; - -const isOneSelect = (udf: UserDefinedField) => { - return !!udf.oneof; // if we have a oneof prop, it's a radio button -}; - -const isMultiSelect = (udf: UserDefinedField) => { - return !!udf.manyof; // if we have a manyof prop, it's a checkbox -}; - -const isHeader = (udf: UserDefinedField) => { - return udf.header?.toLowerCase() === 'yes'; -}; - -/** - * Used to separate required UDFs from non-required ones - * - * @return nested array [[...requiredUDFs], [...nonRequiredUDFs]] - */ -const separateUDFsByRequiredStatus = (udfs: UserDefinedField[] = []) => { - return udfs.reduce( - (accum, eachUDF) => { - /** - * if the "default" key exists, it's optional - */ - if ( - eachUDF.hasOwnProperty('default') && - !eachUDF.hasOwnProperty('required') - ) { - return [[...accum[0]], [...accum[1], eachUDF]]; - } else { - return [[...accum[0], eachUDF], [...accum[1]]]; - } - }, - [[], []] - ); -}; - export default RenderGuard(UserDefinedFieldsPanel); diff --git a/packages/manager/src/features/StackScripts/stackScriptUtils.ts b/packages/manager/src/features/StackScripts/stackScriptUtils.ts index 3eeda53dce0..f66cb6420c1 100644 --- a/packages/manager/src/features/StackScripts/stackScriptUtils.ts +++ b/packages/manager/src/features/StackScripts/stackScriptUtils.ts @@ -23,7 +23,6 @@ export const baseApps = { '401698': 'Drupal - Latest One-Click', '401701': 'LAMP One-Click', '401702': 'MERN One-Click', - '401703': 'Rust - Latest One-Click', '401706': 'WireGuard - Latest One-Click', '401707': 'GitLab - Latest One-Click', '401708': 'WooCommerce - Latest One-Click', @@ -34,8 +33,6 @@ export const baseApps = { '604068': 'Shadowsocks - Latest One-Click', '606691': 'LEMP - Latest One-Click', '607026': 'MySQL - Latest One-Click', - '607034': 'Prometheus - Latest One-Click', - '607256': 'Grafana - Latest One-Click', '607401': 'Jenkins - Latest One-Click', '607433': 'Docker - Latest One-Click', '607488': 'Redis One-Click', @@ -46,8 +43,6 @@ export const baseApps = { '611376': 'PostgreSQL One-Click', '611895': 'MEAN One-Click', '632758': 'Nextcloud', - '662116': 'Webmin', - '662117': 'Virtualmin', '662118': 'Azuracast', '662119': 'Plex', '662121': 'Jitsi', @@ -66,7 +61,6 @@ export const baseApps = { '741207': 'Yacht', '741208': 'Zabbix', '774829': 'ServerWand', - '781317': 'Valheim', '804143': 'Peppermint', '804144': 'Ant Media Server', '804172': 'Owncast', @@ -133,9 +127,10 @@ export const baseApps = { '1243780': 'SimpleX Chat', '1298017': 'JupyterLab', '1308539': 'NATS Single Node', - // uncomment after 3/18/24 release - // '1329430': 'Passbolt', - // '1329462': 'LinuxGSM', + '1329430': 'Passbolt', + '1329462': 'LinuxGSM', + '1350733': 'Jitsi Cluster', + '1350783': 'GlusterFS Cluster', }; const oneClickFilter = [ diff --git a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx b/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx index 35f703b238a..1b0b629d373 100644 --- a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx +++ b/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx @@ -27,6 +27,7 @@ import VPCIcon from 'src/assets/icons/entityIcons/vpc.svg'; import { Button } from 'src/components/Button/Button'; import { Divider } from 'src/components/Divider'; import { useIsACLBEnabled } from 'src/features/LoadBalancers/utils'; +import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; import { useFlags } from 'src/hooks/useFlags'; import { useAccount } from 'src/queries/account/account'; import { useDatabaseEnginesQuery } from 'src/queries/databases'; @@ -63,6 +64,7 @@ export const AddNewMenu = () => { (checkRestrictedUser && !enginesLoading && !enginesError); const { isACLBEnabled } = useIsACLBEnabled(); + const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -114,7 +116,7 @@ export const AddNewMenu = () => { { description: "Control your Linodes' physical placement", entity: 'Placement Groups', - hide: !flags.placementGroups?.enabled, + hide: !isPlacementGroupsEnabled, icon: PlacementGroupsIcon, link: '/placement-groups/create', }, diff --git a/packages/manager/src/features/TopMenu/Community.tsx b/packages/manager/src/features/TopMenu/Community.tsx index 6a039e6e074..a128294fefe 100644 --- a/packages/manager/src/features/TopMenu/Community.tsx +++ b/packages/manager/src/features/TopMenu/Community.tsx @@ -1,27 +1,22 @@ +import { IconButton } from '@mui/material'; import * as React from 'react'; import CommunitySVGIcon from 'src/assets/icons/community_nav.svg'; -import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; import { LINODE_COMMUNITY_URL } from 'src/constants'; -import { StyledTopMenuIconWrapper, TopMenuIcon } from './TopMenuIcon'; +import { TopMenuTooltip, topMenuIconButtonSx } from './TopMenuTooltip'; export const Community = () => { - const openInNewTab = (url: string) => { - window.open(url, '_blank', 'noopener noreferrer'); - }; - return ( - - openInNewTab(LINODE_COMMUNITY_URL)} - role="link" + + - - - - - + + + ); }; diff --git a/packages/manager/src/features/TopMenu/Help.tsx b/packages/manager/src/features/TopMenu/Help.tsx index dd8ceaed417..c758e665e5d 100644 --- a/packages/manager/src/features/TopMenu/Help.tsx +++ b/packages/manager/src/features/TopMenu/Help.tsx @@ -1,25 +1,22 @@ +import { IconButton } from '@mui/material'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import HelpSVGIcon from 'src/assets/icons/get_help.svg'; -import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; +import { Link } from 'src/components/Link'; -import { StyledTopMenuIconWrapper, TopMenuIcon } from './TopMenuIcon'; +import { TopMenuTooltip, topMenuIconButtonSx } from './TopMenuTooltip'; export const Help = () => { - const history = useHistory(); - return ( - - history.push('/support')} - role="link" + + - - - - - + + + ); }; diff --git a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx index 15da71881d7..72c033c80ec 100644 --- a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx +++ b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx @@ -1,20 +1,15 @@ -import Popper from '@mui/material/Popper'; -import { styled, useTheme } from '@mui/material/styles'; +import { IconButton } from '@mui/material'; +import Popover from '@mui/material/Popover'; +import { styled } from '@mui/material/styles'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import Bell from 'src/assets/icons/notification.svg'; -import { Button } from 'src/components/Button/Button'; import { Chip } from 'src/components/Chip'; -import { ClickAwayListener } from 'src/components/ClickAwayListener'; -import { WrapperMenuItem } from 'src/components/MenuItem/MenuItem'; -import { MenuList } from 'src/components/MenuList'; -import { Paper } from 'src/components/Paper'; import Events from 'src/features/NotificationCenter/Events'; import { notificationContext as _notificationContext, menuButtonId, - menuId, } from 'src/features/NotificationCenter/NotificationContext'; import { useEventNotifications } from 'src/features/NotificationCenter/NotificationData/useEventNotifications'; import { useFormattedNotifications } from 'src/features/NotificationCenter/NotificationData/useFormattedNotifications'; @@ -24,18 +19,8 @@ import { usePrevious } from 'src/hooks/usePrevious'; import { useNotificationsQuery } from 'src/queries/account/notifications'; import { useMarkEventsAsSeen } from 'src/queries/events/events'; import { ThunkDispatch } from 'src/store/types'; -import { omittedProps } from 'src/utilities/omittedProps'; -import { StyledTopMenuIconWrapper, TopMenuIcon } from '../TopMenuIcon'; - -const NotificationIconWrapper = styled(StyledTopMenuIconWrapper, { - label: 'NotificationIconWrapper', - shouldForwardProp: omittedProps(['isMenuOpen']), -})<{ - isMenuOpen: boolean; -}>(({ ...props }) => ({ - color: props.isMenuOpen ? '#606469' : '#c9c7c7', -})); +import { TopMenuTooltip, topMenuIconButtonSx } from '../TopMenuTooltip'; const StyledChip = styled(Chip)(() => ({ '& .MuiChip-label': { @@ -48,12 +33,10 @@ const StyledChip = styled(Chip)(() => ({ left: 20, padding: 0, position: 'absolute', - top: 1, + top: 4, })); export const NotificationMenu = () => { - const theme = useTheme(); - const { dismissNotifications } = useDismissibleNotifications(); const { data: notifications } = useNotificationsQuery(); const formattedNotifications = useFormattedNotifications(); @@ -78,24 +61,10 @@ export const NotificationMenu = () => { } }; - const handleClose = (event: Event | React.SyntheticEvent) => { - if ( - anchorRef.current && - anchorRef.current.contains(event.target as HTMLElement) - ) { - return; - } - + const handleClose = () => { notificationContext.closeMenu(); }; - const handleMenuListKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Escape') { - notificationContext.closeMenu(); - anchorRef.current!.focus(); // Refocus the notification menu button after the menu has closed. - } - }; - React.useEffect(() => { if (prevOpen && !notificationContext.menuOpen) { // Dismiss seen notifications after the menu has closed. @@ -111,92 +80,59 @@ export const NotificationMenu = () => { notifications, dispatch, prevOpen, + markEventsAsSeen, ]); + const id = notificationContext.menuOpen ? 'notifications-popover' : undefined; + return ( <> - - - - - + {numNotifications > 0 && ( + + )} + + + ({ + maxHeight: 'calc(100vh - 150px)', + maxWidth: 430, + py: 2, + [theme.breakpoints.down('sm')]: { + left: '0 !important', + minWidth: '100%', + right: '0 !important', + }, + }), }, - top: '50px !important', - width: '430px', - zIndex: 3000, }} anchorEl={anchorRef.current} - disablePortal + id={id} + onClose={handleClose} open={notificationContext.menuOpen} - transition > - - - - - - - - - - - + + + ); }; diff --git a/packages/manager/src/features/TopMenu/TopMenu.tsx b/packages/manager/src/features/TopMenu/TopMenu.tsx index ddccb62cd6c..bc5c34fcdc1 100644 --- a/packages/manager/src/features/TopMenu/TopMenu.tsx +++ b/packages/manager/src/features/TopMenu/TopMenu.tsx @@ -14,7 +14,7 @@ import { Community } from './Community'; import { Help } from './Help'; import { NotificationMenu } from './NotificationMenu/NotificationMenu'; import SearchBar from './SearchBar/SearchBar'; -import { TopMenuIcon } from './TopMenuIcon'; +import { TopMenuTooltip } from './TopMenuTooltip'; import { UserMenu } from './UserMenu'; export interface TopMenuProps { @@ -64,7 +64,7 @@ export const TopMenu = React.memo((props: TopMenuProps) => { variant="dense" > - + { > - + - + { > - + diff --git a/packages/manager/src/features/TopMenu/TopMenuIcon.tsx b/packages/manager/src/features/TopMenu/TopMenuIcon.tsx deleted file mode 100644 index 712f2f41512..00000000000 --- a/packages/manager/src/features/TopMenu/TopMenuIcon.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { styled } from '@mui/material/styles'; -import * as React from 'react'; - -import { Tooltip } from 'src/components/Tooltip'; - -interface Props { - children: JSX.Element; - title: string; -} - -export const StyledTopMenuIconWrapper = styled('div')(({ theme }) => ({ - '& svg': { - height: 20, - width: 20, - }, - '&:hover, &:focus': { - color: '#606469', - }, - alignItems: 'center', - backgroundColor: 'inherit', - border: 'none', - color: '#c9c7c7', - cursor: 'pointer', - display: 'flex', - height: '100%', - margin: 0, - outlineOffset: 'initial', - padding: 8, - position: 'relative', - [theme.breakpoints.down(370)]: { - padding: 3, - }, - [theme.breakpoints.up('sm')]: { - padding: '8px 12px', - }, -})); - -export const TopMenuIcon = React.memo(({ children, title }: Props) => { - return ( - -
{children}
-
- ); -}); diff --git a/packages/manager/src/features/TopMenu/TopMenuTooltip.tsx b/packages/manager/src/features/TopMenu/TopMenuTooltip.tsx new file mode 100644 index 00000000000..b03d486105d --- /dev/null +++ b/packages/manager/src/features/TopMenu/TopMenuTooltip.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +import { Tooltip } from 'src/components/Tooltip'; + +import type { Theme } from '@mui/material'; + +interface Props { + children: JSX.Element; + title: string; +} + +export const TopMenuTooltip = React.memo(({ children, title }: Props) => { + return ( + +
{children}
+
+ ); +}); + +export const topMenuIconButtonSx = (theme: Theme) => ({ + '&:hover, &:focus': { + color: '#606469', + }, + color: '#c9c7c7', + [theme.breakpoints.down('sm')]: { + padding: 1, + }, +}); diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index 3dc5c540da8..7c4cadd0288 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -225,7 +225,7 @@ export const UserMenu = React.memo(() => { isProxyUser ? ( ) : ( - + ) } sx={(theme) => ({ diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx index 64f5e071639..2a8a854ca64 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx @@ -17,8 +17,8 @@ import { RemovableSelectionsListTable } from 'src/components/RemovableSelections import { TextField } from 'src/components/TextField'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { defaultPublicInterface } from 'src/features/Linodes/LinodesCreate/LinodeCreate'; +import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { VPC_AUTO_ASSIGN_IPV4_TOOLTIP, VPC_MULTIPLE_CONFIGURATIONS_LEARN_MORE_LINK, @@ -200,7 +200,6 @@ export const SubnetAssignLinodesDrawer = ( await invalidateQueries({ configId, linodeId: selectedLinode?.id ?? -1, - subnetId: subnet?.id ?? -1, vpcId, }); } catch (errors) { @@ -244,7 +243,6 @@ export const SubnetAssignLinodesDrawer = ( configId, interfaceId: interfaceData?.id ?? -1, linodeId, - subnetId: subnet?.id ?? -1, vpcId, }); } catch (errors) { diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx index d7e613b0157..3bd1be7a28a 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx @@ -6,7 +6,7 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { useGrants, useProfile } from 'src/queries/profile'; -import { useCreateSubnetMutation, useVPCQuery } from 'src/queries/vpcs'; +import { useCreateSubnetMutation, useVPCQuery } from 'src/queries/vpcs/vpcs'; import { getErrorMap } from 'src/utilities/errorUtils'; import { DEFAULT_SUBNET_IPV4_VALUE, diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDeleteDialog.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDeleteDialog.tsx index f8e0abfe9ad..fe1873b20f0 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetDeleteDialog.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDeleteDialog.tsx @@ -3,7 +3,7 @@ import { useSnackbar } from 'notistack'; import * as React from 'react'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; -import { useDeleteSubnetMutation } from 'src/queries/vpcs'; +import { useDeleteSubnetMutation } from 'src/queries/vpcs/vpcs'; interface Props { onClose: () => void; diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx index 1fa9536e49d..e90b6bc1a83 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx @@ -8,7 +8,7 @@ import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; import { useGrants, useProfile } from 'src/queries/profile'; -import { useUpdateSubnetMutation } from 'src/queries/vpcs'; +import { useUpdateSubnetMutation } from 'src/queries/vpcs/vpcs'; import { getErrorMap } from 'src/utilities/errorUtils'; interface Props { diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx index bee06841f95..ed4fa97d7d6 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx @@ -1,8 +1,8 @@ import { Subnet } from '@linode/api-v4/lib/vpcs/types'; import { Stack, Typography } from '@mui/material'; +import { useQueryClient } from '@tanstack/react-query'; import { useFormik } from 'formik'; import * as React from 'react'; -import { useQueryClient } from '@tanstack/react-query'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; @@ -225,7 +225,6 @@ export const SubnetUnassignLinodesDrawer = React.memo( configId: _interface.configId, interfaceId: _interface.interfaceId, linodeId: _interface.linodeId, - subnetId: subnetId ?? -1, vpcId, }); } catch (error) { diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx index ce5e4ceca74..93957c50c98 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx @@ -13,7 +13,7 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; import { VPC_DOCS_LINK, VPC_LABEL } from 'src/features/VPCs/constants'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { useVPCQuery } from 'src/queries/vpcs'; +import { useVPCQuery } from 'src/queries/vpcs/vpcs'; import { truncate } from 'src/utilities/truncate'; import { VPCDeleteDialog } from '../VPCLanding/VPCDeleteDialog'; diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx index 88b46e2a178..2652d929870 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx @@ -23,7 +23,7 @@ import { PowerActionsDialog } from 'src/features/Linodes/PowerActionsDialogOrDra import { SubnetActionMenu } from 'src/features/VPCs/VPCDetail/SubnetActionMenu'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; -import { useSubnetsQuery } from 'src/queries/vpcs'; +import { useSubnetsQuery } from 'src/queries/vpcs/vpcs'; import { SubnetAssignLinodesDrawer } from './SubnetAssignLinodesDrawer'; import { SubnetCreateDrawer } from './SubnetCreateDrawer'; diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCDeleteDialog.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCDeleteDialog.tsx index eb0411fe17e..6eac860e1bd 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCDeleteDialog.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCDeleteDialog.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { useHistory } from 'react-router-dom'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; -import { useDeleteVPCMutation } from 'src/queries/vpcs'; +import { useDeleteVPCMutation } from 'src/queries/vpcs/vpcs'; interface Props { id?: number; diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx index 1efe064dc19..83de5473f36 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx @@ -10,7 +10,7 @@ import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { TextField } from 'src/components/TextField'; import { useGrants, useProfile } from 'src/queries/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { useUpdateVPCMutation } from 'src/queries/vpcs'; +import { useUpdateVPCMutation } from 'src/queries/vpcs/vpcs'; import { getErrorMap } from 'src/utilities/errorUtils'; interface Props { diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx index d07bf67508a..423e4ac7f67 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx @@ -16,7 +16,7 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { VPC_DOCS_LINK, VPC_LABEL } from 'src/features/VPCs/constants'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; -import { useVPCsQuery } from 'src/queries/vpcs'; +import { useVPCsQuery } from 'src/queries/vpcs/vpcs'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { VPCDeleteDialog } from './VPCDeleteDialog'; diff --git a/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx b/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx index b886b720a7e..f6c98de9dd9 100644 --- a/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx @@ -10,14 +10,17 @@ import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { useEventsPollingActions } from 'src/queries/events/events'; import { useGrants } from 'src/queries/profile'; -import { useCloneVolumeMutation } from 'src/queries/volumes'; +import { + useCloneVolumeMutation, + useVolumeTypesQuery, +} from 'src/queries/volumes'; import { handleFieldErrors, handleGeneralErrors, } from 'src/utilities/formikErrorUtils'; +import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; import { PricePanel } from './VolumeDrawer/PricePanel'; - interface Props { onClose: () => void; open: boolean; @@ -34,6 +37,7 @@ export const CloneVolumeDrawer = (props: Props) => { const { checkForNewEvents } = useEventsPollingActions(); const { data: grants } = useGrants(); + const { data: types, isError, isLoading } = useVolumeTypesQuery(); // Even if a restricted user has the ability to create Volumes, they // can't clone a Volume they only have read only permission on. @@ -42,6 +46,8 @@ export const CloneVolumeDrawer = (props: Props) => { grants.volume.find((grant) => grant.id === volume?.id)?.permissions === 'read_only'; + const isInvalidPrice = !types || isError; + const { errors, handleBlur, @@ -109,9 +115,13 @@ export const CloneVolumeDrawer = (props: Props) => { /> { const { enqueueSnackbar } = useSnackbar(); const { data: grants } = useGrants(); + const { data: types, isError, isLoading } = useVolumeTypesQuery(); const isReadOnly = grants !== undefined && grants.volume.find((grant) => grant.id === volume?.id)?.permissions === 'read_only'; + const isInvalidPrice = !types || isError; + const { dirty, errors, @@ -113,9 +120,13 @@ export const ResizeVolumeDrawer = (props: Props) => { /> { const { classes } = useStyles(); const history = useHistory(); + const { data: types, isError, isLoading } = useVolumeTypesQuery(); + const { data: profile } = useProfile(); const { data: grants } = useGrants(); @@ -215,8 +221,12 @@ export const VolumeCreate = () => { selectedRegionId: values.region, }); + const isInvalidPrice = !types || isError; + const disabled = Boolean( - doesNotHavePermission || (showGDPRCheckbox && !hasSignedAgreement) + doesNotHavePermission || + (showGDPRCheckbox && !hasSignedAgreement) || + isInvalidPrice ); const handleLinodeChange = (linode: Linode | null) => { @@ -396,6 +406,11 @@ export const VolumeCreate = () => {