diff --git a/README.md b/README.md index e3837574fb4..1d2c50db13e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- +

Akamai Connected Cloud Manager diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 2666b963c68..2eadb3daf3f 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,14 @@ +## [2024-04-15] - v0.114.0 + +### Added: + +- New endpoint and type for `nodebalancers/types` ([#10265](https://github.com/linode/manager/pull/10265)) +- Severity fields to support ticket endpoints and new account capability ([#10317](https://github.com/linode/manager/pull/10317)) + +### Upcoming Features: + +- Modify Region Placement Groups Limits types ([#10343](https://github.com/linode/manager/pull/10343)) + ## [2024-04-01] - v0.113.0 ### Added: @@ -16,7 +27,6 @@ ## [2024-03-18] - v0.112.0 - ### Changed: - Make `match_condition` optional in Rule types to support TCP rules ([#10264](https://github.com/linode/manager/pull/10264)) diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 4bd987d68c6..5c4358e0126 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.113.0", + "version": "0.114.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 955ec311566..1cc3e89e7bd 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -72,6 +72,7 @@ export type AccountCapability = | 'Object Storage Access Key Regions' | 'Object Storage' | 'Placement Group' + | 'Support Ticket Severity' | 'Vlans' | 'VPCs'; diff --git a/packages/api-v4/src/nodebalancers/nodebalancers.ts b/packages/api-v4/src/nodebalancers/nodebalancers.ts index 42401c3fb4c..c3039693740 100644 --- a/packages/api-v4/src/nodebalancers/nodebalancers.ts +++ b/packages/api-v4/src/nodebalancers/nodebalancers.ts @@ -10,8 +10,8 @@ import Request, { setURL, setXFilter, } from '../request'; -import { Filter, Params, ResourcePage as Page } from '../types'; -import { +import type { Filter, Params, ResourcePage as Page, PriceType } from '../types'; +import type { CreateNodeBalancerPayload, NodeBalancer, NodeBalancerStats, @@ -130,3 +130,16 @@ export const getNodeBalancerFirewalls = ( setXFilter(filter), setParams(params) ); + +/** + * getNodeBalancerTypes + * + * Return a paginated list of available NodeBalancer types; used for pricing. + * This endpoint does not require authentication. + */ +export const getNodeBalancerTypes = (params?: Params) => + Request>( + setURL(`${API_ROOT}/nodebalancers/types`), + setMethod('GET'), + setParams(params) + ); diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index 84871a6a6b0..b651e8b9930 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -31,8 +31,10 @@ export interface Region { label: string; country: Country; capabilities: Capabilities[]; - maximum_pgs_per_customer: number; - maximum_vms_per_pg: number; + placement_group_limits: { + maximum_pgs_per_customer: number; + maximum_linodes_per_pg: number; + }; status: RegionStatus; resolvers: DNSResolvers; site_type: RegionSite; diff --git a/packages/api-v4/src/support/types.ts b/packages/api-v4/src/support/types.ts index d2aa400f26c..f2ab261a93e 100644 --- a/packages/api-v4/src/support/types.ts +++ b/packages/api-v4/src/support/types.ts @@ -14,6 +14,7 @@ export interface SupportTicket { summary: string; updated: string; updated_by: string | null; + severity: TicketSeverity | null; } export interface SupportReply { @@ -39,4 +40,7 @@ export interface TicketRequest { longviewclient_id?: number; nodebalancer_id?: number; volume_id?: number; + severity?: TicketSeverity; } + +export type TicketSeverity = 1 | 2 | 3; diff --git a/packages/api-v4/src/types.ts b/packages/api-v4/src/types.ts index e25f09fd38f..13d8df47e77 100644 --- a/packages/api-v4/src/types.ts +++ b/packages/api-v4/src/types.ts @@ -1,3 +1,5 @@ +import type { PriceObject, RegionPriceObject } from './linodes/types'; + export interface APIError { field?: string; reason: string; @@ -115,3 +117,11 @@ export interface RequestHeaders { 'User-Agent'?: string; 'Content-Type'?: RequestContentType; } + +export interface PriceType { + id: string; + label: string; + price: PriceObject; + region_prices: RegionPriceObject[]; + transfer: number; +} diff --git a/packages/manager/.storybook/main.ts b/packages/manager/.storybook/main.ts index a4a9b625d6f..d5e56868cfe 100644 --- a/packages/manager/.storybook/main.ts +++ b/packages/manager/.storybook/main.ts @@ -20,7 +20,6 @@ const config: StorybookConfig = { name: '@storybook/react-vite', options: {}, }, - features: { storyStoreV7: true }, typescript: { reactDocgenTypescriptOptions: { // makes union prop types like variant and size appear as select controls @@ -33,6 +32,8 @@ const config: StorybookConfig = { ? !/node_modules\/(?!@mui)/.test(prop.parent.fileName) : true, }, + + reactDocgen: 'react-docgen-typescript', }, docs: { autodocs: true, diff --git a/packages/manager/.storybook/manager.ts b/packages/manager/.storybook/manager.ts index 70e0cce5db5..19cc78d5356 100644 --- a/packages/manager/.storybook/manager.ts +++ b/packages/manager/.storybook/manager.ts @@ -1,5 +1,5 @@ import { create } from '@storybook/theming'; -import { addons } from '@storybook/addons'; +import { addons } from '@storybook/manager-api'; import Logo from '../src/assets/logo/akamai-logo-color.svg'; const theme = create({ diff --git a/packages/manager/.storybook/preview.tsx b/packages/manager/.storybook/preview.tsx index 43df4076fb8..538c0b07cea 100644 --- a/packages/manager/.storybook/preview.tsx +++ b/packages/manager/.storybook/preview.tsx @@ -53,7 +53,6 @@ const preview: Preview = { }), ], parameters: { - actions: { argTypesRegex: '^on[A-Z].*' }, backgrounds: { grid: { disable: true, diff --git a/packages/manager/.storybook/vite.config.ts b/packages/manager/.storybook/vite.config.ts new file mode 100644 index 00000000000..4f312acb1a8 --- /dev/null +++ b/packages/manager/.storybook/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react-swc'; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 5df7fb1eb81..0f720f45036 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,55 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2024-04-15] - v1.117.0 + +### Added: + +- Resource links to NodeBalancers empty state landing page ([#10345](https://github.com/linode/manager/pull/10345)) +- New DescriptionList component ([#10325](https://github.com/linode/manager/pull/10325)) +- Akamai's Japanese QI System ID to Japanese Invoices ([#10356](https://github.com/linode/manager/pull/10356)) + +### Changed: + +- Improve tags experience ([#10122](https://github.com/linode/manager/pull/10122)) +- Use Chip for notification badge ([#10333](https://github.com/linode/manager/pull/10333)) + +### Fixed: + +- Direction of the Bucket Access ACL select field carat with `Autocomplete` ([#10286](https://github.com/linode/manager/pull/10286)) +- Reset SSH key form state on cancel ([#10344](https://github.com/linode/manager/pull/10344)) +- `usePersonAccessTokensQuery` running without option to be disabled ([#10358](https://github.com/linode/manager/pull/10358)) +- Unable to update label of OBJ limited access key ([#10362](https://github.com/linode/manager/pull/10362)) +- Tooltip displaying for current Dedicated plan when resizing Database Cluster ([#10364](https://github.com/linode/manager/pull/10364)) + +### Tech Stories: + +- Price NodeBalancers dynamically with `nodebalancers/types` endpoint ([#10265](https://github.com/linode/manager/pull/10265)) +- Update Storybook to 8.0.5 ([#10336](https://github.com/linode/manager/pull/10336)) +- Update Notistack to 3.0.1 ([#10357](https://github.com/linode/manager/pull/10357)) + +### Tests: + +- Add tests to check Parent and Child Close Account flows ([#10316](https://github.com/linode/manager/pull/10316), [#10296](https://github.com/linode/manager/pull/10296)) +- Add UI test for account switch flow with expired Parent token ([#10341](https://github.com/linode/manager/pull/10341)) +- Add Cypress tests for Account billing drawers ([#10349](https://github.com/linode/manager/pull/10349)) + +### Upcoming Features: + +- Disable fetching buckets with clusters when ObjMultiClusterEnabled flag is enabled (#10282) +- Update error message in EditAccessKeyDrawer ([#10329](https://github.com/linode/manager/pull/10329)) +- Support ticket severity ([#10317](https://github.com/linode/manager/pull/10317)) +- Refactor account switching utils for reusability and automatic token refreshing ([#10323](https://github.com/linode/manager/pull/10323)) +- Update Placement Groups detail and summaries ([#10325](https://github.com/linode/manager/pull/10325)) +- Update and clean up Placement Group assign/unassign features (#10328) +- Update navigation and add new menu items for Placement Groups ([#10340](https://github.com/linode/manager/pull/10340)) +- Update UI for Region Placement Groups Limits type changes ([#10343](https://github.com/linode/manager/pull/10343)) +- Linode Create Refactor: + - User Data ([#10331](https://github.com/linode/manager/pull/10331)) + - Summary ([#10334](https://github.com/linode/manager/pull/10334)) + - VLANs ([#10342](https://github.com/linode/manager/pull/10342)) +- Include powered-off status in Clone Linode event ([#10337](https://github.com/linode/manager/pull/10337)) + ## [2024-04-08] - v1.116.1 ### Fixed: diff --git a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts index a33e12296ca..a4e15d14991 100644 --- a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -9,6 +9,15 @@ import { mockCancelAccount, mockCancelAccountError, } from 'support/intercepts/account'; +import { + cancellationDataLossWarning, + cancellationPaymentErrorMessage, +} from 'support/constants/account'; +import { + CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, + PARENT_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, + PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, +} from 'src/features/Account/constants'; import { mockGetProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { @@ -24,29 +33,6 @@ import { } from 'support/intercepts/feature-flags'; import { makeFeatureFlagData } from 'support/util/feature-flags'; -// Data loss warning which is displayed in the account cancellation dialog. -const cancellationDataLossWarning = - 'Please note this is an extremely destructive action. Closing your account \ -means that all services Linodes, Volumes, DNS Records, etc will be lost and \ -may not be able be restored.'; - -// Error message that appears when a payment failure occurs upon cancellation attempt. -const cancellationPaymentErrorMessage = - 'We were unable to charge your credit card for services rendered. \ -We cannot cancel this account until the balance has been paid.'; - -// Tooltip message that appears when a child account tries to close the account. -const contactParentUserTooltipsMessage = - 'Contact your parent user to close your account.'; - -// Tooltip message that appears when a child account tries to close the account. -const contactCustomerSupportTooltipsMessage = - 'Contact customer support to close this account.'; - -// Tooltip message that appears when a parent account with one and more child accounts tries to close the account. -const removeChildAccountTooltipsMessage = - 'Remove child accounts before closing the account.'; - describe('Account cancellation', () => { /* * - Confirms that a user can cancel their account from the Account Settings page. @@ -246,7 +232,7 @@ describe('Parent/Child account cancellation', () => { .trigger('mouseover'); // Click the button first, then confirm the tooltip is shown. ui.tooltip - .findByText(contactParentUserTooltipsMessage) + .findByText(CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT) .should('be.visible'); }); }); @@ -254,7 +240,7 @@ describe('Parent/Child account cancellation', () => { /** * Confirms that a proxy account cannot close the account */ - it('disables a proxy account to close the account', () => { + it('disables "Close Account" button for proxy users', () => { const mockAccount = accountFactory.build(); const mockProfile = profileFactory.build({ username: 'proxy-user', @@ -280,7 +266,7 @@ describe('Parent/Child account cancellation', () => { .trigger('mouseover'); // Click the button first, then confirm the tooltip is shown. ui.tooltip - .findByText(contactCustomerSupportTooltipsMessage) + .findByText(PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT) .should('be.visible'); }); }); @@ -288,7 +274,7 @@ describe('Parent/Child account cancellation', () => { /** * Confirms that a parent account with one or more active child accounts cannot close the account */ - it('disables a parent account with one or more active child accounts to close the account', () => { + it('disables "Close Account" button for parent users', () => { const mockAccount = accountFactory.build(); const mockProfile = profileFactory.build({ username: 'parent-user', @@ -314,7 +300,7 @@ describe('Parent/Child account cancellation', () => { .trigger('mouseover'); // Click the button first, then confirm the tooltip is shown. ui.tooltip - .findByText(removeChildAccountTooltipsMessage) + .findByText(PARENT_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT) .should('be.visible'); }); }); @@ -326,7 +312,7 @@ describe('Parent/Child account cancellation', () => { const mockAccount = accountFactory.build(); const mockProfile = profileFactory.build({ username: 'default-user', - restricted: true, + restricted: false, user_type: 'default', }); const mockCancellationResponse: CancelAccount = { diff --git a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts index c0c349545b2..55566712282 100644 --- a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts @@ -5,6 +5,7 @@ import { paymentMethodFactory, profileFactory } from '@src/factories'; import { accountUserFactory } from '@src/factories/accountUsers'; import { grantsFactory } from '@src/factories/grants'; +import { ADMINISTRATOR, PARENT_USER } from 'src/features/Account/constants'; import { mockGetPaymentMethods, mockGetUser } from 'support/intercepts/account'; import { mockAppendFeatureFlags, @@ -173,6 +174,56 @@ const assertAddPaymentMethodEnabled = () => { }); }; +/** + * Asserts that the "Make a Payment" button is disabled. + * + * Additionally confirms that clicking the "Make a Payment" button reveals + * a tooltip and does not open the "Make a Payment" drawer. + * + * @param tooltipText - Expected tooltip message to be shown to the user. + */ +const assertMakeAPaymentDisabled = (tooltipText: string) => { + // Confirm "Make A Payment" button is disabled, then click it. + ui.button + .findByTitle('Make a Payment') + .should('be.visible') + .should('be.disabled') + .click(); + + // Assert that "Make a Payment" drawer does not open and that tooltip is revealed. + cy.get(`[data-qa-drawer-title="Make a Payment"]`).should('not.exist'); + ui.tooltip.findByText(tooltipText).should('be.visible'); +}; + +/** + * Asserts that the "Make a Payment" button is enabled. + * + * Additionally confirms that clicking the "Make a Payment" button reveals + * a tooltip and does not open the "Make a Payment" drawer. + * + * @param tooltipText - Expected tooltip message to be shown to the user. + */ +const assertMakeAPaymentEnabled = () => { + // Confirm "Make A Payment" button is enabled, then click it. + ui.button + .findByTitle('Make a Payment') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.get(`[data-qa-drawer-title="Make a Payment"]`).should('be.visible'); + ui.drawer + .findByTitle('Make a Payment') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Pay Now') + .should('be.visible') + .should('be.enabled'); + ui.drawerCloseButton.find().click(); + }); +}; + describe('restricted user billing flows', () => { beforeEach(() => { mockGetPaymentMethods(mockPaymentMethods); @@ -215,6 +266,7 @@ describe('restricted user billing flows', () => { cy.visitWithLogin('/account/billing'); assertEditBillingInfoEnabled(); assertAddPaymentMethodEnabled(); + assertMakeAPaymentEnabled(); }); }); @@ -261,6 +313,10 @@ describe('restricted user billing flows', () => { assertEditBillingInfoDisabled(restrictedUserTooltip); assertAddPaymentMethodDisabled(restrictedUserTooltip); + assertMakeAPaymentDisabled( + restrictedUserTooltip + + ` Please contact your ${ADMINISTRATOR} to request the necessary permissions.` + ); }); /* @@ -287,6 +343,10 @@ describe('restricted user billing flows', () => { assertEditBillingInfoDisabled(restrictedUserTooltip); assertAddPaymentMethodDisabled(restrictedUserTooltip); + assertMakeAPaymentDisabled( + restrictedUserTooltip + + ` Please contact your ${PARENT_USER} to request the necessary permissions.` + ); }); /* @@ -323,6 +383,7 @@ describe('restricted user billing flows', () => { cy.findByText(mockProfileRegular.username); assertEditBillingInfoEnabled(); assertAddPaymentMethodEnabled(); + assertMakeAPaymentEnabled(); // Confirm button behavior for parent users. mockGetProfile(mockProfileParent); @@ -331,6 +392,7 @@ describe('restricted user billing flows', () => { cy.findByText(mockProfileParent.username); assertEditBillingInfoEnabled(); assertAddPaymentMethodEnabled(); + assertMakeAPaymentEnabled(); }); }); }); 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 4d694a4794d..72456bd05ee 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 @@ -9,9 +9,38 @@ import { import 'cypress-file-upload'; import { interceptGetProfile } from 'support/intercepts/profile'; import { apiMatcher } from 'support/util/intercepts'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { ui } from 'support/ui'; +import { + randomItem, + randomLabel, + randomNumber, + randomPhrase, +} from 'support/util/random'; +import { supportTicketFactory } from 'src/factories'; +import { + mockCreateSupportTicket, + mockGetSupportTicket, + mockGetSupportTickets, + mockGetSupportTicketReplies, +} from 'support/intercepts/support'; +import { severityLabelMap } from 'src/features/Support/SupportTickets/ticketUtils'; describe('help & support', () => { + /* + * - 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', () => { + mockAppendFeatureFlags({ + supportTicketSeverity: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + const image = 'test_screenshot.png'; const ticketDescription = 'this is a test ticket'; const ticketLabel = 'cy-test ticket'; @@ -21,6 +50,12 @@ describe('help & support', () => { interceptGetProfile().as('getProfile'); cy.visitWithLogin('/support/tickets'); + + // Confirm that "Severity" table column is not shown. + cy.get('[data-qa-open-tickets-tab]').within(() => { + cy.findByLabelText('Sort by severity').should('not.exist'); + }); + cy.wait('@getProfile').then((xhr) => { const user = xhr.response?.body['username']; const mockTicketData = { @@ -67,7 +102,9 @@ describe('help & support', () => { cy.get('input[placeholder="Enter a title for your ticket."]') .click({ scrollBehavior: false }) .type(ticketLabel); - getVisible('[data-qa-enhanced-select="General/Account/Billing"]'); + 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('[id="attach-file"]').attachFile(image); getVisible('[value="test_screenshot.png"]'); @@ -83,4 +120,93 @@ describe('help & support', () => { containsVisible(image); }); }); + + /* + * - Opens a Help & Support ticket with a severity level specified using mock API data. + * - Confirms that outgoing API request includes the expected severity level in its payload. + * - Confirms that specified severity level is displayed on the created ticket. + */ + it('can create a ticket with a severity level specified', () => { + // TODO Integrate this test with the above test when feature flag goes away. + const mockTicket = supportTicketFactory.build({ + id: randomNumber(), + summary: randomLabel(), + description: randomPhrase(), + severity: randomItem([1, 2, 3]), + status: 'new', + }); + + // 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!); + if (!severityLabel) { + throw new Error( + `Unable to retrieve label for severity level '${mockTicket.severity}'. Is this a valid support severity level?` + ); + } + + mockAppendFeatureFlags({ + supportTicketSeverity: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + mockCreateSupportTicket(mockTicket).as('createTicket'); + mockGetSupportTickets([]); + mockGetSupportTicket(mockTicket); + mockGetSupportTicketReplies(mockTicket.id, []); + + cy.visitWithLogin('/support/tickets'); + + // Confirm that "Severity" table column is displayed. + cy.get('[data-qa-open-tickets-tab]').within(() => { + cy.findByLabelText('Sort by severity').should('be.visible'); + }); + + 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.findByLabelText('Severity') + .should('be.visible') + .click() + .type(`${mockTicket.severity}{downarrow}{enter}`); + + cy.get('[data-qa-ticket-description]') + .should('be.visible') + .click() + .type(mockTicket.description); + + 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); + expect(xhr.request.body?.severity).to.eq(mockTicket.severity); + }); + + // Confirm redirect to details page and that severity level is displayed. + cy.url().should('endWith', `support/tickets/${mockTicket.id}`); + cy.get('[data-qa-ticket-status]') + .should('be.visible') + .within(() => { + cy.findByText(severityLabel).should('be.visible'); + }); + }); }); diff --git a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-empty-landing-page.spec.ts new file mode 100644 index 00000000000..b93401cab6c --- /dev/null +++ b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-empty-landing-page.spec.ts @@ -0,0 +1,55 @@ +import { ui } from 'support/ui'; +import { mockGetNodeBalancers } from 'support/intercepts/nodebalancers'; + +describe('NodeBalancers empty landing page', () => { + /** + * - Confirms NodeBalancers landing page empty state is shown when no NodeBalancers are present: + * - Confirms that "Getting Started Guides" and "Video Playlist" are listed on landing page. + * - Confirms that clicking "Create NodeBalancers" navigates user to NodeBalancer create page. + */ + it('shows the empty state when there are no nodebalancers', () => { + mockGetNodeBalancers([]).as('getNodebalancers'); + + cy.visitWithLogin('/nodebalancers'); + cy.wait(['@getNodebalancers']); + + // confirms helper text + cy.findByText('Cloud-based load balancing service').should('be.visible'); + cy.findByText( + 'Add high availability and horizontal scaling to web applications hosted on Linode Compute Instances.' + ).should('be.visible'); + + // checks that guides are visible + cy.findByText('Getting Started Guides').should('be.visible'); + cy.findByText('Getting Started with NodeBalancers').should('be.visible'); + cy.findByText('Create a NodeBalancer').should('be.visible'); + cy.findByText('Configuration Options for NodeBalancers').should( + 'be.visible' + ); + cy.findByText('View additional NodeBalancer documentation').should( + 'be.visible' + ); + + // checks that videos are visible + cy.findByText('Video Playlist').should('be.visible'); + cy.findByText( + 'Getting Started With NodeBalancers | How To Prepare For High Server Traffic' + ).should('be.visible'); + cy.findByText( + 'Linode NodeBalancers Explained | Manage Scale with Transparent Load Distribution' + ).should('be.visible'); + cy.findByText('Load Balancing on an LKE Kubernetes Cluster').should( + 'be.visible' + ); + cy.findByText('View our YouTube channel').should('be.visible'); + + // confirms clicking on 'Create NodeBalancer' button + ui.button + .findByTitle('Create NodeBalancer') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.url().should('endWith', '/nodebalancers/create'); + }); +}); 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 91dbbdbfaec..1bd55fd7712 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 @@ -12,6 +12,8 @@ import { interceptDeleteBucket, interceptGetBuckets, interceptUploadBucketObjectS3, + interceptGetBucketAccess, + interceptUpdateBucketAccess, } from 'support/intercepts/object-storage'; import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; @@ -388,4 +390,47 @@ describe('object storage end-to-end tests', () => { cy.findByText(emptyBucketMessage).should('be.visible'); }); }); + + /* + * - Confirms that user can update Bucket access. + */ + it('can update bucket access', () => { + const bucketLabel = randomLabel(); + const bucketCluster = 'us-southeast-1'; + const bucketAccessPage = `/object-storage/buckets/${bucketCluster}/${bucketLabel}/access`; + + cy.defer( + setUpBucket(bucketLabel, bucketCluster), + 'creating Object Storage bucket' + ).then(() => { + interceptGetBucketAccess(bucketLabel, bucketCluster).as( + 'getBucketAccess' + ); + interceptUpdateBucketAccess(bucketLabel, bucketCluster).as( + 'updateBucketAccess' + ); + }); + + // Navigate to new bucket page, upload and delete an object. + cy.visitWithLogin(bucketAccessPage); + + cy.wait('@getBucketAccess'); + + // Make object public, confirm it can be accessed. + cy.findByText('Access Control List (ACL)') + .should('be.visible') + .click() + .type('Public Read'); + + ui.autocompletePopper + .findByTitle('Public Read') + .should('be.visible') + .click(); + + ui.button.findByTitle('Save').should('be.visible').click(); + + cy.wait('@updateBucketAccess'); + + cy.findByText('Bucket access updated successfully.'); + }); }); diff --git a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts index d0a0e550dec..28db3eca210 100644 --- a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts @@ -334,7 +334,7 @@ describe('Parent/Child account switching', () => { * - Confirms that Parent account information is displayed in user menu button after switch. * - Confirms that Cloud updates local storage auth values upon account switch. */ - it('can switch from Proxy user back to Parent account user from Billing page', () => { + it.skip('can switch from Proxy user back to Parent account user from Billing page', () => { const mockParentToken = randomString(32); const mockParentExpiration = DateTime.now().plus({ minutes: 15 }).toISO(); diff --git a/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts b/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts new file mode 100644 index 00000000000..bf91b178e3b --- /dev/null +++ b/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts @@ -0,0 +1,95 @@ +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + accountFactory, + accountUserFactory, + profileFactory, +} from 'src/factories'; +import { randomLabel, randomString } from 'support/util/random'; +import { + mockGetAccount, + mockGetChildAccounts, +} from 'support/intercepts/account'; +import { mockGetProfile } from 'support/intercepts/profile'; +import { DateTime } from 'luxon'; +import { ui } from 'support/ui'; + +const mockChildAccount = accountFactory.build({ + company: 'Partner Company', +}); + +const mockChildAccountProxyUser = accountUserFactory.build({ + username: randomLabel(), + user_type: 'proxy', +}); + +const mockChildAccountProxyProfile = profileFactory.build({ + username: mockChildAccountProxyUser.username, + user_type: 'proxy', +}); + +describe('Parent/Child token expiration', () => { + // @TODO M3-7554, M3-7559: Remove feature flag mocks after launch and clean-up. + beforeEach(() => { + mockAppendFeatureFlags({ + parentChildAccountAccess: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms flow when a Proxy user attempts to switch back to a Parent account with expired auth token. + * - Uses mock API and local storage data. + */ + it('shows session expiry prompt upon switching back to Parent account with expired Parent token', () => { + mockGetLinodes([]).as('getLinodes'); + mockGetAccount(mockChildAccount); + mockGetProfile(mockChildAccountProxyProfile); + mockGetChildAccounts([]); + + // Mock local storage parent token expiry to have already passed. + cy.visitWithLogin('/', { + localStorageOverrides: { + proxy_user: true, + 'authentication/parent_token/token': `Bearer ${randomString(32)}`, + 'authentication/parent_token/expire': DateTime.local() + .minus({ minutes: 30 }) + .toISO(), + 'authentication/parent_token/scopes': '*', + }, + }); + + // Wait for page load, then click "Switch Account" button. + cy.wait('@getLinodes'); + ui.userMenuButton.find().should('be.visible').click(); + + ui.userMenu + .find() + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Switch Account') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm session expiry prompt, and that clicking "Log In" prompts login flow. + ui.dialog + .findByTitle('Session expired') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Log in') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.url().should('endWith', '/logout'); + }); +}); diff --git a/packages/manager/cypress/support/constants/account.ts b/packages/manager/cypress/support/constants/account.ts new file mode 100644 index 00000000000..7ca6940fbf1 --- /dev/null +++ b/packages/manager/cypress/support/constants/account.ts @@ -0,0 +1,14 @@ +/** + * Data loss warning which is displayed in the account cancellation dialog. + */ +export const cancellationDataLossWarning = + 'Please note this is an extremely destructive action. Closing your account \ +means that all services Linodes, Volumes, DNS Records, etc will be lost and \ +may not be able be restored.'; + +/** + * Error message that appears when a payment failure occurs upon cancellation attempt. + */ +export const cancellationPaymentErrorMessage = + 'We were unable to charge your credit card for services rendered. \ +We cannot cancel this account until the balance has been paid.'; diff --git a/packages/manager/cypress/support/intercepts/nodebalancers.ts b/packages/manager/cypress/support/intercepts/nodebalancers.ts new file mode 100644 index 00000000000..08580c09f88 --- /dev/null +++ b/packages/manager/cypress/support/intercepts/nodebalancers.ts @@ -0,0 +1,25 @@ +/** + * @file Cypress intercepts and mocks for NodeBalancer API requests. + */ + +import { apiMatcher } from 'support/util/intercepts'; +import { paginateResponse } from 'support/util/paginate'; + +import type { NodeBalancer } from '@linode/api-v4'; + +/** + * Intercepts GET request to mock nodeBalancer data. + * + * @param nodeBalancers - an array of mock nodeBalancer objects + * + * @returns Cypress chainable. + */ +export const mockGetNodeBalancers = ( + nodeBalancers: NodeBalancer[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('nodebalancers*'), + paginateResponse(nodeBalancers) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/object-storage.ts b/packages/manager/cypress/support/intercepts/object-storage.ts index 8d13d437d8c..9d5527f011f 100644 --- a/packages/manager/cypress/support/intercepts/object-storage.ts +++ b/packages/manager/cypress/support/intercepts/object-storage.ts @@ -3,16 +3,16 @@ */ import { sequentialStub } from 'support/stubs/sequential-stub'; +import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; import type { ObjectStorageBucket, - ObjectStorageKey, ObjectStorageCluster, + ObjectStorageKey, } from '@linode/api-v4'; -import { makeErrorResponse } from 'support/util/errors'; /** * Intercepts GET requests to fetch buckets. @@ -439,3 +439,42 @@ export const mockGetClusters = ( paginateResponse(clusters) ); }; + +/** + * Intercepts GET request to fetch access information (ACL, CORS) for a given Bucket. + * + * + * @param label - Object storage bucket label. + * @param cluster - Object storage bucket cluster. + * @param data - response data. + * @param statusCode - response status code. + * + * @returns Cypress chainable. + */ +export const interceptGetBucketAccess = ( + label: string, + cluster: string +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`object-storage/buckets/${cluster}/${label}/access`) + ); +}; + +/** + * Intercepts PUT request to update access information (ACL, CORS) for a given Bucket. + * + * @param label - Object storage bucket label. + * @param cluster - Object storage bucket cluster. + * + * @returns Cypress chainable. + */ +export const interceptUpdateBucketAccess = ( + label: string, + cluster: string +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`object-storage/buckets/${cluster}/${label}/access`) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/support.ts b/packages/manager/cypress/support/intercepts/support.ts new file mode 100644 index 00000000000..7e1ea4d36d9 --- /dev/null +++ b/packages/manager/cypress/support/intercepts/support.ts @@ -0,0 +1,79 @@ +/** + * @file Cypress mock and intercept utilities for Help & Support API requests. + */ + +import { apiMatcher } from 'support/util/intercepts'; +import { makeResponse } from 'support/util/response'; +import { paginateResponse } from 'support/util/paginate'; + +import type { SupportTicket, SupportReply } from '@linode/api-v4'; + +/** + * Intercepts request to open a support ticket and mocks response. + * + * @param ticket - Support ticket object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockCreateSupportTicket = ( + ticket: SupportTicket +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher('support/tickets'), + makeResponse(ticket) + ); +}; + +/** + * Intercepts request to fetch a support ticket and mocks response. + * + * @param ticket - Support ticket object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetSupportTicket = ( + ticket: SupportTicket +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`support/tickets/${ticket.id}`), + makeResponse(ticket) + ); +}; + +/** + * Intercepts request to fetch support tickets and mocks response. + * + * @param tickets - Array of support ticket objects with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetSupportTickets = ( + tickets: SupportTicket[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('support/tickets*'), + paginateResponse(tickets) + ); +}; + +/** + * Interepts request to fetch a support ticket's replies and mocks response. + * + * @param ticketId - Numeric ID of support ticket for which to mock replies. + * @param replies - Array of support ticket reply objects with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetSupportTicketReplies = ( + ticketId: number, + replies: SupportReply[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`support/tickets/${ticketId}/replies*`), + paginateResponse(replies) + ); +}; diff --git a/packages/manager/cypress/support/setup/login-command.ts b/packages/manager/cypress/support/setup/login-command.ts index 92867d351da..a3ed2289b37 100644 --- a/packages/manager/cypress/support/setup/login-command.ts +++ b/packages/manager/cypress/support/setup/login-command.ts @@ -14,16 +14,12 @@ const overrideLocalStorage = ( }; const _loginWithToken = (win: Window) => { - win.localStorage.setItem('authentication/oauth-token', oauthToken); win.localStorage.setItem('authentication/scopes', '*'); - // cy.log(window.localStorage.getItem('authentication/oauth-token')); - const expireDate = DateTime.local().plus({ days: 30 }); - const isoExpire = expireDate.toISO(); - // cy.log(isoExpire); - win.localStorage.setItem('authentication/expires', isoExpire); - win.localStorage.setItem('authentication/expire-datetime', isoExpire); win.localStorage.setItem('authentication/token', 'Bearer ' + oauthToken); - win.localStorage.setItem('authentication/expire', isoExpire); + win.localStorage.setItem( + 'authentication/expire', + DateTime.local().plus({ days: 30 }).toISO() + ); }; /** diff --git a/packages/manager/cypress/support/ui/toast.ts b/packages/manager/cypress/support/ui/toast.ts index e5b50f4c0e7..2e59008b9ab 100644 --- a/packages/manager/cypress/support/ui/toast.ts +++ b/packages/manager/cypress/support/ui/toast.ts @@ -24,7 +24,11 @@ export const toast = { message: string, options?: ToastFindOptions | undefined ): void => { - cy.contains('[data-qa-toast]', message, options).should('be.visible'); + cy.contains( + '[aria-describedby="notistack-snackbar"]', + message, + options + ).should('be.visible'); }, /** @@ -42,6 +46,10 @@ export const toast = { message: string, options?: ToastFindOptions | undefined ): Cypress.Chainable => { - return cy.contains('[data-qa-toast]', message, options); + return cy.contains( + '[aria-describedby="notistack-snackbar"]', + message, + options + ); }, }; diff --git a/packages/manager/package.json b/packages/manager/package.json index 5977669c68d..91bdabc2d93 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.116.1", + "version": "1.117.0", "private": true, "type": "module", "bugs": { @@ -48,7 +48,7 @@ "luxon": "3.4.4", "markdown-it": "^12.3.2", "md5": "^2.2.1", - "notistack": "^2.0.5", + "notistack": "^3.0.1", "patch-package": "^7.0.0", "qrcode.react": "^0.8.0", "ramda": "~0.25.0", @@ -110,18 +110,19 @@ }, "devDependencies": { "@linode/eslint-plugin-cloud-manager": "^0.0.3", - "@storybook/addon-actions": "^7.6.17", - "@storybook/addon-controls": "^7.6.17", - "@storybook/addon-docs": "^7.6.17", - "@storybook/addon-mdx-gfm": "^7.6.17", - "@storybook/addon-measure": "^7.6.17", - "@storybook/addon-storysource": "^7.6.17", - "@storybook/addon-viewport": "^7.6.17", - "@storybook/addons": "^7.6.17", - "@storybook/client-api": "^7.6.17", - "@storybook/react": "^7.6.17", - "@storybook/react-vite": "^7.6.17", - "@storybook/theming": "^7.6.17", + "@storybook/addon-actions": "^8.0.5", + "@storybook/addon-controls": "^8.0.5", + "@storybook/addon-docs": "^8.0.5", + "@storybook/addon-mdx-gfm": "^8.0.5", + "@storybook/addon-measure": "^8.0.5", + "@storybook/addon-storysource": "^8.0.5", + "@storybook/addon-viewport": "^8.0.5", + "@storybook/blocks": "^8.0.5", + "@storybook/manager-api": "^8.0.5", + "@storybook/preview-api": "^8.0.5", + "@storybook/react": "^8.0.5", + "@storybook/react-vite": "^8.0.5", + "@storybook/theming": "^8.0.5", "@swc/core": "^1.3.1", "@testing-library/cypress": "^10.0.0", "@testing-library/jest-dom": "~6.4.2", @@ -184,7 +185,7 @@ "eslint-plugin-react-hooks": "^3.0.0", "eslint-plugin-scanjs-rules": "^0.2.1", "eslint-plugin-sonarjs": "^0.5.0", - "eslint-plugin-storybook": "^0.6.15", + "eslint-plugin-storybook": "^0.8.0", "eslint-plugin-testing-library": "^3.1.2", "eslint-plugin-xss": "^0.1.10", "factory.ts": "^0.5.1", @@ -200,10 +201,10 @@ "reselect-tools": "^0.0.7", "serve": "^14.0.1", "simple-git": "^3.19.0", - "storybook": "^7.6.17", - "storybook-dark-mode": "^3.0.3", + "storybook": "^8.0.5", + "storybook-dark-mode": "^4.0.1", "ts-node": "^10.9.2", - "vite": "^5.0.12", + "vite": "^5.1.7", "vite-plugin-svgr": "^3.2.0", "vitest": "^1.2.0" }, diff --git a/packages/manager/src/__data__/regionsData.ts b/packages/manager/src/__data__/regionsData.ts index bebc7183db4..e5d0a0d6afd 100644 --- a/packages/manager/src/__data__/regionsData.ts +++ b/packages/manager/src/__data__/regionsData.ts @@ -16,8 +16,10 @@ export const regions: Region[] = [ country: 'in', id: 'ap-west', label: 'Mumbai, IN', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: 10, + }, resolvers: { ipv4: '172.105.34.5, 172.105.35.5, 172.105.36.5, 172.105.37.5, 172.105.38.5, 172.105.39.5, 172.105.40.5, 172.105.41.5, 172.105.42.5, 172.105.43.5', @@ -42,8 +44,10 @@ export const regions: Region[] = [ country: 'ca', id: 'ca-central', label: 'Toronto, CA', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 1, + placement_group_limits: { + maximum_linodes_per_pg: 1, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '172.105.0.5, 172.105.3.5, 172.105.4.5, 172.105.5.5, 172.105.6.5, 172.105.7.5, 172.105.8.5, 172.105.9.5, 172.105.10.5, 172.105.11.5', @@ -67,8 +71,10 @@ export const regions: Region[] = [ country: 'au', id: 'ap-southeast', label: 'Sydney, AU', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '172.105.166.5, 172.105.169.5, 172.105.168.5, 172.105.172.5, 172.105.162.5, 172.105.170.5, 172.105.167.5, 172.105.171.5, 172.105.181.5, 172.105.161.5', @@ -95,8 +101,10 @@ export const regions: Region[] = [ country: 'us', id: 'us-iad', label: 'Washington, DC', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '139.144.192.62, 139.144.192.60, 139.144.192.61, 139.144.192.53, 139.144.192.54, 139.144.192.67, 139.144.192.69, 139.144.192.66, 139.144.192.52, 139.144.192.68', @@ -122,8 +130,10 @@ export const regions: Region[] = [ country: 'us', id: 'us-ord', label: 'Chicago, IL', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '172.232.0.17, 172.232.0.16, 172.232.0.21, 172.232.0.13, 172.232.0.22, 172.232.0.9, 172.232.0.19, 172.232.0.20, 172.232.0.15, 172.232.0.18', @@ -149,8 +159,10 @@ export const regions: Region[] = [ country: 'fr', id: 'fr-par', label: 'Paris, FR', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '172.232.32.21, 172.232.32.23, 172.232.32.17, 172.232.32.18, 172.232.32.16, 172.232.32.22, 172.232.32.20, 172.232.32.14, 172.232.32.11, 172.232.32.12', @@ -176,8 +188,10 @@ export const regions: Region[] = [ country: 'us', id: 'us-sea', label: 'Seattle, WA', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '172.232.160.19, 172.232.160.21, 172.232.160.17, 172.232.160.15, 172.232.160.18, 172.232.160.8, 172.232.160.12, 172.232.160.11, 172.232.160.14, 172.232.160.16', @@ -203,8 +217,10 @@ export const regions: Region[] = [ country: 'br', id: 'br-gru', label: 'Sao Paulo, BR', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '172.233.0.4, 172.233.0.9, 172.233.0.7, 172.233.0.12, 172.233.0.5, 172.233.0.13, 172.233.0.10, 172.233.0.6, 172.233.0.8, 172.233.0.11', @@ -229,8 +245,10 @@ export const regions: Region[] = [ country: 'nl', id: 'nl-ams', label: 'Amsterdam, NL', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '172.233.33.36, 172.233.33.38, 172.233.33.35, 172.233.33.39, 172.233.33.34, 172.233.33.33, 172.233.33.31, 172.233.33.30, 172.233.33.37, 172.233.33.32', @@ -255,8 +273,10 @@ export const regions: Region[] = [ country: 'se', id: 'se-sto', label: 'Stockholm, SE', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '172.232.128.24, 172.232.128.26, 172.232.128.20, 172.232.128.22, 172.232.128.25, 172.232.128.19, 172.232.128.23, 172.232.128.18, 172.232.128.21, 172.232.128.27', @@ -281,8 +301,10 @@ export const regions: Region[] = [ country: 'in', id: 'in-maa', label: 'Chennai, IN', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '172.232.96.17, 172.232.96.26, 172.232.96.19, 172.232.96.20, 172.232.96.25, 172.232.96.21, 172.232.96.18, 172.232.96.22, 172.232.96.23, 172.232.96.24', @@ -308,8 +330,10 @@ export const regions: Region[] = [ country: 'jp', id: 'jp-osa', label: 'Osaka, JP', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '172.233.64.44, 172.233.64.43, 172.233.64.37, 172.233.64.40, 172.233.64.46, 172.233.64.41, 172.233.64.39, 172.233.64.42, 172.233.64.45, 172.233.64.38', @@ -334,8 +358,10 @@ export const regions: Region[] = [ country: 'it', id: 'it-mil', label: 'Milan, IT', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '172.232.192.19, 172.232.192.18, 172.232.192.16, 172.232.192.20, 172.232.192.24, 172.232.192.21, 172.232.192.22, 172.232.192.17, 172.232.192.15, 172.232.192.23', @@ -361,8 +387,10 @@ export const regions: Region[] = [ country: 'us', id: 'us-mia', label: 'Miami, FL', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '172.233.160.34, 172.233.160.27, 172.233.160.30, 172.233.160.29, 172.233.160.32, 172.233.160.28, 172.233.160.33, 172.233.160.26, 172.233.160.25, 172.233.160.31', @@ -387,8 +415,10 @@ export const regions: Region[] = [ country: 'id', id: 'id-cgk', label: 'Jakarta, ID', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '172.232.224.23, 172.232.224.32, 172.232.224.26, 172.232.224.27, 172.232.224.21, 172.232.224.24, 172.232.224.22, 172.232.224.20, 172.232.224.31, 172.232.224.28', @@ -414,8 +444,10 @@ export const regions: Region[] = [ country: 'us', id: 'us-lax', label: 'Los Angeles, CA', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '172.233.128.45, 172.233.128.38, 172.233.128.53, 172.233.128.37, 172.233.128.34, 172.233.128.36, 172.233.128.33, 172.233.128.39, 172.233.128.43, 172.233.128.44', @@ -440,8 +472,10 @@ export const regions: Region[] = [ country: 'us', id: 'us-central', label: 'Dallas, TX', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '72.14.179.5, 72.14.188.5, 173.255.199.5, 66.228.53.5, 96.126.122.5, 96.126.124.5, 96.126.127.5, 198.58.107.5, 198.58.111.5, 23.239.24.5', @@ -465,8 +499,10 @@ export const regions: Region[] = [ country: 'us', id: 'us-west', label: 'Fremont, CA', - maximum_pgs_per_customer: 1, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 1, + }, resolvers: { ipv4: '173.230.145.5, 173.230.147.5, 173.230.155.5, 173.255.212.5, 173.255.219.5, 173.255.241.5, 173.255.243.5, 173.255.244.5, 74.207.241.5, 74.207.242.5', @@ -493,8 +529,10 @@ export const regions: Region[] = [ country: 'us', id: 'us-southeast', label: 'Atlanta, GA', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '74.207.231.5, 173.230.128.5, 173.230.129.5, 173.230.136.5, 173.230.140.5, 66.228.59.5, 66.228.62.5, 50.116.35.5, 50.116.41.5, 23.239.18.5', @@ -522,8 +560,10 @@ export const regions: Region[] = [ country: 'us', id: 'us-east', label: 'Newark, NJ', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '66.228.42.5, 96.126.106.5, 50.116.53.5, 50.116.58.5, 50.116.61.5, 50.116.62.5, 66.175.211.5, 97.107.133.4, 207.192.69.4, 207.192.69.5', @@ -547,8 +587,10 @@ export const regions: Region[] = [ country: 'gb', id: 'eu-west', label: 'London, UK', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '178.79.182.5, 176.58.107.5, 176.58.116.5, 176.58.121.5, 151.236.220.5, 212.71.252.5, 212.71.253.5, 109.74.192.20, 109.74.193.20, 109.74.194.20', @@ -574,8 +616,10 @@ export const regions: Region[] = [ country: 'sg', id: 'ap-south', label: 'Singapore, SG', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '139.162.11.5, 139.162.13.5, 139.162.14.5, 139.162.15.5, 139.162.16.5, 139.162.21.5, 139.162.27.5, 103.3.60.18, 103.3.60.19, 103.3.60.20', @@ -601,8 +645,10 @@ export const regions: Region[] = [ country: 'de', id: 'eu-central', label: 'Frankfurt, DE', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '139.162.130.5, 139.162.131.5, 139.162.132.5, 139.162.133.5, 139.162.134.5, 139.162.135.5, 139.162.136.5, 139.162.137.5, 139.162.138.5, 139.162.139.5', @@ -617,8 +663,10 @@ export const regions: Region[] = [ country: 'us', id: 'us-edge-1', label: 'Gecko Edge Test', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '139.162.130.5, 139.162.131.5, 139.162.132.5, 139.162.133.5, 139.162.134.5, 139.162.135.5, 139.162.136.5, 139.162.137.5, 139.162.138.5, 139.162.139.5', @@ -633,8 +681,10 @@ export const regions: Region[] = [ country: 'us', id: 'us-edge-2', label: 'Gecko Edge Test 2', - maximum_pgs_per_customer: 5, - maximum_vms_per_pg: 10, + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, resolvers: { ipv4: '139.162.130.5, 139.162.131.5, 139.162.132.5, 139.162.133.5, 139.162.134.5, 139.162.135.5, 139.162.136.5, 139.162.137.5, 139.162.138.5, 139.162.139.5', diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.tsx index 20fd80ba968..cc9a4100c73 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.tsx @@ -6,6 +6,8 @@ import React from 'react'; import { Box } from 'src/components/Box'; import { TextField, TextFieldProps } from 'src/components/TextField'; +import { CircleProgress } from '../CircleProgress'; +import { InputAdornment } from '../InputAdornment'; import { CustomPopper, SelectedIcon, @@ -31,8 +33,8 @@ export interface EnhancedAutocompleteProps< label: string; /** Removes the top margin from the input label, if desired. */ noMarginTop?: boolean; - /** Text to show when the Autocomplete search yields no results. */ - noOptionsText?: string; + /** Element to show when the Autocomplete search yields no results. */ + noOptionsText?: JSX.Element | string; placeholder?: string; /** Label for the "select all" option. */ selectAllLabel?: string; @@ -115,10 +117,15 @@ export const Autocomplete = < ...params.InputProps, ...textFieldProps?.InputProps, endAdornment: ( - + <> + {loading && ( + + + + )} {textFieldProps?.InputProps?.endAdornment} {params.InputProps.endAdornment} - + ), }} /> diff --git a/packages/manager/src/components/Breadcrumb/FinalCrumb.tsx b/packages/manager/src/components/Breadcrumb/FinalCrumb.tsx index d2f32161ea7..5cb20414d84 100644 --- a/packages/manager/src/components/Breadcrumb/FinalCrumb.tsx +++ b/packages/manager/src/components/Breadcrumb/FinalCrumb.tsx @@ -33,7 +33,6 @@ export const FinalCrumb = React.memo((props: Props) => { onCancel={onEditHandlers.onCancel} onEdit={onEditHandlers.onEdit} text={onEditHandlers.editableTextTitle} - textSuffix={onEditHandlers.editableTextTitleSuffix} /> ); } diff --git a/packages/manager/src/components/Breadcrumb/types.ts b/packages/manager/src/components/Breadcrumb/types.ts index 77e97930bb7..1c8e6fc096f 100644 --- a/packages/manager/src/components/Breadcrumb/types.ts +++ b/packages/manager/src/components/Breadcrumb/types.ts @@ -10,7 +10,6 @@ export interface LabelProps { export interface EditableProps { editableTextTitle: string; - editableTextTitleSuffix?: string; errorText?: string; handleAnalyticsEvent?: () => void; onCancel: () => void; diff --git a/packages/manager/src/components/Button/StyledTagButton.ts b/packages/manager/src/components/Button/StyledTagButton.ts index a35a6ef302a..df83fcc4c88 100644 --- a/packages/manager/src/components/Button/StyledTagButton.ts +++ b/packages/manager/src/components/Button/StyledTagButton.ts @@ -1,6 +1,7 @@ import { styled } from '@mui/material/styles'; import Plus from 'src/assets/icons/plusSign.svg'; +import { omittedProps } from 'src/utilities/omittedProps'; import { Button } from './Button'; @@ -12,9 +13,15 @@ import { Button } from './Button'; */ export const StyledTagButton = styled(Button, { label: 'StyledTagButton', -})(({ theme, ...props }) => ({ + shouldForwardProp: omittedProps(['panel']), +})<{ panel?: boolean }>(({ theme, ...props }) => ({ border: 'none', fontSize: '0.875rem', + minHeight: 30, + whiteSpace: 'nowrap', + ...(props.panel && { + height: 34, + }), ...(!props.disabled && { '&:hover, &:focus': { backgroundColor: theme.color.tagButton, diff --git a/packages/manager/src/components/DescriptionList/DescriptionList.stories.tsx b/packages/manager/src/components/DescriptionList/DescriptionList.stories.tsx new file mode 100644 index 00000000000..39096fd00cc --- /dev/null +++ b/packages/manager/src/components/DescriptionList/DescriptionList.stories.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import { DescriptionList } from 'src/components/DescriptionList/DescriptionList'; + +import type { Meta, StoryObj } from '@storybook/react'; +import type { DescriptionListProps } from 'src/components/DescriptionList/DescriptionList'; + +const defaultItems = [ + { + description: 'Some description related to the label (long)', + title: 'Random title', + }, + { + description: 'That', + title: 'This', + }, + { + description: 'Another description', + title: 'With a tooltip', + tooltip: { + text: 'This is a tooltip', + }, + }, + { + description: 'Fourth description', + title: 'Number 4', + }, +]; + +const meta: Meta = { + args: { + columnSpacing: 4, + displayMode: 'column', + fontSize: '0.9rem', + items: defaultItems, + rowSpacing: 1, + stackAt: undefined, + sx: { + mb: 3, + mt: 3, + }, + }, + component: DescriptionList, + title: 'Components/DescriptionList', +}; + +export default meta; + +type Story = StoryObj; + +export const Column: Story = { + name: 'Column (default)', + render: (args: DescriptionListProps) => ( + + ), +}; + +export const Row: Story = { + name: 'Row', + render: (args: DescriptionListProps) => ( + + ), +}; + +export const Grid: Story = { + name: 'Grid', + render: (args: DescriptionListProps) => ( + + ), +}; diff --git a/packages/manager/src/components/DescriptionList/DescriptionList.styles.ts b/packages/manager/src/components/DescriptionList/DescriptionList.styles.ts new file mode 100644 index 00000000000..6b450e9b98d --- /dev/null +++ b/packages/manager/src/components/DescriptionList/DescriptionList.styles.ts @@ -0,0 +1,48 @@ +import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; + +import { Typography } from 'src/components/Typography'; +import { omittedProps } from 'src/utilities/omittedProps'; + +import type { DescriptionListProps } from './DescriptionList'; +import type { TypographyProps } from '@mui/material'; +import type { Grid2Props } from '@mui/material/Unstable_Grid2'; + +interface StyledDLProps extends Omit { + component: Grid2Props['component']; + gridColumns?: number; + isStacked: boolean; +} + +export const StyledDL = styled(Grid, { + label: 'StyledDL', + shouldForwardProp: omittedProps(['gridColumns', 'isStacked', 'displayMode']), +})(({ ...props }) => ({ + '& dt, & dd': { + fontSize: props.fontSize ? props.fontSize : '0.9rem', + }, + display: 'flex', + flexDirection: props.displayMode === 'column' ? 'column' : 'row', + ...(props.displayMode === 'grid' && + !props.isStacked && + props.gridColumns && + props.gridColumns > 1 && { + display: 'grid', + gap: '0px 0px', + gridTemplateColumns: `repeat(${props.gridColumns}, 1fr)`, + }), +})); + +export const StyledDLItemSeparator = styled(Grid, { + label: 'StyledDLItemSeparator', +})(() => ({ + display: 'flex', + flexWrap: 'wrap', +})); + +export const StyledDT = styled(Typography, { + label: 'StyledDT', +})(({ theme }) => ({ + fontFamily: theme.font.bold, + marginRight: theme.spacing(0.75), +})); diff --git a/packages/manager/src/components/DescriptionList/DescriptionList.test.tsx b/packages/manager/src/components/DescriptionList/DescriptionList.test.tsx new file mode 100644 index 00000000000..6fc2fd2fe20 --- /dev/null +++ b/packages/manager/src/components/DescriptionList/DescriptionList.test.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DescriptionList } from './DescriptionList'; + +const items = [ + { + description: 'Some description related to the label (long)', + title: 'Random title', + }, + { + description: 'That', + title: 'This', + }, + { + description: 'Another description', + title: 'Another Title', + }, +]; + +describe('Description List', () => { + it('displays the correct list of items', () => { + const { getByText } = renderWithTheme(); + + items.forEach((item) => { + expect(getByText(item.title)).toBeInTheDocument(); + expect(getByText(item.description)).toBeInTheDocument(); + }); + }); + + it('has it title bolded', () => { + const { getByText } = renderWithTheme(); + const title = getByText('Random title'); + expect(title).toHaveStyle('font-family: "LatoWebBold",sans-serif'); + }); + + it('renders a column by default', () => { + const { container } = renderWithTheme(); + expect(container.firstChild?.firstChild).toHaveStyle( + 'flex-direction: column' + ); + }); +}); diff --git a/packages/manager/src/components/DescriptionList/DescriptionList.tsx b/packages/manager/src/components/DescriptionList/DescriptionList.tsx new file mode 100644 index 00000000000..575412bcd76 --- /dev/null +++ b/packages/manager/src/components/DescriptionList/DescriptionList.tsx @@ -0,0 +1,154 @@ +import { SxProps } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import * as React from 'react'; + +import { Box } from 'src/components/Box'; +import { TooltipIcon } from 'src/components/TooltipIcon'; +import { Typography } from 'src/components/Typography'; + +import { + StyledDL, + StyledDLItemSeparator, + StyledDT, +} from './DescriptionList.styles'; + +import type { Breakpoint, Theme } from '@mui/material/styles'; +import type { TooltipIconProps } from 'src/components/TooltipIcon'; + +type DescriptionListBaseProps = { + /** + * The amount of space between the title and description. + * Only for the "row" and "grid" display modes. + * + * @default 4 + */ + columnSpacing?: number; + /** + * The display mode of the list. + * - "column" will display the list in a column (stacked) + * - "row" will display the list in one, wrappable row + * - "grid" will display the list in a grid, which can be configured via the `gridProps` prop + * + * @default 'column' + */ + displayMode?: 'column' | 'row'; + /** + * The font size of the title and description. + * If not provided, the default font size is 0.9rem. + * + * @default '0.9rem' + */ + fontSize?: string; + /** + * Array of objects containing a title and description matching the semantic markup of a description list. + * Includes an optional tooltip for contextual help. + */ + items: { + description: string; + title: string; + tooltip?: { + position?: TooltipIconProps['tooltipPosition']; + text: TooltipIconProps['text']; + width?: TooltipIconProps['width']; + }; + }[]; + /** + * The breakpoint at which the list will stack if the direction is row. + * This is particularly useful to control lists we always their items all in row or all stacked (no in-between state). + * Can be one of our defined breakpoints or a number (in px). + * + * @default 'md' (if prop provided) + */ + rowSpacing?: number; + /** + * The optional breakpoint at which the list will stack if the direction is row or grid. + */ + stackAt?: Breakpoint | number; + /** + * Additional styles to apply to the component. + */ + sx?: SxProps; +}; + +interface DescriptionListGridProps + extends Omit { + displayMode?: 'grid'; + /** + * Props to pass to the Grid + * Only for the "grid" display mode. + */ + gridProps: { + columnSpacing?: number; + columns: number; + }; +} + +export type DescriptionListProps = + | DescriptionListBaseProps + | DescriptionListGridProps; + +/** + * DescriptionList is a component that displays a list of items in a semantic description list format. + * This component is often used in summaries, detail sections or drawers + * + * Its main purpose to to keep a unified styles for you description lists while providing enough flexibility with its layout. + */ +export const DescriptionList = (props: DescriptionListProps) => { + const { + columnSpacing = 4, + displayMode = 'column', + fontSize, + items, + rowSpacing = 1, + stackAt = 0, + sx, + } = props; + let gridProps; + if ('gridProps' in props) { + gridProps = props.gridProps; + } + + const isStacked = useMediaQuery((theme: Theme) => + theme.breakpoints.down(stackAt) + ); + const responsiveMode = + (displayMode === 'grid' || displayMode === 'row') && isStacked + ? 'column' + : displayMode; + + return ( + + + {items.map((item, idx) => { + const { description, title, tooltip } = item; + + return ( + + {title} + {description} + {tooltip && ( + + )} + + ); + })} + + + ); +}; diff --git a/packages/manager/src/components/EditableText/EditableText.stories.tsx b/packages/manager/src/components/EditableText/EditableText.stories.tsx index e3afe866788..7ff5a3842a6 100644 --- a/packages/manager/src/components/EditableText/EditableText.stories.tsx +++ b/packages/manager/src/components/EditableText/EditableText.stories.tsx @@ -1,5 +1,5 @@ import { action } from '@storybook/addon-actions'; -import { useArgs } from '@storybook/client-api'; +import { useArgs } from '@storybook/preview-api'; import * as React from 'react'; import { EditableText } from './EditableText'; diff --git a/packages/manager/src/components/EditableText/EditableText.tsx b/packages/manager/src/components/EditableText/EditableText.tsx index 6a048460590..5b054906f4c 100644 --- a/packages/manager/src/components/EditableText/EditableText.tsx +++ b/packages/manager/src/components/EditableText/EditableText.tsx @@ -15,15 +15,15 @@ import { TextField, TextFieldProps } from '../TextField'; const useStyles = makeStyles()( (theme: Theme, _params, classes) => ({ button: { - '&:first-of-type': { + '&[aria-label="Save"]': { marginLeft: theme.spacing(2), [theme.breakpoints.down('md')]: { marginLeft: theme.spacing(2), }, }, background: 'transparent !important', + height: 34, marginLeft: 0, - marginTop: 2, minWidth: 'auto', paddingLeft: 6, paddingRight: 6, diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesMoreLink.tsx b/packages/manager/src/components/EmptyLandingPageResources/ResourcesMoreLink.tsx index bf7e0dc090e..6198dedcfb5 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesMoreLink.tsx +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesMoreLink.tsx @@ -10,6 +10,11 @@ interface ResourcesMoreLinkProps extends LinkProps { } const StyledMoreLink = styled(Link)(({ ...props }) => ({ + '& svg': { + // nifty trick to avoid the icon from wrapping by itself after the last word + marginLeft: -18, + transform: 'translateX(18px)', + }, alignItems: props.external ? 'baseline' : 'center', })); diff --git a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.test.tsx b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.test.tsx index 023a67e704c..8e4eb9587c7 100644 --- a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.test.tsx +++ b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.test.tsx @@ -90,7 +90,9 @@ describe('PlacementGroupSelect', () => { selectedRegion={regionFactory.build({ capabilities: ['Placement Group'], id: 'ca-central', - maximum_vms_per_pg: 1, + placement_group_limits: { + maximum_linodes_per_pg: 1, + }, })} /> ); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index b9fdd30b099..631a36b6991 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -34,6 +34,7 @@ import { useObjectStorageBuckets, useObjectStorageClusters, } from 'src/queries/objectStorage'; +import { useRegionsQuery } from 'src/queries/regions/regions'; import { useStackScriptsOCA } from 'src/queries/stackscripts'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; @@ -98,6 +99,18 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { _isManagedAccount, account, accountError } = useAccountManagement(); + const isObjMultiClusterEnabled = isFeatureEnabled( + 'Object Storage Access Key Regions', + Boolean(flags.objMultiCluster), + account?.capabilities ?? [] + ); + + const { data: regions } = useRegionsQuery(); + + const regionsSupportingObjectStorage = regions?.filter((region) => + region.capabilities.includes('Object Storage') + ); + const { data: oneClickApps, error: oneClickAppsError, @@ -108,13 +121,28 @@ export const PrimaryNav = (props: PrimaryNavProps) => { data: clusters, error: clustersError, isLoading: clustersLoading, - } = useObjectStorageClusters(enableObjectPrefetch); + } = useObjectStorageClusters( + enableObjectPrefetch && !isObjMultiClusterEnabled + ); + /* + @TODO OBJ Multicluster:'region' will become required, and the + 'cluster' field will be deprecated once the feature is fully rolled out in production. + As part of the process of cleaning up after the 'objMultiCluster' feature flag, we will + remove 'cluster' and retain 'regions'. + */ const { data: buckets, error: bucketsError, isLoading: bucketsLoading, - } = useObjectStorageBuckets(clusters, enableObjectPrefetch); + } = useObjectStorageBuckets({ + clusters: isObjMultiClusterEnabled ? undefined : clusters, + enabled: enableObjectPrefetch, + isObjMultiClusterEnabled, + regions: isObjMultiClusterEnabled + ? regionsSupportingObjectStorage + : undefined, + }); const allowObjPrefetch = !buckets && @@ -172,14 +200,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { href: '/linodes', icon: , }, - { - betaChipClassName: 'beta-chip-placement-groups', - display: 'Placement Groups', - hide: !flags.placementGroups?.enabled, - href: '/placement-groups', - icon: , - isBeta: flags.placementGroups?.beta, - }, { display: 'Volumes', href: '/volumes', @@ -222,6 +242,14 @@ export const PrimaryNav = (props: PrimaryNavProps) => { href: '/images', icon: , }, + { + betaChipClassName: 'beta-chip-placement-groups', + display: 'Placement Groups', + hide: !flags.placementGroups?.enabled, + href: '/placement-groups', + icon: , + isBeta: flags.placementGroups?.beta, + }, ], [ { diff --git a/packages/manager/src/components/PrimaryNav/SideMenu.stories.tsx b/packages/manager/src/components/PrimaryNav/SideMenu.stories.tsx index 2397a933cd7..5bc651dc6f4 100644 --- a/packages/manager/src/components/PrimaryNav/SideMenu.stories.tsx +++ b/packages/manager/src/components/PrimaryNav/SideMenu.stories.tsx @@ -1,5 +1,5 @@ import MenuIcon from '@mui/icons-material/Menu'; -import { useArgs } from '@storybook/client-api'; +import { useArgs } from '@storybook/preview-api'; import * as React from 'react'; import { Box } from 'src/components/Box'; diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx index 61a5f1b2a67..db01e6a6f9f 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx @@ -36,6 +36,14 @@ export interface RemovableSelectionsListProps { * Has no effect if isRemovable is false */ RemoveButton?: (props: ButtonProps) => JSX.Element; + /** + * If true, disable all items when one is removed to prevent race conditions with multiple removals. + */ + disableItemsOnRemove?: boolean; + /** + * If true, reset loading states. The value should be based on a mutation status. + */ + hasEncounteredMutationError?: boolean; /** * The descriptive text to display above the list */ @@ -74,6 +82,11 @@ export interface RemovableSelectionsListProps { * The data to display in the list */ selectionData: RemovableItem[]; + /** + * Will display a loading indicator in place of the remove button when removing an item. + * Only if isRemovable and RemoveButton are true. + */ + showLoadingIndicatorOnRemove?: boolean; /** * Additional styles to apply to the component */ @@ -86,6 +99,8 @@ export const RemovableSelectionsList = ( const { LabelComponent, RemoveButton, + disableItemsOnRemove = false, + hasEncounteredMutationError, headerText, id, isRemovable = true, @@ -95,20 +110,32 @@ export const RemovableSelectionsList = ( onRemove, preferredDataLabel, selectionData, + showLoadingIndicatorOnRemove = false, sx, } = props; // used to determine when to display a box-shadow to indicate scrollability const listRef = React.useRef(null); const [listHeight, setListHeight] = React.useState(0); + const [removingItemId, setRemovingItemId] = React.useState( + null + ); + const [isRemoving, setIsRemoving] = React.useState(false); React.useEffect(() => { if (listRef.current) { setListHeight(listRef.current.clientHeight); } - }, [selectionData]); + + return () => { + setRemovingItemId(null); + setIsRemoving(false); + }; + }, [hasEncounteredMutationError, selectionData]); const handleOnClick = (selection: RemovableItem) => { + setIsRemoving(true); + setRemovingItemId(selection.id); onRemove(selection); }; @@ -140,7 +167,15 @@ export const RemovableSelectionsList = ( {isRemovable && (RemoveButton ? ( - handleOnClick(selection)} /> + handleOnClick(selection)} + /> ) : ( ({ + '&.notistack-MuiContent-error': { + borderLeft: `6px solid ${theme.palette.error.dark}`, + }, + '&.notistack-MuiContent-info': { + borderLeft: `6px solid ${theme.palette.primary.main}`, + }, + '&.notistack-MuiContent-success': { + borderLeft: `6px solid ${theme.palette.success.main}`, // corrected to palette.success + }, + '&.notistack-MuiContent-warning': { + borderLeft: `6px solid ${theme.palette.warning.dark}`, + }, + }) +); + const useStyles = makeStyles()((theme: Theme) => ({ - error: { - borderLeft: `6px solid ${theme.palette.error.dark}`, - }, - info: { - borderLeft: `6px solid ${theme.palette.primary.main}`, - }, root: { '& div': { backgroundColor: `${theme.bg.white} !important`, @@ -32,12 +46,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, }, }, - success: { - borderLeft: `6px solid ${theme.color.green}`, - }, - warning: { - borderLeft: `6px solid ${theme.palette.warning.dark}`, - }, })); export const Snackbar = (props: SnackbarProviderProps) => { @@ -59,6 +67,12 @@ export const Snackbar = (props: SnackbarProviderProps) => { ( { )} classes={{ root: classes.root, - variantError: classes.error, - variantInfo: classes.info, - variantSuccess: classes.success, - variantWarning: classes.warning, }} > {children} diff --git a/packages/manager/src/components/TagCell.stories.tsx b/packages/manager/src/components/TagCell.stories.tsx new file mode 100644 index 00000000000..2e5fd4ffcd9 --- /dev/null +++ b/packages/manager/src/components/TagCell.stories.tsx @@ -0,0 +1,62 @@ +import { useArgs } from '@storybook/preview-api'; +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { Box } from 'src/components/Box'; + +import { TagCell, TagCellProps } from './TagCell/TagCell'; + +const _tags: string[] = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5']; + +export const Default: StoryObj = { + render: (args) => { + const TagsInputWrapper = () => { + const [{ tags }, updateArgs] = useArgs(); + const handleUpdateTags = (selected: string[]) => { + return Promise.resolve(updateArgs({ tags: selected })); + }; + + return ( + + + + ); + }; + + return TagsInputWrapper(); + }, +}; + +export const InlineView: StoryObj = { + render: (args) => { + const TagsInputWrapper = () => { + const [{ tags }, updateArgs] = useArgs(); + const handleUpdateTags = (selected: string[]) => { + return Promise.resolve(updateArgs({ tags: selected })); + }; + + return ( + + undefined} + tags={tags} + updateTags={handleUpdateTags} + /> + + ); + }; + + return TagsInputWrapper(); + }, +}; + +const meta: Meta = { + args: { + disabled: false, + tags: _tags, + }, + component: TagCell, + title: 'Components/Tags/Tag Cell', +}; +export default meta; diff --git a/packages/manager/src/components/TagCell/AddTag.tsx b/packages/manager/src/components/TagCell/AddTag.tsx index 2f5bb5d1b9b..a03008278e2 100644 --- a/packages/manager/src/components/TagCell/AddTag.tsx +++ b/packages/manager/src/components/TagCell/AddTag.tsx @@ -1,26 +1,21 @@ -import { styled } from '@mui/material/styles'; import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; import { useProfile } from 'src/queries/profile'; import { updateTagsSuggestionsData, useTagSuggestions } from 'src/queries/tags'; -import { omittedProps } from 'src/utilities/omittedProps'; + +import { Autocomplete } from '../Autocomplete/Autocomplete'; interface AddTagProps { addTag: (tag: string) => Promise; - fixedMenu?: boolean; - inDetailsContext?: boolean; - label?: string; + existingTags: string[]; onClose?: () => void; - tags: string[]; } -const AddTag = (props: AddTagProps) => { - const { addTag, fixedMenu, label, onClose, tags } = props; +export const AddTag = (props: AddTagProps) => { + const { addTag, existingTags, onClose } = props; const queryClient = useQueryClient(); - const [isLoading, setIsLoading] = React.useState(false); const { data: profile } = useProfile(); const { data: accountTags, @@ -30,67 +25,68 @@ const AddTag = (props: AddTagProps) => { // thing we lose is preexisting tabs as options; the add tag flow // should still work. - const tagOptions = accountTags - ?.filter((tag) => !tags.includes(tag.label)) - .map((tag) => ({ label: tag.label, value: tag.label })); + const [inputValue, setInputValue] = React.useState(''); + const [loading, setLoading] = React.useState(false); - const handleAddTag = (newTag: Item) => { - if (newTag?.value) { - setIsLoading(true); - addTag(newTag.value) - .then(() => { - if (accountTags) { - updateTagsSuggestionsData([...accountTags, newTag], queryClient); - } - if (onClose) { - onClose(); - } - }) - .finally(() => setIsLoading(false)); - } - }; + const createTag = + !!accountTags && + !!inputValue && + !accountTags.some( + (tag) => tag.label.toLowerCase() == inputValue.toLowerCase() + ); + + const tagOptions: { displayLabel?: string; label: string }[] = [ + ...(createTag + ? [{ displayLabel: `Create "${inputValue}"`, label: inputValue }] + : []), + ...(accountTags?.filter((tag) => !existingTags.includes(tag.label)) ?? []), + ]; - const loading = accountTagsLoading || isLoading; + const handleAddTag = (newTag: string) => { + setLoading(true); + addTag(newTag) + .then(() => { + if (accountTags) { + updateTagsSuggestionsData( + [...accountTags, { label: newTag }], + queryClient + ); + } + }) + .finally(() => { + setLoading(false); + if (onClose) { + onClose(); + } + }); + }; return ( - { + if (onClose) { + onClose(); + } + }} + onChange={(_, value) => { + if (value) { + handleAddTag(typeof value == 'string' ? value : value.label); + } + }} + renderOption={(props, option) => ( +
  • {option.displayLabel ?? option.label}
  • + )} + disableClearable + forcePopupIcon + label={'Create or Select a Tag'} + loading={accountTagsLoading || loading} + noOptionsText={{`"${inputValue}" already added`}} // Will display create option unless that tag is already added + onInputChange={(_, value) => setInputValue(value)} + openOnFocus + options={tagOptions ?? []} placeholder="Create or Select a Tag" - small + sx={{ width: '100%' }} + textFieldProps={{ autoFocus: true, hideLabel: true }} /> ); }; - -export { AddTag }; - -const StyledSelect = styled(Select, { - shouldForwardProp: omittedProps(['fixedMenu', 'inDetailsContext']), -})<{ - fixedMenu?: boolean; - inDetailsContext?: boolean; -}>(({ ...props }) => ({ - padding: '0px', - width: '100%', - ...(props.fixedMenu && { - '& .react-select__menu': { - margin: '2px 0 0 0', - }, - }), - ...(props.inDetailsContext && { - display: 'flex', - flexBasis: '100%', - justifyContent: 'flex-end', - width: '415px', - }), -})); diff --git a/packages/manager/src/components/TagCell/TagCell.tsx b/packages/manager/src/components/TagCell/TagCell.tsx index cdccac6561c..a79ea11bd84 100644 --- a/packages/manager/src/components/TagCell/TagCell.tsx +++ b/packages/manager/src/components/TagCell/TagCell.tsx @@ -1,27 +1,49 @@ import MoreHoriz from '@mui/icons-material/MoreHoriz'; -import Grid from '@mui/material/Unstable_Grid2'; import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import { SxProps } from '@mui/system'; import * as React from 'react'; -import { CircleProgress } from 'src/components/CircleProgress'; import { IconButton } from 'src/components/IconButton'; import { Tag } from 'src/components/Tag/Tag'; +import { useWindowDimensions } from 'src/hooks/useWindowDimensions'; import { omittedProps } from 'src/utilities/omittedProps'; import { StyledPlusIcon, StyledTagButton } from '../Button/StyledTagButton'; +import { CircleProgress } from '../CircleProgress'; import { AddTag } from './AddTag'; -interface TagCellProps { +export interface TagCellProps { + /** + * Disable adding or deleting tags. + */ disabled?: boolean; - listAllTags: (tags: string[]) => void; + + /** + * An optional callback that is invoked when the tag list + * overflows and the user clicks to view all tags. + */ + listAllTags?: () => void; + + /** + * Additional styles to apply to the tag list. + */ sx?: SxProps; + + /** + * The list of tags to display. + */ tags: string[]; + + /** + * A callback that is invoked when the user updates + * the tag list (i.e., by adding or deleting a tag). + */ updateTags: (tags: string[]) => Promise; } // https://stackoverflow.com/questions/143815/determine-if-an-html-elements-content-overflows -const checkOverflow = (el: any) => { +const checkOverflow = (el: HTMLElement) => { const curOverflow = el.style.overflow; if (!curOverflow || curOverflow === 'visible') { @@ -35,103 +57,123 @@ const checkOverflow = (el: any) => { return isOverflowing; }; -const TagCell = (props: TagCellProps) => { - const { disabled, sx, tags, updateTags } = props; - - const [hasOverflow, setOverflow] = React.useState(false); - const [addingTag, setAddingTag] = React.useState(false); - const [loading, setLoading] = React.useState(false); - const overflowRef = React.useCallback( - (node: HTMLDivElement) => { - if (node !== null) { - setOverflow(checkOverflow(node)); - } - }, - // The function doesn't care about tags directly, - // but if the tags list changes we want to check to see if - // the overflow state has changed. - // eslint-disable-next-line - [tags] - ); +export const TagCell = (props: TagCellProps) => { + const { disabled, listAllTags, sx, tags } = props; - const handleAddTag = async (tag: string) => { - await updateTags([...tags, tag]); - }; + const [addingTag, setAddingTag] = React.useState(false); + const [loading, setLoading] = React.useState(false); + + const [elRef, setElRef] = React.useState(null); + + const windowDimensions = useWindowDimensions(); + + const [hasOverflow, setHasOverflow] = React.useState(false); + React.useLayoutEffect(() => { + setHasOverflow(!!elRef && checkOverflow(elRef)); + }, [windowDimensions, tags, elRef]); - const handleDeleteTag = (tagToDelete: string) => { + const handleUpdateTag = (updatedTags: string[]) => { setLoading(true); - updateTags(tags.filter((tag) => tag !== tagToDelete)).finally(() => - setLoading(false) - ); + return props.updateTags(updatedTags).finally(() => { + setLoading(false); + }); }; - return ( - ( + } + onClick={() => setAddingTag(true)} + panel={props.panel} + title="Add a tag" > - {loading ? ( - - - - ) : null} - {addingTag ? ( - setAddingTag(false)} - tags={tags} - /> - ) : ( - <> - + Add a tag + + ); + + return ( + <> + {(addingTag || panelView) && ( +
    + {panelView && !addingTag && } + {addingTag && ( + handleUpdateTag([...tags, tag])} + existingTags={tags} + onClose={() => setAddingTag(false)} + /> + )} +
    + )} + {(!addingTag || panelView) && ( + + + {loading ? ( + + + + ) : null} {tags.map((thisTag) => ( + handleUpdateTag(tags.filter((tag) => tag !== thisTag)) + } colorVariant="lightBlue" disabled={disabled} key={`tag-item-${thisTag}`} label={thisTag} loading={loading} - onDelete={() => handleDeleteTag(thisTag)} /> ))} - {hasOverflow ? ( + {hasOverflow && !panelView ? ( props.listAllTags(tags)} - onKeyPress={() => props.listAllTags(tags)} + onClick={() => listAllTags()} + onKeyPress={() => listAllTags()} size="large" > ) : null} - } - onClick={() => setAddingTag(true)} - title="Add a tag" - > - Add a tag - - + {!panelView && } + )} -
    + ); }; -export { TagCell }; - -const StyledGrid = styled(Grid)({ - justifyContent: 'flex-end', +const StyledGrid = styled(Grid)((props) => ({ + justifyContent: props.wrap == 'wrap' ? 'flex-start' : 'flex-end', minHeight: 40, position: 'relative', -}); +})); const StyledCircleDiv = styled('div')({ alignItems: 'center', @@ -144,15 +186,16 @@ const StyledCircleDiv = styled('div')({ }); const StyledTagListDiv = styled('div', { - shouldForwardProp: omittedProps(['hasOverflow']), + shouldForwardProp: omittedProps(['hasOverflow', 'wrap']), })<{ hasOverflow: boolean; + wrap: boolean; }>(({ ...props }) => ({ '& .MuiChip-root:last-child': { marginRight: 4, }, display: 'flex', - flexWrap: 'nowrap', + flexWrap: props.wrap ? 'wrap' : 'nowrap', overflow: 'hidden', position: 'relative', whiteSpace: 'nowrap', diff --git a/packages/manager/src/components/TagCell/TagDrawer.tsx b/packages/manager/src/components/TagCell/TagDrawer.tsx index 7a820fb74f5..71fd0a78bcb 100644 --- a/packages/manager/src/components/TagCell/TagDrawer.tsx +++ b/packages/manager/src/components/TagCell/TagDrawer.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import { Drawer } from 'src/components/Drawer'; -import { TagsPanel } from 'src/components/TagsPanel/TagsPanel'; + +import { TagCell } from './TagCell'; export type OpenTagDrawer = (id: number, label: string, tags: string[]) => void; @@ -14,14 +15,12 @@ export interface TagDrawerProps { updateTags: (tags: string[]) => Promise; } -const TagDrawer = (props: TagDrawerProps) => { +export const TagDrawer = (props: TagDrawerProps) => { const { disabled, entityLabel, onClose, open, tags, updateTags } = props; return ( - + ); }; - -export { TagDrawer }; diff --git a/packages/manager/src/components/TagsInput/TagsInput.stories.tsx b/packages/manager/src/components/TagsInput/TagsInput.stories.tsx index 77c1266f579..9959783b617 100644 --- a/packages/manager/src/components/TagsInput/TagsInput.stories.tsx +++ b/packages/manager/src/components/TagsInput/TagsInput.stories.tsx @@ -1,4 +1,4 @@ -import { useArgs } from '@storybook/client-api'; +import { useArgs } from '@storybook/preview-api'; import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.stories.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.stories.tsx deleted file mode 100644 index a7c8ce8b5cb..00000000000 --- a/packages/manager/src/components/TagsPanel/TagsPanel.stories.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useArgs } from '@storybook/client-api'; -import { Meta, StoryObj } from '@storybook/react'; -import React from 'react'; - -import { Box } from 'src/components/Box'; - -import { TagsPanel } from './TagsPanel'; - -import type { TagsPanelProps } from './TagsPanel'; - -const _tags: string[] = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5']; - -export const Default: StoryObj = { - render: (args) => { - const TagsInputWrapper = () => { - const [{ tags }, updateArgs] = useArgs(); - const handleUpdateTags = (selected: string[]) => { - return Promise.resolve(updateArgs({ tags: selected })); - }; - - return ( - - - - ); - }; - - return TagsInputWrapper(); - }, -}; - -const meta: Meta = { - args: { - disabled: false, - tags: _tags, - }, - component: TagsPanel, - title: 'Components/Tags/Tags Panel', -}; -export default meta; diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.styles.ts b/packages/manager/src/components/TagsPanel/TagsPanel.styles.ts deleted file mode 100644 index 68509434b4b..00000000000 --- a/packages/manager/src/components/TagsPanel/TagsPanel.styles.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; - -export const useStyles = makeStyles()((theme: Theme) => ({ - '@keyframes fadeIn': { - from: { - opacity: 0, - }, - to: { - opacity: 1, - }, - }, - addButtonWrapper: { - display: 'flex', - justifyContent: 'flex-start', - width: '100%', - }, - errorNotice: { - '& .noticeText': { - fontFamily: '"LatoWeb", sans-serif', - }, - animation: '$fadeIn 225ms linear forwards', - borderLeft: `5px solid ${theme.palette.error.dark}`, - marginTop: 20, - paddingLeft: 10, - textAlign: 'left', - }, - hasError: { - marginTop: 0, - }, - loading: { - opacity: 0.4, - }, - progress: { - alignItems: 'center', - display: 'flex', - height: '100%', - justifyContent: 'center', - position: 'absolute', - width: '100%', - zIndex: 2, - }, - selectTag: { - '& .error-for-scroll > div': { - flexDirection: 'row', - flexWrap: 'wrap-reverse', - }, - '& .input': { - '& p': { - borderLeft: 'none', - color: theme.color.grey1, - fontSize: '.9rem', - }, - }, - '& .react-select__input': { - backgroundColor: 'transparent', - color: theme.palette.text.primary, - fontSize: '.9rem', - }, - '& .react-select__value-container': { - padding: '6px', - }, - animation: '$fadeIn .3s ease-in-out forwards', - marginTop: -3.5, - minWidth: 275, - position: 'relative', - textAlign: 'left', - width: '100%', - zIndex: 3, - }, - tag: { - marginRight: 4, - marginTop: theme.spacing(0.5), - }, - tagsPanelItemWrapper: { - marginBottom: theme.spacing(), - position: 'relative', - }, -})); diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx deleted file mode 100644 index 93ee05ff995..00000000000 --- a/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { TagsPanel } from './TagsPanel'; - -const tagsPanelProps = { - entityId: 123, - tags: ['Tag1', 'Tag2'], -}; - -describe('TagsPanel', () => { - it('renders TagsPanel component with existing tags', async () => { - const updateTagsMock = vi.fn(() => Promise.resolve()); - - const { getByLabelText, getByText } = renderWithTheme( - - ); - - expect(getByText('Tag1')).toBeInTheDocument(); - expect(getByText('Tag2')).toBeInTheDocument(); - - const addTagButton = getByText('Add a tag'); - expect(addTagButton).toBeInTheDocument(); - - fireEvent.click(addTagButton); - - const tagInput = getByLabelText('Create or Select a Tag'); - expect(tagInput).toBeInTheDocument(); - }); - - it('creates a new tag successfully', async () => { - const updateTagsMock = vi.fn(() => Promise.resolve()); - - const { getByLabelText, getByText } = renderWithTheme( - - ); - - await userEvent.click(getByText('Add a tag')); - - fireEvent.change(getByLabelText('Create or Select a Tag'), { - target: { value: 'NewTag' }, - }); - - const newTagItem = getByText('Create "NewTag"'); - await userEvent.click(newTagItem); - - await waitFor(() => { - expect(updateTagsMock).toHaveBeenCalledWith(['NewTag', 'Tag1', 'Tag2']); - }); - }); - - it('displays an error message for invalid tag creation', async () => { - const updateTagsMock = vi.fn(() => Promise.resolve()); - - const { getByLabelText, getByText } = renderWithTheme( - - ); - - await userEvent.click(getByText('Add a tag')); - - fireEvent.change(getByLabelText('Create or Select a Tag'), { - target: { value: 'yz' }, - }); - - const newTagItem = getByText('Create "yz"'); - - await userEvent.click(newTagItem); - - await waitFor(() => - expect( - getByText('Tag "yz" length must be 3-50 characters') - ).toBeInTheDocument() - ); - }); - - it('deletes a tag successfully', async () => { - const updateTagsMock = vi.fn(() => Promise.resolve()); - - const { getByLabelText, getByText, queryByLabelText } = renderWithTheme( - - ); - - expect(getByText('Tag1')).toBeInTheDocument(); - expect(getByText('Tag2')).toBeInTheDocument(); - - const deleteTagButton = getByLabelText("Delete Tag 'Tag1'"); - fireEvent.click(deleteTagButton); - - await waitFor(() => expect(updateTagsMock).toHaveBeenCalledWith(['Tag2'])); - - expect(queryByLabelText("Search for Tag 'tag2'")).toBeNull(); - }); - - it('prevents creation or deletion of tags when disabled', async () => { - const updateTagsMock = vi.fn(() => Promise.resolve()); - - const { getByText, queryByLabelText, queryByText } = renderWithTheme( - - ); - - expect(getByText('Tag1')).toBeInTheDocument(); - expect(getByText('Tag2')).toBeInTheDocument(); - - const addTagButton = getByText('Add a tag'); - expect(addTagButton).toBeInTheDocument(); - - fireEvent.click(addTagButton); - - const tagInput = queryByText('Create or Select a Tag'); - expect(tagInput).toBeNull(); - - const deleteTagButton = queryByLabelText("Delete Tag 'Tag1'"); - expect(deleteTagButton).toBeNull(); - - await waitFor(() => expect(updateTagsMock).not.toHaveBeenCalled()); - }); -}); diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.tsx deleted file mode 100644 index e3d262c82dc..00000000000 --- a/packages/manager/src/components/TagsPanel/TagsPanel.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query'; -import * as React from 'react'; - -import { - StyledPlusIcon, - StyledTagButton, -} from 'src/components/Button/StyledTagButton'; -import { CircleProgress } from 'src/components/CircleProgress'; -import Select from 'src/components/EnhancedSelect/Select'; -import { Tag } from 'src/components/Tag/Tag'; -import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; -import { updateTagsSuggestionsData, useTagSuggestions } from 'src/queries/tags'; -import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; - -import { useStyles } from './TagsPanel.styles'; - -interface Item { - label: string; - value: string; -} - -interface Tag { - label: string; -} - -interface ActionMeta { - action: string; -} - -export interface TagsPanelProps { - /** - * If true, the input will be disabled and no tags can be added or removed. - */ - disabled?: boolean; - /** - * The tags to display. - */ - tags: string[]; - /** - * Callback fired when the tags are updated. - */ - updateTags: (tags: string[]) => Promise; -} - -export const TagsPanel = (props: TagsPanelProps) => { - const { classes, cx } = useStyles(); - const { disabled, tags, updateTags } = props; - - const queryClient = useQueryClient(); - - const [tagError, setTagError] = React.useState(''); - const [isCreatingTag, setIsCreatingTag] = React.useState(false); - const [tagsLoading, setTagsLoading] = React.useState(false); - - const { data: profile } = useProfile(); - - const { - data: userTags, - error: userTagsError, - isFetching: userTagsLoading, - } = useTagSuggestions(!profile?.restricted); - - const tagsToSuggest = React.useMemo( - () => - userTags - ?.filter((tag) => !tags.some((appliedTag) => appliedTag === tag.label)) - .map((tag) => ({ - label: tag.label, - value: tag.label, - })), - [userTags, tags] - ); - - React.useEffect(() => { - setTagError(''); - }, [isCreatingTag]); - - const toggleTagInput = () => { - if (!disabled) { - setIsCreatingTag((prev) => !prev); - } - }; - - const userTagsErrorDisplay = userTagsError - ? 'There was an error retrieving your tags.' - : ''; - - const handleDeleteTag = (label: string) => { - setTagsLoading(true); - - const tagsWithoutDeletedTag = tags.filter( - (thisTag: string) => thisTag !== label - ); - - updateTags(tagsWithoutDeletedTag) - .then(() => { - setTagError(''); - }) - .catch((e) => { - const tagError = getErrorStringOrDefault(e, 'Error while deleting tag'); - setTagError(tagError); - }) - .finally(() => { - setTagsLoading(false); - }); - }; - - const handleCreateTag = (value: Item, actionMeta: ActionMeta) => { - const inputValue = value && value.value; - - /* - * This comes from the react-select API - * basically, we only want to make a request if the user is either - * hitting the enter button or choosing a selection from the dropdown - */ - if ( - actionMeta.action !== 'select-option' && - actionMeta.action !== 'create-option' - ) { - return; - } - - const tagExists = (tag: string) => { - return tags.some((el) => { - return el === tag; - }); - }; - - toggleTagInput(); - - if (inputValue.length < 3 || inputValue.length > 50) { - setTagError(`Tag "${inputValue}" length must be 3-50 characters`); - } else if (tagExists(inputValue)) { - setTagError(`Tag "${inputValue}" is a duplicate`); - } else { - setTagError(''); - setTagsLoading(true); - updateTags([...tags, value.label].sort()) - .then(() => { - if (userTags) { - updateTagsSuggestionsData([...userTags, value], queryClient); - } - }) - .catch((e) => { - const tagError = getErrorStringOrDefault( - e, - 'Error while creating tag' - ); - setTagError(tagError); - }) - .finally(() => { - setTagsLoading(false); - }); - } - }; - - return ( - <> - {isCreatingTag ? ( - handleEntityTypeChange(type.value)} options={topicOptions} value={selectedTopic} /> {!['general', 'none'].includes(entityType) && ( <> -