diff --git a/.github/workflows/e2e_schedule_and_push.yml b/.github/workflows/e2e_schedule_and_push.yml index 63578c1a25c..e95da872490 100644 --- a/.github/workflows/e2e_schedule_and_push.yml +++ b/.github/workflows/e2e_schedule_and_push.yml @@ -7,6 +7,7 @@ env: USER_4: ${{ secrets.USER_4 }} CLIENT_ID: ${{ secrets.REACT_APP_CLIENT_ID }} CY_TEST_FAIL_ON_MANAGED: 1 + CY_TEST_RESET_PREFERENCES: 1 on: schedule: - cron: "0 13 * * 1-5" diff --git a/docker-compose.yml b/docker-compose.yml index ced361320f6..b3cea0e4449 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,7 @@ x-e2e-env: CY_TEST_FEATURE_FLAGS: ${CY_TEST_FEATURE_FLAGS} CY_TEST_TAGS: ${CY_TEST_TAGS} CY_TEST_DISABLE_RETRIES: ${CY_TEST_DISABLE_RETRIES} + CY_TEST_RESET_PREFERENCES: ${CY_TEST_RESET_PREFERENCES} # Cypress environment variables for alternative parallelization. CY_TEST_SPLIT_RUN: ${CY_TEST_SPLIT_RUN} diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index a97bf29a06c..27ad221daee 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -223,6 +223,7 @@ Environment variables related to Cypress logging and reporting, as well as repor | `CY_TEST_DISABLE_RETRIES` | Disable test retries on failure in CI | `1` | Unset; disabled by default | | `CY_TEST_FAIL_ON_MANAGED` | Fail affected tests when Managed is enabled | `1` | Unset; disabled by default | | `CY_TEST_GENWEIGHTS` | Generate and output test weights to the given path | `./weights.json` | Unset; disabled by default | +| `CY_TEST_RESET_PREFERENCES` | Reset user preferences when test run begins | `1` | Unset; disabled by default | ###### Performance @@ -281,17 +282,15 @@ Environment variables that can be used to improve test performance in some scena cy.wait('@getProfilePreferences'); cy.wait('@getAccountSettings'); - /* `getVisible` defined in /cypress/support/helpers.ts - plus a few other commonly used commands shortened as methods */ - getVisible(`tr[data-qa-linode="${label}"]`).within(() => { + cy.get(`tr[data-qa-linode="${label}"]`).should('be.visible').within(() => { // use `within` to search inside/use data from/assert on a specific page element cy.get(`[data-qa-ip-main]`) // `realHover` and more real event methods from cypress real events plugin .realHover() .then(() => { - getVisible(`[aria-label="Copy ${ip} to clipboard"]`); + cy.get(`[aria-label="Copy ${ip} to clipboard"]`).should('be.visible'); }); - getVisible(`[aria-label="Action menu for Linode ${label}"]`); + cy.get(`[aria-label="Action menu for Linode ${label}"]`).should('be.visible'); }); // `findByText` and others from cypress testing library plugin cy.findByText('Oh Snap!', { timeout: 1000 }).should('not.exist'); diff --git a/package.json b/package.json index 46ba965031f..a23e6466eff 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "Apache-2.0", "devDependencies": { "husky": "^9.1.6", - "typescript": "^5.5.4", + "typescript": "^5.7.3", "vitest": "^2.1.1" }, "scripts": { @@ -52,7 +52,8 @@ "node-fetch": "^2.6.7", "yaml": "^2.3.0", "semver": "^7.5.2", - "cookie": "^0.7.0" + "cookie": "^0.7.0", + "nanoid": "^3.3.8" }, "workspaces": { "packages": [ diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 2094dabd71e..c3f91bdc99e 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,20 @@ +## [2025-01-28] - v0.133.0 + +### Changed: + +- Allow `cipher_suite` to be `none` in `NodeBalancerConfig` and `CreateNodeBalancerConfig` ([#11515](https://github.com/linode/manager/pull/11515)) + +### Tech Stories: + +- Update `tsconfig.json` to use `bundler` moduleResolution ([#11487](https://github.com/linode/manager/pull/11487)) + +### Upcoming Features: + +- Update types for IAM and resources API ([#11429](https://github.com/linode/manager/pull/11429)) +- Add types for Quotas endpoints ([#11493](https://github.com/linode/manager/pull/11493)) +- Add Notification Channel related types to cloudpulse/alerts.ts ([#11511](https://github.com/linode/manager/pull/11511)) + + ## [2025-01-14] - v0.132.0 ### Added: diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index ccb47929f77..9be35fb4b2e 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.132.0", + "version": "0.133.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 2e4e6c4658a..1fa7b63d280 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -20,6 +20,13 @@ export type MetricUnitType = | 'KB' | 'MB' | 'GB'; +export type NotificationStatus = 'Enabled' | 'Disabled'; +export type ChannelType = 'email' | 'slack' | 'pagerduty' | 'webhook'; +export type AlertNotificationType = 'default' | 'custom'; +type AlertNotificationEmail = 'email'; +type AlertNotificationSlack = 'slack'; +type AlertNotificationPagerDuty = 'pagerduty'; +type AlertNotificationWebHook = 'webhook'; export interface Dashboard { id: number; label: string; @@ -55,7 +62,7 @@ export interface Widgets { filters: Filters[]; serviceType: string; service_type: string; - resource_id: string[]; + entity_ids: string[]; time_granularity: TimeGranularity; time_duration: TimeDuration; unit: string; @@ -106,7 +113,7 @@ export interface Dimension { } export interface JWETokenPayLoad { - resource_ids: number[]; + entity_ids: number[]; } export interface JWEToken { @@ -120,7 +127,7 @@ export interface CloudPulseMetricsRequest { group_by: string; relative_time_duration: TimeDuration; time_granularity: TimeGranularity | undefined; - resource_ids: number[]; + entity_ids: number[]; } export interface CloudPulseMetricsResponse { @@ -218,3 +225,72 @@ export interface Alert { created: string; updated: string; } + +interface NotificationChannelAlerts { + id: number; + label: string; + url: string; + type: 'alerts-definitions'; +} +interface NotificationChannelBase { + id: number; + label: string; + channel_type: ChannelType; + type: AlertNotificationType; + status: NotificationStatus; + alerts: NotificationChannelAlerts[]; + created_by: string; + updated_by: string; + created_at: string; + updated_at: string; +} + +interface NotificationChannelEmail extends NotificationChannelBase { + channel_type: AlertNotificationEmail; + content: { + email: { + email_addresses: string[]; + subject: string; + message: string; + }; + }; +} + +interface NotificationChannelSlack extends NotificationChannelBase { + channel_type: AlertNotificationSlack; + content: { + slack: { + slack_webhook_url: string; + slack_channel: string; + message: string; + }; + }; +} + +interface NotificationChannelPagerDuty extends NotificationChannelBase { + channel_type: AlertNotificationPagerDuty; + content: { + pagerduty: { + service_api_key: string; + attributes: string[]; + description: string; + }; + }; +} +interface NotificationChannelWebHook extends NotificationChannelBase { + channel_type: AlertNotificationWebHook; + content: { + webhook: { + webhook_url: string; + http_headers: { + header_key: string; + header_value: string; + }[]; + }; + }; +} +export type NotificationChannel = + | NotificationChannelEmail + | NotificationChannelSlack + | NotificationChannelWebHook + | NotificationChannelPagerDuty; diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index dd996b686d2..fab28fabeb5 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -32,6 +32,8 @@ export * from './placement-groups'; export * from './profile'; +export * from './quotas'; + export * from './regions'; export * from './stackscripts'; diff --git a/packages/api-v4/src/nodebalancers/types.ts b/packages/api-v4/src/nodebalancers/types.ts index 68f89c7ac32..ef1e1b62e4b 100644 --- a/packages/api-v4/src/nodebalancers/types.ts +++ b/packages/api-v4/src/nodebalancers/types.ts @@ -94,7 +94,10 @@ export interface NodeBalancerConfig { stickiness: Stickiness; algorithm: Algorithm; ssl_fingerprint: string; - cipher_suite: 'recommended' | 'legacy'; + /** + * Is `none` when protocol is UDP + */ + cipher_suite: 'recommended' | 'legacy' | 'none'; nodes: NodeBalancerConfigNode[]; } @@ -160,7 +163,7 @@ export interface CreateNodeBalancerConfig { * @default 80 */ udp_check_port?: number; - cipher_suite?: 'recommended' | 'legacy'; + cipher_suite?: 'recommended' | 'legacy' | 'none'; ssl_cert?: string; ssl_key?: string; } diff --git a/packages/api-v4/src/quotas/index.ts b/packages/api-v4/src/quotas/index.ts new file mode 100644 index 00000000000..2b1b25700ed --- /dev/null +++ b/packages/api-v4/src/quotas/index.ts @@ -0,0 +1,3 @@ +export * from './types'; + +export * from './quotas'; diff --git a/packages/api-v4/src/quotas/quotas.ts b/packages/api-v4/src/quotas/quotas.ts new file mode 100644 index 00000000000..ecb9b8057d1 --- /dev/null +++ b/packages/api-v4/src/quotas/quotas.ts @@ -0,0 +1,36 @@ +import { Filter, Params, ResourcePage as Page } from 'src/types'; +import { API_ROOT } from '../constants'; +import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; +import { Quota, QuotaType } from './types'; + +/** + * getQuota + * + * Returns the details for a single quota within a particular service specified by `type`. + * + * @param type { QuotaType } retrieve a quota within this service type. + * @param id { number } the quota ID to look up. + */ +export const getQuota = (type: QuotaType, id: number) => + Request(setURL(`${API_ROOT}/${type}/quotas/${id}`), setMethod('GET')); + +/** + * getQuotas + * + * Returns a paginated list of quotas for a particular service specified by `type`. + * + * This request can be filtered on `quota_name`, `service_name` and `scope`. + * + * @param type { QuotaType } retrieve quotas within this service type. + */ +export const getQuotas = ( + type: QuotaType, + params: Params = {}, + filter: Filter = {} +) => + Request>( + setURL(`${API_ROOT}/${type}/quotas`), + setMethod('GET'), + setXFilter(filter), + setParams(params) + ); diff --git a/packages/api-v4/src/quotas/types.ts b/packages/api-v4/src/quotas/types.ts new file mode 100644 index 00000000000..23c42f00165 --- /dev/null +++ b/packages/api-v4/src/quotas/types.ts @@ -0,0 +1,70 @@ +import { ObjectStorageEndpointTypes } from 'src/object-storage'; +import { Region } from 'src/regions'; + +/** + * A Quota is a service used limit that is rated based on service metrics such + * as vCPUs used, instances or storage size. + */ +export interface Quota { + /** + * A unique identifier for the quota. + */ + quota_id: number; + + /** + * Customer facing label describing the quota. + */ + quota_name: string; + + /** + * Longer explanatory description for the quota. + */ + description: string; + + /** + * The account-wide limit for this service, measured in units + * specified by the `resource_metric` field. + */ + quota_limit: number; + + /** + * Current account usage, measured in units specified by the + * `resource_metric` field. + */ + used: number; + + /** + * The unit of measurement for this service limit. + */ + resource_metric: + | 'instance' + | 'CPU' + | 'GPU' + | 'VPU' + | 'cluster' + | 'node' + | 'bucket' + | 'object' + | 'byte'; + + /** + * The region slug to which this limit applies. + */ + region_applied: Region['id'] | 'global'; + + /** + * The OBJ endpoint type to which this limit applies. + * + * For OBJ limits only. + */ + endpoint_type?: ObjectStorageEndpointTypes; + + /** + * The S3 endpoint URL to which this limit applies. + * + * For OBJ limits only. + */ + s3_endpoint?: string; +} + +export type QuotaType = 'linode' | 'lke' | 'object-storage'; diff --git a/packages/api-v4/src/request.test.ts b/packages/api-v4/src/request.test.ts index 97ffeb98d7a..f719403dead 100644 --- a/packages/api-v4/src/request.test.ts +++ b/packages/api-v4/src/request.test.ts @@ -1,3 +1,4 @@ +import { beforeEach, describe, vi, expect, it } from 'vitest'; import adapter from 'axios-mock-adapter'; import { object, string } from 'yup'; import request, { diff --git a/packages/api-v4/src/resources/types.ts b/packages/api-v4/src/resources/types.ts index bf6d89ad037..680aa4c03a5 100644 --- a/packages/api-v4/src/resources/types.ts +++ b/packages/api-v4/src/resources/types.ts @@ -1,4 +1,4 @@ -type ResourceType = +export type ResourceType = | 'linode' | 'firewall' | 'nodebalancer' @@ -10,10 +10,10 @@ type ResourceType = | 'database' | 'vpc'; -export type IamAccountResource = { +export interface IamAccountResource { resource_type: ResourceType; resources: Resource[]; -}[]; +} export interface Resource { name: string; diff --git a/packages/api-v4/tsconfig.json b/packages/api-v4/tsconfig.json index 099ebaf3a2e..58df0f1dff7 100644 --- a/packages/api-v4/tsconfig.json +++ b/packages/api-v4/tsconfig.json @@ -1,13 +1,12 @@ { "compilerOptions": { "target": "esnext", - "types": ["vitest/globals"], - "module": "umd", + "module": "esnext", "emitDeclarationOnly": true, "declaration": true, "outDir": "./lib", "esModuleInterop": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "skipLibCheck": true, "strict": true, "baseUrl": ".", @@ -17,9 +16,5 @@ }, "include": [ "src" - ], - "exclude": [ - "node_modules/**/*", - "**/__tests__/*" ] } diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index cc85bbb89d0..291f9ec2e25 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -91,6 +91,7 @@ module.exports = { // for each new features added to the migration router, add its directory here 'src/features/Betas/**/*', 'src/features/Domains/**/*', + 'src/features/Longview/**/*', 'src/features/Volumes/**/*', ], rules: { @@ -119,14 +120,20 @@ module.exports = { 'withRouter', ], message: - 'Please use routing utilities from @tanstack/react-router.', + 'Please use routing utilities intended for @tanstack/react-router.', name: 'react-router-dom', }, { - importNames: ['renderWithTheme'], + importNames: ['TabLinkList'], message: - 'Please use the wrapWithThemeAndRouter helper function for testing components being migrated to TanStack Router.', - name: 'src/utilities/testHelpers', + 'Please use the TanStackTabLinkList component for components being migrated to TanStack Router.', + name: 'src/components/Tabs/TabLinkList', + }, + { + importNames: ['OrderBy', 'default'], + message: + 'Please use useOrderV2 hook for components being migrated to TanStack Router.', + name: 'src/components/OrderBy', }, ], }, diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 820634b5612..26b9b97e057 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,66 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2025-01-28] - v1.135.0 + +### Added: + +- `useCreateUserMutation` for adding new users ([#11402](https://github.com/linode/manager/pull/11402) +- GPU plans in LKE create flow ([#11544](https://github.com/linode/manager/pull/11544)) + +### Changed: + +- Improve backups banner styles ([#11480](https://github.com/linode/manager/pull/11480)) +- Disable resizable plans when the usable storage equals the used storage of the database cluster ([#11481](https://github.com/linode/manager/pull/11481)) +([#11495](https://github.com/linode/manager/pull/11495)) +- Tech doc link for Bucket rate limits ([#11513](https://github.com/linode/manager/pull/11513)) +- Search v2 `not equal` syntax ([#11521](https://github.com/linode/manager/pull/11521)) +- Revise Disk Encryption description copy in Linode Create flow ([#11536](https://github.com/linode/manager/pull/11536)) + +### Fixed: + +- Spacing for LKE cluster tags at desktop screen sizes ([#11507](https://github.com/linode/manager/pull/11507)) +- Zoom-in icon hover effect in CloudPulse ([#11526](https://github.com/linode/manager/pull/11526)) +- Linode Config Dialog misrepresenting primary interface ([#11542](https://github.com/linode/manager/pull/11542)) + +### Tech Stories: + +- Update to TypeScript v5.7 ([#11531](https://github.com/linode/manager/pull/11531)) +- Replace EnhancedSelect with Autocomplete component in the Help feature ([#11470](https://github.com/linode/manager/pull/11470)) +- Replace ramda's `splitAt` with custom utility ([#11483](https://github.com/linode/manager/pull/11483)) +- Update `tsconfig.json` to use `bundler` moduleResolution ([#11487](https://github.com/linode/manager/pull/11487)) +- Replace one-off hardcoded color values with color tokens (part 5) ([#11488](https://github.com/linode/manager/pull/11488)) +- Replace remaining react-select instances & types in Linodes Feature ([#11509](https://github.com/linode/manager/pull/11509)) +- Dependabot security fixes ([#11510](https://github.com/linode/manager/pull/11510)) +- Remove `ramda` from `DomainRecords` part 1 ([#11514](https://github.com/linode/manager/pull/11514)) +- Remove `ramda` from `CreateDomain.tsx` ([#11505](https://github.com/linode/manager/pull/11505)) +- Refactor and convert DomainRecords to functional component ([#11447](https://github.com/linode/manager/pull/11447)) +- Add `Asia/Calcutta` zonename in `timezones.ts`, `disabledTimeZone` property in `DateTimeRangePicker`, and `minDate` property to `DateTimePicker` ([#11495](https://github.com/linode/manager/pull/11495)) + + +### Tests: + +- Improve organization of Object Storage and Object Storage Multicluster tests ([#11484](https://github.com/linode/manager/pull/11484)) +- Fix test notification formatting and output issues ([#11489](https://github.com/linode/manager/pull/11489)) +- Remove cypress deprecated helper.ts functions ([#11501](https://github.com/linode/manager/pull/11501)) +- Add component tests for PasswordInput ([#11508](https://github.com/linode/manager/pull/11508)) +- Add `CY_TEST_RESET_PREFERENCES` env var to reset user preferences at test run start ([#11522](https://github.com/linode/manager/pull/11522)) +- Increase timeouts when performing Linode clone operations ([#11529](https://github.com/linode/manager/pull/11529)) + +### Upcoming Features: + +- Add Proxy users table, removing users, adding users to IAM ([#11402](https://github.com/linode/manager/pull/11402)) +- Add new entities component for IAM ([#11429](https://github.com/linode/manager/pull/11429)) +- Display cluster provisioning after an LKE-E cluster is created ([#11518](https://github.com/linode/manager/pull/11518)) +- Add Alert Details Criteria section in CloudPulse Alert Details page ([#11477](https://github.com/linode/manager/pull/11477)) +- Update Metrics API request and JWE Token API request in CloudPulse ([#11506](https://github.com/linode/manager/pull/11506)) +- Improve UDP NodeBalancer support ([#11515](https://github.com/linode/manager/pull/11515)) +- Add scaffolding for Resources section in CloudPulse Alert details page ([#11524](https://github.com/linode/manager/pull/11524)) +- Fix redirects from /account to /iam ([#11539](https://github.com/linode/manager/pull/11539))) +- Add `AddNotificationChannel` component with unit tests with necessary changes for constants, `CreateAlertDefinition` and other components. ([#11511](https://github.com/linode/manager/pull/11511)) +- Add Quotas feature flag, queries, and MSW CRUD preset support ([#11493](https://github.com/linode/manager/pull/11493)) + + ## [2025-01-14] - v1.134.0 ### Added: diff --git a/packages/manager/cypress.config.ts b/packages/manager/cypress.config.ts index 83903ec697c..b8596bed4d9 100644 --- a/packages/manager/cypress.config.ts +++ b/packages/manager/cypress.config.ts @@ -19,6 +19,7 @@ import { logTestTagInfo } from './cypress/support/plugins/test-tagging-info'; import cypressViteConfig from './cypress/vite.config'; import { featureFlagOverrides } from './cypress/support/plugins/feature-flag-override'; import { postRunCleanup } from './cypress/support/plugins/post-run-cleanup'; +import { resetUserPreferences } from './cypress/support/plugins/reset-user-preferences'; /** * Exports a Cypress configuration object. @@ -92,6 +93,7 @@ export default defineConfig({ discardPassedTestRecordings, fetchAccount, fetchLinodeRegions, + resetUserPreferences, regionOverrideCheck, featureFlagOverrides, logTestTagInfo, diff --git a/packages/manager/cypress/component/components/password-input.spec.tsx b/packages/manager/cypress/component/components/password-input.spec.tsx new file mode 100644 index 00000000000..1313a09052c --- /dev/null +++ b/packages/manager/cypress/component/components/password-input.spec.tsx @@ -0,0 +1,176 @@ +import * as React from 'react'; +import { checkComponentA11y } from 'support/util/accessibility'; +import { componentTests, visualTests } from 'support/util/components'; + +import PasswordInput from 'src/components/PasswordInput/PasswordInput'; + +const fakePassword = 'this is a password'; +const props = { + label: 'Password Input', + value: fakePassword, +}; + +componentTests('PasswordInput', (mount) => { + describe('PasswordInput interactions', () => { + /** + * - Confirms password text starts hidden + * - Confirms password text can be revealed or hidden when toggling visibility icon + */ + it('can show and hide password text', () => { + mount(); + + // Password textfield starts off as 'password' type + cy.get('[type="password"]').should('be.visible'); + cy.findByTestId('VisibilityIcon').should('be.visible').click(); + cy.findByTestId('VisibilityIcon').should('not.exist'); + + // After clicking the visibility icon, textfield becomes a normal textfield + cy.get('[type="password"]').should('not.exist'); + cy.get('[type="text"]').should('be.visible'); + + // Clicking VisibilityOffIcon changes input type to password again + cy.findByTestId('VisibilityOffIcon').should('be.visible').click(); + cy.findByTestId('VisibilityOffIcon').should('not.exist'); + cy.findByTestId('VisibilityIcon').should('be.visible'); + cy.get('[type="password"]').should('be.visible'); + cy.get('[type="text"]').should('not.exist'); + }); + + /** + * - Confirms password input displays when a weak password is entered + */ + it('displays an indicator for a weak password', () => { + const TestWeakStrength = () => { + const [password, setPassword] = React.useState(''); + return ( + setPassword(e.target.value)} + value={password} + /> + ); + }; + + mount(); + + // Starts off as 'Weak' if no password entered + cy.findByText('Weak').should('be.visible'); + + cy.findByTestId('textfield-input').should('be.visible').type('weak'); + cy.findByText('Weak').should('be.visible'); + }); + + /** + * - Confirm password indicator can update when a password is entered + * - Confirms password input can display indicator for a fair password + */ + it('displays an indicator for a fair password', () => { + const TestMediumStrength = () => { + const [password, setPassword] = React.useState(''); + return ( + setPassword(e.target.value)} + value={password} + /> + ); + }; + + mount(); + + // Starts off as 'Weak' when no password entered + cy.findByText('Weak').should('be.visible'); + + cy.findByTestId('textfield-input') + .should('be.visible') + .type('fair-pass1'); + + // After typing in a fair password, the strength indicator updates + cy.findByText('Fair').should('be.visible'); + cy.findByText('Weak').should('not.exist'); + }); + + /** + * - Confirm password indicator can update when a password is entered + * - Confirms password input can display indicator for a good password + */ + it('displays an indicator for a "good" password', () => { + const TestGoodStrength = () => { + const [password, setPassword] = React.useState(''); + return ( + setPassword(e.target.value)} + value={password} + /> + ); + }; + + mount(); + + // Starts off as 'Weak' when no password entered + cy.findByText('Weak').should('be.visible'); + + cy.findByTestId('textfield-input') + .should('be.visible') + .type('str0ng!!-password1!!'); + + // After typing in a strong password, the strength indicator updates + cy.findByText('Good').should('be.visible'); + cy.findByText('Weak').should('not.exist'); + }); + }); + + visualTests((mount) => { + describe('Accessibility checks', () => { + it('passes aXe check when password input is visible', () => { + mount(); + cy.findByTestId('VisibilityIcon').should('be.visible').click(); + + checkComponentA11y(); + }); + + it('passes aXe check when password input is not visible', () => { + mount(); + + checkComponentA11y(); + }); + + it('passes aXe check for a weak password', () => { + mount(); + + checkComponentA11y(); + }); + + it('passes aXe check for a fair password', () => { + mount(); + + checkComponentA11y(); + }); + + it('passes aXe check for a "good" password', () => { + mount(); + + checkComponentA11y(); + }); + + it('passes aXe check when password input is designated as required', () => { + mount(); + + checkComponentA11y(); + }); + + it('passes aXe check when strength value is hidden', () => { + mount(); + + checkComponentA11y(); + }); + + it('passes aXe check when strength label is shown', () => { + mount(); + + checkComponentA11y(); + }); + }); + }); +}); diff --git a/packages/manager/cypress/component/components/select.spec.tsx b/packages/manager/cypress/component/components/select.spec.tsx index bf49abb3415..71d0c2c5c97 100644 --- a/packages/manager/cypress/component/components/select.spec.tsx +++ b/packages/manager/cypress/component/components/select.spec.tsx @@ -4,7 +4,7 @@ import { ui } from 'support/ui'; import { createSpy } from 'support/util/components'; import { componentTests } from 'support/util/components'; -import type { SelectOptionType, SelectProps } from '@linode/ui'; +import type { SelectOption, SelectProps } from '@linode/ui'; const options = [ { label: 'Option 1', value: 'option-1' }, @@ -260,7 +260,7 @@ componentTests('Select', (mount) => { }); }); - const defaultProps: SelectProps = { + const defaultProps = { label: 'My Select', onChange: () => {}, options, @@ -268,10 +268,10 @@ componentTests('Select', (mount) => { }; describe('Logic', () => { - const WrappedSelect = (props: Partial) => { - const [value, setValue] = React.useState< - SelectOptionType | null | undefined - >(null); + const WrappedSelect = (props: Partial>) => { + const [value, setValue] = React.useState( + null + ); return ( <> @@ -280,7 +280,9 @@ componentTests('Select', (mount) => { onChange={(_, newValue) => setValue({ label: newValue?.label ?? '', - value: newValue?.value.replace(' ', '-').toLowerCase() ?? '', + value: + newValue?.value.toString().replace(' ', '-').toLowerCase() ?? + '', }) } textFieldProps={{ diff --git a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts index c984bf30ec1..6211b624c99 100644 --- a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -197,7 +197,7 @@ describe('Account cancellation', () => { // Check both boxes but verify submit remains disabled without email cy.get('[data-qa-checkbox="deleteAccountServices"]').click(); cy.get('[data-qa-checkbox="deleteAccountUsers"]').click(); - + ui.button .findByTitle('Close Account') .should('be.visible') @@ -382,7 +382,7 @@ describe('Parent/Child account cancellation', () => { // Check both boxes but verify submit remains disabled without email cy.get('[data-qa-checkbox="deleteAccountServices"]').click(); cy.get('[data-qa-checkbox="deleteAccountUsers"]').click(); - + ui.button .findByTitle('Close Account') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts b/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts index 1253783f7af..cef3136b233 100644 --- a/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts @@ -1,4 +1,3 @@ -import { fbltClick } from 'support/helpers'; import { oauthClientFactory } from '@src/factories'; import { mockCreateOAuthApp, @@ -31,8 +30,11 @@ const createOAuthApp = (oauthApp: OAuthClient) => { .findByTitle('Create OAuth App') .should('be.visible') .within(() => { - fbltClick('Label').clear().type(oauthApp.label); - fbltClick('Callback URL').clear().type(oauthApp.redirect_uri); + cy.findByLabelText('Label').click().clear().type(oauthApp.label); + cy.findByLabelText('Callback URL') + .click() + .clear() + .type(oauthApp.redirect_uri); ui.buttonGroup .findButtonByTitle('Cancel') .should('be.visible') @@ -54,8 +56,11 @@ const createOAuthApp = (oauthApp: OAuthClient) => { .findByTitle('Create OAuth App') .should('be.visible') .within(() => { - fbltClick('Label').clear().type(oauthApp.label); - fbltClick('Callback URL').clear().type(oauthApp.redirect_uri); + cy.findByLabelText('Label').click().clear().type(oauthApp.label); + cy.findByLabelText('Callback URL') + .click() + .clear() + .type(oauthApp.redirect_uri); }); ui.drawerCloseButton.find().click(); @@ -75,8 +80,8 @@ const createOAuthApp = (oauthApp: OAuthClient) => { .should('be.visible') .within(() => { // An error message appears when attempting to create an OAuth App without a label - fbltClick('Label').clear(); - fbltClick('Callback URL').clear(); + cy.findByLabelText('Label').click().clear(); + cy.findByLabelText('Callback URL').click().clear(); ui.button .findByTitle('Create') .should('be.visible') @@ -86,8 +91,11 @@ const createOAuthApp = (oauthApp: OAuthClient) => { cy.findByText('Redirect URI is required.'); // Fill out and submit OAuth App create form. - fbltClick('Label').clear().type(oauthApp.label); - fbltClick('Callback URL').clear().type(oauthApp.redirect_uri); + cy.findByLabelText('Label').click().clear().type(oauthApp.label); + cy.findByLabelText('Callback URL') + .click() + .clear() + .type(oauthApp.redirect_uri); // Check the 'public' checkbox if (oauthApp.public) { cy.get('[data-qa-checked]').should('be.visible').click(); @@ -312,8 +320,11 @@ describe('OAuth Apps', () => { .should('be.visible') .should('be.disabled'); - fbltClick('Label').clear().type(updatedApps[0].label); - fbltClick('Callback URL').clear().type(updatedApps[0].label); + cy.findByLabelText('Label').click().clear().type(updatedApps[0].label); + cy.findByLabelText('Callback URL') + .click() + .clear() + .type(updatedApps[0].label); ui.buttonGroup .findButtonByTitle('Save Changes') diff --git a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts index 5bed5edc465..854077817ba 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts @@ -432,7 +432,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { .should('be.visible') .within(() => { ui.button - .findByAttribute('aria-label', 'Zoom In') + .findByAttribute('aria-label', 'Zoom Out') .should('be.visible') .should('be.enabled') .click(); @@ -464,7 +464,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { // click zoom out and validate the same ui.button - .findByAttribute('aria-label', 'Zoom Out') + .findByAttribute('aria-label', 'Zoom In') .should('be.visible') .should('be.enabled') .scrollIntoView() diff --git a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts index 70a6ef1c615..5b4afb0f031 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts @@ -404,7 +404,7 @@ describe('Integration Tests for Linode Dashboard ', () => { .should('be.visible') .within(() => { ui.button - .findByAttribute('aria-label', 'Zoom In') + .findByAttribute('aria-label', 'Zoom Out') .should('be.visible') .should('be.enabled') .click(); @@ -435,7 +435,7 @@ describe('Integration Tests for Linode Dashboard ', () => { // click zoom out and validate the same ui.button - .findByAttribute('aria-label', 'Zoom Out') + .findByAttribute('aria-label', 'Zoom In') .should('be.visible') .should('be.enabled') .scrollIntoView() diff --git a/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts index f379f8c35a5..2b3903f78de 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts @@ -1,6 +1,5 @@ import { Domain } from '@linode/api-v4'; import { domainFactory } from '@src/factories'; -import { getClick, fbtClick, fbltClick } from 'support/helpers'; import { authenticate } from 'support/api/authentication'; import { randomDomainName } from 'support/util/random'; import { createDomain } from '@linode/api-v4/lib/domains'; @@ -44,11 +43,11 @@ describe('Clone a Domain', () => { domainRecords.forEach((rec) => { interceptCreateDomainRecord().as('apiCreateRecord'); - fbtClick(rec.name); + cy.findByText(rec.name).click(); rec.fields.forEach((f) => { - getClick(f.name).type(f.value); + cy.get(f.name).click().type(f.value); }); - fbtClick('Save'); + cy.findByText('Save').click(); cy.wait('@apiCreateRecord'); }); @@ -102,7 +101,7 @@ describe('Clone a Domain', () => { .should('be.disabled'); // Confirm that an error is displayed when entering an invalid domain name - fbltClick('New Domain').type(invalidDomainName); + cy.findByLabelText('New Domain').click().type(invalidDomainName); ui.buttonGroup .findButtonByTitle('Create Domain') .should('be.visible') @@ -110,7 +109,10 @@ describe('Clone a Domain', () => { .click(); cy.findByText('Domain is not valid.').should('be.visible'); - fbltClick('New Domain').clear().type(clonedDomainName); + cy.findByLabelText('New Domain') + .click() + .clear() + .type(clonedDomainName); ui.buttonGroup .findButtonByTitle('Create Domain') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/domains/smoke-create-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-create-domain.spec.ts index e3d8d513de1..9f9e832cc58 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-create-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-create-domain.spec.ts @@ -1,7 +1,6 @@ import { Domain } from '@linode/api-v4'; import { domainFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; -import { fbtClick, getClick, getVisible } from 'support/helpers'; import { interceptCreateDomain, mockGetDomains, @@ -30,13 +29,15 @@ describe('Create a Domain', () => { interceptCreateDomain().as('createDomain'); cy.visitWithLogin('/domains'); cy.wait('@getDomains'); - fbtClick('Create Domain'); + cy.findByText('Create Domain').click(); const label = randomDomainName(); - getVisible('[id="domain"][data-testid="textfield-input"]').type(label); - getVisible('[id="soa-email-address"][data-testid="textfield-input"]').type( - 'devs@linode.com' - ); - getClick('[data-testid="submit"]'); + cy.get('[id="domain"][data-testid="textfield-input"]') + .should('be.visible') + .type(label); + cy.get('[id="soa-email-address"][data-testid="textfield-input"]') + .should('be.visible') + .type('devs@linode.com'); + cy.get('[data-testid="submit"]').click(); cy.wait('@createDomain'); cy.get('[data-qa-header]').should('contain', label); }); diff --git a/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts index 0e4710ec621..c6bdd8b30ae 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts @@ -1,6 +1,5 @@ import { Domain } from '@linode/api-v4'; import { domainFactory } from '@src/factories'; -import { containsClick } from 'support/helpers'; import { authenticate } from 'support/api/authentication'; import { randomDomainName } from 'support/util/random'; import { createDomain } from '@linode/api-v4/lib/domains'; @@ -73,8 +72,8 @@ describe('Delete a Domain', () => { .findButtonByTitle('Delete Domain') .should('be.visible') .should('be.disabled'); + cy.contains('Domain Name').click().type(domain.domain); - containsClick('Domain Name').type(domain.domain); ui.buttonGroup .findButtonByTitle('Delete Domain') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/domains/smoke-domain-download-zone-file.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-domain-download-zone-file.spec.ts index 951516fecf3..4c5401df232 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-domain-download-zone-file.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-domain-download-zone-file.spec.ts @@ -4,7 +4,6 @@ import { domainZoneFileFactory, } from '@src/factories'; import { authenticate } from 'support/api/authentication'; -import { fbtClick, fbtVisible } from 'support/helpers'; import { mockGetDomains, mockGetDomain, @@ -46,8 +45,8 @@ describe('Download a Zone file', () => { mockGetDomain(mockDomain.id, mockDomain).as('getDomain'); mockGetDomainRecords([mockDomainRecords]).as('getDomainRecords'); - fbtVisible(mockDomain.domain); - fbtClick(mockDomain.domain); + cy.findByText(mockDomain.domain).should('be.visible').should('be.visible'); + cy.findByText(mockDomain.domain).click(); cy.wait('@getDomain'); cy.wait('@getDomainRecords'); diff --git a/packages/manager/cypress/e2e/core/domains/smoke-domain-import-a-zone.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-domain-import-a-zone.spec.ts index 54a1cc8b439..17587c4f1ae 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-domain-import-a-zone.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-domain-import-a-zone.spec.ts @@ -1,7 +1,6 @@ import { ImportZonePayload } from '@linode/api-v4'; import { domainFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; -import { fbltClick } from 'support/helpers'; import { randomDomainName, randomIp } from 'support/util/random'; import { mockGetDomains, mockImportDomain } from 'support/intercepts/domains'; import { ui } from 'support/ui'; @@ -46,7 +45,7 @@ describe('Import a Zone', () => { .should('be.disabled'); // Verify only filling out Domain cannot import - fbltClick('Domain').clear().type(zone.domain); + cy.findByLabelText('Domain').click().clear().type(zone.domain); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') @@ -55,8 +54,11 @@ describe('Import a Zone', () => { cy.findByText('Remote nameserver is required.'); // Verify invalid domain cannot import - fbltClick('Domain').clear().type('1'); - fbltClick('Remote Nameserver').clear().type(zone.remote_nameserver); + cy.findByLabelText('Domain').click().clear().type('1'); + cy.findByLabelText('Remote Nameserver') + .click() + .clear() + .type(zone.remote_nameserver); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') @@ -65,8 +67,11 @@ describe('Import a Zone', () => { cy.findByText('Domain is not valid.'); // Verify only filling out RemoteNameserver cannot import - fbltClick('Domain').clear(); - fbltClick('Remote Nameserver').clear().type(zone.remote_nameserver); + cy.findByLabelText('Domain').click().clear(); + cy.findByLabelText('Remote Nameserver') + .click() + .clear() + .type(zone.remote_nameserver); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') @@ -75,8 +80,8 @@ describe('Import a Zone', () => { cy.findByText('Domain is required.'); // Verify invalid remote nameserver cannot import - fbltClick('Domain').clear().type(zone.domain); - fbltClick('Remote Nameserver').clear().type('1'); + cy.findByLabelText('Domain').click().clear().type(zone.domain); + cy.findByLabelText('Remote Nameserver').click().clear().type('1'); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') @@ -87,8 +92,11 @@ describe('Import a Zone', () => { // Fill out and import the zone. mockImportDomain(mockDomain).as('importDomain'); mockGetDomains([mockDomain]).as('getDomains'); - fbltClick('Domain').clear().type(zone.domain); - fbltClick('Remote Nameserver').clear().type(zone.remote_nameserver); + cy.findByLabelText('Domain').click().clear().type(zone.domain); + cy.findByLabelText('Remote Nameserver') + .click() + .clear() + .type(zone.remote_nameserver); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts index 7be2db7cd11..8f55e035d5b 100644 --- a/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts @@ -3,7 +3,6 @@ import { firewallFactory } from 'src/factories/firewalls'; import { authenticate } from 'support/api/authentication'; import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; -import { fbtVisible, fbtClick } from 'support/helpers'; import { cleanUp } from 'support/util/cleanup'; authenticate(); @@ -35,8 +34,8 @@ describe('delete firewall', () => { .should('be.visible') .closest('tr') .within(() => { - fbtVisible('Delete'); - fbtClick('Delete'); + cy.findByText('Delete').should('be.visible'); + cy.findByText('Delete').click(); }); // Cancel deletion when prompted to confirm. @@ -56,8 +55,8 @@ describe('delete firewall', () => { .should('be.visible') .closest('tr') .within(() => { - fbtVisible('Delete'); - fbtClick('Delete'); + cy.findByText('Delete').should('be.visible'); + cy.findByText('Delete').click(); }); // Confirm deletion. diff --git a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts index a76d5ee8a09..3aa89d01e85 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -14,13 +14,11 @@ import { firewallRulesFactory, } from 'src/factories'; import { authenticate } from 'support/api/authentication'; -import { containsClick } from 'support/helpers'; import { interceptUpdateFirewallLinodes, interceptUpdateFirewallRules, } from 'support/intercepts/firewalls'; import { randomItem, randomString, randomLabel } from 'support/util/random'; -import { fbtVisible, fbtClick } from 'support/helpers'; import { ui } from 'support/ui'; import { chooseRegion } from 'support/util/regions'; import { cleanUp } from 'support/util/cleanup'; @@ -84,11 +82,13 @@ const addFirewallRules = (rule: FirewallRuleType, direction: string) => { const description = rule.description ? rule.description : 'test-description'; - containsClick('Label').type('{selectall}{backspace}' + label); - containsClick('Description').type(description); + cy.contains('Label') + .click() + .type('{selectall}{backspace}' + label); + cy.contains('Description').click().type(description); const action = rule.action ? getRuleActionLabel(rule.action) : 'Accept'; - containsClick(action).click(); + cy.contains(action).click(); ui.button .findByTitle('Add Rule') @@ -346,8 +346,8 @@ describe('update firewall', () => { .should('be.visible') .closest('tr') .within(() => { - fbtVisible('Disable'); - fbtClick('Disable'); + cy.findByText('Disable').should('be.visible'); + cy.findByText('Disable').click(); }); ui.dialog @@ -375,8 +375,8 @@ describe('update firewall', () => { .should('be.visible') .closest('tr') .within(() => { - fbtVisible('Enable'); - fbtClick('Enable'); + cy.findByText('Enable').should('be.visible'); + cy.findByText('Enable').click(); }); ui.dialog 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 d2799cb7419..da4f61f4efb 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 @@ -1,4 +1,3 @@ -import { fbtClick, fbtVisible, getClick } from 'support/helpers'; import { apiMatcher } from 'support/util/intercepts'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { mockGetAllImages } from 'support/intercepts/images'; @@ -49,8 +48,8 @@ const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionId(region.id).click(); - fbtClick('Shared CPU'); - getClick('[id="g6-nanode-1"][type="radio"]'); + cy.findByText('Shared CPU').click(); + cy.get('[id="g6-nanode-1"][type="radio"]').click(); cy.get('[id="root-password"]').type(randomString(32)); ui.button @@ -62,9 +61,9 @@ const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { cy.wait('@mockLinodeRequest'); - fbtVisible(mockLinode.label); - fbtVisible(region.label); - fbtVisible(`${mockLinode.id}`); + cy.findByText(mockLinode.label).should('be.visible'); + cy.findByText(region.label).should('be.visible'); + cy.findByText(`${mockLinode.id}`).should('be.visible'); }; describe('create linode from image, mocked data', () => { 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 1f0e42c010a..e44b08523a2 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -58,6 +58,7 @@ import { latestKubernetesVersion, } from 'support/constants/lke'; import { lkeEnterpriseTypeFactory } from 'src/factories'; +import { pluralize } from 'src/utilities/pluralize'; const dedicatedNodeCount = 4; const nanodeNodeCount = 3; @@ -317,6 +318,11 @@ describe('LKE Cluster Creation', () => { .should('have.length', similarNodePoolCount) .first() .should('be.visible'); + + // Confirm total number of nodes are shown for each pool + cy.findAllByText( + pluralize('Node', 'Nodes', clusterPlan.nodeCount) + ).should('be.visible'); }); ui.breadcrumb @@ -1073,6 +1079,7 @@ describe('LKE Cluster Creation with LKE-E', () => { * - Confirms an LKE-E supported k8 version can be selected * - Confirms the checkout bar displays the correct LKE-E info * - Confirms an enterprise cluster can be created with the correct chip, version, and price + * - Confirms that the total node count for each pool is displayed */ it('creates an LKE-E cluster with the account capability', () => { const clusterLabel = randomLabel(); @@ -1268,6 +1275,13 @@ describe('LKE Cluster Creation with LKE-E', () => { `Version ${latestEnterpriseTierKubernetesVersion.id}` ).should('be.visible'); cy.findByText('$459.00/month').should('be.visible'); + + clusterPlans.forEach((clusterPlan) => { + // Confirm total number of nodes are shown for each pool + cy.findAllByText( + pluralize('Node', 'Nodes', clusterPlan.nodeCount) + ).should('be.visible'); + }); }); it('disables the Cluster Type selection without the LKE-E account capability', () => { 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 55a1ce53a76..0f698c50e20 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -18,6 +18,10 @@ import { randomLabel } from 'support/util/random'; import { authenticate } from 'support/api/authentication'; import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; +import { + LINODE_CLONE_TIMEOUT, + LINODE_CREATE_TIMEOUT, +} from 'support/constants/linodes'; import type { Linode } from '@linode/api-v4'; /** @@ -33,9 +37,6 @@ const getLinodeCloneUrl = (linode: Linode): string => { return `/linodes/create?linodeID=${linode.id}${regionQuery}&type=Clone+Linode${typeQuery}`; }; -/* Timeout after 4 minutes while waiting for clone. */ -const CLONE_TIMEOUT = 240_000; - authenticate(); describe('clone linode', () => { before(() => { @@ -69,7 +70,9 @@ describe('clone linode', () => { cy.visitWithLogin(`/linodes/${linode.id}`); // Wait for Linode to boot, then initiate clone flow. - cy.findByText('OFFLINE').should('be.visible'); + cy.findByText('OFFLINE', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); ui.actionMenu .findByTitle(`Action menu for Linode ${linode.label}`) @@ -108,7 +111,7 @@ describe('clone linode', () => { ui.toast.assertMessage(`Your Linode ${newLinodeLabel} is being created.`); ui.toast.assertMessage( `Linode ${linode.label} has been cloned to ${newLinodeLabel}.`, - { timeout: CLONE_TIMEOUT } + { timeout: LINODE_CLONE_TIMEOUT } ); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts index cf8707c1ec9..ba4b950c8ea 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts @@ -7,7 +7,7 @@ import { makeFeatureFlagData } from 'support/util/feature-flags'; import { checkboxTestId, headerTestId, -} from 'src/components/Encryption/Encryption'; +} from 'src/components/Encryption/constants'; describe('Create Linode with Disk Encryption', () => { it('should not have a "Disk Encryption" section visible if the feature flag is off and user does not have capability', () => { diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index 37e5309715d..467fa122445 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -40,13 +40,6 @@ import { mockGetRegions } from 'support/intercepts/regions'; import { mockGetVLANs } from 'support/intercepts/vlans'; import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; import { mockGetLinodeConfigs } from 'support/intercepts/configs'; -import { - fbtClick, - fbtVisible, - getClick, - getVisible, - containsVisible, -} from 'support/helpers'; let username: string; @@ -374,23 +367,27 @@ describe('Create Linode', () => { // Verify VPCs get fetched once a region is selected cy.wait('@getVPCs'); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + cy.findByText('Shared CPU').click(); + cy.get(`[id="${dcPricingMockLinodeTypes[0].id}"]`).click(); // the "VPC" section is present, and the VPC in the same region of // the linode can be selected. - getVisible('[data-testid="vpc-panel"]').within(() => { - containsVisible('Assign this Linode to an existing VPC.'); - // select VPC - cy.findByLabelText('Assign VPC') - .should('be.visible') - .focus() - .type(`${mockVPC.label}{downArrow}{enter}`); - // select subnet - cy.findByPlaceholderText('Select Subnet') - .should('be.visible') - .type(`${mockSubnet.label}{downArrow}{enter}`); - }); + cy.get('[data-testid="vpc-panel"]') + .should('be.visible') + .within(() => { + cy.contains('Assign this Linode to an existing VPC.').should( + 'be.visible' + ); + // select VPC + cy.findByLabelText('Assign VPC') + .should('be.visible') + .focus() + .type(`${mockVPC.label}{downArrow}{enter}`); + // select subnet + cy.findByPlaceholderText('Select Subnet') + .should('be.visible') + .type(`${mockSubnet.label}{downArrow}{enter}`); + }); // The drawer opens when clicking "Add an SSH Key" button ui.button @@ -430,13 +427,13 @@ describe('Create Linode', () => { // When a user creates an SSH key, the list of SSH keys for each user updates to show the new key for the signed in user cy.findByText(sshPublicKeyLabel, { exact: false }).should('be.visible'); - getClick('#linode-label').clear().type(linodeLabel); + cy.get('#linode-label').clear().type(linodeLabel).click(); cy.get('#root-password').type(rootpass); ui.button.findByTitle('Create Linode').click(); cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - fbtVisible(linodeLabel); + cy.findByText(linodeLabel).should('be.visible'); cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); }); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts index 68ec28b9701..903c5932224 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -4,6 +4,7 @@ import { authenticate } from 'support/api/authentication'; import { cleanUp } from 'support/util/cleanup'; import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; +import { LINODE_CLONE_TIMEOUT } from 'support/constants/linodes'; import { chooseRegion, getRegionById } from 'support/util/regions'; import { mockGetVLANs } from 'support/intercepts/vlans'; import { @@ -225,7 +226,8 @@ describe('Linode Config management', () => { .its('response.statusCode') .should('eq', 200); ui.toast.assertMessage( - `Configuration ${config.label} successfully updated` + `Configuration ${config.label} successfully updated`, + { timeout: LINODE_CLONE_TIMEOUT } ); // Confirm that updated IPAM is automatically listed in config table. diff --git a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts index da168866efc..30ecd433839 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -131,7 +131,7 @@ const planSelectionTable = 'List of Linode Plans'; const notices = { limitedAvailability: '[data-testid="limited-availability-banner"]', - unavailable: '[data-testid="notice-error"]', + unavailable: '[data-qa-error="true"]', }; authenticate(); @@ -409,6 +409,57 @@ describe('displays specific linode plans for GPU', () => { }); }); +describe('displays specific kubernetes plans for GPU', () => { + beforeEach(() => { + mockGetRegions(mockRegions).as('getRegions'); + mockGetLinodeTypes(mockLinodeTypes).as('getLinodeTypes'); + mockGetRegionAvailability(mockRegions[0].id, mockRegionAvailability).as( + 'getRegionAvailability' + ); + mockAppendFeatureFlags({ + gpuv2: { + transferBanner: true, + planDivider: true, + egressBanner: true, + }, + }).as('getFeatureFlags'); + }); + + it('Should render divided tables when GPU divider enabled', () => { + cy.visitWithLogin('/kubernetes/create'); + cy.wait(['@getRegions', '@getLinodeTypes', '@getFeatureFlags']); + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(mockRegions[0].label).click(); + + // GPU tab + // Should display two separate tables + cy.findByText('GPU').click(); + cy.get(k8PlansPanel).within(() => { + cy.findAllByRole('alert').should('have.length', 2); + cy.get(notices.unavailable).should('be.visible'); + + cy.findByRole('table', { + name: 'List of NVIDIA RTX 4000 Ada Plans', + }).within(() => { + cy.findByText('NVIDIA RTX 4000 Ada').should('be.visible'); + cy.findAllByRole('row').should('have.length', 2); + cy.get('[data-qa-plan-row="gpu-2 Ada"]').should( + 'have.attr', + 'disabled' + ); + }); + + cy.findByRole('table', { + name: 'List of NVIDIA Quadro RTX 6000 Plans', + }).within(() => { + cy.findByText('NVIDIA Quadro RTX 6000').should('be.visible'); + cy.findAllByRole('row').should('have.length', 2); + cy.get('[data-qa-plan-row="gpu-1"]').should('have.attr', 'disabled'); + }); + }); + }); +}); + describe('Linode Accelerated plans', () => { beforeEach(() => { mockGetRegions(mockRegions).as('getRegions'); diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index e1bbfeb6e7b..91d28a545ab 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts @@ -3,12 +3,6 @@ import { Linode } from '@linode/api-v4'; import { accountSettingsFactory } from '@src/factories/accountSettings'; import { linodeFactory } from '@src/factories/linodes'; import { makeResourcePage } from '@src/mocks/serverHandlers'; -import { - containsVisible, - fbtVisible, - getClick, - getVisible, -} from 'support/helpers'; import { ui } from 'support/ui'; import { routes } from 'support/ui/constants'; import { apiMatcher } from 'support/util/intercepts'; @@ -83,37 +77,65 @@ describe('linode landing checks', () => { }); it('checks the landing page side menu items', () => { - getVisible('[title="Akamai - Dashboard"][href="/dashboard"]'); - getVisible('[data-testid="menu-item-Linodes"][href="/linodes"]'); - getVisible('[data-testid="menu-item-Volumes"][href="/volumes"]'); - getVisible( + cy.get('[title="Akamai - Dashboard"][href="/dashboard"]').should( + 'be.visible' + ); + cy.get('[data-testid="menu-item-Linodes"][href="/linodes"]').should( + 'be.visible' + ); + cy.get('[data-testid="menu-item-Volumes"][href="/volumes"]').should( + 'be.visible' + ); + cy.get( '[data-testid="menu-item-NodeBalancers"][href="/nodebalancers"]' + ).should('be.visible'); + cy.get('[data-testid="menu-item-Firewalls"][href="/firewalls"]').should( + 'be.visible' ); - getVisible('[data-testid="menu-item-Firewalls"][href="/firewalls"]'); - getVisible('[data-testid="menu-item-StackScripts"][href="/stackscripts"]'); - getVisible('[data-testid="menu-item-Images"][href="/images"]'); - getVisible('[data-testid="menu-item-Domains"][href="/domains"]'); - getVisible( - '[data-testid="menu-item-Kubernetes"][href="/kubernetes/clusters"]' + cy.get( + '[data-testid="menu-item-StackScripts"][href="/stackscripts"]' + ).should('be.visible'); + cy.get('[data-testid="menu-item-Images"][href="/images"]').should( + 'be.visible' ); - getVisible( + cy.get('[data-testid="menu-item-Domains"][href="/domains"]').should( + 'be.visible' + ); + cy.get( + '[data-testid="menu-item-Kubernetes"][href="/kubernetes/clusters"]' + ).should('be.visible'); + cy.get( '[data-testid="menu-item-Object Storage"][href="/object-storage/buckets"]' + ).should('be.visible'); + cy.get('[data-testid="menu-item-Longview"][href="/longview"]').should( + 'be.visible' ); - getVisible('[data-testid="menu-item-Longview"][href="/longview"]'); - getVisible( + cy.get( '[data-testid="menu-item-Marketplace"][href="/linodes/create?type=One-Click"]' + ).should('be.visible'); + cy.get('[data-testid="menu-item-Account"][href="/account"]').should( + 'be.visible' + ); + cy.get('[data-testid="menu-item-Help & Support"][href="/support"]').should( + 'be.visible' ); - getVisible('[data-testid="menu-item-Account"][href="/account"]'); - getVisible('[data-testid="menu-item-Help & Support"][href="/support"]'); }); it('checks the landing top menu items', () => { cy.wait('@getProfile').then((xhr) => { const username = xhr.response?.body.username; - getVisible('[aria-label="open menu"]'); - getVisible('[data-qa-add-new-menu-button="true"]'); - getVisible('[data-qa-search-icon="true"]'); - fbtVisible('Search Products, IP Addresses, Tags...'); + cy.get('[aria-label="open menu"]') + .should('be.visible') + .should('be.visible'); + cy.get('[data-qa-add-new-menu-button="true"]') + .should('be.visible') + .should('be.visible'); + cy.get('[data-qa-search-icon="true"]') + .should('be.visible') + .should('be.visible'); + cy.findByText('Search Products, IP Addresses, Tags...').should( + 'be.visible' + ); cy.findByLabelText('Help & Support') .should('be.visible') @@ -130,17 +152,21 @@ describe('linode landing checks', () => { .should('be.visible') .should('be.enabled'); - getVisible('[aria-label="Notifications"]'); - getVisible('[data-testid="nav-group-profile"]').within(() => { - fbtVisible(username); - }); + cy.get('[aria-label="Notifications"]').should('be.visible'); + cy.get('[data-testid="nav-group-profile"]') + .should('be.visible') + .within(() => { + cy.findByText(username).should('be.visible'); + }); }); }); it('checks the landing labels and buttons', () => { - getVisible('h1[data-qa-header="Linodes"]'); - getVisible('a[aria-label="Docs - link opens in a new tab"]'); - fbtVisible('Create Linode'); + cy.get('h1[data-qa-header="Linodes"]').should('be.visible'); + cy.get('a[aria-label="Docs - link opens in a new tab"]').should( + 'be.visible' + ); + cy.findByText('Create Linode').should('be.visible'); }); it('checks label and region sorting behavior for linode table', () => { @@ -157,113 +183,161 @@ describe('linode landing checks', () => { ).label; const checkFirstRow = (label: string) => { - getVisible('tr[data-qa-loading="true"]') + cy.get('tr[data-qa-loading="true"]') + .should('be.visible') .first() .within(() => { - containsVisible(label); + cy.contains(label).should('be.visible'); }); }; const checkLastRow = (label: string) => { - getVisible('tr[data-qa-loading="true"]') + cy.get('tr[data-qa-loading="true"]') + .should('be.visible') .last() .within(() => { - containsVisible(label); + cy.contains(label).should('be.visible'); }); }; checkFirstRow(firstLinodeLabel); checkLastRow(lastLinodeLabel); - getClick('[aria-label="Sort by label"]'); + cy.get('[aria-label="Sort by label"]').click(); checkFirstRow(lastLinodeLabel); checkLastRow(firstLinodeLabel); - getClick('[aria-label="Sort by region"]'); + cy.get('[aria-label="Sort by region"]').click(); checkFirstRow(firstRegionLabel); checkLastRow(lastRegionLabel); - getClick('[aria-label="Sort by region"]'); + cy.get('[aria-label="Sort by region"]').click(); checkFirstRow(lastRegionLabel); checkLastRow(firstRegionLabel); }); it('checks the create menu dropdown items', () => { - getClick('[data-qa-add-new-menu-button="true"]'); - - getVisible('[aria-labelledby="create-menu"]').within(() => { - getVisible('[href="/linodes/create"]').within(() => { - fbtVisible('Linode'); - fbtVisible('High performance SSD Linux servers'); - }); - - getVisible('[href="/volumes/create"]').within(() => { - fbtVisible('Volume'); - fbtVisible('Attach additional storage to your Linode'); - }); + cy.get('[data-qa-add-new-menu-button="true"]').click(); - getVisible('[href="/nodebalancers/create"]').within(() => { - fbtVisible('NodeBalancer'); - fbtVisible('Ensure your services are highly available'); + cy.get('[aria-labelledby="create-menu"]') + .should('be.visible') + .within(() => { + cy.get('[href="/linodes/create"]') + .should('be.visible') + .within(() => { + cy.findByText('Linode').should('be.visible'); + cy.findByText('High performance SSD Linux servers').should( + 'be.visible' + ); + }); + + cy.get('[href="/volumes/create"]') + .should('be.visible') + .within(() => { + cy.findByText('Volume').should('be.visible'); + cy.findByText('Attach additional storage to your Linode').should( + 'be.visible' + ); + }); + + cy.get('[href="/nodebalancers/create"]') + .should('be.visible') + .within(() => { + cy.findByText('NodeBalancer').should('be.visible'); + cy.findByText('Ensure your services are highly available').should( + 'be.visible' + ); + }); + + cy.get('[href="/firewalls/create"]') + .should('be.visible') + .within(() => { + cy.findByText('Firewall').should('be.visible'); + cy.findByText('Control network access to your Linodes').should( + 'be.visible' + ); + }); + + cy.get('[href="/firewalls/create"]') + .should('be.visible') + .within(() => { + cy.findByText('Firewall').should('be.visible'); + cy.findByText('Control network access to your Linodes').should( + 'be.visible' + ); + }); + + cy.get('[href="/domains/create"]') + .should('be.visible') + .within(() => { + cy.findByText('Domain').should('be.visible'); + cy.findByText('Manage your DNS records').should('be.visible'); + }); + + cy.get('[href="/kubernetes/create"]') + .should('be.visible') + .within(() => { + cy.findByText('Kubernetes').should('be.visible'); + cy.findByText('Highly available container workloads').should( + 'be.visible' + ); + }); + + cy.get('[href="/object-storage/buckets/create"]') + .should('be.visible') + .within(() => { + cy.findByText('Bucket').should('be.visible'); + cy.findByText('S3-compatible object storage').should('be.visible'); + }); + + cy.get('[href="/linodes/create?type=One-Click"]') + .should('be.visible') + .within(() => { + cy.findByText('Marketplace').should('be.visible'); + cy.findByText('Deploy applications with ease').should('be.visible'); + }); }); + }); - getVisible('[href="/firewalls/create"]').within(() => { - fbtVisible('Firewall'); - fbtVisible('Control network access to your Linodes'); - }); + it('checks the table and action menu buttons/labels', () => { + const label = linodeLabel(1); + const ip = mockLinodes[0].ipv4[0]; - getVisible('[href="/firewalls/create"]').within(() => { - fbtVisible('Firewall'); - fbtVisible('Control network access to your Linodes'); + cy.get('[aria-label="Sort by label"]') + .should('be.visible') + .within(() => { + cy.findByText('Label').should('be.visible'); }); - getVisible('[href="/domains/create"]').within(() => { - fbtVisible('Domain'); - fbtVisible('Manage your DNS records'); + cy.get('[aria-label="Sort by _statusPriority"]') + .should('be.visible') + .within(() => { + cy.findByText('Status').should('be.visible'); }); - - getVisible('[href="/kubernetes/create"]').within(() => { - fbtVisible('Kubernetes'); - fbtVisible('Highly available container workloads'); + cy.get('[aria-label="Sort by type"]') + .should('be.visible') + .within(() => { + cy.findByText('Plan').should('be.visible'); }); - - getVisible('[href="/object-storage/buckets/create"]').within(() => { - fbtVisible('Bucket'); - fbtVisible('S3-compatible object storage'); + cy.get('[aria-label="Sort by ipv4[0]"]') + .should('be.visible') + .within(() => { + cy.findByText('Public IP Address').should('be.visible'); }); - getVisible('[href="/linodes/create?type=One-Click"]').within(() => { - fbtVisible('Marketplace'); - fbtVisible('Deploy applications with ease'); + cy.get(`tr[data-qa-linode="${label}"]`) + .should('be.visible') + .within(() => { + ui.button + .findByTitle(ip) + .should('be.visible') + .realHover() + .then(() => { + cy.get(`[aria-label="Copy ${ip} to clipboard"]`).should( + 'be.visible' + ); + }); + cy.get(`[aria-label="Action menu for Linode ${label}"]`).should( + 'be.visible' + ); }); - }); - }); - - it('checks the table and action menu buttons/labels', () => { - const label = linodeLabel(1); - const ip = mockLinodes[0].ipv4[0]; - - getVisible('[aria-label="Sort by label"]').within(() => { - fbtVisible('Label'); - }); - - getVisible('[aria-label="Sort by _statusPriority"]').within(() => { - fbtVisible('Status'); - }); - getVisible('[aria-label="Sort by type"]').within(() => { - fbtVisible('Plan'); - }); - getVisible('[aria-label="Sort by ipv4[0]"]').within(() => { - fbtVisible('Public IP Address'); - }); - - getVisible(`tr[data-qa-linode="${label}"]`).within(() => { - ui.button - .findByTitle(ip) - .should('be.visible') - .realHover() - .then(() => { - getVisible(`[aria-label="Copy ${ip} to clipboard"]`); - }); - getVisible(`[aria-label="Action menu for Linode ${label}"]`); - }); }); it('checks the action menu items', () => { @@ -296,10 +370,11 @@ describe('linode landing checks', () => { cy.wait('@getLinodes'); // Check 'Group by Tag' button works as expected that can be visible, enabled and clickable - getVisible('[aria-label="Toggle group by tag"]') + cy.get('[aria-label="Toggle group by tag"]') + .should('be.visible') .should('be.enabled') .click(); - getVisible('[data-qa-tag-header="even"]'); + cy.get('[data-qa-tag-header="even"]').should('be.visible'); cy.get('[data-qa-tag-header="even"]').within(() => { mockLinodes.forEach((linode) => { if (linode.tags.includes('even')) { @@ -310,7 +385,7 @@ describe('linode landing checks', () => { }); }); - getVisible('[data-qa-tag-header="odd"]'); + cy.get('[data-qa-tag-header="odd"]').should('be.visible'); cy.get('[data-qa-tag-header="odd"]').within(() => { mockLinodes.forEach((linode) => { if (linode.tags.includes('odd')) { @@ -321,7 +396,7 @@ describe('linode landing checks', () => { }); }); - getVisible('[data-qa-tag-header="nums"]'); + cy.get('[data-qa-tag-header="nums"]').should('be.visible'); cy.get('[data-qa-tag-header="nums"]').within(() => { mockLinodes.forEach((linode) => { cy.findByText(linode.label).should('be.visible'); @@ -329,7 +404,8 @@ describe('linode landing checks', () => { }); // The linode landing table will resume when ungroup the tag. - getVisible('[aria-label="Toggle group by tag"]') + cy.get('[aria-label="Toggle group by tag"]') + .should('be.visible') .should('be.enabled') .click(); cy.get('[data-qa-tag-header="even"]').should('not.exist'); @@ -358,7 +434,10 @@ describe('linode landing checks', () => { cy.wait(['@getLinodes', '@getUserPreferences']); // Check 'Summary View' button works as expected that can be visiable, enabled and clickable - getVisible('[aria-label="Toggle display"]').should('be.enabled').click(); + cy.get('[aria-label="Toggle display"]') + .should('be.visible') + .should('be.enabled') + .click(); cy.wait('@updateUserPreferences'); mockLinodes.forEach((linode) => { @@ -378,7 +457,10 @@ describe('linode landing checks', () => { }); // Toggle the 'List View' button to check the display of table items are back to the original view. - getVisible('[aria-label="Toggle display"]').should('be.enabled').click(); + cy.get('[aria-label="Toggle display"]') + .should('be.visible') + .should('be.enabled') + .click(); cy.findByText('Summary').should('not.exist'); cy.findByText('Public IP Addresses').should('not.exist'); diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/notifications.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/notifications.spec.ts index aa4601cd7b1..4767b1e6d25 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/notifications.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/notifications.spec.ts @@ -1,6 +1,5 @@ import { Notification } from '@linode/api-v4'; import { notificationFactory } from '@src/factories/notification'; -import { getClick } from 'support/helpers'; import { mockGetNotifications } from 'support/intercepts/events'; const notifications: Notification[] = [ @@ -23,8 +22,8 @@ describe('verify notification types and icons', () => { mockGetNotifications(notifications).as('mockNotifications'); cy.visitWithLogin('/linodes'); cy.wait('@mockNotifications'); - getClick('button[aria-label="Notifications"]'); - getClick('[data-test-id="showMoreButton"'); + cy.get('button[aria-label="Notifications"]').click(); + cy.get('[data-test-id="showMoreButton"').click(); notifications.forEach((notification) => { cy.get(`[data-test-id="${notification.type}"]`).within(() => { if (notification.severity != 'minor') { diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts index 8109717608d..05eb396a5d3 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts @@ -2,31 +2,17 @@ * @file Smoke tests for crucial Object Storage Access Keys operations. */ -import { - objectStorageKeyFactory, - objectStorageBucketFactory, -} from 'src/factories/objectStorage'; +import { objectStorageKeyFactory } from 'src/factories/objectStorage'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateAccessKey, mockDeleteAccessKey, mockGetAccessKeys, - mockGetBucketsForRegion, - mockUpdateAccessKey, } from 'support/intercepts/object-storage'; -import { - randomDomainName, - randomLabel, - randomNumber, - randomString, -} from 'support/util/random'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { ui } from 'support/ui'; -import { accountFactory, regionFactory } from 'src/factories'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { buildArray } from 'support/util/arrays'; -import { ObjectStorageKeyBucketAccess } from '@linode/api-v4'; +import { accountFactory } from 'src/factories'; import { mockGetAccount } from 'support/intercepts/account'; -import { extendRegion } from 'support/util/regions'; describe('object storage access keys smoke tests', () => { /* @@ -147,386 +133,4 @@ describe('object storage access keys smoke tests', () => { cy.wait(['@deleteKey', '@getKeys']); cy.findByText('No items to display.').should('be.visible'); }); - - describe('Object Storage Multicluster feature enabled', () => { - const mockRegionsObj = buildArray(3, () => { - return extendRegion( - regionFactory.build({ - id: `us-${randomString(5)}`, - label: `mock-obj-region-${randomString(5)}`, - capabilities: ['Object Storage'], - }) - ); - }); - - const mockRegions = [...mockRegionsObj]; - - beforeEach(() => { - mockGetAccount( - accountFactory.build({ - capabilities: ['Object Storage', 'Object Storage Access Key Regions'], - }) - ); - mockAppendFeatureFlags({ - objMultiCluster: true, - objectStorageGen2: { enabled: false }, - }); - }); - - /* - * - Confirms user can create access keys with unlimited access when OBJ Multicluster is enabled. - * - Confirms multiple regions can be selected when creating an access key. - * - Confirms that UI updates to reflect created access key. - */ - it('can create unlimited access keys with OBJ Multicluster', () => { - const mockAccessKey = objectStorageKeyFactory.build({ - id: randomNumber(10000, 99999), - label: randomLabel(), - access_key: randomString(20), - secret_key: randomString(39), - regions: mockRegionsObj.map((mockObjRegion) => ({ - id: mockObjRegion.id, - s3_endpoint: randomDomainName(), - })), - }); - - mockGetAccessKeys([]); - mockCreateAccessKey(mockAccessKey).as('createAccessKey'); - mockGetRegions(mockRegions); - mockRegions.forEach((region) => { - mockGetBucketsForRegion(region.id, []); - }); - - cy.visitWithLogin('/object-storage/access-keys'); - - ui.button - .findByTitle('Create Access Key') - .should('be.visible') - .should('be.enabled') - .click(); - - mockGetAccessKeys([mockAccessKey]); - ui.drawer - .findByTitle('Create Access Key') - .should('be.visible') - .within(() => { - cy.contains('Label (required)') - .should('be.visible') - .click() - .type(mockAccessKey.label); - - cy.contains('Regions (required)').should('be.visible').click(); - - // Select each region with the OBJ capability. - mockRegionsObj.forEach((mockRegion) => { - cy.contains('Regions (required)').type(mockRegion.label); - ui.autocompletePopper - .findByTitle(`${mockRegion.label} (${mockRegion.id})`) - .should('be.visible') - .click(); - }); - - // Close the regions drop-down. - cy.contains('Regions (required)') - .should('be.visible') - .click() - .type('{esc}'); - - // TODO Confirm expected regions are shown. - ui.buttonGroup - .findButtonByTitle('Create Access Key') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait('@createAccessKey'); - ui.dialog - .findByTitle('Access Keys') - .should('be.visible') - .within(() => { - // TODO Add assertions for S3 hostnames - cy.get('input[id="access-key"]') - .should('be.visible') - .should('have.value', mockAccessKey.access_key); - cy.get('input[id="secret-key"]') - .should('be.visible') - .should('have.value', mockAccessKey.secret_key); - - ui.button - .findByTitle('I Have Saved My Secret Key') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.findByText(mockAccessKey.label) - .should('be.visible') - .closest('tr') - .within(() => { - // TODO Add assertions for regions/S3 hostnames - cy.findByText(mockAccessKey.access_key).should('be.visible'); - }); - }); - - /* - * - COnfirms user can create access keys with limited access when OBJ Multicluster is enabled. - * - Confirms that UI updates to reflect created access key. - * - Confirms that "Permissions" drawer contains expected scope and permission data. - */ - it('can create limited access keys with OBJ Multicluster', () => { - const mockRegion = extendRegion( - regionFactory.build({ - id: `us-${randomString(5)}`, - label: `mock-obj-region-${randomString(5)}`, - capabilities: ['Object Storage'], - }) - ); - - const mockBuckets = objectStorageBucketFactory.buildList(2, { - region: mockRegion.id, - cluster: undefined, - }); - - const mockAccessKey = objectStorageKeyFactory.build({ - id: randomNumber(10000, 99999), - label: randomLabel(), - access_key: randomString(20), - secret_key: randomString(39), - regions: [ - { - id: mockRegion.id, - s3_endpoint: randomDomainName(), - }, - ], - limited: true, - bucket_access: mockBuckets.map( - (bucket): ObjectStorageKeyBucketAccess => ({ - bucket_name: bucket.label, - cluster: '', - permissions: 'read_only', - region: mockRegion.id, - }) - ), - }); - - mockGetAccessKeys([]); - mockCreateAccessKey(mockAccessKey).as('createAccessKey'); - mockGetRegions([mockRegion]); - mockGetBucketsForRegion(mockRegion.id, mockBuckets); - - // Navigate to access keys page, click "Create Access Key" button. - cy.visitWithLogin('/object-storage/access-keys'); - ui.button - .findByTitle('Create Access Key') - .should('be.visible') - .should('be.enabled') - .click(); - - // Fill out form in "Create Access Key" drawer. - ui.drawer - .findByTitle('Create Access Key') - .should('be.visible') - .within(() => { - cy.contains('Label (required)') - .should('be.visible') - .click() - .type(mockAccessKey.label); - - cy.contains('Regions (required)') - .should('be.visible') - .click() - .type(`${mockRegion.label}{enter}`); - - ui.autocompletePopper - .findByTitle(`${mockRegion.label} (${mockRegion.id})`) - .should('be.visible'); - - // Dismiss region drop-down. - cy.contains('Regions (required)') - .should('be.visible') - .click() - .type('{esc}'); - - // Enable "Limited Access" toggle for access key and confirm Create button is disabled. - cy.findByText('Limited Access').should('be.visible').click(); - - ui.buttonGroup - .findButtonByTitle('Create Access Key') - .should('be.disabled'); - - // Select access rules for all buckets to enable Create button. - mockBuckets.forEach((mockBucket) => { - cy.findByText(mockBucket.label) - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByLabelText( - `read-only for ${mockRegion.id}-${mockBucket.label}` - ) - .should('be.enabled') - .click(); - }); - }); - - mockGetAccessKeys([mockAccessKey]); - ui.buttonGroup - .findButtonByTitle('Create Access Key') - .should('be.enabled') - .click(); - }); - - // Dismiss secrets dialog. - cy.wait('@createAccessKey'); - ui.buttonGroup - .findButtonByTitle('I Have Saved My Secret Key') - .should('be.visible') - .should('be.enabled') - .click(); - - // Open "Permissions" drawer for new access key. - cy.findByText(mockAccessKey.label) - .should('be.visible') - .closest('tr') - .within(() => { - ui.actionMenu - .findByTitle( - `Action menu for Object Storage Key ${mockAccessKey.label}` - ) - .should('be.visible') - .click(); - }); - - ui.actionMenuItem.findByTitle('Permissions').click(); - ui.drawer - .findByTitle(`Permissions for ${mockAccessKey.label}`) - .should('be.visible') - .within(() => { - mockBuckets.forEach((mockBucket) => { - // TODO M3-7733 Update this selector when ARIA label is fixed. - cy.findByLabelText( - `This token has read-only access for ${mockRegion.id}-${mockBucket.label}` - ); - }); - }); - }); - - /* - * - Confirms user can edit access key labels and regions when OBJ Multicluster is enabled. - * - Confirms that user can deselect regions via the region selection list. - * - Confirms that access keys landing page automatically updates to reflect edited access key. - */ - it('can update access keys with OBJ Multicluster', () => { - const mockInitialRegion = extendRegion( - regionFactory.build({ - id: `us-${randomString(5)}`, - label: `mock-obj-region-${randomString(5)}`, - capabilities: ['Object Storage'], - }) - ); - - const mockUpdatedRegion = extendRegion( - regionFactory.build({ - id: `us-${randomString(5)}`, - label: `mock-obj-region-${randomString(5)}`, - capabilities: ['Object Storage'], - }) - ); - - const mockRegions = [mockInitialRegion, mockUpdatedRegion]; - - const mockAccessKey = objectStorageKeyFactory.build({ - id: randomNumber(10000, 99999), - label: randomLabel(), - access_key: randomString(20), - secret_key: randomString(39), - regions: [ - { - id: mockInitialRegion.id, - s3_endpoint: randomDomainName(), - }, - ], - }); - - const mockUpdatedAccessKeyEndpoint = randomDomainName(); - - const mockUpdatedAccessKey = { - ...mockAccessKey, - label: randomLabel(), - regions: [ - { - id: mockUpdatedRegion.id, - s3_endpoint: mockUpdatedAccessKeyEndpoint, - }, - ], - }; - - mockGetAccessKeys([mockAccessKey]); - mockGetRegions(mockRegions); - cy.visitWithLogin('/object-storage/access-keys'); - - cy.findByText(mockAccessKey.label) - .should('be.visible') - .closest('tr') - .within(() => { - ui.actionMenu - .findByTitle( - `Action menu for Object Storage Key ${mockAccessKey.label}` - ) - .should('be.visible') - .click(); - }); - - ui.actionMenuItem - .findByTitle('Edit') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.drawer - .findByTitle('Edit Access Key') - .should('be.visible') - .within(() => { - cy.contains('Label (required)') - .should('be.visible') - .click() - .type('{selectall}{backspace}') - .type(mockUpdatedAccessKey.label); - - cy.contains('Regions (required)') - .should('be.visible') - .click() - .type(`${mockUpdatedRegion.label}{enter}{esc}`); - - cy.contains(mockUpdatedRegion.label) - .should('be.visible') - .and('exist'); - - // Directly find the close button within the chip - cy.findByTestId(`${mockUpdatedRegion.id}`) - .findByTestId('CloseIcon') - .click(); - - mockUpdateAccessKey(mockUpdatedAccessKey).as('updateAccessKey'); - mockGetAccessKeys([mockUpdatedAccessKey]); - ui.button - .findByTitle('Save Changes') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait('@updateAccessKey'); - - // Confirm that access key landing page reflects updated key. - cy.findByText(mockAccessKey.label).should('not.exist'); - cy.findByText(mockUpdatedAccessKey.label) - .should('be.visible') - .closest('tr') - .within(() => { - cy.contains(mockUpdatedRegion.label).should('be.visible'); - cy.contains(mockUpdatedAccessKeyEndpoint).should('be.visible'); - }); - }); - }); }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index f9c431f5a30..1fa96cb91c1 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -2,12 +2,10 @@ * @file End-to-end tests for Object Storage operations. */ -import 'cypress-file-upload'; import { createBucket } from '@linode/api-v4/lib/object-storage'; import { accountFactory, createObjectStorageBucketFactoryLegacy, - createObjectStorageBucketFactoryGen1, } from 'src/factories'; import { authenticate } from 'support/api/authentication'; import { @@ -18,7 +16,6 @@ import { interceptCreateBucket, interceptDeleteBucket, interceptGetBuckets, - interceptUploadBucketObjectS3, interceptGetBucketAccess, interceptUpdateBucketAccess, } from 'support/intercepts/object-storage'; @@ -27,26 +24,6 @@ import { randomLabel } from 'support/util/random'; import { cleanUp } from 'support/util/cleanup'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -// Message shown on-screen when user navigates to an empty bucket. -const emptyBucketMessage = 'This bucket is empty.'; - -// Message shown on-screen when user navigates to an empty folder. -const emptyFolderMessage = 'This folder is empty.'; - -/** - * Returns the non-empty bucket error message for a bucket with the given label. - * - * This message appears when attempting to delete a bucket that has one or - * more objects. - * - * @param bucketLabel - Label of bucket being deleted. - * - * @returns Non-empty bucket error message. - */ -const getNonEmptyBucketMessage = (bucketLabel: string) => { - return `Bucket ${bucketLabel} is not empty. Please delete all objects and try again.`; -}; - /** * Create a bucket with the given label and cluster. * @@ -78,82 +55,6 @@ const setUpBucket = ( ); }; -/** - * Create a bucket with the given label and cluster. - * - * This function assumes that OBJ Multicluster is enabled. Use - * `setUpBucket` to set up OBJ buckets when Multicluster is disabled. - * - * @param label - Bucket label. - * @param regionId - ID of Bucket region. - * @param cors_enabled - Enable CORS on the bucket: defaults to true for Gen1 and false for Gen2. - * - * @returns Promise that resolves to created Bucket. - */ -const setUpBucketMulticluster = ( - label: string, - regionId: string, - cors_enabled: boolean = true -) => { - return createBucket( - createObjectStorageBucketFactoryGen1.build({ - label, - region: regionId, - cors_enabled, - - // API accepts either `cluster` or `region`, but not both. Our factory - // populates both fields, so we have to manually set `cluster` to `undefined` - // to avoid 400 responses from the API. - cluster: undefined, - }) - ); -}; - -/** - * Uploads the file at the given path and assigns it the given filename. - * - * This assumes that Cypress has already navigated to a page where a file - * upload prompt is present. - * - * @param filepath - Path to file to upload. - * @param filename - Filename to assign to uploaded file. - */ -const uploadFile = (filepath: string, filename: string) => { - cy.fixture(filepath, null).then((contents) => { - cy.get('[data-qa-drop-zone]').attachFile( - { - fileContent: contents, - fileName: filename, - }, - { - subjectType: 'drag-n-drop', - } - ); - }); -}; - -/** - * Asserts that a URL assigned to an alias responds with a given status code. - * - * @param urlAlias - Cypress alias containing the URL to request. - * @param expectedStatus - HTTP status to expect for URL. - */ -const assertStatusForUrlAtAlias = ( - urlAlias: string, - expectedStatus: number -) => { - cy.get(urlAlias).then((url: unknown) => { - // An alias can resolve to anything. We're assuming the user passed a valid - // alias which resolves to a string. - cy.request({ - url: url as string, - failOnStatusCode: false, - }).then((response) => { - expect(response.status).to.eq(expectedStatus); - }); - }); -}; - authenticate(); beforeEach(() => { cy.tag('method:e2e'); @@ -245,216 +146,10 @@ describe('object storage end-to-end tests', () => { cy.findByText(bucketLabel).should('not.exist'); }); - /* - * - Confirms that users can upload new objects. - * - Confirms that users can replace objects with identical filenames. - * - Confirms that users can delete objects. - * - Confirms that users can create folders. - * - Confirms that users can delete empty folders. - * - Confirms that users cannot delete folders with objects. - * - Confirms that users cannot delete buckets with objects. - * - Confirms that private objects cannot be accessed over HTTP. - * - Confirms that public objects can be accessed over HTTP. - */ - it('can upload, access, and delete objects', () => { - const bucketLabel = randomLabel(); - const bucketCluster = 'us-southeast-1'; - const bucketRegionId = 'us-southeast'; - const bucketPage = `/object-storage/buckets/${bucketRegionId}/${bucketLabel}/objects`; - const bucketFolderName = randomLabel(); - - const bucketFiles = [ - { path: 'object-storage-files/1.txt', name: '1.txt' }, - { path: 'object-storage-files/2.jpg', name: '2.jpg' }, - ]; - - cy.defer( - () => setUpBucketMulticluster(bucketLabel, bucketRegionId), - 'creating Object Storage bucket' - ).then(() => { - interceptUploadBucketObjectS3( - bucketLabel, - bucketCluster, - bucketFiles[0].name - ).as('uploadObject'); - - // Navigate to new bucket page, upload and delete an object. - cy.visitWithLogin(bucketPage); - ui.entityHeader.find().within(() => { - cy.findByText(bucketLabel).should('be.visible'); - }); - - uploadFile(bucketFiles[0].path, bucketFiles[0].name); - - // @TODO Investigate why files do not appear automatically in Cypress. - cy.wait('@uploadObject'); - cy.reload(); - - cy.findByText(bucketFiles[0].name).should('be.visible'); - ui.button.findByTitle('Delete').should('be.visible').click(); - - ui.dialog - .findByTitle(`Delete ${bucketFiles[0].name}`) - .should('be.visible') - .within(() => { - ui.buttonGroup - .findButtonByTitle('Delete') - .should('be.visible') - .click(); - }); - - cy.findByText(emptyBucketMessage).should('be.visible'); - cy.findByText(bucketFiles[0].name).should('not.exist'); - - // Create a folder, navigate into it and upload object. - ui.button.findByTitle('Create Folder').should('be.visible').click(); - - ui.drawer - .findByTitle('Create Folder') - .should('be.visible') - .within(() => { - cy.findByLabelText('Folder Name') - .should('be.visible') - .click() - .type(bucketFolderName); - - ui.buttonGroup - .findButtonByTitle('Create') - .should('be.visible') - .click(); - }); - - cy.findByText(bucketFolderName).should('be.visible').click(); - - cy.findByText(emptyFolderMessage).should('be.visible'); - interceptUploadBucketObjectS3( - bucketLabel, - bucketCluster, - `${bucketFolderName}/${bucketFiles[1].name}` - ).as('uploadObject'); - uploadFile(bucketFiles[1].path, bucketFiles[1].name); - cy.wait('@uploadObject'); - - // Re-upload file to confirm replace prompt behavior. - uploadFile(bucketFiles[1].path, bucketFiles[1].name); - cy.findByText( - 'This file already exists. Are you sure you want to overwrite it?' - ); - ui.button.findByTitle('Replace').should('be.visible').click(); - cy.wait('@uploadObject'); - - // Confirm that you cannot delete a bucket with objects in it. - cy.visitWithLogin('/object-storage/buckets'); - cy.findByText(bucketLabel) - .should('be.visible') - .closest('tr') - .within(() => { - ui.button.findByTitle('Delete').should('be.visible').click(); - }); - - ui.dialog - .findByTitle(`Delete Bucket ${bucketLabel}`) - .should('be.visible') - .within(() => { - cy.findByText('Bucket Name').click().type(bucketLabel); - - ui.buttonGroup - .findButtonByTitle('Delete') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.findByText(getNonEmptyBucketMessage(bucketLabel)).should( - 'be.visible' - ); - }); - - // Confirm that you cannot delete a folder with objects in it. - cy.visitWithLogin(bucketPage); - ui.button.findByTitle('Delete').should('be.visible').click(); - - ui.dialog - .findByTitle(`Delete ${bucketFolderName}`) - .should('be.visible') - .within(() => { - ui.button.findByTitle('Delete').should('be.visible').click(); - - cy.findByText('The folder must be empty to delete it.').should( - 'be.visible' - ); - - ui.button.findByTitle('Cancel').should('be.visible').click(); - }); - - // Confirm public/private access controls work as expected. - cy.findByText(bucketFolderName).should('be.visible').click(); - cy.findByText(bucketFiles[1].name).should('be.visible').click(); - - ui.drawer - .findByTitle(`${bucketFolderName}/${bucketFiles[1].name}`) - .should('be.visible') - .within(() => { - // Confirm that object is not public by default. - cy.get('[data-testid="external-site-link"]') - .should('be.visible') - .invoke('attr', 'href') - .as('bucketObjectUrl'); - - assertStatusForUrlAtAlias('@bucketObjectUrl', 403); - - // Make object public, confirm it can be accessed, then close drawer. - cy.findByLabelText('Access Control List (ACL)') - .should('be.visible') - .should('not.have.value', 'Loading access...') - .should('have.value', 'Private') - .click() - .type('Public Read'); - - ui.autocompletePopper - .findByTitle('Public Read') - .should('be.visible') - .click(); - - ui.button.findByTitle('Save').should('be.visible').click(); - - cy.findByText('Object access updated successfully.'); - assertStatusForUrlAtAlias('@bucketObjectUrl', 200); - - ui.drawerCloseButton.find().should('be.visible').click(); - }); - - // Delete object, then delete folder that contained the object. - ui.button.findByTitle('Delete').should('be.visible').click(); - - ui.dialog - .findByTitle(`Delete ${bucketFiles[1].name}`) - .should('be.visible') - .within(() => { - ui.buttonGroup - .findButtonByTitle('Delete') - .should('be.visible') - .click(); - }); - - cy.findByText(emptyFolderMessage).should('be.visible'); - - cy.visitWithLogin(bucketPage); - ui.button.findByTitle('Delete').should('be.visible').click(); - - ui.dialog - .findByTitle(`Delete ${bucketFolderName}`) - .should('be.visible') - .within(() => { - ui.button.findByTitle('Delete').should('be.visible').click(); - }); - - // Confirm that bucket is empty. - cy.findByText(emptyBucketMessage).should('be.visible'); - }); - }); - /* * - Confirms that user can update Bucket access. + * - Confirms user can switch bucket access from Private to Public Read. + * - Confirms that toast notification appears confirming operation. */ it('can update bucket access', () => { const bucketLabel = randomLabel(); @@ -493,6 +188,7 @@ describe('object storage end-to-end tests', () => { ui.button.findByTitle('Save').should('be.visible').click(); + // TODO Confirm that outgoing API request contains expected values. cy.wait('@updateBucketAccess'); cy.findByText('Bucket access updated successfully.'); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts index ec96b743c0b..479bd129fbb 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts @@ -4,7 +4,6 @@ import 'cypress-file-upload'; import { objectStorageBucketFactory } from 'src/factories/objectStorage'; -import { mockGetRegions } from 'support/intercepts/regions'; import { mockCreateBucket, mockDeleteBucket, @@ -14,136 +13,14 @@ import { mockGetBucketObjects, mockUploadBucketObject, mockUploadBucketObjectS3, - mockCreateBucketError, } from 'support/intercepts/object-storage'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { randomLabel, randomString } from 'support/util/random'; +import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; -import { accountFactory, regionFactory } from 'src/factories'; +import { accountFactory } from 'src/factories'; import { mockGetAccount } from 'support/intercepts/account'; -import { extendRegion } from 'support/util/regions'; describe('object storage smoke tests', () => { - /* - * - Tests Object Storage bucket creation flow when OBJ Multicluster is enabled. - * - Confirms that expected regions are displayed in drop-down. - * - Confirms that region can be selected during create. - * - Confirms that API errors are handled gracefully by drawer. - * - Confirms that request payload contains desired Bucket region and not cluster. - * - Confirms that created Bucket is listed on the landing page. - */ - it('can create object storage bucket with OBJ Multicluster', () => { - const mockErrorMessage = 'An unknown error has occurred.'; - - const mockRegionWithObj = extendRegion( - regionFactory.build({ - label: randomLabel(), - id: `${randomString(2)}-${randomString(3)}`, - capabilities: ['Object Storage'], - }) - ); - - const mockRegionsWithoutObj = regionFactory - .buildList(2, { - capabilities: [], - }) - .map((region) => extendRegion(region)); - - const mockRegions = [mockRegionWithObj, ...mockRegionsWithoutObj]; - - const mockBucket = objectStorageBucketFactory.build({ - label: randomLabel(), - region: mockRegionWithObj.id, - cluster: undefined, - objects: 0, - }); - - mockGetAccount( - accountFactory.build({ - capabilities: ['Object Storage', 'Object Storage Access Key Regions'], - }) - ); - mockAppendFeatureFlags({ - objMultiCluster: true, - objectStorageGen2: { enabled: false }, - }).as('getFeatureFlags'); - - mockGetRegions(mockRegions).as('getRegions'); - mockGetBuckets([]).as('getBuckets'); - mockCreateBucketError(mockErrorMessage).as('createBucket'); - - cy.visitWithLogin('/object-storage'); - cy.wait(['@getRegions', '@getBuckets']); - - ui.entityHeader.find().within(() => { - ui.button.findByTitle('Create Bucket').should('be.visible').click(); - }); - - ui.drawer - .findByTitle('Create Bucket') - .should('be.visible') - .within(() => { - // Enter label. - cy.contains('Label').click().type(mockBucket.label); - cy.log(`${mockRegionWithObj.label}`); - cy.contains('Region').click().type(mockRegionWithObj.label); - - ui.autocompletePopper - .find() - .should('be.visible') - .within(() => { - // Confirm that regions without 'Object Storage' capability are not listed. - mockRegionsWithoutObj.forEach((mockRegionWithoutObj) => { - cy.contains(mockRegionWithoutObj.id).should('not.exist'); - }); - - // Confirm that region with 'Object Storage' capability is listed, - // then select it. - cy.findByText( - `${mockRegionWithObj.label} (${mockRegionWithObj.id})` - ) - .should('be.visible') - .click(); - }); - - // Close region select. - cy.contains('Region').click(); - - // On first attempt, mock an error response and confirm message is shown. - ui.buttonGroup - .findButtonByTitle('Create Bucket') - .should('be.visible') - .click(); - - cy.wait('@createBucket'); - cy.findByText(mockErrorMessage).should('be.visible'); - - // Click submit again, mock a successful response. - mockCreateBucket(mockBucket).as('createBucket'); - ui.buttonGroup - .findButtonByTitle('Create Bucket') - .should('be.visible') - .click(); - }); - - // Confirm that Cloud includes the "region" property and omits the "cluster" - // property in its payload when creating a bucket. - cy.wait('@createBucket').then((xhr) => { - const body = xhr.request.body; - expect(body.cluster).to.be.undefined; - expect(body.region).to.eq(mockRegionWithObj.id); - }); - - cy.findByText(mockBucket.label) - .should('be.visible') - .closest('tr') - .within(() => { - // TODO Confirm that bucket region is shown in landing page. - cy.findByText(mockBucket.hostname).should('be.visible'); - // cy.findByText(mockRegionWithObj.label).should('be.visible'); - }); - }); - /* * - Tests core object storage bucket create flow using mocked API responses. * - Creates bucket. @@ -289,7 +166,7 @@ describe('object storage smoke tests', () => { * - Mocks existing buckets. * - Deletes mocked bucket, confirms that landing page reflects deletion. */ - it('can delete object storage bucket - smoke - Multi Cluster Disabled', () => { + it('can delete object storage bucket - smoke', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; const bucketMock = objectStorageBucketFactory.build({ @@ -333,58 +210,4 @@ describe('object storage smoke tests', () => { cy.wait('@deleteBucket'); cy.findByText('S3-compatible storage solution').should('be.visible'); }); - - /* - * - Tests core object storage bucket deletion flow using mocked API responses. - * - Mocks existing buckets. - * - Deletes mocked bucket, confirms that landing page reflects deletion. - */ - it('can delete object storage bucket - smoke - Multi Cluster Enabled', () => { - const bucketLabel = randomLabel(); - const bucketCluster = 'us-southeast-1'; - const bucketMock = objectStorageBucketFactory.build({ - label: bucketLabel, - cluster: bucketCluster, - hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, - objects: 0, - }); - - mockGetAccount( - accountFactory.build({ - capabilities: ['Object Storage', 'Object Storage Access Key Regions'], - }) - ); - mockAppendFeatureFlags({ - objMultiCluster: true, - objectStorageGen2: { enabled: false }, - }); - - mockGetBuckets([bucketMock]).as('getBuckets'); - mockDeleteBucket(bucketLabel, bucketMock.region!).as('deleteBucket'); - - cy.visitWithLogin('/object-storage'); - cy.wait('@getBuckets'); - - cy.findByText(bucketLabel) - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByText('Delete').should('be.visible').click(); - }); - - ui.dialog - .findByTitle(`Delete Bucket ${bucketLabel}`) - .should('be.visible') - .within(() => { - cy.findByLabelText('Bucket Name').click().type(bucketLabel); - ui.buttonGroup - .findButtonByTitle('Delete') - .should('be.enabled') - .should('be.visible') - .click(); - }); - - cy.wait('@deleteBucket'); - cy.findByText('S3-compatible storage solution').should('be.visible'); - }); }); diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts new file mode 100644 index 00000000000..56e1e24d44f --- /dev/null +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts @@ -0,0 +1,406 @@ +import { buildArray } from 'support/util/arrays'; +import { extendRegion } from 'support/util/regions'; +import { + accountFactory, + regionFactory, + objectStorageKeyFactory, + objectStorageBucketFactory, +} from 'src/factories'; +import { + randomString, + randomNumber, + randomLabel, + randomDomainName, +} from 'support/util/random'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + mockGetAccessKeys, + mockCreateAccessKey, + mockGetBucketsForRegion, + mockUpdateAccessKey, +} from 'support/intercepts/object-storage'; +import { ui } from 'support/ui'; + +import type { ObjectStorageKeyBucketAccess } from '@linode/api-v4'; + +describe('Object Storage Multicluster access keys', () => { + const mockRegionsObj = buildArray(3, () => { + return extendRegion( + regionFactory.build({ + id: `us-${randomString(5)}`, + label: `mock-obj-region-${randomString(5)}`, + capabilities: ['Object Storage'], + }) + ); + }); + + const mockRegions = [...mockRegionsObj]; + + beforeEach(() => { + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage', 'Object Storage Access Key Regions'], + }) + ); + mockAppendFeatureFlags({ + objMultiCluster: true, + objectStorageGen2: { enabled: false }, + }); + }); + + /* + * - Confirms user can create access keys with unlimited access when OBJ Multicluster is enabled. + * - Confirms multiple regions can be selected when creating an access key. + * - Confirms that UI updates to reflect created access key. + */ + it('can create unlimited access keys with OBJ Multicluster', () => { + const mockAccessKey = objectStorageKeyFactory.build({ + id: randomNumber(10000, 99999), + label: randomLabel(), + access_key: randomString(20), + secret_key: randomString(39), + regions: mockRegionsObj.map((mockObjRegion) => ({ + id: mockObjRegion.id, + s3_endpoint: randomDomainName(), + })), + }); + + mockGetAccessKeys([]); + mockCreateAccessKey(mockAccessKey).as('createAccessKey'); + mockGetRegions(mockRegions); + mockRegions.forEach((region) => { + mockGetBucketsForRegion(region.id, []); + }); + + cy.visitWithLogin('/object-storage/access-keys'); + + ui.button + .findByTitle('Create Access Key') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetAccessKeys([mockAccessKey]); + ui.drawer + .findByTitle('Create Access Key') + .should('be.visible') + .within(() => { + cy.contains('Label (required)') + .should('be.visible') + .click() + .type(mockAccessKey.label); + + cy.contains('Regions (required)').should('be.visible').click(); + + // Select each region with the OBJ capability. + mockRegionsObj.forEach((mockRegion) => { + cy.contains('Regions (required)').type(mockRegion.label); + ui.autocompletePopper + .findByTitle(`${mockRegion.label} (${mockRegion.id})`) + .should('be.visible') + .click(); + }); + + // Close the regions drop-down. + cy.contains('Regions (required)') + .should('be.visible') + .click() + .type('{esc}'); + + // TODO Confirm expected regions are shown. + ui.buttonGroup + .findButtonByTitle('Create Access Key') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createAccessKey'); + ui.dialog + .findByTitle('Access Keys') + .should('be.visible') + .within(() => { + // TODO Add assertions for S3 hostnames + cy.get('input[id="access-key"]') + .should('be.visible') + .should('have.value', mockAccessKey.access_key); + cy.get('input[id="secret-key"]') + .should('be.visible') + .should('have.value', mockAccessKey.secret_key); + + ui.button + .findByTitle('I Have Saved My Secret Key') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByText(mockAccessKey.label) + .should('be.visible') + .closest('tr') + .within(() => { + // TODO Add assertions for regions/S3 hostnames + cy.findByText(mockAccessKey.access_key).should('be.visible'); + }); + }); + + /* + * - Confirms user can create access keys with limited access when OBJ Multicluster is enabled. + * - Confirms that UI updates to reflect created access key. + * - Confirms that "Permissions" drawer contains expected scope and permission data. + */ + it('can create limited access keys with OBJ Multicluster', () => { + const mockRegion = extendRegion( + regionFactory.build({ + id: `us-${randomString(5)}`, + label: `mock-obj-region-${randomString(5)}`, + capabilities: ['Object Storage'], + }) + ); + + const mockBuckets = objectStorageBucketFactory.buildList(2, { + region: mockRegion.id, + cluster: undefined, + }); + + const mockAccessKey = objectStorageKeyFactory.build({ + id: randomNumber(10000, 99999), + label: randomLabel(), + access_key: randomString(20), + secret_key: randomString(39), + regions: [ + { + id: mockRegion.id, + s3_endpoint: randomDomainName(), + }, + ], + limited: true, + bucket_access: mockBuckets.map( + (bucket): ObjectStorageKeyBucketAccess => ({ + bucket_name: bucket.label, + cluster: '', + permissions: 'read_only', + region: mockRegion.id, + }) + ), + }); + + mockGetAccessKeys([]); + mockCreateAccessKey(mockAccessKey).as('createAccessKey'); + mockGetRegions([mockRegion]); + mockGetBucketsForRegion(mockRegion.id, mockBuckets); + + // Navigate to access keys page, click "Create Access Key" button. + cy.visitWithLogin('/object-storage/access-keys'); + ui.button + .findByTitle('Create Access Key') + .should('be.visible') + .should('be.enabled') + .click(); + + // Fill out form in "Create Access Key" drawer. + ui.drawer + .findByTitle('Create Access Key') + .should('be.visible') + .within(() => { + cy.contains('Label (required)') + .should('be.visible') + .click() + .type(mockAccessKey.label); + + cy.contains('Regions (required)') + .should('be.visible') + .click() + .type(`${mockRegion.label}{enter}`); + + ui.autocompletePopper + .findByTitle(`${mockRegion.label} (${mockRegion.id})`) + .should('be.visible'); + + // Dismiss region drop-down. + cy.contains('Regions (required)') + .should('be.visible') + .click() + .type('{esc}'); + + // Enable "Limited Access" toggle for access key and confirm Create button is disabled. + cy.findByText('Limited Access').should('be.visible').click(); + + ui.buttonGroup + .findButtonByTitle('Create Access Key') + .should('be.disabled'); + + // Select access rules for all buckets to enable Create button. + mockBuckets.forEach((mockBucket) => { + cy.findByText(mockBucket.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByLabelText( + `read-only for ${mockRegion.id}-${mockBucket.label}` + ) + .should('be.enabled') + .click(); + }); + }); + + mockGetAccessKeys([mockAccessKey]); + ui.buttonGroup + .findButtonByTitle('Create Access Key') + .should('be.enabled') + .click(); + }); + + // Dismiss secrets dialog. + cy.wait('@createAccessKey'); + ui.buttonGroup + .findButtonByTitle('I Have Saved My Secret Key') + .should('be.visible') + .should('be.enabled') + .click(); + + // Open "Permissions" drawer for new access key. + cy.findByText(mockAccessKey.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle( + `Action menu for Object Storage Key ${mockAccessKey.label}` + ) + .should('be.visible') + .click(); + }); + + ui.actionMenuItem.findByTitle('Permissions').click(); + ui.drawer + .findByTitle(`Permissions for ${mockAccessKey.label}`) + .should('be.visible') + .within(() => { + mockBuckets.forEach((mockBucket) => { + // TODO M3-7733 Update this selector when ARIA label is fixed. + cy.findByLabelText( + `This token has read-only access for ${mockRegion.id}-${mockBucket.label}` + ); + }); + }); + }); + + /* + * - Confirms user can edit access key labels and regions when OBJ Multicluster is enabled. + * - Confirms that user can deselect regions via the region selection list. + * - Confirms that access keys landing page automatically updates to reflect edited access key. + */ + it('can update access keys with OBJ Multicluster', () => { + const mockInitialRegion = extendRegion( + regionFactory.build({ + id: `us-${randomString(5)}`, + label: `mock-obj-region-${randomString(5)}`, + capabilities: ['Object Storage'], + }) + ); + + const mockUpdatedRegion = extendRegion( + regionFactory.build({ + id: `us-${randomString(5)}`, + label: `mock-obj-region-${randomString(5)}`, + capabilities: ['Object Storage'], + }) + ); + + const mockRegions = [mockInitialRegion, mockUpdatedRegion]; + + const mockAccessKey = objectStorageKeyFactory.build({ + id: randomNumber(10000, 99999), + label: randomLabel(), + access_key: randomString(20), + secret_key: randomString(39), + regions: [ + { + id: mockInitialRegion.id, + s3_endpoint: randomDomainName(), + }, + ], + }); + + const mockUpdatedAccessKeyEndpoint = randomDomainName(); + + const mockUpdatedAccessKey = { + ...mockAccessKey, + label: randomLabel(), + regions: [ + { + id: mockUpdatedRegion.id, + s3_endpoint: mockUpdatedAccessKeyEndpoint, + }, + ], + }; + + mockGetAccessKeys([mockAccessKey]); + mockGetRegions(mockRegions); + cy.visitWithLogin('/object-storage/access-keys'); + + cy.findByText(mockAccessKey.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle( + `Action menu for Object Storage Key ${mockAccessKey.label}` + ) + .should('be.visible') + .click(); + }); + + ui.actionMenuItem + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Edit Access Key') + .should('be.visible') + .within(() => { + cy.contains('Label (required)') + .should('be.visible') + .click() + .type('{selectall}{backspace}') + .type(mockUpdatedAccessKey.label); + + cy.contains('Regions (required)') + .should('be.visible') + .click() + .type(`${mockUpdatedRegion.label}{enter}{esc}`); + + cy.contains(mockUpdatedRegion.label).should('be.visible').and('exist'); + + // Directly find the close button within the chip + cy.findByTestId(`${mockUpdatedRegion.id}`) + .findByTestId('CloseIcon') + .click(); + + mockUpdateAccessKey(mockUpdatedAccessKey).as('updateAccessKey'); + mockGetAccessKeys([mockUpdatedAccessKey]); + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@updateAccessKey'); + + // Confirm that access key landing page reflects updated key. + cy.findByText(mockAccessKey.label).should('not.exist'); + cy.findByText(mockUpdatedAccessKey.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.contains(mockUpdatedRegion.label).should('be.visible'); + cy.contains(mockUpdatedAccessKeyEndpoint).should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts new file mode 100644 index 00000000000..cccbd8542cd --- /dev/null +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts @@ -0,0 +1,138 @@ +import { extendRegion } from 'support/util/regions'; +import { + accountFactory, + regionFactory, + objectStorageBucketFactory, +} from 'src/factories'; +import { randomLabel, randomString } from 'support/util/random'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + mockCreateBucket, + mockCreateBucketError, + mockGetBuckets, +} from 'support/intercepts/object-storage'; +import { ui } from 'support/ui'; + +describe('Object Storage Multicluster Bucket create', () => { + /* + * - Tests Object Storage bucket creation flow when OBJ Multicluster is enabled. + * - Confirms that expected regions are displayed in drop-down. + * - Confirms that region can be selected during create. + * - Confirms that API errors are handled gracefully by drawer. + * - Confirms that request payload contains desired Bucket region and not cluster. + * - Confirms that created Bucket is listed on the landing page. + */ + it('can create object storage bucket with OBJ Multicluster', () => { + const mockErrorMessage = 'An unknown error has occurred.'; + + const mockRegionWithObj = extendRegion( + regionFactory.build({ + label: randomLabel(), + id: `${randomString(2)}-${randomString(3)}`, + capabilities: ['Object Storage'], + }) + ); + + const mockRegionsWithoutObj = regionFactory + .buildList(2, { + capabilities: [], + }) + .map((region) => extendRegion(region)); + + const mockRegions = [mockRegionWithObj, ...mockRegionsWithoutObj]; + + const mockBucket = objectStorageBucketFactory.build({ + label: randomLabel(), + region: mockRegionWithObj.id, + cluster: undefined, + objects: 0, + }); + + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage', 'Object Storage Access Key Regions'], + }) + ); + mockAppendFeatureFlags({ + objMultiCluster: true, + objectStorageGen2: { enabled: false }, + }).as('getFeatureFlags'); + + mockGetRegions(mockRegions).as('getRegions'); + mockGetBuckets([]).as('getBuckets'); + mockCreateBucketError(mockErrorMessage).as('createBucket'); + + cy.visitWithLogin('/object-storage'); + cy.wait(['@getRegions', '@getBuckets']); + + ui.entityHeader.find().within(() => { + ui.button.findByTitle('Create Bucket').should('be.visible').click(); + }); + + ui.drawer + .findByTitle('Create Bucket') + .should('be.visible') + .within(() => { + // Enter label. + cy.contains('Label').click().type(mockBucket.label); + cy.log(`${mockRegionWithObj.label}`); + cy.contains('Region').click().type(mockRegionWithObj.label); + + ui.autocompletePopper + .find() + .should('be.visible') + .within(() => { + // Confirm that regions without 'Object Storage' capability are not listed. + mockRegionsWithoutObj.forEach((mockRegionWithoutObj) => { + cy.contains(mockRegionWithoutObj.id).should('not.exist'); + }); + + // Confirm that region with 'Object Storage' capability is listed, + // then select it. + cy.findByText( + `${mockRegionWithObj.label} (${mockRegionWithObj.id})` + ) + .should('be.visible') + .click(); + }); + + // Close region select. + cy.contains('Region').click(); + + // On first attempt, mock an error response and confirm message is shown. + ui.buttonGroup + .findButtonByTitle('Create Bucket') + .should('be.visible') + .click(); + + cy.wait('@createBucket'); + cy.findByText(mockErrorMessage).should('be.visible'); + + // Click submit again, mock a successful response. + mockCreateBucket(mockBucket).as('createBucket'); + ui.buttonGroup + .findButtonByTitle('Create Bucket') + .should('be.visible') + .click(); + }); + + // Confirm that Cloud includes the "region" property and omits the "cluster" + // property in its payload when creating a bucket. + cy.wait('@createBucket').then((xhr) => { + const body = xhr.request.body; + expect(body.cluster).to.be.undefined; + expect(body.region).to.eq(mockRegionWithObj.id); + }); + + cy.findByText(mockBucket.label) + .should('be.visible') + .closest('tr') + .within(() => { + // TODO Confirm that bucket region is shown in landing page. + cy.findByText(mockBucket.hostname).should('be.visible'); + // cy.findByText(mockRegionWithObj.label).should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-delete-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-delete-multicluster.spec.ts new file mode 100644 index 00000000000..d810cab82ab --- /dev/null +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-delete-multicluster.spec.ts @@ -0,0 +1,65 @@ +import { randomLabel } from 'support/util/random'; +import { accountFactory, objectStorageBucketFactory } from 'src/factories'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetBuckets, + mockDeleteBucket, +} from 'support/intercepts/object-storage'; +import { ui } from 'support/ui'; + +describe('Object Storage Multicluster Bucket delete', () => { + /* + * - Tests core object storage bucket deletion flow using mocked API responses. + * - Mocks existing buckets. + * - Deletes mocked bucket, confirms that landing page reflects deletion. + */ + it('can delete object storage bucket with OBJ Multicluster', () => { + const bucketLabel = randomLabel(); + const bucketCluster = 'us-southeast-1'; + const bucketMock = objectStorageBucketFactory.build({ + label: bucketLabel, + cluster: bucketCluster, + hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, + objects: 0, + }); + + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage', 'Object Storage Access Key Regions'], + }) + ); + mockAppendFeatureFlags({ + objMultiCluster: true, + objectStorageGen2: { enabled: false }, + }); + + mockGetBuckets([bucketMock]).as('getBuckets'); + mockDeleteBucket(bucketLabel, bucketMock.region!).as('deleteBucket'); + + cy.visitWithLogin('/object-storage'); + cy.wait('@getBuckets'); + + cy.findByText(bucketLabel) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Delete').should('be.visible').click(); + }); + + ui.dialog + .findByTitle(`Delete Bucket ${bucketLabel}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Bucket Name').click().type(bucketLabel); + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.enabled') + .should('be.visible') + .click(); + }); + + cy.wait('@deleteBucket'); + cy.findByText('S3-compatible storage solution').should('be.visible'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/objectStorage/bucket-details.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts similarity index 75% rename from packages/manager/cypress/e2e/core/objectStorage/bucket-details.spec.ts rename to packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts index fc2e75c90e7..a5cc6e7158e 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/bucket-details.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts @@ -7,8 +7,10 @@ import { regionFactory, } from 'src/factories'; import { randomLabel } from 'support/util/random'; +import { mockGetBucket } from 'support/intercepts/object-storage'; +import { mockGetRegions } from 'support/intercepts/regions'; -describe('Object Storage Gen 1 Bucket Details Tabs', () => { +describe('Object Storage Multicluster Bucket Details Tabs', () => { beforeEach(() => { mockAppendFeatureFlags({ objMultiCluster: true, @@ -31,11 +33,17 @@ describe('Object Storage Gen 1 Bucket Details Tabs', () => { }); describe('Properties tab without required capabilities', () => { + /* + * - Confirms that Gen 2-specific "Properties" tab is absent when OBJ Multicluster is enabled. + */ it(`confirms the Properties tab does not exist for users without 'Object Storage Endpoint Types' capability`, () => { - const { region, label } = mockBucket; + const { label } = mockBucket; + + mockGetBucket(label, mockRegion.id); + mockGetRegions([mockRegion]); cy.visitWithLogin( - `/object-storage/buckets/${region}/${label}/properties` + `/object-storage/buckets/${mockRegion.id}/${label}/properties` ); cy.wait(['@getFeatureFlags', '@getAccount']); @@ -47,8 +55,6 @@ describe('Object Storage Gen 1 Bucket Details Tabs', () => { // Confirm that "Properties" tab is absent. cy.findByText('Properties').should('not.exist'); - - // TODO Confirm "Not Found" notice is present. }); }); }); diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts new file mode 100644 index 00000000000..1138f4f99dc --- /dev/null +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts @@ -0,0 +1,327 @@ +import 'cypress-file-upload'; +import { authenticate } from 'support/api/authentication'; +import { cleanUp } from 'support/util/cleanup'; +import { randomLabel } from 'support/util/random'; +import { ui } from 'support/ui'; +import { createObjectStorageBucketFactoryGen1 } from 'src/factories'; +import { interceptUploadBucketObjectS3 } from 'support/intercepts/object-storage'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { createBucket } from '@linode/api-v4'; + +// Message shown on-screen when user navigates to an empty bucket. +const emptyBucketMessage = 'This bucket is empty.'; + +// Message shown on-screen when user navigates to an empty folder. +const emptyFolderMessage = 'This folder is empty.'; + +/** + * Returns the non-empty bucket error message for a bucket with the given label. + * + * This message appears when attempting to delete a bucket that has one or + * more objects. + * + * @param bucketLabel - Label of bucket being deleted. + * + * @returns Non-empty bucket error message. + */ +const getNonEmptyBucketMessage = (bucketLabel: string) => { + return `Bucket ${bucketLabel} is not empty. Please delete all objects and try again.`; +}; + +/** + * Create a bucket with the given label and cluster. + * + * This function assumes that OBJ Multicluster is enabled. Use + * `setUpBucket` to set up OBJ buckets when Multicluster is disabled. + * + * @param label - Bucket label. + * @param regionId - ID of Bucket region. + * @param cors_enabled - Enable CORS on the bucket: defaults to true for Gen1 and false for Gen2. + * + * @returns Promise that resolves to created Bucket. + */ +const setUpBucketMulticluster = ( + label: string, + regionId: string, + cors_enabled: boolean = true +) => { + return createBucket( + createObjectStorageBucketFactoryGen1.build({ + label, + region: regionId, + cors_enabled, + + // API accepts either `cluster` or `region`, but not both. Our factory + // populates both fields, so we have to manually set `cluster` to `undefined` + // to avoid 400 responses from the API. + cluster: undefined, + }) + ); +}; + +/** + * Asserts that a URL assigned to an alias responds with a given status code. + * + * @param urlAlias - Cypress alias containing the URL to request. + * @param expectedStatus - HTTP status to expect for URL. + */ +const assertStatusForUrlAtAlias = ( + urlAlias: string, + expectedStatus: number +) => { + cy.get(urlAlias).then((url: unknown) => { + // An alias can resolve to anything. We're assuming the user passed a valid + // alias which resolves to a string. + cy.request({ + url: url as string, + failOnStatusCode: false, + }).then((response) => { + expect(response.status).to.eq(expectedStatus); + }); + }); +}; + +/** + * Uploads the file at the given path and assigns it the given filename. + * + * This assumes that Cypress has already navigated to a page where a file + * upload prompt is present. + * + * @param filepath - Path to file to upload. + * @param filename - Filename to assign to uploaded file. + */ +const uploadFile = (filepath: string, filename: string) => { + cy.fixture(filepath, null).then((contents) => { + cy.get('[data-qa-drop-zone]').attachFile( + { + fileContent: contents, + fileName: filename, + }, + { + subjectType: 'drag-n-drop', + } + ); + }); +}; + +authenticate(); +describe('Object Storage Multicluster objects', () => { + before(() => { + cleanUp('obj-buckets'); + }); + + beforeEach(() => { + cy.tag('method:e2e'); + mockAppendFeatureFlags({ + objMultiCluster: true, + }); + }); + + /* + * - Confirms that users can upload new objects. + * - Confirms that users can replace objects with identical filenames. + * - Confirms that users can delete objects. + * - Confirms that users can create folders. + * - Confirms that users can delete empty folders. + * - Confirms that users cannot delete folders with objects. + * - Confirms that users cannot delete buckets with objects. + * - Confirms that private objects cannot be accessed over HTTP. + * - Confirms that public objects can be accessed over HTTP. + */ + it('can upload, access, and delete objects', () => { + const bucketLabel = randomLabel(); + const bucketCluster = 'us-southeast-1'; + const bucketRegionId = 'us-southeast'; + const bucketPage = `/object-storage/buckets/${bucketRegionId}/${bucketLabel}/objects`; + const bucketFolderName = randomLabel(); + + const bucketFiles = [ + { path: 'object-storage-files/1.txt', name: '1.txt' }, + { path: 'object-storage-files/2.jpg', name: '2.jpg' }, + ]; + + cy.defer( + () => setUpBucketMulticluster(bucketLabel, bucketRegionId), + 'creating Object Storage bucket' + ).then(() => { + interceptUploadBucketObjectS3( + bucketLabel, + bucketCluster, + bucketFiles[0].name + ).as('uploadObject'); + + // Navigate to new bucket page, upload and delete an object. + cy.visitWithLogin(bucketPage); + ui.entityHeader.find().within(() => { + cy.findByText(bucketLabel).should('be.visible'); + }); + + uploadFile(bucketFiles[0].path, bucketFiles[0].name); + + // @TODO Investigate why files do not appear automatically in Cypress. + cy.wait('@uploadObject'); + cy.reload(); + + cy.findByText(bucketFiles[0].name).should('be.visible'); + ui.button.findByTitle('Delete').should('be.visible').click(); + + ui.dialog + .findByTitle(`Delete ${bucketFiles[0].name}`) + .should('be.visible') + .within(() => { + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.visible') + .click(); + }); + + cy.findByText(emptyBucketMessage).should('be.visible'); + cy.findByText(bucketFiles[0].name).should('not.exist'); + + // Create a folder, navigate into it and upload object. + ui.button.findByTitle('Create Folder').should('be.visible').click(); + + ui.drawer + .findByTitle('Create Folder') + .should('be.visible') + .within(() => { + cy.findByLabelText('Folder Name') + .should('be.visible') + .click() + .type(bucketFolderName); + + ui.buttonGroup + .findButtonByTitle('Create') + .should('be.visible') + .click(); + }); + + cy.findByText(bucketFolderName).should('be.visible').click(); + + cy.findByText(emptyFolderMessage).should('be.visible'); + interceptUploadBucketObjectS3( + bucketLabel, + bucketCluster, + `${bucketFolderName}/${bucketFiles[1].name}` + ).as('uploadObject'); + uploadFile(bucketFiles[1].path, bucketFiles[1].name); + cy.wait('@uploadObject'); + + // Re-upload file to confirm replace prompt behavior. + uploadFile(bucketFiles[1].path, bucketFiles[1].name); + cy.findByText( + 'This file already exists. Are you sure you want to overwrite it?' + ); + ui.button.findByTitle('Replace').should('be.visible').click(); + cy.wait('@uploadObject'); + + // Confirm that you cannot delete a bucket with objects in it. + cy.visitWithLogin('/object-storage/buckets'); + cy.findByText(bucketLabel) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button.findByTitle('Delete').should('be.visible').click(); + }); + + ui.dialog + .findByTitle(`Delete Bucket ${bucketLabel}`) + .should('be.visible') + .within(() => { + cy.findByText('Bucket Name').click().type(bucketLabel); + + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByText(getNonEmptyBucketMessage(bucketLabel)).should( + 'be.visible' + ); + }); + + // Confirm that you cannot delete a folder with objects in it. + cy.visitWithLogin(bucketPage); + ui.button.findByTitle('Delete').should('be.visible').click(); + + ui.dialog + .findByTitle(`Delete ${bucketFolderName}`) + .should('be.visible') + .within(() => { + ui.button.findByTitle('Delete').should('be.visible').click(); + + cy.findByText('The folder must be empty to delete it.').should( + 'be.visible' + ); + + ui.button.findByTitle('Cancel').should('be.visible').click(); + }); + + // Confirm public/private access controls work as expected. + cy.findByText(bucketFolderName).should('be.visible').click(); + cy.findByText(bucketFiles[1].name).should('be.visible').click(); + + ui.drawer + .findByTitle(`${bucketFolderName}/${bucketFiles[1].name}`) + .should('be.visible') + .within(() => { + // Confirm that object is not public by default. + cy.get('[data-testid="external-site-link"]') + .should('be.visible') + .invoke('attr', 'href') + .as('bucketObjectUrl'); + + assertStatusForUrlAtAlias('@bucketObjectUrl', 403); + + // Make object public, confirm it can be accessed, then close drawer. + cy.findByLabelText('Access Control List (ACL)') + .should('be.visible') + .should('not.have.value', 'Loading access...') + .should('have.value', 'Private') + .click() + .type('Public Read'); + + ui.autocompletePopper + .findByTitle('Public Read') + .should('be.visible') + .click(); + + ui.button.findByTitle('Save').should('be.visible').click(); + + cy.findByText('Object access updated successfully.'); + assertStatusForUrlAtAlias('@bucketObjectUrl', 200); + + ui.drawerCloseButton.find().should('be.visible').click(); + }); + + // Delete object, then delete folder that contained the object. + ui.button.findByTitle('Delete').should('be.visible').click(); + + ui.dialog + .findByTitle(`Delete ${bucketFiles[1].name}`) + .should('be.visible') + .within(() => { + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.visible') + .click(); + }); + + cy.findByText(emptyFolderMessage).should('be.visible'); + + cy.visitWithLogin(bucketPage); + ui.button.findByTitle('Delete').should('be.visible').click(); + + ui.dialog + .findByTitle(`Delete ${bucketFolderName}`) + .should('be.visible') + .within(() => { + ui.button.findByTitle('Delete').should('be.visible').click(); + }); + + // Confirm that bucket is empty. + cy.findByText(emptyBucketMessage).should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts index f82c9d4650c..1ee95a8652c 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts @@ -16,6 +16,7 @@ import { LinodeConfigInterfaceFactoryWithVPC, subnetFactory, vpcFactory, + LinodeConfigInterfaceFactory, } from '@src/factories'; import { randomLabel, randomNumber, randomPhrase } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -333,6 +334,63 @@ describe('VPC details page', () => { cy.findByTestId(WARNING_ICON_UNRECOMMENDED_CONFIG).should('not.exist'); }); + /** + * - Confirms UI for Linode with a config with an implicit primary VPC interface (no notice) + */ + it('does not display an unrecommended config notice for a Linode with an implicit primary VPC', () => { + const linodeRegion = chooseRegion({ capabilities: ['VPCs'] }); + + const mockInterfaceId = randomNumber(); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + const mockSubnet = subnetFactory.build({ + id: randomNumber(), + label: randomLabel(), + linodes: [ + { + id: mockLinode.id, + interfaces: [{ id: mockInterfaceId, active: true }], + }, + ], + ipv4: '10.0.0.0/24', + }); + + const mockVPC = vpcFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + subnets: [mockSubnet], + }); + + const mockInterface = LinodeConfigInterfaceFactoryWithVPC.build({ + id: mockInterfaceId, + vpc_id: mockVPC.id, + subnet_id: mockSubnet.id, + primary: false, + active: true, + }); + + const mockLinodeConfig = linodeConfigFactory.build({ + interfaces: [mockInterface], + }); + + mockGetVPC(mockVPC).as('getVPC'); + mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + mockGetLinodeConfigs(mockLinode.id, [mockLinodeConfig]).as( + 'getLinodeConfigs' + ); + + cy.visitWithLogin(`/vpcs/${mockVPC.id}`); + cy.findByLabelText(`expand ${mockSubnet.label} row`).click(); + cy.wait('@getLinodeConfigs'); + cy.findByTestId(WARNING_ICON_UNRECOMMENDED_CONFIG).should('not.exist'); + }); + /** * - Confirms UI for Linode with an unrecommended config (notice displayed) */ @@ -365,6 +423,12 @@ describe('VPC details page', () => { subnets: [mockSubnet], }); + const mockPrimaryInterface = LinodeConfigInterfaceFactory.build({ + primary: true, + active: false, + purpose: 'public', + }); + const mockInterface = LinodeConfigInterfaceFactoryWithVPC.build({ id: mockInterfaceId, vpc_id: mockVPC.id, @@ -374,7 +438,7 @@ describe('VPC details page', () => { }); const mockLinodeConfig = linodeConfigFactory.build({ - interfaces: [mockInterface], + interfaces: [mockInterface, mockPrimaryInterface], }); mockGetVPC(mockVPC).as('getVPC'); diff --git a/packages/manager/cypress/support/constants/linodes.ts b/packages/manager/cypress/support/constants/linodes.ts index 35d6762f810..c09601fe5de 100644 --- a/packages/manager/cypress/support/constants/linodes.ts +++ b/packages/manager/cypress/support/constants/linodes.ts @@ -8,3 +8,10 @@ * Equals 5 minutes. */ export const LINODE_CREATE_TIMEOUT = 300_000; + +/** + * Length of time to wait for a Linode to be cloned. + * + * Equals 5 minutes. + */ +export const LINODE_CLONE_TIMEOUT = 300_000; diff --git a/packages/manager/cypress/support/helpers.ts b/packages/manager/cypress/support/helpers.ts deleted file mode 100644 index 4cbccea82dd..00000000000 --- a/packages/manager/cypress/support/helpers.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* These are shortened methods that will handle finding and clicking or -finding and asserting visible without having to chain. They don't chain off of cy */ -const visible = 'be.visible'; - -/** - * Deprecated. Use `cy.contains(text).should('be.visible')` instead. - * - * @deprecated - */ -export const containsVisible = (text: string) => { - return cy.contains(text).should(visible); -}; - -/** - * Deprecated. Use `cy.contains(text).click()` instead. - * - * @deprecated - */ -export const containsClick = (text: string) => { - return cy.contains(text).click(); -}; - -/** - * Deprecated. Use `cy.findByPlaceholderText(text).click()` instead. - * - * @deprecated - */ -export const containsPlaceholderClick = (text: string) => { - return cy.get(`[placeholder="${text}"]`).click(); -}; - -/** - * Deprecated. Use `cy.get(element).should('be.visible')` instead. - * - * @deprecated - */ -export const getVisible = (element: string) => { - return cy.get(element).should(visible); -}; - -/** - * Deprecated. Use `cy.get(element).click()` instead. - * - * @deprecated - */ -export const getClick = (element: string) => { - return cy.get(element).click(); -}; - -/** - * Deprecated. Use `cy.findByText(text).should('be.visible')` instead. - * - * @deprecated - */ -export const fbtVisible = (text: string) => { - return cy.findByText(text).should(visible); -}; - -/** - * Deprecated. Use `cy.findByText(text).click()` instead. - * - * @deprecated - */ -export const fbtClick = (text: string) => { - return cy.findByText(text).click(); -}; - -/** - * Deprecated. Use `cy.findByLabelText(text).should('be.visible')` instead. - * - * @deprecated - */ -export const fbltVisible = (text: string) => { - return cy.findByLabelText(text).should(visible); -}; - -/** - * Deprecated. Use `cy.findByLabelText(text).click()` instead. - * - * @deprecated - */ -export const fbltClick = (text: string) => { - return cy.findByLabelText(text).click(); -}; diff --git a/packages/manager/cypress/support/plugins/reset-user-preferences.ts b/packages/manager/cypress/support/plugins/reset-user-preferences.ts new file mode 100644 index 00000000000..313a9215ecb --- /dev/null +++ b/packages/manager/cypress/support/plugins/reset-user-preferences.ts @@ -0,0 +1,25 @@ +import { CypressPlugin } from './plugin'; +import { updateUserPreferences } from '@linode/api-v4'; + +const envVarName = 'CY_TEST_RESET_PREFERENCES'; + +/** + * Resets test account user preferences to expected state when + * `CY_TEST_RESET_PREFERENCES` is set. + */ +export const resetUserPreferences: CypressPlugin = async (_on, config) => { + if (config.env[envVarName]) { + await updateUserPreferences({ + // Sidebar categories are fully expanded. + collapsedSideNavProductFamilies: [], + + // Sidebar is not pinned. + desktop_sidebar_open: false, + + // Type-to-confirm is enabled. + type_to_confirm: true, + }); + + console.info('Reset test account user preferences'); + } +}; diff --git a/packages/manager/cypress/tsconfig.json b/packages/manager/cypress/tsconfig.json index bedcfa42811..2bf14da000b 100644 --- a/packages/manager/cypress/tsconfig.json +++ b/packages/manager/cypress/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../tsconfig.json", "compilerOptions": { + "moduleResolution": "node", "baseUrl": "..", "paths": { "src/*": ["./src/*"], diff --git a/packages/manager/package.json b/packages/manager/package.json index b84877cb298..430b6b8ee03 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.134.0", + "version": "1.135.0", "private": true, "type": "module", "bugs": { @@ -119,20 +119,20 @@ }, "devDependencies": { "@linode/eslint-plugin-cloud-manager": "^0.0.5", - "@storybook/addon-a11y": "^8.3.0", - "@storybook/addon-actions": "^8.3.0", - "@storybook/addon-controls": "^8.3.0", - "@storybook/addon-docs": "^8.3.0", - "@storybook/addon-mdx-gfm": "^8.3.0", - "@storybook/addon-measure": "^8.3.0", - "@storybook/addon-storysource": "^8.3.0", - "@storybook/addon-viewport": "^8.3.0", - "@storybook/blocks": "^8.3.0", - "@storybook/manager-api": "^8.3.0", - "@storybook/preview-api": "^8.3.0", - "@storybook/react": "^8.3.0", - "@storybook/react-vite": "^8.3.0", - "@storybook/theming": "^8.3.0", + "@storybook/addon-a11y": "^8.4.7", + "@storybook/addon-actions": "^8.4.7", + "@storybook/addon-controls": "^8.4.7", + "@storybook/addon-docs": "^8.4.7", + "@storybook/addon-mdx-gfm": "^8.4.7", + "@storybook/addon-measure": "^8.4.7", + "@storybook/addon-storysource": "^8.4.7", + "@storybook/addon-viewport": "^8.4.7", + "@storybook/blocks": "^8.4.7", + "@storybook/manager-api": "^8.4.7", + "@storybook/preview-api": "^8.4.7", + "@storybook/react": "^8.4.7", + "@storybook/react-vite": "^8.4.7", + "@storybook/theming": "^8.4.7", "@swc/core": "^1.3.1", "@testing-library/cypress": "^10.0.2", "@testing-library/dom": "^10.1.0", @@ -203,7 +203,7 @@ "msw": "^2.2.3", "prettier": "~2.2.1", "redux-mock-store": "^1.5.3", - "storybook": "^8.3.0", + "storybook": "^8.4.7", "storybook-dark-mode": "4.0.1", "vite": "^5.4.6", "vite-plugin-svgr": "^3.2.0" diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index dcdae2ec607..8a8628951f4 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -159,7 +159,6 @@ const SupportTicketDetail = React.lazy(() => }) ) ); -const Longview = React.lazy(() => import('src/features/Longview')); const Managed = React.lazy(() => import('src/features/Managed/ManagedLanding')); const Help = React.lazy(() => import('./features/Help/index').then((module) => ({ @@ -339,7 +338,6 @@ export const MainContent = () => { path="/nodebalancers" /> - + + + + + + + + + diff --git a/packages/manager/src/assets/icons/zoomin.svg b/packages/manager/src/assets/icons/zoomin.svg index fcb722675ef..86d7a2a4f4c 100644 --- a/packages/manager/src/assets/icons/zoomin.svg +++ b/packages/manager/src/assets/icons/zoomin.svg @@ -1,10 +1,10 @@ - - - - - - - - + + + + + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/zoomout.svg b/packages/manager/src/assets/icons/zoomout.svg index 7021f3a5f61..fcb722675ef 100644 --- a/packages/manager/src/assets/icons/zoomout.svg +++ b/packages/manager/src/assets/icons/zoomout.svg @@ -1,10 +1,10 @@ - - - - - - - - - + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/timezones/timezones.ts b/packages/manager/src/assets/timezones/timezones.ts index 87655164181..0888381c189 100644 --- a/packages/manager/src/assets/timezones/timezones.ts +++ b/packages/manager/src/assets/timezones/timezones.ts @@ -1290,6 +1290,11 @@ export const timezones = [ name: 'Asia/Kolkata', offset: 5.5, }, + { + label: 'India Standard Time - Calcutta', + name: 'Asia/Calcutta', + offset: 5.5, + }, { label: 'Nepal Time', name: 'Asia/Kathmandu', diff --git a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx index 43937633388..2c49ff413eb 100644 --- a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx +++ b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -21,17 +21,20 @@ export interface ConfirmationDialogProps extends DialogProps { * - Avoid “Are you sure?” language. Assume the user knows what they want to do while helping them avoid unintended consequences. * */ -export const ConfirmationDialog = (props: ConfirmationDialogProps) => { +export const ConfirmationDialog = React.forwardRef< + HTMLDivElement, + ConfirmationDialogProps +>((props, ref) => { const { actions, children, ...dialogProps } = props; return ( - + {children} {actions && typeof actions === 'function' ? actions(dialogProps) @@ -39,4 +42,4 @@ export const ConfirmationDialog = (props: ConfirmationDialogProps) => { ); -}; +}); diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx index 86c66ee834a..1cee4f53b99 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -21,12 +21,15 @@ import type { DateTime } from 'luxon'; export interface DateTimePickerProps { /** Additional props for the DateCalendar */ dateCalendarProps?: Partial>; + disabledTimeZone?: boolean; /** Error text for the date picker field */ errorText?: string; /** Format for displaying the date-time */ format?: string; /** Label for the input field */ label?: string; + /** Minimum date-time before which all date-time will be disabled */ + minDate?: DateTime; /** Callback when the "Apply" button is clicked */ onApply?: () => void; /** Callback when the "Cancel" button is clicked */ @@ -61,9 +64,11 @@ export interface DateTimePickerProps { export const DateTimePicker = ({ dateCalendarProps = {}, + disabledTimeZone = false, errorText = '', format = 'yyyy-MM-dd HH:mm', label = 'Select Date and Time', + minDate, onApply, onCancel, onChange, @@ -193,6 +198,7 @@ export const DateTimePicker = ({ > ({ @@ -266,6 +278,7 @@ export const DateTimePicker = ({ {showTimeZone && ( ; export const Default: Story = { args: { + enablePresets: true, endDateProps: { - errorMessage: '', label: 'End Date and Time', placeholder: '', showTimeZone: false, value: null, }, + format: 'yyyy-MM-dd HH:mm', onChange: action('DateTime range changed'), presetsProps: { - defaultValue: { label: '', value: '' }, - enablePresets: true, + defaultValue: '', label: '', placeholder: '', }, @@ -40,16 +40,17 @@ export const Default: Story = { export const WithInitialValues: Story = { args: { + enablePresets: true, endDateProps: { label: 'End Date and Time', showTimeZone: true, value: DateTime.now(), }, + format: 'yyyy-MM-dd HH:mm', onChange: action('DateTime range changed'), presetsProps: { - defaultValue: { label: 'Last 7 Days', value: '7days' }, - enablePresets: true, + defaultValue: '7days', label: 'Time Range', placeholder: 'Select Range', }, @@ -65,8 +66,8 @@ export const WithInitialValues: Story = { export const WithCustomErrors: Story = { args: { + enablePresets: true, endDateProps: { - errorMessage: 'End date must be after the start date.', label: 'Custom End Label', placeholder: '', showTimeZone: false, @@ -75,8 +76,8 @@ export const WithCustomErrors: Story = { format: 'yyyy-MM-dd HH:mm', onChange: action('DateTime range changed'), presetsProps: { - defaultValue: { label: '', value: '' }, - enablePresets: true, + defaultValue: '', + label: '', placeholder: '', }, diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx index 3dc542f4c36..1011c755345 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx @@ -12,12 +12,12 @@ import type { DateTimeRangePickerProps } from './DateTimeRangePicker'; const onChangeMock = vi.fn(); const Props: DateTimeRangePickerProps = { + enablePresets: true, endDateProps: { label: 'End Date and Time', }, onChange: onChangeMock, presetsProps: { - enablePresets: true, label: 'Date Presets', }, @@ -74,7 +74,7 @@ describe('DateTimeRangePicker Component', () => { }); }); - it('should show error when end date-time is before start date-time', async () => { + it('should disable the end date-time which is before the selected start date-time', async () => { renderWithTheme(); // Set start date-time to the 15th @@ -87,20 +87,14 @@ describe('DateTimeRangePicker Component', () => { const endDateField = screen.getByLabelText('End Date and Time'); await userEvent.click(endDateField); - // Set start date-time to the 10th - await userEvent.click(screen.getByRole('gridcell', { name: '10' })); - await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - - // Confirm error message is displayed - expect( - screen.getByText('End date/time cannot be before the start date/time.') - ).toBeInTheDocument(); + expect(screen.getByRole('gridcell', { name: '10' })).toBeDisabled(); }); it('should show error when start date-time is after end date-time', async () => { const updateProps = { ...Props, - presetsProps: { ...Props.presetsProps, enablePresets: false }, + enablePresets: false, + presetsProps: { ...Props.presetsProps }, }; renderWithTheme(); @@ -125,6 +119,7 @@ describe('DateTimeRangePicker Component', () => { it('should display custom error messages when start date-time is after end date-time', async () => { const updatedProps = { ...Props, + enablePresets: false, endDateProps: { ...Props.endDateProps, errorMessage: 'Custom end date error', @@ -323,24 +318,9 @@ describe('DateTimeRangePicker Component', () => { await userEvent.click(endDateField); // Set start date-time to the 12th - await userEvent.click(screen.getByRole('gridcell', { name: '12' })); + await userEvent.click(screen.getByRole('gridcell', { name: '17' })); await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - // Confirm error message is shown since the click was blocked - expect( - screen.getByText('End date/time cannot be before the start date/time.') - ).toBeInTheDocument(); - - // Set start date-time to the 11th - await userEvent.click(startDateField); - await userEvent.click(screen.getByRole('gridcell', { name: '11' })); - await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - - // Confirm error message is not displayed - expect( - screen.queryByText('End date/time cannot be before the start date/time.') - ).not.toBeInTheDocument(); - // Set start date-time to the 20th await userEvent.click(startDateField); await userEvent.click(screen.getByRole('gridcell', { name: '20' })); diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx index 7083ba7bee8..a83aab1206e 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx @@ -9,10 +9,14 @@ import { DateTimePicker } from './DateTimePicker'; import type { SxProps, Theme } from '@mui/material/styles'; export interface DateTimeRangePickerProps { + /** If true, disable the timezone drop down */ + disabledTimeZone?: boolean; + + /** If true, shows the date presets field instead of the date pickers */ + enablePresets?: boolean; + /** Properties for the end date field */ endDateProps?: { - /** Custom error message for invalid end date */ - errorMessage?: string; /** Label for the end date field */ label?: string; /** placeholder for the end date field */ @@ -40,9 +44,7 @@ export interface DateTimeRangePickerProps { /** Additional settings for the presets dropdown */ presetsProps?: { /** Default value for the presets field */ - defaultValue?: { label: string; value: string }; - /** If true, shows the date presets field instead of the date pickers */ - enablePresets?: boolean; + defaultValue?: string; /** Label for the presets field */ label?: string; /** placeholder for the presets field */ @@ -71,13 +73,17 @@ export interface DateTimeRangePickerProps { type DatePresetType = | '7days' + | '12hours' | '24hours' | '30days' + | '30minutes' | 'custom_range' | 'last_month' | 'this_month'; const presetsOptions: { label: string; value: DatePresetType }[] = [ + { label: 'Last 30 Minutes', value: '30minutes' }, + { label: 'Last 12 Hours', value: '12hours' }, { label: 'Last 24 Hours', value: '24hours' }, { label: 'Last 7 Days', value: '7days' }, { label: 'Last 30 Days', value: '30days' }, @@ -88,21 +94,20 @@ const presetsOptions: { label: string; value: DatePresetType }[] = [ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { const { + disabledTimeZone = false, + + enablePresets = false, + endDateProps: { - errorMessage: endDateErrorMessage = 'End date/time cannot be before the start date/time.', label: endLabel = 'End Date and Time', placeholder: endDatePlaceholder, showTimeZone: showEndTimeZone = false, value: endDateTimeValue = null, } = {}, - format = 'yyyy-MM-dd HH:mm', - onChange, - presetsProps: { - defaultValue: presetsDefaultValue = { label: '', value: '' }, - enablePresets = false, + defaultValue: presetsDefaultValue = presetsOptions[0].value, label: presetsLabel = 'Time Range', placeholder: presetsPlaceholder = 'Select a preset', } = {}, @@ -123,17 +128,25 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { const [endDateTime, setEndDateTime] = useState( endDateTimeValue ); - const [presetValue, setPresetValue] = useState<{ - label: string; - value: string; - }>(presetsDefaultValue); + const [presetValue, setPresetValue] = useState< + | { + label: string; + value: string; + } + | undefined + >( + presetsOptions.find((option) => option.value === presetsDefaultValue) ?? + presetsOptions[0] + ); const [startTimeZone, setStartTimeZone] = useState( startTimeZoneValue ); const [startDateError, setStartDateError] = useState(null); - const [endDateError, setEndDateError] = useState(null); - const [showPresets, setShowPresets] = useState(enablePresets); - + const [showPresets, setShowPresets] = useState( + presetsDefaultValue + ? presetsDefaultValue !== 'custom_range' && enablePresets + : enablePresets + ); const theme = useTheme(); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); @@ -142,38 +155,34 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { end: DateTime | null, source: 'end' | 'start' ) => { - if (start && end) { - if (source === 'start' && start > end) { - setStartDateError(startDateErrorMessage); - return; - } - if (source === 'end' && end < start) { - setEndDateError(endDateErrorMessage); - return; - } + if (start && end && source === 'start' && start > end) { + setStartDateError(startDateErrorMessage); + return; } // Reset validation errors setStartDateError(null); - setEndDateError(null); }; const handlePresetSelection = (value: DatePresetType) => { const now = DateTime.now(); let newStartDateTime: DateTime | null = null; - let newEndDateTime: DateTime | null = null; + let newEndDateTime: DateTime | null = now; switch (value) { + case '30minutes': + newStartDateTime = now.minus({ minutes: 30 }); + break; + case '12hours': + newStartDateTime = now.minus({ hours: 12 }); + break; case '24hours': newStartDateTime = now.minus({ hours: 24 }); - newEndDateTime = now; break; case '7days': newStartDateTime = now.minus({ days: 7 }); - newEndDateTime = now; break; case '30days': newStartDateTime = now.minus({ days: 30 }); - newEndDateTime = now; break; case 'this_month': newStartDateTime = now.startOf('month'); @@ -196,7 +205,7 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { setEndDateTime(newEndDateTime); setPresetValue( presetsOptions.find((option) => option.value === value) ?? - presetsDefaultValue + presetsOptions[0] ); if (onChange) { @@ -248,7 +257,8 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { handlePresetSelection(selection.value as DatePresetType); } }} - defaultValue={presetsDefaultValue} + data-qa-preset="preset-select" + data-testid="preset-select" disableClearable fullWidth label={presetsLabel} @@ -269,6 +279,7 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { onChange: (value) => setStartTimeZone(value), value: startTimeZone, }} + disabledTimeZone={disabledTimeZone} errorText={startDateError ?? undefined} format={format} label={startLabel} @@ -282,24 +293,22 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { timeZoneSelectProps={{ value: startTimeZone, }} - errorText={endDateError ?? undefined} + disabledTimeZone={disabledTimeZone} format={format} label={endLabel} + minDate={startDateTime || undefined} onChange={handleEndDateTimeChange} placeholder={endDatePlaceholder} showTimeZone={showEndTimeZone} timeSelectProps={{ label: 'End Time' }} value={endDateTime} /> - + { setShowPresets(true); - setPresetValue(presetsDefaultValue); + setPresetValue(undefined); + setStartDateError(null); }} variant="text" > diff --git a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.styles.ts b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.styles.ts deleted file mode 100644 index 6ec0bfda539..00000000000 --- a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.styles.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Notice, StyledLinkButton } from '@linode/ui'; -import { styled } from '@mui/material/styles'; - -export const StyledNotice = styled(Notice, { label: 'StyledNotice' })( - ({ theme }) => ({ - '&&': { - p: { - lineHeight: '1.25rem', - }, - }, - alignItems: 'center', - background: theme.bg.bgPaper, - borderRadius: 1, - display: 'flex', - flexFlow: 'row nowrap', - justifyContent: 'space-between', - marginBottom: theme.spacing(), - padding: theme.spacing(2), - }) -); - -export const StyledButton = styled(StyledLinkButton, { label: 'StyledButton' })( - ({ theme }) => ({ - color: theme.textColors.tableStatic, - display: 'flex', - marginLeft: 20, - }) -); diff --git a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx index 4ee35065ea8..14c5ed1017e 100644 --- a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx +++ b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx @@ -1,12 +1,9 @@ -import { Box } from '@linode/ui'; +import { IconButton, Notice, Stack } from '@linode/ui'; import Close from '@mui/icons-material/Close'; -import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { useDismissibleNotifications } from 'src/hooks/useDismissibleNotifications'; -import { StyledButton, StyledNotice } from './DismissibleBanner.styles'; - import type { NoticeProps } from '@linode/ui'; import type { DismissibleNotificationOptions } from 'src/hooks/useDismissibleNotifications'; @@ -42,14 +39,7 @@ interface Props extends NoticeProps { * - Call to action: Primary Button or text link allows a user to take action directly from the banner. */ export const DismissibleBanner = (props: Props) => { - const { - actionButton, - children, - className, - options, - preferenceKey, - ...rest - } = props; + const { actionButton, children, options, preferenceKey, ...rest } = props; const { handleDismiss, hasDismissedBanner } = useDismissibleBanner( preferenceKey, @@ -61,32 +51,30 @@ export const DismissibleBanner = (props: Props) => { } const dismissibleButton = ( - - - - - + + + ); return ( - - - {children} - - {actionButton} - {dismissibleButton} - - - + theme.palette.background.paper} + display="flex" + gap={1} + justifyContent="space-between" + {...rest} + > + {children} + + {actionButton} + {dismissibleButton} + + ); }; diff --git a/packages/manager/src/components/Encryption/Encryption.test.tsx b/packages/manager/src/components/Encryption/Encryption.test.tsx index 1a4750c0846..3b65e7dba5d 100644 --- a/packages/manager/src/components/Encryption/Encryption.test.tsx +++ b/packages/manager/src/components/Encryption/Encryption.test.tsx @@ -2,12 +2,8 @@ import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { - Encryption, - checkboxTestId, - descriptionTestId, - headerTestId, -} from './Encryption'; +import { checkboxTestId, descriptionTestId, headerTestId } from './constants'; +import { Encryption } from './Encryption'; describe('DiskEncryption', () => { it('should render a header', () => { diff --git a/packages/manager/src/components/Encryption/Encryption.tsx b/packages/manager/src/components/Encryption/Encryption.tsx index 60fb435cc07..1b90722ce39 100644 --- a/packages/manager/src/components/Encryption/Encryption.tsx +++ b/packages/manager/src/components/Encryption/Encryption.tsx @@ -2,6 +2,8 @@ import { Box, Checkbox, Notice, Typography } from '@linode/ui'; import { List, ListItem } from '@mui/material'; import * as React from 'react'; +import { checkboxTestId, descriptionTestId, headerTestId } from './constants'; + export interface EncryptionProps { descriptionCopy: JSX.Element | string; disabled?: boolean; @@ -13,10 +15,6 @@ export interface EncryptionProps { onChange: (checked: boolean) => void; } -export const headerTestId = 'encryption-header'; -export const descriptionTestId = 'encryption-description'; -export const checkboxTestId = 'encrypt-entity-checkbox'; - export const Encryption = (props: EncryptionProps) => { const { descriptionCopy, diff --git a/packages/manager/src/components/Encryption/constants.tsx b/packages/manager/src/components/Encryption/constants.tsx index 7224e491364..0cb07201c56 100644 --- a/packages/manager/src/components/Encryption/constants.tsx +++ b/packages/manager/src/components/Encryption/constants.tsx @@ -2,15 +2,20 @@ import React from 'react'; import { Link } from 'src/components/Link'; +/* Test IDs */ +export const headerTestId = 'encryption-header'; +export const descriptionTestId = 'encryption-description'; +export const checkboxTestId = 'encrypt-entity-checkbox'; + /* Disk Encryption constants */ const DISK_ENCRYPTION_GUIDE_LINK = 'https://techdocs.akamai.com/cloud-computing/docs/local-disk-encryption'; export const DISK_ENCRYPTION_GENERAL_DESCRIPTION = ( <> - Secure this Linode using data at rest encryption. Data center systems take - care of encrypting and decrypting for you. After the Linode is created, use - Rebuild to enable or disable this feature.{' '} + Secure this Linode with data-at-rest encryption. Data center systems handle + encryption automatically for you. After the Linode is created, use Rebuild + to enable or disable encryption.{' '} Learn more. ); diff --git a/packages/manager/src/components/EnhancedSelect/components/DropdownIndicator.tsx b/packages/manager/src/components/EnhancedSelect/components/DropdownIndicator.tsx index d6bf5f480a3..8d5c3b48d22 100644 --- a/packages/manager/src/components/EnhancedSelect/components/DropdownIndicator.tsx +++ b/packages/manager/src/components/EnhancedSelect/components/DropdownIndicator.tsx @@ -6,8 +6,8 @@ export const DropdownIndicator = () => { return ; }; -const StyledKeyboardArrowDown = styled(KeyboardArrowDown)(() => ({ - color: '#aaa !important', +const StyledKeyboardArrowDown = styled(KeyboardArrowDown)(({ theme }) => ({ + color: `${theme.tokens.color.Neutrals[50]} !important`, height: 28, marginRight: '4px', marginTop: 0, diff --git a/packages/manager/src/components/OrderBy.tsx b/packages/manager/src/components/OrderBy.tsx index 9ffcba5454a..82b5a2a9801 100644 --- a/packages/manager/src/components/OrderBy.tsx +++ b/packages/manager/src/components/OrderBy.tsx @@ -1,5 +1,5 @@ import { DateTime } from 'luxon'; -import { equals, pathOr, sort, splitAt } from 'ramda'; +import { equals, pathOr, sort } from 'ramda'; import * as React from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; @@ -16,6 +16,7 @@ import { sortByString, sortByUTFDate, } from 'src/utilities/sort-by'; +import { splitAt } from 'src/utilities/splitAt'; import type { Order } from 'src/hooks/useOrder'; import type { ManagerPreferences } from 'src/types/ManagerPreferences'; diff --git a/packages/manager/src/components/PasswordInput/HideShowText.stories.tsx b/packages/manager/src/components/PasswordInput/HideShowText.stories.tsx index b2561a58866..5608aa6bd4e 100644 --- a/packages/manager/src/components/PasswordInput/HideShowText.stories.tsx +++ b/packages/manager/src/components/PasswordInput/HideShowText.stories.tsx @@ -1,9 +1,10 @@ /* eslint-disable react-hooks/rules-of-hooks */ -import { Meta, StoryObj } from '@storybook/react'; import React, { useState } from 'react'; import { HideShowText } from './HideShowText'; +import type { Meta, StoryObj } from '@storybook/react'; + const meta: Meta = { component: HideShowText, title: 'Components/Input/Hide Show Text', diff --git a/packages/manager/src/components/PasswordInput/PasswordInput.stories.tsx b/packages/manager/src/components/PasswordInput/PasswordInput.stories.tsx index 67a76b58e8e..12ec8a4e7be 100644 --- a/packages/manager/src/components/PasswordInput/PasswordInput.stories.tsx +++ b/packages/manager/src/components/PasswordInput/PasswordInput.stories.tsx @@ -1,9 +1,10 @@ /* eslint-disable react-hooks/rules-of-hooks */ -import { Meta, StoryObj } from '@storybook/react'; import React, { useState } from 'react'; import PasswordInput from './PasswordInput'; +import type { Meta, StoryObj } from '@storybook/react'; + const meta: Meta = { component: PasswordInput, title: 'Components/Input/Password Input', diff --git a/packages/manager/src/components/PasswordInput/StrengthIndicator.stories.tsx b/packages/manager/src/components/PasswordInput/StrengthIndicator.stories.tsx index 227b36af4e1..bd41bb63e86 100644 --- a/packages/manager/src/components/PasswordInput/StrengthIndicator.stories.tsx +++ b/packages/manager/src/components/PasswordInput/StrengthIndicator.stories.tsx @@ -1,8 +1,9 @@ -import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; import { StrengthIndicator } from './StrengthIndicator'; +import type { Meta, StoryObj } from '@storybook/react'; + const meta: Meta = { component: StrengthIndicator, title: 'Components/Strength Indicator', diff --git a/packages/manager/src/components/Tabs/TanStackTabLinkList.tsx b/packages/manager/src/components/Tabs/TanStackTabLinkList.tsx new file mode 100644 index 00000000000..89701c8b0ce --- /dev/null +++ b/packages/manager/src/components/Tabs/TanStackTabLinkList.tsx @@ -0,0 +1,39 @@ +import { Link as TanstackLink } from '@tanstack/react-router'; +import * as React from 'react'; + +import { Tab } from 'src/components/Tabs/Tab'; +import { TabList } from 'src/components/Tabs/TabList'; + +import type { Tab as TanstackTab } from 'src/hooks/useTabs'; + +export interface Tab { + chip?: React.JSX.Element | null; + routeName: string; + title: string; +} + +interface TabLinkListProps { + noLink?: boolean; + tabs: TanstackTab[]; +} + +export const TanStackTabLinkList = ({ noLink, tabs }: TabLinkListProps) => { + return ( + + {tabs.map((tab, _index) => { + return ( + + {tab.title} + {tab.chip} + + ); + })} + + ); +}; diff --git a/packages/manager/src/components/Tags/Tags.tsx b/packages/manager/src/components/Tags/Tags.tsx index 970e750d9e5..c7cbc70a326 100644 --- a/packages/manager/src/components/Tags/Tags.tsx +++ b/packages/manager/src/components/Tags/Tags.tsx @@ -1,8 +1,8 @@ -import { splitAt } from 'ramda'; import * as React from 'react'; import { ShowMore } from 'src/components/ShowMore/ShowMore'; import { Tag } from 'src/components/Tag/Tag'; +import { splitAt } from 'src/utilities/splitAt'; export interface TagsProps { /** diff --git a/packages/manager/src/components/Uploaders/FileUpload.styles.ts b/packages/manager/src/components/Uploaders/FileUpload.styles.ts index 8d0ac7bb2a5..24c89059e8d 100644 --- a/packages/manager/src/components/Uploaders/FileUpload.styles.ts +++ b/packages/manager/src/components/Uploaders/FileUpload.styles.ts @@ -73,7 +73,9 @@ export const StyledActionsContainer = styled('div', { export const useStyles = makeStyles()((theme: Theme) => ({ barColorPrimary: { backgroundColor: - theme.name === 'light' ? theme.tokens.color.Brand[30] : '#243142', + theme.name === 'light' + ? theme.tokens.color.Brand[30] + : theme.tokens.color.Brand[100], }, error: { '& g': { diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx index 9386d2150fa..6c70707cd20 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx @@ -96,7 +96,7 @@ export const ImageUploader = React.memo((props: Props) => { }); const Dropzone = styled('div')<{ active: boolean }>(({ active, theme }) => ({ - borderColor: 'gray', + borderColor: theme.tokens.color.Neutrals[60], borderStyle: 'dashed', borderWidth: 1, display: 'flex', diff --git a/packages/manager/src/components/Uploaders/reducer.test.ts b/packages/manager/src/components/Uploaders/reducer.test.ts index 3bd3102b849..78571ad5c34 100644 --- a/packages/manager/src/components/Uploaders/reducer.test.ts +++ b/packages/manager/src/components/Uploaders/reducer.test.ts @@ -15,6 +15,7 @@ describe('reducer', () => { const file1: File = { arrayBuffer: vi.fn(), + bytes: async () => new Uint8Array(), lastModified: 0, name: 'my-file1', size: 0, @@ -24,8 +25,10 @@ describe('reducer', () => { type: '', webkitRelativePath: '', }; + const file2: File = { arrayBuffer: vi.fn(), + bytes: async () => new Uint8Array(), lastModified: 0, name: 'my-file2', size: 0, diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index a11cf2ab320..d1cf640975b 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -26,6 +26,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'gecko2', label: 'Gecko' }, { flag: 'imageServiceGen2', label: 'Image Service Gen2' }, { flag: 'imageServiceGen2Ga', label: 'Image Service Gen2 GA' }, + { flag: 'limitsEvolution', label: 'Limits Evolution' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'lkeEnterprise', label: 'LKE-Enterprise' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, diff --git a/packages/manager/src/factories/accountResources.ts b/packages/manager/src/factories/accountResources.ts index df33e999ac8..e9bb03940e5 100644 --- a/packages/manager/src/factories/accountResources.ts +++ b/packages/manager/src/factories/accountResources.ts @@ -1,29 +1,102 @@ -import { IamAccountResource } from '@linode/api-v4'; import Factory from 'src/factories/factoryProxy'; -export const accountResourcesFactory = Factory.Sync.makeFactory( - [ - { - resource_type: 'linode', - resources: [ - { - name: 'debian-us-123', - id: 12345678, - }, - { - name: 'linode-uk-123', - id: 23456789, - }, - ], - }, - { - resource_type: 'firewall', - resources: [ - { - name: 'firewall-us-123', - id: 45678901, - }, - ], - }, - ] -); +import type { IamAccountResource } from '@linode/api-v4'; + +export const accountResourcesFactory = Factory.Sync.makeFactory< + IamAccountResource[] +>([ + { + resource_type: 'linode', + resources: [ + { + id: 12345678, + name: 'debian-us-123', + }, + { + id: 23456789, + name: 'linode-uk-123', + }, + ], + }, + { + resource_type: 'firewall', + resources: [ + { + id: 45678901, + name: 'firewall-us-123', + }, + ], + }, + { + resource_type: 'image', + resources: [ + { + id: 65789745, + name: 'image-us-123', + }, + ], + }, + { + resource_type: 'vpc', + resources: [ + { + id: 7654321, + name: 'vpc-us-123', + }, + ], + }, + { + resource_type: 'volume', + resources: [ + { + id: 890357, + name: 'volume-us-123', + }, + ], + }, + { + resource_type: 'nodebalancer', + resources: [ + { + id: 4532187, + name: 'nodebalancer-us-123', + }, + ], + }, + { + resource_type: 'longview', + resources: [ + { + id: 432178973, + name: 'longview-us-123', + }, + ], + }, + { + resource_type: 'domain', + resources: [ + { + id: 5437894, + name: 'domain-us-123', + }, + ], + }, + { + resource_type: 'stackscript', + resources: [ + { + id: 654321789, + name: 'stackscript-us-123', + }, + ], + }, + { + resource_type: 'database', + resources: [ + { + id: 643218965, + name: 'database-us-123', + }, + ], + }, +]); diff --git a/packages/manager/src/factories/cloudpulse/alerts.ts b/packages/manager/src/factories/cloudpulse/alerts.ts index 5a47ea798ae..58669164b9e 100644 --- a/packages/manager/src/factories/cloudpulse/alerts.ts +++ b/packages/manager/src/factories/cloudpulse/alerts.ts @@ -1,7 +1,32 @@ import Factory from 'src/factories/factoryProxy'; +import type { + AlertDefinitionDimensionFilter, + AlertDefinitionMetricCriteria, +} from '@linode/api-v4'; import type { Alert } from '@linode/api-v4'; +export const alertDimensionsFactory = Factory.Sync.makeFactory( + { + dimension_label: 'operating_system', + label: 'Operating System', + operator: 'eq', + value: 'Linux', + } +); + +export const alertRulesFactory = Factory.Sync.makeFactory( + { + aggregation_type: 'avg', + dimension_filters: alertDimensionsFactory.buildList(1), + label: 'CPU Usage', + metric: 'cpu_usage', + operator: 'eq', + threshold: 60, + unit: 'Bytes', + } +); + export const alertFactory = Factory.Sync.makeFactory({ channels: [], created: new Date().toISOString(), @@ -20,9 +45,9 @@ export const alertFactory = Factory.Sync.makeFactory({ tags: ['tag1', 'tag2'], trigger_conditions: { criteria_condition: 'ALL', - evaluation_period_seconds: 0, - polling_interval_seconds: 0, - trigger_occurrences: 0, + evaluation_period_seconds: 240, + polling_interval_seconds: 120, + trigger_occurrences: 3, }, type: 'user', updated: new Date().toISOString(), diff --git a/packages/manager/src/factories/cloudpulse/channels.ts b/packages/manager/src/factories/cloudpulse/channels.ts new file mode 100644 index 00000000000..d7560717414 --- /dev/null +++ b/packages/manager/src/factories/cloudpulse/channels.ts @@ -0,0 +1,32 @@ +import Factory from 'src/factories/factoryProxy'; + +import type { NotificationChannel } from '@linode/api-v4'; + +export const notificationChannelFactory = Factory.Sync.makeFactory( + { + alerts: [ + { + id: Number(Factory.each((i) => i)), + label: String(Factory.each((id) => `Alert-${id}`)), + type: 'alerts-definitions', + url: 'Sample', + }, + ], + channel_type: 'email', + content: { + email: { + email_addresses: ['test@test.com', 'test2@test.com'], + message: 'You have a new Alert', + subject: 'Sample Alert', + }, + }, + created_at: new Date().toISOString(), + created_by: 'user1', + id: Factory.each((i) => i), + label: Factory.each((id) => `Channel-${id}`), + status: 'Enabled', + type: 'custom', + updated_at: new Date().toISOString(), + updated_by: 'user1', + } +); diff --git a/packages/manager/src/factories/dashboards.ts b/packages/manager/src/factories/dashboards.ts index 275b05f0506..c02bf807d92 100644 --- a/packages/manager/src/factories/dashboards.ts +++ b/packages/manager/src/factories/dashboards.ts @@ -30,13 +30,13 @@ export const widgetFactory = Factory.Sync.makeFactory({ aggregate_function: 'avg', chart_type: Factory.each((i) => chart_type[i % chart_type.length]), color: Factory.each((i) => color[i % color.length]), + entity_ids: Factory.each((i) => [`resource-${i}`]), filters: [], group_by: 'region', label: Factory.each((i) => `widget_label_${i}`), metric: Factory.each((i) => `widget_metric_${i}`), namespace_id: Factory.each((i) => i % 10), region_id: Factory.each((i) => i % 5), - resource_id: Factory.each((i) => [`resource-${i}`]), service_type: 'default', serviceType: 'default', size: 12, diff --git a/packages/manager/src/factories/index.ts b/packages/manager/src/factories/index.ts index 230144fbb9a..7811496bc26 100644 --- a/packages/manager/src/factories/index.ts +++ b/packages/manager/src/factories/index.ts @@ -54,6 +54,7 @@ export * from './vpcs'; export * from './dashboards'; export * from './cloudpulse/services'; export * from './cloudpulse/alerts'; +export * from './cloudpulse/channels'; // Convert factory output to our itemsById pattern export const normalizeEntities = (entities: any[]) => { diff --git a/packages/manager/src/factories/quotas.ts b/packages/manager/src/factories/quotas.ts new file mode 100644 index 00000000000..739c62ccc36 --- /dev/null +++ b/packages/manager/src/factories/quotas.ts @@ -0,0 +1,13 @@ +import Factory from 'src/factories/factoryProxy'; + +import type { Quota } from '@linode/api-v4/lib/quotas/types'; + +export const quotaFactory = Factory.Sync.makeFactory({ + description: 'Maximimum number of vCPUs allowed', + quota_id: Factory.each((id) => id), + quota_limit: 50, + quota_name: 'Linode Dedicated vCPUs', + region_applied: 'us-east', + resource_metric: 'CPU', + used: 25, +}); diff --git a/packages/manager/src/factories/types.ts b/packages/manager/src/factories/types.ts index fb0a9ae3674..97e5dae32d2 100644 --- a/packages/manager/src/factories/types.ts +++ b/packages/manager/src/factories/types.ts @@ -1,7 +1,6 @@ import Factory from 'src/factories/factoryProxy'; -import type { LinodeType } from '@linode/api-v4/lib/linodes/types'; -import type { PriceType } from '@linode/api-v4/src/types'; +import type { LinodeType, PriceType } from '@linode/api-v4'; import type { PlanSelectionAvailabilityTypes, PlanWithAvailability, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 38d5300b4cc..1cc71554ccf 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -121,6 +121,7 @@ export interface Flags { imageServiceGen2: boolean; imageServiceGen2Ga: boolean; ipv6Sharing: boolean; + limitsEvolution: BaseFeatureFlag; linodeDiskEncryption: boolean; lkeEnterprise: LkeEnterpriseFlag; mainContentBanner: MainContentBanner; diff --git a/packages/manager/src/features/Backups/BackupsCTA.styles.ts b/packages/manager/src/features/Backups/BackupsCTA.styles.ts deleted file mode 100644 index d01ba8ff10c..00000000000 --- a/packages/manager/src/features/Backups/BackupsCTA.styles.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Paper } from '@linode/ui'; -import { styled } from '@mui/material/styles'; - -export const StyledPaper = styled(Paper, { - label: 'StyledPaper', -})(({ theme }) => ({ - alignItems: 'center', - display: 'flex', - justifyContent: 'space-between', - margin: `${theme.spacing(1)} 0 ${theme.spacing(3)} 0`, - padding: theme.spacing(1), - paddingRight: theme.spacing(2), -})); diff --git a/packages/manager/src/features/Backups/BackupsCTA.tsx b/packages/manager/src/features/Backups/BackupsCTA.tsx index dd7d36153e9..5aa84cc5aa8 100644 --- a/packages/manager/src/features/Backups/BackupsCTA.tsx +++ b/packages/manager/src/features/Backups/BackupsCTA.tsx @@ -1,7 +1,8 @@ -import { Box, StyledLinkButton, Typography } from '@linode/ui'; +import { IconButton, Notice, Typography } from '@linode/ui'; import Close from '@mui/icons-material/Close'; -import * as React from 'react'; +import React from 'react'; +import { LinkButton } from 'src/components/LinkButton'; import { useAccountSettings } from 'src/queries/account/settings'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { @@ -11,7 +12,6 @@ import { import { useProfile } from 'src/queries/profile/profile'; import { BackupDrawer } from './BackupDrawer'; -import { StyledPaper } from './BackupsCTA.styles'; export const BackupsCTA = () => { const { data: accountSettings } = useAccountSettings(); @@ -20,7 +20,7 @@ export const BackupsCTA = () => { const { data: isBackupsBannerDismissed } = usePreferences( (preferences) => preferences?.backups_cta_dismissed ); - const { mutateAsync: updatePreferences } = useMutatePreferences(); + const { mutate: updatePreferences } = useMutatePreferences(); const [isBackupsDrawerOpen, setIsBackupsDrawerOpen] = React.useState(false); @@ -44,26 +44,31 @@ export const BackupsCTA = () => { } return ( - - - setIsBackupsDrawerOpen(true)}> + theme.palette.background.paper} + display="flex" + flexDirection="row" + justifyContent="space-between" + spacingBottom={8} + variant="info" + > + + setIsBackupsDrawerOpen(true)}> Enable Linode Backups - {' '} + {' '} to protect your data and recover quickly in an emergency. - - - - - + + + setIsBackupsDrawerOpen(false)} open={isBackupsDrawerOpen} /> - + ); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx index e053f94493d..27fa75df515 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx @@ -1,6 +1,11 @@ import React from 'react'; -import { alertFactory, serviceTypesFactory } from 'src/factories/'; +import { + alertFactory, + linodeFactory, + regionFactory, + serviceTypesFactory, +} from 'src/factories/'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AlertDetail } from './AlertDetail'; @@ -8,10 +13,15 @@ import { AlertDetail } from './AlertDetail'; // Mock Data const alertDetails = alertFactory.build({ service_type: 'linode' }); +const linodes = linodeFactory.buildList(3); +const regions = regionFactory.buildList(3); + // Mock Queries const queryMocks = vi.hoisted(() => ({ useAlertDefinitionQuery: vi.fn(), useCloudPulseServiceTypes: vi.fn(), + useRegionsQuery: vi.fn(), + useResourcesQuery: vi.fn(), })); vi.mock('src/queries/cloudpulse/alerts', () => ({ @@ -26,6 +36,16 @@ vi.mock('src/queries/cloudpulse/services', () => { }; }); +vi.mock('src/queries/cloudpulse/resources', () => ({ + ...vi.importActual('src/queries/cloudpulse/resources'), + useResourcesQuery: queryMocks.useResourcesQuery, +})); + +vi.mock('src/queries/regions/regions', () => ({ + ...vi.importActual('src/queries/regions/regions'), + useRegionsQuery: queryMocks.useRegionsQuery, +})); + // Shared Setup beforeEach(() => { queryMocks.useAlertDefinitionQuery.mockReturnValue({ @@ -37,6 +57,16 @@ beforeEach(() => { data: { data: serviceTypesFactory.buildList(1) }, isFetching: false, }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodes, + isError: false, + isFetching: false, + }); + queryMocks.useRegionsQuery.mockReturnValue({ + data: regions, + isError: false, + isFetching: false, + }); }); describe('AlertDetail component tests', () => { @@ -81,6 +111,8 @@ describe('AlertDetail component tests', () => { const { getByText } = renderWithTheme(); // validate overview is present with its couple of properties (values will be validated in its own components test) expect(getByText('Overview')).toBeInTheDocument(); + expect(getByText('Criteria')).toBeInTheDocument(); // validate if criteria is present + expect(getByText('Resources')).toBeInTheDocument(); // validate if resources is present expect(getByText('Name:')).toBeInTheDocument(); expect(getByText('Description:')).toBeInTheDocument(); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx index cd6c3a3aaa7..3b81a48bdf2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx @@ -1,4 +1,4 @@ -import { Box, CircleProgress } from '@linode/ui'; +import { Box, Chip, CircleProgress, Typography } from '@linode/ui'; import { styled, useTheme } from '@mui/material'; import React from 'react'; import { useParams } from 'react-router-dom'; @@ -9,7 +9,9 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Placeholder } from 'src/components/Placeholder/Placeholder'; import { useAlertDefinitionQuery } from 'src/queries/cloudpulse/alerts'; +import { AlertResources } from '../AlertsResources/AlertsResources'; import { getAlertBoxStyles } from '../Utils/utils'; +import { AlertDetailCriteria } from './AlertDetailCriteria'; import { AlertDetailOverview } from './AlertDetailOverview'; interface RouteParams { @@ -48,12 +50,14 @@ export const AlertDetail = () => { }, [alertId, serviceType]); const theme = useTheme(); + const nonSuccessBoxHeight = '600px'; + const sectionMaxHeight = '785px'; if (isFetching) { return ( <> - + @@ -64,7 +68,7 @@ export const AlertDetail = () => { return ( <> - + @@ -75,7 +79,7 @@ export const AlertDetail = () => { return ( <> - + { ); } - // TODO: The criteria, resources details for alerts will be added by consuming the results of useAlertDefinitionQuery call in the coming PR's + const { entity_ids: entityIds } = alertDetails; return ( <> @@ -93,11 +97,32 @@ export const AlertDetail = () => { + + + + + + @@ -114,3 +139,25 @@ export const StyledPlaceholder = styled(Placeholder, { maxHeight: theme.spacing(10), }, })); + +export const StyledAlertChip = styled(Chip, { + label: 'StyledAlertChip', + shouldForwardProp: (prop) => prop !== 'borderRadius', +})<{ + borderRadius?: string; +}>(({ borderRadius, theme }) => ({ + '& .MuiChip-label': { + color: theme.tokens.content.Text.Primary.Default, + marginRight: theme.spacing(1), + }, + backgroundColor: theme.tokens.background.Normal, + borderRadius: borderRadius || 0, + height: theme.spacing(3), +})); + +export const StyledAlertTypography = styled(Typography, { + label: 'StyledAlertTypography', +})(({ theme }) => ({ + color: theme.tokens.content.Text.Primary.Default, + fontSize: theme.typography.body1.fontSize, +})); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.test.tsx new file mode 100644 index 00000000000..49ea7b6e017 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.test.tsx @@ -0,0 +1,64 @@ +import React from 'react'; + +import { + alertDimensionsFactory, + alertFactory, + alertRulesFactory, +} from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { metricOperatorTypeMap } from '../constants'; +import { convertSecondsToMinutes } from '../Utils/utils'; +import { AlertDetailCriteria } from './AlertDetailCriteria'; + +describe('AlertDetailCriteria component tests', () => { + it('should render the alert detail criteria successfully on correct inputs', () => { + const alertDetails = alertFactory.build({ + rule_criteria: { + rules: alertRulesFactory.buildList(2, { + aggregation_type: 'avg', + dimension_filters: alertDimensionsFactory.buildList(2), + label: 'CPU Usage', + metric: 'cpu_usage', + operator: 'gt', + unit: 'bytes', + }), + }, + }); + const { getAllByText, getByText } = renderWithTheme( + + ); + const { rules } = alertDetails.rule_criteria; + expect(getAllByText('Metric Threshold:').length).toBe(rules.length); + expect(getAllByText('Dimension Filter:').length).toBe(rules.length); + expect(getByText('Criteria')).toBeInTheDocument(); + expect(getAllByText('Average').length).toBe(2); + expect(getAllByText('CPU Usage').length).toBe(2); + expect(getAllByText('bytes').length).toBe(2); + expect(getAllByText(metricOperatorTypeMap['gt']).length).toBe(2); + const { + evaluation_period_seconds, + polling_interval_seconds, + } = alertDetails.trigger_conditions; + expect( + getByText(convertSecondsToMinutes(polling_interval_seconds)) + ).toBeInTheDocument(); + expect( + getByText(convertSecondsToMinutes(evaluation_period_seconds)) + ).toBeInTheDocument(); + }); + + it('should render the alert detail criteria even if rules are empty', () => { + const alert = alertFactory.build({ + rule_criteria: { + rules: [], + }, + }); + const { getByText, queryByText } = renderWithTheme( + + ); + expect(getByText('Criteria')).toBeInTheDocument(); // empty criteria should be there + expect(queryByText('Metric Threshold:')).not.toBeInTheDocument(); + expect(queryByText('Dimension Filter:')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx new file mode 100644 index 00000000000..73af8b4e528 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx @@ -0,0 +1,81 @@ +import { Typography } from '@linode/ui'; +import { Grid, useTheme } from '@mui/material'; +import React from 'react'; + +import { convertSecondsToMinutes } from '../Utils/utils'; +import { StyledAlertChip, StyledAlertTypography } from './AlertDetail'; +import { DisplayAlertDetailChips } from './DisplayAlertDetailChips'; +import { RenderAlertMetricsAndDimensions } from './RenderAlertsMetricsAndDimensions'; + +import type { Alert } from '@linode/api-v4'; + +interface CriteriaProps { + /** + * The alert detail object for which the criteria needs to be displayed + */ + alertDetails: Alert; +} + +export const AlertDetailCriteria = React.memo((props: CriteriaProps) => { + const { alertDetails } = props; + const { + evaluation_period_seconds: evaluationPeriod, + polling_interval_seconds: pollingIntervalSeconds, + trigger_occurrences: triggerOccurrences, + } = alertDetails.trigger_conditions; + const { rule_criteria: ruleCriteria = { rules: [] } } = alertDetails; + const theme = useTheme(); + + // Memoized trigger criteria rendering + const renderTriggerCriteria = React.useMemo( + () => ( + <> + + + Trigger Alert When: + + + + + + criteria are met for + + + + consecutive occurrences. + + + + ), + [theme, triggerOccurrences] + ); + return ( + <> + + Criteria + + + + + + {renderTriggerCriteria} {/** Render the trigger criteria */} + + + ); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx index 6e7683fdb8d..02d6c63af0b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx @@ -1,9 +1,10 @@ -import { Typography } from '@linode/ui'; import { Grid, useTheme } from '@mui/material'; import React from 'react'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { StyledAlertTypography } from './AlertDetail'; + import type { Status } from 'src/components/StatusIcon/StatusIcon'; interface AlertDetailRowProps { @@ -46,13 +47,9 @@ export const AlertDetailRow = React.memo((props: AlertDetailRowProps) => { return ( - + {label}: - + {status && ( @@ -63,12 +60,7 @@ export const AlertDetailRow = React.memo((props: AlertDetailRowProps) => { status={status} /> )} - - {value} - + {value} ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/DisplayAlertDetailChips.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/DisplayAlertDetailChips.tsx new file mode 100644 index 00000000000..c3fff1256ed --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/DisplayAlertDetailChips.tsx @@ -0,0 +1,93 @@ +import { Grid, useTheme } from '@mui/material'; +import React from 'react'; + +import { getAlertChipBorderRadius } from '../Utils/utils'; +import { StyledAlertChip, StyledAlertTypography } from './AlertDetail'; + +export interface AlertDimensionsProp { + /** + * The label or title of the chips + */ + label: string; + /** + * Number of grid columns for the label on medium to larger screens. + * Defaults to 4. This controls the width of the label in the grid layout. + */ + labelGridColumns?: number; + /** + * Determines whether chips should be displayed individually + * or merged into a single row + */ + mergeChips?: boolean; + /** + * Number of grid columns for the value on medium to larger screens. + * Defaults to 8. This controls the width of the value in the grid layout. + */ + valueGridColumns?: number; + /** + * The list of chip labels to be displayed. + * Can be a flat array of strings or a nested array for grouped chips. + * Example: ['chip1', 'chip2'] or [['group1-chip1', 'group1-chip2'], ['group2-chip1']] + */ + values: Array | Array; +} + +export const DisplayAlertDetailChips = React.memo( + (props: AlertDimensionsProp) => { + const { + label, + labelGridColumns = 4, + mergeChips, + valueGridColumns = 8, + values: values, + } = props; + + const chipValues: string[][] = Array.isArray(values) + ? values.every(Array.isArray) + ? values + : [values] + : []; + const theme = useTheme(); + return ( + + {chipValues.map((value, index) => ( + + + {index === 0 && ( + + {label}: + + )} + + + + {value.map((label, index) => ( + 0 ? -1 : 0} + > + + + ))} + + + + ))} + + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/RenderAlertsMetricsAndDimensions.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/RenderAlertsMetricsAndDimensions.tsx new file mode 100644 index 00000000000..0aac5132726 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/RenderAlertsMetricsAndDimensions.tsx @@ -0,0 +1,86 @@ +import { Divider } from '@linode/ui'; +import { Grid } from '@mui/material'; +import React from 'react'; + +import NullComponent from 'src/components/NullComponent'; + +import { + aggregationTypeMap, + dimensionOperatorTypeMap, + metricOperatorTypeMap, +} from '../constants'; +import { DisplayAlertDetailChips } from './DisplayAlertDetailChips'; + +import type { AlertDefinitionMetricCriteria } from '@linode/api-v4'; + +interface AlertMetricAndDimensionsProp { + /* + * The rule criteria associated with the alert for which the dimension filters are needed to be displayed + */ + ruleCriteria: { + rules: AlertDefinitionMetricCriteria[]; + }; +} + +export const RenderAlertMetricsAndDimensions = React.memo( + (props: AlertMetricAndDimensionsProp) => { + const { ruleCriteria } = props; + + if (!ruleCriteria.rules?.length) { + return ; + } + + return ruleCriteria.rules.map( + ( + { + aggregation_type: aggregationType, + dimension_filters: dimensionFilters, + label, + operator, + threshold, + unit, + }, + index + ) => ( + + + + + + {dimensionFilters && dimensionFilters.length > 0 && ( + + [ + dimensionLabel, + dimensionOperatorTypeMap[dimensionOperator], + value, + ] + )} + label="Dimension Filter" + mergeChips + /> + + )} + + + + + ) + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.test.tsx new file mode 100644 index 00000000000..34651adcc73 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.test.tsx @@ -0,0 +1,55 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { regionFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AlertsRegionFilter } from './AlertsRegionFilter'; + +describe('AlertsRegionFilter component tests', () => { + const mockRegions = regionFactory.buildList(3); + + it('should render the AlertsRegionFilter with required options', async () => { + const mockHandleSelectionChange = vi.fn(); + const { getByRole, getByTestId, queryByTestId } = renderWithTheme( + + ); + await userEvent.click(getByRole('button', { name: 'Open' })); + expect(getByTestId(mockRegions[0].id)).toBeInTheDocument(); + // select an option + await userEvent.click(getByTestId(mockRegions[0].id)); + + await userEvent.click(getByRole('button', { name: 'Close' })); + expect(mockHandleSelectionChange).toHaveBeenCalledWith([mockRegions[0].id]); + // validate the option is selected + expect(queryByTestId(mockRegions[0].id)).toBeInTheDocument(); + // validate other options are not selected + expect(queryByTestId(mockRegions[1].id)).not.toBeInTheDocument(); + + // select another option + await userEvent.click(getByRole('button', { name: 'Open' })); + expect(getByTestId(mockRegions[1].id)).toBeInTheDocument(); + // select an option + await userEvent.click(getByTestId(mockRegions[1].id)); + + await userEvent.click(getByRole('button', { name: 'Close' })); + // validate both the options are selected + expect(queryByTestId(mockRegions[0].id)).toBeInTheDocument(); + expect(queryByTestId(mockRegions[1].id)).toBeInTheDocument(); + expect(mockHandleSelectionChange).toHaveBeenCalledWith([ + mockRegions[0].id, + mockRegions[1].id, + ]); + }); + + it('should render the AlertsRegionFilter with empty options', async () => { + const { getByRole, getByText } = renderWithTheme( + + ); + await userEvent.click(getByRole('button', { name: 'Open' })); // indicates there is a drop down + expect(getByText('No results')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.tsx new file mode 100644 index 00000000000..18058cf0595 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect'; + +import type { Region } from '@linode/api-v4'; + +export interface AlertsRegionProps { + /** + * Callback for publishing the IDs of the selected regions. + */ + handleSelectionChange: (regions: string[]) => void; + /** + * The regions to be displayed according to the resources associated with alerts + */ + regionOptions: Region[]; +} + +export const AlertsRegionFilter = React.memo((props: AlertsRegionProps) => { + const { handleSelectionChange, regionOptions } = props; + + const [selectedRegion, setSelectedRegion] = React.useState([]); + + const handleRegionChange = React.useCallback( + (regionIds: string[]) => { + handleSelectionChange( + regionIds.length ? regionIds : regionOptions.map(({ id }) => id) // If no regions are selected, include all region IDs + ); + setSelectedRegion( + regionOptions.filter((region) => regionIds.includes(region.id)) // Update the state with the regions matching the selected IDs + ); + }, + [handleSelectionChange, regionOptions] + ); + return ( + region.id)} + /> + ); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx new file mode 100644 index 00000000000..684c804eff9 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; + +import { linodeFactory, regionFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AlertResources } from './AlertsResources'; + +vi.mock('src/queries/cloudpulse/resources', () => ({ + ...vi.importActual('src/queries/cloudpulse/resources'), + useResourcesQuery: queryMocks.useResourcesQuery, +})); + +vi.mock('src/queries/regions/regions', () => ({ + ...vi.importActual('src/queries/regions/regions'), + useRegionsQuery: queryMocks.useRegionsQuery, +})); + +const queryMocks = vi.hoisted(() => ({ + useRegionsQuery: vi.fn(), + useResourcesQuery: vi.fn(), +})); + +const regions = regionFactory.buildList(3); + +const linodes = linodeFactory.buildList(3); + +const searchPlaceholder = 'Search for a Region or Resource'; +const regionPlaceholder = 'Select Regions'; + +beforeEach(() => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodes, + isError: false, + isFetching: false, + }); + queryMocks.useRegionsQuery.mockReturnValue({ + data: regions, + isError: false, + isFetching: false, + }); +}); + +describe('AlertResources component tests', () => { + it('should render search input, region filter', () => { + const { getByText } = renderWithTheme( + + ); + expect(getByText(searchPlaceholder)).toBeInTheDocument(); + expect(getByText(regionPlaceholder)).toBeInTheDocument(); + }); + it('should render circle progress if api calls are in fetching state', () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodes, + isError: false, + isFetching: true, + }); + const { getByTestId, queryByText } = renderWithTheme( + + ); + expect(getByTestId('circle-progress')).toBeInTheDocument(); + expect(queryByText(searchPlaceholder)).not.toBeInTheDocument(); + expect(queryByText(regionPlaceholder)).not.toBeInTheDocument(); + }); + + it('should render error state if api call fails', () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodes, + isError: true, + isFetching: false, + }); + const { getByText } = renderWithTheme( + + ); + expect( + getByText('Table data is unavailable. Please try again later.') + ).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx new file mode 100644 index 00000000000..4ca342bbda8 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -0,0 +1,123 @@ +import { CircleProgress, Stack, Typography } from '@linode/ui'; +import { Grid } from '@mui/material'; +import React from 'react'; + +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import { + getRegionOptions, + getRegionsIdRegionMap, +} from '../Utils/AlertResourceUtils'; +import { AlertsRegionFilter } from './AlertsRegionFilter'; +import { DisplayAlertResources } from './DisplayAlertResources'; + +import type { Region } from '@linode/api-v4'; + +export interface AlertResourcesProp { + /** + * The label of the alert to be displayed + */ + alertLabel?: string; + + /** + * The set of resource ids associated with the alerts, that needs to be displayed + */ + alertResourceIds: string[]; + + /** + * The service type associated with the alerts like DBaaS, Linode etc., + */ + serviceType: string; +} + +export const AlertResources = React.memo((props: AlertResourcesProp) => { + const { alertLabel, alertResourceIds, serviceType } = props; + const [searchText, setSearchText] = React.useState(); + + const [, setFilteredRegions] = React.useState(); + + const { + data: regions, + isError: isRegionsError, + isFetching: isRegionsFetching, + } = useRegionsQuery(); + + const { + data: resources, + isError: isResourcesError, + isFetching: isResourcesFetching, + } = useResourcesQuery( + Boolean(serviceType), + serviceType, + {}, + serviceType === 'dbaas' ? { platform: 'rdbms-default' } : {} + ); + + // A map linking region IDs to their corresponding region objects, used for quick lookup when displaying data in the table. + const regionsIdToRegionMap: Map = React.useMemo(() => { + return getRegionsIdRegionMap(regions); + }, [regions]); + + // Derived list of regions associated with the provided resource IDs, filtered based on available data. + const regionOptions: Region[] = React.useMemo(() => { + return getRegionOptions({ + data: resources, + regionsIdToRegionMap, + resourceIds: alertResourceIds, + }); + }, [resources, alertResourceIds, regionsIdToRegionMap]); + + const handleSearchTextChange = (searchText: string) => { + setSearchText(searchText); + }; + + const handleFilteredRegionsChange = (selectedRegions: string[]) => { + setFilteredRegions(selectedRegions); + }; + + const titleRef = React.useRef(null); // Reference to the component title, used for scrolling to the title when the table's page size or page number changes. + + if (isResourcesFetching || isRegionsFetching) { + return ; + } + + const isDataLoadingError = isRegionsError || isResourcesError; + + return ( + + + {alertLabel || 'Resources'} + {/* It can be either the passed alert label or just Resources */} + + + + + + + + + + + + + + + + + ); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx new file mode 100644 index 00000000000..f8f3bee12e0 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx @@ -0,0 +1,66 @@ +import React from 'react'; + +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; +import { TableSortCell } from 'src/components/TableSortCell'; + +export interface DisplayAlertResourceProp { + /** + * A flag indicating if there was an error loading the data. If true, the error message + * (specified by `errorText`) will be displayed in the table. + */ + isDataLoadingError?: boolean; +} + +export const DisplayAlertResources = React.memo( + (props: DisplayAlertResourceProp) => { + const { isDataLoadingError } = props; + return ( + + + + {}} // TODO: Implement sorting logic for this column. + label="label" + > + Resource + + {}} // TODO: Implement sorting logic for this column. + label="region" + > + Region + + + + + {isDataLoadingError && ( + + )} + {!isDataLoadingError && ( + // Placeholder cell to maintain table structure before body content is implemented. + + + {/* TODO: Populate the table body with resource data and implement sorting and pagination in future PRs. */} + + )} + +
+ ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index 8df01ae47cc..be961d7b308 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -1,5 +1,5 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { Paper, TextField, Typography } from '@linode/ui'; +import { Box, Button, Paper, TextField, Typography } from '@linode/ui'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; @@ -7,6 +7,8 @@ import { useHistory } from 'react-router-dom'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; +import { Drawer } from 'src/components/Drawer'; +import { notificationChannelFactory } from 'src/factories'; import { useCreateAlertDefinition } from 'src/queries/cloudpulse/alerts'; import { MetricCriteriaField } from './Criteria/MetricCriteria'; @@ -16,6 +18,7 @@ import { EngineOption } from './GeneralInformation/EngineOption'; import { CloudPulseRegionSelect } from './GeneralInformation/RegionSelect'; import { CloudPulseMultiResourceSelect } from './GeneralInformation/ResourceMultiSelect'; import { CloudPulseServiceSelect } from './GeneralInformation/ServiceTypeSelect'; +import { AddNotificationChannel } from './NotificationChannels/AddNotificationChannel'; import { CreateAlertDefinitionFormSchema } from './schemas'; import { filterFormValues } from './utilities'; @@ -78,18 +81,34 @@ export const CreateAlertDefinition = () => { ), }); - const { control, formState, getValues, handleSubmit, setError } = formMethods; + const { + control, + formState, + getValues, + handleSubmit, + setError, + setValue, + } = formMethods; const { enqueueSnackbar } = useSnackbar(); const { mutateAsync: createAlert } = useCreateAlertDefinition( getValues('serviceType')! ); - /** - * The maxScrapeInterval variable will be required for the Trigger Conditions part of the Critieria section. - */ + const notificationChannelWatcher = useWatch({ control, name: 'channel_ids' }); + const serviceTypeWatcher = useWatch({ control, name: 'serviceType' }); + + const [openAddNotification, setOpenAddNotification] = React.useState(false); const [maxScrapeInterval, setMaxScrapeInterval] = React.useState(0); - const serviceTypeWatcher = useWatch({ control, name: 'serviceType' }); + const onSubmitAddNotification = (notificationId: number) => { + setValue('channel_ids', [...notificationChannelWatcher, notificationId], { + shouldDirty: false, + shouldTouch: false, + shouldValidate: false, + }); + setOpenAddNotification(false); + }; + const onSubmit = handleSubmit(async (values) => { try { await createAlert(filterFormValues(values)); @@ -111,6 +130,13 @@ export const CreateAlertDefinition = () => { } }); + const onExitNotifications = () => { + setOpenAddNotification(false); + }; + + const onAddNotifications = () => { + setOpenAddNotification(true); + }; return ( @@ -172,6 +198,15 @@ export const CreateAlertDefinition = () => { maxScrapingInterval={maxScrapeInterval} name="trigger_conditions" /> + + + { }} sx={{ display: 'flex', justifyContent: 'flex-end' }} /> + + + diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.test.tsx new file mode 100644 index 00000000000..4544e3195eb --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.test.tsx @@ -0,0 +1,121 @@ +import { within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { notificationChannelFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { channelTypeOptions } from '../../constants'; +import { AddNotificationChannel } from './AddNotificationChannel'; + +const mockData = [notificationChannelFactory.build()]; + +describe('AddNotificationChannel component', () => { + const user = userEvent.setup(); + it('should render the components', () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); + expect(getByText('Channel Settings')).toBeVisible(); + expect(getByLabelText('Type')).toBeVisible(); + expect(getByLabelText('Channel')).toBeVisible(); + }); + + it('should render the type component with happy path and able to select an option', async () => { + const { findByRole, getByTestId } = renderWithTheme( + + ); + const channelTypeContainer = getByTestId('channel-type'); + const channelLabel = channelTypeOptions.find( + (option) => option.value === mockData[0].channel_type + )?.label; + user.click( + within(channelTypeContainer).getByRole('button', { name: 'Open' }) + ); + expect( + await findByRole('option', { + name: channelLabel, + }) + ).toBeInTheDocument(); + + await userEvent.click(await findByRole('option', { name: channelLabel })); + expect(within(channelTypeContainer).getByRole('combobox')).toHaveAttribute( + 'value', + channelLabel + ); + }); + it('should render the label component with happy path and able to select an option', async () => { + const { findByRole, getByRole, getByTestId } = renderWithTheme( + + ); + // selecting the type as the label field is disabled with type is null + const channelTypeContainer = getByTestId('channel-type'); + await user.click( + within(channelTypeContainer).getByRole('button', { name: 'Open' }) + ); + await user.click( + await findByRole('option', { + name: 'Email', + }) + ); + expect(within(channelTypeContainer).getByRole('combobox')).toHaveAttribute( + 'value', + 'Email' + ); + + const channelLabelContainer = getByTestId('channel-label'); + await user.click( + within(channelLabelContainer).getByRole('button', { name: 'Open' }) + ); + expect( + getByRole('option', { + name: mockData[0].label, + }) + ).toBeInTheDocument(); + + await userEvent.click( + await findByRole('option', { + name: mockData[0].label, + }) + ); + expect(within(channelLabelContainer).getByRole('combobox')).toHaveAttribute( + 'value', + mockData[0].label + ); + }); + + it('should render the error messages from the client side validation', async () => { + const { getAllByText, getByRole } = renderWithTheme( + + ); + await user.click(getByRole('button', { name: 'Add channel' })); + expect(getAllByText('This field is required.').length).toBe(2); + getAllByText('This field is required.').forEach((element) => { + expect(element).toBeVisible(); + }); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.tsx new file mode 100644 index 00000000000..12238c3c375 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.tsx @@ -0,0 +1,181 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { Autocomplete, Box, Typography } from '@linode/ui'; +import React from 'react'; +import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; + +import { channelTypeOptions } from '../../constants'; +import { getAlertBoxStyles } from '../../Utils/utils'; +import { notificationChannelSchema } from '../schemas'; + +import type { NotificationChannelForm } from '../types'; +import type { ChannelType, NotificationChannel } from '@linode/api-v4'; +import type { ObjectSchema } from 'yup'; + +interface AddNotificationChannelProps { + /** + * Boolean for the Notification channels api error response + */ + isNotificationChannelsError: boolean; + /** + * Boolean for the Notification channels api loading response + */ + isNotificationChannelsLoading: boolean; + /** + * Method to exit the Drawer on cancel + * @returns void + */ + onCancel: () => void; + /** + * Method to add the notification id to the form context + * @param notificationId id of the Notification that is being submitted + * @returns void + */ + onSubmitAddNotification: (notificationId: number) => void; + /** + * Notification template data fetched from the api + */ + templateData: NotificationChannel[]; +} + +export const AddNotificationChannel = (props: AddNotificationChannelProps) => { + const { + isNotificationChannelsError, + isNotificationChannelsLoading, + onCancel, + onSubmitAddNotification, + templateData, + } = props; + + const formMethods = useForm({ + defaultValues: { + channel_type: null, + label: null, + }, + mode: 'onBlur', + resolver: yupResolver( + notificationChannelSchema as ObjectSchema + ), + }); + + const { control, handleSubmit, setValue } = formMethods; + const onSubmit = handleSubmit(() => { + onSubmitAddNotification(selectedTemplate?.id ?? 0); + }); + + const channelTypeWatcher = useWatch({ control, name: 'channel_type' }); + const channelLabelWatcher = useWatch({ control, name: 'label' }); + const selectedChannelTypeTemplate = + channelTypeWatcher && templateData + ? templateData.filter( + (template) => template.channel_type === channelTypeWatcher + ) + : null; + + const selectedTemplate = selectedChannelTypeTemplate?.find( + (template) => template.label === channelLabelWatcher + ); + + return ( + +
+ ({ + ...getAlertBoxStyles(theme), + borderRadius: 1, + overflow: 'auto', + p: 2, + })} + > + ({ + color: theme.tokens.content.Text, + })} + gutterBottom + variant="h3" + > + Channel Settings + + ( + { + field.onChange( + reason === 'selectOption' ? newValue.value : null + ); + if (reason !== 'selectOption') { + setValue('label', null); + } + }} + value={ + channelTypeOptions.find( + (option) => option.value === field.value + ) ?? null + } + data-testid="channel-type" + label="Type" + onBlur={field.onBlur} + options={channelTypeOptions} + placeholder="Select a Type" + /> + )} + control={control} + name="channel_type" + /> + + ( + { + field.onChange( + reason === 'selectOption' ? selected.label : null + ); + }} + value={ + selectedChannelTypeTemplate?.find( + (option) => option.label === field.value + ) ?? null + } + data-testid="channel-label" + disabled={!selectedChannelTypeTemplate} + errorText={fieldState.error?.message} + key={channelTypeWatcher} + label="Channel" + onBlur={field.onBlur} + options={selectedChannelTypeTemplate ?? []} + placeholder="Select a Channel" + /> + )} + control={control} + name="label" + /> + + + + +
+ ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts index 8b9301c3ebf..77e667d3237 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts @@ -1,6 +1,8 @@ import { createAlertDefinitionSchema } from '@linode/validation'; import { object, string } from 'yup'; +const fieldErrorMessage = 'This field is required.'; + const engineOptionValidation = string().when('service_type', { is: 'dbaas', otherwise: (schema) => schema.notRequired().nullable(), @@ -14,3 +16,8 @@ export const CreateAlertDefinitionFormSchema = createAlertDefinitionSchema.conca serviceType: string().required('Service is required.'), }) ); + +export const notificationChannelSchema = object({ + channel_type: string().required(fieldErrorMessage), + label: string().required(fieldErrorMessage), +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts index 90671fce719..a25582af56d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts @@ -1,6 +1,7 @@ import type { AlertServiceType, AlertSeverityType, + ChannelType, CreateAlertDefinitionPayload, DimensionFilter, DimensionFilterOperatorType, @@ -52,3 +53,8 @@ export interface TriggerConditionForm evaluation_period_seconds: null | number; polling_interval_seconds: null | number; } + +export interface NotificationChannelForm { + channel_type: ChannelType | null; + label: null | string; +} diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts new file mode 100644 index 00000000000..905094b1b36 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts @@ -0,0 +1,67 @@ +import { regionFactory } from 'src/factories'; + +import { getRegionOptions, getRegionsIdRegionMap } from './AlertResourceUtils'; + +import type { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect'; + +describe('getRegionsIdLabelMap', () => { + it('should return a proper map for given regions', () => { + const regions = regionFactory.buildList(10); + const result = getRegionsIdRegionMap(regions); + // check for a key + expect(result.has(regions[0].id)).toBe(true); + // check for value to match the region object + expect(result.get(regions[0].id)).toBe(regions[0]); + }); + it('should return 0 if regions is passed as undefined', () => { + const result = getRegionsIdRegionMap(undefined); + // if regions passed undefined, it should return an empty map + expect(result.size).toBe(0); + }); +}); + +describe('getRegionOptions', () => { + const regions = regionFactory.buildList(10); + const regionsIdToLabelMap = getRegionsIdRegionMap(regions); + const data: CloudPulseResources[] = [ + { id: '1', label: 'Test', region: regions[0].id }, + { id: '2', label: 'Test2', region: regions[1].id }, + { id: '3', label: 'Test3', region: regions[2].id }, + ]; + it('should return correct region objects for given resourceIds', () => { + const result = getRegionOptions({ + data, + regionsIdToRegionMap: regionsIdToLabelMap, + resourceIds: ['1', '2'], + }); + // Valid case + expect(result.length).toBe(2); + }); + + it('should return an empty region options if data is not passed', () => { + // Case with no data + const result = getRegionOptions({ + regionsIdToRegionMap: regionsIdToLabelMap, + resourceIds: ['1', '2'], + }); + expect(result.length).toBe(0); + }); + + it('should return an empty region options if there is no matching resource ids', () => { + const result = getRegionOptions({ + data, + regionsIdToRegionMap: regionsIdToLabelMap, + resourceIds: ['101'], + }); + expect(result.length).toBe(0); + }); + + it('should return unique regions even if resourceIds contains duplicates', () => { + const result = getRegionOptions({ + data, + regionsIdToRegionMap: regionsIdToLabelMap, + resourceIds: ['1', '1', '2', '2'], // Duplicate IDs + }); + expect(result.length).toBe(2); // Should still return unique regions + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts new file mode 100644 index 00000000000..d3c2b9bc0e4 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts @@ -0,0 +1,55 @@ +import type { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect'; +import type { Region } from '@linode/api-v4'; + +interface FilterResourceProps { + /** + * The data to be filtered + */ + data?: CloudPulseResources[]; + /** + * The map that holds the id of the region to Region object, helps in building the alert resources + */ + regionsIdToRegionMap: Map; + /** + * The resources associated with the alerts + */ + resourceIds: string[]; +} + +/** + * @param regions The list of regions + * @returns A Map of region ID to Region object. Returns an empty Map if regions is undefined. + */ +export const getRegionsIdRegionMap = ( + regions: Region[] | undefined +): Map => { + if (!regions) { + return new Map(); + } + return new Map(regions.map((region) => [region.id, region])); +}; + +/** + * @param filterProps The props required to get the region options and the filtered resources + * @returns Array of unique regions associated with the resource ids of the alert + */ +export const getRegionOptions = ( + filterProps: FilterResourceProps +): Region[] => { + const { data, regionsIdToRegionMap, resourceIds } = filterProps; + if (!data || !resourceIds.length || !regionsIdToRegionMap.size) { + return []; + } + const uniqueRegions = new Set(); + data.forEach(({ id, region }) => { + if (resourceIds.includes(String(id))) { + const regionObject = region + ? regionsIdToRegionMap.get(region) + : undefined; + if (regionObject) { + uniqueRegions.add(regionObject); + } + } + }); + return Array.from(uniqueRegions); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts index 549b35e9a98..7fcdd3f5873 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts @@ -1,6 +1,6 @@ import { serviceTypesFactory } from 'src/factories'; -import { getServiceTypeLabel } from './utils'; +import { convertSecondsToMinutes, getServiceTypeLabel } from './utils'; it('test getServiceTypeLabel method', () => { const services = serviceTypesFactory.buildList(3); @@ -13,3 +13,11 @@ it('test getServiceTypeLabel method', () => { expect(getServiceTypeLabel('test', { data: services })).toBe('test'); expect(getServiceTypeLabel('', { data: services })).toBe(''); }); +it('test convertSecondsToMinutes method', () => { + expect(convertSecondsToMinutes(0)).toBe('0 minutes'); + expect(convertSecondsToMinutes(60)).toBe('1 minute'); + expect(convertSecondsToMinutes(120)).toBe('2 minutes'); + expect(convertSecondsToMinutes(65)).toBe('1 minute and 5 seconds'); + expect(convertSecondsToMinutes(1)).toBe('1 second'); + expect(convertSecondsToMinutes(59)).toBe('59 seconds'); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts index 4e1ffe31d26..00b734f4447 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -1,6 +1,26 @@ import type { ServiceTypesList } from '@linode/api-v4'; import type { Theme } from '@mui/material'; +interface AlertChipBorderProps { + /** + * The radius needed for the border + */ + borderRadiusPxValue: string; + /** + * The index of the chip + */ + index: number; + /** + * The total length of the chips to be build + */ + length: number; + + /** + * Indicates Whether to merge the chips into single or keep it individually + */ + mergeChips: boolean | undefined; +} + /** * @param serviceType Service type for which the label needs to be displayed * @param serviceTypeList List of available service types in Cloud Pulse @@ -29,3 +49,41 @@ export const getAlertBoxStyles = (theme: Theme) => ({ backgroundColor: theme.tokens.background.Neutral, padding: theme.spacing(3), }); +/** + * Converts seconds into a human-readable minutes and seconds format. + * @param seconds The seconds that need to be converted into minutes. + * @returns A string representing the time in minutes and seconds. + */ +export const convertSecondsToMinutes = (seconds: number): string => { + if (seconds <= 0) { + return '0 minutes'; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + const minuteString = + minutes > 0 ? `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}` : ''; + const secondString = + remainingSeconds > 0 + ? `${remainingSeconds} ${remainingSeconds === 1 ? 'second' : 'seconds'}` + : ''; + return [minuteString, secondString].filter(Boolean).join(' and '); +}; +/** + * @param props The props/parameters needed to determine the alert chip's border + * @returns The border radius to be applied on chips based on the parameters + */ +export const getAlertChipBorderRadius = ( + props: AlertChipBorderProps +): string => { + const { borderRadiusPxValue, index, length, mergeChips } = props; + if (!mergeChips || length === 1) { + return borderRadiusPxValue; + } + if (index === 0) { + return `${borderRadiusPxValue} 0 0 ${borderRadiusPxValue}`; + } + if (index === length - 1) { + return `0 ${borderRadiusPxValue} ${borderRadiusPxValue} 0`; + } + return '0'; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index f6397392284..8d8ff5bd25d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -1,7 +1,8 @@ import type { AlertSeverityType, - DimensionFilterOperatorType, AlertStatusType, + ChannelType, + DimensionFilterOperatorType, MetricAggregationType, MetricOperatorType, } from '@linode/api-v4'; @@ -116,7 +117,7 @@ export const PollingIntervalOptions = { { label: '10 min', value: 600 }, ], }; - + export const severityMap: Record = { 0: 'Severe', 1: 'Medium', @@ -128,3 +129,41 @@ export const alertStatusToIconStatusMap: Record = { disabled: 'inactive', enabled: 'active', }; + +export const channelTypes: Record = { + email: 'Email', + pagerduty: 'Pagerduty', + slack: 'Slack', + webhook: 'Webhook', +}; + +export const channelTypeOptions: Item[] = Object.entries( + channelTypes +).map(([key, label]) => ({ + label, + value: key as ChannelType, +})); + +export const metricOperatorTypeMap: Record = { + eq: '=', + gt: '>', + gte: '>=', + lt: '<', + lte: '<=', +}; +export const aggregationTypeMap: Record = { + avg: 'Average', + count: 'Count', + max: 'Maximum', + min: 'Minimum', + sum: 'Sum', +}; +export const dimensionOperatorTypeMap: Record< + DimensionFilterOperatorType, + string +> = { + endswith: 'ends with', + eq: 'equals', + neq: 'not equals', + startswith: 'starts with', +}; diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index 5cb487cc9b8..4aa133e65cd 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -72,7 +72,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { const getJweTokenPayload = (): JWETokenPayLoad => { return { - resource_ids: resources?.map((resource) => Number(resource)) ?? [], + entity_ids: resources?.map((resource) => Number(resource)) ?? [], }; }; diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index 6057a3dc770..f565b7ae272 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -103,9 +103,9 @@ interface MetricRequestProps { duration: TimeDuration; /** - * resource ids selected by user + * entity ids selected by user */ - resourceIds: string[]; + entityIds: string[]; /** * list of CloudPulse resources available @@ -286,16 +286,16 @@ export const generateMaxUnit = ( export const getCloudPulseMetricRequest = ( props: MetricRequestProps ): CloudPulseMetricsRequest => { - const { duration, resourceIds, resources, widget } = props; + const { duration, entityIds, resources, widget } = props; return { aggregate_function: widget.aggregate_function, + entity_ids: resources + ? entityIds.map((id) => parseInt(id, 10)) + : widget.entity_ids.map((id) => parseInt(id, 10)), filters: undefined, group_by: widget.group_by, metric: widget.metric, relative_time_duration: duration ?? widget.time_duration, - resource_ids: resources - ? resourceIds.map((obj) => parseInt(obj, 10)) - : widget.resource_id.map((obj) => parseInt(obj, 10)), time_granularity: widget.time_granularity.unit === 'Auto' ? undefined diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx index e5d3a1fae74..476ad9997ac 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx @@ -26,8 +26,8 @@ const props: CloudPulseWidgetProperties = { unit: 'percent', }, duration: { unit: 'min', value: 30 }, + entityIds: ['1', '2'], isJweTokenFetching: false, - resourceIds: ['1', '2'], resources: [ { id: '1', @@ -124,7 +124,7 @@ describe('Cloud pulse widgets', () => { expect(getByTestId('Aggregation function')).toBeInTheDocument(); // Verify zoom icon - expect(getByTestId('zoom-in')).toBeInTheDocument(); + expect(getByTestId('zoom-out')).toBeInTheDocument(); // Verify graph component expect( @@ -146,7 +146,7 @@ describe('Cloud pulse widgets', () => { it('should update preferences for zoom toggle', async () => { const { getByTestId } = renderWithTheme(); - const zoomButton = getByTestId('zoom-in'); + const zoomButton = getByTestId('zoom-out'); await userEvent.click(zoomButton); expect(mockUpdatePreferences).toHaveBeenCalledWith('CPU Utilization', { size: 6, diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index e8d6142276b..e71e19e68f1 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -63,6 +63,11 @@ export interface CloudPulseWidgetProperties { */ duration: TimeDuration; + /** + * entity ids selected by user to show metrics for + */ + entityIds: string[]; + /** * Any error to be shown in this widget */ @@ -73,11 +78,6 @@ export interface CloudPulseWidgetProperties { */ isJweTokenFetching: boolean; - /** - * resources ids selected by user to show metrics for - */ - resourceIds: string[]; - /** * List of resources available of selected service type */ @@ -141,8 +141,8 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { authToken, availableMetrics, duration, + entityIds, isJweTokenFetching, - resourceIds, resources, savePref, serviceType, @@ -230,7 +230,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { { ...getCloudPulseMetricRequest({ duration, - resourceIds, + entityIds, resources, widget, }), diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx index df9c19f4124..167d6aa80b8 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx @@ -75,9 +75,9 @@ export const RenderWidgets = React.memo( authToken: '', availableMetrics: undefined, duration, + entityIds: resources, errorLabel: 'Error occurred while loading data.', isJweTokenFetching: false, - resourceIds: resources, resources: [], serviceType: dashboard?.service_type ?? '', timeStamp: manualRefreshTimeStamp, diff --git a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.test.tsx index 40ab57a50f3..06e31143700 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.test.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.test.tsx @@ -10,21 +10,21 @@ describe('Cloud Pulse Zoomer', () => { it('Should render zoomer with zoom-out button', () => { const props: ZoomIconProperties = { handleZoomToggle: vi.fn(), - zoomIn: false, + zoomIn: true, }; const { getByTestId } = renderWithTheme(); expect(getByTestId('zoom-out')).toBeInTheDocument(); - expect(getByTestId('Maximize')).toBeInTheDocument(); // test id for tooltip + expect(getByTestId('Minimize')).toBeInTheDocument(); // test id for tooltip }), it('Should render zoomer with zoom-in button', () => { const props: ZoomIconProperties = { handleZoomToggle: vi.fn(), - zoomIn: true, + zoomIn: false, }; const { getByTestId } = renderWithTheme(); expect(getByTestId('zoom-in')).toBeInTheDocument(); - expect(getByTestId('Minimize')).toBeInTheDocument(); // test id for tooltip + expect(getByTestId('Maximize')).toBeInTheDocument(); // test id for tooltip }); }); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx index e54155352ff..85666e556c2 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx @@ -29,11 +29,11 @@ export const ZoomIcon = React.memo((props: ZoomIconProperties) => { fontSize: 'x-large', padding: 0, }} - aria-label="Zoom In" - data-testid="zoom-in" + aria-label="Zoom Out" + data-testid="zoom-out" onClick={() => handleClick(false)} > - + ); @@ -47,11 +47,11 @@ export const ZoomIcon = React.memo((props: ZoomIconProperties) => { fontSize: 'x-large', padding: 0, }} - aria-label="Zoom Out" - data-testid="zoom-out" + aria-label="Zoom In" + data-testid="zoom-in" onClick={() => handleClick(true)} > - + ); diff --git a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx index f75c545105d..b8d47ad83a5 100644 --- a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx +++ b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx @@ -13,7 +13,6 @@ import { styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { useNavigate } from '@tanstack/react-router'; import { useFormik } from 'formik'; -import { path } from 'ramda'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -201,8 +200,8 @@ export const CreateDomain = () => { return generateDefaultDomainRecords( domainData.domain, domainData.id, - path(['ipv4', 0], selectedDefaultLinode), - path(['ipv6'], selectedDefaultLinode) + selectedDefaultLinode?.ipv4?.[0], + selectedDefaultLinode?.ipv6 ) .then(() => { return redirectToLandingOrDetail(type, domainData.id); @@ -212,8 +211,8 @@ export const CreateDomain = () => { `Default DNS Records couldn't be created from Linode: ${e[0].reason}`, { domainID: domainData.id, - ipv4: path(['ipv4', 0], selectedDefaultLinode), - ipv6: path(['ipv6'], selectedDefaultLinode), + ipv4: selectedDefaultLinode?.ipv4?.[0], + ipv6: selectedDefaultLinode?.ipv6, selectedLinode: selectedDefaultLinode!.id, } ); @@ -228,8 +227,8 @@ export const CreateDomain = () => { return generateDefaultDomainRecords( domainData.domain, domainData.id, - path(['ipv4'], selectedDefaultNodeBalancer), - path(['ipv6'], selectedDefaultNodeBalancer) + selectedDefaultNodeBalancer?.ipv4, + selectedDefaultNodeBalancer?.ipv6 ) .then(() => { return redirectToLandingOrDetail(type, domainData.id); @@ -239,8 +238,8 @@ export const CreateDomain = () => { `Default DNS Records couldn't be created from NodeBalancer: ${e[0].reason}`, { domainID: domainData.id, - ipv4: path(['ipv4'], selectedDefaultNodeBalancer), - ipv6: path(['ipv6'], selectedDefaultNodeBalancer), + ipv4: selectedDefaultNodeBalancer?.ipv4, + ipv6: selectedDefaultNodeBalancer?.ipv6, selectedNodeBalancer: selectedDefaultNodeBalancer!.id, } ); @@ -279,10 +278,9 @@ export const CreateDomain = () => { }; const updatePrimaryIPAddress = (newIPs: ExtendedIP[]) => { - const master_ips = - newIPs.length > 0 ? newIPs.map(extendedIPToString) : ['']; + const masterIps = newIPs.length > 0 ? newIPs.map(extendedIPToString) : ['']; if (mounted) { - formik.setFieldValue('master_ips', master_ips); + formik.setFieldValue('master_ips', masterIps); } }; diff --git a/packages/manager/src/features/Domains/DomainActionMenu.tsx b/packages/manager/src/features/Domains/DomainActionMenu.tsx index 6347df03205..4ad64e42d90 100644 --- a/packages/manager/src/features/Domains/DomainActionMenu.tsx +++ b/packages/manager/src/features/Domains/DomainActionMenu.tsx @@ -1,12 +1,15 @@ -import { Domain } from '@linode/api-v4/lib/domains'; -import { Theme, useTheme } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { splitAt } from 'ramda'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; +import { splitAt } from 'src/utilities/splitAt'; + +import type { Domain } from '@linode/api-v4/lib/domains'; +import type { Theme } from '@mui/material/styles'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; const useStyles = makeStyles()(() => ({ button: { diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx index 6d59718b8ae..c04480b163a 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx @@ -15,8 +15,8 @@ import { } from 'src/queries/domains'; import { DeleteDomain } from '../DeleteDomain'; -import DomainRecords from '../DomainRecords'; import { DownloadDNSZoneFileButton } from '../DownloadDNSZoneFileButton'; +import { DomainRecords } from './DomainRecords/DomainRecords'; import type { DomainState } from 'src/routes/domains'; diff --git a/packages/manager/src/features/Domains/DomainRecordActionMenu.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordActionMenu.tsx similarity index 84% rename from packages/manager/src/features/Domains/DomainRecordActionMenu.tsx rename to packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordActionMenu.tsx index f49d8ccf3e0..1c1b4014d32 100644 --- a/packages/manager/src/features/Domains/DomainRecordActionMenu.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordActionMenu.tsx @@ -1,8 +1,9 @@ -import { Domain } from '@linode/api-v4/lib/domains'; -import { has } from 'ramda'; import * as React from 'react'; -import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +import type { Domain } from '@linode/api-v4/lib/domains'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; interface EditPayload { id?: number; @@ -47,7 +48,7 @@ export const DomainRecordActionMenu = (props: DomainRecordActionMenuProps) => { }, title: 'Edit', }, - has('deleteData', props) + Boolean(props.deleteData) ? { onClick: () => { handleDelete(); diff --git a/packages/manager/src/features/Domains/DomainRecordDrawer.test.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.test.tsx similarity index 100% rename from packages/manager/src/features/Domains/DomainRecordDrawer.test.tsx rename to packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.test.tsx diff --git a/packages/manager/src/features/Domains/DomainRecordDrawer.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx similarity index 99% rename from packages/manager/src/features/Domains/DomainRecordDrawer.tsx rename to packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx index 8122a08cc26..8ed31e146ae 100644 --- a/packages/manager/src/features/Domains/DomainRecordDrawer.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx @@ -30,7 +30,7 @@ import { transferHelperText as helperText, isValidCNAME, isValidDomainRecord, -} from './domainUtils'; +} from '../../domainUtils'; import type { Domain, diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordTable.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordTable.tsx new file mode 100644 index 00000000000..ebb575ff192 --- /dev/null +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordTable.tsx @@ -0,0 +1,88 @@ +import React from 'react'; + +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; + +import { StyledTableCell } from './DomainRecords.styles'; + +import type { IType } from './generateTypes'; +import type { Domain, DomainRecord } from '@linode/api-v4/lib/domains'; + +interface DomainRecordTableProps { + count: number; + handlePageChange: (page: number) => void; + handlePageSizeChange: (pageSize: number) => void; + page: number; + pageSize: number; + paginatedData: Domain[] | DomainRecord[]; + type: IType; +} + +export const DomainRecordTable = (props: DomainRecordTableProps) => { + const { + count, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + paginatedData, + type, + } = props; + + return ( + <> + + + + {type.columns.length > 0 && + type.columns.map((col, columnIndex) => { + return {col.title}; + })} + + + + {type.data.length === 0 ? ( + + ) : ( + paginatedData.map((data, idx) => { + return ( + + {type.columns.length > 0 && + type.columns.map(({ render, title }, columnIndex) => { + return ( + + {render(data)} + + ); + })} + + ); + }) + )} + +
+ + + ); +}; diff --git a/packages/manager/src/features/Domains/DomainRecords.styles.ts b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts similarity index 98% rename from packages/manager/src/features/Domains/DomainRecords.styles.ts rename to packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts index 93f252e1f65..3ee8534e082 100644 --- a/packages/manager/src/features/Domains/DomainRecords.styles.ts +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts @@ -1,12 +1,10 @@ import { styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; + import { TableCell } from 'src/components/TableCell'; export const StyledGrid = styled(Grid, { label: 'StyledGrid' })( ({ theme }) => ({ - margin: 0, - marginTop: theme.spacing(2), - width: '100%', '& .MuiGrid-item': { paddingLeft: 0, paddingRight: 0, @@ -16,30 +14,33 @@ export const StyledGrid = styled(Grid, { label: 'StyledGrid' })( marginRight: theme.spacing(), }, }, + margin: 0, + marginTop: theme.spacing(2), [theme.breakpoints.down('md')]: { marginLeft: theme.spacing(), marginRight: theme.spacing(), }, + width: '100%', }) ); -export const StyledTableCell = styled(TableCell, { label: 'StyledTabelCell' })( +export const StyledTableCell = styled(TableCell, { label: 'StyledTableCell' })( ({ theme }) => ({ - whiteSpace: 'nowrap' as const, - width: 'auto', '& .data': { maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', - whiteSpace: 'nowrap' as const, [theme.breakpoints.up('md')]: { maxWidth: 750, }, + whiteSpace: 'nowrap' as const, }, '&:last-of-type': { display: 'flex', justifyContent: 'flex-end', }, + whiteSpace: 'nowrap' as const, + width: 'auto', }) ); diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx new file mode 100644 index 00000000000..eb5e4b9d48f --- /dev/null +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx @@ -0,0 +1,343 @@ +import { deleteDomainRecord as _deleteDomainRecord } from '@linode/api-v4/lib/domains'; +import { Typography } from '@linode/ui'; +import Grid from '@mui/material/Unstable_Grid2'; +import * as React from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +// eslint-disable-next-line no-restricted-imports +import OrderBy from 'src/components/OrderBy'; +import Paginate from 'src/components/Paginate'; +import { + getAPIErrorOrDefault, + getErrorStringOrDefault, +} from 'src/utilities/errorUtils'; +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; +import { storage } from 'src/utilities/storage'; + +import { DomainRecordDrawer } from './DomainRecordDrawer'; +import { StyledDiv, StyledGrid } from './DomainRecords.styles'; +import { DomainRecordTable } from './DomainRecordTable'; +import { generateTypes } from './generateTypes'; + +import type { GenerateTypesHandlers, IType } from './generateTypes'; +import type { + Domain, + DomainRecord, + DomainType, + RecordType, + UpdateDomainPayload, +} from '@linode/api-v4/lib/domains'; +import type { APIError } from '@linode/api-v4/lib/types'; + +interface UpdateDomainDataProps extends UpdateDomainPayload { + id: number; +} + +export interface Props { + domain: Domain; + domainRecords: DomainRecord[]; + updateDomain: (data: UpdateDomainDataProps) => Promise; + updateRecords: () => void; +} + +interface ConfirmationState { + errors?: APIError[]; + open: boolean; + recordId?: number; + submitting: boolean; +} + +interface DrawerState { + fields?: Partial | Partial; + mode: 'create' | 'edit'; + open: boolean; + type: DomainType | RecordType; +} + +interface State { + confirmDialog: ConfirmationState; + drawer: DrawerState; + types: IType[]; +} + +export const DomainRecords = (props: Props) => { + const { domain, domainRecords, updateDomain, updateRecords } = props; + + const defaultDrawerState: DrawerState = { + mode: 'create', + open: false, + type: 'NS', + }; + + const [state, setState] = React.useState({ + confirmDialog: { + open: false, + submitting: false, + }, + drawer: defaultDrawerState, + types: [], + }); + + const confirmDialogRef = React.useRef(null); + + const confirmDeletion = (recordId: number) => + updateConfirmDialog((confirmDialog) => ({ + ...confirmDialog, + open: true, + recordId, + })); + + const deleteDomainRecord = () => { + const { + domain: { id: domainId }, + } = props; + const { + confirmDialog: { recordId }, + } = state; + + if (!domainId || !recordId) { + return; + } + + updateConfirmDialog((confirmDialog) => ({ + ...confirmDialog, + errors: undefined, + submitting: true, + })); + + _deleteDomainRecord(domainId, recordId) + .then(() => { + updateRecords(); + + updateConfirmDialog((_) => ({ + errors: undefined, + open: false, + recordId: undefined, + submitting: false, + })); + }) + .catch((errorResponse) => { + const errors = getAPIErrorOrDefault(errorResponse); + updateConfirmDialog((confirmDialog) => ({ + ...confirmDialog, + errors, + submitting: false, + })); + }); + + updateConfirmDialog((confirmDialog) => ({ + ...confirmDialog, + submitting: true, + })); + }; + + const handleCloseDialog = () => { + updateConfirmDialog(() => ({ + open: false, + recordId: undefined, + submitting: false, + })); + }; + + const handleOpenSOADrawer = (domain: Domain) => { + return domain.type === 'master' + ? openForEditPrimaryDomain(domain) + : openForEditSecondaryDomain(domain); + }; + + const openForCreation = (type: RecordType) => + updateDrawer(() => ({ + mode: 'create', + open: true, + submitting: false, + type, + })); + + const openForEditing = ( + type: DomainType | RecordType, + fields: Partial | Partial + ) => + updateDrawer(() => ({ + fields, + mode: 'edit', + open: true, + submitting: false, + type, + })); + + const openForEditPrimaryDomain = (fields: Partial) => + openForEditing('master', fields); + + const openForEditSecondaryDomain = (fields: Partial) => + openForEditing('slave', fields); + + const renderDialogActions = () => { + return ( + + ); + }; + + const resetDrawer = () => updateDrawer(() => defaultDrawerState); + + const updateConfirmDialog = ( + fn: (confirmDialog: ConfirmationState) => ConfirmationState + ) => { + setState((prevState) => { + const newState = { + ...prevState, + confirmDialog: fn(prevState.confirmDialog), + }; + scrollErrorIntoViewV2(confirmDialogRef); + + return newState; + }); + }; + + const updateDrawer = (fn: (drawer: DrawerState) => DrawerState) => { + setState((prevState) => { + return { + ...prevState, + drawer: fn(prevState.drawer), + }; + }); + }; + + const handlers: GenerateTypesHandlers = { + confirmDeletion, + handleOpenSOADrawer, + openForCreateARecord: () => openForCreation('AAAA'), + openForCreateCAARecord: () => openForCreation('CAA'), + openForCreateCNAMERecord: () => openForCreation('CNAME'), + openForCreateMXRecord: () => openForCreation('MX'), + openForCreateNSRecord: () => openForCreation('NS'), + openForCreateSRVRecord: () => openForCreation('SRV'), + openForCreateTXTRecord: () => openForCreation('TXT'), + openForEditARecord: (fields) => openForEditing('AAAA', fields), + openForEditCAARecord: (fields) => openForEditing('CAA', fields), + openForEditCNAMERecord: (fields) => openForEditing('CNAME', fields), + openForEditMXRecord: (fields) => openForEditing('MX', fields), + openForEditNSRecord: (fields) => openForEditing('NS', fields), + openForEditSRVRecord: (fields) => openForEditing('SRV', fields), + openForEditTXTRecord: (fields) => openForEditing('TXT', fields), + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + const types = React.useMemo(() => generateTypes(props, handlers), [ + domain, + domainRecords, + ]); + + React.useEffect(() => { + setState((prevState) => ({ + ...prevState, + types, + })); + }, [types]); + + return ( + <> + + {state.types.map((type, eachTypeIdx) => { + const ref: React.RefObject = React.createRef(); + + return ( +
+ + + + {type.title} + + + {type.link && ( + + {' '} + {type.link()}{' '} + + )} + + + {({ data: orderedData }) => { + return ( + + {({ + count, + data: paginatedData, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + }) => ( + + )} + + ); + }} + +
+ ); + })} + + Are you sure you want to delete this record? + + + + ); +}; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordsUtils.ts b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordsUtils.ts new file mode 100644 index 00000000000..b5baa8a5ed1 --- /dev/null +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordsUtils.ts @@ -0,0 +1,129 @@ +import type { Props } from './DomainRecords'; +import type { + Domain, + DomainRecord, + RecordType, +} from '@linode/api-v4/lib/domains'; + +type DomainTimeFields = Pick< + Domain, + 'expire_sec' | 'refresh_sec' | 'retry_sec' | 'ttl_sec' +>; + +type DomainRecordTimeFields = Pick; + +export const msToReadableTime = (v: number): null | string => { + const msToReadableTimeMap: { [key: number]: string } = { + 0: 'Default', + 30: '30 seconds', + 120: '2 minutes', + 300: '5 minutes', + 3600: '1 hour', + 7200: '2 hours', + 14400: '4 hours', + 28800: '8 hours', + 57600: '16 hours', + 86400: '1 day', + 172800: '2 days', + 345600: '4 days', + 604800: '1 week', + 1209600: '2 weeks', + 2419200: '4 weeks', + }; + + return v in msToReadableTimeMap ? msToReadableTimeMap[v] : null; +}; + +export function getTimeColumn( + record: Domain, + keyPath: keyof DomainTimeFields +): null | string; + +export function getTimeColumn( + record: DomainRecord, + keyPath: keyof DomainRecordTimeFields +): null | string; + +export function getTimeColumn( + record: Domain | DomainRecord, + keyPath: keyof (DomainRecordTimeFields | DomainTimeFields) +) { + return msToReadableTime(record[keyPath] ?? 0); +} + +export const typeEq = (type: RecordType) => (record: DomainRecord): boolean => + record.type === type; + +const prependLinodeNS: Partial[] = [ + { + id: -1, + name: '', + port: 0, + priority: 0, + protocol: null, + service: null, + tag: null, + target: 'ns1.linode.com', + ttl_sec: 0, + type: 'NS', + weight: 0, + }, + { + id: -1, + name: '', + port: 0, + priority: 0, + protocol: null, + service: null, + tag: null, + target: 'ns2.linode.com', + ttl_sec: 0, + type: 'NS', + weight: 0, + }, + { + id: -1, + name: '', + port: 0, + priority: 0, + protocol: null, + service: null, + tag: null, + target: 'ns3.linode.com', + ttl_sec: 0, + type: 'NS', + weight: 0, + }, + { + id: -1, + name: '', + port: 0, + priority: 0, + protocol: null, + service: null, + tag: null, + target: 'ns4.linode.com', + ttl_sec: 0, + type: 'NS', + weight: 0, + }, + { + id: -1, + name: '', + port: 0, + priority: 0, + protocol: null, + service: null, + tag: null, + target: 'ns5.linode.com', + ttl_sec: 0, + type: 'NS', + weight: 0, + }, +]; + +export const getNSRecords = (props: Props): Partial[] => { + const domainRecords = props.domainRecords || []; + const filteredNSRecords = domainRecords.filter(typeEq('NS')); + return [...prependLinodeNS, ...filteredNSRecords]; +}; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateTypes.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateTypes.tsx new file mode 100644 index 00000000000..29c23c111b3 --- /dev/null +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateTypes.tsx @@ -0,0 +1,464 @@ +import { Button } from '@linode/ui'; +import React from 'react'; + +import { truncateEnd } from 'src/utilities/truncate'; + +import { DomainRecordActionMenu } from './DomainRecordActionMenu'; +import { getNSRecords, getTimeColumn, typeEq } from './DomainRecordsUtils'; + +import type { Props as DomainRecordsProps } from './DomainRecords'; +import type { Domain, DomainRecord } from '@linode/api-v4/lib/domains'; + +export interface IType { + columns: { + render: (record: Domain | DomainRecord) => JSX.Element | null | string; + title: string; + }[]; + data: any[]; + link?: () => JSX.Element | null; + order: 'asc' | 'desc'; + orderBy: 'domain' | 'name' | 'target'; + title: string; +} + +export interface GenerateTypesHandlers { + confirmDeletion: (recordId: number) => void; + handleOpenSOADrawer: (domain: Domain) => void; + openForCreateARecord: () => void; + openForCreateCAARecord: () => void; + openForCreateCNAMERecord: () => void; + openForCreateMXRecord: () => void; + openForCreateNSRecord: () => void; + openForCreateSRVRecord: () => void; + openForCreateTXTRecord: () => void; + openForEditARecord: ( + fields: Pick + ) => void; + openForEditCAARecord: ( + fields: Pick + ) => void; + openForEditCNAMERecord: ( + fields: Pick + ) => void; + openForEditMXRecord: ( + fields: Pick< + DomainRecord, + 'id' | 'name' | 'priority' | 'target' | 'ttl_sec' + > + ) => void; + openForEditNSRecord: ( + fields: Pick + ) => void; + openForEditSRVRecord: ( + fields: Pick< + DomainRecord, + 'id' | 'name' | 'port' | 'priority' | 'protocol' | 'target' | 'weight' + > + ) => void; + openForEditTXTRecord: ( + fields: Pick + ) => void; +} + +const createLink = (title: string, handler: () => void) => ( + +); + +export const generateTypes = ( + props: DomainRecordsProps, + handlers: GenerateTypesHandlers +): IType[] => [ + /** SOA Record */ + { + columns: [ + { + render: (domain: Domain) => domain.domain, + title: 'Primary Domain', + }, + { + render: (domain: Domain) => domain.soa_email, + title: 'Email', + }, + { + render: (domain: Domain) => getTimeColumn(domain, 'ttl_sec'), + title: 'Default TTL', + }, + { + render: (domain: Domain) => getTimeColumn(domain, 'refresh_sec'), + title: 'Refresh Rate', + }, + { + render: (domain: Domain) => getTimeColumn(domain, 'retry_sec'), + title: 'Retry Rate', + }, + { + render: (domain: Domain) => getTimeColumn(domain, 'expire_sec'), + title: 'Expire Time', + }, + { + render: (domain: Domain) => { + return domain.type === 'master' ? ( + + ) : null; + }, + title: '', + }, + ], + data: [props.domain], + order: 'asc', + orderBy: 'domain', + title: 'SOA Record', + }, + + /** NS Record */ + { + columns: [ + { + render: (record: DomainRecord) => record.target, + title: 'Name Server', + }, + { + render: (record: DomainRecord) => { + const subdomain = record.name; + return Boolean(subdomain) + ? `${subdomain}.${props.domain.domain}` + : props.domain.domain; + }, + title: 'Subdomain', + }, + { + render: (record: DomainRecord) => getTimeColumn(record, 'ttl_sec'), + title: 'TTL', + }, + { + /** + * If the NS is one of Linode's, don't display the Action menu since the user + * cannot make changes to Linode's nameservers. + */ + render: (domainRecordParams: DomainRecord) => { + const { id, name, target, ttl_sec } = domainRecordParams; + + if (id === -1) { + return null; + } + + return ( + + ); + }, + title: '', + }, + ], + data: getNSRecords(props), + link: () => createLink('Add an NS Record', handlers.openForCreateNSRecord), + order: 'asc', + orderBy: 'target', + title: 'NS Record', + }, + + /** MX Record */ + { + columns: [ + { + render: (record: DomainRecord) => record.target, + title: 'Mail Server', + }, + { + render: (record: DomainRecord) => String(record.priority), + title: 'Preference', + }, + { + render: (record: DomainRecord) => record.name, + title: 'Subdomain', + }, + { + render: (record: DomainRecord) => getTimeColumn(record, 'ttl_sec'), + title: 'TTL', + }, + { + render: (domainRecordParams: DomainRecord) => { + const { id, name, priority, target, ttl_sec } = domainRecordParams; + return ( + + ); + }, + title: '', + }, + ], + data: props.domainRecords.filter(typeEq('MX')), + link: () => createLink('Add a MX Record', handlers.openForCreateMXRecord), + order: 'asc', + orderBy: 'target', + title: 'MX Record', + }, + + /** A/AAAA Record */ + { + columns: [ + { + render: (record: DomainRecord) => record.name || props.domain.domain, + title: 'Hostname', + }, + { render: (record: DomainRecord) => record.target, title: 'IP Address' }, + { + render: (record: DomainRecord) => getTimeColumn(record, 'ttl_sec'), + title: 'TTL', + }, + { + render: (domainRecordParams: DomainRecord) => { + const { id, name, target, ttl_sec } = domainRecordParams; + return ( + + ); + }, + title: '', + }, + ], + data: props.domainRecords.filter( + (record) => typeEq('AAAA')(record) || typeEq('A')(record) + ), + link: () => + createLink('Add an A/AAAA Record', handlers.openForCreateARecord), + order: 'asc', + orderBy: 'name', + title: 'A/AAAA Record', + }, + + /** CNAME Record */ + { + columns: [ + { render: (record: DomainRecord) => record.name, title: 'Hostname' }, + { render: (record: DomainRecord) => record.target, title: 'Aliases to' }, + { + render: (record: DomainRecord) => getTimeColumn(record, 'ttl_sec'), + title: 'TTL', + }, + { + render: (domainRecordParams: DomainRecord) => { + const { id, name, target, ttl_sec } = domainRecordParams; + return ( + + ); + }, + title: '', + }, + ], + data: props.domainRecords.filter(typeEq('CNAME')), + link: () => + createLink('Add a CNAME Record', handlers.openForCreateCNAMERecord), + order: 'asc', + orderBy: 'name', + title: 'CNAME Record', + }, + + /** TXT Record */ + { + columns: [ + { + render: (record: DomainRecord) => record.name || props.domain.domain, + title: 'Hostname', + }, + { + render: (record: DomainRecord) => truncateEnd(record.target, 100), + title: 'Value', + }, + { + render: (record: DomainRecord) => getTimeColumn(record, 'ttl_sec'), + title: 'TTL', + }, + { + render: (domainRecordParams: DomainRecord) => { + const { id, name, target, ttl_sec } = domainRecordParams; + return ( + + ); + }, + title: '', + }, + ], + data: props.domainRecords.filter(typeEq('TXT')), + link: () => createLink('Add a TXT Record', handlers.openForCreateTXTRecord), + order: 'asc', + orderBy: 'name', + title: 'TXT Record', + }, + /** SRV Record */ + { + columns: [ + { + render: (record: DomainRecord) => record.name, + title: 'Service/Protocol', + }, + { + render: () => props.domain.domain, + title: 'Name', + }, + { + render: (record: DomainRecord) => String(record.priority), + title: 'Priority', + }, + { + render: (record: DomainRecord) => String(record.weight), + title: 'Weight', + }, + { render: (record: DomainRecord) => String(record.port), title: 'Port' }, + { render: (record: DomainRecord) => record.target, title: 'Target' }, + { + render: (record: DomainRecord) => getTimeColumn(record, 'ttl_sec'), + title: 'TTL', + }, + { + render: ({ + id, + port, + priority, + protocol, + service, + target, + weight, + }: DomainRecord) => ( + + ), + title: '', + }, + ], + data: props.domainRecords.filter(typeEq('SRV')), + link: () => + createLink('Add an SRV Record', handlers.openForCreateSRVRecord), + order: 'asc', + orderBy: 'name', + title: 'SRV Record', + }, + + /** CAA Record */ + { + columns: [ + { render: (record: DomainRecord) => record.name, title: 'Name' }, + { render: (record: DomainRecord) => record.tag, title: 'Tag' }, + { + render: (record: DomainRecord) => record.target, + title: 'Value', + }, + { + render: (record: DomainRecord) => getTimeColumn(record, 'ttl_sec'), + title: 'TTL', + }, + { + render: (domainRecordParams: DomainRecord) => { + const { id, name, tag, target, ttl_sec } = domainRecordParams; + return ( + + ); + }, + title: '', + }, + ], + data: props.domainRecords.filter(typeEq('CAA')), + link: () => createLink('Add a CAA Record', handlers.openForCreateCAARecord), + order: 'asc', + orderBy: 'name', + title: 'CAA Record', + }, +]; diff --git a/packages/manager/src/features/Domains/DomainRecords.tsx b/packages/manager/src/features/Domains/DomainRecords.tsx deleted file mode 100644 index b7e12400ef2..00000000000 --- a/packages/manager/src/features/Domains/DomainRecords.tsx +++ /dev/null @@ -1,889 +0,0 @@ -import { deleteDomainRecord } from '@linode/api-v4/lib/domains'; -import { Button, Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; -import { - compose, - equals, - filter, - flatten, - isEmpty, - lensPath, - over, - pathOr, - prepend, - propEq, -} from 'ramda'; -import * as React from 'react'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import OrderBy from 'src/components/OrderBy'; -import Paginate from 'src/components/Paginate'; -import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; -import { Table } from 'src/components/Table'; -import { TableBody } from 'src/components/TableBody'; -import { TableCell } from 'src/components/TableCell'; -import { TableHead } from 'src/components/TableHead'; -import { TableRow } from 'src/components/TableRow'; -import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; -import { - getAPIErrorOrDefault, - getErrorStringOrDefault, -} from 'src/utilities/errorUtils'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; -import { storage } from 'src/utilities/storage'; -import { truncateEnd } from 'src/utilities/truncate'; - -import { DomainRecordActionMenu } from './DomainRecordActionMenu'; -import { DomainRecordDrawer } from './DomainRecordDrawer'; -import { StyledDiv, StyledGrid, StyledTableCell } from './DomainRecords.styles'; - -import type { - Domain, - DomainRecord, - DomainType, - RecordType, - UpdateDomainPayload, -} from '@linode/api-v4/lib/domains'; -import type { APIError } from '@linode/api-v4/lib/types'; - -interface UpdateDomainDataProps extends UpdateDomainPayload { - id: number; -} - -interface Props { - domain: Domain; - domainRecords: DomainRecord[]; - updateDomain: (data: UpdateDomainDataProps) => Promise; - updateRecords: () => void; -} - -interface ConfirmationState { - errors?: APIError[]; - open: boolean; - recordId?: number; - submitting: boolean; -} - -interface DrawerState { - fields?: Partial | Partial; - mode: 'create' | 'edit'; - open: boolean; - type: DomainType | RecordType; -} - -interface State { - confirmDialog: ConfirmationState; - drawer: DrawerState; - types: IType[]; -} - -interface IType { - columns: { - render: (r: Domain | DomainRecord) => JSX.Element | null | string; - title: string; - }[]; - data: any[]; - link?: () => JSX.Element | null; - order: 'asc' | 'desc'; - orderBy: 'domain' | 'name' | 'target'; - title: string; -} - -const createLink = (title: string, handler: () => void) => ( - -); - -class DomainRecords extends React.Component { - static defaultDrawerState: DrawerState = { - mode: 'create', - open: false, - type: 'NS', - }; - - confirmDeletion = (recordId: number) => - this.updateConfirmDialog((confirmDialog) => ({ - ...confirmDialog, - open: true, - recordId, - })); - - deleteDomainRecord = () => { - const { - domain: { id: domainId }, - } = this.props; - const { - confirmDialog: { recordId }, - } = this.state; - if (!domainId || !recordId) { - return; - } - - this.updateConfirmDialog((c) => ({ - ...c, - errors: undefined, - submitting: true, - })); - - deleteDomainRecord(domainId, recordId) - .then(() => { - this.props.updateRecords(); - - this.updateConfirmDialog((_) => ({ - errors: undefined, - open: false, - recordId: undefined, - submitting: false, - })); - }) - .catch((errorResponse) => { - const errors = getAPIErrorOrDefault(errorResponse); - this.updateConfirmDialog((c) => ({ - ...c, - errors, - submitting: false, - })); - }); - this.updateConfirmDialog((c) => ({ ...c, submitting: true })); - }; - - generateTypes = (): IType[] => [ - /** SOA Record */ - { - columns: [ - { - render: (d: Domain) => d.domain, - title: 'Primary Domain', - }, - { - render: (d: Domain) => d.soa_email, - title: 'Email', - }, - { - render: compose(msToReadable, pathOr(0, ['ttl_sec'])), - title: 'Default TTL', - }, - { - render: compose(msToReadable, pathOr(0, ['refresh_sec'])), - title: 'Refresh Rate', - }, - { - render: compose(msToReadable, pathOr(0, ['retry_sec'])), - title: 'Retry Rate', - }, - { - render: compose(msToReadable, pathOr(0, ['expire_sec'])), - title: 'Expire Time', - }, - { - render: (d: Domain) => { - return d.type === 'master' ? ( - - ) : null; - }, - title: '', - }, - ], - data: [this.props.domain], - order: 'asc', - orderBy: 'domain', - title: 'SOA Record', - }, - - /** NS Record */ - { - columns: [ - { - render: (r: DomainRecord) => r.target, - title: 'Name Server', - }, - { - render: (r: DomainRecord) => { - const sd = r.name; - const { - domain: { domain }, - } = this.props; - return isEmpty(sd) ? domain : `${sd}.${domain}`; - }, - title: 'Subdomain', - }, - { - render: getTTL, - title: 'TTL', - }, - { - /** - * If the NS is one of Linode's, don't display the Action menu since the user - * cannot make changes to Linode's nameservers. - */ - render: ({ id, name, target, ttl_sec }: DomainRecord) => - id === -1 ? null : ( - - ), - title: '', - }, - ], - data: getNSRecords(this.props), - link: () => createLink('Add an NS Record', this.openForCreateNSRecord), - order: 'asc', - orderBy: 'target', - title: 'NS Record', - }, - - /** MX Record */ - { - columns: [ - { - render: (r: DomainRecord) => r.target, - title: 'Mail Server', - }, - { - render: (r: DomainRecord) => String(r.priority), - title: 'Preference', - }, - { - render: (r: DomainRecord) => r.name, - title: 'Subdomain', - }, - { - render: getTTL, - title: 'TTL', - }, - { - render: ({ id, name, priority, target, ttl_sec }: DomainRecord) => ( - - ), - title: '', - }, - ], - data: this.props.domainRecords.filter(typeEq('MX')), - link: () => createLink('Add a MX Record', this.openForCreateMXRecord), - order: 'asc', - orderBy: 'target', - title: 'MX Record', - }, - - /** A/AAAA Record */ - { - columns: [ - { - render: (r: DomainRecord) => r.name || this.props.domain.domain, - title: 'Hostname', - }, - { render: (r: DomainRecord) => r.target, title: 'IP Address' }, - { render: getTTL, title: 'TTL' }, - { - render: ({ id, name, target, ttl_sec }: DomainRecord) => ( - - ), - title: '', - }, - ], - data: this.props.domainRecords.filter( - (r) => typeEq('AAAA', r) || typeEq('A', r) - ), - link: () => createLink('Add an A/AAAA Record', this.openForCreateARecord), - order: 'asc', - orderBy: 'name', - title: 'A/AAAA Record', - }, - - /** CNAME Record */ - { - columns: [ - { render: (r: DomainRecord) => r.name, title: 'Hostname' }, - { render: (r: DomainRecord) => r.target, title: 'Aliases to' }, - { render: getTTL, title: 'TTL' }, - { - render: ({ id, name, target, ttl_sec }: DomainRecord) => ( - - ), - title: '', - }, - ], - data: this.props.domainRecords.filter(typeEq('CNAME')), - link: () => - createLink('Add a CNAME Record', this.openForCreateCNAMERecord), - order: 'asc', - orderBy: 'name', - title: 'CNAME Record', - }, - - /** TXT Record */ - { - columns: [ - { - render: (r: DomainRecord) => r.name || this.props.domain.domain, - title: 'Hostname', - }, - { - render: (r: DomainRecord) => truncateEnd(r.target, 100), - title: 'Value', - }, - { render: getTTL, title: 'TTL' }, - { - render: ({ id, name, target, ttl_sec }: DomainRecord) => ( - - ), - title: '', - }, - ], - data: this.props.domainRecords.filter(typeEq('TXT')), - link: () => createLink('Add a TXT Record', this.openForCreateTXTRecord), - order: 'asc', - orderBy: 'name', - title: 'TXT Record', - }, - /** SRV Record */ - { - columns: [ - { render: (r: DomainRecord) => r.name, title: 'Service/Protocol' }, - { - render: () => this.props.domain.domain, - title: 'Name', - }, - { - render: (r: DomainRecord) => String(r.priority), - title: 'Priority', - }, - { - render: (r: DomainRecord) => String(r.weight), - title: 'Weight', - }, - { render: (r: DomainRecord) => String(r.port), title: 'Port' }, - { render: (r: DomainRecord) => r.target, title: 'Target' }, - { render: getTTL, title: 'TTL' }, - { - render: ({ - id, - port, - priority, - protocol, - service, - target, - weight, - }: DomainRecord) => ( - - ), - title: '', - }, - ], - data: this.props.domainRecords.filter(typeEq('SRV')), - link: () => createLink('Add an SRV Record', this.openForCreateSRVRecord), - order: 'asc', - orderBy: 'name', - title: 'SRV Record', - }, - - /** CAA Record */ - { - columns: [ - { render: (r: DomainRecord) => r.name, title: 'Name' }, - { render: (r: DomainRecord) => r.tag, title: 'Tag' }, - { - render: (r: DomainRecord) => r.target, - title: 'Value', - }, - { render: getTTL, title: 'TTL' }, - { - render: ({ id, name, tag, target, ttl_sec }: DomainRecord) => ( - - ), - title: '', - }, - ], - data: this.props.domainRecords.filter(typeEq('CAA')), - link: () => createLink('Add a CAA Record', this.openForCreateCAARecord), - order: 'asc', - orderBy: 'name', - title: 'CAA Record', - }, - ]; - - handleCloseDialog = () => { - this.updateConfirmDialog(() => ({ - open: false, - recordId: undefined, - submitting: false, - })); - }; - - handleOpenSOADrawer = (d: Domain) => { - return d.type === 'master' - ? this.openForEditPrimaryDomain(d) - : this.openForEditSecondaryDomain(d); - }; - - openForCreateARecord = () => this.openForCreation('AAAA'); - - openForCreateCAARecord = () => this.openForCreation('CAA'); - - openForCreateCNAMERecord = () => this.openForCreation('CNAME'); - openForCreateMXRecord = () => this.openForCreation('MX'); - - openForCreateNSRecord = () => this.openForCreation('NS'); - openForCreateSRVRecord = () => this.openForCreation('SRV'); - - openForCreateTXTRecord = () => this.openForCreation('TXT'); - openForCreation = (type: RecordType) => - this.updateDrawer(() => ({ - mode: 'create', - open: true, - submitting: false, - type, - })); - - openForEditARecord = ( - f: Pick - ) => this.openForEditing('AAAA', f); - openForEditCAARecord = ( - f: Pick - ) => this.openForEditing('CAA', f); - - openForEditCNAMERecord = ( - f: Pick - ) => this.openForEditing('CNAME', f); - openForEditMXRecord = ( - f: Pick - ) => this.openForEditing('MX', f); - - openForEditNSRecord = ( - f: Pick - ) => this.openForEditing('NS', f); - openForEditPrimaryDomain = (f: Partial) => - this.openForEditing('master', f); - - openForEditSRVRecord = ( - f: Pick< - DomainRecord, - 'id' | 'name' | 'port' | 'priority' | 'protocol' | 'target' | 'weight' - > - ) => this.openForEditing('SRV', f); - openForEditSecondaryDomain = (f: Partial) => - this.openForEditing('slave', f); - - openForEditTXTRecord = ( - f: Pick - ) => this.openForEditing('TXT', f); - - openForEditing = ( - type: DomainType | RecordType, - fields: Partial | Partial - ) => - this.updateDrawer(() => ({ - fields, - mode: 'edit', - open: true, - submitting: false, - type, - })); - - renderDialogActions = () => { - return ( - - ); - }; - - resetDrawer = () => this.updateDrawer(() => DomainRecords.defaultDrawerState); - - updateConfirmDialog = (fn: (d: ConfirmationState) => ConfirmationState) => - this.setState(over(lensPath(['confirmDialog']), fn), () => { - scrollErrorIntoView(); - }); - - updateDrawer = (fn: (d: DrawerState) => DrawerState) => - this.setState(over(lensPath(['drawer']), fn)); - - constructor(props: Props) { - super(props); - this.state = { - confirmDialog: { - open: false, - submitting: false, - }, - drawer: DomainRecords.defaultDrawerState, - types: this.generateTypes(), - }; - } - - componentDidUpdate(prevProps: Props) { - if ( - !equals(prevProps.domainRecords, this.props.domainRecords) || - !equals(prevProps.domain, this.props.domain) - ) { - this.setState({ types: this.generateTypes() }); - } - } - - render() { - const { domain, domainRecords } = this.props; - const { confirmDialog, drawer } = this.state; - - return ( - <> - - {this.state.types.map((type, eachTypeIdx) => { - const ref: React.Ref = React.createRef(); - - return ( -
- - - - {type.title} - - - {type.link && ( - - {' '} - {type.link()}{' '} - - )} - - - {({ data: orderedData }) => { - return ( - - {({ - count, - data: paginatedData, - handlePageChange, - handlePageSizeChange, - page, - pageSize, - }) => { - return ( - <> - - - - {type.columns.length > 0 && - type.columns.map((col, columnIndex) => { - return ( - - {col.title} - - ); - })} - - - - {type.data.length === 0 ? ( - - ) : ( - paginatedData.map((data, idx) => { - return ( - - {type.columns.length > 0 && - type.columns.map( - ( - { render, title }, - columnIndex - ) => { - return ( - - {render(data)} - - ); - } - )} - - ); - }) - )} - -
- - - ); - }} -
- ); - }} -
-
- ); - })} - - Are you sure you want to delete this record? - - - - ); - } -} - -const msToReadable = (v: number): null | string => - pathOr(null, [v], { - 0: 'Default', - 30: '30 seconds', - 120: '2 minutes', - 300: '5 minutes', - 3600: '1 hour', - 7200: '2 hours', - 14400: '4 hours', - 28800: '8 hours', - 57600: '16 hours', - 86400: '1 day', - 172800: '2 days', - 345600: '4 days', - 604800: '1 week', - 1209600: '2 weeks', - 2419200: '4 weeks', - }); - -const getTTL = compose(msToReadable, pathOr(0, ['ttl_sec'])); - -const typeEq = propEq('type'); - -const prependLinodeNS = compose( - flatten, - prepend([ - { - id: -1, - name: '', - port: 0, - priority: 0, - protocol: null, - service: null, - tag: null, - target: 'ns1.linode.com', - ttl_sec: 0, - type: 'NS', - weight: 0, - }, - { - id: -1, - name: '', - port: 0, - priority: 0, - protocol: null, - service: null, - tag: null, - target: 'ns2.linode.com', - ttl_sec: 0, - type: 'NS', - weight: 0, - }, - { - id: -1, - name: '', - port: 0, - priority: 0, - protocol: null, - service: null, - tag: null, - target: 'ns3.linode.com', - ttl_sec: 0, - type: 'NS', - weight: 0, - }, - { - id: -1, - name: '', - port: 0, - priority: 0, - protocol: null, - service: null, - tag: null, - target: 'ns4.linode.com', - ttl_sec: 0, - type: 'NS', - weight: 0, - }, - { - id: -1, - name: '', - port: 0, - priority: 0, - protocol: null, - service: null, - tag: null, - target: 'ns5.linode.com', - ttl_sec: 0, - type: 'NS', - weight: 0, - }, - ]) -); - -const getNSRecords = compose< - Props, - DomainRecord[], - DomainRecord[], - DomainRecord[] ->(prependLinodeNS, filter(typeEq('NS')), pathOr([], ['domainRecords'])); - -export default DomainRecords; diff --git a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx index cfe556231a0..11e45705837 100644 --- a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx +++ b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx @@ -128,8 +128,8 @@ const EmailBounceNotification = React.memo((props: Props) => { } return ( - - + + {text} diff --git a/packages/manager/src/features/GlobalNotifications/VerificationDetailsBanner.tsx b/packages/manager/src/features/GlobalNotifications/VerificationDetailsBanner.tsx index 7e62dedbd18..f763c7142aa 100644 --- a/packages/manager/src/features/GlobalNotifications/VerificationDetailsBanner.tsx +++ b/packages/manager/src/features/GlobalNotifications/VerificationDetailsBanner.tsx @@ -24,7 +24,7 @@ export const VerificationDetailsBanner = ({ } return ( - + ({ - enhancedSelectWrapper: { - '& .input': { - '& > div': { - marginRight: 0, - }, - '& p': { - color: theme.color.grey1, - paddingLeft: theme.spacing(3), - }, - maxWidth: '100%', - }, - '& .react-select__value-container': { - paddingLeft: theme.spacing(4), - }, - margin: '0 auto', - maxHeight: 500, - [theme.breakpoints.up('md')]: { - width: 500, - }, - width: 300, - }, - notice: { - '& p': { - color: theme.color.white, - fontFamily: 'LatoWeb', - }, - }, - root: { - position: 'relative', - }, - searchIcon: { - color: theme.color.grey1, - left: 5, - position: 'absolute', - top: 4, - zIndex: 3, - }, -})); +interface SelectedItem { + data: { source: string }; + label: string; + value: string; +} interface AlgoliaSearchBarProps extends AlgoliaProps, RouteComponentProps<{}> {} +/** + * For Algolia search to work locally, ensure you have valid values set for + * REACT_APP_ALGOLIA_APPLICATION_ID and REACT_APP_ALGOLIA_SEARCH_KEY in your .env file. + */ const AlgoliaSearchBar = (props: AlgoliaSearchBarProps) => { - const { classes } = useStyles(); const [inputValue, setInputValue] = React.useState(''); const { history, @@ -98,47 +61,112 @@ const AlgoliaSearchBar = (props: AlgoliaSearchBarProps) => { : '/support/search/'; }; - const handleSelect = (selected: Item) => { + const handleSelect = (selected: ConvertedItems | SelectedItem | null) => { if (!selected || !inputValue) { return; } - if (selected.value === 'search') { + const href = pathOr('', ['data', 'href'], selected); + if (href) { + // If an href exists for the selected option, redirect directly to that link. + window.open(href, '_blank', 'noopener'); + } else { + // If no href, we redirect to the search landing page. const link = getLinkTarget(inputValue); history.push(link); - } else { - const href = pathOr('', ['data', 'href'], selected); - window.open(href, '_blank', 'noopener'); } }; - return ( {searchError && ( - + ({ + '& p': { + color: theme.color.white, + fontFamily: 'LatoWeb', + }, + })} + spacingTop={8} + variant="error" + > {searchError} )} -
- - null, Option: SearchItem } as any - } - className={classes.enhancedSelectWrapper} - disabled={!searchEnabled} - hideLabel - inputValue={inputValue} - isClearable={true} - isMulti={false} - label="Search for answers" - onChange={handleSelect} - onInputChange={onInputValueChange} - options={options} - placeholder="Search for answers..." - styles={selectStyles} - /> -
+ { + return ( + + ); + }} + slotProps={{ + paper: { + sx: (theme) => ({ + '& .MuiAutocomplete-listbox': { + '&::-webkit-scrollbar': { + display: 'none', + }, + border: 'none !important', + msOverflowStyle: 'none', + scrollbarWidth: 'none', + }, + '& .MuiAutocomplete-option': { + ':hover': { + backgroundColor: + theme.name == 'light' + ? `${theme.tokens.color.Brand[10]} !important` + : `${theme.tokens.color.Neutrals[80]} !important`, + color: theme.color.black, + transition: 'background-color 0.2s', + }, + }, + boxShadow: '0px 2px 8px 0px rgba(58, 59, 63, 0.18)', + marginTop: 0.5, + }), + }, + }} + sx={(theme) => ({ + maxHeight: 500, + [theme.breakpoints.up('md')]: { + width: 500, + }, + width: 300, + })} + textFieldProps={{ + InputProps: { + startAdornment: ( + + ({ + color: `${theme.tokens.search.Default.SearchIcon} !important`, + })} + data-qa-search-icon + /> + + ), + sx: (theme) => ({ + '&.Mui-focused': { + borderColor: `${theme.tokens.color.Brand[70]} !important`, + boxShadow: 'none', + }, + ':hover': { + borderColor: theme.tokens.search.Hover.Border, + }, + }), + }, + hideLabel: true, + }} + disabled={!searchEnabled} + inputValue={inputValue} + label="Search for answers" + onChange={(_, selected) => handleSelect(selected)} + onInputChange={(_, value) => onInputValueChange(value)} + options={options} + placeholder="Search" + />
); }; diff --git a/packages/manager/src/features/Help/Panels/SearchItem.tsx b/packages/manager/src/features/Help/Panels/SearchItem.tsx index 96aef5779ac..80bee89b327 100644 --- a/packages/manager/src/features/Help/Panels/SearchItem.tsx +++ b/packages/manager/src/features/Help/Panels/SearchItem.tsx @@ -1,58 +1,68 @@ -import { Typography } from '@linode/ui'; +import { ListItem, Typography } from '@linode/ui'; import * as React from 'react'; -import { useStyles } from 'tss-react/mui'; +import { makeStyles } from 'tss-react/mui'; import Arrow from 'src/assets/icons/diagonalArrow.svg'; -import { Option } from 'src/components/EnhancedSelect/components/Option'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; -import type { OptionProps } from 'react-select'; +import type { Theme } from '@mui/material/styles'; -interface Props extends OptionProps { +const useStyles = makeStyles()((theme: Theme) => ({ + arrow: { + color: theme.palette.primary.main, + height: 12, + width: 12, + }, + root: { + display: 'flex', + justifyContent: 'space-between', + width: '100%', + }, +})); + +interface Props { data: { - data: any; + data: { + source: string; + }; label: string; }; - searchText: string; } export const SearchItem = (props: Props) => { + const { data } = props; const getLabel = () => { if (isFinal) { - return props.label ? `Search for "${props.label}"` : 'Search'; + return data.label ? `Search for "${data.label}"` : 'Search'; } else { - return props.label; + return data.label; } }; - const { cx } = useStyles(); + const { classes } = useStyles(); - const { - data, - isFocused, - selectProps: { classes }, - } = props; const source = data.data ? data.data.source : ''; const isFinal = source === 'finalLink'; return ( -