diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ab595b6619..9be0e917dca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,6 +185,21 @@ jobs: - run: yarn --frozen-lockfile - run: yarn workspace linode-manager run test + test-search: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: "18.14" + - uses: actions/cache@v3 + with: + path: | + **/node_modules + key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + - run: yarn --frozen-lockfile + - run: yarn workspace @linode/search run test + typecheck-manager: runs-on: ubuntu-latest needs: diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 75ab194a0c6..9af57c6599f 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,13 @@ +## [2024-07-08] - v0.121.0 + +### Changed: + +- Update `updateImageRegions` to accept `UpdateImageRegionsPayload` instead of `regions: string[]` ([#10617](https://github.com/linode/manager/pull/10617)) + +### Upcoming Features: + +- Added types needed for DashboardSelect component ([#10589](https://github.com/linode/manager/pull/10589)) + ## [2024-06-24] - v0.120.0 ### Added: diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 99d23eaffec..792784321bb 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.120.0", + "version": "0.121.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/cloudpulse/dashboards.ts b/packages/api-v4/src/cloudpulse/dashboards.ts new file mode 100644 index 00000000000..46755363f44 --- /dev/null +++ b/packages/api-v4/src/cloudpulse/dashboards.ts @@ -0,0 +1,11 @@ +import { ResourcePage } from 'src/types'; +import Request, { setMethod, setURL } from '../request'; +import { Dashboard } from './types'; +import { API_ROOT } from 'src/constants'; + +//Returns the list of all the dashboards available +export const getDashboards = () => + Request>( + setURL(`${API_ROOT}/monitor/services/linode/dashboards`), + setMethod('GET') + ); diff --git a/packages/api-v4/src/cloudpulse/index.ts b/packages/api-v4/src/cloudpulse/index.ts new file mode 100644 index 00000000000..25a3879e494 --- /dev/null +++ b/packages/api-v4/src/cloudpulse/index.ts @@ -0,0 +1,3 @@ +export * from './types'; + +export * from './dashboards'; diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts new file mode 100644 index 00000000000..6f917202959 --- /dev/null +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -0,0 +1,45 @@ +export interface Dashboard { + id: number; + label: string; + widgets: Widgets[]; + created: string; + updated: string; + time_duration: TimeDuration; + service_type: string; +} + +export interface TimeGranularity { + unit: string; + value: number; +} + +export interface TimeDuration { + unit: string; + value: number; +} + +export interface Widgets { + label: string; + metric: string; + aggregate_function: string; + group_by: string; + region_id: number; + namespace_id: number; + color: string; + size: number; + chart_type: string; + y_label: string; + filters: Filters[]; + serviceType: string; + service_type: string; + resource_id: string[]; + time_granularity: TimeGranularity; + time_duration: TimeDuration; + unit: string; +} + +export interface Filters { + key: string; + operator: string; + value: string; +} diff --git a/packages/api-v4/src/images/images.ts b/packages/api-v4/src/images/images.ts index b012fe396fa..110f19158ed 100644 --- a/packages/api-v4/src/images/images.ts +++ b/packages/api-v4/src/images/images.ts @@ -18,6 +18,7 @@ import type { Image, ImageUploadPayload, UpdateImagePayload, + UpdateImageRegionsPayload, UploadImageResponse, } from './types'; @@ -99,16 +100,14 @@ export const uploadImage = (data: ImageUploadPayload) => { }; /** - * Selects the regions to which this image will be replicated. + * updateImageRegions * - * @param imageId { string } ID of the Image to look up. - * @param regions { string[] } ID of regions to replicate to. Must contain at least one valid region. + * Selects the regions to which this image will be replicated. */ -export const updateImageRegions = (imageId: string, regions: string[]) => { - const data = { - regions, - }; - +export const updateImageRegions = ( + imageId: string, + data: UpdateImageRegionsPayload +) => { return Request( setURL(`${API_ROOT}/images/${encodeURIComponent(imageId)}/regions`), setMethod('POST'), diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index e25fb28f9a2..cd3b34db673 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -8,7 +8,7 @@ export type ImageCapabilities = 'cloud-init' | 'distributed-images'; type ImageType = 'manual' | 'automatic'; -type ImageRegionStatus = +export type ImageRegionStatus = | 'creating' | 'pending' | 'available' @@ -154,3 +154,10 @@ export interface ImageUploadPayload extends BaseImagePayload { label: string; region: string; } + +export interface UpdateImageRegionsPayload { + /** + * An array of region ids + */ + regions: string[]; +} diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index 8de3cbfcf6f..ae104c76a8b 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -2,6 +2,8 @@ export * from './account'; export * from './aclb'; +export * from './cloudpulse'; + export * from './databases'; export * from './domains'; diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index 5df1994d2d9..ab102674177 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -6,6 +6,7 @@ export type Capabilities = | 'Block Storage Migrations' | 'Cloud Firewall' | 'Disk Encryption' + | 'Distributed Plans' | 'GPU Linodes' | 'Kubernetes' | 'Linodes' diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 220f3ccf276..1edbc296bda 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,60 @@ 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-07-08] - v1.123.0 + +### Added: + +- Design Tokens (CDS 2.0) ([#10022](https://github.com/linode/manager/pull/10022)) +- Design update dismissible banner ([#10640](https://github.com/linode/manager/pull/10640)) + +### Changed: + +- Rebuild Linode drawer ([#10594](https://github.com/linode/manager/pull/10594)) +- Auto-populate Image label based on Linode and Disk names ([#10604](https://github.com/linode/manager/pull/10604)) +- Update Linode disk action menu ([#10614](https://github.com/linode/manager/pull/10614)) + +### Fixed: + +- Potential runtime issue with conditional hook ([#10584](https://github.com/linode/manager/pull/10584)) +- Visual bug inside Node Pools table ([#10599](https://github.com/linode/manager/pull/10599)) +- Linode Resize dialog UX when linode data is loading or there is an error ([#10618](https://github.com/linode/manager/pull/10618)) + +### Removed: + +- Region helper text on the Image Upload page ([#10642](https://github.com/linode/manager/pull/10642)) + +### Tech Stories: + +- Refactor `SupportTicketDialog` with React Hook Form ([#10557](https://github.com/linode/manager/pull/10557)) +- Query Key Factory for ACLB ([#10598](https://github.com/linode/manager/pull/10598)) +- Make `Factory.each` start incrementing at 1 instead of 0 ([#10619](https://github.com/linode/manager/pull/10619)) + +### Tests: + +- Cypress integration test for SSH key update and delete ([#10542](https://github.com/linode/manager/pull/10542)) +- Refactor Cypress Longview test to use mock API data/events ([#10579](https://github.com/linode/manager/pull/10579)) +- Add assertions for created LKE cluster in Cypress LKE tests ([#10593](https://github.com/linode/manager/pull/10593)) +- Update Object Storage tests to mock account capabilities as needed for Multicluster ([#10602](https://github.com/linode/manager/pull/10602)) +- Fix OBJ test failure caused by visiting hardcoded and out-of-date URL ([#10609](https://github.com/linode/manager/pull/10609)) +- Combine VPC details page subnet create, edit, and delete Cypress tests ([#10612](https://github.com/linode/manager/pull/10612)) +- De-parameterize Cypress Domain Record Create tests ([#10615](https://github.com/linode/manager/pull/10615)) +- De-parameterize Cypress Deep Link smoke tests ([#10622](https://github.com/linode/manager/pull/10622)) +- Improve security of Linodes created during tests ([#10633](https://github.com/linode/manager/pull/10633)) + +### Upcoming Features: + +- Gecko GA Region Select ([#10479](https://github.com/linode/manager/pull/10479)) +- Add Dashboard Selection component inside the Global Filters of CloudPulse view ([#10589](https://github.com/linode/manager/pull/10589)) +- Conditionally disable regions based on the selected image on Linode Create ([#10607](https://github.com/linode/manager/pull/10607)) +- Prevent Linode Create v2 from toggling mid-creation ([#10611](https://github.com/linode/manager/pull/10611)) +- Add new search query parser to Linode Create v2 StackScripts tab ([#10613](https://github.com/linode/manager/pull/10613)) +- Add ‘Manage Image Regions’ Drawer ([#10617](https://github.com/linode/manager/pull/10617)) +- Add Marketplace Cluster pricing support to Linode Create v2 ([#10623](https://github.com/linode/manager/pull/10623)) +- Add debouncing to the Linode Create v2 `VLANSelect` ([#10628](https://github.com/linode/manager/pull/10628)) +- Add Validation to Linode Create v2 Marketplace Tab ([#10629](https://github.com/linode/manager/pull/10629)) +- Add Image distributed compatibility notice to Linode Create ([#10636](https://github.com/linode/manager/pull/10636)) + ## [2024-06-24] - v1.122.0 ### Added: diff --git a/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts b/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts index 59b34a59101..d0cf29ac00d 100644 --- a/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts +++ b/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts @@ -2,7 +2,9 @@ import { sshKeyFactory } from 'src/factories'; import { mockCreateSSHKey, mockCreateSSHKeyError, + mockDeleteSSHKey, mockGetSSHKeys, + mockUpdateSSHKey, } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; @@ -169,4 +171,181 @@ describe('SSH keys', () => { // When the API responds with an error (e.g. a 400 response), the API response error message is displayed on the form cy.findByText(errorMessage); }); + + /* + * - Validates SSH key update flow using mock data. + * - Confirms that the drawer opens when clicking. + * - Confirms that a form validation error appears when the label is not present. + * - Confirms UI flow when user updates an SSH key. + */ + it('updates an SSH key via Profile page as expected', () => { + const randomKey = randomString(400, { + uppercase: true, + lowercase: true, + numbers: true, + spaces: false, + symbols: false, + }); + const mockSSHKey = sshKeyFactory.build({ + label: randomLabel(), + ssh_key: `ssh-rsa e2etestkey${randomKey} e2etest@linode`, + }); + const newSSHKeyLabel = randomLabel(); + const modifiedSSHKey = sshKeyFactory.build({ + ...mockSSHKey, + label: newSSHKeyLabel, + }); + + mockGetSSHKeys([mockSSHKey]).as('getSSHKeys'); + + // Navigate to SSH key landing page. + cy.visitWithLogin('/profile/keys'); + cy.wait('@getSSHKeys'); + + // When a user clicks "Edit" button on SSH key landing page (/profile/keys), the "Edit SSH Key" drawer opens + ui.button + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled') + .click(); + ui.drawer + .findByTitle(`Edit SSH Key ${mockSSHKey.label}`) + .should('be.visible') + .within(() => { + // When the label is unchanged, the 'Save' button is diabled + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.disabled'); + + // When a user tries to update an SSH key without a label, a form validation error appears + cy.get('[id="label"]').clear(); + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Label is required.'); + + // SSH label is not modified when the operation is cancelled + cy.get('[id="label"]').clear().type(newSSHKeyLabel); + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + cy.findAllByText(mockSSHKey.label).should('be.visible'); + + mockGetSSHKeys([modifiedSSHKey]).as('getSSHKeys'); + mockUpdateSSHKey(mockSSHKey.id, modifiedSSHKey).as('updateSSHKey'); + + ui.button + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled') + .click(); + ui.drawer + .findByTitle(`Edit SSH Key ${mockSSHKey.label}`) + .should('be.visible') + .within(() => { + // Update a new ssh key + cy.get('[id="label"]').clear().type(newSSHKeyLabel); + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@updateSSHKey', '@getSSHKeys']); + + // When a user updates an SSH key, a toast notification appears that says "Successfully updated SSH key." + ui.toast.assertMessage('Successfully updated SSH key.'); + + // When a user updates an SSH key, the list of SSH keys for each user updates to show the new key for the signed in user + cy.findAllByText(modifiedSSHKey.label).should('be.visible'); + }); + + /* + * - Vaildates SSH key delete flow using mock data. + * - Confirms that the dialog opens when clicking. + * - Confirms UI flow when user deletes an SSH key. + */ + it('deletes an SSH key via Profile page as expected', () => { + const mockSSHKeys = sshKeyFactory.buildList(2); + + mockGetSSHKeys(mockSSHKeys).as('getSSHKeys'); + + // Navigate to SSH key landing page. + cy.visitWithLogin('/profile/keys'); + cy.wait('@getSSHKeys'); + + mockDeleteSSHKey(mockSSHKeys[0].id).as('deleteSSHKey'); + mockGetSSHKeys([mockSSHKeys[1]]).as('getUpdatedSSHKeys'); + + // When a user clicks "Delete" button on SSH key landing page (/profile/keys), the "Delete SSH Key" dialog opens + cy.findAllByText(`${mockSSHKeys[0].label}`) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + ui.dialog + .findByTitle('Delete SSH Key') + .should('be.visible') + .within(() => { + cy.findAllByText( + `Are you sure you want to delete SSH key ${mockSSHKeys[0].label}?` + ).should('be.visible'); + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@deleteSSHKey', '@getUpdatedSSHKeys']); + + // When a user deletes an SSH key, the SSH key is removed from the list + cy.findAllByText(mockSSHKeys[0].label).should('not.exist'); + + mockDeleteSSHKey(mockSSHKeys[1].id).as('deleteSSHKey'); + mockGetSSHKeys([]).as('getUpdatedSSHKeys'); + + // When a user clicks "Delete" button on SSH key landing page (/profile/keys), the "Delete SSH Key" dialog opens + cy.findAllByText(`${mockSSHKeys[1].label}`) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + ui.dialog + .findByTitle('Delete SSH Key') + .should('be.visible') + .within(() => { + cy.findAllByText( + `Are you sure you want to delete SSH key ${mockSSHKeys[1].label}?` + ).should('be.visible'); + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@deleteSSHKey', '@getUpdatedSSHKeys']); + + // When a user deletes the last SSH key, the list of SSH keys updates to show "No items to display." + cy.findAllByText(mockSSHKeys[1].label).should('not.exist'); + cy.findAllByText('No items to display.').should('be.visible'); + }); }); diff --git a/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts index 379386f9258..3b1cb74e422 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts @@ -1,40 +1,33 @@ -/* eslint-disable sonarjs/no-duplicate-string */ import { authenticate } from 'support/api/authentication'; import { createDomain } from 'support/api/domains'; -import { fbtClick, getClick } from 'support/helpers'; import { interceptCreateDomainRecord } from 'support/intercepts/domains'; -import { cleanUp } from 'support/util/cleanup'; import { createDomainRecords } from 'support/constants/domains'; authenticate(); -describe('Creates Domains record with Form', () => { - before(() => { - cleanUp('domains'); - }); - createDomainRecords().forEach((rec) => { - return it(rec.name, () => { - createDomain().then((domain) => { - // intercept create api record request - interceptCreateDomainRecord().as('apiCreateRecord'); - const url = `/domains/${domain.id}`; - cy.visitWithLogin(url); - cy.url().should('contain', url); - fbtClick(rec.name); - rec.fields.forEach((f) => { - getClick(f.name).type(f.value); - }); - fbtClick('Save'); - cy.wait('@apiCreateRecord') - .its('response.statusCode') - .should('eq', 200); - cy.get(`[aria-label="${rec.tableAriaLabel}"]`).within((_table) => { - rec.fields.forEach((f) => { - if (f.skipCheck) { - return; - } - cy.findByText(f.value, { exact: !f.approximate }); - }); +describe('Creates Domains records with Form', () => { + it('Adds domain records to a newly created Domain', () => { + createDomain().then((domain) => { + // intercept create api record request + interceptCreateDomainRecord().as('apiCreateRecord'); + const url = `/domains/${domain.id}`; + cy.visitWithLogin(url); + cy.url().should('contain', url); + }); + + createDomainRecords().forEach((rec) => { + cy.findByText(rec.name).click(); + rec.fields.forEach((field) => { + cy.get(field.name).type(field.value); + }); + cy.findByText('Save').click(); + cy.wait('@apiCreateRecord').its('response.statusCode').should('eq', 200); + cy.get(`[aria-label="${rec.tableAriaLabel}"]`).within((_table) => { + rec.fields.forEach((field) => { + if (field.skipCheck) { + return; + } + cy.findByText(field.value, { exact: !field.approximate }); }); }); }); diff --git a/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts b/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts index 1b7316d292c..bc9f2af2951 100644 --- a/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts +++ b/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts @@ -2,29 +2,19 @@ import { pages } from 'support/ui/constants'; import type { Page } from 'support/ui/constants'; -describe('smoke - deep link', () => { - pages.forEach((page: Page) => { - describe(`Go to ${page.name}`, () => { - // check if we run only one test - if (!page.goWithUI) { - return; - } - - // Here we use login to /null here - // so this is independant from what is coded in constants and which path are skipped - beforeEach(() => { - cy.visitWithLogin('/null'); - }); +describe('smoke - deep links', () => { + beforeEach(() => { + cy.visitWithLogin('/null'); + }); - page.goWithUI.forEach((uiPath) => { - (page.first ? it.only : page.skip ? it.skip : it)( - `by ${uiPath.name}`, - () => { - expect(uiPath.name).not.to.be.empty; - uiPath.go(); - cy.url().should('be.eq', `${Cypress.config('baseUrl')}${page.url}`); - } - ); + it('Go to each route and validate deep links', () => { + pages.forEach((page: Page) => { + cy.log(`Go to ${page.name}`); + page.goWithUI?.forEach((uiPath) => { + cy.log(`by ${uiPath.name}`); + expect(uiPath.name).not.to.be.empty; + uiPath.go(); + cy.url().should('be.eq', `${Cypress.config('baseUrl')}${page.url}`); }); }); }); diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts index de1525a929d..95af9d0e48a 100644 --- a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts +++ b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts @@ -1,11 +1,5 @@ /* eslint-disable prettier/prettier */ /* eslint-disable sonarjs/no-duplicate-string */ -import { - getClick, - containsClick, - getVisible, - containsVisible, -} from 'support/helpers'; import 'cypress-file-upload'; import { interceptGetProfile } from 'support/intercepts/profile'; import { @@ -19,8 +13,14 @@ import { randomLabel, randomNumber, randomPhrase, + randomString, } from 'support/util/random'; -import { supportTicketFactory } from 'src/factories'; +import { + accountFactory, + domainFactory, + linodeFactory, + supportTicketFactory, +} from 'src/factories'; import { mockAttachSupportTicketFile, mockCreateSupportTicket, @@ -28,14 +28,38 @@ import { mockGetSupportTickets, mockGetSupportTicketReplies, } from 'support/intercepts/support'; -import { severityLabelMap } from 'src/features/Support/SupportTickets/ticketUtils'; +import { + SEVERITY_LABEL_MAP, + SMTP_DIALOG_TITLE, + SMTP_FIELD_NAME_TO_LABEL_MAP, + SMTP_HELPER_TEXT, +} from 'src/features/Support/SupportTickets/constants'; +import { formatDescription } from 'src/features/Support/SupportTickets/ticketUtils'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + EntityType, + TicketType, +} from 'src/features/Support/SupportTickets/SupportTicketDialog'; +import { createTestLinode } from 'support/util/linodes'; +import { cleanUp } from 'support/util/cleanup'; +import { authenticate } from 'support/api/authentication'; +import { MAGIC_DATE_THAT_EMAIL_RESTRICTIONS_WERE_IMPLEMENTED } from 'src/constants'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { mockGetDomains } from 'support/intercepts/domains'; +import { mockGetClusters } from 'support/intercepts/lke'; describe('help & support', () => { + after(() => { + cleanUp(['linodes']); + }); + + authenticate(); + /* * - Opens a Help & Support ticket using mock API data. * - Confirms that "Severity" field is not present when feature flag is disabled. */ - it('open support ticket', () => { + it('can open a support ticket', () => { mockAppendFeatureFlags({ supportTicketSeverity: makeFeatureFlagData(false), }); @@ -78,25 +102,27 @@ describe('help & support', () => { mockGetSupportTicketReplies(ticketId, []).as('getReplies'); mockAttachSupportTicketFile(ticketId).as('attachmentPost'); - containsClick('Open New Ticket'); + cy.contains('Open New Ticket').click(); cy.get('input[placeholder="Enter a title for your ticket."]') .click({ scrollBehavior: false }) .type(ticketLabel); cy.findByLabelText('Severity').should('not.exist'); - getClick('[data-qa-ticket-entity-type]'); - containsVisible('General/Account/Billing'); - getClick('[data-qa-ticket-description="true"]').type(ticketDescription); + cy.get('[data-qa-ticket-entity-type]').click(); + cy.contains('General/Account/Billing').should('be.visible'); + cy.get('[data-qa-ticket-description="true"]') + .click() + .type(ticketDescription); cy.get('[id="attach-file"]').attachFile(image); - getVisible('[value="test_screenshot.png"]'); - getClick('[data-qa-submit="true"]'); + cy.get('[value="test_screenshot.png"]').should('be.visible'); + cy.get('[data-qa-submit="true"]').click(); cy.wait('@createTicket').its('response.statusCode').should('eq', 200); cy.wait('@attachmentPost').its('response.statusCode').should('eq', 200); cy.wait('@getReplies').its('response.statusCode').should('eq', 200); - containsVisible(`#${ticketId}: ${ticketLabel}`); - containsVisible(ticketDescription); - containsVisible(image); + cy.contains(`#${ticketId}: ${ticketLabel}`).should('be.visible'); + cy.contains(ticketDescription).should('be.visible'); + cy.contains(image).should('be.visible'); }); }); @@ -117,7 +143,7 @@ describe('help & support', () => { // Get severity label for numeric severity level. // Bail out if we're unable to get a valid label -- this indicates a mismatch between the test and source. - const severityLabel = severityLabelMap.get(mockTicket.severity!); + const severityLabel = SEVERITY_LABEL_MAP.get(mockTicket.severity!); if (!severityLabel) { throw new Error( `Unable to retrieve label for severity level '${mockTicket.severity}'. Is this a valid support severity level?` @@ -188,4 +214,228 @@ describe('help & support', () => { cy.findByText(severityLabel).should('be.visible'); }); }); + + /* + * - Opens a SMTP Restriction Removal ticket using mock API data. + * - Creates a new linode that will have SMTP restrictions and navigates to a SMTP support ticket via notice link. + * - Confirms that the SMTP-specific fields are displayed and handled correctly. + */ + it('can create an SMTP support ticket', () => { + const mockAccount = accountFactory.build({ + first_name: 'Jane', + last_name: 'Doe', + company: 'Acme Co.', + active_since: MAGIC_DATE_THAT_EMAIL_RESTRICTIONS_WERE_IMPLEMENTED, + }); + + const mockFormFields = { + description: '', + entityId: '', + entityInputValue: '', + entityType: 'general' as EntityType, + selectedSeverity: undefined, + summary: 'SMTP Restriction Removal on ', + ticketType: 'smtp' as TicketType, + companyName: mockAccount.company, + customerName: `${mockAccount.first_name} ${mockAccount.last_name}`, + useCase: randomString(), + emailDomains: randomString(), + publicInfo: randomString(), + }; + + const mockSMTPTicket = supportTicketFactory.build({ + summary: mockFormFields.summary, + id: randomNumber(), + description: formatDescription(mockFormFields, 'smtp'), + status: 'new', + }); + + mockGetAccount(mockAccount); + mockCreateSupportTicket(mockSMTPTicket).as('createTicket'); + mockGetSupportTickets([]); + mockGetSupportTicket(mockSMTPTicket); + mockGetSupportTicketReplies(mockSMTPTicket.id, []); + + cy.visitWithLogin('/support/tickets'); + + cy.defer(() => createTestLinode({ booted: true })).then((linode) => { + cy.visitWithLogin(`/linodes/${linode.id}`); + cy.findByText('open a support ticket').should('be.visible').click(); + + // Fill out ticket form. + ui.dialog + .findByTitle('Contact Support: SMTP Restriction Removal') + .should('be.visible') + .within(() => { + cy.findByText(SMTP_DIALOG_TITLE).should('be.visible'); + cy.findByText(SMTP_HELPER_TEXT).should('be.visible'); + + // Confirm summary, customer name, and company name fields are pre-populated with user account data. + cy.findByLabelText('Title', { exact: false }) + .should('be.visible') + .should('have.value', mockFormFields.summary + linode.label); + + cy.findByLabelText('First and last name', { exact: false }) + .should('be.visible') + .should('have.value', mockFormFields.customerName); + + cy.findByLabelText('Business or company name', { exact: false }) + .should('be.visible') + .should('have.value', mockFormFields.companyName); + + ui.button + .findByTitle('Open Ticket') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm validation errors display when trying to submit without required fields. + cy.findByText('Use case is required.'); + cy.findByText('Email domains are required.'); + cy.findByText('Links to public information are required.'); + + // Complete the rest of the form. + cy.get('[data-qa-ticket-use-case]') + .should('be.visible') + .click() + .type(mockFormFields.useCase); + + cy.get('[data-qa-ticket-email-domains]') + .should('be.visible') + .click() + .type(mockFormFields.emailDomains); + + cy.get('[data-qa-ticket-public-info]') + .should('be.visible') + .click() + .type(mockFormFields.publicInfo); + + // Confirm there is no description field or file upload section. + cy.findByText('Description').should('not.exist'); + cy.findByText('Attach a File').should('not.exist'); + + ui.button + .findByTitle('Open Ticket') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that ticket create payload contains the expected data. + cy.wait('@createTicket').then((xhr) => { + expect(xhr.request.body?.summary).to.eq( + mockSMTPTicket.summary + linode.label + ); + expect(xhr.request.body?.description).to.eq(mockSMTPTicket.description); + }); + + // Confirm the new ticket is listed with the expected information upon redirecting to the details page. + cy.url().should('endWith', `support/tickets/${mockSMTPTicket.id}`); + cy.contains(`#${mockSMTPTicket.id}: SMTP Restriction Removal`).should( + 'be.visible' + ); + Object.values(SMTP_FIELD_NAME_TO_LABEL_MAP).forEach((fieldLabel) => { + cy.findByText(fieldLabel).should('be.visible'); + }); + }); + }); + + it('can create a support ticket with an entity', () => { + const mockLinodes = linodeFactory.buildList(2); + const mockDomain = domainFactory.build(); + + const mockTicket = supportTicketFactory.build({ + id: randomNumber(), + summary: randomLabel(), + description: randomPhrase(), + status: 'new', + }); + + mockCreateSupportTicket(mockTicket).as('createTicket'); + mockGetClusters([]); + mockGetSupportTickets([]); + mockGetSupportTicket(mockTicket); + mockGetSupportTicketReplies(mockTicket.id, []); + mockGetLinodes(mockLinodes); + mockGetDomains([mockDomain]); + + cy.visitWithLogin('/support/tickets'); + + ui.button + .findByTitle('Open New Ticket') + .should('be.visible') + .should('be.enabled') + .click(); + + // Fill out ticket form. + ui.dialog + .findByTitle('Open a Support Ticket') + .should('be.visible') + .within(() => { + cy.findByLabelText('Title', { exact: false }) + .should('be.visible') + .click() + .type(mockTicket.summary); + + cy.get('[data-qa-ticket-description]') + .should('be.visible') + .click() + .type(mockTicket.description); + + cy.get('[data-qa-ticket-entity-type]') + .click() + .type(`Linodes{downarrow}{enter}`); + + // Attempt to submit the form without an entity selected and confirm validation error. + ui.button + .findByTitle('Open Ticket') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Please select a Linode.').should('be.visible'); + + // Select an entity type for which there are no entities. + cy.get('[data-qa-ticket-entity-type]') + .click() + .type(`Kubernetes{downarrow}{enter}`); + + // Confirm the validation error clears when a new entity type is selected. + cy.findByText('Please select a Linode.').should('not.exist'); + + // Confirm helper text appears and entity id field is disabled. + cy.findByText( + 'You don’t have any Kubernetes Clusters on your account.' + ).should('be.visible'); + cy.get('[data-qa-ticket-entity-id]') + .find('input') + .should('be.disabled'); + + // Select another entity type. + cy.get('[data-qa-ticket-entity-type]') + .click() + .type(`{selectall}{del}Domains{uparrow}{enter}`); + + // Select an entity. + cy.get('[data-qa-ticket-entity-id]') + .should('be.visible') + .click() + .type(`${mockDomain.domain}{downarrow}{enter}`); + + ui.button + .findByTitle('Open Ticket') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that ticket create payload contains the expected data. + cy.wait('@createTicket').then((xhr) => { + expect(xhr.request.body?.summary).to.eq(mockTicket.summary); + expect(xhr.request.body?.description).to.eq(mockTicket.description); + }); + + // Confirm redirect to details page and that severity level is displayed. + cy.url().should('endWith', `support/tickets/${mockTicket.id}`); + }); }); diff --git a/packages/manager/cypress/e2e/core/images/create-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-image.spec.ts index 15782fb004e..ac1b6e794ba 100644 --- a/packages/manager/cypress/e2e/core/images/create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-image.spec.ts @@ -94,6 +94,7 @@ describe('create image (e2e)', () => { cy.findByLabelText('Label') .should('be.enabled') .should('be.visible') + .clear() .type(label); // Give the Image a description diff --git a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts new file mode 100644 index 00000000000..663125cd190 --- /dev/null +++ b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts @@ -0,0 +1,213 @@ +import { imageFactory, regionFactory } from 'src/factories'; +import { + mockGetCustomImages, + mockGetRecoveryImages, + mockUpdateImageRegions, +} from 'support/intercepts/images'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import type { Image } from '@linode/api-v4'; + +describe('Manage Image Regions', () => { + /** + * Adds two new regions to an Image (region3 and region4) + * and removes one existing region (region 1). + */ + it("updates an Image's regions", () => { + const region1 = regionFactory.build({ site_type: 'core' }); + const region2 = regionFactory.build({ site_type: 'core' }); + const region3 = regionFactory.build({ site_type: 'core' }); + const region4 = regionFactory.build({ site_type: 'core' }); + + const image = imageFactory.build({ + size: 50, + total_size: 100, + capabilities: ['distributed-images'], + regions: [ + { region: region1.id, status: 'available' }, + { region: region2.id, status: 'available' }, + ], + }); + + mockGetRegions([region1, region2, region3, region4]).as('getRegions'); + mockGetCustomImages([image]).as('getImages'); + mockGetRecoveryImages([]); + + cy.visitWithLogin('/images'); + cy.wait(['@getImages', '@getRegions']); + + cy.findByText(image.label) + .closest('tr') + .within(() => { + // Verify total size is rendered + cy.findByText(`${image.total_size} MB`).should('be.visible'); + + // Verify capabilities are rendered + cy.findByText('Distributed').should('be.visible'); + + // Verify the first region is rendered + cy.findByText(region1.label + ',').should('be.visible'); + + // Click the "+1" + cy.findByText('+1').should('be.visible').should('be.enabled').click(); + }); + + // Verify the Manage Regions drawer opens and contains basic content + ui.drawer + .findByTitle(`Manage Regions for ${image.label}`) + .should('be.visible') + .within(() => { + // Verify the Image regions render + cy.findByText(region1.label).should('be.visible'); + cy.findByText(region2.label).should('be.visible'); + + cy.findByText('Image will be available in these regions (2)').should( + 'be.visible' + ); + + // Verify the "Save" button is disabled because no changes have been made + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.disabled'); + + // Close the Manage Regions drawer + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByText(image.label) + .closest('tr') + .within(() => { + // Open the Image's action menu + ui.actionMenu + .findByTitle(`Action menu for Image ${image.label}`) + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Click "Manage Regions" option in the action menu + ui.actionMenuItem + .findByTitle('Manage Regions') + .should('be.visible') + .should('be.enabled') + .click(); + + // Open the Regions Multi-Select + cy.findByLabelText('Add Regions') + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify "Select All" shows up as an option + ui.autocompletePopper + .findByTitle('Select All') + .should('be.visible') + .should('be.enabled'); + + // Verify region3 shows up as an option and select it + ui.autocompletePopper + .findByTitle(`${region3.label} (${region3.id})`) + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify region4 shows up as an option and select it + ui.autocompletePopper + .findByTitle(`${region4.label} (${region4.id})`) + .should('be.visible') + .should('be.enabled') + .click(); + + const updatedImage: Image = { + ...image, + total_size: 150, + regions: [ + { region: region2.id, status: 'available' }, + { region: region3.id, status: 'pending replication' }, + { region: region4.id, status: 'pending replication' }, + ], + }; + + // mock the POST /v4/images/:id:regions response + mockUpdateImageRegions(image.id, updatedImage); + + // mock the updated paginated response + mockGetCustomImages([updatedImage]); + + // Click outside of the Region Multi-Select to commit the selection to the list + ui.drawer + .findByTitle(`Manage Regions for ${image.label}`) + .click() + .within(() => { + // Verify the existing image regions render + cy.findByText(region1.label).should('be.visible'); + cy.findByText(region2.label).should('be.visible'); + + // Verify the newly selected image regions render + cy.findByText(region3.label).should('be.visible'); + cy.findByText(region4.label).should('be.visible'); + cy.findAllByText('unsaved').should('be.visible'); + + // Verify the count is now 3 + cy.findByText('Image will be available in these regions (4)').should( + 'be.visible' + ); + + // Verify the "Save" button is enabled because a new region is selected + ui.button.findByTitle('Save').should('be.visible').should('be.enabled'); + + // Remove region1 + cy.findByLabelText(`Remove ${region1.id}`) + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify the image isn't shown in the list after being removed + cy.findByText(region1.label).should('not.exist'); + + // Verify the count is now 2 + cy.findByText('Image will be available in these regions (3)').should( + 'be.visible' + ); + + // Save changes + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + + // "Unsaved" regions should transition to "pending replication" because + // they are now returned by the API + cy.findAllByText('pending replication').should('be.visible'); + + // The save button should become disabled now that changes have been saved + ui.button.findByTitle('Save').should('be.disabled'); + + // The save button should become disabled now that changes have been saved + ui.button.findByTitle('Save').should('be.disabled'); + + cy.findByLabelText('Close drawer').click(); + }); + + ui.toast.assertMessage('Image regions successfully updated.'); + + cy.findByText(image.label) + .closest('tr') + .within(() => { + // Verify the new size is shown + cy.findByText('150 MB'); + + // Verify the first region is rendered + cy.findByText(region2.label + ',').should('be.visible'); + + // Verify the regions count is now "+2" + cy.findByText('+2').should('be.visible').should('be.enabled'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts index 4f578d3bd7e..f6e0a0e1cdc 100644 --- a/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts @@ -72,6 +72,7 @@ describe('create image (using mocks)', () => { cy.findByLabelText('Label') .should('be.enabled') .should('be.visible') + .clear() .type(mockNewImage.label); // Give the Image a description diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index fd495ecc0c0..bff073133a9 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -78,6 +78,7 @@ describe('LKE Cluster Creation', () => { * - Confirms that user is redirected to new LKE cluster summary page. * - Confirms that new LKE cluster summary page shows expected node pools. * - Confirms that new LKE cluster is shown on LKE clusters landing page. + * - Confirms that correct information is shown on the LKE cluster summary page */ it('can create an LKE cluster', () => { const clusterLabel = randomLabel(); @@ -114,6 +115,11 @@ describe('LKE Cluster Creation', () => { cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click(); + let totalCpu = 0; + let totalMemory = 0; + let totalStorage = 0; + let monthPrice = 0; + // Add a node pool for each randomly selected plan, and confirm that the // selected node pool plan is added to the checkout bar. clusterPlans.forEach((clusterPlan) => { @@ -150,7 +156,29 @@ describe('LKE Cluster Creation', () => { // instance of the pool appears in the checkout bar. cy.findAllByText(checkoutName).first().should('be.visible'); }); + + // Expected information on the LKE cluster summary page. + if (clusterPlan.size == 2 && clusterPlan.type == 'Linode') { + totalCpu = totalCpu + nodeCount * 1; + totalMemory = totalMemory + nodeCount * 2; + totalStorage = totalStorage + nodeCount * 50; + monthPrice = monthPrice + nodeCount * 12; + } + if (clusterPlan.size == 4 && clusterPlan.type == 'Linode') { + totalCpu = totalCpu + nodeCount * 2; + totalMemory = totalMemory + nodeCount * 4; + totalStorage = totalStorage + nodeCount * 80; + monthPrice = monthPrice + nodeCount * 24; + } + if (clusterPlan.size == 4 && clusterPlan.type == 'Dedicated') { + totalCpu = totalCpu + nodeCount * 2; + totalMemory = totalMemory + nodeCount * 4; + totalStorage = totalStorage + nodeCount * 80; + monthPrice = monthPrice + nodeCount * 36; + } }); + // $60.00/month for enabling HA control plane + const totalPrice = monthPrice + 60; // Create LKE cluster. cy.get('[data-testid="kube-checkout-bar"]') @@ -184,6 +212,15 @@ describe('LKE Cluster Creation', () => { const similarNodePoolCount = getSimilarPlans(clusterPlan, clusterPlans) .length; + //Confirm that the cluster created with the expected parameters. + cy.findAllByText(`${clusterRegion.label}`).should('be.visible'); + cy.findAllByText(`${totalCpu} CPU Cores`).should('be.visible'); + cy.findAllByText(`${totalMemory} GB RAM`).should('be.visible'); + cy.findAllByText(`${totalStorage} GB Storage`).should('be.visible'); + cy.findAllByText(`$${totalPrice}.00/month`).should('be.visible'); + cy.contains('Kubernetes API Endpoint').should('be.visible'); + cy.contains('linodelke.net:443').should('be.visible'); + cy.findAllByText(nodePoolLabel, { selector: 'h2' }) .should('have.length', similarNodePoolCount) .first() diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts index 9d89090ff43..e576ff2ffcd 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -4,6 +4,7 @@ import { kubeLinodeFactory, linodeFactory, } from 'src/factories'; +import { extendType } from 'src/utilities/extendType'; import { latestKubernetesVersion } from 'support/constants/lke'; import { mockGetCluster, @@ -785,6 +786,7 @@ describe('LKE cluster updates for DC-specific prices', () => { */ it('can resize pools with DC-specific prices', () => { const dcSpecificPricingRegion = getRegionById('us-east'); + const mockPlanType = extendType(dcPricingMockLinodeTypes[0]); const mockCluster = kubernetesClusterFactory.build({ k8s_version: latestKubernetesVersion, @@ -796,7 +798,7 @@ describe('LKE cluster updates for DC-specific prices', () => { const mockNodePoolResized = nodePoolFactory.build({ count: 3, - type: dcPricingMockLinodeTypes[0].id, + type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(3), }); @@ -812,19 +814,19 @@ describe('LKE cluster updates for DC-specific prices', () => { id: node.instance_id ?? undefined, ipv4: [randomIp()], region: dcSpecificPricingRegion.id, - type: dcPricingMockLinodeTypes[0].id, + type: mockPlanType.id, }); } ); - const mockNodePoolDrawerTitle = 'Resize Pool: Linode 0 GB Plan'; + const mockNodePoolDrawerTitle = `Resize Pool: ${mockPlanType.formattedLabel} Plan`; mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( 'getNodePools' ); mockGetLinodes(mockLinodes).as('getLinodes'); - mockGetLinodeType(dcPricingMockLinodeTypes[0]).as('getLinodeType'); + mockGetLinodeType(mockPlanType).as('getLinodeType'); mockGetKubernetesVersions().as('getVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); @@ -938,15 +940,17 @@ describe('LKE cluster updates for DC-specific prices', () => { }, }); + const mockPlanType = extendType(dcPricingMockLinodeTypes[0]); + const mockNewNodePool = nodePoolFactory.build({ count: 2, - type: dcPricingMockLinodeTypes[0].id, + type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(2), }); const mockNodePool = nodePoolFactory.build({ count: 1, - type: dcPricingMockLinodeTypes[0].id, + type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(1), }); @@ -954,7 +958,7 @@ describe('LKE cluster updates for DC-specific prices', () => { mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); mockGetKubernetesVersions().as('getVersions'); mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); - mockGetLinodeType(dcPricingMockLinodeTypes[0]).as('getLinodeType'); + mockGetLinodeType(mockPlanType).as('getLinodeType'); mockGetLinodeTypes(dcPricingMockLinodeTypes); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); @@ -963,7 +967,9 @@ describe('LKE cluster updates for DC-specific prices', () => { cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getLinodeType']); // Assert that initial node pool is shown on the page. - cy.findByText('Linode 0 GB', { selector: 'h2' }).should('be.visible'); + cy.findByText(mockPlanType.formattedLabel, { selector: 'h2' }).should( + 'be.visible' + ); // Confirm total price is listed in Kube Specs. cy.findByText('$14.40/month').should('be.visible'); @@ -987,7 +993,7 @@ describe('LKE cluster updates for DC-specific prices', () => { .should('be.visible') .should('be.enabled') .click(); - cy.findByText('Linode 0 GB') + cy.findByText(mockPlanType.formattedLabel) .should('be.visible') .closest('tr') .within(() => { @@ -1024,6 +1030,7 @@ describe('LKE cluster updates for DC-specific prices', () => { */ it('can resize pools with region prices of $0', () => { const dcSpecificPricingRegion = getRegionById('us-southeast'); + const mockPlanType = extendType(dcPricingMockLinodeTypes[2]); const mockCluster = kubernetesClusterFactory.build({ k8s_version: latestKubernetesVersion, @@ -1035,7 +1042,7 @@ describe('LKE cluster updates for DC-specific prices', () => { const mockNodePoolResized = nodePoolFactory.build({ count: 3, - type: dcPricingMockLinodeTypes[2].id, + type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(3), }); @@ -1051,19 +1058,19 @@ describe('LKE cluster updates for DC-specific prices', () => { id: node.instance_id ?? undefined, ipv4: [randomIp()], region: dcSpecificPricingRegion.id, - type: dcPricingMockLinodeTypes[2].id, + type: mockPlanType.id, }); } ); - const mockNodePoolDrawerTitle = 'Resize Pool: Linode 2 GB Plan'; + const mockNodePoolDrawerTitle = `Resize Pool: ${mockPlanType.formattedLabel} Plan`; mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( 'getNodePools' ); mockGetLinodes(mockLinodes).as('getLinodes'); - mockGetLinodeType(dcPricingMockLinodeTypes[2]).as('getLinodeType'); + mockGetLinodeType(mockPlanType).as('getLinodeType'); mockGetKubernetesVersions().as('getVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); @@ -1160,6 +1167,8 @@ describe('LKE cluster updates for DC-specific prices', () => { it('can add node pools with region prices of $0', () => { const dcSpecificPricingRegion = getRegionById('us-southeast'); + const mockPlanType = extendType(dcPricingMockLinodeTypes[2]); + const mockCluster = kubernetesClusterFactory.build({ k8s_version: latestKubernetesVersion, region: dcSpecificPricingRegion.id, @@ -1170,13 +1179,13 @@ describe('LKE cluster updates for DC-specific prices', () => { const mockNewNodePool = nodePoolFactory.build({ count: 2, - type: dcPricingMockLinodeTypes[2].id, + type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(2), }); const mockNodePool = nodePoolFactory.build({ count: 1, - type: dcPricingMockLinodeTypes[2].id, + type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(1), }); @@ -1184,7 +1193,7 @@ describe('LKE cluster updates for DC-specific prices', () => { mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); mockGetKubernetesVersions().as('getVersions'); mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); - mockGetLinodeType(dcPricingMockLinodeTypes[2]).as('getLinodeType'); + mockGetLinodeType(mockPlanType).as('getLinodeType'); mockGetLinodeTypes(dcPricingMockLinodeTypes); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); @@ -1193,7 +1202,9 @@ describe('LKE cluster updates for DC-specific prices', () => { cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getLinodeType']); // Assert that initial node pool is shown on the page. - cy.findByText('Linode 2 GB', { selector: 'h2' }).should('be.visible'); + cy.findByText(mockPlanType.formattedLabel, { selector: 'h2' }).should( + 'be.visible' + ); // Confirm total price of $0 is listed in Kube Specs. cy.findByText('$0.00/month').should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts index 29273aab7f3..cd9a78f2258 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -33,8 +33,8 @@ const getLinodeCloneUrl = (linode: Linode): string => { return `/linodes/create?linodeID=${linode.id}${regionQuery}&type=Clone+Linode${typeQuery}`; }; -/* Timeout after 3 minutes while waiting for clone. */ -const CLONE_TIMEOUT = 180_000; +/* Timeout after 4 minutes while waiting for clone. */ +const CLONE_TIMEOUT = 240_000; authenticate(); describe('clone linode', () => { @@ -47,7 +47,7 @@ describe('clone linode', () => { * - Confirms that Linode can be cloned successfully. */ it('can clone a Linode from Linode details page', () => { - const linodeRegion = chooseRegion(); + const linodeRegion = chooseRegion({ capabilities: ['Vlans'] }); const linodePayload = createLinodeRequestFactory.build({ label: randomLabel(), region: linodeRegion.id, @@ -64,8 +64,6 @@ describe('clone linode', () => { cy.defer(() => createTestLinode(linodePayload, { securityMethod: 'vlan_no_internet' }) ).then((linode: Linode) => { - const linodeRegion = getRegionById(linodePayload.region!); - interceptCloneLinode(linode.id).as('cloneLinode'); cy.visitWithLogin(`/linodes/${linode.id}`); 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 1945263f5e0..be6fad33848 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -69,7 +69,7 @@ describe('Create Linode', () => { */ it(`creates a ${planConfig.planType} Linode`, () => { const linodeRegion = chooseRegion({ - capabilities: ['Linodes', 'Premium Plans'], + capabilities: ['Linodes', 'Premium Plans', 'Vlans'], }); const linodeLabel = randomLabel(); diff --git a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts index 0f9e5fc2238..cf92b39a21b 100644 --- a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts @@ -12,7 +12,6 @@ import { getVisible, } from 'support/helpers'; import { ui } from 'support/ui'; -import { apiMatcher } from 'support/util/intercepts'; import { randomString, randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { getRegionById } from 'support/util/regions'; @@ -39,6 +38,7 @@ import { import { mockGetVLANs } from 'support/intercepts/vlans'; import { mockGetLinodeConfigs } from 'support/intercepts/configs'; import { + interceptCreateLinode, mockCreateLinode, mockGetLinodeType, mockGetLinodeTypes, @@ -155,10 +155,14 @@ describe('create linode', () => { // intercept request cy.visitWithLogin('/linodes/create'); cy.get('[data-qa-deploy-linode]'); - cy.intercept('POST', apiMatcher('linode/instances')).as('linodeCreated'); + interceptCreateLinode().as('linodeCreated'); cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(chooseRegion().label).click(); + ui.regionSelect + .findItemByRegionLabel( + chooseRegion({ capabilities: ['Vlans', 'Linodes'] }).label + ) + .click(); fbtClick('Shared CPU'); getClick('[id="g6-nanode-1"]'); getClick('#linode-label').clear().type(linodeLabel); diff --git a/packages/manager/cypress/e2e/core/longview/longview.spec.ts b/packages/manager/cypress/e2e/core/longview/longview.spec.ts index 4ecfed6ca39..320f57da41e 100644 --- a/packages/manager/cypress/e2e/core/longview/longview.spec.ts +++ b/packages/manager/cypress/e2e/core/longview/longview.spec.ts @@ -1,28 +1,26 @@ -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 type { LongviewClient } from '@linode/api-v4'; +import { DateTime } from 'luxon'; +import { + longviewResponseFactory, + longviewClientFactory, + longviewAppsFactory, + longviewLatestStatsFactory, + longviewPackageFactory, +} from 'src/factories'; 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 { createTestLinode } from 'support/util/linodes'; -import { randomLabel, randomString } from 'support/util/random'; - -// Timeout if Linode creation and boot takes longer than 1 and a half minutes. -const linodeCreateTimeout = 90000; /** * Returns the command used to install Longview which is shown in Cloud's UI. @@ -35,31 +33,6 @@ const getInstallCommand = (installCode: string): string => { return `curl -s https://lv.linode.com/${installCode} | sudo bash`; }; -/** - * Installs Longview on a Linode. - * - * @param linodeIp - IP of Linode on which to install Longview. - * @param linodePass - Root password of Linode on which to install Longview. - * @param installCommand - Longview installation command. - * - * @returns Cypress chainable. - */ -const installLongview = ( - linodeIp: string, - linodePass: string, - installCommand: string -) => { - return cy.exec('./cypress/support/scripts/longview/install-longview.sh', { - failOnNonZeroExit: true, - timeout: longviewInstallTimeout, - env: { - LINODEIP: linodeIp, - LINODEPASSWORD: linodePass, - CURLCOMMAND: installCommand, - }, - }); -}; - /** * Waits for Cloud Manager to fetch Longview data and receive updates. * @@ -100,6 +73,58 @@ const waitForLongviewData = ( ); }; +/* + * Mocks that represent the state of Longview while waiting for client to be installed. + */ +const longviewLastUpdatedWaiting = longviewResponseFactory.build({ + ACTION: 'lastUpdated', + DATA: { updated: 0 }, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +const longviewGetValuesWaiting = longviewResponseFactory.build({ + ACTION: 'getValues', + DATA: {}, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +const longviewGetLatestValueWaiting = longviewResponseFactory.build({ + ACTION: 'getLatestValue', + DATA: {}, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +/* + * Mocks that represent the state of Longview once client is installed and data is received. + */ +const longviewLastUpdatedInstalled = longviewResponseFactory.build({ + ACTION: 'lastUpdated', + DATA: { + updated: DateTime.now().plus({ minutes: 1 }).toSeconds(), + }, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +const longviewGetValuesInstalled = longviewResponseFactory.build({ + ACTION: 'getValues', + DATA: { + Packages: longviewPackageFactory.buildList(5), + }, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +const longviewGetLatestValueInstalled = longviewResponseFactory.build({ + ACTION: 'getLatestValue', + DATA: longviewLatestStatsFactory.build(), + NOTIFICATIONS: [], + VERSION: 0.4, +}); + authenticate(); describe('longview', () => { before(() => { @@ -107,78 +132,76 @@ describe('longview', () => { }); /* - * - Tests Longview installation end-to-end using real API data. - * - Creates a Linode, connects to it via SSH, and installs Longview using the given cURL command. + * - Tests Longview installation end-to-end using mock API data. * - Confirms that Cloud Manager UI updates to reflect Longview installation and data. */ - // TODO Unskip for M3-8107. - it.skip('can install Longview client on a Linode', () => { - const linodePassword = randomString(32, { - symbols: false, - lowercase: true, - uppercase: true, - numbers: true, - spaces: false, + + it('can install Longview client on a Linode', () => { + const client: LongviewClient = longviewClientFactory.build({ + api_key: '01AE82DD-6F99-44F6-95781512B64FFBC3', + apps: longviewAppsFactory.build(), + created: new Date().toISOString(), + id: 338283, + install_code: '748632FC-E92B-491F-A29D44019039017C', + label: 'longview-client-longview338283', + updated: new Date().toISOString(), }); - const createLinodeAndClient = async () => { - return Promise.all([ - createTestLinode({ - root_pass: linodePassword, - type: 'g6-standard-1', - booted: true, - }), - createLongviewClient(randomLabel()), - ]); - }; - - // Create Linode and Longview Client before loading Longview landing page. - cy.defer(createLinodeAndClient, { - label: 'Creating Linode and Longview Client...', - timeout: linodeCreateTimeout, - }).then(([linode, client]: [Linode, LongviewClient]) => { - const linodeIp = linode.ipv4[0]; - const installCommand = getInstallCommand(client.install_code); - - interceptGetLongviewClients().as('getLongviewClients'); - interceptFetchLongviewStatus().as('fetchLongviewStatus'); - cy.visitWithLogin('/longview'); - cy.wait('@getLongviewClients'); - - // Find the table row for the new Longview client, assert expected information - // is displayed inside of it. - cy.get(`[data-qa-longview-client="${client.id}"]`) - .should('be.visible') - .within(() => { - cy.findByText(client.label).should('be.visible'); - cy.findByText(client.api_key).should('be.visible'); - cy.contains(installCommand).should('be.visible'); - cy.findByText('Waiting for data...'); - }); - - // Install Longview on Linode by SSHing into machine and executing cURL command. - installLongview(linodeIp, linodePassword, installCommand); - - // Wait for Longview to begin serving data and confirm that Cloud Manager - // UI updates accordingly. - waitForLongviewData('fetchLongviewStatus', client.api_key); - - // Sometimes Cloud Manager UI does not updated automatically upon receiving - // Longivew status data. Performing a page reload mitigates this issue. - // TODO Remove call to `cy.reload()`. - cy.reload(); - 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'); - }); + mockGetLongviewClients([client]).as('getLongviewClients'); + mockFetchLongviewStatus(client, 'lastUpdated', longviewLastUpdatedWaiting); + mockFetchLongviewStatus(client, 'getValues', longviewGetValuesWaiting); + mockFetchLongviewStatus( + client, + 'getLatestValue', + longviewGetLatestValueWaiting + ).as('fetchLongview'); + + const installCommand = getInstallCommand(client.install_code); + + cy.visitWithLogin('/longview'); + cy.wait('@getLongviewClients'); + + // Confirm that Longview landing page lists a client that is still waiting for data... + cy.get(`[data-qa-longview-client="${client.id}"]`) + .should('be.visible') + .within(() => { + cy.findByText(client.label).should('be.visible'); + cy.findByText(client.api_key).should('be.visible'); + cy.contains(installCommand).should('be.visible'); + cy.findByText('Waiting for data...'); + }); + + // Update mocks after initial Longview fetch to simulate client installation and data retrieval. + // The next time Cloud makes a request to the fetch endpoint, data will start being returned. + // 3 fetches is necessary because the Longview landing page fires 3 requests to the Longview fetch endpoint for each client. + // See https://github.com/linode/manager/pull/10579#discussion_r1647945160 + cy.wait(['@fetchLongview', '@fetchLongview', '@fetchLongview']).then(() => { + mockFetchLongviewStatus( + client, + 'lastUpdated', + longviewLastUpdatedInstalled + ); + mockFetchLongviewStatus(client, 'getValues', longviewGetValuesInstalled); + mockFetchLongviewStatus( + client, + 'getLatestValue', + longviewGetLatestValueInstalled + ); }); + + // Confirms that UI updates to show that data has been retrieved. + 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'); + }); }); /* @@ -187,10 +210,15 @@ describe('longview', () => { */ 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'); + mockFetchLongviewStatus(client, 'lastUpdated', longviewLastUpdatedWaiting); + mockFetchLongviewStatus(client, 'getValues', longviewGetValuesWaiting); + mockFetchLongviewStatus( + client, + 'getLatestValue', + longviewGetLatestValueWaiting + ).as('fetchLongview'); cy.visitWithLogin('/longview'); cy.wait('@getLongviewClients'); @@ -206,6 +234,24 @@ describe('longview', () => { .click(); cy.wait('@createLongviewClient'); + // Update mocks after initial Longview fetch to simulate client installation and data retrieval. + // The next time Cloud makes a request to the fetch endpoint, data will start being returned. + // 3 fetches is necessary because the Longview landing page fires 3 requests to the Longview fetch endpoint for each client. + // See https://github.com/linode/manager/pull/10579#discussion_r1647945160 + cy.wait(['@fetchLongview', '@fetchLongview', '@fetchLongview']).then(() => { + mockFetchLongviewStatus( + client, + 'lastUpdated', + longviewLastUpdatedInstalled + ); + mockFetchLongviewStatus(client, 'getValues', longviewGetValuesInstalled); + mockFetchLongviewStatus( + client, + 'getLatestValue', + longviewGetLatestValueInstalled + ); + }); + // 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}"]`) diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index be2705f89e3..4d415a8fb19 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -50,6 +50,9 @@ const getNonEmptyBucketMessage = (bucketLabel: string) => { /** * Create a bucket with the given label and cluster. * + * This function assumes that OBJ Multicluster is not enabled. Use + * `setUpBucketMulticluster` to set up OBJ buckets when Multicluster is enabled. + * * @param label - Bucket label. * @param cluster - Bucket cluster. * @@ -60,12 +63,40 @@ const setUpBucket = (label: string, cluster: string) => { objectStorageBucketFactory.build({ label, cluster, - // Default factory sets `region`, but API does not accept it yet. + + // API accepts either `cluster` or `region`, but not both. Our factory + // populates both fields, so we have to manually set `region` to `undefined` + // to avoid 400 responses from the API. region: undefined, }) ); }; +/** + * Create a bucket with the given label and cluster. + * + * This function assumes that OBJ Multicluster is enabled. Use + * `setUpBucket` to set up OBJ buckets when Multicluster is disabled. + * + * @param label - Bucket label. + * @param regionId - ID of Bucket region. + * + * @returns Promise that resolves to created Bucket. + */ +const setUpBucketMulticluster = (label: string, regionId: string) => { + return createBucket( + objectStorageBucketFactory.build({ + label, + region: regionId, + + // API accepts either `cluster` or `region`, but not both. Our factory + // populates both fields, so we have to manually set `cluster` to `undefined` + // to avoid 400 responses from the API. + cluster: undefined, + }) + ); +}; + /** * Uploads the file at the given path and assigns it the given filename. * @@ -211,7 +242,8 @@ describe('object storage end-to-end tests', () => { it('can upload, access, and delete objects', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; - const bucketPage = `/object-storage/buckets/${bucketCluster}/${bucketLabel}/objects`; + const bucketRegionId = 'us-southeast'; + const bucketPage = `/object-storage/buckets/${bucketRegionId}/${bucketLabel}/objects`; const bucketFolderName = randomLabel(); const bucketFiles = [ @@ -220,7 +252,7 @@ describe('object storage end-to-end tests', () => { ]; cy.defer( - () => setUpBucket(bucketLabel, bucketCluster), + () => setUpBucketMulticluster(bucketLabel, bucketRegionId), 'creating Object Storage bucket' ).then(() => { interceptUploadBucketObjectS3( 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 88b3caf6623..3a08199557f 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 @@ -162,7 +162,7 @@ describe('OneClick Apps (OCA)', () => { const password = randomString(16); const image = 'linode/ubuntu22.04'; const rootPassword = randomString(16); - const region = chooseRegion(); + const region = chooseRegion({ capabilities: ['Vlans'] }); const linodeLabel = randomLabel(); const levelName = 'Get the enderman!'; diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index 9afa75c6ce6..2733a68d940 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -169,7 +169,7 @@ describe('Create stackscripts', () => { const stackscriptImageTag = 'alpine3.19'; const linodeLabel = randomLabel(); - const linodeRegion = chooseRegion(); + const linodeRegion = chooseRegion({ capabilities: ['Vlans'] }); interceptCreateStackScript().as('createStackScript'); interceptGetStackScripts().as('getStackScripts'); @@ -372,7 +372,10 @@ describe('Create stackscripts', () => { .click(); interceptCreateLinode().as('createLinode'); - fillOutLinodeForm(linodeLabel, chooseRegion().label); + fillOutLinodeForm( + linodeLabel, + chooseRegion({ capabilities: ['Vlans'] }).label + ); ui.button .findByTitle('Create Linode') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts index e96645f3116..9bc796d2c6e 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts @@ -260,7 +260,7 @@ describe('Community Stackscripts integration tests', () => { const fairPassword = 'Akamai123'; const rootPassword = randomString(16); const image = 'AlmaLinux 9'; - const region = chooseRegion(); + const region = chooseRegion({ capabilities: ['Vlans'] }); const linodeLabel = randomLabel(); interceptGetStackScripts().as('getStackScripts'); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts index cc8341ef251..e7672f90c3c 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts @@ -120,87 +120,18 @@ describe('VPC details page', () => { cy.findByText('Create a private and isolated network'); }); - /** - * - Confirms Subnets section and table is shown on the VPC details page - * - Confirms UI flow when deleting a subnet from a VPC's detail page - */ - it('can delete a subnet from the VPC details page', () => { - const mockSubnet = subnetFactory.build({ - id: randomNumber(), - label: randomLabel(), - linodes: [], - }); - const mockVPC = vpcFactory.build({ - id: randomNumber(), - label: randomLabel(), - subnets: [mockSubnet], - }); - - const mockVPCAfterSubnetDeletion = vpcFactory.build({ - ...mockVPC, - subnets: [], - }); - - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - - mockGetVPC(mockVPC).as('getVPC'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); - mockDeleteSubnet(mockVPC.id, mockSubnet.id).as('deleteSubnet'); - - cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC', '@getSubnets']); - - // confirm that vpc and subnet details get displayed - cy.findByText(mockVPC.label).should('be.visible'); - cy.findByText('Subnets (1)').should('be.visible'); - cy.findByText(mockSubnet.label).should('be.visible'); - - // confirm that subnet can be deleted and that page reflects changes - ui.actionMenu - .findByTitle(`Action menu for Subnet ${mockSubnet.label}`) - .should('be.visible') - .click(); - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); - - mockGetVPC(mockVPCAfterSubnetDeletion).as('getVPC'); - mockGetSubnets(mockVPC.id, []).as('getSubnets'); - - ui.dialog - .findByTitle(`Delete Subnet ${mockSubnet.label}`) - .should('be.visible') - .within(() => { - cy.findByLabelText('Subnet Label') - .should('be.visible') - .click() - .type(mockSubnet.label); - - ui.button - .findByTitle('Delete') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait(['@deleteSubnet', '@getVPC', '@getSubnets']); - - // confirm that user should still be on VPC's detail page - // confirm there are no remaining subnets - cy.url().should('endWith', `/${mockVPC.id}`); - cy.findByText('Subnets (0)'); - cy.findByText('No Subnets are assigned.'); - cy.findByText(mockSubnet.label).should('not.exist'); - }); - /** * - Confirms UI flow when creating a subnet on a VPC's detail page. + * - Confirms UI flow for editing a subnet. + * - Confirms Subnets section and table is shown on the VPC details page. + * - Confirms UI flow when deleting a subnet from a VPC's detail page. */ - it('can create a subnet', () => { + it('can create, edit, and delete a subnet from the VPC details page', () => { + // create a subnet const mockSubnet = subnetFactory.build({ id: randomNumber(), label: randomLabel(), + linodes: [], }); const mockVPC = vpcFactory.build({ @@ -256,22 +187,8 @@ describe('VPC details page', () => { cy.findByText(mockVPC.label).should('be.visible'); cy.findByText('Subnets (1)').should('be.visible'); cy.findByText(mockSubnet.label).should('be.visible'); - }); - - /** - * - Confirms UI flow for editing a subnet - */ - it('can edit a subnet', () => { - const mockSubnet = subnetFactory.build({ - id: randomNumber(), - label: randomLabel(), - }); - const mockVPC = vpcFactory.build({ - id: randomNumber(), - label: randomLabel(), - subnets: [mockSubnet], - }); + // edit a subnet const mockEditedSubnet = subnetFactory.build({ ...mockSubnet, label: randomLabel(), @@ -282,22 +199,6 @@ describe('VPC details page', () => { subnets: [mockEditedSubnet], }); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - - mockGetVPC(mockVPC).as('getVPC'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); - - cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC', '@getSubnets']); - - // confirm that vpc and subnet details get displayed - cy.findByText(mockVPC.label).should('be.visible'); - cy.findByText('Subnets (1)').should('be.visible'); - cy.findByText(mockSubnet.label).should('be.visible'); - // confirm that subnet can be edited and that page reflects changes mockEditSubnet(mockVPC.id, mockEditedSubnet.id, mockEditedSubnet).as( 'editSubnet' @@ -336,5 +237,47 @@ describe('VPC details page', () => { cy.findByText(mockVPC.label).should('be.visible'); cy.findByText('Subnets (1)').should('be.visible'); cy.findByText(mockEditedSubnet.label).should('be.visible'); + + // delete a subnet + const mockVPCAfterSubnetDeletion = vpcFactory.build({ + ...mockVPC, + subnets: [], + }); + mockDeleteSubnet(mockVPC.id, mockEditedSubnet.id).as('deleteSubnet'); + + // confirm that subnet can be deleted and that page reflects changes + ui.actionMenu + .findByTitle(`Action menu for Subnet ${mockEditedSubnet.label}`) + .should('be.visible') + .click(); + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + + mockGetVPC(mockVPCAfterSubnetDeletion).as('getVPC'); + mockGetSubnets(mockVPC.id, []).as('getSubnets'); + + ui.dialog + .findByTitle(`Delete Subnet ${mockEditedSubnet.label}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Subnet Label') + .should('be.visible') + .click() + .type(mockEditedSubnet.label); + + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@deleteSubnet', '@getVPC', '@getSubnets']); + + // confirm that user should still be on VPC's detail page + // confirm there are no remaining subnets + cy.url().should('endWith', `/${mockVPC.id}`); + cy.findByText('Subnets (0)'); + cy.findByText('No Subnets are assigned.'); + cy.findByText(mockEditedSubnet.label).should('not.exist'); }); }); diff --git a/packages/manager/cypress/support/api/domains.ts b/packages/manager/cypress/support/api/domains.ts index f57b470c229..036906273e2 100644 --- a/packages/manager/cypress/support/api/domains.ts +++ b/packages/manager/cypress/support/api/domains.ts @@ -1,17 +1,15 @@ -import { - Domain, - deleteDomain, - getDomains, - CreateDomainPayload, -} from '@linode/api-v4'; -import { createDomainPayloadFactory } from 'src/factories'; +import { deleteDomain, getDomains } from '@linode/api-v4'; import { isTestLabel } from 'support/api/common'; import { oauthToken, pageSize } from 'support/constants/api'; import { depaginate } from 'support/util/paginate'; import { randomDomainName } from 'support/util/random'; +import { createDomainPayloadFactory } from 'src/factories'; + import { apiCheckErrors } from './common'; +import type { CreateDomainPayload, Domain } from '@linode/api-v4'; + /** * Deletes all domains which are prefixed with the test entity prefix. * diff --git a/packages/manager/cypress/support/constants/domains.ts b/packages/manager/cypress/support/constants/domains.ts index 0eedbece6c2..e08003c7dab 100644 --- a/packages/manager/cypress/support/constants/domains.ts +++ b/packages/manager/cypress/support/constants/domains.ts @@ -1,90 +1,91 @@ +/* eslint-disable sonarjs/no-duplicate-string */ import { - randomLabel, + randomDomainName, randomIp, + randomLabel, randomString, - randomDomainName, } from 'support/util/random'; // Array of domain records for which to test creation. export const createDomainRecords = () => [ { - name: 'Add an A/AAAA Record', - tableAriaLabel: 'List of Domains A/AAAA Record', fields: [ { name: '[data-qa-target="Hostname"]', - value: randomLabel(), skipCheck: false, + value: randomLabel(), }, { name: '[data-qa-target="IP Address"]', - value: `${randomIp()}`, skipCheck: false, + value: randomIp(), }, ], + name: 'Add an A/AAAA Record', + tableAriaLabel: 'List of Domains A/AAAA Record', }, { - name: 'Add a CNAME Record', - tableAriaLabel: 'List of Domains CNAME Record', fields: [ { name: '[data-qa-target="Hostname"]', - value: randomLabel(), skipCheck: false, + value: randomLabel(), }, { name: '[data-qa-target="Alias to"]', - value: `${randomLabel()}.net`, skipCheck: false, + value: `${randomLabel()}.net`, }, ], + name: 'Add a CNAME Record', + tableAriaLabel: 'List of Domains CNAME Record', }, { - name: 'Add a TXT Record', - tableAriaLabel: 'List of Domains TXT Record', fields: [ { name: '[data-qa-target="Hostname"]', - value: randomLabel(), skipCheck: false, + value: randomLabel(), }, { name: '[data-qa-target="Value"]', - value: `${randomLabel()}=${randomString()}`, skipCheck: false, + value: `${randomLabel()}=${randomString()}`, }, ], + name: 'Add a TXT Record', + tableAriaLabel: 'List of Domains TXT Record', }, { - name: 'Add an SRV Record', - tableAriaLabel: 'List of Domains SRV Record', fields: [ { name: '[data-qa-target="Service"]', - value: randomLabel(), skipCheck: true, + value: randomLabel(), }, { + approximate: true, name: '[data-qa-target="Target"]', value: randomLabel(), - approximate: true, }, ], + name: 'Add an SRV Record', + tableAriaLabel: 'List of Domains SRV Record', }, { - name: 'Add a CAA Record', - tableAriaLabel: 'List of Domains CAA Record', fields: [ { name: '[data-qa-target="Name"]', - value: randomLabel(), skipCheck: false, + value: randomLabel(), }, { name: '[data-qa-target="Value"]', - value: randomDomainName(), skipCheck: false, + value: randomDomainName(), }, ], + name: 'Add a CAA Record', + tableAriaLabel: 'List of Domains CAA Record', }, ]; diff --git a/packages/manager/cypress/support/intercepts/images.ts b/packages/manager/cypress/support/intercepts/images.ts index a9a38804c06..9e4a0f7a2bb 100644 --- a/packages/manager/cypress/support/intercepts/images.ts +++ b/packages/manager/cypress/support/intercepts/images.ts @@ -54,9 +54,7 @@ export const mockGetCustomImages = ( const filters = getFilters(req); if (filters?.type === 'manual') { req.reply(paginateResponse(images)); - return; } - req.continue(); }); }; @@ -74,9 +72,7 @@ export const mockGetRecoveryImages = ( const filters = getFilters(req); if (filters?.type === 'automatic') { req.reply(paginateResponse(images)); - return; } - req.continue(); }); }; @@ -130,3 +126,23 @@ export const mockDeleteImage = (id: string): Cypress.Chainable => { const encodedId = encodeURIComponent(id); return cy.intercept('DELETE', apiMatcher(`images/${encodedId}`), {}); }; + +/** + * Intercepts POST request to update an image's regions and mocks the response. + * + * @param id - ID of image + * @param updatedImage - Updated image with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockUpdateImageRegions = ( + id: string, + updatedImage: Image +): Cypress.Chainable => { + const encodedId = encodeURIComponent(id); + return cy.intercept( + 'POST', + apiMatcher(`images/${encodedId}/regions`), + updatedImage + ); +}; diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index 2a4a898068c..8dfa481462a 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -6,16 +6,25 @@ 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 { linodeVlanNoInternetConfig } from 'support/util/linodes'; import type { Disk, Kernel, Linode, LinodeType, Volume } from '@linode/api-v4'; /** * Intercepts POST request to create a Linode. * + * The outgoing request payload is modified to create a Linode without access + * to the internet. + * * @returns Cypress chainable. */ export const interceptCreateLinode = (): Cypress.Chainable => { - return cy.intercept('POST', apiMatcher('linode/instances')); + return cy.intercept('POST', apiMatcher('linode/instances'), (req) => { + req.body = { + ...req.body, + interfaces: linodeVlanNoInternetConfig, + }; + }); }; /** diff --git a/packages/manager/cypress/support/intercepts/longview.ts b/packages/manager/cypress/support/intercepts/longview.ts index d0cb2c742fc..2bc3eaf27a9 100644 --- a/packages/manager/cypress/support/intercepts/longview.ts +++ b/packages/manager/cypress/support/intercepts/longview.ts @@ -2,7 +2,10 @@ 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'; +import type { + LongviewAction, + LongviewResponse, +} from 'src/features/Longview/request.types'; /** * Intercepts request to retrieve Longview status for a Longview client. @@ -16,15 +19,38 @@ export const interceptFetchLongviewStatus = (): Cypress.Chainable => { /** * Mocks request to retrieve Longview status for a Longview client. * + * @param client - Longview Client for which to intercept Longview fetch request. + * @param apiAction - Longview API action to intercept. + * @param mockStatus - + * * @returns Cypress chainable. */ export const mockFetchLongviewStatus = ( - status: LongviewResponse + client: LongviewClient, + apiAction: LongviewAction, + mockStatus: LongviewResponse ): Cypress.Chainable => { return cy.intercept( - 'POST', - 'https://longview.linode.com/fetch', - makeResponse(status) + { + url: 'https://longview.linode.com/fetch', + method: 'POST', + }, + async (req) => { + const payload = req.body; + const response = new Response(payload, { + headers: { + 'content-type': req.headers['content-type'] as string, + }, + }); + const formData = await response.formData(); + + if ( + formData.get('api_key') === client.api_key && + formData.get('api_action') === apiAction + ) { + req.reply(makeResponse([mockStatus])); + } + } ); }; diff --git a/packages/manager/cypress/support/intercepts/profile.ts b/packages/manager/cypress/support/intercepts/profile.ts index 11aee4b2c76..e2493f09868 100644 --- a/packages/manager/cypress/support/intercepts/profile.ts +++ b/packages/manager/cypress/support/intercepts/profile.ts @@ -458,3 +458,29 @@ export const mockCreateSSHKeyError = ( makeErrorResponse(errorMessage, status) ); }; + +/** + * Intercepts PUT request to update an SSH key and mocks response. + * + * @param sshKeyId - The SSH key ID to update + * @param sshKey - An SSH key with which to update. + * + * @returns Cypress chainable. + */ +export const mockUpdateSSHKey = ( + sshKeyId: number, + sshKey: SSHKey +): Cypress.Chainable => { + return cy.intercept('PUT', apiMatcher(`profile/sshkeys/${sshKeyId}`), sshKey); +}; + +/** + * Intercepts DELETE request to delete an SSH key and mocks response. + * + * @param sshKeyId - The SSH key ID to delete + * + * @returns Cypress chainable. + */ +export const mockDeleteSSHKey = (sshKeyId: number): Cypress.Chainable => { + return cy.intercept('DELETE', apiMatcher(`profile/sshkeys/${sshKeyId}`), {}); +}; diff --git a/packages/manager/cypress/support/ui/constants.ts b/packages/manager/cypress/support/ui/constants.ts index f067604ea1d..6b69ac11300 100644 --- a/packages/manager/cypress/support/ui/constants.ts +++ b/packages/manager/cypress/support/ui/constants.ts @@ -1,4 +1,3 @@ -import { containsClick, containsVisible, getVisible } from '../helpers'; import { waitForAppLoad } from './common'; export const loadAppNoLogin = (path: string) => waitForAppLoad(path, false); @@ -31,10 +30,8 @@ interface GoWithUI { export interface Page { assertIsLoaded: () => void; - first?: boolean; goWithUI?: GoWithUI[]; name: string; - skip?: boolean; url: string; } @@ -117,10 +114,10 @@ export const pages: Page[] = [ go: () => { const url = `${routes.profile}/auth`; loadAppNoLogin(url); - getVisible('[data-qa-header="My Profile"]'); - containsVisible( + cy.get('[data-qa-header="My Profile"]').should('be.visible'); + cy.contains( 'How to Enable Third Party Authentication on Your User Account' - ); + ).should('be.visible'); waitDoubleRerender(); cy.contains('Display').should('be.visible').click(); }, @@ -149,7 +146,7 @@ export const pages: Page[] = [ loadAppNoLogin(routes.profile); cy.findByText('Username').should('be.visible'); waitDoubleRerender(); - containsClick('Login & Authentication'); + cy.contains('Login & Authentication').click(); }, name: 'Tab', }, @@ -182,7 +179,7 @@ export const pages: Page[] = [ loadAppNoLogin(routes.profile); cy.findByText('Username'); waitDoubleRerender(); - containsClick('LISH Console Settings'); + cy.contains('LISH Console Settings').click(); }, name: 'Tab', }, @@ -204,7 +201,7 @@ export const pages: Page[] = [ name: 'Tab', }, ], - name: 'Profile/API tokens', + name: 'Profile/API Tokens', url: `${routes.profile}/tokens`, }, { @@ -215,7 +212,7 @@ export const pages: Page[] = [ }, { assertIsLoaded: () => cy.findByText('Open New Ticket').should('be.visible'), - name: 'Support/tickets', + name: 'Support/Tickets', url: `${routes.supportTickets}`, }, { @@ -229,7 +226,7 @@ export const pages: Page[] = [ name: 'Tab', }, ], - name: 'Support/tickets/open', + name: 'Support/Tickets/Open', url: `${routes.supportTicketsOpen}`, }, { @@ -243,7 +240,7 @@ export const pages: Page[] = [ name: 'Tab', }, ], - name: 'Support/tickets/closed', + name: 'Support/Tickets/Closed', url: `${routes.supportTicketsClosed}`, }, { @@ -281,7 +278,7 @@ export const pages: Page[] = [ name: 'Tab', }, ], - name: 'account/Users', + name: 'Account/Users', url: `${routes.account}/users`, }, { @@ -298,7 +295,7 @@ export const pages: Page[] = [ name: 'Tab', }, ], - name: 'account/Settings', + name: 'Account/Settings', url: `${routes.account}/settings`, }, ]; diff --git a/packages/manager/cypress/support/util/linodes.ts b/packages/manager/cypress/support/util/linodes.ts index 76186a2ccc0..ad6e6b538d4 100644 --- a/packages/manager/cypress/support/util/linodes.ts +++ b/packages/manager/cypress/support/util/linodes.ts @@ -7,9 +7,26 @@ import { chooseRegion } from 'support/util/regions'; import { depaginate } from './paginate'; import { pageSize } from 'support/constants/api'; -import type { Config, CreateLinodeRequest, Linode } from '@linode/api-v4'; +import type { + Config, + CreateLinodeRequest, + InterfacePayload, + Linode, +} from '@linode/api-v4'; import { findOrCreateDependencyFirewall } from 'support/api/firewalls'; +/** + * Linode create interface to configure a Linode with no public internet access. + */ +export const linodeVlanNoInternetConfig: InterfacePayload[] = [ + { + purpose: 'vlan', + primary: false, + label: randomLabel(), + ipam_address: null, + }, +]; + /** * Methods used to secure test Linodes. * @@ -77,14 +94,7 @@ export const createTestLinode = async ( case 'vlan_no_internet': return { - interfaces: [ - { - purpose: 'vlan', - primary: false, - label: randomLabel(), - ipam_address: null, - }, - ], + interfaces: linodeVlanNoInternetConfig, }; case 'powered_off': diff --git a/packages/manager/package.json b/packages/manager/package.json index a49eb4653d0..9c03007ac00 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.122.0", + "version": "1.123.0", "private": true, "type": "module", "bugs": { @@ -18,7 +18,9 @@ "@emotion/styled": "^11.11.0", "@hookform/resolvers": "2.9.11", "@linode/api-v4": "*", + "@linode/design-language-system": "^2.3.0", "@linode/validation": "*", + "@linode/search": "*", "@lukemorales/query-key-factory": "^1.3.4", "@mui/icons-material": "^5.14.7", "@mui/material": "^5.14.7", @@ -221,4 +223,4 @@ "Firefox ESR", "not ie < 9" ] -} \ No newline at end of file +} diff --git a/packages/manager/src/components/ActionMenu/ActionMenu.tsx b/packages/manager/src/components/ActionMenu/ActionMenu.tsx index 679c0b34df9..6f5faf359b1 100644 --- a/packages/manager/src/components/ActionMenu/ActionMenu.tsx +++ b/packages/manager/src/components/ActionMenu/ActionMenu.tsx @@ -1,4 +1,4 @@ -import { IconButton, ListItemText, useTheme } from '@mui/material'; +import { IconButton, ListItemText } from '@mui/material'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import * as React from 'react'; @@ -37,7 +37,6 @@ export interface ActionMenuProps { */ export const ActionMenu = React.memo((props: ActionMenuProps) => { const { actionsList, ariaLabel, onOpen } = props; - const theme = useTheme(); const menuId = convertToKebabCase(ariaLabel); const buttonId = `${convertToKebabCase(ariaLabel)}-button`; @@ -70,16 +69,6 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { } const sxTooltipIcon = { - '& :hover': { - color: '#4d99f1', - }, - '&& .MuiSvgIcon-root': { - fill: theme.color.disabledText, - height: '20px', - width: '20px', - }, - - color: '#fff', padding: '0 0 0 8px', pointerEvents: 'all', // Allows the tooltip to be hovered on a disabled MenuItem }; @@ -89,12 +78,12 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { ({ ':hover': { - backgroundColor: theme.palette.primary.main, - color: '#fff', + backgroundColor: theme.color.buttonPrimaryHover, + color: theme.color.white, }, - backgroundColor: open ? theme.palette.primary.main : undefined, + backgroundColor: open ? theme.color.buttonPrimaryHover : undefined, borderRadius: 'unset', - color: open ? '#fff' : theme.textColors.linkActiveLight, + color: open ? theme.color.white : theme.textColors.linkActiveLight, height: '100%', minWidth: '40px', padding: '10px', @@ -122,7 +111,6 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { paper: { sx: (theme) => ({ backgroundColor: theme.palette.primary.main, - boxShadow: 'none', }), }, }} @@ -147,15 +135,6 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { a.onClick(); } }} - sx={{ - '&:hover': { - background: '#226dc3', - }, - background: '#3683dc', - borderBottom: '1px solid #5294e0', - color: '#fff', - padding: '10px 10px 10px 16px', - }} data-qa-action-menu-item={a.title} data-testid={a.title} disabled={a.disabled} diff --git a/packages/manager/src/components/BetaChip/BetaChip.test.tsx b/packages/manager/src/components/BetaChip/BetaChip.test.tsx index 47a86d03207..39d28178640 100644 --- a/packages/manager/src/components/BetaChip/BetaChip.test.tsx +++ b/packages/manager/src/components/BetaChip/BetaChip.test.tsx @@ -17,7 +17,7 @@ describe('BetaChip', () => { const { getByTestId } = renderWithTheme(); const betaChip = getByTestId('betaChip'); expect(betaChip).toBeInTheDocument(); - expect(betaChip).toHaveStyle('background-color: #3683dc'); + expect(betaChip).toHaveStyle('background-color: rgb(16, 138, 214)'); }); it('triggers an onClick callback', () => { diff --git a/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx b/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx index 8fe3480a3a9..848a0a164bc 100644 --- a/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx +++ b/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx @@ -4,11 +4,10 @@ import { Typography } from 'src/components/Typography'; export const StyledTypography = styled(Typography, { label: 'StyledTypography', -})(({ theme }) => ({ +})(({}) => ({ '&:hover': { textDecoration: 'underline', }, - color: theme.textColors.tableHeader, fontSize: '1.125rem', lineHeight: 'normal', textTransform: 'capitalize', diff --git a/packages/manager/src/components/Button/StyledActionButton.ts b/packages/manager/src/components/Button/StyledActionButton.ts index aa711d36626..008257f5a50 100644 --- a/packages/manager/src/components/Button/StyledActionButton.ts +++ b/packages/manager/src/components/Button/StyledActionButton.ts @@ -15,10 +15,12 @@ export const StyledActionButton = styled(Button, { })(({ theme, ...props }) => ({ ...(!props.disabled && { '&:hover': { - backgroundColor: theme.palette.primary.main, - color: theme.name === 'dark' ? theme.color.black : theme.color.white, + backgroundColor: theme.color.buttonPrimaryHover, + color: theme.color.white, }, }), + background: 'transparent', + color: theme.textColors.linkActiveLight, fontFamily: latoWeb.normal, fontSize: '14px', lineHeight: '16px', diff --git a/packages/manager/src/components/Button/StyledLinkButton.ts b/packages/manager/src/components/Button/StyledLinkButton.ts index 8c4cec0b4a8..1688a156f79 100644 --- a/packages/manager/src/components/Button/StyledLinkButton.ts +++ b/packages/manager/src/components/Button/StyledLinkButton.ts @@ -10,20 +10,5 @@ import { styled } from '@mui/material/styles'; export const StyledLinkButton = styled('button', { label: 'StyledLinkButton', })(({ theme }) => ({ - '&:disabled': { - color: theme.palette.text.disabled, - cursor: 'not-allowed', - }, - '&:hover:not(:disabled)': { - backgroundColor: 'transparent', - color: theme.palette.primary.main, - textDecoration: 'underline', - }, - background: 'none', - border: 'none', - color: theme.textColors.linkActiveLight, - cursor: 'pointer', - font: 'inherit', - minWidth: 0, - padding: 0, + ...theme.applyLinkStyles, })); diff --git a/packages/manager/src/components/Button/StyledTagButton.ts b/packages/manager/src/components/Button/StyledTagButton.ts index df83fcc4c88..d0dae58b7cd 100644 --- a/packages/manager/src/components/Button/StyledTagButton.ts +++ b/packages/manager/src/components/Button/StyledTagButton.ts @@ -24,11 +24,12 @@ export const StyledTagButton = styled(Button, { }), ...(!props.disabled && { '&:hover, &:focus': { - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, border: 'none', + color: theme.color.tagButtonText, }, - backgroundColor: theme.color.tagButton, - color: theme.textColors.linkActiveLight, + backgroundColor: theme.color.tagButtonBg, + color: theme.color.tagButtonText, }), })); diff --git a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx index 0fe1a57cbae..bd844227670 100644 --- a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx +++ b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx @@ -1,5 +1,3 @@ -import Paper from '@mui/material/Paper'; -import TableContainer from '@mui/material/TableContainer'; import * as React from 'react'; import { Table } from 'src/components/Table'; @@ -25,25 +23,23 @@ export const CollapsibleTable = (props: Props) => { const { TableItems, TableRowEmpty, TableRowHead } = props; return ( - - - - {TableRowHead} - - - {TableItems.length === 0 && TableRowEmpty} - {TableItems.map((item) => { - return ( - - ); - })} - -
-
+ + + {TableRowHead} + + + {TableItems.length === 0 && TableRowEmpty} + {TableItems.map((item) => { + return ( + + ); + })} + +
); }; diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.test.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.test.tsx deleted file mode 100644 index a9f6024520e..00000000000 --- a/packages/manager/src/components/ColorPalette/ColorPalette.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { ColorPalette } from './ColorPalette'; - -describe('Color Palette', () => { - it('renders the Color Palette', () => { - const { getAllByText, getByText } = renderWithTheme(); - - // primary colors - getByText('Primary Colors'); - getByText('theme.palette.primary.main'); - const mainHash = getAllByText('#3683dc'); - expect(mainHash).toHaveLength(2); - getByText('theme.palette.primary.light'); - getByText('#4d99f1'); - getByText('theme.palette.primary.dark'); - getByText('#2466b3'); - getByText('theme.palette.text.primary'); - const primaryHash = getAllByText('#606469'); - expect(primaryHash).toHaveLength(3); - getByText('theme.color.headline'); - const headlineHash = getAllByText('#32363c'); - expect(headlineHash).toHaveLength(2); - getByText('theme.palette.divider'); - const dividerHash = getAllByText('#f4f4f4'); - expect(dividerHash).toHaveLength(2); - const whiteColor = getAllByText('theme.color.white'); - expect(whiteColor).toHaveLength(2); - const whiteHash = getAllByText('#fff'); - expect(whiteHash).toHaveLength(3); - - // etc - getByText('Etc.'); - getByText('theme.color.red'); - getByText('#ca0813'); - getByText('theme.color.orange'); - getByText('#ffb31a'); - getByText('theme.color.yellow'); - getByText('#fecf2f'); - getByText('theme.color.green'); - getByText('#00b159'); - getByText('theme.color.teal'); - getByText('#17cf73'); - getByText('theme.color.border2'); - getByText('#c5c6c8'); - getByText('theme.color.border3'); - getByText('#eee'); - getByText('theme.color.grey1'); - getByText('#abadaf'); - getByText('theme.color.grey2'); - getByText('#e7e7e7'); - getByText('theme.color.grey3'); - getByText('#ccc'); - getByText('theme.color.grey4'); - getByText('#8C929D'); - getByText('theme.color.grey5'); - getByText('#f5f5f5'); - getByText('theme.color.grey6'); - const borderGreyHash = getAllByText('#e3e5e8'); - expect(borderGreyHash).toHaveLength(3); - getByText('theme.color.grey7'); - getByText('#e9eaef'); - getByText('theme.color.grey8'); - getByText('#dbdde1'); - getByText('theme.color.grey9'); - const borderGrey9Hash = getAllByText('#f4f5f6'); - expect(borderGrey9Hash).toHaveLength(3); - getByText('theme.color.black'); - getByText('#222'); - getByText('theme.color.offBlack'); - getByText('#444'); - getByText('theme.color.boxShadow'); - getByText('#ddd'); - getByText('theme.color.boxShadowDark'); - getByText('#aaa'); - getByText('theme.color.blueDTwhite'); - getByText('theme.color.tableHeaderText'); - getByText('rgba(0, 0, 0, 0.54)'); - getByText('theme.color.drawerBackdrop'); - getByText('rgba(255, 255, 255, 0.5)'); - getByText('theme.color.label'); - getByText('#555'); - getByText('theme.color.disabledText'); - getByText('#c9cacb'); - getByText('theme.color.tagButton'); - getByText('#f1f7fd'); - getByText('theme.color.tagIcon'); - getByText('#7daee8'); - - // background colors - getByText('Background Colors'); - getByText('theme.bg.app'); - getByText('theme.bg.main'); - getByText('theme.bg.offWhite'); - getByText('#fbfbfb'); - getByText('theme.bg.lightBlue1'); - getByText('#f0f7ff'); - getByText('theme.bg.lightBlue2'); - getByText('#e5f1ff'); - getByText('theme.bg.white'); - getByText('theme.bg.tableHeader'); - getByText('#f9fafa'); - getByText('theme.bg.primaryNavPaper'); - getByText('#3a3f46'); - getByText('theme.bg.mainContentBanner'); - getByText('#33373d'); - getByText('theme.bg.bgPaper'); - getByText('#ffffff'); - getByText('theme.bg.bgAccessRow'); - getByText('#fafafa'); - getByText('theme.bg.bgAccessRowTransparentGradient'); - getByText('rgb(255, 255, 255, .001)'); - - // typography colors - getByText('Typography Colors'); - getByText('theme.textColors.linkActiveLight'); - getByText('#2575d0'); - getByText('theme.textColors.headlineStatic'); - getByText('theme.textColors.tableHeader'); - getByText('#888f91'); - getByText('theme.textColors.tableStatic'); - getByText('theme.textColors.textAccessTable'); - - // border colors - getByText('Border Colors'); - getByText('theme.borderColors.borderTypography'); - getByText('theme.borderColors.borderTable'); - getByText('theme.borderColors.divider'); - }); -}); diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.tsx index a3bfaadb121..9404371eea9 100644 --- a/packages/manager/src/components/ColorPalette/ColorPalette.tsx +++ b/packages/manager/src/components/ColorPalette/ColorPalette.tsx @@ -1,12 +1,12 @@ -// eslint-disable-next-line no-restricted-imports import { useTheme } from '@mui/material'; import Grid from '@mui/material/Unstable_Grid2'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { Typography } from 'src/components/Typography'; +import type { Theme } from '@mui/material/styles'; + interface Color { alias: string; color: string; @@ -45,7 +45,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ /** * Add a new color to the palette, especially another tint of gray or blue, only after exhausting the option of using an existing color. * - * - Colors used in light mode are located in `foundations/light.ts + * - Colors used in light mode are located in `foundations/light.ts` * - Colors used in dark mode are located in `foundations/dark.ts` * * If a color does not exist in the current palette and is only used once, consider applying the color conditionally: @@ -102,7 +102,7 @@ export const ColorPalette = () => { { alias: 'theme.color.drawerBackdrop', color: theme.color.drawerBackdrop }, { alias: 'theme.color.label', color: theme.color.label }, { alias: 'theme.color.disabledText', color: theme.color.disabledText }, - { alias: 'theme.color.tagButton', color: theme.color.tagButton }, + { alias: 'theme.color.tagButton', color: theme.color.tagButtonBg }, { alias: 'theme.color.tagIcon', color: theme.color.tagIcon }, ]; diff --git a/packages/manager/src/components/Divider.tsx b/packages/manager/src/components/Divider.tsx index 6daa2d34fdb..cfd18a7fe5a 100644 --- a/packages/manager/src/components/Divider.tsx +++ b/packages/manager/src/components/Divider.tsx @@ -24,13 +24,6 @@ const StyledDivider = styled(_Divider, { 'dark', ]), })(({ theme, ...props }) => ({ - borderColor: props.dark - ? theme.color.border2 - : props.light - ? theme.name === 'light' - ? '#e3e5e8' - : '#2e3238' - : '', marginBottom: props.spacingBottom, marginTop: props.spacingTop, })); diff --git a/packages/manager/src/components/DocsLink/DocsLink.tsx b/packages/manager/src/components/DocsLink/DocsLink.tsx index fc13e6b3baf..aba71077a05 100644 --- a/packages/manager/src/components/DocsLink/DocsLink.tsx +++ b/packages/manager/src/components/DocsLink/DocsLink.tsx @@ -50,13 +50,10 @@ export const DocsLink = (props: DocsLinkProps) => { const StyledDocsLink = styled(Link, { label: 'StyledDocsLink', })(({ theme }) => ({ + ...theme.applyLinkStyles, '& svg': { marginRight: theme.spacing(), }, - '&:hover': { - color: theme.textColors.linkActiveLight, - textDecoration: 'underline', - }, alignItems: 'center', display: 'flex', fontFamily: theme.font.normal, diff --git a/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx b/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx index 9f291496d6b..6d8d47f0307 100644 --- a/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx +++ b/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx @@ -1,22 +1,22 @@ -import { Box } from 'src/components/Box'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import Minus from 'src/assets/icons/LKEminusSign.svg'; import Plus from 'src/assets/icons/LKEplusSign.svg'; +import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { TextField } from 'src/components/TextField'; const sxTextFieldBase = { '&::-webkit-inner-spin-button': { - '-webkit-appearance': 'none', + WebkitAppearance: 'none', margin: 0, }, '&::-webkit-outer-spin-button': { - '-webkit-appearance': 'none', + WebkitAppearance: 'none', margin: 0, }, - '-moz-appearance': 'textfield', + MozAppearance: 'textfield', padding: '0 8px', textAlign: 'right', }; diff --git a/packages/manager/src/components/EnhancedSelect/Select.styles.ts b/packages/manager/src/components/EnhancedSelect/Select.styles.ts index 9ffe05b76e6..597687971d1 100644 --- a/packages/manager/src/components/EnhancedSelect/Select.styles.ts +++ b/packages/manager/src/components/EnhancedSelect/Select.styles.ts @@ -1,6 +1,7 @@ -import { Theme } from '@mui/material/styles'; import { makeStyles } from 'tss-react/mui'; +import type { Theme } from '@mui/material/styles'; + // TODO jss-to-tss-react codemod: usages of this hook outside of this file will not be converted. export const useStyles = makeStyles()((theme: Theme) => ({ algoliaRoot: { @@ -225,6 +226,9 @@ export const useStyles = makeStyles()((theme: Theme) => ({ }, width: '100%', }, + '& .select-placeholder': { + color: theme.color.grey1, + }, '& [class*="MuiFormHelperText-error"]': { paddingBottom: theme.spacing(1), }, diff --git a/packages/manager/src/components/EnhancedSelect/Select.tsx b/packages/manager/src/components/EnhancedSelect/Select.tsx index 085210785f1..77f4ec721e5 100644 --- a/packages/manager/src/components/EnhancedSelect/Select.tsx +++ b/packages/manager/src/components/EnhancedSelect/Select.tsx @@ -1,18 +1,10 @@ -import { Theme, useTheme } from '@mui/material'; +import { useTheme } from '@mui/material'; import * as React from 'react'; -import ReactSelect, { - ActionMeta, - NamedProps as SelectProps, - ValueType, -} from 'react-select'; -import CreatableSelect, { - CreatableProps as CreatableSelectProps, -} from 'react-select/creatable'; +import ReactSelect from 'react-select'; +import CreatableSelect from 'react-select/creatable'; -import { TextFieldProps } from 'src/components/TextField'; import { convertToKebabCase } from 'src/utilities/convertToKebobCase'; -import { reactSelectStyles, useStyles } from './Select.styles'; import { DropdownIndicator } from './components/DropdownIndicator'; import Input from './components/Input'; import { LoadingIndicator } from './components/LoadingIndicator'; @@ -23,6 +15,16 @@ import NoOptionsMessage from './components/NoOptionsMessage'; import { Option } from './components/Option'; import Control from './components/SelectControl'; import { SelectPlaceholder as Placeholder } from './components/SelectPlaceholder'; +import { reactSelectStyles, useStyles } from './Select.styles'; + +import type { Theme } from '@mui/material'; +import type { + ActionMeta, + NamedProps as SelectProps, + ValueType, +} from 'react-select'; +import type { CreatableProps as CreatableSelectProps } from 'react-select/creatable'; +import type { TextFieldProps } from 'src/components/TextField'; export interface Item { data?: any; diff --git a/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx b/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx index 795daa1010f..e1b05f120ad 100644 --- a/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx +++ b/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx @@ -40,32 +40,14 @@ export const Default: Story = { variant: 'h2', }, render: (args) => { - const sxActionItem = { - '&:hover': { - backgroundColor: '#3683dc', - color: '#fff', - }, - color: '#2575d0', - fontFamily: '"LatoWeb", sans-serif', - fontSize: '0.875rem', - height: '34px', - minWidth: 'auto', - }; - return ( Chip / Progress Go Here - - - + + + { React.useEffect(() => { try { if (rootRef.current) { - const blocks = rootRef.current.querySelectorAll('pre code') ?? []; + const blocks: NodeListOf = + rootRef.current.querySelectorAll('pre code') ?? []; const len = blocks.length ?? 0; let i = 0; for (i; i < len; i++) { diff --git a/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap b/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap index 94033594dc5..238b90d44c9 100644 --- a/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap +++ b/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap @@ -3,7 +3,7 @@ exports[`HighlightedMarkdown component > should highlight text consistently 1`] = `

Some markdown diff --git a/packages/manager/src/components/HighlightedMarkdown/highlight.d.ts b/packages/manager/src/components/HighlightedMarkdown/highlight.d.ts deleted file mode 100644 index da3a13c1ae1..00000000000 --- a/packages/manager/src/components/HighlightedMarkdown/highlight.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module 'highlight.js/lib/core'; -declare module 'highlight.js/lib/languages/apache'; -declare module 'highlight.js/lib/languages/javascript'; -declare module 'highlight.js/lib/languages/yaml'; -declare module 'highlight.js/lib/languages/bash'; -declare module 'highlight.js/lib/languages/nginx'; diff --git a/packages/manager/src/components/ImageSelect/ImageOption.tsx b/packages/manager/src/components/ImageSelect/ImageOption.tsx index b1da36086ea..c619d845fa2 100644 --- a/packages/manager/src/components/ImageSelect/ImageOption.tsx +++ b/packages/manager/src/components/ImageSelect/ImageOption.tsx @@ -74,7 +74,7 @@ export const ImageOption = (props: ImageOptionProps) => { {data.isDistributedCompatible && ( - +
diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx index 664525d5383..76b63511e1d 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx @@ -1,8 +1,10 @@ import { DateTime } from 'luxon'; +import React from 'react'; import { imageFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; -import { imagesToGroupedItems } from './ImageSelect'; +import { ImageSelect, imagesToGroupedItems } from './ImageSelect'; describe('imagesToGroupedItems', () => { it('should filter deprecated images when end of life is past beyond 6 months ', () => { @@ -36,7 +38,7 @@ describe('imagesToGroupedItems', () => { isCloudInitCompatible: false, isDistributedCompatible: false, label: 'Slackware 14.1', - value: 'private/4', + value: 'private/5', }, { className: 'fl-tux', @@ -44,13 +46,15 @@ describe('imagesToGroupedItems', () => { isCloudInitCompatible: false, isDistributedCompatible: false, label: 'Slackware 14.1', - value: 'private/5', + value: 'private/6', }, ], }, ]; + expect(imagesToGroupedItems(images)).toStrictEqual(expected); }); + it('should add suffix `deprecated` to images at end of life ', () => { const images = [ ...imageFactory.buildList(2, { @@ -76,7 +80,7 @@ describe('imagesToGroupedItems', () => { isCloudInitCompatible: false, isDistributedCompatible: false, label: 'Debian 9 (deprecated)', - value: 'private/6', + value: 'private/7', }, { className: 'fl-tux', @@ -84,7 +88,7 @@ describe('imagesToGroupedItems', () => { isCloudInitCompatible: false, isDistributedCompatible: false, label: 'Debian 9 (deprecated)', - value: 'private/7', + value: 'private/8', }, ], }, @@ -92,3 +96,26 @@ describe('imagesToGroupedItems', () => { expect(imagesToGroupedItems(images)).toStrictEqual(expected); }); }); + +describe('ImageSelect', () => { + it('renders a "Indicates compatibility with distributed compute regions." notice if the user has at least one image with the distributed capability', async () => { + const images = [ + imageFactory.build({ capabilities: [] }), + imageFactory.build({ capabilities: ['distributed-images'] }), + imageFactory.build({ capabilities: [] }), + ]; + + const { getByText } = renderWithTheme( + + ); + + expect( + getByText('Indicates compatibility with distributed compute regions.') + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.tsx index 5b320a4d7a7..1fdbb9a4498 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.tsx @@ -1,13 +1,11 @@ -import { Image } from '@linode/api-v4/lib/images'; -import Grid from '@mui/material/Unstable_Grid2'; import produce from 'immer'; import { DateTime } from 'luxon'; import { equals, groupBy } from 'ramda'; import * as React from 'react'; -import Select, { GroupType, Item } from 'src/components/EnhancedSelect'; +import DistributedRegionIcon from 'src/assets/icons/entityIcons/distributed-region.svg'; +import Select from 'src/components/EnhancedSelect'; import { _SingleValue } from 'src/components/EnhancedSelect/components/SingleValue'; -import { BaseSelectProps } from 'src/components/EnhancedSelect/Select'; import { ImageOption } from 'src/components/ImageSelect/ImageOption'; import { Paper } from 'src/components/Paper'; import { Typography } from 'src/components/Typography'; @@ -17,7 +15,13 @@ import { arePropsEqual } from 'src/utilities/arePropsEqual'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getSelectedOptionFromGroupedOptions } from 'src/utilities/getSelectedOptionFromGroupedOptions'; +import { Box } from '../Box'; import { distroIcons } from '../DistributionIcon'; +import { Stack } from '../Stack'; + +import type { Image } from '@linode/api-v4/lib/images'; +import type { GroupType, Item } from 'src/components/EnhancedSelect'; +import type { BaseSelectProps } from 'src/components/EnhancedSelect/Select'; export type Variant = 'all' | 'private' | 'public'; @@ -32,7 +36,10 @@ interface ImageSelectProps { classNames?: string; disabled?: boolean; error?: string; - handleSelectImage: (selection?: string) => void; + handleSelectImage: ( + selection: string | undefined, + image: Image | undefined + ) => void; images: Image[]; selectedImageID?: string; title: string; @@ -148,12 +155,12 @@ export const ImageSelect = React.memo((props: ImageSelectProps) => { const { classNames, disabled, + error: errorText, handleSelectImage, images, selectedImageID, title, variant, - ...reactSelectProps } = props; // Check for loading status and request errors in React Query @@ -192,37 +199,55 @@ export const ImageSelect = React.memo((props: ImageSelectProps) => { const onChange = (selection: ImageItem | null) => { if (selection === null) { - return handleSelectImage(undefined); + return handleSelectImage(undefined, undefined); } - return handleSelectImage(selection.value); + const selectedImage = images.find((i) => i.id === selection.value); + + return handleSelectImage(selection.value, selectedImage); }; + const showDistributedCapabilityNotice = + variant === 'private' && + filteredImages.some((image) => + image.capabilities.includes('distributed-images') + ); + return ( {title} - - - + {showDistributedCapabilityNotice && ( + + + + Indicates compatibility with distributed compute regions. + + + )} + ); }, isMemo); diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx index 33923a9f889..67da3a0bbf6 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx @@ -44,7 +44,9 @@ describe('ImageOptionv2', () => { ); expect( - getByLabelText('This image is compatible with distributed regions.') + getByLabelText( + 'This image is compatible with distributed compute regions.' + ) ).toBeVisible(); }); }); diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx index d8ceb098d02..c1d8c139f3f 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx @@ -40,7 +40,7 @@ export const ImageOptionv2 = ({ image, isSelected, listItemProps }: Props) => {
{image.capabilities.includes('distributed-images') && ( - +
diff --git a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx index 94e0f7d417f..dba7a9ae442 100644 --- a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx +++ b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx @@ -52,7 +52,7 @@ export const InlineMenuAction = (props: InlineMenuActionProps) => { = { export default meta; const StyledWrapper = styled('div')(({ theme }) => ({ - backgroundColor: theme.color.grey2, - padding: theme.spacing(2), })); diff --git a/packages/manager/src/components/Notice/Notice.test.tsx b/packages/manager/src/components/Notice/Notice.test.tsx index cf12ad28ca2..e7d536cd907 100644 --- a/packages/manager/src/components/Notice/Notice.test.tsx +++ b/packages/manager/src/components/Notice/Notice.test.tsx @@ -58,7 +58,7 @@ describe('Notice Component', () => { it('applies variant prop', () => { const { container } = renderWithTheme(); - expect(container.firstChild).toHaveStyle('border-left: 5px solid #ca0813;'); + expect(container.firstChild).toHaveStyle('border-left: 5px solid #d63c42;'); }); it('displays icon for important notices', () => { diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts index c62e5c2d996..03912e910e5 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts @@ -1,8 +1,9 @@ -import { Theme } from '@mui/material/styles'; import { makeStyles } from 'tss-react/mui'; import { SIDEBAR_WIDTH } from 'src/components/PrimaryNav/SideMenu'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()( (theme: Theme, _params, classes) => ({ active: { @@ -10,7 +11,7 @@ const useStyles = makeStyles()( opacity: 1, }, '& svg': { - color: theme.color.teal, + color: theme.palette.success.dark, }, backgroundImage: 'linear-gradient(98deg, #38584B 1%, #3A5049 166%)', textDecoration: 'none', @@ -22,12 +23,6 @@ const useStyles = makeStyles()( backgroundColor: 'rgba(0, 0, 0, 0.12)', color: '#222', }, - fadeContainer: { - display: 'flex', - flexDirection: 'column', - height: 'calc(100% - 90px)', - width: '100%', - }, linkItem: { '&.hiddenWhenCollapsed': { maxHeight: 36, @@ -70,8 +65,8 @@ const useStyles = makeStyles()( opacity: 1, }, '& svg': { - color: theme.color.teal, - fill: theme.color.teal, + color: theme.palette.success.dark, + fill: theme.palette.success.dark, }, [`& .${classes.linkItem}`]: { color: 'white', @@ -86,7 +81,6 @@ const useStyles = makeStyles()( minWidth: SIDEBAR_WIDTH, padding: '8px 13px', position: 'relative', - transition: theme.transitions.create(['background-color']), }, logo: { '& .akamai-logo-name': { @@ -96,7 +90,7 @@ const useStyles = makeStyles()( transition: 'width .1s linear', }, logoAkamaiCollapsed: { - background: theme.bg.primaryNavPaper, + background: theme.bg.appBar, width: 83, }, logoContainer: { @@ -111,6 +105,7 @@ const useStyles = makeStyles()( }, logoItemAkamai: { alignItems: 'center', + backgroundColor: theme.name === 'dark' ? theme.bg.appBar : undefined, display: 'flex', height: 50, paddingLeft: 13, diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 4c7ec032ab0..180c6c5a32b 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -1,6 +1,6 @@ import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; -import { Link, LinkProps, useLocation } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import Account from 'src/assets/icons/account.svg'; import CloudPulse from 'src/assets/icons/cloudpulse.svg'; @@ -43,6 +43,8 @@ import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import useStyles from './PrimaryNav.styles'; import { linkIsActive } from './utils'; +import type { LinkProps } from 'react-router-dom'; + type NavEntity = | 'Account' | 'Account' @@ -343,7 +345,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { spacing={0} wrap="nowrap" > - + { -
- {primaryLinkGroups.map((thisGroup, idx) => { - const filteredLinks = thisGroup.filter((thisLink) => !thisLink.hide); - if (filteredLinks.length === 0) { - return null; - } - return ( -
- - {filteredLinks.map((thisLink) => { - const props = { - closeMenu, - isCollapsed, - key: thisLink.display, - locationPathname: location.pathname, - locationSearch: location.search, - ...thisLink, - }; - - // PrefetchPrimaryLink and PrimaryLink are two separate components because invocation of - // hooks cannot be conditional. is a wrapper around - // that includes the usePrefetch hook. - return thisLink.prefetchRequestFn && - thisLink.prefetchRequestCondition !== undefined ? ( - - ) : ( - - ); + + {primaryLinkGroups.map((thisGroup, idx) => { + const filteredLinks = thisGroup.filter((thisLink) => !thisLink.hide); + if (filteredLinks.length === 0) { + return null; + } + return ( +
+ ({ + borderColor: + theme.name === 'light' + ? theme.borderColors.dividerDark + : 'rgba(0, 0, 0, 0.19)', })} -
- ); - })} -
+ className={classes.divider} + spacingBottom={11} + /> + {filteredLinks.map((thisLink) => { + const props = { + closeMenu, + isCollapsed, + key: thisLink.display, + locationPathname: location.pathname, + locationSearch: location.search, + ...thisLink, + }; + + // PrefetchPrimaryLink and PrimaryLink are two separate components because invocation of + // hooks cannot be conditional. is a wrapper around + // that includes the usePrefetch hook. + return thisLink.prefetchRequestFn && + thisLink.prefetchRequestCondition !== undefined ? ( + + ) : ( + + ); + })} +
+ ); + })}
); }; diff --git a/packages/manager/src/components/PrimaryNav/SideMenu.tsx b/packages/manager/src/components/PrimaryNav/SideMenu.tsx index e901df9883c..5be1241600f 100644 --- a/packages/manager/src/components/PrimaryNav/SideMenu.tsx +++ b/packages/manager/src/components/PrimaryNav/SideMenu.tsx @@ -66,7 +66,8 @@ const StyledDrawer = styled(Drawer, { shouldForwardProp: (prop) => prop !== 'collapse', })<{ collapse?: boolean }>(({ theme, ...props }) => ({ '& .MuiDrawer-paper': { - backgroundColor: theme.bg.primaryNavPaper, + backgroundColor: + theme.name === 'dark' ? theme.bg.appBar : theme.bg.primaryNavPaper, borderRight: 'none', boxShadow: 'none', height: '100%', diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index 4ba6d5879a7..2d3126e008a 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -67,6 +67,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { selectedIds, sortRegionOptions, width, + onClose, } = props; const { @@ -171,6 +172,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { options={regionOptions} placeholder={placeholder ?? 'Select Regions'} value={selectedRegions} + onClose={onClose} /> {selectedRegions.length > 0 && SelectedRegionsList && ( diff --git a/packages/manager/src/components/RegionSelect/RegionOption.tsx b/packages/manager/src/components/RegionSelect/RegionOption.tsx index d5d87778acd..718f43358dc 100644 --- a/packages/manager/src/components/RegionSelect/RegionOption.tsx +++ b/packages/manager/src/components/RegionSelect/RegionOption.tsx @@ -4,6 +4,7 @@ import React from 'react'; import DistributedRegion from 'src/assets/icons/entityIcons/distributed-region.svg'; import { Box } from 'src/components/Box'; import { Flag } from 'src/components/Flag'; +import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Tooltip } from 'src/components/Tooltip'; import { TooltipIcon } from 'src/components/TooltipIcon'; @@ -34,9 +35,9 @@ export const RegionOption = ({ const { className, onClick } = props; const isRegionDisabled = Boolean(disabledOptions); const isRegionDisabledReason = disabledOptions?.reason; - + const { isGeckoBetaEnabled, isGeckoGAEnabled } = useIsGeckoEnabled(); const displayDistributedRegionIcon = - region.site_type === 'edge' || region.site_type === 'distributed'; + isGeckoBetaEnabled && region.site_type === 'distributed'; return ( - {region.label} ({region.id}) + {isGeckoGAEnabled ? region.label : `${region.label} (${region.id})`} {displayDistributedRegionIcon && (  (This region is a distributed region.) @@ -86,6 +87,7 @@ export const RegionOption = ({ {isRegionDisabledReason} )} + {isGeckoGAEnabled && `(${region.id})`} {selected && } {displayDistributedRegionIcon && ( { + // @TODO Gecko: Remove adornment after GA + if (isGeckoBetaEnabled && selectedRegion?.site_type === 'distributed') { + return ( + } + status="other" + sxTooltipIcon={sxDistributedRegionIcon} + text="This region is a distributed region." + /> + ); + } + if (isGeckoGAEnabled && selectedRegion) { + return `(${selectedRegion?.id})`; + } + return null; + }, [isGeckoBetaEnabled, isGeckoGAEnabled, selectedRegion]); + return ( + getOptionLabel={(region) => + isGeckoGAEnabled ? region.label : `${region.label} (${region.id})` + } renderOption={(props, region) => ( } - status="other" - sxTooltipIcon={sxDistributedRegionIcon} - text="This region is a distributed region." - /> - ), + endAdornment: EndAdornment, required, startAdornment: selectedRegion && ( @@ -139,7 +156,6 @@ export const RegionSelect = < disabled={disabled} errorText={errorText} getOptionDisabled={(option) => Boolean(disabledRegions[option.id])} - getOptionLabel={(region) => `${region.label} (${region.id})`} groupBy={(option) => getRegionCountryGroup(option)} helperText={helperText} label={label ?? 'Region'} diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index 83413b4bae0..0b01fc594c0 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -20,6 +20,20 @@ export interface DisableRegionOption { tooltipWidth?: number; } +export type RegionFilterValue = + | 'distributed-AF' + | 'distributed-ALL' + | 'distributed-AS' + | 'distributed-EU' + | 'distributed-NA' + | 'distributed-OC' + | 'distributed-SA' + | RegionSite; + +export interface GetRegionLabel { + includeSlug?: boolean; + region: Region; +} export interface RegionSelectProps< DisableClearable extends boolean | undefined = undefined > extends Omit< @@ -40,7 +54,7 @@ export interface RegionSelectProps< disabledRegions?: Record; helperText?: string; label?: string; - regionFilter?: RegionSite; + regionFilter?: RegionFilterValue; regions: Region[]; required?: boolean; showDistributedRegionIconHelperText?: boolean; @@ -79,5 +93,3 @@ export interface GetRegionOptionAvailability { currentCapability: Capabilities | undefined; region: Region; } - -export type SupportedDistributedRegionTypes = 'Distributions' | 'StackScripts'; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx index 22ba45e2dc8..1681e0bb59b 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx @@ -5,6 +5,8 @@ import { isRegionOptionUnavailable, } from './RegionSelect.utils'; +import type { Region } from '@linode/api-v4'; + describe('getRegionOptions', () => { it('should return an empty array if no regions are provided', () => { const result = getRegionOptions({ @@ -177,6 +179,97 @@ describe('getRegionOptions', () => { expect(result).toEqual(regions); }); + + it('should filter out distributed regions by continent if the regionFilter includes continent', () => { + const regions2 = [ + regionFactory.build({ + id: 'us-1', + label: 'US Site 1', + site_type: 'distributed', + }), + regionFactory.build({ + id: 'us-1', + label: 'US Site 2', + site_type: 'core', + }), + regionFactory.build({ + country: 'de', + id: 'eu-2', + label: 'EU Site 2', + site_type: 'distributed', + }), + ]; + + const resultNA = getRegionOptions({ + currentCapability: undefined, + regionFilter: 'distributed-NA', + regions: regions2, + }); + const resultEU = getRegionOptions({ + currentCapability: undefined, + regionFilter: 'distributed-EU', + regions: regions2, + }); + + expect(resultNA).toEqual([ + regionFactory.build({ + id: 'us-1', + label: 'US Site 1', + site_type: 'distributed', + }), + ]); + expect(resultEU).toEqual([ + regionFactory.build({ + country: 'de', + id: 'eu-2', + label: 'EU Site 2', + site_type: 'distributed', + }), + ]); + }); + + it('should not filter out distributed regions by continent if the regionFilter includes all', () => { + const regions: Region[] = [ + regionFactory.build({ + id: 'us-1', + label: 'US Site 1', + site_type: 'core', + }), + regionFactory.build({ + country: 'de', + id: 'eu-2', + label: 'EU Site 2', + site_type: 'distributed', + }), + regionFactory.build({ + country: 'us', + id: 'us-2', + label: 'US Site 2', + site_type: 'distributed', + }), + ]; + + const resultAll = getRegionOptions({ + currentCapability: undefined, + regionFilter: 'distributed-ALL', + regions, + }); + + expect(resultAll).toEqual([ + regionFactory.build({ + country: 'us', + id: 'us-2', + label: 'US Site 2', + site_type: 'distributed', + }), + regionFactory.build({ + country: 'de', + id: 'eu-2', + label: 'EU Site 2', + site_type: 'distributed', + }), + ]); + }); }); const accountAvailabilityData = [ diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx index 1c417cb7d6d..7d7c21d8e9b 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx @@ -1,28 +1,29 @@ -import { - CONTINENT_CODE_TO_CONTINENT, - Capabilities, - RegionSite, -} from '@linode/api-v4'; +import { CONTINENT_CODE_TO_CONTINENT } from '@linode/api-v4'; +import { useFlags } from 'src/hooks/useFlags'; +import { useRegionsQuery } from 'src/queries/regions/regions'; import { getRegionCountryGroup } from 'src/utilities/formatRegion'; import type { + GetRegionLabel, GetRegionOptionAvailability, - SupportedDistributedRegionTypes, + RegionFilterValue, } from './RegionSelect.types'; -import type { AccountAvailability, Region } from '@linode/api-v4'; +import type { AccountAvailability, Capabilities, Region } from '@linode/api-v4'; import type { LinodeCreateType } from 'src/features/Linodes/LinodesCreate/types'; const NORTH_AMERICA = CONTINENT_CODE_TO_CONTINENT.NA; interface RegionSelectOptionsOptions { currentCapability: Capabilities | undefined; - regionFilter?: RegionSite; + isGeckoGAEnabled?: boolean; + regionFilter?: RegionFilterValue; regions: Region[]; } export const getRegionOptions = ({ currentCapability, + isGeckoGAEnabled, regionFilter, regions, }: RegionSelectOptionsOptions) => { @@ -34,8 +35,18 @@ export const getRegionOptions = ({ ) { return false; } - if (regionFilter && region.site_type !== regionFilter) { - return false; + if (regionFilter) { + const [, distributedContinentCode] = regionFilter.split('distributed-'); + // Filter distributed regions by geographical area + if (distributedContinentCode && distributedContinentCode !== 'ALL') { + const group = getRegionCountryGroup(region); + return ( + region.site_type === 'edge' || + (region.site_type === 'distributed' && + CONTINENT_CODE_TO_CONTINENT[distributedContinentCode] === group) + ); + } + return regionFilter.includes(region.site_type); } return true; }) @@ -62,18 +73,24 @@ export const getRegionOptions = ({ return 1; } - // Then we group by country - if (region1.country < region2.country) { - return 1; - } - if (region1.country > region2.country) { - return -1; + // We want to group by label for Gecko GA + if (!isGeckoGAEnabled) { + // Then we group by country + if (region1.country < region2.country) { + return 1; + } + if (region1.country > region2.country) { + return -1; + } } - // If regions are in the same group or country, sort alphabetically by label + // Then we group by label if (region1.label < region2.label) { return -1; } + if (region1.label > region2.label) { + return 1; + } return 1; }); @@ -113,15 +130,13 @@ export const isRegionOptionUnavailable = ({ * @returns a boolean indicating whether or not the create type supports distributed regions. */ export const isDistributedRegionSupported = (createType: LinodeCreateType) => { - const supportedDistributedRegionTypes: SupportedDistributedRegionTypes[] = [ + const supportedDistributedRegionTypes = [ 'Distributions', 'StackScripts', + 'Images', + undefined, // /linodes/create route ]; - return ( - supportedDistributedRegionTypes.includes( - createType as SupportedDistributedRegionTypes - ) || typeof createType === 'undefined' // /linodes/create route - ); + return supportedDistributedRegionTypes.includes(createType); }; /** @@ -138,3 +153,26 @@ export const getIsDistributedRegion = ( ); return region?.site_type === 'distributed' || region?.site_type === 'edge'; }; + +export const getNewRegionLabel = ({ includeSlug, region }: GetRegionLabel) => { + const [city] = region.label.split(', '); + if (includeSlug) { + return `${region.country.toUpperCase()}, ${city} ${`(${region.id})`}`; + } + return `${region.country.toUpperCase()}, ${city}`; +}; + +export const useIsGeckoEnabled = () => { + const flags = useFlags(); + const isGeckoGA = flags?.gecko2?.enabled && flags.gecko2.ga; + const isGeckoBeta = flags.gecko2?.enabled && !flags.gecko2?.ga; + const { data: regions } = useRegionsQuery(isGeckoGA); + + const hasDistributedRegionCapability = regions?.some((region: Region) => + region.capabilities.includes('Distributed Plans') + ); + const isGeckoGAEnabled = hasDistributedRegionCapability && isGeckoGA; + const isGeckoBetaEnabled = hasDistributedRegionCapability && isGeckoBeta; + + return { isGeckoBetaEnabled, isGeckoGAEnabled }; +}; diff --git a/packages/manager/src/components/RegionSelect/TwoStepRegionSelect.tsx b/packages/manager/src/components/RegionSelect/TwoStepRegionSelect.tsx new file mode 100644 index 00000000000..c651d2d2a5d --- /dev/null +++ b/packages/manager/src/components/RegionSelect/TwoStepRegionSelect.tsx @@ -0,0 +1,139 @@ +import * as React from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Box } from 'src/components/Box'; +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +import { Tab } from 'src/components/Tabs/Tab'; +import { TabList } from 'src/components/Tabs/TabList'; +import { TabPanels } from 'src/components/Tabs/TabPanels'; +import { Tabs } from 'src/components/Tabs/Tabs'; +import { sendLinodeCreateDocsEvent } from 'src/utilities/analytics/customEventAnalytics'; + +import type { + DisableRegionOption, + RegionFilterValue, +} from './RegionSelect.types'; +import type { Region } from '@linode/api-v4'; +import type { SelectRegionPanelProps } from 'src/components/SelectRegionPanel/SelectRegionPanel'; + +interface TwoStepRegionSelectProps + extends Omit { + disabledRegions: Record; + regions: Region[]; + value?: string; +} + +interface GeographicalAreaOption { + label: string; + value: RegionFilterValue; +} + +const GEOGRAPHICAL_AREA_OPTIONS: GeographicalAreaOption[] = [ + { + label: 'All', + value: 'distributed-ALL', + }, + { + label: 'North America', + value: 'distributed-NA', + }, + { + label: 'Africa', + value: 'distributed-AF', + }, + { + label: 'Asia', + value: 'distributed-AS', + }, + { + label: 'Europe', + value: 'distributed-EU', + }, + { + label: 'Oceania', + value: 'distributed-OC', + }, + { + label: 'South America', + value: 'distributed-SA', + }, +]; + +export const TwoStepRegionSelect = (props: TwoStepRegionSelectProps) => { + const { + RegionSelectProps, + currentCapability, + disabled, + disabledRegions, + error, + handleSelection, + helperText, + regions, + value, + } = props; + + const [regionFilter, setRegionFilter] = React.useState( + 'distributed' + ); + + return ( + + + Core + Distributed + + + + + sendLinodeCreateDocsEvent('Speedtest')} + /> + + handleSelection(region.id)} + regionFilter="core" + regions={regions ?? []} + showDistributedRegionIconHelperText={false} + value={value} + {...RegionSelectProps} + /> + + + { + if (selectedOption?.value) { + setRegionFilter(selectedOption.value); + } + }} + defaultValue={GEOGRAPHICAL_AREA_OPTIONS[0]} + disableClearable + label="Geographical Area" + options={GEOGRAPHICAL_AREA_OPTIONS} + /> + handleSelection(region.id)} + regionFilter={regionFilter} + regions={regions ?? []} + showDistributedRegionIconHelperText={false} + value={value} + {...RegionSelectProps} + /> + + + + ); +}; diff --git a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.test.tsx b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.test.tsx index ab77c8c36be..48d8a822850 100644 --- a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.test.tsx +++ b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.test.tsx @@ -1,40 +1,27 @@ -import { Capabilities } from '@linode/api-v4'; +import userEvent from '@testing-library/user-event'; import React from 'react'; -import { regionFactory } from 'src/factories'; +import { imageFactory, regionFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { SelectRegionPanel } from './SelectRegionPanel'; +import type { Capabilities } from '@linode/api-v4'; + const pricingMocks = vi.hoisted(() => ({ isLinodeTypeDifferentPriceInSelectedRegion: vi.fn().mockReturnValue(false), })); -const queryParamMocks = vi.hoisted(() => ({ - getQueryParamFromQueryString: vi.fn().mockReturnValue({}), - getQueryParamsFromQueryString: vi.fn().mockReturnValue({}), -})); - vi.mock('src/utilities/pricing/linodes', () => ({ isLinodeTypeDifferentPriceInSelectedRegion: pricingMocks.isLinodeTypeDifferentPriceInSelectedRegion, })); -vi.mock('src/utilities/queryParams', () => ({ - getQueryParamFromQueryString: queryParamMocks.getQueryParamFromQueryString, - getQueryParamsFromQueryString: queryParamMocks.getQueryParamsFromQueryString, -})); - const createPath = '/linodes/create'; describe('SelectRegionPanel on the Clone Flow', () => { - beforeEach(() => { - queryParamMocks.getQueryParamsFromQueryString.mockReturnValue({ - regionID: 'us-east', - type: 'Clone+Linode', - }); - }); - const regions = [...regionFactory.buildList(3)]; const mockedProps = { currentCapability: 'Linodes' as Capabilities, @@ -92,7 +79,9 @@ describe('SelectRegionPanel on the Clone Flow', () => { , { MemoryRouter: { - initialEntries: [createPath], + initialEntries: [ + '/linodes/create?regionID=us-east&type=Clone+Linode', + ], }, } ); @@ -110,7 +99,9 @@ describe('SelectRegionPanel on the Clone Flow', () => { , { MemoryRouter: { - initialEntries: [createPath], + initialEntries: [ + '/linodes/create?regionID=us-east&type=Clone+Linode', + ], }, } ); @@ -129,7 +120,9 @@ describe('SelectRegionPanel on the Clone Flow', () => { , { MemoryRouter: { - initialEntries: [createPath], + initialEntries: [ + '/linodes/create?regionID=us-east&type=Clone+Linode', + ], }, } ); @@ -139,4 +132,51 @@ describe('SelectRegionPanel on the Clone Flow', () => { expect(getByTestId('cross-data-center-notice')).toBeInTheDocument(); expect(getByTestId('different-price-structure-notice')).toBeInTheDocument(); }); + + it('should disable distributed regions if the selected image does not have the `distributed-images` capability', async () => { + const image = imageFactory.build({ capabilities: [] }); + + const distributedRegion = regionFactory.build({ + capabilities: ['Linodes'], + site_type: 'distributed', + }); + const coreRegion = regionFactory.build({ + capabilities: ['Linodes'], + site_type: 'core', + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json( + makeResourcePage([coreRegion, distributedRegion]) + ); + }), + http.get('*/v4/images', () => { + return HttpResponse.json(makeResourcePage([image])); + }) + ); + + const { findByText, getByLabelText } = renderWithTheme( + , + { + MemoryRouter: { initialEntries: ['/linodes/create?type=Images'] }, + } + ); + + const regionSelect = getByLabelText('Region'); + + await userEvent.click(regionSelect); + + const distributedRegionOption = await findByText(distributedRegion.id, { + exact: false, + }); + + expect(distributedRegionOption.closest('li')?.textContent).toContain( + 'The selected image cannot be deployed to a distributed region.' + ); + }); }); diff --git a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx index 4c53d1cf9e2..b78fcdc2e42 100644 --- a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx +++ b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx @@ -5,11 +5,15 @@ import { useLocation } from 'react-router-dom'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { isDistributedRegionSupported } from 'src/components/RegionSelect/RegionSelect.utils'; +import { TwoStepRegionSelect } from 'src/components/RegionSelect/TwoStepRegionSelect'; import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; import { Typography } from 'src/components/Typography'; +import { getDisabledRegions } from 'src/features/Linodes/LinodeCreatev2/Region.utils'; import { CROSS_DATA_CENTER_CLONE_WARNING } from 'src/features/Linodes/LinodesCreate/constants'; import { useFlags } from 'src/hooks/useFlags'; +import { useImageQuery } from 'src/queries/images'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useTypeQuery } from 'src/queries/types'; import { sendLinodeCreateDocsEvent } from 'src/utilities/analytics/customEventAnalytics'; @@ -29,7 +33,7 @@ import type { RegionSelectProps } from '../RegionSelect/RegionSelect.types'; import type { Capabilities } from '@linode/api-v4/lib/regions'; import type { LinodeCreateType } from 'src/features/Linodes/LinodesCreate/types'; -interface SelectRegionPanelProps { +export interface SelectRegionPanelProps { RegionSelectProps?: Partial>; currentCapability: Capabilities; disabled?: boolean; @@ -37,10 +41,12 @@ interface SelectRegionPanelProps { handleSelection: (id: string) => void; helperText?: string; selectedId?: string; + selectedImageId?: string; /** * Include a `selectedLinodeTypeId` so we can tell if the region selection will have an affect on price */ selectedLinodeTypeId?: string; + updateTypeID?: (key: string) => void; } export const SelectRegionPanel = (props: SelectRegionPanelProps) => { @@ -52,14 +58,19 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { handleSelection, helperText, selectedId, + selectedImageId, selectedLinodeTypeId, + updateTypeID, } = props; const flags = useFlags(); const location = useLocation(); const theme = useTheme(); const params = getQueryParamsFromQueryString(location.search); - const { data: regions } = useRegionsQuery(); + + const { isGeckoGAEnabled } = useIsGeckoEnabled(); + + const { data: regions } = useRegionsQuery(isGeckoGAEnabled); const isCloning = /clone/i.test(params.type); const isFromLinodeCreate = location.pathname.includes('/linodes/create'); @@ -69,6 +80,11 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { Boolean(selectedLinodeTypeId) ); + const { data: image } = useImageQuery( + selectedImageId ?? '', + Boolean(selectedImageId) + ); + const currentLinodeRegion = params.regionID; const showCrossDataCenterCloneWarning = @@ -84,7 +100,6 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { const hideDistributedRegions = !flags.gecko2?.enabled || - flags.gecko2?.ga || !isDistributedRegionSupported(params.type as LinodeCreateType); const showDistributedRegionIconHelperText = Boolean( @@ -97,10 +112,24 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { ) ); + const disabledRegions = getDisabledRegions({ + linodeCreateTab: params.type as LinodeCreateType, + regions: regions ?? [], + selectedImage: image, + }); + if (regions?.length === 0) { return null; } + const handleRegionSelection = (regionId: string) => { + handleSelection(regionId); + // Reset plan selection on region change to prevent creation of an edge plan in a core region and vice versa + if (updateTypeID) { + updateTypeID(''); + } + }; + return ( { label={DOCS_LINK_LABEL_DC_PRICING} /> - sendLinodeCreateDocsEvent('Speedtest')} - /> + {!isGeckoGAEnabled && ( + sendLinodeCreateDocsEvent('Speedtest')} + /> + )} {showCrossDataCenterCloneWarning ? ( { ) : null} - handleSelection(region.id)} - regionFilter={hideDistributedRegions ? 'core' : undefined} - regions={regions ?? []} - value={selectedId} - {...RegionSelectProps} - /> + {isGeckoGAEnabled && + isDistributedRegionSupported(params.type as LinodeCreateType) ? ( + + ) : ( + handleSelection(region.id)} + regions={regions ?? []} + value={selectedId} + {...RegionSelectProps} + /> + )} {showClonePriceWarning && ( ({ '&.notistack-MuiContent-error': { - borderLeft: `6px solid ${theme.palette.error.dark}`, + backgroundColor: theme.notificationToast.error.backgroundColor, + borderLeft: theme.notificationToast.error.borderLeft, }, '&.notistack-MuiContent-info': { - borderLeft: `6px solid ${theme.palette.primary.main}`, + backgroundColor: theme.notificationToast.info.backgroundColor, + borderLeft: theme.notificationToast.info.borderLeft, }, '&.notistack-MuiContent-success': { - borderLeft: `6px solid ${theme.palette.success.main}`, // corrected to palette.success + backgroundColor: theme.notificationToast.success.backgroundColor, + borderLeft: theme.notificationToast.success.borderLeft, }, '&.notistack-MuiContent-warning': { - borderLeft: `6px solid ${theme.palette.warning.dark}`, + backgroundColor: theme.notificationToast.warning.backgroundColor, + borderLeft: theme.notificationToast.warning.borderLeft, }, }) ); const useStyles = makeStyles()((theme: Theme) => ({ root: { - '& div': { - backgroundColor: `${theme.bg.white} !important`, - color: theme.palette.text.primary, + '& .notistack-MuiContent': { + color: theme.notificationToast.default.color, fontSize: '0.875rem', }, + '& .notistack-MuiContent-default': { + backgroundColor: theme.notificationToast.default.backgroundColor, + borderLeft: theme.notificationToast.default.borderLeft, + }, [theme.breakpoints.down('md')]: { '& .SnackbarItem-contentRoot': { flexWrap: 'nowrap', diff --git a/packages/manager/src/components/StackScript/StackScript.test.tsx b/packages/manager/src/components/StackScript/StackScript.test.tsx index e6ccb1e4e11..f8b642cd540 100644 --- a/packages/manager/src/components/StackScript/StackScript.test.tsx +++ b/packages/manager/src/components/StackScript/StackScript.test.tsx @@ -8,7 +8,7 @@ import { StackScript } from './StackScript'; describe('StackScript', () => { it('should render the StackScript label, id, and username', () => { - const stackScript = stackScriptFactory.build(); + const stackScript = stackScriptFactory.build({ id: 1234 }); renderWithTheme(); expect(screen.getByText(stackScript.label)).toBeInTheDocument(); diff --git a/packages/manager/src/components/StatusIcon/StatusIcon.tsx b/packages/manager/src/components/StatusIcon/StatusIcon.tsx index fab623fe350..54d84bfee4f 100644 --- a/packages/manager/src/components/StatusIcon/StatusIcon.tsx +++ b/packages/manager/src/components/StatusIcon/StatusIcon.tsx @@ -53,16 +53,16 @@ const StyledDiv = styled(Box, { transition: theme.transitions.create(['color']), width: '16px', ...(props.status === 'active' && { - backgroundColor: theme.color.teal, + backgroundColor: theme.palette.success.dark, }), ...(props.status === 'inactive' && { backgroundColor: theme.color.grey8, }), ...(props.status === 'error' && { - backgroundColor: theme.color.red, + backgroundColor: theme.palette.error.dark, }), ...(!['active', 'error', 'inactive'].includes(props.status) && { - backgroundColor: theme.color.orange, + backgroundColor: theme.palette.warning.dark, }), ...(props.pulse && { animation: 'pulse 1.5s ease-in-out infinite', diff --git a/packages/manager/src/components/Table/Table.styles.ts b/packages/manager/src/components/Table/Table.styles.ts index f6b70032f91..87318bf2aaf 100644 --- a/packages/manager/src/components/Table/Table.styles.ts +++ b/packages/manager/src/components/Table/Table.styles.ts @@ -26,11 +26,9 @@ export const StyledTableWrapper = styled('div', { borderRight: 'none', }, backgroundColor: theme.bg.tableHeader, - borderBottom: `2px solid ${theme.borderColors.borderTable}`, - borderLeft: `1px solid ${theme.borderColors.borderTable}`, + borderBottom: `1px solid ${theme.borderColors.borderTable}`, borderRight: `1px solid ${theme.borderColors.borderTable}`, - borderTop: `2px solid ${theme.borderColors.borderTable}`, - color: theme.textColors.tableHeader, + borderTop: `1px solid ${theme.borderColors.borderTable}`, fontFamily: theme.font.bold, padding: '10px 15px', }, @@ -43,11 +41,4 @@ export const StyledTableWrapper = styled('div', { border: 0, }, }), - ...(props.rowHoverState && { - '& tbody tr': { - '&:hover': { - backgroundColor: theme.bg.lightBlue1, - }, - }, - }), })); diff --git a/packages/manager/src/components/TableRow/TableRow.styles.ts b/packages/manager/src/components/TableRow/TableRow.styles.ts index 78fc55f09a4..745ff361b3c 100644 --- a/packages/manager/src/components/TableRow/TableRow.styles.ts +++ b/packages/manager/src/components/TableRow/TableRow.styles.ts @@ -9,9 +9,6 @@ export const StyledTableRow = styled(_TableRow, { label: 'StyledTableRow', shouldForwardProp: omittedProps(['forceIndex']), })(({ theme, ...props }) => ({ - backgroundColor: theme.bg.bgPaper, - borderLeft: `1px solid ${theme.borderColors.borderTable}`, - borderRight: `1px solid ${theme.borderColors.borderTable}`, [theme.breakpoints.up('md')]: { boxShadow: `inset 3px 0 0 transparent`, }, @@ -38,14 +35,14 @@ export const StyledTableRow = styled(_TableRow, { ...(props.selected && { '& td': { '&:first-of-type': { - borderLeft: `1px solid ${theme.palette.primary.light}`, + borderLeft: `1px solid ${theme.borderColors.borderTable}`, }, - borderBottomColor: theme.palette.primary.light, - borderTop: `1px solid ${theme.palette.primary.light}`, + borderBottomColor: theme.borderColors.borderTable, + borderTop: `1px solid ${theme.borderColors.borderTable}`, position: 'relative', [theme.breakpoints.down('lg')]: { '&:last-child': { - borderRight: `1px solid ${theme.palette.primary.light}`, + borderRight: `1px solid ${theme.borderColors.borderTable}`, }, }, }, diff --git a/packages/manager/src/components/TableSortCell/TableSortCell.tsx b/packages/manager/src/components/TableSortCell/TableSortCell.tsx index 8cd56dd0452..0928cc1787b 100644 --- a/packages/manager/src/components/TableSortCell/TableSortCell.tsx +++ b/packages/manager/src/components/TableSortCell/TableSortCell.tsx @@ -18,7 +18,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ marginRight: 4, }, label: { - color: theme.textColors.tableHeader, fontSize: '.875rem', minHeight: 20, transition: 'none', diff --git a/packages/manager/src/components/Tabs/Tab.test.tsx b/packages/manager/src/components/Tabs/Tab.test.tsx index 38736410cdb..6463053b864 100644 --- a/packages/manager/src/components/Tabs/Tab.test.tsx +++ b/packages/manager/src/components/Tabs/Tab.test.tsx @@ -20,7 +20,7 @@ describe('Tab Component', () => { expect(tabElement).toHaveStyle(` display: inline-flex; - color: rgb(54, 131, 220); + color: rgb(0, 156, 222); `); }); diff --git a/packages/manager/src/components/Tabs/Tab.tsx b/packages/manager/src/components/Tabs/Tab.tsx index c940218ba07..ea65565187c 100644 --- a/packages/manager/src/components/Tabs/Tab.tsx +++ b/packages/manager/src/components/Tabs/Tab.tsx @@ -11,7 +11,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, '&:hover': { backgroundColor: theme.color.grey7, - color: theme.palette.primary.main, + color: theme.textColors.linkHover, }, alignItems: 'center', borderBottom: '2px solid transparent', @@ -29,7 +29,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, '&[data-reach-tab][data-selected]': { '&:hover': { - color: theme.palette.primary.main, + color: theme.textColors.linkHover, }, borderBottom: `3px solid ${theme.textColors.linkActiveLight}`, color: theme.textColors.headlineStatic, diff --git a/packages/manager/src/components/Tabs/TabList.tsx b/packages/manager/src/components/Tabs/TabList.tsx index 16a12a41296..0ae8e8a8714 100644 --- a/packages/manager/src/components/Tabs/TabList.tsx +++ b/packages/manager/src/components/Tabs/TabList.tsx @@ -23,9 +23,7 @@ export { TabList }; const StyledReachTabList = styled(ReachTabList)(({ theme }) => ({ '&[data-reach-tab-list]': { background: 'none !important', - boxShadow: `inset 0 -1px 0 ${ - theme.name === 'light' ? '#e3e5e8' : '#2e3238' - }`, + boxShadow: `inset 0 -1px 0 ${theme.borderColors.divider}`, marginBottom: theme.spacing(), [theme.breakpoints.down('lg')]: { overflowX: 'auto', diff --git a/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap b/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap index 947542f4e9b..7c5d66fe7d1 100644 --- a/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap +++ b/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap @@ -8,7 +8,7 @@ exports[`TabList component > renders TabList correctly 1`] = ` >
diff --git a/packages/manager/src/components/Tag/Tag.styles.ts b/packages/manager/src/components/Tag/Tag.styles.ts index a54f9b67755..74ab54e1dd9 100644 --- a/packages/manager/src/components/Tag/Tag.styles.ts +++ b/packages/manager/src/components/Tag/Tag.styles.ts @@ -16,7 +16,6 @@ export const StyledChip = styled(Chip, { borderTopRightRadius: props.onDelete && 0, }, borderRadius: 4, - color: theme.name === 'light' ? '#3a3f46' : '#fff', fontFamily: theme.font.normal, maxWidth: 350, padding: '7px 10px', @@ -32,18 +31,19 @@ export const StyledChip = styled(Chip, { ['& .StyledDeleteButton']: { color: theme.color.tagIcon, }, - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, }, // Overrides MUI chip default styles so these appear as separate elements. '&:hover': { ['& .StyledDeleteButton']: { color: theme.color.tagIcon, }, - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, }, fontSize: '0.875rem', height: 30, padding: 0, + transition: 'none', ...(props.colorVariant === 'blue' && { '& > span': { '&:hover, &:focus': { @@ -58,15 +58,16 @@ export const StyledChip = styled(Chip, { ...(props.colorVariant === 'lightBlue' && { '& > span': { '&:focus': { - backgroundColor: theme.color.tagButton, - color: theme.color.black, + backgroundColor: theme.color.tagButtonBg, + color: theme.color.white, }, '&:hover': { - backgroundColor: theme.palette.primary.main, - color: 'white', + backgroundColor: theme.color.tagButtonBgHover, + color: theme.color.tagButtonTextHover, }, }, - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, + color: theme.color.tagButtonText, }), })); @@ -85,10 +86,9 @@ export const StyledDeleteButton = styled(StyledLinkButton, { }, '&:hover': { '& svg': { - color: 'white', + color: theme.color.tagIconHover, }, - backgroundColor: theme.palette.primary.main, - color: 'white', + backgroundColor: theme.color.buttonPrimaryHover, }, borderBottomRightRadius: 3, borderLeft: `1px solid ${theme.name === 'light' ? '#fff' : '#2e3238'}`, diff --git a/packages/manager/src/components/TagCell/TagCell.tsx b/packages/manager/src/components/TagCell/TagCell.tsx index 9226281fee9..c1433e9bd9e 100644 --- a/packages/manager/src/components/TagCell/TagCell.tsx +++ b/packages/manager/src/components/TagCell/TagCell.tsx @@ -225,7 +225,7 @@ const StyledIconButton = styled(IconButton)(({ theme }) => ({ backgroundColor: theme.palette.primary.main, color: '#ffff', }, - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, borderRadius: 0, color: theme.color.tagIcon, height: 30, diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx index 849a034e535..301c5787ca5 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx @@ -56,7 +56,7 @@ describe('TextTooltip', () => { const displayText = getByText(props.displayText); - expect(displayText).toHaveStyle('color: rgb(54, 131, 220)'); + expect(displayText).toHaveStyle('color: rgb(0, 156, 222)'); expect(displayText).toHaveStyle('font-size: 18px'); }); diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.tsx index 5ce4c7f1598..25e43179479 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.tsx @@ -82,10 +82,13 @@ export const TextTooltip = (props: TextTooltipProps) => { const StyledRootTooltip = styled(Tooltip, { label: 'StyledRootTooltip', })(({ theme }) => ({ + '&:hover': { + color: theme.textColors.linkHover, + }, borderRadius: 4, - color: theme.palette.primary.main, + color: theme.textColors.linkActiveLight, cursor: 'pointer', position: 'relative', - textDecoration: `underline dotted ${theme.palette.primary.main}`, + textDecoration: `underline dotted ${theme.textColors.linkActiveLight}`, textUnderlineOffset: 4, })); diff --git a/packages/manager/src/components/Tile/Tile.styles.ts b/packages/manager/src/components/Tile/Tile.styles.ts index ddeb5994f3f..a1a26d525ea 100644 --- a/packages/manager/src/components/Tile/Tile.styles.ts +++ b/packages/manager/src/components/Tile/Tile.styles.ts @@ -15,8 +15,8 @@ export const useStyles = makeStyles()( }, card: { alignItems: 'center', - backgroundColor: theme.color.white, - border: `1px solid ${theme.color.grey2}`, + backgroundColor: theme.bg.bgPaper, + border: `1px solid ${theme.borderColors.divider}`, display: 'flex', flexDirection: 'column', height: '100%', @@ -51,7 +51,7 @@ export const useStyles = makeStyles()( icon: { '& .insidePath': { fill: 'none', - stroke: '#3683DC', + stroke: theme.palette.primary.main, strokeLinejoin: 'round', strokeWidth: 1.25, }, diff --git a/packages/manager/src/components/TooltipIcon.tsx b/packages/manager/src/components/TooltipIcon.tsx index 0977bf75fb9..fc982c1a1b6 100644 --- a/packages/manager/src/components/TooltipIcon.tsx +++ b/packages/manager/src/components/TooltipIcon.tsx @@ -110,16 +110,16 @@ export const TooltipIcon = (props: TooltipIconProps) => { const sxRootStyle = { '&&': { - fill: '#888f91', - stroke: '#888f91', + fill: theme.color.grey4, + stroke: theme.color.grey4, strokeWidth: 0, }, '&:hover': { - color: '#3683dc', - fill: '#3683dc', - stroke: '#3683dc', + color: theme.palette.primary.main, + fill: theme.palette.primary.main, + stroke: theme.palette.primary.main, }, - color: '#888f91', + color: theme.color.grey4, height: 20, width: 20, }; diff --git a/packages/manager/src/components/VLANSelect.tsx b/packages/manager/src/components/VLANSelect.tsx index f95f584a680..560b744ee19 100644 --- a/packages/manager/src/components/VLANSelect.tsx +++ b/packages/manager/src/components/VLANSelect.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; +import { useDebouncedValue } from 'src/hooks/useDebouncedValue'; import { useVLANsInfiniteQuery } from 'src/queries/vlans'; import { Autocomplete } from './Autocomplete/Autocomplete'; @@ -59,9 +60,11 @@ export const VLANSelect = (props: Props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); + const debouncedInputValue = useDebouncedValue(inputValue); + const apiFilter = getVLANSelectFilter({ defaultFilter: filter, - inputValue, + inputValue: debouncedInputValue, }); const { diff --git a/packages/manager/src/env.d.ts b/packages/manager/src/env.d.ts index 0956ed09f6e..8d705542528 100644 --- a/packages/manager/src/env.d.ts +++ b/packages/manager/src/env.d.ts @@ -43,7 +43,7 @@ declare module '*.svg' { export default src; } -declare module '*.css?raw' { +declare module '*?raw' { const src: string; export default src; } diff --git a/packages/manager/src/factories/account.ts b/packages/manager/src/factories/account.ts index 770b4098e4d..eace884c756 100644 --- a/packages/manager/src/factories/account.ts +++ b/packages/manager/src/factories/account.ts @@ -3,7 +3,7 @@ import { ActivePromotion, RegionalNetworkUtilization, } from '@linode/api-v4/lib/account/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const promoFactory = Factory.Sync.makeFactory({ credit_monthly_cap: '20.00', diff --git a/packages/manager/src/factories/accountAgreements.ts b/packages/manager/src/factories/accountAgreements.ts index abc9eabeb94..e14f1db920f 100644 --- a/packages/manager/src/factories/accountAgreements.ts +++ b/packages/manager/src/factories/accountAgreements.ts @@ -1,5 +1,5 @@ import { Agreements } from '@linode/api-v4/lib/account'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const accountAgreementsFactory = Factory.Sync.makeFactory({ eu_model: false, diff --git a/packages/manager/src/factories/accountAvailability.ts b/packages/manager/src/factories/accountAvailability.ts index 7f1cff796a0..3196a439674 100644 --- a/packages/manager/src/factories/accountAvailability.ts +++ b/packages/manager/src/factories/accountAvailability.ts @@ -1,5 +1,5 @@ import { AccountAvailability } from '@linode/api-v4'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { pickRandom } from 'src/utilities/random'; diff --git a/packages/manager/src/factories/accountLogin.ts b/packages/manager/src/factories/accountLogin.ts index 878519a2c43..e4f36ab9cce 100644 --- a/packages/manager/src/factories/accountLogin.ts +++ b/packages/manager/src/factories/accountLogin.ts @@ -1,5 +1,5 @@ import { AccountLogin } from '@linode/api-v4'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const accountLoginFactory = Factory.Sync.makeFactory({ datetime: '2021-05-21T14:27:51', diff --git a/packages/manager/src/factories/accountMaintenance.ts b/packages/manager/src/factories/accountMaintenance.ts index 11223cf6c16..987769b936a 100644 --- a/packages/manager/src/factories/accountMaintenance.ts +++ b/packages/manager/src/factories/accountMaintenance.ts @@ -1,5 +1,5 @@ import { AccountMaintenance } from '@linode/api-v4/lib/account/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { pickRandom, randomDate } from 'src/utilities/random'; diff --git a/packages/manager/src/factories/accountOAuth.ts b/packages/manager/src/factories/accountOAuth.ts index 277632864ba..37c380efea6 100644 --- a/packages/manager/src/factories/accountOAuth.ts +++ b/packages/manager/src/factories/accountOAuth.ts @@ -1,5 +1,5 @@ import { OAuthClient } from '@linode/api-v4'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const oauthClientFactory = Factory.Sync.makeFactory({ id: Factory.each((id) => String(id)), diff --git a/packages/manager/src/factories/accountPayment.ts b/packages/manager/src/factories/accountPayment.ts index 392d3cc94c2..844613a1367 100644 --- a/packages/manager/src/factories/accountPayment.ts +++ b/packages/manager/src/factories/accountPayment.ts @@ -1,5 +1,5 @@ import { PaymentMethod } from '@linode/api-v4'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const paymentMethodFactory = Factory.Sync.makeFactory({ created: '2021-05-21T14:27:51', diff --git a/packages/manager/src/factories/accountSettings.ts b/packages/manager/src/factories/accountSettings.ts index 4c7f9a073af..5b9d320bf66 100644 --- a/packages/manager/src/factories/accountSettings.ts +++ b/packages/manager/src/factories/accountSettings.ts @@ -1,5 +1,5 @@ import { AccountSettings } from '@linode/api-v4/lib/account/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const accountSettingsFactory = Factory.Sync.makeFactory( { diff --git a/packages/manager/src/factories/accountUsers.ts b/packages/manager/src/factories/accountUsers.ts index 664cf75bd8b..49e8e968db1 100644 --- a/packages/manager/src/factories/accountUsers.ts +++ b/packages/manager/src/factories/accountUsers.ts @@ -1,5 +1,5 @@ import { User } from '@linode/api-v4'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const accountUserFactory = Factory.Sync.makeFactory({ email: 'support@linode.com', diff --git a/packages/manager/src/factories/aclb.ts b/packages/manager/src/factories/aclb.ts index 7a3779e4ebc..1ca143ec038 100644 --- a/packages/manager/src/factories/aclb.ts +++ b/packages/manager/src/factories/aclb.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { pickRandom } from 'src/utilities/random'; diff --git a/packages/manager/src/factories/betas.ts b/packages/manager/src/factories/betas.ts index c50e76f2a28..80041895527 100644 --- a/packages/manager/src/factories/betas.ts +++ b/packages/manager/src/factories/betas.ts @@ -1,5 +1,5 @@ import { Beta, AccountBeta } from '@linode/api-v4'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { DateTime } from 'luxon'; export const betaFactory = Factory.Sync.makeFactory({ diff --git a/packages/manager/src/factories/billing.ts b/packages/manager/src/factories/billing.ts index dbbf732a8cf..f14b7e80c97 100644 --- a/packages/manager/src/factories/billing.ts +++ b/packages/manager/src/factories/billing.ts @@ -5,7 +5,7 @@ import { PaymentResponse, } from '@linode/api-v4/lib/account'; import { APIWarning } from '@linode/api-v4/lib/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const invoiceItemFactory = Factory.Sync.makeFactory({ amount: 5, diff --git a/packages/manager/src/factories/config.ts b/packages/manager/src/factories/config.ts index a98b0e798d5..72199f914f1 100644 --- a/packages/manager/src/factories/config.ts +++ b/packages/manager/src/factories/config.ts @@ -1,5 +1,5 @@ import { Config } from '@linode/api-v4/lib/linodes/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const configFactory = Factory.Sync.makeFactory({ comments: '', diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index 36264eeb324..b9188b54d62 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -8,7 +8,7 @@ import { MySQLReplicationType, PostgresReplicationType, } from '@linode/api-v4/lib/databases/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { v4 } from 'uuid'; import { pickRandom, randomDate } from 'src/utilities/random'; diff --git a/packages/manager/src/factories/disk.ts b/packages/manager/src/factories/disk.ts index 9aef8fe3c1c..0ee25de8cea 100644 --- a/packages/manager/src/factories/disk.ts +++ b/packages/manager/src/factories/disk.ts @@ -1,5 +1,5 @@ import { Disk } from '@linode/api-v4/lib/linodes/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const linodeDiskFactory = Factory.Sync.makeFactory({ created: '2018-01-01', diff --git a/packages/manager/src/factories/domain.ts b/packages/manager/src/factories/domain.ts index f0b2bad1c13..3244d825b7f 100644 --- a/packages/manager/src/factories/domain.ts +++ b/packages/manager/src/factories/domain.ts @@ -4,7 +4,7 @@ import { DomainRecord, ZoneFile, } from '@linode/api-v4/lib/domains/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const domainFactory = Factory.Sync.makeFactory({ axfr_ips: [], diff --git a/packages/manager/src/factories/entityTransfers.ts b/packages/manager/src/factories/entityTransfers.ts index 826f2889400..a785fbf0c45 100644 --- a/packages/manager/src/factories/entityTransfers.ts +++ b/packages/manager/src/factories/entityTransfers.ts @@ -2,7 +2,7 @@ import { EntityTransfer, TransferEntities, } from '@linode/api-v4/lib/entity-transfers/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { DateTime } from 'luxon'; import { v4 } from 'uuid'; diff --git a/packages/manager/src/factories/events.ts b/packages/manager/src/factories/events.ts index 3b74a662149..f0dd62292bb 100644 --- a/packages/manager/src/factories/events.ts +++ b/packages/manager/src/factories/events.ts @@ -1,5 +1,5 @@ import { Entity, Event } from '@linode/api-v4/lib/account/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { DateTime } from 'luxon'; export const entityFactory = Factory.Sync.makeFactory({ diff --git a/packages/manager/src/factories/factoryProxy.ts b/packages/manager/src/factories/factoryProxy.ts new file mode 100644 index 00000000000..437f443b27a --- /dev/null +++ b/packages/manager/src/factories/factoryProxy.ts @@ -0,0 +1,32 @@ +import * as Factory from 'factory.ts'; + +const originalEach = Factory.each; + +/** + * This file is a proxy for the factory.ts library. + * + * We Override the `each` method of the factory.ts library to start the index from 1 + * This prevents a a variety of issues with entity IDs being falsy when starting from 0. + * + * As a result, `Factory` must be imported from the `factoryProxy` file. ex: + * `import Factory from 'src/factories/factoryProxy';` + */ +const factoryProxyHandler = { + get( + target: typeof Factory, + prop: keyof typeof Factory, + receiver: typeof Factory + ) { + if (prop === 'each') { + return (fn: (index: number) => number | string) => { + return originalEach((i) => { + return fn(i + 1); + }); + }; + } + + return Reflect.get(target, prop, receiver); + }, +}; + +export default new Proxy(Factory, factoryProxyHandler); diff --git a/packages/manager/src/factories/featureFlags.ts b/packages/manager/src/factories/featureFlags.ts index 0c7589388e6..2a7e64d93db 100644 --- a/packages/manager/src/factories/featureFlags.ts +++ b/packages/manager/src/factories/featureFlags.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { ProductInformationBannerFlag } from 'src/featureFlags'; diff --git a/packages/manager/src/factories/firewalls.ts b/packages/manager/src/factories/firewalls.ts index 5d70b52d97c..5c72caa6d61 100644 --- a/packages/manager/src/factories/firewalls.ts +++ b/packages/manager/src/factories/firewalls.ts @@ -5,7 +5,7 @@ import { FirewallRuleType, FirewallRules, } from '@linode/api-v4/lib/firewalls/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const firewallRuleFactory = Factory.Sync.makeFactory({ action: 'DROP', diff --git a/packages/manager/src/factories/grants.ts b/packages/manager/src/factories/grants.ts index d1cd55caba0..bfa53a7de8b 100644 --- a/packages/manager/src/factories/grants.ts +++ b/packages/manager/src/factories/grants.ts @@ -1,5 +1,5 @@ import { Grant, Grants } from '@linode/api-v4/lib/account'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const grantFactory = Factory.Sync.makeFactory({ id: Factory.each((i) => i), diff --git a/packages/manager/src/factories/images.ts b/packages/manager/src/factories/images.ts index 18a6246e599..85255aacb17 100644 --- a/packages/manager/src/factories/images.ts +++ b/packages/manager/src/factories/images.ts @@ -1,5 +1,5 @@ import { Image } from '@linode/api-v4/lib/images/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const imageFactory = Factory.Sync.makeFactory({ capabilities: [], diff --git a/packages/manager/src/factories/kernels.ts b/packages/manager/src/factories/kernels.ts index 316704080ee..ad1f0418993 100644 --- a/packages/manager/src/factories/kernels.ts +++ b/packages/manager/src/factories/kernels.ts @@ -1,5 +1,5 @@ import { Kernel } from '@linode/api-v4'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const kernelFactory = Factory.Sync.makeFactory({ id: Factory.each((i) => `kernel-${i}`), diff --git a/packages/manager/src/factories/kubernetesCluster.ts b/packages/manager/src/factories/kubernetesCluster.ts index c2ca312899a..5bf468c00fe 100644 --- a/packages/manager/src/factories/kubernetesCluster.ts +++ b/packages/manager/src/factories/kubernetesCluster.ts @@ -6,7 +6,7 @@ import { KubernetesVersion, PoolNodeResponse, } from '@linode/api-v4/lib/kubernetes/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { v4 } from 'uuid'; export const kubeLinodeFactory = Factory.Sync.makeFactory({ diff --git a/packages/manager/src/factories/linodeConfigInterfaceFactory.ts b/packages/manager/src/factories/linodeConfigInterfaceFactory.ts index 914551a8fa6..4f041afec55 100644 --- a/packages/manager/src/factories/linodeConfigInterfaceFactory.ts +++ b/packages/manager/src/factories/linodeConfigInterfaceFactory.ts @@ -1,5 +1,5 @@ import { Interface } from '@linode/api-v4/lib/linodes/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const LinodeConfigInterfaceFactory = Factory.Sync.makeFactory( { diff --git a/packages/manager/src/factories/linodeConfigs.ts b/packages/manager/src/factories/linodeConfigs.ts index 66c2ec0de7a..08c64f8f2d9 100644 --- a/packages/manager/src/factories/linodeConfigs.ts +++ b/packages/manager/src/factories/linodeConfigs.ts @@ -1,5 +1,5 @@ import { Config } from '@linode/api-v4/lib/linodes/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { LinodeConfigInterfaceFactory, diff --git a/packages/manager/src/factories/linodes.ts b/packages/manager/src/factories/linodes.ts index 735dac98182..41adb044a39 100644 --- a/packages/manager/src/factories/linodes.ts +++ b/packages/manager/src/factories/linodes.ts @@ -12,7 +12,7 @@ import { Stats, StatsData, } from '@linode/api-v4/lib/linodes/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { placementGroupFactory } from './placementGroups'; diff --git a/packages/manager/src/factories/longviewClient.ts b/packages/manager/src/factories/longviewClient.ts index 9de4845d034..f146da0e0c5 100644 --- a/packages/manager/src/factories/longviewClient.ts +++ b/packages/manager/src/factories/longviewClient.ts @@ -1,5 +1,5 @@ import { Apps, LongviewClient } from '@linode/api-v4/lib/longview'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const longviewAppsFactory = Factory.Sync.makeFactory({ apache: false, diff --git a/packages/manager/src/factories/longviewDisks.ts b/packages/manager/src/factories/longviewDisks.ts index 35d77269cb6..ea43a79a5ac 100644 --- a/packages/manager/src/factories/longviewDisks.ts +++ b/packages/manager/src/factories/longviewDisks.ts @@ -1,11 +1,37 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; -import { Disk, LongviewDisk } from 'src/features/Longview/request.types'; +import { + Disk, + LongviewDisk, + LongviewCPU, + CPU, + LongviewSystemInfo, + LongviewNetworkInterface, + InboundOutboundNetwork, + LongviewNetwork, + LongviewMemory, + LongviewLoad, + Uptime, +} from 'src/features/Longview/request.types'; const mockStats = [ - { x: 0, y: 1 }, - { x: 0, y: 2 }, - { x: 0, y: 3 }, + { x: 1717770900, y: 0 }, + { x: 1717770900, y: 20877.4637037037 }, + { x: 1717770900, y: 4.09420479302832 }, + { x: 1717770900, y: 83937959936 }, + { x: 1717770900, y: 5173267 }, + { x: 1717770900, y: 5210112 }, + { x: 1717770900, y: 82699642934.6133 }, + { x: 1717770900, y: 0.0372984749455338 }, + { x: 1717770900, y: 0.00723311546840959 }, + { x: 1717770900, y: 0.0918300653594771 }, + { x: 1717770900, y: 466.120718954248 }, + { x: 1717770900, y: 451.9651416122 }, + { x: 1717770900, y: 524284 }, + { x: 1717770900, y: 547242.706666667 }, + { x: 1717770900, y: 3466265.29333333 }, + { x: 1717770900, y: 57237.6133333333 }, + { x: 1717770900, y: 365385.893333333 }, ]; export const diskFactory = Factory.Sync.makeFactory({ @@ -14,8 +40,23 @@ export const diskFactory = Factory.Sync.makeFactory({ dm: 0, isswap: 0, mounted: 1, - reads: mockStats, - writes: mockStats, + reads: [mockStats[0]], + write_bytes: [mockStats[1]], + writes: [mockStats[2]], + fs: { + total: [mockStats[3]], + ifree: [mockStats[4]], + itotal: [mockStats[5]], + path: '/', + free: [mockStats[6]], + }, + read_bytes: [mockStats[0]], +}); + +export const cpuFactory = Factory.Sync.makeFactory({ + system: [mockStats[7]], + wait: [mockStats[8]], + user: [mockStats[9]], }); export const longviewDiskFactory = Factory.Sync.makeFactory({ @@ -24,3 +65,75 @@ export const longviewDiskFactory = Factory.Sync.makeFactory({ '/dev/sdb': diskFactory.build({ isswap: 1 }), }, }); + +export const longviewCPUFactory = Factory.Sync.makeFactory({ + CPU: { + cpu0: cpuFactory.build(), + cpu1: cpuFactory.build(), + }, +}); + +export const longviewSysInfoFactory = Factory.Sync.makeFactory( + { + SysInfo: { + arch: 'x86_64', + client: '1.1.5', + cpu: { + cores: 2, + type: 'AMD EPYC 7713 64-Core Processor', + }, + hostname: 'localhost', + kernel: 'Linux 5.10.0-28-amd64', + os: { + dist: 'Debian', + distversion: '11.9', + }, + type: 'kvm', + }, + } +); + +export const InboundOutboundNetworkFactory = Factory.Sync.makeFactory( + { + rx_bytes: [mockStats[10]], + tx_bytes: [mockStats[11]], + } +); + +export const LongviewNetworkInterfaceFactory = Factory.Sync.makeFactory( + { + eth0: InboundOutboundNetworkFactory.build(), + } +); + +export const longviewNetworkFactory = Factory.Sync.makeFactory( + { + Network: { + Interface: LongviewNetworkInterfaceFactory.build(), + mac_addr: 'f2:3c:94:e6:81:e2', + }, + } +); + +export const LongviewMemoryFactory = Factory.Sync.makeFactory({ + Memory: { + swap: { + free: [mockStats[12]], + used: [mockStats[0]], + }, + real: { + used: [mockStats[13]], + free: [mockStats[14]], + buffers: [mockStats[15]], + cache: [mockStats[16]], + }, + }, +}); + +export const LongviewLoadFactory = Factory.Sync.makeFactory({ + Load: [mockStats[0]], +}); + +export const UptimeFactory = Factory.Sync.makeFactory({ + uptime: 84516.53, +}); diff --git a/packages/manager/src/factories/longviewProcess.ts b/packages/manager/src/factories/longviewProcess.ts index 0da3ab24193..cc465b690f1 100644 --- a/packages/manager/src/factories/longviewProcess.ts +++ b/packages/manager/src/factories/longviewProcess.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { LongviewProcesses, diff --git a/packages/manager/src/factories/longviewResponse.ts b/packages/manager/src/factories/longviewResponse.ts index 315fad71bff..427b3809c5d 100644 --- a/packages/manager/src/factories/longviewResponse.ts +++ b/packages/manager/src/factories/longviewResponse.ts @@ -1,14 +1,58 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { LongviewResponse } from 'src/features/Longview/request.types'; +import { AllData, LongviewPackage } from 'src/features/Longview/request.types'; -import { longviewDiskFactory } from './longviewDisks'; +import { + longviewDiskFactory, + longviewCPUFactory, + longviewSysInfoFactory, + longviewNetworkFactory, + LongviewMemoryFactory, + LongviewLoadFactory, + UptimeFactory, +} from './longviewDisks'; + +const longviewResponseData = () => { + const diskData = longviewDiskFactory.build(); + const cpuData = longviewCPUFactory.build(); + const sysinfoData = longviewSysInfoFactory.build(); + const networkData = longviewNetworkFactory.build(); + const memoryData = LongviewMemoryFactory.build(); + const loadData = LongviewLoadFactory.build(); + const uptimeData = UptimeFactory.build(); + + return { + ...diskData, + ...cpuData, + ...sysinfoData, + ...networkData, + ...memoryData, + ...loadData, + ...uptimeData, + }; +}; export const longviewResponseFactory = Factory.Sync.makeFactory( { - ACTION: 'getValues', - DATA: longviewDiskFactory.build(), + ACTION: 'getLatestValue', + DATA: {}, NOTIFICATIONS: [], VERSION: 0.4, } ); + +export const longviewLatestStatsFactory = Factory.Sync.makeFactory< + Partial +>({ + ...longviewResponseData(), +}); + +export const longviewPackageFactory = Factory.Sync.makeFactory( + { + current: Factory.each((i) => `${i + 1}.${i + 2}.${i + 3}`), + held: 0, + name: Factory.each((i) => `mock-package-${i}`), + new: Factory.each((i) => `${i + 1}.${i + 2}.${i + 3}`), + } +); diff --git a/packages/manager/src/factories/longviewService.ts b/packages/manager/src/factories/longviewService.ts index ff3462c7fd5..fbc8978a36e 100644 --- a/packages/manager/src/factories/longviewService.ts +++ b/packages/manager/src/factories/longviewService.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { LongviewPort, diff --git a/packages/manager/src/factories/longviewSubscription.ts b/packages/manager/src/factories/longviewSubscription.ts index 4fe2d140d9c..6e63fe5bf35 100644 --- a/packages/manager/src/factories/longviewSubscription.ts +++ b/packages/manager/src/factories/longviewSubscription.ts @@ -2,7 +2,7 @@ import { ActiveLongviewPlan, LongviewSubscription, } from '@linode/api-v4/lib/longview/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const longviewSubscriptionFactory = Factory.Sync.makeFactory( { diff --git a/packages/manager/src/factories/longviewTopProcesses.ts b/packages/manager/src/factories/longviewTopProcesses.ts index ee51a280775..22cd669ce31 100644 --- a/packages/manager/src/factories/longviewTopProcesses.ts +++ b/packages/manager/src/factories/longviewTopProcesses.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { LongviewTopProcesses, diff --git a/packages/manager/src/factories/managed.ts b/packages/manager/src/factories/managed.ts index 9652ce62c11..daceda4dabf 100644 --- a/packages/manager/src/factories/managed.ts +++ b/packages/manager/src/factories/managed.ts @@ -9,7 +9,7 @@ import { ManagedServiceMonitor, ManagedStats, } from '@linode/api-v4/lib/managed/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const contactFactory = Factory.Sync.makeFactory({ email: Factory.each((i) => `john.doe.${i}@example.com`), diff --git a/packages/manager/src/factories/networking.ts b/packages/manager/src/factories/networking.ts index 7580e12be81..74a29840383 100644 --- a/packages/manager/src/factories/networking.ts +++ b/packages/manager/src/factories/networking.ts @@ -1,5 +1,5 @@ import { IPAddress } from '@linode/api-v4/lib/networking'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const ipAddressFactory = Factory.Sync.makeFactory({ address: Factory.each((id) => `192.168.1.${id}`), diff --git a/packages/manager/src/factories/nodebalancer.ts b/packages/manager/src/factories/nodebalancer.ts index 0be6b30b428..711289d7abf 100644 --- a/packages/manager/src/factories/nodebalancer.ts +++ b/packages/manager/src/factories/nodebalancer.ts @@ -3,7 +3,7 @@ import { NodeBalancerConfig, NodeBalancerConfigNode, } from '@linode/api-v4/lib/nodebalancers/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const nodeBalancerFactory = Factory.Sync.makeFactory({ client_conn_throttle: 0, diff --git a/packages/manager/src/factories/notification.ts b/packages/manager/src/factories/notification.ts index 45d146243ef..16f59842c5c 100644 --- a/packages/manager/src/factories/notification.ts +++ b/packages/manager/src/factories/notification.ts @@ -1,5 +1,5 @@ import { Entity, Notification } from '@linode/api-v4/lib/account'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { DateTime } from 'luxon'; const generateEntity = ( diff --git a/packages/manager/src/factories/oauth.ts b/packages/manager/src/factories/oauth.ts index be6b64dd528..e2151eb62d5 100644 --- a/packages/manager/src/factories/oauth.ts +++ b/packages/manager/src/factories/oauth.ts @@ -1,5 +1,5 @@ import { Token } from '@linode/api-v4/lib/profile/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const appTokenFactory = Factory.Sync.makeFactory({ created: '2020-01-01T12:00:00', diff --git a/packages/manager/src/factories/objectStorage.ts b/packages/manager/src/factories/objectStorage.ts index a45038cc9ee..6d5d7411f34 100644 --- a/packages/manager/src/factories/objectStorage.ts +++ b/packages/manager/src/factories/objectStorage.ts @@ -4,7 +4,7 @@ import { ObjectStorageKey, ObjectStorageObject, } from '@linode/api-v4/lib/object-storage/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const objectStorageBucketFactory = Factory.Sync.makeFactory( { diff --git a/packages/manager/src/factories/placementGroups.ts b/packages/manager/src/factories/placementGroups.ts index 03d89ba1009..c3fa3b8dfea 100644 --- a/packages/manager/src/factories/placementGroups.ts +++ b/packages/manager/src/factories/placementGroups.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { pickRandom } from 'src/utilities/random'; diff --git a/packages/manager/src/factories/preferences.ts b/packages/manager/src/factories/preferences.ts index 06deb6125b6..3072b842b4b 100644 --- a/packages/manager/src/factories/preferences.ts +++ b/packages/manager/src/factories/preferences.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { ManagerPreferences } from 'src/types/ManagerPreferences'; diff --git a/packages/manager/src/factories/profile.ts b/packages/manager/src/factories/profile.ts index 8bbb442f188..b16a34dc49e 100644 --- a/packages/manager/src/factories/profile.ts +++ b/packages/manager/src/factories/profile.ts @@ -4,7 +4,7 @@ import { SecurityQuestionsData, UserPreferences, } from '@linode/api-v4/lib/profile'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const profileFactory = Factory.Sync.makeFactory({ authentication_type: 'password', diff --git a/packages/manager/src/factories/promotionalOffer.ts b/packages/manager/src/factories/promotionalOffer.ts index b68eaa0f1d4..e401266b511 100644 --- a/packages/manager/src/factories/promotionalOffer.ts +++ b/packages/manager/src/factories/promotionalOffer.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { PromotionalOffer } from 'src/featureFlags'; diff --git a/packages/manager/src/factories/regions.ts b/packages/manager/src/factories/regions.ts index 16387addac8..369e3e1f09f 100644 --- a/packages/manager/src/factories/regions.ts +++ b/packages/manager/src/factories/regions.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import type { Country, diff --git a/packages/manager/src/factories/stackscripts.ts b/packages/manager/src/factories/stackscripts.ts index 9406658d0b8..090db396c83 100644 --- a/packages/manager/src/factories/stackscripts.ts +++ b/packages/manager/src/factories/stackscripts.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import type { StackScript, diff --git a/packages/manager/src/factories/statusPage.ts b/packages/manager/src/factories/statusPage.ts index 6781cbb2e87..f485eee7069 100644 --- a/packages/manager/src/factories/statusPage.ts +++ b/packages/manager/src/factories/statusPage.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import { v4 } from 'uuid'; import { diff --git a/packages/manager/src/factories/subnets.ts b/packages/manager/src/factories/subnets.ts index 4e8bc5c5796..3b0f4e7f145 100644 --- a/packages/manager/src/factories/subnets.ts +++ b/packages/manager/src/factories/subnets.ts @@ -2,7 +2,7 @@ import { Subnet, SubnetAssignedLinodeData, } from '@linode/api-v4/lib/vpcs/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; // NOTE: Changing to fixed array length for the interfaces and linodes fields of the // subnetAssignedLinodeDataFactory and subnetFactory respectively -- see [M3-7227] for more details diff --git a/packages/manager/src/factories/support.ts b/packages/manager/src/factories/support.ts index 524ed6c0c1f..7fddfe1f36e 100644 --- a/packages/manager/src/factories/support.ts +++ b/packages/manager/src/factories/support.ts @@ -1,5 +1,5 @@ import { SupportReply, SupportTicket } from '@linode/api-v4/lib/support/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const supportTicketFactory = Factory.Sync.makeFactory({ attachments: [], diff --git a/packages/manager/src/factories/tags.ts b/packages/manager/src/factories/tags.ts index 6c1699952bc..07fc150d870 100644 --- a/packages/manager/src/factories/tags.ts +++ b/packages/manager/src/factories/tags.ts @@ -1,5 +1,5 @@ import { Tag } from '@linode/api-v4/lib/tags/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const tagFactory = Factory.Sync.makeFactory({ label: Factory.each((id) => `tag-${id + 1}`), diff --git a/packages/manager/src/factories/types.ts b/packages/manager/src/factories/types.ts index 192c8219ec2..4b7cb8f755e 100644 --- a/packages/manager/src/factories/types.ts +++ b/packages/manager/src/factories/types.ts @@ -1,4 +1,4 @@ -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; import type { LinodeType } from '@linode/api-v4/lib/linodes/types'; import type { PriceType } from '@linode/api-v4/src/types'; diff --git a/packages/manager/src/factories/vlans.ts b/packages/manager/src/factories/vlans.ts index ec6b5154920..0ee8708c553 100644 --- a/packages/manager/src/factories/vlans.ts +++ b/packages/manager/src/factories/vlans.ts @@ -1,5 +1,5 @@ import { VLAN } from '@linode/api-v4/lib/vlans/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const VLANFactory = Factory.Sync.makeFactory({ cidr_block: '10.0.0.0/24', diff --git a/packages/manager/src/factories/volume.ts b/packages/manager/src/factories/volume.ts index 5b7b6b52c14..5a127893af9 100644 --- a/packages/manager/src/factories/volume.ts +++ b/packages/manager/src/factories/volume.ts @@ -1,5 +1,5 @@ import { Volume, VolumeRequestPayload } from '@linode/api-v4/lib/volumes/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const volumeFactory = Factory.Sync.makeFactory({ created: '2018-01-01', diff --git a/packages/manager/src/factories/vpcs.ts b/packages/manager/src/factories/vpcs.ts index f3d66072b2a..5584639f695 100644 --- a/packages/manager/src/factories/vpcs.ts +++ b/packages/manager/src/factories/vpcs.ts @@ -1,5 +1,5 @@ import { VPC } from '@linode/api-v4/lib/vpcs/types'; -import * as Factory from 'factory.ts'; +import Factory from 'src/factories/factoryProxy'; export const vpcFactory = Factory.Sync.makeFactory({ created: '2023-07-12T16:08:53', diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index ff4ae7b05d1..632dcedfac4 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -66,13 +66,20 @@ interface AclpFlag { interface gpuV2 { planDivider: boolean; } + type OneClickApp = Record; +interface DesignUpdatesBannerFlag extends BaseFeatureFlag { + key: string; + link: string; +} + export interface Flags { aclb: boolean; aclbFullCreateFlow: boolean; aclp: AclpFlag; apiMaintenance: APIMaintenance; + cloudManagerDesignUpdatesBanner: DesignUpdatesBannerFlag; databaseBeta: boolean; databaseResize: boolean; databases: boolean; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.test.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.test.tsx index 8f17374c8b2..ba8d988fd30 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.test.tsx @@ -63,10 +63,10 @@ describe('BillingActivityPanel', () => { ); await waitFor(() => { - getByText('Invoice #0'); getByText('Invoice #1'); - getByTestId(`payment-0`); + getByText('Invoice #2'); getByTestId(`payment-1`); + getByTestId(`payment-2`); }); }); diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx index c0891c7d83d..9a7235d8729 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx @@ -4,7 +4,7 @@ import { Payment, getInvoiceItems, } from '@linode/api-v4/lib/account'; -import { Theme } from '@mui/material/styles'; +import { Theme, styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { DateTime } from 'luxon'; import * as React from 'react'; @@ -335,9 +335,11 @@ export const BillingActivityPanel = (props: Props) => { }, [selectedTransactionType, combinedData]); return ( - +
-
+ {`${isAkamaiCustomer ? 'Usage' : 'Billing & Payment'} History`} @@ -397,7 +399,7 @@ export const BillingActivityPanel = (props: Props) => { />
-
+ { ); }; +const StyledBillingAndPaymentHistoryHeader = styled('div', { + name: 'BillingAndPaymentHistoryHeader', +})(({ theme }) => ({ + border: theme.name === 'dark' ? `1px solid ${theme.borderColors.divider}` : 0, + borderBottom: 0, +})); + // ============================================================================= // // ============================================================================= diff --git a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx index cfe7c4a3cad..e633213429d 100644 --- a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx +++ b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx @@ -1,8 +1,6 @@ import { InvoiceItem } from '@linode/api-v4/lib/account'; import { APIError } from '@linode/api-v4/lib/types'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; import { Currency } from 'src/components/Currency'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; @@ -21,18 +19,6 @@ import { useRegionsQuery } from 'src/queries/regions/regions'; import { getInvoiceRegion } from '../PdfGenerator/utils'; -const useStyles = makeStyles()((theme: Theme) => ({ - table: { - '& thead th': { - '&:last-of-type': { - paddingRight: 15, - }, - borderBottom: `1px solid ${theme.borderColors.borderTable}`, - }, - border: `1px solid ${theme.borderColors.borderTable}`, - }, -})); - interface Props { errors?: APIError[]; items?: InvoiceItem[]; @@ -41,7 +27,6 @@ interface Props { } export const InvoiceTable = (props: Props) => { - const { classes } = useStyles(); const MIN_PAGE_SIZE = 25; const { @@ -157,7 +142,7 @@ export const InvoiceTable = (props: Props) => { }; return ( - +
Description diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx index c134e02cd63..a91ff4e6259 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx @@ -8,6 +8,8 @@ import { CloudPulseTimeRangeSelect } from '../shared/CloudPulseTimeRangeSelect'; import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; import type { WithStartAndEnd } from 'src/features/Longview/request.types'; +import { Dashboard } from '@linode/api-v4'; +import { CloudPulseDashboardSelect } from '../shared/CloudPulseDashboardSelect'; export interface GlobalFilterProperties { handleAnyFilterChange(filters: FiltersObject): undefined | void; @@ -27,8 +29,12 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { start: 0, }); - const [selectedRegion, setRegion] = React.useState(); + const [selectedDashboard, setSelectedDashboard] = React.useState< + Dashboard | undefined + >(); + const [selectedRegion, setRegion] = React.useState(); const [, setResources] = React.useState(); // removed the unused variable, this will be used later point of time + React.useEffect(() => { const triggerGlobalFilterChange = () => { const globalFilters: FiltersObject = { @@ -64,12 +70,27 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { [] ); + const handleDashboardChange = React.useCallback( + (dashboard: Dashboard | undefined) => { + setSelectedDashboard(dashboard); + setRegion(undefined); + }, + [] + ); + return ( + + + @@ -77,7 +98,7 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx new file mode 100644 index 00000000000..18f7880ca7f --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx @@ -0,0 +1,72 @@ +import { renderWithTheme } from 'src/utilities/testHelpers'; +import { + CloudPulseDashboardSelect, + CloudPulseDashboardSelectProps, +} from './CloudPulseDashboardSelect'; +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; + +const props: CloudPulseDashboardSelectProps = { + handleDashboardChange: vi.fn(), +}; + +const queryMocks = vi.hoisted(() => ({ + useCloudViewDashboardsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/dashboards', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/dashboards'); + return { + ...actual, + useCloudViewDashboardsQuery: queryMocks.useCloudViewDashboardsQuery, + }; +}); + +queryMocks.useCloudViewDashboardsQuery.mockReturnValue({ + data: { + data: [ + { + id: 1, + type: 'standard', + service_type: 'linode', + label: 'Dashboard 1', + created: '2024-04-29T17:09:29', + updated: null, + widgets: {}, + }, + ], + }, + isLoading: false, + error: false, +}); + +describe('CloudPulse Dashboard select', () => { + it('Should render dashboard select component', () => { + const { getByTestId, getByPlaceholderText } = renderWithTheme( + + ); + + expect(getByTestId('cloudview-dashboard-select')).toBeInTheDocument(); + expect(getByPlaceholderText('Select a Dashboard')).toBeInTheDocument(); + }), + it('Should render dashboard select component with data', () => { + renderWithTheme(); + + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + + expect( + screen.getByRole('option', { name: 'Dashboard 1' }) + ).toBeInTheDocument(); + }), + it('Should select the option on click', () => { + renderWithTheme(); + + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + fireEvent.click(screen.getByRole('option', { name: 'Dashboard 1' })); + + expect(screen.getByRole('combobox')).toHaveAttribute( + 'value', + 'Dashboard 1' + ); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx new file mode 100644 index 00000000000..ebf47d3b74e --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx @@ -0,0 +1,77 @@ +import React from 'react'; + +import { Dashboard } from '@linode/api-v4'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Box } from 'src/components/Box'; +import { Typography } from 'src/components/Typography'; +import { useCloudViewDashboardsQuery } from 'src/queries/cloudpulse/dashboards'; + +export interface CloudPulseDashboardSelectProps { + handleDashboardChange: (dashboard: Dashboard | undefined) => void; +} + +export const CloudPulseDashboardSelect = React.memo( + (props: CloudPulseDashboardSelectProps) => { + const { + data: dashboardsList, + error, + isLoading, + } = useCloudViewDashboardsQuery(true); //Fetch the list of dashboards + + const errorText: string = error ? 'Error loading dashboards' : ''; + + const placeHolder = 'Select a Dashboard'; + + // sorts dashboards by service type. Required due to unexpected autocomplete grouping behaviour + const getSortedDashboardsList = (options: Dashboard[]) => { + return options.sort( + (a, b) => -b.service_type.localeCompare(a.service_type) + ); + }; + + if (!dashboardsList) { + return ( + {}} + data-testid="cloudview-dashboard-select" + placeholder={placeHolder} + errorText={errorText} + /> + ); + } + + return ( + { + props.handleDashboardChange(dashboard); + }} + options={getSortedDashboardsList(dashboardsList.data)} + renderGroup={(params) => ( + + + {params.group} + + {params.children} + + )} + autoHighlight + clearOnBlur + data-testid="cloudview-dashboard-select" + errorText={errorText} + fullWidth + groupBy={(option: Dashboard) => option.service_type} + isOptionEqualToValue={(option, value) => option.label === value.label} + label="" + loading={isLoading} + noMarginTop + placeholder={placeHolder} + /> + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx index 6f17a20649d..ea920629b82 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx @@ -7,6 +7,8 @@ import { CloudPulseRegionSelect } from './CloudPulseRegionSelect'; const props: CloudPulseRegionSelectProps = { handleRegionChange: vi.fn(), + selectedDashboard: undefined, + selectedRegion: undefined, }; describe('CloudViewRegionSelect', () => { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index 898f947a94a..1f45fb150b7 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -1,3 +1,5 @@ +/* eslint-disable no-console */ +import { Dashboard } from '@linode/api-v4'; import * as React from 'react'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; @@ -5,30 +7,25 @@ import { useRegionsQuery } from 'src/queries/regions/regions'; export interface CloudPulseRegionSelectProps { handleRegionChange: (region: string | undefined) => void; + selectedDashboard: Dashboard | undefined; + selectedRegion: string | undefined; } export const CloudPulseRegionSelect = React.memo( (props: CloudPulseRegionSelectProps) => { const { data: regions } = useRegionsQuery(); - const [selectedRegion, setRegion] = React.useState(); - - React.useEffect(() => { - if (selectedRegion) { - props.handleRegionChange(selectedRegion); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedRegion]); return ( setRegion(region.id)} + onChange={(e, region) => props.handleRegionChange(region?.id)} regions={regions ? regions : []} - value={undefined} + disabled={!props.selectedDashboard} + value={props.selectedRegion} /> ); } diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx index 41ece50f4f1..26c797e231f 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx @@ -51,12 +51,12 @@ describe('CloudPulseResourcesSelect component tests', () => { fireEvent.click(screen.getByRole('button', { name: 'Open' })); expect( screen.getByRole('option', { - name: 'linode-0', + name: 'linode-1', }) ).toBeInTheDocument(); expect( screen.getByRole('option', { - name: 'linode-1', + name: 'linode-2', }) ).toBeInTheDocument(); }); @@ -79,12 +79,12 @@ describe('CloudPulseResourcesSelect component tests', () => { fireEvent.click(screen.getByRole('option', { name: SELECT_ALL })); expect( screen.getByRole('option', { - name: 'linode-2', + name: 'linode-3', }) ).toHaveAttribute(ARIA_SELECTED, 'true'); expect( screen.getByRole('option', { - name: 'linode-3', + name: 'linode-4', }) ).toHaveAttribute(ARIA_SELECTED, 'true'); }); @@ -108,12 +108,12 @@ describe('CloudPulseResourcesSelect component tests', () => { fireEvent.click(screen.getByRole('option', { name: 'Deselect All' })); expect( screen.getByRole('option', { - name: 'linode-4', + name: 'linode-5', }) ).toHaveAttribute(ARIA_SELECTED, 'false'); expect( screen.getByRole('option', { - name: 'linode-5', + name: 'linode-6', }) ).toHaveAttribute(ARIA_SELECTED, 'false'); }); @@ -133,22 +133,22 @@ describe('CloudPulseResourcesSelect component tests', () => { /> ); fireEvent.click(screen.getByRole('button', { name: 'Open' })); - fireEvent.click(screen.getByRole('option', { name: 'linode-6' })); fireEvent.click(screen.getByRole('option', { name: 'linode-7' })); + fireEvent.click(screen.getByRole('option', { name: 'linode-8' })); expect( screen.getByRole('option', { - name: 'linode-6', + name: 'linode-7', }) ).toHaveAttribute(ARIA_SELECTED, 'true'); expect( screen.getByRole('option', { - name: 'linode-7', + name: 'linode-8', }) ).toHaveAttribute(ARIA_SELECTED, 'true'); expect( screen.getByRole('option', { - name: 'linode-8', + name: 'linode-9', }) ).toHaveAttribute(ARIA_SELECTED, 'false'); expect( diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration.test.tsx index 3ad5df36400..c94327ec954 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration.test.tsx @@ -63,7 +63,7 @@ describe('database current configuration section', () => { getByText('1 GB'); getByText('CPUs'); - getByText('2'); + getByText('4'); getByText('Total Disk Size'); getByText('15 GB'); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx index 7128c1ecb87..e24fcdc7924 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx @@ -58,7 +58,7 @@ describe('FirewallRow', () => { const { getByTestId, getByText } = render( wrapWithTableBody() ); - getByTestId('firewall-row-0'); + getByTestId('firewall-row-1'); getByText(firewall.label); getByText(capitalize(firewall.status)); getByText(getRuleString(getCountOfRules(firewall.rules))); diff --git a/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx b/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx index 756ad8b912e..2d600740b1b 100644 --- a/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx +++ b/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx @@ -17,7 +17,9 @@ import { ComplianceUpdateModal } from './ComplianceUpdateModal'; import { EmailBounceNotificationSection } from './EmailBounce'; import { RegionStatusBanner } from './RegionStatusBanner'; import { TaxCollectionBanner } from './TaxCollectionBanner'; +import { DesignUpdateBanner } from './TokensUpdateBanner'; import { VerificationDetailsBanner } from './VerificationDetailsBanner'; + export const GlobalNotifications = () => { const flags = useFlags(); const { data: profile } = useProfile(); @@ -51,6 +53,7 @@ export const GlobalNotifications = () => { return ( <> + diff --git a/packages/manager/src/features/GlobalNotifications/TokensUpdateBanner.tsx b/packages/manager/src/features/GlobalNotifications/TokensUpdateBanner.tsx new file mode 100644 index 00000000000..73efd621c61 --- /dev/null +++ b/packages/manager/src/features/GlobalNotifications/TokensUpdateBanner.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; + +import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; +import { Link } from 'src/components/Link'; +import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; + +export const DesignUpdateBanner = () => { + const flags = useFlags(); + const designUpdateFlag = flags.cloudManagerDesignUpdatesBanner; + + if (!designUpdateFlag || !designUpdateFlag.enabled) { + return null; + } + const { key, link } = designUpdateFlag; + + /** + * This banner is a reusable banner for future Cloud Manager design updates. + * Since this banner is dismissible, we want to be able to dynamically change the key, + * so we can show it again as needed to users who have dismissed it in the past in the case of a new series of UI updates. + * + * Flag shape is as follows: + * + * { + * "enabled": boolean, + * "key": "some-key", + * "link": "link to docs" + * } + * + */ + return ( + + + We are improving the Cloud Manager experience for our users.{' '} + Read more about recent updates. + + + ); +}; diff --git a/packages/manager/src/features/Help/Panels/PopularPosts.tsx b/packages/manager/src/features/Help/Panels/PopularPosts.tsx index 96d2d47feac..ec5f14daa7e 100644 --- a/packages/manager/src/features/Help/Panels/PopularPosts.tsx +++ b/packages/manager/src/features/Help/Panels/PopularPosts.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 * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -18,7 +18,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ margin: `${theme.spacing(6)} 0`, }, withSeparator: { - borderLeft: `1px solid ${theme.palette.divider}`, + borderLeft: `1px solid ${theme.borderColors.divider}`, paddingLeft: theme.spacing(4), [theme.breakpoints.down('sm')]: { borderLeft: 'none', diff --git a/packages/manager/src/features/Help/Panels/SearchPanel.tsx b/packages/manager/src/features/Help/Panels/SearchPanel.tsx index 3a2150403b3..49cb8a97d1c 100644 --- a/packages/manager/src/features/Help/Panels/SearchPanel.tsx +++ b/packages/manager/src/features/Help/Panels/SearchPanel.tsx @@ -22,7 +22,10 @@ const StyledRootContainer = styled(Paper, { label: 'StyledRootContainer', })(({ theme }) => ({ alignItems: 'center', - backgroundColor: theme.color.green, + backgroundColor: + theme.name === 'dark' + ? theme.palette.primary.light + : theme.palette.primary.dark, display: 'flex', flexDirection: 'column', justifyContent: 'center', @@ -36,7 +39,7 @@ const StyledRootContainer = styled(Paper, { const StyledH1Header = styled(H1Header, { label: 'StyledH1Header', })(({ theme }) => ({ - color: theme.name === 'dark' ? theme.color.black : theme.color.white, + color: theme.color.white, marginBottom: theme.spacing(), position: 'relative', textAlign: 'center', diff --git a/packages/manager/src/features/Images/ImageSelect.test.tsx b/packages/manager/src/features/Images/ImageSelect.test.tsx index cf49ce439ea..58f40b9a9a3 100644 --- a/packages/manager/src/features/Images/ImageSelect.test.tsx +++ b/packages/manager/src/features/Images/ImageSelect.test.tsx @@ -49,6 +49,7 @@ describe('ImageSelect', () => { expect(items[0]).toHaveProperty('label', groupNameMap.recommended); expect(items[0].options).toHaveLength(2); }); + it('should handle multiple groups', () => { const items = getImagesOptions([ recommendedImage1, @@ -60,12 +61,14 @@ describe('ImageSelect', () => { const deleted = items.find((item) => item.label === groupNameMap.deleted); expect(deleted!.options).toHaveLength(1); }); + it('should properly format GroupType options as RS Item type', () => { const category = getImagesOptions([recommendedImage1])[0]; const option = category.options[0]; expect(option).toHaveProperty('label', recommendedImage1.label); expect(option).toHaveProperty('value', recommendedImage1.id); }); + it('should handle empty input', () => { expect(getImagesOptions([])).toEqual([]); }); @@ -74,8 +77,9 @@ describe('ImageSelect', () => { describe('ImageSelect component', () => { it('should render', () => { const { getByText } = renderWithTheme(); - getByText(/image-0/i); + getByText(/image-1(?!\d)/i); }); + it('should display an error', () => { const imageError = 'An error'; const { getByText } = renderWithTheme( diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx index cdda9cd1340..7047a89e20a 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx @@ -1,3 +1,4 @@ +import { waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import React from 'react'; @@ -40,6 +41,33 @@ describe('CreateImageTab', () => { expect(submitButton).toBeEnabled(); }); + it('should pre-fill Linode and Disk from search params', async () => { + const linode = linodeFactory.build(); + const disk = linodeDiskFactory.build(); + + server.use( + http.get('*/v4/linode/instances', () => { + return HttpResponse.json(makeResourcePage([linode])); + }), + http.get('*/v4/linode/instances/:id/disks', () => { + return HttpResponse.json(makeResourcePage([disk])); + }) + ); + + const { getByLabelText } = renderWithTheme(, { + MemoryRouter: { + initialEntries: [ + `/images/create/disk?selectedLinode=${linode.id}&selectedDisk=${disk.id}`, + ], + }, + }); + + await waitFor(() => { + expect(getByLabelText('Linode')).toHaveValue(linode.label); + expect(getByLabelText('Disk')).toHaveValue(disk.label); + }); + }); + it('should render client side validation errors', async () => { const { getByText } = renderWithTheme(); @@ -165,4 +193,71 @@ describe('CreateImageTab', () => { // Verify encryption notice renders await findByText('Virtual Machine Images are not encrypted.'); }); + + it('should auto-populate image label based on linode and disk', async () => { + const linode = linodeFactory.build(); + const disk1 = linodeDiskFactory.build(); + const disk2 = linodeDiskFactory.build(); + const image = imageFactory.build(); + + server.use( + http.get('*/v4/linode/instances', () => { + return HttpResponse.json(makeResourcePage([linode])); + }), + http.get('*/v4/linode/instances/:id', () => { + return HttpResponse.json(linode); + }), + http.get('*/v4/linode/instances/:id/disks', () => { + return HttpResponse.json(makeResourcePage([disk1, disk2])); + }), + http.post('*/v4/images', () => { + return HttpResponse.json(image); + }) + ); + + const { findByText, getByLabelText, queryByText } = renderWithTheme( + + ); + + const linodeSelect = getByLabelText('Linode'); + + await userEvent.click(linodeSelect); + + const linodeOption = await findByText(linode.label); + + await userEvent.click(linodeOption); + + const diskSelect = getByLabelText('Disk'); + + // Once a Linode is selected, the Disk select should become enabled + expect(diskSelect).toBeEnabled(); + expect(queryByText('Select a Linode to see available disks')).toBeNull(); + + await userEvent.click(diskSelect); + + const diskOption = await findByText(disk1.label); + + await userEvent.click(diskOption); + + // Image label should auto-populate + const imageLabel = getByLabelText('Label'); + expect(imageLabel).toHaveValue(`${linode.label}-${disk1.label}`); + + // Image label should update + await userEvent.click(diskSelect); + + const disk2Option = await findByText(disk2.label); + await userEvent.click(disk2Option); + + expect(imageLabel).toHaveValue(`${linode.label}-${disk2.label}`); + + // Image label should not override user input + const customLabel = 'custom-label'; + await userEvent.clear(imageLabel); + await userEvent.type(imageLabel, customLabel); + expect(imageLabel).toHaveValue(customLabel); + await userEvent.click(diskSelect); + await userEvent.click(diskOption); + expect(imageLabel).toHaveValue(customLabel); + }); }); diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index b1f27f2e10b..4de39587203 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -1,10 +1,9 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { CreateImagePayload } from '@linode/api-v4'; import { createImageSchema } from '@linode/validation'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { Controller, useForm } from 'react-hook-form'; -import { useHistory } from 'react-router-dom'; +import { useHistory, useLocation } from 'react-router-dom'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Box } from 'src/components/Box'; @@ -31,10 +30,16 @@ import { useAllLinodeDisksQuery } from 'src/queries/linodes/disks'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; import { useGrants } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; +import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; + +import type { CreateImagePayload } from '@linode/api-v4'; export const CreateImageTab = () => { - const [selectedLinodeId, setSelectedLinodeId] = React.useState( - null + const location = useLocation(); + + const queryParams = React.useMemo( + () => getQueryParamsFromQueryString(location.search), + [location.search] ); const { @@ -43,8 +48,12 @@ export const CreateImageTab = () => { handleSubmit, resetField, setError, + setValue, watch, } = useForm({ + defaultValues: { + disk_id: +queryParams.selectedDisk, + }, mode: 'onBlur', resolver: yupResolver(createImageSchema), }); @@ -89,6 +98,15 @@ export const CreateImageTab = () => { } }); + const [selectedLinodeId, setSelectedLinodeId] = React.useState( + queryParams.selectedLinode ? +queryParams.selectedLinode : null + ); + + const { data: selectedLinode } = useLinodeQuery( + selectedLinodeId ?? -1, + selectedLinodeId !== null + ); + const { data: disks, error: disksError, @@ -99,18 +117,30 @@ export const CreateImageTab = () => { const selectedDisk = disks?.find((disk) => disk.id === selectedDiskId) ?? null; + React.useEffect(() => { + if (formState.touchedFields.label) { + return; + } + if (selectedLinode) { + setValue('label', `${selectedLinode.label}-${selectedDisk?.label ?? ''}`); + } else { + resetField('label'); + } + }, [ + selectedLinode, + selectedDisk, + formState.touchedFields.label, + setValue, + resetField, + ]); + const isRawDisk = selectedDisk?.filesystem === 'raw'; const { data: regionsData } = useRegionsQuery(); - const { data: linode } = useLinodeQuery( - selectedLinodeId ?? -1, - selectedLinodeId !== null - ); - const linodeIsInDistributedRegion = getIsDistributedRegion( regionsData ?? [], - linode?.region ?? '' + selectedLinode?.region ?? '' ); /* diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx index bfef25f2a31..51bf538c31e 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx @@ -251,14 +251,12 @@ export const ImageUpload = () => { isImageCreateRestricted || form.formState.isSubmitting } textFieldProps={{ - helperTextPosition: 'top', inputRef: field.ref, onBlur: field.onBlur, }} currentCapability={undefined} disableClearable errorText={fieldState.error?.message} - helperText="For fastest initial upload, select the region that is geographically closest to you. Once uploaded, you will be able to deploy the image to other regions." label="Region" onChange={(e, region) => field.onChange(region.id)} regionFilter="core" // Images service will not be supported for Gecko Beta diff --git a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx index 4ee4a28ba84..b02932582f1 100644 --- a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx @@ -52,7 +52,7 @@ describe('EditImageDrawer', () => { fireEvent.click(getByText('Save Changes')); await waitFor(() => { - expect(mockUpdateImage).toHaveBeenCalledWith('private/0', { + expect(mockUpdateImage).toHaveBeenCalledWith('private/1', { description: 'test description', label: 'test-image-label', tags: ['new-tag'], diff --git a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx index 582a7738462..09c2d02e8b2 100644 --- a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx @@ -8,7 +8,6 @@ import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; -import { usePrevious } from 'src/hooks/usePrevious'; import { useUpdateImageMutation } from 'src/queries/images'; import { useImageAndLinodeGrantCheck } from '../utils'; @@ -18,18 +17,17 @@ import type { APIError, Image, UpdateImagePayload } from '@linode/api-v4'; interface Props { image: Image | undefined; onClose: () => void; + open: boolean; } export const EditImageDrawer = (props: Props) => { - const { image, onClose } = props; + const { image, onClose, open } = props; const { canCreateImage } = useImageAndLinodeGrantCheck(); - // Prevent content from disappearing when closing drawer - const prevImage = usePrevious(image); const defaultValues = { - description: image?.description ?? prevImage?.description ?? undefined, - label: image?.label ?? prevImage?.label, - tags: image?.tags ?? prevImage?.tags, + description: image?.description ?? undefined, + label: image?.label, + tags: image?.tags, }; const { @@ -78,12 +76,7 @@ export const EditImageDrawer = (props: Props) => { }); return ( - + {!canCreateImage && ( { + it('renders a region label and status', async () => { + const region = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + + const { findByText, getByText } = renderWithTheme( + + ); + + expect(getByText('creating')).toBeVisible(); + expect(await findByText('Newark, NJ')).toBeVisible(); + }); + + it('calls onRemove when the remove button is clicked', async () => { + const onRemove = vi.fn(); + + const { getByLabelText } = renderWithTheme( + + ); + + const removeButton = getByLabelText('Remove us-east'); + + await userEvent.click(removeButton); + + expect(onRemove).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx new file mode 100644 index 00000000000..a3a1ccd292b --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx @@ -0,0 +1,64 @@ +import Close from '@mui/icons-material/Close'; +import React from 'react'; + +import { Box } from 'src/components/Box'; +import { Flag } from 'src/components/Flag'; +import { IconButton } from 'src/components/IconButton'; +import { Stack } from 'src/components/Stack'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { Typography } from 'src/components/Typography'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import type { ImageRegionStatus } from '@linode/api-v4'; +import type { Status } from 'src/components/StatusIcon/StatusIcon'; + +type ExtendedImageRegionStatus = 'unsaved' | ImageRegionStatus; + +interface Props { + onRemove: () => void; + region: string; + status: ExtendedImageRegionStatus; +} + +export const ImageRegionRow = (props: Props) => { + const { onRemove, region, status } = props; + + const { data: regions } = useRegionsQuery(); + + const actualRegion = regions?.find((r) => r.id === region); + + return ( + + + + {actualRegion?.label ?? region} + + + {status} + + + + + + + ); +}; + +const IMAGE_REGION_STATUS_TO_STATUS_ICON_STATUS: Readonly< + Record +> = { + available: 'active', + creating: 'other', + pending: 'other', + 'pending deletion': 'other', + 'pending replication': 'inactive', + replicating: 'other', + timedout: 'inactive', + unsaved: 'inactive', +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx new file mode 100644 index 00000000000..c3623e4d789 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx @@ -0,0 +1,108 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { imageFactory, regionFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ManageImageRegionsForm } from './ManageImageRegionsForm'; + +describe('ManageImageRegionsDrawer', () => { + it('should render a save button and a cancel button', () => { + const image = imageFactory.build(); + const { getByText } = renderWithTheme( + + ); + + const cancelButton = getByText('Cancel').closest('button'); + const saveButton = getByText('Save').closest('button'); + + expect(cancelButton).toBeVisible(); + expect(cancelButton).toBeEnabled(); + + expect(saveButton).toBeVisible(); + expect(saveButton).toBeDisabled(); // The save button should become enabled when regions are changed + }); + + it('should render existing regions and their statuses', async () => { + const region1 = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' }); + const region2 = regionFactory.build({ id: 'us-west', label: 'Place, CA' }); + + const image = imageFactory.build({ + regions: [ + { + region: 'us-east', + status: 'available', + }, + { + region: 'us-west', + status: 'pending replication', + }, + ], + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region1, region2])); + }) + ); + + const { findByText } = renderWithTheme( + + ); + + await findByText('Newark, NJ'); + await findByText('available'); + await findByText('Place, CA'); + await findByText('pending replication'); + }); + + it('should render a status of "unsaved" when a new region is selected', async () => { + const region1 = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' }); + const region2 = regionFactory.build({ id: 'us-west', label: 'Place, CA' }); + + const image = imageFactory.build({ + regions: [ + { + region: 'us-east', + status: 'available', + }, + ], + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region1, region2])); + }) + ); + + const { findByText, getByLabelText, getByText } = renderWithTheme( + + ); + + const saveButton = getByText('Save').closest('button'); + + expect(saveButton).toBeVisible(); + + // Verify the save button is disabled because no changes have been made + expect(saveButton).toBeDisabled(); + + const regionSelect = getByLabelText('Add Regions'); + + // Open the Region Select + await userEvent.click(regionSelect); + + // Select new region + await userEvent.click(await findByText('us-west', { exact: false })); + + // Close the Region Multi-Select to that selections are committed to the list + await userEvent.type(regionSelect, '{escape}'); + + expect(getByText('Place, CA')).toBeVisible(); + expect(getByText('unsaved')).toBeVisible(); + + // Verify the save button is enabled because changes have been made + expect(saveButton).toBeEnabled(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx new file mode 100644 index 00000000000..f50c82a36aa --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx @@ -0,0 +1,150 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { updateImageRegionsSchema } from '@linode/validation'; +import { useSnackbar } from 'notistack'; +import React, { useState } from 'react'; +import { useForm } from 'react-hook-form'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { useUpdateImageRegionsMutation } from 'src/queries/images'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import { ImageRegionRow } from './ImageRegionRow'; + +import type { Image, UpdateImageRegionsPayload } from '@linode/api-v4'; + +interface Props { + image: Image | undefined; + onClose: () => void; +} + +export const ManageImageRegionsForm = (props: Props) => { + const { image, onClose } = props; + + const imageRegionIds = image?.regions.map(({ region }) => region) ?? []; + + const { enqueueSnackbar } = useSnackbar(); + const { data: regions } = useRegionsQuery(); + const { mutateAsync } = useUpdateImageRegionsMutation(image?.id ?? ''); + + const [selectedRegions, setSelectedRegions] = useState([]); + + const { + formState: { errors, isDirty, isSubmitting }, + handleSubmit, + setError, + setValue, + watch, + } = useForm({ + defaultValues: { regions: imageRegionIds }, + resolver: yupResolver(updateImageRegionsSchema), + values: { regions: imageRegionIds }, + }); + + const onSubmit = async (data: UpdateImageRegionsPayload) => { + try { + await mutateAsync(data); + + enqueueSnackbar('Image regions successfully updated.', { + variant: 'success', + }); + } catch (errors) { + for (const error of errors) { + if (error.field) { + setError(error.field, { message: error.reason }); + } else { + setError('root', { message: error.reason }); + } + } + } + }; + + const values = watch(); + + return ( +
+ {errors.root?.message && ( + + )} + + Custom images are billed monthly, at $.10/GB. Check out{' '} + + this guide + {' '} + for details on managing your Linux system's disk space. + + { + setValue('regions', [...values.regions, ...selectedRegions], { + shouldDirty: true, + shouldValidate: true, + }); + setSelectedRegions([]); + }} + regions={(regions ?? []).filter( + (r) => !values.regions.includes(r.id) && r.site_type === 'core' + )} + currentCapability={undefined} + errorText={errors.regions?.message} + label="Add Regions" + onChange={setSelectedRegions} + placeholder="Select Regions" + selectedIds={selectedRegions} + /> + + Image will be available in these regions ({values.regions.length}) + + ({ + backgroundColor: theme.palette.background.paper, + p: 2, + py: 1, + })} + variant="outlined" + > + + {values.regions.length === 0 && ( + + No Regions Selected + + )} + {values.regions.map((regionId) => ( + + setValue( + 'regions', + values.regions.filter((r) => r !== regionId), + { shouldDirty: true, shouldValidate: true } + ) + } + status={ + image?.regions.find( + (regionItem) => regionItem.region === regionId + )?.status ?? 'unsaved' + } + key={regionId} + region={regionId} + /> + ))} + + + + + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx index c6fa1646bda..2d09bb8cbbc 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx @@ -8,7 +8,7 @@ import { wrapWithTableBody, } from 'src/utilities/testHelpers'; -import ImageRow from './ImageRow'; +import { ImageRow } from './ImageRow'; import type { Handlers } from './ImagesActionMenu'; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx index bd3e50581e2..1c3c07dacc2 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx @@ -26,7 +26,7 @@ interface Props { multiRegionsEnabled?: boolean; // TODO Image Service v2: delete after GA } -const ImageRow = (props: Props) => { +export const ImageRow = (props: Props) => { const { event, handlers, image, multiRegionsEnabled } = props; const { @@ -171,5 +171,3 @@ const ProgressDisplay: React.FC<{ ); }; - -export default React.memo(ImageRow); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx index 41ea6d1b519..90bd29494c3 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx @@ -91,7 +91,7 @@ export const ImagesActionMenu = (props: Props) => { }, { onClick: () => onDelete?.(label, id, status), - title: isAvailable ? 'Delete' : 'Cancel Upload', + title: isAvailable ? 'Delete' : 'Cancel', }, ]; }, [ diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx index 1a9601dcfc6..f0e753fe8b1 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx @@ -181,7 +181,7 @@ describe('Images Landing Table', () => { await userEvent.click(getByText('Rebuild an Existing Linode')); - getByText('Restore from Image'); + getByText('Rebuild an Existing Linode from an Image'); }); it('should allow deploying to a new Linode', async () => { diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index c3cc58de087..28d72c8abb5 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -10,6 +10,7 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { CircleProgress } from 'src/components/CircleProgress'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { Drawer } from 'src/components/Drawer'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Hidden } from 'src/components/Hidden'; import { IconButton } from 'src/components/IconButton'; @@ -24,7 +25,6 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; -import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; @@ -45,13 +45,13 @@ import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { getEventsForImages } from '../utils'; import { EditImageDrawer } from './EditImageDrawer'; -import ImageRow from './ImageRow'; +import { ManageImageRegionsForm } from './ImageRegions/ManageImageRegionsForm'; +import { ImageRow } from './ImageRow'; import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; import { RebuildImageDrawer } from './RebuildImageDrawer'; import type { Handlers as ImageHandlers } from './ImagesActionMenu'; -import type { Image, ImageStatus } from '@linode/api-v4'; -import type { APIError } from '@linode/api-v4/lib/types'; +import type { ImageStatus } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; const searchQueryKey = 'query'; @@ -212,13 +212,18 @@ export const ImagesLanding = () => { imageEvents ); + const [selectedImageId, setSelectedImageId] = React.useState(); + const [ - // @ts-expect-error This will be unused until the regions drawer is implemented - manageRegionsDrawerImage, - setManageRegionsDrawerImage, - ] = React.useState(); - const [editDrawerImage, setEditDrawerImage] = React.useState(); - const [rebuildDrawerImage, setRebuildDrawerImage] = React.useState(); + isManageRegionsDrawerOpen, + setIsManageRegionsDrawerOpen, + ] = React.useState(false); + const [isEditDrawerOpen, setIsEditDrawerOpen] = React.useState(false); + const [isRebuildDrawerOpen, setIsRebuildDrawerOpen] = React.useState(false); + + const selectedImage = + manualImages?.data.find((i) => i.id === selectedImageId) ?? + automaticImages?.data.find((i) => i.id === selectedImageId); const [dialog, setDialogState] = React.useState( defaultDialogState @@ -312,24 +317,6 @@ export const ImagesLanding = () => { }); }; - const getActions = () => { - return ( - - ); - }; - const resetSearch = () => { queryParams.delete(searchQueryKey); history.push({ search: queryParams.toString() }); @@ -345,61 +332,44 @@ export const ImagesLanding = () => { onCancelFailed: onCancelFailedClick, onDelete: openDialog, onDeploy: deployNewLinode, - onEdit: setEditDrawerImage, + onEdit: (image) => { + setSelectedImageId(image.id); + setIsEditDrawerOpen(true); + }, onManageRegions: multiRegionsEnabled - ? setManageRegionsDrawerImage + ? (image) => { + setSelectedImageId(image.id); + setIsManageRegionsDrawerOpen(true); + } : undefined, - onRestore: setRebuildDrawerImage, + onRestore: (image) => { + setSelectedImageId(image.id); + setIsRebuildDrawerOpen(true); + }, onRetry: onRetryClick, }; - const renderError = (_: APIError[]) => { + if (manualImagesLoading || automaticImagesLoading) { + return ; + } + + if (manualImagesError || automaticImagesError) { return ( ); - }; - - const renderLoading = () => { - return ; - }; - - const renderEmpty = () => { - return ; - }; - - if (manualImagesLoading || automaticImagesLoading) { - return renderLoading(); - } - - /** Error State */ - if (manualImagesError) { - return renderError(manualImagesError); - } - - if (automaticImagesError) { - return renderError(automaticImagesError); } - /** Empty States */ if ( - !manualImages.data.length && - !automaticImages.data.length && + manualImages.results === 0 && + automaticImages.results === 0 && !imageLabelFromParam ) { - return renderEmpty(); + return ; } - const noManualImages = ( - - ); - - const noAutomaticImages = ( - - ); - const isFetching = manualImagesIsFetching || automaticImagesIsFetching; return ( @@ -501,17 +471,21 @@ export const ImagesLanding = () => {
- {manualImages.data.length > 0 - ? manualImages.data.map((manualImage) => ( - - )) - : noManualImages} + {manualImages.results === 0 && ( + + )} + {manualImages.data.map((manualImage) => ( + + ))}
{ - {isFetching ? ( - - ) : automaticImages.data.length > 0 ? ( - automaticImages.data.map((automaticImage) => ( - - )) - ) : ( - noAutomaticImages + {automaticImages.results === 0 && ( + )} + {automaticImages.data.map((automaticImage) => ( + + ))} { />
setEditDrawerImage(undefined)} + image={selectedImage} + onClose={() => setIsEditDrawerOpen(false)} + open={isEditDrawerOpen} /> setRebuildDrawerImage(undefined)} + image={selectedImage} + onClose={() => setIsRebuildDrawerOpen(false)} + open={isRebuildDrawerOpen} /> + setIsManageRegionsDrawerOpen(false)} + open={isManageRegionsDrawerOpen} + title={`Manage Regions for ${selectedImage?.label}`} + > + setIsManageRegionsDrawerOpen(false)} + /> + + } title={ dialogAction === 'cancel' ? 'Cancel Upload' : `Delete Image ${dialog.image}` } - actions={getActions} onClose={closeDialog} open={dialog.open} > diff --git a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx index 1214868b31d..b2ddbb5aa01 100644 --- a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx @@ -30,7 +30,10 @@ describe('RebuildImageDrawer', () => { const { getByText } = renderWithTheme(); // Verify title renders - getByText('Restore from Image'); + getByText('Rebuild an Existing Linode from an Image'); + + // Verify image label is displayed + getByText(props.image.label); }); it('should allow selecting a Linode to rebuild', async () => { @@ -46,11 +49,11 @@ describe('RebuildImageDrawer', () => { await userEvent.click(getByRole('combobox')); await userEvent.click(await findByText('linode-1')); - await userEvent.click(getByText('Restore Image')); + await userEvent.click(getByText('Rebuild Linode')); expect(mockHistoryPush).toBeCalledWith({ pathname: '/linodes/1/rebuild', - search: 'selectedImageId=private%2F0', + search: 'selectedImageId=private%2F1', }); }); }); diff --git a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx index 2c7685bc32d..dc2bf134a93 100644 --- a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx @@ -3,8 +3,11 @@ import { Controller, useForm } from 'react-hook-form'; import { useHistory } from 'react-router-dom'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { DescriptionList } from 'src/components/DescriptionList/DescriptionList'; +import { Divider } from 'src/components/Divider'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; +import { Stack } from 'src/components/Stack'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { REBUILD_LINODE_IMAGE_PARAM_NAME } from '../../Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage'; @@ -15,10 +18,11 @@ import type { Image } from '@linode/api-v4'; interface Props { image: Image | undefined; onClose: () => void; + open: boolean; } export const RebuildImageDrawer = (props: Props) => { - const { image, onClose } = props; + const { image, onClose, open } = props; const history = useHistory(); const { @@ -51,54 +55,63 @@ export const RebuildImageDrawer = (props: Props) => { - {formState.errors.root?.message && ( - - )} - - ( - { - field.onChange(linode?.id); - }} - optionsFilter={(linode) => - availableLinodes ? availableLinodes.includes(linode.id) : true - } - clearable={true} - errorText={fieldState.error?.message} - onBlur={field.onBlur} - value={field.value} + + {formState.errors.root?.message && ( + )} - rules={{ - required: { - message: 'Select a Linode to restore.', - value: true, - }, - }} - control={control} - name="linodeId" - /> - + + + + + ( + { + field.onChange(linode?.id); + }} + optionsFilter={(linode) => + availableLinodes ? availableLinodes.includes(linode.id) : true + } + clearable={true} + errorText={fieldState.error?.message} + onBlur={field.onBlur} + placeholder="Select Linode or Type to Search" + value={field.value} + /> + )} + rules={{ + required: { + message: 'Select a Linode to restore.', + value: true, + }, + }} + control={control} + name="linodeId" + /> + + + ); }; diff --git a/packages/manager/src/features/Images/utils.test.tsx b/packages/manager/src/features/Images/utils.test.tsx index 81dc5f25783..eba0b26ca23 100644 --- a/packages/manager/src/features/Images/utils.test.tsx +++ b/packages/manager/src/features/Images/utils.test.tsx @@ -13,6 +13,7 @@ describe('getImageLabelForLinode', () => { }); expect(getImageLabelForLinode(linode, images)).toBe('Cool Image'); }); + it('falls back to the linodes image id if there is no match in the images array', () => { const linode = linodeFactory.build({ image: 'public/cool-image', @@ -23,6 +24,7 @@ describe('getImageLabelForLinode', () => { }); expect(getImageLabelForLinode(linode, images)).toBe('public/cool-image'); }); + it('returns null if the linode does not have an image', () => { const linode = linodeFactory.build({ image: null, @@ -36,9 +38,11 @@ describe('getEventsForImages', () => { it('sorts events by image', () => { imageFactory.resetSequenceNumber(); const images = imageFactory.buildList(3); - const successfulEvent = eventFactory.build({ secondary_entity: { id: 0 } }); + const successfulEvent = eventFactory.build({ + secondary_entity: { id: 1 }, + }); const failedEvent = eventFactory.build({ - entity: { id: 1 }, + entity: { id: 2 }, status: 'failed', }); const unrelatedEvent = eventFactory.build(); @@ -46,8 +50,8 @@ describe('getEventsForImages', () => { expect( getEventsForImages(images, [successfulEvent, failedEvent, unrelatedEvent]) ).toEqual({ - ['private/0']: successfulEvent, - ['private/1']: failedEvent, + ['private/1']: successfulEvent, + ['private/2']: failedEvent, }); }); }); diff --git a/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.test.tsx b/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.test.tsx index f914a962fd2..8a9d945d3b2 100644 --- a/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.test.tsx +++ b/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.test.tsx @@ -3,10 +3,12 @@ import * as React from 'react'; import { kubernetesClusterFactory, regionFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { wrapWithTableBody, wrapWithTheme } from 'src/utilities/testHelpers'; -import { KubernetesClusterRow, Props } from './KubernetesClusterRow'; +import { KubernetesClusterRow } from './KubernetesClusterRow'; + +import type { Props } from './KubernetesClusterRow'; const cluster = kubernetesClusterFactory.build({ region: 'us-central' }); @@ -36,11 +38,11 @@ describe('ClusterRow component', () => { }) ); - const { getByText, findByText } = render( + const { findByText, getByText } = render( wrapWithTableBody() ); - getByText('cluster-0'); + getByText('cluster-1'); await findByText('Fake Region, NC'); }); diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx index 60e58baa7cc..f9770335db0 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx @@ -1,7 +1,6 @@ -import { waitForElementToBeRemoved } from '@testing-library/react'; import * as React from 'react'; -import { regionFactory } from 'src/factories'; +import { regionFactory, typeFactory } from 'src/factories'; import { nodePoolFactory } from 'src/factories/kubernetesCluster'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { LKE_CREATE_CLUSTER_CHECKOUT_MESSAGE } from 'src/utilities/pricing/constants'; @@ -32,13 +31,23 @@ const renderComponent = (_props: Props) => renderWithTheme(); describe('KubeCheckoutBar', () => { + beforeAll(() => { + vi.mock('src/queries/types', async () => { + const actual = await vi.importActual('src/queries/types'); + return { + ...actual, + useSpecificTypes: vi + .fn() + .mockImplementation(() => [{ data: typeFactory.build() }]), + }; + }); + }); + it('should render helper text and disable create button until a region has been selected', async () => { - const { findByText, getByTestId, getByText } = renderWithTheme( + const { findByText, getByText } = renderWithTheme( ); - await waitForElementToBeRemoved(getByTestId('circle-progress')); - await findByText(LKE_CREATE_CLUSTER_CHECKOUT_MESSAGE); expect(getByText('Create Cluster').closest('button')).toHaveAttribute( 'aria-disabled', @@ -47,9 +56,7 @@ describe('KubeCheckoutBar', () => { }); it('should render a section for each pool', async () => { - const { getByTestId, queryAllByTestId } = renderComponent(props); - - await waitForElementToBeRemoved(getByTestId('circle-progress')); + const { queryAllByTestId } = renderComponent(props); expect(queryAllByTestId('node-pool-summary')).toHaveLength(pools.length); }); @@ -90,7 +97,7 @@ describe('KubeCheckoutBar', () => { ); // 5 node pools * 3 linodes per pool * 12 per linode * 20% increase for Jakarta + 72 per month per cluster for HA - await findByText(/\$180\.00/); + await findByText(/\$183\.00/); }); it('should display the DC-Specific total price of the cluster for a region with a price increase with HA selection', async () => { @@ -104,7 +111,7 @@ describe('KubeCheckoutBar', () => { ); // 5 node pools * 3 linodes per pool * 12 per linode * 20% increase for Jakarta + 72 per month per cluster for HA - await findByText(/\$252\.00/); + await findByText(/\$255\.00/); }); it('should display UNKNOWN_PRICE for HA when not available and show total price of cluster as the sum of the node pools', async () => { @@ -118,7 +125,7 @@ describe('KubeCheckoutBar', () => { ); // 5 node pools * 3 linodes per pool * 12 per linode * 20% increase for Jakarta + UNKNOWN_PRICE - await findByText(/\$180\.00/); + await findByText(/\$183\.00/); getByText(/\$--.--\/month/); }); }); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx index ee5b4f6a2b5..27a278a2ec1 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx @@ -1,4 +1,3 @@ -import { APIError } from '@linode/api-v4/lib/types'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { Link } from 'react-router-dom'; @@ -13,6 +12,8 @@ import { useInProgressEvents } from 'src/queries/events/events'; import NodeActionMenu from './NodeActionMenu'; import { StyledCopyTooltip, StyledTableRow } from './NodeTable.styles'; +import type { APIError } from '@linode/api-v4/lib/types'; + export interface NodeRow { instanceId?: number; instanceStatus?: string; @@ -100,7 +101,7 @@ export const NodeRow = React.memo((props: NodeRowProps) => { )} - + {linodeError ? ( ({ diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx index 3ba84624448..3d27a03c5a7 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx @@ -18,7 +18,6 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import { LinodeWithMaintenance } from 'src/utilities/linodes'; import { NodeRow as _NodeRow } from './NodeRow'; import { @@ -30,6 +29,7 @@ import { import type { NodeRow } from './NodeRow'; import type { PoolNodeResponse } from '@linode/api-v4/lib/kubernetes'; import type { EncryptionStatus } from '@linode/api-v4/lib/linodes/types'; +import type { LinodeWithMaintenance } from 'src/utilities/linodes'; export interface Props { encryptionStatus: EncryptionStatus | undefined; @@ -88,7 +88,7 @@ export const NodeTable = React.memo((props: Props) => { ({ ...theme.applyTableHeaderStyles, - width: '35%', + width: '25%', })} active={orderBy === 'instanceStatus'} direction={order} @@ -100,7 +100,7 @@ export const NodeTable = React.memo((props: Props) => { ({ ...theme.applyTableHeaderStyles, - width: '15%', + width: '35%', })} active={orderBy === 'ip'} direction={order} diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx index bd6e2ad6352..3bf7367eef6 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.test.tsx @@ -1,16 +1,28 @@ import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { nodePoolFactory } from 'src/factories/kubernetesCluster'; +import { nodePoolFactory, typeFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { Props, ResizeNodePoolDrawer } from './ResizeNodePoolDrawer'; +import { ResizeNodePoolDrawer } from './ResizeNodePoolDrawer'; + +import type { Props } from './ResizeNodePoolDrawer'; const pool = nodePoolFactory.build({ type: 'g6-standard-1', }); const smallPool = nodePoolFactory.build({ count: 2 }); +vi.mock('src/queries/types', async () => { + const actual = await vi.importActual('src/queries/types'); + return { + ...actual, + useSpecificTypes: vi + .fn() + .mockReturnValue([{ data: typeFactory.build({ label: 'Linode 1 GB' }) }]), + }; +}); + const props: Props = { kubernetesClusterId: 1, kubernetesRegionId: 'us-east', diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.test.tsx index 2e7e9b08a4c..805b58657a4 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { grantsFactory, + imageFactory, linodeFactory, linodeTypeFactory, profileFactory, @@ -173,4 +174,55 @@ describe('Region', () => { ) ).toBeVisible(); }); + + it('should disable distributed regions if the selected image does not have the `distributed-images` capability', async () => { + const image = imageFactory.build({ capabilities: [] }); + + const distributedRegion = regionFactory.build({ + capabilities: ['Linodes'], + site_type: 'distributed', + }); + const coreRegion = regionFactory.build({ + capabilities: ['Linodes'], + site_type: 'core', + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json( + makeResourcePage([coreRegion, distributedRegion]) + ); + }), + http.get('*/v4/images/:id', () => { + return HttpResponse.json(image); + }) + ); + + const { + findByText, + getByLabelText, + } = renderWithThemeAndHookFormContext({ + component: , + options: { + MemoryRouter: { initialEntries: ['/linodes/create?type=Images'] }, + }, + useFormOptions: { + defaultValues: { + image: image.id, + }, + }, + }); + + const regionSelect = getByLabelText('Region'); + + await userEvent.click(regionSelect); + + const distributedRegionOption = await findByText(distributedRegion.id, { + exact: false, + }); + + expect(distributedRegionOption.closest('li')?.textContent).toContain( + 'The selected image cannot be deployed to a distributed region.' + ); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx index 3b3d42e5896..92aa0b7fae8 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx @@ -13,6 +13,7 @@ import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperT import { Typography } from 'src/components/Typography'; import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { useImageQuery } from 'src/queries/images'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useTypeQuery } from 'src/queries/types'; import { @@ -22,6 +23,7 @@ import { import { isLinodeTypeDifferentPriceInSelectedRegion } from 'src/utilities/pricing/linodes'; import { CROSS_DATA_CENTER_CLONE_WARNING } from '../LinodesCreate/constants'; +import { getDisabledRegions } from './Region.utils'; import { defaultInterfaces, useLinodeCreateQueryParams } from './utilities'; import type { LinodeCreateFormValues } from './utilities'; @@ -42,7 +44,15 @@ export const Region = () => { name: 'region', }); - const selectedLinode = useWatch({ control, name: 'linode' }); + const [selectedLinode, selectedImage] = useWatch({ + control, + name: ['linode', 'image'], + }); + + const { data: image } = useImageQuery( + selectedImage ?? '', + Boolean(selectedImage) + ); const { data: type } = useTypeQuery( selectedLinode?.type ?? '', @@ -112,6 +122,12 @@ export const Region = () => { region.site_type === 'distributed' || region.site_type === 'edge' ); + const disabledRegions = getDisabledRegions({ + linodeCreateTab: params.type, + regions: regions ?? [], + selectedImage: image, + }); + return ( @@ -130,15 +146,21 @@ export const Region = () => {
)} onChange(region)} - regionFilter={hideDistributedRegions ? 'core' : undefined} regions={regions ?? []} textFieldProps={{ onBlur: field.onBlur }} value={field.value} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.test.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.test.ts new file mode 100644 index 00000000000..4fb2cd8fb7c --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.test.ts @@ -0,0 +1,40 @@ +import { imageFactory, regionFactory } from 'src/factories'; + +import { getDisabledRegions } from './Region.utils'; + +describe('getDisabledRegions', () => { + it('disables distributed regions if the selected image does not have the distributed capability', () => { + const distributedRegion = regionFactory.build({ site_type: 'distributed' }); + const coreRegion = regionFactory.build({ site_type: 'core' }); + + const image = imageFactory.build({ capabilities: [] }); + + const result = getDisabledRegions({ + linodeCreateTab: 'Images', + regions: [distributedRegion, coreRegion], + selectedImage: image, + }); + + expect(result).toStrictEqual({ + [distributedRegion.id]: { + reason: + 'The selected image cannot be deployed to a distributed region.', + }, + }); + }); + + it('does not disable any regions if the selected image has the distributed regions capability', () => { + const distributedRegion = regionFactory.build({ site_type: 'distributed' }); + const coreRegion = regionFactory.build({ site_type: 'core' }); + + const image = imageFactory.build({ capabilities: ['distributed-images'] }); + + const result = getDisabledRegions({ + linodeCreateTab: 'Images', + regions: [distributedRegion, coreRegion], + selectedImage: image, + }); + + expect(result).toStrictEqual({}); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.ts new file mode 100644 index 00000000000..d611a82971f --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.ts @@ -0,0 +1,42 @@ +import type { LinodeCreateType } from '../LinodesCreate/types'; +import type { Image, Region } from '@linode/api-v4'; +import type { DisableRegionOption } from 'src/components/RegionSelect/RegionSelect.types'; + +interface DisabledRegionOptions { + linodeCreateTab: LinodeCreateType | undefined; + regions: Region[]; + selectedImage: Image | undefined; +} + +/** + * Returns regions that should be disabled on the Linode Create flow. + * + * @returns key/value pairs for disabled regions. the key is the region id and the value is why the region is disabled + */ +export const getDisabledRegions = (options: DisabledRegionOptions) => { + const { linodeCreateTab, regions, selectedImage } = options; + + // On the images tab, we disabled distributed regions if: + // - The user has selected an Image + // - The selected image does not have the `distributed-images` capability + if ( + linodeCreateTab === 'Images' && + selectedImage && + !selectedImage.capabilities.includes('distributed-images') + ) { + const disabledRegions: Record = {}; + + for (const region of regions) { + if (region.site_type === 'distributed') { + disabledRegions[region.id] = { + reason: + 'The selected image cannot be deployed to a distributed region.', + }; + } + } + + return disabledRegions; + } + + return {}; +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.test.tsx similarity index 87% rename from packages/manager/src/features/Linodes/LinodeCreatev2/Summary.test.tsx rename to packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.test.tsx index 6663aa287a7..05994a8951b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.test.tsx @@ -85,7 +85,7 @@ describe('Linode Create v2 Summary', () => { await findByText(region.label); }); - it('should render a plan (type) label if a type is selected', async () => { + it('should render a plan (type) label if a region and type are selected', async () => { const type = typeFactory.build(); server.use( @@ -96,7 +96,9 @@ describe('Linode Create v2 Summary', () => { const { findByText } = renderWithThemeAndHookFormContext({ component: , - useFormOptions: { defaultValues: { type: type.id } }, + useFormOptions: { + defaultValues: { region: 'fake-region', type: type.id }, + }, }); await findByText(type.label); @@ -233,4 +235,33 @@ describe('Linode Create v2 Summary', () => { expect(getByText('Encrypted')).toBeVisible(); }); + + it('should render correct pricing for Marketplace app cluster deployments', async () => { + const type = typeFactory.build({ + price: { hourly: 0.5, monthly: 2 }, + }); + + server.use( + http.get('*/v4/linode/types/*', () => { + return HttpResponse.json(type); + }) + ); + + const { + findByText, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + region: 'fake-region', + stackscript_data: { + cluster_size: 5, + }, + type: type.id, + }, + }, + }); + + await findByText(`5 Nodes - $10/month $2.50/hr`); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx similarity index 93% rename from packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx rename to packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx index 0ce13e3c03b..73277af3bc7 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx @@ -13,7 +13,8 @@ import { useTypeQuery } from 'src/queries/types'; import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; import { getMonthlyBackupsPrice } from 'src/utilities/pricing/backups'; import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/dynamicPricing'; -import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; + +import { getLinodePrice } from './utilities'; import type { CreateLinodeRequest } from '@linode/api-v4'; @@ -35,6 +36,7 @@ export const Summary = () => { vlanLabel, vpcId, diskEncryption, + clusterSize, ] = useWatch({ control, name: [ @@ -49,6 +51,7 @@ export const Summary = () => { 'interfaces.1.label', 'interfaces.0.vpc_id', 'disk_encryption', + 'stackscript_data.cluster_size', ], }); @@ -58,13 +61,12 @@ export const Summary = () => { const region = regions?.find((r) => r.id === regionId); - // @todo handle marketplace cluster pricing (support many nodes by looking at UDF data) - const price = getLinodeRegionPrice(type, regionId); - const backupsPrice = renderMonthlyPriceToCorrectDecimalPlace( getMonthlyBackupsPrice({ region: regionId, type }) ); + const price = getLinodePrice({ type, regionId, clusterSize }); + const summaryItems = [ { item: { @@ -80,10 +82,10 @@ export const Summary = () => { }, { item: { - details: `$${price?.monthly}/month`, + details: price, title: type ? formatStorageUnits(type.label) : typeId, }, - show: Boolean(typeId), + show: price, }, { item: { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.test.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.test.ts new file mode 100644 index 00000000000..1da6463ccd4 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.test.ts @@ -0,0 +1,33 @@ +import { linodeTypeFactory } from 'src/factories'; + +import { getLinodePrice } from './utilities'; + +describe('getLinodePrice', () => { + it('gets a price for a normal Linode', () => { + const type = linodeTypeFactory.build({ + price: { hourly: 0.1, monthly: 5 }, + }); + + const result = getLinodePrice({ + clusterSize: undefined, + regionId: 'fake-region-id', + type, + }); + + expect(result).toBe('$5/month'); + }); + + it('gets a price for a Marketplace Cluster deployment', () => { + const type = linodeTypeFactory.build({ + price: { hourly: 0.2, monthly: 5 }, + }); + + const result = getLinodePrice({ + clusterSize: '3', + regionId: 'fake-region-id', + type, + }); + + expect(result).toBe('3 Nodes - $15/month $0.60/hr'); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.ts new file mode 100644 index 00000000000..9fc3df07966 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.ts @@ -0,0 +1,42 @@ +import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/dynamicPricing'; +import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; + +import type { LinodeType } from '@linode/api-v4'; + +interface LinodePriceOptions { + clusterSize: string | undefined; + regionId: string | undefined; + type: LinodeType | undefined; +} + +export const getLinodePrice = (options: LinodePriceOptions) => { + const { clusterSize, regionId, type } = options; + const price = getLinodeRegionPrice(type, regionId); + + const isCluster = clusterSize !== undefined; + + if ( + regionId === undefined || + price === undefined || + price.monthly === null || + price.hourly === null + ) { + return undefined; + } + + if (isCluster) { + const numberOfNodes = Number(clusterSize); + + const totalMonthlyPrice = renderMonthlyPriceToCorrectDecimalPlace( + price.monthly * numberOfNodes + ); + + const totalHourlyPrice = renderMonthlyPriceToCorrectDecimalPlace( + price.hourly * numberOfNodes + ); + + return `${numberOfNodes} Nodes - $${totalMonthlyPrice}/month $${totalHourlyPrice}/hr`; + } + + return `$${renderMonthlyPriceToCorrectDecimalPlace(price.monthly)}/month`; +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.test.tsx index 4ba95eac43a..b2565653537 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.test.tsx @@ -3,6 +3,9 @@ import React from 'react'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { Images } from './Images'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { imageFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; describe('Images', () => { it('renders a header', () => { @@ -27,4 +30,27 @@ describe('Images', () => { expect(getByLabelText('Images')).toBeVisible(); expect(getByPlaceholderText('Choose an image')).toBeVisible(); }); + + it('renders a "Indicates compatibility with distributed compute regions." notice if the user has at least one image with the distributed capability', async () => { + server.use( + http.get('*/v4/images', () => { + const images = [ + imageFactory.build({ capabilities: [] }), + imageFactory.build({ capabilities: ['distributed-images'] }), + imageFactory.build({ capabilities: [] }), + ]; + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { findByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect( + await findByText( + 'Indicates compatibility with distributed compute regions.' + ) + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx index 484b2e6f27d..17542f0e313 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx @@ -1,15 +1,24 @@ import React from 'react'; -import { useController } from 'react-hook-form'; +import { useController, useFormContext, useWatch } from 'react-hook-form'; +import DistributedRegionIcon from 'src/assets/icons/entityIcons/distributed-region.svg'; +import { Box } from 'src/components/Box'; import { ImageSelectv2 } from 'src/components/ImageSelectv2/ImageSelectv2'; +import { getAPIFilterForImageSelect } from 'src/components/ImageSelectv2/utilities'; import { Paper } from 'src/components/Paper'; +import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { useAllImagesQuery } from 'src/queries/images'; +import { useRegionsQuery } from 'src/queries/regions/regions'; -import type { CreateLinodeRequest } from '@linode/api-v4'; +import type { LinodeCreateFormValues } from '../utilities'; +import type { Image } from '@linode/api-v4'; export const Images = () => { - const { field, fieldState } = useController({ + const { control, setValue } = useFormContext(); + const { field, fieldState } = useController({ + control, name: 'image', }); @@ -17,17 +26,59 @@ export const Images = () => { globalGrantType: 'add_linodes', }); + const regionId = useWatch({ control, name: 'region' }); + + const { data: regions } = useRegionsQuery(); + + const onChange = (image: Image | null) => { + field.onChange(image?.id ?? null); + + const selectedRegion = regions?.find((r) => r.id === regionId); + + // Non-"distributed compatible" Images must only be deployed to core sites. + // Clear the region field if the currently selected region is a distributed site and the Image is only core compatible. + // @todo: delete this logic when all Images are "distributed compatible" + if ( + image && + !image.capabilities.includes('distributed-images') && + selectedRegion?.site_type === 'distributed' + ) { + setValue('region', ''); + } + }; + + const { data: images } = useAllImagesQuery( + {}, + getAPIFilterForImageSelect('private') + ); + + // @todo: delete this logic when all Images are "distributed compatible" + const showDistributedCapabilityNotice = images?.some((image) => + image.capabilities.includes('distributed-images') + ); + return ( Choose an Image - field.onChange(image?.id ?? null)} - value={field.value} - variant="private" - /> + + + {showDistributedCapabilityNotice && ( + + + + Indicates compatibility with distributed compute regions. + + + )} + ); }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx index a804f3e21ff..43082645282 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx @@ -1,8 +1,10 @@ import React, { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Box } from 'src/components/Box'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; @@ -11,6 +13,7 @@ import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; import { AppsList } from './AppsList'; import { categoryOptions } from './utilities'; +import type { LinodeCreateFormValues } from '../../utilities'; import type { AppCategory } from 'src/features/OneClickApps/types'; interface Props { @@ -23,6 +26,10 @@ interface Props { export const AppSelect = (props: Props) => { const { onOpenDetailsDrawer } = props; + const { + formState: { errors }, + } = useFormContext(); + const { isLoading } = useMarketplaceAppsQuery(true); const [query, setQuery] = useState(''); @@ -32,6 +39,9 @@ export const AppSelect = (props: Props) => { Select an App + {errors.stackscript_id?.message && ( + + )} { it('should render StackScript data from the API', async () => { - const stackscript = stackScriptFactory.build(); + const stackscript = stackScriptFactory.build({ + id: 1234, + }); server.use( http.get('*/v4/linode/stackscripts/:id', () => { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx index 5e610e48967..7b9c379620a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx @@ -1,18 +1,27 @@ +import { getAPIFilterFromQuery } from '@linode/search'; +import CloseIcon from '@mui/icons-material/Close'; import React, { useState } from 'react'; import { useController, useFormContext } from 'react-hook-form'; import { Waypoint } from 'react-waypoint'; +import { debounce } from 'throttle-debounce'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { IconButton } from 'src/components/IconButton'; +import { InputAdornment } from 'src/components/InputAdornment'; import { Stack } from 'src/components/Stack'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; +import { TextField } from 'src/components/TextField'; +import { TooltipIcon } from 'src/components/TooltipIcon'; import { useOrder } from 'src/hooks/useOrder'; import { useStackScriptQuery, @@ -36,6 +45,8 @@ interface Props { } export const StackScriptSelectionList = ({ type }: Props) => { + const [query, setQuery] = useState(); + const { handleOrderChange, order, orderBy } = useOrder({ order: 'desc', orderBy: 'deployments_total', @@ -64,17 +75,26 @@ export const StackScriptSelectionList = ({ type }: Props) => { ? communityStackScriptFilter : accountStackScriptFilter; + const { + error: searchParseError, + filter: searchFilter, + } = getAPIFilterFromQuery(query, { + searchableFieldsWithoutOperator: ['username', 'label', 'description'], + }); + const { data, error, fetchNextPage, hasNextPage, + isFetching, isFetchingNextPage, isLoading, } = useStackScriptsInfiniteQuery( { ['+order']: order, ['+order_by']: orderBy, + ...searchFilter, ...filter, }, !hasPreselectedStackScript @@ -120,8 +140,38 @@ export const StackScriptSelectionList = ({ type }: Props) => { } return ( - - + + + {isFetching && } + {searchParseError && ( + + )} + setQuery('')} + size="small" + > + + + + ), + }} + tooltipText={ + type === 'Community' + ? 'Hint: try searching for a specific item by prepending your search term with "username:", "label:", or "description:"' + : undefined + } + hideLabel + label="Search" + onChange={debounce(400, (e) => setQuery(e.target.value))} + placeholder="Search StackScripts" + spellCheck={false} + value={query} + /> +
@@ -153,6 +203,7 @@ export const StackScriptSelectionList = ({ type }: Props) => { stackscript={stackscript} /> ))} + {data?.pages[0].results === 0 && } {error && } {isLoading && } {(isFetchingNextPage || hasNextPage) && ( diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index f476eaef074..b59ad02d374 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -17,7 +17,6 @@ import { } from 'src/queries/linodes/linodes'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; -import { Security } from './Security'; import { Actions } from './Actions'; import { Addons } from './Addons/Addons'; import { Details } from './Details/Details'; @@ -26,7 +25,8 @@ import { Firewall } from './Firewall'; import { Plan } from './Plan'; import { Region } from './Region'; import { linodeCreateResolvers } from './resolvers'; -import { Summary } from './Summary'; +import { Security } from './Security'; +import { Summary } from './Summary/Summary'; import { Backups } from './Tabs/Backups/Backups'; import { Clone } from './Tabs/Clone/Clone'; import { Distributions } from './Tabs/Distributions'; @@ -35,7 +35,6 @@ import { Marketplace } from './Tabs/Marketplace/Marketplace'; import { StackScripts } from './Tabs/StackScripts/StackScripts'; import { UserData } from './UserData/UserData'; import { - LinodeCreateFormValues, defaultValues, defaultValuesMap, getLinodeCreatePayload, @@ -46,6 +45,7 @@ import { import { VLAN } from './VLAN'; import { VPC } from './VPC/VPC'; +import type { LinodeCreateFormValues } from './utilities'; import type { SubmitHandler } from 'react-hook-form'; export const LinodeCreatev2 = () => { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts index 601e7ac2104..b5c93accfd4 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/resolvers.ts @@ -4,12 +4,13 @@ import { CreateLinodeSchema } from '@linode/validation'; import { CreateLinodeByCloningSchema, CreateLinodeFromBackupSchema, + CreateLinodeFromMarketplaceAppSchema, CreateLinodeFromStackScriptSchema, } from './schemas'; import { getLinodeCreatePayload } from './utilities'; -import { LinodeCreateFormValues } from './utilities'; import type { LinodeCreateType } from '../LinodesCreate/types'; +import type { LinodeCreateFormValues } from './utilities'; import type { Resolver } from 'react-hook-form'; export const resolver: Resolver = async ( @@ -52,6 +53,26 @@ export const stackscriptResolver: Resolver = async ( return { errors: {}, values }; }; +export const marketplaceResolver: Resolver = async ( + values, + context, + options +) => { + const transformedValues = getLinodeCreatePayload(structuredClone(values)); + + const { errors } = await yupResolver( + CreateLinodeFromMarketplaceAppSchema, + {}, + { mode: 'async', rawValues: true } + )(transformedValues, context, options); + + if (errors) { + return { errors, values }; + } + + return { errors: {}, values }; +}; + export const cloneResolver: Resolver = async ( values, context, @@ -107,6 +128,6 @@ export const linodeCreateResolvers: Record< 'Clone Linode': cloneResolver, Distributions: resolver, Images: resolver, - 'One-Click': stackscriptResolver, + 'One-Click': marketplaceResolver, StackScripts: stackscriptResolver, }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/schemas.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/schemas.ts index d7e826c1a52..b97945a9868 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/schemas.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/schemas.ts @@ -27,3 +27,12 @@ export const CreateLinodeFromStackScriptSchema = CreateLinodeSchema.concat( stackscript_id: number().required('You must select a StackScript.'), }) ); + +/** + * Extends the Linode Create schema to make stackscript_id required for the Marketplace tab + */ +export const CreateLinodeFromMarketplaceAppSchema = CreateLinodeSchema.concat( + object({ + stackscript_id: number().required('You must select a Marketplace App.'), + }) +); diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts b/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts index 47af5c1e3da..f59d4fb895c 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts @@ -122,7 +122,8 @@ export const StyledTable = styled(Table, { label: 'StyledTable' })( whiteSpace: 'nowrap', }, '& th': { - backgroundColor: theme.bg.app, + backgroundColor: + theme.name === 'light' ? theme.color.grey10 : theme.bg.app, borderBottom: `1px solid ${theme.bg.bgPaper}`, color: theme.textColors.textAccessTable, fontFamily: theme.font.bold, @@ -136,6 +137,7 @@ export const StyledTable = styled(Table, { label: 'StyledTable' })( '& tr': { height: 32, }, + border: 'none', tableLayout: 'fixed', }) ); diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx index 398a5284d51..09ceb219071 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx @@ -134,10 +134,21 @@ export const LinodeEntityDetailHeader = ( formattedTransitionText !== formattedStatus; const sxActionItem = { + '&:focus': { + color: theme.color.white, + }, '&:hover': { - backgroundColor: theme.color.blue, - color: '#fff', + '&[aria-disabled="true"]': { + color: theme.color.disabledText, + }, + + color: theme.color.white, + }, + '&[aria-disabled="true"]': { + background: 'transparent', + color: theme.color.disabledText, }, + background: 'transparent', color: theme.textColors.linkActiveLight, fontFamily: theme.font.normal, fontSize: '0.875rem', @@ -197,14 +208,14 @@ export const LinodeEntityDetailHeader = ( onClick={() => handlers.onOpenPowerDialog(isRunning ? 'Power Off' : 'Power On') } - buttonType="secondary" + buttonType="primary" disabled={!(isRunning || isOffline) || isLinodesGrantReadOnly} sx={sxActionItem} > {isRunning ? 'Power Off' : 'Power On'} - - + text={ + + Would you like{' '} + {_shouldEnableAutoResizeDiskOption ? ( + {diskToResize} + ) : ( + 'your disk' + )}{' '} + to be automatically scaled with this Linode’s new size?{' '} +
+ We recommend you keep this option enabled when available. +
+ } + disabled={!_shouldEnableAutoResizeDiskOption || isSmaller} + /> + + + + To confirm these changes, type the label of the Linode ( + {linode?.label}) in the field below: + + } + hideLabel + label="Linode Label" + onChange={setConfirmationText} + textFieldStyle={{ marginBottom: 16 }} + title="Confirm" + typographyStyle={{ marginBottom: 8 }} + value={confirmationText} + visible={preferences?.type_to_confirm} + /> + + + + + + )} ); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateImageFromDiskDialog.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateImageFromDiskDialog.test.tsx deleted file mode 100644 index 423eaaeb977..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateImageFromDiskDialog.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import * as React from 'react'; - -import { DISK_ENCRYPTION_IMAGES_CAVEAT_COPY } from 'src/components/DiskEncryption/constants'; -import { linodeDiskFactory } from 'src/factories'; -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { CreateImageFromDiskDialog } from './CreateImageFromDiskDialog'; - -const diskEncryptionEnabledMock = vi.hoisted(() => { - return { - useIsDiskEncryptionFeatureEnabled: vi.fn(), - }; -}); - -describe('CreateImageFromDiskDialog component', () => { - const mockDisk = linodeDiskFactory.build(); - - vi.mock('src/components/DiskEncryption/utils.ts', async () => { - const actual = await vi.importActual( - 'src/components/DiskEncryption/utils.ts' - ); - return { - ...actual, - __esModule: true, - useIsDiskEncryptionFeatureEnabled: diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockImplementation( - () => { - return { - isDiskEncryptionFeatureEnabled: false, // indicates the feature flag is off or account capability is absent - }; - } - ), - }; - }); - - it('does not display a notice regarding Images not being encrypted if the Disk Encryption feature is disabled', () => { - const { queryByText } = renderWithTheme( - - ); - - const encryptionImagesCaveatNotice = queryByText( - DISK_ENCRYPTION_IMAGES_CAVEAT_COPY - ); - - expect(encryptionImagesCaveatNotice).not.toBeInTheDocument(); - }); - - it('displays a notice regarding Images not being encrypted if the Disk Encryption feature is enabled', () => { - diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockImplementationOnce( - () => { - return { - isDiskEncryptionFeatureEnabled: true, - }; - } - ); - - const { queryByText } = renderWithTheme( - - ); - - const encryptionImagesCaveatNotice = queryByText( - DISK_ENCRYPTION_IMAGES_CAVEAT_COPY - ); - - expect(encryptionImagesCaveatNotice).toBeInTheDocument(); - - diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockRestore(); - }); -}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateImageFromDiskDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateImageFromDiskDialog.tsx deleted file mode 100644 index a268ad7d764..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateImageFromDiskDialog.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Disk } from '@linode/api-v4'; -import { Typography } from '@mui/material'; -import { useSnackbar } from 'notistack'; -import React from 'react'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { DISK_ENCRYPTION_IMAGES_CAVEAT_COPY } from 'src/components/DiskEncryption/constants'; -import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; -import { Notice } from 'src/components/Notice/Notice'; -import { SupportLink } from 'src/components/SupportLink/SupportLink'; -import { useCreateImageMutation } from 'src/queries/images'; - -interface Props { - disk: Disk | undefined; - linodeId: number; - onClose: () => void; - open: boolean; -} - -export const CreateImageFromDiskDialog = (props: Props) => { - const { disk, linodeId, onClose, open } = props; - const { enqueueSnackbar } = useSnackbar(); - - const { - error, - isLoading, - mutateAsync: createImage, - reset, - } = useCreateImageMutation(); - - const { - isDiskEncryptionFeatureEnabled, - } = useIsDiskEncryptionFeatureEnabled(); - - React.useEffect(() => { - if (open) { - reset(); - } - }, [open]); - - const onCreate = async () => { - await createImage({ - disk_id: disk?.id ?? -1, - }); - enqueueSnackbar('Image scheduled for creation.', { - variant: 'info', - }); - onClose(); - }; - - const ticketDescription = error - ? `I see a notice saying "${error?.[0].reason}" when trying to create an Image from my disk ${disk?.label} (${disk?.id}).` - : `I would like to create an Image from my disk ${disk?.label} (${disk?.id}).`; - - return ( - - } - error={error?.[0].reason} - onClose={onClose} - open={open} - title={`Create Image from ${disk?.label}?`} - > - {isDiskEncryptionFeatureEnabled && ( - - )} - - Linode Images are limited to 6144 MB of data per disk by default. Please - ensure that your disk content does not exceed this size limit, or{' '} - {' '} - to request a higher limit. Additionally, Linode Images cannot be created - if you are using raw disks or disks that have been formatted using - custom filesystems. - - - ); -}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.test.tsx new file mode 100644 index 00000000000..e591146c959 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.test.tsx @@ -0,0 +1,176 @@ +import { fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { linodeDiskFactory } from 'src/factories'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +import { LinodeDiskActionMenu } from './LinodeDiskActionMenu'; + +const mockHistory = { + push: vi.fn(), +}; + +// Mock useHistory +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useHistory: vi.fn(() => mockHistory), + }; +}); + +const defaultProps = { + disk: linodeDiskFactory.build(), + linodeId: 0, + linodeStatus: 'running' as const, + onDelete: vi.fn(), + onRename: vi.fn(), + onResize: vi.fn(), +}; + +describe('LinodeActionMenu', () => { + beforeEach(() => mockMatchMedia()); + + it('should contain all basic actions when the Linode is running', async () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); + + const actionMenuButton = getByLabelText( + `Action menu for Disk ${defaultProps.disk.label}` + ); + + await userEvent.click(actionMenuButton); + + const actions = [ + 'Rename', + 'Resize', + 'Create Disk Image', + 'Clone', + 'Delete', + ]; + + for (const action of actions) { + expect(getByText(action)).toBeVisible(); + } + }); + + it('should show inline actions for md screens', async () => { + mockMatchMedia(false); + + const { getByText } = renderWithTheme( + + ); + + ['Rename', 'Resize'].forEach((action) => + expect(getByText(action)).toBeVisible() + ); + }); + + it('should hide inline actions for sm screens', async () => { + const { queryByText } = renderWithTheme( + + ); + + ['Rename', 'Resize'].forEach((action) => + expect(queryByText(action)).toBeNull() + ); + }); + + it('should allow performing actions', async () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); + + const actionMenuButton = getByLabelText( + `Action menu for Disk ${defaultProps.disk.label}` + ); + + await userEvent.click(actionMenuButton); + + await userEvent.click(getByText('Rename')); + expect(defaultProps.onRename).toHaveBeenCalled(); + + await userEvent.click(getByText('Resize')); + expect(defaultProps.onResize).toHaveBeenCalled(); + + await userEvent.click(getByText('Delete')); + expect(defaultProps.onDelete).toHaveBeenCalled(); + }); + + it('Create Disk Image should redirect to image create tab', async () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); + + const actionMenuButton = getByLabelText( + `Action menu for Disk ${defaultProps.disk.label}` + ); + + await userEvent.click(actionMenuButton); + + await userEvent.click(getByText('Create Disk Image')); + + expect(mockHistory.push).toHaveBeenCalledWith( + `/images/create/disk?selectedLinode=${defaultProps.linodeId}&selectedDisk=${defaultProps.disk.id}` + ); + }); + + it('Clone should redirect to clone page', async () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); + + const actionMenuButton = getByLabelText( + `Action menu for Disk ${defaultProps.disk.label}` + ); + + await userEvent.click(actionMenuButton); + + await userEvent.click(getByText('Clone')); + + expect(mockHistory.push).toHaveBeenCalledWith( + `/linodes/${defaultProps.linodeId}/clone/disks?selectedDisk=${defaultProps.disk.id}` + ); + }); + + it('should disable Resize and Delete when the Linode is running', async () => { + const { getAllByLabelText, getByLabelText } = renderWithTheme( + + ); + + const actionMenuButton = getByLabelText( + `Action menu for Disk ${defaultProps.disk.label}` + ); + + await userEvent.click(actionMenuButton); + + expect( + getAllByLabelText( + 'Your Linode must be fully powered down in order to perform this action' + ) + ).toHaveLength(2); + }); + + it('should disable Create Disk Image when the disk is a swap image', async () => { + const disk = linodeDiskFactory.build({ filesystem: 'swap' }); + + const { getByLabelText } = renderWithTheme( + + ); + + const actionMenuButton = getByLabelText( + `Action menu for Disk ${disk.label}` + ); + + await userEvent.click(actionMenuButton); + + const tooltip = getByLabelText( + 'You cannot create images from Swap images.' + ); + expect(tooltip).toBeInTheDocument(); + fireEvent.click(tooltip); + expect(tooltip).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx index d3a8aa5f563..c1962d68618 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx @@ -1,21 +1,23 @@ -import { Theme, useTheme } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import { splitAt } from 'ramda'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { Box } from 'src/components/Box'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { sendEvent } from 'src/utilities/analytics/utils'; +import type { Disk, Linode } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + interface Props { - diskId?: number; - label: string; - linodeId?: number; - linodeStatus: string; + disk: Disk; + linodeId: number; + linodeStatus: Linode['status']; onDelete: () => void; - onImagize: () => void; onRename: () => void; onResize: () => void; readOnly?: boolean; @@ -27,21 +29,25 @@ export const LinodeDiskActionMenu = (props: Props) => { const history = useHistory(); const { - diskId, + disk, linodeId, linodeStatus, onDelete, - onImagize, onRename, onResize, readOnly, } = props; - const tooltip = + const poweredOnTooltip = linodeStatus !== 'offline' ? 'Your Linode must be fully powered down in order to perform this action' : undefined; + const swapTooltip = + disk.filesystem == 'swap' + ? 'You cannot create images from Swap images.' + : undefined; + const actions: Action[] = [ { disabled: readOnly, @@ -52,17 +58,23 @@ export const LinodeDiskActionMenu = (props: Props) => { disabled: linodeStatus !== 'offline' || readOnly, onClick: onResize, title: 'Resize', - tooltip, + tooltip: poweredOnTooltip, }, { - disabled: readOnly, - onClick: onImagize, - title: 'Imagize', + disabled: readOnly || !!swapTooltip, + onClick: () => + history.push( + `/images/create/disk?selectedLinode=${linodeId}&selectedDisk=${disk.id}` + ), + title: 'Create Disk Image', + tooltip: swapTooltip, }, { disabled: readOnly, onClick: () => { - history.push(`/linodes/${linodeId}/clone/disks?selectedDisk=${diskId}`); + history.push( + `/linodes/${linodeId}/clone/disks?selectedDisk=${disk.id}` + ); }, title: 'Clone', }, @@ -70,7 +82,7 @@ export const LinodeDiskActionMenu = (props: Props) => { disabled: linodeStatus !== 'offline' || readOnly, onClick: onDelete, title: 'Delete', - tooltip, + tooltip: poweredOnTooltip, }, ]; @@ -101,7 +113,7 @@ export const LinodeDiskActionMenu = (props: Props) => { ))} ); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx index f68bfa0d38b..b93f6090e31 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx @@ -1,4 +1,3 @@ -import { Disk } from '@linode/api-v4/lib/linodes'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; @@ -11,12 +10,13 @@ import { useInProgressEvents } from 'src/queries/events/events'; import { LinodeDiskActionMenu } from './LinodeDiskActionMenu'; +import type { Disk, Linode } from '@linode/api-v4'; + interface Props { disk: Disk; - linodeId?: number; - linodeStatus: string; + linodeId: number; + linodeStatus: Linode['status']; onDelete: () => void; - onImagize: () => void; onRename: () => void; onResize: () => void; readOnly: boolean; @@ -30,7 +30,6 @@ export const LinodeDiskRow = React.memo((props: Props) => { linodeId, linodeStatus, onDelete, - onImagize, onRename, onResize, readOnly, @@ -86,12 +85,10 @@ export const LinodeDiskRow = React.memo((props: Props) => { { const disksHeaderRef = React.useRef(null); @@ -48,7 +48,6 @@ export const LinodeDisks = () => { const [isCreateDrawerOpen, setIsCreateDrawerOpen] = React.useState(false); const [isRenameDrawerOpen, setIsRenameDrawerOpen] = React.useState(false); const [isResizeDrawerOpen, setIsResizeDrawerOpen] = React.useState(false); - const [isImageDialogOpen, setIsImageDialogOpen] = React.useState(false); const [selectedDiskId, setSelectedDiskId] = React.useState(); const selectedDisk = disks?.find((d) => d.id === selectedDiskId); @@ -81,11 +80,6 @@ export const LinodeDisks = () => { setIsDeleteDialogOpen(true); }; - const onImagize = (disk: Disk) => { - setSelectedDiskId(disk.id); - setIsImageDialogOpen(true); - }; - const renderTableContent = (disks: Disk[] | undefined) => { if (error) { return ; @@ -106,7 +100,6 @@ export const LinodeDisks = () => { linodeId={id} linodeStatus={linode?.status ?? 'offline'} onDelete={() => onDelete(disk)} - onImagize={() => onImagize(disk)} onRename={() => onRename(disk)} onResize={() => onResize(disk)} readOnly={readOnly} @@ -247,12 +240,6 @@ export const LinodeDisks = () => { onClose={() => setIsResizeDrawerOpen(false)} open={isResizeDrawerOpen} /> - setIsImageDialogOpen(false)} - open={isImageDialogOpen} - /> ); }; diff --git a/packages/manager/src/features/Linodes/LinodesLanding/utils.test.ts b/packages/manager/src/features/Linodes/LinodesLanding/utils.test.ts index bf1a98e828d..043894100ed 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/utils.test.ts +++ b/packages/manager/src/features/Linodes/LinodesLanding/utils.test.ts @@ -1,10 +1,11 @@ -import { parseMaintenanceStartTime, getVPCsFromLinodeConfigs } from './utils'; import { - configFactory, LinodeConfigInterfaceFactory, LinodeConfigInterfaceFactoryWithVPC, + configFactory, } from 'src/factories'; +import { getVPCsFromLinodeConfigs, parseMaintenanceStartTime } from './utils'; + describe('Linode Landing Utilites', () => { it('should return "Maintenance Window Unknown" for invalid dates', () => { expect(parseMaintenanceStartTime('inVALid DATE')).toBe( @@ -52,18 +53,18 @@ describe('Linode Landing Utilites', () => { ...configFactory.buildList(3), config, ]); - expect(vpcIds).toEqual([2, 3]); + expect(vpcIds).toEqual([3, 4]); }); it('returns unique vpc ids (no duplicates)', () => { const vpcInterface = LinodeConfigInterfaceFactoryWithVPC.build({ - vpc_id: 2, + vpc_id: 3, }); const config = configFactory.build({ interfaces: [...vpcInterfaceList, vpcInterface], }); const vpcIds = getVPCsFromLinodeConfigs([config]); - expect(vpcIds).toEqual([2, 3]); + expect(vpcIds).toEqual([3, 4]); }); }); }); diff --git a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx index 607a1bb09a0..da0a2bae841 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx @@ -6,6 +6,7 @@ import { Notice } from 'src/components/Notice/Notice'; import { PlacementGroupsSelect } from 'src/components/PlacementGroupsSelect/PlacementGroupsSelect'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { sxDistributedRegionIcon } from 'src/components/RegionSelect/RegionSelect.styles'; +import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { NO_PLACEMENT_GROUPS_IN_SELECTED_REGION_MESSAGE } from 'src/features/PlacementGroups/constants'; @@ -148,6 +149,8 @@ export const ConfigureForm = React.memo((props: Props) => { currentActualRegion?.site_type === 'distributed' || currentActualRegion?.site_type === 'edge'; + const { isGeckoBetaEnabled } = useIsGeckoEnabled(); + return ( Configure Migration @@ -159,7 +162,7 @@ export const ConfigureForm = React.memo((props: Props) => { {`${getRegionCountryGroup(currentActualRegion)}: ${ currentActualRegion?.label ?? currentRegion }`} - {linodeIsInDistributedRegion && ( + {isGeckoBetaEnabled && linodeIsInDistributedRegion && ( } status="other" diff --git a/packages/manager/src/features/Linodes/index.tsx b/packages/manager/src/features/Linodes/index.tsx index 9d8c7a7312a..db48cf3d1e3 100644 --- a/packages/manager/src/features/Linodes/index.tsx +++ b/packages/manager/src/features/Linodes/index.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import React, { useState } from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; @@ -25,13 +25,16 @@ const LinodesCreatev2 = React.lazy(() => const LinodesRoutes = () => { const flags = useFlags(); + + // Hold this feature flag in state so that the user's Linode creation + // isn't interupted when the flag is toggled. + const [isLinodeCreateV2Enabled] = useState(flags.linodeCreateRefactor); + return ( }> diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/EndpointHealth.test.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/EndpointHealth.test.tsx index b0274db2eff..b5aca9a2eba 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/EndpointHealth.test.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/EndpointHealth.test.tsx @@ -11,6 +11,7 @@ describe('EndpointHealth', () => { expect(getByText('0 up')).toBeVisible(); expect(getByText('0 down')).toBeVisible(); }); + it('renders endpoints that are up and down', () => { const { getByLabelText, getByText } = renderWithTheme( , @@ -20,12 +21,13 @@ describe('EndpointHealth', () => { const upStatusIcon = getByLabelText('Status is active'); const downStatusIcon = getByLabelText('Status is error'); - expect(upStatusIcon).toHaveStyle({ backgroundColor: '#17cf73' }); - expect(downStatusIcon).toHaveStyle({ backgroundColor: '#ca0813' }); + expect(upStatusIcon).toHaveStyle({ backgroundColor: 'rgba(23, 207, 115)' }); + expect(downStatusIcon).toHaveStyle({ backgroundColor: 'rgbs(202, 8, 19)' }); expect(getByText('18 up')).toBeVisible(); expect(getByText('6 down')).toBeVisible(); }); + it('should render gray when the "down" number is zero', () => { const { getByLabelText, getByText } = renderWithTheme( @@ -34,9 +36,10 @@ describe('EndpointHealth', () => { const statusIcon = getByLabelText('Status is inactive'); expect(statusIcon).toBeVisible(); - expect(statusIcon).toHaveStyle({ backgroundColor: '#dbdde1' }); + expect(statusIcon).toHaveStyle({ backgroundColor: 'rgba(219, 221, 225)' }); expect(getByText('0 down')).toBeVisible(); }); + it('should render gray when the "up" number is zero', () => { const { getByLabelText, getByText } = renderWithTheme( @@ -45,7 +48,7 @@ describe('EndpointHealth', () => { const statusIcon = getByLabelText('Status is inactive'); expect(statusIcon).toBeVisible(); - expect(statusIcon).toHaveStyle({ backgroundColor: '#dbdde1' }); + expect(statusIcon).toHaveStyle({ backgroundColor: 'rgba(219, 221, 225)' }); expect(getByText('0 up')).toBeVisible(); }); }); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/EndpointTable.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/EndpointTable.tsx index b41ef639fa9..b783906b503 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/EndpointTable.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/EndpointTable.tsx @@ -65,17 +65,17 @@ export const EndpointTable = (props: Props) => { {linode?.label ?? endpoint.ip}:{endpoint.port} {fieldErrors.ip && ( - theme.palette.error.main}> + theme.palette.error.dark}> {fieldErrors.ip} )} {fieldErrors.port && ( - theme.palette.error.main}> + theme.palette.error.dark}> {fieldErrors.port} )} {fieldErrors.rate_capacity && ( - theme.palette.error.main}> + theme.palette.error.dark}> {fieldErrors.rate_capacity} )} @@ -83,7 +83,7 @@ export const EndpointTable = (props: Props) => { {endpoint.host} {fieldErrors.host && ( - theme.palette.error.main}> + theme.palette.error.dark}> {fieldErrors.host} )} diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.test.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.test.tsx index 1ff3d36970f..0035b1657b2 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.test.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.test.tsx @@ -1,4 +1,3 @@ -import { LongviewClient } from '@linode/api-v4/lib/longview'; import { fireEvent, waitFor } from '@testing-library/react'; import * as React from 'react'; @@ -11,13 +10,15 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { LongviewClients, - LongviewClientsCombinedProps, filterLongviewClientsByQuery, sortClientsBy, sortFunc, } from './LongviewClients'; import { LongviewLanding } from './LongviewLanding'; +import type { LongviewClientsCombinedProps } from './LongviewClients'; +import type { LongviewClient } from '@linode/api-v4/lib/longview'; + afterEach(() => { vi.clearAllMocks(); }); @@ -54,7 +55,7 @@ const props: LongviewClientsCombinedProps = { describe('Utility Functions', () => { it('should properly filter longview clients by query', () => { expect(filterLongviewClientsByQuery('client-1', clients, {})).toEqual([ - clients[1], + clients[0], ]), expect(filterLongviewClientsByQuery('client', clients, {})).toEqual( clients diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index 502d9f7e933..92c38f87ae5 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -1,5 +1,4 @@ import { useTheme } from '@mui/material'; -import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import { append, @@ -19,18 +18,25 @@ import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { CheckoutSummary } from 'src/components/CheckoutSummary/CheckoutSummary'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { DocsLink } from 'src/components/DocsLink/DocsLink'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { SelectFirewallPanel } from 'src/components/SelectFirewallPanel/SelectFirewallPanel'; -import { SelectRegionPanel } from 'src/components/SelectRegionPanel/SelectRegionPanel'; -import { Tag, TagsInput } from 'src/components/TagsInput/TagsInput'; +import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; +import { Stack } from 'src/components/Stack'; +import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { FIREWALL_GET_STARTED_LINK } from 'src/constants'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { + StyledDocsLinkContainer, + StyledRegionSelectStack, +} from 'src/features/Kubernetes/CreateCluster/CreateCluster.styles'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { reportAgreementSigningError, @@ -48,6 +54,7 @@ import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getGDPRDetails } from 'src/utilities/formatRegion'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; import { PRICE_ERROR_TOOLTIP_TEXT } from 'src/utilities/pricing/constants'; +import { DOCS_LINK_LABEL_DC_PRICING } from 'src/utilities/pricing/constants'; import { getDCSpecificPriceByType, renderMonthlyPriceToCorrectDecimalPlace, @@ -64,6 +71,8 @@ import { import type { NodeBalancerConfigFieldsWithStatus } from './types'; import type { APIError } from '@linode/api-v4/lib/types'; +import type { Theme } from '@mui/material/styles'; +import type { Tag } from 'src/components/TagsInput/TagsInput'; interface NodeBalancerConfigFieldsWithStatusAndErrors extends NodeBalancerConfigFieldsWithStatus { @@ -508,14 +517,29 @@ const NodeBalancerCreate = () => { onChange={tagsChange} tagError={hasErrorFor('tags')} /> + + + , + helperTextPosition: 'top', + }} + currentCapability="NodeBalancers" + disableClearable + errorText={hasErrorFor('region')} + onChange={(e, region) => regionChange(region?.id ?? '')} + regions={regions ?? []} + value={nodeBalancerFields.region ?? ''} + /> + + + + + - { setNodeBalancerFields((prev) => ({ diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx index 9da07d66e95..1fba29828e2 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx @@ -23,40 +23,42 @@ const defaultUnwantedEvents: EventAction[] = [ 'volume_update', ]; -export const useEventNotifications = (givenEvents?: Event[]) => { - const events = removeBlocklistedEvents( - givenEvents ?? useEventsInfiniteQuery().events - ); +export const useEventNotifications = (): NotificationItem[] => { + const { events: fetchedEvents } = useEventsInfiniteQuery(); + const relevantEvents = removeBlocklistedEvents(fetchedEvents); const { isTaxIdEnabled } = useIsTaxIdEnabled(); const notificationContext = React.useContext(_notificationContext); // TODO: TaxId - This entire function can be removed when we cleanup tax id feature flags - const unwantedEvents = React.useMemo(() => { - const events = [...defaultUnwantedEvents]; + const unwantedEventTypes = React.useMemo(() => { + const eventTypes = [...defaultUnwantedEvents]; if (!isTaxIdEnabled) { - events.push('tax_id_invalid'); + eventTypes.push('tax_id_invalid'); } - return events; + return eventTypes; }, [isTaxIdEnabled]); - const _events = events.filter( - (thisEvent) => !unwantedEvents.includes(thisEvent.action) + const filteredEvents = relevantEvents.filter( + (event) => !unwantedEventTypes.includes(event.action) ); - const [inProgress, completed] = partition(isInProgressEvent, _events); + const [inProgressEvents, completedEvents] = partition( + isInProgressEvent, + filteredEvents + ); - const allEvents = [ - ...inProgress.map((thisEvent) => - formatProgressEventForDisplay(thisEvent, notificationContext.closeMenu) + const allNotificationItems = [ + ...inProgressEvents.map((event) => + formatProgressEventForDisplay(event, notificationContext.closeMenu) ), - ...completed.map((thisEvent) => - formatEventForDisplay(thisEvent, notificationContext.closeMenu) + ...completedEvents.map((event) => + formatEventForDisplay(event, notificationContext.closeMenu) ), ]; - return allEvents.filter((thisAction) => - Boolean(thisAction.body) - ) as NotificationItem[]; + return allNotificationItems.filter((notification) => + Boolean(notification.body) + ); }; const formatEventForDisplay = ( diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.test.tsx index ded2bc7bfaf..cc46045d6c4 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.test.tsx @@ -73,7 +73,7 @@ describe('ObjectStorageLanding', () => { // Mock Buckets server.use( http.get( - '*/object-storage/buckets/cluster-0', + '*/object-storage/buckets/cluster-1', () => { return HttpResponse.json([{ reason: 'Cluster offline!' }], { status: 500, diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx index cfaef030495..167f6fc107a 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx @@ -10,6 +10,8 @@ import { Divider } from 'src/components/Divider'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { getNewRegionLabel } from 'src/components/RegionSelect/RegionSelect.utils'; +import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { Stack } from 'src/components/Stack'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; @@ -137,6 +139,8 @@ export const PlacementGroupsCreateDrawer = ( selectedRegion )}`; + const { isGeckoGAEnabled } = useIsGeckoEnabled(); + const disabledRegions = regions?.reduce>( (acc, region) => { const isRegionAtCapacity = hasRegionReachedPlacementGroupCapacity({ @@ -188,7 +192,12 @@ export const PlacementGroupsCreateDrawer = ( { ); const isPlacementGroupSelectDisabled = !selectedRegionId || !hasRegionPlacementGroupCapability; + const { isGeckoGAEnabled } = useIsGeckoEnabled(); const placementGroupSelectLabel = selectedRegion - ? `Placement Groups in ${selectedRegion.label} (${selectedRegion.id})` + ? `Placement Groups in ${ + isGeckoGAEnabled + ? getNewRegionLabel({ + includeSlug: true, + region: selectedRegion, + }) + : `${selectedRegion.label} (${selectedRegion.id})` + }` : 'Placement Group'; return ( diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx index 8e289b32aed..d84a2b78b46 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; -import { regionFactory } from 'src/factories'; -import { linodeFactory } from 'src/factories'; -import { placementGroupFactory } from 'src/factories'; +import { + linodeFactory, + placementGroupFactory, + regionFactory, +} from 'src/factories'; import { renderWithTheme, resizeScreenSize, @@ -14,41 +16,44 @@ import { PlacementGroupsRow } from './PlacementGroupsRow'; const handleDeletePlacementGroupMock = vi.fn(); const handleEditPlacementGroupMock = vi.fn(); +const linode = linodeFactory.build({ + label: 'linode-1', + region: 'us-east', +}); + +const placementGroup = placementGroupFactory.build({ + affinity_type: 'anti_affinity:local', + id: 1, + is_compliant: false, + label: 'group 1', + members: [ + { + is_compliant: true, + linode_id: 1, + }, + ], + region: 'us-east', +}); + +const region = regionFactory.build({ + country: 'us', + id: 'us-east', + label: 'Newark, NJ', + status: 'ok', +}); + describe('PlacementGroupsRow', () => { it('renders the columns with proper data', () => { resizeScreenSize(1200); - const { getByRole, getByTestId, getByText } = renderWithTheme( wrapWithTableBody( ) ); diff --git a/packages/manager/src/features/StackScripts/StackScriptForm/StackScriptForm.styles.ts b/packages/manager/src/features/StackScripts/StackScriptForm/StackScriptForm.styles.ts index 44e0b04283c..296fda0b312 100644 --- a/packages/manager/src/features/StackScripts/StackScriptForm/StackScriptForm.styles.ts +++ b/packages/manager/src/features/StackScripts/StackScriptForm/StackScriptForm.styles.ts @@ -32,7 +32,7 @@ export const StyledTextField = styled(TextField, { label: 'StyledTextField' })({ export const StyledNotice = styled(Notice, { label: 'StyledNotice' })( ({ theme }) => ({ - backgroundColor: theme.palette.divider, + backgroundColor: theme.palette.background.default, marginLeft: theme.spacing(4), marginTop: `${theme.spacing(4)} !important`, padding: theme.spacing(4), diff --git a/packages/manager/src/features/Support/SupportTicketDetail/SeverityChip.tsx b/packages/manager/src/features/Support/SupportTicketDetail/SeverityChip.tsx index 570b6a891f5..8cc7fd08521 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/SeverityChip.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/SeverityChip.tsx @@ -1,9 +1,11 @@ -import { TicketSeverity } from '@linode/api-v4'; import React from 'react'; -import { Chip, ChipProps } from 'src/components/Chip'; +import { Chip } from 'src/components/Chip'; -import { severityLabelMap } from '../SupportTickets/ticketUtils'; +import { SEVERITY_LABEL_MAP } from '../SupportTickets/constants'; + +import type { TicketSeverity } from '@linode/api-v4'; +import type { ChipProps } from 'src/components/Chip'; const severityColorMap: Record = { 1: 'error', @@ -14,7 +16,7 @@ const severityColorMap: Record = { export const SeverityChip = ({ severity }: { severity: TicketSeverity }) => ( ({ padding: theme.spacing() })} /> ); diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx index b560a34ac98..c882264048d 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx @@ -1,8 +1,8 @@ -import { TicketSeverity, uploadAttachment } from '@linode/api-v4/lib/support'; -import { APIError } from '@linode/api-v4/lib/types'; -import { Theme } from '@mui/material/styles'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { uploadAttachment } from '@linode/api-v4/lib/support'; import { update } from 'ramda'; import * as React from 'react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; import { debounce } from 'throttle-debounce'; import { makeStyles } from 'tss-react/mui'; @@ -10,42 +10,36 @@ import { Accordion } from 'src/components/Accordion'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Dialog } from 'src/components/Dialog/Dialog'; -import { FormHelperText } from 'src/components/FormHelperText'; -import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; -import { EntityForTicketDetails } from 'src/components/SupportLink/SupportLink'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; -import { useAccount } from 'src/queries/account/account'; -import { useAllDatabasesQuery } from 'src/queries/databases/databases'; -import { useAllDomainsQuery } from 'src/queries/domains'; -import { useAllFirewallsQuery } from 'src/queries/firewalls'; -import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; -import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; import { useCreateSupportTicketMutation } from 'src/queries/support'; -import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; -import { - getAPIErrorOrDefault, - getErrorMap, - getErrorStringOrDefault, -} from 'src/utilities/errorUtils'; +import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { reduceAsync } from 'src/utilities/reduceAsync'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import { storage } from 'src/utilities/storage'; import { AttachFileForm } from '../AttachFileForm'; -import { FileAttachment } from '../index'; -import { AttachmentError } from '../SupportTicketDetail/SupportTicketDetail'; import { MarkdownReference } from '../SupportTicketDetail/TabbedReply/MarkdownReference'; import { TabbedReply } from '../SupportTicketDetail/TabbedReply/TabbedReply'; -import { TICKET_SEVERITY_TOOLTIP_TEXT } from './constants'; -import SupportTicketSMTPFields, { - fieldNameToLabelMap, - smtpDialogTitle, - smtpHelperText, -} from './SupportTicketSMTPFields'; -import { severityLabelMap, useTicketSeverityCapability } from './ticketUtils'; +import { + ENTITY_ID_TO_NAME_MAP, + SCHEMA_MAP, + SEVERITY_LABEL_MAP, + SEVERITY_OPTIONS, + TICKET_SEVERITY_TOOLTIP_TEXT, + TICKET_TYPE_MAP, +} from './constants'; +import { SupportTicketProductSelectionFields } from './SupportTicketProductSelectionFields'; +import { SupportTicketSMTPFields } from './SupportTicketSMTPFields'; +import { formatDescription, useTicketSeverityCapability } from './ticketUtils'; + +import type { FileAttachment } from '../index'; +import type { AttachmentError } from '../SupportTicketDetail/SupportTicketDetail'; +import type { SMTPCustomFields } from './SupportTicketSMTPFields'; +import type { TicketSeverity } from '@linode/api-v4/lib/support'; +import type { Theme } from '@mui/material/styles'; +import type { EntityForTicketDetails } from 'src/components/SupportLink/SupportLink'; const useStyles = makeStyles()((theme: Theme) => ({ expPanelSummary: { @@ -63,6 +57,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ rootReply: { marginBottom: theme.spacing(2), padding: 0, + marginTop: theme.spacing(2), }, })); @@ -89,7 +84,10 @@ export type EntityType = export type TicketType = 'general' | 'smtp'; -interface TicketTypeData { +export type AllSupportTicketFormFields = SupportTicketFormFields & + SMTPCustomFields; + +export interface TicketTypeData { dialogTitle: string; helperText: JSX.Element | string; } @@ -107,53 +105,15 @@ export interface SupportTicketDialogProps { prefilledTitle?: string; } -const ticketTypeMap: Record = { - general: { - dialogTitle: 'Open a Support Ticket', - helperText: ( - <> - {`We love our customers, and we\u{2019}re here to help if you need us. - Please keep in mind that not all topics are within the scope of our support. - For overall system status, please see `} - status.linode.com. - - ), - }, - smtp: { - dialogTitle: smtpDialogTitle, - helperText: smtpHelperText, - }, -}; - -const entityMap: Record = { - Databases: 'database_id', - Domains: 'domain_id', - Firewalls: 'firewall_id', - Kubernetes: 'lkecluster_id', - Linodes: 'linode_id', - NodeBalancers: 'nodebalancer_id', - Volumes: 'volume_id', -}; - -const entityIdToNameMap: Record = { - database_id: 'Database Cluster', - domain_id: 'Domain', - firewall_id: 'Firewall', - general: '', - linode_id: 'Linode', - lkecluster_id: 'Kubernetes Cluster', - nodebalancer_id: 'NodeBalancer', - none: '', - volume_id: 'Volume', -}; - -const severityOptions: { - label: string; - value: TicketSeverity; -}[] = Array.from(severityLabelMap).map(([severity, label]) => ({ - label, - value: severity, -})); +export interface SupportTicketFormFields { + description: string; + entityId: string; + entityInputValue: string; + entityType: EntityType; + selectedSeverity: TicketSeverity | undefined; + summary: string; + ticketType: TicketType; +} export const entitiesToItems = (type: string, entities: any) => { return entities.map((entity: any) => { @@ -180,48 +140,41 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { prefilledTitle, } = props; - const { data: account } = useAccount(); + const formContainerRef = React.useRef(null); const hasSeverityCapability = useTicketSeverityCapability(); const valuesFromStorage = storage.supportText.get(); // Ticket information - const [summary, setSummary] = React.useState( - getInitialValue(prefilledTitle, valuesFromStorage.title) - ); - const [ - selectedSeverity, - setSelectedSeverity, - ] = React.useState(); - const [description, setDescription] = React.useState( - getInitialValue(prefilledDescription, valuesFromStorage.description) - ); - const [entityType, setEntityType] = React.useState( - prefilledEntity?.type ?? 'general' - ); - const [entityInputValue, setEntityInputValue] = React.useState(''); - const [entityID, setEntityID] = React.useState( - prefilledEntity ? String(prefilledEntity.id) : '' - ); - const [ticketType, setTicketType] = React.useState( - prefilledTicketType ?? 'general' - ); - - // SMTP ticket information - const [smtpFields, setSMTPFields] = React.useState({ - companyName: '', - customerName: account ? `${account?.first_name} ${account?.last_name}` : '', - emailDomains: '', - publicInfo: '', - useCase: '', + const form = useForm({ + defaultValues: { + description: getInitialValue( + prefilledDescription, + valuesFromStorage.description + ), + entityId: prefilledEntity ? String(prefilledEntity.id) : '', + entityInputValue: '', + entityType: prefilledEntity?.type ?? 'general', + summary: getInitialValue(prefilledTitle, valuesFromStorage.title), + ticketType: prefilledTicketType ?? 'general', + }, + resolver: yupResolver(SCHEMA_MAP[prefilledTicketType ?? 'general']), }); + const { + description, + entityId, + entityType, + selectedSeverity, + summary, + ticketType, + } = form.watch(); + const { mutateAsync: createSupportTicket } = useCreateSupportTicketMutation(); const [files, setFiles] = React.useState([]); - const [errors, setErrors] = React.useState(); const [submitting, setSubmitting] = React.useState(false); const { classes } = useStyles(); @@ -232,48 +185,6 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { } }, [open]); - // React Query entities - const { - data: databases, - error: databasesError, - isLoading: databasesLoading, - } = useAllDatabasesQuery(entityType === 'database_id'); - - const { - data: firewalls, - error: firewallsError, - isLoading: firewallsLoading, - } = useAllFirewallsQuery(entityType === 'firewall_id'); - - const { - data: domains, - error: domainsError, - isLoading: domainsLoading, - } = useAllDomainsQuery(entityType === 'domain_id'); - const { - data: nodebalancers, - error: nodebalancersError, - isLoading: nodebalancersLoading, - } = useAllNodeBalancersQuery(entityType === 'nodebalancer_id'); - - const { - data: clusters, - error: clustersError, - isLoading: clustersLoading, - } = useAllKubernetesClustersQuery(entityType === 'lkecluster_id'); - - const { - data: linodes, - error: linodesError, - isLoading: linodesLoading, - } = useAllLinodesQuery({}, {}, entityType === 'linode_id'); - - const { - data: volumes, - error: volumesError, - isLoading: volumesLoading, - } = useAllVolumesQuery({}, {}, entityType === 'volume_id'); - const saveText = (_title: string, _description: string) => { storage.supportText.set({ description: _description, title: _title }); }; @@ -286,22 +197,19 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { debouncedSave(summary, description); }, [summary, description]); + /** + * Clear the drawer completely if clearValues is passed (when canceling out of the drawer or successfully submitting) + * or reset to the default values (from localStorage) otherwise. + */ const resetTicket = (clearValues: boolean = false) => { - /** - * Clear the drawer completely if clearValues is passed (as in when closing the drawer) - * or reset to the default values (from props or localStorage) otherwise. - */ - const _summary = clearValues - ? '' - : getInitialValue(prefilledTitle, valuesFromStorage.title); - const _description = clearValues - ? '' - : getInitialValue(prefilledDescription, valuesFromStorage.description); - setSummary(_summary); - setDescription(_description); - setEntityID(''); - setEntityType('general'); - setTicketType('general'); + form.reset({ + ...form.formState.defaultValues, + description: clearValues ? '' : valuesFromStorage.description, + entityId: '', + entityType: 'general', + summary: clearValues ? '' : valuesFromStorage.title, + ticketType: 'general', + }); }; const resetDrawer = (clearValues: boolean = false) => { @@ -313,51 +221,14 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { } }; - const handleSummaryInputChange = (e: React.ChangeEvent) => { - setSummary(e.target.value); - }; - - const handleDescriptionInputChange = (value: string) => { - setDescription(value); - // setErrors? - }; - - const handleEntityTypeChange = (type: EntityType) => { - // Don't reset things if the type hasn't changed - if (type === entityType) { - return; - } - setEntityType(type); - setEntityID(''); - setEntityInputValue(''); - }; - - const handleSMTPFieldChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setSMTPFields((smtpFields) => ({ ...smtpFields, [name]: value })); - }; - - /** - * When variant ticketTypes include additional fields, fields must concat to one description string. - * For readability, replace field names with field labels and format the description in Markdown. - */ - const formatDescription = (fields: Record) => { - return Object.entries(fields) - .map( - ([key, value]) => - `**${fieldNameToLabelMap[key]}**\n${value ? value : 'No response'}` - ) - .join('\n\n'); - }; - - const close = () => { + const handleClose = () => { props.onClose(); if (ticketType === 'smtp') { window.setTimeout(() => resetDrawer(true), 500); } }; - const onCancel = () => { + const handleCancel = () => { props.onClose(); window.setTimeout(() => resetDrawer(true), 500); }; @@ -430,38 +301,35 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { }); }; - const onSubmit = () => { + const handleSubmit = form.handleSubmit(async (values) => { const { onSuccess } = props; - const _description = - ticketType === 'smtp' ? formatDescription(smtpFields) : description; - if (!['general', 'none'].includes(entityType) && !entityID) { - setErrors([ - { - field: 'input', - reason: `Please select a ${entityIdToNameMap[entityType]}.`, - }, - ]); + + const _description = formatDescription(values, ticketType); + + if (!['general', 'none'].includes(entityType) && !entityId) { + form.setError('entityId', { + message: `Please select a ${ENTITY_ID_TO_NAME_MAP[entityType]}.`, + }); + return; } - setErrors(undefined); setSubmitting(true); createSupportTicket({ description: _description, - [entityType]: Number(entityID), + [entityType]: Number(entityId), severity: selectedSeverity, summary, }) .then((response) => { - setErrors(undefined); - setSubmitting(false); - window.setTimeout(() => resetDrawer(true), 500); return response; }) .then((response) => { attachFiles(response!.id).then(({ errors: _errors }: Accumulator) => { + setSubmitting(false); if (!props.keepOpenOnSuccess) { - close(); + window.setTimeout(() => resetDrawer(true), 500); + props.onClose(); } /* Errors will be an array of errors, or empty if all attachments succeeded. */ onSuccess(response!.id, _errors); @@ -470,113 +338,21 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { .catch((errResponse) => { /* This block will only handle errors in creating the actual ticket; attachment * errors are handled above. */ - setErrors(getAPIErrorOrDefault(errResponse)); + for (const error of errResponse) { + if (error.field) { + form.setError(error.field, { message: error.reason }); + } else { + form.setError('root', { message: error.reason }); + } + } + setSubmitting(false); - scrollErrorIntoView(); + scrollErrorIntoViewV2(formContainerRef); }); - }; - - const renderEntityTypes = () => { - return Object.keys(entityMap).map((key: string) => { - return { label: key, value: entityMap[key] }; - }); - }; - - const smtpRequirementsMet = - smtpFields.customerName.length > 0 && - smtpFields.useCase.length > 0 && - smtpFields.emailDomains.length > 0 && - smtpFields.publicInfo.length > 0; - const requirementsMet = - summary.length > 0 && - (ticketType === 'smtp' ? smtpRequirementsMet : description.length > 0); - - const hasErrorFor = getErrorMap(['summary', 'description', 'input'], errors); - const summaryError = hasErrorFor.summary; - const descriptionError = hasErrorFor.description; - const generalError = hasErrorFor.none; - const inputError = hasErrorFor.input; - - const topicOptions: { label: string; value: EntityType }[] = [ - { label: 'General/Account/Billing', value: 'general' }, - ...renderEntityTypes(), - ]; - - const selectedTopic = topicOptions.find((eachTopic) => { - return eachTopic.value === entityType; }); - const getEntityOptions = (): { label: string; value: number }[] => { - const reactQueryEntityDataMap = { - database_id: databases, - domain_id: domains, - firewall_id: firewalls, - linode_id: linodes, - lkecluster_id: clusters, - nodebalancer_id: nodebalancers, - volume_id: volumes, - }; - - if (!reactQueryEntityDataMap[entityType]) { - return []; - } - - // domain's don't have a label so we map the domain as the label - if (entityType === 'domain_id') { - return ( - reactQueryEntityDataMap[entityType]?.map(({ domain, id }) => ({ - label: domain, - value: id, - })) || [] - ); - } - - return ( - reactQueryEntityDataMap[entityType]?.map( - ({ id, label }: { id: number; label: string }) => ({ - label, - value: id, - }) - ) || [] - ); - }; - - const loadingMap: Record = { - database_id: databasesLoading, - domain_id: domainsLoading, - firewall_id: firewallsLoading, - general: false, - linode_id: linodesLoading, - lkecluster_id: clustersLoading, - nodebalancer_id: nodebalancersLoading, - none: false, - volume_id: volumesLoading, - }; - - const errorMap: Record = { - database_id: databasesError, - domain_id: domainsError, - firewall_id: firewallsError, - general: null, - linode_id: linodesError, - lkecluster_id: clustersError, - nodebalancer_id: nodebalancersError, - none: null, - volume_id: volumesError, - }; - - const entityOptions = getEntityOptions(); - const areEntitiesLoading = loadingMap[entityType]; - const entityError = Boolean(errorMap[entityType]) - ? `Error loading ${entityIdToNameMap[entityType]}s` - : undefined; - - const selectedEntity = - entityOptions.find((thisEntity) => String(thisEntity.value) === entityID) || - null; - const selectedSeverityLabel = - selectedSeverity && severityLabelMap.get(selectedSeverity); + selectedSeverity && SEVERITY_LABEL_MAP.get(selectedSeverity); const selectedSeverityOption = selectedSeverity != undefined && selectedSeverityLabel != undefined ? { @@ -586,106 +362,94 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { : undefined; return ( - - {props.children || ( - - {generalError && ( - - )} + +
+ + {props.children || ( + <> + {form.formState.errors.root && ( + + )} - - {ticketTypeMap[ticketType].helperText} - - - {hasSeverityCapability && ( - - setSelectedSeverity( - severity != null ? severity.value : undefined - ) - } - textFieldProps={{ - tooltipPosition: 'right', - tooltipText: TICKET_SEVERITY_TOOLTIP_TEXT, - }} - autoHighlight - clearOnBlur - data-qa-ticket-severity - label="Severity" - options={severityOptions} - sx={{ maxWidth: 'initial' }} - value={selectedSeverityOption ?? null} - /> + + {TICKET_TYPE_MAP[ticketType].helperText} + + ( + + )} + control={form.control} + name="summary" + /> + {hasSeverityCapability && ( + ( + + field.onChange( + severity != null ? severity.value : undefined + ) + } + textFieldProps={{ + tooltipPosition: 'right', + tooltipText: TICKET_SEVERITY_TOOLTIP_TEXT, + }} + autoHighlight + data-qa-ticket-severity + label="Severity" + options={SEVERITY_OPTIONS} + sx={{ maxWidth: 'initial' }} + value={selectedSeverityOption ?? null} + /> + )} + control={form.control} + name="selectedSeverity" + /> + )} + )} {ticketType === 'smtp' ? ( - + ) : ( - + <> {props.hideProductSelection ? null : ( - - handleEntityTypeChange(type.value)} - options={topicOptions} - value={selectedTopic} - /> - {!['general', 'none'].includes(entityType) && ( - <> - - setEntityID(id ? String(id?.value) : '') - } - data-qa-ticket-entity-id - disabled={entityOptions.length === 0} - errorText={entityError || inputError} - inputValue={entityInputValue} - label={entityIdToNameMap[entityType] ?? 'Entity Select'} - loading={areEntitiesLoading} - onInputChange={(e, value) => setEntityInputValue(value)} - options={entityOptions} - placeholder={`Select a ${entityIdToNameMap[entityType]}`} - value={selectedEntity} - /> - {!areEntitiesLoading && entityOptions.length === 0 ? ( - - You don’t have any{' '} - {entityIdToNameMap[entityType]}s on your account. - - ) : null} - - )} - + )} - ( + + )} + control={form.control} + name="description" /> { - + )} -
- )} -
+ + + ); }; diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx new file mode 100644 index 00000000000..57bebb30a6c --- /dev/null +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { FormHelperText } from 'src/components/FormHelperText'; +import { useAllDatabasesQuery } from 'src/queries/databases/databases'; +import { useAllDomainsQuery } from 'src/queries/domains'; +import { useAllFirewallsQuery } from 'src/queries/firewalls'; +import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; +import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; +import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; +import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; + +import { ENTITY_ID_TO_NAME_MAP, ENTITY_MAP } from './constants'; + +import type { + EntityType, + SupportTicketFormFields, +} from './SupportTicketDialog'; +import type { APIError } from '@linode/api-v4'; + +export const SupportTicketProductSelectionFields = () => { + const { + control, + setValue, + watch, + formState: { errors }, + clearErrors, + } = useFormContext(); + + const { entityId, entityInputValue, entityType } = watch(); + + // React Query entities + const { + data: databases, + error: databasesError, + isLoading: databasesLoading, + } = useAllDatabasesQuery(entityType === 'database_id'); + + const { + data: firewalls, + error: firewallsError, + isLoading: firewallsLoading, + } = useAllFirewallsQuery(entityType === 'firewall_id'); + + const { + data: domains, + error: domainsError, + isLoading: domainsLoading, + } = useAllDomainsQuery(entityType === 'domain_id'); + + const { + data: nodebalancers, + error: nodebalancersError, + isLoading: nodebalancersLoading, + } = useAllNodeBalancersQuery(entityType === 'nodebalancer_id'); + + const { + data: clusters, + error: clustersError, + isLoading: clustersLoading, + } = useAllKubernetesClustersQuery(entityType === 'lkecluster_id'); + + const { + data: linodes, + error: linodesError, + isLoading: linodesLoading, + } = useAllLinodesQuery({}, {}, entityType === 'linode_id'); + + const { + data: volumes, + error: volumesError, + isLoading: volumesLoading, + } = useAllVolumesQuery({}, {}, entityType === 'volume_id'); + + const getEntityOptions = (): { label: string; value: number }[] => { + const reactQueryEntityDataMap = { + database_id: databases, + domain_id: domains, + firewall_id: firewalls, + linode_id: linodes, + lkecluster_id: clusters, + nodebalancer_id: nodebalancers, + volume_id: volumes, + }; + + if (!reactQueryEntityDataMap[entityType]) { + return []; + } + + // Domains don't have a label so we map the domain as the label + if (entityType === 'domain_id') { + return ( + reactQueryEntityDataMap[entityType]?.map(({ domain, id }) => ({ + label: domain, + value: id, + })) || [] + ); + } + + return ( + reactQueryEntityDataMap[entityType]?.map( + ({ id, label }: { id: number; label: string }) => ({ + label, + value: id, + }) + ) || [] + ); + }; + + const loadingMap: Record = { + database_id: databasesLoading, + domain_id: domainsLoading, + firewall_id: firewallsLoading, + general: false, + linode_id: linodesLoading, + lkecluster_id: clustersLoading, + nodebalancer_id: nodebalancersLoading, + none: false, + volume_id: volumesLoading, + }; + + const errorMap: Record = { + database_id: databasesError, + domain_id: domainsError, + firewall_id: firewallsError, + general: null, + linode_id: linodesError, + lkecluster_id: clustersError, + nodebalancer_id: nodebalancersError, + none: null, + volume_id: volumesError, + }; + + const entityOptions = getEntityOptions(); + const areEntitiesLoading = loadingMap[entityType]; + const entityError = Boolean(errorMap[entityType]) + ? `Error loading ${ENTITY_ID_TO_NAME_MAP[entityType]}s` + : undefined; + + const selectedEntity = + entityOptions.find((thisEntity) => String(thisEntity.value) === entityId) || + null; + + const renderEntityTypes = () => { + return Object.keys(ENTITY_MAP).map((key: string) => { + return { label: key, value: ENTITY_MAP[key] }; + }); + }; + + const topicOptions: { label: string; value: EntityType }[] = [ + { label: 'General/Account/Billing', value: 'general' }, + ...renderEntityTypes(), + ]; + + const selectedTopic = topicOptions.find((eachTopic) => { + return eachTopic.value === entityType; + }); + + return ( + <> + ( + { + // Don't reset things if the type hasn't changed. + if (type.value === entityType) { + return; + } + field.onChange(type.value); + setValue('entityId', ''); + setValue('entityInputValue', ''); + clearErrors('entityId'); + }} + data-qa-ticket-entity-type + disableClearable + label="What is this regarding?" + options={topicOptions} + value={selectedTopic} + /> + )} + control={control} + name="entityType" + /> + {!['general', 'none'].includes(entityType) && ( + <> + ( + + setValue('entityId', id ? String(id?.value) : '') + } + data-qa-ticket-entity-id + disabled={entityOptions.length === 0} + errorText={ + entityError || + fieldState.error?.message || + errors.entityId?.message + } + inputValue={entityInputValue} + label={ENTITY_ID_TO_NAME_MAP[entityType] ?? 'Entity Select'} + loading={areEntitiesLoading} + onInputChange={(e, value) => field.onChange(value ? value : '')} + options={entityOptions} + placeholder={`Select a ${ENTITY_ID_TO_NAME_MAP[entityType]}`} + value={selectedEntity} + /> + )} + control={control} + name="entityInputValue" + /> + {!areEntitiesLoading && entityOptions.length === 0 ? ( + + You don’t have any {ENTITY_ID_TO_NAME_MAP[entityType]}s on + your account. + + ) : null} + + )} + + ); +}; diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketSMTPFields.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketSMTPFields.tsx index 12a6c71415d..fcb6e1e6dcc 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketSMTPFields.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketSMTPFields.tsx @@ -1,84 +1,115 @@ import * as React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; import { TextField } from 'src/components/TextField'; +import { useAccount } from 'src/queries/account/account'; -export interface Props { - formState: { - companyName: string; - customerName: string; - emailDomains: string; - publicInfo: string; - useCase: string; - }; - handleChange: (e: React.ChangeEvent) => void; +import { SMTP_FIELD_NAME_TO_LABEL_MAP } from './constants'; + +import type { CustomFields } from './constants'; + +export interface SMTPCustomFields extends Omit { + companyName: string | undefined; + emailDomains: string; } -export const smtpDialogTitle = 'Contact Support: SMTP Restriction Removal'; -export const smtpHelperText = - 'In an effort to fight spam, outbound connections are restricted on ports 25, 465, and 587. To have these restrictions removed, please provide us with the following information. A member of the Support team will review your request and follow up with you as soon as possible.'; +export const SupportTicketSMTPFields = () => { + const form = useFormContext(); + const { data: account } = useAccount(); -export const fieldNameToLabelMap: Record = { - companyName: 'Business or company name', - customerName: 'First and last name', - emailDomains: 'Domain(s) that will be sending emails', - publicInfo: - "Links to public information - e.g. your business or application's website, Twitter profile, GitHub, etc.", - useCase: - "A clear and detailed description of your email use case, including how you'll avoid sending unwanted emails", -}; + const defaultValues = { + companyName: account?.company, + customerName: `${account?.first_name} ${account?.last_name}`, + ...form.formState.defaultValues, + }; -const SupportTicketSMTPFields: React.FC = (props) => { - const { formState, handleChange } = props; + React.useEffect(() => { + form.reset(defaultValues); + }, []); return ( - - + ( + + )} + control={form.control} name="customerName" - onChange={handleChange} - required - value={formState.customerName} /> - ( + + )} + control={form.control} name="companyName" - onChange={handleChange} - value={formState.companyName} /> - ( + + )} + control={form.control} name="useCase" - onChange={handleChange} - required - value={formState.useCase} /> - ( + + )} + control={form.control} name="emailDomains" - onChange={handleChange} - required - value={formState.emailDomains} /> - ( + + )} + control={form.control} name="publicInfo" - onChange={handleChange} - required - value={formState.publicInfo} /> - + ); }; - -export default SupportTicketSMTPFields; diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx index ca550291802..3d66011bb75 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketsLanding.tsx @@ -10,10 +10,11 @@ import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; -import { AttachmentError } from '../SupportTicketDetail/SupportTicketDetail'; import { SupportTicketDialog } from './SupportTicketDialog'; import TicketList from './TicketList'; +import type { AttachmentError } from '../SupportTicketDetail/SupportTicketDetail'; + const tabs = ['open', 'closed']; const SupportTicketsLanding = () => { diff --git a/packages/manager/src/features/Support/SupportTickets/TicketList.tsx b/packages/manager/src/features/Support/SupportTickets/TicketList.tsx index 3907fc95e27..fddf8f1804a 100644 --- a/packages/manager/src/features/Support/SupportTickets/TicketList.tsx +++ b/packages/manager/src/features/Support/SupportTickets/TicketList.tsx @@ -1,4 +1,3 @@ -import { SupportTicket } from '@linode/api-v4/lib/support'; import * as React from 'react'; import { Hidden } from 'src/components/Hidden'; @@ -19,6 +18,8 @@ import { useSupportTicketsQuery } from 'src/queries/support'; import { TicketRow } from './TicketRow'; import { getStatusFilter, useTicketSeverityCapability } from './ticketUtils'; +import type { SupportTicket } from '@linode/api-v4/lib/support'; + export interface Props { filterStatus: 'closed' | 'open'; newTicket?: SupportTicket; @@ -63,9 +64,12 @@ export const TicketList = (props: Props) => { return ( diff --git a/packages/manager/src/features/Support/SupportTickets/TicketRow.tsx b/packages/manager/src/features/Support/SupportTickets/TicketRow.tsx index 031f71c2f19..29210e74fd8 100644 --- a/packages/manager/src/features/Support/SupportTickets/TicketRow.tsx +++ b/packages/manager/src/features/Support/SupportTickets/TicketRow.tsx @@ -1,4 +1,3 @@ -import { SupportTicket } from '@linode/api-v4/lib/support'; import * as React from 'react'; import { Link } from 'react-router-dom'; @@ -10,7 +9,10 @@ import { Typography } from 'src/components/Typography'; import { getLinkTargets } from 'src/utilities/getEventsActionLink'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; -import { severityLabelMap, useTicketSeverityCapability } from './ticketUtils'; +import { SEVERITY_LABEL_MAP } from './constants'; +import { useTicketSeverityCapability } from './ticketUtils'; + +import type { SupportTicket } from '@linode/api-v4/lib/support'; interface Props { ticket: SupportTicket; @@ -67,7 +69,7 @@ export const TicketRow = ({ ticket }: Props) => { {hasSeverityCapability && ( - {ticket.severity ? severityLabelMap.get(ticket.severity) : ''} + {ticket.severity ? SEVERITY_LABEL_MAP.get(ticket.severity) : ''} )} diff --git a/packages/manager/src/features/Support/SupportTickets/constants.tsx b/packages/manager/src/features/Support/SupportTickets/constants.tsx index 61b36aa1e72..bfe5e70a6f7 100644 --- a/packages/manager/src/features/Support/SupportTickets/constants.tsx +++ b/packages/manager/src/features/Support/SupportTickets/constants.tsx @@ -1,6 +1,112 @@ -import { Typography } from '@mui/material'; +import { + createSMTPSupportTicketSchema, + createSupportTicketSchema, +} from '@linode/validation'; import React from 'react'; +import { Link } from 'src/components/Link'; +import { Typography } from 'src/components/Typography'; + +import type { + EntityType, + TicketType, + TicketTypeData, +} from './SupportTicketDialog'; +import type { TicketSeverity } from '@linode/api-v4'; +import type { AnyObjectSchema } from 'yup'; + +export interface CustomFields { + companyName: string; + customerName: string; + publicInfo: string; + useCase: string; +} + +export const SMTP_DIALOG_TITLE = 'Contact Support: SMTP Restriction Removal'; +export const SMTP_HELPER_TEXT = + 'In an effort to fight spam, outbound connections are restricted on ports 25, 465, and 587. To have these restrictions removed, please provide us with the following information. A member of the Support team will review your request and follow up with you as soon as possible.'; + +export const TICKET_TYPE_MAP: Record = { + general: { + dialogTitle: 'Open a Support Ticket', + helperText: ( + <> + {`We love our customers, and we\u{2019}re here to help if you need us. + Please keep in mind that not all topics are within the scope of our support. + For overall system status, please see `} + status.linode.com. + + ), + }, + smtp: { + dialogTitle: SMTP_DIALOG_TITLE, + helperText: SMTP_HELPER_TEXT, + }, +}; + +// Validation +export const SCHEMA_MAP: Record = { + general: createSupportTicketSchema, + smtp: createSMTPSupportTicketSchema, +}; + +export const ENTITY_MAP: Record = { + Databases: 'database_id', + Domains: 'domain_id', + Firewalls: 'firewall_id', + Kubernetes: 'lkecluster_id', + Linodes: 'linode_id', + NodeBalancers: 'nodebalancer_id', + Volumes: 'volume_id', +}; + +export const ENTITY_ID_TO_NAME_MAP: Record = { + database_id: 'Database Cluster', + domain_id: 'Domain', + firewall_id: 'Firewall', + general: '', + linode_id: 'Linode', + lkecluster_id: 'Kubernetes Cluster', + nodebalancer_id: 'NodeBalancer', + none: '', + volume_id: 'Volume', +}; + +// General custom fields common to multiple custom ticket types. +export const CUSTOM_FIELD_NAME_TO_LABEL_MAP: Record = { + companyName: 'Business or company name', + customerName: 'First and last name', + publicInfo: + "Links to public information - e.g. your business or application's website, Twitter profile, GitHub, etc.", + useCase: 'A clear and detailed description of your use case', +}; + +export const SMTP_FIELD_NAME_TO_LABEL_MAP: Record = { + ...CUSTOM_FIELD_NAME_TO_LABEL_MAP, + emailDomains: 'Domain(s) that will be sending emails', + useCase: + "A clear and detailed description of your email use case, including how you'll avoid sending unwanted emails", +}; + +// Used for finding specific custom fields within form data, based on the ticket type. +export const TICKET_TYPE_TO_CUSTOM_FIELD_KEYS_MAP: Record = { + smtp: Object.keys(SMTP_FIELD_NAME_TO_LABEL_MAP), +}; + +export const SEVERITY_LABEL_MAP: Map = new Map([ + [1, '1-Major Impact'], + [2, '2-Moderate Impact'], + [3, '3-Low Impact'], +]); + +export const SEVERITY_OPTIONS: { + label: string; + value: TicketSeverity; +}[] = Array.from(SEVERITY_LABEL_MAP).map(([severity, label]) => ({ + label, + value: severity, +})); + export const TICKET_SEVERITY_TOOLTIP_TEXT = ( <> diff --git a/packages/manager/src/features/Support/SupportTickets/ticketUtils.test.ts b/packages/manager/src/features/Support/SupportTickets/ticketUtils.test.ts new file mode 100644 index 00000000000..025f0469bea --- /dev/null +++ b/packages/manager/src/features/Support/SupportTickets/ticketUtils.test.ts @@ -0,0 +1,49 @@ +import { SMTP_FIELD_NAME_TO_LABEL_MAP } from './constants'; +import { formatDescription } from './ticketUtils'; + +import type { SupportTicketFormFields } from './SupportTicketDialog'; +import type { SMTPCustomFields } from './SupportTicketSMTPFields'; + +const mockSupportTicketFormFields: SupportTicketFormFields = { + description: 'Mock description.', + entityId: '', + entityInputValue: '', + entityType: 'general', + selectedSeverity: undefined, + summary: 'My Summary', + ticketType: 'general', +}; + +const mockSupportTicketCustomFormFields: SMTPCustomFields = { + companyName: undefined, + customerName: 'Jane Doe', + emailDomains: 'test@akamai.com', + publicInfo: 'public info', + useCase: 'use case', +}; + +describe('formatDescription', () => { + it('returns the original description if there are no custom fields in the payload', () => { + expect(formatDescription(mockSupportTicketFormFields, 'general')).toEqual( + mockSupportTicketFormFields.description + ); + }); + + it('returns the formatted description if there are custom fields in the payload', () => { + const expectedFormattedDescription = `**${SMTP_FIELD_NAME_TO_LABEL_MAP['companyName']}**\nNo response\n\n\ +**${SMTP_FIELD_NAME_TO_LABEL_MAP['customerName']}**\n${mockSupportTicketCustomFormFields.customerName}\n\n\ +**${SMTP_FIELD_NAME_TO_LABEL_MAP['emailDomains']}**\n${mockSupportTicketCustomFormFields.emailDomains}\n\n\ +**${SMTP_FIELD_NAME_TO_LABEL_MAP['publicInfo']}**\n${mockSupportTicketCustomFormFields.publicInfo}\n\n\ +**${SMTP_FIELD_NAME_TO_LABEL_MAP['useCase']}**\n${mockSupportTicketCustomFormFields.useCase}`; + + expect( + formatDescription( + { + ...mockSupportTicketFormFields, + ...mockSupportTicketCustomFormFields, + }, + 'smtp' + ) + ).toEqual(expectedFormattedDescription); + }); +}); diff --git a/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts b/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts index 3ac529852d3..1abf530610c 100644 --- a/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts +++ b/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts @@ -1,10 +1,21 @@ -import { Filter, Params } from '@linode/api-v4'; -import { TicketSeverity, getTickets } from '@linode/api-v4/lib/support'; +import { getTickets } from '@linode/api-v4/lib/support'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { + SMTP_FIELD_NAME_TO_LABEL_MAP, + TICKET_TYPE_TO_CUSTOM_FIELD_KEYS_MAP, +} from './constants'; + +import type { + AllSupportTicketFormFields, + SupportTicketFormFields, + TicketType, +} from './SupportTicketDialog'; +import type { Filter, Params } from '@linode/api-v4'; + /** * getStatusFilter * @@ -60,8 +71,40 @@ export const useTicketSeverityCapability = () => { ); }; -export const severityLabelMap: Map = new Map([ - [1, '1-Major Impact'], - [2, '2-Moderate Impact'], - [3, '3-Low Impact'], -]); +/** + * formatDescription + * + * When variant ticketTypes include additional fields, fields must concat to one description string to submit in the payload. + * For readability, replace field names with field labels and format the description in Markdown. + * @param values - the form payload, which can either be the general fields, or the general fields plus any custom fields + * @param ticketType - either 'general' or a custom ticket type (e.g. 'smtp') + * + * @returns a description string + */ +export const formatDescription = ( + values: AllSupportTicketFormFields | SupportTicketFormFields, + ticketType: TicketType +) => { + type customFieldTuple = [string, string | undefined]; + const customFields: customFieldTuple[] = Object.entries( + values + ).filter(([key, _value]: customFieldTuple) => + TICKET_TYPE_TO_CUSTOM_FIELD_KEYS_MAP[ticketType]?.includes(key) + ); + + // If there are no custom fields, just return the initial description. + if (customFields.length === 0) { + return values.description; + } + + // Add all custom fields to the description in the ticket payload, to be viewed on ticket details page and by Customer Support. + return customFields + .map(([key, value]) => { + let label = key; + if (ticketType === 'smtp') { + label = SMTP_FIELD_NAME_TO_LABEL_MAP[key]; + } + return `**${label}**\n${value ? value : 'No response'}`; + }) + .join('\n\n'); +}; diff --git a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx b/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx index 0acd005aaf6..3d59b269e96 100644 --- a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx +++ b/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx @@ -25,7 +25,6 @@ import PlacementGroupsIcon from 'src/assets/icons/entityIcons/placement-groups.s import VolumeIcon from 'src/assets/icons/entityIcons/volume.svg'; import VPCIcon from 'src/assets/icons/entityIcons/vpc.svg'; import { Button } from 'src/components/Button/Button'; -import { Divider } from 'src/components/Divider'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { useIsACLBEnabled } from 'src/features/LoadBalancers/utils'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; @@ -181,16 +180,7 @@ export const AddNewMenu = () => { {links.map( (link, i) => !link.hide && [ - i !== 0 && , { }} > - + - - {link.entity} - + {link.entity} {link.description} , diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.styles.ts b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.styles.ts index bc4144e18cc..ce2713f1cf1 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.styles.ts +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.styles.ts @@ -31,7 +31,14 @@ export const StyledSearchBarWrapperDiv = styled('div', { label: 'StyledSearchBarWrapperDiv', })(({ theme }) => ({ '& > div .react-select__control': { + '&:hover': { + borderColor: 'transparent', + }, backgroundColor: 'transparent', + borderColor: 'transparent', + }, + '& > div .react-select__control--is-focused:hover': { + borderColor: 'transparent', }, '& > div .react-select__indicators': { display: 'none', @@ -55,9 +62,21 @@ export const StyledSearchBarWrapperDiv = styled('div', { }, overflow: 'hidden', }, + '& svg': { + height: 20, + width: 20, + }, + '&.active': { + ...theme.inputStyles.focused, + '&:hover': { + ...theme.inputStyles.focused, + }, + }, + '&:hover': { + ...theme.inputStyles.hover, + }, + ...theme.inputStyles.default, alignItems: 'center', - backgroundColor: theme.bg.app, - borderRadius: 3, display: 'flex', flex: 1, height: 34, @@ -70,7 +89,6 @@ export const StyledSearchBarWrapperDiv = styled('div', { visibility: 'visible', zIndex: 3, }, - backgroundColor: theme.bg.white, left: 0, margin: 0, opacity: 0, diff --git a/packages/manager/src/features/TopMenu/TopMenu.tsx b/packages/manager/src/features/TopMenu/TopMenu.tsx index f26072d765d..c45414b1a5c 100644 --- a/packages/manager/src/features/TopMenu/TopMenu.tsx +++ b/packages/manager/src/features/TopMenu/TopMenu.tsx @@ -50,13 +50,7 @@ export const TopMenu = React.memo((props: TopMenuProps) => { )} - ({ - backgroundColor: theme.bg.bgPaper, - color: theme.palette.text.primary, - position: 'relative', - })} - > + ({ '&.MuiToolbar-root': { @@ -71,7 +65,6 @@ export const TopMenu = React.memo((props: TopMenuProps) => { ({ color: '#606469', }, color: '#c9c7c7', + height: `50px`, [theme.breakpoints.down('sm')]: { padding: 1, }, diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx index 3f582284da8..c51eb4d5fad 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.test.tsx @@ -68,7 +68,7 @@ describe('SubnetLinodeRow', () => { handlePowerActionsLinode={handlePowerActionsLinode} handleUnassignLinode={handleUnassignLinode} linodeId={linodeFactory1.id} - subnetId={0} + subnetId={1} /> ) ); diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts index 5d8f28ef52b..7dc1846cbb2 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts @@ -3,7 +3,6 @@ import { styled } from '@mui/material/styles'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; -import { Paper } from 'src/components/Paper'; export const StyledActionButton = styled(Button, { label: 'StyledActionButton', @@ -55,9 +54,10 @@ export const StyledSummaryTextTypography = styled(Typography, { whiteSpace: 'nowrap', })); -export const StyledPaper = styled(Paper, { - label: 'StyledPaper', +export const StyledBox = styled(Box, { + label: 'StyledBox', })(({ theme }) => ({ + background: theme.bg.bgPaper, borderTop: `1px solid ${theme.borderColors.borderTable}`, display: 'flex', padding: theme.spacing(2), diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx index 93957c50c98..46ee3584406 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx @@ -23,7 +23,7 @@ import { getUniqueLinodesFromSubnets } from '../utils'; import { StyledActionButton, StyledDescriptionBox, - StyledPaper, + StyledBox, StyledSummaryBox, StyledSummaryTextTypography, } from './VPCDetail.styles'; @@ -133,7 +133,7 @@ const VPCDetail = () => { - + {summaryData.map((col) => { return ( @@ -174,7 +174,7 @@ const VPCDetail = () => {
)} -
+ { it('should render a VPC row', () => { const vpc = vpcFactory.build(); + resizeScreenSize(1600); const { getAllByText, getByText } = renderWithTheme( wrapWithTableBody( diff --git a/packages/manager/src/features/VPCs/utils.test.ts b/packages/manager/src/features/VPCs/utils.test.ts index c572b42ad6a..90e4ea5e2da 100644 --- a/packages/manager/src/features/VPCs/utils.test.ts +++ b/packages/manager/src/features/VPCs/utils.test.ts @@ -50,7 +50,7 @@ describe('getUniqueLinodesFromSubnets', () => { expect(getUniqueLinodesFromSubnets(subnets0)).toBe(0); expect(getUniqueLinodesFromSubnets(subnets1)).toBe(4); expect(getUniqueLinodesFromSubnets(subnets2)).toBe(2); - expect(getUniqueLinodesFromSubnets(subnets3)).toBe(7); + expect(getUniqueLinodesFromSubnets(subnets3)).toBe(6); }); }); @@ -60,15 +60,15 @@ describe('getSubnetInterfaceFromConfigs', () => { const singleConfig = linodeConfigFactory.build({ interfaces }); const configs = [linodeConfigFactory.build(), singleConfig]; - const subnetInterface1 = getSubnetInterfaceFromConfigs(configs, 1); + const subnetInterface1 = getSubnetInterfaceFromConfigs(configs, 2); expect(subnetInterface1).toEqual(interfaces[0]); - const subnetInterface2 = getSubnetInterfaceFromConfigs(configs, 2); + const subnetInterface2 = getSubnetInterfaceFromConfigs(configs, 3); expect(subnetInterface2).toEqual(interfaces[1]); - const subnetInterface3 = getSubnetInterfaceFromConfigs(configs, 3); + const subnetInterface3 = getSubnetInterfaceFromConfigs(configs, 4); expect(subnetInterface3).toEqual(interfaces[2]); - const subnetInterface4 = getSubnetInterfaceFromConfigs(configs, 4); + const subnetInterface4 = getSubnetInterfaceFromConfigs(configs, 5); expect(subnetInterface4).toEqual(interfaces[3]); - const subnetInterface5 = getSubnetInterfaceFromConfigs(configs, 5); + const subnetInterface5 = getSubnetInterfaceFromConfigs(configs, 6); expect(subnetInterface5).toEqual(interfaces[4]); }); diff --git a/packages/manager/src/features/components/PlansPanel/PlanContainer.styles.ts b/packages/manager/src/features/components/PlansPanel/PlanContainer.styles.ts index 935c059aabe..30cc5351841 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanContainer.styles.ts +++ b/packages/manager/src/features/components/PlansPanel/PlanContainer.styles.ts @@ -11,8 +11,6 @@ interface StyledTableCellPropsProps extends TableCellProps { export const StyledTable = styled(Table, { label: 'StyledTable', })(({ theme }) => ({ - borderLeft: `1px solid ${theme.borderColors.borderTable}`, - borderRight: `1px solid ${theme.borderColors.borderTable}`, overflowX: 'hidden', })); diff --git a/packages/manager/src/foundations/themes/dark.ts b/packages/manager/src/foundations/themes/dark.ts index aadb1219ee8..4a374ee9d1b 100644 --- a/packages/manager/src/foundations/themes/dark.ts +++ b/packages/manager/src/foundations/themes/dark.ts @@ -1,67 +1,123 @@ -import { ThemeOptions } from '@mui/material/styles'; +import { + Action, + Badge, + Button, + Color, + Dropdown, + Interaction, + NotificationToast, + Select, + TextField, +} from '@linode/design-language-system/themes/dark'; import { breakpoints } from 'src/foundations/breakpoints'; +import { latoWeb } from 'src/foundations/fonts'; + +import type { ThemeOptions } from '@mui/material/styles'; const primaryColors = { - dark: '#2466b3', - divider: '#222222', - headline: '#f4f4f4', - light: '#4d99f1', - main: '#3683dc', - text: '#ffffff', - white: '#222', + dark: Color.Brand[90], + divider: Color.Neutrals.Black, + headline: Color.Neutrals[5], + light: Color.Brand[60], + main: Color.Brand[80], + text: Color.Neutrals.White, + white: Color.Neutrals.Black, }; +// Eventually we'll probably want Color.Neutrals.Black once we fully migrate to CDS 2.0 +// We will need to consult with the design team to determine the correct dark shade handling for: +// - appBar +// - popoverPaper (create menu, notification center) +// - MenuItem (create menu, action menu) +// since Color.Neutrals.Black is pitch black and may not be the correct choice yet. +const tempReplacementforColorNeutralsBlack = '#222'; + export const customDarkModeOptions = { bg: { - app: '#3a3f46', - bgAccessRow: '#454b54', + app: Color.Neutrals[100], + appBar: tempReplacementforColorNeutralsBlack, + bgAccessRow: Color.Neutrals[80], bgAccessRowTransparentGradient: 'rgb(69, 75, 84, .001)', - bgPaper: '#2e3238', - lightBlue1: '#222', - lightBlue2: '#364863', - main: '#2f3236', - mainContentBanner: '#23272b', - offWhite: '#444', - primaryNavPaper: '#2e3238', - tableHeader: '#33373e', - white: '#32363c', + bgPaper: Color.Neutrals[90], + interactionBgPrimary: Interaction.Background.Secondary, + lightBlue1: Color.Neutrals.Black, + lightBlue2: Color.Brand[100], + main: Color.Neutrals[100], + mainContentBanner: Color.Neutrals[100], + offWhite: Color.Neutrals[90], + primaryNavPaper: Color.Neutrals[100], + tableHeader: Color.Neutrals[100], + white: Color.Neutrals[100], }, borderColors: { - borderTable: '#3a3f46', - borderTypography: '#454b54', - divider: '#222', + borderFocus: Interaction.Border.Focus, + borderHover: Interaction.Border.Hover, + borderTable: Color.Neutrals[80], + borderTypography: Color.Neutrals[80], + divider: Color.Neutrals[80], }, color: { - black: '#ffffff', - blueDTwhite: '#fff', - border2: '#111', - border3: '#222', - boxShadow: '#222', - boxShadowDark: '#000', + black: Color.Neutrals.White, + blueDTwhite: Color.Neutrals.White, + border2: Color.Neutrals.Black, + border3: Color.Neutrals.Black, + boxShadow: 'rgba(0, 0, 0, 0.5)', + boxShadowDark: Color.Neutrals.Black, + buttonPrimaryHover: Button.Primary.Hover.Background, drawerBackdrop: 'rgba(0, 0, 0, 0.5)', - grey1: '#abadaf', - grey2: 'rgba(0, 0, 0, 0.2)', - grey3: '#999', - grey5: 'rgba(0, 0, 0, 0.2)', - grey6: '#606469', - grey7: '#2e3238', + grey1: Color.Neutrals[50], + grey2: Color.Neutrals[100], + grey3: Color.Neutrals[60], + grey5: Color.Neutrals[100], + grey6: Color.Neutrals[50], + grey7: Color.Neutrals[80], grey9: primaryColors.divider, headline: primaryColors.headline, - label: '#c9cacb', - offBlack: '#ffffff', - red: '#fb6d6d', - tableHeaderText: '#fff', - tagButton: '#364863', - tagIcon: '#9caec9', - white: '#32363c', + label: Color.Neutrals[40], + offBlack: Color.Neutrals.White, + red: Color.Red[70], + tableHeaderText: Color.Neutrals.White, + // TODO: `tagButton*` should be moved to component level. + tagButtonBg: Color.Brand[40], + tagButtonBgHover: Button.Primary.Hover.Background, + tagButtonText: Button.Primary.Default.Text, + tagButtonTextHover: Button.Primary.Hover.Text, + tagIcon: Button.Primary.Default.Icon, + tagIconHover: Button.Primary.Default.Text, + white: Color.Neutrals[100], }, textColors: { - headlineStatic: '#e6e6e6', - linkActiveLight: '#74aae6', - tableHeader: '#888F91', - tableStatic: '#e6e6e6', - textAccessTable: '#acb0b4', + headlineStatic: Color.Neutrals[20], + linkActiveLight: Action.Primary.Default, + linkHover: Action.Primary.Hover, + tableHeader: Color.Neutrals[60], + tableStatic: Color.Neutrals[20], + textAccessTable: Color.Neutrals[50], + }, +} as const; + +export const notificationToast = { + default: { + backgroundColor: NotificationToast.Informative.Background, + borderLeft: `6px solid ${NotificationToast.Informative.Border}`, + color: NotificationToast.Text, + }, + error: { + backgroundColor: NotificationToast.Error.Background, + borderLeft: `6px solid ${NotificationToast.Error.Border}`, + }, + info: { + backgroundColor: NotificationToast.Informative.Background, + borderLeft: `6px solid ${NotificationToast.Informative.Border}`, + }, + success: { + backgroundColor: NotificationToast.Success.Background, + borderLeft: `6px solid ${NotificationToast.Success.Border}`, + }, + warning: { + backgroundColor: NotificationToast.Warning.Background, + borderLeft: `6px solid ${NotificationToast.Warning.Border}`, }, } as const; @@ -71,7 +127,7 @@ const iconCircleAnimation = { transition: 'fill .2s ease-in-out .2s', }, '& .insidePath *': { - stroke: 'white', + stroke: Color.Neutrals.White, transition: 'fill .2s ease-in-out .2s, stroke .2s ease-in-out .2s', }, '& .outerCircle': { @@ -85,12 +141,12 @@ const iconCircleAnimation = { // Used for styling html buttons to look like our generic links const genericLinkStyle = { '&:hover': { - color: primaryColors.main, + color: Action.Primary.Hover, textDecoration: 'underline', }, background: 'none', border: 'none', - color: customDarkModeOptions.textColors.linkActiveLight, + color: Action.Primary.Default, cursor: 'pointer', font: 'inherit', padding: 0, @@ -148,6 +204,10 @@ export const darkTheme: ThemeOptions = { colorDefault: { backgroundColor: 'transparent', }, + root: { + backgroundColor: tempReplacementforColorNeutralsBlack, + border: 0, + }, }, }, MuiAutocomplete: { @@ -157,10 +217,10 @@ export const darkTheme: ThemeOptions = { border: `1px solid ${primaryColors.main}`, }, loading: { - color: '#fff', + color: Color.Neutrals.White, }, noOptions: { - color: '#fff', + color: Color.Neutrals.White, }, tag: { '.MuiChip-deleteIcon': { color: primaryColors.text }, @@ -176,63 +236,100 @@ export const darkTheme: ThemeOptions = { MuiButton: { styleOverrides: { containedPrimary: { + // TODO: We can remove this after migration since we can define variants '&.loading': { - color: primaryColors.text, + backgroundColor: primaryColors.text, }, '&:active': { - backgroundColor: primaryColors.dark, + backgroundColor: Button.Primary.Pressed.Background, }, '&:disabled': { - backgroundColor: '#454b54', - color: '#5c6470', + backgroundColor: Button.Primary.Disabled.Background, + color: Button.Primary.Disabled.Text, }, '&:hover, &:focus': { - backgroundColor: '#226dc3', + backgroundColor: Button.Primary.Hover.Background, + color: Button.Primary.Default.Text, }, '&[aria-disabled="true"]': { - backgroundColor: '#454b54', - color: '#5c6470', + backgroundColor: Button.Primary.Disabled.Background, + color: Button.Primary.Disabled.Text, }, + backgroundColor: Button.Primary.Default.Background, + color: Button.Primary.Default.Text, + padding: '2px 20px', }, containedSecondary: { - '&[aria-disabled="true"]': { - color: '#c9cacb', + // TODO: We can remove this after migration since we can define variants + '&.loading': { + color: primaryColors.text, + }, + '&:active': { + backgroundColor: 'transparent', + color: Button.Secondary.Pressed.Text, + }, + '&:disabled': { + backgroundColor: 'transparent', + color: Button.Secondary.Disabled.Text, }, - }, - outlined: { '&:hover, &:focus': { backgroundColor: 'transparent', - border: '1px solid #fff', - color: '#fff', + color: Button.Secondary.Hover.Text, }, '&[aria-disabled="true"]': { - backgroundColor: '#454b54', - border: '1px solid rgba(255, 255, 255, 0.12)', - color: '#5c6470', + backgroundColor: 'transparent', + color: Button.Secondary.Disabled.Text, }, - color: customDarkModeOptions.textColors.linkActiveLight, + backgroundColor: 'transparent', + color: Button.Secondary.Default.Text, }, - root: { - '&.loading': { - color: primaryColors.text, + outlined: { + '&:active': { + backgroundColor: Button.Secondary.Pressed.Background, + borderColor: Button.Secondary.Pressed.Text, + color: Button.Secondary.Pressed.Text, }, - '&:disabled': { - backgroundColor: '#454b54', - color: '#5c6470', + '&:hover, &:focus': { + backgroundColor: Button.Secondary.Hover.Background, + border: `1px solid ${Button.Secondary.Hover.Border}`, + color: Button.Secondary.Hover.Text, }, - '&:hover': { - backgroundColor: '#000', + '&[aria-disabled="true"]': { + backgroundColor: Button.Secondary.Disabled.Background, + border: `1px solid ${Button.Secondary.Disabled.Border}`, + color: Button.Secondary.Disabled.Text, }, + backgroundColor: Button.Secondary.Default.Background, + border: `1px solid ${Button.Secondary.Default.Border}`, + color: Button.Secondary.Default.Text, + minHeight: 34, + }, + root: { '&[aria-disabled="true"]': { cursor: 'not-allowed', }, - color: primaryColors.main, + border: 'none', + borderRadius: 1, + cursor: 'pointer', + fontFamily: latoWeb.bold, + fontSize: '1rem', + lineHeight: 1, + minHeight: 34, + minWidth: 105, + textTransform: 'capitalize', + transition: 'none', }, }, }, MuiButtonBase: { styleOverrides: { root: { + '&[aria-disabled="true"]': { + '& .MuiSvgIcon-root': { + fill: Button.Primary.Disabled.Icon, + }, + cursor: 'not-allowed', + }, fontSize: '1rem', }, }, @@ -247,7 +344,7 @@ export const darkTheme: ThemeOptions = { MuiCardHeader: { styleOverrides: { root: { - backgroundColor: 'rgba(0, 0, 0, 0.2)', + backgroundColor: Color.Neutrals[50], }, }, }, @@ -258,21 +355,42 @@ export const darkTheme: ThemeOptions = { }, styleOverrides: { clickable: { - '&:focus': { - backgroundColor: '#374863', - }, - '&:hover': { - backgroundColor: '#374863', - }, - backgroundColor: '#415d81', + color: Color.Brand[100], + }, + colorError: { + backgroundColor: Badge.Bold.Red.Background, + color: Badge.Bold.Red.Text, }, colorInfo: { - color: primaryColors.dark, + backgroundColor: Badge.Bold.Ultramarine.Background, + color: Badge.Bold.Ultramarine.Text, + }, + colorPrimary: { + backgroundColor: Badge.Bold.Ultramarine.Background, + color: Badge.Bold.Ultramarine.Text, + }, + colorSecondary: { + '&.MuiChip-clickable': { + '&:hover': { + backgroundColor: Badge.Bold.Ultramarine.Background, + color: Badge.Bold.Ultramarine.Text, + }, + }, + backgroundColor: Badge.Bold.Ultramarine.Background, + color: Badge.Bold.Ultramarine.Text, + }, + colorSuccess: { + backgroundColor: Badge.Bold.Green.Background, + color: Badge.Bold.Green.Text, }, colorWarning: { - color: primaryColors.dark, + backgroundColor: Badge.Bold.Amber.Background, + color: Badge.Bold.Amber.Text, }, outlined: { + '& .MuiChip-label': { + color: primaryColors.text, + }, backgroundColor: 'transparent', borderRadius: 1, }, @@ -284,30 +402,39 @@ export const darkTheme: ThemeOptions = { MuiDialog: { styleOverrides: { paper: { - boxShadow: '0 0 5px #222', + boxShadow: `0 0 5px ${Color.Neutrals[100]}`, }, }, }, MuiDialogTitle: { styleOverrides: { root: { - borderBottom: '1px solid #222', + borderBottom: `1px solid ${Color.Neutrals[100]}`, color: primaryColors.headline, }, }, }, + MuiDivider: { + styleOverrides: { + root: { + borderColor: customDarkModeOptions.borderColors.divider, + }, + }, + }, MuiDrawer: { styleOverrides: { paper: { - boxShadow: '0 0 5px #222', + border: 0, + boxShadow: `0 0 5px ${Color.Neutrals[100]}`, }, }, }, MuiFormControl: { styleOverrides: { root: { + // Component.Checkbox.Checked.Disabled '&.copy > div': { - backgroundColor: '#2f3236', + backgroundColor: Color.Neutrals[100], }, }, }, @@ -317,7 +444,7 @@ export const darkTheme: ThemeOptions = { disabled: {}, label: { '&.Mui-disabled': { - color: '#aaa !important', + color: `${Color.Neutrals[50]} !important`, }, color: primaryColors.text, }, @@ -327,10 +454,10 @@ export const darkTheme: ThemeOptions = { MuiFormHelperText: { styleOverrides: { root: { - '&$error': { - color: '#fb6d6d', + '&[class*="error"]': { + color: Select.Error.HintText, }, - color: '#c9cacb', + color: Color.Neutrals[40], lineHeight: 1.25, }, }, @@ -339,15 +466,24 @@ export const darkTheme: ThemeOptions = { styleOverrides: { root: { '&$disabled': { - color: '#c9cacb', + color: Color.Neutrals[40], }, '&$error': { - color: '#c9cacb', + color: Color.Neutrals[40], }, '&.Mui-focused': { - color: '#c9cacb', + color: Color.Neutrals[40], + }, + color: Color.Neutrals[40], + }, + }, + }, + MuiIconButton: { + styleOverrides: { + root: { + '&:hover': { + color: primaryColors.main, }, - color: '#c9cacb', }, }, }, @@ -358,31 +494,48 @@ export const darkTheme: ThemeOptions = { input: { '&.Mui-disabled': { WebkitTextFillColor: 'unset !important', - borderColor: '#606469', - color: '#ccc !important', - opacity: 0.5, }, }, root: { '& svg': { - color: primaryColors.main, + color: TextField.Default.InfoIcon, }, '&.Mui-disabled': { - backgroundColor: '#444444', - borderColor: '#606469', - color: '#ccc !important', - opacity: 0.5, + '& svg': { + color: TextField.Disabled.InfoIcon, + }, + backgroundColor: TextField.Disabled.Background, + borderColor: TextField.Disabled.Border, + color: TextField.Disabled.Text, }, '&.Mui-error': { - borderColor: '#fb6d6d', + '& svg': { + color: TextField.Error.Icon, + }, + backgroundColor: TextField.Error.Background, + borderColor: TextField.Error.Border, + color: TextField.Error.Text, }, '&.Mui-focused': { - borderColor: primaryColors.main, - boxShadow: '0 0 2px 1px #222', + '& svg': { + color: TextField.Focus.Icon, + }, + backgroundColor: TextField.Focus.Background, + borderColor: TextField.Focus.Border, + boxShadow: `0 0 2px 1px ${Color.Neutrals[100]}`, + color: TextField.Focus.Text, + }, + '&.Mui-hover': { + '& svg': { + color: TextField.Hover.Icon, + }, + backgroundColor: TextField.Hover.Background, + borderColor: TextField.Hover.Border, + color: TextField.Hover.Text, }, - backgroundColor: '#444', - border: '1px solid #222', - color: primaryColors.text, + backgroundColor: TextField.Default.Background, + borderColor: TextField.Default.Border, + color: TextField.Filled.Text, }, }, }, @@ -390,9 +543,9 @@ export const darkTheme: ThemeOptions = { styleOverrides: { root: { '& p': { - color: '#eee', + color: Color.Neutrals[20], }, - color: '#eee', + color: Color.Neutrals[20], }, }, }, @@ -409,12 +562,32 @@ export const darkTheme: ThemeOptions = { MuiMenuItem: { styleOverrides: { root: { - '&$selected, &$selected:hover': { - backgroundColor: 'transparent', - color: primaryColors.main, + '&.loading': { + backgroundColor: primaryColors.text, + }, + '&:active': { + backgroundColor: Dropdown.Background.Default, + }, + '&:disabled': { + backgroundColor: Dropdown.Background.Default, + color: Dropdown.Text.Disabled, opacity: 1, }, - color: primaryColors.text, + '&:hover, &:focus': { + backgroundColor: Dropdown.Background.Hover, + color: Dropdown.Text.Default, + }, + '&:last-child': { + borderBottom: 0, + }, + '&[aria-disabled="true"]': { + backgroundColor: Dropdown.Background.Default, + color: Dropdown.Text.Disabled, + opacity: 1, + }, + backgroundColor: tempReplacementforColorNeutralsBlack, + color: Dropdown.Text.Default, + padding: '10px 10px 10px 16px', }, selected: {}, }, @@ -422,18 +595,23 @@ export const darkTheme: ThemeOptions = { MuiPaper: { styleOverrides: { outlined: { - border: '1px solid rgba(0, 0, 0, 0.2)', + // TODO: We can remove this variant since they will always have a border + backgroundColor: Color.Neutrals[90], + border: `1px solid ${Color.Neutrals[80]}`, }, root: { - backgroundColor: '#2e3238', + backgroundColor: Color.Neutrals[90], backgroundImage: 'none', // I have no idea why MUI defaults to setting a background image... + border: 0, }, }, }, MuiPopover: { styleOverrides: { paper: { - boxShadow: '0 0 5px #222', + background: tempReplacementforColorNeutralsBlack, + border: 0, + boxShadow: `0 2px 6px 0 rgba(0, 0, 0, 0.18)`, // TODO: Fix Elevation.S to remove `inset` }, }, }, @@ -454,14 +632,14 @@ export const darkTheme: ThemeOptions = { root: ({ theme }) => ({ '& .defaultFill': { '& circle': { - color: '#ccc', + color: Color.Neutrals[40], }, - color: '#55595c', - fill: '#53575a', + color: Color.Neutrals[80], + fill: Color.Neutrals[80], }, '&.Mui-disabled': { '& .defaultFill': { - color: '#ccc', + color: Color.Neutrals[40], opacity: 0.15, }, }, @@ -477,8 +655,8 @@ export const darkTheme: ThemeOptions = { MuiSnackbarContent: { styleOverrides: { root: { - backgroundColor: '#32363c', - boxShadow: '0 0 5px #222', + backgroundColor: Color.Neutrals[100], + boxShadow: `0 0 5px ${Color.Neutrals[100]}`, color: primaryColors.text, }, }, @@ -494,7 +672,7 @@ export const darkTheme: ThemeOptions = { }, }, track: { - backgroundColor: '#55595c', + backgroundColor: Color.Neutrals[80], }, }, }, @@ -502,16 +680,29 @@ export const darkTheme: ThemeOptions = { styleOverrides: { root: { '&$selected, &$selected:hover': { - color: '#fff', + color: Color.Neutrals.White, }, - color: '#fff', + color: Color.Neutrals.White, }, selected: {}, textColorPrimary: { '&$selected, &$selected:hover': { - color: '#fff', + color: Color.Neutrals.White, }, - color: '#fff', + color: Color.Neutrals.White, + }, + }, + }, + MuiTable: { + styleOverrides: { + root: { + // For nested tables like VPC + '& table': { + border: 0, + }, + border: `1px solid ${customDarkModeOptions.borderColors.borderTable}`, + borderBottom: 0, + borderTop: 0, }, }, }, @@ -523,10 +714,10 @@ export const darkTheme: ThemeOptions = { }, root: { '& a': { - color: customDarkModeOptions.textColors.linkActiveLight, + color: Action.Primary.Default, }, '& a:hover': { - color: primaryColors.main, + color: Action.Primary.Hover, }, borderBottom: `1px solid ${primaryColors.divider}`, borderTop: `1px solid ${primaryColors.divider}`, @@ -539,7 +730,7 @@ export const darkTheme: ThemeOptions = { '&:before': { backgroundColor: 'rgba(0, 0, 0, 0.15) !important', }, - backgroundColor: '#32363c', + backgroundColor: Color.Neutrals[100], }, hover: { '& a': { @@ -548,14 +739,13 @@ export const darkTheme: ThemeOptions = { }, root: { '&:before': { - borderLeftColor: '#32363c', + borderLeftColor: Color.Neutrals[90], }, '&:hover, &:focus': { - '&$hover': { - backgroundColor: 'rgba(0, 0, 0, 0.1)', - }, + backgroundColor: Color.Neutrals[80], }, - backgroundColor: '#32363c', + backgroundColor: Color.Neutrals[90], + border: `1px solid ${Color.Neutrals[50]}`, }, }, }, @@ -563,23 +753,23 @@ export const darkTheme: ThemeOptions = { styleOverrides: { flexContainer: { '& $scrollButtons:first-of-type': { - color: '#222', + color: Color.Neutrals.Black, }, }, root: { - boxShadow: 'inset 0 -1px 0 #222', + boxShadow: `inset 0 -1px 0 ${Color.Neutrals[100]}`, }, scrollButtons: { - color: '#fff', + color: Color.Neutrals.White, }, }, }, MuiTooltip: { styleOverrides: { tooltip: { - backgroundColor: '#444', - boxShadow: '0 0 5px #222', - color: '#fff', + backgroundColor: Color.Neutrals[70], + boxShadow: `0 0 5px ${Color.Neutrals[100]}`, + color: Color.Neutrals.White, }, }, }, @@ -587,7 +777,7 @@ export const darkTheme: ThemeOptions = { styleOverrides: { root: { '& a': { - color: customDarkModeOptions.textColors.linkActiveLight, + color: Action.Primary.Default, }, '& a.black': { color: primaryColors.text, @@ -599,7 +789,7 @@ export const darkTheme: ThemeOptions = { color: primaryColors.text, }, '& a:hover': { - color: primaryColors.main, + color: Action.Primary.Hover, }, }, }, @@ -623,17 +813,58 @@ export const darkTheme: ThemeOptions = { red: `rgb(255, 99, 60)`, yellow: `rgb(255, 220, 125)`, }, + inputStyles: { + default: { + backgroundColor: Select.Default.Background, + borderColor: Select.Default.Border, + color: Select.Default.Text, + }, + disabled: { + '& svg': { + color: Select.Disabled.Icon, + }, + backgroundColor: Select.Disabled.Background, + borderColor: Select.Disabled.Border, + color: Select.Disabled.Text, + }, + error: { + '& svg': { + color: Select.Error.Icon, + }, + backgroundColor: Select.Error.Background, + borderColor: Select.Error.Border, + color: Select.Error.Text, + }, + focused: { + '& svg': { + color: Select.Focus.Icon, + }, + backgroundColor: Select.Focus.Background, + borderColor: Select.Focus.Border, + boxShadow: `0 0 2px 1px ${Color.Neutrals[100]}`, + color: Select.Focus.Text, + }, + hover: { + '& svg': { + color: Select.Hover.Icon, + }, + backgroundColor: Select.Hover.Background, + borderColor: Select.Hover.Border, + color: Select.Hover.Text, + }, + }, name: 'dark', + notificationToast, palette: { background: { default: customDarkModeOptions.bg.app, - paper: '#2e3238', + paper: Color.Neutrals[100], }, divider: primaryColors.divider, error: { - dark: customDarkModeOptions.color.red, - light: customDarkModeOptions.color.red, - main: customDarkModeOptions.color.red, + dark: Color.Red[60], + light: Color.Red[10], + main: Color.Red[40], }, mode: 'dark', primary: primaryColors, diff --git a/packages/manager/src/foundations/themes/index.ts b/packages/manager/src/foundations/themes/index.ts index a30625e34f3..113dd754683 100644 --- a/packages/manager/src/foundations/themes/index.ts +++ b/packages/manager/src/foundations/themes/index.ts @@ -1,18 +1,23 @@ import { createTheme } from '@mui/material/styles'; -import { latoWeb } from 'src/foundations/fonts'; // Themes & Brands import { darkTheme } from 'src/foundations/themes/dark'; -// Types & Interfaces -import { customDarkModeOptions } from 'src/foundations/themes/dark'; import { lightTheme } from 'src/foundations/themes/light'; -import { +import { deepMerge } from 'src/utilities/deepMerge'; + +import type { latoWeb } from 'src/foundations/fonts'; +// Types & Interfaces +import type { + customDarkModeOptions, + notificationToast as notificationToastDark, +} from 'src/foundations/themes/dark'; +import type { bg, borderColors, color, + notificationToast, textColors, } from 'src/foundations/themes/light'; -import { deepMerge } from 'src/utilities/deepMerge'; export type ThemeName = 'dark' | 'light'; @@ -38,9 +43,15 @@ type TextColors = MergeTypes; type LightModeBorderColors = typeof borderColors; type DarkModeBorderColors = typeof customDarkModeOptions.borderColors; - type BorderColors = MergeTypes; +type LightNotificationToast = typeof notificationToast; +type DarkNotificationToast = typeof notificationToastDark; +type NotificationToast = MergeTypes< + LightNotificationToast, + DarkNotificationToast +>; + /** * Augmenting the Theme and ThemeOptions. * This allows us to add custom fields to the theme. @@ -58,7 +69,9 @@ declare module '@mui/material/styles/createTheme' { color: Colors; font: Fonts; graphs: any; + inputStyles: any; name: ThemeName; + notificationToast: NotificationToast; textColors: TextColors; visually: any; } @@ -74,7 +87,9 @@ declare module '@mui/material/styles/createTheme' { color?: DarkModeColors | LightModeColors; font?: Fonts; graphs?: any; + inputStyles?: any; name: ThemeName; + notificationToast?: NotificationToast; textColors?: DarkModeTextColors | LightModeTextColors; visually?: any; } diff --git a/packages/manager/src/foundations/themes/light.ts b/packages/manager/src/foundations/themes/light.ts index 75c064890b4..548cfb375cb 100644 --- a/packages/manager/src/foundations/themes/light.ts +++ b/packages/manager/src/foundations/themes/light.ts @@ -1,80 +1,128 @@ -import { ThemeOptions } from '@mui/material/styles'; +import { + Action, + Border, + Button, + Color, + Dropdown, + Interaction, + NotificationToast, + Select, +} from '@linode/design-language-system'; import { breakpoints } from 'src/foundations/breakpoints'; import { latoWeb } from 'src/foundations/fonts'; +import type { ThemeOptions } from '@mui/material/styles'; + export const inputMaxWidth = 416; export const bg = { - app: '#f4f5f6', - bgAccessRow: '#fafafa', + app: Color.Neutrals[5], + appBar: 'transparent', + bgAccessRow: Color.Neutrals[5], bgAccessRowTransparentGradient: 'rgb(255, 255, 255, .001)', - bgPaper: '#ffffff', - lightBlue1: '#f0f7ff', - lightBlue2: '#e5f1ff', - main: '#f4f4f4', - mainContentBanner: '#33373d', - offWhite: '#fbfbfb', - primaryNavPaper: '#3a3f46', - tableHeader: '#f9fafa', - white: '#fff', + bgPaper: Color.Neutrals.White, + interactionBgPrimary: Interaction.Background.Secondary, + lightBlue1: Color.Brand[10], + lightBlue2: Color.Brand[40], + main: Color.Neutrals[5], + mainContentBanner: Color.Neutrals[100], + offWhite: Color.Neutrals[5], + primaryNavPaper: Color.Neutrals[100], + tableHeader: Color.Neutrals[10], + white: Color.Neutrals.White, } as const; const primaryColors = { - dark: '#2466b3', - divider: '#f4f4f4', - headline: '#32363c', - light: '#4d99f1', - main: '#3683dc', - text: '#606469', - white: '#fff', + dark: Color.Brand[90], + divider: Color.Neutrals[5], + headline: Color.Neutrals[100], + light: Color.Brand[60], + main: Color.Brand[80], + text: Color.Neutrals[70], + white: Color.Neutrals.White, }; export const color = { - black: '#222', - blue: '#3683dc', - blueDTwhite: '#3683dc', - border2: '#c5c6c8', - border3: '#eee', - boxShadow: '#ddd', - boxShadowDark: '#aaa', - disabledText: '#c9cacb', + black: Color.Neutrals.Black, + blue: Color.Brand[80], + blueDTwhite: Color.Brand[80], + border2: Color.Neutrals[40], + border3: Color.Neutrals[20], + boxShadow: Color.Neutrals[30], + boxShadowDark: Color.Neutrals[50], + buttonPrimaryHover: Button.Primary.Hover.Background, + disabledText: Color.Neutrals[40], drawerBackdrop: 'rgba(255, 255, 255, 0.5)', - green: '#00b159', - grey1: '#abadaf', - grey2: '#e7e7e7', - grey3: '#ccc', - grey4: '#8C929D', - grey5: '#f5f5f5', - grey6: '#e3e5e8', - grey7: '#e9eaef', - grey8: '#dbdde1', - grey9: '#f4f5f6', + green: Color.Green[70], + grey1: Color.Neutrals[50], + grey2: Color.Neutrals[30], + grey3: Color.Neutrals[40], + grey4: Color.Neutrals[60], + grey5: Color.Neutrals[5], + grey6: Color.Neutrals[30], + grey7: Color.Neutrals[20], + grey8: Color.Neutrals[30], + grey9: Color.Neutrals[5], + grey10: Color.Neutrals[10], headline: primaryColors.headline, - label: '#555', - offBlack: '#444', - orange: '#ffb31a', - red: '#ca0813', + label: Color.Neutrals[70], + offBlack: Color.Neutrals[90], + orange: Color.Amber[70], + red: Color.Red[70], tableHeaderText: 'rgba(0, 0, 0, 0.54)', - tagButton: '#f1f7fd', - tagIcon: '#7daee8', - teal: '#17cf73', - white: '#fff', - yellow: '#fecf2f', + // TODO: `tagButton*` should be moved to component level. + tagButtonBg: Color.Brand[10], + tagButtonBgHover: Button.Primary.Hover.Background, + tagButtonText: Color.Brand[60], + tagButtonTextHover: Color.Neutrals.White, + tagIcon: Color.Brand[60], + tagIconHover: Button.Primary.Default.Text, + teal: Color.Teal[70], + white: Color.Neutrals.White, + yellow: Color.Yellow[70], } as const; export const textColors = { - headlineStatic: '#32363c', - linkActiveLight: '#2575d0', - tableHeader: '#888f91', - tableStatic: '#606469', - textAccessTable: '#606469', + headlineStatic: Color.Neutrals[100], + linkActiveLight: Action.Primary.Default, + linkHover: Action.Primary.Hover, + tableHeader: Color.Neutrals[60], + tableStatic: Color.Neutrals[70], + textAccessTable: Color.Neutrals[70], } as const; export const borderColors = { - borderTable: '#f4f5f6', - borderTypography: '#e3e5e8', - divider: '#e3e5e8', + borderFocus: Interaction.Border.Focus, + borderHover: Interaction.Border.Hover, + borderTable: Color.Neutrals[5], + borderTypography: Color.Neutrals[30], + divider: Color.Neutrals[30], + dividerDark: Color.Neutrals[80], +} as const; + +export const notificationToast = { + default: { + backgroundColor: NotificationToast.Informative.Background, + borderLeft: `6px solid ${NotificationToast.Informative.Border}`, + color: NotificationToast.Text, + }, + error: { + backgroundColor: NotificationToast.Error.Background, + borderLeft: `6px solid ${NotificationToast.Error.Border}`, + }, + info: { + backgroundColor: NotificationToast.Informative.Background, + borderLeft: `6px solid ${NotificationToast.Informative.Border}`, + }, + success: { + backgroundColor: NotificationToast.Success.Background, + borderLeft: `6px solid ${NotificationToast.Success.Border}`, + }, + warning: { + backgroundColor: NotificationToast.Warning.Background, + borderLeft: `6px solid ${NotificationToast.Warning.Border}`, + }, } as const; const iconCircleAnimation = { @@ -105,14 +153,18 @@ const iconCircleHoverEffect = { // Used for styling html buttons to look like our generic links const genericLinkStyle = { - '&:hover': { + '&:disabled': { + color: Action.Primary.Disabled, + cursor: 'not-allowed', + }, + '&:hover:not(:disabled)': { backgroundColor: 'transparent', - color: primaryColors.main, + color: Action.Primary.Hover, textDecoration: 'underline', }, background: 'none', border: 'none', - color: textColors.linkActiveLight, + color: Action.Primary.Default, cursor: 'pointer', font: 'inherit', minWidth: 0, @@ -196,6 +248,9 @@ export const lightTheme: ThemeOptions = { paddingBottom: 12, paddingLeft: 16, }, + '&:before': { + display: 'none', + }, flexBasis: '100%', width: '100%', }, @@ -222,8 +277,8 @@ export const lightTheme: ThemeOptions = { transition: 'color 400ms cubic-bezier(0.4, 0, 0.2, 1) 0ms', }, '& svg': { - fill: '#2575d0', - stroke: '#2575d0', + fill: Color.Brand[80], + stroke: Color.Brand[80], }, '&.Mui-expanded': { '& .caret': { @@ -249,6 +304,13 @@ export const lightTheme: ThemeOptions = { colorDefault: { backgroundColor: 'inherit', }, + root: { + backgroundColor: bg.bgPaper, + borderLeft: 0, + borderTop: 0, + color: primaryColors.text, + position: 'relative', + }, }, }, MuiAutocomplete: { @@ -268,7 +330,7 @@ export const lightTheme: ThemeOptions = { }, paddingRight: 4, svg: { - color: '#aaa', + color: Color.Neutrals[40], }, top: 'unset', }, @@ -360,7 +422,7 @@ export const lightTheme: ThemeOptions = { styleOverrides: { colorDefault: { backgroundColor: 'unset', - color: '#c9c7c7', + color: Color.Neutrals[40], // TODO: This was the closest color according to our palette }, }, }, @@ -382,20 +444,22 @@ export const lightTheme: ThemeOptions = { backgroundColor: primaryColors.text, }, '&:active': { - backgroundColor: primaryColors.dark, + backgroundColor: Button.Primary.Pressed.Background, }, '&:disabled': { - color: 'white', + backgroundColor: Button.Primary.Disabled.Background, + color: Button.Primary.Disabled.Text, }, '&:hover, &:focus': { - backgroundColor: '#226dc3', + backgroundColor: Button.Primary.Hover.Background, + color: Button.Primary.Default.Text, }, '&[aria-disabled="true"]': { - backgroundColor: 'rgba(0, 0, 0, 0.12)', - color: 'white', + backgroundColor: Button.Primary.Disabled.Background, + color: Button.Primary.Disabled.Text, }, - backgroundColor: primaryColors.main, - color: '#fff', + backgroundColor: Button.Primary.Default.Background, + color: Button.Primary.Default.Text, padding: '2px 20px', }, containedSecondary: { @@ -405,29 +469,29 @@ export const lightTheme: ThemeOptions = { }, '&:active': { backgroundColor: 'transparent', - borderColor: primaryColors.dark, - color: primaryColors.dark, + borderColor: Button.Secondary.Pressed.Text, + color: Button.Secondary.Pressed.Text, }, '&:disabled': { backgroundColor: 'transparent', - borderColor: '#c9cacb', - color: '#c9cacb', + borderColor: Button.Secondary.Disabled.Text, + color: Button.Secondary.Disabled.Text, }, '&:hover, &:focus': { backgroundColor: 'transparent', - color: textColors.linkActiveLight, + color: Button.Secondary.Hover.Text, }, '&[aria-disabled="true"]': { color: '#c9cacb', }, backgroundColor: 'transparent', - color: textColors.linkActiveLight, + color: Button.Secondary.Default.Text, }, outlined: { '&:hover, &:focus': { - backgroundColor: '#f5f8ff', - border: '1px solid #d7dfed', - color: '#2575d0', + backgroundColor: Color.Neutrals[5], + border: `1px solid ${Border.Normal}`, + color: Color.Brand[80], }, '&[aria-disabled="true"]': { backgroundColor: 'transparent', @@ -459,6 +523,12 @@ export const lightTheme: ThemeOptions = { MuiButtonBase: { styleOverrides: { root: { + '&[aria-disabled="true"]': { + '& .MuiSvgIcon-root': { + fill: Button.Primary.Disabled.Icon, + }, + cursor: 'not-allowed', + }, fontSize: '1rem', }, }, @@ -470,14 +540,14 @@ export const lightTheme: ThemeOptions = { minWidth: 0, }, root: { - backgroundColor: '#fbfbfb', + backgroundColor: Color.Neutrals[5], }, }, }, MuiCheckbox: { styleOverrides: { root: { - color: '#ccc', + color: Color.Neutrals[40], }, }, }, @@ -485,14 +555,15 @@ export const lightTheme: ThemeOptions = { styleOverrides: { clickable: { '&:focus': { - backgroundColor: '#cce2ff', + backgroundColor: Color.Brand[30], // TODO: This was the closest color according to our palette }, '&:hover': { - backgroundColor: '#cce2ff', + backgroundColor: Color.Brand[30], // TODO: This was the closest color according to our palette }, - backgroundColor: '#e5f1ff', + backgroundColor: Color.Brand[10], // TODO: This was the closest color according to our palette }, colorError: { + background: Color.Red[70], color: color.white, }, colorPrimary: { @@ -502,6 +573,7 @@ export const lightTheme: ThemeOptions = { color: color.white, }, colorSuccess: { + background: Color.Green[70], color: color.white, }, deleteIcon: { @@ -528,7 +600,7 @@ export const lightTheme: ThemeOptions = { }, root: { '&:focus': { - outline: '1px dotted #999', + outline: `1px dotted ${Color.Neutrals[60]}`, }, '&:last-child': { marginRight: 0, @@ -571,7 +643,7 @@ export const lightTheme: ThemeOptions = { MuiDialog: { styleOverrides: { paper: { - boxShadow: '0 0 5px #bbb', + boxShadow: `0 0 5px ${Color.Neutrals[50]}`, // TODO: This was the closest color according to our palette [breakpoints.down('sm')]: { margin: 24, maxHeight: 'calc(100% - 48px)', @@ -608,7 +680,7 @@ export const lightTheme: ThemeOptions = { '& h2': { lineHeight: 1.2, }, - borderBottom: '1px solid #eee', + borderBottom: `1px solid ${Color.Neutrals[20]}`, color: primaryColors.headline, marginBottom: 20, padding: '16px 24px', @@ -618,7 +690,7 @@ export const lightTheme: ThemeOptions = { MuiDivider: { styleOverrides: { root: { - borderColor: 'rgba(0, 0, 0, 0.12)', + borderColor: borderColors.divider, marginBottom: spacing, marginTop: spacing, }, @@ -627,7 +699,7 @@ export const lightTheme: ThemeOptions = { MuiDrawer: { styleOverrides: { paper: { - boxShadow: '0 0 5px #bbb', + boxShadow: `0 0 5px ${Color.Neutrals[50]}`, // TODO: This was the closest color according to our palette /** @todo This is breaking typing. */ // overflowY: 'overlay', display: 'block', @@ -641,7 +713,7 @@ export const lightTheme: ThemeOptions = { styleOverrides: { root: { '&.copy > div': { - backgroundColor: '#f4f4f4', + backgroundColor: Color.Neutrals[5], }, [breakpoints.down('xs')]: { width: '100%', @@ -675,7 +747,7 @@ export const lightTheme: ThemeOptions = { styleOverrides: { root: { '&$error': { - color: '#ca0813', + color: Select.Error.HintText, }, fontSize: '0.875rem', lineHeight: 1.25, @@ -687,16 +759,16 @@ export const lightTheme: ThemeOptions = { styleOverrides: { root: { '&$disabled': { - color: '#555', + color: Color.Neutrals[70], opacity: 0.5, }, '&$error': { - color: '#555', + color: Color.Neutrals[70], }, '&.Mui-focused': { - color: '#555', + color: Color.Neutrals[70], }, - color: '#555', + color: Color.Neutrals[70], fontFamily: latoWeb.bold, fontSize: '.875rem', marginBottom: 8, @@ -733,6 +805,9 @@ export const lightTheme: ThemeOptions = { }, }, input: { + '&::placeholder': { + color: Color.Neutrals[50], + }, boxSizing: 'border-box', [breakpoints.only('xs')]: { fontSize: '1rem', @@ -748,14 +823,14 @@ export const lightTheme: ThemeOptions = { root: { '& svg': { '&:hover': { - color: '#5e9aea', + color: Color.Brand[60], }, color: primaryColors.main, fontSize: 18, }, '&.Mui-disabled': { backgroundColor: '#f4f4f4', - borderColor: '#ccc', + borderColor: Color.Neutrals[40], color: 'rgba(0, 0, 0, 0.75)', input: { cursor: 'not-allowed', @@ -763,21 +838,21 @@ export const lightTheme: ThemeOptions = { opacity: 0.5, }, '&.Mui-error': { - borderColor: '#ca0813', + borderColor: Interaction.Border.Error, }, '&.Mui-focused': { '& .select-option-icon': { paddingLeft: `30px !important`, }, borderColor: primaryColors.main, - boxShadow: '0 0 2px 1px #e1edfa', + boxShadow: `0 0 2px 1px ${Color.Neutrals[30]}`, }, '&.affirmative': { - borderColor: '#00b159', + borderColor: Color.Green[70], }, alignItems: 'center', - backgroundColor: '#fff', - border: '1px solid #ccc', + backgroundColor: Color.Neutrals.White, + border: `1px solid ${Color.Neutrals[40]}`, boxSizing: 'border-box', [breakpoints.down('xs')]: { maxWidth: '100%', @@ -801,13 +876,13 @@ export const lightTheme: ThemeOptions = { [breakpoints.only('xs')]: { fontSize: '1rem', }, - color: '#606469', + color: Color.Neutrals[70], fontSize: '0.9rem', }, [breakpoints.only('xs')]: { fontSize: '1rem', }, - color: '#606469', + color: Color.Neutrals[70], fontSize: '0.9rem', whiteSpace: 'nowrap', }, @@ -817,7 +892,7 @@ export const lightTheme: ThemeOptions = { styleOverrides: { input: { '&::placeholder': { - opacity: 0.42, + opacity: 1, }, height: 'auto', }, @@ -836,7 +911,7 @@ export const lightTheme: ThemeOptions = { MuiLinearProgress: { styleOverrides: { colorPrimary: { - backgroundColor: '#b7d6f9', + backgroundColor: Color.Brand[40], // TODO: This was the closest color according to our palette }, }, }, @@ -921,6 +996,8 @@ export const lightTheme: ThemeOptions = { outline: 0, position: 'absolute', }, + borderLeft: 0, + borderRight: 0, maxWidth: 350, }, }, @@ -928,28 +1005,31 @@ export const lightTheme: ThemeOptions = { MuiMenuItem: { styleOverrides: { root: { - '& em': { - fontStyle: 'normal !important', + '&.loading': { + backgroundColor: primaryColors.text, }, - '&$selected, &$selected:hover': { - backgroundColor: 'transparent', - color: primaryColors.main, - opacity: 1, + '&:active': { + backgroundColor: Dropdown.Background.Default, }, - '&:hover': { - backgroundColor: primaryColors.main, - color: 'white', + '&:disabled': { + backgroundColor: Dropdown.Background.Default, + color: Dropdown.Text.Disabled, }, - color: primaryColors.text, - fontFamily: latoWeb.normal, - fontSize: '.9rem', - height: 'auto', - minHeight: '38px', - paddingBottom: 8, - paddingTop: 8, - textOverflow: 'initial', - transition: `${'background-color 150ms cubic-bezier(0.4, 0, 0.2, 1)'}`, - whiteSpace: 'initial', + '&:hover, &:focus': { + backgroundColor: Dropdown.Background.Hover, + color: Dropdown.Text.Default, + }, + '&:last-child)': { + borderBottom: 0, + }, + '&[aria-disabled="true"]': { + backgroundColor: Dropdown.Background.Default, + color: Dropdown.Text.Disabled, + opacity: 1, + }, + backgroundColor: Dropdown.Background.Default, + color: Dropdown.Text.Default, + padding: '10px 10px 10px 16px', }, selected: {}, }, @@ -957,7 +1037,7 @@ export const lightTheme: ThemeOptions = { MuiPaper: { styleOverrides: { outlined: { - border: '1px solid #e7e7e7', + border: `1px solid ${Color.Neutrals[30]}`, }, root: {}, rounded: { @@ -969,7 +1049,7 @@ export const lightTheme: ThemeOptions = { styleOverrides: { paper: { borderRadius: 0, - boxShadow: '0 0 5px #ddd', + boxShadow: `0 2px 6px 0 rgba(0, 0, 0, 0.18)`, // TODO: Fix Elevation.S to remove `inset` [breakpoints.up('lg')]: { minWidth: 250, }, @@ -1004,10 +1084,10 @@ export const lightTheme: ThemeOptions = { }, '&.Mui-disabled': { '& .defaultFill': { - fill: '#f4f4f4', + fill: Color.Neutrals[5], }, - color: '#ccc !important', - fill: '#f4f4f4 !important', + color: `${Color.Neutrals[40]} !important`, + fill: `${Color.Neutrals[5]} !important`, pointerEvents: 'none', }, '&:hover': { @@ -1017,7 +1097,7 @@ export const lightTheme: ThemeOptions = { color: theme.palette.primary.main, fill: theme.color.white, }, - color: '#ccc', + color: Color.Neutrals[40], padding: '10px 10px', transition: theme.transitions.create(['color']), }), @@ -1027,7 +1107,7 @@ export const lightTheme: ThemeOptions = { styleOverrides: { disabled: {}, icon: { - color: '#aaa !important', + color: `${Color.Neutrals[50]} !important`, height: 28, marginRight: 4, marginTop: -2, @@ -1061,8 +1141,8 @@ export const lightTheme: ThemeOptions = { backgroundColor: 'white', borderLeft: `6px solid transparent`, borderRadius: 4, - boxShadow: '0 0 5px #ddd', - color: '#606469', + boxShadow: `0 0 5px ${Color.Neutrals[30]}`, + color: Color.Neutrals[70], }, }, }, @@ -1095,8 +1175,8 @@ export const lightTheme: ThemeOptions = { '& $disabled': { '&$switchBase': { '& + $track': { - backgroundColor: '#ddd', - borderColor: '#ccc', + backgroundColor: Color.Neutrals[30], + borderColor: Color.Neutrals[40], }, '& .square': { fill: 'white', @@ -1134,15 +1214,15 @@ export const lightTheme: ThemeOptions = { }, '&.Mui-disabled': { '& +.MuiSwitch-track': { - backgroundColor: '#ddd', - borderColor: '#ccc', + backgroundColor: Color.Neutrals[30], + borderColor: Color.Neutrals[40], }, }, color: primaryColors.main, padding: 16, }, track: { - backgroundColor: '#C9CACB', + backgroundColor: Color.Neutrals[40], borderRadius: 1, boxSizing: 'content-box', height: 24, @@ -1190,7 +1270,7 @@ export const lightTheme: ThemeOptions = { selected: {}, textColorPrimary: { '&$selected': { - color: '#32363c', + color: Color.Neutrals[100], }, }, }, @@ -1198,7 +1278,10 @@ export const lightTheme: ThemeOptions = { MuiTable: { styleOverrides: { root: { + border: `1px solid ${borderColors.borderTable}`, + borderBottom: 0, borderCollapse: 'initial', + borderTop: 0, }, }, }, @@ -1222,7 +1305,7 @@ export const lightTheme: ThemeOptions = { MuiTableRow: { styleOverrides: { head: { - backgroundColor: '#fbfbfb', + backgroundColor: Color.Neutrals[5], height: 'auto', }, hover: { @@ -1239,12 +1322,7 @@ export const lightTheme: ThemeOptions = { }, root: { '&:hover, &:focus': { - '&$hover': { - backgroundColor: '#fbfbfb', - [breakpoints.up('md')]: { - boxShadow: `inset 5px 0 0 ${primaryColors.main}`, - }, - }, + backgroundColor: Color.Neutrals[5], }, backfaceVisibility: 'hidden', backgroundColor: primaryColors.white, @@ -1268,11 +1346,8 @@ export const lightTheme: ThemeOptions = { transform: 'rotate(180deg)', }, root: { - '&.Mui-active': { - color: textColors.tableHeader, - }, '&:focus': { - outline: '1px dotted #999', + outline: `1px dotted ${Color.Neutrals[60]}`, }, '&:hover': { color: primaryColors.main, @@ -1319,7 +1394,7 @@ export const lightTheme: ThemeOptions = { width: 38, }, }, - boxShadow: 'inset 0 -1px 0 #c5c6c8', + boxShadow: `inset 0 -1px 0 ${Color.Neutrals[40]}`, margin: '16px 0', minHeight: 48, position: 'relative', @@ -1337,12 +1412,12 @@ export const lightTheme: ThemeOptions = { tooltip: { backgroundColor: 'white', borderRadius: 0, - boxShadow: '0 0 5px #bbb', + boxShadow: `0 0 5px ${Color.Neutrals[50]}`, // TODO: This was the closest color according to our palette [breakpoints.up('sm')]: { fontSize: '.9rem', padding: '8px 10px', }, - color: '#606469', + color: Color.Neutrals[70], maxWidth: 200, textAlign: 'left', }, @@ -1377,7 +1452,7 @@ export const lightTheme: ThemeOptions = { maxHeight: 34, minWidth: 100, }, - color: '#fff', + color: Color.Neutrals.White, cursor: 'pointer', fontFamily: latoWeb.bold, fontSize: '1rem', @@ -1456,37 +1531,78 @@ export const lightTheme: ThemeOptions = { }, yellow: `rgba(255, 220, 125, ${graphTransparency})`, }, + inputStyles: { + default: { + backgroundColor: Select.Default.Background, + border: `1px solid ${Color.Neutrals[40]}`, // TODO: This should convert to token in future + color: Select.Default.Text, + }, + disabled: { + '& svg': { + color: Select.Disabled.Icon, + }, + backgroundColor: Select.Disabled.Background, + border: `1px solid ${Select.Disabled.Border}`, + color: Select.Disabled.Text, + }, + error: { + '& svg': { + color: Select.Error.Icon, + }, + backgroundColor: Select.Error.Background, + border: `1px solid ${Select.Error.Border}`, + color: Select.Error.Text, + }, + focused: { + '& svg': { + color: Select.Focus.Icon, + }, + backgroundColor: Select.Focus.Background, + border: `1px solid ${Select.Focus.Border}`, + boxShadow: `0 0 2px 1px ${Color.Neutrals[30]}`, + color: Select.Focus.Text, + }, + hover: { + '& svg': { + color: Select.Hover.Icon, + }, + backgroundColor: Select.Hover.Background, + border: `1px solid ${Color.Neutrals[40]}`, // TODO: This should convert to token in future + color: Select.Hover.Text, + }, + }, name: 'light', // @todo remove this because we leverage pallete.mode now + notificationToast, palette: { background: { default: bg.app, }, divider: primaryColors.divider, error: { - dark: color.red, - light: color.red, - main: color.red, + dark: Color.Red[70], + light: Color.Red[10], + main: Color.Red[40], }, info: { - dark: '#3682dd', - light: '#d7e3ef', - main: '#d7e3ef', + dark: Color.Ultramarine[70], + light: Color.Ultramarine[10], + main: Color.Ultramarine[40], }, mode: 'light', primary: primaryColors, secondary: primaryColors, success: { - dark: '#00b159', - light: '#00b159', - main: '#00b159', + dark: Color.Green[70], + light: Color.Green[10], + main: Color.Green[40], }, text: { primary: primaryColors.text, }, warning: { - dark: '#ffd002', - light: '#ffd002', - main: '#ffd002', + dark: Color.Amber[70], + light: Color.Amber[10], + main: Color.Amber[40], }, }, shadows: [ diff --git a/packages/manager/src/hooks/useDebouncedValue.test.ts b/packages/manager/src/hooks/useDebouncedValue.test.ts new file mode 100644 index 00000000000..cf560b93944 --- /dev/null +++ b/packages/manager/src/hooks/useDebouncedValue.test.ts @@ -0,0 +1,32 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useDebouncedValue } from './useDebouncedValue'; + +describe('useDebouncedValue', () => { + it('debounces the provided value by the given delay', () => { + vi.useFakeTimers(); + + const { rerender, result } = renderHook( + ({ value }) => useDebouncedValue(value, 500), + { initialProps: { value: 'test' } } + ); + + expect(result.current).toBe('test'); + + rerender({ value: 'test-1' }); + + expect(result.current).toBe('test'); + + act(() => { + vi.advanceTimersByTime(400); + }); + + expect(result.current).toBe('test'); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(result.current).toBe('test-1'); + }); +}); diff --git a/packages/manager/src/hooks/useDebouncedValue.ts b/packages/manager/src/hooks/useDebouncedValue.ts new file mode 100644 index 00000000000..526ed0a470a --- /dev/null +++ b/packages/manager/src/hooks/useDebouncedValue.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export const useDebouncedValue = (value: T, delay: number = 500) => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +}; diff --git a/packages/manager/src/hooks/useFlags.ts b/packages/manager/src/hooks/useFlags.ts index f3f64b623bd..36da4807a00 100644 --- a/packages/manager/src/hooks/useFlags.ts +++ b/packages/manager/src/hooks/useFlags.ts @@ -1,8 +1,8 @@ import { useFlags as ldUseFlags } from 'launchdarkly-react-client-sdk'; import { useSelector } from 'react-redux'; -import { FlagSet } from 'src/featureFlags'; -import { ApplicationState } from 'src/store'; +import type { FlagSet } from 'src/featureFlags'; +import type { ApplicationState } from 'src/store'; export { useLDClient } from 'launchdarkly-react-client-sdk'; /** @@ -29,5 +29,9 @@ export const useFlags = () => { return { ...flags, ...mockFlags, + // gecko2: { + // enabled: true, + // ga: true, + // }, }; }; diff --git a/packages/manager/src/index.css b/packages/manager/src/index.css index d5a52915b96..86b93124e98 100644 --- a/packages/manager/src/index.css +++ b/packages/manager/src/index.css @@ -305,10 +305,6 @@ button::-moz-focus-inner { } } -.fade-in-table { - animation: fadeIn 0.3s ease-in-out; -} - @keyframes pulse { to { background-color: hsla(40, 100%, 55%, 0); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index f0ad8f8fc6d..198d63e9244 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -107,6 +107,7 @@ import type { ObjectStorageKeyRequest, SecurityQuestionsPayload, TokenRequest, + UpdateImageRegionsPayload, User, VolumeStatus, } from '@linode/api-v4'; @@ -607,6 +608,20 @@ export const handlers = [ http.get('*/regions', async () => { return HttpResponse.json(makeResourcePage(regions)); }), + http.get<{ id: string }>('*/v4/images/:id', ({ params }) => { + const distributedImage = imageFactory.build({ + capabilities: ['cloud-init', 'distributed-images'], + id: 'private/distributed-image', + label: 'distributed-image', + regions: [{ region: 'us-east', status: 'available' }], + }); + + if (params.id === distributedImage.id) { + return HttpResponse.json(distributedImage); + } + + return HttpResponse.json(imageFactory.build()); + }), http.get('*/images', async ({ request }) => { const privateImages = imageFactory.buildList(5, { status: 'available', @@ -652,6 +667,7 @@ export const handlers = [ const publicImages = imageFactory.buildList(4, { is_public: true }); const distributedImage = imageFactory.build({ capabilities: ['cloud-init', 'distributed-images'], + id: 'private/distributed-image', label: 'distributed-image', regions: [{ region: 'us-east', status: 'available' }], }); @@ -666,16 +682,37 @@ export const handlers = [ ...pendingImages, ...creatingImages, ]; - return HttpResponse.json( - makeResourcePage( - images.filter((image) => - request.headers.get('x-filter')?.includes('manual') - ? image.type == 'manual' - : image.type == 'automatic' - ) - ) - ); + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json( + makeResourcePage(images.filter((image) => image.type === 'manual')) + ); + } + + if (filter?.includes('automatic')) { + return HttpResponse.json( + makeResourcePage(images.filter((image) => image.type === 'automatic')) + ); + } + + return HttpResponse.json(makeResourcePage(images)); }), + http.post( + '*/v4/images/:id/regions', + async ({ request }) => { + const data = await request.json(); + + const image = imageFactory.build(); + + image.regions = data.regions.map((regionId) => ({ + region: regionId, + status: 'pending replication', + })); + + return HttpResponse.json(image); + } + ), http.get('*/linode/types', () => { return HttpResponse.json( @@ -2336,6 +2373,64 @@ export const handlers = [ return HttpResponse.json(response); }), + http.get('*/v4/monitor/services/linode/dashboards', () => { + const response = { + data: [ + { + id: 1, + type: 'standard', + service_type: 'linode', + label: 'Linode Service I/O Statistics', + created: '2024-04-29T17:09:29', + updated: null, + widgets: [ + { + metric: 'system_cpu_utilization_percent', + unit: '%', + label: 'CPU utilization', + color: 'blue', + size: 12, + chart_type: 'area', + y_label: 'system_cpu_utilization_ratio', + aggregate_function: 'avg', + }, + { + metric: 'system_memory_usage_by_resource', + unit: 'Bytes', + label: 'Memory Usage', + color: 'red', + size: 12, + chart_type: 'area', + y_label: 'system_memory_usage_bytes', + aggregate_function: 'avg', + }, + { + metric: 'system_network_io_by_resource', + unit: 'Bytes', + label: 'Network Traffic', + color: 'green', + size: 6, + chart_type: 'area', + y_label: 'system_network_io_bytes_total', + aggregate_function: 'avg', + }, + { + metric: 'system_disk_OPS_total', + unit: 'OPS', + label: 'Disk I/O', + color: 'yellow', + size: 6, + chart_type: 'area', + y_label: 'system_disk_operations_total', + aggregate_function: 'avg', + }, + ], + }, + ], + }; + + return HttpResponse.json(response); + }), ...entityTransfers, ...statusPage, ...databases, diff --git a/packages/manager/src/queries/aclb/certificates.ts b/packages/manager/src/queries/aclb/certificates.ts index 840dc4537ea..24d9586e6d6 100644 --- a/packages/manager/src/queries/aclb/certificates.ts +++ b/packages/manager/src/queries/aclb/certificates.ts @@ -1,8 +1,6 @@ import { createLoadbalancerCertificate, deleteLoadbalancerCertificate, - getLoadbalancerCertificate, - getLoadbalancerCertificates, updateLoadbalancerCertificate, } from '@linode/api-v4'; import { @@ -12,7 +10,7 @@ import { useQueryClient, } from '@tanstack/react-query'; -import { QUERY_KEY } from './loadbalancers'; +import { aclbQueries } from './queries'; import type { APIError, @@ -29,19 +27,12 @@ export const useLoadBalancerCertificatesQuery = ( params: Params, filter: Filter ) => { - return useQuery, APIError[]>( - [ - QUERY_KEY, - 'loadbalancer', - id, - 'certificates', - 'paginated', - params, - filter, - ], - () => getLoadbalancerCertificates(id, params, filter), - { keepPreviousData: true } - ); + return useQuery, APIError[]>({ + ...aclbQueries + .loadbalancer(id) + ._ctx.certificates._ctx.lists._ctx.paginated(params, filter), + keepPreviousData: true, + }); }; export const useLoadbalancerCertificateQuery = ( @@ -49,48 +40,34 @@ export const useLoadbalancerCertificateQuery = ( certificateId: number, enabled = true ) => { - return useQuery( - [ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'certificates', - 'certificate', - certificateId, - ], - () => getLoadbalancerCertificate(loadbalancerId, certificateId), - { enabled } - ); + return useQuery({ + ...aclbQueries + .loadbalancer(loadbalancerId) + ._ctx.certificates._ctx.certificate(certificateId), + enabled, + }); }; export const useLoadBalancerCertificateCreateMutation = ( loadbalancerId: number ) => { const queryClient = useQueryClient(); - return useMutation( - (data) => createLoadbalancerCertificate(loadbalancerId, data), - { - onSuccess(certificate) { - queryClient.invalidateQueries([ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'certificates', - ]); - queryClient.setQueryData( - [ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'certificates', - 'certificate', - certificate.id, - ], - certificate - ); - }, - } - ); + return useMutation({ + mutationFn: (data) => createLoadbalancerCertificate(loadbalancerId, data), + onSuccess(certificate) { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.certificates + ._ctx.lists.queryKey, + }); + + queryClient.setQueryData( + aclbQueries + .loadbalancer(loadbalancerId) + ._ctx.certificates._ctx.certificate(certificate.id).queryKey, + certificate + ); + }, + }); }; export const useLoadBalancerCertificateMutation = ( @@ -98,31 +75,23 @@ export const useLoadBalancerCertificateMutation = ( certificateId: number ) => { const queryClient = useQueryClient(); - return useMutation( - (data) => + return useMutation({ + mutationFn: (data) => updateLoadbalancerCertificate(loadbalancerId, certificateId, data), - { - onSuccess(certificate) { - queryClient.setQueryData( - [ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'certificates', - 'certificate', - certificate.id, - ], - certificate - ); - queryClient.invalidateQueries([ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'certificates', - ]); - }, - } - ); + onSuccess(certificate) { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.certificates + ._ctx.lists.queryKey, + }); + + queryClient.setQueryData( + aclbQueries + .loadbalancer(loadbalancerId) + ._ctx.certificates._ctx.certificate(certificate.id).queryKey, + certificate + ); + }, + }); }; export const useLoadBalancerCertificateDeleteMutation = ( @@ -130,48 +99,36 @@ export const useLoadBalancerCertificateDeleteMutation = ( certificateId: number ) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>( - () => deleteLoadbalancerCertificate(loadbalancerId, certificateId), - { - onSuccess() { - queryClient.removeQueries([ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'certificates', - 'certificate', - certificateId, - ]); - queryClient.invalidateQueries([ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'certificates', - ]); - }, - } - ); + return useMutation<{}, APIError[]>({ + mutationFn: () => + deleteLoadbalancerCertificate(loadbalancerId, certificateId), + onSuccess() { + queryClient.removeQueries({ + queryKey: aclbQueries + .loadbalancer(loadbalancerId) + ._ctx.certificates._ctx.certificate(certificateId).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.certificates + ._ctx.lists.queryKey, + }); + }, + }); }; export const useLoadBalancerCertificatesInfiniteQuery = ( id: number, filter: Filter = {} ) => { - return useInfiniteQuery, APIError[]>( - [QUERY_KEY, 'loadbalancer', id, 'certificates', 'infinite', filter], - ({ pageParam }) => - getLoadbalancerCertificates( - id, - { page: pageParam, page_size: 25 }, - filter - ), - { - getNextPageParam: ({ page, pages }) => { - if (page === pages) { - return undefined; - } - return page + 1; - }, - } - ); + return useInfiniteQuery, APIError[]>({ + ...aclbQueries + .loadbalancer(id) + ._ctx.certificates._ctx.lists._ctx.infinite(filter), + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + }); }; diff --git a/packages/manager/src/queries/aclb/configurations.ts b/packages/manager/src/queries/aclb/configurations.ts index 8c7dfe347ce..2a54b7233f3 100644 --- a/packages/manager/src/queries/aclb/configurations.ts +++ b/packages/manager/src/queries/aclb/configurations.ts @@ -1,8 +1,6 @@ import { createLoadbalancerConfiguration, deleteLoadbalancerConfiguration, - getLoadbalancerConfigurations, - getLoadbalancerConfigurationsEndpointHealth, updateLoadbalancerConfiguration, } from '@linode/api-v4'; import { @@ -12,7 +10,7 @@ import { useQueryClient, } from '@tanstack/react-query'; -import { QUERY_KEY } from './loadbalancers'; +import { aclbQueries } from './queries'; import type { APIError, @@ -30,25 +28,20 @@ export const useLoadBalancerConfigurationsQuery = ( params?: Params, filter?: Filter ) => { - return useQuery, APIError[]>( - [QUERY_KEY, 'aclb', loadbalancerId, 'configurations', params, filter], - () => getLoadbalancerConfigurations(loadbalancerId, params, filter), - { keepPreviousData: true } - ); + return useQuery, APIError[]>({ + ...aclbQueries + .loadbalancer(loadbalancerId) + ._ctx.configurations._ctx.lists._ctx.paginated(params, filter), + keepPreviousData: true, + }); }; export const useLoadBalancerConfigurationsEndpointsHealth = ( loadbalancerId: number ) => { return useQuery({ - queryFn: () => getLoadbalancerConfigurationsEndpointHealth(loadbalancerId), - queryKey: [ - QUERY_KEY, - 'aclb', - loadbalancerId, - 'configurations', - 'endpoint-health', - ], + ...aclbQueries.loadbalancer(loadbalancerId)._ctx.configurations._ctx + .endpointHealth, refetchInterval: 10_000, }); }; @@ -56,22 +49,16 @@ export const useLoadBalancerConfigurationsEndpointsHealth = ( export const useLoabalancerConfigurationsInfiniteQuery = ( loadbalancerId: number ) => { - return useInfiniteQuery, APIError[]>( - [QUERY_KEY, 'aclb', loadbalancerId, 'configurations', 'infinite'], - ({ pageParam }) => - getLoadbalancerConfigurations(loadbalancerId, { - page: pageParam, - page_size: 25, - }), - { - getNextPageParam: ({ page, pages }) => { - if (page === pages) { - return undefined; - } - return page + 1; - }, - } - ); + return useInfiniteQuery, APIError[]>({ + ...aclbQueries.loadbalancer(loadbalancerId)._ctx.configurations._ctx.lists + ._ctx.infinite, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + }); }; export const useLoadBalancerConfigurationMutation = ( @@ -80,23 +67,23 @@ export const useLoadBalancerConfigurationMutation = ( ) => { const queryClient = useQueryClient(); - return useMutation( - (data) => + return useMutation({ + mutationFn: (data) => updateLoadbalancerConfiguration(loadbalancerId, configurationId, data), - { - onSuccess() { - queryClient.invalidateQueries([ - QUERY_KEY, - 'aclb', - loadbalancerId, - 'configurations', - ]); - // The GET /v4/aclb endpoint also returns configuration data that we must update - queryClient.invalidateQueries([QUERY_KEY, 'paginated']); - queryClient.invalidateQueries([QUERY_KEY, 'aclb', loadbalancerId]); - }, - } - ); + onSuccess() { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.configurations + ._ctx.lists.queryKey, + }); + // The GET /v4/aclb endpoint also returns configuration data that we must update + // the paginated list and the ACLB object + queryClient.invalidateQueries({ queryKey: aclbQueries.paginated._def }); + queryClient.invalidateQueries({ + exact: true, + queryKey: aclbQueries.loadbalancer(loadbalancerId).queryKey, + }); + }, + }); }; export const useLoadBalancerConfigurationCreateMutation = ( @@ -104,22 +91,22 @@ export const useLoadBalancerConfigurationCreateMutation = ( ) => { const queryClient = useQueryClient(); - return useMutation( - (data) => createLoadbalancerConfiguration(loadbalancerId, data), - { - onSuccess() { - queryClient.invalidateQueries([ - QUERY_KEY, - 'aclb', - loadbalancerId, - 'configurations', - ]); - // The GET /v4/aclb endpoint also returns configuration data that we must update - queryClient.invalidateQueries([QUERY_KEY, 'paginated']); - queryClient.invalidateQueries([QUERY_KEY, 'aclb', loadbalancerId]); - }, - } - ); + return useMutation({ + mutationFn: (data) => createLoadbalancerConfiguration(loadbalancerId, data), + onSuccess() { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.configurations + ._ctx.lists.queryKey, + }); + // The GET /v4/aclb endpoint also returns configuration data that we must update + // the paginated list and the ACLB object + queryClient.invalidateQueries({ queryKey: aclbQueries.paginated._def }); + queryClient.invalidateQueries({ + exact: true, + queryKey: aclbQueries.loadbalancer(loadbalancerId).queryKey, + }); + }, + }); }; export const useLoadBalancerConfigurationDeleteMutation = ( @@ -128,20 +115,21 @@ export const useLoadBalancerConfigurationDeleteMutation = ( ) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>( - () => deleteLoadbalancerConfiguration(loadbalancerId, configurationId), - { - onSuccess() { - queryClient.invalidateQueries([ - QUERY_KEY, - 'aclb', - loadbalancerId, - 'configurations', - ]); - // The GET /v4/aclb endpoint also returns configuration data that we must update - queryClient.invalidateQueries([QUERY_KEY, 'paginated']); - queryClient.invalidateQueries([QUERY_KEY, 'aclb', loadbalancerId]); - }, - } - ); + return useMutation<{}, APIError[]>({ + mutationFn: () => + deleteLoadbalancerConfiguration(loadbalancerId, configurationId), + onSuccess() { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.configurations + ._ctx.lists.queryKey, + }); + // The GET /v4/aclb endpoint also returns configuration data that we must update + // the paginated list and the ACLB object + queryClient.invalidateQueries({ queryKey: aclbQueries.paginated._def }); + queryClient.invalidateQueries({ + exact: true, + queryKey: aclbQueries.loadbalancer(loadbalancerId).queryKey, + }); + }, + }); }; diff --git a/packages/manager/src/queries/aclb/loadbalancers.ts b/packages/manager/src/queries/aclb/loadbalancers.ts index b46215e267f..eb437f6e635 100644 --- a/packages/manager/src/queries/aclb/loadbalancers.ts +++ b/packages/manager/src/queries/aclb/loadbalancers.ts @@ -2,13 +2,12 @@ import { createBasicLoadbalancer, createLoadbalancer, deleteLoadbalancer, - getLoadbalancer, - getLoadbalancerEndpointHealth, - getLoadbalancers, updateLoadbalancer, } from '@linode/api-v4'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { aclbQueries } from './queries'; + import type { APIError, CreateBasicLoadbalancerPayload, @@ -21,75 +20,86 @@ import type { UpdateLoadbalancerPayload, } from '@linode/api-v4'; -export const QUERY_KEY = 'aclbs'; - export const useLoadBalancersQuery = (params?: Params, filter?: Filter) => { - return useQuery, APIError[]>( - [QUERY_KEY, 'paginated', params, filter], - () => getLoadbalancers(params, filter), - { keepPreviousData: true } - ); + return useQuery, APIError[]>({ + ...aclbQueries.paginated(params, filter), + keepPreviousData: true, + }); }; export const useLoadBalancerQuery = (id: number, enabled = true) => { - return useQuery( - [QUERY_KEY, 'aclb', id], - () => getLoadbalancer(id), - { enabled } - ); + return useQuery({ + ...aclbQueries.loadbalancer(id), + enabled, + }); }; export const useLoadBalancerEndpointHealthQuery = (id: number) => { return useQuery({ - queryFn: () => getLoadbalancerEndpointHealth(id), - queryKey: [QUERY_KEY, 'aclb', id, 'endpoint-health'], + ...aclbQueries.loadbalancer(id)._ctx.endpointHealth, refetchInterval: 10_000, }); }; export const useLoadBalancerMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => updateLoadbalancer(id, data), - { - onSuccess(data) { - queryClient.setQueryData([QUERY_KEY, 'aclb', id], data); - queryClient.invalidateQueries([QUERY_KEY, 'paginated']); - }, - } - ); + return useMutation({ + mutationFn: (data) => updateLoadbalancer(id, data), + onSuccess(loadbalancer) { + queryClient.setQueryData( + aclbQueries.loadbalancer(id).queryKey, + loadbalancer + ); + queryClient.invalidateQueries({ + queryKey: aclbQueries.paginated._def, + }); + }, + }); }; export const useLoadBalancerBasicCreateMutation = () => { const queryClient = useQueryClient(); - return useMutation( - (data) => createBasicLoadbalancer(data), - { - onSuccess(data) { - queryClient.setQueryData([QUERY_KEY, 'aclb', data.id], data); - queryClient.invalidateQueries([QUERY_KEY, 'paginated']); - }, - } - ); + return useMutation({ + mutationFn: createBasicLoadbalancer, + onSuccess(loadbalancer) { + queryClient.setQueryData( + aclbQueries.loadbalancer(loadbalancer.id).queryKey, + loadbalancer + ); + queryClient.invalidateQueries({ + queryKey: aclbQueries.paginated._def, + }); + }, + }); }; export const useLoadBalancerCreateMutation = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: createLoadbalancer, - onSuccess(data) { - queryClient.setQueryData([QUERY_KEY, 'aclb', data.id], data); - queryClient.invalidateQueries([QUERY_KEY, 'paginated']); + onSuccess(loadbalancer) { + queryClient.setQueryData( + aclbQueries.loadbalancer(loadbalancer.id).queryKey, + loadbalancer + ); + queryClient.invalidateQueries({ + queryKey: aclbQueries.paginated._def, + }); }, }); }; export const useLoadBalancerDeleteMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => deleteLoadbalancer(id), { + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteLoadbalancer(id), onSuccess() { - queryClient.removeQueries([QUERY_KEY, 'aclb', id]); - queryClient.invalidateQueries([QUERY_KEY, 'paginated']); + queryClient.removeQueries({ + queryKey: aclbQueries.loadbalancer(id).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: aclbQueries.paginated._def, + }); }, }); }; diff --git a/packages/manager/src/queries/aclb/queries.ts b/packages/manager/src/queries/aclb/queries.ts new file mode 100644 index 00000000000..abe18c7e096 --- /dev/null +++ b/packages/manager/src/queries/aclb/queries.ts @@ -0,0 +1,146 @@ +import { + getLoadbalancer, + getLoadbalancerCertificate, + getLoadbalancerCertificates, + getLoadbalancerConfigurations, + getLoadbalancerConfigurationsEndpointHealth, + getLoadbalancerEndpointHealth, + getLoadbalancerRoutes, + getLoadbalancerServiceTargets, + getLoadbalancers, + getServiceTargetsEndpointHealth, +} from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +import type { Filter, Params } from '@linode/api-v4'; + +export const aclbQueries = createQueryKeys('aclbs', { + loadbalancer: (id: number) => ({ + contextQueries: { + certificates: { + contextQueries: { + certificate: (certificateId: number) => ({ + queryFn: () => getLoadbalancerCertificate(id, certificateId), + queryKey: [certificateId], + }), + lists: { + contextQueries: { + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getLoadbalancerCertificates( + id, + { + page: pageParam, + page_size: 25, + }, + filter + ), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getLoadbalancerCertificates(id, params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + }, + queryKey: null, + }, + configurations: { + contextQueries: { + endpointHealth: { + queryFn: () => getLoadbalancerConfigurationsEndpointHealth(id), + queryKey: null, + }, + lists: { + contextQueries: { + infinite: { + queryFn: ({ pageParam }) => + getLoadbalancerConfigurations(id, { + page: pageParam, + page_size: 25, + }), + queryKey: null, + }, + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => + getLoadbalancerConfigurations(id, params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + }, + queryKey: null, + }, + endpointHealth: { + queryFn: () => getLoadbalancerEndpointHealth(id), + queryKey: null, + }, + routes: { + contextQueries: { + lists: { + contextQueries: { + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getLoadbalancerRoutes( + id, + { + page: pageParam, + page_size: 25, + }, + filter + ), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getLoadbalancerRoutes(id, params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + }, + queryKey: null, + }, + serviceTargets: { + contextQueries: { + endpointHealth: { + queryFn: () => getServiceTargetsEndpointHealth(id), + queryKey: null, + }, + lists: { + contextQueries: { + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getLoadbalancerServiceTargets( + id, + { + page: pageParam, + page_size: 25, + }, + filter + ), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => + getLoadbalancerServiceTargets(id, params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + }, + queryKey: null, + }, + }, + queryFn: () => getLoadbalancer(id), + queryKey: [id], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getLoadbalancers(params, filter), + queryKey: [params, filter], + }), +}); diff --git a/packages/manager/src/queries/aclb/routes.ts b/packages/manager/src/queries/aclb/routes.ts index 2769d9846d3..3c3cf062e5d 100644 --- a/packages/manager/src/queries/aclb/routes.ts +++ b/packages/manager/src/queries/aclb/routes.ts @@ -1,8 +1,6 @@ import { - CreateRoutePayload, createLoadbalancerRoute, deleteLoadbalancerRoute, - getLoadbalancerRoutes, updateLoadbalancerRoute, } from '@linode/api-v4'; import { @@ -13,10 +11,11 @@ import { } from '@tanstack/react-query'; import { updateInPaginatedStore } from '../base'; -import { QUERY_KEY } from './loadbalancers'; +import { aclbQueries } from './queries'; import type { APIError, + CreateRoutePayload, Filter, Params, ResourcePage, @@ -29,28 +28,25 @@ export const useLoadBalancerRoutesQuery = ( params: Params, filter: Filter ) => { - return useQuery, APIError[]>( - [QUERY_KEY, 'loadbalancer', id, 'routes', 'paginated', params, filter], - () => getLoadbalancerRoutes(id, params, filter), - { keepPreviousData: true } - ); + return useQuery, APIError[]>({ + ...aclbQueries + .loadbalancer(id) + ._ctx.routes._ctx.lists._ctx.paginated(params, filter), + keepPreviousData: true, + }); }; export const useLoadBalancerRouteCreateMutation = (loadbalancerId: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => createLoadbalancerRoute(loadbalancerId, data), - { - onSuccess() { - queryClient.invalidateQueries([ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'routes', - ]); - }, - } - ); + return useMutation({ + mutationFn: (data) => createLoadbalancerRoute(loadbalancerId, data), + onSuccess() { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.routes._ctx + .lists.queryKey, + }); + }, + }); }; export const useLoadBalancerRouteUpdateMutation = ( @@ -58,31 +54,35 @@ export const useLoadBalancerRouteUpdateMutation = ( routeId: number ) => { const queryClient = useQueryClient(); - return useMutation( - (data) => updateLoadbalancerRoute(loadbalancerId, routeId, data), - { - onError() { - // On error, refetch to keep the client in sync with the API - queryClient.invalidateQueries([ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'routes', - ]); - }, - onMutate(variables) { - const key = [ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'routes', - 'paginated', - ]; - // Optimistically update the route on mutate - updateInPaginatedStore(key, routeId, variables, queryClient); - }, - } - ); + return useMutation({ + mutationFn: (data) => + updateLoadbalancerRoute(loadbalancerId, routeId, data), + onError() { + // On error, refetch to keep the client in sync with the API + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.routes._ctx + .lists.queryKey, + }); + }, + onMutate(variables) { + const key = aclbQueries.loadbalancer(loadbalancerId)._ctx.routes._ctx + .lists._ctx.paginated._def; + // Optimistically update the route on mutate + updateInPaginatedStore(key, routeId, variables, queryClient); + }, + onSuccess() { + // Invalidate the infinite store (the paginated store is optimistically updated already) + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.routes._ctx + .lists._ctx.infinite._def, + }); + // Invalidate configs because GET configs returns configuration labels + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.configurations + ._ctx.lists.queryKey, + }); + }, + }); }; export const useLoadBalancerRouteDeleteMutation = ( @@ -90,36 +90,30 @@ export const useLoadBalancerRouteDeleteMutation = ( routeId: number ) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>( - () => deleteLoadbalancerRoute(loadbalancerId, routeId), - { - onSuccess() { - queryClient.invalidateQueries([ - QUERY_KEY, - 'loadbalancer', - loadbalancerId, - 'routes', - ]); - }, - } - ); + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteLoadbalancerRoute(loadbalancerId, routeId), + onSuccess() { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.routes._ctx + .lists.queryKey, + }); + }, + }); }; export const useLoadBalancerRoutesInfiniteQuery = ( id: number, filter: Filter = {} ) => { - return useInfiniteQuery, APIError[]>( - [QUERY_KEY, 'loadbalancer', id, 'routes', 'infinite', filter], - ({ pageParam }) => - getLoadbalancerRoutes(id, { page: pageParam, page_size: 25 }, filter), - { - getNextPageParam: ({ page, pages }) => { - if (page === pages) { - return undefined; - } - return page + 1; - }, - } - ); + return useInfiniteQuery, APIError[]>({ + ...aclbQueries + .loadbalancer(id) + ._ctx.routes._ctx.lists._ctx.infinite(filter), + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + }); }; diff --git a/packages/manager/src/queries/aclb/serviceTargets.ts b/packages/manager/src/queries/aclb/serviceTargets.ts index 535c89e5a06..a44bea86aaa 100644 --- a/packages/manager/src/queries/aclb/serviceTargets.ts +++ b/packages/manager/src/queries/aclb/serviceTargets.ts @@ -1,8 +1,6 @@ import { createLoadbalancerServiceTarget, deleteLoadbalancerServiceTarget, - getLoadbalancerServiceTargets, - getServiceTargetsEndpointHealth, updateLoadbalancerServiceTarget, } from '@linode/api-v4'; import { @@ -12,7 +10,7 @@ import { useQueryClient, } from '@tanstack/react-query'; -import { QUERY_KEY } from './loadbalancers'; +import { aclbQueries } from './queries'; import type { APIError, @@ -29,44 +27,35 @@ export const useLoadBalancerServiceTargetsQuery = ( params: Params, filter: Filter ) => { - return useQuery, APIError[]>( - [QUERY_KEY, 'aclb', loadbalancerId, 'service-targets', params, filter], - () => getLoadbalancerServiceTargets(loadbalancerId, params, filter), - { keepPreviousData: true } - ); + return useQuery, APIError[]>({ + ...aclbQueries + .loadbalancer(loadbalancerId) + ._ctx.serviceTargets._ctx.lists._ctx.paginated(params, filter), + keepPreviousData: true, + }); }; export const useLoadBalancerServiceTargetsEndpointHealthQuery = ( loadbalancerId: number ) => { return useQuery({ - queryFn: () => getServiceTargetsEndpointHealth(loadbalancerId), - queryKey: [ - QUERY_KEY, - 'aclb', - loadbalancerId, - 'service-targets', - 'endpoint-health', - ], + ...aclbQueries.loadbalancer(loadbalancerId)._ctx.serviceTargets._ctx + .endpointHealth, refetchInterval: 10_000, }); }; export const useServiceTargetCreateMutation = (loadbalancerId: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => createLoadbalancerServiceTarget(loadbalancerId, data), - { - onSuccess() { - queryClient.invalidateQueries([ - QUERY_KEY, - 'aclb', - loadbalancerId, - 'service-targets', - ]); - }, - } - ); + return useMutation({ + mutationFn: (data) => createLoadbalancerServiceTarget(loadbalancerId, data), + onSuccess() { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.serviceTargets + ._ctx.lists.queryKey, + }); + }, + }); }; export const useServiceTargetUpdateMutation = ( @@ -74,20 +63,21 @@ export const useServiceTargetUpdateMutation = ( serviceTargetId: number ) => { const queryClient = useQueryClient(); - return useMutation( - (data) => + return useMutation({ + mutationFn: (data) => updateLoadbalancerServiceTarget(loadbalancerId, serviceTargetId, data), - { - onSuccess() { - queryClient.invalidateQueries([ - QUERY_KEY, - 'aclb', - loadbalancerId, - 'service-targets', - ]); - }, - } - ); + onSuccess() { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.serviceTargets + ._ctx.lists.queryKey, + }); + // Invalidate routes because GET routes returns service target labels + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.routes._ctx + .lists.queryKey, + }); + }, + }); }; export const useLoadBalancerServiceTargetDeleteMutation = ( @@ -95,40 +85,31 @@ export const useLoadBalancerServiceTargetDeleteMutation = ( serviceTargetId: number ) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>( - () => deleteLoadbalancerServiceTarget(loadbalancerId, serviceTargetId), - { - onSuccess() { - queryClient.invalidateQueries([ - QUERY_KEY, - 'aclb', - loadbalancerId, - 'service-targets', - ]); - }, - } - ); + return useMutation<{}, APIError[]>({ + mutationFn: () => + deleteLoadbalancerServiceTarget(loadbalancerId, serviceTargetId), + onSuccess() { + queryClient.invalidateQueries({ + queryKey: aclbQueries.loadbalancer(loadbalancerId)._ctx.serviceTargets + ._ctx.lists.queryKey, + }); + }, + }); }; export const useLoadBalancerServiceTargetsInfiniteQuery = ( id: number, filter: Filter = {} ) => { - return useInfiniteQuery, APIError[]>( - [QUERY_KEY, 'aclb', id, 'service-targets', 'infinite', filter], - ({ pageParam }) => - getLoadbalancerServiceTargets( - id, - { page: pageParam, page_size: 25 }, - filter - ), - { - getNextPageParam: ({ page, pages }) => { - if (page === pages) { - return undefined; - } - return page + 1; - }, - } - ); + return useInfiniteQuery, APIError[]>({ + ...aclbQueries + .loadbalancer(id) + ._ctx.serviceTargets._ctx.lists._ctx.infinite(filter), + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + }); }; diff --git a/packages/manager/src/queries/cloudpulse/dashboards.ts b/packages/manager/src/queries/cloudpulse/dashboards.ts new file mode 100644 index 00000000000..4371a79862a --- /dev/null +++ b/packages/manager/src/queries/cloudpulse/dashboards.ts @@ -0,0 +1,36 @@ +import { Dashboard, getDashboards } from '@linode/api-v4'; +import { APIError, ResourcePage } from '@linode/api-v4/lib/types'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { useQuery } from '@tanstack/react-query'; + +export const queryKey = 'cloudview-dashboards'; + +export const dashboardQueries = createQueryKeys('cloudview-dashboards', { + lists: { + contextQueries: { + allDashboards: { + queryFn: getDashboards, + queryKey: null, + }, + }, + queryKey: null, + }, + + dashboardById: (dashboardId: number) => ({ + contextQueries: { + dashboard: { + queryFn: () => {}, //Todo: will be implemented later + queryKey: [dashboardId], + }, + }, + queryKey: [dashboardId], + }), +}); + +//Fetch the list of all the dashboard available +export const useCloudViewDashboardsQuery = (enabled: boolean) => { + return useQuery, APIError[]>({ + ...dashboardQueries.lists._ctx.allDashboards, + enabled, + }); +}; diff --git a/packages/manager/src/queries/images.ts b/packages/manager/src/queries/images.ts index 93d2717850b..66cc2eab3d4 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -2,12 +2,14 @@ import { CreateImagePayload, Image, ImageUploadPayload, + UpdateImageRegionsPayload, UploadImageResponse, createImage, deleteImage, getImage, getImages, updateImage, + updateImageRegions, uploadImage, } from '@linode/api-v4'; import { @@ -134,6 +136,21 @@ export const useUploadImageMutation = () => { }); }; +export const useUpdateImageRegionsMutation = (imageId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data) => updateImageRegions(imageId, data), + onSuccess(image) { + queryClient.invalidateQueries(imageQueries.paginated._def); + queryClient.invalidateQueries(imageQueries.all._def); + queryClient.setQueryData( + imageQueries.image(image.id).queryKey, + image + ); + }, + }); +}; + export const imageEventsHandler = ({ event, queryClient, diff --git a/packages/manager/src/queries/regions/regions.ts b/packages/manager/src/queries/regions/regions.ts index 85330003adf..4016e6914c5 100644 --- a/packages/manager/src/queries/regions/regions.ts +++ b/packages/manager/src/queries/regions/regions.ts @@ -1,18 +1,18 @@ -import { - Region, - RegionAvailability, - getRegionAvailability, -} from '@linode/api-v4/lib/regions'; -import { APIError } from '@linode/api-v4/lib/types'; +import { getRegionAvailability } from '@linode/api-v4/lib/regions'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useQuery } from '@tanstack/react-query'; +import { getNewRegionLabel } from 'src/components/RegionSelect/RegionSelect.utils'; + import { queryPresets } from '../base'; import { getAllRegionAvailabilitiesRequest, getAllRegionsRequest, } from './requests'; +import type { Region, RegionAvailability } from '@linode/api-v4/lib/regions'; +import type { APIError } from '@linode/api-v4/lib/types'; + export const regionQueries = createQueryKeys('regions', { availability: { contextQueries: { @@ -33,10 +33,20 @@ export const regionQueries = createQueryKeys('regions', { }, }); -export const useRegionsQuery = () => +export const useRegionsQuery = (transformRegionLabel: boolean = false) => useQuery({ ...regionQueries.regions, ...queryPresets.longLived, + select: (regions: Region[]) => { + // Display Country, City instead of City, State + if (transformRegionLabel) { + return regions.map((region) => ({ + ...region, + label: getNewRegionLabel({ region }), + })); + } + return regions; + }, }); export const useRegionsAvailabilitiesQuery = (enabled: boolean = true) => diff --git a/packages/manager/src/utilities/mapIdsToDevices.test.ts b/packages/manager/src/utilities/mapIdsToDevices.test.ts index 1f8a43091a4..af30a9537d1 100644 --- a/packages/manager/src/utilities/mapIdsToDevices.test.ts +++ b/packages/manager/src/utilities/mapIdsToDevices.test.ts @@ -1,46 +1,51 @@ -import { Linode } from '@linode/api-v4'; -import { NodeBalancer } from '@linode/api-v4'; - -import { linodeFactory } from 'src/factories'; import { nodeBalancerFactory } from 'src/factories'; +import { linodeFactory } from 'src/factories'; import { mapIdsToDevices } from './mapIdsToDevices'; +import type { NodeBalancer } from '@linode/api-v4'; +import type { Linode } from '@linode/api-v4'; + describe('mapIdsToDevices', () => { const linodes = linodeFactory.buildList(5); const nodebalancers = nodeBalancerFactory.buildList(5); + it('works with a single Linode ID', () => { - expect(mapIdsToDevices(1, linodes)).toBe(linodes[1]); + expect(mapIdsToDevices(1, linodes)).toBe(linodes[0]); }); + it('works with a single NodeBalancer ID', () => { expect(mapIdsToDevices(1, nodebalancers)).toBe( - nodebalancers[1] + nodebalancers[0] ); }); + it('works with a multiple Linode IDs', () => { - expect(mapIdsToDevices([0, 1, 2], linodes)).toEqual([ + expect(mapIdsToDevices([1, 2, 3], linodes)).toEqual([ linodes[0], linodes[1], linodes[2], ]); }); + it('works with a multiple NodeBalancer IDs', () => { - expect(mapIdsToDevices([0, 1, 2], nodebalancers)).toEqual([ + expect(mapIdsToDevices([1, 2, 3], nodebalancers)).toEqual([ nodebalancers[0], nodebalancers[1], nodebalancers[2], ]); }); + it('omits missing IDs', () => { expect(mapIdsToDevices(99, linodes)).toBe(null); expect(mapIdsToDevices(99, nodebalancers)).toBe(null); - expect(mapIdsToDevices([0, 99, 2], linodes)).toEqual([ + expect(mapIdsToDevices([1, 99, 2], linodes)).toEqual([ linodes[0], - linodes[2], + linodes[1], ]); - expect(mapIdsToDevices([0, 99, 2], nodebalancers)).toEqual([ + expect(mapIdsToDevices([1, 99, 2], nodebalancers)).toEqual([ nodebalancers[0], - nodebalancers[2], + nodebalancers[1], ]); }); }); diff --git a/packages/manager/src/utilities/sort-by.test.ts b/packages/manager/src/utilities/sort-by.test.ts index dd1760f419e..379bac12ab3 100644 --- a/packages/manager/src/utilities/sort-by.test.ts +++ b/packages/manager/src/utilities/sort-by.test.ts @@ -15,7 +15,7 @@ describe('sortByVersion', () => { const result = sortByVersion('1.1.2', '1.1.1', 'asc'); expect(result).toBeGreaterThan(0); }); - + it('should identify the later minor version with differing number of digits', () => { const result = sortByVersion('1.30', '1.3', 'asc'); expect(result).toBeGreaterThan(0); diff --git a/packages/search/README.md b/packages/search/README.md new file mode 100644 index 00000000000..56980bfa80f --- /dev/null +++ b/packages/search/README.md @@ -0,0 +1,43 @@ +# Search + +Search is a parser written with [Peggy](https://peggyjs.org) that takes a human readable search query and transforms it into a [Linode API v4 filter](https://techdocs.akamai.com/linode-api/reference/filtering-and-sorting). + +The goal of this package is to provide a shared utility that enables a powerful, scalable, and consistent search experience throughout Akamai Connected Cloud Manager. + +## Example + +### Search Query +``` +label: my-volume and size >= 20 +``` +### Resulting `X-Filter` +```json +{ + "+and": [ + { + "label": { + "+contains": "my-volume" + } + }, + { + "size": { + "+gte": 20 + } + } + ] +} +``` + +## Supported Operations + +| Operation | Aliases | Example | Description | +|-----------|----------------|--------------------------------|-----------------------------------------------------------------| +| `and` | `&`, `&&` | `label: prod and size > 20` | Performs a boolean *and* on two expressions | +| `or` | `|`, `||` | `label: prod or size > 20` | Performs a boolean *or* on two expressions | +| `>` | None | `size > 20` | Greater than | +| `<` | None | `size < 20` | Less than | +| `>=` | None | `size >= 20` | Great than or equal to | +| `<=` | None | `size <= 20` | Less than or equal to | +| `!` | `-` | `!label = my-linode-1` | Not equal to (does not work as a *not* for boolean expressions) | +| `=` | None | `label = my-linode-1` | Equal to | +| `:` | `~` | `label: my-linode` | Contains | diff --git a/packages/search/package.json b/packages/search/package.json new file mode 100644 index 00000000000..7445271aa83 --- /dev/null +++ b/packages/search/package.json @@ -0,0 +1,25 @@ +{ + "name": "@linode/search", + "version": "0.0.1", + "description": "Search query parser for Linode API filtering", + "type": "module", + "main": "src/search.ts", + "module": "src/search.ts", + "types": "src/search.ts", + "license": "Apache-2.0", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "precommit": "tsc" + }, + "dependencies": { + "peggy": "^4.0.3" + }, + "peerDependencies": { + "@linode/api-v4": "*", + "vite": "*" + }, + "devDependencies": { + "vitest": "^1.6.0" + } +} diff --git a/packages/search/src/search.peggy b/packages/search/src/search.peggy new file mode 100644 index 00000000000..7a28192bd2b --- /dev/null +++ b/packages/search/src/search.peggy @@ -0,0 +1,99 @@ +start + = orQuery + +orQuery + = left:andQuery Or right:orQuery { return { "+or": [left, right] }; } + / andQuery + / DefaultQuery + +andQuery + = left:subQuery And right:andQuery { return { "+and": [left, right] }; } + / subQuery + +subQuery + = '(' ws* query:orQuery ws* ')' { return query; } + / EqualQuery + / ContainsQuery + / NotEqualQuery + / LessThanQuery + / LessThenOrEqualTo + / GreaterThanQuery + / GreaterThanOrEqualTo + +DefaultQuery + = input:String { + const keys = options.searchableFieldsWithoutOperator; + return { "+or": keys.map((key) => ({ [key]: { "+contains": input } })) }; + } + +EqualQuery + = key:FilterableField ws* Equal ws* value:Number { return { [key]: value }; } + / key:FilterableField ws* Equal ws* value:String { return { [key]: value }; } + +ContainsQuery + = key:FilterableField ws* Contains ws* value:String { return { [key]: { "+contains": value } }; } + +TagQuery + = "tag" ws* Equal ws* value:String { return { "tags": { "+contains": value } }; } + +NotEqualQuery + = Not key:FilterableField ws* Equal ws* value:String { return { [key]: { "+neq": value } }; } + +LessThanQuery + = key:FilterableField ws* Less ws* value:Number { return { [key]: { "+lt": value } }; } + +GreaterThanQuery + = key:FilterableField ws* Greater ws* value:Number { return { [key]: { "+gt": value } }; } + +GreaterThanOrEqualTo + = key:FilterableField ws* Gte ws* value:Number { return { [key]: { "+gte": value } }; } + +LessThenOrEqualTo + = key:FilterableField ws* Lte ws* value:Number { return { [key]: { "+lte": value } }; } + +Or + = ws+ 'or'i ws+ + / ws* '||' ws* + / ws* '|' ws* + +And + = ws+ 'and'i ws+ + / ws* '&&' ws* + / ws* '&' ws* + / ws + +Not + = '!' + / '-' + +Less + = '<' + +Greater + = '>' + +Gte + = '>=' + +Lte + = '<=' + +Equal + = "=" + +Contains + = "~" + / ":" + +FilterableField "filterable field" + = [a-zA-Z0-9\-\.]+ { return text(); } + +String "search value" + = [a-zA-Z0-9\-\.]+ { return text(); } + +Number "numeric search value" + = number:[0-9\.]+ { return parseFloat(number.join("")); } + / number:[0-9]+ { return parseInt(number.join(""), 10); } + +ws "whitespace" + = [ \t\r\n] \ No newline at end of file diff --git a/packages/search/src/search.test.ts b/packages/search/src/search.test.ts new file mode 100644 index 00000000000..0726cba626a --- /dev/null +++ b/packages/search/src/search.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; +import { getAPIFilterFromQuery } from './search'; + +describe("getAPIFilterFromQuery", () => { + it("handles +contains", () => { + const query = "label: my-linode"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + label: { "+contains": "my-linode" }, + }, + error: null, + }); + }); + + it("handles +eq with strings", () => { + const query = "label = my-linode"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + label: "my-linode", + }, + error: null, + }); + }); + + it("handles +eq with numbers", () => { + const query = "id = 100"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + id: 100, + }, + error: null, + }); + }); + + it("handles +lt", () => { + const query = "size < 20"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + size: { '+lt': 20 } + }, + error: null, + }); + }); + + it("handles +gt", () => { + const query = "size > 20"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + size: { '+gt': 20 } + }, + error: null, + }); + }); + + it("handles +gte", () => { + const query = "size >= 20"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + size: { '+gte': 20 } + }, + error: null, + }); + }); + + it("handles +lte", () => { + const query = "size <= 20"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + size: { '+lte': 20 } + }, + error: null, + }); + }); + + it("handles an 'and' search", () => { + const query = "label: my-linode-1 and tags: production"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + ["+and"]: [ + { label: { "+contains": "my-linode-1" } }, + { tags: { '+contains': "production" } }, + ], + }, + error: null, + }); + }); + + it("handles an 'or' search", () => { + const query = "label: prod or size >= 20"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + ["+or"]: [ + { label: { "+contains": "prod" } }, + { size: { '+gte': 20 } }, + ], + }, + error: null, + }); + }); + + it("handles nested queries", () => { + const query = "(label: prod and size >= 20) or (label: staging and size < 50)"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + ["+or"]: [ + { ["+and"]: [{ label: { '+contains': 'prod' } }, { size: { '+gte': 20 } }] }, + { ["+and"]: [{ label: { '+contains': 'staging' } }, { size: { '+lt': 50 } }] }, + ], + }, + error: null, + }); + }); + + it("returns a default query based on the 'defaultSearchKeys' provided", () => { + const query = "my-linode-1"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: ['label', 'tags'] })).toEqual({ + filter: { + ["+or"]: [ + { label: { "+contains": "my-linode-1" } }, + { tags: { '+contains': "my-linode-1" } }, + ], + }, + error: null, + }); + }); + + it("returns an error for an incomplete search query", () => { + const query = "label: "; + + expect( + getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] }).error?.message + ).toEqual("Expected search value or whitespace but end of input found."); + }); +}); \ No newline at end of file diff --git a/packages/search/src/search.ts b/packages/search/src/search.ts new file mode 100644 index 00000000000..3cfb368c29a --- /dev/null +++ b/packages/search/src/search.ts @@ -0,0 +1,35 @@ +import { generate } from 'peggy'; +import type { Filter } from '@linode/api-v4'; +import grammar from './search.peggy?raw'; + +const parser = generate(grammar); + +interface Options { + /** + * Defines the API fields filtered against (currently using +contains) + * when the search query contains no operators. + * + * @example ['label', 'tags'] + */ + searchableFieldsWithoutOperator: string[]; +} + +/** + * Takes a search query and returns a valid X-Filter for Linode API v4 + */ +export function getAPIFilterFromQuery(query: string | null | undefined, options: Options) { + if (!query) { + return { filter: {}, error: null }; + } + + let filter: Filter = {}; + let error: SyntaxError | null = null; + + try { + filter = parser.parse(query, options); + } catch (e) { + error = e as SyntaxError; + } + + return { filter, error }; +} \ No newline at end of file diff --git a/packages/search/src/vite-env.d.ts b/packages/search/src/vite-env.d.ts new file mode 100644 index 00000000000..11f02fe2a00 --- /dev/null +++ b/packages/search/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/search/tsconfig.json b/packages/search/tsconfig.json new file mode 100644 index 00000000000..134d0055fe4 --- /dev/null +++ b/packages/search/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "forceConsistentCasingInFileNames": true, + "incremental": true + }, + "include": ["src"], +} diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index b0ba34e3bbc..0f7aed6d817 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,5 +1,10 @@ -## [2024-06-10] - v0.48.0 +## [2024-07-08] - v0.49.0 + +### Added: +- `createSMTPSupportTicketSchema` to support schemas ([#10557](https://github.com/linode/manager/pull/10557)) + +## [2024-06-10] - v0.48.0 ### Added: @@ -8,7 +13,6 @@ ## [2024-05-28] - v0.47.0 - ### Added: - `tags` to `createImageSchema` ([#10471](https://github.com/linode/manager/pull/10471)) @@ -18,19 +22,15 @@ - Adjust DiskEncryptionSchema so it is not an object ([#10462](https://github.com/linode/manager/pull/10462)) - Improve Image `label` validation ([#10471](https://github.com/linode/manager/pull/10471)) - ## [2024-05-13] - v0.46.0 - ### Changed: - Include disk_encryption in CreateLinodeSchema and RebuildLinodeSchema ([#10413](https://github.com/linode/manager/pull/10413)) - Allow `backup_id` to be nullable in `CreateLinodeSchema` ([#10421](https://github.com/linode/manager/pull/10421)) - ## [2024-04-29] - v0.45.0 - ### Changed: - Improved VPC `ip_ranges` validation in `LinodeInterfaceSchema` ([#10354](https://github.com/linode/manager/pull/10354)) @@ -52,12 +52,10 @@ ## [2024-03-18] - v0.42.0 - ### Changed: - Update TCP rules to not include a `match_condition` ([#10264](https://github.com/linode/manager/pull/10264)) - ## [2024-03-04] - v0.41.0 ### Upcoming Features: diff --git a/packages/validation/package.json b/packages/validation/package.json index 06dcd0ca568..34d55b981b4 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.48.0", + "version": "0.49.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", diff --git a/packages/validation/src/support.schema.ts b/packages/validation/src/support.schema.ts index 92d60a870a7..1845fe64629 100644 --- a/packages/validation/src/support.schema.ts +++ b/packages/validation/src/support.schema.ts @@ -18,6 +18,19 @@ export const createSupportTicketSchema = object({ volume_id: number(), }); +export const createSMTPSupportTicketSchema = object({ + summary: string() + .required('Summary is required.') + .min(1, 'Summary must be between 1 and 64 characters.') + .max(64, 'Summary must be between 1 and 64 characters.') + .trim(), + description: string().trim(), + customerName: string().required('First and last name are required.'), + useCase: string().required('Use case is required.'), + emailDomains: string().required('Email domains are required.'), + publicInfo: string().required('Links to public information are required.'), +}); + export const createReplySchema = object({ description: string() .required('Description is required.') diff --git a/yarn.lock b/yarn.lock index 603a8dfc998..c4094ed6d0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1522,6 +1522,40 @@ dependencies: cookie "^0.5.0" +"@bundled-es-modules/deepmerge@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@bundled-es-modules/deepmerge/-/deepmerge-4.3.1.tgz#e0ef866494125f64f6fb75adeffacedc25f2f31b" + integrity sha512-Rk453EklPUPC3NRWc3VUNI/SSUjdBaFoaQvFRmNBNtMHVtOFD5AntiWg5kEE1hqcPqedYFDzxE3ZcMYPcA195w== + dependencies: + deepmerge "^4.3.1" + +"@bundled-es-modules/glob@^10.3.13": + version "10.3.13" + resolved "https://registry.yarnpkg.com/@bundled-es-modules/glob/-/glob-10.3.13.tgz#162af7285f224cbeacd8112754babf80adc0b732" + integrity sha512-eK+st/vwMmQy0pVvHLa2nzsS+p6NkNVR34e8qfiuzpzS1he4bMU3ODl0gbyv4r9INq5x41GqvRmFr8PtNw4yRA== + dependencies: + buffer "^6.0.3" + events "^3.3.0" + glob "^10.3.10" + patch-package "^8.0.0" + path "^0.12.7" + stream "^0.0.2" + string_decoder "^1.3.0" + url "^0.11.1" + +"@bundled-es-modules/memfs@^4.8.1": + version "4.8.1" + resolved "https://registry.yarnpkg.com/@bundled-es-modules/memfs/-/memfs-4.8.1.tgz#0a37f5a7050eced8d03d3af81f44579548437fa6" + integrity sha512-9BodQuihWm3XJGKYuV/vXckK8Tkf9EDiT/au1NJeFUyBMe7EMYRtOqL9eLzrjqJSDJUFoGwQFHvraFHwR8cysQ== + dependencies: + assert "^2.0.0" + buffer "^6.0.3" + events "^3.3.0" + memfs "^4.8.1" + path "^0.12.7" + stream "^0.0.2" + util "^0.12.5" + "@bundled-es-modules/statuses@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz#761d10f44e51a94902c4da48675b71a76cc98872" @@ -2231,6 +2265,26 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jsonjoy.com/base64@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/base64/-/base64-1.1.2.tgz#cf8ea9dcb849b81c95f14fc0aaa151c6b54d2578" + integrity sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA== + +"@jsonjoy.com/json-pack@^1.0.3": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pack/-/json-pack-1.0.4.tgz#ab59c642a2e5368e8bcfd815d817143d4f3035d0" + integrity sha512-aOcSN4MeAtFROysrbqG137b7gaDDSmVrl5mpo6sT/w+kcXpWnzhMjmY/Fh/sDx26NBxyIE7MB1seqLeCAzy9Sg== + dependencies: + "@jsonjoy.com/base64" "^1.1.1" + "@jsonjoy.com/util" "^1.1.2" + hyperdyperid "^1.2.0" + thingies "^1.20.0" + +"@jsonjoy.com/util@^1.1.2": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/util/-/util-1.1.3.tgz#75b1c3cf21b70e665789d1ad3eabeff8b7fd1429" + integrity sha512-g//kkF4kOwUjemValCtOc/xiYzmwMRmWq3Bn+YnzOzuZLHq2PpMOxxIayN3cKbo7Ko2Np65t6D9H81IvXbXhqg== + "@kwsites/file-exists@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@kwsites/file-exists/-/file-exists-1.1.1.tgz#ad1efcac13e1987d8dbaf235ef3be5b0d96faa99" @@ -2243,6 +2297,16 @@ resolved "https://registry.yarnpkg.com/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz#8ace5259254426ccef57f3175bc64ed7095ed919" integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw== +"@linode/design-language-system@^2.3.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@linode/design-language-system/-/design-language-system-2.4.0.tgz#c405b98ec64adf73381e81bc46136aa8b07aab50" + integrity sha512-UNwmtYTCAC5w/Q4RbbWY/qY4dhqCbq231glWDfbacoMq3NRmT75y3MCwmsXSPt9XwkUJepGz6L/PV/Mm6MfTsA== + dependencies: + "@tokens-studio/sd-transforms" "^0.15.2" + react "^17.0.2" + react-dom "^17.0.2" + style-dictionary "4.0.0-prerelease.25" + "@linode/eslint-plugin-cloud-manager@^0.0.3": version "0.0.3" resolved "https://registry.yarnpkg.com/@linode/eslint-plugin-cloud-manager/-/eslint-plugin-cloud-manager-0.0.3.tgz#dcb78ab36065bf0fb71106a586c1f3f88dbf840a" @@ -2438,6 +2502,13 @@ dependencies: hi-base32 "^0.5.0" +"@peggyjs/from-mem@1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@peggyjs/from-mem/-/from-mem-1.3.0.tgz#16470cf7dfa22fc75ca217a4e064a5f0c4e1111b" + integrity sha512-kzGoIRJjkg3KuGI4bopz9UvF3KguzfxalHRDEIdqEZUe45xezsQ6cx30e0RKuxPUexojQRBfu89Okn7f4/QXsw== + dependencies: + semver "7.6.0" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -3701,6 +3772,25 @@ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.2.tgz#db7257d727c891905947bd1c1a99da20e03c2ebd" integrity sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ== +"@tokens-studio/sd-transforms@^0.15.2": + version "0.15.2" + resolved "https://registry.yarnpkg.com/@tokens-studio/sd-transforms/-/sd-transforms-0.15.2.tgz#2cd374b89a1167d66a9c29c2779623103221fac7" + integrity sha512-0ryA1xdZ75cmneUZ/0UQIpzMFUyKPsfQgeu/jZguGFF7vB3/Yr+JsjGU/HFFvWtZfy0c4EQToCSHYwI0g13cBg== + dependencies: + "@tokens-studio/types" "^0.4.0" + color2k "^2.0.1" + colorjs.io "^0.4.3" + deepmerge "^4.3.1" + expr-eval-fork "^2.0.2" + is-mergeable-object "^1.1.1" + postcss-calc-ast-parser "^0.1.4" + style-dictionary "^4.0.0-prerelease.22" + +"@tokens-studio/types@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@tokens-studio/types/-/types-0.4.0.tgz#882088f22201e8f9112279f3ebacf8557213c615" + integrity sha512-rp5t0NP3Kai+Z+euGfHRUMn3AvPQ0bd9Dd2qbtfgnTvujxM5QYVr4psx/mwrVwA3NS9829mE6cD3ln+PIaptBA== + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" @@ -4726,6 +4816,11 @@ resolved "https://registry.yarnpkg.com/@zeit/schemas/-/schemas-2.29.0.tgz#a59ae6ebfdf4ddc66a876872dd736baa58b6696c" integrity sha512-g5QiLIfbg3pLuYUJPlisNKY+epQJTcMDsOnVNkscrDP1oi7vmJnzOANYJI/1pZcVJ6umUkBv3aFtlg1UvUHGzA== +"@zip.js/zip.js@^2.7.44": + version "2.7.45" + resolved "https://registry.yarnpkg.com/@zip.js/zip.js/-/zip.js-2.7.45.tgz#823fe2789401d8c1d836ce866578379ec1bd6f0b" + integrity sha512-Mm2EXF33DJQ/3GWWEWeP1UCqzpQ5+fiMvT3QWspsXY05DyqqxWu7a9awSzU4/spHMHVFrTjani1PR0vprgZpow== + abab@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" @@ -5452,6 +5547,14 @@ buffer@^5.5.0, buffer@^5.7.1: base64-js "^1.3.1" ieee754 "^1.1.13" +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + bundle-require@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/bundle-require/-/bundle-require-4.0.2.tgz#65fc74ff14eabbba36d26c9a6161bd78fff6b29e" @@ -5489,6 +5592,17 @@ call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6: get-intrinsic "^1.2.3" set-function-length "^1.2.0" +call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + caller-callsite@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" @@ -5594,7 +5708,7 @@ chalk@5.0.1: resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.0.1.tgz#ca57d71e82bb534a296df63bbacc4a1c22b2a4b6" integrity sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w== -chalk@5.3.0, chalk@^5.0.1, chalk@^5.2.0: +chalk@5.3.0, chalk@^5.0.1, chalk@^5.2.0, chalk@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== @@ -5624,6 +5738,11 @@ chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" +change-case@^5.3.0: + version "5.4.4" + resolved "https://registry.yarnpkg.com/change-case/-/change-case-5.4.4.tgz#0d52b507d8fb8f204343432381d1a6d7bff97a02" + integrity sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w== + change-emitter@^0.1.2: version "0.1.6" resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515" @@ -5865,11 +5984,21 @@ color-name@^1.0.0, color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color2k@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/color2k/-/color2k-2.0.3.tgz#a771244f6b6285541c82aa65ff0a0c624046e533" + integrity sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog== + colorette@^2.0.16, colorette@^2.0.20: version "2.0.20" resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== +colorjs.io@^0.4.3: + version "0.4.5" + resolved "https://registry.yarnpkg.com/colorjs.io/-/colorjs.io-0.4.5.tgz#7775f787ff90aca7a38f6edb7b7c0f8cce1e6418" + integrity sha512-yCtUNCmge7llyfd/Wou19PMAcf5yC3XXhgFoAh6zsO2pGswhUPBaaUh8jzgHnXtXuZyFKzXZNAnyF5i+apICow== + combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -5882,6 +6011,11 @@ commander@11.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67" integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ== +commander@^12.1.0: + version "12.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" + integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA== + commander@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" @@ -5892,6 +6026,11 @@ commander@^6.2.1: resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== +commander@^8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" + integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== + common-tags@^1.8.0: version "1.8.2" resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6" @@ -6415,6 +6554,11 @@ deepmerge@^2.1.1: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170" integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA== +deepmerge@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + default-browser-id@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/default-browser-id/-/default-browser-id-3.0.0.tgz#bee7bbbef1f4e75d31f98f4d3f1556a14cea790c" @@ -6440,6 +6584,15 @@ define-data-property@^1.0.1, define-data-property@^1.1.2: gopd "^1.0.1" has-property-descriptors "^1.0.1" +define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + define-lazy-prop@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" @@ -6659,6 +6812,11 @@ electron-to-chromium@^1.4.668: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.767.tgz#b885cfefda5a2e7a7ee356c567602012294ed260" integrity sha512-nzzHfmQqBss7CE3apQHkHjXW77+8w3ubGCIoEijKCJebPufREaFETgGXWTkh32t259F3Kcq+R8MZdFdOJROgYw== +emitter-component@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/emitter-component/-/emitter-component-1.1.2.tgz#d65af5833dc7c682fd0ade35f902d16bc4bad772" + integrity sha512-QdXO3nXOzZB4pAjM0n6ZE+R9/+kPpECA/XSELIcc54NeYVnBqIk+4DFiBgK+8QbV3mdvTG6nedl7dTYgO+5wDw== + emoji-regex@^7.0.1: version "7.0.3" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" @@ -6761,6 +6919,13 @@ es-abstract@^1.22.1, es-abstract@^1.22.3: unbox-primitive "^1.0.2" which-typed-array "^1.1.13" +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + es-errors@^1.0.0, es-errors@^1.1.0, es-errors@^1.2.1, es-errors@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" @@ -7339,6 +7504,11 @@ eventemitter3@^5.0.1: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + execa@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" @@ -7419,6 +7589,11 @@ executable@^4.1.1: dependencies: pify "^2.2.0" +expr-eval-fork@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/expr-eval-fork/-/expr-eval-fork-2.0.2.tgz#97136ac0a8178522055500f55d3d3c5ad54f400d" + integrity sha512-NaAnObPVwHEYrODd7Jzp3zzT9pgTAlUUL4MZiZu9XAYPDpx89cPsfyEImFb2XY0vQNbrqg2CG7CLiI+Rs3seaQ== + express@^4.17.3: version "4.19.2" resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" @@ -8186,6 +8361,13 @@ has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.1: dependencies: get-intrinsic "^1.2.2" +has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + has-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0" @@ -8391,6 +8573,11 @@ husky@^3.0.1: run-node "^1.0.0" slash "^3.0.0" +hyperdyperid@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/hyperdyperid/-/hyperdyperid-1.2.0.tgz#59668d323ada92228d2a869d3e474d5a33b69e6b" + integrity sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A== + iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -8405,7 +8592,7 @@ iconv-lite@0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -ieee754@^1.1.13: +ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -8464,6 +8651,11 @@ inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, i resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +inherits@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== + ini@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" @@ -8716,6 +8908,11 @@ is-map@^2.0.1: resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.2.tgz#00922db8c9bf73e81b7a335827bc2a43f2b91127" integrity sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg== +is-mergeable-object@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-mergeable-object/-/is-mergeable-object-1.1.1.tgz#faaa3ed1cfce87d6f7d2f5885e92cc30af3e2ebf" + integrity sha512-CPduJfuGg8h8vW74WOxHtHmtQutyQBzR+3MjQ6iDHIYdbOnm1YC7jv43SqCoU8OPGTJD4nibmiryA4kmogbGrA== + is-nan@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" @@ -8756,7 +8953,7 @@ is-path-inside@^3.0.2, is-path-inside@^3.0.3: resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== -is-plain-obj@^4.0.0: +is-plain-obj@^4.0.0, is-plain-obj@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== @@ -9115,6 +9312,16 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== +json-stable-stringify@^1.0.2: + version "1.1.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.1.1.tgz#52d4361b47d49168bcc4e564189a42e5a7439454" + integrity sha512-SU/971Kt5qVQfJpyDveVhQ/vya+5hvrjClFOcr8c0Fq5aODJjMwutrOfCU+eCnVD5gpx1Q3fEqkyom77zH1iIg== + dependencies: + call-bind "^1.0.5" + isarray "^2.0.5" + jsonify "^0.0.1" + object-keys "^1.1.1" + json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" @@ -9139,6 +9346,11 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonify@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978" + integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg== + jspdf-autotable@^3.5.14: version "3.8.1" resolved "https://registry.yarnpkg.com/jspdf-autotable/-/jspdf-autotable-3.8.1.tgz#e4d9b62356a412024e8f08e84fdeb5b85e1383b5" @@ -9791,6 +10003,16 @@ mem@^4.0.0: mimic-fn "^2.0.0" p-is-promise "^2.0.0" +memfs@^4.8.1: + version "4.9.2" + resolved "https://registry.yarnpkg.com/memfs/-/memfs-4.9.2.tgz#42e7b48207268dad8c9c48ea5d4952c5d3840433" + integrity sha512-f16coDZlTG1jskq3mxarwB+fGRrd0uXWt+o1WIhRfOwbXQZqUDsTVxQBFK9JjRQHblg8eAG2JSbprDXKjc7ijQ== + dependencies: + "@jsonjoy.com/json-pack" "^1.0.3" + "@jsonjoy.com/util" "^1.1.2" + sonic-forest "^1.0.0" + tslib "^2.0.0" + memoize-one@^5.0.0, memoize-one@^5.1.1: version "5.2.1" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" @@ -10794,6 +11016,27 @@ patch-package@^7.0.0: tmp "^0.0.33" yaml "^2.2.2" +patch-package@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.0.tgz#d191e2f1b6e06a4624a0116bcb88edd6714ede61" + integrity sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA== + dependencies: + "@yarnpkg/lockfile" "^1.1.0" + chalk "^4.1.2" + ci-info "^3.7.0" + cross-spawn "^7.0.3" + find-yarn-workspace-root "^2.0.0" + fs-extra "^9.0.0" + json-stable-stringify "^1.0.2" + klaw-sync "^6.0.0" + minimist "^1.2.6" + open "^7.4.2" + rimraf "^2.6.3" + semver "^7.5.3" + slash "^2.0.0" + tmp "^0.0.33" + yaml "^2.2.2" + path-exists@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" @@ -10881,6 +11124,19 @@ path-type@^5.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-5.0.0.tgz#14b01ed7aea7ddf9c7c3f46181d4d04f9c785bb8" integrity sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg== +path-unified@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/path-unified/-/path-unified-0.1.0.tgz#fd751e787ab019a88cdf5cecbd7e5e4711c66c7d" + integrity sha512-/Oaz9ZJforrkmFrwkR/AcvjVsCAwGSJHO0X6O6ISj8YeFbATjIEBXLDcZfnK3MO4uvCBrJTdVIxdOc79PMqSdg== + +path@^0.12.7: + version "0.12.7" + resolved "https://registry.yarnpkg.com/path/-/path-0.12.7.tgz#d4dc2a506c4ce2197eb481ebfcd5b36c0140b10f" + integrity sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q== + dependencies: + process "^0.11.1" + util "^0.10.3" + pathe@^1.1.0, pathe@^1.1.1, pathe@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec" @@ -10900,6 +11156,15 @@ peek-stream@^1.1.0: duplexify "^3.5.0" through2 "^2.0.3" +peggy@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/peggy/-/peggy-4.0.3.tgz#7bcd47718483ab405c960350c5250e3e487dec74" + integrity sha512-v7/Pt6kGYsfXsCrfb52q7/yg5jaAwiVaUMAPLPvy4DJJU6Wwr72t6nDIqIDkGfzd1B4zeVuTnQT0RGeOhe/uSA== + dependencies: + "@peggyjs/from-mem" "1.3.0" + commander "^12.1.0" + source-map-generator "0.8.0" + pend@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" @@ -10994,6 +11259,13 @@ polished@^4.2.2: dependencies: "@babel/runtime" "^7.17.8" +postcss-calc-ast-parser@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/postcss-calc-ast-parser/-/postcss-calc-ast-parser-0.1.4.tgz#9aeee3650a91c0b2902789689bc044c9f83bc447" + integrity sha512-CebpbHc96zgFjGgdQ6BqBy6XIUgRx1xXWCAAk6oke02RZ5nxwo9KQejTg8y7uYEeI9kv8jKQPYjoe6REsY23vw== + dependencies: + postcss-value-parser "^3.3.1" + postcss-load-config@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-4.0.2.tgz#7159dcf626118d33e299f485d6afe4aff7c4a3e3" @@ -11002,6 +11274,11 @@ postcss-load-config@^4.0.1: lilconfig "^3.0.0" yaml "^2.3.4" +postcss-value-parser@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" + integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== + postcss@^8.4.35: version "8.4.35" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.35.tgz#60997775689ce09011edf083a549cea44aabe2f7" @@ -11082,7 +11359,7 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== -process@^0.11.10: +process@^0.11.1, process@^0.11.10: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== @@ -11179,7 +11456,7 @@ pumpify@^1.3.3: inherits "^2.0.3" pump "^2.0.0" -punycode@^1.3.2: +punycode@^1.3.2, punycode@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ== @@ -11223,6 +11500,13 @@ qs@^6.10.0: dependencies: side-channel "^1.0.4" +qs@^6.11.2: + version "6.12.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.12.1.tgz#39422111ca7cbdb70425541cba20c7d7b216599a" + integrity sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ== + dependencies: + side-channel "^1.0.6" + querystringify@^2.1.1: version "2.2.0" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" @@ -11337,6 +11621,15 @@ react-docgen@^7.0.0: loose-envify "^1.1.0" scheduler "^0.23.0" +react-dom@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" + integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler "^0.20.2" + react-dropzone@~11.2.0: version "11.2.4" resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-11.2.4.tgz#391a8d2e41a8a974340f83524d306540192e3313" @@ -11556,6 +11849,14 @@ react-waypoint@^10.3.0: dependencies: loose-envify "^1.1.0" +react@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" + integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + read-pkg-up@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507" @@ -12146,6 +12447,14 @@ scheduler@^0.19.1: loose-envify "^1.1.0" object-assign "^4.1.1" +scheduler@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" + integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler@^0.23.0: version "0.23.0" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" @@ -12163,7 +12472,7 @@ semver-compare@^1.0.0: resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== -"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4: +"semver@2 || 3 || 4 || 5", semver@7.6.0, semver@^5.5.0, semver@^5.6.0, semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4: version "7.6.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== @@ -12245,6 +12554,18 @@ set-function-length@^1.2.0: gopd "^1.0.1" has-property-descriptors "^1.0.1" +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + set-function-name@^2.0.0, set-function-name@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.1.tgz#12ce38b7954310b9f61faa12701620a0c882793a" @@ -12310,6 +12631,16 @@ side-channel@^1.0.4: get-intrinsic "^1.2.4" object-inspect "^1.13.1" +side-channel@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + siginfo@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" @@ -12406,6 +12737,18 @@ snake-case@^3.0.4: dot-case "^3.0.4" tslib "^2.0.3" +sonic-forest@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sonic-forest/-/sonic-forest-1.0.3.tgz#81363af60017daba39b794fce24627dc412563cb" + integrity sha512-dtwajos6IWMEWXdEbW1IkEkyL2gztCAgDplRIX+OT5aRKnEd5e7r7YCxRgXZdhRP1FBdOBf8axeTPhzDv8T4wQ== + dependencies: + tree-dump "^1.0.0" + +source-map-generator@0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/source-map-generator/-/source-map-generator-0.8.0.tgz#10d5ca0651e2c9302ea338739cbd4408849c5d00" + integrity sha512-psgxdGMwl5MZM9S3FWee4EgsEaIjahYV5AzGnwUvPhWeITz/j6rKpysQHlQ4USdxvINlb8lKfWGIXwfkrgtqkA== + source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" @@ -12543,6 +12886,13 @@ stream-shift@^1.0.0: resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.3.tgz#85b8fab4d71010fc3ba8772e8046cc49b8a3864b" integrity sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ== +stream@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stream/-/stream-0.0.2.tgz#7f5363f057f6592c5595f00bc80a27f5cec1f0ef" + integrity sha512-gCq3NDI2P35B2n6t76YJuOp7d6cN/C7Rt0577l91wllh0sY9ZBuw9KaSGqH/b0hzn3CWWJbpbW0W0WvQ1H/Q7g== + dependencies: + emitter-component "^1.1.1" + strict-event-emitter@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz#1602ece81c51574ca39c6815e09f1a3e8550bd93" @@ -12648,7 +12998,7 @@ string.prototype.trimstart@^1.0.7: define-properties "^1.2.0" es-abstract "^1.22.1" -string_decoder@^1.1.1: +string_decoder@^1.1.1, string_decoder@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== @@ -12748,6 +13098,42 @@ strip-literal@^2.0.0: dependencies: js-tokens "^9.0.0" +style-dictionary@4.0.0-prerelease.25: + version "4.0.0-prerelease.25" + resolved "https://registry.yarnpkg.com/style-dictionary/-/style-dictionary-4.0.0-prerelease.25.tgz#df3d552e4324a277c13880e377f6be756db6db61" + integrity sha512-1dqKBBSvGbXPH2WFLUqqZBrmLnuNyXRkUOG1SEGJ0vDVrx+o4guOcx5aIBI9sLz2pyL7B8Yo0r4FizltFPi9WA== + dependencies: + "@bundled-es-modules/deepmerge" "^4.3.1" + "@bundled-es-modules/glob" "^10.3.13" + "@bundled-es-modules/memfs" "^4.8.1" + chalk "^5.3.0" + change-case "^5.3.0" + commander "^8.3.0" + is-plain-obj "^4.1.0" + json5 "^2.2.2" + lodash-es "^4.17.21" + patch-package "^8.0.0" + path-unified "^0.1.0" + tinycolor2 "^1.6.0" + +style-dictionary@^4.0.0-prerelease.22: + version "4.0.0-prerelease.35" + resolved "https://registry.yarnpkg.com/style-dictionary/-/style-dictionary-4.0.0-prerelease.35.tgz#3085de0d9212b56be2c9ed0c1c51de8005a4e68f" + integrity sha512-03e05St/a9XdorK0pN30zprI7J8rrRDnGCiga4Do2rjbR3jfKEKSvtUe6Inl/HQBZXm0RBFrMhVGX9MF1P2sdw== + dependencies: + "@bundled-es-modules/deepmerge" "^4.3.1" + "@bundled-es-modules/glob" "^10.3.13" + "@bundled-es-modules/memfs" "^4.8.1" + "@zip.js/zip.js" "^2.7.44" + chalk "^5.3.0" + change-case "^5.3.0" + commander "^8.3.0" + is-plain-obj "^4.1.0" + json5 "^2.2.2" + patch-package "^8.0.0" + path-unified "^0.1.0" + tinycolor2 "^1.6.0" + stylis@4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.2.0.tgz#79daee0208964c8fe695a42fcffcac633a211a51" @@ -12945,6 +13331,11 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" +thingies@^1.20.0: + version "1.21.0" + resolved "https://registry.yarnpkg.com/thingies/-/thingies-1.21.0.tgz#e80fbe58fd6fdaaab8fad9b67bd0a5c943c445c1" + integrity sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g== + throttle-debounce@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.3.0.tgz#fd31865e66502071e411817e241465b3e9c372e2" @@ -12988,6 +13379,11 @@ tinybench@^2.5.1: resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.6.0.tgz#1423284ee22de07c91b3752c048d2764714b341b" integrity sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA== +tinycolor2@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" + integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== + tinypool@^0.8.3: version "0.8.4" resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.8.4.tgz#e217fe1270d941b39e98c625dcecebb1408c9aa8" @@ -13078,6 +13474,11 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== +tree-dump@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tree-dump/-/tree-dump-1.0.1.tgz#b448758da7495580e6b7830d6b7834fca4c45b96" + integrity sha512-WCkcRBVPSlHHq1dc/px9iOfqklvzCbdRwvlNfxGZsrHqf6aZttfPrd7DJTt6oR10dwUfpFFQeVTkPbBIZxX/YA== + tree-kill@^1.2.1, tree-kill@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" @@ -13485,6 +13886,14 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" +url@^0.11.1: + version "0.11.3" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.3.tgz#6f495f4b935de40ce4a0a52faee8954244f3d3ad" + integrity sha512-6hxOLGfZASQK/cijlZnZJTq8OXAkt/3YGfQX45vvMYXpZoo8NdWZcY73K108Jf759lS1Bv/8wXnHDTSz17dSRw== + dependencies: + punycode "^1.4.1" + qs "^6.11.2" + use-callback-ref@^1.3.0: version "1.3.2" resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.2.tgz#6134c7f6ff76e2be0b56c809b17a650c942b1693" @@ -13515,6 +13924,13 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +util@^0.10.3: + version "0.10.4" + resolved "https://registry.yarnpkg.com/util/-/util-0.10.4.tgz#3aa0125bfe668a4672de58857d3ace27ecb76901" + integrity sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A== + dependencies: + inherits "2.0.3" + util@^0.12.4, util@^0.12.5: version "0.12.5" resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc"