diff --git a/packages/api-v4/.changeset/pr-11261-added-1732225555236.md b/packages/api-v4/.changeset/pr-11261-added-1732225555236.md new file mode 100644 index 00000000000..0ec0038ff54 --- /dev/null +++ b/packages/api-v4/.changeset/pr-11261-added-1732225555236.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +Placement Groups migrations Types ([#11261](https://github.com/linode/manager/pull/11261)) diff --git a/packages/api-v4/.changeset/pr-11330-removed-1732610877556.md b/packages/api-v4/.changeset/pr-11330-removed-1732610877556.md new file mode 100644 index 00000000000..8a2ef67f05a --- /dev/null +++ b/packages/api-v4/.changeset/pr-11330-removed-1732610877556.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Removed +--- + +Recently added camelCase rule ([#11330](https://github.com/linode/manager/pull/11330)) diff --git a/packages/api-v4/.eslintrc.json b/packages/api-v4/.eslintrc.json index aa613f8f952..039685b3d24 100644 --- a/packages/api-v4/.eslintrc.json +++ b/packages/api-v4/.eslintrc.json @@ -49,7 +49,6 @@ "sonarjs/no-redundant-jump": "warn", "sonarjs/no-small-switch": "warn", "no-multiple-empty-lines": "error", - "camelcase": ["warn", { "properties": "always" }], "curly": "warn", "sort-keys": "off", "comma-dangle": "off", diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index f7c4b732043..3c6f909b9db 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -5,12 +5,12 @@ import { BETA_API_ROOT as API_ROOT } from 'src/constants'; export const createAlertDefinition = ( data: CreateAlertDefinitionPayload, - service_type: AlertServiceType + serviceType: AlertServiceType ) => Request( setURL( `${API_ROOT}/monitor/services/${encodeURIComponent( - service_type! + serviceType! )}/alert-definitions` ), setMethod('POST'), diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 6a863076a73..4b64bf16c30 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -143,7 +143,7 @@ export interface ServiceTypesList { export interface CreateAlertDefinitionPayload { label: string; description?: string; - resource_ids?: string[]; + entity_ids?: string[]; severity: AlertSeverityType; rule_criteria: { rules: MetricCriteria[]; @@ -174,11 +174,12 @@ export interface Alert { id: number; label: string; description: string; + has_more_resources: boolean; status: AlertStatusType; type: AlertDefinitionType; severity: AlertSeverityType; service_type: AlertServiceType; - resource_ids: string[]; + entity_ids: string[]; rule_criteria: { rules: MetricCriteria[]; }; diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 7c70fcb4d07..6f3d94caad3 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -1,7 +1,7 @@ import type { Region, RegionSite } from '../regions'; import type { IPAddress, IPRange } from '../networking/types'; import type { SSHKey } from '../profile/types'; -import type { PlacementGroupPayload } from '../placement-groups/types'; +import type { LinodePlacementGroupPayload } from '../placement-groups/types'; export type Hypervisor = 'kvm' | 'zen'; @@ -30,7 +30,7 @@ export interface Linode { ipv6: string | null; label: string; lke_cluster_id: number | null; - placement_group?: PlacementGroupPayload; // If not in a placement group, this will be excluded from the response. + placement_group?: LinodePlacementGroupPayload; // If not in a placement group, this will be excluded from the response. type: string | null; status: LinodeStatus; updated: string; diff --git a/packages/api-v4/src/placement-groups/types.ts b/packages/api-v4/src/placement-groups/types.ts index 4b79bba15fe..197a28fe44d 100644 --- a/packages/api-v4/src/placement-groups/types.ts +++ b/packages/api-v4/src/placement-groups/types.ts @@ -24,15 +24,22 @@ export interface PlacementGroup { is_compliant: boolean; }[]; placement_group_policy: PlacementGroupPolicy; + migrations: { + inbound?: Array<{ linode_id: number }>; + outbound?: Array<{ linode_id: number }>; + } | null; } -export type PlacementGroupPayload = Pick< - PlacementGroup, - 'id' | 'label' | 'placement_group_type' | 'placement_group_policy' ->; +export interface LinodePlacementGroupPayload + extends Pick< + PlacementGroup, + 'id' | 'label' | 'placement_group_type' | 'placement_group_policy' + > { + migrating_to: number | null; +} export interface CreatePlacementGroupPayload - extends Omit { + extends Omit { region: Region['id']; } diff --git a/packages/manager/.changeset/pr-11261-changed-1732225439368.md b/packages/manager/.changeset/pr-11261-changed-1732225439368.md new file mode 100644 index 00000000000..c2f4450cb07 --- /dev/null +++ b/packages/manager/.changeset/pr-11261-changed-1732225439368.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Improve Placement Groups UI during Linode Migrations ([#11261](https://github.com/linode/manager/pull/11261)) diff --git a/packages/manager/.changeset/pr-11296-removed-1732128125283.md b/packages/manager/.changeset/pr-11296-removed-1732128125283.md new file mode 100644 index 00000000000..7944068f058 --- /dev/null +++ b/packages/manager/.changeset/pr-11296-removed-1732128125283.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Removed +--- + +`Toggle` component and `ToggleOn` and `ToggleOff` icons (migrated to `ui` package) ([#11296](https://github.com/linode/manager/pull/11296)) diff --git a/packages/manager/.changeset/pr-11309-upcoming-features-1732282296647.md b/packages/manager/.changeset/pr-11309-upcoming-features-1732282296647.md new file mode 100644 index 00000000000..4eff88ffb62 --- /dev/null +++ b/packages/manager/.changeset/pr-11309-upcoming-features-1732282296647.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Handle JWE token limit of 250 in ACLP UI ([#11309](https://github.com/linode/manager/pull/11309)) diff --git a/packages/manager/.changeset/pr-11317-upcoming-features-1732533936298.md b/packages/manager/.changeset/pr-11317-upcoming-features-1732533936298.md new file mode 100644 index 00000000000..45112c15a2c --- /dev/null +++ b/packages/manager/.changeset/pr-11317-upcoming-features-1732533936298.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Modify `generate12HoursTicks` method in AreaChart `utils.ts`, Remove breakpoint condition in `MetricsDisplay.tsx`, modify `legendHeight` and `xAxisTickCount` in `CloudPulseLineGraph.tsx` ([#11317](https://github.com/linode/manager/pull/11317)) diff --git a/packages/manager/.changeset/pr-11323-tests-1732563014945.md b/packages/manager/.changeset/pr-11323-tests-1732563014945.md new file mode 100644 index 00000000000..a4210bd1b4a --- /dev/null +++ b/packages/manager/.changeset/pr-11323-tests-1732563014945.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add tests for accelerated plans in `plan-selection.spec.ts` ([#11323](https://github.com/linode/manager/pull/11323)) diff --git a/packages/manager/.changeset/pr-11327-tests-1732571554878.md b/packages/manager/.changeset/pr-11327-tests-1732571554878.md new file mode 100644 index 00000000000..d9a98df67a1 --- /dev/null +++ b/packages/manager/.changeset/pr-11327-tests-1732571554878.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add test to create a mock accelerated Linode ([#11327](https://github.com/linode/manager/pull/11327)) diff --git a/packages/manager/.changeset/pr-11330-removed-1732610901455.md b/packages/manager/.changeset/pr-11330-removed-1732610901455.md new file mode 100644 index 00000000000..757e92d81ae --- /dev/null +++ b/packages/manager/.changeset/pr-11330-removed-1732610901455.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Removed +--- + +Recently added camelCase rule ([#11330](https://github.com/linode/manager/pull/11330)) diff --git a/packages/manager/.changeset/pr-11331-added-1732627930598.md b/packages/manager/.changeset/pr-11331-added-1732627930598.md new file mode 100644 index 00000000000..6a2f1d24a13 --- /dev/null +++ b/packages/manager/.changeset/pr-11331-added-1732627930598.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +ResourceMultiSelect component, along with UT. Changed case for few variables and properties ([#11331](https://github.com/linode/manager/pull/11331)) diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index ae58ca2a7f9..37a8ee55d6c 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -174,7 +174,6 @@ module.exports = { '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-use-before-define': 'off', 'array-callback-return': 'error', - camelcase: ['warn', { properties: 'always' }], 'comma-dangle': 'off', // Prettier and TS both handle and check for this one // radix: Codacy considers it as an error, i put it here to fix it before push curly: 'warn', diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index f57807153d6..fbe81f6b9c6 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -12,13 +12,20 @@ import { authenticate } from 'support/api/authentication'; import { interceptCreateLinode, mockCreateLinodeError, + mockCreateLinode, + mockGetLinodeDisks, + mockGetLinodeType, + mockGetLinodeTypes, + mockGetLinodeVolumes, } from 'support/intercepts/linodes'; import { interceptGetProfile } from 'support/intercepts/profile'; import { Region, VLAN, Config, Disk } from '@linode/api-v4'; import { getRegionById } from 'support/util/regions'; import { + accountFactory, linodeFactory, linodeConfigFactory, + linodeTypeFactory, VLANFactory, vpcFactory, subnetFactory, @@ -27,18 +34,11 @@ import { LinodeConfigInterfaceFactoryWithVPC, } from 'src/factories'; import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; -import { - mockGetLinodeType, - mockGetLinodeTypes, -} from 'support/intercepts/linodes'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetRegions } from 'support/intercepts/regions'; import { mockGetVLANs } from 'support/intercepts/vlans'; import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; -import { - mockCreateLinode, - mockGetLinodeDisks, - mockGetLinodeVolumes, -} from 'support/intercepts/linodes'; import { mockGetLinodeConfigs } from 'support/intercepts/configs'; import { fbtClick, @@ -47,7 +47,7 @@ import { getVisible, containsVisible, } from 'support/helpers'; -import {} from 'support/helpers'; + let username: string; authenticate(); @@ -85,6 +85,7 @@ describe('Create Linode', () => { planId: 'g7-premium-2', }, // TODO Include GPU plan types. + // TODO Include Accelerated plan types (when they're no longer as restricted) ].forEach((planConfig) => { /* * - Parameterized end-to-end test to create a Linode for each plan type. @@ -170,6 +171,107 @@ describe('Create Linode', () => { }); }); + // Mocks creating an accelerated Linode due to accelerated linodes currently having limited deployment availability + // TODO: eventually transition this to an e2e test (in the above test) + it('creates a mock accelerated Linode and confirms response', () => { + // Create mocks + const linodeLabel = randomLabel(); + const mockLinode = linodeFactory.build({ + label: linodeLabel, + specs: { + accelerated_devices: 2, + disk: 51200, + gpus: 0, + memory: 2048, + transfer: 2000, + vcpus: 1, + }, + type: 'accelerated-1', + }); + const mockAcceleratedType = [ + linodeTypeFactory.build({ + id: 'accelerated-1', + label: 'accelerated-1', + class: 'accelerated', + }), + ]; + const mockRegions = [ + regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'NETINT Quadra T1U'], + id: 'us-east', + label: 'Newark, NJ', + }), + ]; + const linodeRegion = mockRegions[0]; + + // Create request intercepts + mockGetAccount( + accountFactory.build({ + capabilities: ['NETINT Quadra T1U'], + }) + ).as('getAccount'); + mockAppendFeatureFlags({ + acceleratedPlans: { + linodePlans: true, + lkePlans: false, + }, + }).as('getFeatureFlags'); + mockGetRegions(mockRegions).as('getRegions'); + mockGetLinodeTypes([...mockAcceleratedType]).as('getLinodeTypes'); + mockCreateLinode(mockLinode).as('createLinode'); + + cy.visitWithLogin('/linodes/create'); + cy.wait([ + '@getRegions', + '@getLinodeTypes', + '@getAccount', + '@getFeatureFlags', + ]); + + // Set Linode label, OS, plan type, password, etc. + linodeCreatePage.setLabel(linodeLabel); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Accelerated', mockAcceleratedType[0].label); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm information in summary is shown as expected. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('Debian 11').should('be.visible'); + cy.findByText(`US, ${linodeRegion.label}`).should('be.visible'); + cy.findByText(mockAcceleratedType[0].label).should('be.visible'); + }); + + // Create Linode and confirm it's provisioned as expected. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const responsePayload = xhr.response?.body; + + // Confirm that API request and response contain expected data + expect(requestPayload['label']).to.equal(linodeLabel); + expect(requestPayload['region']).to.equal(linodeRegion.id); + expect(requestPayload['type']).to.equal(mockAcceleratedType[0].id); + + expect(responsePayload['label']).to.equal(linodeLabel); + expect(responsePayload['region']).to.equal(linodeRegion.id); + expect(responsePayload['type']).to.equal(mockAcceleratedType[0].id); + + // Accelerated linodes: Confirm accelerated_devices value is returned as expected + expect(responsePayload['specs']).has.property('accelerated_devices', 2); + + // Confirm that Cloud redirects to details page + cy.url().should('endWith', `/linodes/${responsePayload['id']}`); + }); + }); + it('adds an SSH key to the linode during create flow', () => { const rootpass = randomString(32); const sshPublicKeyLabel = randomLabel(); diff --git a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts index 99255a0241c..d381283aa07 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -2,9 +2,10 @@ // Move this to cypress component testing once the setup is complete - see https://github.com/linode/manager/pull/10134 import { ui } from 'support/ui'; import { + accountFactory, + linodeTypeFactory, regionFactory, regionAvailabilityFactory, - linodeTypeFactory, } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { @@ -12,6 +13,7 @@ import { mockGetRegionAvailability, } from 'support/intercepts/regions'; import { mockGetLinodeTypes } from 'support/intercepts/linodes'; +import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; const mockRegions = [ @@ -84,11 +86,20 @@ const mockGPUType = [ }), ]; +const mockAcceleratedType = [ + linodeTypeFactory.build({ + id: 'accelerated-1', + label: 'accelerated-1', + class: 'accelerated', + }), +]; + const mockLinodeTypes = [ ...mockDedicatedLinodeTypes, ...mockHighMemoryLinodeTypes, ...mockSharedLinodeTypes, ...mockGPUType, + ...mockAcceleratedType, ]; const mockRegionAvailability = [ @@ -397,3 +408,168 @@ describe('displays specific linode plans for GPU', () => { }); }); }); + +describe('Linode Accelerated plans', () => { + beforeEach(() => { + mockGetRegions(mockRegions).as('getRegions'); + mockGetLinodeTypes(mockLinodeTypes).as('getLinodeTypes'); + mockGetRegionAvailability(mockRegions[0].id, mockRegionAvailability).as( + 'getRegionAvailability' + ); + }); + + describe('without necessary account capability', () => { + beforeEach(() => { + mockGetAccount( + accountFactory.build({ + capabilities: [], + }) + ).as('getAccount'); + mockAppendFeatureFlags({ + acceleratedPlans: { + linodePlans: true, + lkePlans: true, + }, + }).as('getFeatureFlags'); + }); + + it('should not render accelerated plans for linodes', () => { + cy.visitWithLogin('/linodes/create'); + cy.wait([ + '@getRegions', + '@getLinodeTypes', + '@getAccount', + '@getFeatureFlags', + ]); + + cy.findByText('Accelerated').should('not.exist'); + }); + + it('should not render accelerated plans for kubernetes', () => { + cy.visitWithLogin('/kubernetes/create'); + cy.wait([ + '@getRegions', + '@getLinodeTypes', + '@getAccount', + '@getFeatureFlags', + ]); + + cy.findByText('Accelerated').should('not.exist'); + }); + }); + + describe('with necessary account capability', () => { + beforeEach(() => { + mockGetAccount( + accountFactory.build({ + capabilities: ['NETINT Quadra T1U'], + }) + ).as('getAccount'); + }); + + describe('Linodes plans panel', () => { + it('should render Accelerated plans when the feature flag is on', () => { + mockAppendFeatureFlags({ + acceleratedPlans: { + linodePlans: true, + lkePlans: false, + }, + }).as('getFeatureFlags'); + cy.visitWithLogin('/linodes/create'); + cy.wait([ + '@getRegions', + '@getLinodeTypes', + '@getAccount', + '@getFeatureFlags', + ]); + + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(mockRegions[0].label).click(); + + cy.findByText('Accelerated').click(); + cy.get(linodePlansPanel).within(() => { + cy.findAllByRole('alert').should('have.length', 1); + + cy.findByRole('table', { + name: 'List of Linode Plans', + }).within(() => { + cy.findByText('NETINT Quadra T1U').should('be.visible'); + cy.findAllByRole('row').should('have.length', 2); + cy.get('[id="accelerated-1"]').should('be.disabled'); + }); + }); + }); + + it('should not render Accelerated plans when the feature flag is off', () => { + mockAppendFeatureFlags({ + acceleratedPlans: { + linodePlans: false, + lkePlans: false, + }, + }).as('getFeatureFlags'); + cy.visitWithLogin('/linodes/create'); + cy.wait([ + '@getRegions', + '@getLinodeTypes', + '@getAccount', + '@getFeatureFlags', + ]); + + // Confirms Accelerated tab does not show up for linodes + cy.findByText('Accelerated').should('not.exist'); + }); + }); + + describe('kubernetes plans panel', () => { + it('should render Accelerated plans when the feature flag is on', () => { + mockAppendFeatureFlags({ + acceleratedPlans: { + linodePlans: false, + lkePlans: true, + }, + }).as('getFeatureFlags'); + cy.visitWithLogin('/kubernetes/create'); + cy.wait([ + '@getRegions', + '@getLinodeTypes', + '@getAccount', + '@getFeatureFlags', + ]); + + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(mockRegions[0].label).click(); + + cy.wait(['@getRegionAvailability']); + + cy.findByText('Accelerated').click(); + cy.get(k8PlansPanel).within(() => { + cy.findAllByRole('alert').should('have.length', 1); + + cy.findByRole('table', { name: planSelectionTable }).within(() => { + cy.findAllByRole('row').should('have.length', 2); + cy.get('[data-qa-plan-row="accelerated-1"]').should('be.visible'); + }); + }); + }); + + it('should not render Accelerated plans when the feature flag is off', () => { + mockAppendFeatureFlags({ + acceleratedPlans: { + linodePlans: false, + lkePlans: false, + }, + }).as('getFeatureFlags'); + cy.visitWithLogin('/kubernetes/create'); + cy.wait([ + '@getRegions', + '@getLinodeTypes', + '@getAccount', + '@getFeatureFlags', + ]); + + // Confirms Accelerated tab does not show up for LKE clusters + cy.findByText('Accelerated').should('not.exist'); + }); + }); + }); +}); diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index 85a06a1bf44..5bce0758ef1 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -357,7 +357,7 @@ export const MainContent = () => { )} {isACLPEnabled && ( - + )} {/** We don't want to break any bookmarks. This can probably be removed eventually. */} diff --git a/packages/manager/src/__data__/linodes.ts b/packages/manager/src/__data__/linodes.ts index b3fa200b66a..cf156461fa9 100644 --- a/packages/manager/src/__data__/linodes.ts +++ b/packages/manager/src/__data__/linodes.ts @@ -28,6 +28,7 @@ export const linode1: Linode = { placement_group: { id: 1, label: 'pg-1', + migrating_to: null, placement_group_policy: 'strict', placement_group_type: 'anti_affinity:local', }, @@ -76,6 +77,7 @@ export const linode2: Linode = { placement_group: { id: 1, label: 'pg-1', + migrating_to: null, placement_group_policy: 'strict', placement_group_type: 'anti_affinity:local', }, @@ -124,6 +126,7 @@ export const linode3: Linode = { placement_group: { id: 1, label: 'pg-1', + migrating_to: null, placement_group_policy: 'strict', placement_group_type: 'anti_affinity:local', }, @@ -172,6 +175,7 @@ export const linode4: Linode = { placement_group: { id: 1, label: 'pg-1', + migrating_to: null, placement_group_policy: 'strict', placement_group_type: 'anti_affinity:local', }, diff --git a/packages/manager/src/components/AreaChart/AreaChart.tsx b/packages/manager/src/components/AreaChart/AreaChart.tsx index 3a5c014453c..1ebe751e78f 100644 --- a/packages/manager/src/components/AreaChart/AreaChart.tsx +++ b/packages/manager/src/components/AreaChart/AreaChart.tsx @@ -64,7 +64,7 @@ interface YAxisProps { /** * The formatter function for the y-axis tick. */ - tickFormat: () => string; + tickFormat: (value: number) => string; } export interface AreaChartProps { diff --git a/packages/manager/src/components/AreaChart/utils.ts b/packages/manager/src/components/AreaChart/utils.ts index 8777ca93bc1..0bbcb638a0b 100644 --- a/packages/manager/src/components/AreaChart/utils.ts +++ b/packages/manager/src/components/AreaChart/utils.ts @@ -57,6 +57,10 @@ export const generate12HourTicks = ( const startTime = data[0].timestamp; const endTime = data[data.length - 1].timestamp; + if (tickCount === 1) { + return [(startTime + endTime) / 2]; + } + // Calculate duration in hours const duration = DateTime.fromMillis(endTime, { zone: timezone }).diff( DateTime.fromMillis(startTime, { zone: timezone }), diff --git a/packages/manager/src/components/FormControlLabel.stories.tsx b/packages/manager/src/components/FormControlLabel.stories.tsx index f306160b57c..b088aa6e51a 100644 --- a/packages/manager/src/components/FormControlLabel.stories.tsx +++ b/packages/manager/src/components/FormControlLabel.stories.tsx @@ -1,8 +1,7 @@ -import { Checkbox, Radio } from '@linode/ui'; +import { Checkbox, Radio, Toggle } from '@linode/ui'; import React from 'react'; import { FormControlLabel } from './FormControlLabel'; -import { Toggle } from './Toggle/Toggle'; import type { Meta, StoryObj } from '@storybook/react'; diff --git a/packages/manager/src/factories/cloudpulse/alerts.ts b/packages/manager/src/factories/cloudpulse/alerts.ts index 4fbf4e0e222..a0bc2b6edf7 100644 --- a/packages/manager/src/factories/cloudpulse/alerts.ts +++ b/packages/manager/src/factories/cloudpulse/alerts.ts @@ -7,9 +7,10 @@ export const alertFactory = Factory.Sync.makeFactory({ created: new Date().toISOString(), created_by: 'user1', description: '', + entity_ids: ['0', '1', '2', '3'], + has_more_resources: true, id: Factory.each((i) => i), label: Factory.each((id) => `Alert-${id}`), - resource_ids: ['0', '1', '2', '3'], rule_criteria: { rules: [], }, diff --git a/packages/manager/src/factories/linodes.ts b/packages/manager/src/factories/linodes.ts index b5063025665..b2e14153965 100644 --- a/packages/manager/src/factories/linodes.ts +++ b/packages/manager/src/factories/linodes.ts @@ -1,8 +1,5 @@ import Factory from 'src/factories/factoryProxy'; -import { placementGroupFactory } from './placementGroups'; - -import type { RegionalNetworkUtilization } from '@linode/api-v4/lib/account'; import type { CreateLinodeRequest, Linode, @@ -10,12 +7,14 @@ import type { LinodeBackup, LinodeBackups, LinodeIPsResponse, + LinodePlacementGroupPayload, LinodeSpecs, LinodeType, NetStats, + RegionalNetworkUtilization, Stats, StatsData, -} from '@linode/api-v4/lib/linodes/types'; +} from '@linode/api-v4'; export const linodeAlertsFactory = Factory.Sync.makeFactory({ cpu: 10, @@ -269,6 +268,16 @@ export const proDedicatedTypeFactory = Factory.Sync.makeFactory({ vcpus: 56, }); +export const linodePlacementGroupPayloadFactory = Factory.Sync.makeFactory( + { + id: Factory.each((i) => i), + label: Factory.each((i) => `pg-${i}`), + migrating_to: null, + placement_group_policy: 'strict', + placement_group_type: 'anti_affinity:local', + } +); + export const linodeFactory = Factory.Sync.makeFactory({ alerts: linodeAlertsFactory.build(), backups: linodeBackupsFactory.build(), @@ -283,10 +292,9 @@ export const linodeFactory = Factory.Sync.makeFactory({ ipv6: '2600:3c00::f03c:92ff:fee2:6c40/64', label: Factory.each((i) => `linode-${i}`), lke_cluster_id: null, - placement_group: placementGroupFactory.build({ + placement_group: linodePlacementGroupPayloadFactory.build({ id: 1, label: 'pg-1', - placement_group_type: 'anti_affinity:local', }), region: 'us-east', site_type: 'core', diff --git a/packages/manager/src/factories/placementGroups.ts b/packages/manager/src/factories/placementGroups.ts index dd13d13f6a4..6e8d95c051c 100644 --- a/packages/manager/src/factories/placementGroups.ts +++ b/packages/manager/src/factories/placementGroups.ts @@ -1,5 +1,4 @@ import Factory from 'src/factories/factoryProxy'; - import { pickRandom } from 'src/utilities/random'; import type { @@ -8,10 +7,8 @@ import type { } from '@linode/api-v4'; export const placementGroupFactory = Factory.Sync.makeFactory({ - placement_group_type: 'anti_affinity:local', id: Factory.each((id) => id), is_compliant: Factory.each(() => pickRandom([true, false])), - placement_group_policy: 'strict', label: Factory.each((id) => `pg-${id}`), members: [ { @@ -51,14 +48,17 @@ export const placementGroupFactory = Factory.Sync.makeFactory({ linode_id: 43, }, ], + migrations: null, + placement_group_policy: 'strict', + placement_group_type: 'anti_affinity:local', region: 'us-east', }); export const createPlacementGroupPayloadFactory = Factory.Sync.makeFactory( { - placement_group_type: 'anti_affinity:local', - placement_group_policy: 'strict', label: Factory.each((id) => `mock-pg-${id}`), + placement_group_policy: 'strict', + placement_group_type: 'anti_affinity:local', region: pickRandom(['us-east', 'us-southeast', 'ca-central']), } ); diff --git a/packages/manager/src/features/Account/AutoBackups.tsx b/packages/manager/src/features/Account/AutoBackups.tsx index 759cd216d25..a6de470a3c3 100644 --- a/packages/manager/src/features/Account/AutoBackups.tsx +++ b/packages/manager/src/features/Account/AutoBackups.tsx @@ -1,11 +1,10 @@ -import { Accordion, Notice, Typography } from '@linode/ui'; +import { Accordion, Notice, Toggle, Typography } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Link } from 'src/components/Link'; -import { Toggle } from 'src/components/Toggle/Toggle'; import type { Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/features/Account/NetworkHelper.tsx b/packages/manager/src/features/Account/NetworkHelper.tsx index 354e6f5ccab..912bbee79f7 100644 --- a/packages/manager/src/features/Account/NetworkHelper.tsx +++ b/packages/manager/src/features/Account/NetworkHelper.tsx @@ -1,9 +1,8 @@ -import { Accordion, Typography } from '@linode/ui'; +import { Accordion, Toggle, Typography } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { FormControlLabel } from 'src/components/FormControlLabel'; -import { Toggle } from 'src/components/Toggle/Toggle'; interface Props { networkHelperEnabled: boolean; diff --git a/packages/manager/src/features/Backups/AutoEnroll.tsx b/packages/manager/src/features/Backups/AutoEnroll.tsx index 817a038a425..311b8ce4986 100644 --- a/packages/manager/src/features/Backups/AutoEnroll.tsx +++ b/packages/manager/src/features/Backups/AutoEnroll.tsx @@ -1,10 +1,9 @@ -import { Notice, Paper, Typography } from '@linode/ui'; +import { Notice, Paper, Toggle, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Link } from 'src/components/Link'; -import { Toggle } from 'src/components/Toggle/Toggle'; interface AutoEnrollProps { enabled: boolean; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx index f6be4ae8b84..352ded4e3e2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx @@ -14,7 +14,7 @@ export const AlertDefinitionLanding = () => { /> } - path="/monitor/cloudpulse/alerts/definitions/create" + path="/monitor/alerts/definitions/create" /> ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx index 50aaa6fa994..718050113c1 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx @@ -84,7 +84,6 @@ export const AlertsLanding = React.memo(() => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx index 35d4e4ad328..c8342ee6293 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx @@ -34,5 +34,6 @@ describe('AlertDefinition Create', () => { expect(getByText('Severity is required.')).toBeVisible(); expect(getByText('Service is required.')).toBeVisible(); expect(getByText('Region is required.')).toBeVisible(); + expect(getByText('At least one resource is needed.')).toBeVisible(); }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index 4b7cb07bd0f..61a6822075e 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -12,6 +12,7 @@ import { useCreateAlertDefinition } from 'src/queries/cloudpulse/alerts'; import { CloudPulseAlertSeveritySelect } from './GeneralInformation/AlertSeveritySelect'; import { EngineOption } from './GeneralInformation/EngineOption'; import { CloudPulseRegionSelect } from './GeneralInformation/RegionSelect'; +import { CloudPulseMultiResourceSelect } from './GeneralInformation/ResourceMultiSelect'; import { CloudPulseServiceSelect } from './GeneralInformation/ServiceTypeSelect'; import { CreateAlertDefinitionFormSchema } from './schemas'; import { filterFormValues, filterMetricCriteriaFormValues } from './utilities'; @@ -33,14 +34,14 @@ const criteriaInitialValues: MetricCriteriaForm = { }; const initialValues: CreateAlertDefinitionForm = { channel_ids: [], - engine_type: null, + engineType: null, + entity_ids: [], label: '', region: '', - resource_ids: [], rule_criteria: { rules: filterMetricCriteriaFormValues(criteriaInitialValues), }, - service_type: null, + serviceType: null, severity: null, triggerCondition: triggerConditionInitialValues, }; @@ -48,19 +49,18 @@ const initialValues: CreateAlertDefinitionForm = { const overrides = [ { label: 'Definitions', - linkTo: '/monitor/cloudpulse/alerts/definitions', + linkTo: '/monitor/alerts/definitions', position: 1, }, { label: 'Details', - linkTo: `/monitor/cloudpulse/alerts/definitions/create`, + linkTo: `/monitor/alerts/definitions/create`, position: 2, }, ]; export const CreateAlertDefinition = () => { const history = useHistory(); - const alertCreateExit = () => - history.push('/monitor/cloudpulse/alerts/definitions'); + const alertCreateExit = () => history.push('/monitor/alerts/definitions'); const formMethods = useForm({ defaultValues: initialValues, @@ -78,10 +78,10 @@ export const CreateAlertDefinition = () => { } = formMethods; const { enqueueSnackbar } = useSnackbar(); const { mutateAsync: createAlert } = useCreateAlertDefinition( - getValues('service_type')! + getValues('serviceType')! ); - const serviceWatcher = watch('service_type'); + const serviceTypeWatcher = watch('serviceType'); const onSubmit = handleSubmit(async (values) => { try { await createAlert(filterFormValues(values)); @@ -140,9 +140,15 @@ export const CreateAlertDefinition = () => { control={control} name="description" /> - - {serviceWatcher === 'dbaas' && } + + {serviceTypeWatcher === 'dbaas' && } + { it('should render the component when resource type is dbaas', () => { const { getByLabelText, getByTestId } = renderWithThemeAndHookFormContext({ - component: , + component: , }); expect(getByLabelText('Engine Option')).toBeInTheDocument(); expect(getByTestId('engine-option')).toBeInTheDocument(); @@ -17,7 +17,7 @@ describe('EngineOption component tests', () => { it('should render the options happy path', async () => { const user = userEvent.setup(); renderWithThemeAndHookFormContext({ - component: , + component: , }); user.click(screen.getByRole('button', { name: 'Open' })); expect(await screen.findByRole('option', { name: 'MySQL' })); @@ -26,7 +26,7 @@ describe('EngineOption component tests', () => { it('should be able to select an option', async () => { const user = userEvent.setup(); renderWithThemeAndHookFormContext({ - component: , + component: , }); user.click(screen.getByRole('button', { name: 'Open' })); await user.click(await screen.findByRole('option', { name: 'MySQL' })); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.test.tsx new file mode 100644 index 00000000000..d89fe9d3a24 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.test.tsx @@ -0,0 +1,234 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { linodeFactory } from 'src/factories'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { CloudPulseMultiResourceSelect } from './ResourceMultiSelect'; + +const queryMocks = vi.hoisted(() => ({ + useResourcesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/resources', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/resources'); + return { + ...actual, + useResourcesQuery: queryMocks.useResourcesQuery, + }; +}); +const SELECT_ALL = 'Select All'; +const ARIA_SELECTED = 'aria-selected'; +describe('ResourceMultiSelect component tests', () => { + it('should render disabled component if the props are undefined or regions and service type does not have any values', () => { + const mockLinodes = linodeFactory.buildList(2); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockLinodes, + isError: false, + isLoading: false, + status: 'success', + }); + const { + getByPlaceholderText, + getByTestId, + } = renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + expect(getByTestId('resource-select')).toBeInTheDocument(); + expect(getByPlaceholderText('Select Resources')).toBeInTheDocument(); + }); + + it('should render resources happy path', async () => { + const user = userEvent.setup(); + const mockLinodes = linodeFactory.buildList(2); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockLinodes, + isError: false, + isLoading: false, + status: 'success', + }); + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + user.click(screen.getByRole('button', { name: 'Open' })); + expect( + await screen.findByRole('option', { + name: mockLinodes[0].label, + }) + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { + name: mockLinodes[1].label, + }) + ).toBeInTheDocument(); + }); + + it('should be able to select all resources', async () => { + const user = userEvent.setup(); + const mockLinodes = linodeFactory.buildList(2); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockLinodes, + isError: false, + isLoading: false, + status: 'success', + }); + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + user.click(await screen.findByRole('button', { name: 'Open' })); + await user.click(await screen.findByRole('option', { name: SELECT_ALL })); + expect( + await screen.findByRole('option', { + name: mockLinodes[0].label, + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + screen.getByRole('option', { + name: mockLinodes[0].label, + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + }); + + it('should be able to deselect the selected resources', async () => { + const user = userEvent.setup(); + const mockLinodes = linodeFactory.buildList(2); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockLinodes, + isError: false, + isLoading: false, + status: 'success', + }); + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + user.click(screen.getByRole('button', { name: 'Open' })); + await user.click(await screen.findByRole('option', { name: SELECT_ALL })); + await user.click( + await screen.findByRole('option', { name: 'Deselect All' }) + ); + expect( + await screen.findByRole('option', { + name: mockLinodes[0].label, + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + expect( + screen.getByRole('option', { + name: mockLinodes[1].label, + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + }); + + it('should select multiple resources', async () => { + const user = userEvent.setup(); + const mockLinodes = linodeFactory.buildList(3); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockLinodes, + isError: false, + isLoading: false, + status: 'success', + }); + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + user.click(screen.getByRole('button', { name: 'Open' })); + await user.click( + await screen.findByRole('option', { name: mockLinodes[0].label }) + ); + await user.click( + await screen.findByRole('option', { name: mockLinodes[1].label }) + ); + + expect( + await screen.findByRole('option', { + name: mockLinodes[0].label, + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + screen.getByRole('option', { + name: mockLinodes[1].label, + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + screen.getByRole('option', { + name: mockLinodes[2].label, + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + expect( + screen.getByRole('option', { + name: 'Select All', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + }); + + it('should render the label as cluster when resource is of dbaas type', () => { + const { getByLabelText } = renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + expect(getByLabelText('Clusters')); + }); + + it('should render error messages when there is an API call failure', () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: undefined, + isError: true, + isLoading: false, + status: 'error', + }); + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + expect( + screen.getByText('Failed to fetch the resources.') + ).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx new file mode 100644 index 00000000000..fc19b4335f9 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx @@ -0,0 +1,98 @@ +import * as React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; + +import type { Item } from '../../constants'; +import type { CreateAlertDefinitionForm } from '../types'; +import type { AlertServiceType } from '@linode/api-v4'; +import type { FieldPathByValue } from 'react-hook-form'; + +interface CloudPulseResourceSelectProps { + /** + * engine option type selected by the user + */ + engine: null | string; + /** + * name used for the component to set in the form + */ + name: FieldPathByValue; + /** + * region selected by the user + */ + region: string | undefined; + /** + * service type selected by the user + */ + serviceType: AlertServiceType | null; +} + +export const CloudPulseMultiResourceSelect = ( + props: CloudPulseResourceSelectProps +) => { + const { engine, name, region, serviceType } = { ...props }; + const { control, setValue } = useFormContext(); + + const { data: resources, isError, isLoading } = useResourcesQuery( + Boolean(region && serviceType), + serviceType?.toString(), + {}, + engine !== null ? { engine, region } : { region } + ); + + const getResourcesList = React.useMemo((): Item[] => { + return resources && resources.length > 0 + ? resources.map((resource) => ({ + label: resource.label, + value: resource.id, + })) + : []; + }, [resources]); + + /* useEffect is used here to reset the value of entity_ids back to [] when the region, engine, serviceType props are changed , + as the options to the Autocomplete component are dependent on those props , the values of the Autocomplete won't match with the given options that are passed + and this may raise a warning or error with the isOptionEqualToValue prop in the Autocomplete. + */ + React.useEffect(() => { + setValue(name, []); + }, [region, serviceType, engine, setValue, name]); + + return ( + ( + { + const resourceIds = resources.map((resource) => resource.value); + field.onChange(resourceIds); + }} + value={ + field.value + ? getResourcesList.filter((resource) => + field.value.includes(resource.value) + ) + : [] + } + autoHighlight + clearOnBlur + data-testid="resource-select" + disabled={!Boolean(region && serviceType)} + isOptionEqualToValue={(option, value) => option.value === value.value} + label={serviceType === 'dbaas' ? 'Clusters' : 'Resources'} + limitTags={2} + loading={isLoading && Boolean(region && serviceType)} + multiple + onBlur={field.onBlur} + options={getResourcesList} + placeholder="Select Resources" + /> + )} + control={control} + name={name} + /> + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx index 5e14b886375..42e3f9e1e68 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx @@ -41,7 +41,7 @@ queryMocks.useCloudPulseServiceTypes.mockReturnValue({ describe('ServiceTypeSelect component tests', () => { it('should render the Autocomplete component', () => { const { getAllByText, getByTestId } = renderWithThemeAndHookFormContext({ - component: , + component: , }); expect(getByTestId('servicetype-select')).toBeInTheDocument(); getAllByText('Service'); @@ -49,7 +49,7 @@ describe('ServiceTypeSelect component tests', () => { it('should render service types happy path', async () => { renderWithThemeAndHookFormContext({ - component: , + component: , }); userEvent.click(screen.getByRole('button', { name: 'Open' })); expect( @@ -66,7 +66,7 @@ describe('ServiceTypeSelect component tests', () => { it('should be able to select a service type', async () => { renderWithThemeAndHookFormContext({ - component: , + component: , }); userEvent.click(screen.getByRole('button', { name: 'Open' })); await userEvent.click( @@ -81,7 +81,7 @@ describe('ServiceTypeSelect component tests', () => { isLoading: false, }); renderWithThemeAndHookFormContext({ - component: , + component: , }); expect( screen.getByText('Failed to fetch the service types.') diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts index 544fd10ebb4..8b9301c3ebf 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts @@ -6,10 +6,11 @@ const engineOptionValidation = string().when('service_type', { otherwise: (schema) => schema.notRequired().nullable(), then: (schema) => schema.required('Engine type is required.').nullable(), }); + export const CreateAlertDefinitionFormSchema = createAlertDefinitionSchema.concat( object({ - engine_type: engineOptionValidation, + engineType: engineOptionValidation, region: string().required('Region is required.'), - service_type: string().required('Service is required.'), + serviceType: string().required('Service is required.'), }) ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts index c7c58fc1dcb..844b47639a0 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts @@ -9,9 +9,10 @@ import type { export interface CreateAlertDefinitionForm extends Omit { - engine_type: null | string; + engineType: null | string; + entity_ids: string[]; region: string; - service_type: AlertServiceType | null; + serviceType: AlertServiceType | null; severity: AlertSeverityType | null; } diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts index 3db313f7b1f..7459ca1c5da 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts @@ -11,21 +11,22 @@ export const filterFormValues = ( formValues: CreateAlertDefinitionForm ): CreateAlertDefinitionPayload => { const values = omitProps(formValues, [ - 'service_type', + 'serviceType', 'region', - 'engine_type', + 'engineType', 'severity', ]); // severity has a need for null in the form for edge-cases, so null-checking and returning it as an appropriate type const severity = formValues.severity!; - return { ...values, severity }; + const entityIds = formValues.entity_ids; + return { ...values, entity_ids: entityIds, severity }; }; export const filterMetricCriteriaFormValues = ( formValues: MetricCriteriaForm ): MetricCriteria[] => { - const aggregation_type = formValues.aggregation_type!; + const aggregationType = formValues.aggregation_type!; const operator = formValues.operator!; const values = omitProps(formValues, ['aggregation_type', 'operator']); - return [{ ...values, aggregation_type, operator }]; + return [{ ...values, aggregation_type: aggregationType, operator }]; }; diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index 484c18f014e..623e12db24a 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -67,7 +67,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { const getJweTokenPayload = (): JWETokenPayLoad => { return { - resource_ids: resourceList?.map((resource) => Number(resource.id)) ?? [], + resource_ids: resources?.map((resource) => Number(resource)) ?? [], }; }; @@ -100,11 +100,11 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { const { data: jweToken, isError: isJweTokenError, - isLoading: isJweTokenLoading, + isFetching: isJweTokenFetching, } = useCloudPulseJWEtokenQuery( dashboard?.service_type, getJweTokenPayload(), - Boolean(resourceList) + Boolean(resources) && !isDashboardLoading && !isDashboardApiError ); if (isDashboardApiError) { @@ -123,12 +123,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { return renderErrorState('Error loading the definitions of metrics.'); } - if ( - isMetricDefinitionLoading || - isDashboardLoading || - isResourcesLoading || - isJweTokenLoading - ) { + if (isMetricDefinitionLoading || isDashboardLoading || isResourcesLoading) { return ; } @@ -137,6 +132,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { additionalFilters={additionalFilters} dashboard={dashboard} duration={duration} + isJweTokenFetching={isJweTokenFetching} jweToken={jweToken} manualRefreshTimeStamp={manualRefreshTimeStamp} metricDefinitions={metricDefinitions} diff --git a/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts b/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts index 7dfccb8d7bb..a22a473c7e7 100644 --- a/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts +++ b/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts @@ -166,7 +166,7 @@ export const convertValueToUnit = (value: number, maxUnit: string) => { if (convertingValue === 1) { return roundTo(value); } - return value / convertingValue; + return roundTo(value / convertingValue); }; /** diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index 8ee8a56eb32..26b82f1cfbf 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -51,7 +51,7 @@ export interface CloudPulseWidgetProperties { /** * token to fetch metrics data */ - authToken: string; + authToken?: string; /** * metrics defined of this widget @@ -68,6 +68,11 @@ export interface CloudPulseWidgetProperties { */ errorLabel?: string; + /** + * Jwe token fetching status check + */ + isJweTokenFetching: boolean; + /** * resources ids selected by user to show metrics for */ @@ -136,6 +141,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { authToken, availableMetrics, duration, + isJweTokenFetching, resourceIds, resources, savePref, @@ -232,7 +238,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { }, { authToken, - isFlags: Boolean(flags), + isFlags: Boolean(flags && !isJweTokenFetching), label: widget.label, timeStamp, url: flags.aclpReadEndpoint!, @@ -326,13 +332,17 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { ? metricsApiCallError ?? 'Error while rendering graph' : undefined } + loading={ + isLoading || + metricsApiCallError === jweTokenExpiryError || + isJweTokenFetching + } // keep loading until we are trying to fetch the refresh token areas={areas} ariaLabel={ariaLabel ? ariaLabel : ''} data={data} dotRadius={1.5} height={424} legendRows={legendRows} - loading={isLoading || metricsApiCallError === jweTokenExpiryError} // keep loading until we fetch the refresh token showDot showLegend={data.length !== 0} timezone={timezone} diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx index 56d27e950a2..b444754fb46 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx @@ -30,6 +30,7 @@ interface WidgetProps { additionalFilters?: CloudPulseMetricsAdditionalFilters[]; dashboard?: Dashboard | undefined; duration: TimeDuration; + isJweTokenFetching: boolean; jweToken?: JWEToken | undefined; manualRefreshTimeStamp?: number; metricDefinitions: MetricDefinitions | undefined; @@ -55,6 +56,7 @@ export const RenderWidgets = React.memo( additionalFilters, dashboard, duration, + isJweTokenFetching, jweToken, manualRefreshTimeStamp, metricDefinitions, @@ -74,6 +76,7 @@ export const RenderWidgets = React.memo( availableMetrics: undefined, duration, errorLabel: 'Error occurred while loading data.', + isJweTokenFetching: false, resourceIds: resources, resources: [], serviceType: dashboard?.service_type ?? '', @@ -123,7 +126,7 @@ export const RenderWidgets = React.memo( if ( !dashboard.service_type || !Boolean(resources.length > 0) || - !jweToken?.token || + (!isJweTokenFetching && !jweToken?.token) || !Boolean(resourceList?.length) ) { return renderPlaceHolder( @@ -162,6 +165,7 @@ export const RenderWidgets = React.memo( {...cloudPulseWidgetProperties} authToken={jweToken?.token} availableMetrics={availMetrics} + isJweTokenFetching={isJweTokenFetching} resources={resourceList!} savePref={savePref} /> diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx index 76cb91b9cf5..d983bb332fb 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { AreaChart } from 'src/components/AreaChart/AreaChart'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { roundTo } from 'src/utilities/roundTo'; import type { AreaChartProps } from 'src/components/AreaChart/AreaChart'; @@ -38,9 +39,20 @@ export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { ) : ( `${roundTo(value, 3)}`, + }} fillOpacity={0.5} legendHeight="150px" - xAxisTickCount={isSmallScreen ? undefined : 7} /> )} {rest.data.length === 0 && ( diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index f79d34c0363..d836b73ec63 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -311,7 +311,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( container display={showFilter ? 'flex' : 'none'} item - maxHeight={theme.spacing(22)} + maxHeight={theme.spacing(23)} overflow={'auto'} rowGap={theme.spacing(2)} xs={12} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx index 989a706dc43..d6dc9f0c9e5 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx @@ -254,7 +254,7 @@ describe('CloudPulseResourcesSelect component tests', () => { expect(screen.getByText('Failed to fetch Resources.')).toBeInTheDocument(); }); - it('should be able to select limited resources', async () => { + it('should be able to select limited resources and select/deselect all will not be available if resource are more than max resource selection limit', async () => { const user = userEvent.setup(); queryMocks.useResourcesQuery.mockReturnValue({ @@ -298,4 +298,36 @@ describe('CloudPulseResourcesSelect component tests', () => { expect(queryByRole('option', { name: SELECT_ALL })).not.toBeInTheDocument(); }); + + it('should be able to select all and deselect all the resources when number of resources are equal to resource limit', async () => { + const user = userEvent.setup(); + + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodeFactory.buildList(10), + isError: false, + isLoading: false, + status: 'success', + }); + + renderWithTheme( + + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + await user.click(screen.getByRole('option', { name: SELECT_ALL })); + await user.click(screen.getByRole('option', { name: 'Deselect All' })); + + expect(screen.getByLabelText('Resources')).toBeInTheDocument(); + + for (let i = 26; i <= 35; i++) { + expect( + screen.getByRole('option', { name: `linode-${i}` }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + } + }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index 60e98bfc6f8..ddb94a60152 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -1,8 +1,11 @@ -import { Box, ListItem } from '@mui/material'; +import { Box } from '@mui/material'; import React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; -import { SelectedIcon } from 'src/components/Autocomplete/Autocomplete.styles'; +import { + SelectedIcon, + StyledListItem, +} from 'src/components/Autocomplete/Autocomplete.styles'; import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { themes } from 'src/utilities/theme'; @@ -149,10 +152,20 @@ export const CloudPulseResourcesSelect = React.memo( const isResourceSelected = selectedResources?.some( (item) => item.label === option.label ); + + const isSelectAllORDeslectAllOption = + option.label === 'Select All ' || option.label === 'Deselect All '; + const isMaxSelectionsReached = selectedResources && selectedResources.length >= maxResourceSelectionLimit && - !isResourceSelected; + !isResourceSelected && + !isSelectAllORDeslectAllOption; + + const ListItem = isSelectAllORDeslectAllOption + ? StyledListItem + : 'li'; + return ( { it('should render a loading spinner on loading state', () => { const renderBody = vi.fn(); const { getByTestId } = renderWithTheme( - + ); expect(getByTestId('circle-progress')).toBeInTheDocument(); @@ -25,11 +21,7 @@ describe('StatsPanel component', () => { const renderBody = vi.fn(); renderWithTheme( - + ); expect(renderBody).toHaveBeenCalled(); diff --git a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx index e0ddb0fc00f..c423512c291 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx @@ -194,6 +194,8 @@ export const ConfigureForm = React.memo((props: Props) => { handlePlacementGroupSelection(placementGroup); }} textFieldProps={{ + helperText: + 'If your Linode already belongs to a placement group, it will be automatically unassigned during the migration. You can choose to move it to a new placement group in the same region here.', tooltipText: hasRegionPlacementGroupCapability ? '' : 'Placement Groups are not available in this region.', diff --git a/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx b/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx index d0813da6a16..5e93778b783 100644 --- a/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx +++ b/packages/manager/src/features/Managed/SSHAccess/EditSSHAccessDrawer.tsx @@ -1,4 +1,4 @@ -import { Notice, TextField } from '@linode/ui'; +import { Notice, TextField, Toggle } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2'; import { Formik } from 'formik'; import * as React from 'react'; @@ -7,7 +7,6 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { IPSelect } from 'src/components/IPSelect/IPSelect'; -import { Toggle } from 'src/components/Toggle/Toggle'; import { useUpdateLinodeSettingsMutation } from 'src/queries/managed/managed'; import { handleFieldErrors, diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerPassiveCheck.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerPassiveCheck.tsx index 3d2099436f6..b111a603c2f 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerPassiveCheck.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerPassiveCheck.tsx @@ -1,9 +1,8 @@ -import { FormHelperText, Typography } from '@linode/ui'; +import { FormHelperText, Toggle, Typography } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { FormControlLabel } from 'src/components/FormControlLabel'; -import { Toggle } from 'src/components/Toggle/Toggle'; import type { NodeBalancerConfigPanelProps } from './types'; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/LimitedAccessControls.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/LimitedAccessControls.tsx index f58c993a531..605afdf84a0 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/LimitedAccessControls.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/LimitedAccessControls.tsx @@ -1,8 +1,7 @@ -import { TooltipIcon, Typography } from '@linode/ui'; +import { Toggle, TooltipIcon, Typography } from '@linode/ui'; import * as React from 'react'; import { FormControlLabel } from 'src/components/FormControlLabel'; -import { Toggle } from 'src/components/Toggle/Toggle'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx index 80a0de8f411..68c9c084ab8 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx @@ -1,4 +1,4 @@ -import { Notice, Typography } from '@linode/ui'; +import { Notice, Toggle, Typography } from '@linode/ui'; import * as React from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -7,7 +7,6 @@ import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Link } from 'src/components/Link'; -import { Toggle } from 'src/components/Toggle/Toggle'; import { useOpenClose } from 'src/hooks/useOpenClose'; import { useBucketAccess, diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx index 9c8b4d1cbe3..5b53be099bc 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx @@ -1,12 +1,27 @@ import * as React from 'react'; +import { useParams } from 'react-router-dom'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { Link } from 'src/components/Link'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { ProgressDisplay } from 'src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow'; +import { StyledButton } from 'src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.styles'; import { getLinodeIconStatus } from 'src/features/Linodes/LinodesLanding/utils'; +import { + getProgressOrDefault, + linodeInTransition, + transitionText, +} from 'src/features/Linodes/transitions'; +import { notificationCenterContext } from 'src/features/NotificationCenter/NotificationCenterContext'; +import { + PLACEMENT_GROUP_MIGRATION_INBOUND_MESSAGE, + PLACEMENT_GROUP_MIGRATION_OUTBOUND_MESSAGE, +} from 'src/features/PlacementGroups/constants'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { useInProgressEvents } from 'src/queries/events/events'; +import { usePlacementGroupQuery } from 'src/queries/placementGroups'; import { capitalizeAllWords } from 'src/utilities/capitalize'; import type { Linode } from '@linode/api-v4'; @@ -16,33 +31,85 @@ interface Props { linode: Linode; } +type MigrationType = 'inbound' | 'outbound' | null; + export const PlacementGroupsLinodesTableRow = React.memo((props: Props) => { const { handleUnassignLinodeModal, linode } = props; const { label, status } = linode; + const { id: placementGroupId } = useParams<{ id: string }>(); + const notificationContext = React.useContext(notificationCenterContext); + const isLinodeMigrating = Boolean(linode.placement_group?.migrating_to); + const { data: placementGroup } = usePlacementGroupQuery( + Number(placementGroupId), + isLinodeMigrating // we only really need to fetch the placement group if the linode is migrating + ); + const { data: events } = useInProgressEvents(); + const recentEvent = events?.find( + (e) => e.entity?.type === 'linode' && e.entity.id === linode.id + ); const iconStatus = getLinodeIconStatus(status); - + const isMigrationInProgress = linodeInTransition(status, recentEvent); const isLinodeReadOnly = useIsResourceRestricted({ grantLevel: 'read_write', grantType: 'linode', id: linode.id, }); + const getMigrationType = React.useCallback((): MigrationType => { + if (!placementGroup?.migrations) { + return null; + } + + if ( + placementGroup.migrations.inbound?.some((m) => m.linode_id === linode.id) + ) { + return 'inbound'; + } + + if ( + placementGroup.migrations.outbound?.some((m) => m.linode_id === linode.id) + ) { + return 'outbound'; + } + + return null; + }, [placementGroup, linode.id]); + return ( {label} - - {capitalizeAllWords(linode.status.replace('_', ' '))} + {isMigrationInProgress ? ( + <> + + + + + + ) : ( + <> + + {capitalizeAllWords(status.replace('_', ' '))} + + )} handleUnassignLinodeModal(linode)} /> diff --git a/packages/manager/src/features/PlacementGroups/constants.ts b/packages/manager/src/features/PlacementGroups/constants.ts index 0d8de076906..506556f8120 100644 --- a/packages/manager/src/features/PlacementGroups/constants.ts +++ b/packages/manager/src/features/PlacementGroups/constants.ts @@ -38,3 +38,10 @@ export const PLACEMENT_GROUP_POLICY_STRICT = export const PLACEMENT_GROUP_POLICY_FLEXIBLE = "Allows the addition of more compute instances to the group even if it breaks the placement group's compliance."; + +// Migrations +export const PLACEMENT_GROUP_MIGRATION_INBOUND_MESSAGE = + 'This Linode is migrating into this placement group. It will be available after the migration is complete.'; + +export const PLACEMENT_GROUP_MIGRATION_OUTBOUND_MESSAGE = + 'This Linode is being migrated. It will be removed from this placement group after the migration completes.'; diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/TwoFactorToggle.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/TwoFactorToggle.tsx index 0bfeea24e34..6a1cea4c0b9 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/TwoFactorToggle.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/TwoFactorToggle.tsx @@ -1,8 +1,7 @@ -import { FormControl } from '@linode/ui'; +import { FormControl, Toggle } from '@linode/ui'; import * as React from 'react'; import { FormControlLabel } from 'src/components/FormControlLabel'; -import { Toggle } from 'src/components/Toggle/Toggle'; interface ToggleProps { disabled?: boolean; diff --git a/packages/manager/src/features/Profile/Settings/Settings.tsx b/packages/manager/src/features/Profile/Settings/Settings.tsx index 86447b1b97b..7ea47c473fb 100644 --- a/packages/manager/src/features/Profile/Settings/Settings.tsx +++ b/packages/manager/src/features/Profile/Settings/Settings.tsx @@ -1,4 +1,11 @@ -import { Paper, Radio, RadioGroup, Stack, Typography } from '@linode/ui'; +import { + Paper, + Radio, + RadioGroup, + Stack, + Toggle, + Typography, +} from '@linode/ui'; import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; import { useHistory, useLocation } from 'react-router-dom'; @@ -6,7 +13,6 @@ import { useHistory, useLocation } from 'react-router-dom'; import { Code } from 'src/components/Code/Code'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { FormControlLabel } from 'src/components/FormControlLabel'; -import { Toggle } from 'src/components/Toggle/Toggle'; import { useMutatePreferences, usePreferences, diff --git a/packages/manager/src/features/Users/CreateUserDrawer.tsx b/packages/manager/src/features/Users/CreateUserDrawer.tsx index 4810355a83b..80a1e5ea22b 100644 --- a/packages/manager/src/features/Users/CreateUserDrawer.tsx +++ b/packages/manager/src/features/Users/CreateUserDrawer.tsx @@ -1,12 +1,11 @@ import { createUser } from '@linode/api-v4/lib/account'; -import { Notice, TextField } from '@linode/ui'; +import { Notice, TextField, Toggle } from '@linode/ui'; import * as React from 'react'; import { withRouter } from 'react-router-dom'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { FormControlLabel } from 'src/components/FormControlLabel'; -import { Toggle } from 'src/components/Toggle/Toggle'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; diff --git a/packages/manager/src/features/Users/UserPermissions.tsx b/packages/manager/src/features/Users/UserPermissions.tsx index e6e756eb686..07729a25b11 100644 --- a/packages/manager/src/features/Users/UserPermissions.tsx +++ b/packages/manager/src/features/Users/UserPermissions.tsx @@ -4,7 +4,14 @@ import { updateGrants, updateUser, } from '@linode/api-v4/lib/account'; -import { Box, CircleProgress, Notice, Paper, Typography } from '@linode/ui'; +import { + Box, + CircleProgress, + Notice, + Paper, + Toggle, + Typography, +} from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2'; import { enqueueSnackbar } from 'notistack'; import { compose, flatten, lensPath, omit, set } from 'ramda'; @@ -19,7 +26,6 @@ import { Tab } from 'src/components/Tabs/Tab'; import { TabList } from 'src/components/Tabs/TabList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; -import { Toggle } from 'src/components/Toggle/Toggle'; import { withFeatureFlags } from 'src/containers/flags.container'; import { withQueryClient } from 'src/containers/withQueryClient.container'; import { PARENT_USER, grantTypeMap } from 'src/features/Account/constants'; diff --git a/packages/manager/src/hooks/useEventHandlers.ts b/packages/manager/src/hooks/useEventHandlers.ts index 9f6312c36ea..f12d5c3345a 100644 --- a/packages/manager/src/hooks/useEventHandlers.ts +++ b/packages/manager/src/hooks/useEventHandlers.ts @@ -9,6 +9,7 @@ import { imageEventsHandler } from 'src/queries/images'; import { linodeEventsHandler } from 'src/queries/linodes/events'; import { diskEventHandler } from 'src/queries/linodes/events'; import { nodebalancerEventHandler } from 'src/queries/nodebalancers'; +import { placementGroupEventHandler } from 'src/queries/placementGroups'; import { sshKeyEventHandler } from 'src/queries/profile/profile'; import { tokenEventHandler } from 'src/queries/profile/tokens'; import { stackScriptEventHandler } from 'src/queries/stackscripts'; @@ -69,6 +70,10 @@ export const eventHandlers: { filter: (event) => event.action.startsWith('oauth_client'), handler: oauthClientsEventHandler, }, + { + filter: (event) => event.action.startsWith('placement_group'), + handler: placementGroupEventHandler, + }, { filter: (event) => event.action.startsWith('linode') || event.action.startsWith('backups'), diff --git a/packages/manager/src/mocks/presets/crud/handlers/placementGroups.ts b/packages/manager/src/mocks/presets/crud/handlers/placementGroups.ts index 0095fe2cd63..b97f3a536d4 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/placementGroups.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/placementGroups.ts @@ -204,6 +204,7 @@ export const placementGroupLinodeAssignment = (mockState: MockState) => [ placement_group: { id: placementGroup.id, label: placementGroup.label, + migrating_to: null, placement_group_policy: placementGroup.placement_group_policy, placement_group_type: placementGroup.placement_group_type, }, diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts index 4406a90e0c2..0da27a07093 100644 --- a/packages/manager/src/queries/cloudpulse/alerts.ts +++ b/packages/manager/src/queries/cloudpulse/alerts.ts @@ -10,10 +10,10 @@ import type { } from '@linode/api-v4/lib/cloudpulse'; import type { APIError } from '@linode/api-v4/lib/types'; -export const useCreateAlertDefinition = (service_type: AlertServiceType) => { +export const useCreateAlertDefinition = (serviceType: AlertServiceType) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data) => createAlertDefinition(data, service_type), + mutationFn: (data) => createAlertDefinition(data, serviceType), onSuccess() { queryClient.invalidateQueries(queryFactory.alerts); }, diff --git a/packages/manager/src/queries/cloudpulse/metrics.ts b/packages/manager/src/queries/cloudpulse/metrics.ts index 4f6f23622ab..f83b7fd98f9 100644 --- a/packages/manager/src/queries/cloudpulse/metrics.ts +++ b/packages/manager/src/queries/cloudpulse/metrics.ts @@ -18,7 +18,7 @@ export const useCloudPulseMetricsQuery = ( serviceType: string, request: CloudPulseMetricsRequest, obj: { - authToken: string; + authToken?: string; isFlags: boolean; label: string; timeStamp: number | undefined; @@ -29,7 +29,7 @@ export const useCloudPulseMetricsQuery = ( const query = useQuery({ ...queryFactory.metrics( - obj.authToken, + obj.authToken ?? '', obj.url, serviceType, request, diff --git a/packages/manager/src/queries/cloudpulse/queries.ts b/packages/manager/src/queries/cloudpulse/queries.ts index 371ac0d1204..dc9205f0ad4 100644 --- a/packages/manager/src/queries/cloudpulse/queries.ts +++ b/packages/manager/src/queries/cloudpulse/queries.ts @@ -84,6 +84,6 @@ export const queryFactory = createQueryKeys(key, { token: (serviceType: string | undefined, request: JWETokenPayLoad) => ({ queryFn: () => getJWEToken(request, serviceType!), - queryKey: [serviceType], + queryKey: [serviceType, { resource_ids: request.resource_ids.sort() }], }), }); diff --git a/packages/manager/src/queries/placementGroups.ts b/packages/manager/src/queries/placementGroups.ts index f63f80ea927..efc4313c285 100644 --- a/packages/manager/src/queries/placementGroups.ts +++ b/packages/manager/src/queries/placementGroups.ts @@ -31,6 +31,7 @@ import type { UnassignLinodesFromPlacementGroupPayload, UpdatePlacementGroupPayload, } from '@linode/api-v4'; +import type { EventHandlerData } from 'src/hooks/useEventHandlers'; const getAllPlacementGroupsRequest = ( _params: Params = {}, @@ -223,3 +224,63 @@ export const useUnassignLinodesFromPlacementGroup = ( }, }); }; + +export const placementGroupEventHandler = ({ + event, + invalidateQueries, +}: EventHandlerData) => { + const { action, entity, secondary_entity } = event; + // for assignment/unassignment events + // in the case of a migration, the assignment/unassignment events are happening asynchronously, + // without using the hook. We need to invalidate the placement group queries here. + // invalidateQueries({ queryKey: placementGroupQueries._def }); + // event looks as follow: + // { + // "id": {id}, + // "created": {created}, + // "seen": false, + // "read": false, + // "percent_complete": null, + // "time_remaining": null, + // "rate": null, + // "duration": null, + // "action": "placement_group_unassign", + // "username": {username}, + // "entity": { + // "label": {label}, + // "id": {id}, + // "type": "placement_group", + // "url": "/v4/placement/groups/{id}" + // }, + // "status": "notification", + // "secondary_entity": { + // "id": {id}, + // "type": "linode", + // "label": {label}, + // "url": "/v4/linode/instances/{id}" + // }, + // "message": "" + // } + if ( + action !== 'placement_group_unassign' && + action !== 'placement_group_assign' + ) { + return; + } + + if (entity && secondary_entity) { + invalidateQueries({ + queryKey: placementGroupQueries.placementGroup(entity.id).queryKey, + }); + invalidateQueries({ + queryKey: linodeQueries.linode(secondary_entity.id).queryKey, + }); + } + + invalidateQueries({ + queryKey: placementGroupQueries.paginated._def, + }); + invalidateQueries({ + queryKey: linodeQueries.linodes._ctx.all._def, + }); +}; diff --git a/packages/ui/.changeset/pr-11296-added-1732128187717.md b/packages/ui/.changeset/pr-11296-added-1732128187717.md new file mode 100644 index 00000000000..1e23a675ba6 --- /dev/null +++ b/packages/ui/.changeset/pr-11296-added-1732128187717.md @@ -0,0 +1,5 @@ +--- +"@linode/ui": Added +--- + +`Toggle` component and `ToggleOn` and `ToggleOff` icons (migrated from `manager` package) ([#11296](https://github.com/linode/manager/pull/11296)) diff --git a/packages/ui/src/assets/icons/index.ts b/packages/ui/src/assets/icons/index.ts index 18a225e108e..995d45988e8 100644 --- a/packages/ui/src/assets/icons/index.ts +++ b/packages/ui/src/assets/icons/index.ts @@ -6,4 +6,6 @@ export { default as PlusSignIcon } from './plusSign.svg'; export { default as RadioIcon } from './radio.svg'; export { default as RadioIconRadioed } from './radioRadioed.svg'; export { default as ReloadIcon } from './reload.svg'; +export { default as ToggleOffIcon } from './toggleOff.svg'; +export { default as ToggleOnIcon } from './toggleOn.svg'; export { default as WarningIcon } from './warning.svg'; diff --git a/packages/manager/src/assets/icons/toggleOff.svg b/packages/ui/src/assets/icons/toggleOff.svg similarity index 100% rename from packages/manager/src/assets/icons/toggleOff.svg rename to packages/ui/src/assets/icons/toggleOff.svg diff --git a/packages/manager/src/assets/icons/toggleOn.svg b/packages/ui/src/assets/icons/toggleOn.svg similarity index 100% rename from packages/manager/src/assets/icons/toggleOn.svg rename to packages/ui/src/assets/icons/toggleOn.svg diff --git a/packages/ui/src/components/BetaChip/BetaChip.test.tsx b/packages/ui/src/components/BetaChip/BetaChip.test.tsx index c4da709edd5..1e1a3fac32c 100644 --- a/packages/ui/src/components/BetaChip/BetaChip.test.tsx +++ b/packages/ui/src/components/BetaChip/BetaChip.test.tsx @@ -1,11 +1,10 @@ +import '@testing-library/jest-dom/vitest'; import { fireEvent, render } from '@testing-library/react'; import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; import { BetaChip } from './BetaChip'; -import { expect, vi, describe, it } from 'vitest'; -import '@testing-library/jest-dom/vitest'; - describe('BetaChip', () => { it('renders with default color', () => { const { getByTestId } = render(); diff --git a/packages/ui/src/components/BetaChip/BetaChip.tsx b/packages/ui/src/components/BetaChip/BetaChip.tsx index 981003043fa..589d5deeb65 100644 --- a/packages/ui/src/components/BetaChip/BetaChip.tsx +++ b/packages/ui/src/components/BetaChip/BetaChip.tsx @@ -1,6 +1,8 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; + import { Chip } from '../Chip'; + import type { ChipProps } from '@mui/material'; export interface BetaChipProps @@ -52,8 +54,8 @@ const StyledBetaChip = styled(Chip, { '& .MuiChip-label': { padding: 0, }, - fontSize: '0.625rem', fontFamily: '"LatoWebBold", sans-serif', // TODO: remove hardcoded font once theme is added to this package + fontSize: '0.625rem', height: 16, letterSpacing: '.25px', marginLeft: theme.spacing(0.5), diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx index 493ddcaa526..451091fa179 100644 --- a/packages/ui/src/components/Button/Button.stories.tsx +++ b/packages/ui/src/components/Button/Button.stories.tsx @@ -1,9 +1,8 @@ import { action } from '@storybook/addon-actions'; import React from 'react'; -import { StyledLinkButton } from './StyledLinkButton'; - import { Button } from './Button'; +import { StyledLinkButton } from './StyledLinkButton'; import type { Meta, StoryObj } from '@storybook/react'; diff --git a/packages/ui/src/components/Button/Button.test.tsx b/packages/ui/src/components/Button/Button.test.tsx index 45efbb0a165..39b91338bf7 100644 --- a/packages/ui/src/components/Button/Button.test.tsx +++ b/packages/ui/src/components/Button/Button.test.tsx @@ -1,9 +1,9 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; +import { describe, expect, it } from 'vitest'; -import { Button } from './Button'; import { renderWithTheme } from '../../utilities/testHelpers'; -import { expect, describe, it } from 'vitest'; +import { Button } from './Button'; describe('Button', () => { it('should render', () => { diff --git a/packages/ui/src/components/Button/Button.tsx b/packages/ui/src/components/Button/Button.tsx index fbe02ae7aa6..52e4d48e459 100644 --- a/packages/ui/src/components/Button/Button.tsx +++ b/packages/ui/src/components/Button/Button.tsx @@ -1,13 +1,12 @@ -import { Tooltip } from '../Tooltip'; import HelpOutline from '@mui/icons-material/HelpOutline'; import _Button from '@mui/material/Button'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import { ReloadIcon } from '../../assets'; - import { rotate360 } from '../../foundations'; import { omittedProps } from '../../utilities'; +import { Tooltip } from '../Tooltip'; import type { ButtonProps as _ButtonProps } from '@mui/material/Button'; import type { SxProps, Theme } from '@mui/material/styles'; diff --git a/packages/ui/src/components/Button/StyledActionButton.ts b/packages/ui/src/components/Button/StyledActionButton.ts index 2d6f4122bca..2a8816ef49d 100644 --- a/packages/ui/src/components/Button/StyledActionButton.ts +++ b/packages/ui/src/components/Button/StyledActionButton.ts @@ -1,6 +1,6 @@ -import { latoWeb } from '../../foundations'; import { styled } from '@mui/material/styles'; +import { latoWeb } from '../../foundations'; import { Button } from './Button'; /** diff --git a/packages/ui/src/components/Button/StyledTagButton.ts b/packages/ui/src/components/Button/StyledTagButton.ts index 33954319dd2..6e1eb60f54b 100644 --- a/packages/ui/src/components/Button/StyledTagButton.ts +++ b/packages/ui/src/components/Button/StyledTagButton.ts @@ -1,8 +1,7 @@ -import { omittedProps } from '../../utilities'; import { styled } from '@mui/material/styles'; import { PlusSignIcon } from '../../assets/icons'; - +import { omittedProps } from '../../utilities'; import { Button } from './Button'; /** diff --git a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx index 4e8a4afddb8..1cb40fce6b4 100644 --- a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx +++ b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx @@ -1,8 +1,9 @@ -import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; import { Checkbox } from './Checkbox'; +import type { Meta, StoryObj } from '@storybook/react'; + const meta: Meta = { component: Checkbox, title: 'Foundations/Checkbox', diff --git a/packages/ui/src/components/Checkbox/Checkbox.tsx b/packages/ui/src/components/Checkbox/Checkbox.tsx index 396c1952411..87e63fefa89 100644 --- a/packages/ui/src/components/Checkbox/Checkbox.tsx +++ b/packages/ui/src/components/Checkbox/Checkbox.tsx @@ -1,10 +1,9 @@ -import { TooltipIcon } from '../TooltipIcon'; import _Checkbox from '@mui/material/Checkbox'; import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { CheckboxIcon, CheckboxCheckedIcon } from '../../assets/icons'; - +import { CheckboxCheckedIcon, CheckboxIcon } from '../../assets/icons'; +import { TooltipIcon } from '../TooltipIcon'; // @todo: modularization - Import from 'ui' package once FormControlLabel is migrated. import { FormControlLabel } from '@mui/material'; diff --git a/packages/ui/src/components/CircleProgress/CircleProgress.test.tsx b/packages/ui/src/components/CircleProgress/CircleProgress.test.tsx index 9784efa81ef..9319b1846aa 100644 --- a/packages/ui/src/components/CircleProgress/CircleProgress.test.tsx +++ b/packages/ui/src/components/CircleProgress/CircleProgress.test.tsx @@ -1,11 +1,10 @@ +import '@testing-library/jest-dom/vitest'; import { render } from '@testing-library/react'; import React from 'react'; +import { describe, expect, it } from 'vitest'; import { CircleProgress } from './CircleProgress'; -import { expect, describe, it } from 'vitest'; -import '@testing-library/jest-dom/vitest'; - const CONTENT_LOADING = 'Content is loading'; describe('CircleProgress', () => { diff --git a/packages/ui/src/components/CircleProgress/CircleProgress.tsx b/packages/ui/src/components/CircleProgress/CircleProgress.tsx index 49a96ec8ee7..987ef01300b 100644 --- a/packages/ui/src/components/CircleProgress/CircleProgress.tsx +++ b/packages/ui/src/components/CircleProgress/CircleProgress.tsx @@ -1,9 +1,10 @@ -import { Box } from '../Box'; -import { omittedProps } from '../../utilities'; import _CircularProgress from '@mui/material/CircularProgress'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import { omittedProps } from '../../utilities'; +import { Box } from '../Box'; + import type { CircularProgressProps } from '@mui/material/CircularProgress'; import type { SxProps, Theme } from '@mui/material/styles'; diff --git a/packages/ui/src/components/Divider/Divider.stories.tsx b/packages/ui/src/components/Divider/Divider.stories.tsx index 364e02f1308..3b8c42f8a82 100644 --- a/packages/ui/src/components/Divider/Divider.stories.tsx +++ b/packages/ui/src/components/Divider/Divider.stories.tsx @@ -1,8 +1,9 @@ -import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; import { Divider } from './Divider'; +import type { Meta, StoryObj } from '@storybook/react'; + const meta: Meta = { component: Divider, title: 'Foundations/Divider', diff --git a/packages/ui/src/components/Divider/Divider.tsx b/packages/ui/src/components/Divider/Divider.tsx index 202d428a716..9c08c6794d3 100644 --- a/packages/ui/src/components/Divider/Divider.tsx +++ b/packages/ui/src/components/Divider/Divider.tsx @@ -1,9 +1,11 @@ import _Divider from '@mui/material/Divider'; import { styled } from '@mui/material/styles'; import * as React from 'react'; -import type { DividerProps as _DividerProps } from '@mui/material/Divider'; + import { omittedProps } from '../../utilities'; +import type { DividerProps as _DividerProps } from '@mui/material/Divider'; + export interface DividerProps extends _DividerProps { dark?: boolean; light?: boolean; diff --git a/packages/ui/src/components/FormControl/FormControl.stories.tsx b/packages/ui/src/components/FormControl/FormControl.stories.tsx index 28f66e3ee08..2fdd5f07830 100644 --- a/packages/ui/src/components/FormControl/FormControl.stories.tsx +++ b/packages/ui/src/components/FormControl/FormControl.stories.tsx @@ -1,9 +1,9 @@ -import { FormHelperText } from '../FormHelperText'; import React from 'react'; -import { FormControl } from './FormControl'; +import { FormHelperText } from '../FormHelperText'; import { Input } from '../Input'; import { InputLabel } from '../InputLabel'; +import { FormControl } from './FormControl'; import type { Meta, StoryObj } from '@storybook/react'; diff --git a/packages/ui/src/components/FormHelperText/FormHelperText.stories.tsx b/packages/ui/src/components/FormHelperText/FormHelperText.stories.tsx index 92f8274d61c..bc2ab03f9d0 100644 --- a/packages/ui/src/components/FormHelperText/FormHelperText.stories.tsx +++ b/packages/ui/src/components/FormHelperText/FormHelperText.stories.tsx @@ -1,10 +1,11 @@ -import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; import { FormControl } from '../FormControl'; -import { FormHelperText } from './FormHelperText'; import { Input } from '../Input'; import { InputLabel } from '../InputLabel'; +import { FormHelperText } from './FormHelperText'; + +import type { Meta, StoryObj } from '@storybook/react'; const meta: Meta = { component: FormHelperText, diff --git a/packages/ui/src/components/H1Header/H1Header.tsx b/packages/ui/src/components/H1Header/H1Header.tsx index 59acd691c2e..a844e91dbe6 100644 --- a/packages/ui/src/components/H1Header/H1Header.tsx +++ b/packages/ui/src/components/H1Header/H1Header.tsx @@ -1,7 +1,7 @@ -// @todo: modularization - Import from 'ui' package once Typography is migrated. -import { Typography } from '@mui/material'; import * as React from 'react'; +import { Typography } from '../Typography'; + import type { SxProps, Theme } from '@mui/material/styles'; interface H1HeaderProps { diff --git a/packages/ui/src/components/Input/Input.stories.tsx b/packages/ui/src/components/Input/Input.stories.tsx index f9fd57ddbf9..4ba62c08827 100644 --- a/packages/ui/src/components/Input/Input.stories.tsx +++ b/packages/ui/src/components/Input/Input.stories.tsx @@ -1,8 +1,9 @@ -import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; import { Input } from './Input'; +import type { Meta, StoryObj } from '@storybook/react'; + const meta: Meta = { component: Input, title: 'Components/Input', diff --git a/packages/ui/src/components/Input/Input.tsx b/packages/ui/src/components/Input/Input.tsx index 163d6f3072b..21437ca7bc8 100644 --- a/packages/ui/src/components/Input/Input.tsx +++ b/packages/ui/src/components/Input/Input.tsx @@ -1,6 +1,8 @@ -import _Input, { InputProps as _InputProps } from '@mui/material/Input'; +import _Input from '@mui/material/Input'; import React from 'react'; +import type { InputProps as _InputProps } from '@mui/material/Input'; + export type InputProps = _InputProps; /** diff --git a/packages/ui/src/components/InputAdornment/InputAdornment.stories.tsx b/packages/ui/src/components/InputAdornment/InputAdornment.stories.tsx index 85376fd2b0c..e496e8dcbc3 100644 --- a/packages/ui/src/components/InputAdornment/InputAdornment.stories.tsx +++ b/packages/ui/src/components/InputAdornment/InputAdornment.stories.tsx @@ -1,9 +1,10 @@ -import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; import { Input } from '../Input'; import { InputAdornment } from './InputAdornment'; +import type { Meta, StoryObj } from '@storybook/react'; + const meta: Meta = { component: InputAdornment, title: 'Components/Input/InputAdornment', diff --git a/packages/ui/src/components/InputLabel/InputLabel.stories.tsx b/packages/ui/src/components/InputLabel/InputLabel.stories.tsx index c892a9207ec..5441a08ba5e 100644 --- a/packages/ui/src/components/InputLabel/InputLabel.stories.tsx +++ b/packages/ui/src/components/InputLabel/InputLabel.stories.tsx @@ -1,10 +1,11 @@ -import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; import { FormControl } from '../FormControl'; import { Input } from '../Input'; import { InputLabel } from './InputLabel'; +import type { Meta, StoryObj } from '@storybook/react'; + const meta: Meta = { component: InputLabel, title: 'Components/Input/InputLabel', diff --git a/packages/ui/src/components/Notice/Notice.test.tsx b/packages/ui/src/components/Notice/Notice.test.tsx index 3e1e343d900..86a6d5b651a 100644 --- a/packages/ui/src/components/Notice/Notice.test.tsx +++ b/packages/ui/src/components/Notice/Notice.test.tsx @@ -1,9 +1,9 @@ import { fireEvent } from '@testing-library/react'; import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; -import { Notice } from './Notice'; -import { expect, describe, it, vi } from 'vitest'; import { renderWithTheme } from '../../utilities/testHelpers'; +import { Notice } from './Notice'; describe('Notice Component', () => { it('renders without errors with proper spacing', () => { diff --git a/packages/ui/src/components/Notice/Notice.tsx b/packages/ui/src/components/Notice/Notice.tsx index 1a73ef2259e..2d4d3b42803 100644 --- a/packages/ui/src/components/Notice/Notice.tsx +++ b/packages/ui/src/components/Notice/Notice.tsx @@ -5,7 +5,7 @@ import { CheckIcon, AlertIcon as Error, WarningIcon } from '../../assets/icons'; import { Typography } from '../Typography'; import { useStyles } from './Notice.styles'; -import type { TypographyProps } from '@mui/material'; +import type { TypographyProps } from '../Typography'; import type { Grid2Props } from '@mui/material/Unstable_Grid2'; export type NoticeVariant = diff --git a/packages/ui/src/components/Paper/Paper.stories.tsx b/packages/ui/src/components/Paper/Paper.stories.tsx index 99ae8f85374..5e15a8d8e87 100644 --- a/packages/ui/src/components/Paper/Paper.stories.tsx +++ b/packages/ui/src/components/Paper/Paper.stories.tsx @@ -1,8 +1,9 @@ -import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; import { Paper } from './Paper'; +import type { Meta, StoryObj } from '@storybook/react'; + const meta: Meta = { component: Paper, title: 'Foundations/Paper', diff --git a/packages/ui/src/components/Paper/Paper.tsx b/packages/ui/src/components/Paper/Paper.tsx index 7badae6d9df..caa909adadf 100644 --- a/packages/ui/src/components/Paper/Paper.tsx +++ b/packages/ui/src/components/Paper/Paper.tsx @@ -1,8 +1,9 @@ -import { FormHelperText } from '../FormHelperText/FormHelperText'; import _Paper from '@mui/material/Paper'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import { FormHelperText } from '../FormHelperText/FormHelperText'; + import type { PaperProps } from '@mui/material/Paper'; interface Props extends PaperProps { diff --git a/packages/ui/src/components/Radio/Radio.stories.tsx b/packages/ui/src/components/Radio/Radio.stories.tsx index 38b848c453d..4171518783f 100644 --- a/packages/ui/src/components/Radio/Radio.stories.tsx +++ b/packages/ui/src/components/Radio/Radio.stories.tsx @@ -4,8 +4,8 @@ import React from 'react'; import { FormControlLabel } from '@mui/material'; import { Box } from '../Box'; -import { Radio } from './Radio'; import { RadioGroup } from '../RadioGroup'; +import { Radio } from './Radio'; import type { RadioProps } from './Radio'; import type { Meta, StoryObj } from '@storybook/react'; diff --git a/packages/ui/src/components/Radio/Radio.test.tsx b/packages/ui/src/components/Radio/Radio.test.tsx index 35c14687ce3..27f7aebecf1 100644 --- a/packages/ui/src/components/Radio/Radio.test.tsx +++ b/packages/ui/src/components/Radio/Radio.test.tsx @@ -1,9 +1,9 @@ import { fireEvent } from '@testing-library/react'; import * as React from 'react'; +import { describe, expect, it } from 'vitest'; -import { Radio } from './Radio'; -import { expect, describe, it } from 'vitest'; import { renderWithTheme } from '../../utilities/testHelpers'; +import { Radio } from './Radio'; // This test is for a single radio button, not a radio group describe('Radio', () => { diff --git a/packages/ui/src/components/Radio/Radio.tsx b/packages/ui/src/components/Radio/Radio.tsx index 4977151e8ce..d8ca9d1f2a3 100644 --- a/packages/ui/src/components/Radio/Radio.tsx +++ b/packages/ui/src/components/Radio/Radio.tsx @@ -1,8 +1,10 @@ -import { default as _Radio, RadioProps } from '@mui/material/Radio'; +import { default as _Radio } from '@mui/material/Radio'; import * as React from 'react'; import { RadioIcon, RadioIconRadioed } from '../../assets/icons'; +import type { RadioProps } from '@mui/material/Radio'; + /** ### Use radio buttons to diff --git a/packages/ui/src/components/Stack/Stack.stories.tsx b/packages/ui/src/components/Stack/Stack.stories.tsx index d28537ad374..ba60e95c361 100644 --- a/packages/ui/src/components/Stack/Stack.stories.tsx +++ b/packages/ui/src/components/Stack/Stack.stories.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { Stack } from './Stack'; import { Divider } from '../Divider'; import { Paper } from '../Paper'; +import { Stack } from './Stack'; import type { Meta, StoryObj } from '@storybook/react'; diff --git a/packages/ui/src/components/TextField/TextField.stories.tsx b/packages/ui/src/components/TextField/TextField.stories.tsx index bfea2020a6b..d01ba9fea7c 100644 --- a/packages/ui/src/components/TextField/TextField.stories.tsx +++ b/packages/ui/src/components/TextField/TextField.stories.tsx @@ -1,6 +1,6 @@ -import { InputAdornment } from '../InputAdornment'; import React from 'react'; +import { InputAdornment } from '../InputAdornment'; import { TextField } from './TextField'; import type { Meta, StoryObj } from '@storybook/react'; diff --git a/packages/ui/src/components/TextField/TextField.test.tsx b/packages/ui/src/components/TextField/TextField.test.tsx index 86ae0d05822..bdf4eb45919 100644 --- a/packages/ui/src/components/TextField/TextField.test.tsx +++ b/packages/ui/src/components/TextField/TextField.test.tsx @@ -1,10 +1,10 @@ -import { InputAdornment } from '../InputAdornment'; import { fireEvent, getDefaultNormalizer } from '@testing-library/react'; import * as React from 'react'; +import { describe, expect, it } from 'vitest'; -import { TextField } from './TextField'; -import { expect, describe, it } from 'vitest'; import { renderWithTheme } from '../../utilities/testHelpers'; +import { InputAdornment } from '../InputAdornment'; +import { TextField } from './TextField'; describe('TextField', () => { const props = { diff --git a/packages/manager/src/components/Toggle/Toggle.stories.tsx b/packages/ui/src/components/Toggle/Toggle.stories.tsx similarity index 100% rename from packages/manager/src/components/Toggle/Toggle.stories.tsx rename to packages/ui/src/components/Toggle/Toggle.stories.tsx diff --git a/packages/manager/src/components/Toggle/Toggle.test.tsx b/packages/ui/src/components/Toggle/Toggle.test.tsx similarity index 87% rename from packages/manager/src/components/Toggle/Toggle.test.tsx rename to packages/ui/src/components/Toggle/Toggle.test.tsx index c6f8d9e805e..4c38268a966 100644 --- a/packages/manager/src/components/Toggle/Toggle.test.tsx +++ b/packages/ui/src/components/Toggle/Toggle.test.tsx @@ -1,8 +1,8 @@ import { fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; +import { describe, expect, it } from 'vitest'; -import { renderWithTheme } from 'src/utilities/testHelpers'; - +import { renderWithTheme } from '../../utilities/testHelpers'; import { Toggle } from './Toggle'; describe('Toggle component', () => { diff --git a/packages/manager/src/components/Toggle/Toggle.tsx b/packages/ui/src/components/Toggle/Toggle.tsx similarity index 87% rename from packages/manager/src/components/Toggle/Toggle.tsx rename to packages/ui/src/components/Toggle/Toggle.tsx index b585c45beb6..b3bebf11e78 100644 --- a/packages/manager/src/components/Toggle/Toggle.tsx +++ b/packages/ui/src/components/Toggle/Toggle.tsx @@ -1,9 +1,8 @@ -import { TooltipIcon } from '@linode/ui'; import Switch from '@mui/material/Switch'; import * as React from 'react'; -import ToggleOff from 'src/assets/icons/toggleOff.svg'; -import ToggleOn from 'src/assets/icons/toggleOn.svg'; +import { ToggleOffIcon, ToggleOnIcon } from '../../assets/icons'; +import { TooltipIcon } from '../TooltipIcon'; import type { SwitchProps } from '@mui/material/Switch'; @@ -31,10 +30,10 @@ export const Toggle = (props: ToggleProps) => { return ( } + checkedIcon={} color="primary" data-qa-toggle={props.checked} - icon={} + icon={} {...rest} /> {tooltipText && } diff --git a/packages/ui/src/components/Toggle/index.ts b/packages/ui/src/components/Toggle/index.ts new file mode 100644 index 00000000000..7aaab1d76e7 --- /dev/null +++ b/packages/ui/src/components/Toggle/index.ts @@ -0,0 +1 @@ +export * from './Toggle'; diff --git a/packages/ui/src/components/Tooltip/Tooltip.stories.tsx b/packages/ui/src/components/Tooltip/Tooltip.stories.tsx index d8f467dcd28..1b51740fad5 100644 --- a/packages/ui/src/components/Tooltip/Tooltip.stories.tsx +++ b/packages/ui/src/components/Tooltip/Tooltip.stories.tsx @@ -1,10 +1,9 @@ -import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; +import { Typography } from '../Typography'; import { Tooltip } from './Tooltip'; -// @todo: modularization - Import from 'ui' package once Typography is migrated. -import { Typography } from '@mui/material'; +import type { Meta, StoryObj } from '@storybook/react'; const meta: Meta = { component: Tooltip, diff --git a/packages/ui/src/components/TooltipIcon/TooltipIcon.stories.tsx b/packages/ui/src/components/TooltipIcon/TooltipIcon.stories.tsx index ea722db36b4..8b2c1122da9 100644 --- a/packages/ui/src/components/TooltipIcon/TooltipIcon.stories.tsx +++ b/packages/ui/src/components/TooltipIcon/TooltipIcon.stories.tsx @@ -1,8 +1,9 @@ -import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; import { TooltipIcon } from './TooltipIcon'; +import type { Meta, StoryObj } from '@storybook/react'; + const meta: Meta = { component: TooltipIcon, title: 'Components/Tooltip/Tooltip Icon', diff --git a/packages/ui/src/components/TooltipIcon/TooltipIcon.tsx b/packages/ui/src/components/TooltipIcon/TooltipIcon.tsx index 03608ef4f9a..a3b22fb19f1 100644 --- a/packages/ui/src/components/TooltipIcon/TooltipIcon.tsx +++ b/packages/ui/src/components/TooltipIcon/TooltipIcon.tsx @@ -1,7 +1,4 @@ import styled from '@emotion/styled'; -import { IconButton } from '../IconButton'; -import { Tooltip, tooltipClasses } from '../Tooltip'; -import { omittedProps } from '../../utilities'; import SuccessOutline from '@mui/icons-material/CheckCircleOutlined'; import ErrorOutline from '@mui/icons-material/ErrorOutline'; import HelpOutline from '@mui/icons-material/HelpOutline'; @@ -10,6 +7,10 @@ import WarningSolid from '@mui/icons-material/Warning'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; +import { omittedProps } from '../../utilities'; +import { IconButton } from '../IconButton'; +import { Tooltip, tooltipClasses } from '../Tooltip'; + import type { TooltipProps } from '../Tooltip'; import type { SxProps, Theme } from '@mui/material/styles'; diff --git a/packages/ui/src/components/VisibilityTooltip/VisibilityTooltip.tsx b/packages/ui/src/components/VisibilityTooltip/VisibilityTooltip.tsx index 4ab51ae43d5..fcc2d119b26 100644 --- a/packages/ui/src/components/VisibilityTooltip/VisibilityTooltip.tsx +++ b/packages/ui/src/components/VisibilityTooltip/VisibilityTooltip.tsx @@ -1,12 +1,14 @@ -import VisibilityShowIcon from 'src/assets/icons/visibilityShow.svg'; -import VisibilityHideIcon from 'src/assets/icons/visibilityHide.svg'; - import { styled } from '@mui/material/styles'; import React from 'react'; -import type { SxProps, Theme } from '@mui/material/styles'; +import VisibilityHideIcon from 'src/assets/icons/visibilityHide.svg'; +import VisibilityShowIcon from 'src/assets/icons/visibilityShow.svg'; + import { IconButton } from '../IconButton'; -import { Tooltip, TooltipProps } from '../Tooltip'; +import { Tooltip } from '../Tooltip'; + +import type { TooltipProps } from '../Tooltip'; +import type { SxProps, Theme } from '@mui/material/styles'; interface Props { /** @@ -17,28 +19,28 @@ interface Props { * If true, displays the icon to toggle visibility to hidden; if false, displays the icon to toggle visibility to shown. */ isVisible: boolean; - /** - * Additional styles to apply to the component. - */ - sx?: SxProps; /** * The placement of the tooltip. */ placement?: TooltipProps['placement']; + /** + * Additional styles to apply to the component. + */ + sx?: SxProps; } /** * Toggle-able visibility icon with tooltip on hover */ export const VisibilityTooltip = (props: Props) => { - const { handleClick, isVisible, sx, placement } = props; + const { handleClick, isVisible, placement, sx } = props; return ( {!isVisible ? ( diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 501779c3044..db690f6940e 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -21,6 +21,7 @@ export * from './Radio'; export * from './RadioGroup'; export * from './Stack'; export * from './TextField'; +export * from './Toggle'; export * from './Tooltip'; export * from './TooltipIcon'; export * from './Typography'; diff --git a/packages/ui/src/foundations/themes/dark.ts b/packages/ui/src/foundations/themes/dark.ts index ed25a8f7e53..fec7fd4ed22 100644 --- a/packages/ui/src/foundations/themes/dark.ts +++ b/packages/ui/src/foundations/themes/dark.ts @@ -214,6 +214,11 @@ export const darkTheme: ThemeOptions = { }, MuiAutocomplete: { styleOverrides: { + input: { + '&::selection': { + backgroundColor: customDarkModeOptions.bg.appBar, + }, + }, listbox: { backgroundColor: customDarkModeOptions.bg.white, border: `1px solid ${primaryColors.main}`, @@ -228,11 +233,6 @@ export const darkTheme: ThemeOptions = { '.MuiChip-deleteIcon': { color: primaryColors.text }, backgroundColor: customDarkModeOptions.bg.lightBlue1, }, - input: { - '&::selection': { - backgroundColor: customDarkModeOptions.bg.appBar, - }, - }, }, }, MuiBackdrop: { diff --git a/packages/ui/src/foundations/themes/index.ts b/packages/ui/src/foundations/themes/index.ts index d1f453303da..37f1ab514eb 100644 --- a/packages/ui/src/foundations/themes/index.ts +++ b/packages/ui/src/foundations/themes/index.ts @@ -9,17 +9,17 @@ import type { AccentTypes as AccentTypesLight, ActionTypes as ActionTypesLight, BackgroundTypes as BackgroundTypesLight, - BorderTypes as BorderTypesLight, BorderRadiusTypes, + BorderTypes as BorderTypesLight, ChartTypes, ColorTypes, ContentTypes as ContentTypesLight, ElevationTypes as ElevationTypesLight, FontTypes, InteractionTypes as InteractionTypesLight, - TypographyTypes, RadiusTypes, SpacingTypes, + TypographyTypes, } from '@linode/design-language-system'; import type { AccentTypes as AccentTypesDark, @@ -106,6 +106,7 @@ declare module '@mui/material/styles/createTheme' { inputStyles: any; name: ThemeName; notificationToast: NotificationToast; + textColors: TextColors; tokens: { // ---- Global tokens: theme agnostic ---- borderRadius: BorderRadiusTypes; @@ -124,7 +125,6 @@ declare module '@mui/material/styles/createTheme' { radius: RadiusTypes; typography: TypographyTypes; }; - textColors: TextColors; visually: any; } @@ -143,6 +143,7 @@ declare module '@mui/material/styles/createTheme' { inputStyles?: any; name: ThemeName; notificationToast?: NotificationToast; + textColors?: DarkModeTextColors | LightModeTextColors; tokens?: { // ---- Global tokens: theme agnostic ---- borderRadius?: BorderRadiusTypes; @@ -161,7 +162,6 @@ declare module '@mui/material/styles/createTheme' { radius?: RadiusTypes; typography?: TypographyTypes; }; - textColors?: DarkModeTextColors | LightModeTextColors; visually?: any; } } diff --git a/packages/ui/src/foundations/themes/light.ts b/packages/ui/src/foundations/themes/light.ts index 8e377265bee..a42aec2b366 100644 --- a/packages/ui/src/foundations/themes/light.ts +++ b/packages/ui/src/foundations/themes/light.ts @@ -15,8 +15,8 @@ import { NotificationToast, Radius, Select, - Typography, Spacing, + Typography, } from '@linode/design-language-system'; import { breakpoints } from '../breakpoints'; @@ -1645,15 +1645,15 @@ export const lightTheme: ThemeOptions = { background: Background, border: Border, borderRadius: BorderRadius, - color: Color, chart: Chart, + color: Color, content: Content, elevation: Elevation, + font: Font, interaction: Interaction, radius: Radius, - typography: Typography, - font: Font, spacing: Spacing, + typography: Typography, }, typography: { body1: { diff --git a/packages/ui/src/utilities/omittedProps.test.tsx b/packages/ui/src/utilities/omittedProps.test.tsx index 9510870f3da..685498c9ac6 100644 --- a/packages/ui/src/utilities/omittedProps.test.tsx +++ b/packages/ui/src/utilities/omittedProps.test.tsx @@ -2,6 +2,7 @@ import styled from '@emotion/styled'; import '@testing-library/jest-dom/vitest'; import { describe, expect, it } from 'vitest'; + import { omitProps, omittedProps } from './omittedProps'; type StyledProps = { diff --git a/packages/ui/src/utilities/testHelpers.tsx b/packages/ui/src/utilities/testHelpers.tsx index 1fb55370691..bbeedc455bc 100644 --- a/packages/ui/src/utilities/testHelpers.tsx +++ b/packages/ui/src/utilities/testHelpers.tsx @@ -1,9 +1,11 @@ import { StyledEngineProvider, ThemeProvider } from '@mui/material'; -import { render, RenderResult } from '@testing-library/react'; +import { render } from '@testing-library/react'; import * as React from 'react'; import * as themes from '../foundations/themes'; +import type { RenderResult } from '@testing-library/react'; + interface Options { theme?: 'dark' | 'light'; } diff --git a/packages/validation/src/cloudpulse.schema.ts b/packages/validation/src/cloudpulse.schema.ts index 75f3ab034b7..c639deb9d7d 100644 --- a/packages/validation/src/cloudpulse.schema.ts +++ b/packages/validation/src/cloudpulse.schema.ts @@ -30,8 +30,8 @@ const triggerCondition = object({ export const createAlertDefinitionSchema = object({ label: string().required('Name is required.'), description: string().optional(), - resource_ids: array().of(string()).min(1, 'At least one resource is needed.'), severity: string().required('Severity is required.'), + entity_ids: array().of(string()).min(1, 'At least one resource is needed.'), criteria: array() .of(metricCriteria) .min(1, 'At least one metric criteria is needed.'),