= {
dimensionKey: 'cluster_id',
maxResourceSelections: 10,
serviceType: 'dbaas',
- supportedRegionIds: 'us-ord',
},
],
};
@@ -256,6 +260,7 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura
minute: endMinute,
} = getDateRangeInGMT(12, 30);
+ cy.wait(1000);
// --- Select start date ---
cy.get('[aria-labelledby="start-date"]').as('startDateInput');
cy.get('@startDateInput').click();
@@ -263,17 +268,20 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura
cy.findAllByText(startDay).first().click();
cy.findAllByText(endDay).first().click();
});
+
ui.button
.findByAttribute('aria-label^', 'Choose time')
.first()
- .should('be.visible')
+ .should('be.visible', { timeout: 10000 }) // waits up to 10 seconds
.as('timePickerButton');
-
cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' });
- cy.get('@timePickerButton').click();
+ cy.get('@timePickerButton', { timeout: 15000 })
+ .wait(300) // ⛔ doesn't work like this! (cy.wait isn't chainable on element)
+ .click();
// Selects the start hour, minute, and meridiem (AM/PM) in the time picker.
+ cy.wait(1000);
cy.findByLabelText('Select hours')
.as('selectHours')
.scrollIntoView({ easing: 'linear' });
@@ -282,15 +290,18 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura
cy.get(`[aria-label="${startHour} hours"]`).click();
});
+ cy.wait(1000);
ui.button
.findByAttribute('aria-label^', 'Choose time')
.first()
- .should('be.visible')
+ .should('be.visible', { timeout: 10000 })
.as('timePickerButton');
cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' });
- cy.get('@timePickerButton').click();
+ cy.get('@timePickerButton', { timeout: 15000 })
+ .wait(300) // ⛔ doesn't work like this! (cy.wait isn't chainable on element)
+ .click();
cy.findByLabelText('Select minutes')
.as('selectMinutes')
@@ -303,12 +314,14 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura
ui.button
.findByAttribute('aria-label^', 'Choose time')
.first()
- .should('be.visible')
+ .should('be.visible', { timeout: 10000 })
.as('timePickerButton');
cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' });
- cy.get('@timePickerButton').click();
+ cy.get('@timePickerButton', { timeout: 15000 })
+ .wait(300) // ⛔ doesn't work like this! (cy.wait isn't chainable on element)
+ .click();
cy.findByLabelText('Select meridiem')
.as('startMeridiemSelect')
@@ -319,10 +332,10 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura
ui.button
.findByAttribute('aria-label^', 'Choose time')
.last()
- .should('be.visible')
+ .should('be.visible', { timeout: 10000 })
.as('timePickerButton');
- cy.get('@timePickerButton').click();
+ cy.get('@timePickerButton', { timeout: 15000 }).click();
// Selects the start hour, minute, and meridiem (AM/PM) in the time picker.
cy.findByLabelText('Select hours').scrollIntoView({
@@ -338,8 +351,9 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura
.should('be.visible')
.as('timePickerButton');
- cy.get('@timePickerButton').click();
-
+ cy.get('@timePickerButton', { timeout: 15000 })
+ .wait(300) // ⛔ doesn't work like this! (cy.wait isn't chainable on element)
+ .click();
cy.findByLabelText('Select minutes').scrollIntoView({
duration: 500,
easing: 'linear',
@@ -350,10 +364,12 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura
cy.get('[aria-label^="Choose time"]')
.last()
- .should('be.visible')
+ .should('be.visible', { timeout: 10000 })
.as('timePickerButton');
- cy.get('@timePickerButton').click();
+ cy.get('@timePickerButton', { timeout: 15000 })
+ .wait(300) // ⛔ doesn't work like this! (cy.wait isn't chainable on element)
+ .click();
cy.findByLabelText('Select meridiem')
.as('endMeridiemSelect')
diff --git a/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts b/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts
index 8bec02bed7d..31a056ded31 100644
--- a/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts
+++ b/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts
@@ -48,14 +48,14 @@ describe('account login redirect', () => {
* This test validates that the encoded redirect param is valid and can be properly decoded when the user is redirected to our application.
*/
it('should redirect the user to the page they were on if the redirect param is present and valid', () => {
- cy.visitWithLogin('/linodes/create?type=Images');
- cy.url().should('contain', '/linodes/create');
+ cy.visitWithLogin('/linodes/create/images');
+ cy.url().should('contain', '/linodes/create/images');
cy.clearLocalStorage(tokenLocalStorageKey);
cy.reload();
cy.url().should(
'contain',
- 'returnTo%253D%252Flinodes%252Fcreate%253Ftype%253DImages'
+ 'returnTo%253D%252Flinodes%252Fcreate%252Fimages'
);
cy.url().then((url) => {
// We need to decode the URL twice to get the original redirect URL.
@@ -63,7 +63,7 @@ describe('account login redirect', () => {
const decodedOnce = decodeURIComponent(url);
const decodedTwice = decodeURIComponent(decodedOnce);
- expect(decodedTwice).to.contain('/linodes/create?type=Images');
+ expect(decodedTwice).to.contain('/linodes/create/images');
});
});
});
diff --git a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts
index 22e776ed49e..40aef7bf9ed 100644
--- a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts
+++ b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts
@@ -80,7 +80,7 @@ describe('create linode from image, mocked data', () => {
];
mockGetAllImages([]).as('getImages');
- cy.visitWithLogin('/linodes/create?type=Images');
+ cy.visitWithLogin('/linodes/create/images');
cy.wait('@getImages');
noImagesMessages.forEach((message: string) => {
cy.findByText(message, { exact: false }).should('be.visible');
@@ -88,12 +88,12 @@ describe('create linode from image, mocked data', () => {
});
it('creates linode from image on images tab', () => {
- createLinodeWithImageMock('/linodes/create?type=Images', false);
+ createLinodeWithImageMock('/linodes/create/images', false);
});
it('creates linode from preselected image on images tab', () => {
createLinodeWithImageMock(
- `/linodes/create/?type=Images&imageID=${mockImage.id}`,
+ `/linodes/create/images?imageID=${mockImage.id}`,
true
);
});
diff --git a/packages/manager/cypress/e2e/core/images/search-images.spec.ts b/packages/manager/cypress/e2e/core/images/search-images.spec.ts
index 2945baacd87..a64cac73e20 100644
--- a/packages/manager/cypress/e2e/core/images/search-images.spec.ts
+++ b/packages/manager/cypress/e2e/core/images/search-images.spec.ts
@@ -67,7 +67,7 @@ describe('Search Images', () => {
cy.contains(image2.label).should('not.exist');
// Clear search, confirm both images are shown.
- cy.findByTestId('clear-images-search').click();
+ cy.findByLabelText('Clear').click();
cy.contains(image1.label).should('be.visible');
cy.contains(image2.label).should('be.visible');
diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts
index a860eb97e74..f6717cbbf68 100644
--- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts
+++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts
@@ -697,6 +697,7 @@ describe('LKE Cluster Creation with ACL', () => {
describe('with LKE IPACL account capability', () => {
beforeEach(() => {
+ mockGetKubernetesVersions([clusterVersion]).as('getLKEVersions');
mockGetRegions([mockRegion]).as('getRegions');
mockGetLinodeTypes(mockLinodeTypes).as('getLinodeTypes');
mockGetRegionAvailability(mockRegion.id, []).as('getRegionAvailability');
@@ -731,7 +732,7 @@ describe('LKE Cluster Creation with ACL', () => {
.click();
cy.url().should('endWith', '/kubernetes/create');
- cy.wait(['@getRegions', '@getLinodeTypes']);
+ cy.wait(['@getRegions', '@getLinodeTypes', '@getLKEVersions']);
// Fill out LKE creation form label, region, and Kubernetes version fields.
cy.findByLabelText('Cluster Label').should('be.visible').click();
diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts
new file mode 100644
index 00000000000..1c2761ba27f
--- /dev/null
+++ b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts
@@ -0,0 +1,494 @@
+import { regionAvailabilityFactory, regionFactory } from '@linode/utilities';
+import { mockGetAccountSettings } from 'support/intercepts/account';
+import { mockGetAlertDefinition } from 'support/intercepts/cloudpulse';
+import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags';
+import { interceptCreateLinode } from 'support/intercepts/linodes';
+import {
+ mockGetRegionAvailability,
+ mockGetRegions,
+} from 'support/intercepts/regions';
+import { ui } from 'support/ui';
+import { randomLabel, randomString } from 'support/util/random';
+
+import { accountSettingsFactory, alertFactory } from 'src/factories';
+import {
+ ALERTS_BETA_MODE_BANNER_TEXT,
+ ALERTS_BETA_MODE_BUTTON_TEXT,
+ ALERTS_LEGACY_MODE_BANNER_TEXT,
+ ALERTS_LEGACY_MODE_BUTTON_TEXT,
+} from 'src/features/Linodes/constants';
+
+describe('Create flow when beta alerts enabled by region and feature flag', function () {
+ beforeEach(() => {
+ const mockEnabledRegion = regionFactory.build({
+ capabilities: ['Linodes'],
+ monitors: {
+ alerts: ['Linodes'],
+ },
+ });
+ const mockDisabledRegion = regionFactory.build({
+ capabilities: ['Linodes'],
+ monitors: {
+ alerts: [],
+ },
+ });
+ const mockRegions = [mockEnabledRegion, mockDisabledRegion];
+ cy.wrap(mockRegions).as('mockRegions');
+ mockGetRegions(mockRegions).as('getRegions');
+ mockAppendFeatureFlags({
+ aclpBetaServices: {
+ linode: {
+ alerts: true,
+ metrics: false,
+ },
+ },
+ }).as('getFeatureFlags');
+ // mock network interface type in case test account has setting that disables snippet
+ const mockInitialAccountSettings = accountSettingsFactory.build({
+ interfaces_for_new_linodes: 'legacy_config_default_but_linode_allowed',
+ });
+ mockGetAccountSettings(mockInitialAccountSettings).as('getSettings');
+ });
+
+ it('Alerts panel becomes visible after switching to region w/ alerts enabled', function () {
+ const disabledRegion = this.mockRegions[1];
+ mockGetRegionAvailability(disabledRegion.id, []).as(
+ 'getRegionAvailability'
+ );
+ cy.visitWithLogin('/linodes/create');
+ cy.wait(['@getFeatureFlags', '@getRegions']);
+
+ ui.regionSelect.find().click();
+ ui.regionSelect.find().type(`${disabledRegion.label}{enter}`);
+ cy.wait('@getRegionAvailability');
+
+ // Alerts section is not visible until enabled region is selected
+ cy.get('[data-qa-panel="Alerts"]').should('not.exist');
+ ui.regionSelect.find().click();
+ ui.regionSelect.find().clear();
+ const enabledRegion = this.mockRegions[0];
+ ui.regionSelect.find().type(`${enabledRegion.label}{enter}`);
+
+ // Alerts section is visible after enabled region is selected
+ cy.contains('Additional Options').should('be.visible');
+ cy.get('[data-qa-panel="Alerts"]').should('be.visible');
+ });
+
+ it('create flow defaults to legacy alerts', function () {
+ interceptCreateLinode().as('createLinode');
+ cy.visitWithLogin('/linodes/create');
+ cy.wait(['@getFeatureFlags', '@getSettings', '@getRegions']);
+ ui.regionSelect.find().click();
+ const enabledRegion = this.mockRegions[0];
+ mockGetRegionAvailability(enabledRegion.id, []).as('getRegionAvailability');
+ ui.regionSelect.find().type(`${enabledRegion.label}{enter}`);
+
+ // legacy alerts panel appears
+ cy.wait('@getRegionAvailability');
+ cy.get('[data-qa-panel="Alerts"]')
+ .should('be.visible')
+ .within(() => {
+ ui.accordionHeading.findByTitle('Alerts');
+ ui.accordionHeading
+ .findByTitle('Alerts')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ // legacy alert form
+ // inputs are ON but readonly, cant be added to POST
+ cy.get('[data-qa-alerts-panel="true"]').each((panel) => {
+ cy.wrap(panel).within(() => {
+ ui.toggle
+ .find()
+ .should('have.attr', 'data-qa-toggle', 'true')
+ .should('be.visible')
+ .should('be.disabled');
+ // numeric inputs are disabled
+ cy.get('[type="number"]')
+ .should('be.visible')
+ .should('be.disabled');
+ });
+ });
+ });
+
+ // enter plan and password form fields to enable "View Code Snippets" button
+ cy.get('[data-qa-tp="Linode Plan"]').scrollIntoView();
+ cy.get('[data-qa-tp="Linode Plan"]')
+ .should('be.visible')
+ .within(() => {
+ cy.get('[data-qa-plan-row="Dedicated 8 GB"]').click();
+ });
+ cy.get('[type="password"]').should('be.visible').scrollIntoView();
+ cy.get('[id="root-password"]').type(randomString(12));
+ cy.scrollTo('bottom');
+ ui.button
+ .findByTitle('View Code Snippets')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ ui.dialog
+ .findByTitle('Create Linode')
+ .should('be.visible')
+ .within(() => {
+ cy.get('pre code').should('be.visible');
+ // 'alert' is not present anywhere in snippet
+ cy.contains('alert').should('not.exist');
+ // cURL tab
+ ui.tabList.findTabByTitle('cURL').should('be.visible').click();
+ cy.contains('alert').should('not.exist');
+ ui.button
+ .findByTitle('Close')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
+ cy.scrollTo('bottom');
+ ui.button
+ .findByTitle('Create Linode')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ cy.wait('@createLinode').then((intercept) => {
+ const alerts = intercept.request.body['alerts'];
+ expect(alerts).to.eq(undefined);
+ });
+ });
+
+ it('create flow after switching to beta alerts', function () {
+ const alertDefinitions = [
+ alertFactory.build({
+ description: randomLabel(),
+ entity_ids: ['1', '2', '3'],
+ label: randomLabel(),
+ service_type: 'linode',
+ severity: 1,
+ status: 'enabled',
+ type: 'system',
+ }),
+ alertFactory.build({
+ description: randomLabel(),
+ entity_ids: ['1', '2', '3'],
+ label: randomLabel(),
+ service_type: 'linode',
+ severity: 1,
+ status: 'enabled',
+ type: 'system',
+ }),
+ alertFactory.build({
+ description: randomLabel(),
+ entity_ids: ['1', '2', '3'],
+ label: randomLabel(),
+ service_type: 'linode',
+ severity: 1,
+ status: 'enabled',
+ type: 'user',
+ }),
+ ];
+ mockGetAlertDefinition('linode', alertDefinitions).as(
+ 'getAlertDefinitions'
+ );
+ interceptCreateLinode().as('createLinode');
+ cy.visitWithLogin('/linodes/create');
+ cy.wait(['@getFeatureFlags', '@getSettings', '@getRegions']);
+ ui.regionSelect.find().click();
+ const enabledRegion = this.mockRegions[0];
+ mockGetRegionAvailability(enabledRegion.id, []).as('getRegionAvailability');
+ ui.regionSelect.find().type(`${enabledRegion.label}{enter}`);
+
+ // legacy alerts panel appears
+ cy.wait('@getRegionAvailability');
+ cy.get('[data-qa-panel="Alerts"]')
+ .should('be.visible')
+ .within(() => {
+ ui.accordionHeading.findByTitle('Alerts');
+ ui.accordionHeading
+ .findByTitle('Alerts')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ ui.accordion.findByTitle('Alerts').within(() => {
+ // switch to beta
+ // alerts are off/false but enabled, can switch to on/true
+ ui.button
+ .findByTitle(ALERTS_LEGACY_MODE_BUTTON_TEXT)
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
+ });
+ cy.wait(['@getAlertDefinitions']);
+
+ // verify summary at bottom displays 0 alerts selected
+ cy.scrollTo('bottom');
+ cy.get('[data-qa-linode-create-summary="true"]')
+ .should('be.visible')
+ .within(() => {
+ cy.contains('Alerts Assigned');
+ cy.contains('0');
+ });
+ // scroll back up to alerts table, select beta alerts
+ cy.get('table[data-testid="alert-table"]').scrollIntoView();
+ cy.get('table[data-testid="alert-table"]')
+ .should('be.visible')
+ .find('tbody > tr')
+ .should('have.length', 3)
+ .each((row, index) => {
+ // match alert definitions to table cell contents
+ cy.wrap(row).within(() => {
+ cy.get('td')
+ .eq(0)
+ .within(() => {
+ // select each alert
+ ui.toggle
+ .find()
+ .should('have.attr', 'data-qa-toggle', 'false')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ // value is now on/true
+ ui.toggle.find().should('have.attr', 'data-qa-toggle', 'true');
+ });
+ cy.get('td')
+ .eq(1)
+ .within(() => {
+ cy.findByText(alertDefinitions[index].label).should('be.visible');
+ });
+ cy.get('td')
+ .eq(2)
+ .within(() => {
+ const rule = alertDefinitions[index].rule_criteria.rules[0];
+ const str = `${rule.label} = ${rule.threshold} ${rule.unit}`;
+ cy.findByText(str).should('be.visible');
+ });
+ cy.get('td')
+ .eq(3)
+ .within(() => {
+ cy.findByText(alertDefinitions[index].type, {
+ exact: false,
+ }).should('be.visible');
+ });
+ });
+ });
+
+ // enter plan and password form fields to enable "View Code Snippets" button
+ cy.get('[data-qa-tp="Linode Plan"]').scrollIntoView();
+ cy.get('[data-qa-tp="Linode Plan"]')
+ .should('be.visible')
+ .within(() => {
+ cy.get('[data-qa-plan-row="Dedicated 8 GB"]').click();
+ });
+ cy.get('[type="password"]').should('be.visible').scrollIntoView();
+ cy.get('[id="root-password"]').type(randomString(12));
+ cy.scrollTo('bottom');
+ ui.button
+ .findByTitle('View Code Snippets')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ ui.dialog
+ .findByTitle('Create Linode')
+ .should('be.visible')
+ .within(() => {
+ cy.get('pre code').should('be.visible');
+ /** alert in code snippet
+ * "alerts": {
+ * "system": [
+ * 1,
+ * 2,
+ * ],
+ * "user": [
+ * 2
+ * ]
+ * }
+ */
+ const strAlertSnippet = `alerts '{"system": [${alertDefinitions[0].id},${alertDefinitions[1].id}],"user":[${alertDefinitions[2].id}]}`;
+ cy.contains(strAlertSnippet).should('be.visible');
+ // cURL tab
+ ui.tabList.findTabByTitle('cURL').should('be.visible').click();
+ // hard to consolidate text within multiple spans in
+ cy.get('pre code').within(() => {
+ cy.contains('alerts');
+ cy.contains('system');
+ cy.contains('user');
+ });
+ ui.button
+ .findByTitle('Close')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
+ // verify alerts counter in summary displays number selected
+ cy.scrollTo('bottom');
+ // summary displays number of alerts ("+3")
+ cy.get('[data-qa-linode-create-summary="true"]')
+ .should('be.visible')
+ .within(() => {
+ cy.contains('Alerts Assigned');
+ cy.contains(`+${alertDefinitions.length}`);
+ });
+ // window scrolls to top, RegionSelect displays error msg
+ ui.button
+ .findByTitle('Create Linode')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ cy.wait('@createLinode').then((intercept) => {
+ const alerts = intercept.request.body['alerts'];
+ expect(alerts.system.length).to.equal(2);
+ expect(alerts.system[0]).to.eq(alertDefinitions[0].id);
+ expect(alerts.system[1]).to.eq(alertDefinitions[1].id);
+ expect(alerts.user.length).to.equal(1);
+ expect(alerts.user[0]).to.eq(alertDefinitions[2].id);
+ });
+ });
+
+ it('can toggle from legacy to beta alerts and back to legacy', function () {
+ cy.visitWithLogin('/linodes/create');
+ cy.wait(['@getFeatureFlags', '@getRegions']);
+ ui.regionSelect.find().click();
+ const enabledRegion = this.mockRegions[0];
+ ui.regionSelect.find().type(`${enabledRegion.label}{enter}`);
+
+ // legacy alerts are visible
+ ui.accordionHeading
+ .findByTitle('Alerts')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ ui.accordion.findByTitle('Alerts').within(() => {
+ cy.get('[data-testid="notice-info"]')
+ .should('be.visible')
+ .within(() => {
+ cy.contains(ALERTS_LEGACY_MODE_BANNER_TEXT);
+ });
+ });
+ // legacy alert form, inputs are ON but readonly
+ cy.get('[data-qa-alerts-panel="true"]').each((panel) => {
+ cy.wrap(panel).within(() => {
+ ui.toggle
+ .find()
+ .should('have.attr', 'data-qa-toggle', 'true')
+ .should('be.visible')
+ .should('be.disabled');
+ // numeric inputs are disabled
+ cy.get('[type="number"]').should('be.visible').should('be.disabled');
+ });
+ });
+
+ // upgrade from legacy alerts to beta alerts
+ ui.button
+ .findByTitle(ALERTS_LEGACY_MODE_BUTTON_TEXT)
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ cy.get('[data-qa-panel="Alerts"]')
+ .should('be.visible')
+ .within(() => {
+ cy.get('[data-testid="betaChip"]').should('be.visible');
+ cy.get('[data-testid="notice-info"]')
+ .should('be.visible')
+ .within(() => {
+ cy.contains(ALERTS_BETA_MODE_BANNER_TEXT);
+ });
+ // possible to downgrade from ACLP alerts to legacy alerts
+ ui.button
+ .findByTitle(ALERTS_BETA_MODE_BUTTON_TEXT)
+ .should('be.visible')
+ .should('be.enabled');
+ });
+ });
+
+ it('alerts not present for region where alerts disabled', function () {
+ const createLinodeErrorMsg = 'region is not valid';
+ interceptCreateLinode().as('createLinode');
+ cy.visitWithLogin('/linodes/create');
+ cy.wait(['@getRegions']);
+ ui.regionSelect.find().click();
+ const disabledRegion = this.mockRegions[1];
+
+ const mockRegionAvailability = [
+ regionAvailabilityFactory.build({
+ available: true,
+ region: disabledRegion.id,
+ }),
+ ];
+ mockGetRegionAvailability(disabledRegion.id, mockRegionAvailability).as(
+ 'getRegionAvailability'
+ );
+ ui.regionSelect.find().type(`${disabledRegion.label}{enter}`);
+
+ cy.wait('@getRegionAvailability');
+ // enter plan and password form fields to enable "View Code Snippets" button
+ cy.get('[data-qa-tp="Linode Plan"]').scrollIntoView();
+ cy.get('[data-qa-tp="Linode Plan"]')
+ .should('be.visible')
+ .within(() => {
+ cy.get('[data-qa-plan-row="Dedicated 8 GB"]').click();
+ });
+ cy.get('[type="password"]').should('be.visible').scrollIntoView();
+ cy.get('[id="root-password"]').type(randomString(12));
+ // no alerts panel
+ cy.get('[data-qa-panel="Alerts"]').should('not.exist');
+ cy.scrollTo('bottom');
+ ui.button
+ .findByTitle('Create Linode')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ cy.wait('@createLinode').then((intercept) => {
+ const body = intercept.response?.body;
+ const alerts = body['alerts'];
+ expect(alerts).to.eq(undefined);
+ const error = body.errors[0];
+ expect(error.field).to.eq('region');
+ expect(error.reason).to.eq(createLinodeErrorMsg);
+ });
+ // window scrolls to top, RegionSelect displays error msg
+ // Creation fails but not bc of factors related to this test setup
+ cy.get('[data-qa-textfield-error-text="Region"]')
+ .should('be.visible')
+ .should('have.text', createLinodeErrorMsg);
+ });
+});
+
+describe('aclpBetaServices feature flag disabled', function () {
+ it('Alerts not present when feature flag disabled', function () {
+ const mockEnabledRegion = regionFactory.build({
+ capabilities: ['Linodes'],
+ monitors: {
+ alerts: ['Linodes'],
+ },
+ });
+ const mockRegions = [mockEnabledRegion];
+ cy.wrap(mockRegions).as('mockRegions');
+ mockGetRegions(mockRegions).as('getRegions');
+ mockAppendFeatureFlags({
+ aclpBetaServices: {
+ linode: {
+ alerts: false,
+ metrics: false,
+ },
+ },
+ }).as('getFeatureFlags');
+ interceptCreateLinode().as('createLinode');
+ cy.visitWithLogin('/linodes/create');
+ cy.wait(['@getRegions']);
+ ui.regionSelect.find().click();
+
+ const mockRegionAvailability = [
+ regionAvailabilityFactory.build({
+ available: true,
+ region: mockEnabledRegion.id,
+ }),
+ ];
+ mockGetRegionAvailability(mockEnabledRegion.id, mockRegionAvailability).as(
+ 'getRegionAvailability'
+ );
+ ui.regionSelect.find().type(`${mockEnabledRegion.label}{enter}`);
+ cy.wait('@getRegionAvailability');
+
+ cy.get('[data-qa-panel="Alerts"]').should('not.exist');
+ });
+});
diff --git a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts
index 88337c0c2b3..6fbb52b8e7d 100644
--- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts
+++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts
@@ -59,7 +59,7 @@ import type { Event, Linode } from '@linode/api-v4';
const getLinodeCloneUrl = (linode: Linode): string => {
const regionQuery = `®ionID=${linode.region}`;
const typeQuery = linode.type ? `&typeID=${linode.type}` : '';
- return `/linodes/create?linodeID=${linode.id}${regionQuery}&type=Clone%20Linode${typeQuery}`;
+ return `/linodes/create/clone?linodeID=${linode.id}${regionQuery}${typeQuery}`;
};
authenticate();
diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts
index 6229f2a2b96..a8b5a11bcd2 100644
--- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts
+++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts
@@ -71,7 +71,7 @@ describe('Create Linode with DC-specific pricing', () => {
mockCreateLinode(mockLinode).as('linodeCreated');
- cy.get('[data-qa-header="Create"]').should('have.text', 'Create');
+ cy.get('[data-qa-header="OS"]').should('have.text', 'OS');
ui.button.findByTitle('Create Linode').click();
diff --git a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts
index 05743936663..1a47787c0cd 100644
--- a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts
+++ b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts
@@ -74,6 +74,7 @@ describe('IP Addresses', () => {
global: [_ipv6Range],
link_local: ipv6Address,
slaac: ipv6Address,
+ vpc: [],
},
}).as('getLinodeIPAddresses');
mockUpdateIPAddress(linodeIPv4, mockRDNS).as('updateIPAddress');
diff --git a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts
index daaf7eb21ce..dbab679f216 100644
--- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts
+++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts
@@ -444,7 +444,7 @@ describe('rebuild linode', () => {
mockGetAllImages([image]);
mockGetImage(image.id, image);
- cy.visitWithLogin(`/linodes/${linode.id}?rebuild=true`);
+ cy.visitWithLogin(`/linodes/${linode.id}/metrics/?rebuild=true`);
findRebuildDialog(linode.label).within(() => {
// Select an Image
diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/qemu-reboot-upgrade-notice.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/qemu-reboot-upgrade-notice.spec.ts
new file mode 100644
index 00000000000..03b2c3d802f
--- /dev/null
+++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/qemu-reboot-upgrade-notice.spec.ts
@@ -0,0 +1,343 @@
+import {
+ capitalize,
+ linodeConfigInterfaceFactory,
+ linodeFactory,
+ profileFactory,
+} from '@linode/utilities';
+import { notificationFactory } from '@src/factories/notification';
+import { mockGetAccount, mockGetMaintenance } from 'support/intercepts/account';
+import { mockGetLinodeConfigs } from 'support/intercepts/configs';
+import { mockGetNotifications } from 'support/intercepts/events';
+import {
+ mockGetLinodeDetails,
+ mockGetLinodes,
+ mockGetLinodeVolumes,
+} from 'support/intercepts/linodes';
+import { mockGetProfile } from 'support/intercepts/profile';
+import { mockGetVLANs } from 'support/intercepts/vlans';
+import { randomIp, randomLabel, randomNumber } from 'support/util/random';
+import { chooseRegion } from 'support/util/regions';
+
+import {
+ accountFactory,
+ accountMaintenanceFactory,
+ linodeConfigFactory,
+ VLANFactory,
+ volumeFactory,
+} from 'src/factories';
+import { formatDate } from 'src/utilities/formatDate';
+
+import type { Notification } from '@linode/api-v4';
+
+describe('QEMU reboot upgrade notification', () => {
+ const NOTIFICATION_BANNER_TEXT = 'critical platform maintenance';
+ const noticeMessageShort =
+ 'One or more Linodes need to be rebooted for critical platform maintenance.';
+ const noticeMessage = `${noticeMessageShort} See which Linodes are scheduled for reboot on the Account Maintenance page.`;
+ const rebootReason =
+ 'This maintenance is scheduled to upgrade the QEMU version.';
+
+ const notifications: Notification[] = [
+ notificationFactory.build({
+ severity: 'major',
+ type: 'security_reboot_maintenance_scheduled',
+ message: noticeMessageShort,
+ label: 'QEMU Reboot Upgrade Notice',
+ }),
+ ];
+
+ /**
+ * This test verifies that the QEMU reboot upgrade notice is displayed in the Linode landing page.
+ *
+ * - Check that the notice is visible and contains the expected message.
+ * - Check that user gets notified and the notification is present in the notifications dropdown.
+ * - Check that a maintenance help button is displayed near the status of impacted Linodes.
+ */
+ it(`should display maintenance banner in 'Linode' landing page when one or more Linodes get impacted.`, () => {
+ const mockAccount = accountFactory.build();
+ const mockProfile = profileFactory.build({
+ restricted: false,
+ username: 'mock-user',
+ });
+ const mockLinodes = linodeFactory.buildList(5, {
+ region: chooseRegion({
+ capabilities: ['Linodes', 'Vlans'],
+ }).id,
+ });
+ const upcomingMaintenance = [
+ accountMaintenanceFactory.build({
+ status: 'scheduled',
+ type: 'reboot',
+ reason: rebootReason,
+ entity: {
+ label: `${mockLinodes[0].label}`,
+ id: mockLinodes[0].id,
+ type: 'linode',
+ url: `/v4/linode/instances/${mockLinodes[0].id}`,
+ },
+ start_time: new Date().toISOString(),
+ }),
+ ];
+ const formattedTime = formatDate(upcomingMaintenance[0].start_time, {
+ timezone: mockProfile.timezone,
+ });
+ const maintenanceTooltipText = `This Linode’s maintenance window opens at ${formattedTime}. For more information, see your open support tickets.`;
+
+ mockGetAccount(mockAccount).as('getAccount');
+ mockGetLinodes(mockLinodes).as('getLinodes');
+ mockGetNotifications(notifications).as('getNotifications');
+ mockGetProfile(mockProfile).as('getProfile');
+
+ mockGetMaintenance(upcomingMaintenance, []).as('getMaintenance');
+
+ cy.visitWithLogin('/linodes');
+ cy.wait(['@getAccount', '@getLinodes', '@getNotifications', '@getProfile']);
+
+ // Confirm that a maintenance help button is present
+ cy.get('[data-qa-help-tooltip="true"]')
+ .should('be.visible')
+ .trigger('mouseover');
+ // Click the button first, then confirm the tooltip is shown.
+ cy.get('[role="tooltip"]').then(($tooltip) => {
+ expect($tooltip).to.have.length(1);
+ expect($tooltip.text()).to.include(maintenanceTooltipText);
+ });
+
+ // Confirm that the notice is visible and contains the expected message
+ cy.findByText(NOTIFICATION_BANNER_TEXT, { exact: false })
+ .should('be.visible')
+ .closest('[data-testid="notice-warning"]')
+ .within(() => {
+ cy.get('p').then(($el) => {
+ const noticeText = $el.text();
+ expect(noticeText).to.include(noticeMessage);
+ });
+ });
+
+ cy.get('button[aria-label="Notifications"]').click();
+ cy.findByText(notifications[0].message).should('be.visible');
+ });
+
+ /**
+ * This test verifies that the QEMU reboot upgrade notice is displayed in the Linode Details page.
+ *
+ * - Check that the notice is visible and contains the expected message.
+ * - Check that user gets notified and the notification is present in the notifications dropdown.
+ */
+ it(`should display maintenance banner in 'Linode Details' page when the Linode instance gets impacted.`, () => {
+ const mockLinodeRegion = chooseRegion({
+ capabilities: ['Linodes', 'Vlans'],
+ });
+ const mockLinode = linodeFactory.build({
+ id: randomNumber(),
+ label: randomLabel(),
+ region: mockLinodeRegion.id,
+ status: 'running',
+ });
+ const mockVolume = volumeFactory.build();
+ const mockPublicConfigInterface = linodeConfigInterfaceFactory.build({
+ ipam_address: null,
+ purpose: 'public',
+ });
+ const mockConfig = linodeConfigFactory.build({
+ id: randomNumber(),
+ interfaces: [
+ // The order of this array is significant. Index 0 (eth0) should be public.
+ mockPublicConfigInterface,
+ ],
+ });
+ const mockVlan = VLANFactory.build({
+ cidr_block: `${randomIp()}/24`,
+ id: randomNumber(),
+ label: randomLabel(),
+ linodes: [],
+ region: mockLinodeRegion.id,
+ });
+ const rebootNoticeMessage = `${mockLinode.label} needs to be rebooted for critical platform maintenance.`;
+ const notifications: Notification[] = [
+ notificationFactory.build({
+ severity: 'major',
+ type: 'security_reboot_maintenance_scheduled',
+ message: rebootNoticeMessage,
+ label: 'QEMU Reboot Upgrade Notice',
+ entity: {
+ label: mockLinode.label,
+ id: mockLinode.id,
+ type: 'linode',
+ url: `/v4/linode/instances/${mockLinode.id}`,
+ },
+ }),
+ ];
+
+ mockGetVLANs([mockVlan]);
+ mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode');
+ mockGetLinodeVolumes(mockLinode.id, [mockVolume]).as('getLinodeVolumes');
+ mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getLinodeConfigs');
+ mockGetNotifications(notifications).as('getNotifications');
+ cy.visitWithLogin(`/linodes/${mockLinode.id}`);
+ cy.wait(['@getLinode', '@getNotifications']);
+
+ cy.findByText(NOTIFICATION_BANNER_TEXT, { exact: false })
+ .should('be.visible')
+ .closest('[data-testid="notice-warning"]')
+ .within(() => {
+ cy.get('p').then(($el) => {
+ const noticeText = $el.text();
+ expect(noticeText).to.include(rebootNoticeMessage);
+ });
+ });
+
+ cy.get('button[aria-label="Notifications"]').click();
+ cy.get('[data-testid="security_reboot_maintenance_scheduled"]').within(
+ () => {
+ cy.get('p').then(($el) => {
+ const noticeText = $el.text();
+ expect(noticeText).to.include(rebootNoticeMessage);
+ });
+ }
+ );
+ });
+
+ /**
+ * This test verifies that the QEMU reboot upgrade notice is displayed in the Account page.
+ *
+ * - Check that the notice is visible and contains the expected message.
+ * - Check that user gets notified and the notification is present in the notifications dropdown.
+ * - Check that the status of impacted Linodes will be changed to "Scheduled" under the upcoming table of Account Maintenance page.
+ */
+ it(`should display maintenance banner in 'Account Maintenance' page when one or more Linodes get impacted.`, () => {
+ const linodeId = randomNumber(10000, 20000);
+ const completedMaintenanceNumber = 2;
+ const accountpendingMaintenance = [
+ accountMaintenanceFactory.build({
+ status: 'scheduled',
+ type: 'reboot',
+ reason: rebootReason,
+ entity: {
+ label: `linode-${linodeId}`,
+ id: linodeId,
+ type: 'linode',
+ url: `/v4/linode/instances/${linodeId}`,
+ },
+ start_time: new Date().toISOString(),
+ }),
+ ];
+ const accountcompletedMaintenance = accountMaintenanceFactory.buildList(
+ completedMaintenanceNumber,
+ { status: 'completed' }
+ );
+
+ mockGetMaintenance(
+ accountpendingMaintenance,
+ accountcompletedMaintenance
+ ).as('getMaintenance');
+
+ mockGetNotifications(notifications).as('getNotifications');
+ cy.visitWithLogin('/account/maintenance');
+ cy.wait(['@getMaintenance', '@getNotifications']);
+
+ cy.contains('No pending maintenance').should('not.exist');
+ cy.contains('No completed maintenance').should('not.exist');
+
+ // Confirm In Progress table is not empty and contains exact number of pending maintenances
+ cy.get('[aria-label="List of in progress maintenance"]')
+ .should('be.visible')
+ .find('tbody')
+ .within(() => {
+ const upcomingMaintenance = accountpendingMaintenance[0];
+ // Confirm that the type of entity is displayed correctly
+ cy.findByText(upcomingMaintenance.entity.type).should('be.visible');
+ // Confirm that the label of entity is displayed correctly
+ cy.findByText(upcomingMaintenance.entity.label).should('be.visible');
+ // Confirm that the type of maintenance is displayed correctly
+ cy.findByText(capitalize(upcomingMaintenance.type)).should(
+ 'be.visible'
+ );
+ // Confirm that the reason of maintenance is displayed correctly
+ cy.findByText(upcomingMaintenance.reason, { exact: false }).should(
+ 'be.visible'
+ );
+ });
+
+ // Confirm Upcoming table is not empty and contains exact number of pending maintenances
+ cy.get('[aria-label="List of upcoming maintenance"]')
+ .should('be.visible')
+ .find('tbody')
+ .within(() => {
+ const upcomingMaintenance = accountpendingMaintenance[0];
+ // Confirm that the type of entity is displayed correctly
+ cy.findByText(upcomingMaintenance.entity.type).should('be.visible');
+ // Confirm that the label of entity is displayed correctly
+ cy.findByText(upcomingMaintenance.entity.label).should('be.visible');
+ // Confirm that the type of maintenance is displayed correctly
+ cy.findAllByText(capitalize(upcomingMaintenance.type)).should(
+ 'be.visible'
+ );
+ // Confirm that the status of maintenance is displayed correctly
+ cy.get('[aria-label="Status is active"]')
+ .parent()
+ .contains(capitalize(upcomingMaintenance.status))
+ .should('be.visible');
+ // Confirm that the reason of maintenance is displayed correctly
+ cy.findByText(upcomingMaintenance.reason, { exact: false }).should(
+ 'be.visible'
+ );
+ });
+
+ // Confirm Completed table is not empty and contains exact number of completed maintenances
+ cy.get('[aria-label="List of completed maintenance"]')
+ .should('be.visible')
+ .find('tbody')
+ .within(() => {
+ accountcompletedMaintenance.forEach(() => {
+ cy.get('tr')
+ .should('have.length', accountcompletedMaintenance.length)
+ .each((row, index) => {
+ const completedMaintenance = accountcompletedMaintenance[index];
+ cy.wrap(row).within(() => {
+ cy.contains(completedMaintenance.entity.label).should(
+ 'be.visible'
+ );
+ // Confirm that the first 90 characters of each reason string are rendered on screen
+ const truncatedReason = completedMaintenance.reason.substring(
+ 0,
+ 90
+ );
+ cy.findByText(truncatedReason, { exact: false }).should(
+ 'be.visible'
+ );
+ // Check the content of each element
+ cy.get('td').each(($cell, idx, $cells) => {
+ cy.wrap($cell).should('not.be.empty');
+ });
+ });
+ });
+ });
+ });
+
+ // Confirm that the notice is visible and contains the expected message
+ cy.findByText(NOTIFICATION_BANNER_TEXT, { exact: false })
+ .should('be.visible')
+ .closest('[data-testid="notice-warning"]')
+ .within(() => {
+ cy.get('p').then(($el) => {
+ const noticeText = $el.text();
+ expect(noticeText).to.include(noticeMessageShort);
+ });
+ });
+ cy.findByText(' upcoming', { exact: false })
+ .closest('[data-testid="notice-warning"]')
+ .should('be.visible')
+ .within(() => {
+ cy.get('p').then(($el) => {
+ const noticeText = $el.text();
+ expect(noticeText).to.include(
+ `${accountpendingMaintenance.length} Linode has upcoming scheduled maintenance.`
+ );
+ });
+ });
+
+ cy.get('button[aria-label="Notifications"]').click();
+ cy.findByText(notifications[0].message).should('be.visible');
+ });
+});
diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts
index 67f44a6b5a1..bd5f13f4f28 100644
--- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts
+++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts
@@ -23,7 +23,7 @@ describe('OneClick Apps (OCA)', () => {
cy.tag('method:e2e', 'env:marketplaceApps');
interceptGetStackScripts().as('getStackScripts');
- cy.visitWithLogin(`/linodes/create?type=One-Click`);
+ cy.visitWithLogin(`/linodes/create/marketplace`);
cy.wait('@getStackScripts').then((xhr) => {
const stackScripts: StackScript[] = xhr.response?.body.data ?? [];
@@ -60,7 +60,7 @@ describe('OneClick Apps (OCA)', () => {
cy.tag('method:e2e', 'env:marketplaceApps');
interceptGetStackScripts().as('getStackScripts');
- cy.visitWithLogin(`/linodes/create?type=One-Click`);
+ cy.visitWithLogin(`/linodes/create/marketplace`);
cy.wait('@getStackScripts').then((xhr) => {
const stackScripts: StackScript[] = xhr.response?.body.data ?? [];
@@ -171,7 +171,7 @@ describe('OneClick Apps (OCA)', () => {
mockGetStackScripts([stackscript]).as('getStackScripts');
mockGetStackScript(stackscript.id, stackscript);
- cy.visitWithLogin(`/linodes/create?type=One-Click`);
+ cy.visitWithLogin(`/linodes/create/marketplace`);
cy.wait('@getStackScripts');
@@ -253,7 +253,7 @@ describe('OneClick Apps (OCA)', () => {
cy.tag('method:e2e', 'env:marketplaceApps');
interceptGetStackScripts().as('getStackScripts');
- cy.visitWithLogin(`/linodes/create?type=One-Click`);
+ cy.visitWithLogin(`/linodes/create/marketplace`);
cy.wait('@getStackScripts').then((xhr) => {
// Check the content of the app list
diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts
index eda280ed210..26b17237855 100644
--- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts
+++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts
@@ -307,7 +307,7 @@ describe('Community Stackscripts integration tests', () => {
.click();
cy.url().should(
'endWith',
- `linodes/create?type=StackScripts&subtype=Community&stackScriptID=${stackScriptId}`
+ `linodes/create/stackscripts?subtype=Community&stackScriptID=${stackScriptId}`
);
});
@@ -331,7 +331,7 @@ describe('Community Stackscripts integration tests', () => {
.click();
cy.url().should(
'endWith',
- `linodes/create?type=StackScripts&subtype=Community&stackScriptID=${stackScriptId}`
+ `linodes/create/stackscripts?subtype=Community&stackScriptID=${stackScriptId}`
);
// Input VPN information
diff --git a/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts
index 34cfd8678b8..7f3374632d7 100644
--- a/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts
+++ b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts
@@ -50,7 +50,7 @@ describe('Search Volumes', () => {
cy.findByText(volume2.label).should('not.exist');
// Clear search, confirm both volumes are shown.
- cy.findByTestId('clear-volumes-search').click();
+ cy.findByLabelText('Clear').click();
cy.findByText(volume1.label).should('be.visible');
cy.findByText(volume2.label).should('be.visible');
diff --git a/packages/manager/cypress/support/constants/widgets.ts b/packages/manager/cypress/support/constants/widgets.ts
index 30c97edaf94..a169454b36f 100644
--- a/packages/manager/cypress/support/constants/widgets.ts
+++ b/packages/manager/cypress/support/constants/widgets.ts
@@ -55,7 +55,7 @@ export const widgetDetails = {
},
linode: {
dashboardName: 'Linode Dashboard',
- id: 1,
+ id: 2,
metrics: [
{
expectedAggregation: 'max',
@@ -98,4 +98,51 @@ export const widgetDetails = {
resource: 'linode-resource',
serviceType: 'linode',
},
+ nodebalancer: {
+ dashboardName: 'NodeBalancer Dashboard',
+ id: 3,
+ metrics: [
+ {
+ expectedAggregation: 'max',
+ expectedAggregationArray: ['sum'],
+ expectedGranularity: '1 hr',
+ name: 'system_cpu_utilization_percent',
+ title: 'CPU Utilization',
+ unit: '%',
+ yLabel: 'system_cpu_utilization_ratio',
+ },
+ {
+ expectedAggregation: 'max',
+ expectedAggregationArray: ['sum'],
+ expectedGranularity: '1 hr',
+ name: 'system_memory_usage_by_resource',
+ title: 'Memory Usage',
+ unit: 'B',
+ yLabel: 'system_memory_usage_bytes',
+ },
+ {
+ expectedAggregation: 'max',
+ expectedAggregationArray: ['sum'],
+ expectedGranularity: '1 hr',
+ name: 'system_network_io_by_resource',
+ title: 'Network Traffic',
+ unit: 'B',
+ yLabel: 'system_network_io_bytes_total',
+ },
+ {
+ expectedAggregation: 'max',
+ expectedAggregationArray: ['sum'],
+ expectedGranularity: '1 hr',
+ name: 'system_disk_OPS_total',
+ title: 'Disk I/O',
+ unit: 'OPS',
+ yLabel: 'system_disk_operations_total',
+ },
+ ],
+ region: 'Newark, NJ, USA (us-east)',
+ resource: 'NodeBalancer-resource',
+ serviceType: 'nodebalancer',
+ port: 1,
+ protocols: ['TCP', 'UDP'],
+ },
};
diff --git a/packages/manager/cypress/support/ui/constants.ts b/packages/manager/cypress/support/ui/constants.ts
index e4bb5251d6a..d2ef557df87 100644
--- a/packages/manager/cypress/support/ui/constants.ts
+++ b/packages/manager/cypress/support/ui/constants.ts
@@ -6,7 +6,7 @@ export const loadAppNoLogin = (path: string) => waitForAppLoad(path, false);
export const routes = {
account: '/account',
createLinode: '/linodes/create',
- createLinodeOCA: '/linodes/create?type=One-Click',
+ createLinodeOCA: '/linodes/create/marketplace',
linodeLanding: '/linodes',
profile: '/profile',
support: '/support',
@@ -49,7 +49,7 @@ export const pages: Page[] = [
},
],
name: 'Linode/Create/OS',
- url: `${routes.createLinode}?type=OS`,
+ url: `${routes.createLinode}/os`,
},
{
assertIsLoaded: () => cy.findByText('Select App').should('be.visible'),
@@ -89,18 +89,18 @@ export const pages: Page[] = [
{
assertIsLoaded: () => cy.findByText('Choose an Image').should('be.visible'),
name: 'Linode/Create/FromImages',
- url: `${routes.createLinode}?type=Images`,
+ url: `${routes.createLinode}/images`,
},
{
assertIsLoaded: () => cy.findByText('Select Backup').should('be.visible'),
name: 'Linode/Create/FromBackup',
- url: `${routes.createLinode}?type=Backups`,
+ url: `${routes.createLinode}/backups`,
},
{
assertIsLoaded: () =>
cy.findByText('Select Linode to Clone From').should('be.visible'),
name: 'Linode/Create/Clone',
- url: `${routes.createLinode}?type=Clone%20Linode`,
+ url: `${routes.createLinode}/clone`,
},
{
assertIsLoaded: () => cy.findByText('My Profile').should('be.visible'),
diff --git a/packages/manager/cypress/support/ui/locators/common-locators.ts b/packages/manager/cypress/support/ui/locators/common-locators.ts
index d904a5d2700..15fcc44ec42 100644
--- a/packages/manager/cypress/support/ui/locators/common-locators.ts
+++ b/packages/manager/cypress/support/ui/locators/common-locators.ts
@@ -29,7 +29,7 @@ export const topMenuCreateItemsLocator = {
/** Top menu create dropdown items Linodes Link. */
linodesLink: '[href="/linodes/create"]',
/** Top menu create dropdown items Marketplace(One-Click) Link. */
- marketplaceOneClickLink: '[href="/linodes/create?type=One-Click"]',
+ marketplaceOneClickLink: '[href="/linodes/create/marketplace"]',
/** Top menu create dropdown items NodeBalancers Link. */
nodeBalancersLink: '[href="/nodebalancers/create"]',
/** Top menu create dropdown items Volumes Link. */
diff --git a/packages/manager/package.json b/packages/manager/package.json
index c95de968d37..7004068c743 100644
--- a/packages/manager/package.json
+++ b/packages/manager/package.json
@@ -2,7 +2,7 @@
"name": "linode-manager",
"author": "Linode",
"description": "The Linode Manager website",
- "version": "1.147.0",
+ "version": "1.148.0",
"private": true,
"type": "module",
"bugs": {
diff --git a/packages/manager/public/assets/arangodb.svg b/packages/manager/public/assets/arangodb.svg
new file mode 100644
index 00000000000..57041d335d7
--- /dev/null
+++ b/packages/manager/public/assets/arangodb.svg
@@ -0,0 +1,33 @@
+
+
\ No newline at end of file
diff --git a/packages/manager/public/assets/memgraph.svg b/packages/manager/public/assets/memgraph.svg
new file mode 100644
index 00000000000..85f3162de92
--- /dev/null
+++ b/packages/manager/public/assets/memgraph.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/manager/public/assets/neo4j.svg b/packages/manager/public/assets/neo4j.svg
new file mode 100644
index 00000000000..f0e856064a8
--- /dev/null
+++ b/packages/manager/public/assets/neo4j.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/manager/public/assets/white/arangodb.svg b/packages/manager/public/assets/white/arangodb.svg
new file mode 100644
index 00000000000..8d288886c42
--- /dev/null
+++ b/packages/manager/public/assets/white/arangodb.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/manager/public/assets/white/memgraph.svg b/packages/manager/public/assets/white/memgraph.svg
new file mode 100644
index 00000000000..9472c1151c3
--- /dev/null
+++ b/packages/manager/public/assets/white/memgraph.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/manager/public/assets/white/neo4j.svg b/packages/manager/public/assets/white/neo4j.svg
new file mode 100644
index 00000000000..1bcc7c60ac6
--- /dev/null
+++ b/packages/manager/public/assets/white/neo4j.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx
index d3187103a34..8314f43cd16 100644
--- a/packages/manager/src/GoTo.tsx
+++ b/packages/manager/src/GoTo.tsx
@@ -101,7 +101,7 @@ export const GoTo = React.memo(() => {
{
display: 'Marketplace',
- href: '/linodes/create?type=One-Click',
+ href: '/linodes/create/marketplace',
},
{
display: 'Account',
diff --git a/packages/manager/src/assets/icons/docs.svg b/packages/manager/src/assets/icons/docs.svg
index 787f090ffd2..142cff7eaf0 100644
--- a/packages/manager/src/assets/icons/docs.svg
+++ b/packages/manager/src/assets/icons/docs.svg
@@ -1,5 +1,3 @@
-
+
\ No newline at end of file
diff --git a/packages/manager/src/assets/icons/external-link.svg b/packages/manager/src/assets/icons/external-link.svg
index 12218264094..54f1d8ae3ae 100644
--- a/packages/manager/src/assets/icons/external-link.svg
+++ b/packages/manager/src/assets/icons/external-link.svg
@@ -1,3 +1,3 @@
diff --git a/packages/manager/src/components/ActionMenu/ActionMenu.tsx b/packages/manager/src/components/ActionMenu/ActionMenu.tsx
index c51f734de97..c7d334871aa 100644
--- a/packages/manager/src/components/ActionMenu/ActionMenu.tsx
+++ b/packages/manager/src/components/ActionMenu/ActionMenu.tsx
@@ -1,4 +1,4 @@
-import { convertToKebabCase, TooltipIcon } from '@linode/ui';
+import { CircleProgress, convertToKebabCase, TooltipIcon } from '@linode/ui';
import { IconButton, ListItemText } from '@mui/material';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
@@ -23,6 +23,10 @@ export interface ActionMenuProps {
* Gives the Menu Button an accessible name
*/
ariaLabel: string;
+ /**
+ * If true, show a loading indicator
+ */
+ loading?: boolean;
/**
* A function that is called when the Menu is opened. Useful for analytics.
*/
@@ -40,7 +44,8 @@ export interface ActionMenuProps {
* No more than 8 items should be displayed within an action menu.
*/
export const ActionMenu = React.memo((props: ActionMenuProps) => {
- const { actionsList, ariaLabel, onOpen, stopClickPropagation } = props;
+ const { actionsList, ariaLabel, loading, onOpen, stopClickPropagation } =
+ props;
const menuId = convertToKebabCase(ariaLabel);
const buttonId = `${convertToKebabCase(ariaLabel)}-button`;
@@ -95,6 +100,8 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => {
aria-label={ariaLabel}
color="inherit"
id={buttonId}
+ loading={loading}
+ loadingIndicator={}
onClick={handleClick}
onKeyDown={handleKeyPress}
sx={(theme) => ({
@@ -112,66 +119,68 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => {
>
-
+ {!loading && (
+
+ )}
>
);
});
diff --git a/packages/manager/src/components/DocsLink/DocsLink.tsx b/packages/manager/src/components/DocsLink/DocsLink.tsx
index a01381d1cda..ec0e0d78efa 100644
--- a/packages/manager/src/components/DocsLink/DocsLink.tsx
+++ b/packages/manager/src/components/DocsLink/DocsLink.tsx
@@ -53,9 +53,8 @@ const StyledDocsLink = styled(Link, {
})(({ theme }) => ({
...theme.applyLinkStyles,
'& svg': {
- marginRight: theme.spacing(),
+ marginRight: theme.spacingFunction(4),
position: 'relative',
- top: -2,
},
alignItems: 'center',
display: 'flex',
diff --git a/packages/manager/src/components/Encryption/Encryption.tsx b/packages/manager/src/components/Encryption/Encryption.tsx
index 9290ff8f3f2..d0ee10a87e9 100644
--- a/packages/manager/src/components/Encryption/Encryption.tsx
+++ b/packages/manager/src/components/Encryption/Encryption.tsx
@@ -5,6 +5,8 @@ import type { JSX } from 'react';
import { checkboxTestId, descriptionTestId, headerTestId } from './constants';
+import type { SxProps, Theme } from '@mui/material/styles';
+
export interface EncryptionProps {
descriptionCopy: JSX.Element | string;
disabled?: boolean;
@@ -14,6 +16,7 @@ export interface EncryptionProps {
isEncryptEntityChecked: boolean;
notices?: string[];
onChange: (checked: boolean) => void;
+ sxCheckbox?: SxProps;
}
export const Encryption = (props: EncryptionProps) => {
@@ -26,6 +29,7 @@ export const Encryption = (props: EncryptionProps) => {
isEncryptEntityChecked,
notices,
onChange,
+ sxCheckbox,
} = props;
return (
@@ -57,6 +61,8 @@ export const Encryption = (props: EncryptionProps) => {
data-testid={checkboxTestId}
disabled={disabled}
onChange={(e, checked) => onChange(checked)}
+ sx={sxCheckbox}
+ sxFormLabel={{ marginLeft: '0px' }}
text={`Encrypt ${entityType ?? 'Disk'}`}
toolTipText={disabled ? disabledReason : ''}
/>
diff --git a/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.test.tsx b/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.test.tsx
index 3e4162196a0..407fd089762 100644
--- a/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.test.tsx
+++ b/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.test.tsx
@@ -1,4 +1,5 @@
-import { fireEvent, render } from '@testing-library/react';
+import { render } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { EnhancedNumberInput } from 'src/components/EnhancedNumberInput/EnhancedNumberInput';
@@ -17,48 +18,63 @@ const disabledProps = {
};
describe('EnhancedNumberInput', () => {
- it("should increment the input's value by 1 when the plus button is clicked", () => {
+ it("should increment the input's value by 1 when the plus button is clicked", async () => {
const { getByTestId } = render(
wrapWithTheme()
);
const addButton = getByTestId('increment-button');
- fireEvent.click(addButton);
+ await userEvent.click(addButton);
expect(setValue).toHaveBeenCalledWith(2);
});
- it("should decrement the input's value by 1 when the minus button is clicked", () => {
+ it("should decrement the input's value by 1 when the minus button is clicked", async () => {
const { getByTestId } = render(
wrapWithTheme()
);
const subtractButton = getByTestId('decrement-button');
- fireEvent.click(subtractButton);
+ await userEvent.click(subtractButton);
expect(setValue).toHaveBeenCalledWith(0);
});
- it('should update the input if the user manually adds numeric text', () => {
+ it('should update the input if the user manually adds numeric text', async () => {
const { getByTestId } = render(
wrapWithTheme()
);
const input = getByTestId('textfield-input');
- fireEvent.change(input, { target: { value: '100' } });
+ await userEvent.type(input, '100');
expect(setValue).toHaveBeenCalledWith(100);
});
- it('should set the value to 0 if the user inputs invalid data', () => {
+ it('should set the value to 0 if the user inputs invalid data', async () => {
const { getByTestId } = render(
wrapWithTheme()
);
const input = getByTestId('textfield-input');
- fireEvent.change(input, { target: { value: 'prestidigitation' } });
+ await userEvent.type(input, 'prestidigitation');
expect(setValue).toHaveBeenCalledWith(0);
});
+ const inputToPrevent = ['+', '--3', '.', 'e', 'E'];
+
+ it.each(inputToPrevent)(
+ 'prevents the use of special characters like "%s", setting the value to the min',
+ async (inputValue) => {
+ const { getByTestId } = render(
+ wrapWithTheme()
+ );
+
+ const input = getByTestId('textfield-input');
+ await userEvent.type(input, inputValue);
+ expect(setValue).toHaveBeenCalledWith(0);
+ }
+ );
+
it('should respect min values', () => {
const { getByTestId } = render(
wrapWithTheme()
diff --git a/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx b/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx
index 7172149e1ec..0174d6e5420 100644
--- a/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx
+++ b/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx
@@ -27,6 +27,12 @@ const sxTextField = {
maxWidth: 70,
};
+/**
+ * Using MUI's TextField component with type=number causes known issues, which MUI has documented: https://mui.com/material-ui/react-text-field/#type-quot-number-quot.
+ * Until MUI has a dedicated NumberInput component (https://github.com/mui/material-ui/issues/19154) or we redesign this one, this fixes the erroneous character issue.
+ */
+const charsToPrevent = ['+', '-', '.', 'e', 'E'];
+
interface EnhancedNumberInputProps {
/** Disables the input and the +/- buttons */
disabled?: boolean;
@@ -60,6 +66,12 @@ export const EnhancedNumberInput = React.memo(
const parsedValue = +e.target.value;
if (parsedValue >= min && parsedValue <= max) {
setValue(+e.target.value);
+ } else {
+ if (e.target.value === '' && value === min) {
+ setValue(0);
+ } else {
+ setValue(min);
+ }
}
};
@@ -105,6 +117,11 @@ export const EnhancedNumberInput = React.memo(
min={min}
name="Quantity"
onChange={onChange}
+ onKeyDown={(e) => {
+ if (charsToPrevent.includes(e.key)) {
+ e.preventDefault();
+ }
+ }}
sx={{
...sxTextField,
'.MuiInputBase-input': sxTextFieldBase,
diff --git a/packages/manager/src/components/Flag.tsx b/packages/manager/src/components/Flag.tsx
index 5653b3d2fa8..2cbf7b8a23c 100644
--- a/packages/manager/src/components/Flag.tsx
+++ b/packages/manager/src/components/Flag.tsx
@@ -1,6 +1,6 @@
import { Box } from '@linode/ui';
-import { styled } from '@mui/material/styles';
import 'flag-icons/css/flag-icons.min.css';
+import { styled } from '@mui/material/styles';
import React from 'react';
import type { Country } from '@linode/api-v4';
@@ -10,6 +10,13 @@ const COUNTRY_FLAG_OVERRIDES = {
uk: 'gb',
};
+// Countries that need a css border in the Flag component (countries that have DCs)
+const COUNTRIES_WITH_BORDERS = [
+ 'id', // indonesia
+ 'jp', // japan
+ 'sg', // singapore
+];
+
interface Props extends BoxProps {
country: Country;
}
@@ -23,6 +30,7 @@ export const Flag = (props: Props) => {
return (
);
@@ -37,10 +45,15 @@ const getFlagClass = (country: Country | string) => {
return country;
};
-const StyledFlag = styled(Box, { label: 'StyledFlag' })(({ theme }) => ({
+const StyledFlag = styled(Box, { label: 'StyledFlag' })<{
+ hasBorder: boolean;
+}>(({ theme, hasBorder }) => ({
boxShadow:
theme.palette.mode === 'light' ? `0px 0px 0px 1px #00000010` : undefined,
fontSize: '1.5rem',
verticalAlign: 'top',
width: '1.41rem',
+ ...(hasBorder && {
+ border: `1px solid ${theme.tokens.alias.Border.Normal}`,
+ }),
}));
diff --git a/packages/manager/src/components/ImageSelect/ImageOption.test.tsx b/packages/manager/src/components/ImageSelect/ImageOption.test.tsx
index b3380fee7cb..a24cb2df61f 100644
--- a/packages/manager/src/components/ImageSelect/ImageOption.test.tsx
+++ b/packages/manager/src/components/ImageSelect/ImageOption.test.tsx
@@ -65,7 +65,7 @@ describe('ImageOption', () => {
);
expect(
getByText(image.label).closest('li')?.getAttribute('aria-label')
- ).toBe('');
+ ).toBeNull();
});
it('renders (deprecated) if the image is deprecated', () => {
diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.tsx
index 744d5a335db..9229a075e08 100644
--- a/packages/manager/src/components/ImageSelect/ImageSelect.tsx
+++ b/packages/manager/src/components/ImageSelect/ImageSelect.tsx
@@ -210,6 +210,7 @@ export const ImageSelect = (props: Props) => {
rest.disableClearable ??
(selectIfOnlyOneOption && options.length === 1 && !multiple)
}
+ disabledItemsFocusable
errorText={rest.errorText ?? error?.[0].reason}
getOptionDisabled={(option) => Boolean(disabledImages[option.id])}
multiple={multiple}
diff --git a/packages/manager/src/components/ImageSelect/utilities.ts b/packages/manager/src/components/ImageSelect/utilities.ts
index e18797f7500..0b355706380 100644
--- a/packages/manager/src/components/ImageSelect/utilities.ts
+++ b/packages/manager/src/components/ImageSelect/utilities.ts
@@ -109,8 +109,7 @@ export const getDisabledImages = (options: DisabledImageOptions) => {
for (const image of images) {
if (!image.capabilities.includes('distributed-sites')) {
disabledImages[image.id] = {
- reason:
- 'The selected image cannot be deployed to a distributed region.',
+ reason: 'This image cannot be deployed to a distributed region.',
};
}
}
diff --git a/packages/manager/src/components/Link.styles.ts b/packages/manager/src/components/Link.styles.ts
index dbe1a1ee379..5e35642b325 100644
--- a/packages/manager/src/components/Link.styles.ts
+++ b/packages/manager/src/components/Link.styles.ts
@@ -14,8 +14,8 @@ export const useStyles = makeStyles()(
iconContainer: {
'& svg': {
color: theme.textColors.linkActiveLight,
- height: 12,
- width: 12,
+ height: 16,
+ width: 16,
},
color: theme.palette.primary.main,
display: 'inline-block',
@@ -25,10 +25,17 @@ export const useStyles = makeStyles()(
// nifty trick to avoid the icon from wrapping by itself after the last word
transform: 'translateX(18px)',
width: 14,
+ top: '3px',
},
root: {
alignItems: 'baseline',
color: theme.textColors.linkActiveLight,
+ '&:hover': {
+ color: theme.textColors.linkHover,
+ '& svg': {
+ color: theme.textColors.linkHover,
+ },
+ },
},
})
);
diff --git a/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx b/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx
index 1b4d5ea663f..3a3d2c775cf 100644
--- a/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx
+++ b/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx
@@ -109,7 +109,9 @@ export const MaintenancePolicySelect = (props: Props) => {
),
},
- tooltipText: (
+ tooltipText: disabled ? (
+ "You don't have permission to change this setting."
+ ) : (
Migrate: {MIGRATE_TOOLTIP_TEXT}
diff --git a/packages/manager/src/components/MaintenancePolicySelect/constants.ts b/packages/manager/src/components/MaintenancePolicySelect/constants.ts
index f09203b98b6..198693ce460 100644
--- a/packages/manager/src/components/MaintenancePolicySelect/constants.ts
+++ b/packages/manager/src/components/MaintenancePolicySelect/constants.ts
@@ -31,7 +31,7 @@ export const MAINTENANCE_POLICY_NOT_AVAILABLE_IN_REGION_TEXT =
'Maintenance policy is not available in the selected region.';
export const GPU_PLAN_NOTICE =
- 'GPU plan does not support live migration and will perform a warm migration and then cold migration as fallbacks.';
+ 'GPU plans do not support live migrations. Instead, when the migrate policy is selected, a warm migration is attempted first during maintenance events.';
export const UPCOMING_MAINTENANCE_NOTICE =
'Changes to this policy will not affect this existing planned maintenance event and, instead, will be applied to future maintenance events scheduled after the change is made.';
diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx
index baca2bc1224..2a89c34e545 100644
--- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx
+++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx
@@ -291,6 +291,11 @@ export const MultipleIPInput = React.memo((props: MultipeIPInputProps) => {
data-testid="button"
disabled={disabled}
onClick={() => removeInput(idx)}
+ sx={(theme) => ({
+ height: 20,
+ width: 20,
+ marginTop: `${theme.spacingFunction(8)} !important`,
+ })}
>
diff --git a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.test.tsx b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.test.tsx
index 35548d688c0..6fc3c569ed4 100644
--- a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.test.tsx
+++ b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.test.tsx
@@ -11,6 +11,19 @@ import { renderWithTheme } from 'src/utilities/testHelpers';
import { PaymentMethodRow } from './PaymentMethodRow';
+const queryMocks = vi.hoisted(() => ({
+ userPermissions: vi.fn(() => ({
+ data: {
+ make_billing_payment: false,
+ update_account: false,
+ },
+ })),
+}));
+
+vi.mock('src/features/IAM/hooks/usePermissions', () => ({
+ usePermissions: queryMocks.userPermissions,
+}));
+
vi.mock('@linode/api-v4/lib/account', async () => {
const actual = await vi.importActual('@linode/api-v4/lib/account');
return {
@@ -132,7 +145,12 @@ describe('Payment Method Row', () => {
it('Calls `onDelete` callback when "Delete" action is clicked', async () => {
const mockFunction = vi.fn();
-
+ queryMocks.userPermissions.mockReturnValue({
+ data: {
+ make_billing_payment: false,
+ update_account: true,
+ },
+ });
const { getByLabelText, getByText } = renderWithTheme(
{
});
it('Makes payment method default when "Make Default" action is clicked', async () => {
+ queryMocks.userPermissions.mockReturnValue({
+ data: {
+ make_billing_payment: true,
+ update_account: true,
+ },
+ });
const paymentMethod = paymentMethodFactory.build({
data: {
card_type: 'Visa',
@@ -177,6 +201,58 @@ describe('Payment Method Row', () => {
expect(makeDefaultPaymentMethod).toBeCalledTimes(1);
});
+ it('should disable "Make a Payment" button if the user does not have make_billing_payment permissions', async () => {
+ queryMocks.userPermissions.mockReturnValue({
+ data: {
+ make_billing_payment: false,
+ update_account: false,
+ },
+ });
+ const { getByLabelText, getByText } = renderWithTheme(
+
+
+
+ );
+
+ const actionMenu = getByLabelText('Action menu for card ending in 1881');
+ await userEvent.click(actionMenu);
+
+ const makePaymentButton = getByText('Make a Payment');
+ expect(makePaymentButton).toBeVisible();
+ expect(
+ makePaymentButton.closest('li')?.getAttribute('aria-disabled')
+ ).toEqual('true');
+ });
+
+ it('should enable "Make a Payment" button if the user has make_billing_payment permissions', async () => {
+ queryMocks.userPermissions.mockReturnValue({
+ data: {
+ make_billing_payment: true,
+ update_account: false,
+ },
+ });
+ const { getByLabelText, getByText } = renderWithTheme(
+
+
+
+ );
+
+ const actionMenu = getByLabelText('Action menu for card ending in 1881');
+ await userEvent.click(actionMenu);
+
+ const makePaymentButton = getByText('Make a Payment');
+ expect(makePaymentButton).toBeVisible();
+ expect(
+ makePaymentButton.closest('li')?.getAttribute('aria-disabled')
+ ).not.toEqual('true');
+ });
+
it('Opens "Make a Payment" drawer with the payment method preselected if "Make a Payment" action is clicked', async () => {
const paymentMethods = [
paymentMethodFactory.build({
diff --git a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx
index e323c598317..4a2f6fe98aa 100644
--- a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx
+++ b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx
@@ -7,6 +7,7 @@ import * as React from 'react';
import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';
import CreditCard from 'src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/CreditCard';
+import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
import { ThirdPartyPayment } from './ThirdPartyPayment';
@@ -18,10 +19,6 @@ interface Props {
* Whether the user is a child user.
*/
isChildUser?: boolean | undefined;
- /**
- * Whether the user is a restricted user.
- */
- isRestrictedUser?: boolean | undefined;
/**
* Function called when the delete button in the Action Menu is pressed.
*/
@@ -38,7 +35,7 @@ interface Props {
*/
export const PaymentMethodRow = (props: Props) => {
const theme = useTheme();
- const { isRestrictedUser, onDelete, paymentMethod } = props;
+ const { onDelete, paymentMethod, isChildUser } = props;
const { is_default, type } = paymentMethod;
const { enqueueSnackbar } = useSnackbar();
const navigate = useNavigate();
@@ -46,6 +43,11 @@ export const PaymentMethodRow = (props: Props) => {
const { mutateAsync: makePaymentMethodDefault } =
useMakeDefaultPaymentMethodMutation(props.paymentMethod.id);
+ const { data: permissions } = usePermissions('account', [
+ 'make_billing_payment',
+ 'update_account',
+ ]);
+
const makeDefault = () => {
makePaymentMethodDefault().catch((errors) =>
enqueueSnackbar(
@@ -57,7 +59,7 @@ export const PaymentMethodRow = (props: Props) => {
const actions: Action[] = [
{
- disabled: isRestrictedUser,
+ disabled: isChildUser || !permissions.make_billing_payment,
onClick: () => {
navigate({
to: '/account/billing',
@@ -71,7 +73,8 @@ export const PaymentMethodRow = (props: Props) => {
title: 'Make a Payment',
},
{
- disabled: isRestrictedUser || paymentMethod.is_default,
+ disabled:
+ isChildUser || !permissions.update_account || paymentMethod.is_default,
onClick: makeDefault,
title: 'Make Default',
tooltip: paymentMethod.is_default
@@ -79,7 +82,8 @@ export const PaymentMethodRow = (props: Props) => {
: undefined,
},
{
- disabled: isRestrictedUser || paymentMethod.is_default,
+ disabled:
+ isChildUser || !permissions.update_account || paymentMethod.is_default,
onClick: onDelete,
title: 'Delete',
tooltip: paymentMethod.is_default
diff --git a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx
index cd38e57d164..67b6b142598 100644
--- a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx
+++ b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx
@@ -100,7 +100,11 @@ export const PlacementGroupsSelect = (props: PlacementGroupsSelectProps) => {
clearOnBlur={true}
data-testid="placement-groups-select"
disabled={Boolean(!selectedRegion?.id) || disabled}
+ disabledItemsFocusable
errorText={error?.[0]?.reason}
+ getOptionDisabled={(placementGroup) =>
+ isDisabledPlacementGroup(placementGroup, selectedRegion)
+ }
getOptionLabel={(placementGroup: PlacementGroup) => placementGroup.label}
helperText={
!selectedRegion
diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx
index fd8375e6673..cfa12deb62d 100644
--- a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx
+++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx
@@ -227,6 +227,8 @@ describe('PrimaryNav', () => {
enabled: true,
},
aclpAlerting: {
+ accountAlertLimit: 10,
+ accountMetricLimit: 10,
alertDefinitions: true,
notificationChannels: false,
recentActivity: false,
@@ -266,6 +268,8 @@ describe('PrimaryNav', () => {
enabled: false,
},
aclpAlerting: {
+ accountAlertLimit: 10,
+ accountMetricLimit: 10,
alertDefinitions: true,
notificationChannels: true,
recentActivity: true,
@@ -304,6 +308,8 @@ describe('PrimaryNav', () => {
enabled: true,
},
aclpAlerting: {
+ accountAlertLimit: 10,
+ accountMetricLimit: 10,
alertDefinitions: false,
notificationChannels: false,
recentActivity: false,
diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx
index cbc9573592e..1ad111ebac0 100644
--- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx
+++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx
@@ -164,7 +164,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => {
{
attr: { 'data-qa-one-click-nav-btn': true },
display: 'Marketplace',
- href: '/linodes/create?type=One-Click',
+ href: '/linodes/create/marketplace',
},
],
name: 'Compute',
diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx
index d137cef6064..016fa3471c3 100644
--- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx
+++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx
@@ -110,6 +110,7 @@ export const RegionSelect = <
data-testid="region-select"
disableClearable={disableClearable}
disabled={disabled}
+ disabledItemsFocusable
errorText={errorText}
filterOptions={filterOptions}
getOptionDisabled={(option) => Boolean(disabledRegions[option.id])}
@@ -126,7 +127,7 @@ export const RegionSelect = <
onChange={onChange}
options={regionOptions}
placeholder={placeholder ?? 'Select a Region'}
- renderOption={(props, region, { selected }) => {
+ renderOption={(props, region, state) => {
const { key, ...rest } = props;
return (
@@ -136,7 +137,7 @@ export const RegionSelect = <
item={region}
key={`${region.id}-${key}`}
props={rest}
- selected={selected}
+ selected={state.selected}
/>
);
}}
diff --git a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.test.tsx b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.test.tsx
index 1d06b176d32..3af10cf7b5c 100644
--- a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.test.tsx
+++ b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.test.tsx
@@ -15,7 +15,7 @@ const testId = 'select-firewall-panel';
const queryMocks = vi.hoisted(() => ({
usePermissions: vi.fn(() => ({
- permissions: { delete_firewall: true, update_firewall: true },
+ data: { delete_firewall: true, update_firewall: true },
})),
useQueryWithPermissions: vi.fn().mockReturnValue({
data: [],
diff --git a/packages/manager/src/factories/datastream.ts b/packages/manager/src/factories/datastream.ts
index 70d63b12a26..3a4314c7386 100644
--- a/packages/manager/src/factories/datastream.ts
+++ b/packages/manager/src/factories/datastream.ts
@@ -15,11 +15,18 @@ export const destinationFactory = Factory.Sync.makeFactory({
id: Factory.each((id) => id),
label: Factory.each((id) => `Destination ${id}`),
type: destinationType.LinodeObjectStorage,
+ version: '1.0',
+ updated: '2025-07-30',
+ updated_by: 'username',
+ created: '2025-07-30',
+ created_by: 'username',
});
export const streamFactory = Factory.Sync.makeFactory({
created_by: 'username',
- destinations: [destinationFactory.build({ id: 1, label: 'Destination 1' })],
+ destinations: Factory.each(() => [
+ { ...destinationFactory.build(), id: 123 },
+ ]),
details: {},
updated: '2025-07-30',
updated_by: 'username',
diff --git a/packages/manager/src/factories/kubernetesCluster.ts b/packages/manager/src/factories/kubernetesCluster.ts
index f9d6947a434..cf976e9b138 100644
--- a/packages/manager/src/factories/kubernetesCluster.ts
+++ b/packages/manager/src/factories/kubernetesCluster.ts
@@ -3,7 +3,6 @@ import { Factory } from '@linode/utilities';
import type {
ControlPlaneACLOptions,
KubeNodePoolResponse,
- KubeNodePoolResponseBeta,
KubernetesCluster,
KubernetesControlPlaneACLPayload,
KubernetesDashboardResponse,
@@ -41,32 +40,6 @@ export const nodePoolFactory = Factory.Sync.makeFactory({
type: 'g6-standard-1',
});
-export const nodePoolBetaFactory =
- Factory.Sync.makeFactory({
- autoscaler: {
- enabled: false,
- max: 1,
- min: 1,
- },
- count: 3,
- disk_encryption: 'enabled',
- id: Factory.each((id) => id),
- labels: {},
- nodes: kubeLinodeFactory.buildList(3),
- tags: [],
- taints: [
- {
- effect: 'NoExecute',
- key: 'example.com/my-app',
- value: 'my-taint',
- },
- ],
- type: 'g6-standard-1',
- firewall_id: 0,
- k8s_version: 'v1.31.1+lke4',
- update_strategy: 'on_recycle',
- });
-
export const kubernetesClusterFactory =
Factory.Sync.makeFactory({
control_plane: { high_availability: true },
diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts
index 52d733759b2..c68b06b8d1f 100644
--- a/packages/manager/src/featureFlags.ts
+++ b/packages/manager/src/featureFlags.ts
@@ -86,7 +86,6 @@ export interface CloudPulseResourceTypeMapFlag {
dimensionKey: string;
maxResourceSelections?: number;
serviceType: string;
- supportedRegionIds?: string;
}
interface GpuV2 {
@@ -106,6 +105,8 @@ interface DesignUpdatesBannerFlag extends BaseFeatureFlag {
}
interface AclpAlerting {
+ accountAlertLimit: number;
+ accountMetricLimit: number;
alertDefinitions: boolean;
notificationChannels: boolean;
recentActivity: boolean;
diff --git a/packages/manager/src/features/Account/AccountLanding.test.tsx b/packages/manager/src/features/Account/AccountLanding.test.tsx
new file mode 100644
index 00000000000..09bcc926a22
--- /dev/null
+++ b/packages/manager/src/features/Account/AccountLanding.test.tsx
@@ -0,0 +1,39 @@
+import * as React from 'react';
+
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { AccountLanding } from './AccountLanding';
+
+const queryMocks = vi.hoisted(() => ({
+ userPermissions: vi.fn(() => ({
+ data: { make_billing_payment: false },
+ })),
+}));
+
+vi.mock('src/features/IAM/hooks/usePermissions', () => ({
+ usePermissions: queryMocks.userPermissions,
+}));
+
+describe('AccountLanding', () => {
+ it('should disable "Make a Payment" button if the user does not have make_billing_payment permission', async () => {
+ const { getByRole } = renderWithTheme();
+
+ const addTagBtn = getByRole('button', {
+ name: 'Make a Payment',
+ });
+ expect(addTagBtn).toHaveAttribute('aria-disabled', 'true');
+ });
+
+ it('should enable "Make a Payment" button if the user has make_billing_payment permission', async () => {
+ queryMocks.userPermissions.mockReturnValue({
+ data: { make_billing_payment: true },
+ });
+
+ const { getByRole } = renderWithTheme();
+
+ const addTagBtn = getByRole('button', {
+ name: 'Make a Payment',
+ });
+ expect(addTagBtn).not.toHaveAttribute('aria-disabled', 'true');
+ });
+});
diff --git a/packages/manager/src/features/Account/AccountLanding.tsx b/packages/manager/src/features/Account/AccountLanding.tsx
index f31bf88f84c..3c8f658fa1a 100644
--- a/packages/manager/src/features/Account/AccountLanding.tsx
+++ b/packages/manager/src/features/Account/AccountLanding.tsx
@@ -23,6 +23,7 @@ import { useTabs } from 'src/hooks/useTabs';
import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics';
import { PlatformMaintenanceBanner } from '../../components/PlatformMaintenanceBanner/PlatformMaintenanceBanner';
+import { usePermissions } from '../IAM/hooks/usePermissions';
import { SwitchAccountButton } from './SwitchAccountButton';
import { SwitchAccountDrawer } from './SwitchAccountDrawer';
@@ -38,6 +39,10 @@ export const AccountLanding = () => {
const { data: profile } = useProfile();
const { limitsEvolution } = useFlags();
+ const { data: permissions } = usePermissions('account', [
+ 'make_billing_payment',
+ ]);
+
const [isDrawerOpen, setIsDrawerOpen] = React.useState(false);
const sessionContext = React.useContext(switchAccountSessionContext);
@@ -55,11 +60,7 @@ export const AccountLanding = () => {
const showQuotasTab = limitsEvolution?.enabled ?? false;
- const isReadOnly =
- useRestrictedGlobalGrantCheck({
- globalGrantType: 'account_access',
- permittedGrantLevel: 'read_write',
- }) || isChildUser;
+ const isReadOnly = !permissions.make_billing_payment || isChildUser;
const isChildAccountAccessRestricted = useRestrictedGlobalGrantCheck({
globalGrantType: 'child_account_access',
diff --git a/packages/manager/src/features/Account/AutoBackups.tsx b/packages/manager/src/features/Account/AutoBackups.tsx
index cf5609ced2c..32832cd22f0 100644
--- a/packages/manager/src/features/Account/AutoBackups.tsx
+++ b/packages/manager/src/features/Account/AutoBackups.tsx
@@ -11,6 +11,8 @@ import { makeStyles } from 'tss-react/mui';
import { Link } from 'src/components/Link';
+import { usePermissions } from '../IAM/hooks/usePermissions';
+
import type { Theme } from '@mui/material/styles';
const useStyles = makeStyles()((theme: Theme) => ({
@@ -47,7 +49,9 @@ const AutoBackups = (props: Props) => {
} = props;
const { classes } = useStyles();
-
+ const { data: permissions } = usePermissions('account', [
+ 'update_account_settings',
+ ]);
return (
Backup Auto Enrollment
@@ -77,7 +81,9 @@ const AutoBackups = (props: Props) => {
}
diff --git a/packages/manager/src/features/Account/CloseAccountSetting.test.tsx b/packages/manager/src/features/Account/CloseAccountSetting.test.tsx
index cfc3b1cce39..debc63e48fc 100644
--- a/packages/manager/src/features/Account/CloseAccountSetting.test.tsx
+++ b/packages/manager/src/features/Account/CloseAccountSetting.test.tsx
@@ -14,8 +14,14 @@ import {
// Mock the useProfile hook to immediately return the expected data, circumventing the HTTP request and loading state.
const queryMocks = vi.hoisted(() => ({
useProfile: vi.fn().mockReturnValue({}),
+ userPermissions: vi.fn(() => ({
+ data: { cancel_account: true },
+ })),
}));
+vi.mock('src/features/IAM/hooks/usePermissions', () => ({
+ usePermissions: queryMocks.userPermissions,
+}));
vi.mock('@linode/queries', async () => {
const actual = await vi.importActual('@linode/queries');
return {
@@ -104,4 +110,18 @@ describe('Close Account Settings', () => {
expect(button).not.toHaveAttribute('disabled');
expect(button).toHaveAttribute('aria-disabled', 'true');
});
+
+ it('should disable Close Account button if the user does not have close_account permissions', async () => {
+ queryMocks.userPermissions.mockReturnValue({
+ data: { cancel_account: false },
+ });
+ queryMocks.useProfile.mockReturnValue({
+ data: profileFactory.build({ user_type: 'default' }),
+ });
+
+ const { getByTestId } = renderWithTheme();
+ const button = getByTestId('close-account-button');
+ expect(button).toBeInTheDocument();
+ expect(button).toBeDisabled();
+ });
});
diff --git a/packages/manager/src/features/Account/CloseAccountSetting.tsx b/packages/manager/src/features/Account/CloseAccountSetting.tsx
index a822d414393..7f8b6fdc955 100644
--- a/packages/manager/src/features/Account/CloseAccountSetting.tsx
+++ b/packages/manager/src/features/Account/CloseAccountSetting.tsx
@@ -2,6 +2,7 @@ import { useProfile } from '@linode/queries';
import { Box, Button, Paper, Typography } from '@linode/ui';
import * as React from 'react';
+import { usePermissions } from '../IAM/hooks/usePermissions';
import CloseAccountDialog from './CloseAccountDialog';
import {
CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT,
@@ -9,11 +10,13 @@ import {
PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT,
} from './constants';
-const CloseAccountSetting = () => {
+export const CloseAccountSetting = () => {
const [dialogOpen, setDialogOpen] = React.useState(false);
const { data: profile } = useProfile();
+ const { data: permissions } = usePermissions('account', ['cancel_account']);
+
// Disable the Close Account button for users with a Parent/Proxy/Child user type.
const isCloseAccountDisabled = Boolean(profile?.user_type !== 'default');
@@ -40,9 +43,13 @@ const CloseAccountSetting = () => {
diff --git a/packages/manager/src/features/Account/DefaultFirewalls.test.tsx b/packages/manager/src/features/Account/DefaultFirewalls.test.tsx
index fafad2e3d56..e71a5ea92eb 100644
--- a/packages/manager/src/features/Account/DefaultFirewalls.test.tsx
+++ b/packages/manager/src/features/Account/DefaultFirewalls.test.tsx
@@ -11,6 +11,17 @@ import { renderWithTheme } from 'src/utilities/testHelpers';
import { DefaultFirewalls } from './DefaultFirewalls';
+const queryMocks = vi.hoisted(() => ({
+ useProfile: vi.fn().mockReturnValue({}),
+ userPermissions: vi.fn(() => ({
+ data: { update_account_settings: true },
+ })),
+}));
+
+vi.mock('src/features/IAM/hooks/usePermissions', () => ({
+ usePermissions: queryMocks.userPermissions,
+}));
+
describe('NetworkInterfaces', () => {
it('renders the NetworkInterfaces section', async () => {
const account = accountFactory.build({
@@ -50,4 +61,50 @@ describe('NetworkInterfaces', () => {
expect(getByText('NodeBalancers Firewall')).toBeVisible();
expect(getByText('Save')).toBeVisible();
});
+
+ it('should disable Save button and all select boxes if the user does not have "update_account_settings" permissions', async () => {
+ queryMocks.userPermissions.mockReturnValue({
+ data: { update_account_settings: false },
+ });
+ const account = accountFactory.build({
+ capabilities: ['Linode Interfaces'],
+ });
+
+ server.use(
+ http.get('*/v4/account', () => HttpResponse.json(account)),
+ http.get('*/v4beta/networking/firewalls/settings', () =>
+ HttpResponse.json(firewallSettingsFactory.build())
+ ),
+ http.get('*/v4beta/networking/firewalls', () =>
+ HttpResponse.json(makeResourcePage(firewallFactory.buildList(1)))
+ )
+ );
+
+ const { getByLabelText, getByText } = renderWithTheme(
+ ,
+ {
+ flags: { linodeInterfaces: { enabled: true } },
+ }
+ );
+
+ const configurationSelect = getByLabelText(
+ 'Configuration Profile Interfaces Firewall'
+ );
+ expect(configurationSelect).toHaveAttribute('disabled');
+
+ const linodePublicSelect = getByLabelText(
+ 'Linode Interfaces - Public Interface Firewall'
+ );
+ expect(linodePublicSelect).toHaveAttribute('disabled');
+
+ const linodeVPCSelect = getByLabelText(
+ 'Linode Interfaces - VPC Interface Firewall'
+ );
+ expect(linodeVPCSelect).toHaveAttribute('disabled');
+
+ const nodeBalancerSelect = getByLabelText('NodeBalancers Firewall');
+ expect(nodeBalancerSelect).toHaveAttribute('disabled');
+
+ expect(getByText('Save')).toHaveAttribute('aria-disabled', 'true');
+ });
});
diff --git a/packages/manager/src/features/Account/DefaultFirewalls.tsx b/packages/manager/src/features/Account/DefaultFirewalls.tsx
index c80292a0ee1..84af676e298 100644
--- a/packages/manager/src/features/Account/DefaultFirewalls.tsx
+++ b/packages/manager/src/features/Account/DefaultFirewalls.tsx
@@ -22,6 +22,7 @@ import { Controller, useForm } from 'react-hook-form';
import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes';
import { FirewallSelect } from '../Firewalls/components/FirewallSelect';
+import { usePermissions } from '../IAM/hooks/usePermissions';
import type { UpdateFirewallSettings } from '@linode/api-v4';
@@ -38,7 +39,9 @@ export const DefaultFirewalls = () => {
} = useFirewallSettingsQuery({ enabled: isLinodeInterfacesEnabled });
const { mutateAsync: updateFirewallSettings } = useMutateFirewallSettings();
-
+ const { data: permissions } = usePermissions('account', [
+ 'update_account_settings',
+ ]);
const values = {
default_firewall_ids: { ...firewallSettings?.default_firewall_ids },
};
@@ -117,6 +120,7 @@ export const DefaultFirewalls = () => {
render={({ field, fieldState }) => (
{
render={({ field, fieldState }) => (
{
render={({ field, fieldState }) => (
{
render={({ field, fieldState }) => (
{
({ marginTop: theme.spacingFunction(16) })}>
+ >
);
};
diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.test.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.test.tsx
new file mode 100644
index 00000000000..7f2bd1e34ce
--- /dev/null
+++ b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.test.tsx
@@ -0,0 +1,104 @@
+import userEvent from '@testing-library/user-event';
+import * as React from 'react';
+
+import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers';
+
+import { ClusterNetworkingPanel } from './ClusterNetworkingPanel';
+
+const queryMocks = vi.hoisted(() => ({
+ useRegionQuery: vi.fn().mockReturnValue({ data: { capabilities: ['VPCs'] } }),
+ useAllVPCsQuery: vi.fn().mockReturnValue({ data: [], isLoading: false }),
+ useIsLkeEnterpriseEnabled: vi.fn(() => ({
+ isLkeEnterprisePhase2FeatureEnabled: true,
+ })),
+}));
+
+vi.mock('@linode/queries', async () => {
+ const actual = await vi.importActual('@linode/queries');
+ return {
+ ...actual,
+ useRegionQuery: queryMocks.useRegionQuery,
+ useAllVPCsQuery: queryMocks.useAllVPCsQuery,
+ };
+});
+
+vi.mock('../kubeUtils', async () => {
+ const actual = await vi.importActual('../kubeUtils');
+ return {
+ ...actual,
+ useIsLkeEnterpriseEnabled: queryMocks.useIsLkeEnterpriseEnabled,
+ };
+});
+
+const props = {
+ selectedRegionId: 'us-east',
+};
+
+const defaultValues = {
+ stack_type: 'ipv4',
+ nodePools: [],
+};
+
+describe('ClusterNetworkingPanel', () => {
+ it('renders IP version options and VPC radio buttons when LKE-E phase2MTC feature is enabled', () => {
+ const { getByText } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: {
+ defaultValues,
+ },
+ });
+
+ // Confirm stack type section
+ expect(getByText('IP Version')).toBeVisible();
+ expect(getByText('IPv4')).toBeVisible();
+ expect(getByText('IPv4 + IPv6')).toBeVisible();
+
+ // Confirm VPC section
+ expect(getByText('VPC')).toBeVisible();
+ expect(
+ getByText('Automatically generate a VPC for this cluster')
+ ).toBeVisible();
+ expect(getByText('Use an existing VPC')).toBeVisible();
+ });
+
+ it('selects correct default values for radio buttons', () => {
+ const { getByRole } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: {
+ defaultValues,
+ },
+ });
+
+ // Confirm stack type default
+ expect(getByRole('radio', { name: 'IPv4' })).toBeChecked();
+ expect(getByRole('radio', { name: 'IPv4 + IPv6' })).not.toBeChecked();
+
+ // Confirm VPC default
+ expect(
+ getByRole('radio', {
+ name: 'Automatically generate a VPC for this cluster',
+ })
+ ).toBeChecked();
+
+ expect(
+ getByRole('radio', {
+ name: 'Use an existing VPC',
+ })
+ ).not.toBeChecked();
+ });
+
+ it('shows VPC and Subnet fields when "Use an existing VPC" is selected', async () => {
+ const { getByLabelText } = renderWithThemeAndHookFormContext({
+ component: ,
+ useFormOptions: {
+ defaultValues,
+ },
+ });
+
+ await userEvent.click(getByLabelText('Use an existing VPC'));
+
+ // Confirm VPC options display
+ expect(getByLabelText('VPC')).toBeVisible();
+ expect(getByLabelText('Subnet')).toBeVisible();
+ });
+});
diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx
index 232b5e30cba..5bdfa0639c5 100644
--- a/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx
+++ b/packages/manager/src/features/Kubernetes/CreateCluster/ClusterNetworkingPanel.tsx
@@ -1,8 +1,189 @@
-import { Stack, Typography } from '@linode/ui';
+import { useAllVPCsQuery, useRegionQuery } from '@linode/queries';
+import {
+ Autocomplete,
+ Divider,
+ FormControlLabel,
+ Radio,
+ RadioGroup,
+ Stack,
+ Typography,
+} from '@linode/ui';
import React from 'react';
+import { Controller, useFormContext, useWatch } from 'react-hook-form';
-export const ClusterNetworkingPanel = () => {
- return (
+import { FormLabel } from 'src/components/FormLabel';
+import { REGION_CAVEAT_HELPER_TEXT } from 'src/features/VPCs/constants';
+
+import { useIsLkeEnterpriseEnabled } from '../kubeUtils';
+
+interface Props {
+ selectedRegionId: string | undefined;
+ subnetErrorText?: string;
+ vpcErrorText?: string;
+}
+
+export const ClusterNetworkingPanel = (props: Props) => {
+ const { selectedRegionId, vpcErrorText, subnetErrorText } = props;
+
+ const [isUsingOwnVpc, setIsUsingOwnVpc] = React.useState(false);
+
+ const { isLkeEnterprisePhase2FeatureEnabled } = useIsLkeEnterpriseEnabled();
+
+ const { control, resetField, clearErrors } = useFormContext();
+ const [selectedVPCId] = useWatch({
+ control,
+ name: ['vpc_id'],
+ });
+
+ const { data: region } = useRegionQuery(selectedRegionId ?? '');
+ const regionSupportsVPCs = region?.capabilities.includes('VPCs') ?? false;
+
+ const {
+ data: vpcs,
+ error,
+ isLoading,
+ } = useAllVPCsQuery({
+ enabled: regionSupportsVPCs,
+ filter: { region: selectedRegionId },
+ });
+ const selectedVPC = vpcs?.find((vpc) => vpc.id === selectedVPCId);
+
+ return isLkeEnterprisePhase2FeatureEnabled ? (
+ } spacing={3}>
+ (
+ field.onChange(e.target.value)}
+ value={field.value ?? null}
+ >
+ IP Version
+ } label="IPv4" value="ipv4" />
+ }
+ label="IPv4 + IPv6"
+ value="ipv4-ipv6"
+ />
+
+ )}
+ />
+
+ ({
+ font: theme.tokens.alias.Typography.Label.Bold.S,
+ })}
+ >
+ VPC
+
+
+ Allow for private communications within and across clusters in the
+ same data center.
+
+ ) => {
+ setIsUsingOwnVpc(e.target.value === 'yes');
+
+ if (!isUsingOwnVpc) {
+ clearErrors(['vpc_id', 'subnet_id']);
+ }
+ }}
+ value={isUsingOwnVpc}
+ >
+ }
+ label="Automatically generate a VPC for this cluster"
+ value="no"
+ />
+ }
+ label="Use an existing VPC"
+ value="yes"
+ />
+
+
+ {isUsingOwnVpc && (
+
+ (
+ {
+ field.onChange(vpc?.id ?? null);
+ resetField('subnet_id');
+ }}
+ options={vpcs ?? []}
+ placeholder="Select a VPC"
+ textFieldProps={{
+ tooltipText: REGION_CAVEAT_HELPER_TEXT,
+ }}
+ value={selectedVPC ?? null}
+ />
+ )}
+ rules={{
+ validate: (value) => {
+ if (isUsingOwnVpc && !value) {
+ return 'You must either select a VPC or select automatic VPC generation.';
+ }
+ return true;
+ },
+ }}
+ />
+ (
+
+ `${subnet.label} (${subnet.ipv4})`
+ }
+ label="Subnet"
+ loading={isLoading}
+ noMarginTop
+ onBlur={field.onBlur}
+ onChange={(e, subnet) => field.onChange(subnet?.id ?? null)}
+ options={selectedVPC?.subnets ?? []}
+ placeholder="None"
+ value={
+ selectedVPC?.subnets.find((s) => s.id === field.value) ??
+ null
+ }
+ />
+ )}
+ />
+
+ )}
+
+
+ ) : (
VPC & Firewall
diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx
index b9bc7ba15a2..fab84c92d8b 100644
--- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx
+++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx
@@ -79,18 +79,21 @@ import { NodePoolPanel } from './NodePoolPanel';
import type { NodePoolConfigDrawerMode } from '../KubernetesPlansPanel/NodePoolConfigDrawer';
import type {
+ APIError,
CreateKubeClusterPayload,
- CreateNodePoolDataBeta,
- KubeNodePoolResponseBeta,
+ CreateNodePoolData,
+ KubernetesStackType,
KubernetesTier,
-} from '@linode/api-v4/lib/kubernetes';
-import type { Region } from '@linode/api-v4/lib/regions';
-import type { APIError } from '@linode/api-v4/lib/types';
+ Region,
+} from '@linode/api-v4';
import type { ExtendedIP } from 'src/utilities/ipUtils';
-type FormValues = {
- nodePools: KubeNodePoolResponseBeta[];
-};
+export interface CreateClusterFormValues {
+ nodePools: CreateNodePoolData[];
+ stack_type: KubernetesStackType | null;
+ subnet_id?: number;
+ vpc_id?: number;
+}
export interface NodePoolConfigDrawerHandlerParams {
drawerMode: NodePoolConfigDrawerMode;
@@ -141,13 +144,22 @@ export const CreateCluster = () => {
const [selectedType, setSelectedType] = React.useState();
const [selectedPoolIndex, setSelectedPoolIndex] = React.useState();
+ const {
+ isLkeEnterpriseLAFeatureEnabled,
+ isLkeEnterpriseLAFlagEnabled,
+ isLkeEnterprisePhase2FeatureEnabled,
+ } = useIsLkeEnterpriseEnabled();
+
// Use React Hook Form for node pools to make updating pools and their configs easier.
// TODO - Future: use RHF for the rest of the form and replace FormValues with CreateKubeClusterPayload.
- const { control, ...form } = useForm({
- defaultValues: {
- nodePools: [],
- },
- });
+ const { control, trigger, formState, ...form } =
+ useForm({
+ defaultValues: {
+ nodePools: [],
+ stack_type: isLkeEnterprisePhase2FeatureEnabled ? 'ipv4' : null,
+ },
+ shouldUnregister: true,
+ });
const nodePools = useWatch({ control, name: 'nodePools' });
const { update } = useFieldArray({
control,
@@ -227,9 +239,6 @@ export const CreateCluster = () => {
const { mutateAsync: createKubernetesClusterBeta } =
useCreateKubernetesClusterBetaMutation();
- const { isLkeEnterpriseLAFeatureEnabled, isLkeEnterpriseLAFlagEnabled } =
- useIsLkeEnterpriseEnabled();
-
const {
isLoadingVersions,
versions: versionData,
@@ -270,7 +279,11 @@ export const CreateCluster = () => {
const node_pools = nodePools.map(
pick(['type', 'count', 'update_strategy'])
- ) as CreateNodePoolDataBeta[];
+ ) as CreateNodePoolData[];
+
+ const vpcId = form.getValues('vpc_id');
+ const subnetId = form.getValues('subnet_id');
+ const stackType = form.getValues('stack_type');
const _ipv4 = ipV4Addr
.map((ip) => {
@@ -320,10 +333,31 @@ export const CreateCluster = () => {
payload = { ...payload, tier: selectedTier };
}
+ if (isLkeEnterprisePhase2FeatureEnabled) {
+ payload = {
+ ...payload,
+ vpc_id: vpcId,
+ subnet_id: subnetId,
+ stack_type: stackType ?? undefined,
+ };
+ }
+
const createClusterFn = isUsingBetaEndpoint
? createKubernetesClusterBeta
: createKubernetesCluster;
+ // TODO: Improve error handling in M3-10429, at which point we shouldn't need this.
+ if (isLkeEnterprisePhase2FeatureEnabled && selectedTier === 'enterprise') {
+ // Trigger the React Hook Form validation for BYO VPC selection.
+ const isValid = await trigger();
+ // Don't submit the form while RHF errors persist.
+ if (!isValid) {
+ setSubmitting(false);
+ scrollErrorIntoViewV2(formContainerRef);
+ return;
+ }
+ }
+
// Since ACL is enabled by default for LKE-E clusters, run validation on the ACL IP Address fields if the acknowledgement is not explicitly checked.
if (selectedTier === 'enterprise' && !isACLAcknowledgementChecked) {
try {
@@ -379,6 +413,8 @@ export const CreateCluster = () => {
'k8s_version',
'versionLoad',
'control_plane',
+ 'vpc_id',
+ 'subnet_id',
],
errors
);
@@ -404,7 +440,12 @@ export const CreateCluster = () => {
}
return (
-
+
{
)}
@@ -555,7 +596,13 @@ export const CreateCluster = () => {
/>
)}
- {selectedTier === 'enterprise' && }
+ {selectedTier === 'enterprise' && (
+
+ )}
<>
{
component: ,
useFormOptions: {
defaultValues: {
- nodePools: [nodePoolBetaFactory.build()],
+ nodePools: [nodePoolFactory.build()],
},
},
});
@@ -63,7 +60,7 @@ describe('KubeCheckoutBar', () => {
component: ,
useFormOptions: {
defaultValues: {
- nodePools: [nodePoolBetaFactory.build()],
+ nodePools: [nodePoolFactory.build()],
},
},
});
@@ -76,7 +73,7 @@ describe('KubeCheckoutBar', () => {
component: ,
useFormOptions: {
defaultValues: {
- nodePools: [nodePoolBetaFactory.build()],
+ nodePools: [nodePoolFactory.build()],
},
},
});
@@ -88,7 +85,7 @@ describe('KubeCheckoutBar', () => {
component: ,
useFormOptions: {
defaultValues: {
- nodePools: [nodePoolBetaFactory.build()],
+ nodePools: [nodePoolFactory.build()],
},
},
});
@@ -112,7 +109,7 @@ describe('KubeCheckoutBar', () => {
component: ,
useFormOptions: {
defaultValues: {
- nodePools: [nodePoolBetaFactory.build()],
+ nodePools: [nodePoolFactory.build()],
},
},
});
@@ -125,7 +122,7 @@ describe('KubeCheckoutBar', () => {
component: ,
useFormOptions: {
defaultValues: {
- nodePools: [nodePoolBetaFactory.build()],
+ nodePools: [nodePoolFactory.build()],
},
},
});
@@ -139,7 +136,7 @@ describe('KubeCheckoutBar', () => {
component: ,
useFormOptions: {
defaultValues: {
- nodePools: [nodePoolBetaFactory.build()],
+ nodePools: [nodePoolFactory.build()],
},
},
});
@@ -153,7 +150,7 @@ describe('KubeCheckoutBar', () => {
component: ,
useFormOptions: {
defaultValues: {
- nodePools: [nodePoolBetaFactory.build()],
+ nodePools: [nodePoolFactory.build()],
},
},
});
@@ -174,7 +171,7 @@ describe('KubeCheckoutBar', () => {
),
useFormOptions: {
defaultValues: {
- nodePools: [nodePoolBetaFactory.build()],
+ nodePools: [nodePoolFactory.build()],
},
},
});
@@ -195,7 +192,7 @@ describe('KubeCheckoutBar', () => {
),
useFormOptions: {
defaultValues: {
- nodePools: [nodePoolBetaFactory.build()],
+ nodePools: [nodePoolFactory.build()],
},
},
});
@@ -210,7 +207,7 @@ describe('KubeCheckoutBar', () => {
component: ,
useFormOptions: {
defaultValues: {
- nodePools: [nodePoolBetaFactory.build()],
+ nodePools: [nodePoolFactory.build()],
},
},
});
@@ -231,7 +228,7 @@ describe('KubeCheckoutBar', () => {
),
useFormOptions: {
defaultValues: {
- nodePools: [nodePoolBetaFactory.build()],
+ nodePools: [nodePoolFactory.build()],
},
},
});
diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx
index b5eed90d61b..786684b08a0 100644
--- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx
+++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx
@@ -34,7 +34,7 @@ import { nodeWarning } from '../constants';
import { NodePoolSummaryItem } from './NodePoolSummaryItem';
import type { NodePoolConfigDrawerHandlerParams } from '../CreateCluster/CreateCluster';
-import type { KubeNodePoolResponse, Region } from '@linode/api-v4';
+import type { CreateNodePoolData, Region } from '@linode/api-v4';
export interface Props {
createCluster: () => void;
@@ -43,7 +43,7 @@ export interface Props {
hasAgreed: boolean;
highAvailability?: boolean;
highAvailabilityPrice: string;
- pools: KubeNodePoolResponse[];
+ pools: CreateNodePoolData[];
region: string | undefined;
regionsData: Region[];
submitting: boolean;
@@ -72,7 +72,7 @@ export const KubeCheckoutBar = (props: Props) => {
});
// Show a warning if any of the pools have fewer than 3 nodes
- const showWarning = pools.some((thisPool) => thisPool.count < 3);
+ const showWarning = pools?.some((thisPool) => thisPool.count < 3);
const { data: profile } = useProfile();
const { data: agreements } = useAccountAgreements();
diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.test.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.test.tsx
index 780f7962bfc..393744445dd 100644
--- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.test.tsx
+++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.test.tsx
@@ -2,7 +2,7 @@ import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { extendedTypes } from 'src/__data__/ExtendedType';
-import { nodePoolBetaFactory } from 'src/factories/kubernetesCluster';
+import { nodePoolFactory } from 'src/factories/kubernetesCluster';
import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers';
import { NodePoolSummaryItem } from './NodePoolSummaryItem';
@@ -24,7 +24,7 @@ describe('Node Pool Summary Item', () => {
component: ,
useFormOptions: {
defaultValues: {
- nodePools: [nodePoolBetaFactory.build()],
+ nodePools: [nodePoolFactory.build()],
},
},
});
@@ -37,7 +37,7 @@ describe('Node Pool Summary Item', () => {
component: ,
useFormOptions: {
defaultValues: {
- nodePools: [nodePoolBetaFactory.build()],
+ nodePools: [nodePoolFactory.build()],
},
},
});
@@ -50,7 +50,7 @@ describe('Node Pool Summary Item', () => {
component: ,
useFormOptions: {
defaultValues: {
- nodePools: [nodePoolBetaFactory.build()],
+ nodePools: [nodePoolFactory.build()],
},
},
});
diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummaryItem.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummaryItem.tsx
index d28da4935f7..41406e1a9df 100644
--- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummaryItem.tsx
+++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummaryItem.tsx
@@ -21,8 +21,11 @@ import {
import { useIsLkeEnterpriseEnabled } from '../kubeUtils';
-import type { NodePoolConfigDrawerHandlerParams } from '../CreateCluster/CreateCluster';
-import type { KubeNodePoolResponseBeta, KubernetesTier } from '@linode/api-v4';
+import type {
+ CreateClusterFormValues,
+ NodePoolConfigDrawerHandlerParams,
+} from '../CreateCluster/CreateCluster';
+import type { KubernetesTier } from '@linode/api-v4';
import type { ExtendedType } from 'src/utilities/extendType';
export interface Props {
@@ -50,8 +53,8 @@ export const NodePoolSummaryItem = React.memo((props: Props) => {
const { isLkeEnterprisePostLAFeatureEnabled } = useIsLkeEnterpriseEnabled();
- const { control } = useFormContext();
- const nodePoolsWatcher: KubeNodePoolResponseBeta[] = useWatch({
+ const { control } = useFormContext();
+ const nodePoolsWatcher = useWatch({
control,
name: 'nodePools',
});
diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx
index 54628f8ee7d..c25df231353 100644
--- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx
+++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx
@@ -6,10 +6,10 @@ import {
Typography,
} from '@linode/ui';
import { Hidden } from '@linode/ui';
-import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { useSnackbar } from 'notistack';
import * as React from 'react';
+import ExternalLinkIcon from 'src/assets/icons/external-link.svg';
import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';
import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog';
import { EntityDetail } from 'src/components/EntityDetail/EntityDetail';
@@ -185,7 +185,7 @@ export const KubeSummaryPanel = React.memo((props: Props) => {
disabled={
Boolean(dashboardError) || !dashboard || isClusterReadOnly
}
- endIcon={}
+ endIcon={}
onClick={() => window.open(dashboard?.url, '_blank')}
>
Kubernetes Dashboard
diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx
index 4c62f964cd9..7fbe2a883b5 100644
--- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx
+++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx
@@ -15,10 +15,7 @@ import {
ADD_NODE_POOLS_ENTERPRISE_DESCRIPTION,
nodeWarning,
} from 'src/features/Kubernetes/constants';
-import {
- useCreateNodePoolBetaMutation,
- useCreateNodePoolMutation,
-} from 'src/queries/kubernetes';
+import { useCreateNodePoolMutation } from 'src/queries/kubernetes';
import { extendType } from 'src/utilities/extendType';
import { filterCurrentTypes } from 'src/utilities/filterCurrentLinodeTypes';
import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants';
@@ -87,11 +84,6 @@ export const AddNodePoolDrawer = (props: Props) => {
const { classes } = useStyles();
const { data: types } = useAllTypes(open);
- const {
- error: errorBeta,
- isPending: isPendingBeta,
- mutateAsync: createPoolBeta,
- } = useCreateNodePoolBetaMutation(clusterId);
const {
error,
isPending,
@@ -142,11 +134,7 @@ export const AddNodePoolDrawer = (props: Props) => {
setAddNodePoolError(error?.[0].reason);
scrollErrorIntoViewV2(drawerRef);
}
- if (errorBeta) {
- setAddNodePoolError(errorBeta?.[0].reason);
- scrollErrorIntoViewV2(drawerRef);
- }
- }, [error, errorBeta]);
+ }, [error]);
const resetDrawer = () => {
setSelectedTypeInfo(undefined);
@@ -160,14 +148,6 @@ export const AddNodePoolDrawer = (props: Props) => {
if (!selectedTypeInfo) {
return;
}
- if (clusterTier === 'enterprise') {
- return createPoolBeta({
- count: selectedTypeInfo.count,
- type: selectedTypeInfo.planId,
- }).then(() => {
- onClose();
- });
- }
return createPool({
count: selectedTypeInfo.count,
type: selectedTypeInfo.planId,
@@ -218,7 +198,7 @@ export const AddNodePoolDrawer = (props: Props) => {
hasSelectedRegion={hasSelectedRegion}
isPlanPanelDisabled={isPlanPanelDisabled}
isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan}
- isSubmitting={isPending || isPendingBeta}
+ isSubmitting={isPending}
notice={}
onSelect={(newType: string) => {
if (selectedTypeInfo?.planId !== newType) {
diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx
index bfe196d8822..2f8fd25eb48 100644
--- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx
+++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx
@@ -18,6 +18,7 @@ import { NodeTable } from './NodeTable';
import type { StatusFilter } from './NodePoolsDisplay';
import type {
AutoscaleSettings,
+ KubeNodePoolResponse,
KubernetesTier,
PoolNodeResponse,
} from '@linode/api-v4/lib/kubernetes';
@@ -30,7 +31,7 @@ interface Props {
clusterId: number;
clusterTier: KubernetesTier;
count: number;
- encryptionStatus: EncryptionStatus | undefined;
+ encryptionStatus: EncryptionStatus;
handleAccordionClick: () => void;
handleClickAutoscale: (poolId: number) => void;
handleClickLabelsAndTaints: (poolId: number) => void;
@@ -42,6 +43,7 @@ interface Props {
openRecycleAllNodesDialog: (poolId: number) => void;
openRecycleNodeDialog: (nodeID: string, linodeLabel: string) => void;
poolId: number;
+ poolVersion: KubeNodePoolResponse['k8s_version'];
regionSupportsDiskEncryption: boolean;
statusFilter: StatusFilter;
tags: string[];
@@ -68,6 +70,7 @@ export const NodePool = (props: Props) => {
openRecycleAllNodesDialog,
openRecycleNodeDialog,
poolId,
+ poolVersion,
regionSupportsDiskEncryption,
statusFilter,
tags,
@@ -241,6 +244,7 @@ export const NodePool = (props: Props) => {
nodes={nodes}
openRecycleNodeDialog={openRecycleNodeDialog}
poolId={poolId}
+ poolVersion={poolVersion}
regionSupportsDiskEncryption={regionSupportsDiskEncryption}
statusFilter={statusFilter}
tags={tags}
diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx
index b34f96bd887..f6d86557b9c 100644
--- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx
+++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx
@@ -320,6 +320,7 @@ export const NodePoolsDisplay = (props: Props) => {
setIsRecycleNodeOpen(true);
}}
poolId={thisPool.id}
+ poolVersion={thisPool.k8s_version}
regionSupportsDiskEncryption={regionSupportsDiskEncryption}
statusFilter={statusFilter}
tags={tags}
diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx
index a9f32f8d166..39bcf9d7b42 100644
--- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx
+++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeRow.tsx
@@ -1,5 +1,5 @@
import styled from '@emotion/styled';
-import { usePreferences } from '@linode/queries';
+import { useLinodeIPsQuery, usePreferences } from '@linode/queries';
import { Box, Typography } from '@linode/ui';
import * as React from 'react';
@@ -13,7 +13,7 @@ import { useInProgressEvents } from 'src/queries/events/events';
import { NodeActionMenu } from './NodeActionMenu';
-import type { APIError } from '@linode/api-v4';
+import type { APIError, VPCIP } from '@linode/api-v4';
export interface NodeRow {
instanceId?: number;
@@ -22,6 +22,7 @@ export interface NodeRow {
label?: string;
nodeId: string;
nodeStatus: string;
+ shouldShowVpcIPAddressColumns: boolean;
}
interface NodeRowProps extends NodeRow {
@@ -43,13 +44,25 @@ export const NodeRow = React.memo((props: NodeRowProps) => {
nodeStatus,
openRecycleNodeDialog,
typeLabel,
+ shouldShowVpcIPAddressColumns,
} = props;
+ const { data: ips, error: ipsError } = useLinodeIPsQuery(
+ instanceId ?? -1,
+ Boolean(instanceId)
+ );
const { data: events } = useInProgressEvents();
const { data: maskSensitiveDataPreference } = usePreferences(
(preferences) => preferences?.maskSensitiveData
);
+ const vpcIpv4: VPCIP = ips?.ipv4?.vpc.find(
+ (ip: VPCIP) => ip.address !== null
+ );
+ const vpcIpv6: VPCIP = ips?.ipv6?.vpc?.find(
+ (ip: VPCIP) => ip.ipv6_addresses[0].slaac_address !== null
+ );
+
const recentEvent = events?.find(
(event) =>
event.entity?.id === instanceId && event.entity?.type === 'linode'
@@ -67,29 +80,27 @@ export const NodeRow = React.memo((props: NodeRowProps) => {
? 'active'
: 'inactive';
- const displayLabel = label ?? typeLabel;
+ const labelText = label ?? typeLabel;
- const displayStatus =
+ const statusText =
nodeStatus === 'not_ready'
? 'Provisioning'
: transitionText(instanceStatus ?? '', instanceId ?? -1, recentEvent);
- const displayIP = ip ?? '';
+ const publicIPv4Text = ip ?? '';
+ const vpcIpv4Text = vpcIpv4?.address ?? '';
+ const vpcIpv6Text = vpcIpv6?.ipv6_addresses[0].slaac_address ?? '';
return (
- {linodeLink ? (
- {displayLabel}
- ) : (
- displayLabel
- )}
+ {linodeLink ? {labelText} : labelText}
{linodeError ? (
({
- color: theme.color.red,
+ color: theme.tokens.alias.Content.Text.Negative,
})}
>
Error retrieving status
@@ -97,7 +108,7 @@ export const NodeRow = React.memo((props: NodeRowProps) => {
) : (
<>
- {displayStatus}
+ {statusText}
>
)}
@@ -105,23 +116,71 @@ export const NodeRow = React.memo((props: NodeRowProps) => {
{linodeError ? (
({
- color: theme.color.red,
+ color: theme.tokens.alias.Content.Text.Negative,
})}
>
Error retrieving IP
- ) : displayIP.length > 0 ? (
+ ) : publicIPv4Text.length > 0 ? (
-
+
) : null}
+ {shouldShowVpcIPAddressColumns && (
+
+ {linodeError || ipsError ? (
+ ({
+ color: theme.tokens.alias.Content.Text.Negative,
+ })}
+ >
+ Error retrieving IP
+
+ ) : vpcIpv4Text.length > 0 ? (
+
+
+
+
+ ) : null}
+
+ )}
+ {shouldShowVpcIPAddressColumns && (
+
+ {linodeError || ipsError ? (
+ ({
+ color: theme.tokens.alias.Content.Text.Negative,
+ })}
+ >
+ Error retrieving IP
+
+ ) : vpcIpv6Text.length > 0 ? (
+
+
+
+
+ ) : (
+ '—'
+ )}
+
+ )}
({
- '& svg': {
- height: `12px`,
- opacity: 0,
- width: `12px`,
- },
- marginLeft: 4,
- top: 1,
-}));
-
-export const StyledVerticalDivider = styled(VerticalDivider, {
- label: 'StyledVerticalDivider',
-})(({ theme }) => ({
- margin: `0 ${theme.spacing(2)}`,
-}));
-
-export const StyledTypography = styled(Typography, {
- label: 'StyledTypography',
-})(({ theme }) => ({
- margin: `0 ${theme.spacing(2)} 0 ${theme.spacing()}`,
- [theme.breakpoints.down('md')]: {
- padding: theme.spacing(),
- },
-}));
-
-export const StyledNotEncryptedBox = styled(Box, {
- label: 'StyledNotEncryptedBox',
+export const NodePoolTableFooter = styled(Box, {
+ label: 'NodePoolTableFooter',
})(({ theme }) => ({
- alignItems: 'center',
- display: 'flex',
- margin: `0 ${theme.spacing(2)} 0 ${theme.spacing()}`,
-}));
-
-export const StyledPoolInfoBox = styled(Box, {
- label: 'StyledPoolInfoBox',
-})(() => ({
- display: 'flex',
- width: '50%',
-}));
-
-export const StyledTableFooter = styled(Box, {
- label: 'StyledTableFooter',
-})(({ theme }) => ({
- alignItems: 'center',
- background: theme.bg.bgPaper,
display: 'flex',
+ flexDirection: 'row',
justifyContent: 'space-between',
- paddingLeft: 0,
- paddingTop: theme.spacing(),
+ alignItems: 'center',
+ columnGap: theme.spacingFunction(32),
+ rowGap: theme.spacingFunction(8),
+ paddingTop: theme.spacingFunction(8),
+ paddingButtom: theme.spacingFunction(8),
[theme.breakpoints.down('md')]: {
- display: 'block',
+ alignItems: 'unset',
flexDirection: 'column',
- paddingBottom: theme.spacing(),
},
}));
diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx
index eb99d8bc342..a51d3fabe8b 100644
--- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx
+++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx
@@ -1,75 +1,51 @@
import { linodeFactory } from '@linode/utilities';
import { DateTime } from 'luxon';
-import * as React from 'react';
+import React from 'react';
+import { accountFactory } from 'src/factories';
import { kubeLinodeFactory } from 'src/factories/kubernetesCluster';
import { makeResourcePage } from 'src/mocks/serverHandlers';
import { http, HttpResponse, server } from 'src/mocks/testServer';
import { renderWithTheme } from 'src/utilities/testHelpers';
-import { encryptionStatusTestId, NodeTable } from './NodeTable';
+import { NodeTable } from './NodeTable';
import type { Props } from './NodeTable';
import type { KubernetesTier } from '@linode/api-v4';
-const mockLinodes = new Array(3)
- .fill(null)
- .map((_element: null, index: number) => {
- return linodeFactory.build({
- ipv4: [`50.116.6.${index}`],
- });
- });
-
-const mockKubeNodes = mockLinodes.map((mockLinode) =>
- kubeLinodeFactory.build({
- instance_id: mockLinode.id,
- })
-);
-
-const props: Props = {
- clusterCreated: '2025-01-13T02:58:58',
- clusterId: 1,
- clusterTier: 'standard',
- encryptionStatus: 'enabled',
- isLkeClusterRestricted: false,
- nodes: mockKubeNodes,
- openRecycleNodeDialog: vi.fn(),
- poolId: 1,
- regionSupportsDiskEncryption: false,
- statusFilter: 'all',
- tags: [],
- typeLabel: 'Linode 2G',
-};
-
-beforeAll(() => linodeFactory.resetSequenceNumber());
-
describe('NodeTable', () => {
- const mocks = vi.hoisted(() => {
- return {
- useIsDiskEncryptionFeatureEnabled: vi.fn(),
- };
- });
-
- vi.mock('src/components/Encryption/utils.ts', async () => {
- const actual = await vi.importActual(
- 'src/components/Encryption/utils.ts'
- );
- return {
- ...actual,
- __esModule: true,
- useIsDiskEncryptionFeatureEnabled:
- mocks.useIsDiskEncryptionFeatureEnabled.mockImplementation(() => {
- return {
- isDiskEncryptionFeatureEnabled: false, // indicates the feature flag is off or account capability is absent
- };
- }),
- };
- });
+ const linodes = [
+ linodeFactory.build({ label: 'linode-1', ipv4: ['50.116.6.1'] }),
+ linodeFactory.build({ label: 'linode-2', ipv4: ['50.116.6.2'] }),
+ linodeFactory.build({ label: 'linode-3', ipv4: ['50.116.6.3'] }),
+ ];
+
+ const nodes = linodes.map((linode) =>
+ kubeLinodeFactory.build({
+ instance_id: linode.id,
+ })
+ );
+
+ const props: Props = {
+ clusterCreated: '2025-01-13T02:58:58',
+ clusterId: 1,
+ clusterTier: 'standard',
+ encryptionStatus: 'enabled',
+ isLkeClusterRestricted: false,
+ nodes,
+ openRecycleNodeDialog: vi.fn(),
+ poolId: 1,
+ poolVersion: undefined,
+ regionSupportsDiskEncryption: false,
+ statusFilter: 'all',
+ tags: [],
+ typeLabel: 'g6-standard-1',
+ };
it('includes label, status, and IP columns', async () => {
server.use(
http.get('*/linode/instances*', () => {
- return HttpResponse.json(makeResourcePage(mockLinodes));
+ return HttpResponse.json(makeResourcePage(linodes));
})
);
@@ -80,16 +56,50 @@ describe('NodeTable', () => {
expect(await findAllByText('Running')).toHaveLength(3);
await Promise.all(
- mockLinodes.map(async (mockLinode) => {
- await findByText(mockLinode.label);
- await findByText(mockLinode.ipv4[0]);
+ linodes.map(async (linode) => {
+ await findByText(linode.label);
+ await findByText(linode.ipv4[0]);
})
);
});
- it('includes the Pool ID', async () => {
+ it('shows the Pool ID', async () => {
const { getByText } = renderWithTheme();
- getByText('Pool ID 1');
+
+ expect(getByText('Pool ID')).toBeVisible();
+ expect(getByText(props.poolId)).toBeVisible();
+ });
+
+ it("shows the Node Pool's tags", async () => {
+ const tags = ['dev', 'staging', 'production'];
+
+ const { getByText } = renderWithTheme();
+
+ for (const tag of tags) {
+ expect(getByText(tag)).toBeVisible();
+ }
+ });
+
+ it("shows the node pool's version for a LKE Enterprise cluster", async () => {
+ const { getByText } = renderWithTheme(
+
+ );
+
+ expect(getByText('Version')).toBeVisible();
+ expect(getByText('v1.31.8+lke5')).toBeVisible();
+ });
+
+ it("does not show the node pool's version for a standard LKE cluster", async () => {
+ const { queryByText } = renderWithTheme(
+
+ );
+
+ expect(queryByText('Version')).toBeNull();
+ expect(queryByText('v1.31.8+lke5')).toBeNull();
});
it('displays a provisioning message if the cluster was created within the first 20 mins and there are no nodes yet', async () => {
@@ -114,25 +124,61 @@ describe('NodeTable', () => {
});
it('does not display the encryption status of the pool if the account lacks the capability or the feature flag is off', async () => {
- // situation where isDiskEncryptionFeatureEnabled === false
- const { queryByTestId } = renderWithTheme();
- const encryptionStatusFragment = queryByTestId(encryptionStatusTestId);
+ const { queryByText } = renderWithTheme(, {
+ flags: { linodeDiskEncryption: false },
+ });
- expect(encryptionStatusFragment).not.toBeInTheDocument();
+ expect(queryByText('Encrypted')).not.toBeInTheDocument();
+ expect(queryByText('Not Encrypted')).not.toBeInTheDocument();
});
- it('displays the encryption status of the pool if the feature flag is on and the account has the capability', async () => {
- mocks.useIsDiskEncryptionFeatureEnabled.mockImplementationOnce(() => {
- return {
- isDiskEncryptionFeatureEnabled: true,
- };
- });
+ it('shows "Encrypted" with an icon if the Node Pool is encrypted, the feature flag is on, and the account has the capability', async () => {
+ const account = accountFactory.build({ capabilities: ['Disk Encryption'] });
+
+ server.use(
+ http.get('*/v4*/account', () => {
+ return HttpResponse.json(account);
+ })
+ );
+
+ const { findAllByText } = renderWithTheme(
+ ,
+ {
+ flags: { linodeDiskEncryption: true },
+ }
+ );
+
+ const encryptedContext = await findAllByText('Encrypted');
+
+ // Two elements exist: A lock icon and the actual "Encrypted" text
+ expect(encryptedContext).toHaveLength(2);
+
+ // Verify the "Encrypted" text is visible
+ expect(encryptedContext[1]).toBeVisible();
+ });
+
+ it('shows "Not Encrypted" with an icon if the Node Pool is not encrypted, the feature flag is on, and the account has the capability', async () => {
+ const account = accountFactory.build({ capabilities: ['Disk Encryption'] });
+
+ server.use(
+ http.get('*/v4*/account', () => {
+ return HttpResponse.json(account);
+ })
+ );
+
+ const { findAllByText } = renderWithTheme(
+ ,
+ {
+ flags: { linodeDiskEncryption: true },
+ }
+ );
- const { queryByTestId } = renderWithTheme();
- const encryptionStatusFragment = queryByTestId(encryptionStatusTestId);
+ const encryptedContext = await findAllByText('Not Encrypted');
- expect(encryptionStatusFragment).toBeInTheDocument();
+ // Two elements exist: A unlock icon and the actual "Not Encrypted" text
+ expect(encryptedContext).toHaveLength(2);
- mocks.useIsDiskEncryptionFeatureEnabled.mockRestore();
+ // Verify the "Encrypted" text is visible
+ expect(encryptedContext[1]).toBeVisible();
});
});
diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx
index bb61e9204ea..229f62b2237 100644
--- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx
+++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx
@@ -1,5 +1,12 @@
import { useAllLinodesQuery, useProfile } from '@linode/queries';
-import { Box, ErrorState, TooltipIcon, Typography } from '@linode/ui';
+import {
+ Box,
+ Divider,
+ ErrorState,
+ Stack,
+ TooltipIcon,
+ Typography,
+} from '@linode/ui';
import { DateTime, Interval } from 'luxon';
import { enqueueSnackbar } from 'notistack';
import * as React from 'react';
@@ -23,33 +30,29 @@ import { useUpdateNodePoolMutation } from 'src/queries/kubernetes';
import { parseAPIDate } from 'src/utilities/date';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
+import { useIsLkeEnterpriseEnabled } from '../../kubeUtils';
import { NodeRow as _NodeRow } from './NodeRow';
-import {
- StyledNotEncryptedBox,
- StyledPoolInfoBox,
- StyledTableFooter,
- StyledTypography,
- StyledVerticalDivider,
-} from './NodeTable.styles';
+import { NodePoolTableFooter } from './NodeTable.styles';
+import { nodeToRow } from './utils';
import type { StatusFilter } from './NodePoolsDisplay';
-import type { NodeRow } from './NodeRow';
import type {
+ KubeNodePoolResponse,
KubernetesTier,
PoolNodeResponse,
} from '@linode/api-v4/lib/kubernetes';
import type { EncryptionStatus } from '@linode/api-v4/lib/linodes/types';
-import type { LinodeWithMaintenance } from 'src/utilities/linodes';
export interface Props {
clusterCreated: string;
clusterId: number;
clusterTier: KubernetesTier;
- encryptionStatus: EncryptionStatus | undefined;
+ encryptionStatus: EncryptionStatus;
isLkeClusterRestricted: boolean;
nodes: PoolNodeResponse[];
openRecycleNodeDialog: (nodeID: string, linodeLabel: string) => void;
poolId: number;
+ poolVersion: KubeNodePoolResponse['k8s_version'];
regionSupportsDiskEncryption: boolean;
statusFilter: StatusFilter;
tags: string[];
@@ -63,6 +66,7 @@ export const NodeTable = React.memo((props: Props) => {
clusterCreated,
clusterId,
clusterTier,
+ poolVersion,
encryptionStatus,
nodes,
openRecycleNodeDialog,
@@ -79,6 +83,7 @@ export const NodeTable = React.memo((props: Props) => {
const { data: linodes, error, isLoading } = useAllLinodesQuery();
const { isDiskEncryptionFeatureEnabled } =
useIsDiskEncryptionFeatureEnabled();
+ const { isLkeEnterprisePhase2FeatureEnabled } = useIsLkeEnterpriseEnabled();
const { mutateAsync: updateNodePool } = useUpdateNodePoolMutation(
clusterId,
@@ -99,7 +104,13 @@ export const NodeTable = React.memo((props: Props) => {
[updateNodePool]
);
- const rowData = nodes.map((thisNode) => nodeToRow(thisNode, linodes ?? []));
+ const shouldShowVpcIPAddressColumns =
+ isLkeEnterprisePhase2FeatureEnabled && clusterTier === 'enterprise';
+ const numColumns = shouldShowVpcIPAddressColumns ? 6 : 4;
+
+ const rowData = nodes.map((thisNode) =>
+ nodeToRow(thisNode, linodes ?? [], shouldShowVpcIPAddressColumns)
+ );
const filteredRowData = ['offline', 'provisioning', 'running'].includes(
statusFilter
@@ -197,8 +208,15 @@ export const NodeTable = React.memo((props: Props) => {
width: '35%',
})}
>
- IP Address
+ Public IPv4
+ {shouldShowVpcIPAddressColumns && (
+ <>
+ VPC IPv4
+ VPC IPv6
+ >
+ )}
+
@@ -206,7 +224,7 @@ export const NodeTable = React.memo((props: Props) => {
{rowData.length === 0 &&
isEnterpriseClusterWithin20MinsOfCreation() && (
-
+
{
{paginatedAndOrderedData.map((eachRow) => {
return (
@@ -249,6 +267,9 @@ export const NodeTable = React.memo((props: Props) => {
nodeId={eachRow.nodeId}
nodeStatus={eachRow.nodeStatus}
openRecycleNodeDialog={openRecycleNodeDialog}
+ shouldShowVpcIPAddressColumns={
+ shouldShowVpcIPAddressColumns
+ }
typeLabel={typeLabel}
/>
);
@@ -270,63 +291,52 @@ export const NodeTable = React.memo((props: Props) => {
**/
sx={{ position: 'relative' }}
/>
-
-
- {isDiskEncryptionFeatureEnabled &&
- encryptionStatus !== undefined ? (
-
+
+
+
+ }
+ flexWrap={{ sm: 'unset', xs: 'wrap' }}
+ rowGap={1}
+ >
+
+ Pool ID {poolId}
+
+ {clusterTier === 'enterprise' && poolVersion && (
- Pool ID {poolId}
+ Version {poolVersion}
-
+ )}
+ {isDiskEncryptionFeatureEnabled && (
-
- ) : (
- Pool ID {poolId}
- )}
-
+ )}
+
+
-
+
>
)}
);
});
-/**
- * Transforms an LKE Pool Node to a NodeRow.
- */
-export const nodeToRow = (
- node: PoolNodeResponse,
- linodes: LinodeWithMaintenance[]
-): NodeRow => {
- const foundLinode = linodes.find(
- (thisLinode) => thisLinode.id === node.instance_id
- );
-
- return {
- instanceId: node.instance_id || undefined,
- instanceStatus: foundLinode?.status,
- ip: foundLinode?.ipv4[0],
- label: foundLinode?.label,
- nodeId: node.id,
- nodeStatus: node.status,
- };
-};
-
export const EncryptedStatus = ({
encryptionStatus,
regionSupportsDiskEncryption,
@@ -336,20 +346,22 @@ export const EncryptedStatus = ({
regionSupportsDiskEncryption: boolean;
tooltipText: string | undefined;
}) => {
- return encryptionStatus === 'enabled' ? (
- <>
-
- Encrypted
- >
- ) : encryptionStatus === 'disabled' ? (
- <>
+ if (encryptionStatus === 'enabled') {
+ return (
+
+
+ Encrypted
+
+ );
+ }
+
+ return (
+
-
- Not Encrypted
- {regionSupportsDiskEncryption && tooltipText ? (
-
- ) : null}
-
- >
- ) : null;
+ Not Encrypted
+ {regionSupportsDiskEncryption && tooltipText && (
+
+ )}
+
+ );
};
diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts
index 3b52451b27d..db841b3f55a 100644
--- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts
+++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/utils.ts
@@ -1,3 +1,7 @@
+import type { NodeRow } from './NodeRow';
+import type { PoolNodeResponse } from '@linode/api-v4';
+import type { LinodeWithMaintenance } from 'src/utilities/linodes';
+
/**
* Checks whether prices are valid - 0 is valid, but undefined and null prices are invalid.
* @returns true if either value is null or undefined
@@ -11,3 +15,26 @@ export const hasInvalidNodePoolPrice = (
return isInvalidPricePerNode || isInvalidTotalPrice;
};
+
+/**
+ * Transforms an LKE Pool Node to a NodeRow.
+ */
+export const nodeToRow = (
+ node: PoolNodeResponse,
+ linodes: LinodeWithMaintenance[],
+ shouldShowVpcIPAddressColumns: boolean
+): NodeRow => {
+ const foundLinode = linodes.find(
+ (thisLinode) => thisLinode.id === node.instance_id
+ );
+
+ return {
+ instanceId: node.instance_id || undefined,
+ instanceStatus: foundLinode?.status,
+ ip: foundLinode?.ipv4[0],
+ label: foundLinode?.label,
+ nodeId: node.id,
+ nodeStatus: node.status,
+ shouldShowVpcIPAddressColumns,
+ };
+};
diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigDrawer.tsx
index 5cd1d02bdff..bdad71529d6 100644
--- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigDrawer.tsx
+++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/NodePoolConfigDrawer.tsx
@@ -25,8 +25,8 @@ import {
} from '../constants';
import { NodePoolConfigOptions } from './NodePoolConfigOptions';
+import type { CreateClusterFormValues } from '../CreateCluster/CreateCluster';
import type {
- CreateNodePoolDataBeta,
KubernetesTier,
NodePoolUpdateStrategy,
Region,
@@ -62,8 +62,9 @@ export const NodePoolConfigDrawer = (props: Props) => {
} = props;
// Use the node pool state from the main create flow from.
- const { control: parentFormControl } = useFormContext();
- const _nodePools: CreateNodePoolDataBeta[] = useWatch({
+ const { control: parentFormControl } =
+ useFormContext();
+ const _nodePools = useWatch({
control: parentFormControl,
name: 'nodePools',
});
@@ -126,7 +127,7 @@ export const NodePoolConfigDrawer = (props: Props) => {
count: values.nodeCount,
update_strategy: values.updateStrategy,
});
- } else {
+ } else if (planId) {
append({
count: values.nodeCount,
type: planId,
diff --git a/packages/manager/src/features/Linodes/AclpPreferenceToggle.tsx b/packages/manager/src/features/Linodes/AclpPreferenceToggle.tsx
index e23bc557376..831695dd3db 100644
--- a/packages/manager/src/features/Linodes/AclpPreferenceToggle.tsx
+++ b/packages/manager/src/features/Linodes/AclpPreferenceToggle.tsx
@@ -67,6 +67,13 @@ const preferenceConfig: Record<
},
};
+/**
+ * - For Alerts, the toggle uses local state, not preferences. We do this because each Linode should manage its own alert mode individually.
+ * - Create Linode: Toggle defaults to false (legacy mode). It's a simple UI toggle with no persistence.
+ * - Edit Linode: Toggle defaults based on useIsLinodeAclpSubscribed (true if the Linode is already subscribed to ACLP). Still local state - not saved to preferences.
+ *
+ * - For Metrics, we use account-level preferences, since it's a global setting shared across all Linodes.
+ */
export const AclpPreferenceToggle = (props: AclpPreferenceToggleType) => {
const { isAlertsBetaMode, onAlertsModeChange, type } = props;
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Actions.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Actions.test.tsx
index 0c81bd5584d..2a395aa1ec0 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Actions.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Actions.test.tsx
@@ -9,7 +9,7 @@ const queryMocks = vi.hoisted(() => ({
useParams: vi.fn(),
useSearch: vi.fn(),
userPermissions: vi.fn(() => ({
- permissions: {
+ data: {
create_linode: false,
clone_linode: false,
},
@@ -65,7 +65,7 @@ describe('Actions', () => {
it('should render an enabled create button, if user has create_linode permission', () => {
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: true,
clone_linode: true,
},
@@ -84,7 +84,7 @@ describe('Actions', () => {
it('should render an enabled create button for cloning, if user has clone_linode permission', () => {
queryMocks.useParams.mockReturnValue({ type: 'Clone Linode' });
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: true,
clone_linode: true,
},
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx
index 1238fc5c7a6..e9371f92ad2 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx
@@ -4,6 +4,7 @@ import React, { useState } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
+import { useGetLinodeCreateType } from 'src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType';
import { useFlags } from 'src/hooks/useFlags';
import { sendApiAwarenessClickEvent } from 'src/utilities/analytics/customEventAnalytics';
import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics';
@@ -13,7 +14,6 @@ import { ApiAwarenessModal } from './ApiAwarenessModal/ApiAwarenessModal';
import {
getDoesEmployeeNeedToAssignFirewall,
getLinodeCreatePayload,
- useLinodeCreateQueryParams,
} from './utilities';
import type { LinodeCreateFormValues } from './utilities';
@@ -23,8 +23,7 @@ interface ActionProps {
}
export const Actions = ({ isAlertsBetaMode }: ActionProps) => {
- const { params } = useLinodeCreateQueryParams();
-
+ const createType = useGetLinodeCreateType();
const [isAPIAwarenessModalOpen, setIsAPIAwarenessModalOpen] = useState(false);
const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled();
@@ -44,13 +43,17 @@ export const Actions = ({ isAlertsBetaMode }: ActionProps) => {
],
});
- const { permissions } = usePermissions('linode', ['clone_linode'], linodeId);
+ const { data: permissions } = usePermissions(
+ 'linode',
+ ['clone_linode'],
+ linodeId
+ );
- const { permissions: accountPermissions } = usePermissions('account', [
+ const { data: accountPermissions } = usePermissions('account', [
'create_linode',
]);
- const isCloneMode = params.type === 'Clone Linode';
+ const isCloneMode = createType === 'Clone Linode';
const isDisabled = isCloneMode
? !permissions.clone_linode
: !accountPermissions.create_linode;
@@ -66,7 +69,7 @@ export const Actions = ({ isAlertsBetaMode }: ActionProps) => {
const onOpenAPIAwareness = async () => {
sendApiAwarenessClickEvent('Button', 'View Code Snippets');
sendLinodeCreateFormInputEvent({
- createType: params.type ?? 'OS',
+ createType: createType ?? 'OS',
interaction: 'click',
label: 'View Code Snippets',
});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Addons/Backups.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Addons/Backups.test.tsx
index 4a891ae67fc..500c4602dde 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Addons/Backups.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Addons/Backups.test.tsx
@@ -16,7 +16,7 @@ const queryMocks = vi.hoisted(() => ({
useParams: vi.fn(),
useSearch: vi.fn(),
userPermissions: vi.fn(() => ({
- permissions: {
+ data: {
create_linode: false,
},
})),
@@ -65,7 +65,7 @@ describe('Linode Create Backups Addon', () => {
it('should be enabled if the user has create_linode permission', async () => {
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: true,
},
});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Addons/Backups.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Addons/Backups.tsx
index aa468a887ec..758f683f2b1 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Addons/Backups.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Addons/Backups.tsx
@@ -35,7 +35,7 @@ export const Backups = () => {
name: ['region', 'type', 'disk_encryption'],
});
- const { permissions } = usePermissions('account', ['create_linode']);
+ const { data: permissions } = usePermissions('account', ['create_linode']);
const { data: type } = useTypeQuery(typeId, Boolean(typeId));
const { data: regions } = useRegionsQuery();
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Addons/PrivateIP.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Addons/PrivateIP.test.tsx
index 07d21530313..b7b82537cc8 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Addons/PrivateIP.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Addons/PrivateIP.test.tsx
@@ -12,7 +12,7 @@ import type { CreateLinodeRequest } from '@linode/api-v4';
const queryMocks = vi.hoisted(() => ({
userPermissions: vi.fn(() => ({
- permissions: {
+ data: {
create_linode: false,
},
})),
@@ -45,7 +45,7 @@ describe('Linode Create Private IP Add-on', () => {
it('should be enabled if the user has create_linode permission', async () => {
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: true,
},
});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Addons/PrivateIP.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Addons/PrivateIP.tsx
index 6b9f3608bf9..70e7e9802fc 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Addons/PrivateIP.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Addons/PrivateIP.tsx
@@ -23,7 +23,7 @@ export const PrivateIP = () => {
const { data: regions } = useRegionsQuery();
- const { permissions } = usePermissions('account', ['create_linode']);
+ const { data: permissions } = usePermissions('account', ['create_linode']);
const regionId = useWatch({ name: 'region' });
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/CurlTabPanel.tsx b/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/CurlTabPanel.tsx
index fc12b737311..3cfbcff7379 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/CurlTabPanel.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/CurlTabPanel.tsx
@@ -9,7 +9,7 @@ import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel';
import { sendApiAwarenessClickEvent } from 'src/utilities/analytics/customEventAnalytics';
import { generateCurlCommand } from 'src/utilities/codesnippets/generate-cURL';
-import { useLinodeCreateQueryParams } from '../utilities';
+import { useGetLinodeCreateType } from '../Tabs/utils/useGetLinodeCreateType';
import type { LinodeCreateFormValues } from '../utilities';
import type { CreateLinodeRequest } from '@linode/api-v4/lib/linodes';
@@ -26,8 +26,8 @@ export const CurlTabPanel = ({ index, payLoad, title }: CurlTabPanelProps) => {
const { getValues } = useFormContext();
const sourceLinodeID = getValues('linode.id');
- const { params } = useLinodeCreateQueryParams();
- const linodeCLIAction = params.type === 'Clone Linode' ? 'clone' : 'create';
+ const createType = useGetLinodeCreateType();
+ const linodeCLIAction = createType === 'Clone Linode' ? 'clone' : 'create';
const path =
linodeCLIAction === 'create'
? '/linode/instances'
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/LinodeCLIPanel.tsx b/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/LinodeCLIPanel.tsx
index 049020065ee..9872f5f21d2 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/LinodeCLIPanel.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/ApiAwarenessModal/LinodeCLIPanel.tsx
@@ -1,4 +1,4 @@
-import { Notice, Typography } from '@linode/ui';
+import { Typography } from '@linode/ui';
import React, { useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
@@ -8,7 +8,7 @@ import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel';
import { sendApiAwarenessClickEvent } from 'src/utilities/analytics/customEventAnalytics';
import { generateCLICommand } from 'src/utilities/codesnippets/generate-cli';
-import { useLinodeCreateQueryParams } from '../utilities';
+import { useGetLinodeCreateType } from '../Tabs/utils/useGetLinodeCreateType';
import type { LinodeCreateFormValues } from '../utilities';
import type { CreateLinodeRequest } from '@linode/api-v4/lib/linodes';
@@ -24,13 +24,9 @@ export const LinodeCLIPanel = ({
payLoad,
title,
}: LinodeCLIPanelProps) => {
- const { params } = useLinodeCreateQueryParams();
+ const createType = useGetLinodeCreateType();
- // @TODO - Linode Interfaces
- // DX support (CLI, integrations, sdks) for Linode Interfaces is not yet available. Remove this when it is.
- const showDXCodeSnippets = payLoad.interface_generation !== 'linode';
-
- const linodeCLIAction = params.type;
+ const linodeCLIAction = createType;
const { getValues } = useFormContext();
const sourceLinodeID = getValues('linode.id');
@@ -68,17 +64,7 @@ export const LinodeCLIPanel = ({
.
- {!showDXCodeSnippets && (
-
- )}
- {showDXCodeSnippets && (
-
- )}
+
);
};
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Details/Details.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Details/Details.test.tsx
index 7be92eb06b1..b656b66041f 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Details/Details.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Details/Details.test.tsx
@@ -10,7 +10,7 @@ const queryMocks = vi.hoisted(() => ({
useParams: vi.fn(),
useSearch: vi.fn(),
userPermissions: vi.fn(() => ({
- permissions: {
+ data: {
create_linode: false,
},
})),
@@ -84,9 +84,7 @@ describe('Linode Create Details', () => {
const { queryByText } = renderWithThemeAndHookFormContext({
component: ,
options: {
- MemoryRouter: {
- initialEntries: ['/linodes/create?type=Clone+Linode'],
- },
+ initialRoute: '/linodes/create/clone',
},
});
@@ -107,7 +105,7 @@ describe('Linode Create Details', () => {
it('should enable the label and tag TextFields if the user has create_linode permission', async () => {
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: true,
},
});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Details/Details.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Details/Details.tsx
index 598c6923ff8..dd9febe93e8 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Details/Details.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Details/Details.tsx
@@ -6,7 +6,7 @@ import { TagsInput } from 'src/components/TagsInput/TagsInput';
import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils';
-import { useLinodeCreateQueryParams } from '../utilities';
+import { useGetLinodeCreateType } from '../Tabs/utils/useGetLinodeCreateType';
import { PlacementGroupPanel } from './PlacementGroupPanel';
import type { CreateLinodeRequest } from '@linode/api-v4';
@@ -15,9 +15,9 @@ export const Details = () => {
const { control } = useFormContext();
const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled();
- const { params } = useLinodeCreateQueryParams();
+ const createType = useGetLinodeCreateType();
- const { permissions } = usePermissions('account', ['create_linode']);
+ const { data: permissions } = usePermissions('account', ['create_linode']);
return (
@@ -36,7 +36,7 @@ export const Details = () => {
/>
)}
/>
- {params.type !== 'Clone Linode' && (
+ {createType !== 'Clone Linode' && (
{
const regionId = useWatch({ name: 'region' });
- const { params } = useLinodeCreateQueryParams();
+ const createType = useGetLinodeCreateType();
const placementGroupFormEventOptions: LinodeCreateFormEventOptions = {
- createType: params.type ?? 'OS',
+ createType: createType ?? 'OS',
headerName: 'Details',
interaction: 'change',
label: 'Placement Group',
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/EUAgreement.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/EUAgreement.test.tsx
index efb06e5ebaa..05ef20a0f51 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/EUAgreement.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/EUAgreement.test.tsx
@@ -12,7 +12,7 @@ import type { LinodeCreateFormValues } from './utilities';
const queryMocks = vi.hoisted(() => ({
userPermissions: vi.fn(() => ({
- permissions: {
+ data: {
acknowledge_account_agreement: false,
},
})),
@@ -48,7 +48,7 @@ describe('EUAgreement', () => {
});
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
acknowledge_account_agreement: true,
},
});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/EUAgreement.tsx b/packages/manager/src/features/Linodes/LinodeCreate/EUAgreement.tsx
index c6f9a0b7857..2f2ad558a56 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/EUAgreement.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/EUAgreement.tsx
@@ -26,7 +26,7 @@ export const EUAgreement = () => {
const { data: agreements } = useAccountAgreements(hasSelectedAnEURegion);
- const { permissions } = usePermissions('account', [
+ const { data: permissions } = usePermissions('account', [
'acknowledge_account_agreement',
]);
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Firewall.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Firewall.test.tsx
index 1efdd94dc7b..94f6f00b35f 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Firewall.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Firewall.test.tsx
@@ -15,7 +15,7 @@ const queryMocks = vi.hoisted(() => ({
useParams: vi.fn(),
useSearch: vi.fn(),
userPermissions: vi.fn(() => ({
- permissions: {
+ data: {
create_linode: false,
create_firewall: false,
},
@@ -78,7 +78,7 @@ describe('Linode Create Firewall', () => {
it('should enable a Firewall select if the user has create_linode permission', () => {
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: true,
create_firewall: true,
},
@@ -95,7 +95,7 @@ describe('Linode Create Firewall', () => {
it('should enable a "Create Firewall" button if the user has create_firewall permission', () => {
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: true,
create_firewall: true,
},
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Firewall.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Firewall.tsx
index 21b2b56e3c1..162254cf02a 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Firewall.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Firewall.tsx
@@ -14,7 +14,7 @@ import { useFlags } from 'src/hooks/useFlags';
import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled';
import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics';
-import { useLinodeCreateQueryParams } from './utilities';
+import { useGetLinodeCreateType } from './Tabs/utils/useGetLinodeCreateType';
import type { CreateLinodeRequest } from '@linode/api-v4';
import type { LinodeCreateFormEventOptions } from 'src/utilities/analytics/types';
@@ -30,19 +30,19 @@ export const Firewall = () => {
const flags = useFlags();
- const { params } = useLinodeCreateQueryParams();
+ const createType = useGetLinodeCreateType();
const { secureVMNoticesEnabled } = useSecureVMNoticesEnabled();
const secureVMFirewallBanner =
(secureVMNoticesEnabled && flags.secureVmCopy) ?? false;
- const { permissions } = usePermissions('account', [
+ const { data: permissions } = usePermissions('account', [
'create_linode',
'create_firewall',
]);
const firewallFormEventOptions: LinodeCreateFormEventOptions = {
- createType: params.type ?? 'OS',
+ createType: createType ?? 'OS',
headerName: 'Firewall',
interaction: 'click',
label: 'Firewall',
@@ -58,7 +58,7 @@ export const Firewall = () => {
sendLinodeCreateFormInputEvent({
- createType: params.type ?? 'OS',
+ createType: createType ?? 'OS',
headerName: 'Firewall',
interaction: 'click',
label: 'Learn more',
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/Firewall.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/Firewall.tsx
index bb2849ba357..1d02c5f3434 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/Firewall.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/Firewall.tsx
@@ -30,7 +30,7 @@ export const Firewall = () => {
] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
- const { permissions } = usePermissions('account', [
+ const { data: permissions } = usePermissions('account', [
'create_linode',
'create_firewall',
]);
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceFirewall.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceFirewall.test.tsx
index 7a7b359fee2..7d5dc4c1be9 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceFirewall.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceFirewall.test.tsx
@@ -6,7 +6,7 @@ import { InterfaceFirewall } from './InterfaceFirewall';
const queryMocks = vi.hoisted(() => ({
userPermissions: vi.fn(() => ({
- permissions: {
+ data: {
create_linode: false,
create_firewall: false,
},
@@ -42,7 +42,7 @@ describe('InterfaceFirewall', () => {
it('should enable a Firewall select if the user has create_linode permission', () => {
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: true,
create_firewall: true,
},
@@ -59,7 +59,7 @@ describe('InterfaceFirewall', () => {
it('should enable a "Create Firewall" button if the user has create_firewall permission', () => {
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: true,
create_firewall: true,
},
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceFirewall.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceFirewall.tsx
index a0e76685a5f..58f41dac985 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceFirewall.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceFirewall.tsx
@@ -40,7 +40,7 @@ export const InterfaceFirewall = ({ index }: Props) => {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
- const { permissions } = usePermissions('account', [
+ const { data: permissions } = usePermissions('account', [
'create_linode',
'create_firewall',
]);
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/LinodeInterface.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/LinodeInterface.test.tsx
index b4ebe104224..74602447795 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/LinodeInterface.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/LinodeInterface.test.tsx
@@ -12,7 +12,7 @@ import type { LinodeCreateFormValues } from '../utilities';
vi.mock('src/features/IAM/hooks/usePermissions', () => ({
usePermissions: vi.fn(() => ({
- permissions: { delete_firewall: true, update_firewall: true },
+ data: { delete_firewall: true, update_firewall: true },
})),
}));
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/VLAN.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/VLAN.test.tsx
index faff1615604..3031352c54c 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/VLAN.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/VLAN.test.tsx
@@ -9,7 +9,7 @@ import { VLAN } from './VLAN';
const queryMocks = vi.hoisted(() => ({
userPermissions: vi.fn(() => ({
- permissions: {
+ data: {
create_linode: false,
},
})),
@@ -40,7 +40,7 @@ describe('VLAN', () => {
const region = regionFactory.build({ capabilities: ['Vlans'] });
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: true,
},
});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/VLAN.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/VLAN.tsx
index 11334f13321..d64ceaf6110 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/VLAN.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/VLAN.tsx
@@ -17,7 +17,7 @@ interface Props {
export const VLAN = ({ index }: Props) => {
const { control } = useFormContext();
- const { permissions } = usePermissions('account', ['create_linode']);
+ const { data: permissions } = usePermissions('account', ['create_linode']);
const regionId = useWatch({ control, name: 'region' });
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Plan.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Plan.tsx
index 0f5395b3483..497c04cd73b 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Plan.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Plan.tsx
@@ -9,7 +9,7 @@ import { sendLinodeCreateFlowDocsClickEvent } from 'src/utilities/analytics/cust
import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics';
import { extendType } from 'src/utilities/extendType';
-import { useLinodeCreateQueryParams } from './utilities';
+import { useGetLinodeCreateType } from './Tabs/utils/useGetLinodeCreateType';
import type { LinodeCreateFormValues } from './utilities';
import type { CreateLinodeRequest } from '@linode/api-v4';
@@ -24,9 +24,9 @@ export const Plan = () => {
const { data: regions } = useRegionsQuery();
const { data: types } = useAllTypes();
- const { params } = useLinodeCreateQueryParams();
+ const createType = useGetLinodeCreateType();
- const { permissions } = usePermissions('account', ['create_linode']);
+ const { data: permissions } = usePermissions('account', ['create_linode']);
return (
{
onClick={() => {
sendLinodeCreateFlowDocsClickEvent('Choosing a Plan');
sendLinodeCreateFormInputEvent({
- createType: params.type ?? 'OS',
+ createType: createType ?? 'OS',
headerName: 'Linode Plan',
interaction: 'click',
label: 'Choosing a Plan',
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Region.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Region.test.tsx
index 293e8773ec9..2b3e5b92d37 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Region.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Region.test.tsx
@@ -21,7 +21,7 @@ const queryMocks = vi.hoisted(() => ({
useParams: vi.fn(),
useSearch: vi.fn(),
userPermissions: vi.fn(() => ({
- permissions: {
+ data: {
create_linode: false,
},
})),
@@ -77,7 +77,7 @@ describe('Region', () => {
it('should enable the region select is the user has create_linode permission', async () => {
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: true,
},
});
@@ -116,6 +116,9 @@ describe('Region', () => {
});
it('renders a warning if the user selects a region with different pricing when cloning', async () => {
+ queryMocks.useLocation.mockReturnValue({
+ pathname: '/linodes/create/clone',
+ });
const regionA = regionFactory.build({ capabilities: ['Linodes'] });
const regionB = regionFactory.build({ capabilities: ['Linodes'] });
@@ -160,6 +163,9 @@ describe('Region', () => {
});
it('renders a warning if the user tries to clone across datacenters', async () => {
+ queryMocks.useLocation.mockReturnValue({
+ pathname: '/linodes/create/clone',
+ });
const regionA = regionFactory.build({ capabilities: ['Linodes'] });
const regionB = regionFactory.build({ capabilities: ['Linodes'] });
@@ -196,7 +202,7 @@ describe('Region', () => {
).toBeVisible();
});
- //TODO: this is an expected failure until we fix the filtering
+ // TODO: this is an expected failure until we fix the filtering
it.skip('should disable distributed regions if the selected image does not have the `distributed-sites` capability', async () => {
const image = imageFactory.build({ capabilities: [] });
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Region.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Region.tsx
index e3846ffbc41..427812f51d7 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Region.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Region.tsx
@@ -25,11 +25,9 @@ import {
import { isLinodeTypeDifferentPriceInSelectedRegion } from 'src/utilities/pricing/linodes';
import { getDisabledRegions } from './Region.utils';
+import { useGetLinodeCreateType } from './Tabs/utils/useGetLinodeCreateType';
import { TwoStepRegion } from './TwoStepRegion';
-import {
- getGeneratedLinodeLabel,
- useLinodeCreateQueryParams,
-} from './utilities';
+import { getGeneratedLinodeLabel } from './utilities';
import type { LinodeCreateFormValues } from './utilities';
import type { Region as RegionType } from '@linode/api-v4';
@@ -41,7 +39,7 @@ export const Region = React.memo(() => {
const flags = useFlags();
const queryClient = useQueryClient();
- const { params } = useLinodeCreateQueryParams();
+ const createType = useGetLinodeCreateType();
const {
control,
@@ -72,7 +70,7 @@ export const Region = React.memo(() => {
Boolean(selectedLinode?.type)
);
- const { permissions } = usePermissions('account', ['create_linode']);
+ const { data: permissions } = usePermissions('account', ['create_linode']);
const { data: regions } = useRegionsQuery();
@@ -82,7 +80,7 @@ export const Region = React.memo(() => {
);
const showTwoStepRegion =
- isGeckoLAEnabled && isDistributedRegionSupported(params.type ?? 'OS');
+ isGeckoLAEnabled && isDistributedRegionSupported(createType ?? 'OS');
const onChange = async (region: RegionType) => {
const values = getValues();
@@ -167,7 +165,7 @@ export const Region = React.memo(() => {
// Auto-generate the Linode label because the region is included in the generated label
const label = await getGeneratedLinodeLabel({
queryClient,
- tab: params.type ?? 'OS',
+ tab: createType ?? 'OS',
values: { ...values, region: region.id },
});
@@ -176,17 +174,17 @@ export const Region = React.memo(() => {
// Begin tracking the Linode Create form.
sendLinodeCreateFormStartEvent({
- createType: params.type ?? 'OS',
+ createType: createType ?? 'OS',
});
};
const showCrossDataCenterCloneWarning =
- params.type === 'Clone Linode' &&
+ createType === 'Clone Linode' &&
selectedLinode &&
selectedLinode.region !== field.value;
const showClonePriceWarning =
- params.type === 'Clone Linode' &&
+ createType === 'Clone Linode' &&
isLinodeTypeDifferentPriceInSelectedRegion({
regionA: selectedLinode?.region,
regionB: field.value,
@@ -194,8 +192,7 @@ export const Region = React.memo(() => {
});
const hideDistributedRegions =
- !flags.gecko2?.enabled ||
- !isDistributedRegionSupported(params.type ?? 'OS');
+ !flags.gecko2?.enabled || !isDistributedRegionSupported(createType ?? 'OS');
const disabledRegions = getDisabledRegions({
regions: regions ?? [],
@@ -211,9 +208,7 @@ export const Region = React.memo(() => {
onChange={onChange}
regionFilter={
// We don't want the Image Service Gen2 work to abide by Gecko feature flags
- hideDistributedRegions && params.type !== 'Images'
- ? 'core'
- : undefined
+ hideDistributedRegions && createType !== 'Images' ? 'core' : undefined
}
textFieldProps={{ onBlur: field.onBlur }}
value={field.value}
@@ -230,7 +225,7 @@ export const Region = React.memo(() => {
label={DOCS_LINK_LABEL_DC_PRICING}
onClick={() =>
sendLinodeCreateFormInputEvent({
- createType: params.type ?? 'OS',
+ createType: createType ?? 'OS',
headerName: 'Region',
interaction: 'click',
label: DOCS_LINK_LABEL_DC_PRICING,
@@ -257,9 +252,7 @@ export const Region = React.memo(() => {
onChange={(e, region) => onChange(region)}
regionFilter={
// We don't want the Image Service Gen2 work to abide by Gecko feature flags
- hideDistributedRegions && params.type !== 'Images'
- ? 'core'
- : undefined
+ hideDistributedRegions && createType !== 'Images' ? 'core' : undefined
}
regions={regions ?? []}
textFieldProps={{ onBlur: field.onBlur }}
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Security.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Security.test.tsx
index 501c57114e4..eecc3105370 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Security.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Security.test.tsx
@@ -24,7 +24,7 @@ const queryMocks = vi.hoisted(() => ({
useSearch: vi.fn().mockReturnValue({}),
userPermissions: vi.fn(() => ({
- permissions: {
+ data: {
create_linode: false,
},
})),
@@ -52,17 +52,20 @@ describe('Security', () => {
component: ,
});
- await waitFor(() => {
- const rootPasswordInput = getByLabelText('Root Password');
+ await waitFor(
+ () => {
+ const rootPasswordInput = getByLabelText('Root Password');
- expect(rootPasswordInput).toBeVisible();
- expect(rootPasswordInput).toBeDisabled();
- });
+ expect(rootPasswordInput).toBeVisible();
+ expect(rootPasswordInput).toBeDisabled();
+ },
+ { timeout: 5_000 }
+ );
});
it('should enable the root password input if the user does has create_linode permission', async () => {
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: true,
},
});
@@ -92,7 +95,7 @@ describe('Security', () => {
it('should disable an "Add An SSH Key" button if the user does not have create_linode permission', async () => {
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: false,
},
});
@@ -109,7 +112,7 @@ describe('Security', () => {
it('should enable an "Add An SSH Key" button if the user has create_linode permission', async () => {
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: true,
},
});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx
index bada708aef6..781fe44396a 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Security.tsx
@@ -45,7 +45,7 @@ export const Security = () => {
selectedRegion?.id ?? ''
);
- const { permissions } = usePermissions('account', ['create_linode']);
+ const { data: permissions } = usePermissions('account', ['create_linode']);
return (
@@ -117,6 +117,7 @@ export const Security = () => {
onChange={(checked) =>
field.onChange(checked ? 'enabled' : 'disabled')
}
+ sxCheckbox={{ paddingLeft: '0px' }}
/>
)}
/>
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Backups/backupsLazyRoute.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Backups/backupsLazyRoute.ts
new file mode 100644
index 00000000000..a097f93c5b2
--- /dev/null
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Backups/backupsLazyRoute.ts
@@ -0,0 +1,7 @@
+import { createLazyRoute } from '@tanstack/react-router';
+
+import { Backups } from './Backups';
+
+export const backupsLazyRoute = createLazyRoute('/linodes/create/backups')({
+ component: Backups,
+});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Clone/cloneLazyRoute.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Clone/cloneLazyRoute.ts
new file mode 100644
index 00000000000..eccf49dd75a
--- /dev/null
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Clone/cloneLazyRoute.ts
@@ -0,0 +1,7 @@
+import { createLazyRoute } from '@tanstack/react-router';
+
+import { Clone } from './Clone';
+
+export const cloneLazyRoute = createLazyRoute('/linodes/create/clone')({
+ component: Clone,
+});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.test.tsx
index eb7614780a2..e3d3f068610 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.test.tsx
@@ -9,7 +9,7 @@ const queryMocks = vi.hoisted(() => ({
useParams: vi.fn(),
useSearch: vi.fn(),
userPermissions: vi.fn(() => ({
- permissions: {
+ data: {
create_linode: false,
},
})),
@@ -60,7 +60,7 @@ describe('Images', () => {
it('renders an enables image select, if user has create_linode permission', () => {
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: true,
},
});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx
index 7fbd8f25315..717cdc85696 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx
@@ -32,7 +32,7 @@ export const Images = () => {
});
const queryClient = useQueryClient();
- const { permissions } = usePermissions('account', ['create_linode']);
+ const { data: permissions } = usePermissions('account', ['create_linode']);
const regionId = useWatch({ control, name: 'region' });
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/marketPlaceLazyRoute.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/marketPlaceLazyRoute.ts
new file mode 100644
index 00000000000..9ad7a17e79c
--- /dev/null
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/marketPlaceLazyRoute.ts
@@ -0,0 +1,9 @@
+import { createLazyRoute } from '@tanstack/react-router';
+
+import { Marketplace } from './Marketplace';
+
+export const marketPlaceLazyRoute = createLazyRoute(
+ '/linodes/create/marketplace'
+)({
+ component: Marketplace,
+});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/OperatingSystems.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/OperatingSystems.test.tsx
index 9c24f808bf8..23dcfaea4f9 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/OperatingSystems.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/OperatingSystems.test.tsx
@@ -9,7 +9,7 @@ const queryMocks = vi.hoisted(() => ({
useParams: vi.fn(),
useSearch: vi.fn(),
userPermissions: vi.fn(() => ({
- permissions: {
+ data: {
create_linode: false,
},
})),
@@ -69,7 +69,7 @@ describe('OperatingSystems', () => {
it('should enable "ImageSelect" component if the user does has create_linode permission', async () => {
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: true,
},
});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/OperatingSystems.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/OperatingSystems.tsx
index 61c9b5bc44d..dbfa4b981d2 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/OperatingSystems.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/OperatingSystems.tsx
@@ -34,7 +34,7 @@ export const OperatingSystems = () => {
const { data: region } = useRegionQuery(regionId);
- const { permissions } = usePermissions('account', ['create_linode']);
+ const { data: permissions } = usePermissions('account', ['create_linode']);
const onChange = async (image: Image | null) => {
field.onChange(image?.id ?? null);
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptImages.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptImages.tsx
index 9fff703f417..c5fda5f1250 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptImages.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptImages.tsx
@@ -5,7 +5,7 @@ import { Controller, useWatch } from 'react-hook-form';
import { ImageSelect } from 'src/components/ImageSelect/ImageSelect';
-import { useLinodeCreateQueryParams } from '../../utilities';
+import { useGetLinodeCreateType } from '../utils/useGetLinodeCreateType';
import type { CreateLinodeRequest, Image } from '@linode/api-v4';
@@ -14,7 +14,7 @@ export const StackScriptImages = () => {
name: 'stackscript_id',
});
- const { params } = useLinodeCreateQueryParams();
+ const createType = useGetLinodeCreateType();
const hasStackScriptSelected =
stackscriptId !== null && stackscriptId !== undefined;
@@ -34,7 +34,7 @@ export const StackScriptImages = () => {
const helperText = !hasStackScriptSelected
? `Select ${
- params.type === 'One-Click' ? 'an app' : 'a StackScript'
+ createType === 'One-Click' ? 'an app' : 'a StackScript'
} to see compatible Images.`
: undefined;
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelection.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelection.tsx
index e34a1cfd045..0bd4d9a9344 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelection.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelection.tsx
@@ -1,4 +1,5 @@
import { Notice, Paper, Typography } from '@linode/ui';
+import { useNavigate, useSearch } from '@tanstack/react-router';
import React from 'react';
import { useFormContext } from 'react-hook-form';
@@ -8,21 +9,26 @@ import { TabList } from 'src/components/Tabs/TabList';
import { TabPanels } from 'src/components/Tabs/TabPanels';
import { Tabs } from 'src/components/Tabs/Tabs';
-import { useLinodeCreateQueryParams } from '../../utilities';
import { StackScriptSelectionList } from './StackScriptSelectionList';
import { getStackScriptTabIndex, tabs } from './utilities';
import type { CreateLinodeRequest } from '@linode/api-v4';
export const StackScriptSelection = () => {
- const { params, updateParams } = useLinodeCreateQueryParams();
+ const navigate = useNavigate();
+ const search = useSearch({
+ from: '/linodes/create/stackscripts',
+ });
const { formState, reset } = useFormContext();
const onTabChange = (index: number) => {
// Update the "subtype" query param. (This switches between "Community" and "Account" tabs).
- updateParams({
- stackScriptID: undefined,
- subtype: tabs[index],
+ navigate({
+ to: `/linodes/create/stackscripts`,
+ search: {
+ subtype: tabs[index],
+ stackScriptID: undefined,
+ },
});
// Reset the selected image, the selected StackScript, and the StackScript data when changing tabs.
reset((prev) => ({
@@ -43,7 +49,7 @@ export const StackScriptSelection = () => {
)}
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.test.tsx
index d5849204891..acf8037383a 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.test.tsx
@@ -70,12 +70,7 @@ describe('StackScriptSelectionList', () => {
const { findByLabelText, getByText } = renderWithThemeAndHookFormContext({
component: ,
options: {
- initialRoute: '/linodes/create',
- MemoryRouter: {
- initialEntries: [
- '/linodes/create?type=StackScripts&subtype=Account&stackScriptID=921609',
- ],
- },
+ initialRoute: '/linodes/create/stackscripts',
},
});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.tsx
index 1cb4c92700c..eacbd01a048 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/StackScriptSelectionList.tsx
@@ -15,7 +15,7 @@ import {
TooltipIcon,
} from '@linode/ui';
import { useQueryClient } from '@tanstack/react-query';
-import { useLocation } from '@tanstack/react-router';
+import { useLocation, useNavigate, useSearch } from '@tanstack/react-router';
import React, { useState } from 'react';
import { useController, useFormContext } from 'react-hook-form';
import { Waypoint } from 'react-waypoint';
@@ -33,10 +33,7 @@ import { TableSortCell } from 'src/components/TableSortCell';
import { StackScriptSearchHelperText } from 'src/features/StackScripts/Partials/StackScriptSearchHelperText';
import { useOrderV2 } from 'src/hooks/useOrderV2';
-import {
- getGeneratedLinodeLabel,
- useLinodeCreateQueryParams,
-} from '../../utilities';
+import { getGeneratedLinodeLabel } from '../../utilities';
import { StackScriptDetailsDialog } from './StackScriptDetailsDialog';
import { StackScriptSelectionRow } from './StackScriptSelectionRow';
import { getDefaultUDFData } from './UserDefinedFields/utilities';
@@ -54,6 +51,10 @@ interface Props {
export const StackScriptSelectionList = ({ type }: Props) => {
const [query, setQuery] = useState();
+ const search = useSearch({
+ strict: false,
+ });
+ const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
@@ -65,8 +66,10 @@ export const StackScriptSelectionList = ({ type }: Props) => {
orderBy: 'deployments_total',
},
from: location.pathname.includes('/linodes/create')
- ? '/linodes/create'
- : '/linodes/$linodeId',
+ ? '/linodes/create/stackscripts'
+ : location.pathname === '/linodes'
+ ? '/linodes'
+ : '/linodes/$linodeId',
},
preferenceKey: 'linode-clone-stackscripts',
});
@@ -87,13 +90,11 @@ export const StackScriptSelectionList = ({ type }: Props) => {
const [selectedStackScriptId, setSelectedStackScriptId] = useState();
- const { params, updateParams } = useLinodeCreateQueryParams();
-
- const hasPreselectedStackScript = Boolean(params.stackScriptID);
+ const hasPreselectedStackScript = Boolean(search.stackScriptID);
const { data: stackscript, isLoading: isSelectedStackScriptLoading } =
useStackScriptQuery(
- params.stackScriptID ? Number(params.stackScriptID) : -1,
+ search.stackScriptID ? Number(search.stackScriptID) : -1,
hasPreselectedStackScript
);
@@ -156,7 +157,12 @@ export const StackScriptSelectionList = ({ type }: Props) => {
onClick={() => {
field.onChange(null);
setValue('image', null);
- updateParams({ stackScriptID: undefined });
+ navigate({
+ to: `/linodes/create/stackscripts`,
+ search: {
+ stackScriptID: undefined,
+ },
+ });
}}
>
Choose Another StackScript
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/stackScriptsLazyRoute.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/stackScriptsLazyRoute.ts
new file mode 100644
index 00000000000..ffbc0dae948
--- /dev/null
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/stackScriptsLazyRoute.ts
@@ -0,0 +1,9 @@
+import { createLazyRoute } from '@tanstack/react-router';
+
+import { StackScripts } from './StackScripts';
+
+export const stackScriptsLazyRoute = createLazyRoute(
+ '/linodes/create/stackscripts'
+)({
+ component: StackScripts,
+});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/imagesLazyRoutes.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/imagesLazyRoutes.ts
new file mode 100644
index 00000000000..198f2d8310b
--- /dev/null
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/imagesLazyRoutes.ts
@@ -0,0 +1,7 @@
+import { createLazyRoute } from '@tanstack/react-router';
+
+import { Images } from './Images';
+
+export const imagesLazyRoute = createLazyRoute('/linodes/create/images')({
+ component: Images,
+});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/operatingSystemsLazyRoute.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/operatingSystemsLazyRoute.ts
new file mode 100644
index 00000000000..d2bbf8e5d1f
--- /dev/null
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/operatingSystemsLazyRoute.ts
@@ -0,0 +1,7 @@
+import { createLazyRoute } from '@tanstack/react-router';
+
+import { OperatingSystems } from 'src/features/Linodes/LinodeCreate/Tabs/OperatingSystems';
+
+export const operatingSystemsLazyRoute = createLazyRoute('/linodes/create/os')({
+ component: OperatingSystems,
+});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType.ts
new file mode 100644
index 00000000000..aafbbbe43a3
--- /dev/null
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType.ts
@@ -0,0 +1,47 @@
+import { useLocation } from '@tanstack/react-router';
+
+import type { LinodeCreateType } from '@linode/utilities';
+import type { LinkProps } from '@tanstack/react-router';
+
+type LinodeCreatePathSegments =
+ | 'backups'
+ | 'clone'
+ | 'images'
+ | 'marketplace'
+ | 'os'
+ | 'stackscripts';
+
+export const linodesCreateTypesMap = new Map<
+ LinodeCreateType,
+ LinodeCreatePathSegments
+>([
+ ['Backups', 'backups'],
+ ['Clone Linode', 'clone'],
+ ['Images', 'images'],
+ ['One-Click', 'marketplace'],
+ ['OS', 'os'],
+ ['StackScripts', 'stackscripts'],
+]);
+
+export const linodesCreateTypes = Array.from(linodesCreateTypesMap.keys());
+
+export const useGetLinodeCreateType = () => {
+ const { pathname } = useLocation() as { pathname: LinkProps['to'] };
+
+ switch (pathname) {
+ case '/linodes/create/backups':
+ return 'Backups';
+ case '/linodes/create/clone':
+ return 'Clone Linode';
+ case '/linodes/create/images':
+ return 'Images';
+ case '/linodes/create/marketplace':
+ return 'One-Click';
+ case '/linodes/create/os':
+ return 'OS';
+ case '/linodes/create/stackscripts':
+ return 'StackScripts';
+ default:
+ return 'OS';
+ }
+};
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.test.tsx
index 998b134cf49..87dc7b09080 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.test.tsx
@@ -1,3 +1,4 @@
+import { screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import React from 'react';
@@ -6,36 +7,14 @@ import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers';
import { TwoStepRegion } from './TwoStepRegion';
-const queryMocks = vi.hoisted(() => ({
- useNavigate: vi.fn(),
- useParams: vi.fn(),
- useSearch: vi.fn(),
-}));
-
-vi.mock('@tanstack/react-router', async () => {
- const actual = await vi.importActual('@tanstack/react-router');
- return {
- ...actual,
- useNavigate: queryMocks.useNavigate,
- useSearch: queryMocks.useSearch,
- useParams: queryMocks.useParams,
- };
-});
-
describe('TwoStepRegion', () => {
- beforeEach(() => {
- queryMocks.useNavigate.mockReturnValue(vi.fn());
- queryMocks.useSearch.mockReturnValue({});
- queryMocks.useParams.mockReturnValue({});
- });
-
it('should render a heading and docs link', () => {
- const { getAllByText, getByText } = renderWithThemeAndHookFormContext({
+ renderWithThemeAndHookFormContext({
component: ,
});
- const heading = getAllByText('Region')[0];
- const link = getByText(DOCS_LINK_LABEL_DC_PRICING);
+ const heading = screen.getAllByText('Region')[0];
+ const link = screen.getByText(DOCS_LINK_LABEL_DC_PRICING);
expect(heading).toBeVisible();
expect(heading.tagName).toBe('H2');
@@ -46,36 +25,36 @@ describe('TwoStepRegion', () => {
});
it('should render two tabs, Core and Distributed', () => {
- const { getAllByRole } = renderWithThemeAndHookFormContext({
+ renderWithThemeAndHookFormContext({
component: ,
});
- const tabs = getAllByRole('tab');
- expect(tabs[0]).toHaveTextContent('Core');
- expect(tabs[1]).toHaveTextContent('Distributed');
+ const [coreTab, distributedTab] = screen.getAllByRole('tab');
+
+ expect(coreTab).toHaveTextContent('Core');
+ expect(distributedTab).toHaveTextContent('Distributed');
});
it('should render a Region Select for the Core tab', () => {
- const { getByPlaceholderText } = renderWithThemeAndHookFormContext({
+ renderWithThemeAndHookFormContext({
component: ,
});
- const select = getByPlaceholderText('Select a Region');
+ const regionSelect = screen.getByPlaceholderText('Select a Region');
- expect(select).toBeVisible();
- expect(select).toBeEnabled();
+ expect(regionSelect).toBeVisible();
+ expect(regionSelect).toBeEnabled();
});
it('should only display core regions in the Core tab region select', async () => {
- const { getByPlaceholderText, getByRole } =
- renderWithThemeAndHookFormContext({
- component: ,
- });
+ renderWithThemeAndHookFormContext({
+ component: ,
+ });
- const select = getByPlaceholderText('Select a Region');
- await userEvent.click(select);
+ const regionSelect = screen.getByPlaceholderText('Select a Region');
+ await userEvent.click(regionSelect);
- const dropdown = getByRole('listbox');
+ const dropdown = screen.getByRole('listbox');
expect(dropdown.innerHTML).toContain('US, Newark');
expect(dropdown.innerHTML).not.toContain(
'US, Gecko Distributed Region Test'
@@ -83,36 +62,57 @@ describe('TwoStepRegion', () => {
});
it('should only display distributed regions in the Distributed tab region select', async () => {
- const { getAllByRole, getByPlaceholderText, getByRole } =
- renderWithThemeAndHookFormContext({
- component: ,
- });
+ renderWithThemeAndHookFormContext({
+ component: ,
+ });
- const tabs = getAllByRole('tab');
- await userEvent.click(tabs[1]);
+ const distributedTab = screen.getByRole('tab', { name: 'Distributed' });
+ await userEvent.click(distributedTab);
- const select = getByPlaceholderText('Select a Region');
- await userEvent.click(select);
+ const regionSelect = screen.getByPlaceholderText('Select a Region');
+ await userEvent.click(regionSelect);
- const dropdown = getByRole('listbox');
+ const dropdown = screen.getByRole('listbox');
expect(dropdown.innerHTML).toContain('US, Gecko Distributed Region Test');
expect(dropdown.innerHTML).not.toContain('US, Newark');
});
it('should render a Geographical Area select with All pre-selected and a Region Select for the Distributed tab', async () => {
- const { getAllByRole } = renderWithThemeAndHookFormContext({
+ renderWithThemeAndHookFormContext({
component: ,
});
- const tabs = getAllByRole('tab');
- await userEvent.click(tabs[1]);
+ const [, distributedTab] = screen.getAllByRole('tab');
+ await userEvent.click(distributedTab);
- const inputs = getAllByRole('combobox');
- const geographicalAreaSelect = inputs[0];
- const regionSelect = inputs[1];
+ const [geographicalAreaSelect, regionSelect] =
+ screen.getAllByRole('combobox');
expect(geographicalAreaSelect).toHaveAttribute('value', 'All');
expect(regionSelect).toHaveAttribute('placeholder', 'Select a Region');
expect(regionSelect).toBeEnabled();
});
+
+ it('should persist the selected Geographical Area when switching between the Core and Distributed tabs', async () => {
+ renderWithThemeAndHookFormContext({
+ component: ,
+ });
+
+ const [coreTab, distributedTab] = screen.getAllByRole('tab');
+ await userEvent.click(distributedTab);
+
+ const geographicalAreaSelect = screen.getByLabelText('Geographical Area');
+ // Open the dropdown
+ await userEvent.click(geographicalAreaSelect);
+
+ const lastMonthOption = screen.getByText('North America');
+ await userEvent.click(lastMonthOption);
+ expect(geographicalAreaSelect).toHaveAttribute('value', 'North America');
+
+ // Geographical area selection should persist after switching tabs
+ await userEvent.click(coreTab);
+ await userEvent.click(distributedTab);
+ const geographicalAreaSelect2 = screen.getByLabelText('Geographical Area');
+ expect(geographicalAreaSelect2).toHaveAttribute('value', 'North America');
+ });
});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx b/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx
index c92b038ba8f..1b7eaa011c7 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/TwoStepRegion.tsx
@@ -16,7 +16,7 @@ import { sendLinodeCreateDocsEvent } from 'src/utilities/analytics/customEventAn
import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics';
import { DOCS_LINK_LABEL_DC_PRICING } from 'src/utilities/pricing/constants';
-import { useLinodeCreateQueryParams } from './utilities';
+import { useGetLinodeCreateType } from './Tabs/utils/useGetLinodeCreateType';
import type { Region as RegionType } from '@linode/api-v4';
import type {
@@ -73,7 +73,7 @@ export const TwoStepRegion = (props: CombinedProps) => {
React.useState('distributed');
const { data: regions } = useRegionsQuery();
- const { params } = useLinodeCreateQueryParams();
+ const createType = useGetLinodeCreateType();
const flags = useFlags();
const { isGeckoLAEnabled } = useIsGeckoEnabled(
flags.gecko2?.enabled,
@@ -89,7 +89,7 @@ export const TwoStepRegion = (props: CombinedProps) => {
label={DOCS_LINK_LABEL_DC_PRICING}
onClick={() =>
sendLinodeCreateFormInputEvent({
- createType: params.type ?? 'OS',
+ createType: createType ?? 'OS',
headerName: 'Region',
interaction: 'click',
label: DOCS_LINK_LABEL_DC_PRICING,
@@ -140,6 +140,9 @@ export const TwoStepRegion = (props: CombinedProps) => {
}
}}
options={GEOGRAPHICAL_AREA_OPTIONS}
+ value={GEOGRAPHICAL_AREA_OPTIONS.find(
+ (option) => option.value === regionFilter
+ )}
/>
{
[regions, regionId]
);
- const { permissions } = usePermissions('account', ['create_linode']);
+ const { data: permissions } = usePermissions('account', ['create_linode']);
if (!region?.capabilities.includes('Metadata')) {
return null;
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/UserData/UserDataHeading.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/UserData/UserDataHeading.test.tsx
index 3c7b7722fc0..2fc80320c42 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/UserData/UserDataHeading.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/UserData/UserDataHeading.test.tsx
@@ -5,7 +5,6 @@ import { renderWithTheme } from 'src/utilities/testHelpers';
import { UserDataHeading } from './UserDataHeading';
const queryMocks = vi.hoisted(() => ({
- useSearch: vi.fn(),
useParams: vi.fn(),
}));
@@ -13,26 +12,20 @@ vi.mock('@tanstack/react-router', async () => {
const actual = await vi.importActual('@tanstack/react-router');
return {
...actual,
- useSearch: queryMocks.useSearch,
useParams: queryMocks.useParams,
};
});
describe('UserDataHeading', () => {
beforeEach(() => {
- queryMocks.useSearch.mockReturnValue({});
queryMocks.useParams.mockReturnValue({
linodeId: '123',
});
});
it('should display a warning in the header for cloning', async () => {
- queryMocks.useSearch.mockReturnValue({
- type: 'Clone Linode',
- });
-
const { getByText } = renderWithTheme(, {
- initialRoute: '/linodes/create',
+ initialRoute: '/linodes/create/clone',
});
expect(
@@ -43,12 +36,8 @@ describe('UserDataHeading', () => {
});
it('should display a warning in the header for creating from a Linode backup', async () => {
- queryMocks.useSearch.mockReturnValue({
- type: 'Backups',
- });
-
const { getByText } = renderWithTheme(, {
- initialRoute: '/linodes/create',
+ initialRoute: '/linodes/create/backups',
});
expect(
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/UserData/UserDataHeading.tsx b/packages/manager/src/features/Linodes/LinodeCreate/UserData/UserDataHeading.tsx
index 6b099af27cd..c902a8f54ac 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/UserData/UserDataHeading.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/UserData/UserDataHeading.tsx
@@ -3,12 +3,12 @@ import React from 'react';
import { Link } from 'src/components/Link';
-import { useLinodeCreateQueryParams } from '../utilities';
+import { useGetLinodeCreateType } from '../Tabs/utils/useGetLinodeCreateType';
import type { LinodeCreateType } from '@linode/utilities';
export const UserDataHeading = () => {
- const { params } = useLinodeCreateQueryParams();
+ const createType = useGetLinodeCreateType();
const warningMessageMap: Record = {
Backups:
@@ -21,7 +21,7 @@ export const UserDataHeading = () => {
StackScripts: null,
};
- const warningMessage = params.type ? warningMessageMap[params.type] : null;
+ const warningMessage = createType ? warningMessageMap[createType] : null;
return (
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/VLAN/VLAN.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/VLAN/VLAN.test.tsx
index 17ad2ddccf6..d10bbdb2be1 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/VLAN/VLAN.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/VLAN/VLAN.test.tsx
@@ -12,7 +12,7 @@ const queryMocks = vi.hoisted(() => ({
useParams: vi.fn(),
useSearch: vi.fn(),
userPermissions: vi.fn(() => ({
- permissions: {
+ data: {
create_linode: false,
},
})),
@@ -69,7 +69,7 @@ describe('VLAN', () => {
const region = regionFactory.build({ capabilities: ['Vlans'] });
queryMocks.userPermissions.mockReturnValue({
- permissions: {
+ data: {
create_linode: true,
},
});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/VLAN/VLAN.tsx b/packages/manager/src/features/Linodes/LinodeCreate/VLAN/VLAN.tsx
index 7bb7f2c8a95..18860a918b3 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/VLAN/VLAN.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/VLAN/VLAN.tsx
@@ -14,16 +14,16 @@ import { VLANSelect } from 'src/components/VLANSelect';
import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
import { VLANAvailabilityNotice } from '../Networking/VLANAvailabilityNotice';
-import { useLinodeCreateQueryParams } from '../utilities';
+import { useGetLinodeCreateType } from '../Tabs/utils/useGetLinodeCreateType';
import type { CreateLinodeRequest } from '@linode/api-v4';
export const VLAN = () => {
const { control } = useFormContext();
- const { params } = useLinodeCreateQueryParams();
+ const createType = useGetLinodeCreateType();
- const { permissions } = usePermissions('account', ['create_linode']);
+ const { data: permissions } = usePermissions('account', ['create_linode']);
const [imageId, regionId] = useWatch({ control, name: ['image', 'region'] });
@@ -32,7 +32,7 @@ export const VLAN = () => {
const regionSupportsVLANs =
selectedRegion?.capabilities.includes('Vlans') ?? false;
- const isCreatingFromBackup = params.type === 'Backups';
+ const isCreatingFromBackup = createType === 'Backups';
const disabled =
!imageId ||
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx
index 2f9a094f50c..fb40be0ac3f 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx
@@ -27,7 +27,7 @@ import { VPCCreateDrawer } from 'src/features/VPCs/VPCCreateDrawer/VPCCreateDraw
import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics';
import { VPCAvailabilityNotice } from '../Networking/VPCAvailabilityNotice';
-import { useLinodeCreateQueryParams } from '../utilities';
+import { useGetLinodeCreateType } from '../Tabs/utils/useGetLinodeCreateType';
import { VPCRanges } from './VPCRanges';
import type { CreateLinodeRequest } from '@linode/api-v4';
@@ -70,10 +70,10 @@ export const VPC = () => {
? 'Allow Linode to communicate in an isolated environment.'
: 'Assign this Linode to an existing VPC.';
- const { params } = useLinodeCreateQueryParams();
+ const createType = useGetLinodeCreateType();
const vpcFormEventOptions: LinodeCreateFormEventOptions = {
- createType: params.type ?? 'OS',
+ createType: createType ?? 'OS',
headerName: 'VPC',
interaction: 'click',
label: 'VPC',
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/index.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/index.test.tsx
index a76a67edf75..4d402c7b206 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/index.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/index.test.tsx
@@ -29,7 +29,7 @@ describe('Linode Create', () => {
it('Should not render VLANs when cloning', () => {
const { queryByText } = renderWithTheme(, {
- MemoryRouter: { initialEntries: ['/linodes/create?type=Clone+Linode'] },
+ MemoryRouter: { initialEntries: ['/linodes/create/clone'] },
});
expect(queryByText('VLAN')).toBeNull();
@@ -37,7 +37,7 @@ describe('Linode Create', () => {
it('Should not render access panel items when cloning', () => {
const { queryByText } = renderWithTheme(, {
- MemoryRouter: { initialEntries: ['/linodes/create?type=Clone+Linode'] },
+ MemoryRouter: { initialEntries: ['/linodes/create/clone'] },
});
expect(queryByText('Root Password')).toBeNull();
@@ -46,7 +46,7 @@ describe('Linode Create', () => {
it('Should not render the region select when creating from a backup', () => {
const { queryByText } = renderWithTheme(, {
- MemoryRouter: { initialEntries: ['/linodes/create?type=Backups'] },
+ MemoryRouter: { initialEntries: ['/linodes/create/backups'] },
});
expect(queryByText('Region')).toBeNull();
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/index.tsx b/packages/manager/src/features/Linodes/LinodeCreate/index.tsx
index 2b3321601a2..993ef94c63b 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/index.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/index.tsx
@@ -8,7 +8,12 @@ import {
import { CircleProgress, Notice, Stack } from '@linode/ui';
import { scrollErrorIntoView } from '@linode/utilities';
import { useQueryClient } from '@tanstack/react-query';
-import { useNavigate } from '@tanstack/react-router';
+import {
+ Outlet,
+ useLocation,
+ useNavigate,
+ useSearch,
+} from '@tanstack/react-router';
import { useSnackbar } from 'notistack';
import React, { useEffect, useRef } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
@@ -16,18 +21,18 @@ import type { SubmitHandler } from 'react-hook-form';
import { DocumentTitleSegment } from 'src/components/DocumentTitle';
import { LandingHeader } from 'src/components/LandingHeader';
-import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel';
-import { Tab } from 'src/components/Tabs/Tab';
-import { TabList } from 'src/components/Tabs/TabList';
import { TabPanels } from 'src/components/Tabs/TabPanels';
import { Tabs } from 'src/components/Tabs/Tabs';
+import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList';
import {
getRestrictedResourceText,
useVMHostMaintenanceEnabled,
} from 'src/features/Account/utils';
import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
+import { useGetLinodeCreateType } from 'src/features/Linodes/LinodeCreate/Tabs/utils/useGetLinodeCreateType';
import { useFlags } from 'src/hooks/useFlags';
import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled';
+import { useTabs } from 'src/hooks/useTabs';
import {
sendLinodeCreateFormInputEvent,
sendLinodeCreateFormSubmitEvent,
@@ -52,21 +57,12 @@ import { getLinodeCreateResolver } from './resolvers';
import { Security } from './Security';
import { SMTP } from './SMTP';
import { Summary } from './Summary/Summary';
-import { Backups } from './Tabs/Backups/Backups';
-import { Clone } from './Tabs/Clone/Clone';
-import { Images } from './Tabs/Images';
-import { Marketplace } from './Tabs/Marketplace/Marketplace';
-import { OperatingSystems } from './Tabs/OperatingSystems';
-import { StackScripts } from './Tabs/StackScripts/StackScripts';
import { UserData } from './UserData/UserData';
import {
captureLinodeCreateAnalyticsEvent,
defaultValues,
getLinodeCreatePayload,
- getTabIndex,
- tabs,
useHandleLinodeCreateAnalyticsFormError,
- useLinodeCreateQueryParams,
} from './utilities';
import { VLAN } from './VLAN/VLAN';
import { VPC } from './VPC/VPC';
@@ -77,12 +73,16 @@ import type {
} from './utilities';
export const LinodeCreate = () => {
- const { params, setParams } = useLinodeCreateQueryParams();
+ const location = useLocation();
+ const search = useSearch({
+ from: '/linodes/create',
+ });
const { secureVMNoticesEnabled } = useSecureVMNoticesEnabled();
const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled();
const { data: profile } = useProfile();
const { isLinodeCloneFirewallEnabled } = useIsLinodeCloneFirewallEnabled();
const { isVMHostMaintenanceEnabled } = useVMHostMaintenanceEnabled();
+ const linodeCreateType = useGetLinodeCreateType();
const { aclpBetaServices } = useFlags();
@@ -96,12 +96,12 @@ export const LinodeCreate = () => {
const form = useForm({
context: { isLinodeInterfacesEnabled, profile, secureVMNoticesEnabled },
defaultValues: () =>
- defaultValues(params, queryClient, {
+ defaultValues(linodeCreateType, search, queryClient, {
isLinodeInterfacesEnabled,
isVMHostMaintenanceEnabled,
}),
mode: 'onBlur',
- resolver: getLinodeCreateResolver(params.type, queryClient),
+ resolver: getLinodeCreateResolver(linodeCreateType, queryClient),
shouldFocusError: false, // We handle this ourselves with `scrollErrorIntoView`
});
@@ -111,23 +111,43 @@ export const LinodeCreate = () => {
const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements();
const { handleLinodeCreateAnalyticsFormError } =
- useHandleLinodeCreateAnalyticsFormError(params.type ?? 'OS');
+ useHandleLinodeCreateAnalyticsFormError(linodeCreateType ?? 'OS');
- const currentTabIndex = getTabIndex(params.type);
+ const { data: permissions } = usePermissions('account', ['create_linode']);
- const { permissions } = usePermissions('account', ['create_linode']);
+ const { tabs, handleTabChange, tabIndex } = useTabs([
+ {
+ title: 'OS',
+ to: '/linodes/create/os',
+ },
+ {
+ title: 'Marketplace',
+ to: '/linodes/create/marketplace',
+ },
+ {
+ title: 'StackScripts',
+ to: '/linodes/create/stackscripts',
+ },
+ {
+ title: 'Images',
+ to: '/linodes/create/images',
+ },
+ {
+ title: 'Backups',
+ to: '/linodes/create/backups',
+ },
+ {
+ title: 'Clone Linode',
+ to: '/linodes/create/clone',
+ },
+ ]);
const onTabChange = (index: number) => {
- if (index !== currentTabIndex) {
- const newTab = tabs[index];
-
- const newParams = { type: newTab };
-
- // Update tab "type" query param. (This changes the selected tab)
- setParams(newParams);
+ handleTabChange(index);
+ if (index !== tabIndex) {
// Get the default values for the new tab and reset the form
- defaultValues(newParams, queryClient, {
+ defaultValues(linodeCreateType, search, queryClient, {
isLinodeInterfacesEnabled,
isVMHostMaintenanceEnabled,
}).then(form.reset);
@@ -143,7 +163,7 @@ export const LinodeCreate = () => {
try {
const linode =
- params.type === 'Clone Linode'
+ linodeCreateType === 'Clone Linode'
? await cloneLinode({
sourceLinodeId: values.linode?.id ?? -1,
...payload,
@@ -163,12 +183,12 @@ export const LinodeCreate = () => {
captureLinodeCreateAnalyticsEvent({
queryClient,
secureVMNoticesEnabled,
- type: params.type ?? 'OS',
+ type: linodeCreateType ?? 'OS',
values,
});
sendLinodeCreateFormSubmitEvent({
- createType: params.type ?? 'OS',
+ createType: linodeCreateType ?? 'OS',
});
if (values.hasSignedEUAgreement) {
@@ -208,15 +228,24 @@ export const LinodeCreate = () => {
return ;
}
+ if (location.pathname === '/linodes/create') {
+ navigate({
+ to: '/linodes/create/os',
+ });
+ }
+
return (
sendLinodeCreateFormInputEvent({
- createType: params.type ?? 'OS',
+ createType: linodeCreateType ?? 'OS',
interaction: 'click',
label: 'Getting Started',
})
@@ -227,15 +256,8 @@ export const LinodeCreate = () => {
|