diff --git a/.github/workflows/e2e_schedule_and_push.yml b/.github/workflows/e2e_schedule_and_push.yml index e95da872490..63578c1a25c 100644 --- a/.github/workflows/e2e_schedule_and_push.yml +++ b/.github/workflows/e2e_schedule_and_push.yml @@ -7,7 +7,6 @@ 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 b3cea0e4449..ced361320f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,6 @@ 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 27ad221daee..a97bf29a06c 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -223,7 +223,6 @@ 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 @@ -282,15 +281,17 @@ Environment variables that can be used to improve test performance in some scena cy.wait('@getProfilePreferences'); cy.wait('@getAccountSettings'); - cy.get(`tr[data-qa-linode="${label}"]`).should('be.visible').within(() => { + /* `getVisible` defined in /cypress/support/helpers.ts + plus a few other commonly used commands shortened as methods */ + getVisible(`tr[data-qa-linode="${label}"]`).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(() => { - cy.get(`[aria-label="Copy ${ip} to clipboard"]`).should('be.visible'); + getVisible(`[aria-label="Copy ${ip} to clipboard"]`); }); - cy.get(`[aria-label="Action menu for Linode ${label}"]`).should('be.visible'); + getVisible(`[aria-label="Action menu for Linode ${label}"]`); }); // `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 a23e6466eff..46ba965031f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "Apache-2.0", "devDependencies": { "husky": "^9.1.6", - "typescript": "^5.7.3", + "typescript": "^5.5.4", "vitest": "^2.1.1" }, "scripts": { @@ -52,8 +52,7 @@ "node-fetch": "^2.6.7", "yaml": "^2.3.0", "semver": "^7.5.2", - "cookie": "^0.7.0", - "nanoid": "^3.3.8" + "cookie": "^0.7.0" }, "workspaces": { "packages": [ diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index c3f91bdc99e..2094dabd71e 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,20 +1,3 @@ -## [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 9be35fb4b2e..ccb47929f77 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.133.0", + "version": "0.132.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 1fa7b63d280..2e4e6c4658a 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -20,13 +20,6 @@ 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; @@ -62,7 +55,7 @@ export interface Widgets { filters: Filters[]; serviceType: string; service_type: string; - entity_ids: string[]; + resource_id: string[]; time_granularity: TimeGranularity; time_duration: TimeDuration; unit: string; @@ -113,7 +106,7 @@ export interface Dimension { } export interface JWETokenPayLoad { - entity_ids: number[]; + resource_ids: number[]; } export interface JWEToken { @@ -127,7 +120,7 @@ export interface CloudPulseMetricsRequest { group_by: string; relative_time_duration: TimeDuration; time_granularity: TimeGranularity | undefined; - entity_ids: number[]; + resource_ids: number[]; } export interface CloudPulseMetricsResponse { @@ -225,72 +218,3 @@ 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 fab28fabeb5..dd996b686d2 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -32,8 +32,6 @@ 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 ef1e1b62e4b..68f89c7ac32 100644 --- a/packages/api-v4/src/nodebalancers/types.ts +++ b/packages/api-v4/src/nodebalancers/types.ts @@ -94,10 +94,7 @@ export interface NodeBalancerConfig { stickiness: Stickiness; algorithm: Algorithm; ssl_fingerprint: string; - /** - * Is `none` when protocol is UDP - */ - cipher_suite: 'recommended' | 'legacy' | 'none'; + cipher_suite: 'recommended' | 'legacy'; nodes: NodeBalancerConfigNode[]; } @@ -163,7 +160,7 @@ export interface CreateNodeBalancerConfig { * @default 80 */ udp_check_port?: number; - cipher_suite?: 'recommended' | 'legacy' | 'none'; + cipher_suite?: 'recommended' | 'legacy'; ssl_cert?: string; ssl_key?: string; } diff --git a/packages/api-v4/src/quotas/index.ts b/packages/api-v4/src/quotas/index.ts deleted file mode 100644 index 2b1b25700ed..00000000000 --- a/packages/api-v4/src/quotas/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './types'; - -export * from './quotas'; diff --git a/packages/api-v4/src/quotas/quotas.ts b/packages/api-v4/src/quotas/quotas.ts deleted file mode 100644 index ecb9b8057d1..00000000000 --- a/packages/api-v4/src/quotas/quotas.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 23c42f00165..00000000000 --- a/packages/api-v4/src/quotas/types.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 f719403dead..97ffeb98d7a 100644 --- a/packages/api-v4/src/request.test.ts +++ b/packages/api-v4/src/request.test.ts @@ -1,4 +1,3 @@ -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 680aa4c03a5..bf6d89ad037 100644 --- a/packages/api-v4/src/resources/types.ts +++ b/packages/api-v4/src/resources/types.ts @@ -1,4 +1,4 @@ -export type ResourceType = +type ResourceType = | 'linode' | 'firewall' | 'nodebalancer' @@ -10,10 +10,10 @@ export type ResourceType = | 'database' | 'vpc'; -export interface IamAccountResource { +export type 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 58df0f1dff7..099ebaf3a2e 100644 --- a/packages/api-v4/tsconfig.json +++ b/packages/api-v4/tsconfig.json @@ -1,12 +1,13 @@ { "compilerOptions": { "target": "esnext", - "module": "esnext", + "types": ["vitest/globals"], + "module": "umd", "emitDeclarationOnly": true, "declaration": true, "outDir": "./lib", "esModuleInterop": true, - "moduleResolution": "bundler", + "moduleResolution": "node", "skipLibCheck": true, "strict": true, "baseUrl": ".", @@ -16,5 +17,9 @@ }, "include": [ "src" + ], + "exclude": [ + "node_modules/**/*", + "**/__tests__/*" ] } diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index 291f9ec2e25..cc85bbb89d0 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -91,7 +91,6 @@ 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: { @@ -120,20 +119,14 @@ module.exports = { 'withRouter', ], message: - 'Please use routing utilities intended for @tanstack/react-router.', + 'Please use routing utilities from @tanstack/react-router.', name: 'react-router-dom', }, { - importNames: ['TabLinkList'], + importNames: ['renderWithTheme'], message: - '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', + 'Please use the wrapWithThemeAndRouter helper function for testing components being migrated to TanStack Router.', + name: 'src/utilities/testHelpers', }, ], }, diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 26b9b97e057..820634b5612 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,66 +4,6 @@ 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 b8596bed4d9..83903ec697c 100644 --- a/packages/manager/cypress.config.ts +++ b/packages/manager/cypress.config.ts @@ -19,7 +19,6 @@ 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. @@ -93,7 +92,6 @@ 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 deleted file mode 100644 index 1313a09052c..00000000000 --- a/packages/manager/cypress/component/components/password-input.spec.tsx +++ /dev/null @@ -1,176 +0,0 @@ -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 71d0c2c5c97..bf49abb3415 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 { SelectOption, SelectProps } from '@linode/ui'; +import type { SelectOptionType, SelectProps } from '@linode/ui'; const options = [ { label: 'Option 1', value: 'option-1' }, @@ -260,7 +260,7 @@ componentTests('Select', (mount) => { }); }); - const defaultProps = { + const defaultProps: SelectProps = { label: 'My Select', onChange: () => {}, options, @@ -268,10 +268,10 @@ componentTests('Select', (mount) => { }; describe('Logic', () => { - const WrappedSelect = (props: Partial>) => { - const [value, setValue] = React.useState( - null - ); + const WrappedSelect = (props: Partial) => { + const [value, setValue] = React.useState< + SelectOptionType | null | undefined + >(null); return ( <> @@ -280,9 +280,7 @@ componentTests('Select', (mount) => { onChange={(_, newValue) => setValue({ label: newValue?.label ?? '', - value: - newValue?.value.toString().replace(' ', '-').toLowerCase() ?? - '', + value: newValue?.value.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 6211b624c99..c984bf30ec1 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 cef3136b233..1253783f7af 100644 --- a/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts @@ -1,3 +1,4 @@ +import { fbltClick } from 'support/helpers'; import { oauthClientFactory } from '@src/factories'; import { mockCreateOAuthApp, @@ -30,11 +31,8 @@ const createOAuthApp = (oauthApp: OAuthClient) => { .findByTitle('Create OAuth App') .should('be.visible') .within(() => { - cy.findByLabelText('Label').click().clear().type(oauthApp.label); - cy.findByLabelText('Callback URL') - .click() - .clear() - .type(oauthApp.redirect_uri); + fbltClick('Label').clear().type(oauthApp.label); + fbltClick('Callback URL').clear().type(oauthApp.redirect_uri); ui.buttonGroup .findButtonByTitle('Cancel') .should('be.visible') @@ -56,11 +54,8 @@ const createOAuthApp = (oauthApp: OAuthClient) => { .findByTitle('Create OAuth App') .should('be.visible') .within(() => { - cy.findByLabelText('Label').click().clear().type(oauthApp.label); - cy.findByLabelText('Callback URL') - .click() - .clear() - .type(oauthApp.redirect_uri); + fbltClick('Label').clear().type(oauthApp.label); + fbltClick('Callback URL').clear().type(oauthApp.redirect_uri); }); ui.drawerCloseButton.find().click(); @@ -80,8 +75,8 @@ const createOAuthApp = (oauthApp: OAuthClient) => { .should('be.visible') .within(() => { // An error message appears when attempting to create an OAuth App without a label - cy.findByLabelText('Label').click().clear(); - cy.findByLabelText('Callback URL').click().clear(); + fbltClick('Label').clear(); + fbltClick('Callback URL').clear(); ui.button .findByTitle('Create') .should('be.visible') @@ -91,11 +86,8 @@ const createOAuthApp = (oauthApp: OAuthClient) => { cy.findByText('Redirect URI is required.'); // Fill out and submit OAuth App create form. - cy.findByLabelText('Label').click().clear().type(oauthApp.label); - cy.findByLabelText('Callback URL') - .click() - .clear() - .type(oauthApp.redirect_uri); + fbltClick('Label').clear().type(oauthApp.label); + fbltClick('Callback URL').clear().type(oauthApp.redirect_uri); // Check the 'public' checkbox if (oauthApp.public) { cy.get('[data-qa-checked]').should('be.visible').click(); @@ -320,11 +312,8 @@ describe('OAuth Apps', () => { .should('be.visible') .should('be.disabled'); - cy.findByLabelText('Label').click().clear().type(updatedApps[0].label); - cy.findByLabelText('Callback URL') - .click() - .clear() - .type(updatedApps[0].label); + fbltClick('Label').clear().type(updatedApps[0].label); + fbltClick('Callback URL').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 854077817ba..5bed5edc465 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 Out') + .findByAttribute('aria-label', 'Zoom In') .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 In') + .findByAttribute('aria-label', 'Zoom Out') .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 5b4afb0f031..70a6ef1c615 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 Out') + .findByAttribute('aria-label', 'Zoom In') .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 In') + .findByAttribute('aria-label', 'Zoom Out') .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 2b3903f78de..f379f8c35a5 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,5 +1,6 @@ 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'; @@ -43,11 +44,11 @@ describe('Clone a Domain', () => { domainRecords.forEach((rec) => { interceptCreateDomainRecord().as('apiCreateRecord'); - cy.findByText(rec.name).click(); + fbtClick(rec.name); rec.fields.forEach((f) => { - cy.get(f.name).click().type(f.value); + getClick(f.name).type(f.value); }); - cy.findByText('Save').click(); + fbtClick('Save'); cy.wait('@apiCreateRecord'); }); @@ -101,7 +102,7 @@ describe('Clone a Domain', () => { .should('be.disabled'); // Confirm that an error is displayed when entering an invalid domain name - cy.findByLabelText('New Domain').click().type(invalidDomainName); + fbltClick('New Domain').type(invalidDomainName); ui.buttonGroup .findButtonByTitle('Create Domain') .should('be.visible') @@ -109,10 +110,7 @@ describe('Clone a Domain', () => { .click(); cy.findByText('Domain is not valid.').should('be.visible'); - cy.findByLabelText('New Domain') - .click() - .clear() - .type(clonedDomainName); + fbltClick('New Domain').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 9f9e832cc58..e3d8d513de1 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,6 +1,7 @@ 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, @@ -29,15 +30,13 @@ describe('Create a Domain', () => { interceptCreateDomain().as('createDomain'); cy.visitWithLogin('/domains'); cy.wait('@getDomains'); - cy.findByText('Create Domain').click(); + fbtClick('Create Domain'); const label = randomDomainName(); - 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(); + 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.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 c6bdd8b30ae..0e4710ec621 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,5 +1,6 @@ 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'; @@ -72,8 +73,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 4c5401df232..951516fecf3 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,6 +4,7 @@ import { domainZoneFileFactory, } from '@src/factories'; import { authenticate } from 'support/api/authentication'; +import { fbtClick, fbtVisible } from 'support/helpers'; import { mockGetDomains, mockGetDomain, @@ -45,8 +46,8 @@ describe('Download a Zone file', () => { mockGetDomain(mockDomain.id, mockDomain).as('getDomain'); mockGetDomainRecords([mockDomainRecords]).as('getDomainRecords'); - cy.findByText(mockDomain.domain).should('be.visible').should('be.visible'); - cy.findByText(mockDomain.domain).click(); + fbtVisible(mockDomain.domain); + fbtClick(mockDomain.domain); 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 17587c4f1ae..54a1cc8b439 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,6 +1,7 @@ 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'; @@ -45,7 +46,7 @@ describe('Import a Zone', () => { .should('be.disabled'); // Verify only filling out Domain cannot import - cy.findByLabelText('Domain').click().clear().type(zone.domain); + fbltClick('Domain').clear().type(zone.domain); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') @@ -54,11 +55,8 @@ describe('Import a Zone', () => { cy.findByText('Remote nameserver is required.'); // Verify invalid domain cannot import - cy.findByLabelText('Domain').click().clear().type('1'); - cy.findByLabelText('Remote Nameserver') - .click() - .clear() - .type(zone.remote_nameserver); + fbltClick('Domain').clear().type('1'); + fbltClick('Remote Nameserver').clear().type(zone.remote_nameserver); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') @@ -67,11 +65,8 @@ describe('Import a Zone', () => { cy.findByText('Domain is not valid.'); // Verify only filling out RemoteNameserver cannot import - cy.findByLabelText('Domain').click().clear(); - cy.findByLabelText('Remote Nameserver') - .click() - .clear() - .type(zone.remote_nameserver); + fbltClick('Domain').clear(); + fbltClick('Remote Nameserver').clear().type(zone.remote_nameserver); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') @@ -80,8 +75,8 @@ describe('Import a Zone', () => { cy.findByText('Domain is required.'); // Verify invalid remote nameserver cannot import - cy.findByLabelText('Domain').click().clear().type(zone.domain); - cy.findByLabelText('Remote Nameserver').click().clear().type('1'); + fbltClick('Domain').clear().type(zone.domain); + fbltClick('Remote Nameserver').clear().type('1'); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') @@ -92,11 +87,8 @@ describe('Import a Zone', () => { // Fill out and import the zone. mockImportDomain(mockDomain).as('importDomain'); mockGetDomains([mockDomain]).as('getDomains'); - cy.findByLabelText('Domain').click().clear().type(zone.domain); - cy.findByLabelText('Remote Nameserver') - .click() - .clear() - .type(zone.remote_nameserver); + fbltClick('Domain').clear().type(zone.domain); + fbltClick('Remote Nameserver').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 8f55e035d5b..7be2db7cd11 100644 --- a/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts @@ -3,6 +3,7 @@ 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(); @@ -34,8 +35,8 @@ describe('delete firewall', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText('Delete').should('be.visible'); - cy.findByText('Delete').click(); + fbtVisible('Delete'); + fbtClick('Delete'); }); // Cancel deletion when prompted to confirm. @@ -55,8 +56,8 @@ describe('delete firewall', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText('Delete').should('be.visible'); - cy.findByText('Delete').click(); + fbtVisible('Delete'); + fbtClick('Delete'); }); // 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 3aa89d01e85..a76d5ee8a09 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -14,11 +14,13 @@ 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'; @@ -82,13 +84,11 @@ const addFirewallRules = (rule: FirewallRuleType, direction: string) => { const description = rule.description ? rule.description : 'test-description'; - cy.contains('Label') - .click() - .type('{selectall}{backspace}' + label); - cy.contains('Description').click().type(description); + containsClick('Label').type('{selectall}{backspace}' + label); + containsClick('Description').type(description); const action = rule.action ? getRuleActionLabel(rule.action) : 'Accept'; - cy.contains(action).click(); + containsClick(action).click(); ui.button .findByTitle('Add Rule') @@ -346,8 +346,8 @@ describe('update firewall', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText('Disable').should('be.visible'); - cy.findByText('Disable').click(); + fbtVisible('Disable'); + fbtClick('Disable'); }); ui.dialog @@ -375,8 +375,8 @@ describe('update firewall', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText('Enable').should('be.visible'); - cy.findByText('Enable').click(); + fbtVisible('Enable'); + fbtClick('Enable'); }); 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 da4f61f4efb..d2799cb7419 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,3 +1,4 @@ +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'; @@ -48,8 +49,8 @@ const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionId(region.id).click(); - cy.findByText('Shared CPU').click(); - cy.get('[id="g6-nanode-1"][type="radio"]').click(); + fbtClick('Shared CPU'); + getClick('[id="g6-nanode-1"][type="radio"]'); cy.get('[id="root-password"]').type(randomString(32)); ui.button @@ -61,9 +62,9 @@ const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { cy.wait('@mockLinodeRequest'); - cy.findByText(mockLinode.label).should('be.visible'); - cy.findByText(region.label).should('be.visible'); - cy.findByText(`${mockLinode.id}`).should('be.visible'); + fbtVisible(mockLinode.label); + fbtVisible(region.label); + fbtVisible(`${mockLinode.id}`); }; 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 e44b08523a2..1f0e42c010a 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -58,7 +58,6 @@ import { latestKubernetesVersion, } from 'support/constants/lke'; import { lkeEnterpriseTypeFactory } from 'src/factories'; -import { pluralize } from 'src/utilities/pluralize'; const dedicatedNodeCount = 4; const nanodeNodeCount = 3; @@ -318,11 +317,6 @@ 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 @@ -1079,7 +1073,6 @@ 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(); @@ -1275,13 +1268,6 @@ 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 0f698c50e20..55a1ce53a76 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -18,10 +18,6 @@ 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'; /** @@ -37,6 +33,9 @@ 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(() => { @@ -70,9 +69,7 @@ describe('clone linode', () => { cy.visitWithLogin(`/linodes/${linode.id}`); // Wait for Linode to boot, then initiate clone flow. - cy.findByText('OFFLINE', { timeout: LINODE_CREATE_TIMEOUT }).should( - 'be.visible' - ); + cy.findByText('OFFLINE').should('be.visible'); ui.actionMenu .findByTitle(`Action menu for Linode ${linode.label}`) @@ -111,7 +108,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: LINODE_CLONE_TIMEOUT } + { timeout: 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 ba4b950c8ea..cf8707c1ec9 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/constants'; +} from 'src/components/Encryption/Encryption'; 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 467fa122445..37e5309715d 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -40,6 +40,13 @@ 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; @@ -367,27 +374,23 @@ describe('Create Linode', () => { // Verify VPCs get fetched once a region is selected cy.wait('@getVPCs'); - cy.findByText('Shared CPU').click(); - cy.get(`[id="${dcPricingMockLinodeTypes[0].id}"]`).click(); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); // the "VPC" section is present, and the VPC in the same region of // the linode can be selected. - 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}`); - }); + 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}`); + }); // The drawer opens when clicking "Add an SSH Key" button ui.button @@ -427,13 +430,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'); - cy.get('#linode-label').clear().type(linodeLabel).click(); + getClick('#linode-label').clear().type(linodeLabel); cy.get('#root-password').type(rootpass); ui.button.findByTitle('Create Linode').click(); cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - cy.findByText(linodeLabel).should('be.visible'); + fbtVisible(linodeLabel); 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 903c5932224..68ec28b9701 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -4,7 +4,6 @@ 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 { @@ -226,8 +225,7 @@ describe('Linode Config management', () => { .its('response.statusCode') .should('eq', 200); ui.toast.assertMessage( - `Configuration ${config.label} successfully updated`, - { timeout: LINODE_CLONE_TIMEOUT } + `Configuration ${config.label} successfully updated` ); // 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 30ecd433839..da168866efc 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-qa-error="true"]', + unavailable: '[data-testid="notice-error"]', }; authenticate(); @@ -409,57 +409,6 @@ 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 91d28a545ab..e1bbfeb6e7b 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,6 +3,12 @@ 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'; @@ -77,65 +83,37 @@ describe('linode landing checks', () => { }); it('checks the landing page side menu items', () => { - 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( + getVisible('[title="Akamai - Dashboard"][href="/dashboard"]'); + getVisible('[data-testid="menu-item-Linodes"][href="/linodes"]'); + getVisible('[data-testid="menu-item-Volumes"][href="/volumes"]'); + getVisible( '[data-testid="menu-item-NodeBalancers"][href="/nodebalancers"]' - ).should('be.visible'); - cy.get('[data-testid="menu-item-Firewalls"][href="/firewalls"]').should( - 'be.visible' ); - cy.get( - '[data-testid="menu-item-StackScripts"][href="/stackscripts"]' - ).should('be.visible'); - cy.get('[data-testid="menu-item-Images"][href="/images"]').should( - 'be.visible' - ); - cy.get('[data-testid="menu-item-Domains"][href="/domains"]').should( - 'be.visible' - ); - cy.get( + 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"]' - ).should('be.visible'); - cy.get( + ); + getVisible( '[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' ); - cy.get( + getVisible('[data-testid="menu-item-Longview"][href="/longview"]'); + getVisible( '[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; - 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' - ); + 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.findByLabelText('Help & Support') .should('be.visible') @@ -152,21 +130,17 @@ describe('linode landing checks', () => { .should('be.visible') .should('be.enabled'); - 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'); - }); + getVisible('[aria-label="Notifications"]'); + getVisible('[data-testid="nav-group-profile"]').within(() => { + fbtVisible(username); + }); }); }); it('checks the landing labels and buttons', () => { - 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'); + getVisible('h1[data-qa-header="Linodes"]'); + getVisible('a[aria-label="Docs - link opens in a new tab"]'); + fbtVisible('Create Linode'); }); it('checks label and region sorting behavior for linode table', () => { @@ -183,161 +157,113 @@ describe('linode landing checks', () => { ).label; const checkFirstRow = (label: string) => { - cy.get('tr[data-qa-loading="true"]') - .should('be.visible') + getVisible('tr[data-qa-loading="true"]') .first() .within(() => { - cy.contains(label).should('be.visible'); + containsVisible(label); }); }; const checkLastRow = (label: string) => { - cy.get('tr[data-qa-loading="true"]') - .should('be.visible') + getVisible('tr[data-qa-loading="true"]') .last() .within(() => { - cy.contains(label).should('be.visible'); + containsVisible(label); }); }; checkFirstRow(firstLinodeLabel); checkLastRow(lastLinodeLabel); - cy.get('[aria-label="Sort by label"]').click(); + getClick('[aria-label="Sort by label"]'); checkFirstRow(lastLinodeLabel); checkLastRow(firstLinodeLabel); - cy.get('[aria-label="Sort by region"]').click(); + getClick('[aria-label="Sort by region"]'); checkFirstRow(firstRegionLabel); checkLastRow(lastRegionLabel); - cy.get('[aria-label="Sort by region"]').click(); + getClick('[aria-label="Sort by region"]'); checkFirstRow(lastRegionLabel); checkLastRow(firstRegionLabel); }); it('checks the create menu dropdown items', () => { - cy.get('[data-qa-add-new-menu-button="true"]').click(); + getClick('[data-qa-add-new-menu-button="true"]'); - 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('[aria-labelledby="create-menu"]').within(() => { + getVisible('[href="/linodes/create"]').within(() => { + fbtVisible('Linode'); + fbtVisible('High performance SSD Linux servers'); }); - }); - it('checks the table and action menu buttons/labels', () => { - const label = linodeLabel(1); - const ip = mockLinodes[0].ipv4[0]; + getVisible('[href="/volumes/create"]').within(() => { + fbtVisible('Volume'); + fbtVisible('Attach additional storage to your Linode'); + }); - cy.get('[aria-label="Sort by label"]') - .should('be.visible') - .within(() => { - cy.findByText('Label').should('be.visible'); + getVisible('[href="/nodebalancers/create"]').within(() => { + fbtVisible('NodeBalancer'); + fbtVisible('Ensure your services are highly available'); }); - cy.get('[aria-label="Sort by _statusPriority"]') - .should('be.visible') - .within(() => { - cy.findByText('Status').should('be.visible'); + getVisible('[href="/firewalls/create"]').within(() => { + fbtVisible('Firewall'); + fbtVisible('Control network access to your Linodes'); }); - cy.get('[aria-label="Sort by type"]') - .should('be.visible') - .within(() => { - cy.findByText('Plan').should('be.visible'); + + getVisible('[href="/firewalls/create"]').within(() => { + fbtVisible('Firewall'); + fbtVisible('Control network access to your Linodes'); }); - cy.get('[aria-label="Sort by ipv4[0]"]') - .should('be.visible') - .within(() => { - cy.findByText('Public IP Address').should('be.visible'); + + getVisible('[href="/domains/create"]').within(() => { + fbtVisible('Domain'); + fbtVisible('Manage your DNS records'); }); - 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' - ); + getVisible('[href="/kubernetes/create"]').within(() => { + fbtVisible('Kubernetes'); + fbtVisible('Highly available container workloads'); + }); + + getVisible('[href="/object-storage/buckets/create"]').within(() => { + fbtVisible('Bucket'); + fbtVisible('S3-compatible object storage'); + }); + + getVisible('[href="/linodes/create?type=One-Click"]').within(() => { + fbtVisible('Marketplace'); + fbtVisible('Deploy applications with ease'); }); + }); + }); + + 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', () => { @@ -370,11 +296,10 @@ describe('linode landing checks', () => { cy.wait('@getLinodes'); // Check 'Group by Tag' button works as expected that can be visible, enabled and clickable - cy.get('[aria-label="Toggle group by tag"]') - .should('be.visible') + getVisible('[aria-label="Toggle group by tag"]') .should('be.enabled') .click(); - cy.get('[data-qa-tag-header="even"]').should('be.visible'); + getVisible('[data-qa-tag-header="even"]'); cy.get('[data-qa-tag-header="even"]').within(() => { mockLinodes.forEach((linode) => { if (linode.tags.includes('even')) { @@ -385,7 +310,7 @@ describe('linode landing checks', () => { }); }); - cy.get('[data-qa-tag-header="odd"]').should('be.visible'); + getVisible('[data-qa-tag-header="odd"]'); cy.get('[data-qa-tag-header="odd"]').within(() => { mockLinodes.forEach((linode) => { if (linode.tags.includes('odd')) { @@ -396,7 +321,7 @@ describe('linode landing checks', () => { }); }); - cy.get('[data-qa-tag-header="nums"]').should('be.visible'); + getVisible('[data-qa-tag-header="nums"]'); cy.get('[data-qa-tag-header="nums"]').within(() => { mockLinodes.forEach((linode) => { cy.findByText(linode.label).should('be.visible'); @@ -404,8 +329,7 @@ describe('linode landing checks', () => { }); // The linode landing table will resume when ungroup the tag. - cy.get('[aria-label="Toggle group by tag"]') - .should('be.visible') + getVisible('[aria-label="Toggle group by tag"]') .should('be.enabled') .click(); cy.get('[data-qa-tag-header="even"]').should('not.exist'); @@ -434,10 +358,7 @@ describe('linode landing checks', () => { cy.wait(['@getLinodes', '@getUserPreferences']); // Check 'Summary View' button works as expected that can be visiable, enabled and clickable - cy.get('[aria-label="Toggle display"]') - .should('be.visible') - .should('be.enabled') - .click(); + getVisible('[aria-label="Toggle display"]').should('be.enabled').click(); cy.wait('@updateUserPreferences'); mockLinodes.forEach((linode) => { @@ -457,10 +378,7 @@ describe('linode landing checks', () => { }); // Toggle the 'List View' button to check the display of table items are back to the original view. - cy.get('[aria-label="Toggle display"]') - .should('be.visible') - .should('be.enabled') - .click(); + getVisible('[aria-label="Toggle display"]').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 4767b1e6d25..aa4601cd7b1 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/notifications.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/notifications.spec.ts @@ -1,5 +1,6 @@ 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[] = [ @@ -22,8 +23,8 @@ describe('verify notification types and icons', () => { mockGetNotifications(notifications).as('mockNotifications'); cy.visitWithLogin('/linodes'); cy.wait('@mockNotifications'); - cy.get('button[aria-label="Notifications"]').click(); - cy.get('[data-test-id="showMoreButton"').click(); + getClick('button[aria-label="Notifications"]'); + getClick('[data-test-id="showMoreButton"'); 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 05eb396a5d3..8109717608d 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,17 +2,31 @@ * @file Smoke tests for crucial Object Storage Access Keys operations. */ -import { objectStorageKeyFactory } from 'src/factories/objectStorage'; +import { + objectStorageKeyFactory, + objectStorageBucketFactory, +} from 'src/factories/objectStorage'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateAccessKey, mockDeleteAccessKey, mockGetAccessKeys, + mockGetBucketsForRegion, + mockUpdateAccessKey, } from 'support/intercepts/object-storage'; -import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { + randomDomainName, + randomLabel, + randomNumber, + randomString, +} from 'support/util/random'; import { ui } from 'support/ui'; -import { accountFactory } from 'src/factories'; +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 { mockGetAccount } from 'support/intercepts/account'; +import { extendRegion } from 'support/util/regions'; describe('object storage access keys smoke tests', () => { /* @@ -133,4 +147,386 @@ 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/objectStorageMulticluster/bucket-details-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/bucket-details.spec.ts similarity index 75% rename from packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts rename to packages/manager/cypress/e2e/core/objectStorage/bucket-details.spec.ts index a5cc6e7158e..fc2e75c90e7 100644 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/bucket-details.spec.ts @@ -7,10 +7,8 @@ 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 Multicluster Bucket Details Tabs', () => { +describe('Object Storage Gen 1 Bucket Details Tabs', () => { beforeEach(() => { mockAppendFeatureFlags({ objMultiCluster: true, @@ -33,17 +31,11 @@ describe('Object Storage Multicluster 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 { label } = mockBucket; - - mockGetBucket(label, mockRegion.id); - mockGetRegions([mockRegion]); + const { region, label } = mockBucket; cy.visitWithLogin( - `/object-storage/buckets/${mockRegion.id}/${label}/properties` + `/object-storage/buckets/${region}/${label}/properties` ); cy.wait(['@getFeatureFlags', '@getAccount']); @@ -55,6 +47,8 @@ describe('Object Storage Multicluster 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/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index 1fa96cb91c1..f9c431f5a30 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,10 +2,12 @@ * @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 { @@ -16,6 +18,7 @@ import { interceptCreateBucket, interceptDeleteBucket, interceptGetBuckets, + interceptUploadBucketObjectS3, interceptGetBucketAccess, interceptUpdateBucketAccess, } from 'support/intercepts/object-storage'; @@ -24,6 +27,26 @@ 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. * @@ -55,6 +78,82 @@ 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'); @@ -146,10 +245,216 @@ 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(); @@ -188,7 +493,6 @@ 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 479bd129fbb..ec96b743c0b 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,6 +4,7 @@ import 'cypress-file-upload'; import { objectStorageBucketFactory } from 'src/factories/objectStorage'; +import { mockGetRegions } from 'support/intercepts/regions'; import { mockCreateBucket, mockDeleteBucket, @@ -13,14 +14,136 @@ import { mockGetBucketObjects, mockUploadBucketObject, mockUploadBucketObjectS3, + mockCreateBucketError, } from 'support/intercepts/object-storage'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { randomLabel } from 'support/util/random'; +import { randomLabel, randomString } from 'support/util/random'; import { ui } from 'support/ui'; -import { accountFactory } from 'src/factories'; +import { accountFactory, regionFactory } 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. @@ -166,7 +289,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', () => { + it('can delete object storage bucket - smoke - Multi Cluster Disabled', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; const bucketMock = objectStorageBucketFactory.build({ @@ -210,4 +333,58 @@ 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 deleted file mode 100644 index 56e1e24d44f..00000000000 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts +++ /dev/null @@ -1,406 +0,0 @@ -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 deleted file mode 100644 index cccbd8542cd..00000000000 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -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 deleted file mode 100644 index d810cab82ab..00000000000 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-delete-multicluster.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -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/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts deleted file mode 100644 index 1138f4f99dc..00000000000 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts +++ /dev/null @@ -1,327 +0,0 @@ -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 1ee95a8652c..f82c9d4650c 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,7 +16,6 @@ import { LinodeConfigInterfaceFactoryWithVPC, subnetFactory, vpcFactory, - LinodeConfigInterfaceFactory, } from '@src/factories'; import { randomLabel, randomNumber, randomPhrase } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -334,63 +333,6 @@ 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) */ @@ -423,12 +365,6 @@ 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, @@ -438,7 +374,7 @@ describe('VPC details page', () => { }); const mockLinodeConfig = linodeConfigFactory.build({ - interfaces: [mockInterface, mockPrimaryInterface], + interfaces: [mockInterface], }); mockGetVPC(mockVPC).as('getVPC'); diff --git a/packages/manager/cypress/support/constants/linodes.ts b/packages/manager/cypress/support/constants/linodes.ts index c09601fe5de..35d6762f810 100644 --- a/packages/manager/cypress/support/constants/linodes.ts +++ b/packages/manager/cypress/support/constants/linodes.ts @@ -8,10 +8,3 @@ * 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 new file mode 100644 index 00000000000..4cbccea82dd --- /dev/null +++ b/packages/manager/cypress/support/helpers.ts @@ -0,0 +1,84 @@ +/* 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 deleted file mode 100644 index 313a9215ecb..00000000000 --- a/packages/manager/cypress/support/plugins/reset-user-preferences.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 2bf14da000b..bedcfa42811 100644 --- a/packages/manager/cypress/tsconfig.json +++ b/packages/manager/cypress/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "moduleResolution": "node", "baseUrl": "..", "paths": { "src/*": ["./src/*"], diff --git a/packages/manager/package.json b/packages/manager/package.json index 430b6b8ee03..b84877cb298 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.135.0", + "version": "1.134.0", "private": true, "type": "module", "bugs": { @@ -119,20 +119,20 @@ }, "devDependencies": { "@linode/eslint-plugin-cloud-manager": "^0.0.5", - "@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", + "@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", "@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.4.7", + "storybook": "^8.3.0", "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 8a8628951f4..dcdae2ec607 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -159,6 +159,7 @@ 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) => ({ @@ -338,6 +339,7 @@ export const MainContent = () => { path="/nodebalancers" /> + - - - - - - - - - diff --git a/packages/manager/src/assets/icons/zoomin.svg b/packages/manager/src/assets/icons/zoomin.svg index 86d7a2a4f4c..fcb722675ef 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 fcb722675ef..7021f3a5f61 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 0888381c189..87655164181 100644 --- a/packages/manager/src/assets/timezones/timezones.ts +++ b/packages/manager/src/assets/timezones/timezones.ts @@ -1290,11 +1290,6 @@ 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 2c49ff413eb..43937633388 100644 --- a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx +++ b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -21,20 +21,17 @@ 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 = React.forwardRef< - HTMLDivElement, - ConfirmationDialogProps ->((props, ref) => { +export const ConfirmationDialog = (props: ConfirmationDialogProps) => { const { actions, children, ...dialogProps } = props; return ( - + {children} {actions && typeof actions === 'function' ? actions(dialogProps) @@ -42,4 +39,4 @@ export const ConfirmationDialog = React.forwardRef< ); -}); +}; diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx index 1cee4f53b99..86c66ee834a 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -21,15 +21,12 @@ 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 */ @@ -64,11 +61,9 @@ 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, @@ -198,7 +193,6 @@ export const DateTimePicker = ({ > ({ @@ -278,7 +266,6 @@ 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: '', + defaultValue: { label: '', value: '' }, + enablePresets: true, label: '', placeholder: '', }, @@ -40,17 +40,16 @@ 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: '7days', + defaultValue: { label: 'Last 7 Days', value: '7days' }, + enablePresets: true, label: 'Time Range', placeholder: 'Select Range', }, @@ -66,8 +65,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, @@ -76,8 +75,8 @@ export const WithCustomErrors: Story = { format: 'yyyy-MM-dd HH:mm', onChange: action('DateTime range changed'), presetsProps: { - defaultValue: '', - + defaultValue: { label: '', value: '' }, + enablePresets: true, label: '', placeholder: '', }, diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx index 1011c755345..3dc542f4c36 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 disable the end date-time which is before the selected start date-time', async () => { + it('should show error when end date-time is before start date-time', async () => { renderWithTheme(); // Set start date-time to the 15th @@ -87,14 +87,20 @@ describe('DateTimeRangePicker Component', () => { const endDateField = screen.getByLabelText('End Date and Time'); await userEvent.click(endDateField); - expect(screen.getByRole('gridcell', { name: '10' })).toBeDisabled(); + // 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(); }); it('should show error when start date-time is after end date-time', async () => { const updateProps = { ...Props, - enablePresets: false, - presetsProps: { ...Props.presetsProps }, + presetsProps: { ...Props.presetsProps, enablePresets: false }, }; renderWithTheme(); @@ -119,7 +125,6 @@ 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', @@ -318,9 +323,24 @@ describe('DateTimeRangePicker Component', () => { await userEvent.click(endDateField); // Set start date-time to the 12th - await userEvent.click(screen.getByRole('gridcell', { name: '17' })); + await userEvent.click(screen.getByRole('gridcell', { name: '12' })); 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 a83aab1206e..7083ba7bee8 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx @@ -9,14 +9,10 @@ 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 */ @@ -44,7 +40,9 @@ export interface DateTimeRangePickerProps { /** Additional settings for the presets dropdown */ presetsProps?: { /** Default value for the presets field */ - defaultValue?: string; + defaultValue?: { label: string; value: string }; + /** If true, shows the date presets field instead of the date pickers */ + enablePresets?: boolean; /** Label for the presets field */ label?: string; /** placeholder for the presets field */ @@ -73,17 +71,13 @@ 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' }, @@ -94,20 +88,21 @@ 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 = presetsOptions[0].value, + defaultValue: presetsDefaultValue = { label: '', value: '' }, + enablePresets = false, label: presetsLabel = 'Time Range', placeholder: presetsPlaceholder = 'Select a preset', } = {}, @@ -128,25 +123,17 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { const [endDateTime, setEndDateTime] = useState( endDateTimeValue ); - const [presetValue, setPresetValue] = useState< - | { - label: string; - value: string; - } - | undefined - >( - presetsOptions.find((option) => option.value === presetsDefaultValue) ?? - presetsOptions[0] - ); + const [presetValue, setPresetValue] = useState<{ + label: string; + value: string; + }>(presetsDefaultValue); const [startTimeZone, setStartTimeZone] = useState( startTimeZoneValue ); const [startDateError, setStartDateError] = useState(null); - const [showPresets, setShowPresets] = useState( - presetsDefaultValue - ? presetsDefaultValue !== 'custom_range' && enablePresets - : enablePresets - ); + const [endDateError, setEndDateError] = useState(null); + const [showPresets, setShowPresets] = useState(enablePresets); + const theme = useTheme(); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); @@ -155,34 +142,38 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { end: DateTime | null, source: 'end' | 'start' ) => { - if (start && end && source === 'start' && start > end) { - setStartDateError(startDateErrorMessage); - return; + if (start && end) { + if (source === 'start' && start > end) { + setStartDateError(startDateErrorMessage); + return; + } + if (source === 'end' && end < start) { + setEndDateError(endDateErrorMessage); + 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 = now; + let newEndDateTime: DateTime | null = null; 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'); @@ -205,7 +196,7 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { setEndDateTime(newEndDateTime); setPresetValue( presetsOptions.find((option) => option.value === value) ?? - presetsOptions[0] + presetsDefaultValue ); if (onChange) { @@ -257,8 +248,7 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { handlePresetSelection(selection.value as DatePresetType); } }} - data-qa-preset="preset-select" - data-testid="preset-select" + defaultValue={presetsDefaultValue} disableClearable fullWidth label={presetsLabel} @@ -279,7 +269,6 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { onChange: (value) => setStartTimeZone(value), value: startTimeZone, }} - disabledTimeZone={disabledTimeZone} errorText={startDateError ?? undefined} format={format} label={startLabel} @@ -293,22 +282,24 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { timeZoneSelectProps={{ value: startTimeZone, }} - disabledTimeZone={disabledTimeZone} + errorText={endDateError ?? undefined} format={format} label={endLabel} - minDate={startDateTime || undefined} onChange={handleEndDateTimeChange} placeholder={endDatePlaceholder} showTimeZone={showEndTimeZone} timeSelectProps={{ label: 'End Time' }} value={endDateTime} /> - + { setShowPresets(true); - setPresetValue(undefined); - setStartDateError(null); + setPresetValue(presetsDefaultValue); }} variant="text" > diff --git a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.styles.ts b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.styles.ts new file mode 100644 index 00000000000..6ec0bfda539 --- /dev/null +++ b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.styles.ts @@ -0,0 +1,28 @@ +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 14c5ed1017e..4ee35065ea8 100644 --- a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx +++ b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx @@ -1,9 +1,12 @@ -import { IconButton, Notice, Stack } from '@linode/ui'; +import { Box } 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'; @@ -39,7 +42,14 @@ 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, options, preferenceKey, ...rest } = props; + const { + actionButton, + children, + className, + options, + preferenceKey, + ...rest + } = props; const { handleDismiss, hasDismissedBanner } = useDismissibleBanner( preferenceKey, @@ -51,30 +61,32 @@ export const DismissibleBanner = (props: Props) => { } const dismissibleButton = ( - - - + + + + + ); return ( - theme.palette.background.paper} - display="flex" - gap={1} - justifyContent="space-between" - {...rest} - > - {children} - - {actionButton} - {dismissibleButton} - - + + + {children} + + {actionButton} + {dismissibleButton} + + + ); }; diff --git a/packages/manager/src/components/Encryption/Encryption.test.tsx b/packages/manager/src/components/Encryption/Encryption.test.tsx index 3b65e7dba5d..1a4750c0846 100644 --- a/packages/manager/src/components/Encryption/Encryption.test.tsx +++ b/packages/manager/src/components/Encryption/Encryption.test.tsx @@ -2,8 +2,12 @@ import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { checkboxTestId, descriptionTestId, headerTestId } from './constants'; -import { Encryption } from './Encryption'; +import { + Encryption, + checkboxTestId, + descriptionTestId, + headerTestId, +} 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 1b90722ce39..60fb435cc07 100644 --- a/packages/manager/src/components/Encryption/Encryption.tsx +++ b/packages/manager/src/components/Encryption/Encryption.tsx @@ -2,8 +2,6 @@ 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; @@ -15,6 +13,10 @@ 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 0cb07201c56..7224e491364 100644 --- a/packages/manager/src/components/Encryption/constants.tsx +++ b/packages/manager/src/components/Encryption/constants.tsx @@ -2,20 +2,15 @@ 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 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.{' '} + 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.{' '} Learn more. ); diff --git a/packages/manager/src/components/EnhancedSelect/components/DropdownIndicator.tsx b/packages/manager/src/components/EnhancedSelect/components/DropdownIndicator.tsx index 8d5c3b48d22..d6bf5f480a3 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)(({ theme }) => ({ - color: `${theme.tokens.color.Neutrals[50]} !important`, +const StyledKeyboardArrowDown = styled(KeyboardArrowDown)(() => ({ + color: '#aaa !important', height: 28, marginRight: '4px', marginTop: 0, diff --git a/packages/manager/src/components/OrderBy.tsx b/packages/manager/src/components/OrderBy.tsx index 82b5a2a9801..9ffcba5454a 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 } from 'ramda'; +import { equals, pathOr, sort, splitAt } from 'ramda'; import * as React from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; @@ -16,7 +16,6 @@ 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 5608aa6bd4e..b2561a58866 100644 --- a/packages/manager/src/components/PasswordInput/HideShowText.stories.tsx +++ b/packages/manager/src/components/PasswordInput/HideShowText.stories.tsx @@ -1,10 +1,9 @@ /* 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 12ec8a4e7be..67a76b58e8e 100644 --- a/packages/manager/src/components/PasswordInput/PasswordInput.stories.tsx +++ b/packages/manager/src/components/PasswordInput/PasswordInput.stories.tsx @@ -1,10 +1,9 @@ /* 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 bd41bb63e86..227b36af4e1 100644 --- a/packages/manager/src/components/PasswordInput/StrengthIndicator.stories.tsx +++ b/packages/manager/src/components/PasswordInput/StrengthIndicator.stories.tsx @@ -1,9 +1,8 @@ +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 deleted file mode 100644 index 89701c8b0ce..00000000000 --- a/packages/manager/src/components/Tabs/TanStackTabLinkList.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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 c7cbc70a326..970e750d9e5 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 24c89059e8d..8d0ac7bb2a5 100644 --- a/packages/manager/src/components/Uploaders/FileUpload.styles.ts +++ b/packages/manager/src/components/Uploaders/FileUpload.styles.ts @@ -73,9 +73,7 @@ export const StyledActionsContainer = styled('div', { export const useStyles = makeStyles()((theme: Theme) => ({ barColorPrimary: { backgroundColor: - theme.name === 'light' - ? theme.tokens.color.Brand[30] - : theme.tokens.color.Brand[100], + theme.name === 'light' ? theme.tokens.color.Brand[30] : '#243142', }, error: { '& g': { diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx index 6c70707cd20..9386d2150fa 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: theme.tokens.color.Neutrals[60], + borderColor: 'gray', 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 78571ad5c34..3bd3102b849 100644 --- a/packages/manager/src/components/Uploaders/reducer.test.ts +++ b/packages/manager/src/components/Uploaders/reducer.test.ts @@ -15,7 +15,6 @@ describe('reducer', () => { const file1: File = { arrayBuffer: vi.fn(), - bytes: async () => new Uint8Array(), lastModified: 0, name: 'my-file1', size: 0, @@ -25,10 +24,8 @@ 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 d1cf640975b..a11cf2ab320 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -26,7 +26,6 @@ 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 e9bb03940e5..df33e999ac8 100644 --- a/packages/manager/src/factories/accountResources.ts +++ b/packages/manager/src/factories/accountResources.ts @@ -1,102 +1,29 @@ +import { IamAccountResource } from '@linode/api-v4'; import Factory from 'src/factories/factoryProxy'; -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', - }, - ], - }, -]); +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, + }, + ], + }, + ] +); diff --git a/packages/manager/src/factories/cloudpulse/alerts.ts b/packages/manager/src/factories/cloudpulse/alerts.ts index 58669164b9e..5a47ea798ae 100644 --- a/packages/manager/src/factories/cloudpulse/alerts.ts +++ b/packages/manager/src/factories/cloudpulse/alerts.ts @@ -1,32 +1,7 @@ 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(), @@ -45,9 +20,9 @@ export const alertFactory = Factory.Sync.makeFactory({ tags: ['tag1', 'tag2'], trigger_conditions: { criteria_condition: 'ALL', - evaluation_period_seconds: 240, - polling_interval_seconds: 120, - trigger_occurrences: 3, + evaluation_period_seconds: 0, + polling_interval_seconds: 0, + trigger_occurrences: 0, }, type: 'user', updated: new Date().toISOString(), diff --git a/packages/manager/src/factories/cloudpulse/channels.ts b/packages/manager/src/factories/cloudpulse/channels.ts deleted file mode 100644 index d7560717414..00000000000 --- a/packages/manager/src/factories/cloudpulse/channels.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 c02bf807d92..275b05f0506 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 7811496bc26..230144fbb9a 100644 --- a/packages/manager/src/factories/index.ts +++ b/packages/manager/src/factories/index.ts @@ -54,7 +54,6 @@ 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 deleted file mode 100644 index 739c62ccc36..00000000000 --- a/packages/manager/src/factories/quotas.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 97e5dae32d2..fb0a9ae3674 100644 --- a/packages/manager/src/factories/types.ts +++ b/packages/manager/src/factories/types.ts @@ -1,6 +1,7 @@ import Factory from 'src/factories/factoryProxy'; -import type { LinodeType, PriceType } from '@linode/api-v4'; +import type { LinodeType } from '@linode/api-v4/lib/linodes/types'; +import type { PriceType } from '@linode/api-v4/src/types'; import type { PlanSelectionAvailabilityTypes, PlanWithAvailability, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 1cc71554ccf..38d5300b4cc 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -121,7 +121,6 @@ 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 new file mode 100644 index 00000000000..d01ba8ff10c --- /dev/null +++ b/packages/manager/src/features/Backups/BackupsCTA.styles.ts @@ -0,0 +1,13 @@ +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 5aa84cc5aa8..dd7d36153e9 100644 --- a/packages/manager/src/features/Backups/BackupsCTA.tsx +++ b/packages/manager/src/features/Backups/BackupsCTA.tsx @@ -1,8 +1,7 @@ -import { IconButton, Notice, Typography } from '@linode/ui'; +import { Box, StyledLinkButton, Typography } from '@linode/ui'; import Close from '@mui/icons-material/Close'; -import React from 'react'; +import * as React from 'react'; -import { LinkButton } from 'src/components/LinkButton'; import { useAccountSettings } from 'src/queries/account/settings'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { @@ -12,6 +11,7 @@ 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 { mutate: updatePreferences } = useMutatePreferences(); + const { mutateAsync: updatePreferences } = useMutatePreferences(); const [isBackupsDrawerOpen, setIsBackupsDrawerOpen] = React.useState(false); @@ -44,31 +44,26 @@ export const BackupsCTA = () => { } return ( - theme.palette.background.paper} - display="flex" - flexDirection="row" - justifyContent="space-between" - spacingBottom={8} - variant="info" - > - - setIsBackupsDrawerOpen(true)}> + + + 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 27fa75df515..e053f94493d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx @@ -1,11 +1,6 @@ import React from 'react'; -import { - alertFactory, - linodeFactory, - regionFactory, - serviceTypesFactory, -} from 'src/factories/'; +import { alertFactory, serviceTypesFactory } from 'src/factories/'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AlertDetail } from './AlertDetail'; @@ -13,15 +8,10 @@ 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', () => ({ @@ -36,16 +26,6 @@ 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({ @@ -57,16 +37,6 @@ 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', () => { @@ -111,8 +81,6 @@ 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 3b81a48bdf2..cd6c3a3aaa7 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, Chip, CircleProgress, Typography } from '@linode/ui'; +import { Box, CircleProgress } from '@linode/ui'; import { styled, useTheme } from '@mui/material'; import React from 'react'; import { useParams } from 'react-router-dom'; @@ -9,9 +9,7 @@ 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 { @@ -50,14 +48,12 @@ export const AlertDetail = () => { }, [alertId, serviceType]); const theme = useTheme(); - const nonSuccessBoxHeight = '600px'; - const sectionMaxHeight = '785px'; if (isFetching) { return ( <> - + @@ -68,7 +64,7 @@ export const AlertDetail = () => { return ( <> - + @@ -79,7 +75,7 @@ export const AlertDetail = () => { return ( <> - + { ); } - const { entity_ids: entityIds } = alertDetails; + // TODO: The criteria, resources details for alerts will be added by consuming the results of useAlertDefinitionQuery call in the coming PR's return ( <> @@ -97,32 +93,11 @@ export const AlertDetail = () => { - - - - - - @@ -139,25 +114,3 @@ 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 deleted file mode 100644 index 49ea7b6e017..00000000000 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 73af8b4e528..00000000000 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx +++ /dev/null @@ -1,81 +0,0 @@ -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 02d6c63af0b..6e7683fdb8d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx @@ -1,10 +1,9 @@ +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 { @@ -47,9 +46,13 @@ export const AlertDetailRow = React.memo((props: AlertDetailRowProps) => { return ( - + {label}: - + {status && ( @@ -60,7 +63,12 @@ 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 deleted file mode 100644 index c3fff1256ed..00000000000 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/DisplayAlertDetailChips.tsx +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 0aac5132726..00000000000 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/RenderAlertsMetricsAndDimensions.tsx +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 34651adcc73..00000000000 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 18058cf0595..00000000000 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 684c804eff9..00000000000 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index 4ca342bbda8..00000000000 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx +++ /dev/null @@ -1,123 +0,0 @@ -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 deleted file mode 100644 index f8f3bee12e0..00000000000 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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 be961d7b308..8df01ae47cc 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 { Box, Button, Paper, TextField, Typography } from '@linode/ui'; +import { 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,8 +7,6 @@ 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'; @@ -18,7 +16,6 @@ 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'; @@ -81,34 +78,18 @@ export const CreateAlertDefinition = () => { ), }); - const { - control, - formState, - getValues, - handleSubmit, - setError, - setValue, - } = formMethods; + const { control, formState, getValues, handleSubmit, setError } = formMethods; const { enqueueSnackbar } = useSnackbar(); const { mutateAsync: createAlert } = useCreateAlertDefinition( getValues('serviceType')! ); - const notificationChannelWatcher = useWatch({ control, name: 'channel_ids' }); - const serviceTypeWatcher = useWatch({ control, name: 'serviceType' }); - - const [openAddNotification, setOpenAddNotification] = React.useState(false); + /** + * The maxScrapeInterval variable will be required for the Trigger Conditions part of the Critieria section. + */ const [maxScrapeInterval, setMaxScrapeInterval] = React.useState(0); - const onSubmitAddNotification = (notificationId: number) => { - setValue('channel_ids', [...notificationChannelWatcher, notificationId], { - shouldDirty: false, - shouldTouch: false, - shouldValidate: false, - }); - setOpenAddNotification(false); - }; - + const serviceTypeWatcher = useWatch({ control, name: 'serviceType' }); const onSubmit = handleSubmit(async (values) => { try { await createAlert(filterFormValues(values)); @@ -130,13 +111,6 @@ export const CreateAlertDefinition = () => { } }); - const onExitNotifications = () => { - setOpenAddNotification(false); - }; - - const onAddNotifications = () => { - setOpenAddNotification(true); - }; return ( @@ -198,15 +172,6 @@ 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 deleted file mode 100644 index 4544e3195eb..00000000000 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -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 deleted file mode 100644 index 12238c3c375..00000000000 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.tsx +++ /dev/null @@ -1,181 +0,0 @@ -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 77e667d3237..8b9301c3ebf 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts @@ -1,8 +1,6 @@ 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(), @@ -16,8 +14,3 @@ 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 a25582af56d..90671fce719 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts @@ -1,7 +1,6 @@ import type { AlertServiceType, AlertSeverityType, - ChannelType, CreateAlertDefinitionPayload, DimensionFilter, DimensionFilterOperatorType, @@ -53,8 +52,3 @@ 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 deleted file mode 100644 index 905094b1b36..00000000000 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index d3c2b9bc0e4..00000000000 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 7fcdd3f5873..549b35e9a98 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 { convertSecondsToMinutes, getServiceTypeLabel } from './utils'; +import { getServiceTypeLabel } from './utils'; it('test getServiceTypeLabel method', () => { const services = serviceTypesFactory.buildList(3); @@ -13,11 +13,3 @@ 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 00b734f4447..4e1ffe31d26 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -1,26 +1,6 @@ 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 @@ -49,41 +29,3 @@ 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 8d8ff5bd25d..f6397392284 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -1,8 +1,7 @@ import type { AlertSeverityType, - AlertStatusType, - ChannelType, DimensionFilterOperatorType, + AlertStatusType, MetricAggregationType, MetricOperatorType, } from '@linode/api-v4'; @@ -117,7 +116,7 @@ export const PollingIntervalOptions = { { label: '10 min', value: 600 }, ], }; - + export const severityMap: Record = { 0: 'Severe', 1: 'Medium', @@ -129,41 +128,3 @@ 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 4aa133e65cd..5cb487cc9b8 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 { - entity_ids: resources?.map((resource) => Number(resource)) ?? [], + resource_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 f565b7ae272..6057a3dc770 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; /** - * entity ids selected by user + * resource ids selected by user */ - entityIds: string[]; + resourceIds: string[]; /** * list of CloudPulse resources available @@ -286,16 +286,16 @@ export const generateMaxUnit = ( export const getCloudPulseMetricRequest = ( props: MetricRequestProps ): CloudPulseMetricsRequest => { - const { duration, entityIds, resources, widget } = props; + const { duration, resourceIds, 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 476ad9997ac..e5d3a1fae74 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-out')).toBeInTheDocument(); + expect(getByTestId('zoom-in')).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-out'); + const zoomButton = getByTestId('zoom-in'); 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 e71e19e68f1..e8d6142276b 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -63,11 +63,6 @@ export interface CloudPulseWidgetProperties { */ duration: TimeDuration; - /** - * entity ids selected by user to show metrics for - */ - entityIds: string[]; - /** * Any error to be shown in this widget */ @@ -78,6 +73,11 @@ 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, - entityIds, + resourceIds, resources, widget, }), diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx index 167d6aa80b8..df9c19f4124 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 06e31143700..40ab57a50f3 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: true, + zoomIn: false, }; const { getByTestId } = renderWithTheme(); expect(getByTestId('zoom-out')).toBeInTheDocument(); - expect(getByTestId('Minimize')).toBeInTheDocument(); // test id for tooltip + expect(getByTestId('Maximize')).toBeInTheDocument(); // test id for tooltip }), it('Should render zoomer with zoom-in button', () => { const props: ZoomIconProperties = { handleZoomToggle: vi.fn(), - zoomIn: false, + zoomIn: true, }; const { getByTestId } = renderWithTheme(); expect(getByTestId('zoom-in')).toBeInTheDocument(); - expect(getByTestId('Maximize')).toBeInTheDocument(); // test id for tooltip + expect(getByTestId('Minimize')).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 85666e556c2..e54155352ff 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 Out" - data-testid="zoom-out" + aria-label="Zoom In" + data-testid="zoom-in" onClick={() => handleClick(false)} > - + ); @@ -47,11 +47,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(true)} > - + ); diff --git a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx index b8d47ad83a5..f75c545105d 100644 --- a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx +++ b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx @@ -13,6 +13,7 @@ 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'; @@ -200,8 +201,8 @@ export const CreateDomain = () => { return generateDefaultDomainRecords( domainData.domain, domainData.id, - selectedDefaultLinode?.ipv4?.[0], - selectedDefaultLinode?.ipv6 + path(['ipv4', 0], selectedDefaultLinode), + path(['ipv6'], selectedDefaultLinode) ) .then(() => { return redirectToLandingOrDetail(type, domainData.id); @@ -211,8 +212,8 @@ export const CreateDomain = () => { `Default DNS Records couldn't be created from Linode: ${e[0].reason}`, { domainID: domainData.id, - ipv4: selectedDefaultLinode?.ipv4?.[0], - ipv6: selectedDefaultLinode?.ipv6, + ipv4: path(['ipv4', 0], selectedDefaultLinode), + ipv6: path(['ipv6'], selectedDefaultLinode), selectedLinode: selectedDefaultLinode!.id, } ); @@ -227,8 +228,8 @@ export const CreateDomain = () => { return generateDefaultDomainRecords( domainData.domain, domainData.id, - selectedDefaultNodeBalancer?.ipv4, - selectedDefaultNodeBalancer?.ipv6 + path(['ipv4'], selectedDefaultNodeBalancer), + path(['ipv6'], selectedDefaultNodeBalancer) ) .then(() => { return redirectToLandingOrDetail(type, domainData.id); @@ -238,8 +239,8 @@ export const CreateDomain = () => { `Default DNS Records couldn't be created from NodeBalancer: ${e[0].reason}`, { domainID: domainData.id, - ipv4: selectedDefaultNodeBalancer?.ipv4, - ipv6: selectedDefaultNodeBalancer?.ipv6, + ipv4: path(['ipv4'], selectedDefaultNodeBalancer), + ipv6: path(['ipv6'], selectedDefaultNodeBalancer), selectedNodeBalancer: selectedDefaultNodeBalancer!.id, } ); @@ -278,9 +279,10 @@ export const CreateDomain = () => { }; const updatePrimaryIPAddress = (newIPs: ExtendedIP[]) => { - const masterIps = newIPs.length > 0 ? newIPs.map(extendedIPToString) : ['']; + const master_ips = + newIPs.length > 0 ? newIPs.map(extendedIPToString) : ['']; if (mounted) { - formik.setFieldValue('master_ips', masterIps); + formik.setFieldValue('master_ips', master_ips); } }; diff --git a/packages/manager/src/features/Domains/DomainActionMenu.tsx b/packages/manager/src/features/Domains/DomainActionMenu.tsx index 4ad64e42d90..6347df03205 100644 --- a/packages/manager/src/features/Domains/DomainActionMenu.tsx +++ b/packages/manager/src/features/Domains/DomainActionMenu.tsx @@ -1,15 +1,12 @@ -import { useTheme } from '@mui/material/styles'; +import { Domain } from '@linode/api-v4/lib/domains'; +import { Theme, 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 { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { Action, 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 c04480b163a..6d59718b8ae 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/DomainDetail/DomainRecords/DomainRecordTable.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordTable.tsx deleted file mode 100644 index ebb575ff192..00000000000 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordTable.tsx +++ /dev/null @@ -1,88 +0,0 @@ -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/DomainDetail/DomainRecords/DomainRecords.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx deleted file mode 100644 index eb5e4b9d48f..00000000000 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx +++ /dev/null @@ -1,343 +0,0 @@ -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 deleted file mode 100644 index b5baa8a5ed1..00000000000 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordsUtils.ts +++ /dev/null @@ -1,129 +0,0 @@ -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 deleted file mode 100644 index 29c23c111b3..00000000000 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateTypes.tsx +++ /dev/null @@ -1,464 +0,0 @@ -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/DomainDetail/DomainRecords/DomainRecordActionMenu.tsx b/packages/manager/src/features/Domains/DomainRecordActionMenu.tsx similarity index 84% rename from packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordActionMenu.tsx rename to packages/manager/src/features/Domains/DomainRecordActionMenu.tsx index 1c1b4014d32..f49d8ccf3e0 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordActionMenu.tsx +++ b/packages/manager/src/features/Domains/DomainRecordActionMenu.tsx @@ -1,9 +1,8 @@ +import { Domain } from '@linode/api-v4/lib/domains'; +import { has } from 'ramda'; import * as React from 'react'; -import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; - -import type { Domain } from '@linode/api-v4/lib/domains'; -import type { Action } from 'src/components/ActionMenu/ActionMenu'; +import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; interface EditPayload { id?: number; @@ -48,7 +47,7 @@ export const DomainRecordActionMenu = (props: DomainRecordActionMenuProps) => { }, title: 'Edit', }, - Boolean(props.deleteData) + has('deleteData', props) ? { onClick: () => { handleDelete(); diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.test.tsx b/packages/manager/src/features/Domains/DomainRecordDrawer.test.tsx similarity index 100% rename from packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.test.tsx rename to packages/manager/src/features/Domains/DomainRecordDrawer.test.tsx diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx b/packages/manager/src/features/Domains/DomainRecordDrawer.tsx similarity index 99% rename from packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx rename to packages/manager/src/features/Domains/DomainRecordDrawer.tsx index 8ed31e146ae..8122a08cc26 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx +++ b/packages/manager/src/features/Domains/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/DomainRecords.styles.ts b/packages/manager/src/features/Domains/DomainRecords.styles.ts similarity index 98% rename from packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts rename to packages/manager/src/features/Domains/DomainRecords.styles.ts index 3ee8534e082..93f252e1f65 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts +++ b/packages/manager/src/features/Domains/DomainRecords.styles.ts @@ -1,10 +1,12 @@ 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, @@ -14,33 +16,30 @@ 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: 'StyledTableCell' })( +export const StyledTableCell = styled(TableCell, { label: 'StyledTabelCell' })( ({ 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/DomainRecords.tsx b/packages/manager/src/features/Domains/DomainRecords.tsx new file mode 100644 index 00000000000..b7e12400ef2 --- /dev/null +++ b/packages/manager/src/features/Domains/DomainRecords.tsx @@ -0,0 +1,889 @@ +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 11e45705837..cfe556231a0 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 f763c7142aa..7e62dedbd18 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, @@ -61,112 +98,47 @@ const AlgoliaSearchBar = (props: AlgoliaSearchBarProps) => { : '/support/search/'; }; - const handleSelect = (selected: ConvertedItems | SelectedItem | null) => { + const handleSelect = (selected: Item) => { if (!selected || !inputValue) { return; } - 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. + if (selected.value === 'search') { 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} )} - { - 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" - /> +
+ + 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} + /> +
); }; diff --git a/packages/manager/src/features/Help/Panels/SearchItem.tsx b/packages/manager/src/features/Help/Panels/SearchItem.tsx index 80bee89b327..96aef5779ac 100644 --- a/packages/manager/src/features/Help/Panels/SearchItem.tsx +++ b/packages/manager/src/features/Help/Panels/SearchItem.tsx @@ -1,68 +1,58 @@ -import { ListItem, Typography } from '@linode/ui'; +import { Typography } from '@linode/ui'; import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; +import { useStyles } 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 { Theme } from '@mui/material/styles'; +import type { OptionProps } from 'react-select'; -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 { +interface Props extends OptionProps { data: { - data: { - source: string; - }; + data: any; label: string; }; + searchText: string; } export const SearchItem = (props: Props) => { - const { data } = props; const getLabel = () => { if (isFinal) { - return data.label ? `Search for "${data.label}"` : 'Search'; + return props.label ? `Search for "${props.label}"` : 'Search'; } else { - return data.label; + return props.label; } }; - const { classes } = useStyles(); + const { cx } = useStyles(); + const { + data, + isFocused, + selectProps: { classes }, + } = props; const source = data.data ? data.data.source : ''; const isFinal = source === 'finalLink'; return ( - {isFinal ? ( -
- ({ - color: theme.color.headline, - })} - > - {getLabel()} - +
+ {getLabel()}
) : ( -
-
+ <> +
{ text: getLabel(), }), }} + className={classes.label} /> - ({ - color: theme.color.headline, - })} - > - {source} - +
- -
+ {source} + )} - + ); }; diff --git a/packages/manager/src/features/Help/SearchHOC.tsx b/packages/manager/src/features/Help/SearchHOC.tsx index 996bab7c094..ec2bfffcc56 100644 --- a/packages/manager/src/features/Help/SearchHOC.tsx +++ b/packages/manager/src/features/Help/SearchHOC.tsx @@ -1,7 +1,8 @@ -import Algolia from 'algoliasearch'; +import Algolia, { SearchClient } from 'algoliasearch'; import { pathOr } from 'ramda'; import * as React from 'react'; +import { Item } from 'src/components/EnhancedSelect/Select'; import { ALGOLIA_APPLICATION_ID, ALGOLIA_SEARCH_KEY, @@ -10,8 +11,6 @@ import { } from 'src/constants'; import { truncate } from 'src/utilities/truncate'; -import type { SearchClient } from 'algoliasearch'; - interface SearchHit { _highlightResult?: any; description?: string; @@ -25,7 +24,7 @@ export interface AlgoliaState { searchAlgolia: (inputValue: string) => void; searchEnabled: boolean; searchError?: string; - searchResults: [ConvertedItems[], ConvertedItems[]]; + searchResults: [Item[], Item[]]; } interface SearchOptions { @@ -37,17 +36,11 @@ interface AlgoliaContent { results: unknown; } -export interface ConvertedItems { - data: { href: string; source: string }; - label: string; - value: number; -} - // Functional helper methods export const convertDocsToItems = ( highlight: boolean, hits: SearchHit[] = [] -): ConvertedItems[] => { +): Item[] => { return hits.map((hit: SearchHit, idx: number) => { return { data: { @@ -63,7 +56,7 @@ export const convertDocsToItems = ( export const convertCommunityToItems = ( highlight: boolean, hits: SearchHit[] = [] -): ConvertedItems[] => { +): Item[] => { return hits.map((hit: SearchHit, idx: number) => { return { data: { diff --git a/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx b/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx deleted file mode 100644 index 15304503266..00000000000 --- a/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { fireEvent } from '@testing-library/react'; -import React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { Entities } from './Entities'; - -import type { IamAccountResource } from '@linode/api-v4/lib/resources/types'; - -const queryMocks = vi.hoisted(() => ({ - useAccountResources: vi.fn().mockReturnValue({}), -})); - -vi.mock('src/queries/resources/resources', async () => { - const actual = await vi.importActual('src/queries/resources/resources'); - return { - ...actual, - useAccountResources: queryMocks.useAccountResources, - }; -}); - -const mockResources: IamAccountResource[] = [ - { - resource_type: 'linode', - resources: [ - { - id: 23456789, - name: 'linode-uk-123', - }, - { - id: 456728, - name: 'db-us-southeast1', - }, - ], - }, - { - resource_type: 'image', - resources: [ - { id: 3, name: 'image-1' }, - { id: 4, name: 'image-2' }, - ], - }, -]; - -describe('Resources', () => { - it('renders correct data when it is an account access and type is an account', () => { - const { getByText, queryAllByRole } = renderWithTheme( - - ); - - const autocomplete = queryAllByRole('combobox'); - - expect(getByText('Entities')).toBeInTheDocument(); - expect(getByText('All entities')).toBeInTheDocument(); - - // check that the autocomplete doesn't exist - expect(autocomplete.length).toBe(0); - expect(autocomplete[0]).toBeUndefined(); - }); - - it('renders correct data when it is an account access and type is not an account', () => { - const { getByText, queryAllByRole } = renderWithTheme( - - ); - - const autocomplete = queryAllByRole('combobox'); - - expect(getByText('Entities')).toBeInTheDocument(); - expect(getByText('All firewalls')).toBeInTheDocument(); - - // check that the autocomplete doesn't exist - expect(autocomplete.length).toBe(0); - expect(autocomplete[0]).toBeUndefined(); - }); - - it('renders correct data when it is a resources access', () => { - queryMocks.useAccountResources.mockReturnValue({ data: mockResources }); - - const { getAllByRole, getByText } = renderWithTheme( - - ); - - expect(getByText('Entities')).toBeInTheDocument(); - - // Verify comboboxes exist - const autocomplete = getAllByRole('combobox'); - expect(autocomplete).toHaveLength(1); - expect(autocomplete[0]).toBeInTheDocument(); - expect(autocomplete[0]).toHaveAttribute('placeholder', 'Select Images'); - }); - - it('renders correct options in Autocomplete dropdown when it is a resources access', () => { - queryMocks.useAccountResources.mockReturnValue({ data: mockResources }); - - const { getAllByRole, getByText } = renderWithTheme( - - ); - - expect(getByText('Entities')).toBeInTheDocument(); - - const autocomplete = getAllByRole('combobox')[0]; - fireEvent.focus(autocomplete); - fireEvent.mouseDown(autocomplete); - expect(getByText('image-1')).toBeInTheDocument(); - expect(getByText('image-2')).toBeInTheDocument(); - }); - - it('updates selected options when Autocomplete value changes when it is a resources access', () => { - const { getAllByRole, getByText } = renderWithTheme( - - ); - - const autocomplete = getAllByRole('combobox')[0]; - fireEvent.change(autocomplete, { target: { value: 'linode-uk-123' } }); - fireEvent.keyDown(autocomplete, { key: 'Enter' }); - expect(getByText('linode-uk-123')).toBeInTheDocument(); - }); -}); diff --git a/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx b/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx deleted file mode 100644 index dcce25ff8ec..00000000000 --- a/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Autocomplete, Typography } from '@linode/ui'; -import React from 'react'; - -import { FormLabel } from 'src/components/FormLabel'; -import { useAccountResources } from 'src/queries/resources/resources'; - -import { placeholderMap } from '../utilities'; - -import type { - IamAccessType, - IamAccountResource, - Resource, - ResourceType, - ResourceTypePermissions, -} from '@linode/api-v4'; - -interface Props { - access: IamAccessType; - type: ResourceType | ResourceTypePermissions; -} - -interface EntitiesOption { - label: string; - value: number; -} - -export const Entities = ({ access, type }: Props) => { - const { data: resources } = useAccountResources(); - - const [selectedEntities, setSelectedEntities] = React.useState< - EntitiesOption[] - >([]); - - const memoizedEntities = React.useMemo(() => { - if (access !== 'resource_access' || !resources) { - return []; - } - const typeResources = getEntitiesByType(type, resources); - return typeResources ? transformedEntities(typeResources.resources) : []; - }, [resources, access, type]); - - if (access === 'account_access') { - return ( - <> - - - Entities - - - - {type === 'account' ? 'All entities' : `All ${type}s`} - - - ); - } - - return ( - ( -
  • - {option.label} -
  • - )} - ListboxProps={{ sx: { overflowX: 'hidden' } }} - label="Entities" - multiple - onChange={(_, value) => setSelectedEntities(value)} - options={memoizedEntities} - placeholder={selectedEntities.length ? ' ' : getPlaceholder(type)} - sx={{ marginTop: 1 }} - /> - ); -}; - -const getPlaceholder = (type: ResourceType | ResourceTypePermissions): string => - placeholderMap[type] || 'Select'; - -const transformedEntities = (entities: Resource[]): EntitiesOption[] => { - return entities.map((entity) => ({ - label: entity.name, - value: entity.id, - })); -}; - -const getEntitiesByType = ( - roleResourceType: ResourceType | ResourceTypePermissions, - resources: IamAccountResource -): IamAccountResource | undefined => { - const entitiesArray: IamAccountResource[] = Object.values(resources); - - // Find the first matching entity by resource_type - return entitiesArray.find( - (item: IamAccountResource) => item.resource_type === roleResourceType - ); -}; diff --git a/packages/manager/src/features/IAM/Shared/UserDeleteConfirmation.tsx b/packages/manager/src/features/IAM/Shared/UserDeleteConfirmation.tsx deleted file mode 100644 index 316673a89a0..00000000000 --- a/packages/manager/src/features/IAM/Shared/UserDeleteConfirmation.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Notice, Typography } from '@linode/ui'; -import { useSnackbar } from 'notistack'; -import * as React from 'react'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { useAccountUserDeleteMutation } from 'src/queries/account/users'; - -interface Props { - onClose: () => void; - onSuccess?: () => void; - open: boolean; - username: string; -} - -export const UserDeleteConfirmation = (props: Props) => { - const { onClose: _onClose, onSuccess, open, username } = props; - - const { enqueueSnackbar } = useSnackbar(); - - const { - error, - isPending, - mutateAsync: deleteUser, - reset, - } = useAccountUserDeleteMutation(username); - - const onClose = () => { - reset(); // resets the error state of the useMutation - _onClose(); - }; - - const onDelete = async () => { - await deleteUser(); - enqueueSnackbar(`User ${username} has been deleted successfully.`, { - variant: 'success', - }); - if (onSuccess) { - onSuccess(); - } - onClose(); - }; - - return ( - - } - error={error?.[0].reason} - onClose={onClose} - open={open} - title={`Delete user ${username}?`} - > - - - Warning: Deleting this User is permanent and can’t be - undone. - - - - ); -}; diff --git a/packages/manager/src/features/IAM/Shared/utilities.ts b/packages/manager/src/features/IAM/Shared/utilities.ts index fef7ff9da58..1b4f968511a 100644 --- a/packages/manager/src/features/IAM/Shared/utilities.ts +++ b/packages/manager/src/features/IAM/Shared/utilities.ts @@ -16,17 +16,3 @@ export const useIsIAMEnabled = () => { isIAMEnabled, }; }; - -export const placeholderMap: Record = { - account: 'Select Account', - database: 'Select Databases', - domain: 'Select Domains', - firewall: 'Select Firewalls', - image: 'Select Images', - linode: 'Select Linodes', - longview: 'Select Longviews', - nodebalancer: 'Select Nodebalancers', - stackscript: 'Select Stackscripts', - volume: 'Select Volumes', - vpc: 'Select VPCs', -}; diff --git a/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx index 22f6ea766aa..b988df126f8 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx @@ -47,7 +47,7 @@ export const DeleteUserPanel = ({ user }: Props) => { setIsDeleteDialogOpen(false)} - onSuccess={() => history.push(`/iam/users`)} + onSuccess={() => history.push(`/account/users`)} open={isDeleteDialogOpen} username={user.username} /> diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.tsx index 801c1e431db..26ec27c2f88 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.tsx @@ -36,7 +36,7 @@ export const UsernamePanel = ({ user }: Props) => { const user = await mutateAsync(values); // Because the username changed, we need to update the username in the URL - history.replace(`/iam/users/${user.username}/details`); + history.replace(`/account/users/${user.username}`); enqueueSnackbar('Username updated successfully', { variant: 'success' }); } catch (error) { diff --git a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx index 15d16731851..229989cf392 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx @@ -6,7 +6,6 @@ import { useParams } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { NO_ASSIGNED_ROLES_TEXT } from '../../Shared/constants'; -import { Entities } from '../../Shared/Entities/Entities'; import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; import type { IamUserPermissions } from '@linode/api-v4'; @@ -40,14 +39,7 @@ export const UserRoles = ({ assignedRoles }: Props) => { {hasAssignedRoles ? ( -
    -

    UIE-8138 - assigned roles table

    - {/* just for showing the Entities componnet, it will be gone wuth the AssignedPermissions component*/} - - - - -
    +

    UIE-8138 - assigned roles table

    ) : ( )} diff --git a/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.test.tsx b/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.test.tsx deleted file mode 100644 index da6329a686b..00000000000 --- a/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { fireEvent } from '@testing-library/react'; -import React from 'react'; - -import { HttpResponse, http, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { CreateUserDrawer } from './CreateUserDrawer'; - -const props = { - onClose: vi.fn(), - open: true, -}; - -const testEmail = 'testuser@example.com'; - -describe('CreateUserDrawer', () => { - it('should render the drawer when open is true', () => { - const { getByRole } = renderWithTheme(); - - const dialog = getByRole('dialog'); - expect(dialog).toBeInTheDocument(); - }); - - it('should allow the user to fill out the form', () => { - const { getByLabelText, getByRole } = renderWithTheme( - - ); - - const dialog = getByRole('dialog'); - expect(dialog).toBeInTheDocument(); - - fireEvent.change(getByLabelText(/username/i), { - target: { value: 'testuser' }, - }); - fireEvent.change(getByLabelText(/email/i), { - target: { value: testEmail }, - }); - - expect(getByLabelText(/username/i)).toHaveValue('testuser'); - expect(getByLabelText(/email/i)).toHaveValue(testEmail); - }); - - it('should display an error message when submission fails', async () => { - server.use( - http.post('*/account/users', () => { - return HttpResponse.json( - { error: [{ reason: 'An unexpected error occurred.' }] }, - { status: 500 } - ); - }) - ); - - const { - findByText, - getByLabelText, - getByRole, - getByTestId, - } = renderWithTheme(); - - const dialog = getByRole('dialog'); - expect(dialog).toBeInTheDocument(); - - fireEvent.change(getByLabelText(/username/i), { - target: { value: 'testuser' }, - }); - fireEvent.change(getByLabelText(/email/i), { - target: { value: testEmail }, - }); - fireEvent.click(getByTestId('submit')); - - const errorMessage = await findByText('An unexpected error occurred.'); - expect(errorMessage).toBeInTheDocument(); - }); -}); diff --git a/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.tsx b/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.tsx deleted file mode 100644 index f4d27a78482..00000000000 --- a/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { Box, FormControlLabel, Notice, TextField, Toggle } from '@linode/ui'; -import * as React from 'react'; -import { Controller, useForm } from 'react-hook-form'; -import { useHistory } from 'react-router-dom'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; -import { useCreateUserMutation } from 'src/queries/account/users'; - -import type { User } from '@linode/api-v4/lib/account'; - -interface Props { - onClose: () => void; - open: boolean; -} - -export const CreateUserDrawer = (props: Props) => { - const { onClose, open } = props; - const history = useHistory(); - const { mutateAsync: createUserMutation } = useCreateUserMutation(); - - const { - control, - formState: { errors, isSubmitting }, - handleSubmit, - reset, - setError, - } = useForm({ - defaultValues: { - email: '', - restricted: false, - username: '', - }, - }); - - const onSubmit = async (data: { - email: string; - restricted: boolean; - username: string; - }) => { - try { - const user: User = await createUserMutation(data); - handleClose(); - - if (user.restricted) { - history.push(`/account/users/${data.username}/permissions`, { - newUsername: user.username, - }); - } - } catch (errors) { - for (const error of errors) { - setError(error?.field ?? 'root', { message: error.reason }); - } - } - }; - - const handleClose = () => { - reset(); - onClose(); - }; - - return ( - - {errors.root?.message && ( - - )} -
    - ( - - )} - control={control} - name="username" - rules={{ required: 'Username is required' }} - /> - - ( - - )} - control={control} - name="email" - rules={{ required: 'Email is required' }} - /> - - ( - ) => { - field.onChange(!e.target.checked); - }} - checked={!field.value} - control={} - sx={{ marginTop: 1 }} - /> - )} - control={control} - name="restricted" - /> - - - - - - -
    - ); -}; diff --git a/packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx b/packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx deleted file mode 100644 index df9a2bf468a..00000000000 --- a/packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Typography } from '@linode/ui'; -import React from 'react'; - -import { Table } from 'src/components/Table'; -import { TableBody } from 'src/components/TableBody'; -import { PARENT_USER } from 'src/features/Account/constants'; -import { useAccountUsers } from 'src/queries/account/users'; - -import { UsersLandingProxyTableHead } from './UsersLandingProxyTableHead'; -import { UsersLandingTableBody } from './UsersLandingTableBody'; - -import type { Order } from './UsersLandingTableHead'; - -interface Props { - handleDelete: (username: string) => void; - isProxyUser: boolean; - isRestrictedUser: boolean | undefined; - order: Order; -} - -export const ProxyUserTable = ({ - handleDelete, - isProxyUser, - isRestrictedUser, - order, -}: Props) => { - const { - data: proxyUser, - error: proxyUserError, - isLoading: isLoadingProxyUser, - } = useAccountUsers({ - enabled: isProxyUser && !isRestrictedUser, - filters: { user_type: 'proxy' }, - }); - - const proxyNumCols = 3; - - return ( - <> - ({ - marginBottom: theme.spacing(2), - marginTop: theme.spacing(3), - textTransform: 'capitalize', - [theme.breakpoints.down('md')]: { - marginLeft: theme.spacing(1), - }, - })} - variant="h3" - > - {PARENT_USER} Settings - - - - - - - -
    - - ); -}; diff --git a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx index 2db2bfac1f5..b4bcb50bf78 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx @@ -1,8 +1,7 @@ -import { getAPIFilterFromQuery } from '@linode/search'; -import { Box, Button, Paper, Typography } from '@linode/ui'; +import { Box, Button, Paper } from '@linode/ui'; import { useMediaQuery } from '@mui/material'; import { useTheme } from '@mui/material/styles'; -import React, { useState } from 'react'; +import React from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -12,45 +11,24 @@ import { TableBody } from 'src/components/TableBody'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useAccountUsers } from 'src/queries/account/users'; -import { useProfile } from 'src/queries/profile/profile'; -import { UserDeleteConfirmation } from '../../Shared/UserDeleteConfirmation'; -import { CreateUserDrawer } from './CreateUserDrawer'; -import { ProxyUserTable } from './ProxyUserTable'; import { UsersLandingTableBody } from './UsersLandingTableBody'; import { UsersLandingTableHead } from './UsersLandingTableHead'; import type { Filter } from '@linode/api-v4'; export const UsersLanding = () => { - const [isCreateDrawerOpen, setIsCreateDrawerOpen] = React.useState( - false - ); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); - const [selectedUsername, setSelectedUsername] = React.useState(''); - - const [query, setQuery] = useState(); - - const { data: profile } = useProfile(); const theme = useTheme(); const pagination = usePagination(1, 'account-users'); const order = useOrder(); - const isProxyUser = - profile?.user_type === 'child' || profile?.user_type === 'proxy'; - - const { error: searchError, filter } = getAPIFilterFromQuery(query, { - searchableFieldsWithoutOperator: ['username', 'email'], - }); - const usersFilter: Filter = { ['+order']: order.order, ['+order_by']: order.orderBy, - ...filter, }; // Since this query is disabled for restricted users, use isLoading. - const { data: users, error, isFetching, isLoading } = useAccountUsers({ + const { data: users, error, isLoading } = useAccountUsers({ filters: usersFilter, params: { page: pagination.page, @@ -58,8 +36,6 @@ export const UsersLanding = () => { }, }); - const isRestrictedUser = profile?.restricted; - const isSmDown = useMediaQuery(theme.breakpoints.down('sm')); const isLgDown = useMediaQuery(theme.breakpoints.up('lg')); @@ -68,21 +44,16 @@ export const UsersLanding = () => { const numCols = isSmDown ? 2 : numColsLg; const handleDelete = (username: string) => { - setIsDeleteDialogOpen(true); - setSelectedUsername(username); + // mock + }; + + const handleSearch = async (value: string) => { + // mock }; return ( - {isProxyUser && ( - - )} ({ marginTop: theme.spacing(2) })}> ({ @@ -92,43 +63,17 @@ export const UsersLanding = () => { marginBottom: theme.spacing(2), })} > - {isProxyUser ? ( - ({ - [theme.breakpoints.down('md')]: { - marginLeft: theme.spacing(1), - }, - })} - variant="h3" - > - User Settings - - ) : ( - - )} - + + @@ -138,7 +83,7 @@ export const UsersLanding = () => { isLoading={isLoading} numCols={numCols} onDelete={handleDelete} - users={users?.data ?? []} + users={users?.data} />
    @@ -151,15 +96,6 @@ export const UsersLanding = () => { pageSize={pagination.pageSize} />
    - setIsCreateDrawerOpen(false)} - open={isCreateDrawerOpen} - /> - setIsDeleteDialogOpen(false)} - open={isDeleteDialogOpen} - username={selectedUsername} - />
    ); }; diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingProxyTableHead.test.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingProxyTableHead.test.tsx deleted file mode 100644 index b3111495e39..00000000000 --- a/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingProxyTableHead.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { fireEvent } from '@testing-library/react'; -import React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { UsersLandingProxyTableHead } from './UsersLandingProxyTableHead'; - -import type { SortOrder } from './UsersLandingTableHead'; - -const mockOrder = { - handleOrderChange: vi.fn(), - order: 'asc' as SortOrder, - orderBy: 'username', -}; - -describe('UsersLandingProxyTableHead', () => { - it('should render Username cell', () => { - const { getByText } = renderWithTheme( - - ); - - const username = getByText('Username'); - expect(username).toBeInTheDocument(); - }); - - it('should call handleOrderChange when Username sort cell is clicked', () => { - const { getByText } = renderWithTheme( - - ); - - const usernameCell = getByText('Username'); - expect(usernameCell).toBeInTheDocument(); - fireEvent.click(usernameCell); - - // Expect the handleOrderChange to have been called - expect(mockOrder.handleOrderChange).toHaveBeenCalled(); - }); - - it('should render correctly with order props', () => { - const { getByText } = renderWithTheme( - - ); - - const usernameCell = getByText('Username'); - expect(usernameCell).toBeInTheDocument(); - - expect(usernameCell.closest('span')).toHaveAttribute( - 'aria-label', - 'Sort by username' - ); - }); -}); diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingProxyTableHead.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingProxyTableHead.tsx deleted file mode 100644 index b41f1e600a5..00000000000 --- a/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingProxyTableHead.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; - -import { Hidden } from 'src/components/Hidden'; -import { TableCell } from 'src/components/TableCell'; -import { TableHead } from 'src/components/TableHead'; -import { TableRow } from 'src/components/TableRow/TableRow'; -import { TableSortCell } from 'src/components/TableSortCell'; - -import type { Order } from './UsersLandingTableHead'; - -interface Props { - order: Order; -} - -export const UsersLandingProxyTableHead = ({ order }: Props) => { - return ( - - - - Username - - - - Email Address - - - - - - ); -}; diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx index 10e8f126545..075837d2ad9 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx @@ -121,8 +121,8 @@ const Panel = (props: NodePoolPanelProps) => { return false; } - // No Nanodes in Kubernetes clusters - return t.class !== 'nanode'; + // No Nanodes or GPUs in Kubernetes clusters + return t.class !== 'nanode' && t.class !== 'gpu'; })} error={apiError} hasSelectedRegion={hasSelectedRegion} diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutSummary.styles.ts b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutSummary.styles.ts index 6c38e3e84d8..12ac83ef280 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutSummary.styles.ts +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutSummary.styles.ts @@ -28,12 +28,12 @@ export const StyledNodePoolSummaryBox = styled(Box, { export const StyledIconButton = styled(IconButton, { label: 'StyledIconButton', -})(({ theme }) => ({ +})(() => ({ '&:hover': { - color: theme.tokens.color.Neutrals[70], + color: '#6e6e6e', }, alignItems: 'flex-start', - color: theme.tokens.color.Neutrals[60], + color: '#979797', marginTop: -4, padding: 0, })); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx index b0db7e4173c..01ad9644511 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx @@ -107,7 +107,7 @@ export const KubeEntityDetailFooter = React.memo((props: FooterProps) => { }, }} alignItems="flex-start" - lg="auto" + lg={10} xs={12} > @@ -165,8 +165,7 @@ export const KubeEntityDetailFooter = React.memo((props: FooterProps) => { justifyContent: 'flex-start', }, }} - lg={3.5} - marginLeft="auto" + lg={2} xs={12} > { )} diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx index c24ac3419de..a34e1271b94 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx @@ -180,6 +180,10 @@ export const AddNodePoolDrawer = (props: Props) => { setSelectedTypeInfo({ count: 1, planId: newType }); } }} + // No nanodes or GPUs in clusters + types={extendedTypes.filter( + (t) => t.class !== 'nanode' && t.class !== 'gpu' + )} addPool={handleAdd} getTypeCount={getTypeCount} hasSelectedRegion={hasSelectedRegion} @@ -190,8 +194,6 @@ export const AddNodePoolDrawer = (props: Props) => { resetValues={resetDrawer} selectedId={selectedTypeInfo?.planId} selectedRegionId={clusterRegionId} - // No nanodes in clusters - types={extendedTypes.filter((t) => t.class !== 'nanode')} updatePlanCount={updatePlanCount} /> {selectedTypeInfo && diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx index f2cf1a063af..fb0bdbabf88 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx @@ -33,6 +33,9 @@ const useStyles = makeStyles()((theme: Theme) => ({ disabled: { opacity: 0.5, }, + errorText: { + color: theme.color.red, + }, input: { '& input': { width: 70, @@ -216,16 +219,16 @@ export const AutoscalePoolDialog = (props: Props) => { /> - {errors.min && ( - theme.palette.error.dark}> + {errors.min ? ( + {errors.min} - )} - {errors.max && ( - theme.palette.error.dark}> + ) : null} + {errors.max ? ( + {errors.max} - )} + ) : null} diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx index 886e49d8b78..66f7e915a47 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx @@ -6,28 +6,22 @@ import { Tooltip, Typography, } from '@linode/ui'; -import Divider from '@mui/material/Divider'; import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { Hidden } from 'src/components/Hidden'; -import { pluralize } from 'src/utilities/pluralize'; import { NodeTable } from './NodeTable'; import type { AutoscaleSettings, - KubernetesTier, PoolNodeResponse, } from '@linode/api-v4/lib/kubernetes'; import type { EncryptionStatus } from '@linode/api-v4/lib/linodes/types'; interface Props { autoscaler: AutoscaleSettings; - clusterCreated: string; clusterId: number; - clusterTier: KubernetesTier; - count: number; encryptionStatus: EncryptionStatus | undefined; handleClickResize: (poolId: number) => void; isOnlyNodePool: boolean; @@ -44,10 +38,7 @@ interface Props { export const NodePool = (props: Props) => { const { autoscaler, - clusterCreated, clusterId, - clusterTier, - count, encryptionStatus, handleClickResize, isOnlyNodePool, @@ -74,16 +65,7 @@ export const NodePool = (props: Props) => { py: 0, }} > - - {typeLabel} - ({ height: 16, margin: `4px ${theme.spacing(1)}` })} - /> - - {pluralize('Node', 'Nodes', count)} - - + {typeLabel} { { - const { - clusterCreated, - clusterID, - clusterLabel, - clusterRegionId, - clusterTier, - regionsData, - } = props; + const { clusterID, clusterLabel, clusterRegionId, regionsData } = props; const { data: pools, @@ -113,7 +104,7 @@ export const NodePoolsDisplay = (props: Props) => { {poolsError && } {_pools?.map((thisPool) => { - const { count, disk_encryption, id, nodes, tags } = thisPool; + const { disk_encryption, id, nodes, tags } = thisPool; const thisPoolType = types?.find( (thisType) => thisType.id === thisPool.type @@ -140,10 +131,7 @@ export const NodePoolsDisplay = (props: Props) => { setIsRecycleNodeOpen(true); }} autoscaler={thisPool.autoscaler} - clusterCreated={clusterCreated} clusterId={clusterID} - clusterTier={clusterTier} - count={count} encryptionStatus={disk_encryption} handleClickResize={handleOpenResizeDrawer} isOnlyNodePool={pools?.length === 1} diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx index 7dafe67a0d1..33b73495240 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx @@ -1,35 +1,19 @@ -import { DateTime } from 'luxon'; import * as React from 'react'; import { kubeLinodeFactory } from 'src/factories/kubernetesCluster'; import { linodeFactory } from 'src/factories/linodes'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { NodeTable, encryptionStatusTestId } from './NodeTable'; import type { Props } from './NodeTable'; -import type { KubernetesTier } from '@linode/api-v4'; -const mockLinodes = new Array(3) - .fill(null) - .map((_element: null, index: number) => { - return linodeFactory.build({ - ipv4: [`50.116.6.${index}`], - }); - }); +const mockLinodes = linodeFactory.buildList(3); -const mockKubeNodes = mockLinodes.map((mockLinode) => - kubeLinodeFactory.build({ - instance_id: mockLinode.id, - }) -); +const mockKubeNodes = kubeLinodeFactory.buildList(3); const props: Props = { - clusterCreated: '2025-01-13T02:58:58', clusterId: 1, - clusterTier: 'standard', encryptionStatus: 'enabled', nodes: mockKubeNodes, openRecycleNodeDialog: vi.fn(), @@ -64,25 +48,13 @@ describe('NodeTable', () => { }; }); - it('includes label, status, and IP columns', async () => { - server.use( - http.get('*/linode/instances*', () => { - return HttpResponse.json(makeResourcePage(mockLinodes)); - }) - ); - - const { findAllByText, findByText } = renderWithTheme( - - ); - - expect(await findAllByText('Running')).toHaveLength(3); - - await Promise.all( - mockLinodes.map(async (mockLinode) => { - await findByText(mockLinode.label); - await findByText(mockLinode.ipv4[0]); - }) - ); + it('includes label, status, and IP columns', () => { + const { findByText } = renderWithTheme(); + mockLinodes.forEach(async (thisLinode) => { + await findByText(thisLinode.label); + await findByText(thisLinode.ipv4[0]); + await findByText('Ready'); + }); }); it('includes the Pool ID', () => { @@ -90,27 +62,6 @@ describe('NodeTable', () => { getByText('Pool ID 1'); }); - it('displays a provisioning message if the cluster was created within the first 10 mins and there are no nodes yet', async () => { - const clusterProps = { - ...props, - clusterCreated: DateTime.local().toISO(), - clusterTier: 'enterprise' as KubernetesTier, - nodes: [], - }; - - const { findByText } = renderWithTheme(); - - expect( - await findByText( - 'Nodes will appear once cluster provisioning is complete.' - ) - ).toBeVisible(); - - expect( - await findByText('Provisioning can take up to 10 minutes.') - ).toBeVisible(); - }); - it('does not display the encryption status of the pool if the account lacks the capability or the feature flag is off', () => { // situation where isDiskEncryptionFeatureEnabled === false const { queryByTestId } = renderWithTheme(); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx index 57220f59e61..e19ee92496f 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx @@ -1,14 +1,11 @@ import { Box, TooltipIcon, Typography } from '@linode/ui'; -import { DateTime, Interval } from 'luxon'; import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; -import EmptyStateCloud from 'src/assets/icons/empty-state-cloud.svg'; import Lock from 'src/assets/icons/lock.svg'; import Unlock from 'src/assets/icons/unlock.svg'; import { DISK_ENCRYPTION_NODE_POOL_GUIDANCE_COPY } from 'src/components/Encryption/constants'; import { useIsDiskEncryptionFeatureEnabled } from 'src/components/Encryption/utils'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import OrderBy from 'src/components/OrderBy'; import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -22,8 +19,6 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { TagCell } from 'src/components/TagCell/TagCell'; import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import { useProfile } from 'src/queries/profile/profile'; -import { parseAPIDate } from 'src/utilities/date'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { NodeRow as _NodeRow } from './NodeRow'; @@ -36,17 +31,12 @@ import { } from './NodeTable.styles'; import type { NodeRow } from './NodeRow'; -import type { - KubernetesTier, - PoolNodeResponse, -} from '@linode/api-v4/lib/kubernetes'; +import type { PoolNodeResponse } from '@linode/api-v4/lib/kubernetes'; import type { EncryptionStatus } from '@linode/api-v4/lib/linodes/types'; import type { LinodeWithMaintenance } from 'src/utilities/linodes'; export interface Props { - clusterCreated: string; clusterId: number; - clusterTier: KubernetesTier; encryptionStatus: EncryptionStatus | undefined; nodes: PoolNodeResponse[]; openRecycleNodeDialog: (nodeID: string, linodeLabel: string) => void; @@ -59,9 +49,7 @@ export const encryptionStatusTestId = 'encryption-status-fragment'; export const NodeTable = React.memo((props: Props) => { const { - clusterCreated, clusterId, - clusterTier, encryptionStatus, nodes, openRecycleNodeDialog, @@ -70,8 +58,6 @@ export const NodeTable = React.memo((props: Props) => { typeLabel, } = props; - const { data: profile } = useProfile(); - const { data: linodes, error, isLoading } = useAllLinodesQuery(); const { isDiskEncryptionFeatureEnabled, @@ -98,27 +84,6 @@ export const NodeTable = React.memo((props: Props) => { const rowData = nodes.map((thisNode) => nodeToRow(thisNode, linodes ?? [])); - // It takes ~5 minutes for LKE-E cluster nodes to be provisioned and we want to explain this to the user - // since nodes are not returned right away unlike standard LKE - const isEnterpriseClusterWithin10MinsOfCreation = () => { - if (clusterTier !== 'enterprise') { - return false; - } - - const createdTime = parseAPIDate(clusterCreated).setZone(profile?.timezone); - - const interval = Interval.fromDateTimes( - createdTime, - createdTime.plus({ minutes: 10 }) - ); - - const currentTime = DateTime.fromISO(DateTime.now().toISO(), { - zone: profile?.timezone, - }); - - return interval.contains(currentTime); - }; - return ( {({ data: orderedData, handleOrderChange, order, orderBy }) => ( @@ -175,56 +140,28 @@ export const NodeTable = React.memo((props: Props) => { - {count === 0 && isEnterpriseClusterWithin10MinsOfCreation() && ( - - - - - Nodes will appear once cluster provisioning is - complete. - - - Provisioning can take up to 10 minutes. - - - } - CustomIcon={EmptyStateCloud} - compact + + {paginatedAndOrderedData.map((eachRow) => { + return ( + <_NodeRow + instanceId={eachRow.instanceId} + instanceStatus={eachRow.instanceStatus} + ip={eachRow.ip} + key={`node-row-${eachRow.nodeId}`} + label={eachRow.label} + linodeError={error ?? undefined} + nodeId={eachRow.nodeId} + nodeStatus={eachRow.nodeStatus} + openRecycleNodeDialog={openRecycleNodeDialog} + typeLabel={typeLabel} /> - - - )} - {(count > 0 || - !isEnterpriseClusterWithin10MinsOfCreation()) && ( - - {paginatedAndOrderedData.map((eachRow) => { - return ( - <_NodeRow - instanceId={eachRow.instanceId} - instanceStatus={eachRow.instanceStatus} - ip={eachRow.ip} - key={`node-row-${eachRow.nodeId}`} - label={eachRow.label} - linodeError={error ?? undefined} - nodeId={eachRow.nodeId} - nodeStatus={eachRow.nodeStatus} - openRecycleNodeDialog={openRecycleNodeDialog} - typeLabel={typeLabel} - /> - ); - })} - - )} + ); + })} + diff --git a/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx b/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx index 57965ab432b..7d93b7d3b6d 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx @@ -174,10 +174,10 @@ export const KubernetesLanding = () => { {isDiskEncryptionFeatureEnabled && ( - + {DISK_ENCRYPTION_UPDATE_PROTECT_CLUSTERS_COPY} diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx index 68f498da6d2..e88d951a9ab 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx @@ -1,28 +1,36 @@ -import { Notice, Typography } from '@linode/ui'; +import { Notice } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { Hidden } from 'src/components/Hidden'; -import { useFlags } from 'src/hooks/useFlags'; +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 { PLAN_SELECTION_NO_REGION_SELECTED_MESSAGE } from 'src/utilities/pricing/constants'; import { KubernetesPlanSelection } from './KubernetesPlanSelection'; -import { KubernetesPlanSelectionTable } from './KubernetesPlanSelectionTable'; -import type { LinodeTypeClass } from '@linode/api-v4'; -import type { - PlanSelectionDividers, - PlanSelectionFilterOptionsTable, -} from 'src/features/components/PlansPanel/PlanContainer'; import type { PlanWithAvailability } from 'src/features/components/PlansPanel/types'; +const tableCells = [ + { cellName: 'Plan', center: false, noWrap: false, testId: 'plan' }, + { cellName: 'Monthly', center: false, noWrap: false, testId: 'monthly' }, + { cellName: 'Hourly', center: false, noWrap: false, testId: 'hourly' }, + { cellName: 'RAM', center: true, noWrap: false, testId: 'ram' }, + { cellName: 'CPUs', center: true, noWrap: false, testId: 'cpu' }, + { cellName: 'Storage', center: true, noWrap: false, testId: 'storage' }, + { cellName: 'Quantity', center: false, noWrap: false, testId: 'quantity' }, +]; + export interface KubernetesPlanContainerProps { allDisabledPlans: PlanWithAvailability[]; getTypeCount: (planId: string) => number; hasMajorityOfPlansDisabled: boolean; onAdd?: (key: string, value: number) => void; onSelect: (key: string) => void; - planType?: LinodeTypeClass; plans: PlanWithAvailability[]; selectedId?: string; selectedRegionId?: string; @@ -38,75 +46,44 @@ export const KubernetesPlanContainer = ( hasMajorityOfPlansDisabled, onAdd, onSelect, - planType, plans, selectedId, selectedRegionId, updatePlanCount, wholePanelIsDisabled, } = props; - const flags = useFlags(); - const shouldDisplayNoRegionSelectedMessage = !selectedRegionId; - /** - * This features allows us to divide the GPU plans into two separate tables. - * This can be re-used for other plan types in the future. - */ - const planSelectionDividers: PlanSelectionDividers[] = [ - { - flag: Boolean(flags.gpuv2?.planDivider), - planType: 'gpu', - tables: [ - { - header: 'NVIDIA RTX 4000 Ada', - planFilter: (plan: PlanWithAvailability) => - plan.label.includes('Ada'), - }, - { - header: 'NVIDIA Quadro RTX 6000', - planFilter: (plan: PlanWithAvailability) => - !plan.label.includes('Ada'), - }, - ], - }, - ]; - - const renderPlanSelection = React.useCallback( - (filterOptions?: PlanSelectionFilterOptionsTable) => { - const _plans = filterOptions?.planFilter - ? plans.filter(filterOptions.planFilter) - : plans; + const shouldDisplayNoRegionSelectedMessage = !selectedRegionId; - return _plans.map((plan, id) => { - return ( - - ); - }); - }, - [ - wholePanelIsDisabled, - hasMajorityOfPlansDisabled, - getTypeCount, - onAdd, - onSelect, - plans, - selectedId, - selectedRegionId, - updatePlanCount, - ] - ); + const renderPlanSelection = React.useCallback(() => { + return plans.map((plan, id) => { + return ( + + ); + }); + }, [ + wholePanelIsDisabled, + hasMajorityOfPlansDisabled, + getTypeCount, + onAdd, + onSelect, + plans, + selectedId, + selectedRegionId, + updatePlanCount, + ]); return ( @@ -120,65 +97,44 @@ export const KubernetesPlanContainer = ( variant="info" /> ) : ( - planSelectionDividers.map((planSelectionDivider) => - planType === planSelectionDivider.planType && - planSelectionDivider.flag - ? planSelectionDivider.tables.map((table) => { - const filteredPlans = table.planFilter - ? plans.filter(table.planFilter) - : plans; - return [ - filteredPlans.length > 0 && ( - - {table.header} - - ), - renderPlanSelection({ - planFilter: table.planFilter, - }), - ]; - }) - : renderPlanSelection() - ) + renderPlanSelection() )} - {planSelectionDividers.map((planSelectionDivider) => - planType === planSelectionDivider.planType && - planSelectionDivider.flag ? ( - planSelectionDivider.tables.map((table, idx) => { - const filteredPlans = table.planFilter - ? plans.filter(table.planFilter) - : plans; - return ( - filteredPlans.length > 0 && ( - - renderPlanSelection({ - header: table.header, - planFilter: table.planFilter, - }) - } - shouldDisplayNoRegionSelectedMessage={ - shouldDisplayNoRegionSelectedMessage - } - filterOptions={table} - key={`k8-plan-filter-${idx}`} - /> - ) - ); - }) - ) : ( - - ) - )} + + + + {tableCells.map(({ cellName, center, noWrap, testId }) => { + const attributeValue = `${testId}-header`; + return ( + + {cellName === 'Quantity' ? ( +

    {cellName}

    + ) : ( + cellName + )} +
    + ); + })} +
    +
    + + {shouldDisplayNoRegionSelectedMessage ? ( + + ) : ( + renderPlanSelection() + )} + +
    diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx deleted file mode 100644 index 12b682e555d..00000000000 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import * as 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 { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; -import { PLAN_SELECTION_NO_REGION_SELECTED_MESSAGE } from 'src/utilities/pricing/constants'; - -import type { PlanSelectionFilterOptionsTable } from 'src/features/components/PlansPanel/PlanContainer'; - -interface KubernetesPlanSelectionTableProps { - filterOptions?: PlanSelectionFilterOptionsTable; - renderPlanSelection: ( - filterOptions?: PlanSelectionFilterOptionsTable | undefined - ) => React.JSX.Element[]; - shouldDisplayNoRegionSelectedMessage: boolean; -} - -const tableCells = [ - { cellName: 'Plan', center: false, noWrap: false, testId: 'plan' }, - { cellName: 'Monthly', center: false, noWrap: false, testId: 'monthly' }, - { cellName: 'Hourly', center: false, noWrap: false, testId: 'hourly' }, - { cellName: 'RAM', center: true, noWrap: false, testId: 'ram' }, - { cellName: 'CPUs', center: true, noWrap: false, testId: 'cpu' }, - { cellName: 'Storage', center: true, noWrap: false, testId: 'storage' }, - { cellName: 'Quantity', center: false, noWrap: false, testId: 'quantity' }, -]; - -export const KubernetesPlanSelectionTable = ( - props: KubernetesPlanSelectionTableProps -) => { - const { - filterOptions, - renderPlanSelection, - shouldDisplayNoRegionSelectedMessage, - } = props; - - return ( - - - - {tableCells.map(({ cellName, center, noWrap, testId }) => { - const attributeValue = `${testId}-header`; - return ( - - {cellName === 'Quantity' ? ( -

    {cellName}

    - ) : cellName === 'Plan' && filterOptions?.header ? ( - filterOptions.header - ) : ( - cellName - )} -
    - ); - })} -
    -
    - - {shouldDisplayNoRegionSelectedMessage ? ( - - ) : ( - renderPlanSelection() - )} - -
    - ); -}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx index 3980bee33e8..7423c472b17 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx @@ -106,7 +106,6 @@ export const KubernetesPlansPanel = (props: Props) => { isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan( plan )} - flow="kubernetes" hasMajorityOfPlansDisabled={hasMajorityOfPlansDisabled} hasSelectedRegion={hasSelectedRegion} isAPLEnabled={isAPLEnabled} @@ -122,7 +121,6 @@ export const KubernetesPlansPanel = (props: Props) => { hasMajorityOfPlansDisabled={hasMajorityOfPlansDisabled} onAdd={onAdd} onSelect={onSelect} - planType={plan} plans={plansForThisLinodeTypeClass} selectedId={selectedId} selectedRegionId={selectedRegionId} diff --git a/packages/manager/src/features/Linodes/DiskSelect/DiskSelect.tsx b/packages/manager/src/features/Linodes/DiskSelect/DiskSelect.tsx new file mode 100644 index 00000000000..8d66100fb2f --- /dev/null +++ b/packages/manager/src/features/Linodes/DiskSelect/DiskSelect.tsx @@ -0,0 +1,57 @@ +import { Disk } from '@linode/api-v4/lib/linodes'; +import * as React from 'react'; + +import EnhancedSelect, { Item } from 'src/components/EnhancedSelect/Select'; +import { RenderGuard } from 'src/components/RenderGuard'; + +interface Props { + disabled?: boolean; + diskError?: string; + disks: Disk[]; + generalError?: string; + handleChange: (disk: null | string) => void; + required?: boolean; + selectedDisk: null | string; +} + +const disksToOptions = (disks: Disk[]): Item[] => { + return disks.map((disk) => ({ label: disk.label, value: String(disk.id) })); +}; + +const diskFromValue = ( + disks: Item[], + diskId: null | string +): Item | null => { + if (!diskId) { + return null; + } + const thisDisk = disks.find((disk) => disk.value === diskId); + return thisDisk ? thisDisk : null; +}; + +export const DiskSelect = RenderGuard((props: Props) => { + const { + disabled, + diskError, + disks, + generalError, + handleChange, + required, + selectedDisk, + } = props; + const options = disksToOptions(disks); + return ( + | null) => + handleChange(newDisk ? newDisk.value : null) + } + disabled={disabled} + errorText={generalError || diskError} + label={'Disk'} + options={options} + placeholder={'Select a Disk'} + required={required} + value={diskFromValue(options, selectedDisk)} + /> + ); +}); diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx index 2c5c4059209..d21c7b67087 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx @@ -134,7 +134,6 @@ export const LinodeEntityDetailHeader = ( fontSize: '0.875rem', height: theme.spacing(5), minWidth: 'auto', - padding: '2px 10px', }; const sxBoxFlex = { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx index beaaa0ebf28..2595837ce31 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx @@ -1,12 +1,12 @@ import { Box } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; +import { splitAt } from 'ramda'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import { splitAt } from 'src/utilities/splitAt'; import type { Config } from '@linode/api-v4/lib/linodes'; import type { Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx index 74c88480a6b..ea02cd184d7 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx @@ -65,15 +65,14 @@ import { StyledFormGroup, StyledRadioGroup, } from './LinodeConfigDialog.styles'; -import { getPrimaryInterfaceIndex } from './utilities'; import type { ExtendedInterface } from '../LinodeSettings/InterfaceSelect'; import type { - APIError, Config, Interface, LinodeConfigCreationData, -} from '@linode/api-v4'; +} from '@linode/api-v4/lib/linodes'; +import type { APIError } from '@linode/api-v4/lib/types'; import type { DevicesAsStrings } from 'src/utilities/createDevicesFromStrings'; import type { ExtendedIP } from 'src/utilities/ipUtils'; @@ -205,7 +204,10 @@ const interfacesToState = (interfaces?: Interface[]) => { return padInterfaceList(interfacesPayload); }; -const interfacesToPayload = (interfaces?: ExtendedInterface[]) => { +const interfacesToPayload = ( + interfaces?: ExtendedInterface[], + primaryInterfaceIndex?: number +) => { if (!interfaces || interfaces.length === 0) { return []; } @@ -228,6 +230,12 @@ const interfacesToPayload = (interfaces?: ExtendedInterface[]) => { return []; } + if (primaryInterfaceIndex !== undefined) { + interfaces.forEach( + (iface, i) => (iface.primary = i === primaryInterfaceIndex) + ); + } + return filteredInterfaces as Interface[]; }; @@ -279,6 +287,11 @@ export const LinodeConfigDialog = (props: Props) => { const [useCustomRoot, setUseCustomRoot] = React.useState(false); + const [ + primaryInterfaceIndex, + setPrimaryInterfaceIndex, + ] = React.useState(0); + const regionHasVLANS = regions.some( (thisRegion) => thisRegion.id === linode?.region && @@ -324,7 +337,7 @@ export const LinodeConfigDialog = (props: Props) => { devices: createDevicesFromStrings(devices), helpers, initrd: initrd !== '' ? initrd : null, - interfaces: interfacesToPayload(interfaces), + interfaces: interfacesToPayload(interfaces, primaryInterfaceIndex), kernel, label, /** if the user did not toggle the limit radio button, send a value of 0 */ @@ -489,6 +502,14 @@ export const LinodeConfigDialog = (props: Props) => { ) ); + const indexOfExistingPrimaryInterface = config.interfaces.findIndex( + (_interface) => _interface.primary === true + ); + + if (indexOfExistingPrimaryInterface !== -1) { + setPrimaryInterfaceIndex(indexOfExistingPrimaryInterface); + } + resetForm({ values: { comments: config.comments, @@ -512,6 +533,7 @@ export const LinodeConfigDialog = (props: Props) => { resetForm({ values: defaultFieldsValues }); setUseCustomRoot(false); setDeviceCounter(deviceCounterDefault); + setPrimaryInterfaceIndex(0); } } }, [open, config, initrdFromConfig, resetForm, queryClient]); @@ -592,20 +614,20 @@ export const LinodeConfigDialog = (props: Props) => { value: null, }); - const interfacesWithoutPlaceholderInterfaces = values.interfaces.filter( - (i) => i.purpose !== 'none' - ) as Interface[]; + const getPrimaryInterfaceOptions = (interfaces: ExtendedInterface[]) => { + return interfaces.map((_interface, idx) => { + return { + label: `eth${idx}`, + value: idx, + }; + }); + }; - const primaryInterfaceOptions = interfacesWithoutPlaceholderInterfaces.map( - (networkInterface, idx) => ({ - label: `eth${idx}`, - value: idx, - }) - ); + const primaryInterfaceOptions = getPrimaryInterfaceOptions(values.interfaces); - const primaryInterfaceIndex = getPrimaryInterfaceIndex( - interfacesWithoutPlaceholderInterfaces - ); + const handlePrimaryInterfaceChange = (selectedValue: number) => { + setPrimaryInterfaceIndex(selectedValue); + }; /** * Form change handlers @@ -970,36 +992,19 @@ export const LinodeConfigDialog = (props: Props) => { )} <> i.purpose === 'public' || i.purpose === 'vpc' - )} - onChange={(_, selected) => { - const updatedInterfaces = [...values.interfaces]; - - for (let i = 0; i < updatedInterfaces.length; i++) { - if (selected && selected.value === i) { - updatedInterfaces[i].primary = true; - } else { - updatedInterfaces[i].primary = false; - } - } - - formik.setValues({ - ...values, - interfaces: updatedInterfaces, - }); - }} - value={ - primaryInterfaceIndex !== null - ? primaryInterfaceOptions[primaryInterfaceIndex] - : null + isOptionEqualToValue={(option, value) => + option.value === value.value + } + onChange={(_, selected) => + handlePrimaryInterfaceChange(selected?.value) } autoHighlight data-testid="primary-interface-dropdown" + disableClearable disabled={isReadOnly} label="Primary Interface (Default Route)" - options={primaryInterfaceOptions} - placeholder="None" + options={getPrimaryInterfaceOptions(values.interfaces)} + value={primaryInterfaceOptions[primaryInterfaceIndex]} /> { @@ -1250,7 +1255,6 @@ export const unrecommendedConfigNoticeSelector = ({ // Edge case: users w/ ability to have multiple VPC interfaces. Scenario 1 & 2 notices not helpful if that's done const primaryInterfaceIsVPC = - primaryInterfaceIndex !== null && values.interfaces[primaryInterfaceIndex].purpose === 'vpc'; /* diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts deleted file mode 100644 index 1f5f7c47352..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { LinodeConfigInterfaceFactory } from 'src/factories'; - -import { getPrimaryInterfaceIndex } from './utilities'; - -describe('getPrimaryInterfaceIndex', () => { - it('returns null if there are no interfaces', () => { - expect(getPrimaryInterfaceIndex([])).toBeNull(); - }); - - it('returns the primary interface when one is designated as primary', () => { - const interfaces = [ - LinodeConfigInterfaceFactory.build({ primary: false }), - LinodeConfigInterfaceFactory.build({ primary: true }), - LinodeConfigInterfaceFactory.build({ primary: false }), - ]; - - expect(getPrimaryInterfaceIndex(interfaces)).toBe(1); - }); - - it('returns the index of the first non-VLAN interface if there is no interface designated as primary', () => { - const interfaces = [ - LinodeConfigInterfaceFactory.build({ primary: false, purpose: 'vlan' }), - LinodeConfigInterfaceFactory.build({ primary: false, purpose: 'public' }), - ]; - - expect(getPrimaryInterfaceIndex(interfaces)).toBe(1); - }); - - it('returns null when there is no primary interface', () => { - const interfaces = [ - LinodeConfigInterfaceFactory.build({ primary: false, purpose: 'vlan' }), - ]; - - expect(getPrimaryInterfaceIndex(interfaces)).toBe(null); - }); -}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts deleted file mode 100644 index 2b12640e578..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { isEmpty } from '@linode/api-v4'; - -import type { Interface } from '@linode/api-v4'; - -/** - * Gets the index of the primary Linode interface - * - * The function does more than just look for `primary: true`. It will also return the index - * of the implicit primary interface. (The API does not enforce that a Linode config always - * has an interface that is marked as primary) - * - * This is the general logic we follow in this function: - * - If an interface is primary we know that's the primary - * - If the API response returns an empty array "interfaces": [], under the hood, a public interface eth0 is implicit. This interface will be primary. - * - If a config has interfaces, but none of them are marked primary: true, then the first interface in the list that’s not a VLAN will be the primary interface - * - * @returns the index of the primary interface or `null` if there is not a primary interface - */ -export const getPrimaryInterfaceIndex = (interfaces: Interface[]) => { - const indexOfPrimaryInterface = interfaces.findIndex((i) => i.primary); - - // If an interface has `primary: true` we know thats the primary so just return it. - if (indexOfPrimaryInterface !== -1) { - return indexOfPrimaryInterface; - } - - // If the API response returns an empty array "interfaces": [] the Linode will by default have a public interface, - // and it will be eth0 on the Linode. This interface will be primary. - // This case isn't really nessesary because this form is built so that the interfaces state will be - // populated even if the API returns an empty interfaces array, but I'm including it for completeness. - if (isEmpty(interfaces)) { - return null; - } - - // If a config has interfaces but none of them are marked as primary, - // then the first interface in the list that’s not a VLAN will shown as the primary interface. - const inherentIndexOfPrimaryInterface = interfaces.findIndex( - (i) => i.purpose !== 'vlan' - ); - - if (inherentIndexOfPrimaryInterface !== -1) { - // If we're able to find the inherent primary interface, just return it. - return inherentIndexOfPrimaryInterface; - } - - // If we haven't been able to find the primary interface by this point, the Linode doesn't have one. - // As an example, this is the case when a Linode only has a VLAN interface. - return null; -}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx index caf233dc8cb..d7a71740e56 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx @@ -23,15 +23,11 @@ import { useCreateIPv6RangeMutation } from 'src/queries/networking/networking'; import { ExplainerCopy } from './ExplainerCopy'; import type { IPv6Prefix } from '@linode/api-v4/lib/networking'; +import type { Item } from 'src/components/EnhancedSelect/Select'; export type IPType = 'v4Private' | 'v4Public'; -type IPOption = { - label: string; - value: IPType; -}; - -const ipOptions: IPOption[] = [ +const ipOptions: Item[] = [ { label: 'Public', value: 'v4Public' }, { label: 'Private', value: 'v4Private' }, ]; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx index 25660e3fe52..0a775108d92 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx @@ -3,17 +3,17 @@ import { CircleProgress, Divider, Notice, - Select, TextField, Typography, } from '@linode/ui'; -import { useTheme } from '@mui/material/styles'; +import { styled, useTheme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { remove, uniq, update } from 'ramda'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Dialog } from 'src/components/Dialog/Dialog'; +import Select from 'src/components/EnhancedSelect/Select'; import { Link } from 'src/components/Link'; import { API_MAX_PAGE_SIZE } from 'src/constants'; import { useFlags } from 'src/hooks/useFlags'; @@ -32,7 +32,7 @@ import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; import type { Linode } from '@linode/api-v4/lib/linodes'; import type { IPRangeInformation } from '@linode/api-v4/lib/networking'; import type { APIError } from '@linode/api-v4/lib/types'; -import type { SelectOption } from '@linode/ui'; +import type { Item } from 'src/components/EnhancedSelect/Select'; interface Props { linodeId: number; @@ -165,7 +165,7 @@ const IPSharingPanel = (props: Props) => { } }, [ips, ranges]); - const onIPSelect = (ipIdx: number, e: SelectOption) => { + const onIPSelect = (ipIdx: number, e: Item) => { setIpsToShare((currentIps) => { return ipIdx >= currentIps.length ? [...currentIps, e.value] @@ -470,7 +470,7 @@ export const IPRow: React.FC = React.memo((props) => { interface SharingRowProps extends RowProps { getRemainingChoices: (ip: string | undefined) => string[]; handleDelete?: (idx: number) => void; - handleSelect: (idx: number, selected: SelectOption) => void; + handleSelect: (idx: number, selected: Item) => void; idx: number; labels: Record; readOnly: boolean; @@ -505,7 +505,7 @@ export const IPSharingRow: React.FC = React.memo((props) => { - - setSelectedDiskId(Number(item?.value) ?? null) - } - value={ - diskOptions?.find((item) => item.value === selectedDiskId) ?? null - } + setSelectedDiskId(item.value)} + options={diskOptions} placeholder="Select a Disk" + value={diskOptions?.find((item) => item.value === selectedDiskId)} /> ) : null} }> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx index bb730f48bbf..c42beb7814e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx @@ -1,13 +1,13 @@ import { Box } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; +import { splitAt } from 'ramda'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { sendEvent } from 'src/utilities/analytics/utils'; -import { splitAt } from 'src/utilities/splitAt'; import type { Disk, Linode } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx index 250858ed7e2..1988be4bf87 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx @@ -28,12 +28,12 @@ import { NetworkGraphs } from './NetworkGraphs'; import { StatsPanel } from './StatsPanel'; import type { ChartProps } from './NetworkGraphs'; -import type { SelectOption } from '@linode/ui'; import type { CPUTimeData, DiskIOTimeData, Point, } from 'src/components/AreaChart/types'; +import type { Item } from 'src/components/EnhancedSelect/Select'; setUpCharts(); @@ -83,7 +83,7 @@ const LinodeSummary = (props: Props) => { statsErrorString ); - const handleChartRangeChange = (e: SelectOption) => { + const handleChartRangeChange = (e: Item) => { setRangeSelection(e.value); }; @@ -122,7 +122,7 @@ const LinodeSummary = (props: Props) => { { data: metrics, format: formatPercentage, - legendColor: theme.graphs.blue, + legendColor: 'blue', legendTitle: 'CPU %', }, ]} @@ -170,13 +170,13 @@ const LinodeSummary = (props: Props) => { { data: getMetrics(data.io), format: formatNumber, - legendColor: theme.graphs.yellow, + legendColor: 'yellow', legendTitle: 'I/O Rate', }, { data: getMetrics(data.swap), format: formatNumber, - legendColor: theme.graphs.red, + legendColor: 'red', legendTitle: 'Swap Rate', }, ]} @@ -237,21 +237,21 @@ const LinodeSummary = (props: Props) => { handleChartRangeChange(value)} options={options} - sx={{ mt: 1, width: 150 }} /> - + { - + { { data: metrics.publicIn, format, - legendColor: theme.graphs.darkGreen, + legendColor: 'darkGreen', legendTitle: 'Public In', }, { data: metrics.publicOut, format, - legendColor: theme.graphs.lightGreen, + legendColor: 'lightGreen', legendTitle: 'Public Out', }, { data: metrics.privateIn, format, - legendColor: theme.graphs.purple, + legendColor: 'purple', legendTitle: 'Private In', }, { data: metrics.privateOut, format, - legendColor: theme.graphs.yellow, + legendColor: 'yellow', legendTitle: 'Private Out', }, ]} diff --git a/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx b/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx index 4376f0bdf30..d2cddd82d7a 100644 --- a/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx +++ b/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx @@ -1,4 +1,4 @@ -import { Autocomplete, Notice, Typography } from '@linode/ui'; +import { Autocomplete, FormHelperText, Notice, Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; @@ -139,17 +139,18 @@ export const PowerActionsDialog = (props: Props) => { secondaryButtonProps={{ label: 'Cancel', onClick: props.onClose }} /> } + sx={{ + '& .dialog-content': { + paddingBottom: 0, + paddingTop: 0, + }, + }} error={error?.[0].reason} onClose={handleOnClose} open={isOpen} title={`${action} Linode ${linodeLabel ?? ''}?`} > - {isRebootAction && ( - - Are you sure you want to reboot this Linode? - - )} - {isPowerOnAction && ( + {isPowerOnAction ? ( {  for more information. - )} + ) : null} {showConfigSelect && ( - option.value === selectedConfigID - )} - autoHighlight - disablePortal={false} - errorText={configsError?.[0].reason} - label="Config" - loading={configsLoading} - onChange={(_, option) => setSelectConfigID(option?.value ?? null)} - options={configOptions} - helperText='If no value is selected, the last booted config will be used.' - /> + <> + option.value === selectedConfigID + )} + autoHighlight + disablePortal={false} + errorText={configsError?.[0].reason} + label="Config" + loading={configsLoading} + onChange={(_, option) => setSelectConfigID(option?.value ?? null)} + options={configOptions} + /> + + If no value is selected, the last booted config will be used. + + )} {props.action === 'Power Off' && ( - - - Note: Powered down Linodes will still accrue - charges. See the  + + + Note: + Powered down Linodes will still accrue charges. +
    + See the  Billing and Payments documentation  for more information. -
    -
    + + )} ); diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ActiveConnections.test.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ActiveConnections.test.tsx index 162f5d86581..186ed33aff8 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ActiveConnections.test.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ActiveConnections.test.tsx @@ -1,10 +1,9 @@ import * as React from 'react'; import { longviewPortFactory } from 'src/factories/longviewService'; -import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; +import { renderWithTheme } from 'src/utilities/testHelpers'; import { ActiveConnections } from './ActiveConnections'; - import type { TableProps } from './ActiveConnections'; const mockConnections = longviewPortFactory.buildList(10); @@ -15,8 +14,8 @@ const props: TableProps = { }; describe('ActiveConnections (and by extension ListeningServices)', () => { - it('should render a table with one row per active connection', async () => { - const { queryAllByTestId } = await renderWithThemeAndRouter( + it('should render a table with one row per active connection', () => { + const { queryAllByTestId } = renderWithTheme( ); expect(queryAllByTestId('longview-connection-row')).toHaveLength( @@ -24,22 +23,22 @@ describe('ActiveConnections (and by extension ListeningServices)', () => { ); }); - it('should render a loading state', async () => { - const { getByTestId } = await renderWithThemeAndRouter( + it('should render a loading state', () => { + const { getByTestId } = renderWithTheme( ); getByTestId('table-row-loading'); }); - it('should render an empty state', async () => { - const { getByTestId } = await renderWithThemeAndRouter( + it('should render an empty state', () => { + const { getByTestId } = renderWithTheme( ); getByTestId('table-row-empty'); }); - it('should render an error state', async () => { - const { getByTestId, getByText } = await renderWithThemeAndRouter( + it('should render an error state', () => { + const { getByTestId, getByText } = renderWithTheme( { export const ConnectionsTable = (props: TableProps) => { const { connections, connectionsError, connectionsLoading } = props; - const { - handleOrderChange, - order, - orderBy, - sortedData, - } = useOrderV2({ - data: connections, - initialRoute: { - defaultOrder: { - order: 'asc', - orderBy: 'process', - }, - from: '/longview/clients/$id/overview', - }, - preferenceKey: 'active-connections', - prefix: 'active-connections', - }); - return ( - - {({ - count, - data: paginatedData, - handlePageChange, - handlePageSizeChange, - page, - pageSize, - }) => ( - <> - - - - - Name - - - User - - - Count - - - - - {renderLoadingErrorData( - connectionsLoading, - paginatedData ?? [], - connectionsError - )} - -
    - - + + {({ data: orderedData, handleOrderChange, order, orderBy }) => ( + + {({ + count, + data: paginatedData, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + }) => ( + <> + + + + + Name + + + User + + + Count + + + + + {renderLoadingErrorData( + connectionsLoading, + paginatedData, + connectionsError + )} + +
    + + + )} +
    )} -
    +
    ); }; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/ListeningServices.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/ListeningServices.tsx index 0de96fbbd88..4234d9f65e4 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/ListeningServices.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/ListeningServices.tsx @@ -2,6 +2,7 @@ import { Typography } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; +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'; @@ -12,7 +13,6 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; -import { useOrderV2 } from 'src/hooks/useOrderV2'; import { LongviewServiceRow } from './LongviewServiceRow'; @@ -51,109 +51,100 @@ export const ListeningServices = (props: TableProps) => { export const ServicesTable = (props: TableProps) => { const { services, servicesError, servicesLoading } = props; - const { - handleOrderChange, - order, - orderBy, - sortedData, - } = useOrderV2({ - data: services, - initialRoute: { - defaultOrder: { - order: 'asc', - orderBy: 'process', - }, - from: '/longview/clients/$id/overview', - }, - preferenceKey: 'listening-services', - prefix: 'listening-services', - }); - return ( - - {({ - count, - data: paginatedData, - handlePageChange, - handlePageSizeChange, - page, - pageSize, - }) => ( - <> - - - - - Process - - - User - - - Protocol - - - Port - - - IP - - - - - {renderLoadingErrorData( - servicesLoading, - paginatedData, - servicesError - )} - -
    - - + + {({ data: orderedData, handleOrderChange, order, orderBy }) => ( + + {({ + count, + data: paginatedData, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + }) => ( + <> + + + + + Process + + + User + + + Protocol + + + Port + + + IP + + + + + {renderLoadingErrorData( + servicesLoading, + paginatedData, + servicesError + )} + +
    + + + )} +
    )} -
    + ); }; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.test.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.test.tsx index 95546034105..b70650ae963 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.test.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.test.tsx @@ -1,11 +1,10 @@ import * as React from 'react'; import { longviewProcessFactory } from 'src/factories/longviewProcess'; -import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; +import { renderWithTheme } from 'src/utilities/testHelpers'; import { extendData } from './ProcessesLanding'; import { ProcessesTable } from './ProcessesTable'; - import type { ProcessesTableProps } from './ProcessesTable'; const mockSetSelectedRow = vi.fn(); @@ -20,8 +19,8 @@ const props: ProcessesTableProps = { describe('ProcessTable', () => { const extendedData = extendData(longviewProcessFactory.build()); - it('renders all columns for each row', async () => { - const { getAllByTestId, getAllByText } = await renderWithThemeAndRouter( + it('renders all columns for each row', () => { + const { getAllByTestId, getAllByText } = renderWithTheme( ); extendedData.forEach((row) => { @@ -34,15 +33,15 @@ describe('ProcessTable', () => { }); }); - it('renders loading state', async () => { - const { getByTestId } = await renderWithThemeAndRouter( + it('renders loading state', () => { + const { getByTestId } = renderWithTheme( ); getByTestId('table-row-loading'); }); - it('renders error state', async () => { - const { getByText } = await renderWithThemeAndRouter( + it('renders error state', () => { + const { getByText } = renderWithTheme( ); getByText('Error!'); diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx index d1d94eff487..6862e19c93d 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { MaskableText } from 'src/components/MaskableText/MaskableText'; +import OrderBy from 'src/components/OrderBy'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; @@ -10,7 +11,6 @@ import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; import { formatCPU } from 'src/features/Longview/shared/formatters'; -import { useOrderV2 } from 'src/hooks/useOrderV2'; import { useWindowDimensions } from 'src/hooks/useWindowDimensions'; import { readableBytes } from 'src/utilities/unitConversions'; @@ -39,98 +39,90 @@ export const ProcessesTable = React.memo((props: ProcessesTableProps) => { setSelectedProcess, } = props; - const { - handleOrderChange, - order, - orderBy, - sortedData, - } = useOrderV2({ - data: processesData, - initialRoute: { - defaultOrder: { - order: 'asc', - orderBy: 'name', - }, - from: '/longview/clients/$id/processes', - }, - preferenceKey: 'lv-detail-processes', - }); - return ( - = 1280} - spacingTop={16} + - - - - Process - - - User - - - Max Count - - - Avg IO - - - Avg CPU - - - Avg Mem - - - - - {renderLoadingErrorData( - processesLoading, - sortedData ?? [], - selectedProcess, - setSelectedProcess, - error - )} - - + {({ data: orderedData, handleOrderChange, order, orderBy }) => ( + = 1280} + spacingTop={16} + > + + + + Process + + + User + + + Max Count + + + Avg IO + + + Avg CPU + + + Avg Mem + + + + + {renderLoadingErrorData( + processesLoading, + orderedData, + selectedProcess, + setSelectedProcess, + error + )} + + + )} + ); }); diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.test.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.test.tsx index a862e521501..8c6aec75224 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.test.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.test.tsx @@ -1,12 +1,11 @@ +import { render } from '@testing-library/react'; import * as React from 'react'; import { longviewTopProcessesFactory } from 'src/factories/longviewTopProcesses'; -import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; +import { LongviewTopProcesses } from 'src/features/Longview/request.types'; +import { wrapWithTheme } from 'src/utilities/testHelpers'; -import { TopProcesses, extendTopProcesses } from './TopProcesses'; - -import type { Props } from './TopProcesses'; -import type { LongviewTopProcesses } from 'src/features/Longview/request.types'; +import { Props, TopProcesses, extendTopProcesses } from './TopProcesses'; const props: Props = { clientID: 1, @@ -20,52 +19,54 @@ describe('Top Processes', () => { vi.clearAllMocks(); }); - it('renders the title', async () => { - const { getByText } = await renderWithThemeAndRouter( - - ); + it('renders the title', () => { + const { getByText } = render(wrapWithTheme()); getByText('Top Processes'); }); - it('renders the View Details link', async () => { - const { queryByText } = await renderWithThemeAndRouter( - + it('renders the View Details link', () => { + const { queryByText } = render( + wrapWithTheme() ); expect(queryByText('View Details')).toBeDefined(); }); - it('renders rows for each process', async () => { + it('renders rows for each process', () => { const data = longviewTopProcessesFactory.build(); // The component renders a maximum of 6 rows. Assert our test data has // fewer than seven processes so the test is valid. expect(Object.keys(data.Processes || {}).length).toBeLessThan(7); - const { getByText } = await renderWithThemeAndRouter( - + const { getByText } = render( + wrapWithTheme() ); Object.keys(data.Processes || {}).forEach((processName) => { getByText(processName); }); }); - it('renders loading state', async () => { - const { getAllByTestId } = await renderWithThemeAndRouter( - + it('renders loading state', () => { + const { getAllByTestId } = render( + wrapWithTheme( + + ) ); getAllByTestId('table-row-loading'); }); - it('renders error state', async () => { - const { getByText } = await renderWithThemeAndRouter( - + it('renders error state', () => { + const { getByText } = render( + wrapWithTheme( + + ) ); getByText('There was an error getting Top Processes.'); }); diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx index eb12587cff3..f988ef09c16 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx @@ -1,6 +1,7 @@ import { Box, Typography } from '@linode/ui'; import * as React from 'react'; +import OrderBy from 'src/components/OrderBy'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; @@ -10,7 +11,6 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; -import { useOrderV2 } from 'src/hooks/useOrderV2'; import { readableBytes } from 'src/utilities/unitConversions'; import { formatCPU } from '../../shared/formatters'; @@ -40,24 +40,6 @@ export const TopProcesses = React.memo((props: Props) => { topProcessesLoading, } = props; - const { - handleOrderChange, - order, - orderBy, - sortedData, - } = useOrderV2({ - data: extendTopProcesses(topProcessesData), - initialRoute: { - defaultOrder: { - order: 'desc', - orderBy: 'cpu', - }, - from: '/longview/clients/$id/overview', - }, - preferenceKey: 'top-processes', - prefix: 'top-processes', - }); - const errorMessage = Boolean(topProcessesError || lastUpdatedError) ? 'There was an error getting Top Processes.' : undefined; @@ -70,49 +52,58 @@ export const TopProcesses = React.memo((props: Props) => { View Details - - - - - Process - - - CPU - - - Memory - - - - - {renderLoadingErrorData( - sortedData ?? [], - topProcessesLoading, - errorMessage - )} - -
    + + {({ data: orderedData, handleOrderChange, order, orderBy }) => ( + + + + + Process + + + CPU + + + Memory + + + + + {renderLoadingErrorData( + orderedData, + topProcessesLoading, + errorMessage + )} + +
    + )} +
    ); }); diff --git a/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx b/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx index c1c166f33cf..e95e5130623 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx @@ -1,6 +1,8 @@ import { CircleProgress, Notice, Paper } from '@linode/ui'; +import { createLazyRoute } from '@tanstack/react-router'; import { pathOr } from 'ramda'; import * as React from 'react'; +import { matchPath } from 'react-router-dom'; import { compose } from 'recompose'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; @@ -8,13 +10,12 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { NotFound } from 'src/components/NotFound'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +import { TabLinkList } from 'src/components/Tabs/TabLinkList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; -import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import withLongviewClients from 'src/containers/longview.container'; import withClientStats from 'src/containers/longview.stats.container'; import { get } from 'src/features/Longview/request'; import { useAPIRequest } from 'src/hooks/useAPIRequest'; -import { useTabs } from 'src/hooks/useTabs'; import { useProfile } from 'src/queries/profile/profile'; import { useClientLastUpdated } from '../shared/useClientLastUpdated'; @@ -26,6 +27,7 @@ import { ProcessesLanding } from './DetailTabs/Processes/ProcessesLanding'; import { StyledTabs } from './LongviewDetail.styles'; import type { LongviewClient } from '@linode/api-v4/lib/longview'; +import type { RouteComponentProps } from 'react-router-dom'; import type { DispatchProps, Props as LVProps, @@ -51,7 +53,10 @@ const Overview = React.lazy( const Installation = React.lazy(() => import('./DetailTabs/Installation')); const Disks = React.lazy(() => import('./DetailTabs/Disks/Disks')); -export type CombinedProps = Props & LVDataProps & DispatchProps; +export type CombinedProps = RouteComponentProps<{ id: string }> & + Props & + LVDataProps & + DispatchProps; export const LongviewDetail = (props: CombinedProps) => { const { @@ -106,43 +111,59 @@ export const LongviewDetail = (props: CombinedProps) => { [clientAPIKey, lastUpdated] ); - const { handleTabChange, tabIndex, tabs } = useTabs([ + const tabOptions = [ { + display: true, + routeName: `${props.match.url}/overview`, title: 'Overview', - to: '/longview/clients/$id/overview', }, { + display: true, + routeName: `${props.match.url}/processes`, title: 'Processes', - to: '/longview/clients/$id/processes', }, { + display: true, + routeName: `${props.match.url}/network`, title: 'Network', - to: '/longview/clients/$id/network', }, { + display: true, + routeName: `${props.match.url}/disks`, title: 'Disks', - to: '/longview/clients/$id/disks', }, { - hide: !client?.apps.apache, + display: client && client.apps.apache, + routeName: `${props.match.url}/apache`, title: 'Apache', - to: '/longview/clients/$id/apache', }, { - hide: !client?.apps.nginx, + display: client && client.apps.nginx, + routeName: `${props.match.url}/nginx`, title: 'Nginx', - to: '/longview/clients/$id/nginx', }, { - hide: !client?.apps.mysql, + display: client && client.apps.mysql, + routeName: `${props.match.url}/mysql`, title: 'MySQL', - to: '/longview/clients/$id/mysql', }, { + display: true, + routeName: `${props.match.url}/installation`, title: 'Installation', - to: '/longview/clients/$id/installation', }, - ]); + ]; + + // Filtering out conditional tabs if they don't exist on client + const tabs = tabOptions.filter((tab) => tab.display === true); + + const matches = (p: string) => { + return Boolean(matchPath(p, { path: props.location.pathname })); + }; + + const navToURL = (index: number) => { + props.history.push(tabs[index].routeName); + }; if (longviewClientsLoading && longviewClientsLastUpdated === 0) { return ( @@ -173,14 +194,16 @@ export const LongviewDetail = (props: CombinedProps) => { return null; } + // Determining true tab count for indexing based on tab display + const displayedTabs = tabs.filter((tab) => tab.display === true); + return ( { variant="warning" /> ))} - - + matches(tab.routeName)), + 0 + )} + onChange={navToURL} + > + }> @@ -282,7 +311,7 @@ export const LongviewDetail = (props: CombinedProps) => { )} - + { ); }; -type LongviewDetailParams = { - id: string; -}; - const EnhancedLongviewDetail = compose( React.memo, - withClientStats<{ params: LongviewDetailParams }>((ownProps) => { + withClientStats>((ownProps) => { return +pathOr('', ['match', 'params', 'id'], ownProps); }), - withLongviewClients( + withLongviewClients>( ( own, { @@ -329,4 +354,16 @@ const EnhancedLongviewDetail = compose( ) )(LongviewDetail); +export const longviewDetailLazyRoute = createLazyRoute('/longview/clients/$id')( + { + component: React.lazy(() => + import('src/features/Longview/LongviewDetail/LongviewDetail').then( + () => ({ + default: (props: any) => , + }) + ) + ), + } +); + export default EnhancedLongviewDetail; diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx index ef356281aec..b3037b0411e 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx @@ -1,13 +1,12 @@ import { Autocomplete, Typography } from '@linode/ui'; -import { useLocation, useNavigate } from '@tanstack/react-router'; import { isEmpty, pathOr } from 'ramda'; import * as React from 'react'; import { connect } from 'react-redux'; +import { Link } from 'react-router-dom'; import { compose } from 'recompose'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { Link } from 'src/components/Link'; import withLongviewClients from 'src/containers/longview.container'; import { useAccountSettings } from 'src/queries/account/settings'; import { useGrants, useProfile } from 'src/queries/profile/profile'; @@ -32,8 +31,8 @@ import type { LongviewClient, LongviewSubscription, } from '@linode/api-v4/lib/longview/types'; +import type { RouteComponentProps } from 'react-router-dom'; import type { Props as LongviewProps } from 'src/containers/longview.container'; -import type { LongviewState } from 'src/routes/longview'; import type { State as StatsState } from 'src/store/longviewStats/longviewStats.reducer'; import type { MapState } from 'src/store/types'; @@ -48,15 +47,16 @@ interface SortOption { value: SortKey; } -export type LongviewClientsCombinedProps = Props & LongviewProps & StateProps; +export type LongviewClientsCombinedProps = Props & + RouteComponentProps & + LongviewProps & + StateProps; type SortKey = 'cpu' | 'load' | 'name' | 'network' | 'ram' | 'storage' | 'swap'; export const LongviewClients = (props: LongviewClientsCombinedProps) => { const { getLongviewClients } = props; - const navigate = useNavigate(); - const location = useLocation(); - const locationState = location.state as LongviewState; + const { data: profile } = useProfile(); const { data: grants } = useGrants(); const { data: accountSettings } = useAccountSettings(); @@ -130,16 +130,21 @@ export const LongviewClients = (props: LongviewClientsCombinedProps) => { }, []); const handleSubmit = () => { + const { + history: { push }, + } = props; + if (isManaged) { - navigate({ - state: (prev) => ({ ...prev, ...locationState }), - to: '/support/tickets', + push({ + pathname: '/support/tickets', + state: { + open: true, + title: 'Request for additional Longview clients', + }, }); return; } - navigate({ - to: '/longview/plan-details', - }); + props.history.push('/longview/plan-details'); }; /** @@ -294,7 +299,9 @@ const mapStateToProps: MapState = (state, _ownProps) => { const connected = connect(mapStateToProps); -export default compose( +interface ComposeProps extends Props, RouteComponentProps {} + +export default compose( React.memo, connected, withLongviewClients() diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.test.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.test.tsx index 69fa4a44863..0035b1657b2 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.test.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.test.tsx @@ -6,7 +6,7 @@ import { longviewClientFactory, longviewSubscriptionFactory, } from 'src/factories'; -import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; +import { renderWithTheme } from 'src/utilities/testHelpers'; import { LongviewClients, @@ -103,23 +103,19 @@ describe('Utility Functions', () => { }); describe('Longview clients list view', () => { - it('should request clients on load', async () => { - await renderWithThemeAndRouter(); + it('should request clients on load', () => { + renderWithTheme(); expect(props.getLongviewClients).toHaveBeenCalledTimes(1); }); it('should have an Add Client button', async () => { - const { findByText } = await renderWithThemeAndRouter( - - ); + const { findByText } = renderWithTheme(); const addButton = await findByText('Add Client'); expect(addButton).toBeInTheDocument(); }); it('should attempt to add a new client when the Add Client button is clicked', async () => { - const { getByText } = await renderWithThemeAndRouter( - - ); + const { getByText } = renderWithTheme(); const button = getByText('Add Client'); fireEvent.click(button); await waitFor(() => @@ -127,8 +123,8 @@ describe('Longview clients list view', () => { ); }); - it('should render a row for each client', async () => { - const { queryAllByTestId } = await renderWithThemeAndRouter( + it('should render a row for each client', () => { + const { queryAllByTestId } = renderWithTheme( ); @@ -137,16 +133,16 @@ describe('Longview clients list view', () => { ); }); - it('should render a CTA for non-Pro subscribers', async () => { - const { getByText } = await renderWithThemeAndRouter( + it('should render a CTA for non-Pro subscribers', () => { + const { getByText } = renderWithTheme( ); getByText(/upgrade to longview pro/i); }); - it('should not render a CTA for LV Pro subscribers', async () => { - const { queryAllByText } = await renderWithThemeAndRouter( + it('should not render a CTA for LV Pro subscribers', () => { + const { queryAllByText } = renderWithTheme( import('./LongviewClients')); const LongviewPlans = React.lazy(() => import('./LongviewPlans')); -export const LongviewLanding = (props: LongviewProps) => { - const navigate = useNavigate(); - const location = useLocation(); - const locationState = location.state as LongviewState; +interface LongviewLandingProps extends LongviewProps, RouteComponentProps<{}> {} + +export const LongviewLanding = (props: LongviewLandingProps) => { const { enqueueSnackbar } = useSnackbar(); const activeSubscriptionRequestHook = useAPIRequest( () => getActiveLongviewPlan().then((response) => response), @@ -62,30 +61,37 @@ export const LongviewLanding = (props: LongviewProps) => { setSubscriptionDialogOpen, ] = React.useState(false); - const { handleTabChange, tabIndex, tabs } = useTabs([ + const tabs = [ + /* NB: These must correspond to the routes inside the Switch */ { + routeName: `${props.match.url}/clients`, title: 'Clients', - to: '/longview/clients', }, { + routeName: `${props.match.url}/plan-details`, title: 'Plan Details', - to: '/longview/plan-details', }, - ]); + ]; const isLongviewCreationRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_longview', }); + const matches = (p: string) => { + return Boolean(matchPath(p, { path: props.location.pathname })); + }; + + const navToURL = (index: number) => { + props.history.push(tabs[index].routeName); + }; + const handleAddClient = () => { setNewClientLoading(true); createLongviewClient() .then((_) => { setNewClientLoading(false); - if (location.pathname !== '/longview/clients') { - navigate({ - to: '/longview/clients', - }); + if (props.history.location.pathname !== '/longview/clients') { + props.history.push('/longview/clients'); } }) .catch((errorResponse) => { @@ -107,39 +113,51 @@ export const LongviewLanding = (props: LongviewProps) => { }; const handleSubmit = () => { + const { + history: { push }, + } = props; + if (isManaged) { - navigate({ - state: (prev) => ({ ...prev, ...locationState }), - to: '/support/tickets', + push({ + pathname: '/support/tickets', + state: { + open: true, + title: 'Request for additional Longview clients', + }, }); return; } - navigate({ - to: '/longview/plan-details', - }); + props.history.push('/longview/plan-details'); }; return ( <> - - + matches(tab.routeName)), + 0 + )} + onChange={navToURL} + > + + }> @@ -181,4 +199,8 @@ const StyledTabs = styled(Tabs, { marginTop: 0, })); +export const longviewLandingLazyRoute = createLazyRoute('/longview')({ + component: LongviewLanding, +}); + export default withLongviewClients()(LongviewLanding); diff --git a/packages/manager/src/features/Longview/index.tsx b/packages/manager/src/features/Longview/index.tsx new file mode 100644 index 00000000000..6d2950f8e90 --- /dev/null +++ b/packages/manager/src/features/Longview/index.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { Route, RouteComponentProps, Switch } from 'react-router-dom'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; + +const LongviewLanding = React.lazy( + () => import('./LongviewLanding/LongviewLanding') +); +const LongviewDetail = React.lazy( + () => import('./LongviewDetail/LongviewDetail') +); + +type Props = RouteComponentProps<{}>; + +const Longview = (props: Props) => { + const { + match: { path }, + } = props; + + return ( + + + + }> + + + + + + + ); +}; + +export default Longview; diff --git a/packages/manager/src/features/Managed/Monitors/MonitorActionMenu.tsx b/packages/manager/src/features/Managed/Monitors/MonitorActionMenu.tsx index 6990e6475ac..cb23a01f830 100644 --- a/packages/manager/src/features/Managed/Monitors/MonitorActionMenu.tsx +++ b/packages/manager/src/features/Managed/Monitors/MonitorActionMenu.tsx @@ -1,20 +1,18 @@ +import { MonitorStatus } from '@linode/api-v4/lib/managed'; +import { APIError } from '@linode/api-v4/lib/types'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import { useSnackbar } from 'notistack'; +import { splitAt } from 'ramda'; import * as React from 'react'; -import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { useDisableMonitorMutation, useEnableMonitorMutation, } from 'src/queries/managed/managed'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { splitAt } from 'src/utilities/splitAt'; - -import type { MonitorStatus } from '@linode/api-v4/lib/managed'; -import type { APIError } from '@linode/api-v4/lib/types'; -import type { Action } from 'src/components/ActionMenu/ActionMenu'; export interface MonitorActionMenuProps { label: string; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx index b4ebe485e6d..104fb56d6d4 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx @@ -170,10 +170,9 @@ export const NodeBalancerConfigNode = React.memo( {!hideModeSelect && ( option.value === node.mode) ?? - modeOptions.find((option) => option.value === 'accept') - } + value={modeOptions.find( + (option) => option.value === node.mode + )} disableClearable disabled={disabled} errorText={nodesErrorMap.mode} diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx index a108f579ee5..0ba3cf6b091 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx @@ -39,7 +39,7 @@ import { lensFrom } from '../NodeBalancerCreate'; import { createNewNodeBalancerConfig, createNewNodeBalancerConfigNode, - getNodeForRequest, + nodeForRequest, parseAddress, parseAddresses, transformConfigsForRequest, @@ -281,7 +281,7 @@ class NodeBalancerConfigurations extends React.Component< const config = this.state.configs[configIdx]; const node = this.state.configs[configIdx].nodes[nodeIdx]; - const nodeData = getNodeForRequest(node, config); + const nodeData = nodeForRequest(node); if (!nodeBalancerId) { return; @@ -1031,7 +1031,7 @@ class NodeBalancerConfigurations extends React.Component< const config = this.state.configs[configIdx]; const node = this.state.configs[configIdx].nodes[nodeIdx]; - const nodeData = getNodeForRequest(node, config); + const nodeData = nodeForRequest(node); if (!nodeBalancerId) { return; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx index d7708c40cb4..016bbb7d2f9 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx @@ -107,7 +107,7 @@ export const TablesPanel = () => { { data: metrics, format: formatNumber, - legendColor: theme.graphs.purple, + legendColor: 'purple', legendTitle: 'Connections', }, ]} @@ -195,13 +195,13 @@ export const TablesPanel = () => { { data: getMetrics(trafficIn), format: formatBitsPerSecond, - legendColor: theme.graphs.darkGreen, + legendColor: 'darkGreen', legendTitle: 'Traffic In', }, { data: getMetrics(trafficOut), format: formatBitsPerSecond, - legendColor: theme.graphs.lightGreen, + legendColor: 'lightGreen', legendTitle: 'Traffic Out', }, ]} diff --git a/packages/manager/src/features/NodeBalancers/utils.ts b/packages/manager/src/features/NodeBalancers/utils.ts index 7dfd27a05d7..2cb8ea0e646 100644 --- a/packages/manager/src/features/NodeBalancers/utils.ts +++ b/packages/manager/src/features/NodeBalancers/utils.ts @@ -48,17 +48,11 @@ export const createNewNodeBalancerConfig = ( stickiness: SESSION_STICKINESS_DEFAULTS['http'], }); -export const getNodeForRequest = ( - node: NodeBalancerConfigNodeFields, - config: NodeBalancerConfigFields -) => ({ +export const nodeForRequest = (node: NodeBalancerConfigNodeFields) => ({ address: node.address, label: node.label, - /** - * `mode` should not be specified for UDP because UDP does not - * support the various different modes. - */ - mode: config.protocol !== 'udp' ? node.mode : undefined, + /* Force Node creation and updates to set mode to 'accept' */ + mode: node.mode, port: node.port, weight: +node.weight!, }); @@ -114,12 +108,10 @@ export const transformConfigsForRequest = ( check_timeout: !isNil(config.check_timeout) ? +config.check_timeout : undefined, - cipher_suite: shouldIncludeCipherSuite(config) - ? config.cipher_suite - : undefined, + cipher_suite: config.cipher_suite || undefined, id: undefined, nodebalancer_id: undefined, - nodes: config.nodes.map((node) => getNodeForRequest(node, config)), + nodes: config.nodes.map(nodeForRequest), nodes_status: undefined, port: config.port ? +config.port : undefined, protocol: @@ -151,10 +143,6 @@ export const transformConfigsForRequest = ( }); }; -const shouldIncludeCipherSuite = (config: NodeBalancerConfigFields) => { - return config.protocol !== 'udp'; -}; - export const shouldIncludeCheckPath = (config: NodeBalancerConfigFields) => { return ( (config.check === 'http' || config.check === 'http_body') && diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx index 594d77bb535..499f897df60 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx @@ -83,7 +83,7 @@ export const BucketRateLimitTable = ({ 'This endpoint type supports up to 750 Requests Per Second (RPS). ' )} Understand{' '} - + bucket rate limits . diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx index fdab373c4b7..b788bcd91d8 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx @@ -112,9 +112,7 @@ describe('PlacementGroupsDetailPanel', () => { ); expect(getByRole('combobox')).toBeDisabled(); - expect( - getByTestId('placement-groups-no-capability-notice') - ).toHaveTextContent( + expect(getByTestId('notice-warning')).toHaveTextContent( 'Currently, only specific regions support placement groups.' ); expect( diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx index e6cac186449..cb8a78ce1ad 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx @@ -274,13 +274,13 @@ export const PhoneVerification = ({ }))} slotProps={{ paper: { - sx: (theme) => ({ - border: `1px solid ${theme.tokens.color.Ultramarine[80]}`, + sx: { + border: '1px solid #3683dc', maxHeight: '285px', overflow: 'hidden', textWrap: 'nowrap', width: 'fit-content', - }), + }, }, }} textFieldProps={{ diff --git a/packages/manager/src/features/Search/ResultGroup.tsx b/packages/manager/src/features/Search/ResultGroup.tsx index a2f947c6790..3a946e6ab20 100644 --- a/packages/manager/src/features/Search/ResultGroup.tsx +++ b/packages/manager/src/features/Search/ResultGroup.tsx @@ -1,7 +1,8 @@ import Grid from '@mui/material/Unstable_Grid2'; -import { isEmpty } from 'ramda'; +import { isEmpty, splitAt } from 'ramda'; import * as React from 'react'; +import { Item } from 'src/components/EnhancedSelect/Select'; import { Hidden } from 'src/components/Hidden'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; @@ -9,13 +10,10 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { capitalize } from 'src/utilities/capitalize'; -import { splitAt } from 'src/utilities/splitAt'; import { StyledButton, StyledTypography } from './ResultGroup.styles'; import { ResultRow } from './ResultRow'; -import type { Item } from 'src/components/EnhancedSelect/Select'; - interface ResultGroupProps { entity: string; groupSize: number; diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx index d4908d61bf9..9ebcfefbdac 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx @@ -43,11 +43,9 @@ import type { FileAttachment } from '../index'; import type { AttachmentError } from '../SupportTicketDetail/SupportTicketDetail'; import type { AccountLimitCustomFields } from './SupportTicketAccountLimitFields'; import type { SMTPCustomFields } from './SupportTicketSMTPFields'; -import type { - CreateKubeClusterPayload, - CreateLinodeRequest, - TicketSeverity, -} from '@linode/api-v4'; +import type { CreateKubeClusterPayload } from '@linode/api-v4'; +import type { TicketSeverity } from '@linode/api-v4/lib/support'; +import type { CreateLinodeRequest } from '@linode/api-v4/src/linodes/types'; import type { EntityForTicketDetails } from 'src/components/SupportLink/SupportLink'; interface Accumulator { diff --git a/packages/manager/src/features/Users/UserPermissions.tsx b/packages/manager/src/features/Users/UserPermissions.tsx index 3f00eb15b54..975d4a0b3a7 100644 --- a/packages/manager/src/features/Users/UserPermissions.tsx +++ b/packages/manager/src/features/Users/UserPermissions.tsx @@ -55,7 +55,7 @@ import type { User, } from '@linode/api-v4/lib/account'; import type { APIError } from '@linode/api-v4/lib/types'; -import type { SelectOption } from '@linode/ui'; +import type { SelectOptionType } from '@linode/ui'; import type { QueryClient } from '@tanstack/react-query'; import type { WithFeatureFlagProps } from 'src/containers/flags.container'; import type { WithQueryClientProps } from 'src/containers/withQueryClient.container'; @@ -788,7 +788,7 @@ class UserPermissions extends React.Component { }); }; - setAllEntitiesTo = (e: SelectOption | null | undefined) => { + setAllEntitiesTo = (e: SelectOptionType | null | undefined) => { const value = e?.value === 'null' ? null : e?.value; this.entityPerms.map((entity: GrantType) => this.entitySetAllTo(entity, value as GrantLevel)() diff --git a/packages/manager/src/features/VPCs/utils.ts b/packages/manager/src/features/VPCs/utils.ts index ae27bd54f9c..3c622b585c3 100644 --- a/packages/manager/src/features/VPCs/utils.ts +++ b/packages/manager/src/features/VPCs/utils.ts @@ -1,5 +1,3 @@ -import { getPrimaryInterfaceIndex } from '../Linodes/LinodesDetail/LinodeConfigs/utilities'; - import type { Config, Subnet } from '@linode/api-v4'; export const getUniqueLinodesFromSubnets = (subnets: Subnet[]) => { @@ -37,9 +35,8 @@ export const hasUnrecommendedConfiguration = ( const configInterfaces = config.interfaces; /* - If there is a VPC interface marked as active but not primary, we then check if it - is implicitly the primary interface. If not, then we want to display a message - re: it not being a recommended configuration. + If there is a VPC interface marked as active but not primary, we want to display a + message re: it not being a recommended configuration. Rationale: when the VPC interface is not the primary interface, it can communicate to other VMs within the same subnet, but not to VMs in a different subnet @@ -49,21 +46,12 @@ export const hasUnrecommendedConfiguration = ( if ( configInterfaces.some((_interface) => _interface.subnet_id === subnetId) ) { - const nonExplicitPrimaryVPCInterfaceIndex = configInterfaces.findIndex( + return configInterfaces.some( (_interface) => _interface.active && _interface.purpose === 'vpc' && !_interface.primary ); - - const primaryInterfaceIndex = getPrimaryInterfaceIndex(configInterfaces); - - return ( - // if there exists an active VPC interface not explicitly marked as primary, - nonExplicitPrimaryVPCInterfaceIndex !== -1 && - // check if it actually is the (implicit) primary interface - primaryInterfaceIndex !== nonExplicitPrimaryVPCInterfaceIndex - ); } } diff --git a/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx b/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx index 18b14888836..471b83ba3c8 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx @@ -20,7 +20,7 @@ export interface PlanSelectionFilterOptionsTable { planFilter?: (plan: PlanWithAvailability) => boolean; } -export interface PlanSelectionDividers { +interface PlanSelectionDividers { flag: boolean; planType: LinodeTypeClass; tables: PlanSelectionFilterOptionsTable[]; diff --git a/packages/manager/src/features/components/PlansPanel/PlanInformation.test.tsx b/packages/manager/src/features/components/PlansPanel/PlanInformation.test.tsx index ed8357b59d8..37be697e66b 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanInformation.test.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanInformation.test.tsx @@ -11,7 +11,6 @@ import { import type { PlanInformationProps } from './PlanInformation'; const mockProps: PlanInformationProps = { - flow: 'linode', hasMajorityOfPlansDisabled: false, hasSelectedRegion: true, isAPLEnabled: true, diff --git a/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx b/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx index b49ccd659c6..7b4d9386cef 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx @@ -29,7 +29,6 @@ interface ExtendedPlanType { export interface PlanInformationProps extends ExtendedPlanType { disabledClasses?: LinodeTypeClass[]; - flow: 'kubernetes' | 'linode'; hasMajorityOfPlansDisabled: boolean; hasSelectedRegion: boolean; hideLimitedAvailabilityBanner?: boolean; @@ -41,7 +40,6 @@ export interface PlanInformationProps extends ExtendedPlanType { export const PlanInformation = (props: PlanInformationProps) => { const { disabledClasses, - flow, hasMajorityOfPlansDisabled, hasSelectedRegion, hideLimitedAvailabilityBanner, @@ -98,7 +96,7 @@ export const PlanInformation = (props: PlanInformationProps) => { )} - {showTransferBanner && flow === 'linode' && transferBanner} + {showTransferBanner && transferBanner} { plan )} disabledClasses={disabledClasses} - flow="linode" hasMajorityOfPlansDisabled={hasMajorityOfPlansDisabled} hasSelectedRegion={hasSelectedRegion} planType={plan} diff --git a/packages/manager/src/hooks/useOrderV2.test.tsx b/packages/manager/src/hooks/useOrderV2.test.tsx index 03cce394215..6e06c77020e 100644 --- a/packages/manager/src/hooks/useOrderV2.test.tsx +++ b/packages/manager/src/hooks/useOrderV2.test.tsx @@ -21,7 +21,7 @@ vi.mock('@tanstack/react-router', async () => { }); const queryClient = queryClientFactory(); -const defaultProps: UseOrderV2Props = { +const defaultProps: UseOrderV2Props = { initialRoute: { defaultOrder: { order: 'asc', diff --git a/packages/manager/src/hooks/useOrderV2.ts b/packages/manager/src/hooks/useOrderV2.ts index 74fb129713a..5e51f3345b6 100644 --- a/packages/manager/src/hooks/useOrderV2.ts +++ b/packages/manager/src/hooks/useOrderV2.ts @@ -1,7 +1,5 @@ import { useNavigate, useSearch } from '@tanstack/react-router'; -import React from 'react'; -import { sortData } from 'src/components/OrderBy'; import { useMutatePreferences, usePreferences, @@ -13,16 +11,7 @@ import type { OrderSetWithPrefix } from 'src/types/ManagerPreferences'; export type Order = 'asc' | 'desc'; -export interface UseOrderV2Props { - /** - * data to sort - * This is an optional prop to sort client side data, - * when useOrderV2 isn't used to just provide a sort order for our queries. - * - * We usually would rather add to sorting as a param to the query, - * but in some cases the endpoint won't allow it, or we can't get around inheriting the data from a parent component. - */ - data?: T[]; +export interface UseOrderV2Props { /** * initial order to use when no query params are present * Includes the from and search params @@ -54,17 +43,16 @@ export interface UseOrderV2Props { * When a user changes order using the handleOrderChange function, the query params are * updated and the user preferences are also updated. */ -export const useOrderV2 = ({ - data, +export const useOrderV2 = ({ initialRoute, preferenceKey, prefix, -}: UseOrderV2Props) => { +}: UseOrderV2Props) => { const { data: orderPreferences } = usePreferences( (preferences) => preferences?.sortKeys ); const { mutateAsync: updatePreferences } = useMutatePreferences(); - const searchParams = useSearch({ strict: false }); + const searchParams = useSearch({ from: initialRoute.from }); const navigate = useNavigate(); const getOrderValues = () => { @@ -130,10 +118,5 @@ export const useOrderV2 = ({ }); }; - const sortedData = React.useMemo( - () => (data ? sortData(orderBy, order)(data) : null), - [data, orderBy, order] - ); - - return { handleOrderChange, order, orderBy, sortedData }; + return { handleOrderChange, order, orderBy }; }; diff --git a/packages/manager/src/hooks/useTabs.ts b/packages/manager/src/hooks/useTabs.ts deleted file mode 100644 index 05a861f4341..00000000000 --- a/packages/manager/src/hooks/useTabs.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useMatchRoute } from '@tanstack/react-router'; -import * as React from 'react'; - -import type { LinkProps } from '@tanstack/react-router'; - -export interface Tab { - /** - * The chip to display in the tab (a helper icon if disabled for instance). - */ - chip?: React.JSX.Element | null; - /** - * Whether the tab is disabled. - */ - disabled?: boolean; - /** - * Whether the tab is hidden. - */ - hide?: boolean; - /** - * The icon to display in the tab (a helper icon if disabled for instance). - */ - icon?: React.ReactNode; - /** - * The title of the tab. - */ - title: string; - /** - * The path to navigate to when the tab is clicked. - */ - to: LinkProps['to']; -} - -/** - * This hook is a necessary evil to sync routing and tabs, - * since Reach Tabs maintains its own index state. - */ -export function useTabs(tabs: T[]) { - const matchRoute = useMatchRoute(); - - // Filter out hidden tabs - const visibleTabs = React.useMemo(() => tabs.filter((tab) => !tab.hide), [ - tabs, - ]); - - // Calculate current index based on route - const tabIndex = React.useMemo(() => { - const index = visibleTabs.findIndex((tab) => matchRoute({ to: tab.to })); - return index === -1 ? 0 : index; - }, [visibleTabs, matchRoute]); - - // Simple handler to satisfy Reach Tabs props - const handleTabChange = React.useCallback(() => { - // No-op - navigation is handled by Tanstack Router `Link` - }, []); - - return { - handleTabChange, - tabIndex, - tabs: visibleTabs, - }; -} diff --git a/packages/manager/src/mocks/presets/baseline/crud.ts b/packages/manager/src/mocks/presets/baseline/crud.ts index 6a82cef03dc..12679014c81 100644 --- a/packages/manager/src/mocks/presets/baseline/crud.ts +++ b/packages/manager/src/mocks/presets/baseline/crud.ts @@ -6,7 +6,6 @@ import { linodeCrudPreset } from 'src/mocks/presets/crud/linodes'; import { domainCrudPreset } from '../crud/domains'; import { placementGroupsCrudPreset } from '../crud/placementGroups'; -import { quotasCrudPreset } from '../crud/quotas'; import { supportTicketCrudPreset } from '../crud/supportTickets'; import { volumeCrudPreset } from '../crud/volumes'; @@ -17,7 +16,6 @@ export const baselineCrudPreset: MockPresetBaseline = { handlers: [ ...linodeCrudPreset.handlers, ...placementGroupsCrudPreset.handlers, - ...quotasCrudPreset.handlers, ...supportTicketCrudPreset.handlers, ...volumeCrudPreset.handlers, ...domainCrudPreset.handlers, diff --git a/packages/manager/src/mocks/presets/crud/handlers/quotas.ts b/packages/manager/src/mocks/presets/crud/handlers/quotas.ts deleted file mode 100644 index d01593a8214..00000000000 --- a/packages/manager/src/mocks/presets/crud/handlers/quotas.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { http } from 'msw'; - -import { quotaFactory } from 'src/factories/quotas'; -import { - makeNotFoundResponse, - makePaginatedResponse, - makeResponse, -} from 'src/mocks/utilities/response'; - -import type { Quota, QuotaType } from '@linode/api-v4'; -import type { StrictResponse } from 'msw'; -import type { - APIErrorResponse, - APIPaginatedResponse, -} from 'src/mocks/utilities/response'; - -const mockQuotas: Record = { - linode: [ - quotaFactory.build({ - description: - 'Max number of vCPUs assigned to Linodes with Dedicated plans', - quota_limit: 10, - quota_name: 'Dedicated CPU', - region_applied: 'us-east', - resource_metric: 'CPU', - used: 8, - }), - quotaFactory.build({ - description: 'Max number of vCPUs assigned to Linodes with Shared plans', - quota_limit: 25, - quota_name: 'Shared CPU', - region_applied: 'us-east', - resource_metric: 'CPU', - used: 22, - }), - quotaFactory.build({ - description: 'Max number of GPUs assigned to Linodes with GPU plans', - quota_limit: 10, - quota_name: 'GPU', - region_applied: 'us-east', - resource_metric: 'GPU', - used: 5, - }), - quotaFactory.build({ - description: 'Max number of VPUs assigned to Linodes with VPU plans', - quota_limit: 100, - quota_name: 'VPU', - region_applied: 'us-east', - resource_metric: 'VPU', - used: 20, - }), - quotaFactory.build({ - description: - 'Max number of vCPUs assigned to Linodes with High Memory plans', - quota_limit: 30, - quota_name: 'High Memory', - region_applied: 'us-east', - resource_metric: 'CPU', - used: 0, - }), - ], - lke: [ - quotaFactory.build({ - quota_limit: 20, - quota_name: 'Total number of Clusters', - region_applied: 'us-east', - resource_metric: 'cluster', - used: 12, - }), - ], - 'object-storage': [ - quotaFactory.build({ - endpoint_type: 'E3', - quota_limit: 1_000_000_000_000_000, // a petabyte - quota_name: 'Total Capacity', - region_applied: 'us-east', - resource_metric: 'byte', - s3_endpoint: 'us-east-1.linodeobjects.com', - used: 900_000_000_000_000, - }), - quotaFactory.build({ - endpoint_type: 'E3', - quota_limit: 1000, - quota_name: 'Number of Buckets', - region_applied: 'us-east', - resource_metric: 'bucket', - s3_endpoint: 'us-east-1.linodeobjects.com', - }), - quotaFactory.build({ - endpoint_type: 'E3', - quota_limit: 10_000_000, - quota_name: 'Number of Objects', - region_applied: 'us-east', - resource_metric: 'object', - s3_endpoint: 'us-east-1.linodeobjects.com', - }), - ], -}; - -export const getQuotas = () => [ - http.get( - '*/v4/:service/quotas', - async ({ - params, - request, - }): Promise< - StrictResponse> - > => { - return makePaginatedResponse({ - data: mockQuotas[params.service as QuotaType], - request, - }); - } - ), - - http.get( - '*/v4/:service/quotas/:id', - async ({ params }): Promise> => { - const quota = mockQuotas[params.service as QuotaType].find( - ({ quota_id }) => quota_id === +params.id - ); - - if (!quota) { - return makeNotFoundResponse(); - } - - return makeResponse(quota); - } - ), -]; diff --git a/packages/manager/src/mocks/presets/crud/quotas.ts b/packages/manager/src/mocks/presets/crud/quotas.ts deleted file mode 100644 index 49ea495f568..00000000000 --- a/packages/manager/src/mocks/presets/crud/quotas.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { getQuotas } from './handlers/quotas'; - -import type { MockPresetCrud } from 'src/mocks/types'; - -export const quotasCrudPreset: MockPresetCrud = { - group: { id: 'Quotas' }, - handlers: [getQuotas], - id: 'quotas:crud', - label: 'Quotas CRUD', -}; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index d5e31f857b4..da3347b9894 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -19,9 +19,7 @@ import { accountFactory, accountMaintenanceFactory, accountTransferFactory, - alertDimensionsFactory, alertFactory, - alertRulesFactory, appTokenFactory, betaFactory, contactFactory, @@ -131,7 +129,6 @@ import type { VolumeStatus, } from '@linode/api-v4'; import { userPermissionsFactory } from 'src/factories/userPermissions'; -import { accountResourcesFactory } from 'src/factories/accountResources'; export const makeResourcePage = ( e: T[], @@ -401,12 +398,6 @@ const iam = [ }), ]; -const resources = [ - http.get('*/v4*/resources', () => { - return HttpResponse.json(accountResourcesFactory.build()); - }), -]; - const nanodeType = linodeTypeFactory.build({ id: 'g6-nanode-1' }); const standardTypes = linodeTypeFactory.buildList(7); const dedicatedTypes = dedicatedTypeFactory.buildList(7); @@ -1782,16 +1773,13 @@ export const handlers = [ return HttpResponse.json({}); }), http.get('*/longview/plan', () => { - const plan = longviewActivePlanFactory.build({}); + const plan = longviewActivePlanFactory.build(); return HttpResponse.json(plan); }), http.get('*/longview/subscriptions', () => { const subscriptions = longviewSubscriptionFactory.buildList(10); return HttpResponse.json(makeResourcePage(subscriptions)); }), - http.post('https://longview.linode.com/fetch', () => { - return HttpResponse.json({}); - }), http.get('*/longview/clients', () => { const clients = longviewClientFactory.buildList(10); return HttpResponse.json(makeResourcePage(clients)); @@ -2459,14 +2447,6 @@ export const handlers = [ return HttpResponse.json( alertFactory.build({ id: Number(params.id), - rule_criteria: { - rules: [ - ...alertRulesFactory.buildList(2, { - dimension_filters: alertDimensionsFactory.buildList(2), - }), - ...alertRulesFactory.buildList(1, { dimension_filters: [] }), - ], - }, service_type: params.serviceType === 'linode' ? 'linode' : 'dbaas', }) ); @@ -2783,5 +2763,4 @@ export const handlers = [ ...databases, ...vpc, ...iam, - ...resources, ]; diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts index 1ef6da194fb..7597a52cd2f 100644 --- a/packages/manager/src/mocks/types.ts +++ b/packages/manager/src/mocks/types.ts @@ -85,7 +85,6 @@ export type MockPresetCrudGroup = { | 'Domains' | 'Linodes' | 'Placement Groups' - | 'Quotas' | 'Support Tickets' | 'Volumes'; }; @@ -93,7 +92,6 @@ export type MockPresetCrudId = | 'domains:crud' | 'linodes:crud' | 'placement-groups:crud' - | 'quotas:crud' | 'support-tickets:crud' | 'volumes:crud'; export interface MockPresetCrud extends MockPresetBase { diff --git a/packages/manager/src/queries/account/users.ts b/packages/manager/src/queries/account/users.ts index d6c05b3caa0..ae720f52cb7 100644 --- a/packages/manager/src/queries/account/users.ts +++ b/packages/manager/src/queries/account/users.ts @@ -1,4 +1,4 @@ -import { createUser, deleteUser, updateUser } from '@linode/api-v4'; +import { deleteUser, updateUser } from '@linode/api-v4'; import { keepPreviousData, useMutation, @@ -119,21 +119,3 @@ function getIsBlocklistedUser(username: string) { } return false; } - -export const useCreateUserMutation = () => { - const queryClient = useQueryClient(); - - return useMutation>({ - mutationFn: (data) => createUser(data), - onSuccess: (user) => { - queryClient.invalidateQueries({ - queryKey: accountQueries.users._ctx.paginated._def, - }); - - queryClient.setQueryData( - accountQueries.users._ctx.user(user.username).queryKey, - user - ); - }, - }); -}; diff --git a/packages/manager/src/queries/cloudpulse/metrics.ts b/packages/manager/src/queries/cloudpulse/metrics.ts index 35983830f8c..f83b7fd98f9 100644 --- a/packages/manager/src/queries/cloudpulse/metrics.ts +++ b/packages/manager/src/queries/cloudpulse/metrics.ts @@ -52,12 +52,12 @@ export const useCloudPulseMetricsQuery = ( const currentJWEtokenCache: | JWEToken | undefined = queryClient.getQueryData( - queryFactory.token(serviceType, { entity_ids: [] }).queryKey + queryFactory.token(serviceType, { resource_ids: [] }).queryKey ); if (currentJWEtokenCache?.token === obj.authToken) { queryClient.invalidateQueries( { - queryKey: queryFactory.token(serviceType, { entity_ids: [] }) + queryKey: queryFactory.token(serviceType, { resource_ids: [] }) .queryKey, }, { diff --git a/packages/manager/src/queries/cloudpulse/queries.ts b/packages/manager/src/queries/cloudpulse/queries.ts index 7febca23c3a..dbd1fac2c14 100644 --- a/packages/manager/src/queries/cloudpulse/queries.ts +++ b/packages/manager/src/queries/cloudpulse/queries.ts @@ -100,6 +100,6 @@ export const queryFactory = createQueryKeys(key, { token: (serviceType: string | undefined, request: JWETokenPayLoad) => ({ queryFn: () => getJWEToken(request, serviceType!), - queryKey: [serviceType, { resource_ids: request.entity_ids.sort() }], + queryKey: [serviceType, { resource_ids: request.resource_ids.sort() }], }), }); diff --git a/packages/manager/src/queries/quotas/quotas.ts b/packages/manager/src/queries/quotas/quotas.ts deleted file mode 100644 index 8f1ba05067c..00000000000 --- a/packages/manager/src/queries/quotas/quotas.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { getQuota, getQuotas } from '@linode/api-v4'; -import { createQueryKeys } from '@lukemorales/query-key-factory'; -import { keepPreviousData, useQuery } from '@tanstack/react-query'; - -import { getAllQuotas } from './requests'; - -import type { - APIError, - Filter, - Params, - Quota, - QuotaType, - ResourcePage, -} from '@linode/api-v4'; - -export const quotaQueries = createQueryKeys('quotas', { - service: (type: QuotaType) => ({ - contextQueries: { - all: (params: Params = {}, filter: Filter = {}) => ({ - queryFn: () => getAllQuotas(type, params, filter), - queryKey: [params, filter], - }), - paginated: (params: Params = {}, filter: Filter = {}) => ({ - queryFn: () => getQuotas(type, params, filter), - queryKey: [params, filter], - }), - quota: (id: number) => ({ - queryFn: () => getQuota(type, id), - queryKey: [id], - }), - }, - queryKey: [type], - }), -}); - -export const useQuotaQuery = (service: QuotaType, id: number, enabled = true) => - useQuery({ - ...quotaQueries.service(service)._ctx.quota(id), - enabled, - }); - -export const useQuotasQuery = ( - service: QuotaType, - params: Params = {}, - filter: Filter, - enabled = true -) => - useQuery, APIError[]>({ - ...quotaQueries.service(service)._ctx.paginated(params, filter), - enabled, - placeholderData: keepPreviousData, - }); - -export const useAllQuotasQuery = ( - service: QuotaType, - params: Params = {}, - filter: Filter, - enabled = true -) => - useQuery({ - ...quotaQueries.service(service)._ctx.all(params, filter), - enabled, - }); diff --git a/packages/manager/src/queries/quotas/requests.ts b/packages/manager/src/queries/quotas/requests.ts deleted file mode 100644 index 3230da3acf2..00000000000 --- a/packages/manager/src/queries/quotas/requests.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { getQuotas } from '@linode/api-v4'; - -import { getAll } from 'src/utilities/getAll'; - -import type { Filter, Params, Quota, QuotaType } from '@linode/api-v4'; - -export const getAllQuotas = ( - service: QuotaType, - passedParams: Params = {}, - passedFilter: Filter = {} -) => - getAll((params, filter) => - getQuotas( - service, - { ...params, ...passedParams }, - { ...filter, ...passedFilter } - ) - )().then((data) => data.data); diff --git a/packages/manager/src/queries/volumes/volumes.ts b/packages/manager/src/queries/volumes/volumes.ts index e96b6ce85af..7c0add54f1f 100644 --- a/packages/manager/src/queries/volumes/volumes.ts +++ b/packages/manager/src/queries/volumes/volumes.ts @@ -26,18 +26,15 @@ import { profileQueries } from '../profile/profile'; import { getAllVolumeTypes, getAllVolumes } from './requests'; import type { - APIError, AttachVolumePayload, CloneVolumePayload, - Filter, - Params, - PriceType, ResizeVolumePayload, - ResourcePage, UpdateVolumeRequest, Volume, VolumeRequestPayload, } from '@linode/api-v4'; +import type { APIError, ResourcePage } from '@linode/api-v4/lib/types'; +import type { Filter, Params, PriceType } from '@linode/api-v4/src/types'; export const volumeQueries = createQueryKeys('volumes', { linode: (linodeId: number) => ({ diff --git a/packages/manager/src/routes/index.tsx b/packages/manager/src/routes/index.tsx index 8fd5ed337ae..9aa1261c7a6 100644 --- a/packages/manager/src/routes/index.tsx +++ b/packages/manager/src/routes/index.tsx @@ -87,7 +87,6 @@ declare module '@tanstack/react-router' { export const migrationRouteTree = migrationRootRoute.addChildren([ betaRouteTree, domainsRouteTree, - longviewRouteTree, volumesRouteTree, ]); export type MigrationRouteTree = typeof migrationRouteTree; diff --git a/packages/manager/src/routes/longview/LongviewRoute.tsx b/packages/manager/src/routes/longview/LongviewRoute.tsx index df2ba7547cb..57907e7f20d 100644 --- a/packages/manager/src/routes/longview/LongviewRoute.tsx +++ b/packages/manager/src/routes/longview/LongviewRoute.tsx @@ -1,14 +1,12 @@ import { Outlet } from '@tanstack/react-router'; import React from 'react'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; export const LongviewRoute = () => { return ( }> - diff --git a/packages/manager/src/routes/longview/index.ts b/packages/manager/src/routes/longview/index.ts index 48f3a61668e..b684180b509 100644 --- a/packages/manager/src/routes/longview/index.ts +++ b/packages/manager/src/routes/longview/index.ts @@ -1,13 +1,8 @@ -import { createRoute, redirect } from '@tanstack/react-router'; +import { createRoute } from '@tanstack/react-router'; import { rootRoute } from '../root'; import { LongviewRoute } from './LongviewRoute'; -export type LongviewState = { - open?: boolean; - title?: string; -}; - const longviewRoute = createRoute({ component: LongviewRoute, getParentRoute: () => rootRoute, @@ -15,27 +10,30 @@ const longviewRoute = createRoute({ }); const longviewLandingRoute = createRoute({ - beforeLoad: () => { - throw redirect({ to: '/longview/clients' }); - }, getParentRoute: () => longviewRoute, path: '/', }).lazy(() => - import('./longviewLazyRoutes').then((m) => m.longviewLandingLazyRoute) + import('src/features/Longview/LongviewLanding/LongviewLanding').then( + (m) => m.longviewLandingLazyRoute + ) ); const longviewLandingClientsRoute = createRoute({ getParentRoute: () => longviewRoute, path: 'clients', }).lazy(() => - import('./longviewLazyRoutes').then((m) => m.longviewLandingLazyRoute) + import('src/features/Longview/LongviewLanding/LongviewLanding').then( + (m) => m.longviewLandingLazyRoute + ) ); const longviewLandingPlanDetailsRoute = createRoute({ getParentRoute: () => longviewRoute, path: 'plan-details', }).lazy(() => - import('./longviewLazyRoutes').then((m) => m.longviewLandingLazyRoute) + import('src/features/Longview/LongviewLanding/LongviewLanding').then( + (m) => m.longviewLandingLazyRoute + ) ); const longviewDetailRoute = createRoute({ @@ -45,63 +43,54 @@ const longviewDetailRoute = createRoute({ }), path: 'clients/$id', }).lazy(() => - import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) + import('src/features/Longview/LongviewDetail/LongviewDetail').then( + (m) => m.longviewDetailLazyRoute + ) ); const longviewDetailOverviewRoute = createRoute({ getParentRoute: () => longviewDetailRoute, path: 'overview', }).lazy(() => - import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) + import('src/features/Longview/LongviewDetail/LongviewDetail').then( + (m) => m.longviewDetailLazyRoute + ) ); const longviewDetailProcessesRoute = createRoute({ getParentRoute: () => longviewDetailRoute, path: 'processes', }).lazy(() => - import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) + import('src/features/Longview/LongviewDetail/LongviewDetail').then( + (m) => m.longviewDetailLazyRoute + ) ); const longviewDetailNetworkRoute = createRoute({ getParentRoute: () => longviewDetailRoute, path: 'network', }).lazy(() => - import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) + import('src/features/Longview/LongviewDetail/LongviewDetail').then( + (m) => m.longviewDetailLazyRoute + ) ); const longviewDetailDisksRoute = createRoute({ getParentRoute: () => longviewDetailRoute, path: 'disks', }).lazy(() => - import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) + import('src/features/Longview/LongviewDetail/LongviewDetail').then( + (m) => m.longviewDetailLazyRoute + ) ); const longviewDetailInstallationRoute = createRoute({ getParentRoute: () => longviewDetailRoute, path: 'installation', }).lazy(() => - import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) -); - -const longviewDetailApacheRoute = createRoute({ - getParentRoute: () => longviewDetailRoute, - path: 'apache', -}).lazy(() => - import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) -); - -const longviewDetailNginxRoute = createRoute({ - getParentRoute: () => longviewDetailRoute, - path: 'nginx', -}).lazy(() => - import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) -); - -const longviewDetailMySQLRoute = createRoute({ - getParentRoute: () => longviewDetailRoute, - path: 'mysql', -}).lazy(() => - import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) + import('src/features/Longview/LongviewDetail/LongviewDetail').then( + (m) => m.longviewDetailLazyRoute + ) ); export const longviewRouteTree = longviewRoute.addChildren([ @@ -114,8 +103,5 @@ export const longviewRouteTree = longviewRoute.addChildren([ longviewDetailNetworkRoute, longviewDetailDisksRoute, longviewDetailInstallationRoute, - longviewDetailApacheRoute, - longviewDetailNginxRoute, - longviewDetailMySQLRoute, ]), ]); diff --git a/packages/manager/src/routes/longview/longviewLazyRoutes.tsx b/packages/manager/src/routes/longview/longviewLazyRoutes.tsx deleted file mode 100644 index 2e95cce42fc..00000000000 --- a/packages/manager/src/routes/longview/longviewLazyRoutes.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { createLazyRoute, useParams } from '@tanstack/react-router'; -import * as React from 'react'; - -import EnhancedLongviewDetail from 'src/features/Longview/LongviewDetail/LongviewDetail'; -import LongviewLanding from 'src/features/Longview/LongviewLanding/LongviewLanding'; - -export const longviewLandingLazyRoute = createLazyRoute('/longview')({ - component: LongviewLanding, -}); - -// Making a functional component to wrap the EnhancedLongviewDetail HOC -// Ideally we would refactor this and fetch the data properly but considering Longview is nearing its end of life -// we'll just match the legacy routing behavior -const LongviewDetailWrapper = () => { - const { id } = useParams({ from: '/longview/clients/$id' }); - const matchProps = { - match: { - params: { - id, - }, - }, - }; - - return ; -}; - -export const longviewDetailLazyRoute = createLazyRoute('/longview/clients/$id')( - { - component: LongviewDetailWrapper, - } -); diff --git a/packages/manager/src/routes/routes.test.tsx b/packages/manager/src/routes/routes.test.tsx new file mode 100644 index 00000000000..b760499ec50 --- /dev/null +++ b/packages/manager/src/routes/routes.test.tsx @@ -0,0 +1,87 @@ +import { RouterProvider } from '@tanstack/react-router'; +import { screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { migrationRouter } from './index'; +import { getAllRoutePaths } from './utils/allPaths'; + +import type { useQuery } from '@tanstack/react-query'; +// TODO: Tanstack Router - replace AnyRouter once migration is complete. +import type { AnyRouter } from '@tanstack/react-router'; + +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query'); + return { + ...actual, + useQuery: vi + .fn() + .mockImplementation((...args: Parameters) => { + const actualResult = (actual.useQuery as typeof useQuery)(...args); + return { + ...actualResult, + isLoading: false, + }; + }), + }; +}); + +const allMigrationPaths = getAllRoutePaths(migrationRouter); + +describe('Migration Router', () => { + const renderWithRouter = (initialEntry: string) => { + migrationRouter.invalidate(); + migrationRouter.navigate({ replace: true, to: initialEntry }); + + return renderWithTheme( + , + { + flags: { + selfServeBetas: true, + }, + } + ); + }; + + /** + * This test is meant to incrementally test all routes being added to the migration router. + * It will hopefully catch any issues with routes not being added or set up correctly: + * - Route is not found in the router + * - Route is found in the router but the component is not rendered + * - Route is found in the router and the component is rendered but missing a heading (which should be a requirement for all routes) + */ + test.each(allMigrationPaths)('route: %s', async (path) => { + renderWithRouter(path); + + await waitFor( + async () => { + const migrationRouter = screen.getByTestId('migration-router'); + const h1 = screen.getByRole('heading', { level: 1 }); + expect(migrationRouter).toBeInTheDocument(); + expect(h1).toBeInTheDocument(); + expect(h1).not.toHaveTextContent('Not Found'); + }, + { + timeout: 5000, + } + ); + }); + + it('should render the NotFound component for broken routes', async () => { + renderWithRouter('/broken-route'); + + await waitFor( + async () => { + const migrationRouter = screen.getByTestId('migration-router'); + const h1 = screen.getByRole('heading', { level: 1 }); + expect(migrationRouter).toBeInTheDocument(); + expect(h1).toBeInTheDocument(); + expect(h1).toHaveTextContent('Not Found'); + }, + { + timeout: 5000, + } + ); + }); +}); diff --git a/packages/manager/src/routes/utils/allPaths.ts b/packages/manager/src/routes/utils/allPaths.ts new file mode 100644 index 00000000000..b27cc54194e --- /dev/null +++ b/packages/manager/src/routes/utils/allPaths.ts @@ -0,0 +1,29 @@ +import type { AnyRouter } from '@tanstack/react-router'; + +/** + * This function is meant to be used for testing purposes only. + * It allows us to generate a list of all unique @tanstack/router paths defined in the routing factory. + * + * We import this util in routes.test.tsx to loop through all routes and test them. + * It probably should not be used for anything else than testing. + * + * TODO: Tanstack Router - replace AnyRouter once migration is complete. + */ +export const getAllRoutePaths = (router: AnyRouter): string[] => { + return router.flatRoutes + .map((route) => { + let path: string = route.id; + // Replace dynamic segments with placeholders + path = path.replace(/\/\$(\w+)/g, (_, segment) => { + if (segment.toLowerCase().includes('id')) { + return '/1'; + } else { + return `/mock-${segment}`; + } + }); + + return path; + }) + .filter((path) => path !== '/') + .filter(Boolean); +}; diff --git a/packages/manager/src/utilities/splitAt.test.ts b/packages/manager/src/utilities/splitAt.test.ts deleted file mode 100644 index 65acb0bb2fc..00000000000 --- a/packages/manager/src/utilities/splitAt.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { splitAt } from './splitAt'; - -describe('splitAt', () => { - // For arrays - it('splits an array at the given index', () => { - const result = splitAt(3, [1, 2, 3, 4, 5]); - expect(result).toEqual([ - [1, 2, 3], - [4, 5], - ]); - }); - - it('splits an array when index is 0', () => { - const result = splitAt(0, [1, 2, 3, 4, 5]); - expect(result).toEqual([[], [1, 2, 3, 4, 5]]); - }); - - it('splits an array when index is the length of the array', () => { - const result = splitAt(5, [1, 2, 3, 4, 5]); - expect(result).toEqual([[1, 2, 3, 4, 5], []]); - }); - - it('splits an array when index is the (length - 1) of the array', () => { - const result = splitAt(4, [1, 2, 3, 4, 5]); - expect(result).toEqual([[1, 2, 3, 4], [5]]); - }); - - it('splits an empty array', () => { - const result = splitAt(0, []); - expect(result).toEqual([[], []]); - }); - - it('splits an array of one element', () => { - const result = splitAt(1, [1]); - expect(result).toEqual([[1], []]); - }); - - it('splits an array at the given negative index', () => { - const result = splitAt(-1, [1, 2, 3, 4, 5]); - expect(result).toEqual([[1, 2, 3, 4], [5]]); - }); - - // For strings - it('splits a string at the given index', () => { - const result = splitAt(3, 'abcdefgh'); - expect(result).toEqual(['abc', 'defgh']); - }); - - it('splits a string when index is 0', () => { - const result = splitAt(0, 'abcdefgh'); - expect(result).toEqual(['', 'abcdefgh']); - }); - - it('splits a string when index is the length of the string', () => { - const result = splitAt(8, 'abcdefgh'); - expect(result).toEqual(['abcdefgh', '']); - }); - - it('splits a string when index is the (length - 1) of the string', () => { - const result = splitAt(7, 'abcdefgh'); - expect(result).toEqual(['abcdefg', 'h']); - }); - - it('splits an empty string', () => { - const result = splitAt(0, ''); - expect(result).toEqual(['', '']); - }); - - it('splits a string with one character', () => { - const result = splitAt(1, 'a'); - expect(result).toEqual(['a', '']); - }); - - it('splits a string at the given negative index', () => { - const result = splitAt(-1, 'abcdefgh'); - expect(result).toEqual(['abcdefg', 'h']); - }); -}); diff --git a/packages/manager/src/utilities/splitAt.ts b/packages/manager/src/utilities/splitAt.ts deleted file mode 100644 index c88618533a1..00000000000 --- a/packages/manager/src/utilities/splitAt.ts +++ /dev/null @@ -1,18 +0,0 @@ -export function splitAt(index: number, input: T[]): [T[], T[]]; - -export function splitAt(index: number, input: string): [string, string]; - -/** - * Splits a given list or string at a given index. - * - * @param index - The index to split at. - * @param input - The list (array) or string to split. - * @returns An array containing two parts: the first part (0 to index), the second part (index to end). - * - * @example - * splitAt(3, [1, 2, 3, 4, 5]); // [[1, 2, 3], [4, 5]] - * splitAt(3, "hello"); // ["hel", "lo"] - */ -export function splitAt(index: number, input: T[] | string) { - return [input.slice(0, index), input.slice(index)]; -} diff --git a/packages/manager/src/utilities/testHelpers.tsx b/packages/manager/src/utilities/testHelpers.tsx index 56a78294b6d..0ae3424c2bf 100644 --- a/packages/manager/src/utilities/testHelpers.tsx +++ b/packages/manager/src/utilities/testHelpers.tsx @@ -15,7 +15,7 @@ import { mergeDeepRight } from 'ramda'; import * as React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { Provider } from 'react-redux'; -import { BrowserRouter, MemoryRouter, Route } from 'react-router-dom'; +import { MemoryRouter, Route } from 'react-router-dom'; import configureStore from 'redux-mock-store'; import thunk from 'redux-thunk'; @@ -206,9 +206,7 @@ export const wrapWithThemeAndRouter = ( options={{ bootstrap: options.flags }} > - - - + diff --git a/packages/manager/tsconfig.json b/packages/manager/tsconfig.json index 652807581c7..25ce8001a09 100644 --- a/packages/manager/tsconfig.json +++ b/packages/manager/tsconfig.json @@ -7,7 +7,7 @@ /* Modules */ "baseUrl": ".", - "moduleResolution": "bundler", + "moduleResolution": "node", "paths": { "src/*": ["src/*"] }, diff --git a/packages/search/README.md b/packages/search/README.md index bed7cb4d000..56980bfa80f 100644 --- a/packages/search/README.md +++ b/packages/search/README.md @@ -1,6 +1,6 @@ # Search -Search is a parser written with [Peggy](https://peggyjs.org) that takes a human readable search query and transforms it into a [Linode API v4 filter](https://techdocs.akamai.com/linode-api/reference/filtering-and-sorting). +Search is a parser written with [Peggy](https://peggyjs.org) that takes a human readable search query and transforms it into a [Linode API v4 filter](https://techdocs.akamai.com/linode-api/reference/filtering-and-sorting). The goal of this package is to provide a shared utility that enables a powerful, scalable, and consistent search experience throughout Akamai Connected Cloud Manager. @@ -30,14 +30,14 @@ label: my-volume and size >= 20 ## Supported Operations -| Operation | Aliases | Example | Description | -|-----------|----------------|--------------------------------|--------------------------------------------------------------------------------------------| -| `and` | `&`, `&&`, ` ` | `label: prod and size > 20` | Performs a boolean *and* on two expressions (whitespace is interpreted as "and") | -| `or` | `\|`, `\|\|` | `label: prod or size > 20` | Performs a boolean *or* on two expressions | -| `>` | None | `size > 20` | Greater than | -| `<` | None | `size < 20` | Less than | -| `>=` | None | `size >= 20` | Great than or equal to | -| `<=` | None | `size <= 20` | Less than or equal to | -| `!=` | None | `size != 1024` | Not equal to (does not work as a *not* for boolean expressions. Only works as "not equal") | -| `=` | None | `label = my-linode-1` | Equal to | -| `:` | `~` | `label: my-linode` | Contains | +| Operation | Aliases | Example | Description | +|-----------|----------------|--------------------------------|-----------------------------------------------------------------| +| `and` | `&`, `&&` | `label: prod and size > 20` | Performs a boolean *and* on two expressions | +| `or` | `|`, `||` | `label: prod or size > 20` | Performs a boolean *or* on two expressions | +| `>` | None | `size > 20` | Greater than | +| `<` | None | `size < 20` | Less than | +| `>=` | None | `size >= 20` | Great than or equal to | +| `<=` | None | `size <= 20` | Less than or equal to | +| `!` | `-` | `!label = my-linode-1` | Not equal to (does not work as a *not* for boolean expressions) | +| `=` | None | `label = my-linode-1` | Equal to | +| `:` | `~` | `label: my-linode` | Contains | diff --git a/packages/search/src/search.peggy b/packages/search/src/search.peggy index 1783ec4c103..2ca9b0fd5b0 100644 --- a/packages/search/src/search.peggy +++ b/packages/search/src/search.peggy @@ -49,7 +49,7 @@ TagQuery = "tag" ws* Contains ws* value:String { return { "tags": { "+contains": value } }; } NotEqualQuery - = key:FilterableField ws* NotEqual ws* value:SearchValue { return { [key]: { "+neq": value } }; } + = Not key:FilterableField ws* Equal ws* value:String { return { [key]: { "+neq": value } }; } LessThanQuery = key:FilterableField ws* Less ws* value:Number { return { [key]: { "+lt": value } }; } @@ -74,8 +74,9 @@ And / ws* '&' ws* / ws -NotEqual - = '!=' +Not + = '!' + / '-' Less = '<' diff --git a/packages/search/src/search.test.ts b/packages/search/src/search.test.ts index 3e2abb209f9..93e691c5081 100644 --- a/packages/search/src/search.test.ts +++ b/packages/search/src/search.test.ts @@ -35,17 +35,6 @@ describe("getAPIFilterFromQuery", () => { }); }); - it("handles +neq", () => { - const query = "status != active"; - - expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ - filter: { - status: { '+neq': "active" }, - }, - error: null, - }); - }); - it("handles +lt", () => { const query = "size < 20"; diff --git a/packages/search/tsconfig.json b/packages/search/tsconfig.json index a175d2d5de6..134d0055fe4 100644 --- a/packages/search/tsconfig.json +++ b/packages/search/tsconfig.json @@ -10,5 +10,5 @@ "forceConsistentCasingInFileNames": true, "incremental": true }, - "include": ["src"] + "include": ["src"], } diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index a64deb1d125..1dfb210c946 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,14 +1,3 @@ -## [2025-01-28] - v0.6.0 - - -### Changed: - -- Refactor and clean up `Notice` ([#11480](https://github.com/linode/manager/pull/11480)) - -### Removed: - -- `marketing` variant on `Notice` component ([#11480](https://github.com/linode/manager/pull/11480)) - ## [2025-01-14] - v0.5.0 ### Added: diff --git a/packages/ui/package.json b/packages/ui/package.json index 7dd09bfe9cb..5bc5b0b464f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@linode/ui", "author": "Linode", "description": "Linode UI component library", - "version": "0.6.0", + "version": "0.5.0", "type": "module", "main": "src/index.ts", "module": "src/index.ts", diff --git a/packages/ui/src/components/Notice/Notice.styles.ts b/packages/ui/src/components/Notice/Notice.styles.ts index cfb61d71c46..1e6b45ca9d3 100644 --- a/packages/ui/src/components/Notice/Notice.styles.ts +++ b/packages/ui/src/components/Notice/Notice.styles.ts @@ -1,25 +1,50 @@ import { makeStyles } from 'tss-react/mui'; -export const useStyles = makeStyles()((theme) => ({ +import type { Theme } from '@mui/material/styles'; + +export const useStyles = makeStyles< + void, + 'error' | 'icon' | 'important' | 'noticeText' +>()((theme: Theme, _params, classes) => ({ error: { + [`&.${classes.important}`]: { + borderLeftWidth: 32, + }, + borderLeft: `5px solid ${theme.palette.error.dark}`, + }, + errorList: { borderLeft: `5px solid ${theme.palette.error.dark}`, }, icon: { color: theme.tokens.color.Neutrals.White, + left: -25, // This value must be static regardless of theme selection position: 'absolute', - left: -25, - transform: "translateY(-50%)", - top: '50%', }, important: { - backgroundColor: theme.palette.background.paper, - borderLeftWidth: 32, - fontFamily: theme.font.normal, - padding: `${theme.spacing(1)} ${theme.spacing(2)}`, + '&.MuiGrid2-root': { + padding: theme.spacing(1), + paddingRight: 18, + }, + [`& .${classes.noticeText}`]: { + fontFamily: theme.font.normal, + }, + backgroundColor: theme.bg.bgPaper, }, info: { + [`&.${classes.important}`]: { + borderLeftWidth: 32, + }, + borderLeft: `5px solid ${theme.palette.info.dark}`, + }, + infoList: { borderLeft: `5px solid ${theme.palette.info.dark}`, }, + inner: { + width: '100%', + }, + marketing: { + borderLeft: `5px solid ${theme.color.green}`, + }, noticeText: { fontFamily: theme.font.bold, fontSize: '1rem', @@ -29,20 +54,37 @@ export const useStyles = makeStyles()((theme) => ({ '& + .notice': { marginTop: `${theme.spacing()} !important`, }, + [`& .${classes.error}`]: { + borderLeftColor: theme.color.red, + }, alignItems: 'center', borderRadius: 1, + display: 'flex', fontSize: '1rem', maxWidth: '100%', - padding: `${theme.spacing(0.5)} ${theme.spacing(2)}`, + padding: '4px 16px', + paddingRight: 18, position: 'relative', }, success: { + [`&.${classes.important}`]: { + borderLeftWidth: 32, + }, + borderLeft: `5px solid ${theme.palette.success.dark}`, + }, + successList: { borderLeft: `5px solid ${theme.palette.success.dark}`, }, warning: { + [`& .${classes.icon}`]: { + color: theme.tokens.color.Neutrals[80], + }, + [`&.${classes.important}`]: { + borderLeftWidth: 32, + }, borderLeft: `5px solid ${theme.palette.warning.dark}`, }, - warningIcon: { - color: theme.tokens.color.Neutrals[80], + warningList: { + borderLeft: `5px solid ${theme.palette.warning.dark}`, }, })); diff --git a/packages/ui/src/components/Notice/Notice.test.tsx b/packages/ui/src/components/Notice/Notice.test.tsx index 2e8675d54fa..86a6d5b651a 100644 --- a/packages/ui/src/components/Notice/Notice.test.tsx +++ b/packages/ui/src/components/Notice/Notice.test.tsx @@ -46,18 +46,13 @@ describe('Notice Component', () => { expect(container.firstChild).toHaveClass('custom-class'); }); - it('applies a default test-id based on the variant', () => { - const { getByTestId } = renderWithTheme(); - - expect(getByTestId('notice-success')).toBeInTheDocument(); - }); - - it('applies the dataTestId prop', () => { + it('applies dataTestId props', () => { const { getByTestId } = renderWithTheme( - + ); - expect(getByTestId('my-custom-test-id')).toBeInTheDocument(); + expect(getByTestId('notice-success')).toBeInTheDocument(); + expect(getByTestId('test-id')).toBeInTheDocument(); }); it('applies variant prop', () => { diff --git a/packages/ui/src/components/Notice/Notice.tsx b/packages/ui/src/components/Notice/Notice.tsx index 9c8591bcbc4..3108325401a 100644 --- a/packages/ui/src/components/Notice/Notice.tsx +++ b/packages/ui/src/components/Notice/Notice.tsx @@ -1,20 +1,23 @@ -import React from 'react'; +import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; +import * as React from 'react'; -import { - CheckIcon, - AlertIcon as ErrorIcon, - WarningIcon, -} from '../../assets/icons'; -import { Box } from '../Box'; +import { CheckIcon, AlertIcon as Error, WarningIcon } from '../../assets/icons'; +import { omittedProps } from '../../utilities'; import { Typography } from '../Typography'; import { useStyles } from './Notice.styles'; -import type { BoxProps } from '../Box'; import type { TypographyProps } from '../Typography'; +import type { Grid2Props } from '@mui/material/Unstable_Grid2'; -export type NoticeVariant = 'error' | 'info' | 'success' | 'warning'; +export type NoticeVariant = + | 'error' + | 'info' + | 'marketing' + | 'success' + | 'warning'; -export interface NoticeProps extends BoxProps { +export interface NoticeProps extends Grid2Props { /** * If true, the error will be treated as "static" and will not be included in the error group. * This will essentially disable the scroll to error behavior. @@ -46,7 +49,7 @@ export interface NoticeProps extends BoxProps { */ spacingTop?: 0 | 4 | 8 | 12 | 16 | 24 | 32; /** - * The text to display in the notice. If this is not provided, props.children will be used. + * The text to display in the error. If this is not provided, props.children will be used. */ text?: string; /** @@ -54,7 +57,7 @@ export interface NoticeProps extends BoxProps { */ typeProps?: TypographyProps; /** - * The variant of the notice. This will determine the color treatment of the error. + * The variant of the error. This will determine the color treatment of the error. */ variant?: NoticeVariant; } @@ -69,9 +72,9 @@ export interface NoticeProps extends BoxProps { ## Types of Notices: -- Success (green line) +- Success/Marketing (green line) - Info (blue line) -- Error (red line) +- Error/critical (red line) - Warning (yellow line) */ export const Notice = (props: NoticeProps) => { @@ -82,6 +85,7 @@ export const Notice = (props: NoticeProps) => { dataTestId, errorGroup, important, + onClick, spacingBottom, spacingLeft, spacingTop, @@ -89,18 +93,44 @@ export const Notice = (props: NoticeProps) => { text, typeProps, variant, - ...rest } = props; - const { classes, cx } = useStyles(); + const innerText = text ? ( + + {text} + + ) : null; + const variantMap = { error: variant === 'error', info: variant === 'info', + marketing: variant === 'marketing', success: variant === 'success', warning: variant === 'warning', }; + /** + * There are some cases where the message + * can be either a string or JSX. In those + * cases we should use props.children, but + * we want to make sure the string is wrapped + * in Typography and formatted as it would be + * if it were passed as props.text. + */ + const _children = + typeof children === 'string' ? ( + + {children} + + ) : ( + children + ); + const errorScrollClassName = bypassValidation ? '' : errorGroup @@ -117,59 +147,59 @@ export const Notice = (props: NoticeProps) => { }; return ( - ({ - marginBottom: - spacingBottom !== undefined - ? `${spacingBottom}px` - : theme.spacing(3), - marginLeft: spacingLeft !== undefined ? `${spacingLeft}px` : 0, - marginTop: spacingTop !== undefined ? `${spacingTop}px` : 0, - }), - ...(Array.isArray(sx) ? sx : [sx]), - ]} - role="alert" + - {important && variantMap.error && } - {important && variantMap.info && } - {important && variantMap.success && ( - - )} - {important && variantMap.warning && ( - - )} - {text || typeof children === 'string' ? ( - - {text ?? children} - - ) : ( - children - )} - + {important && + ((variantMap.success && ( + + )) || + ((variantMap.warning || variantMap.info) && ( + + )) || + (variantMap.error && ( + + )))} +
    + {innerText || _children} +
    + ); }; + +export const StyledNoticeGrid = styled(Grid, { + label: 'StyledNoticeGrid', + shouldForwardProp: omittedProps([ + 'spacingBottom', + 'spacingLeft', + 'spacingTop', + ]), +})(({ theme, ...props }) => ({ + marginBottom: props.spacingBottom ?? theme.spacing(3), + marginLeft: props.spacingLeft ?? 0, + marginTop: props.spacingTop ?? 0, +})); diff --git a/packages/ui/src/components/Select/Select.stories.tsx b/packages/ui/src/components/Select/Select.stories.tsx index 065b8ee8116..a0a19ea055a 100644 --- a/packages/ui/src/components/Select/Select.stories.tsx +++ b/packages/ui/src/components/Select/Select.stories.tsx @@ -4,18 +4,18 @@ import { Box } from '../Box'; import { Typography } from '../Typography'; import { Select } from './Select'; -import type { SelectOption, SelectProps } from './Select'; +import type { SelectProps } from './Select'; import type { Meta, StoryObj } from '@storybook/react'; -const meta: Meta> = { +const meta: Meta = { component: Select, decorators: [(Story) => {Story()}], title: 'Components/Selects/Select', }; -type Story = StoryObj>; +type Story = StoryObj; -const defaultArgs: SelectProps = { +const defaultArgs: SelectProps = { clearable: false, creatable: false, hideLabel: false, @@ -52,11 +52,7 @@ export const Creatable: Story = { onChange={(_, newValue) => setValue({ label: newValue?.label ?? '', - value: - newValue?.value - .toString() - .replaceAll(' ', '-') - .toLowerCase() ?? '', + value: newValue?.value.replace(' ', '-').toLowerCase() ?? '', }) } textFieldProps={{ diff --git a/packages/ui/src/components/Select/Select.tsx b/packages/ui/src/components/Select/Select.tsx index e178ba3c898..f120c57aeee 100644 --- a/packages/ui/src/components/Select/Select.tsx +++ b/packages/ui/src/components/Select/Select.tsx @@ -7,22 +7,12 @@ import { ListItem } from '../ListItem'; import { TextField } from '../TextField'; import type { EnhancedAutocompleteProps } from '../Autocomplete'; -import type { AutocompleteValue, SxProps } from '@mui/material'; -import type { Theme } from '@mui/material/styles'; -type Option = { +export type SelectOptionType = { label: string; - value: T; + value: string; }; - -export type SelectOption< - T = number | string, - Nullable extends boolean = false -> = Nullable extends true - ? AutocompleteValue, false, false, false> - : Option; - -interface InternalOptionType extends SelectOption { +interface InternalOptionType extends SelectOptionType { /** * Whether the option is a "create" option. * @@ -36,11 +26,9 @@ interface InternalOptionType extends SelectOption { */ noOptions?: boolean; } - -export interface SelectProps +export interface SelectProps extends Pick< - EnhancedAutocompleteProps, - | 'disabled' + EnhancedAutocompleteProps, | 'errorText' | 'helperText' | 'id' @@ -80,7 +68,10 @@ export interface SelectProps /** * The callback function that is invoked when the value changes. */ - onChange?: (_event: React.SyntheticEvent, _value: T) => void; + onChange?: ( + _event: React.SyntheticEvent, + _value: SelectProps['value'] + ) => void; /** * Whether the select is required. * @@ -93,10 +84,6 @@ export interface SelectProps * @default false */ searchable?: boolean; - /** - * The style overrides for the select. - */ - sx?: SxProps; } /** @@ -107,9 +94,7 @@ export interface SelectProps * * For any other use-cases, use the Autocomplete component directly. */ -export const Select = ( - props: SelectProps -) => { +export const Select = (props: SelectProps) => { const { clearable = false, creatable = false, @@ -128,21 +113,21 @@ export const Select = ( const handleChange = ( event: React.SyntheticEvent, - value: SelectOption | null | string + value: SelectOptionType | null | string ) => { if (creatable && typeof value === 'string') { onChange?.(event, { label: value, value, - } as T); + }); } else if (value && typeof value === 'object' && 'label' in value) { const { label, value: optionValue } = value; onChange?.(event, { label, value: optionValue, - } as T); + }); } else { - onChange?.(event, (null as unknown) as T); + onChange?.(event, null); } }; @@ -152,7 +137,7 @@ export const Select = ( ); return ( - + {...rest} isOptionEqualToValue={(option, value) => { if (!option || !value) { @@ -193,7 +178,6 @@ export const Select = ( label={label} placeholder={props.placeholder} required={props.required} - sx={sx} /> )} renderOption={(props, option: InternalOptionType) => { @@ -235,7 +219,7 @@ export const Select = ( disableClearable={!clearable} forcePopupIcon freeSolo={creatable} - getOptionDisabled={(option: SelectOption) => option.value === ''} + getOptionDisabled={(option: SelectOptionType) => option.value === ''} label={label} noOptionsText={noOptionsText} onChange={handleChange} @@ -250,7 +234,7 @@ interface GetOptionsProps { /** * Whether the select can create a new option. */ - creatable: boolean; + creatable: SelectProps['creatable']; /** * The input value. */ @@ -281,13 +265,13 @@ const getOptions = ({ creatable, inputValue, options }: GetOptionsProps) => { const matchingOptions = options.filter( (opt) => opt.label.toLowerCase().includes(inputValue.toLowerCase()) || - opt.value.toString().toLowerCase().includes(inputValue.toLowerCase()) + opt.value.toLowerCase().includes(inputValue.toLowerCase()) ); const exactMatch = matchingOptions.some( (opt) => opt.label.toLowerCase() === inputValue.toLowerCase() || - opt.value.toString().toLowerCase() === inputValue.toLowerCase() + opt.value.toLowerCase() === inputValue.toLowerCase() ); // If there's an exact match, don't show is as a create option diff --git a/packages/ui/src/foundations/breakpoints.ts b/packages/ui/src/foundations/breakpoints.ts index f888047e96d..be731cc5781 100644 --- a/packages/ui/src/foundations/breakpoints.ts +++ b/packages/ui/src/foundations/breakpoints.ts @@ -1,4 +1,4 @@ -import { Chart, Search } from '@linode/design-language-system'; +import { Chart } from '@linode/design-language-system'; import { createTheme } from '@mui/material'; // This is a hack to create breakpoints outside of the theme itself. @@ -13,6 +13,6 @@ export const breakpoints = createTheme({ xs: 0, }, }, + tokens: { chart: Chart }, name: 'light', - tokens: { chart: Chart, search: Search }, }).breakpoints; diff --git a/packages/ui/src/foundations/themes/dark.ts b/packages/ui/src/foundations/themes/dark.ts index 17b6c889ca7..720d7831895 100644 --- a/packages/ui/src/foundations/themes/dark.ts +++ b/packages/ui/src/foundations/themes/dark.ts @@ -11,7 +11,6 @@ import { Elevation, Interaction, NotificationToast, - Search, Select, TextField, Typography, @@ -215,23 +214,6 @@ export const darkTheme: ThemeOptions = { }, MuiAutocomplete: { styleOverrides: { - endAdornment: { - '.MuiAutocomplete-clearIndicator': { - visibility: 'visible !important', - }, - '.MuiAutocomplete-popupIndicator': { - svg: { - fontSize: '28px', - }, - }, - paddingRight: 4, - svg: { - ':hover': { - color: `${Color.Brand[50]} !important`, - }, - color: `${Search.Default.Icon} !important`, - }, - }, input: { '&::selection': { backgroundColor: customDarkModeOptions.bg.appBar, @@ -919,7 +901,6 @@ export const darkTheme: ThemeOptions = { content: Content, elevation: Elevation, interaction: Interaction, - search: Search, typography: Typography, }, typography: { diff --git a/packages/ui/src/foundations/themes/index.ts b/packages/ui/src/foundations/themes/index.ts index 9d3ca01007c..5ee8d7cad6d 100644 --- a/packages/ui/src/foundations/themes/index.ts +++ b/packages/ui/src/foundations/themes/index.ts @@ -18,7 +18,6 @@ import type { FontTypes, InteractionTypes as InteractionTypesLight, RadiusTypes, - SearchTypes as SearchTypesLight, SpacingTypes, TypographyTypes, } from '@linode/design-language-system'; @@ -30,7 +29,6 @@ import type { ContentTypes as ContentTypesDark, ElevationTypes as ElevationTypesDark, InteractionTypes as InteractionTypesDark, - SearchTypes as SearchTypesDark, } from '@linode/design-language-system/themes/dark'; import type { latoWeb } from '../fonts'; // Types & Interfaces @@ -55,7 +53,6 @@ type BorderTypes = MergeTypes; type ContentTypes = MergeTypes; type ElevationTypes = MergeTypes; type InteractionTypes = MergeTypes; -type SearchTypes = MergeTypes; type Fonts = typeof latoWeb; @@ -94,7 +91,7 @@ type NotificationToast = MergeTypes< * Avoid doing this unless you have a good reason. */ declare module '@mui/material/styles/createTheme' { - export interface Theme { + interface Theme { addCircleHoverEffect?: any; animateCircleIcon?: any; applyLinkStyles?: any; @@ -115,7 +112,6 @@ declare module '@mui/material/styles/createTheme' { borderRadius: BorderRadiusTypes; color: ColorTypes; font: FontTypes; - search: SearchTypes; spacing: SpacingTypes; // ---------------------------------------- accent: AccentTypes; @@ -132,7 +128,7 @@ declare module '@mui/material/styles/createTheme' { visually: any; } - export interface ThemeOptions { + interface ThemeOptions { addCircleHoverEffect?: any; animateCircleIcon?: any; applyLinkStyles?: any; @@ -153,7 +149,6 @@ declare module '@mui/material/styles/createTheme' { borderRadius?: BorderRadiusTypes; color?: ColorTypes; font?: FontTypes; - search: SearchTypes; spacing?: SpacingTypes; // ---------------------------------------- accent?: AccentTypes; diff --git a/packages/ui/src/foundations/themes/light.ts b/packages/ui/src/foundations/themes/light.ts index b4c466ae73f..33fe880eb8c 100644 --- a/packages/ui/src/foundations/themes/light.ts +++ b/packages/ui/src/foundations/themes/light.ts @@ -14,7 +14,6 @@ import { Interaction, NotificationToast, Radius, - Search, Select, Spacing, Typography, @@ -326,15 +325,16 @@ export const lightTheme: ThemeOptions = { }, '.MuiAutocomplete-popupIndicator': { svg: { + ':hover': { + opacity: 1, + }, fontSize: '28px', + opacity: 0.5, }, }, paddingRight: 4, svg: { - ':hover': { - color: `${Color.Brand[70]} !important`, - }, - color: `${Search.Default.Icon} !important`, + color: Color.Neutrals[40], }, }, groupLabel: { @@ -374,10 +374,7 @@ export const lightTheme: ThemeOptions = { borderTop: 0, }, option: { - '&.Mui-focused': { - backgroundColor: 'transparent', - }, - '&:hover': { + '&.Mui-focused, :hover': { backgroundColor: `${primaryColors.main} !important`, color: primaryColors.white, transition: 'background-color 0.2s', @@ -1677,7 +1674,6 @@ export const lightTheme: ThemeOptions = { font: Font, interaction: Interaction, radius: Radius, - search: Search, spacing: Spacing, typography: Typography, }, diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index 8de86910f9e..1709f51355b 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,15 +1,3 @@ -## [2025-01-28] - v0.59.0 - - -### Changed: - -- Allow `cipher_suite` to be `none` in NodeBalancer schemas ([#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)) - - ## [2025-01-14] - v0.58.0 ### Added: diff --git a/packages/validation/package.json b/packages/validation/package.json index df077e29fed..b8350e447b5 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.59.0", + "version": "0.58.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index cf0ef8883b9..de1f1d16d31 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -181,17 +181,7 @@ export const LinodeInterfaceSchema = object().shape({ }), }), }), - primary: boolean() - .test( - 'cant-use-with-vlan', - "VLAN interfaces can't be the primary interface", - (value, context) => { - const isVLANandIsSetToPrimary = - value && context.parent.purpose === 'vlan'; - return !isVLANandIsSetToPrimary; - } - ) - .notRequired(), + primary: boolean().notRequired(), subnet_id: number().when('purpose', { is: 'vpc', then: (schema) => diff --git a/packages/validation/src/nodebalancers.schema.ts b/packages/validation/src/nodebalancers.schema.ts index 84a8049d614..6c93bfee5f9 100644 --- a/packages/validation/src/nodebalancers.schema.ts +++ b/packages/validation/src/nodebalancers.schema.ts @@ -116,7 +116,7 @@ export const createNodeBalancerConfigSchema = object({ .typeError('Timeout must be a number.') .integer(), check: mixed().oneOf(['none', 'connection', 'http', 'http_body']), - cipher_suite: string().oneOf(['recommended', 'legacy', 'none']), + cipher_suite: mixed().oneOf(['recommended', 'legacy']), port: number() .integer() .required('Port is required') @@ -206,7 +206,7 @@ export const UpdateNodeBalancerConfigSchema = object({ .typeError('Timeout must be a number.') .integer(), check: mixed().oneOf(['none', 'connection', 'http', 'http_body']), - cipher_suite: string().oneOf(['recommended', 'legacy', 'none']), + cipher_suite: mixed().oneOf(['recommended', 'legacy']), port: number() .typeError('Port must be a number.') .integer() diff --git a/packages/validation/tsconfig.json b/packages/validation/tsconfig.json index 58df0f1dff7..6d5de643935 100644 --- a/packages/validation/tsconfig.json +++ b/packages/validation/tsconfig.json @@ -1,20 +1,20 @@ { "compilerOptions": { "target": "esnext", - "module": "esnext", + "module": "umd", "emitDeclarationOnly": true, "declaration": true, "outDir": "./lib", "esModuleInterop": true, - "moduleResolution": "bundler", + "moduleResolution": "node", "skipLibCheck": true, "strict": true, "baseUrl": ".", "noUnusedLocals": true, + "forceConsistentCasingInFileNames": true, "declarationMap": true, "incremental": true }, - "include": [ - "src" - ] + "include": ["src"], + "exclude": ["node_modules/**/*", "**/__tests__/*"] } diff --git a/scripts/junit-summary/formatters/slack-formatter.ts b/scripts/junit-summary/formatters/slack-formatter.ts index 0e8123d5f71..65b2b3dda1f 100644 --- a/scripts/junit-summary/formatters/slack-formatter.ts +++ b/scripts/junit-summary/formatters/slack-formatter.ts @@ -14,7 +14,7 @@ import { cypressRunCommand } from '../util/cypress'; * The Slack notification has a maximum character limit, so we must truncate * the failure results to reduce the risk of hitting that limit. */ -const FAILURE_SUMMARY_LIMIT = 4; +const FAILURE_SUMMARY_LIMIT = 6; /** * Outputs test result summary formatted as a Slack message. diff --git a/scripts/junit-summary/util/cypress.ts b/scripts/junit-summary/util/cypress.ts index 0f345f39eb7..e55e3e78d0d 100644 --- a/scripts/junit-summary/util/cypress.ts +++ b/scripts/junit-summary/util/cypress.ts @@ -6,7 +6,6 @@ * @returns Cypress run command to run `testFiles`. */ export const cypressRunCommand = (testFiles: string[]): string => { - const dedupedTestFiles = Array.from(new Set(testFiles)); - const testFilesList = dedupedTestFiles.join(','); + const testFilesList = testFiles.join(','); return `yarn cy:run -s "${testFilesList}"`; }; diff --git a/yarn.lock b/yarn.lock index 5d5e43a404b..d059e3f4e02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -307,6 +307,11 @@ "@babel/helper-validator-identifier" "^7.24.7" to-fast-properties "^2.0.0" +"@base2/pretty-print-object@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz#371ba8be66d556812dc7fb169ebc3c08378f69d4" + integrity sha512-4iri8i1AqYHJE2DstZYkyEprg6Pq6sKx3xn5FpySk9sNhH7qN2LLlHJCfDTZRILNwQNPD7mATWM0TBui7uC1pA== + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -651,11 +656,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz#51299374de171dbd80bb7d838e1cfce9af36f353" integrity sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ== -"@esbuild/aix-ppc64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz#38848d3e25afe842a7943643cbcd387cc6e13461" - integrity sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA== - "@esbuild/android-arm64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" @@ -666,11 +666,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz#58565291a1fe548638adb9c584237449e5e14018" integrity sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw== -"@esbuild/android-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz#f592957ae8b5643129fa889c79e69cd8669bb894" - integrity sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg== - "@esbuild/android-arm@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" @@ -681,11 +676,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.23.1.tgz#5eb8c652d4c82a2421e3395b808e6d9c42c862ee" integrity sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ== -"@esbuild/android-arm@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.24.2.tgz#72d8a2063aa630308af486a7e5cbcd1e134335b3" - integrity sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q== - "@esbuild/android-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" @@ -696,11 +686,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.23.1.tgz#ae19d665d2f06f0f48a6ac9a224b3f672e65d517" integrity sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg== -"@esbuild/android-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.24.2.tgz#9a7713504d5f04792f33be9c197a882b2d88febb" - integrity sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw== - "@esbuild/darwin-arm64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" @@ -711,11 +696,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz#05b17f91a87e557b468a9c75e9d85ab10c121b16" integrity sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q== -"@esbuild/darwin-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz#02ae04ad8ebffd6e2ea096181b3366816b2b5936" - integrity sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA== - "@esbuild/darwin-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" @@ -726,11 +706,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz#c58353b982f4e04f0d022284b8ba2733f5ff0931" integrity sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw== -"@esbuild/darwin-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz#9ec312bc29c60e1b6cecadc82bd504d8adaa19e9" - integrity sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA== - "@esbuild/freebsd-arm64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" @@ -741,11 +716,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz#f9220dc65f80f03635e1ef96cfad5da1f446f3bc" integrity sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA== -"@esbuild/freebsd-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz#5e82f44cb4906d6aebf24497d6a068cfc152fa00" - integrity sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg== - "@esbuild/freebsd-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" @@ -756,11 +726,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz#69bd8511fa013b59f0226d1609ac43f7ce489730" integrity sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g== -"@esbuild/freebsd-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz#3fb1ce92f276168b75074b4e51aa0d8141ecce7f" - integrity sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q== - "@esbuild/linux-arm64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" @@ -771,11 +736,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz#8050af6d51ddb388c75653ef9871f5ccd8f12383" integrity sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g== -"@esbuild/linux-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz#856b632d79eb80aec0864381efd29de8fd0b1f43" - integrity sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg== - "@esbuild/linux-arm@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" @@ -786,11 +746,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz#ecaabd1c23b701070484990db9a82f382f99e771" integrity sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ== -"@esbuild/linux-arm@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz#c846b4694dc5a75d1444f52257ccc5659021b736" - integrity sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA== - "@esbuild/linux-ia32@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" @@ -801,11 +756,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz#3ed2273214178109741c09bd0687098a0243b333" integrity sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ== -"@esbuild/linux-ia32@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz#f8a16615a78826ccbb6566fab9a9606cfd4a37d5" - integrity sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw== - "@esbuild/linux-loong64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" @@ -816,11 +766,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz#a0fdf440b5485c81b0fbb316b08933d217f5d3ac" integrity sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw== -"@esbuild/linux-loong64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz#1c451538c765bf14913512c76ed8a351e18b09fc" - integrity sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ== - "@esbuild/linux-mips64el@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" @@ -831,11 +776,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz#e11a2806346db8375b18f5e104c5a9d4e81807f6" integrity sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q== -"@esbuild/linux-mips64el@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz#0846edeefbc3d8d50645c51869cc64401d9239cb" - integrity sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw== - "@esbuild/linux-ppc64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" @@ -846,11 +786,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz#06a2744c5eaf562b1a90937855b4d6cf7c75ec96" integrity sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw== -"@esbuild/linux-ppc64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz#8e3fc54505671d193337a36dfd4c1a23b8a41412" - integrity sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw== - "@esbuild/linux-riscv64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" @@ -861,11 +796,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz#65b46a2892fc0d1af4ba342af3fe0fa4a8fe08e7" integrity sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA== -"@esbuild/linux-riscv64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz#6a1e92096d5e68f7bb10a0d64bb5b6d1daf9a694" - integrity sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q== - "@esbuild/linux-s390x@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" @@ -876,11 +806,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz#e71ea18c70c3f604e241d16e4e5ab193a9785d6f" integrity sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw== -"@esbuild/linux-s390x@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz#ab18e56e66f7a3c49cb97d337cd0a6fea28a8577" - integrity sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw== - "@esbuild/linux-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" @@ -891,16 +816,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz#d47f97391e80690d4dfe811a2e7d6927ad9eed24" integrity sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ== -"@esbuild/linux-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz#8140c9b40da634d380b0b29c837a0b4267aff38f" - integrity sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q== - -"@esbuild/netbsd-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz#65f19161432bafb3981f5f20a7ff45abb2e708e6" - integrity sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw== - "@esbuild/netbsd-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" @@ -911,21 +826,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz#44e743c9778d57a8ace4b72f3c6b839a3b74a653" integrity sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA== -"@esbuild/netbsd-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz#7a3a97d77abfd11765a72f1c6f9b18f5396bcc40" - integrity sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw== - "@esbuild/openbsd-arm64@0.23.1": version "0.23.1" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz#05c5a1faf67b9881834758c69f3e51b7dee015d7" integrity sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q== -"@esbuild/openbsd-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz#58b00238dd8f123bfff68d3acc53a6ee369af89f" - integrity sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A== - "@esbuild/openbsd-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" @@ -936,11 +841,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz#2e58ae511bacf67d19f9f2dcd9e8c5a93f00c273" integrity sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA== -"@esbuild/openbsd-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz#0ac843fda0feb85a93e288842936c21a00a8a205" - integrity sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA== - "@esbuild/sunos-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" @@ -951,11 +851,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz#adb022b959d18d3389ac70769cef5a03d3abd403" integrity sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA== -"@esbuild/sunos-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz#8b7aa895e07828d36c422a4404cc2ecf27fb15c6" - integrity sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig== - "@esbuild/win32-arm64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" @@ -966,11 +861,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz#84906f50c212b72ec360f48461d43202f4c8b9a2" integrity sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A== -"@esbuild/win32-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz#c023afb647cabf0c3ed13f0eddfc4f1d61c66a85" - integrity sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ== - "@esbuild/win32-ia32@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" @@ -981,11 +871,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz#5e3eacc515820ff729e90d0cb463183128e82fac" integrity sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ== -"@esbuild/win32-ia32@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz#96c356132d2dda990098c8b8b951209c3cd743c2" - integrity sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA== - "@esbuild/win32-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" @@ -996,11 +881,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz#81fd50d11e2c32b2d6241470e3185b70c7b30699" integrity sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg== -"@esbuild/win32-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz#34aa0b52d0fbb1a654b596acfa595f0c7b77a77b" - integrity sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg== - "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -1298,11 +1178,13 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== -"@joshwooding/vite-plugin-react-docgen-typescript@0.4.2": - version "0.4.2" - resolved "https://registry.yarnpkg.com/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.4.2.tgz#c2591d2d7b02160341672d6bf3cc248dd60f2530" - integrity sha512-feQ+ntr+8hbVudnsTUapiMN9q8T90XA1d5jn9QzY09sNoj4iD9wi0PY1vsBFTda4ZjEaxRK9S81oarR2nj7TFQ== +"@joshwooding/vite-plugin-react-docgen-typescript@0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.3.0.tgz#67599fca260c2eafdaf234a944f9d471e6d53b08" + integrity sha512-2D6y7fNvFmsLmRt6UCOFJPvFoPMJGT0Uh1Wg0RaigUp7kdQPs6yYn8Dmx6GZkOH/NW0yMTwRz/p0SRMMRo50vA== dependencies: + glob "^7.2.0" + glob-promise "^4.2.0" magic-string "^0.27.0" react-docgen-typescript "^2.2.2" @@ -1812,18 +1694,18 @@ dependencies: "@sentry/types" "7.119.1" -"@storybook/addon-a11y@^8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/addon-a11y/-/addon-a11y-8.4.7.tgz#0073090d8d4e0748249317a292ac27dc2c2b9ef2" - integrity sha512-GpUvXp6n25U1ZSv+hmDC+05BEqxWdlWjQTb/GaboRXZQeMBlze6zckpVb66spjmmtQAIISo0eZxX1+mGcVR7lA== +"@storybook/addon-a11y@^8.3.0": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/addon-a11y/-/addon-a11y-8.3.3.tgz#1ab0db4b559f6ba6bb33c928c634b15d21bd5a72" + integrity sha512-TiCbNfKJOBD2b8mMqHOii8ntdt0V4+ifAgzmGku+F1hdf2EhEw1nL6CHpvnx/GBXoGeK4mrPJIKKoPNp+zz0dw== dependencies: - "@storybook/addon-highlight" "8.4.7" + "@storybook/addon-highlight" "8.3.3" axe-core "^4.2.0" -"@storybook/addon-actions@^8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-8.4.7.tgz#210c6bb5a7e17c3664c300b4b69b6243ec34b9cd" - integrity sha512-mjtD5JxcPuW74T6h7nqMxWTvDneFtokg88p6kQ5OnC1M259iAXb//yiSZgu/quunMHPCXSiqn4FNOSgASTSbsA== +"@storybook/addon-actions@^8.3.0": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-8.3.3.tgz#6b3289071fa887eb08aa858aa64a87e93f0bb440" + integrity sha512-cbpksmld7iADwDGXgojZ4r8LGI3YA3NP68duAHg2n1dtnx1oUaFK5wd6dbNuz7GdjyhIOIy3OKU1dAuylYNGOQ== dependencies: "@storybook/global" "^5.0.0" "@types/uuid" "^9.0.1" @@ -1831,91 +1713,109 @@ polished "^4.2.2" uuid "^9.0.0" -"@storybook/addon-controls@^8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/addon-controls/-/addon-controls-8.4.7.tgz#0c2ace0c7056248577f08f90471f29e861b485be" - integrity sha512-377uo5IsJgXLnQLJixa47+11V+7Wn9KcDEw+96aGCBCfLbWNH8S08tJHHnSu+jXg9zoqCAC23MetntVp6LetHA== +"@storybook/addon-controls@^8.3.0": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/addon-controls/-/addon-controls-8.3.3.tgz#bad8729f03897f9df0909a11e9181a9d88eb274d" + integrity sha512-78xRtVpY7eX/Lti00JLgwYCBRB6ZcvzY3SWk0uQjEqcTnQGoQkVg2L7oWFDlDoA1LBY18P5ei2vu8MYT9GXU4g== dependencies: "@storybook/global" "^5.0.0" dequal "^2.0.2" + lodash "^4.17.21" ts-dedent "^2.0.0" -"@storybook/addon-docs@^8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-8.4.7.tgz#556515da1049f97023427301e11ecb52d0b9dbe7" - integrity sha512-NwWaiTDT5puCBSUOVuf6ME7Zsbwz7Y79WF5tMZBx/sLQ60vpmJVQsap6NSjvK1Ravhc21EsIXqemAcBjAWu80w== +"@storybook/addon-docs@^8.3.0": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-8.3.3.tgz#77869084cbbfaec9d3bbcdf18413de7f627ce81d" + integrity sha512-REUandqq1RnMNOhsocRwx5q2fdlBAYPTDFlKASYfEn4Ln5NgbQRGxOAWl7yXAAFzbDmUDU7K20hkauecF0tyMw== dependencies: "@mdx-js/react" "^3.0.0" - "@storybook/blocks" "8.4.7" - "@storybook/csf-plugin" "8.4.7" - "@storybook/react-dom-shim" "8.4.7" + "@storybook/blocks" "8.3.3" + "@storybook/csf-plugin" "8.3.3" + "@storybook/global" "^5.0.0" + "@storybook/react-dom-shim" "8.3.3" + "@types/react" "^16.8.0 || ^17.0.0 || ^18.0.0" + fs-extra "^11.1.0" react "^16.8.0 || ^17.0.0 || ^18.0.0" react-dom "^16.8.0 || ^17.0.0 || ^18.0.0" + rehype-external-links "^3.0.0" + rehype-slug "^6.0.0" ts-dedent "^2.0.0" -"@storybook/addon-highlight@8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/addon-highlight/-/addon-highlight-8.4.7.tgz#06b9752977e38884007e9446f9a2b0c04c873229" - integrity sha512-whQIDBd3PfVwcUCrRXvCUHWClXe9mQ7XkTPCdPo4B/tZ6Z9c6zD8JUHT76ddyHivixFLowMnA8PxMU6kCMAiNw== +"@storybook/addon-highlight@8.3.3": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/addon-highlight/-/addon-highlight-8.3.3.tgz#2e1d96bdd8049af7343300cbb43adb4480f3ed7d" + integrity sha512-MB084xJM66rLU+iFFk34kjLUiAWzDiy6Kz4uZRa1CnNqEK0sdI8HaoQGgOxTIa2xgJor05/8/mlYlMkP/0INsQ== dependencies: "@storybook/global" "^5.0.0" -"@storybook/addon-mdx-gfm@^8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/addon-mdx-gfm/-/addon-mdx-gfm-8.4.7.tgz#6c382ab67a82aad8be1f8539e1a5b74038dcb910" - integrity sha512-RLenpDmY0HZLqh8T6ZamSeUaLkFFJGMivIs5T3IhAo+BecYA1gWzD+T5er/k8AH8HyYJUtxt/IMCx5UrGnUr7g== +"@storybook/addon-mdx-gfm@^8.3.0": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/addon-mdx-gfm/-/addon-mdx-gfm-8.3.3.tgz#604930985453c4a4bdc3ccbab26ff043a54dc18d" + integrity sha512-jdwVXoBSEdmuw8L4MxUeJ/qIInADfCwdtShnfTQIJBBRucOl8ykgfTKKNjllT79TFiK0gsWoiZmE05P4wuBofw== dependencies: remark-gfm "^4.0.0" ts-dedent "^2.0.0" -"@storybook/addon-measure@^8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/addon-measure/-/addon-measure-8.4.7.tgz#9d556ba34b57c13ad8d00bd953b27ec405a64d23" - integrity sha512-QfvqYWDSI5F68mKvafEmZic3SMiK7zZM8VA0kTXx55hF/+vx61Mm0HccApUT96xCXIgmwQwDvn9gS4TkX81Dmw== +"@storybook/addon-measure@^8.3.0": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/addon-measure/-/addon-measure-8.3.3.tgz#9ff6e749ab6c0661252a195ec355f6a6c5bace07" + integrity sha512-R20Z83gnxDRrocES344dw1Of/zDhe3XHSM6TLq80UQTJ9PhnMI+wYHQlK9DsdP3KiRkI+pQA6GCOp0s2ZRy5dg== dependencies: "@storybook/global" "^5.0.0" tiny-invariant "^1.3.1" -"@storybook/addon-storysource@^8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/addon-storysource/-/addon-storysource-8.4.7.tgz#4e07961307752662c163cc2f713e4436dd4c69d0" - integrity sha512-ckMSiVf+8V3IVN3lTdzCdToXVoGhZ57pwMv0OpkdVIEn6sqHFHwHrOYiXpF3SXTicwayjylcL1JXTGoBFFDVOQ== +"@storybook/addon-storysource@^8.3.0": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/addon-storysource/-/addon-storysource-8.3.3.tgz#de1c0db04a927cc6af91aac9b7590cf30058b627" + integrity sha512-yPYQH9NepSNxoSsV9E7OV3/EVFrbU/r2B3E5WP/mCfqTXPg/5noce7iRi+rWqcVM1tsN1qPnSjfQQc7noF0h0Q== dependencies: - "@storybook/source-loader" "8.4.7" + "@storybook/source-loader" "8.3.3" estraverse "^5.2.0" tiny-invariant "^1.3.1" -"@storybook/addon-viewport@^8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/addon-viewport/-/addon-viewport-8.4.7.tgz#e65c53608f52149c06347b395487960605fc4805" - integrity sha512-hvczh/jjuXXcOogih09a663sRDDSATXwbE866al1DXgbDFraYD/LxX/QDb38W9hdjU9+Qhx8VFIcNWoMQns5HQ== +"@storybook/addon-viewport@^8.3.0": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/addon-viewport/-/addon-viewport-8.3.3.tgz#53315cb90e013fdee514df86e415747f4be3126d" + integrity sha512-2S+UpbKAL+z1ppzUCkixjaem2UDMkfmm/kyJ1wm3A/ofGLYi4fjMSKNRckk+7NdolXGQJjBo0RcaotUTxFIFwQ== dependencies: memoizerific "^1.11.3" -"@storybook/blocks@8.4.7", "@storybook/blocks@^8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/blocks/-/blocks-8.4.7.tgz#ee17f59dd52d11c97c39b0f6b03957085a80ad95" - integrity sha512-+QH7+JwXXXIyP3fRCxz/7E2VZepAanXJM7G8nbR3wWsqWgrRp4Wra6MvybxAYCxU7aNfJX5c+RW84SNikFpcIA== +"@storybook/blocks@8.3.3", "@storybook/blocks@^8.3.0": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/blocks/-/blocks-8.3.3.tgz#a123746b472488d3c6ccc08b1fe831474ec992b0" + integrity sha512-8Vsvxqstop3xfbsx3Dn1nEjyxvQUcOYd8vpxyp2YumxYO8FlXIRuYL6HAkYbcX8JexsKvCZYxor52D2vUGIKZg== dependencies: "@storybook/csf" "^0.1.11" - "@storybook/icons" "^1.2.12" + "@storybook/global" "^5.0.0" + "@storybook/icons" "^1.2.10" + "@types/lodash" "^4.14.167" + color-convert "^2.0.1" + dequal "^2.0.2" + lodash "^4.17.21" + markdown-to-jsx "^7.4.5" + memoizerific "^1.11.3" + polished "^4.2.2" + react-colorful "^5.1.2" + telejson "^7.2.0" ts-dedent "^2.0.0" + util-deprecate "^1.0.2" -"@storybook/builder-vite@8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/builder-vite/-/builder-vite-8.4.7.tgz#3d6d542fa1f46fce5ee7a159dc8491cb4421254d" - integrity sha512-LovyXG5VM0w7CovI/k56ZZyWCveQFVDl0m7WwetpmMh2mmFJ+uPQ35BBsgTvTfc8RHi+9Q3F58qP1MQSByXi9g== +"@storybook/builder-vite@8.3.3": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/builder-vite/-/builder-vite-8.3.3.tgz#40bc458ac735c0c0dac29d9bded6f4dd05bb9104" + integrity sha512-3yTXCLaB6bzhoPH3PqtacKkcaC1uV4L+IHTf1Zypx1NO1pLZHyhYf0T7dIOxTh2JZfqu1Pm9hTvOmWfR12m+9w== dependencies: - "@storybook/csf-plugin" "8.4.7" + "@storybook/csf-plugin" "8.3.3" + "@types/find-cache-dir" "^3.2.1" browser-assert "^1.2.1" + es-module-lexer "^1.5.0" + express "^4.19.2" + find-cache-dir "^3.0.0" + fs-extra "^11.1.0" + magic-string "^0.30.0" ts-dedent "^2.0.0" -"@storybook/components@8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/components/-/components-8.4.7.tgz#09eeffa07aa672ad3966ca1764a43003731b1d30" - integrity sha512-uyJIcoyeMWKAvjrG9tJBUCKxr2WZk+PomgrgrUwejkIfXMO76i6jw9BwLa0NZjYdlthDv30r9FfbYZyeNPmF0g== - -"@storybook/components@^8.0.0": +"@storybook/components@^8.0.0", "@storybook/components@^8.3.3": version "8.3.3" resolved "https://registry.yarnpkg.com/@storybook/components/-/components-8.3.3.tgz#4b3ac4eedba3bca0884782916c4f6f1e7003b741" integrity sha512-i2JYtesFGkdu+Hwuj+o9fLuO3yo+LPT1/8o5xBVYtEqsgDtEAyuRUWjSz8d8NPtzloGPOv5kvR6MokWDfbeMfw== @@ -1925,16 +1825,18 @@ resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-8.3.3.tgz#0b7cb3b737335a5d4091108a01352720e0e1f965" integrity sha512-YL+gBuCS81qktzTkvw0MXUJW0bYAXfRzMoiLfDBTrEKZfcJOB4JAlMGmvRRar0+jygK3icD42Rl5BwWoZY6KFQ== -"@storybook/core@8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/core/-/core-8.4.7.tgz#af9cbb3f26f0b6c98c679a134ce776c202570d66" - integrity sha512-7Z8Z0A+1YnhrrSXoKKwFFI4gnsLbWzr8fnDCU6+6HlDukFYh8GHRcZ9zKfqmy6U3hw2h8H5DrHsxWfyaYUUOoA== +"@storybook/core@8.3.3": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/core/-/core-8.3.3.tgz#657ce39312ceec5ba03382fe4d4d83ca396bb9ab" + integrity sha512-pmf2bP3fzh45e56gqOuBT8sDX05hGdUKIZ/hcI84d5xmd6MeHiPW8th2v946wCHcxHzxib2/UU9vQUh+mB4VNw== dependencies: "@storybook/csf" "^0.1.11" + "@types/express" "^4.17.21" better-opn "^3.0.2" browser-assert "^1.2.1" - esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0" + esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0" esbuild-register "^3.5.0" + express "^4.19.2" jsdoc-type-pratt-parser "^4.0.0" process "^0.11.10" recast "^0.23.5" @@ -1942,10 +1844,10 @@ util "^0.12.5" ws "^8.2.3" -"@storybook/csf-plugin@8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-8.4.7.tgz#0117c872b05bf033eec089ab0224e0fab01da810" - integrity sha512-Fgogplu4HImgC+AYDcdGm1rmL6OR1rVdNX1Be9C/NEXwOCpbbBwi0BxTf/2ZxHRk9fCeaPEcOdP5S8QHfltc1g== +"@storybook/csf-plugin@8.3.3": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-8.3.3.tgz#8112d98222f9b3650d5924673d30dfd9bb55457b" + integrity sha512-7AD7ojpXr3THqpTcEI4K7oKUfSwt1hummgL/cASuQvEPOwAZCVZl2gpGtKxcXhtJXTkn3GMCAvlYMoe7O/1YWw== dependencies: unplugin "^1.3.1" @@ -1961,79 +1863,78 @@ resolved "https://registry.yarnpkg.com/@storybook/global/-/global-5.0.0.tgz#b793d34b94f572c1d7d9e0f44fac4e0dbc9572ed" integrity sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ== -"@storybook/icons@^1.2.12": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-1.3.0.tgz#a5c1460fb15a7260e0b638ab86163f7347a0061e" - integrity sha512-Nz/UzeYQdUZUhacrPyfkiiysSjydyjgg/p0P9HxB4p/WaJUUjMAcaoaLgy3EXx61zZJ3iD36WPuDkZs5QYrA0A== - -"@storybook/icons@^1.2.5": +"@storybook/icons@^1.2.10", "@storybook/icons@^1.2.5": version "1.2.12" resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-1.2.12.tgz#3e4c939113b67df7ab17b78f805dbb57f4acf0db" integrity sha512-UxgyK5W3/UV4VrI3dl6ajGfHM4aOqMAkFLWe2KibeQudLf6NJpDrDMSHwZj+3iKC4jFU7dkKbbtH2h/al4sW3Q== -"@storybook/manager-api@8.4.7", "@storybook/manager-api@^8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/manager-api/-/manager-api-8.4.7.tgz#4e13debf645c9300d7d6d49195e720d0c7ecd261" - integrity sha512-ELqemTviCxAsZ5tqUz39sDmQkvhVAvAgiplYy9Uf15kO0SP2+HKsCMzlrm2ue2FfkUNyqbDayCPPCB0Cdn/mpQ== - -"@storybook/manager-api@^8.0.0": +"@storybook/manager-api@^8.0.0", "@storybook/manager-api@^8.3.0", "@storybook/manager-api@^8.3.3": version "8.3.3" resolved "https://registry.yarnpkg.com/@storybook/manager-api/-/manager-api-8.3.3.tgz#5518cc761264c9972732fcd9e025a7bc2fee7297" integrity sha512-Na4U+McOeVUJAR6qzJfQ6y2Qt0kUgEDUriNoAn+curpoKPTmIaZ79RAXBzIqBl31VyQKknKpZbozoRGf861YaQ== -"@storybook/preview-api@8.4.7", "@storybook/preview-api@^8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/preview-api/-/preview-api-8.4.7.tgz#85e01a97f4182b974581765d725f6c7a7d190013" - integrity sha512-0QVQwHw+OyZGHAJEXo6Knx+6/4er7n2rTDE5RYJ9F2E2Lg42E19pfdLlq2Jhoods2Xrclo3wj6GWR//Ahi39Eg== +"@storybook/preview-api@^8.3.0", "@storybook/preview-api@^8.3.3": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/preview-api/-/preview-api-8.3.3.tgz#9f625a2d5e647137c5df7e419eda59e98f88cd44" + integrity sha512-GP2QlaF3BBQGAyo248N7549YkTQjCentsc1hUvqPnFWU4xfjkejbnFk8yLaIw0VbYbL7jfd7npBtjZ+6AnphMQ== -"@storybook/react-dom-shim@8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-8.4.7.tgz#f0dd5bbf2fc185def72d9d08a11c8de22f152c2a" - integrity sha512-6bkG2jvKTmWrmVzCgwpTxwIugd7Lu+2btsLAqhQSzDyIj2/uhMNp8xIMr/NBDtLgq3nomt9gefNa9xxLwk/OMg== +"@storybook/react-dom-shim@8.3.3": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-8.3.3.tgz#0a23588f507c5c69b1153e43f16c37dbf38b82f1" + integrity sha512-0dPC9K7+K5+X/bt3GwYmh+pCpisUyKVjWsI+PkzqGnWqaXFakzFakjswowIAIO1rf7wYZR591x3ehUAyL2bJiQ== -"@storybook/react-vite@^8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/react-vite/-/react-vite-8.4.7.tgz#1a755596d65551c77850361da76df47027687664" - integrity sha512-iiY9iLdMXhDnilCEVxU6vQsN72pW3miaf0WSenOZRyZv3HdbpgOxI0qapOS0KCyRUnX9vTlmrSPTMchY4cAeOg== +"@storybook/react-vite@^8.3.0": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/react-vite/-/react-vite-8.3.3.tgz#3ce3d5e25b302ba256c74e1e7871f38eba23cdc6" + integrity sha512-vzOqVaA/rv+X5J17eWKxdZztMKEKfsCSP8pNNmrqXWxK3pSlW0fAPxtn1kw3UNxGtAv71pcqvaCUtTJKqI1PYA== dependencies: - "@joshwooding/vite-plugin-react-docgen-typescript" "0.4.2" + "@joshwooding/vite-plugin-react-docgen-typescript" "0.3.0" "@rollup/pluginutils" "^5.0.2" - "@storybook/builder-vite" "8.4.7" - "@storybook/react" "8.4.7" + "@storybook/builder-vite" "8.3.3" + "@storybook/react" "8.3.3" find-up "^5.0.0" magic-string "^0.30.0" react-docgen "^7.0.0" resolve "^1.22.8" tsconfig-paths "^4.2.0" -"@storybook/react@8.4.7", "@storybook/react@^8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/react/-/react-8.4.7.tgz#e2cf62b3c1d8e4bfe5eff82ced07ec473d4e4fd1" - integrity sha512-nQ0/7i2DkaCb7dy0NaT95llRVNYWQiPIVuhNfjr1mVhEP7XD090p0g7eqUmsx8vfdHh2BzWEo6CoBFRd3+EXxw== +"@storybook/react@8.3.3", "@storybook/react@^8.3.0": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/react/-/react-8.3.3.tgz#87d16b3a22f4ace86747f6a382f506a7550a31dc" + integrity sha512-fHOW/mNqI+sZWttGOE32Q+rAIbN7/Oib091cmE8usOM0z0vPNpywUBtqC2cCQH39vp19bhTsQaSsTcoBSweAHw== dependencies: - "@storybook/components" "8.4.7" + "@storybook/components" "^8.3.3" "@storybook/global" "^5.0.0" - "@storybook/manager-api" "8.4.7" - "@storybook/preview-api" "8.4.7" - "@storybook/react-dom-shim" "8.4.7" - "@storybook/theming" "8.4.7" + "@storybook/manager-api" "^8.3.3" + "@storybook/preview-api" "^8.3.3" + "@storybook/react-dom-shim" "8.3.3" + "@storybook/theming" "^8.3.3" + "@types/escodegen" "^0.0.6" + "@types/estree" "^0.0.51" + "@types/node" "^22.0.0" + acorn "^7.4.1" + acorn-jsx "^5.3.1" + acorn-walk "^7.2.0" + escodegen "^2.1.0" + html-tags "^3.1.0" + prop-types "^15.7.2" + react-element-to-jsx-string "^15.0.0" + semver "^7.3.7" + ts-dedent "^2.0.0" + type-fest "~2.19" + util-deprecate "^1.0.2" -"@storybook/source-loader@8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/source-loader/-/source-loader-8.4.7.tgz#c41f213c8e6440a310d5e616353b3266d2b56b56" - integrity sha512-DrsYGGfNbbqlMzkhbLoNyNqrPa4QIkZ6O7FJ8Z/8jWb0cerQH2N6JW6k12ZnXgs8dO2Z33+iSEDIV8odh0E0PA== +"@storybook/source-loader@8.3.3": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@storybook/source-loader/-/source-loader-8.3.3.tgz#f97db24267f6dc66ff662fa2c6f13362135be040" + integrity sha512-NeP7l53mvnnfwi+91vtRaibZer+UJi6gkoaGRCpphL3L+3qVIXN3p41uXhAy+TahdFI2dbrWvLSNgtsvdXVaFg== dependencies: "@storybook/csf" "^0.1.11" - es-toolkit "^1.22.0" estraverse "^5.2.0" + lodash "^4.17.21" prettier "^3.1.1" -"@storybook/theming@8.4.7", "@storybook/theming@^8.4.7": - version "8.4.7" - resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-8.4.7.tgz#c308f6a883999bd35e87826738ab8a76515932b5" - integrity sha512-99rgLEjf7iwfSEmdqlHkSG3AyLcK0sfExcr0jnc6rLiAkBhzuIsvcHjjUwkR210SOCgXqBPW0ZA6uhnuyppHLw== - -"@storybook/theming@^8.0.0": +"@storybook/theming@^8.0.0", "@storybook/theming@^8.3.0", "@storybook/theming@^8.3.3": version "8.3.3" resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-8.3.3.tgz#38f2fb24e719f7a97c359a84c93be86ca2c9a20e" integrity sha512-gWJKetI6XJQgkrvvry4ez10+jLaGNCQKi5ygRPM9N+qrjA3BB8F2LCuFUTBuisa4l64TILDNjfwP/YTWV5+u5A== @@ -2340,6 +2241,14 @@ dependencies: "@babel/types" "^7.20.7" +"@types/body-parser@*": + version "1.19.5" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4" + integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg== + dependencies: + "@types/connect" "*" + "@types/node" "*" + "@types/braintree-web@^3.75.23": version "3.96.14" resolved "https://registry.yarnpkg.com/@types/braintree-web/-/braintree-web-3.96.14.tgz#7303a5439bbc4a3a4b497bbac4bec77921e97cab" @@ -2367,6 +2276,13 @@ dependencies: moment "^2.10.2" +"@types/connect@*": + version "3.4.38" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" + integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + dependencies: + "@types/node" "*" + "@types/cookie@^0.6.0": version "0.6.0" resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5" @@ -2447,21 +2363,71 @@ dependencies: "@types/trusted-types" "*" +"@types/escodegen@^0.0.6": + version "0.0.6" + resolved "https://registry.yarnpkg.com/@types/escodegen/-/escodegen-0.0.6.tgz#5230a9ce796e042cda6f086dbf19f22ea330659c" + integrity sha512-AjwI4MvWx3HAOaZqYsjKWyEObT9lcVV0Y0V8nXo6cXzN8ZiMxVhf6F3d/UNvXVGKrEzL/Dluc5p+y9GkzlTWig== + "@types/estree@1.0.5": version "1.0.5" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== +"@types/estree@^0.0.51": + version "0.0.51" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" + integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== + "@types/estree@^1.0.0", "@types/estree@^1.0.6": version "1.0.6" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== +"@types/express-serve-static-core@^4.17.33": + version "4.19.6" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz#e01324c2a024ff367d92c66f48553ced0ab50267" + integrity sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + +"@types/express@^4.17.21": + version "4.17.21" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" + integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.33" + "@types/qs" "*" + "@types/serve-static" "*" + +"@types/find-cache-dir@^3.2.1": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@types/find-cache-dir/-/find-cache-dir-3.2.1.tgz#7b959a4b9643a1e6a1a5fe49032693cc36773501" + integrity sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw== + +"@types/glob@^7.1.3": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" + integrity sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA== + dependencies: + "@types/minimatch" "*" + "@types/node" "*" + "@types/googlepay@*": version "0.7.6" resolved "https://registry.yarnpkg.com/@types/googlepay/-/googlepay-0.7.6.tgz#ba444ad8b2945e70f873673b8f5371745b8cfe37" integrity sha512-5003wG+qvf4Ktf1hC9IJuRakNzQov00+Xf09pAWGJLpdOjUrq0SSLCpXX7pwSeTG9r5hrdzq1iFyZcW7WVyr4g== +"@types/hast@^3.0.0": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa" + integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ== + dependencies: + "@types/unist" "*" + "@types/he@^1.1.0": version "1.2.3" resolved "https://registry.yarnpkg.com/@types/he/-/he-1.2.3.tgz#c33ca3096f30cbd5d68d78211572de3f9adff75a" @@ -2492,6 +2458,11 @@ "@types/react" "*" hoist-non-react-statics "^3.3.0" +"@types/http-errors@*": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f" + integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== + "@types/json-schema@^7.0.12", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" @@ -2507,6 +2478,11 @@ resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76" integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q== +"@types/lodash@^4.14.167": + version "4.17.9" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.9.tgz#0dc4902c229f6b8e2ac5456522104d7b1a230290" + integrity sha512-w9iWudx1XWOHW5lQRS9iKpK/XuRhnN+0T7HvdCCd802FYkT1AMTnxndJHGrNJwRoRHkslGr4S29tjm1cT7x/7w== + "@types/luxon@3.4.2": version "3.4.2" resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.4.2.tgz#e4fc7214a420173cea47739c33cdf10874694db7" @@ -2544,6 +2520,16 @@ resolved "https://registry.yarnpkg.com/@types/mdx/-/mdx-2.0.13.tgz#68f6877043d377092890ff5b298152b0a21671bd" integrity sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw== +"@types/mime@^1": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" + integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== + +"@types/minimatch@*": + version "5.1.2" + resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" + integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== + "@types/mocha@^10.0.2": version "10.0.8" resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.8.tgz#a7eff5816e070c3b4d803f1d3cd780c4e42934a1" @@ -2561,7 +2547,7 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@^22.5.5": +"@types/node@*", "@types/node@^22.0.0", "@types/node@^22.5.5": version "22.7.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.1.tgz#c6a2628c8a68511ab7b68f3be7c9b38716bdf04f" integrity sha512-adOMRLVmleuWs/5V/w5/l7o0chDK/az+5ncCsIapTKogsu/3MVWvSgP58qVTXi5IwpfGt8pMobNq9rOWtJyu5Q== @@ -2597,6 +2583,11 @@ dependencies: "@types/react" "*" +"@types/qs@*": + version "6.9.16" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.16.tgz#52bba125a07c0482d26747d5d4947a64daf8f794" + integrity sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A== + "@types/raf@^3.4.0": version "3.4.3" resolved "https://registry.yarnpkg.com/@types/raf/-/raf-3.4.3.tgz#85f1d1d17569b28b8db45e16e996407a56b0ab04" @@ -2607,6 +2598,11 @@ resolved "https://registry.yarnpkg.com/@types/ramda/-/ramda-0.25.16.tgz#1d4eeb78d3247d1ceef971b458dd8469646cd1b4" integrity sha512-jNxaEg+kSJ58iaM9bBawJugDxexXVPnLU245yEI1p2BTcfR5pcgM6mpsyBhRRo2ozyfJUvTmasL2Ft+C6BNkVQ== +"@types/range-parser@*": + version "1.2.7" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" + integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== + "@types/react-beautiful-dnd@^13.0.0": version "13.1.8" resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.8.tgz#f52d3ea07e1e19159d6c3c4a48c8da3d855e60b4" @@ -2679,7 +2675,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^18.2.55": +"@types/react@*", "@types/react@^16.8.0 || ^17.0.0 || ^18.0.0", "@types/react@^18.2.55": version "18.3.9" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.9.tgz#2cdf5f425ec8a133d67e9e3673909738b783db20" integrity sha512-+BpAVyTpJkNWWSSnaLBk6ePpHLOGJKnEQNbINNovPWzvEUyAe3e+/d494QdEh71RekM/qV7lw6jzf1HGrJyAtQ== @@ -2712,6 +2708,23 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ== +"@types/send@*": + version "0.17.4" + resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a" + integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA== + dependencies: + "@types/mime" "^1" + "@types/node" "*" + +"@types/serve-static@*": + version "1.15.7" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.7.tgz#22174bbd74fb97fe303109738e9b5c2f3064f714" + integrity sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw== + dependencies: + "@types/http-errors" "*" + "@types/node" "*" + "@types/send" "*" + "@types/sinonjs__fake-timers@8.1.1": version "8.1.1" resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz#b49c2c70150141a15e0fa7e79cf1f92a72934ce3" @@ -2957,6 +2970,11 @@ "@typescript-eslint/types" "6.21.0" eslint-visitor-keys "^3.4.1" +"@ungap/structured-clone@^1.0.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" + integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== + "@vitejs/plugin-react-swc@^3.7.0": version "3.7.0" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.0.tgz#e456c0a6d7f562268e1d231af9ac46b86ef47d88" @@ -3059,12 +3077,25 @@ resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-5.5.0.tgz#275fb8f6e14afa6e8a0c05d4ebc94523ff775396" integrity sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A== +accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + acorn-jsx@^5.2.0, acorn-jsx@^5.3.1, acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^7.1.1, acorn@^7.4.0: +acorn-walk@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" + integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== + +acorn@^7.1.1, acorn@^7.4.0, acorn@^7.4.1: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== @@ -3245,6 +3276,11 @@ array-buffer-byte-length@^1.0.0, array-buffer-byte-length@^1.0.1: call-bind "^1.0.5" is-array-buffer "^3.0.4" +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + array-includes@^3.1.6, array-includes@^3.1.8: version "3.1.8" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.8.tgz#5e370cbe172fdd5dd6530c1d4aadda25281ba97d" @@ -3532,6 +3568,24 @@ bluebird@^3.7.2: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== +body-parser@1.20.3: + version "1.20.3" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" + integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g== + dependencies: + bytes "3.1.2" + content-type "~1.0.5" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.13.0" + raw-body "2.5.2" + type-is "~1.6.18" + unpipe "1.0.0" + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -3620,6 +3674,11 @@ bundle-require@^5.0.0: dependencies: load-tsconfig "^0.2.3" +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + cac@^6.7.14: version "6.7.14" resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" @@ -3944,6 +4003,11 @@ common-tags@^1.8.0: resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6" integrity sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA== +commondir@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" + integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -3972,6 +4036,18 @@ consola@^3.2.3: resolved "https://registry.yarnpkg.com/consolidated-events/-/consolidated-events-2.0.2.tgz#da8d8f8c2b232831413d9e190dc11669c79f4a91" integrity sha512-2/uRVMdRypf5z/TW/ncD/66l75P5hH2vM/GR8Jf8HLc2xnfJtmina6F6du8+v4Z2vTrMo7jC+W1tmEEuuELgkQ== +content-disposition@0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@~1.0.4, content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + convert-source-map@^1.5.0: version "1.9.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" @@ -3982,7 +4058,12 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== -cookie@^0.5.0, cookie@^0.7.0: +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== + +cookie@0.6.0, cookie@^0.5.0, cookie@^0.7.0: version "0.7.2" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== @@ -4316,6 +4397,13 @@ dayjs@^1.10.4: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== +debug@2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@~4.3.6: version "4.3.7" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" @@ -4414,11 +4502,21 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + dequal@^2.0.0, dequal@^2.0.2, dequal@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + devlop@^1.0.0, devlop@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018" @@ -4501,6 +4599,11 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + electron-to-chromium@^1.5.28: version "1.5.28" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.28.tgz#aee074e202c6ee8a0030a9c2ef0b3fe9f967d576" @@ -4526,6 +4629,16 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + +encodeurl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -4670,6 +4783,11 @@ es-iterator-helpers@^1.0.19: iterator.prototype "^1.1.2" safe-array-concat "^1.1.2" +es-module-lexer@^1.5.0: + version "1.5.4" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.5.4.tgz#a8efec3a3da991e60efa6b633a7cad6ab8d26b78" + integrity sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw== + es-object-atoms@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941" @@ -4702,11 +4820,6 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -es-toolkit@^1.22.0: - version "1.31.0" - resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.31.0.tgz#f4fc1382aea09cb239afa38f3c724a5658ff3163" - integrity sha512-vwS0lv/tzjM2/t4aZZRAgN9I9TP0MSkWuvt6By+hEXfG/uLs8yg2S1/ayRXH/x3pinbLgVJYT+eppueg3cM6tg== - esbuild-register@^3.5.0: version "3.6.0" resolved "https://registry.yarnpkg.com/esbuild-register/-/esbuild-register-3.6.0.tgz#cf270cfa677baebbc0010ac024b823cbf723a36d" @@ -4714,36 +4827,35 @@ esbuild-register@^3.5.0: dependencies: debug "^4.3.4" -"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0": - version "0.24.2" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.24.2.tgz#b5b55bee7de017bff5fb8a4e3e44f2ebe2c3567d" - integrity sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA== +"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0", esbuild@^0.23.0, esbuild@~0.23.0: + version "0.23.1" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.23.1.tgz#40fdc3f9265ec0beae6f59824ade1bd3d3d2dab8" + integrity sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg== optionalDependencies: - "@esbuild/aix-ppc64" "0.24.2" - "@esbuild/android-arm" "0.24.2" - "@esbuild/android-arm64" "0.24.2" - "@esbuild/android-x64" "0.24.2" - "@esbuild/darwin-arm64" "0.24.2" - "@esbuild/darwin-x64" "0.24.2" - "@esbuild/freebsd-arm64" "0.24.2" - "@esbuild/freebsd-x64" "0.24.2" - "@esbuild/linux-arm" "0.24.2" - "@esbuild/linux-arm64" "0.24.2" - "@esbuild/linux-ia32" "0.24.2" - "@esbuild/linux-loong64" "0.24.2" - "@esbuild/linux-mips64el" "0.24.2" - "@esbuild/linux-ppc64" "0.24.2" - "@esbuild/linux-riscv64" "0.24.2" - "@esbuild/linux-s390x" "0.24.2" - "@esbuild/linux-x64" "0.24.2" - "@esbuild/netbsd-arm64" "0.24.2" - "@esbuild/netbsd-x64" "0.24.2" - "@esbuild/openbsd-arm64" "0.24.2" - "@esbuild/openbsd-x64" "0.24.2" - "@esbuild/sunos-x64" "0.24.2" - "@esbuild/win32-arm64" "0.24.2" - "@esbuild/win32-ia32" "0.24.2" - "@esbuild/win32-x64" "0.24.2" + "@esbuild/aix-ppc64" "0.23.1" + "@esbuild/android-arm" "0.23.1" + "@esbuild/android-arm64" "0.23.1" + "@esbuild/android-x64" "0.23.1" + "@esbuild/darwin-arm64" "0.23.1" + "@esbuild/darwin-x64" "0.23.1" + "@esbuild/freebsd-arm64" "0.23.1" + "@esbuild/freebsd-x64" "0.23.1" + "@esbuild/linux-arm" "0.23.1" + "@esbuild/linux-arm64" "0.23.1" + "@esbuild/linux-ia32" "0.23.1" + "@esbuild/linux-loong64" "0.23.1" + "@esbuild/linux-mips64el" "0.23.1" + "@esbuild/linux-ppc64" "0.23.1" + "@esbuild/linux-riscv64" "0.23.1" + "@esbuild/linux-s390x" "0.23.1" + "@esbuild/linux-x64" "0.23.1" + "@esbuild/netbsd-x64" "0.23.1" + "@esbuild/openbsd-arm64" "0.23.1" + "@esbuild/openbsd-x64" "0.23.1" + "@esbuild/sunos-x64" "0.23.1" + "@esbuild/win32-arm64" "0.23.1" + "@esbuild/win32-ia32" "0.23.1" + "@esbuild/win32-x64" "0.23.1" esbuild@^0.21.3: version "0.21.5" @@ -4774,41 +4886,16 @@ esbuild@^0.21.3: "@esbuild/win32-ia32" "0.21.5" "@esbuild/win32-x64" "0.21.5" -esbuild@^0.23.0, esbuild@~0.23.0: - version "0.23.1" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.23.1.tgz#40fdc3f9265ec0beae6f59824ade1bd3d3d2dab8" - integrity sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg== - optionalDependencies: - "@esbuild/aix-ppc64" "0.23.1" - "@esbuild/android-arm" "0.23.1" - "@esbuild/android-arm64" "0.23.1" - "@esbuild/android-x64" "0.23.1" - "@esbuild/darwin-arm64" "0.23.1" - "@esbuild/darwin-x64" "0.23.1" - "@esbuild/freebsd-arm64" "0.23.1" - "@esbuild/freebsd-x64" "0.23.1" - "@esbuild/linux-arm" "0.23.1" - "@esbuild/linux-arm64" "0.23.1" - "@esbuild/linux-ia32" "0.23.1" - "@esbuild/linux-loong64" "0.23.1" - "@esbuild/linux-mips64el" "0.23.1" - "@esbuild/linux-ppc64" "0.23.1" - "@esbuild/linux-riscv64" "0.23.1" - "@esbuild/linux-s390x" "0.23.1" - "@esbuild/linux-x64" "0.23.1" - "@esbuild/netbsd-x64" "0.23.1" - "@esbuild/openbsd-arm64" "0.23.1" - "@esbuild/openbsd-x64" "0.23.1" - "@esbuild/sunos-x64" "0.23.1" - "@esbuild/win32-arm64" "0.23.1" - "@esbuild/win32-ia32" "0.23.1" - "@esbuild/win32-x64" "0.23.1" - escalade@^3.1.1, escalade@^3.1.2: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" @@ -4824,6 +4911,17 @@ escape-string-regexp@^5.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== +escodegen@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17" + integrity sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w== + dependencies: + esprima "^4.0.1" + estraverse "^5.2.0" + esutils "^2.0.2" + optionalDependencies: + source-map "~0.6.1" + eslint-config-prettier@~8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.1.0.tgz#4ef1eaf97afe5176e6a75ddfb57c335121abc5a6" @@ -5156,7 +5254,7 @@ espree@^7.3.0, espree@^7.3.1: acorn-jsx "^5.3.1" eslint-visitor-keys "^1.3.0" -esprima@^4.0.0, esprima@~4.0.0: +esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== @@ -5202,6 +5300,11 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + eventemitter2@6.4.7: version "6.4.7" resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.7.tgz#a7f6c4d7abf28a14c1ef3442f21cb306a054271d" @@ -5269,6 +5372,43 @@ executable@^4.1.1: dependencies: pify "^2.2.0" +express@^4.19.2: + version "4.21.0" + resolved "https://registry.yarnpkg.com/express/-/express-4.21.0.tgz#d57cb706d49623d4ac27833f1cbc466b668eb915" + integrity sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "1.20.3" + content-disposition "0.5.4" + content-type "~1.0.4" + cookie "0.6.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "2.0.0" + encodeurl "~2.0.0" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.3.1" + fresh "0.5.2" + http-errors "2.0.0" + merge-descriptors "1.0.3" + methods "~1.1.2" + on-finished "2.4.1" + parseurl "~1.3.3" + path-to-regexp "0.1.10" + proxy-addr "~2.0.7" + qs "6.13.0" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "0.19.0" + serve-static "1.16.2" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + extend@^3.0.0, extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" @@ -5437,11 +5577,41 @@ fill-range@^7.1.1: dependencies: to-regex-range "^5.0.1" +finalhandler@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.1.tgz#0c575f1d1d324ddd1da35ad7ece3df7d19088019" + integrity sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ== + dependencies: + debug "2.6.9" + encodeurl "~2.0.0" + escape-html "~1.0.3" + on-finished "2.4.1" + parseurl "~1.3.3" + statuses "2.0.1" + unpipe "~1.0.0" + +find-cache-dir@^3.0.0: + version "3.3.2" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" + integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== + dependencies: + commondir "^1.0.1" + make-dir "^3.0.2" + pkg-dir "^4.1.0" + find-root@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng== +find-up@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + find-up@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" @@ -5544,6 +5714,11 @@ formik@~2.1.3: tiny-warning "^1.0.2" tslib "^1.10.0" +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + framebus@6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/framebus/-/framebus-6.0.0.tgz#4ebafaf4d78441fdb1f6c55cb9a6ea9f72c55cff" @@ -5551,6 +5726,20 @@ framebus@6.0.0: dependencies: "@braintree/uuid" "^0.1.0" +fresh@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +fs-extra@^11.1.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" + integrity sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs-extra@^9.1.0: version "9.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" @@ -5674,6 +5863,11 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" +github-slugger@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-2.0.0.tgz#52cf2f9279a21eb6c59dd385b410f0c0adda8f1a" + integrity sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw== + glob-parent@^5.0.0, glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" @@ -5688,6 +5882,13 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob-promise@^4.2.0: + version "4.2.2" + resolved "https://registry.yarnpkg.com/glob-promise/-/glob-promise-4.2.2.tgz#15f44bcba0e14219cd93af36da6bb905ff007877" + integrity sha512-xcUzJ8NWN5bktoTIX7eOclO1Npxd/dyVqUJxlLIDasT4C7KZyqlPIwkdJ0Ypiy3p2ZKahTjK4M9uC3sNSfNMzw== + dependencies: + "@types/glob" "^7.1.3" + glob@^10.3.1, glob@^10.3.10, glob@^10.4.1: version "10.4.5" resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" @@ -5700,7 +5901,7 @@ glob@^10.3.1, glob@^10.3.10, glob@^10.4.1: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" -glob@^7.1.3, glob@^7.1.6: +glob@^7.1.3, glob@^7.1.6, glob@^7.2.0: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -5836,6 +6037,27 @@ hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hast-util-heading-rank@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz#2d5c6f2807a7af5c45f74e623498dd6054d2aba8" + integrity sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-is-element@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz#6e31a6532c217e5b533848c7e52c9d9369ca0932" + integrity sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-to-string@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-to-string/-/hast-util-to-string-3.0.0.tgz#2a131948b4b1b26461a2c8ac876e2c88d02946bd" + integrity sha512-OGkAxX1Ua3cbcW6EJ5pT/tslVb90uViVkcJ4ZZIMW/R33DX/AkcJcRrPebPwJkHYwlDHXz4aIwvAAaAdtrACFA== + dependencies: + "@types/hast" "^3.0.0" + he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" @@ -5902,6 +6124,11 @@ html-escaper@^2.0.0: resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== +html-tags@^3.1.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" + integrity sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ== + html2canvas@^1.0.0-rc.5: version "1.4.1" resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543" @@ -5910,6 +6137,17 @@ html2canvas@^1.0.0-rc.5: css-line-break "^2.1.0" text-segmentation "^1.0.3" +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + http-proxy-agent@^7.0.2: version "7.0.2" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" @@ -5955,6 +6193,13 @@ husky@^9.1.6: resolved "https://registry.yarnpkg.com/husky/-/husky-9.1.6.tgz#e23aa996b6203ab33534bdc82306b0cf2cb07d6c" integrity sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A== +iconv-lite@0.4.24, iconv-lite@^0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + iconv-lite@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" @@ -5962,13 +6207,6 @@ iconv-lite@0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -iconv-lite@^0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -6020,7 +6258,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -6088,7 +6326,7 @@ invariant@^2.2.4: dependencies: loose-envify "^1.0.0" -ipaddr.js@^1.9.1: +ipaddr.js@1.9.1, ipaddr.js@^1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== @@ -6098,6 +6336,11 @@ ipaddr.js@^2.0.0: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8" integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA== +is-absolute-url@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-4.0.1.tgz#16e4d487d4fded05cfe0685e53ec86804a5e94dc" + integrity sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A== + is-arguments@^1.0.4, is-arguments@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" @@ -6289,6 +6532,11 @@ is-plain-obj@^4.0.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== +is-plain-object@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" + integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== + is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" @@ -6797,6 +7045,13 @@ localforage@^1.8.1: dependencies: lie "3.1.1" +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + locate-path@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" @@ -6954,6 +7209,13 @@ magicast@^0.3.4: "@babel/types" "^7.25.4" source-map-js "^1.2.0" +make-dir@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + dependencies: + semver "^6.0.0" + make-dir@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" @@ -6982,6 +7244,11 @@ markdown-table@^3.0.0: resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.3.tgz#e6331d30e493127e031dd385488b5bd326e4a6bd" integrity sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw== +markdown-to-jsx@^7.4.5: + version "7.5.0" + resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.5.0.tgz#42ece0c71e842560a7d8bd9f81e7a34515c72150" + integrity sha512-RrBNcMHiFPcz/iqIj0n3wclzHXjwS7mzjBNWecKKVhNTIxQepIix6Il/wZCn2Cg5Y1ow2Qi84+eJrryFRWBEWw== + md5@^2.2.1, md5@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f" @@ -7118,6 +7385,11 @@ mdurl@^1.0.1: resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" integrity sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g== +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + memoize-one@^5.0.0: version "5.2.1" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" @@ -7130,6 +7402,11 @@ memoizerific@^1.11.3: dependencies: map-or-similar "^1.5.0" +merge-descriptors@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -7140,6 +7417,11 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== +methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + micromark-core-commonmark@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.1.tgz#9a45510557d068605c6e9a80f282b2bb8581e43d" @@ -7426,13 +7708,18 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.12, mime-types@~2.1.19: +mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: mime-db "1.52.0" +mime@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -7517,7 +7804,12 @@ mrmime@^2.0.0: resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.0.tgz#151082a6e06e59a9a39b46b3e14d5cfe92b3abb4" integrity sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw== -ms@^2.1.1, ms@^2.1.3: +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +ms@2.1.3, ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -7569,10 +7861,10 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nanoid@^3.3.7, nanoid@^3.3.8: - version "3.3.8" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" - integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== natural-compare-lite@^1.4.0: version "1.4.0" @@ -7584,6 +7876,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -7702,6 +7999,13 @@ object.values@^1.1.6, object.values@^1.2.0: define-properties "^1.2.1" es-object-atoms "^1.0.0" +on-finished@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -7778,6 +8082,13 @@ outvariant@^1.4.0, outvariant@^1.4.2, outvariant@^1.4.3: resolved "https://registry.yarnpkg.com/outvariant/-/outvariant-1.4.3.tgz#221c1bfc093e8fec7075497e7799fdbf43d14873" integrity sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA== +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + p-limit@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" @@ -7785,6 +8096,13 @@ p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + p-locate@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" @@ -7799,6 +8117,11 @@ p-map@^4.0.0: dependencies: aggregate-error "^3.0.0" +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + package-json-from-dist@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" @@ -7828,6 +8151,11 @@ parse5@^7.1.2: dependencies: entities "^4.4.0" +parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -7866,6 +8194,11 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" +path-to-regexp@0.1.10: + version "0.1.10" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.10.tgz#67e9108c5c0551b9e5326064387de4763c4d5f8b" + integrity sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w== + path-to-regexp@^1.7.0: version "1.9.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.9.0.tgz#5dc0753acbf8521ca2e0f137b4578b917b10cf24" @@ -7942,6 +8275,13 @@ pirates@^4.0.1: resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== +pkg-dir@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + polished@^4.2.2: version "4.3.1" resolved "https://registry.yarnpkg.com/polished/-/polished-4.3.1.tgz#5a00ae32715609f83d89f6f31d0f0261c6170548" @@ -8052,6 +8392,14 @@ property-expr@^2.0.5: resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.6.tgz#f77bc00d5928a6c748414ad12882e83f24aec1e8" integrity sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA== +proxy-addr@~2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + proxy-from-env@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee" @@ -8122,6 +8470,26 @@ ramda@0.25.0, ramda@~0.25.0: resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.25.0.tgz#8fdf68231cffa90bc2f9460390a0cb74a29b29a9" integrity sha512-GXpfrYVPwx3K7RQ6aYT8KPS8XViSXUVJT1ONhoKPE9VAleW42YE+U+8VEyGWt41EnEQW7gwecYJriTI0pKoecQ== +range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + +react-colorful@^5.1.2: + version "5.6.1" + resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b" + integrity sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw== + react-csv@^2.0.3: version "2.2.2" resolved "https://registry.yarnpkg.com/react-csv/-/react-csv-2.2.2.tgz#5bbf0d72a846412221a14880f294da9d6def9bfb" @@ -8165,6 +8533,15 @@ react-dropzone@~11.2.0: file-selector "^0.2.2" prop-types "^15.7.2" +react-element-to-jsx-string@^15.0.0: + version "15.0.0" + resolved "https://registry.yarnpkg.com/react-element-to-jsx-string/-/react-element-to-jsx-string-15.0.0.tgz#1cafd5b6ad41946ffc8755e254da3fc752a01ac6" + integrity sha512-UDg4lXB6BzlobN60P8fHWVPX3Kyw8ORrTeBtClmIlGdkOOE+GYQSFvmEU5iLLpwp/6v42DINwNcwOhOLfQ//FQ== + dependencies: + "@base2/pretty-print-object" "1.0.1" + is-plain-object "5.0.0" + react-is "18.1.0" + react-fast-compare@^2.0.1: version "2.0.4" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" @@ -8182,6 +8559,11 @@ react-input-autosize@^2.2.2: dependencies: prop-types "^15.5.8" +react-is@18.1.0: + version "18.1.0" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.1.0.tgz#61aaed3096d30eacf2a2127118b5b41387d32a67" + integrity sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg== + react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.9.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -8434,6 +8816,29 @@ regexpp@^3.1.0: resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== +rehype-external-links@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/rehype-external-links/-/rehype-external-links-3.0.0.tgz#2b28b5cda1932f83f045b6f80a3e1b15f168c6f6" + integrity sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw== + dependencies: + "@types/hast" "^3.0.0" + "@ungap/structured-clone" "^1.0.0" + hast-util-is-element "^3.0.0" + is-absolute-url "^4.0.0" + space-separated-tokens "^2.0.0" + unist-util-visit "^5.0.0" + +rehype-slug@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/rehype-slug/-/rehype-slug-6.0.0.tgz#1d21cf7fc8a83ef874d873c15e6adaee6344eaf1" + integrity sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A== + dependencies: + "@types/hast" "^3.0.0" + github-slugger "^2.0.0" + hast-util-heading-rank "^3.0.0" + hast-util-to-string "^3.0.0" + unist-util-visit "^5.0.0" + remark-gfm@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.0.tgz#aea777f0744701aa288b67d28c43565c7e8c35de" @@ -8658,7 +9063,7 @@ safe-array-concat@^1.1.2: has-symbols "^1.0.3" isarray "^2.0.5" -safe-buffer@^5.0.1, safe-buffer@^5.1.2: +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.2: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -8709,11 +9114,40 @@ search-string@^3.1.0: resolved "https://registry.yarnpkg.com/search-string/-/search-string-3.1.0.tgz#3f111c6919a33de33a8e304fd5f8395c3d806ffb" integrity sha512-yY3b0VlaXfKi2B//34PN5AFF+GQvwme6Kj4FjggmoSBOa7B8AHfS1nYZbsrYu+IyGeYOAkF8ywL9LN9dkrOo6g== -semver@7.6.0, semver@^5.5.0, semver@^6.1.2, semver@^6.3.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.2: +semver@7.6.0, semver@^5.5.0, semver@^6.0.0, semver@^6.1.2, semver@^6.3.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.2: version "7.6.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== +send@0.19.0: + version "0.19.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8" + integrity sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + +serve-static@1.16.2: + version "1.16.2" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.2.tgz#b6a5343da47f6bdd2673848bf45754941e803296" + integrity sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw== + dependencies: + encodeurl "~2.0.0" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.19.0" + set-function-length@^1.2.1: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" @@ -8741,6 +9175,11 @@ setimmediate@^1.0.5: resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + shallow-clone@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" @@ -8911,6 +9350,11 @@ source-map@^0.6.0, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +space-separated-tokens@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f" + integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -8941,7 +9385,7 @@ stackblur-canvas@^2.0.0: resolved "https://registry.yarnpkg.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz#af931277d0b5096df55e1f91c530043e066989b6" integrity sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ== -statuses@^2.0.1: +statuses@2.0.1, statuses@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== @@ -8972,12 +9416,12 @@ storybook-dark-mode@4.0.1: fast-deep-equal "^3.1.3" memoizerific "^1.11.3" -storybook@^8.4.7: - version "8.4.7" - resolved "https://registry.yarnpkg.com/storybook/-/storybook-8.4.7.tgz#a3068787a58074cec1b4197eed1c4427ec644b3f" - integrity sha512-RP/nMJxiWyFc8EVMH5gp20ID032Wvk+Yr3lmKidoegto5Iy+2dVQnUoElZb2zpbVXNHWakGuAkfI0dY1Hfp/vw== +storybook@^8.3.0: + version "8.3.3" + resolved "https://registry.yarnpkg.com/storybook/-/storybook-8.3.3.tgz#3de9be589815403539660653d2ec810348e7dafb" + integrity sha512-FG2KAVQN54T9R6voudiEftehtkXtLO+YVGP2gBPfacEdDQjY++ld7kTbHzpTT/bpCDx7Yq3dqOegLm9arVJfYw== dependencies: - "@storybook/core" "8.4.7" + "@storybook/core" "8.3.3" strict-event-emitter@^0.5.1: version "0.5.1" @@ -8989,7 +9433,7 @@ string-argv@~0.3.2: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -9007,6 +9451,15 @@ string-width@^3.0.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -9087,7 +9540,7 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -9101,6 +9554,13 @@ strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -9227,6 +9687,13 @@ table@^6.0.9: string-width "^4.2.3" strip-ansi "^6.0.1" +telejson@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/telejson/-/telejson-7.2.0.tgz#3994f6c9a8f8d7f2dba9be2c7c5bbb447e876f32" + integrity sha512-1QTEcJkJEhc8OnStBx/ILRu5J2p0GjvWsBx56bmZRqnrkdBMUe+nX92jxV+p3dB4CP6PZCdJMQJwCggkNBMzkQ== + dependencies: + memoizerific "^1.11.3" + test-exclude@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-7.0.1.tgz#20b3ba4906ac20994e275bbcafd68d510264c2a2" @@ -9354,6 +9821,11 @@ toggle-selection@^1.0.6: resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ== +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + toposort@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" @@ -9526,7 +9998,7 @@ type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -type-fest@^2.19.0: +type-fest@^2.19.0, type-fest@~2.19: version "2.19.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== @@ -9536,6 +10008,14 @@ type-fest@^4.9.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.26.1.tgz#a4a17fa314f976dd3e6d6675ef6c775c16d7955e" integrity sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg== +type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + typed-array-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" @@ -9595,10 +10075,10 @@ typescript@^4.6.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== -typescript@^5.7.3: - version "5.7.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e" - integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw== +typescript@^5.5.4: + version "5.6.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0" + integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw== ua-parser-js@^0.7.30: version "0.7.39" @@ -9679,6 +10159,11 @@ universalify@^2.0.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + unplugin@^1.3.1: version "1.14.1" resolved "https://registry.yarnpkg.com/unplugin/-/unplugin-1.14.1.tgz#c76d6155a661e43e6a897bce6b767a1ecc344c1a" @@ -9720,6 +10205,11 @@ use-sync-external-store@^1.2.2: resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9" integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw== +util-deprecate@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + util@^0.12.5: version "0.12.5" resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" @@ -9731,6 +10221,11 @@ util@^0.12.5: is-typed-array "^1.1.3" which-typed-array "^1.1.2" +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + utrie@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645" @@ -9758,6 +10253,11 @@ value-equal@^1.0.1: resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== +vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + verror@1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" @@ -10011,7 +10511,7 @@ word-wrap@^1.2.5, word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -10029,6 +10529,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"