diff --git a/docs/PULL_REQUEST_TEMPLATE.md b/docs/PULL_REQUEST_TEMPLATE.md index 01333bdab73..bc3c7693905 100644 --- a/docs/PULL_REQUEST_TEMPLATE.md +++ b/docs/PULL_REQUEST_TEMPLATE.md @@ -1,15 +1,20 @@ ## Description πŸ“ + Highlight the Pull Request's context and intentions. ## Changes πŸ”„ -List any change relevant to the reviewer. + +List any change(s) relevant to the reviewer. + - ... - ... ## Target release date πŸ—“οΈ -Please specify a release date to guarantee timely review of this PR. If exact date is not known, please approximate and update it as needed. + +Please specify a release date (and environment, if applicable) to guarantee timely review of this PR. If exact date is not known, please approximate and update it as needed. ## Preview πŸ“· + **Include a screenshot or screen recording of the change.** :lock: Use the [Mask Sensitive Data](https://cloud.linode.com/profile/settings) setting for security. @@ -23,38 +28,58 @@ Please specify a release date to guarantee timely review of this PR. If exact da ## How to test πŸ§ͺ ### Prerequisites + (How to setup test environment) + - ... - ... ### Reproduction steps + (How to reproduce the issue, if applicable) -- ... -- ... + +- [ ] ... +- [ ] ... ### Verification steps + (How to verify changes) -- ... -- ... -## As an Author I have considered πŸ€” +- [ ] ... +- [ ] ... + +
+ Author Checklists + +## As an Author, to speed up the review process, I considered πŸ€” + +πŸ‘€ Doing a self review +❔ Our [contribution guidelines](https://github.com/linode/manager/blob/develop/docs/CONTRIBUTING.md) +🀏 Splitting feature into small PRs +βž• Adding a [changeset](https://github.com/linode/manager/blob/develop/docs/CONTRIBUTING.md#writing-a-changeset) +πŸ§ͺ Providing/improving test coverage + πŸ” Removing all sensitive information from the code and PR description +🚩 Using a feature flag to protect the release +πŸ‘£ Providing comprehensive reproduction steps +πŸ“‘ Providing or updating our documentation +πŸ•› Scheduling a pair reviewing session +πŸ“± Providing mobile support +β™Ώ Providing accessibility support -*Check all that apply* +
-- [ ] πŸ‘€ Doing a self review -- [ ] ❔ Our [contribution guidelines](https://github.com/linode/manager/blob/develop/docs/CONTRIBUTING.md) -- [ ] 🀏 Splitting feature into small PRs -- [ ] βž• Adding a [changeset](https://github.com/linode/manager/blob/develop/docs/CONTRIBUTING.md#writing-a-changeset) -- [ ] πŸ§ͺ Providing/Improving test coverage -- [ ] πŸ” Removing all sensitive information from the code and PR description -- [ ] 🚩 Using a feature flag to protect the release -- [ ] πŸ‘£ Providing comprehensive reproduction steps -- [ ] πŸ“‘ Providing or updating our documentation -- [ ] πŸ•› Scheduling a pair reviewing session -- [ ] πŸ“± Providing mobile support -- [ ] β™Ώ Providing accessibility support +- [ ] I have read and considered all applicable items listed above. + +## As an Author, before moving this PR from Draft to Open, I confirmed βœ… + +- [ ] All unit tests are passing +- [ ] TypeScript compilation succeeded without errors +- [ ] Code passes all linting rules + +
--- + ## Commit message and pull request title format standards > **Note**: Remove this section before opening the pull request @@ -63,6 +88,7 @@ Please specify a release date to guarantee timely review of this PR. If exact da `: [JIRA-ticket-number] - ` **Commit Types:** + - `feat`: New feature for the user (not a part of the code, or ci, ...). - `fix`: Bugfix for the user (not a fix to build something, ...). - `change`: Modifying an existing visual UI instance. Such as a component or a feature. diff --git a/docs/development-guide/04-component-library.md b/docs/development-guide/04-component-library.md index 2be52331b2d..5e1ca262d0b 100644 --- a/docs/development-guide/04-component-library.md +++ b/docs/development-guide/04-component-library.md @@ -7,7 +7,7 @@ We use [Material-UI](https://mui.com/material-ui/getting-started/overview/) as t All MUI components have abstractions in the Cloud Manager codebase, meaning you will use relative imports to use them instead of importing from MUI directly: ```ts -import { Typography } from "src/components/Typography"; // NOT from '@mui/material/Typography' +import { Typography } from "@linode/ui"; // NOT from '@mui/material/Typography' ``` We do this because it gives us the ability to customize the component and still keep imports consistent. It also gives us flexibility if we ever wanted to change out the underlying component library. diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index 4a025ce90f4..a97bf29a06c 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -6,38 +6,38 @@ The unit tests for Cloud Manager are written in Typescript using the [Vitest](ht To run tests, first build the **api-v4** package: -``` +```shell yarn install:all && yarn workspace @linode/api-v4 build ``` Then you can start the tests: -``` +```shell yarn test ``` Or you can run the tests in watch mode with: -``` +```shell yarn test:watch ``` To run a specific file or files in a directory: -``` +```shell yarn test myFile.test.tsx yarn test src/some-folder ``` Vitest has built-in pattern matching, so you can also do things like run all tests whose filename contains "Linode" with: -``` +```shell yarn test linode ``` To run a test in debug mode, add a `debugger` breakpoint inside one of the test cases, then run: -``` +```shell yarn workspace linode-manager run test:debug ``` @@ -64,31 +64,25 @@ describe("My component", () => { Handling events such as clicks is a little more involved: ```js -import { fireEvent } from "@testing-library/react"; +import { userEvent } from '@testing-library/user-event'; import { renderWithTheme } from "src/utilities/testHelpers"; import Component from "./wherever"; const props = { onClick: vi.fn() }; describe("My component", () => { - it("should have some text", () => { + it("should have some text", async () => { const { getByText } = renderWithTheme(); const button = getByText("Submit"); - fireEvent.click(button); + await userEvent.click(button); expect(props.onClick).toHaveBeenCalled(); }); }); ``` -If, while using the Testing Library, your tests trigger a warning in the console from React ("Warning: An update to Component inside a test was not wrapped in act(...)"), first check out the library author's [blog post](https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning) about this. Depending on your situation, you probably will have to `wait` for something in your test: - -```js -import { fireEvent, wait } from '@testing-library/react'; +We recommend using `userEvent` rather than `fireEvent` where possible. This is a [React Testing Library best practice](https://testing-library.com/docs/user-event/intro#differences-from-fireevent), because `userEvent` more accurately simulates user interactions in a browser and makes the test more reliable in catching unintended event handler behavior. -... -await wait(() => fireEvent.click(getByText('Delete'))); -... -``` +If, while using the Testing Library, your tests trigger a warning in the console from React ("Warning: An update to Component inside a test was not wrapped in act(...)"), first check out the library author's [blog post](https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning) about this. Depending on your situation, you probably will have to use [`findBy`](https://testing-library.com/docs/dom-testing-library/api-async/#findby-queries) or [`waitFor`](https://testing-library.com/docs/dom-testing-library/api-async/) for something in your test to ensure asynchronous side-effects have completed. ### Mocking @@ -108,7 +102,9 @@ vi.mock('@linode/api-v4/lib/kubernetes', async () => { Some components, such as our ActionMenu, don't lend themselves well to unit testing (they often have complex DOM structures from MUI and it's hard to target). We have mocks for most of these components in a `__mocks__` directory adjacent to their respective components. To make use of these, just tell Vitest to use the mock: +```js vi.mock('src/components/ActionMenu/ActionMenu'); +``` Any ``s rendered by the test will be simplified versions that are easier to work with. @@ -157,6 +153,7 @@ We use [Cypress](https://cypress.io) for end-to-end testing. Test files are foun * Select a reasonable expiry time (avoid "Never") and make sure that every permission is set to "Read/Write". 3. Set the `MANAGER_OAUTH` environment variable in your `.env` file using your new personal access token. * Example of `.env` addition: + ```shell # Manager OAuth token for Cypress tests: # (The real token will be a 64-digit string of hexadecimals). @@ -174,6 +171,7 @@ We use [Cypress](https://cypress.io) for end-to-end testing. Test files are foun Cloud Manager UI tests can be configured using environment variables, which can be defined in `packages/manager/.env` or specified when running Cypress. ##### Cypress Environment Variables + These environment variables are used by Cypress out-of-the-box to override the default configuration. Cypress exposes many other options that can be configured with environment variables, but the items listed below are particularly relevant for Cloud Manager testing. More information can be found at [docs.cypress.io](https://docs.cypress.io/guides/guides/environment-variables). | Environment Variable | Description | Example | Default | @@ -181,9 +179,11 @@ These environment variables are used by Cypress out-of-the-box to override the d | `CYPRESS_BASE_URL` | URL to Cloud Manager environment for tests | `https://cloud.linode.com` | `http://localhost:3000` | ##### Cloud Manager-specific Environment Variables + These environment variables are specific to Cloud Manager UI tests. They can be distinguished from out-of-the-box Cypress environment variables by their `CY_TEST_` prefix. ###### General + Environment variables related to the general operation of the Cloud Manager Cypress tests. | Environment Variable | Description | Example | Default | @@ -192,6 +192,7 @@ Environment variables related to the general operation of the Cloud Manager Cypr | `CY_TEST_TAGS` | Query identifying tests that should run by specifying allowed and disallowed tags. | `method:e2e` | Unset; all tests run by default | ###### Overriding Behavior + These environment variables can be used to override some behaviors of Cloud Manager's UI tests. This can be useful when testing Cloud Manager for nonstandard or work-in-progress functionality. | Environment Variable | Description | Example | Default | @@ -200,6 +201,7 @@ These environment variables can be used to override some behaviors of Cloud Mana | `CY_TEST_FEATURE_FLAGS` | JSON string containing feature flag data | `{}` | Unset; feature flag data is not overridden | ###### Run Splitting + These environment variables facilitate splitting the Cypress run between multiple runners without the use of any third party services. This can be useful for improving Cypress test performance in some circumstances. For additional performance gains, an optional test weights file can be specified using `CY_TEST_SPLIT_RUN_WEIGHTS` (see `CY_TEST_GENWEIGHTS` to generate test weights). | Environment Variable | Description | Example | Default | @@ -210,6 +212,7 @@ These environment variables facilitate splitting the Cypress run between multipl | `CY_TEST_SPLIT_RUN_WEIGHTS` | Path to test weights file | `./weights.json` | Unset; disabled by default | ###### Development, Logging, and Reporting + Environment variables related to Cypress logging and reporting, as well as report generation. | Environment Variable | Description | Example | Default | @@ -222,6 +225,7 @@ Environment variables related to Cypress logging and reporting, as well as repor | `CY_TEST_GENWEIGHTS` | Generate and output test weights to the given path | `./weights.json` | Unset; disabled by default | ###### Performance + Environment variables that can be used to improve test performance in some scenarios. | Environment Variable | Description | Example | Default | @@ -233,6 +237,7 @@ Environment variables that can be used to improve test performance in some scena 1. Look here for [Cypress Best Practices](https://docs.cypress.io/guides/references/best-practices) 2. Test Example: + ```tsx /* this test will not pass on cloud manager. it is only intended to show correct test structure, syntax, @@ -293,13 +298,15 @@ Environment variables that can be used to improve test performance in some scena }); }); ``` + 3. How to use intercepts: + ```tsx // stub response syntax: cy.intercept('POST', β€˜/path’, {response}) or cy.intercept(β€˜/path’, (req) => { req.reply({response})}).as('something'); - // edit and end response syntax: + // edit and end response syntax: cy.intercept('GET', β€˜/path’, (req) => { req.send({edit: something})}).as('something'); - // edit request syntax: + // edit request syntax: cy.intercept('POST', β€˜/path’, (req) => { req.body.storyName = 'some name'; req.continue().as('something'); // use alias syntax: diff --git a/docs/development-guide/15-composition.md b/docs/development-guide/15-composition.md index 26a405c9ea5..fbee415d4f8 100644 --- a/docs/development-guide/15-composition.md +++ b/docs/development-guide/15-composition.md @@ -52,3 +52,74 @@ The Linode Create Page is a good example of a complex form that is built using r ### Uncontrolled Forms Uncontrolled forms are a type of form that does not have a state for its values. It is often used for simple forms that do not need to be controlled, such as forms with a single input field or call to action. +## Form Validation (React Hook Form) +### Best Practices +1. Keep API validation in `@linode/validation` package +2. Create extended schemas in `@linode/manager` package when you need validation beyond the API contract +3. Use yup.concat() to extend existing schemas +4. Add custom validation logic within the resolver function +5. Include type definitions for form values and context + +### Simple Schema Extension +For basic form validation, extend the API schema directly: + +```typescript +import { CreateWidgetSchema } from '@linode/validation'; +import { object, string } from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; + +const extendedSchema = CreateWidgetSchema.concat( + object({ + customField: string().required('Required field'), + }) +); + +const form = useForm({ + resolver: yupResolver(extendedSchema) +}); +``` + +### Complex Schema Extensions +You may create a `resolver` function that handles the validation (see: [ManageImageRegionsForm.tsx](https://github.com/linode/manager/blob/develop/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx#L189-L213)): + +```typescript +// Step 1: Create a Resolver Function +// This function validates your form data against specific requirements + +type Resolver = (values: FormData, context: Context) => { + errors: Record; + values: FormData; +}; + +// Example resolver that checks if at least one item from a list is selected +const resolver: Resolver = (values, context) => { + // Check if at least one valid option is selected + const hasValidSelection = values.selectedItems.some( + item => context.availableItems.includes(item) + ); + + if (!hasValidSelection) { + return { + errors: { + selectedItems: { + message: 'Please select at least one valid option', + type: 'validate' + } + }, + values + }; + } + + return { errors: {}, values }; +}; + +// Step 2: Use the Resolver in Your Form +const form = useForm({ + resolver, + defaultValues: { selectedItems: [] }, + context: { availableItems: ['item1', 'item2'] } +}); +``` + +### Additional Complexity +When working with multiple sequential schemas that require validation, you can create a resolver map and function (see: [LinodeCreate/resolvers.ts](https://github.com/linode/manager/blob/develop/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts])). \ No newline at end of file diff --git a/packages/api-v4/.eslintrc.json b/packages/api-v4/.eslintrc.json index e8664f86a6f..f551347ff3f 100644 --- a/packages/api-v4/.eslintrc.json +++ b/packages/api-v4/.eslintrc.json @@ -1,20 +1,11 @@ { - "ignorePatterns": [ - "node_modules", - "lib", - "index.js", - "!.eslintrc.js" - ], + "ignorePatterns": ["node_modules", "lib", "index.js", "!.eslintrc.js"], "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": 2020, "warnOnUnsupportedTypeScriptVersion": true }, - "plugins": [ - "@typescript-eslint", - "sonarjs", - "prettier" - ], + "plugins": ["@typescript-eslint", "sonarjs", "prettier"], "extends": [ "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended", @@ -22,6 +13,42 @@ "plugin:prettier/recommended" ], "rules": { + "@typescript-eslint/naming-convention": [ + "warn", + { + "format": ["camelCase", "UPPER_CASE", "PascalCase"], + "leadingUnderscore": "allow", + "selector": "variable", + "trailingUnderscore": "allow" + }, + { + "format": null, + "modifiers": ["destructured"], + "selector": "variable" + }, + { + "format": ["camelCase", "PascalCase"], + "selector": "function" + }, + { + "format": ["camelCase"], + "leadingUnderscore": "allow", + "selector": "parameter" + }, + { + "format": ["PascalCase"], + "selector": "typeLike" + } + ], + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-inferrable-types": "off", + "@typescript-eslint/no-namespace": "warn", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-empty-interface": "warn", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-use-before-define": "off", + "@typescript-eslint/interface-name-prefix": "off", "no-unused-vars": [ "warn", { @@ -38,23 +65,10 @@ "array-callback-return": "error", "no-invalid-this": "off", "no-new-wrappers": "error", - "no-restricted-imports": [ - "error", - "rxjs" - ], + "no-restricted-imports": ["error", "rxjs"], "no-console": "error", "no-undef-init": "off", "radix": "error", - "@typescript-eslint/no-unused-vars": "off", - "@typescript-eslint/no-inferrable-types": "off", - "@typescript-eslint/no-namespace": "warn", - "@typescript-eslint/camelcase": "off", - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/no-empty-interface": "warn", - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-use-before-define": "off", - "@typescript-eslint/interface-name-prefix": "off", "sonarjs/cognitive-complexity": "warn", "sonarjs/no-duplicate-string": "warn", "sonarjs/prefer-immediate-return": "warn", @@ -74,9 +88,7 @@ }, "overrides": [ { - "files": [ - "*ts" - ], + "files": ["*ts"], "rules": { "@typescript-eslint/ban-types": [ "warn", @@ -97,4 +109,4 @@ } } ] -} \ No newline at end of file +} diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 06268e31c8f..06eefea7905 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,5 +1,33 @@ -## [2024-11-12] - v0.130.0 +## [2024-12-10] - v0.131.0 + +### Added: + +- Extend support for Object Storage in Support tickets ([#11178](https://github.com/linode/manager/pull/11178)) +- Missing `+eq` type to `FilterConditionTypes` interface ([#11233](https://github.com/linode/manager/pull/11233)) +- New Accelerated-related fields and capabilities to API types ([#11256](https://github.com/linode/manager/pull/11256)) +- Placement Groups migrations Types ([#11261](https://github.com/linode/manager/pull/11261)) +- `service_type` as parameter for the Create Alert POST request ([#11286](https://github.com/linode/manager/pull/11286)) + +### Removed: + +- `deleted` from the `ImageStatus` type ([#11257](https://github.com/linode/manager/pull/11257)) + +### Tech Stories: + +- Update yup from `0.32.9` to `1.4.0` (#11324) +- Add Linter rules for naming convention ([#11337](https://github.com/linode/manager/pull/11337)) +- Update Linter rules for common PR feedback points (#11258) +- Remove recently added camelCase rule ([#11330](https://github.com/linode/manager/pull/11330)) +### Upcoming Features: + +- Modify `chart_type` property type in `types.ts` ([#11204](https://github.com/linode/manager/pull/11204)) +- Add POST request endpoint for create alert in `alerts.ts`, add Alert, add CreateAlertPayload types ([#11255](https://github.com/linode/manager/pull/11255)) +- Add v4beta/account endpoint and update Capabilities for LKE-E ([#11259](https://github.com/linode/manager/pull/11259)) +- Add remaining new types and v4beta endpoints for LKE-E ([#11302](https://github.com/linode/manager/pull/11302)) +- New IAM endpoints and types ([#11146](https://github.com/linode/manager/pull/11146), [#11181](https://github.com/linode/manager/pull/11181)) + +## [2024-11-12] - v0.130.0 ### Added: @@ -17,10 +45,8 @@ - DBaaS: Modify update payload to include version, add patch API ([#11196](https://github.com/linode/manager/pull/11196)) - ## [2024-10-28] - v0.129.0 - ### Added: - ACL related endpoints and types for LKE clusters ([#10968](https://github.com/linode/manager/pull/10968)) @@ -54,7 +80,6 @@ ## [2024-09-30] - v0.127.0 - ### Changed: - Make `replication_type` and `replication_commit_type` optional in MySQL and Postgres interfaces ([#10980](https://github.com/linode/manager/pull/10980)) @@ -70,7 +95,6 @@ ## [2024-09-16] - v0.126.0 - ### Added: - LinodeCapabilities type used for `capabilities` property of Linode interface ([#10920](https://github.com/linode/manager/pull/10920)) @@ -86,7 +110,6 @@ ## [2024-09-03] - v0.125.0 - ### Added: - Managed Databases V2 capability and types ([#10786](https://github.com/linode/manager/pull/10786)) @@ -101,7 +124,6 @@ - Update `AclpConfig` type ([#10769](https://github.com/linode/manager/pull/10769)) - Add service types and `getCloudPulseServiceTypes` request ([#10805](https://github.com/linode/manager/pull/10805)) - ## [2024-08-19] - v0.124.0 ### Added: diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 2c151782dbc..d26261a5949 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.130.0", + "version": "0.131.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" @@ -43,7 +43,7 @@ "@linode/validation": "*", "axios": "~1.7.4", "ipaddr.js": "^2.0.0", - "yup": "^0.32.9" + "yup": "^1.4.0" }, "scripts": { "start": "concurrently --raw \"tsc -w --preserveWatchOutput\" \"tsup --watch\"", @@ -57,7 +57,6 @@ "lib" ], "devDependencies": { - "@types/yup": "^0.29.13", "axios-mock-adapter": "^1.22.0", "concurrently": "^9.0.1", "eslint": "^6.8.0", diff --git a/packages/api-v4/src/account/account.ts b/packages/api-v4/src/account/account.ts index b612da3d082..0b704f230b6 100644 --- a/packages/api-v4/src/account/account.ts +++ b/packages/api-v4/src/account/account.ts @@ -35,6 +35,18 @@ export const getAccountInfo = () => { return Request(setURL(`${API_ROOT}/account`), setMethod('GET')); }; +/** + * getAccountInfoBeta + * + * Return beta endpoint account information, + * including contact and billing info. + * + * @TODO LKE-E - M3-8838: Clean up after released to GA, if not otherwise in use + */ +export const getAccountInfoBeta = () => { + return Request(setURL(`${BETA_API_ROOT}/account`), setMethod('GET')); +}; + /** * getNetworkUtilization * diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 8a0af4e587c..6bd9ee37d3f 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -68,12 +68,14 @@ export type AccountCapability = | 'CloudPulse' | 'Disk Encryption' | 'Kubernetes' + | 'Kubernetes Enterprise' | 'Linodes' | 'LKE HA Control Planes' | 'LKE Network Access Control List (IP ACL)' | 'Machine Images' | 'Managed Databases' | 'Managed Databases Beta' + | 'NETINT Quadra T1U' | 'NodeBalancers' | 'Object Storage Access Key Regions' | 'Object Storage Endpoint Types' diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts new file mode 100644 index 00000000000..3c6f909b9db --- /dev/null +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -0,0 +1,18 @@ +import { createAlertDefinitionSchema } from '@linode/validation'; +import Request, { setURL, setMethod, setData } from '../request'; +import { Alert, AlertServiceType, CreateAlertDefinitionPayload } from './types'; +import { BETA_API_ROOT as API_ROOT } from 'src/constants'; + +export const createAlertDefinition = ( + data: CreateAlertDefinitionPayload, + serviceType: AlertServiceType +) => + Request( + setURL( + `${API_ROOT}/monitor/services/${encodeURIComponent( + serviceType! + )}/alert-definitions` + ), + setMethod('POST'), + setData(data, createAlertDefinitionSchema) + ); diff --git a/packages/api-v4/src/cloudpulse/index.ts b/packages/api-v4/src/cloudpulse/index.ts index 6b4ff9e8b55..72a80c197e5 100644 --- a/packages/api-v4/src/cloudpulse/index.ts +++ b/packages/api-v4/src/cloudpulse/index.ts @@ -3,3 +3,5 @@ export * from './types'; export * from './dashboards'; export * from './services'; + +export * from './alerts'; diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 19a5149c76a..4b64bf16c30 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -1,3 +1,10 @@ +export type AlertSeverityType = 0 | 1 | 2 | 3; +export type MetricAggregationType = 'avg' | 'sum' | 'min' | 'max' | 'count'; +export type MetricOperatorType = 'eq' | 'gt' | 'lt' | 'gte' | 'lte'; +export type AlertServiceType = 'linode' | 'dbaas'; +type DimensionFilterOperatorType = 'eq' | 'neq' | 'startswith' | 'endswith'; +export type AlertDefinitionType = 'default' | 'custom'; +export type AlertStatusType = 'enabled' | 'disabled'; export interface Dashboard { id: number; label: string; @@ -28,7 +35,7 @@ export interface Widgets { namespace_id: number; color: string; size: number; - chart_type: string; + chart_type: 'line' | 'area'; y_label: string; filters: Filters[]; serviceType: string; @@ -132,3 +139,59 @@ export interface ServiceTypes { export interface ServiceTypesList { data: ServiceTypes[]; } + +export interface CreateAlertDefinitionPayload { + label: string; + description?: string; + entity_ids?: string[]; + severity: AlertSeverityType; + rule_criteria: { + rules: MetricCriteria[]; + }; + triggerCondition: TriggerCondition; + channel_ids: number[]; +} +export interface MetricCriteria { + metric: string; + aggregation_type: MetricAggregationType; + operator: MetricOperatorType; + value: number; + dimension_filters: DimensionFilter[]; +} + +export interface DimensionFilter { + dimension_label: string; + operator: DimensionFilterOperatorType; + value: string; +} + +export interface TriggerCondition { + polling_interval_seconds: number; + evaluation_period_seconds: number; + trigger_occurrences: number; +} +export interface Alert { + id: number; + label: string; + description: string; + has_more_resources: boolean; + status: AlertStatusType; + type: AlertDefinitionType; + severity: AlertSeverityType; + service_type: AlertServiceType; + entity_ids: string[]; + rule_criteria: { + rules: MetricCriteria[]; + }; + triggerCondition: TriggerCondition; + channels: { + id: string; + label: string; + url: string; + type: 'channel'; + }[]; + created_by: string; + updated_by: string; + created: string; + updated: string; +} diff --git a/packages/api-v4/src/iam/iam.ts b/packages/api-v4/src/iam/iam.ts new file mode 100644 index 00000000000..2a0e70071a0 --- /dev/null +++ b/packages/api-v4/src/iam/iam.ts @@ -0,0 +1,57 @@ +import { BETA_API_ROOT } from '../constants'; +import Request, { setData, setMethod, setURL } from '../request'; +import { IamUserPermissions, IamAccountPermissions } from './types'; + +/** + * getUserPermissions + * + * Returns the full permissions structure for this User. This includes all entities on + * the Account alongside what level of access this User has to each of them. + * + * @param username { number } the username to look up. + * + */ +export const getUserPermissions = (username: string) => + Request( + setURL( + `${BETA_API_ROOT}/iam/role-permissions/users/${encodeURIComponent( + username + )}` + ), + setMethod('GET') + ); +/** + * updateUserPermissions + * + * Update the permissions a User has. + * + * @param username { number } ID of the client to be viewed. + * @param data { object } the Permissions object to update. + * + */ +export const updateUserPermissions = ( + username: string, + data: Partial +) => + Request( + setURL( + `${BETA_API_ROOT}/iam/role-permissions/users/${encodeURIComponent( + username + )}` + ), + setMethod('PUT'), + setData(data) + ); + +/** + * getAccountPermissions + * + * Return all permissions for account. + * + */ +export const getAccountPermissions = () => { + return Request( + setURL(`${BETA_API_ROOT}/iam/role-permissions`), + setMethod('GET') + ); +}; diff --git a/packages/api-v4/src/iam/index.ts b/packages/api-v4/src/iam/index.ts new file mode 100644 index 00000000000..0b2cffa9534 --- /dev/null +++ b/packages/api-v4/src/iam/index.ts @@ -0,0 +1,3 @@ +export * from './types'; + +export * from './iam'; diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts new file mode 100644 index 00000000000..8aa9fd0ce17 --- /dev/null +++ b/packages/api-v4/src/iam/types.ts @@ -0,0 +1,53 @@ +export interface IamUserPermissions { + account_access: AccountAccessType[]; + resource_access: ResourceAccess[]; +} + +type ResourceType = + | 'linode' + | 'firewall' + | 'nodebalancer' + | 'longview' + | 'domain' + | 'stackscript' + | 'image' + | 'volume' + | 'database' + | 'account' + | 'vpc'; + +type AccountAccessType = + | 'account_linode_admin' + | 'linode_creator' + | 'firewall_creator'; + +type RoleType = 'linode_contributor' | 'firewall_admin'; + +export interface ResourceAccess { + resource_id: number; + resource_type: ResourceType; + roles: RoleType[]; +} + +export interface IamAccountPermissions { + account_access: Access[]; + resource_access: Access[]; +} + +type PermissionType = + | 'create_linode' + | 'update_linode' + | 'update_firewall' + | 'delete_linode' + | 'view_linode'; + +interface Access { + resource_type: ResourceType; + roles: Roles[]; +} + +export interface Roles { + name: string; + description: string; + permissions?: PermissionType[]; +} diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index cc1572d449b..4a707b361d8 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -1,8 +1,4 @@ -export type ImageStatus = - | 'available' - | 'creating' - | 'deleted' - | 'pending_upload'; +export type ImageStatus = 'available' | 'creating' | 'pending_upload'; export type ImageCapabilities = 'cloud-init' | 'distributed-sites'; diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index 838db834c2d..dd996b686d2 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -50,6 +50,10 @@ export * from './vpcs'; export * from './betas'; +export * from './iam'; + +export * from './resources'; + export { baseRequest, setToken, diff --git a/packages/api-v4/src/kubernetes/kubernetes.ts b/packages/api-v4/src/kubernetes/kubernetes.ts index 03e46b53dc9..4281ab35c1c 100644 --- a/packages/api-v4/src/kubernetes/kubernetes.ts +++ b/packages/api-v4/src/kubernetes/kubernetes.ts @@ -16,6 +16,7 @@ import type { KubernetesDashboardResponse, KubernetesVersion, KubernetesControlPlaneACLPayload, + KubernetesTieredVersion, } from './types'; /** @@ -31,6 +32,19 @@ export const getKubernetesClusters = (params?: Params, filters?: Filter) => setURL(`${API_ROOT}/lke/clusters`) ); +/** + * getKubernetesClustersBeta + * + * Gets a list of a user's Kubernetes clusters from beta API + */ +export const getKubernetesClustersBeta = (params?: Params, filters?: Filter) => + Request>( + setMethod('GET'), + setParams(params), + setXFilter(filters), + setURL(`${BETA_API_ROOT}/lke/clusters`) + ); + /** * getKubernetesCluster * @@ -69,11 +83,12 @@ export const createKubernetesCluster = (data: CreateKubeClusterPayload) => { /** * createKubernetesClustersBeta * - * Create a new cluster with the BETA api whenever feature flag for APL is enabled - * and APL is set to enabled in the UI + * Create a new cluster with the beta API: + * 1. When the feature flag for APL is enabled and APL is set to enabled in the UI + * 2. When the LKE-E feature is enabled * * duplicated function of createKubernetesCluster - * necessary to call BETA_API_ROOT in a seperate function based on feature flag + * necessary to call BETA_API_ROOT in a separate function based on feature flag */ export const createKubernetesClusterBeta = (data: CreateKubeClusterPayload) => { return Request( @@ -154,6 +169,24 @@ export const getKubernetesVersions = (params?: Params, filters?: Filter) => setURL(`${API_ROOT}/lke/versions`) ); +/** getKubernetesTieredVersionsBeta + * + * Returns a paginated list of available Kubernetes tiered versions from the beta API. + * + */ + +export const getKubernetesTieredVersionsBeta = ( + tier: string, + params?: Params, + filters?: Filter +) => + Request>( + setMethod('GET'), + setXFilter(filters), + setParams(params), + setURL(`${BETA_API_ROOT}/lke/versions/${encodeURIComponent(tier)}`) + ); + /** getKubernetesVersion * * Returns a single Kubernetes version by ID. @@ -166,6 +199,25 @@ export const getKubernetesVersion = (versionID: string) => setURL(`${API_ROOT}/lke/versions/${encodeURIComponent(versionID)}`) ); +/** getKubernetesTieredVersionBeta + * + * Returns a single tiered Kubernetes version by ID from the beta API. + * + */ + +export const getKubernetesTieredVersionBeta = ( + tier: string, + versionID: string +) => + Request( + setMethod('GET'), + setURL( + `${BETA_API_ROOT}/lke/versions/${encodeURIComponent( + tier + )}/${encodeURIComponent(versionID)}` + ) + ); + /** getKubernetesClusterEndpoint * * Returns the endpoint URL for a single Kubernetes cluster by ID. diff --git a/packages/api-v4/src/kubernetes/types.ts b/packages/api-v4/src/kubernetes/types.ts index c9ea9b25282..262db13dfc0 100644 --- a/packages/api-v4/src/kubernetes/types.ts +++ b/packages/api-v4/src/kubernetes/types.ts @@ -1,5 +1,7 @@ import type { EncryptionStatus } from '../linodes'; +export type KubernetesTier = 'standard' | 'enterprise'; + export interface KubernetesCluster { created: string; updated: string; @@ -11,6 +13,10 @@ export interface KubernetesCluster { tags: string[]; control_plane: ControlPlaneOptions; apl_enabled?: boolean; // this is not the ideal solution, but a necessary compromise to prevent a lot of duplicated code. + /** Marked as 'optional' in this existing interface to prevent duplicated code for beta functionality, in line with the apl_enabled approach. + * @todo LKE-E - Make this field required once LKE-E is in GA. tier defaults to 'standard' in the API. + */ + tier?: KubernetesTier; } export interface KubeNodePoolResponse { @@ -52,6 +58,11 @@ export interface KubernetesVersion { id: string; } +export interface KubernetesTieredVersion { + id: string; + tier: KubernetesTier; +} + export interface KubernetesEndpointResponse { endpoint: string; } @@ -85,4 +96,5 @@ export interface CreateKubeClusterPayload { k8s_version?: string; // Will be caught by Yup if undefined control_plane?: ControlPlaneOptions; apl_enabled?: boolean; // this is not the ideal solution, but a necessary compromise to prevent a lot of duplicated code. + tier?: KubernetesTier; // For LKE-E: Will be assigned 'standard' by the API if not provided } diff --git a/packages/api-v4/src/linodes/actions.ts b/packages/api-v4/src/linodes/actions.ts index 71c52de4722..dc814e2079d 100644 --- a/packages/api-v4/src/linodes/actions.ts +++ b/packages/api-v4/src/linodes/actions.ts @@ -110,7 +110,7 @@ export const resizeLinode = (linodeId: number, data: ResizeLinodePayload) => * automatically appended to the root user's authorized keys file. */ export const rebuildLinode = (linodeId: number, data: RebuildRequest) => - Request<{}>( + Request( setURL( `${API_ROOT}/linode/instances/${encodeURIComponent(linodeId)}/rebuild` ), diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 34316f11adf..6f3d94caad3 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -1,7 +1,7 @@ import type { Region, RegionSite } from '../regions'; import type { IPAddress, IPRange } from '../networking/types'; import type { SSHKey } from '../profile/types'; -import type { PlacementGroupPayload } from '../placement-groups/types'; +import type { LinodePlacementGroupPayload } from '../placement-groups/types'; export type Hypervisor = 'kvm' | 'zen'; @@ -13,6 +13,7 @@ export interface LinodeSpecs { vcpus: number; transfer: number; gpus: number; + accelerated_devices: number; } export interface Linode { @@ -29,7 +30,7 @@ export interface Linode { ipv6: string | null; label: string; lke_cluster_id: number | null; - placement_group?: PlacementGroupPayload; // If not in a placement group, this will be excluded from the response. + placement_group?: LinodePlacementGroupPayload; // If not in a placement group, this will be excluded from the response. type: string | null; status: LinodeStatus; updated: string; @@ -322,6 +323,7 @@ export interface LinodeType extends BaseType { successor: string | null; network_out: number; gpus: number; + accelerated_devices: number; price: PriceObject; region_prices: RegionPriceObject[]; addons: { @@ -330,6 +332,7 @@ export interface LinodeType extends BaseType { } export type LinodeTypeClass = + | 'accelerated' | 'nanode' | 'standard' | 'dedicated' diff --git a/packages/api-v4/src/object-storage/buckets.ts b/packages/api-v4/src/object-storage/buckets.ts index d05db3855c8..c3be674f718 100644 --- a/packages/api-v4/src/object-storage/buckets.ts +++ b/packages/api-v4/src/object-storage/buckets.ts @@ -262,6 +262,11 @@ export const updateBucketAccess = ( setData(params, UpdateBucketAccessSchema) ); +/** + * getObjectStorageEndpoints + * + * Returns a list of Object Storage Endpoints. + */ export const getObjectStorageEndpoints = ({ filter, params }: RequestOptions) => Request>( setMethod('GET'), diff --git a/packages/api-v4/src/placement-groups/types.ts b/packages/api-v4/src/placement-groups/types.ts index 4b79bba15fe..197a28fe44d 100644 --- a/packages/api-v4/src/placement-groups/types.ts +++ b/packages/api-v4/src/placement-groups/types.ts @@ -24,15 +24,22 @@ export interface PlacementGroup { is_compliant: boolean; }[]; placement_group_policy: PlacementGroupPolicy; + migrations: { + inbound?: Array<{ linode_id: number }>; + outbound?: Array<{ linode_id: number }>; + } | null; } -export type PlacementGroupPayload = Pick< - PlacementGroup, - 'id' | 'label' | 'placement_group_type' | 'placement_group_policy' ->; +export interface LinodePlacementGroupPayload + extends Pick< + PlacementGroup, + 'id' | 'label' | 'placement_group_type' | 'placement_group_policy' + > { + migrating_to: number | null; +} export interface CreatePlacementGroupPayload - extends Omit { + extends Omit { region: Region['id']; } diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index e756cc38dca..c477d95f96a 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -11,10 +11,12 @@ export type Capabilities = | 'Distributed Plans' | 'GPU Linodes' | 'Kubernetes' + | 'Kubernetes Enterprise' | 'Linodes' | 'Managed Databases' | 'Metadata' | 'NodeBalancers' + | 'NETINT Quadra T1U' | 'Object Storage' | 'Placement Group' | 'Premium Plans' diff --git a/packages/api-v4/src/resources/index.ts b/packages/api-v4/src/resources/index.ts new file mode 100644 index 00000000000..b8a322debd8 --- /dev/null +++ b/packages/api-v4/src/resources/index.ts @@ -0,0 +1,3 @@ +export * from './resources'; + +export * from './types'; diff --git a/packages/api-v4/src/resources/resources.ts b/packages/api-v4/src/resources/resources.ts new file mode 100644 index 00000000000..55a576694bd --- /dev/null +++ b/packages/api-v4/src/resources/resources.ts @@ -0,0 +1,16 @@ +import { BETA_API_ROOT } from '../constants'; +import Request, { setMethod, setURL } from '../request'; +import { IamAccountResource } from './types'; + +/** + * getAccountResources + * + * Return all resources for account. + * + */ +export const getAccountResources = () => { + return Request( + setURL(`${BETA_API_ROOT}/resources`), + setMethod('GET') + ); +}; diff --git a/packages/api-v4/src/resources/types.ts b/packages/api-v4/src/resources/types.ts new file mode 100644 index 00000000000..bf6d89ad037 --- /dev/null +++ b/packages/api-v4/src/resources/types.ts @@ -0,0 +1,21 @@ +type ResourceType = + | 'linode' + | 'firewall' + | 'nodebalancer' + | 'longview' + | 'domain' + | 'stackscript' + | 'image' + | 'volume' + | 'database' + | 'vpc'; + +export type IamAccountResource = { + resource_type: ResourceType; + resources: Resource[]; +}[]; + +export interface Resource { + name: string; + id: number; +} diff --git a/packages/api-v4/src/support/types.ts b/packages/api-v4/src/support/types.ts index f2ab261a93e..a62c1d96040 100644 --- a/packages/api-v4/src/support/types.ts +++ b/packages/api-v4/src/support/types.ts @@ -35,10 +35,12 @@ export interface ReplyRequest { export interface TicketRequest { summary: string; description: string; + bucket?: string; domain_id?: number; linode_id?: number; longviewclient_id?: number; nodebalancer_id?: number; + region?: string; volume_id?: number; severity?: TicketSeverity; } diff --git a/packages/api-v4/src/types.ts b/packages/api-v4/src/types.ts index 13d8df47e77..0231d49a097 100644 --- a/packages/api-v4/src/types.ts +++ b/packages/api-v4/src/types.ts @@ -39,11 +39,12 @@ export interface RequestOptions { headers?: RequestHeaders; } -interface FilterConditionTypes { +export interface FilterConditionTypes { '+and'?: Filter[]; '+or'?: Filter[] | string[]; '+order_by'?: string; '+order'?: 'asc' | 'desc'; + '+eq'?: string | number; '+gt'?: number; '+gte'?: number; '+lt'?: number; diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index bc0ad624e4a..99870119ff7 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -160,13 +160,39 @@ module.exports = { rules: { '@linode/cloud-manager/deprecate-formik': 'warn', '@linode/cloud-manager/no-custom-fontWeight': 'error', - '@typescript-eslint/camelcase': 'off', '@typescript-eslint/consistent-type-imports': 'warn', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/naming-convention': [ + 'warn', + { + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + leadingUnderscore: 'allow', + selector: 'variable', + trailingUnderscore: 'allow', + }, + { + format: null, + modifiers: ['destructured'], + selector: 'variable', + }, + { + format: ['camelCase', 'PascalCase'], + selector: 'function', + }, + { + format: ['camelCase'], + leadingUnderscore: 'allow', + selector: 'parameter', + }, + { + format: ['PascalCase'], + selector: 'typeLike', + }, + ], '@typescript-eslint/no-empty-interface': 'warn', - '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-inferrable-types': 'off', '@typescript-eslint/no-namespace': 'warn', // this would disallow usage of ! postfix operator on non null types @@ -274,6 +300,7 @@ module.exports = { 'react/no-unescaped-entities': 'warn', // requires the definition of proptypes for react components 'react/prop-types': 'off', + 'react/self-closing-comp': 'warn', 'react-hooks/exhaustive-deps': 'warn', 'react-hooks/rules-of-hooks': 'error', 'react-refresh/only-export-components': 'warn', diff --git a/packages/manager/.storybook/main.ts b/packages/manager/.storybook/main.ts index 831c9af0224..ea63a35c53e 100644 --- a/packages/manager/.storybook/main.ts +++ b/packages/manager/.storybook/main.ts @@ -1,8 +1,5 @@ import type { StorybookConfig } from '@storybook/react-vite'; import { mergeConfig } from 'vite'; -import { getReactDocgenTSFileGlobs } from './utils'; - -const typeScriptFileGlobs = getReactDocgenTSFileGlobs(); const config: StorybookConfig = { stories: [ @@ -41,10 +38,6 @@ const config: StorybookConfig = { prop.parent ? !/node_modules\/(?!@mui)/.test(prop.parent.fileName) : true, - // Only compile files that have stories for faster local development performance - include: /(development|test)/i.test(process.env.NODE_ENV ?? '') - ? typeScriptFileGlobs - : undefined, }, reactDocgen: 'react-docgen-typescript', }, @@ -61,6 +54,17 @@ const config: StorybookConfig = { define: { 'process.env': {}, }, + optimizeDeps: { + include: [ + '@storybook/react', + '@storybook/react-vite', + 'react', + 'react-dom', + ], + esbuildOptions: { + target: 'esnext', + }, + }, }); }, }; diff --git a/packages/manager/.storybook/utils.test.ts b/packages/manager/.storybook/utils.test.ts deleted file mode 100644 index 405fb57b34c..00000000000 --- a/packages/manager/.storybook/utils.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { getReactDocgenTSFileGlobs } from './utils'; - -describe('getReactDocgenTSFileGlobs', () => { - const typeScriptFileGlobs = getReactDocgenTSFileGlobs(); - it('should return component and feature globs for storybook files', () => { - expect( - typeScriptFileGlobs.some((file) => - file.includes('../manager/src/components/Button/**/*.{ts,tsx}') - ) - ).toBe(true); - expect( - typeScriptFileGlobs.some((file) => - file.includes('../ui/src/components/Paper/**/*.{ts,tsx}') - ) - ).toBe(true); - expect( - typeScriptFileGlobs.some((file) => - file.includes('../manager/src/features/TopMenu/**/*.{ts,tsx}') - ) - ).toBe(true); - expect( - typeScriptFileGlobs.some((file) => - file.includes('../manager/src/features/Longview/**/*.{ts,tsx}') - ) - ).toBe(false); - }); -}); diff --git a/packages/manager/.storybook/utils.ts b/packages/manager/.storybook/utils.ts deleted file mode 100644 index 5e2686d5665..00000000000 --- a/packages/manager/.storybook/utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -import globby from 'globby'; - -const PATTERN = __dirname + '/../../**/src/**/*.stories.tsx'; - -/** - * Find all storybook files, then return the glob containing the parent component/feature. - * To be used in main.ts to tell react-docgen-typescript which files to compile. - * https://github.com/linode/manager/pull/10762 - * - * Example: src/components/Button/Button.stories.tsx -> src/components/Button/**\/*.{ts,tsx} - */ -export const getReactDocgenTSFileGlobs = () => { - const filesWithStories = globby.sync(PATTERN); - const files = new Set(); - - filesWithStories.forEach((file) => { - const execArr = /^(.*src\/(components|features)\/[a-zA-Z]*(.|\/))/.exec( - file - ); - if (execArr) { - const isDirectory = execArr[3] === '/'; - const fileBlob = `${execArr[0]}${isDirectory ? '**/*.' : ''}{ts,tsx}`; - files.add(fileBlob); - } - }); - - return Array.from(files); -}; diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 6c693fd2fd0..ead2e382b3c 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,129 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2024-12-10] - v1.133.0 + +### Added: + +- Object Storage buckets to Support tickets dropdown ([#11178](https://github.com/linode/manager/pull/11178)) +- Option to copy token on LKE details page ([#11179](https://github.com/linode/manager/pull/11179)) +- Tooltip for 'Usable Storage' in Create/Resize Database table ([#11223](https://github.com/linode/manager/pull/11223)) +- Ability to perform complex search queries on the Images landing page ([#11233](https://github.com/linode/manager/pull/11233)) +- Credit Card Expired banner ([#11240](https://github.com/linode/manager/pull/11240)) +- Product Families to Create Menu dropdown ([#11260](https://github.com/linode/manager/pull/11260)) +- Accelerated compute plans in Linode/LKE create flows ([#11287](https://github.com/linode/manager/pull/11287)) +- Docs link and region availability notice for Accelerated compute plans ([#11363](https://github.com/linode/manager/pull/11363)) + +### Changed: + +- Replace `react-beautiful-dnd` with `dnd-kit` library ([#11127](https://github.com/linode/manager/pull/11127)) +- Linode details summary VPC IPv4 text to be copyable ([#11172](https://github.com/linode/manager/pull/11172)) +- Replace Pagination page size autocomplete with simple select ([#11203](https://github.com/linode/manager/pull/11203)) +- Replace Select component with Autocomplete in DBaaS ([#11245](https://github.com/linode/manager/pull/11245)) +- Update types based on new Accelerated fields and added mock data ([#11256](https://github.com/linode/manager/pull/11256)) +- Improve the status column on the Images landing page ([#11257](https://github.com/linode/manager/pull/11257)) +- Improve Placement Groups UI during Linode migrations ([#11261](https://github.com/linode/manager/pull/11261)) +- Update docs links on empty Database landing page ([#11262](https://github.com/linode/manager/pull/11262)) +- Implement Dialogs/Drawers loading patterns ([#11273](https://github.com/linode/manager/pull/11273)) +- Improve billing contact info display when Mask Sensitive Data setting is enabled ([#11276](https://github.com/linode/manager/pull/11276)) +- Update and improve DBaaS Detail page styling and UI ([#11282](https://github.com/linode/manager/pull/11282)) +- Add IPV6 tooltip to read-only host in DBaaS summary ([#11291](https://github.com/linode/manager/pull/11291)) +- DBaaS Resize GA: Enable Downsizing (horizontal and vertical), enable 'Shared' tab, update node presentation ([#11311](https://github.com/linode/manager/pull/11311)) +- Update DBaaS Access Controls copy, placeholders, and button text ([#11371](https://github.com/linode/manager/pull/11371)) +- Adjust network_in values for distributed plans ([#11313](https://github.com/linode/manager/pull/11313)) + +### Fixed: + +- Broken firewall rules table ([#11127](https://github.com/linode/manager/pull/11127)) +- Table component styling issue for `noOverflow` property ([#11127](https://github.com/linode/manager/pull/11127)) +- Alignment for Backup Label in Add-ons Panel ([#11160](https://github.com/linode/manager/pull/11160)) +- Kubernetes details page UI issues ([#11217](https://github.com/linode/manager/pull/11217)) +- Radio size prop not affecting the radio button's dimensions ([#11242](https://github.com/linode/manager/pull/11242)) +- Storybook docgen ([#11264](https://github.com/linode/manager/pull/11264)) +- DBaaS: summary read-only host field is blank ([#11265](https://github.com/linode/manager/pull/11265)) +- DBaaS: landing paginator disappears when pageSize is less than the number of instances ([#11275](https://github.com/linode/manager/pull/11275)) +- Incorrect Account Maintenance X-Filter ([#11277](https://github.com/linode/manager/pull/11277)) +- Storybook optimizeDeps config to improve cold start ([#11278](https://github.com/linode/manager/pull/11278)) +- Table and Chart Legend Spacing ([#11294](https://github.com/linode/manager/pull/11294)) +- Content shifting on Linode Details summary graphs ([#11301](https://github.com/linode/manager/pull/11301)) +- CORS toggle incorrectly appearing for Object Storage bucket objects ([#11355](https://github.com/linode/manager/pull/11355)) +- LinodeCreate OS Panel fetching region with -1 on page load ([#11356](https://github.com/linode/manager/pull/11356)) +- Lack of uniform spacing between resource link columns in empty state landing pages ([#11213](https://github.com/linode/manager/pull/11213)) +- Convert Object Storage bucket sizes from `GiB` to `GB` in the frontend ([#11293](https://github.com/linode/manager/pull/11293)) + +### Removed: + +- Migrate CircleProgress from `manager` to `ui` package ([#11214](https://github.com/linode/manager/pull/11214)) +- Move `ClickAwayListener` from `manager` to `ui` package ([#11267](https://github.com/linode/manager/pull/11267)) +- TooltipIcon component (migrated to `ui` package) ([#11269](https://github.com/linode/manager/pull/11269)) +- Move `Checkbox` from `manager` to `ui` package ([#11279](https://github.com/linode/manager/pull/11279)) +- Move `H1Header` from `manager` to `ui` package ([#11283](https://github.com/linode/manager/pull/11283)) +- `TextField` component and `convertToKebabCase` utility function (migrated to `ui` package) ([#11290](https://github.com/linode/manager/pull/11290)) +- `Toggle` component and `ToggleOn` and `ToggleOff` icons (migrated to `ui` package) ([#11296](https://github.com/linode/manager/pull/11296)) +- Migrate `EditableText` from `manager` to `ui` package ([#11308](https://github.com/linode/manager/pull/11308)) +- `Autocomplete`, `List`, and `ListItem` components (migrated to `ui` package) ([#11314](https://github.com/linode/manager/pull/11314)) +- Move `Accordion` from `manager` to `ui` package ([#11316](https://github.com/linode/manager/pull/11316)) +- Recently added camelCase rule ([#11330](https://github.com/linode/manager/pull/11330)) +- Migrate `FormControlLabel` from `manager` to `ui` package ([#11353](https://github.com/linode/manager/pull/11353)) +- Move `Chip` from `manager` to `ui` package ([#11266](https://github.com/linode/manager/pull/11266)) + +### Tech Stories: + +- Update PULL_REQUEST_TEMPLATE ([#11219](https://github.com/linode/manager/pull/11219), [#11236](https://github.com/linode/manager/pull/11236)) +- Optimize Events Polling following changes from incident ([#11263](https://github.com/linode/manager/pull/11263)) +- Add documentation for form validation best practices ([#11298](https://github.com/linode/manager/pull/11298)) +- Update developer docs on unit testing user events ([#11221](https://github.com/linode/manager/pull/11221)) +- Refactor components to use `clamp` from `@linode/ui` rather than `ramda` ([#11306](https://github.com/linode/manager/pull/11306)) +- Update yup from `0.32.9` to `1.4.0` ([#11324](https://github.com/linode/manager/pull/11324)) +- Further improvements to PR template author checklist sections ([#11325](https://github.com/linode/manager/pull/11325)) +- Bump recharts to ^2.14.1 ([#11358](https://github.com/linode/manager/pull/11358)) +- Change Pendo sanitized URL path string ([#11361](https://github.com/linode/manager/pull/11361)) +- Replace one-off hardcoded color values with color tokens pt3 ([#11241](https://github.com/linode/manager/pull/11241)) +- Adjust linter rules for common PR feedback points ([#11258](https://github.com/linode/manager/pull/11258)) +- Adjust linter rules for naming convention ([#11337](https://github.com/linode/manager/pull/11337)) + +### Tests: + +- Add Cypress test for Account Maintenance CSV downloads ([#11168](https://github.com/linode/manager/pull/11168)) +- Mock disable OBJ Gen 2 flags for existing OBJ Cypress tests ([#11191](https://github.com/linode/manager/pull/11191)) +- Fix DBaaS resize tests that fail on first attempt and succeed on second ([#11238](https://github.com/linode/manager/pull/11238)) +- Add Cypress tests to verify ACLP UI's handling of API errors ([#11239](https://github.com/linode/manager/pull/11239)) +- Unskip Placement Group landing page navigation test ([#11272](https://github.com/linode/manager/pull/11272)) +- Fix Linode migration test failure caused by region label conflicts ([#11274](https://github.com/linode/manager/pull/11274)) +- Add Cypress test for restricted user Image Empty landing page ([#11281](https://github.com/linode/manager/pull/11281)) +- Fix StackScript update test failure triggered by recent deprecation ([#11292](https://github.com/linode/manager/pull/11292)) +- Fix test failure in `linode-storage.spec.ts` ([#11304](https://github.com/linode/manager/pull/11304)) +- Fix `machine-image-upload.spec.ts` test failures ([#11319](https://github.com/linode/manager/pull/11319)) +- Add tests for accelerated plans in `plan-selection.spec.ts` ([#11323](https://github.com/linode/manager/pull/11323)) +- Add new assertions for linode backup Cypress tests ([#11326](https://github.com/linode/manager/pull/11326)) +- Add test to create a mock accelerated Linode ([#11327](https://github.com/linode/manager/pull/11327)) +- Fix DBaaS unit test flake ([#11332](https://github.com/linode/manager/pull/11332)) +- Add unit test cases for `DialogTitle` component ([#11340](https://github.com/linode/manager/pull/11340)) +- Add unit test cases for EntityHeader component ([#11222](https://github.com/linode/manager/pull/11222)) +- Add unit test cases for `CopyableTextField` component ([#11268](https://github.com/linode/manager/pull/11268)) +- Add unit test cases for `DocsLink` component ([#11336](https://github.com/linode/manager/pull/11336)) + +### Upcoming Features: + +- Replace `LineGraph` with `AreaChart` and add `DataSet` type in `CloudPulseLineGraph` component, add `connectNulls`, `dotRadius`, `showDot`, `xAxisTickCount` property and `ChartVariant` interface in `AreaChart.ts` ([#11204](https://github.com/linode/manager/pull/11204)) +- Configure max limit on CloudPulse resource selection component ([#11252](https://github.com/linode/manager/pull/11252)) +- Add Create Alert Button, Add Name, Description, Severity components to the Create Alert Form ([#11255](https://github.com/linode/manager/pull/11255)) +- Add feature flag and hook for LKE-E enablement ([#11259](https://github.com/linode/manager/pull/11259)) +- Add and update kubernetes queries for LKE-E beta endpoints ([#11302](https://github.com/linode/manager/pull/11302)) +- Handle JWE token limit of 250 in ACLP UI ([#11309](https://github.com/linode/manager/pull/11309)) +- Modify `generate12HoursTicks` method in AreaChart `utils.ts`, remove breakpoint condition in `MetricsDisplay.tsx`, modify `legendHeight` and `xAxisTickCount` in `CloudPulseLineGraph.tsx` ([#11317](https://github.com/linode/manager/pull/11317)) +- Add new PAT β€˜Monitor’ scope for CloudPulse ([#11318](https://github.com/linode/manager/pull/11318)) +- Add Cluster Type section to Create Cluster flow for LKE-E ([#11322](https://github.com/linode/manager/pull/11322)) +- Update Region Select for LKE-Enterprise ([#11348](https://github.com/linode/manager/pull/11348)) +- Update Regions/S3 Hostnames interface to match new design guidelines with + improved visualization of multiple storage regions ([#11355](https://github.com/linode/manager/pull/11355)) +- Remove Properties tab visibility for users without Gen2 capabilities and fix duplicate bucket display issue ([#11355](https://github.com/linode/manager/pull/11355)) +- Add new routes for IAM, feature flag and menu item ([#11310](https://github.com/linode/manager/pull/11310)) +- Mock data and query for new IAM permission API ([#11146](https://github.com/linode/manager/pull/11146)) +- Mock data and query for new IAM account API ([#11181](https://github.com/linode/manager/pull/11181)) +- Add ResourceMultiSelect component for CloudPulse alerting ([#11331](https://github.com/linode/manager/pull/11331)) +- Service, Engine Option, Region components to CloudPulse Create Alert form ([#11286](https://github.com/linode/manager/pull/11286)) + ## [2024-11-22] - v1.132.2 ### Changed: diff --git a/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts b/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts index 56292a96bd5..ba00f881332 100644 --- a/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts @@ -1,5 +1,6 @@ import { mockGetMaintenance } from 'support/intercepts/account'; import { accountMaintenanceFactory } from 'src/factories'; +import { parseCsv } from 'support/util/csv'; describe('Maintenance', () => { /* @@ -7,6 +8,21 @@ describe('Maintenance', () => { * - When there is no pending maintenance, "No pending maintenance." is shown in the table. * - When there is no completed maintenance, "No completed maintenance." is shown in the table. */ + beforeEach(() => { + const downloadsFolder = Cypress.config('downloadsFolder'); + const filePatterns = '{pending-maintenance*,completed-maintenance*}'; + // Delete the file before the test + cy.exec(`rm -f ${downloadsFolder}/${filePatterns}`, { + failOnNonZeroExit: false, + }).then((result) => { + if (result.code === 0) { + cy.log(`Deleted file: ${filePatterns}`); + } else { + cy.log(`Failed to delete file: ${filePatterns}`); + } + }); + }); + it('table empty when no maintenance', () => { mockGetMaintenance([], []).as('getMaintenance'); @@ -118,12 +134,106 @@ describe('Maintenance', () => { }); }); - // Confirm download buttons work - cy.get('button') - .filter(':contains("Download CSV")') - .should('be.visible') - .should('be.enabled') - .click({ multiple: true }); - // TODO Need to add assertions to confirm CSV contains the expected contents on first trial (M3-8393) + // Validate content of the downloaded CSV for pending maintenance + cy.get('a[download*="pending-maintenance"]') + .invoke('attr', 'download') + .then((fileName) => { + const downloadsFolder = Cypress.config('downloadsFolder'); + + // Locate the element for pending-maintenance and then find its sibling + updateArgs({ open: false })} + open={open} + > + + A most sober dialog, with a title and a description. + + + + + ); + }; + + return DrawerExampleWrapper(); + }, +}; diff --git a/packages/manager/src/components/Dialog/Dialog.tsx b/packages/manager/src/components/Dialog/Dialog.tsx index 6002e942dfc..f4df58c8269 100644 --- a/packages/manager/src/components/Dialog/Dialog.tsx +++ b/packages/manager/src/components/Dialog/Dialog.tsx @@ -1,22 +1,41 @@ -import { Box, omittedProps } from '@linode/ui'; +import { Box, CircleProgress, Notice, omittedProps } from '@linode/ui'; import _Dialog from '@mui/material/Dialog'; import DialogContent from '@mui/material/DialogContent'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; import { DialogTitle } from 'src/components/DialogTitle/DialogTitle'; -import { Notice } from 'src/components/Notice/Notice'; import { convertForAria } from 'src/utilities/stringUtils'; import type { DialogProps as _DialogProps } from '@mui/material/Dialog'; export interface DialogProps extends _DialogProps { + /** + * Additional CSS to be applied to the Dialog. + */ className?: string; + /** + * Error that will be shown in the dialog. + */ error?: string; + /** + * Let the Dialog take up the entire height of the viewport. + */ fullHeight?: boolean; + /** + * Whether the drawer is fetching the entity's data. + * + * If true, the drawer will feature a loading spinner for its content. + */ + isFetching?: boolean; + /** + * Subtitle that will be shown in the dialog. + */ subtitle?: string; + /** + * Title that will be shown in the dialog. + */ title: string; - titleBottomBorder?: boolean; } /** @@ -32,7 +51,7 @@ export interface DialogProps extends _DialogProps { * - **Confirmation** * - Users must confirm a choice * - **Deletion** - * - The user must confirm the deleteion of an entity + * - The user must confirm the deletion of an entity * - Can require user to type the entity name to confirm deletion * * > Clicking off of the modal will not close it. @@ -48,26 +67,44 @@ export const Dialog = React.forwardRef( error, fullHeight, fullWidth, + isFetching, maxWidth = 'md', onClose, + open, subtitle, title, - titleBottomBorder, ...rest } = props; const titleID = convertForAria(title); + // Store the last valid children and title in refs + // This is to prevent flashes of content during the drawer's closing transition, + // and its content becomes potentially undefined + const lastChildrenRef = React.useRef(children); + const lastTitleRef = React.useRef(title); + // Update refs when the drawer is open and content is matched + if (open && children) { + lastChildrenRef.current = children; + lastTitleRef.current = title; + } + return ( { + if (onClose && reason !== 'backdropClick') { + onClose({}, 'escapeKeyDown'); + } + }} aria-labelledby={titleID} + closeAfterTransition={false} data-qa-dialog data-qa-drawer data-testid="drawer" fullHeight={fullHeight} fullWidth={fullWidth} maxWidth={(fullWidth && maxWidth) ?? undefined} - onClose={onClose} + open={open} ref={ref} role="dialog" title={title} @@ -80,11 +117,11 @@ export const Dialog = React.forwardRef( > onClose && onClose({}, 'backdropClick')} + isFetching={isFetching} + onClose={() => onClose?.({}, 'escapeKeyDown')} subtitle={subtitle} - title={title} + title={lastTitleRef.current} /> - {titleBottomBorder && } - {error && } - {children} + {isFetching ? ( + + + + ) : ( + <> + {error && } + {lastChildrenRef.current} + + )} @@ -107,19 +152,10 @@ const StyledDialog = styled(_Dialog, { '& .MuiDialog-paper': { height: props.fullHeight ? '100vh' : undefined, maxHeight: '100%', + minWidth: '500px', padding: 0, - }, - '& .MuiDialogActions-root': { - display: 'flex', - justifyContent: 'flex-end', - marginTop: theme.spacing(2), + [theme.breakpoints.down('md')]: { + minWidth: '380px', + }, }, })); - -const StyledHr = styled('hr')({ - backgroundColor: '#e3e5e8', - border: 'none', - height: 1, - margin: '-2em 8px 0px 8px', - width: '100%', -}); diff --git a/packages/manager/src/components/DialogTitle/DialogTitle.test.tsx b/packages/manager/src/components/DialogTitle/DialogTitle.test.tsx new file mode 100644 index 00000000000..247d4739f81 --- /dev/null +++ b/packages/manager/src/components/DialogTitle/DialogTitle.test.tsx @@ -0,0 +1,54 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DialogTitle } from './DialogTitle'; + +import type { DialogTitleProps } from './DialogTitle'; + +const mockId = '1'; +const mockSubtitle = 'This a basic dialog'; +const mockTitle = 'This is a Dialog'; + +const defaultProps: DialogTitleProps = { + id: mockId, + subtitle: mockSubtitle, + title: mockTitle, +}; + +describe('DialogTitle', () => { + it('should render title, subtitle and id', () => { + const { getByRole, getByText } = renderWithTheme( + + ); + expect(getByText(mockTitle)).toBeVisible(); + expect(getByText(mockSubtitle)).toBeVisible(); + const titleElement = getByRole('heading'); + expect(titleElement).toHaveAttribute('id', mockId); + }); + + it('should not render title when isFetching is true', () => { + const { queryByText } = renderWithTheme( + + ); + expect(queryByText(mockTitle)).not.toBeInTheDocument(); + }); + + it('should close the dialog Box if the close button is clicked', async () => { + const onCloseMock = vi.fn(); + const { getByRole } = renderWithTheme( + + ); + const closeButton = getByRole('button', { + name: 'Close', + }); + expect(closeButton).toBeInTheDocument(); + await userEvent.click(closeButton); + expect(onCloseMock).toHaveBeenCalledTimes(1); + expect(onCloseMock).toHaveBeenCalledWith({}, 'escapeKeyDown'); + }); +}); diff --git a/packages/manager/src/components/DialogTitle/DialogTitle.tsx b/packages/manager/src/components/DialogTitle/DialogTitle.tsx index ecf01cd0200..9134a6e94b8 100644 --- a/packages/manager/src/components/DialogTitle/DialogTitle.tsx +++ b/packages/manager/src/components/DialogTitle/DialogTitle.tsx @@ -6,9 +6,10 @@ import * as React from 'react'; import type { SxProps, Theme } from '@mui/material'; -interface DialogTitleProps { +export interface DialogTitleProps { className?: string; id?: string; + isFetching?: boolean; onClose?: () => void; subtitle?: string; sx?: SxProps; @@ -17,7 +18,7 @@ interface DialogTitleProps { const DialogTitle = (props: DialogTitleProps) => { const ref = React.useRef(null); - const { className, id, onClose, subtitle, sx, title } = props; + const { className, id, isFetching, onClose, subtitle, sx, title } = props; React.useEffect(() => { if (ref.current === null) { @@ -48,8 +49,8 @@ const DialogTitle = (props: DialogTitleProps) => { data-qa-dialog-title={title} data-qa-drawer-title={title} > - {title} - {onClose != null && ( + {!isFetching && title} + {onClose !== null && ( ({ '&&': { diff --git a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.test.tsx b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.test.tsx index cba9ba38d02..693b3f88def 100644 --- a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.test.tsx +++ b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.test.tsx @@ -1,8 +1,7 @@ +import { Button, Typography } from '@linode/ui'; import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { Button } from 'src/components/Button/Button'; -import { Typography } from 'src/components/Typography'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { DismissibleBanner } from './DismissibleBanner'; diff --git a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx index 0322ed2ffbf..4ee35065ea8 100644 --- a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx +++ b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx @@ -7,7 +7,7 @@ import { useDismissibleNotifications } from 'src/hooks/useDismissibleNotificatio import { StyledButton, StyledNotice } from './DismissibleBanner.styles'; -import type { NoticeProps } from 'src/components/Notice/Notice'; +import type { NoticeProps } from '@linode/ui'; import type { DismissibleNotificationOptions } from 'src/hooks/useDismissibleNotifications'; interface Props extends NoticeProps { diff --git a/packages/manager/src/components/DisplayPrice/DisplayPrice.tsx b/packages/manager/src/components/DisplayPrice/DisplayPrice.tsx index 134d880c231..767826df7b3 100644 --- a/packages/manager/src/components/DisplayPrice/DisplayPrice.tsx +++ b/packages/manager/src/components/DisplayPrice/DisplayPrice.tsx @@ -1,8 +1,10 @@ -import { SxProps, Theme, useTheme } from '@mui/material/styles'; +import { Typography } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Currency } from 'src/components/Currency'; -import { Typography } from 'src/components/Typography'; + +import type { SxProps, Theme } from '@mui/material/styles'; export interface DisplayPriceProps { /** diff --git a/packages/manager/src/components/DocsLink/DocsLink.test.tsx b/packages/manager/src/components/DocsLink/DocsLink.test.tsx new file mode 100644 index 00000000000..d8ddf2bee3f --- /dev/null +++ b/packages/manager/src/components/DocsLink/DocsLink.test.tsx @@ -0,0 +1,45 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { sendHelpButtonClickEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DocsLink } from './DocsLink'; + +import type { DocsLinkProps } from './DocsLink'; + +vi.mock('src/utilities/analytics/customEventAnalytics', () => ({ + sendHelpButtonClickEvent: vi.fn(), +})); + +const mockLabel = 'Custom Doc Link Label'; +const mockHref = + 'https://techdocs.akamai.com/cloud-computing/docs/faqs-for-compute-instances'; +const mockAnalyticsLabel = 'Label'; + +const defaultProps: DocsLinkProps = { + analyticsLabel: mockAnalyticsLabel, + href: mockHref, + label: mockLabel, +}; + +describe('DocsLink', () => { + it('should render the label', () => { + const { getByText } = renderWithTheme(); + expect(getByText(mockLabel)).toBeVisible(); + }); + + it('should allow user to click the label and redirect to the url', async () => { + const { getByRole } = renderWithTheme(); + const link = getByRole('link', { + name: 'Custom Doc Link Label - link opens in a new tab', + }); + expect(link).toBeInTheDocument(); + await userEvent.click(link); + expect(sendHelpButtonClickEvent).toHaveBeenCalledTimes(1); + expect(sendHelpButtonClickEvent).toHaveBeenCalledWith( + mockHref, + mockAnalyticsLabel + ); + }); +}); diff --git a/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx b/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx index ace75c60c7f..b0b0030be20 100644 --- a/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx +++ b/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx @@ -1,12 +1,11 @@ +import { Button, StyledLinkButton } from '@linode/ui'; import * as React from 'react'; import { CSVLink } from 'react-csv'; import DownloadIcon from 'src/assets/icons/lke-download.svg'; -import { Button } from 'src/components/Button/Button'; -import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; +import type { ButtonType } from '@linode/ui'; import type { SxProps, Theme } from '@mui/material/styles'; -import type { ButtonType } from 'src/components/Button/Button'; interface DownloadCSVProps { buttonType?: 'styledLink' | ButtonType; diff --git a/packages/manager/src/components/DownloadTooltip.tsx b/packages/manager/src/components/DownloadTooltip.tsx index b6e697e8cf6..dfcd502bb94 100644 --- a/packages/manager/src/components/DownloadTooltip.tsx +++ b/packages/manager/src/components/DownloadTooltip.tsx @@ -1,9 +1,8 @@ -import { Tooltip } from '@linode/ui'; +import { Tooltip, Typography } from '@linode/ui'; import * as React from 'react'; import FileDownload from 'src/assets/icons/download.svg'; import { StyledIconButton } from 'src/components/CopyTooltip/CopyTooltip'; -import { Typography } from 'src/components/Typography'; import { downloadFile } from 'src/utilities/downloadFile'; interface Props { diff --git a/packages/manager/src/components/Drawer.stories.tsx b/packages/manager/src/components/Drawer.stories.tsx index 090b4a5eb0c..6ee56017130 100644 --- a/packages/manager/src/components/Drawer.stories.tsx +++ b/packages/manager/src/components/Drawer.stories.tsx @@ -1,12 +1,12 @@ +import { Button, TextField, Typography } from '@linode/ui'; import { action } from '@storybook/addon-actions'; -import { Meta, StoryObj } from '@storybook/react'; +import { useArgs } from '@storybook/preview-api'; import React from 'react'; import { ActionsPanel } from './ActionsPanel/ActionsPanel'; -import { Button } from './Button/Button'; import { Drawer } from './Drawer'; -import { TextField } from './TextField'; -import { Typography } from './Typography'; + +import type { Meta, StoryObj } from '@storybook/react'; const meta: Meta = { component: Drawer, @@ -24,6 +24,7 @@ export const Default: Story = { render: (args) => { const DrawerExampleWrapper = () => { const [open, setOpen] = React.useState(args.open); + return ( <> + updateArgs({ open: false })} + open={open} + > + + I smirked at their Kale chips banh-mi fingerstache brunch in + Williamsburg. + + + Meanwhile in my closet-style flat in Red-Hook, my pour-over coffee + glitched on my vinyl record player while I styled the bottom left + corner of my beard. Those artisan tacos I ordered were infused + with turmeric and locally sourced honey, a true farm-to-table + vibe. Pabst Blue Ribbon in hand, I sat on my reclaimed wood bench + next to the macramΓ© plant holder. + + + Narwhal selfies dominated my Instagram feed, hashtagged with "slow + living" and "normcore aesthetics". My kombucha brewing kit arrived + just in time for me to ferment my own chai-infused blend. As I + adjusted my vintage round glasses, a tiny house documentary + started playing softly in the background. The retro typewriter + clacked as I typed out my minimalist poetry on sustainably sourced + paper. The sun glowed through the window, shining light on the + delightful cracks of my Apple watch. + + It was Saturday. + updateArgs({ open: false }), + }} + primaryButtonProps={{ label: 'Save' }} + /> + + + ); + }; + + return DrawerExampleWrapper(); + }, +}; + export default meta; diff --git a/packages/manager/src/components/Drawer.test.tsx b/packages/manager/src/components/Drawer.test.tsx new file mode 100644 index 00000000000..9d0245b43ec --- /dev/null +++ b/packages/manager/src/components/Drawer.test.tsx @@ -0,0 +1,77 @@ +import { Button } from '@linode/ui'; +import { fireEvent, waitFor } from '@testing-library/react'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { Drawer } from './Drawer'; + +import type { DrawerProps } from './Drawer'; + +describe('Drawer', () => { + const defaultArgs: DrawerProps = { + onClose: vi.fn(), + open: false, + title: 'This is a Drawer', + }; + + it.each([ + ['not render', false], + ['render', true], + ])('should %s a Dialog with title when open is %s', (_, isOpen) => { + const { queryByTestId, queryByText } = renderWithTheme( + + ); + + const title = queryByText('This is a Drawer'); + const drawer = queryByTestId('drawer'); + + if (isOpen) { + expect(title).toBeInTheDocument(); + expect(drawer).toBeInTheDocument(); + } else { + expect(title).not.toBeInTheDocument(); + expect(drawer).not.toBeInTheDocument(); + } + }); + + it('should render a Dialog with children if provided', () => { + const { getByText } = renderWithTheme( + +

Child items can go here!

+
+ ); + + expect(getByText('Child items can go here!')).toBeInTheDocument(); + }); + + it('should call onClose when the Dialog close button is clicked', async () => { + const { getByRole } = renderWithTheme( + +

Child items can go here!

+ +
+ ); + + const closeButton = getByRole('button', { name: 'Close' }); + fireEvent.click(closeButton); + + await waitFor(() => { + expect(defaultArgs.onClose).toHaveBeenCalled(); + }); + }); + + it('should render a Dialog with a loading spinner if isFetching is true', () => { + const { getByRole } = renderWithTheme( + + ); + + expect(getByRole('progressbar')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/components/Drawer.tsx b/packages/manager/src/components/Drawer.tsx index 07da8da9af5..d9da0d4f1bf 100644 --- a/packages/manager/src/components/Drawer.tsx +++ b/packages/manager/src/components/Drawer.tsx @@ -1,21 +1,27 @@ -import { IconButton } from '@linode/ui'; +import { Box, CircleProgress, IconButton, Typography } from '@linode/ui'; import Close from '@mui/icons-material/Close'; import _Drawer from '@mui/material/Drawer'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { Typography } from 'src/components/Typography'; import { convertForAria } from 'src/utilities/stringUtils'; -import type { DrawerProps } from '@mui/material/Drawer'; +import { ErrorState } from './ErrorState/ErrorState'; +import { NotFound } from './NotFound'; + +import type { APIError } from '@linode/api-v4'; +import type { DrawerProps as _DrawerProps } from '@mui/material/Drawer'; import type { Theme } from '@mui/material/styles'; -interface Props extends DrawerProps { +export interface DrawerProps extends _DrawerProps { + error?: APIError[] | null | string; /** - * Callback fired when the drawer closing animation has completed. + * Whether the drawer is fetching the entity's data. + * + * If true, the drawer will feature a loading spinner for its content. */ - onExited?: () => void; + isFetching?: boolean; /** * Title that appears at the top of the drawer */ @@ -27,6 +33,125 @@ interface Props extends DrawerProps { wide?: boolean; } +/** + * ## Overview + * - Drawers are essentially modal dialogs that appear on the right of the screen rather than the center. + * - Like traditional modals, they block interaction with the page content. + * - They are elevated above the app’s UI and don’t affect the screen’s layout grid. + * + * ## Behavior + * + * - Clicking a button on the screen opens the drawer. + * - Drawers can be closed by pressing the `esc` key, clicking the β€œX” icon, or clicking the β€œCancel” button. + */ +export const Drawer = React.forwardRef( + (props: DrawerProps, ref) => { + const { classes, cx } = useStyles(); + const { + children, + error, + isFetching, + onClose, + open, + title, + wide, + ...rest + } = props; + const titleID = convertForAria(title); + + // Store the last valid children and title in refs + // This is to prevent flashes of content during the drawer's closing transition, + // and its content becomes potentially undefined + const lastChildrenRef = React.useRef(children); + const lastTitleRef = React.useRef(title); + // Update refs when the drawer is open and content is matched + if (open && children) { + lastChildrenRef.current = children; + lastTitleRef.current = title; + } + + return ( + <_Drawer + classes={{ + paper: cx(classes.common, { + [classes.default]: !wide, + [classes.wide]: wide, + }), + }} + onClose={(_, reason) => { + if (onClose && reason !== 'backdropClick') { + onClose({}, 'escapeKeyDown'); + } + }} + anchor="right" + open={open} + ref={ref} + {...rest} + aria-labelledby={titleID} + data-qa-drawer + data-testid="drawer" + role="dialog" + > + + + {isFetching ? null : ( + + {title} + + )} + + + onClose?.({}, 'escapeKeyDown')} + size="large" + > + + + + + {error ? ( + error === 'Not Found' ? ( + + ) : ( + + ) + ) : isFetching ? ( + + + + ) : ( + children + )} + + ); + } +); + const useStyles = makeStyles()((theme: Theme) => ({ button: { '& :hover, & :focus': { @@ -76,85 +201,3 @@ const useStyles = makeStyles()((theme: Theme) => ({ width: '100%', }, })); - -/** - * ## Overview - * - Drawers are essentially modal dialogs that appear on the right of the screen rather than the center. - * - Like traditional modals, they block interaction with the page content. - * - They are elevated above the app’s UI and don’t affect the screen’s layout grid. - * - * ## Behavior - * - * - Clicking a button on the screen opens the drawer. - * - Drawers can be closed by pressing the `esc` key, clicking the β€œX” icon, or clicking the β€œCancel” button. - */ -export const Drawer = (props: Props) => { - const { classes, cx } = useStyles(); - - const { children, onClose, onExited, title, wide, ...rest } = props; - - const titleID = convertForAria(title); - - return ( - <_Drawer - classes={{ - paper: cx(classes.common, { - [classes.default]: !wide, - [classes.wide]: wide, - }), - }} - onClose={(event, reason) => { - if (onClose && reason !== 'backdropClick') { - onClose(event, reason); - } - }} - anchor="right" - {...rest} - aria-labelledby={titleID} - data-qa-drawer - data-testid="drawer" - onTransitionExited={onExited} - role="dialog" - > - - - - {title} - - - - void} - size="large" - > - - - - - {children} - - ); -}; diff --git a/packages/manager/src/components/DrawerContent/DrawerContent.test.tsx b/packages/manager/src/components/DrawerContent/DrawerContent.test.tsx deleted file mode 100644 index 9494eab1dab..00000000000 --- a/packages/manager/src/components/DrawerContent/DrawerContent.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { screen } from '@testing-library/react'; -import * as React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { DrawerContent, DrawerContentProps } from './DrawerContent'; - -const defaultChildren =
Content
; - -const renderDrawer = (props: DrawerContentProps) => - renderWithTheme(); - -const props: DrawerContentProps = { - children: defaultChildren, - error: false, - loading: true, - title: 'my-drawer', -}; - -describe('DrawerContent', () => { - it('should show a loading component while loading is in progress', () => { - renderDrawer(props); - expect(screen.queryByText('Content')).not.toBeInTheDocument(); - expect(screen.getByTestId('circle-progress')).toBeInTheDocument(); - }); - - it('should show error if loading is finished but the error persists', () => { - renderDrawer({ - ...props, - error: true, - errorMessage: 'My Error', - loading: false, - }); - expect(screen.getByText('My Error')).toBeInTheDocument(); - expect(screen.queryByText('Content')).not.toBeInTheDocument(); - expect(screen.queryByTestId('circle-progress')).not.toBeInTheDocument(); - }); - it('should display content if there is no error nor loading', () => { - renderDrawer({ ...props, loading: false }); - expect(screen.getByText('Content')).toBeInTheDocument(); - }); -}); diff --git a/packages/manager/src/components/DrawerContent/DrawerContent.tsx b/packages/manager/src/components/DrawerContent/DrawerContent.tsx deleted file mode 100644 index a19fb1949a6..00000000000 --- a/packages/manager/src/components/DrawerContent/DrawerContent.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import * as React from 'react'; - -import { CircleProgress } from 'src/components/CircleProgress'; -import { Notice } from 'src/components/Notice/Notice'; - -export interface DrawerContentProps { - children: React.ReactNode; - error: boolean; - errorMessage?: string; - loading: boolean; - title: string; -} - -export const DrawerContent = (props: DrawerContentProps) => { - const { children, error, errorMessage, loading, title } = props; - if (loading) { - return ; - } - - if (error) { - return ( - - {errorMessage ?? `Couldn't load ${title}`} - - ); - } - // eslint-disable-next-line - return <>{children}; -}; diff --git a/packages/manager/src/components/DrawerContent/index.ts b/packages/manager/src/components/DrawerContent/index.ts deleted file mode 100644 index b09387604e4..00000000000 --- a/packages/manager/src/components/DrawerContent/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { DrawerContentProps } from './DrawerContent'; -export { DrawerContent } from './DrawerContent'; diff --git a/packages/manager/src/components/EditableEntityLabel/EditableEntityLabel.tsx b/packages/manager/src/components/EditableEntityLabel/EditableEntityLabel.tsx index 08d04d7d1a8..5e9048e0c63 100644 --- a/packages/manager/src/components/EditableEntityLabel/EditableEntityLabel.tsx +++ b/packages/manager/src/components/EditableEntityLabel/EditableEntityLabel.tsx @@ -1,9 +1,9 @@ -import Grid from '@mui/material/Unstable_Grid2'; +import { Typography } from '@linode/ui'; import { styled, useTheme } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { EntityIcon } from 'src/components/EntityIcon/EntityIcon'; -import { Typography } from 'src/components/Typography'; import { EditableInput } from './EditableInput'; diff --git a/packages/manager/src/components/EditableEntityLabel/EditableInput.styles.tsx b/packages/manager/src/components/EditableEntityLabel/EditableInput.styles.tsx index 303542ee695..b36143aa9af 100644 --- a/packages/manager/src/components/EditableEntityLabel/EditableInput.styles.tsx +++ b/packages/manager/src/components/EditableEntityLabel/EditableInput.styles.tsx @@ -1,14 +1,9 @@ -import { Box } from '@linode/ui'; +import { Box, Button, TextField, Typography, fadeIn } from '@linode/ui'; import Edit from '@mui/icons-material/Edit'; import { styled } from '@mui/material/styles'; -import { Button } from 'src/components/Button/Button'; -import { TextField } from 'src/components/TextField'; -import { Typography } from 'src/components/Typography'; -import { fadeIn } from 'src/styles/keyframes'; - import type { EditableTextVariant } from './EditableInput'; -import type { TextFieldProps } from 'src/components/TextField'; +import type { TextFieldProps } from '@linode/ui'; export const StyledTypography = styled(Typography, { label: 'EditableInput__StyledTypography', diff --git a/packages/manager/src/components/EditableEntityLabel/EditableInput.tsx b/packages/manager/src/components/EditableEntityLabel/EditableInput.tsx index 2873c2b8b10..dd195ef8adb 100644 --- a/packages/manager/src/components/EditableEntityLabel/EditableInput.tsx +++ b/packages/manager/src/components/EditableEntityLabel/EditableInput.tsx @@ -1,10 +1,8 @@ -import { IconButton } from '@linode/ui'; +import { ClickAwayListener, IconButton } from '@linode/ui'; import Check from '@mui/icons-material/Check'; import Close from '@mui/icons-material/Close'; import * as React from 'react'; -import { ClickAwayListener } from 'src/components/ClickAwayListener'; - import { StyledButton, StyledEdit, @@ -14,7 +12,7 @@ import { StyledTypography, } from './EditableInput.styles'; -import type { TextFieldProps } from 'src/components/TextField'; +import type { TextFieldProps } from '@linode/ui'; export type EditableTextVariant = 'h1' | 'h2' | 'table-cell'; diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinks.tsx b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinks.tsx index 8e1bdd87c3d..95be51757e4 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinks.tsx +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinks.tsx @@ -1,8 +1,7 @@ +import { List, ListItem } from '@linode/ui'; import * as React from 'react'; import { Link } from 'src/components/Link'; -import { List } from 'src/components/List'; -import { ListItem } from 'src/components/ListItem'; import { getLinkOnClick } from 'src/utilities/emptyStateLandingUtils'; import type { ResourcesLinks } from './ResourcesLinksTypes'; diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksSection.tsx b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksSection.tsx index d25b874d81d..e4317a5f9e1 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksSection.tsx +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksSection.tsx @@ -18,13 +18,16 @@ const StyledResourcesLinksSection = styled('div', { display: 'grid', gridAutoColumns: '1fr', gridAutoFlow: 'column', - justifyItems: 'center', - maxWidth: props.wide === false ? 762 : '100%', + [theme.breakpoints.between('md', 'lg')]: { + width: 'auto', + }, [theme.breakpoints.down(props.wide ? 'lg' : 'md')]: { gridAutoFlow: 'row', justifyItems: 'start', + maxWidth: props.wide === false ? 361 : '100%', rowGap: theme.spacing(8), }, + width: props.wide === false ? 762 : '100%', })); export const ResourcesLinksSection = ({ diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksSubSection.tsx b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksSubSection.tsx index 1756903172b..7af2d825310 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksSubSection.tsx +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksSubSection.tsx @@ -1,8 +1,7 @@ +import { Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { Typography } from 'src/components/Typography'; - interface ResourcesLinksSubSectionProps { MoreLink?: (props: { className?: any }) => JSX.Element; children?: JSX.Element | JSX.Element[]; @@ -17,6 +16,7 @@ const StyledResourcesLinksSubSection = styled('div', { '& > a': { display: 'inline-block', fontFamily: theme.font.bold, + width: '100%', }, '& > h2': { color: theme.palette.text.primary, @@ -34,7 +34,6 @@ const StyledResourcesLinksSubSection = styled('div', { fontSize: '0.875rem', gridTemplateRows: `22px minmax(${theme.spacing(3)}, 100%) 1.125rem`, rowGap: theme.spacing(2), - width: '100%', })); export const ResourcesLinksSubSection = ( diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx b/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx index 6e57fa458d9..4c2107f9d54 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx @@ -1,3 +1,4 @@ +import { Typography } from '@linode/ui'; import * as React from 'react'; import DocsIcon from 'src/assets/icons/docs.svg'; @@ -8,7 +9,6 @@ import { ResourcesLinksSection } from 'src/components/EmptyLandingPageResources/ import { ResourcesLinksSubSection } from 'src/components/EmptyLandingPageResources/ResourcesLinksSubSection'; import { ResourcesMoreLink } from 'src/components/EmptyLandingPageResources/ResourcesMoreLink'; import { Placeholder } from 'src/components/Placeholder/Placeholder'; -import { Typography } from 'src/components/Typography'; import { getLinkOnClick, youtubeChannelLink, diff --git a/packages/manager/src/components/Encryption/Encryption.tsx b/packages/manager/src/components/Encryption/Encryption.tsx index 6b170531c2d..60fb435cc07 100644 --- a/packages/manager/src/components/Encryption/Encryption.tsx +++ b/packages/manager/src/components/Encryption/Encryption.tsx @@ -1,12 +1,7 @@ -import { Box } from '@linode/ui'; +import { Box, Checkbox, Notice, Typography } from '@linode/ui'; import { List, ListItem } from '@mui/material'; import * as React from 'react'; -import { Checkbox } from 'src/components/Checkbox'; -import { Typography } from 'src/components/Typography'; - -import { Notice } from '../Notice/Notice'; - export interface EncryptionProps { descriptionCopy: JSX.Element | string; disabled?: boolean; diff --git a/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx b/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx index f68c63b08a0..026e5ec82e1 100644 --- a/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx +++ b/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx @@ -1,11 +1,8 @@ -import { Box } from '@linode/ui'; +import { Box, Button, PlusSignIcon, TextField } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import Minus from 'src/assets/icons/LKEminusSign.svg'; -import Plus from 'src/assets/icons/LKEplusSign.svg'; -import { Button } from 'src/components/Button/Button'; -import { TextField } from 'src/components/TextField'; const sxTextFieldBase = { '&::-webkit-inner-spin-button': { @@ -156,6 +153,6 @@ const MinusIcon = styled(Minus)({ width: 12, }); -const PlusIcon = styled(Plus)({ +const PlusIcon = styled(PlusSignIcon)({ width: 14, }); diff --git a/packages/manager/src/components/EnhancedSelect/Select.styles.ts b/packages/manager/src/components/EnhancedSelect/Select.styles.ts index def55c13e51..3d1b2d4260f 100644 --- a/packages/manager/src/components/EnhancedSelect/Select.styles.ts +++ b/packages/manager/src/components/EnhancedSelect/Select.styles.ts @@ -86,7 +86,7 @@ export const useStyles = makeStyles()((theme: Theme) => ({ }, '& .react-select__control': { '&:hover': { - border: `1px dotted #ccc`, + border: `1px dotted ${theme.tokens.color.Neutrals[40]}`, cursor: 'text', }, '&--is-focused, &--is-focused:hover': { @@ -136,7 +136,7 @@ export const useStyles = makeStyles()((theme: Theme) => ({ width: 8, }, '&::-webkit-scrollbar-thumb': { - backgroundColor: '#ccc', + backgroundColor: theme.tokens.color.Neutrals[40], borderRadius: 8, }, backgroundColor: theme.bg.white, @@ -290,7 +290,7 @@ export const reactSelectStyles = (theme: Theme) => ({ control: (base: any) => ({ ...base, '&:hover': { - border: `1px dotted #ccc`, + border: `1px dotted ${theme.tokens.color.Neutrals[40]}`, cursor: 'text', }, '&--is-focused, &--is-focused:hover': { @@ -349,7 +349,7 @@ export const reactSelectStyles = (theme: Theme) => ({ width: 8, }, '&::-webkit-scrollbar-thumb': { - backgroundColor: '#ccc', + backgroundColor: theme.tokens.color.Neutrals[40], borderRadius: 8, }, backgroundColor: theme.bg.white, diff --git a/packages/manager/src/components/EnhancedSelect/Select.tsx b/packages/manager/src/components/EnhancedSelect/Select.tsx index 77f4ec721e5..acaccafa7ec 100644 --- a/packages/manager/src/components/EnhancedSelect/Select.tsx +++ b/packages/manager/src/components/EnhancedSelect/Select.tsx @@ -1,10 +1,9 @@ +import { convertToKebabCase } from '@linode/ui'; import { useTheme } from '@mui/material'; import * as React from 'react'; import ReactSelect from 'react-select'; import CreatableSelect from 'react-select/creatable'; -import { convertToKebabCase } from 'src/utilities/convertToKebobCase'; - import { DropdownIndicator } from './components/DropdownIndicator'; import Input from './components/Input'; import { LoadingIndicator } from './components/LoadingIndicator'; @@ -17,6 +16,7 @@ import Control from './components/SelectControl'; import { SelectPlaceholder as Placeholder } from './components/SelectPlaceholder'; import { reactSelectStyles, useStyles } from './Select.styles'; +import type { TextFieldProps } from '@linode/ui'; import type { Theme } from '@mui/material'; import type { ActionMeta, @@ -24,7 +24,6 @@ import type { ValueType, } from 'react-select'; import type { CreatableProps as CreatableSelectProps } from 'react-select/creatable'; -import type { TextFieldProps } from 'src/components/TextField'; export interface Item { data?: any; diff --git a/packages/manager/src/components/EnhancedSelect/components/Guidance.tsx b/packages/manager/src/components/EnhancedSelect/components/Guidance.tsx index dcf79508ff6..4d442182166 100644 --- a/packages/manager/src/components/EnhancedSelect/components/Guidance.tsx +++ b/packages/manager/src/components/EnhancedSelect/components/Guidance.tsx @@ -1,9 +1,8 @@ +import { Typography } from '@linode/ui'; import HelpOutline from '@mui/icons-material/HelpOutline'; import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { Typography } from 'src/components/Typography'; - interface GuidanceProps { text: string; } diff --git a/packages/manager/src/components/EnhancedSelect/components/LoadingIndicator.tsx b/packages/manager/src/components/EnhancedSelect/components/LoadingIndicator.tsx index 88cbadaeeb3..0220686cf22 100644 --- a/packages/manager/src/components/EnhancedSelect/components/LoadingIndicator.tsx +++ b/packages/manager/src/components/EnhancedSelect/components/LoadingIndicator.tsx @@ -1,8 +1,7 @@ +import { CircleProgress } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { CircleProgress } from 'src/components/CircleProgress'; - export const LoadingIndicator = () => { return ; }; diff --git a/packages/manager/src/components/EnhancedSelect/components/NoOptionsMessage.tsx b/packages/manager/src/components/EnhancedSelect/components/NoOptionsMessage.tsx index 42eafba9e8e..504c226a932 100644 --- a/packages/manager/src/components/EnhancedSelect/components/NoOptionsMessage.tsx +++ b/packages/manager/src/components/EnhancedSelect/components/NoOptionsMessage.tsx @@ -1,7 +1,7 @@ +import { Typography } from '@linode/ui'; import * as React from 'react'; -import { NoticeProps } from 'react-select/src/components/Menu'; -import { Typography } from 'src/components/Typography'; +import type { NoticeProps } from 'react-select/src/components/Menu'; type Props = NoticeProps; diff --git a/packages/manager/src/components/EnhancedSelect/components/SelectControl.tsx b/packages/manager/src/components/EnhancedSelect/components/SelectControl.tsx index 590b194b65d..0e42fba78b2 100644 --- a/packages/manager/src/components/EnhancedSelect/components/SelectControl.tsx +++ b/packages/manager/src/components/EnhancedSelect/components/SelectControl.tsx @@ -1,14 +1,13 @@ +import { TextField } from '@linode/ui'; import * as React from 'react'; -import { ControlProps } from 'react-select'; -import { TextField } from 'src/components/TextField'; +import type { ControlProps } from 'react-select'; type Props = ControlProps; const SelectControl: React.FC = (props) => { return ( = (props) => { : props.selectProps.placeholder } fullWidth + placeholder={props.selectProps.placeholder} {...props.selectProps.textFieldProps} /> ); diff --git a/packages/manager/src/components/EnhancedSelect/components/SelectPlaceholder.tsx b/packages/manager/src/components/EnhancedSelect/components/SelectPlaceholder.tsx index fab584a7080..33281a57045 100644 --- a/packages/manager/src/components/EnhancedSelect/components/SelectPlaceholder.tsx +++ b/packages/manager/src/components/EnhancedSelect/components/SelectPlaceholder.tsx @@ -1,8 +1,8 @@ +import { Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { PlaceholderProps } from 'react-select'; -import { Typography } from 'src/components/Typography'; +import type { PlaceholderProps } from 'react-select'; type Props = PlaceholderProps; diff --git a/packages/manager/src/components/EntityDetail/EntityDetail.tsx b/packages/manager/src/components/EntityDetail/EntityDetail.tsx index d019a34c5ff..a72f98c7a77 100644 --- a/packages/manager/src/components/EntityDetail/EntityDetail.tsx +++ b/packages/manager/src/components/EntityDetail/EntityDetail.tsx @@ -51,8 +51,7 @@ const GridBody = styled(Grid, { ? undefined : `1px solid ${theme.borderColors.borderTable}`, // @TODO LKE-E: This conditional can be removed when/if the footer is introduced in M3-8348 borderTop: `1px solid ${theme.borderColors.borderTable}`, - paddingBottom: theme.spacing(), - paddingRight: theme.spacing(), + padding: theme.spacing(), })); const GridFooter = styled(Grid, { diff --git a/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx b/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx index abc97c5a100..4e64482c745 100644 --- a/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx +++ b/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx @@ -1,4 +1,4 @@ -import { Box } from '@linode/ui'; +import { Box, Button } from '@linode/ui'; import { action } from '@storybook/addon-actions'; import React from 'react'; @@ -6,7 +6,6 @@ import { EntityHeader } from 'src/components/EntityHeader/EntityHeader'; import { Hidden } from 'src/components/Hidden'; import { LinodeActionMenu } from 'src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu'; -import { Button } from '../Button/Button'; import { Link } from '../Link'; import type { Meta, StoryObj } from '@storybook/react'; @@ -60,6 +59,7 @@ export const Default: Story = { }, }} linodeType={{ + accelerated_devices: 0, addons: { backups: { price: { diff --git a/packages/manager/src/components/EntityHeader/EntityHeader.test.tsx b/packages/manager/src/components/EntityHeader/EntityHeader.test.tsx new file mode 100644 index 00000000000..fb71431f560 --- /dev/null +++ b/packages/manager/src/components/EntityHeader/EntityHeader.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { EntityHeader } from './EntityHeader'; + +import { HeaderProps } from './EntityHeader'; + +const mockText = 'Hello world'; + +const defaultProps: HeaderProps = { + title: mockText, +}; + +describe('EntityHeader', () => { + it('should render title with variant when isSummaryView is True', () => { + const { getByRole } = renderWithTheme( + + ); + const heading = getByRole('heading', { level: 2 }); + expect(heading).toBeInTheDocument(); + expect(heading).toHaveTextContent(mockText); + }); + + it('should not render title when isSummaryView is False', () => { + const { queryByText } = renderWithTheme( + + ); + expect(queryByText(mockText)).not.toBeInTheDocument(); + }); + + it('should render children if provided', () => { + const { getByText } = renderWithTheme( + +
Child items can go here!
+
+ ); + expect(getByText('Child items can go here!')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/components/EntityHeader/EntityHeader.tsx b/packages/manager/src/components/EntityHeader/EntityHeader.tsx index 69b2b4254e3..c0547894662 100644 --- a/packages/manager/src/components/EntityHeader/EntityHeader.tsx +++ b/packages/manager/src/components/EntityHeader/EntityHeader.tsx @@ -1,10 +1,8 @@ -import { Box } from '@linode/ui'; +import { Box, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { Typography } from 'src/components/Typography'; - -import type { TypographyProps } from 'src/components/Typography'; +import type { TypographyProps } from '@linode/ui'; export interface HeaderProps { children?: React.ReactNode; diff --git a/packages/manager/src/components/ErrorMessage.tsx b/packages/manager/src/components/ErrorMessage.tsx index 7297899a89b..aae52c9770d 100644 --- a/packages/manager/src/components/ErrorMessage.tsx +++ b/packages/manager/src/components/ErrorMessage.tsx @@ -1,9 +1,9 @@ +import { Typography } from '@linode/ui'; import React from 'react'; import { LinodeResizeAllocationError } from './LinodeResizeAllocationError'; import { MigrateError } from './MigrateError'; import { SupportTicketGeneralError } from './SupportTicketGeneralError'; -import { Typography } from './Typography'; import type { EntityForTicketDetails } from './SupportLink/SupportLink'; import type { FormPayloadValues } from 'src/features/Support/SupportTickets/SupportTicketDialog'; diff --git a/packages/manager/src/components/ErrorState/ErrorState.tsx b/packages/manager/src/components/ErrorState/ErrorState.tsx index 702a551a132..f9d43be3767 100644 --- a/packages/manager/src/components/ErrorState/ErrorState.tsx +++ b/packages/manager/src/components/ErrorState/ErrorState.tsx @@ -1,11 +1,9 @@ +import { Button, Typography } from '@linode/ui'; import ErrorOutline from '@mui/icons-material/ErrorOutline'; import { styled, useTheme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; -import { Button } from 'src/components/Button/Button'; -import { Typography } from 'src/components/Typography'; - import type { SvgIconProps } from '../SvgIcon'; import type { SxProps, Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/components/FormGroup.stories.tsx b/packages/manager/src/components/FormGroup.stories.tsx index c8fa0dfe645..b03cf18701c 100644 --- a/packages/manager/src/components/FormGroup.stories.tsx +++ b/packages/manager/src/components/FormGroup.stories.tsx @@ -1,10 +1,10 @@ -import { Meta, StoryObj } from '@storybook/react'; +import { Checkbox, FormControlLabel } from '@linode/ui'; import React from 'react'; -import { Checkbox } from './Checkbox'; -import { FormControlLabel } from './FormControlLabel'; import { FormGroup } from './FormGroup'; +import type { Meta, StoryObj } from '@storybook/react'; + const meta: Meta = { component: FormGroup, title: 'Components/Form/FormGroup', diff --git a/packages/manager/src/components/FormLabel.stories.tsx b/packages/manager/src/components/FormLabel.stories.tsx index b5167d53b5f..d928c7275e4 100644 --- a/packages/manager/src/components/FormLabel.stories.tsx +++ b/packages/manager/src/components/FormLabel.stories.tsx @@ -1,10 +1,7 @@ -import { FormControl } from '@linode/ui'; +import { FormControl, FormControlLabel, Radio, RadioGroup } from '@linode/ui'; import React from 'react'; -import { FormControlLabel } from './FormControlLabel'; import { FormLabel } from './FormLabel'; -import { Radio } from './Radio/Radio'; -import { RadioGroup } from './RadioGroup'; import type { Meta, StoryObj } from '@storybook/react'; diff --git a/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx b/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx index e102a493d0c..cb98deab6a2 100644 --- a/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx +++ b/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx @@ -1,16 +1,13 @@ +import { Button, Notice, Stack, Typography } from '@linode/ui'; import React from 'react'; import { useFlags } from 'src/hooks/useFlags'; import { replaceNewlinesWithLineBreaks } from 'src/utilities/replaceNewlinesWithLineBreaks'; -import { Button } from '../Button/Button'; import { Dialog } from '../Dialog/Dialog'; import { ErrorMessage } from '../ErrorMessage'; import { LinearProgress } from '../LinearProgress'; import { Link } from '../Link'; -import { Notice } from '../Notice/Notice'; -import { Stack } from '../Stack'; -import { Typography } from '../Typography'; import { useCreateFirewallFromTemplate } from './useCreateFirewallFromTemplate'; import type { Firewall } from '@linode/api-v4'; diff --git a/packages/manager/src/components/HighlightedMarkdown/HighlightedMarkdown.tsx b/packages/manager/src/components/HighlightedMarkdown/HighlightedMarkdown.tsx index 95f9aa966d1..97e87c753cd 100644 --- a/packages/manager/src/components/HighlightedMarkdown/HighlightedMarkdown.tsx +++ b/packages/manager/src/components/HighlightedMarkdown/HighlightedMarkdown.tsx @@ -1,3 +1,4 @@ +import { Typography } from '@linode/ui'; import * as hljs from 'highlight.js'; import apache from 'highlight.js/lib/languages/apache'; import bash from 'highlight.js/lib/languages/bash'; @@ -8,7 +9,6 @@ import HLJSDarkTheme from 'highlight.js/styles/a11y-dark.css?raw'; import HLJSLightTheme from 'highlight.js/styles/a11y-light.css?raw'; import * as React from 'react'; -import { Typography } from 'src/components/Typography'; import 'src/formatted-text.css'; import { unsafe_MarkdownIt } from 'src/utilities/markdown'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; diff --git a/packages/manager/src/components/IPSelect/IPSelect.tsx b/packages/manager/src/components/IPSelect/IPSelect.tsx index 6831f0d2632..07d4189969d 100644 --- a/packages/manager/src/components/IPSelect/IPSelect.tsx +++ b/packages/manager/src/components/IPSelect/IPSelect.tsx @@ -1,6 +1,6 @@ +import { Autocomplete } from '@linode/ui'; import * as React from 'react'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; interface Option { diff --git a/packages/manager/src/components/IconTextLink/IconTextLink.tsx b/packages/manager/src/components/IconTextLink/IconTextLink.tsx index 8aac572d997..8a2f79ebf7a 100644 --- a/packages/manager/src/components/IconTextLink/IconTextLink.tsx +++ b/packages/manager/src/components/IconTextLink/IconTextLink.tsx @@ -1,10 +1,10 @@ -import { Theme } from '@mui/material/styles'; +import { Button } from '@linode/ui'; import * as React from 'react'; import { Link } from 'react-router-dom'; import { makeStyles } from 'tss-react/mui'; -import { Button } from 'src/components/Button/Button'; -import { SvgIcon } from 'src/components/SvgIcon'; +import type { Theme } from '@mui/material/styles'; +import type { SvgIcon } from 'src/components/SvgIcon'; const useStyles = makeStyles()((theme: Theme) => ({ active: { diff --git a/packages/manager/src/components/ImageSelect/ImageOption.tsx b/packages/manager/src/components/ImageSelect/ImageOption.tsx index 35d73a1e4ff..208f88224cb 100644 --- a/packages/manager/src/components/ImageSelect/ImageOption.tsx +++ b/packages/manager/src/components/ImageSelect/ImageOption.tsx @@ -1,4 +1,4 @@ -import { Tooltip } from '@linode/ui'; +import { Stack, Tooltip, Typography } from '@linode/ui'; import React from 'react'; import CloudInitIcon from 'src/assets/icons/cloud-init.svg'; @@ -6,8 +6,6 @@ import { ListItemOption } from 'src/components/ListItemOption'; import { useFlags } from 'src/hooks/useFlags'; import { OSIcon } from '../OSIcon'; -import { Stack } from '../Stack'; -import { Typography } from '../Typography'; import { isImageDeprecated } from './utilities'; import type { Image } from '@linode/api-v4'; diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.tsx index ab07b89494e..1e09b698713 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.tsx @@ -1,6 +1,6 @@ +import { Autocomplete } from '@linode/ui'; import React, { useMemo } from 'react'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { imageFactory } from 'src/factories/images'; import { useAllImagesQuery } from 'src/queries/images'; @@ -13,7 +13,7 @@ import { } from './utilities'; import type { Image, RegionSite } from '@linode/api-v4'; -import type { EnhancedAutocompleteProps } from 'src/components/Autocomplete/Autocomplete'; +import type { EnhancedAutocompleteProps } from '@linode/ui'; export type ImageSelectVariant = 'all' | 'private' | 'public'; diff --git a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx index 1f91db3d338..bf85e77865a 100644 --- a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx +++ b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx @@ -1,10 +1,9 @@ /* eslint-disable react/jsx-no-useless-fragment */ +import { StyledActionButton } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import { Link } from 'react-router-dom'; -import { StyledActionButton } from 'src/components/Button/StyledActionButton'; - interface InlineMenuActionProps { /** Required action text */ actionText: string; diff --git a/packages/manager/src/components/LandingHeader/LandingHeader.stories.tsx b/packages/manager/src/components/LandingHeader/LandingHeader.stories.tsx index 035f316edcc..a0cadfdd91a 100644 --- a/packages/manager/src/components/LandingHeader/LandingHeader.stories.tsx +++ b/packages/manager/src/components/LandingHeader/LandingHeader.stories.tsx @@ -1,7 +1,7 @@ +import { Button } from '@linode/ui'; import { action } from '@storybook/addon-actions'; import React from 'react'; -import { Button } from '../Button/Button'; import { LandingHeader } from './LandingHeader'; import type { Meta, StoryObj } from '@storybook/react'; diff --git a/packages/manager/src/components/LandingHeader/LandingHeader.tsx b/packages/manager/src/components/LandingHeader/LandingHeader.tsx index 18f4ae4a10e..701022c458d 100644 --- a/packages/manager/src/components/LandingHeader/LandingHeader.tsx +++ b/packages/manager/src/components/LandingHeader/LandingHeader.tsx @@ -1,16 +1,16 @@ -import { Theme, styled, useTheme } from '@mui/material/styles'; +import { Button } from '@linode/ui'; +import { styled, useTheme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; import BetaFeedbackIcon from 'src/assets/icons/icon-feedback.svg'; -import { - Breadcrumb, - BreadcrumbProps, -} from 'src/components/Breadcrumb/Breadcrumb'; -import { Button } from 'src/components/Button/Button'; +import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; import { DocsLink } from 'src/components/DocsLink/DocsLink'; +import type { Theme } from '@mui/material/styles'; +import type { BreadcrumbProps } from 'src/components/Breadcrumb/Breadcrumb'; + export interface LandingHeaderProps { analyticsLabel?: string; betaFeedbackLink?: string; diff --git a/packages/manager/src/components/LineGraph/LineGraph.styles.ts b/packages/manager/src/components/LineGraph/LineGraph.styles.ts index 75af5f913a9..c1192567abf 100644 --- a/packages/manager/src/components/LineGraph/LineGraph.styles.ts +++ b/packages/manager/src/components/LineGraph/LineGraph.styles.ts @@ -1,7 +1,6 @@ -import { omittedProps } from '@linode/ui'; +import { Button, omittedProps } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import { Button } from 'src/components/Button/Button'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; diff --git a/packages/manager/src/components/LineGraph/LineGraph.tsx b/packages/manager/src/components/LineGraph/LineGraph.tsx index cde35ce4004..3cf464e8199 100644 --- a/packages/manager/src/components/LineGraph/LineGraph.tsx +++ b/packages/manager/src/components/LineGraph/LineGraph.tsx @@ -2,6 +2,7 @@ * ONLY USED IN LONGVIEW * Delete when Lonview is sunsetted, along with AccessibleGraphData */ +import { Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import { Chart } from 'chart.js'; @@ -11,7 +12,6 @@ import * as React from 'react'; import { humanizeLargeData } from 'src/components/AreaChart/utils'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { Typography } from 'src/components/Typography'; import { setUpCharts } from 'src/utilities/charts'; import { roundTo } from 'src/utilities/roundTo'; @@ -284,7 +284,7 @@ export const LineGraph = (props: LineGraphProps) => { intersect: false, mode: 'index', position: 'nearest', - titleFontColor: '#606469', + titleFontColor: theme.tokens.color.Neutrals[70], xPadding: 8, yPadding: 10, }, diff --git a/packages/manager/src/components/LineGraph/MetricDisplay.styles.ts b/packages/manager/src/components/LineGraph/MetricsDisplay.styles.ts similarity index 87% rename from packages/manager/src/components/LineGraph/MetricDisplay.styles.ts rename to packages/manager/src/components/LineGraph/MetricsDisplay.styles.ts index b0f5ff173ee..141baf4f8d5 100644 --- a/packages/manager/src/components/LineGraph/MetricDisplay.styles.ts +++ b/packages/manager/src/components/LineGraph/MetricsDisplay.styles.ts @@ -1,7 +1,6 @@ -import { omittedProps } from '@linode/ui'; +import { Button, omittedProps } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import { Button } from 'src/components/Button/Button'; import { Table } from 'src/components/Table'; import { TableCell } from 'src/components/TableCell'; @@ -37,11 +36,14 @@ export const StyledButton = styled(Button, { label: 'StyledButton', shouldForwardProp: omittedProps(['legendColor', 'hidden']), })<{ legendColor?: string }>(({ hidden, legendColor, theme }) => ({ + padding: 0, ...(legendColor && { '&:before': { backgroundColor: hidden ? theme.color.disabledText - : theme.graphs[legendColor], + : theme.graphs[legendColor] + ? theme.graphs[legendColor] + : legendColor, flexShrink: 0, }, }), diff --git a/packages/manager/src/components/LineGraph/MetricsDisplay.test.tsx b/packages/manager/src/components/LineGraph/MetricsDisplay.test.tsx index dd466110b16..9157f758a38 100644 --- a/packages/manager/src/components/LineGraph/MetricsDisplay.test.tsx +++ b/packages/manager/src/components/LineGraph/MetricsDisplay.test.tsx @@ -86,7 +86,7 @@ describe('CPUMetrics', () => { /> ); - for (const value of ['10.00%', '5.50%', '7.75%']) { + for (const value of ['10.00 %', '5.50 %', '7.75 %']) { expect(getByText(value)).toBeVisible(); } }); diff --git a/packages/manager/src/components/LineGraph/MetricsDisplay.tsx b/packages/manager/src/components/LineGraph/MetricsDisplay.tsx index c22a3c375e8..ffc3e706028 100644 --- a/packages/manager/src/components/LineGraph/MetricsDisplay.tsx +++ b/packages/manager/src/components/LineGraph/MetricsDisplay.tsx @@ -1,16 +1,16 @@ +import { Typography } from '@linode/ui'; import * as React from 'react'; 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 { Typography } from 'src/components/Typography'; import { StyledButton, StyledTable, StyledTableCell, -} from './MetricDisplay.styles'; +} from './MetricsDisplay.styles'; import type { Metrics } from 'src/utilities/statMetrics'; @@ -19,15 +19,6 @@ const ROW_HEADERS = ['Max', 'Avg', 'Last'] as const; type MetricKey = 'average' | 'last' | 'max'; const METRIC_KEYS: MetricKey[] = ['max', 'average', 'last']; -export type LegendColor = - | 'blue' - | 'darkGreen' - | 'green' - | 'lightGreen' - | 'purple' - | 'red' - | 'yellow'; - interface Props { /** * Array of rows to hide. Each row should contain the legend title. @@ -47,7 +38,7 @@ export interface MetricsDisplayRow { data: Metrics; format: (n: number) => string; handleLegendClick?: () => void; - legendColor: LegendColor; + legendColor: string; legendTitle: string; } @@ -95,12 +86,15 @@ const MetricRow = ({ legendColor={legendColor} onClick={handleLegendClick} > - {legendTitle} + + {legendTitle} + {METRIC_KEYS.map((key, idx) => ( ( ({ + sx={{ '.MuiTable-root': { border: 0, }, + height: legendHeight, overflowY: 'auto', - [theme.breakpoints.up(1100)]: { - height: legendHeight, - }, - })} + }} aria-label="Stats and metrics" stickyHeader > diff --git a/packages/manager/src/components/LinkButton.tsx b/packages/manager/src/components/LinkButton.tsx index d3d7caa7f09..e15c1acb3f3 100644 --- a/packages/manager/src/components/LinkButton.tsx +++ b/packages/manager/src/components/LinkButton.tsx @@ -1,11 +1,7 @@ -import { Box } from '@linode/ui'; +import { Box, CircleProgress, StyledLinkButton } from '@linode/ui'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { CircleProgress } from 'src/components/CircleProgress'; - -import { StyledLinkButton } from './Button/StyledLinkButton'; - import type { Theme } from '@mui/material/styles'; const useStyles = makeStyles()((theme: Theme) => ({ diff --git a/packages/manager/src/components/LinodeResizeAllocationError.tsx b/packages/manager/src/components/LinodeResizeAllocationError.tsx index b13f2b833a1..8872e74de69 100644 --- a/packages/manager/src/components/LinodeResizeAllocationError.tsx +++ b/packages/manager/src/components/LinodeResizeAllocationError.tsx @@ -1,7 +1,7 @@ +import { Typography } from '@linode/ui'; import * as React from 'react'; import { Link } from 'src/components/Link'; -import { Typography } from 'src/components/Typography'; export const LinodeResizeAllocationError = () => { return ( diff --git a/packages/manager/src/components/ListItemOption.tsx b/packages/manager/src/components/ListItemOption.tsx index 8a8e4a2bb39..14333d4d7e6 100644 --- a/packages/manager/src/components/ListItemOption.tsx +++ b/packages/manager/src/components/ListItemOption.tsx @@ -1,11 +1,8 @@ -import { Box, Tooltip } from '@linode/ui'; +import { Box, ListItem, SelectedIcon, Tooltip } from '@linode/ui'; import { styled } from '@mui/material/styles'; import { visuallyHidden } from '@mui/utils'; import React from 'react'; -import { SelectedIcon } from 'src/components/Autocomplete/Autocomplete.styles'; -import { ListItem } from 'src/components/ListItem'; - import type { ListItemComponentsPropsOverrides } from '@mui/material/ListItem'; export interface ListItemProps { diff --git a/packages/manager/src/components/LongviewLineGraph/LongviewLineGraph.tsx b/packages/manager/src/components/LongviewLineGraph/LongviewLineGraph.tsx index 7bf5cf630d6..b555d04a789 100644 --- a/packages/manager/src/components/LongviewLineGraph/LongviewLineGraph.tsx +++ b/packages/manager/src/components/LongviewLineGraph/LongviewLineGraph.tsx @@ -1,15 +1,15 @@ -import { Theme } from '@mui/material/styles'; +import { Divider, Typography } from '@linode/ui'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { Divider } from 'src/components/Divider'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; -import { +import { LineGraph } from 'src/components/LineGraph/LineGraph'; + +import type { Theme } from '@mui/material/styles'; +import type { DataSet, - LineGraph, LineGraphProps, } from 'src/components/LineGraph/LineGraph'; -import { Typography } from 'src/components/Typography'; const useStyles = makeStyles()((theme: Theme) => ({ message: { diff --git a/packages/manager/src/components/MainContentBanner.tsx b/packages/manager/src/components/MainContentBanner.tsx index a71c8fb0f37..60678184e50 100644 --- a/packages/manager/src/components/MainContentBanner.tsx +++ b/packages/manager/src/components/MainContentBanner.tsx @@ -1,10 +1,9 @@ -import { Box } from '@linode/ui'; +import { Box, Typography } from '@linode/ui'; import Close from '@mui/icons-material/Close'; import { IconButton } from '@mui/material'; import * as React from 'react'; import { Link } from 'src/components/Link'; -import { Typography } from 'src/components/Typography'; import { useFlags } from 'src/hooks/useFlags'; import { useMutatePreferences, diff --git a/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx b/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx index c01acbaf9ba..402536b26f5 100644 --- a/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx +++ b/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx @@ -1,14 +1,15 @@ -import { AccountMaintenance } from '@linode/api-v4/lib/account'; +import { Notice, Typography } from '@linode/ui'; import * as React from 'react'; import { Link } from 'react-router-dom'; -import { Notice } from 'src/components/Notice/Notice'; -import { Typography } from 'src/components/Typography'; +import { PENDING_MAINTENANCE_FILTER } from 'src/features/Account/Maintenance/utilities'; import { useAllAccountMaintenanceQuery } from 'src/queries/account/maintenance'; import { useProfile } from 'src/queries/profile/profile'; import { formatDate } from 'src/utilities/formatDate'; import { isPast } from 'src/utilities/isPast'; +import type { AccountMaintenance } from '@linode/api-v4/lib/account'; + interface Props { maintenanceEnd?: null | string; /** please keep in mind here that it's possible the start time can be in the past */ @@ -21,7 +22,7 @@ export const MaintenanceBanner = React.memo((props: Props) => { const { data: accountMaintenanceData } = useAllAccountMaintenanceQuery( {}, - { status: { '+or': ['pending, started'] } } + PENDING_MAINTENANCE_FILTER ); const { diff --git a/packages/manager/src/components/MaintenanceScreen.tsx b/packages/manager/src/components/MaintenanceScreen.tsx index 03d1c85efae..00d1f5abb78 100644 --- a/packages/manager/src/components/MaintenanceScreen.tsx +++ b/packages/manager/src/components/MaintenanceScreen.tsx @@ -1,4 +1,4 @@ -import { Box } from '@linode/ui'; +import { Box, Stack, Typography } from '@linode/ui'; import BuildIcon from '@mui/icons-material/Build'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; @@ -6,8 +6,6 @@ import * as React from 'react'; import Logo from 'src/assets/logo/akamai-logo.svg'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Link } from 'src/components/Link'; -import { Stack } from 'src/components/Stack'; -import { Typography } from 'src/components/Typography'; import type { Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/components/MaskableText/MaskableText.test.tsx b/packages/manager/src/components/MaskableText/MaskableText.test.tsx index 8e4061db9a9..8c910d491bf 100644 --- a/packages/manager/src/components/MaskableText/MaskableText.test.tsx +++ b/packages/manager/src/components/MaskableText/MaskableText.test.tsx @@ -85,7 +85,7 @@ describe('MaskableText', () => { expect(queryByText(maskedText)).not.toBeInTheDocument(); }); - it('should render a toggleable VisibilityIcon tooltip if isToggleable is provided', async () => { + it('should render a toggleable VisibilityTooltip if isToggleable is provided', async () => { queryMocks.usePreferences.mockReturnValue({ data: preferences, }); @@ -94,7 +94,7 @@ describe('MaskableText', () => { ); - const visibilityToggle = getByTestId('VisibilityIcon'); + const visibilityToggle = getByTestId('VisibilityTooltip'); // Original text should be masked expect(getByText(maskedText)).toBeVisible(); diff --git a/packages/manager/src/components/MaskableText/MaskableText.tsx b/packages/manager/src/components/MaskableText/MaskableText.tsx index ac0b8ccd68e..7f9b395fc8e 100644 --- a/packages/manager/src/components/MaskableText/MaskableText.tsx +++ b/packages/manager/src/components/MaskableText/MaskableText.tsx @@ -1,12 +1,10 @@ -import { VisibilityTooltip } from '@linode/ui'; +import { Stack, VisibilityTooltip } from '@linode/ui'; import { Typography } from '@mui/material'; import * as React from 'react'; import { usePreferences } from 'src/queries/profile/preferences'; import { createMaskedText } from 'src/utilities/createMaskedText'; -import { Stack } from '../Stack'; - export type MaskableTextLength = 'ipv4' | 'ipv6' | 'plaintext'; export interface MaskableTextProps { diff --git a/packages/manager/src/components/MigrateError.tsx b/packages/manager/src/components/MigrateError.tsx index aa736d7e689..1050677c689 100644 --- a/packages/manager/src/components/MigrateError.tsx +++ b/packages/manager/src/components/MigrateError.tsx @@ -1,7 +1,7 @@ +import { Typography } from '@linode/ui'; import * as React from 'react'; import { SupportLink } from 'src/components/SupportLink'; -import { Typography } from 'src/components/Typography'; import type { EntityForTicketDetails } from './SupportLink/SupportLink'; diff --git a/packages/manager/src/components/ModeSelect/ModeSelect.tsx b/packages/manager/src/components/ModeSelect/ModeSelect.tsx index 4b8d8167b45..bf0a2ae3e07 100644 --- a/packages/manager/src/components/ModeSelect/ModeSelect.tsx +++ b/packages/manager/src/components/ModeSelect/ModeSelect.tsx @@ -1,9 +1,6 @@ +import { FormControlLabel, Radio, RadioGroup } from '@linode/ui'; import * as React from 'react'; -import { Radio } from 'src/components/Radio/Radio'; -import { FormControlLabel } from 'src/components/FormControlLabel'; -import { RadioGroup } from 'src/components/RadioGroup'; - export interface Mode { label: string; mode: modes; diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx index e96367d4b1d..2080d9a92ba 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx @@ -1,16 +1,18 @@ -import { InputLabel } from '@linode/ui'; +import { + Button, + InputLabel, + Notice, + TextField, + TooltipIcon, + Typography, +} from '@linode/ui'; import Close from '@mui/icons-material/Close'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { Button } from 'src/components/Button/Button'; import { LinkButton } from 'src/components/LinkButton'; -import { Notice } from 'src/components/Notice/Notice'; import { StyledLinkButtonBox } from 'src/components/SelectFirewallPanel/SelectFirewallPanel'; -import { TextField } from 'src/components/TextField'; -import { TooltipIcon } from 'src/components/TooltipIcon'; -import { Typography } from 'src/components/Typography'; import type { InputBaseProps } from '@mui/material/InputBase'; import type { Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/components/Paginate.ts b/packages/manager/src/components/Paginate.ts index 370696aef0b..866916e725e 100644 --- a/packages/manager/src/components/Paginate.ts +++ b/packages/manager/src/components/Paginate.ts @@ -1,4 +1,5 @@ -import { clamp, slice } from 'ramda'; +import { clamp } from '@linode/ui'; +import { slice } from 'ramda'; import * as React from 'react'; import scrollTo from 'src/utilities/scrollTo'; diff --git a/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx b/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx index eefa46d7d30..920d7c1173a 100644 --- a/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx +++ b/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx @@ -1,8 +1,8 @@ -import { Box } from '@linode/ui'; +import { Box, TextField } from '@linode/ui'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { MenuItem } from 'src/components/MenuItem'; import { PaginationControls } from '../PaginationControls/PaginationControls'; @@ -80,36 +80,58 @@ export const PaginationFooter = (props: Props) => { /> )} {!fixedSize ? ( - - + handleSizeChange(selected.value)} - options={finalOptions} - textFieldProps={{ hideLabel: true, noMarginTop: true }} - value={defaultPagination} - /> - + onChange={(e) => handleSizeChange(Number(e.target.value))} + select + value={pageSize} + > + {finalOptions.map((option) => ( + + {option.label} + + ))} + + ) : null} ); }; -const PageSizeSelectContainer = styled(Box, { - label: 'PageSizeSelectContainer', +const StyledTextField = styled(TextField, { + label: 'StyledTextField', })(({ theme }) => ({ - '& .MuiInput-input': { - paddingTop: 4, - }, '& .MuiInput-root': { - '&.Mui-focused': { - boxShadow: 'none', - }, backgroundColor: theme.bg.bgPaper, + border: '1px solid transparent', + }, + '& .MuiList-root': { + border: `1px solid ${theme.palette.primary.main}`, + }, + '& .MuiSelect-select': { border: 'none', }, - '& .react-select__value-container': { - paddingLeft: 12, + '& .MuiSvgIcon-root': { + margin: 0, + padding: 0, + position: 'relative', + top: 0, + }, + '&.Mui-focused': { + border: `1px dotted ${theme.color.grey1}`, + boxShadow: 'none', }, })); diff --git a/packages/manager/src/components/PasswordInput/HideShowText.tsx b/packages/manager/src/components/PasswordInput/HideShowText.tsx index 1a617d8d0a7..b758106568a 100644 --- a/packages/manager/src/components/PasswordInput/HideShowText.tsx +++ b/packages/manager/src/components/PasswordInput/HideShowText.tsx @@ -1,8 +1,9 @@ +import { TextField } from '@linode/ui'; import Visibility from '@mui/icons-material/Visibility'; import VisibilityOff from '@mui/icons-material/VisibilityOff'; import * as React from 'react'; -import { TextField, TextFieldProps } from '../TextField'; +import type { TextFieldProps } from '@linode/ui'; export const HideShowText = (props: TextFieldProps) => { const [hidden, setHidden] = React.useState(true); diff --git a/packages/manager/src/components/PasswordInput/PasswordInput.tsx b/packages/manager/src/components/PasswordInput/PasswordInput.tsx index 1168895d2f0..64688e34699 100644 --- a/packages/manager/src/components/PasswordInput/PasswordInput.tsx +++ b/packages/manager/src/components/PasswordInput/PasswordInput.tsx @@ -1,11 +1,11 @@ +import { Stack } from '@linode/ui'; import * as React from 'react'; import zxcvbn from 'zxcvbn'; import { StrengthIndicator } from '../PasswordInput/StrengthIndicator'; -import { Stack } from '../Stack'; import { HideShowText } from './HideShowText'; -import type { TextFieldProps } from 'src/components/TextField'; +import type { TextFieldProps } from '@linode/ui'; interface Props extends TextFieldProps { disabledReason?: JSX.Element | string; diff --git a/packages/manager/src/components/PasswordInput/StrengthIndicator.tsx b/packages/manager/src/components/PasswordInput/StrengthIndicator.tsx index 5eb0b75f3b0..730b31ee8d0 100644 --- a/packages/manager/src/components/PasswordInput/StrengthIndicator.tsx +++ b/packages/manager/src/components/PasswordInput/StrengthIndicator.tsx @@ -1,9 +1,9 @@ +import { Typography } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { Typography } from 'src/components/Typography'; +import type { Theme } from '@mui/material/styles'; type StrengthValues = 0 | 1 | 2 | 3 | 4 | null; @@ -17,7 +17,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ '&[class*="strength-"]': { backgroundColor: theme.palette.primary.main, }, - backgroundColor: '#C9CACB', + backgroundColor: theme.tokens.color.Neutrals[40], height: '4px', transition: 'background-color .5s ease-in-out', }, diff --git a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx index 3222eb34b5f..84e8075e038 100644 --- a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx +++ b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx @@ -1,16 +1,14 @@ -import { Box, Paper } from '@linode/ui'; +import { Box, Paper, Chip } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; -import { Chip } from 'src/components/Chip'; import CreditCard from 'src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/CreditCard'; import { useMakeDefaultPaymentMethodMutation } from 'src/queries/account/payment'; import { ThirdPartyPayment } from './ThirdPartyPayment'; - import type { PaymentMethod } from '@linode/api-v4/lib/account/types'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; diff --git a/packages/manager/src/components/PaymentMethodRow/ThirdPartyPayment.tsx b/packages/manager/src/components/PaymentMethodRow/ThirdPartyPayment.tsx index 2b974fab87b..1bdd3d2c836 100644 --- a/packages/manager/src/components/PaymentMethodRow/ThirdPartyPayment.tsx +++ b/packages/manager/src/components/PaymentMethodRow/ThirdPartyPayment.tsx @@ -1,4 +1,4 @@ -import { Box } from '@linode/ui'; +import { Box, Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; @@ -6,7 +6,6 @@ import { makeStyles } from 'tss-react/mui'; import GooglePayIcon from 'src/assets/icons/payment/googlePay.svg'; import PayPalIcon from 'src/assets/icons/payment/payPal.svg'; -import { Typography } from 'src/components/Typography'; import CreditCard from 'src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/CreditCard'; import { MaskableText } from '../MaskableText/MaskableText'; diff --git a/packages/manager/src/components/Placeholder/Placeholder.tsx b/packages/manager/src/components/Placeholder/Placeholder.tsx index 891cc6dddf6..8b9914b00fa 100644 --- a/packages/manager/src/components/Placeholder/Placeholder.tsx +++ b/packages/manager/src/components/Placeholder/Placeholder.tsx @@ -1,14 +1,13 @@ +import { Button, H1Header, Typography, fadeIn } from '@linode/ui'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; import LinodeIcon from 'src/assets/addnewmenu/linode.svg'; -import { Button, ButtonProps } from 'src/components/Button/Button'; -import { H1Header } from 'src/components/H1Header/H1Header'; -import { Typography } from 'src/components/Typography'; -import { fadeIn } from 'src/styles/keyframes'; import { TransferDisplay } from '../TransferDisplay/TransferDisplay'; +import type { ButtonProps } from '@linode/ui'; + export interface ExtendedButtonProps extends ButtonProps { target?: string; } @@ -218,12 +217,20 @@ const StyledButtonWrapper = styled('div')(({ theme }) => ({ const StyledLinksSection = styled('div')< Pick >(({ theme, ...props }) => ({ - borderTop: `1px solid ${theme.name === 'light' ? '#e3e5e8' : '#2e3238'}`, + borderTop: `1px solid ${ + theme.name === 'light' + ? theme.tokens.color.Neutrals[20] + : theme.tokens.color.Neutrals[100] + }`, gridArea: 'links', paddingTop: '38px', ...(props.showTransferDisplay && { - borderBottom: `1px solid ${theme.name === 'light' ? '#e3e5e8' : '#2e3238'}`, + borderBottom: `1px solid ${ + theme.name === 'light' + ? theme.tokens.color.Neutrals[20] + : theme.tokens.color.Neutrals[100] + }`, paddingBottom: theme.spacing(2), [theme.breakpoints.up('md')]: { paddingBottom: theme.spacing(4), diff --git a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupSelectOption.tsx b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupSelectOption.tsx index 3ba84a95ebe..5e5904431ba 100644 --- a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupSelectOption.tsx +++ b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupSelectOption.tsx @@ -1,9 +1,8 @@ import { PLACEMENT_GROUP_TYPES } from '@linode/api-v4'; -import { Box } from '@linode/ui'; +import { Box, Stack } from '@linode/ui'; import React from 'react'; import { ListItemOption } from 'src/components/ListItemOption'; -import { Stack } from 'src/components/Stack'; import type { PlacementGroup } from '@linode/api-v4'; import type { ListItemProps } from 'src/components/ListItemOption'; diff --git a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx index 9d9b90f96b1..6fadaf12d58 100644 --- a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx +++ b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx @@ -1,6 +1,6 @@ +import { Autocomplete } from '@linode/ui'; import * as React from 'react'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { PLACEMENT_GROUP_HAS_NO_CAPACITY } from 'src/features/PlacementGroups/constants'; import { hasPlacementGroupReachedCapacity } from 'src/features/PlacementGroups/utils'; import { useAllPlacementGroupsQuery } from 'src/queries/placementGroups'; @@ -8,8 +8,8 @@ import { useAllPlacementGroupsQuery } from 'src/queries/placementGroups'; import { PlacementGroupSelectOption } from './PlacementGroupSelectOption'; import type { APIError, PlacementGroup, Region } from '@linode/api-v4'; +import type { TextFieldProps } from '@linode/ui'; import type { SxProps, Theme } from '@mui/material/styles'; -import type { TextFieldProps } from 'src/components/TextField'; export interface PlacementGroupsSelectProps { /** diff --git a/packages/manager/src/components/PrimaryNav/AdditionalMenuItems.tsx b/packages/manager/src/components/PrimaryNav/AdditionalMenuItems.tsx deleted file mode 100644 index 21ebbe2e38e..00000000000 --- a/packages/manager/src/components/PrimaryNav/AdditionalMenuItems.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import * as React from 'react'; - -import Help from 'src/assets/icons/help.svg'; - -import { NavItem, PrimaryLink } from './NavItem'; - -interface Props { - closeMenu: () => void; - dividerClasses: string; - isCollapsed?: boolean; - linkClasses: (href?: string) => string; - listItemClasses: string; -} - -export const AdditionalMenuItems = React.memo((props: Props) => { - const { isCollapsed } = props; - const links: PrimaryLink[] = [ - { - QAKey: 'help', - display: 'Get Help', - href: '/support', - icon: , - }, - ]; - - return ( - - {links.map((eachLink) => { - return ( - - ); - })} - - ); -}); diff --git a/packages/manager/src/components/PrimaryNav/NavItem.tsx b/packages/manager/src/components/PrimaryNav/NavItem.tsx deleted file mode 100644 index 4e210b5f4ca..00000000000 --- a/packages/manager/src/components/PrimaryNav/NavItem.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { Tooltip } from '@linode/ui'; -import * as React from 'react'; -import { Link } from 'react-router-dom'; -import { useStyles } from 'tss-react/mui'; - -import { Divider } from 'src/components/Divider'; -import { ListItem } from 'src/components/ListItem'; -import { ListItemText } from 'src/components/ListItemText'; - -interface Props extends PrimaryLink { - closeMenu: () => void; - dividerClasses?: string; - isCollapsed?: boolean; - linkClasses: (href?: string) => string; - listItemClasses: string; -} - -export interface PrimaryLink { - QAKey: string; - display: string; - href?: string; - icon?: JSX.Element; - isDisabled?: () => string; - logo?: React.ComponentType; - onClick?: () => void; -} - -export const NavItem = React.memo((props: Props) => { - const { - QAKey, - closeMenu, - display, - href, - icon, - isCollapsed, - isDisabled, - linkClasses, - listItemClasses, - onClick, - } = props; - - const { cx } = useStyles(); - - if (!onClick && !href) { - throw new Error('A Primary Link needs either an href or an onClick prop'); - } - - return ( - /* - href takes priority here. So if an href and onClick - are provided, the onClick will not be applied - */ - - {href ? ( - - {icon && isCollapsed &&
{icon}
} - - - ) : ( - - { - props.closeMenu(); - /* disregarding undefined is fine here because of the error handling thrown above */ - onClick!(); - }} - aria-live="polite" - className={linkClasses()} - data-qa-nav-item={QAKey} - disabled={!!isDisabled ? !!isDisabled() : false} - > - - - - )} - -
- ); -}); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx b/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx index 788c0e4e6d0..490fb364a86 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx @@ -4,14 +4,18 @@ import * as React from 'react'; import { StyledActiveLink, StyledPrimaryLinkBox } from './PrimaryNav.styles'; import type { NavEntity } from './PrimaryNav'; +import type { CreateEntity } from 'src/features/TopMenu/CreateMenu/CreateMenu'; -export interface PrimaryLink { - activeLinks?: Array; +export interface BaseNavLink { attr?: { [key: string]: any }; - betaChipClassName?: string; - display: NavEntity; + display: CreateEntity | NavEntity; hide?: boolean; href: string; +} + +export interface PrimaryLink extends BaseNavLink { + activeLinks?: Array; + betaChipClassName?: string; isBeta?: boolean; onClick?: (e: React.ChangeEvent) => void; } @@ -19,7 +23,6 @@ export interface PrimaryLink { interface PrimaryLinkProps extends PrimaryLink { closeMenu: () => void; isActiveLink: boolean; - isBeta?: boolean; isCollapsed: boolean; } diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts index 69b998cfd4a..5de34fcc7c1 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts @@ -1,11 +1,9 @@ -import { Box, omittedProps } from '@linode/ui'; +import { Accordion, Box, Divider, omittedProps } from '@linode/ui'; import { styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { Link } from 'react-router-dom'; import AkamaiLogo from 'src/assets/logo/akamai-logo.svg'; -import { Accordion } from 'src/components/Accordion'; -import { Divider } from 'src/components/Divider'; import { SIDEBAR_WIDTH } from 'src/components/PrimaryNav/SideMenu'; export const StyledGrid = styled(Grid, { diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index fd6b10de5c5..f968da8fd05 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -10,6 +10,7 @@ import Linode from 'src/assets/icons/entityIcons/linode.svg'; import NodeBalancer from 'src/assets/icons/entityIcons/nodebalancer.svg'; import Longview from 'src/assets/icons/longview.svg'; import More from 'src/assets/icons/more.svg'; +import IAM from 'src/assets/icons/entityIcons/iam.svg'; import { useIsACLPEnabled } from 'src/features/CloudPulse/Utils/utils'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; @@ -31,9 +32,9 @@ import { import { linkIsActive } from './utils'; import type { PrimaryLink as PrimaryLinkType } from './PrimaryLink'; +import { useIsIAMEnabled } from 'src/features/IAM/Shared/utilities'; export type NavEntity = - | 'Account' | 'Account' | 'Betas' | 'Cloud Load Balancers' @@ -42,6 +43,7 @@ export type NavEntity = | 'Domains' | 'Firewalls' | 'Help & Support' + | 'Identity and Access' | 'Images' | 'Kubernetes' | 'Linodes' @@ -56,10 +58,18 @@ export type NavEntity = | 'VPC' | 'Volumes'; -interface PrimaryLinkGroup { +export type ProductFamily = + | 'Compute' + | 'Databases' + | 'Monitor' + | 'More' + | 'Networking' + | 'Storage'; + +export interface ProductFamilyLinkGroup { icon?: React.JSX.Element; - links: PrimaryLinkType[]; - title?: string; + links: T; + name?: ProductFamily; } export interface PrimaryNavProps { @@ -81,10 +91,14 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isDatabasesEnabled, isDatabasesV2Beta } = useIsDatabasesEnabled(); + const { isIAMEnabled, isIAMBeta } = useIsIAMEnabled(); + const { data: preferences } = usePreferences(); const { mutateAsync: updatePreferences } = useMutatePreferences(); - const primaryLinkGroups: PrimaryLinkGroup[] = React.useMemo( + const productFamilyLinkGroups: ProductFamilyLinkGroup< + PrimaryLinkType[] + >[] = React.useMemo( () => [ { links: [ @@ -132,7 +146,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { href: '/linodes/create?type=One-Click', }, ], - title: 'Compute', + name: 'Compute', }, { icon: , @@ -150,7 +164,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { href: '/volumes', }, ], - title: 'Storage', + name: 'Storage', }, { icon: , @@ -172,7 +186,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { href: '/domains', }, ], - title: 'Networking', + name: 'Networking', }, { icon: , @@ -184,7 +198,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isBeta: isDatabasesV2Beta, }, ], - title: 'Databases', + name: 'Databases', }, { icon: , @@ -196,11 +210,11 @@ export const PrimaryNav = (props: PrimaryNavProps) => { { display: 'Monitor', hide: !isACLPEnabled, - href: '/monitor/cloudpulse', + href: '/monitor', isBeta: flags.aclp?.beta, }, ], - title: 'Monitor', + name: 'Monitor', }, { icon: , @@ -210,6 +224,13 @@ export const PrimaryNav = (props: PrimaryNavProps) => { hide: !flags.selfServeBetas, href: '/betas', }, + { + display: 'Identity and Access', + hide: !isIAMEnabled, + href: '/iam', + icon: , + isBeta: isIAMBeta, + }, { display: 'Account', href: '/account', @@ -219,7 +240,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { href: '/support', }, ], - title: 'More', + name: 'More', }, ], // eslint-disable-next-line react-hooks/exhaustive-deps @@ -282,8 +303,8 @@ export const PrimaryNav = (props: PrimaryNavProps) => { - {primaryLinkGroups.map((linkGroup, idx) => { - const filteredLinks = linkGroup.links.filter((link) => !link.hide); + {productFamilyLinkGroups.map((productFamily, idx) => { + const filteredLinks = productFamily.links.filter((link) => !link.hide); if (filteredLinks.length === 0) { return null; } @@ -298,7 +319,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { ) ); if (isActiveLink) { - activeProductFamily = linkGroup.title ?? ''; + activeProductFamily = productFamily.name ?? ''; } const props = { closeMenu, @@ -311,17 +332,17 @@ export const PrimaryNav = (props: PrimaryNavProps) => { return (
- {linkGroup.title ? ( // TODO: we can remove this conditional when Managed is removed + {productFamily.name ? ( // TODO: we can remove this conditional when Managed is removed <> - {linkGroup.icon} -

{linkGroup.title}

+ {productFamily.icon} +

{productFamily.name}

} isActiveProductFamily={ - activeProductFamily === linkGroup.title + activeProductFamily === productFamily.name } expanded={!collapsedAccordions.includes(idx)} isCollapsed={isCollapsed} diff --git a/packages/manager/src/components/ProductInformationBanner/ProductInformationBanner.tsx b/packages/manager/src/components/ProductInformationBanner/ProductInformationBanner.tsx index 43ca759db6e..b681403808a 100644 --- a/packages/manager/src/components/ProductInformationBanner/ProductInformationBanner.tsx +++ b/packages/manager/src/components/ProductInformationBanner/ProductInformationBanner.tsx @@ -2,13 +2,13 @@ import * as React from 'react'; import { HighlightedMarkdown } from 'src/components/HighlightedMarkdown/HighlightedMarkdown'; import { reportException } from 'src/exceptionReporting'; -import { ProductInformationBannerLocation } from 'src/featureFlags'; import { useFlags } from 'src/hooks/useFlags'; import { isAfter } from 'src/utilities/date'; import { DismissibleBanner } from '../DismissibleBanner/DismissibleBanner'; -import type { NoticeProps } from 'src/components/Notice/Notice'; +import type { NoticeProps } from '@linode/ui'; +import type { ProductInformationBannerLocation } from 'src/featureFlags'; interface Props { bannerLocation: ProductInformationBannerLocation; diff --git a/packages/manager/src/components/ProductNotification/ProductNotification.tsx b/packages/manager/src/components/ProductNotification/ProductNotification.tsx index 0ee8d6eb8f0..d15bbd83c43 100644 --- a/packages/manager/src/components/ProductNotification/ProductNotification.tsx +++ b/packages/manager/src/components/ProductNotification/ProductNotification.tsx @@ -1,6 +1,7 @@ +import { Notice } from '@linode/ui'; import * as React from 'react'; -import { Notice, NoticeVariant } from 'src/components/Notice/Notice'; +import type { NoticeVariant } from '@linode/ui'; export interface ProductNotificationProps { onClick?: () => void; diff --git a/packages/manager/src/components/PromiseLoader/PromiseLoader.tsx b/packages/manager/src/components/PromiseLoader/PromiseLoader.tsx index 955dd85fc43..c0f085b80ac 100644 --- a/packages/manager/src/components/PromiseLoader/PromiseLoader.tsx +++ b/packages/manager/src/components/PromiseLoader/PromiseLoader.tsx @@ -1,7 +1,6 @@ +import { CircleProgress } from '@linode/ui'; import * as React from 'react'; -import { CircleProgress } from 'src/components/CircleProgress'; - interface State { [name: string]: any; loading: boolean; @@ -22,6 +21,21 @@ export interface PromiseLoaderResponse { export default function preload

(requests: RequestMap

) { return function (Component: React.ComponentType

) { return class LoadedComponent extends React.Component { + static displayName = `PromiseLoader(${ + Component.displayName || Component.name + })`; + handleDone = () => { + if (!this.mounted) { + return; + } + + this.setState((prevState) => ({ ...prevState, loading: false })); + }; + + mounted: boolean = false; + + state = { loading: true }; + componentDidMount() { this.mounted = true; const promises = Object.entries(requests).map(([name, request]) => @@ -50,6 +64,7 @@ export default function preload

(requests: RequestMap

) { Promise.all(promises).then(this.handleDone).catch(this.handleDone); } + componentWillUnmount() { this.mounted = false; } @@ -62,22 +77,6 @@ export default function preload

(requests: RequestMap

) { return ; } - - static displayName = `PromiseLoader(${ - Component.displayName || Component.name - })`; - - handleDone = () => { - if (!this.mounted) { - return; - } - - this.setState((prevState) => ({ ...prevState, loading: false })); - }; - - mounted: boolean = false; - - state = { loading: true }; }; }; } diff --git a/packages/manager/src/components/PromotionalOfferCard/PromotionalOfferCard.tsx b/packages/manager/src/components/PromotionalOfferCard/PromotionalOfferCard.tsx index 47e9dab10a5..1bbb41870a3 100644 --- a/packages/manager/src/components/PromotionalOfferCard/PromotionalOfferCard.tsx +++ b/packages/manager/src/components/PromotionalOfferCard/PromotionalOfferCard.tsx @@ -1,11 +1,10 @@ -import { Paper } from '@linode/ui'; +import { Paper, Typography } from '@linode/ui'; import Button from '@mui/material/Button'; import * as React from 'react'; import { Link } from 'react-router-dom'; import { makeStyles } from 'tss-react/mui'; import HeavenlyBucketIcon from 'src/assets/icons/promotionalOffers/heavenly-bucket.svg'; -import { Typography } from 'src/components/Typography'; import { OFFSITE_URL_REGEX, ONSITE_URL_REGEX } from 'src/constants'; import { useWindowDimensions } from 'src/hooks/useWindowDimensions'; diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index 0bcfd9a3cea..c15bbc78c05 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -1,14 +1,11 @@ +import { Autocomplete, Chip, Stack, StyledListItem } from '@linode/ui'; import CloseIcon from '@mui/icons-material/Close'; import React from 'react'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; -import { Chip } from 'src/components/Chip'; import { Flag } from 'src/components/Flag'; import { useAllAccountAvailabilitiesQuery } from 'src/queries/account/availability'; import { getRegionCountryGroup } from 'src/utilities/formatRegion'; -import { StyledListItem } from '../Autocomplete/Autocomplete.styles'; -import { Stack } from '../Stack'; import { RegionOption } from './RegionOption'; import { StyledAutocompleteContainer } from './RegionSelect.styles'; import { diff --git a/packages/manager/src/components/RegionSelect/RegionOption.tsx b/packages/manager/src/components/RegionSelect/RegionOption.tsx index ae3fe3b182b..d3be0d72e22 100644 --- a/packages/manager/src/components/RegionSelect/RegionOption.tsx +++ b/packages/manager/src/components/RegionSelect/RegionOption.tsx @@ -1,10 +1,9 @@ -import { Box } from '@linode/ui'; +import { Box, Stack } from '@linode/ui'; import React from 'react'; import { Flag } from 'src/components/Flag'; import { ListItemOption } from 'src/components/ListItemOption'; import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; -import { Stack } from 'src/components/Stack'; import type { Region } from '@linode/api-v4'; import type { ListItemProps } from 'src/components/ListItemOption'; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts index 46605c20261..1f274abd608 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts @@ -1,9 +1,6 @@ -import { Box } from '@linode/ui'; +import { Box, Chip, ListItem } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import { Chip } from 'src/components/Chip'; -import { ListItem } from 'src/components/ListItem'; - export const StyledAutocompleteContainer = styled(Box, { label: 'RegionSelect', })(({ theme }) => ({ diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index b719eb824fd..ca23ad027c0 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -1,7 +1,7 @@ import { createFilterOptions } from '@mui/material/Autocomplete'; import * as React from 'react'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Autocomplete } from '@linode/ui'; import { Flag } from 'src/components/Flag'; import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { useAllAccountAvailabilitiesQuery } from 'src/queries/account/availability'; @@ -121,7 +121,7 @@ export const RegionSelect = < }} sx={(theme) => ({ [theme.breakpoints.up('md')]: { - width: '416px', + width: tooltipText ? '458px' : '416px', }, })} textFieldProps={{ diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index 87a9b6b9344..be624354c8d 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -4,8 +4,8 @@ import type { Region, RegionSite, } from '@linode/api-v4'; +import type { EnhancedAutocompleteProps } from '@linode/ui'; import type React from 'react'; -import type { EnhancedAutocompleteProps } from 'src/components/Autocomplete/Autocomplete'; import type { DisableItemOption } from 'src/components/ListItemOption'; export type RegionFilterValue = diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.stories.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.stories.tsx index 300f3e4486b..49dc6d58ad6 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.stories.tsx +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.stories.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-empty-function */ +import { Button } from '@linode/ui'; import * as React from 'react'; -import { Button } from '../Button/Button'; import { RemovableSelectionsList } from './RemovableSelectionsList'; import type { RemovableItem } from './RemovableSelectionsList'; diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts index 5f715da92d9..07e27f559bf 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts @@ -1,11 +1,6 @@ -import { Box, omittedProps } from '@linode/ui'; +import { Box, List, ListItem, Typography, omittedProps } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import { List } from 'src/components/List'; -import { ListItem } from 'src/components/ListItem'; - -import { Typography } from '../Typography'; - import type { RemovableSelectionsListProps } from './RemovableSelectionsList'; export const StyledNoAssignedLinodesBox = styled(Box, { diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.test.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.test.tsx index 89bbf72f9d4..6dc50bb8bec 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.test.tsx +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.test.tsx @@ -1,9 +1,9 @@ +import { Button } from '@linode/ui'; import { fireEvent } from '@testing-library/react'; import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { Button } from '../Button/Button'; import { RemovableSelectionsList } from './RemovableSelectionsList'; const defaultList = Array.from({ length: 5 }, (_, index) => { diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx index 713689dd2c7..817daf34aba 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx @@ -12,8 +12,8 @@ import { StyledScrollBox, } from './RemovableSelectionsList.style'; +import type { ButtonProps } from '@linode/ui'; import type { SxProps, Theme } from '@mui/material'; -import type { ButtonProps } from 'src/components/Button/Button'; export type RemovableItem = { // The remaining key-value pairs must have their values typed diff --git a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx index 0c8e7a6ac1f..ae81fb10c9f 100644 --- a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx +++ b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx @@ -1,16 +1,13 @@ -import { Box, Paper } from '@linode/ui'; +import { Autocomplete, Box, Paper, Stack, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { Stack } from 'src/components/Stack'; -import { Typography } from 'src/components/Typography'; import { CreateFirewallDrawer } from 'src/features/Firewalls/FirewallLanding/CreateFirewallDrawer'; import { useFlags } from 'src/hooks/useFlags'; import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; import { useFirewallsQuery } from 'src/queries/firewalls'; import { AkamaiBanner } from '../AkamaiBanner/AkamaiBanner'; -import { Autocomplete } from '../Autocomplete/Autocomplete'; import { GenerateFirewallDialog } from '../GenerateFirewallDialog/GenerateFirewallDialog'; import { LinkButton } from '../LinkButton'; diff --git a/packages/manager/src/components/SelectRegionPanel/RegionHelperText.tsx b/packages/manager/src/components/SelectRegionPanel/RegionHelperText.tsx index 0844cce2290..b677cba9286 100644 --- a/packages/manager/src/components/SelectRegionPanel/RegionHelperText.tsx +++ b/packages/manager/src/components/SelectRegionPanel/RegionHelperText.tsx @@ -1,8 +1,7 @@ -import { Box } from '@linode/ui'; +import { Box, Typography } from '@linode/ui'; import * as React from 'react'; import { Link } from 'src/components/Link'; -import { Typography } from 'src/components/Typography'; import type { BoxProps } from '@linode/ui'; diff --git a/packages/manager/src/components/SelectableTableRow/SelectableTableRow.tsx b/packages/manager/src/components/SelectableTableRow/SelectableTableRow.tsx index caa8d3c345b..c76965c7422 100644 --- a/packages/manager/src/components/SelectableTableRow/SelectableTableRow.tsx +++ b/packages/manager/src/components/SelectableTableRow/SelectableTableRow.tsx @@ -1,7 +1,7 @@ +import { Checkbox } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { Checkbox } from 'src/components/Checkbox'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; diff --git a/packages/manager/src/components/SelectionCard/SelectionCard.stories.tsx b/packages/manager/src/components/SelectionCard/SelectionCard.stories.tsx index 78d136e972c..8ca2b5e5bfe 100644 --- a/packages/manager/src/components/SelectionCard/SelectionCard.stories.tsx +++ b/packages/manager/src/components/SelectionCard/SelectionCard.stories.tsx @@ -1,11 +1,10 @@ +import { Chip } from '@linode/ui'; import Alarm from '@mui/icons-material/Alarm'; import InsertPhoto from '@mui/icons-material/InsertPhoto'; import DE from 'flag-icons/flags/4x3/de.svg'; import US from 'flag-icons/flags/4x3/us.svg'; import * as React from 'react'; -import { Chip } from 'src/components/Chip'; - import { SelectionCard } from './SelectionCard'; import type { SelectionCardProps } from './SelectionCard'; diff --git a/packages/manager/src/components/SelectionCard/SelectionCard.tsx b/packages/manager/src/components/SelectionCard/SelectionCard.tsx index b82143a66fb..4991bf1e32b 100644 --- a/packages/manager/src/components/SelectionCard/SelectionCard.tsx +++ b/packages/manager/src/components/SelectionCard/SelectionCard.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { CardBase } from './CardBase'; +import type { TooltipProps } from '@linode/ui'; import type { SxProps, Theme } from '@mui/material/styles'; export interface SelectionCardProps { @@ -87,6 +88,11 @@ export interface SelectionCardProps { * Optional text to set in a tooltip when hovering over the card. */ tooltip?: JSX.Element | string; + /** + * The placement of the tooltip + * @default top + */ + tooltipPlacement?: TooltipProps['placement']; } /** @@ -114,6 +120,7 @@ export const SelectionCard = React.memo((props: SelectionCardProps) => { sxGrid, sxTooltip, tooltip, + tooltipPlacement = 'top', } = props; const handleKeyPress = (e: React.KeyboardEvent) => { @@ -171,7 +178,7 @@ export const SelectionCard = React.memo((props: SelectionCardProps) => { componentsProps={{ tooltip: { sx: sxTooltip }, }} - placement="top" + placement={tooltipPlacement} title={tooltip} > {cardGrid} diff --git a/packages/manager/src/components/ShowMore/ShowMore.tsx b/packages/manager/src/components/ShowMore/ShowMore.tsx index 600a4399a74..d9a8640d420 100644 --- a/packages/manager/src/components/ShowMore/ShowMore.tsx +++ b/packages/manager/src/components/ShowMore/ShowMore.tsx @@ -1,8 +1,9 @@ +import { Chip } from '@linode/ui'; import Popover from '@mui/material/Popover'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; -import { Chip, ChipProps } from 'src/components/Chip'; +import type { ChipProps } from '@linode/ui'; interface ShowMoreProps { ariaItemType: string; diff --git a/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.stories.tsx b/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.stories.tsx index a655dd4489a..d67d7e9c573 100644 --- a/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.stories.tsx +++ b/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.stories.tsx @@ -1,7 +1,6 @@ +import { Typography } from '@linode/ui'; import React from 'react'; -import { Typography } from 'src/components/Typography'; - import { ShowMoreExpansion } from './ShowMoreExpansion'; import type { ShowMoreExpansionProps } from './ShowMoreExpansion'; diff --git a/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.test.tsx b/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.test.tsx index b45070b2b6d..c5128d7dc1a 100644 --- a/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.test.tsx +++ b/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.test.tsx @@ -1,7 +1,7 @@ +import { Typography } from '@linode/ui'; import { fireEvent } from '@testing-library/react'; import React from 'react'; -import { Typography } from 'src/components/Typography'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { ShowMoreExpansion } from './ShowMoreExpansion'; diff --git a/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.tsx b/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.tsx index 3b0b6304d38..cc25cd9e66d 100644 --- a/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.tsx +++ b/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.tsx @@ -1,10 +1,10 @@ +import { Button } from '@linode/ui'; import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight'; import Collapse from '@mui/material/Collapse'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { Button } from 'src/components/Button/Button'; +import type { Theme } from '@mui/material/styles'; const useStyles = makeStyles()( (theme: Theme, _params, classes) => ({ diff --git a/packages/manager/src/components/Snackbar/ToastNotifications.stories.tsx b/packages/manager/src/components/Snackbar/ToastNotifications.stories.tsx index 0d66a284d73..79399717b43 100644 --- a/packages/manager/src/components/Snackbar/ToastNotifications.stories.tsx +++ b/packages/manager/src/components/Snackbar/ToastNotifications.stories.tsx @@ -1,12 +1,10 @@ +import { Button, Stack } from '@linode/ui'; import { useSnackbar } from 'notistack'; import React from 'react'; -import { Button } from 'src/components/Button/Button'; import { Snackbar } from 'src/components/Snackbar/Snackbar'; import { getEventMessage } from 'src/features/Events/utils'; -import { Stack } from '../Stack'; - import type { Meta, StoryObj } from '@storybook/react'; import type { VariantType } from 'notistack'; diff --git a/packages/manager/src/components/SplashScreen.tsx b/packages/manager/src/components/SplashScreen.tsx index 4890a2af50d..1a2e65b66f7 100644 --- a/packages/manager/src/components/SplashScreen.tsx +++ b/packages/manager/src/components/SplashScreen.tsx @@ -1,7 +1,6 @@ -import { Box } from '@linode/ui'; +import { Box, CircleProgress } from '@linode/ui'; import * as React from 'react'; -import { CircleProgress } from 'src/components/CircleProgress'; import { srSpeak } from 'src/utilities/accessibility'; export const SplashScreen = () => { diff --git a/packages/manager/src/components/StackScript/StackScript.tsx b/packages/manager/src/components/StackScript/StackScript.tsx index 2951c34ee34..7ad79f8138c 100644 --- a/packages/manager/src/components/StackScript/StackScript.tsx +++ b/packages/manager/src/components/StackScript/StackScript.tsx @@ -1,23 +1,24 @@ -import { Box } from '@linode/ui'; +import { + Box, + Button, + Chip, + Divider, + H1Header, + TooltipIcon, + Typography, +} from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Link, useHistory } from 'react-router-dom'; import { makeStyles } from 'tss-react/mui'; -import { Button } from 'src/components/Button/Button'; -import { Chip } from 'src/components/Chip'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; -import { Divider } from 'src/components/Divider'; -import { H1Header } from 'src/components/H1Header/H1Header'; import { ScriptCode } from 'src/components/ScriptCode/ScriptCode'; -import { Typography } from 'src/components/Typography'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { listToItemsByID } from 'src/queries/base'; import { useAllImagesQuery } from 'src/queries/images'; -import { TooltipIcon } from '../TooltipIcon'; - import type { StackScript as StackScriptType } from '@linode/api-v4/lib/stackscripts'; import type { Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/components/SupportTicketGeneralError.tsx b/packages/manager/src/components/SupportTicketGeneralError.tsx index e3c64a64292..91d64171b28 100644 --- a/packages/manager/src/components/SupportTicketGeneralError.tsx +++ b/packages/manager/src/components/SupportTicketGeneralError.tsx @@ -1,3 +1,4 @@ +import { Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import React from 'react'; @@ -5,7 +6,6 @@ import { SupportLink } from 'src/components/SupportLink'; import { capitalize } from 'src/utilities/capitalize'; import { supportTextRegex } from './ErrorMessage'; -import { Typography } from './Typography'; import type { EntityForTicketDetails } from './SupportLink/SupportLink'; import type { FormPayloadValues } from 'src/features/Support/SupportTickets/SupportTicketDialog'; diff --git a/packages/manager/src/components/SuspenseLoader.tsx b/packages/manager/src/components/SuspenseLoader.tsx index fe6fc62fce3..55a245e06f9 100644 --- a/packages/manager/src/components/SuspenseLoader.tsx +++ b/packages/manager/src/components/SuspenseLoader.tsx @@ -1,7 +1,6 @@ +import { CircleProgress } from '@linode/ui'; import * as React from 'react'; -import { CircleProgress } from 'src/components/CircleProgress'; - interface Props { /** * Ammount of time before the CircleProgress shows diff --git a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx index 8393e3cbde1..4602d14b363 100644 --- a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx +++ b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx @@ -1,15 +1,13 @@ -import { Box, Paper, Tooltip } from '@linode/ui'; +import { Box, Notice, Paper, Tooltip, Typography } from '@linode/ui'; import HelpOutline from '@mui/icons-material/HelpOutline'; import { styled } from '@mui/material/styles'; import React, { useEffect, useState } from 'react'; -import { Notice } from 'src/components/Notice/Notice'; import { Tab } from 'src/components/Tabs/Tab'; import { TabList } from 'src/components/Tabs/TabList'; import { TabPanel } from 'src/components/Tabs/TabPanel'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; -import { Typography } from 'src/components/Typography'; import type { SxProps, Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/components/Table/Table.styles.ts b/packages/manager/src/components/Table/Table.styles.ts index d0a07122ea4..2a0c27838fc 100644 --- a/packages/manager/src/components/Table/Table.styles.ts +++ b/packages/manager/src/components/Table/Table.styles.ts @@ -13,25 +13,24 @@ export const StyledTableWrapper = styled('div', { 'spacingTop', ]), })(({ theme, ...props }) => ({ + '& thead': { + '& th': { + '&:first-of-type': { + borderLeft: 'none', + }, + '&:last-of-type': { + borderRight: 'none', + }, + backgroundColor: theme.bg.tableHeader, + borderBottom: `1px solid ${theme.borderColors.borderTable}`, + borderRight: `1px solid ${theme.borderColors.borderTable}`, + borderTop: `1px solid ${theme.borderColors.borderTable}`, + fontFamily: theme.font.bold, + }, + }, marginBottom: props.spacingBottom !== undefined ? props.spacingBottom : 0, marginTop: props.spacingTop !== undefined ? props.spacingTop : 0, ...(!props.noOverflow && { - '& thead': { - '& th': { - '&:first-of-type': { - borderLeft: 'none', - }, - '&:last-of-type': { - borderRight: 'none', - }, - backgroundColor: theme.bg.tableHeader, - borderBottom: `1px solid ${theme.borderColors.borderTable}`, - borderRight: `1px solid ${theme.borderColors.borderTable}`, - borderTop: `1px solid ${theme.borderColors.borderTable}`, - fontFamily: theme.font.bold, - padding: '10px 15px', - }, - }, overflowX: 'auto', overflowY: 'hidden', }), diff --git a/packages/manager/src/components/TableCell/TableCell.tsx b/packages/manager/src/components/TableCell/TableCell.tsx index f30c4a116dc..dde56344668 100644 --- a/packages/manager/src/components/TableCell/TableCell.tsx +++ b/packages/manager/src/components/TableCell/TableCell.tsx @@ -1,9 +1,8 @@ +import { TooltipIcon } from '@linode/ui'; import { default as _TableCell } from '@mui/material/TableCell'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { TooltipIcon } from 'src/components/TooltipIcon'; - import type { Theme } from '@mui/material/styles'; import type { TableCellProps as _TableCellProps } from '@mui/material/TableCell'; diff --git a/packages/manager/src/components/TableFooter.stories.tsx b/packages/manager/src/components/TableFooter.stories.tsx index e5066d7d460..4f43aa61f34 100644 --- a/packages/manager/src/components/TableFooter.stories.tsx +++ b/packages/manager/src/components/TableFooter.stories.tsx @@ -1,3 +1,4 @@ +import { Typography } from '@linode/ui'; import React from 'react'; import { Table } from 'src/components/Table'; @@ -7,7 +8,6 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableFooter } from './TableFooter'; -import { Typography } from './Typography'; import type { Meta, StoryObj } from '@storybook/react'; diff --git a/packages/manager/src/components/TableSortCell/TableSortCell.tsx b/packages/manager/src/components/TableSortCell/TableSortCell.tsx index 0928cc1787b..67fd8047260 100644 --- a/packages/manager/src/components/TableSortCell/TableSortCell.tsx +++ b/packages/manager/src/components/TableSortCell/TableSortCell.tsx @@ -1,15 +1,14 @@ -import { - TableCellProps as _TableCellProps, - default as TableCell, -} from '@mui/material/TableCell'; +import { CircleProgress } from '@linode/ui'; +import { default as TableCell } from '@mui/material/TableCell'; import TableSortLabel from '@mui/material/TableSortLabel'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import SortUp from 'src/assets/icons/sort-up.svg'; import Sort from 'src/assets/icons/unsorted.svg'; -import { CircleProgress } from 'src/components/CircleProgress'; + +import type { Theme } from '@mui/material/styles'; +import type { TableCellProps as _TableCellProps } from '@mui/material/TableCell'; const useStyles = makeStyles()((theme: Theme) => ({ initialIcon: { diff --git a/packages/manager/src/components/Tabs/Tabs.stories.tsx b/packages/manager/src/components/Tabs/Tabs.stories.tsx index 89d488081d8..12b127768fb 100644 --- a/packages/manager/src/components/Tabs/Tabs.stories.tsx +++ b/packages/manager/src/components/Tabs/Tabs.stories.tsx @@ -1,10 +1,9 @@ -import { Paper } from '@linode/ui'; +import { Paper, Typography } from '@linode/ui'; import * as React from 'react'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; -import { Typography } from 'src/components/Typography'; import { TabLinkList } from './TabLinkList'; diff --git a/packages/manager/src/components/Tag/Tag.styles.ts b/packages/manager/src/components/Tag/Tag.styles.ts index 77a491cc8cb..2c2309550a8 100644 --- a/packages/manager/src/components/Tag/Tag.styles.ts +++ b/packages/manager/src/components/Tag/Tag.styles.ts @@ -1,10 +1,6 @@ -import { omittedProps } from '@linode/ui'; +import { Chip, StyledLinkButton, omittedProps } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import { Chip } from 'src/components/Chip'; - -import { StyledLinkButton } from '../Button/StyledLinkButton'; - import type { TagProps } from './Tag'; export const StyledChip = styled(Chip, { @@ -92,7 +88,9 @@ export const StyledDeleteButton = styled(StyledLinkButton, { }, borderBottomRightRadius: 3, borderLeft: `1px solid ${ - theme.name === 'light' ? theme.tokens.color.Neutrals.White : '#2e3238' + theme.name === 'light' + ? theme.tokens.color.Neutrals.White + : theme.tokens.color.Neutrals[100] }`, borderRadius: theme.tokens.borderRadius.None, borderTopRightRadius: 3, diff --git a/packages/manager/src/components/Tag/Tag.tsx b/packages/manager/src/components/Tag/Tag.tsx index ba0bc40ca92..b1dab233322 100644 --- a/packages/manager/src/components/Tag/Tag.tsx +++ b/packages/manager/src/components/Tag/Tag.tsx @@ -6,7 +6,7 @@ import { truncateEnd } from 'src/utilities/truncate'; import { StyledChip, StyledDeleteButton } from './Tag.styles'; -import type { ChipProps } from 'src/components/Chip'; +import type { ChipProps } from '@linode/ui'; type Variants = 'blue' | 'lightBlue'; diff --git a/packages/manager/src/components/TagCell/AddTag.tsx b/packages/manager/src/components/TagCell/AddTag.tsx index d6e0b58364f..efa75c81202 100644 --- a/packages/manager/src/components/TagCell/AddTag.tsx +++ b/packages/manager/src/components/TagCell/AddTag.tsx @@ -1,11 +1,10 @@ +import { Autocomplete } from '@linode/ui'; import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; import { useProfile } from 'src/queries/profile/profile'; import { updateTagsSuggestionsData, useAllTagsQuery } from 'src/queries/tags'; -import { Autocomplete } from '../Autocomplete/Autocomplete'; - interface AddTagProps { addTag: (tag: string) => Promise; existingTags: string[]; diff --git a/packages/manager/src/components/TagCell/TagCell.tsx b/packages/manager/src/components/TagCell/TagCell.tsx index 92d667b0f32..ec217726a24 100644 --- a/packages/manager/src/components/TagCell/TagCell.tsx +++ b/packages/manager/src/components/TagCell/TagCell.tsx @@ -1,4 +1,10 @@ -import { IconButton, omittedProps } from '@linode/ui'; +import { + CircleProgress, + IconButton, + StyledPlusIcon, + StyledTagButton, + omittedProps, +} from '@linode/ui'; import MoreHoriz from '@mui/icons-material/MoreHoriz'; import { styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; @@ -7,8 +13,6 @@ import * as React from 'react'; import { Tag } from 'src/components/Tag/Tag'; import { useWindowDimensions } from 'src/hooks/useWindowDimensions'; -import { StyledPlusIcon, StyledTagButton } from '../Button/StyledTagButton'; -import { CircleProgress } from '../CircleProgress'; import { AddTag } from './AddTag'; import { TagDrawer } from './TagDrawer'; diff --git a/packages/manager/src/components/TagsInput/TagsInput.tsx b/packages/manager/src/components/TagsInput/TagsInput.tsx index c88260181a8..ae0c205148f 100644 --- a/packages/manager/src/components/TagsInput/TagsInput.tsx +++ b/packages/manager/src/components/TagsInput/TagsInput.tsx @@ -1,15 +1,15 @@ +import { Autocomplete, Chip } from '@linode/ui'; import CloseIcon from '@mui/icons-material/Close'; -import { APIError } from '@linode/api-v4/lib/types'; import { useQueryClient } from '@tanstack/react-query'; import { concat } from 'ramda'; import * as React from 'react'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; -import { Chip } from 'src/components/Chip'; import { useProfile } from 'src/queries/profile/profile'; import { updateTagsSuggestionsData, useAllTagsQuery } from 'src/queries/tags'; import { getErrorMap } from 'src/utilities/errorUtils'; +import type { APIError } from '@linode/api-v4/lib/types'; + export interface Tag { label: string; value: string; diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.stories.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.stories.tsx index 5bdbf80d011..c89cc737c92 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.stories.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.stories.tsx @@ -1,7 +1,6 @@ +import { Typography } from '@linode/ui'; import React from 'react'; -import { Typography } from 'src/components/Typography'; - import { TextTooltip } from './TextTooltip'; import type { TextTooltipProps } from './TextTooltip'; diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.tsx index 9a1f1f43979..5a7f3ffc206 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.tsx @@ -1,12 +1,10 @@ -import { Tooltip } from '@linode/ui'; +import { Tooltip, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { Typography } from 'src/components/Typography'; - +import type { TypographyProps } from '@linode/ui'; import type { SxProps, Theme } from '@mui/material'; import type { TooltipProps } from '@mui/material/Tooltip'; -import type { TypographyProps } from 'src/components/Typography'; export interface TextTooltipProps { /** diff --git a/packages/manager/src/components/Tile/Tile.tsx b/packages/manager/src/components/Tile/Tile.tsx index b717753b230..200a07bde1d 100644 --- a/packages/manager/src/components/Tile/Tile.tsx +++ b/packages/manager/src/components/Tile/Tile.tsx @@ -1,9 +1,8 @@ +import { Notice, Typography } from '@linode/ui'; import Button from '@mui/material/Button'; import * as React from 'react'; import { Link } from 'src/components/Link'; -import { Notice } from 'src/components/Notice/Notice'; -import { Typography } from 'src/components/Typography'; import { useStyles } from './Tile.styles'; diff --git a/packages/manager/src/components/TransferDisplay/TransferDisplay.tsx b/packages/manager/src/components/TransferDisplay/TransferDisplay.tsx index ff3e9b810f8..e878fa4db59 100644 --- a/packages/manager/src/components/TransferDisplay/TransferDisplay.tsx +++ b/packages/manager/src/components/TransferDisplay/TransferDisplay.tsx @@ -1,12 +1,9 @@ -import { Box } from '@linode/ui'; +import { Box, CircleProgress, StyledLinkButton, Typography } from '@linode/ui'; import * as React from 'react'; -import { CircleProgress } from 'src/components/CircleProgress'; -import { Typography } from 'src/components/Typography'; import { useAccountNetworkTransfer } from 'src/queries/account/transfer'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { StyledLinkButton } from '../Button/StyledLinkButton'; import { StyledTransferDisplayContainer } from './TransferDisplay.styles'; import { TransferDisplayDialog } from './TransferDisplayDialog'; import { diff --git a/packages/manager/src/components/TransferDisplay/TransferDisplayDialog.tsx b/packages/manager/src/components/TransferDisplay/TransferDisplayDialog.tsx index e5ad1ec0225..e74567adf10 100644 --- a/packages/manager/src/components/TransferDisplay/TransferDisplayDialog.tsx +++ b/packages/manager/src/components/TransferDisplay/TransferDisplayDialog.tsx @@ -1,12 +1,10 @@ -import { Box } from '@linode/ui'; -import { useTheme } from '@mui/material/styles'; +import { Box, Divider, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Dialog } from 'src/components/Dialog/Dialog'; -import { Divider } from 'src/components/Divider'; import { Link } from 'src/components/Link'; -import { Typography } from 'src/components/Typography'; import { useIsGeckoEnabled } from '../RegionSelect/RegionSelect.utils'; import { NETWORK_TRANSFER_USAGE_AND_COST_LINK } from './constants'; diff --git a/packages/manager/src/components/TransferDisplay/TransferDisplayDialogHeader.tsx b/packages/manager/src/components/TransferDisplay/TransferDisplayDialogHeader.tsx index d48106c3e8c..62b6cacbad2 100644 --- a/packages/manager/src/components/TransferDisplay/TransferDisplayDialogHeader.tsx +++ b/packages/manager/src/components/TransferDisplay/TransferDisplayDialogHeader.tsx @@ -1,9 +1,7 @@ +import { TooltipIcon, Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; -import { TooltipIcon } from 'src/components/TooltipIcon'; -import { Typography } from 'src/components/Typography'; - interface Props { dataTestId: string; headerText: string; diff --git a/packages/manager/src/components/TransferDisplay/TransferDisplayUsage.tsx b/packages/manager/src/components/TransferDisplay/TransferDisplayUsage.tsx index 75d072be66c..075cf7cb4b9 100644 --- a/packages/manager/src/components/TransferDisplay/TransferDisplayUsage.tsx +++ b/packages/manager/src/components/TransferDisplay/TransferDisplayUsage.tsx @@ -1,9 +1,9 @@ +import { Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { BarPercent } from 'src/components/BarPercent'; -import { Typography } from 'src/components/Typography'; import { formatPoolUsagePct } from './utils'; diff --git a/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx b/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx index 84f70c6380c..7c88b881acd 100644 --- a/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx +++ b/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx @@ -1,8 +1,9 @@ +import { TextField, Typography } from '@linode/ui'; import * as React from 'react'; import { Link } from 'src/components/Link'; -import { TextField, TextFieldProps } from 'src/components/TextField'; -import { Typography } from 'src/components/Typography'; + +import type { TextFieldProps } from '@linode/ui'; export interface TypeToConfirmProps extends Omit { confirmationText?: JSX.Element | string; diff --git a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.test.tsx b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.test.tsx index e3b308e52f7..4b4853c371a 100644 --- a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.test.tsx +++ b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.test.tsx @@ -1,7 +1,7 @@ +import { Typography } from '@linode/ui'; import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { Typography } from 'src/components/Typography'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { TypeToConfirmDialog } from './TypeToConfirmDialog'; diff --git a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx index 55e555abe50..1132f9cd7d3 100644 --- a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx +++ b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx @@ -1,17 +1,14 @@ -import { APIError } from '@linode/api-v4/lib/types'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { - ConfirmationDialog, - ConfirmationDialogProps, -} from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { - TypeToConfirm, - TypeToConfirmProps, -} from 'src/components/TypeToConfirm/TypeToConfirm'; +import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { TypeToConfirm } from 'src/components/TypeToConfirm/TypeToConfirm'; import { usePreferences } from 'src/queries/profile/preferences'; +import type { APIError } from '@linode/api-v4/lib/types'; +import type { ConfirmationDialogProps } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import type { TypeToConfirmProps } from 'src/components/TypeToConfirm/TypeToConfirm'; + interface EntityInfo { action?: | 'cancellation' @@ -37,10 +34,6 @@ interface EntityInfo { } interface TypeToConfirmDialogProps { - /** - * Chidlren are rendered above the TypeToConfirm input - */ - children?: React.ReactNode; /** * Props to be allow disabling the input */ @@ -69,10 +62,6 @@ interface TypeToConfirmDialogProps { * The click handler for the primary button */ onClick: () => void; - /** - * Optional callback to be executed when the closing animation has completed - */ - onExited?: () => void; /** * The open/closed state of the dialog */ @@ -95,7 +84,6 @@ export const TypeToConfirmDialog = (props: CombinedProps) => { loading, onClick, onClose, - onExited, open, textFieldStyle, title, @@ -133,7 +121,7 @@ export const TypeToConfirmDialog = (props: CombinedProps) => { secondaryButtonProps={{ 'data-testid': 'cancel', label: 'Cancel', - onClick: onClose, + onClick: onClose ? () => onClose({}, 'escapeKeyDown') : undefined, }} style={{ padding: 0 }} /> @@ -144,7 +132,6 @@ export const TypeToConfirmDialog = (props: CombinedProps) => { actions={actions} error={errors ? errors[0].reason : undefined} onClose={onClose} - onExited={onExited} open={open} title={title} > diff --git a/packages/manager/src/components/Uploaders/FileUpload.styles.ts b/packages/manager/src/components/Uploaders/FileUpload.styles.ts index c5b1ef3bdff..d33745e45c0 100644 --- a/packages/manager/src/components/Uploaders/FileUpload.styles.ts +++ b/packages/manager/src/components/Uploaders/FileUpload.styles.ts @@ -1,9 +1,8 @@ +import { Typography, rotate360 } from '@linode/ui'; import { styled } from '@mui/material/styles'; import { makeStyles } from 'tss-react/mui'; import UploadPending from 'src/assets/icons/uploadPending.svg'; -import { Typography } from 'src/components/Typography'; -import { rotate360 } from 'src/styles/keyframes'; import type { FileUploadProps } from './FileUpload'; import type { Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/components/Uploaders/FileUpload.tsx b/packages/manager/src/components/Uploaders/FileUpload.tsx index 6152322f7af..b53d5cb3810 100644 --- a/packages/manager/src/components/Uploaders/FileUpload.tsx +++ b/packages/manager/src/components/Uploaders/FileUpload.tsx @@ -1,11 +1,9 @@ -import { Tooltip } from '@linode/ui'; +import { Button, Tooltip, Typography } from '@linode/ui'; import * as React from 'react'; import CautionIcon from 'src/assets/icons/caution.svg'; import FileUploadComplete from 'src/assets/icons/fileUploadComplete.svg'; -import { Button } from 'src/components/Button/Button'; import { LinearProgress } from 'src/components/LinearProgress'; -import { Typography } from 'src/components/Typography'; import { readableBytes } from 'src/utilities/unitConversions'; import { @@ -94,7 +92,8 @@ export const FileUpload = React.memo((props: FileUploadProps) => { })} variant="body1" > - {readableBytes(props.sizeInBytes).formatted} + {/* to convert from binary units (GiB) to decimal units (GB) we need to pass the base10 flag */} + {readableBytes(props.sizeInBytes, { base10: true }).formatted} {props.percentCompleted === 100 ? ( , 'value'>> { /** diff --git a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.stories.tsx b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.stories.tsx index f455e4dce7a..dbfc6be04e4 100644 --- a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.stories.tsx +++ b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.stories.tsx @@ -1,9 +1,10 @@ -import { Meta, StoryObj } from '@storybook/react'; +import { Typography } from '@linode/ui'; import React from 'react'; -import { Typography } from '../Typography'; import { VerticalLinearStepper } from './VerticalLinearStepper'; +import type { Meta, StoryObj } from '@storybook/react'; + const meta: Meta = { component: VerticalLinearStepper, title: 'Components/VerticalLinearStepper', diff --git a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx index 99f9424c9e1..8e2e46e0e62 100644 --- a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx +++ b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx @@ -1,3 +1,4 @@ +import { Button, convertToKebabCase } from '@linode/ui'; import { Step, StepConnector, @@ -7,18 +8,16 @@ import { useTheme, } from '@mui/material'; import Box from '@mui/material/Box'; -import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import React, { useState } from 'react'; -import { convertToKebabCase } from 'src/utilities/convertToKebobCase'; - import { CustomStepIcon, StyledCircleIcon, StyledColorlibConnector, } from './VerticalLinearStepper.styles'; -import { Button } from '../Button/Button'; + +import type { Theme } from '@mui/material/styles'; type VerticalLinearStep = { content: JSX.Element; @@ -73,10 +72,10 @@ export const VerticalLinearStepper = ({ /> } sx={{ - cursor: 'pointer !important', '& .MuiStepIcon-text': { display: 'none', }, + cursor: 'pointer !important', p: 0, }} > @@ -137,10 +136,10 @@ export const VerticalLinearStepper = ({ )} {index !== 2 && ( + + )} - + diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx new file mode 100644 index 00000000000..c8342ee6293 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx @@ -0,0 +1,39 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CreateAlertDefinition } from './CreateAlertDefinition'; +describe('AlertDefinition Create', () => { + it('should render input components', async () => { + const { getByLabelText } = renderWithTheme(); + + expect(getByLabelText('Name')).toBeVisible(); + expect(getByLabelText('Description (optional)')).toBeVisible(); + expect(getByLabelText('Severity')).toBeVisible(); + }); + it('should be able to enter a value in the textbox', async () => { + const { getByLabelText } = renderWithTheme(); + const input = getByLabelText('Name'); + + await userEvent.type(input, 'text'); + const specificInput = within(screen.getByTestId('alert-name')).getByTestId( + 'textfield-input' + ); + expect(specificInput).toHaveAttribute('value', 'text'); + }); + it('should render client side validation errors', async () => { + const { getByText } = renderWithTheme(); + + const submitButton = getByText('Submit').closest('button'); + + await userEvent.click(submitButton!); + + expect(getByText('Name is required.')).toBeVisible(); + expect(getByText('Severity is required.')).toBeVisible(); + expect(getByText('Service is required.')).toBeVisible(); + expect(getByText('Region is required.')).toBeVisible(); + expect(getByText('At least one resource is needed.')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx new file mode 100644 index 00000000000..61a6822075e --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -0,0 +1,169 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { Paper, TextField, Typography } from '@linode/ui'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; +import { useHistory } from 'react-router-dom'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; +import { useCreateAlertDefinition } from 'src/queries/cloudpulse/alerts'; + +import { CloudPulseAlertSeveritySelect } from './GeneralInformation/AlertSeveritySelect'; +import { EngineOption } from './GeneralInformation/EngineOption'; +import { CloudPulseRegionSelect } from './GeneralInformation/RegionSelect'; +import { CloudPulseMultiResourceSelect } from './GeneralInformation/ResourceMultiSelect'; +import { CloudPulseServiceSelect } from './GeneralInformation/ServiceTypeSelect'; +import { CreateAlertDefinitionFormSchema } from './schemas'; +import { filterFormValues, filterMetricCriteriaFormValues } from './utilities'; + +import type { CreateAlertDefinitionForm, MetricCriteriaForm } from './types'; +import type { TriggerCondition } from '@linode/api-v4/lib/cloudpulse/types'; + +const triggerConditionInitialValues: TriggerCondition = { + evaluation_period_seconds: 0, + polling_interval_seconds: 0, + trigger_occurrences: 0, +}; +const criteriaInitialValues: MetricCriteriaForm = { + aggregation_type: null, + dimension_filters: [], + metric: '', + operator: null, + value: 0, +}; +const initialValues: CreateAlertDefinitionForm = { + channel_ids: [], + engineType: null, + entity_ids: [], + label: '', + region: '', + rule_criteria: { + rules: filterMetricCriteriaFormValues(criteriaInitialValues), + }, + serviceType: null, + severity: null, + triggerCondition: triggerConditionInitialValues, +}; + +const overrides = [ + { + label: 'Definitions', + linkTo: '/monitor/alerts/definitions', + position: 1, + }, + { + label: 'Details', + linkTo: `/monitor/alerts/definitions/create`, + position: 2, + }, +]; +export const CreateAlertDefinition = () => { + const history = useHistory(); + const alertCreateExit = () => history.push('/monitor/alerts/definitions'); + + const formMethods = useForm({ + defaultValues: initialValues, + mode: 'onBlur', + resolver: yupResolver(CreateAlertDefinitionFormSchema), + }); + + const { + control, + formState, + getValues, + handleSubmit, + setError, + watch, + } = formMethods; + const { enqueueSnackbar } = useSnackbar(); + const { mutateAsync: createAlert } = useCreateAlertDefinition( + getValues('serviceType')! + ); + + const serviceTypeWatcher = watch('serviceType'); + const onSubmit = handleSubmit(async (values) => { + try { + await createAlert(filterFormValues(values)); + enqueueSnackbar('Alert successfully created', { + variant: 'success', + }); + alertCreateExit(); + } catch (errors) { + for (const error of errors) { + if (error.field) { + setError(error.field, { message: error.reason }); + } else { + setError('root', { message: error.reason }); + } + } + } + }); + + return ( + + + +

+ + 1. General Information + + ( + field.onChange(e.target.value)} + placeholder="Enter Name" + value={field.value ?? ''} + /> + )} + control={control} + name="label" + /> + ( + field.onChange(e.target.value)} + optional + placeholder="Enter Description" + value={field.value ?? ''} + /> + )} + control={control} + name="description" + /> + + {serviceTypeWatcher === 'dbaas' && } + + + + + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.test.tsx new file mode 100644 index 00000000000..6ee98f68bf2 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.test.tsx @@ -0,0 +1,50 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { CloudPulseAlertSeveritySelect } from './AlertSeveritySelect'; + +describe('EngineOption component tests', () => { + it('should render the component when resource type is dbaas', () => { + const { getByLabelText, getByTestId } = renderWithThemeAndHookFormContext({ + component: , + }); + expect(getByLabelText('Severity')).toBeInTheDocument(); + expect(getByTestId('severity')).toBeInTheDocument(); + }); + it('should render the options happy path', async () => { + renderWithThemeAndHookFormContext({ + component: , + }); + userEvent.click(screen.getByRole('button', { name: 'Open' })); + expect(await screen.findByRole('option', { name: 'Info' })); + expect(screen.getByRole('option', { name: 'Low' })); + }); + it('should be able to select an option', async () => { + renderWithThemeAndHookFormContext({ + component: , + }); + userEvent.click(screen.getByRole('button', { name: 'Open' })); + await userEvent.click( + await screen.findByRole('option', { name: 'Medium' }) + ); + expect(screen.getByRole('combobox')).toHaveAttribute('value', 'Medium'); + }); + it('should render the tooltip text', () => { + const container = renderWithThemeAndHookFormContext({ + component: , + }); + + const severityContainer = container.getByTestId('severity'); + userEvent.click(severityContainer); + + expect( + screen.getByRole('button', { + name: + 'Define a severity level associated with the alert to help you prioritize and manage alerts in the Recent activity tab.', + }) + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.tsx new file mode 100644 index 00000000000..cdd10fe690a --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.tsx @@ -0,0 +1,63 @@ +import { Autocomplete } from '@linode/ui'; +import * as React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { alertSeverityOptions } from '../../constants'; + +import type { CreateAlertDefinitionForm } from '../types'; +import type { AlertSeverityType } from '@linode/api-v4'; +import type { FieldPathByValue } from 'react-hook-form'; +export interface CloudPulseAlertSeveritySelectProps { + /** + * name used for the component in the form + */ + name: FieldPathByValue; +} + +export const CloudPulseAlertSeveritySelect = ( + props: CloudPulseAlertSeveritySelectProps +) => { + const { name } = props; + const { control } = useFormContext(); + + return ( + ( + { + if (selected) { + field.onChange(selected.value); + } + if (reason === 'clear') { + field.onChange(null); + } + }} + textFieldProps={{ + labelTooltipText: + 'Define a severity level associated with the alert to help you prioritize and manage alerts in the Recent activity tab.', + }} + value={ + field.value !== null + ? alertSeverityOptions.find( + (option) => option.value === field.value + ) + : null + } + data-testid={'severity'} + errorText={fieldState.error?.message} + label="Severity" + onBlur={field.onBlur} + options={alertSeverityOptions} + placeholder="Select a Severity" + size="medium" + /> + )} + control={control} + name={name} + /> + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.test.tsx new file mode 100644 index 00000000000..1da3f1df9e0 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.test.tsx @@ -0,0 +1,35 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { EngineOption } from './EngineOption'; + +describe('EngineOption component tests', () => { + it('should render the component when resource type is dbaas', () => { + const { getByLabelText, getByTestId } = renderWithThemeAndHookFormContext({ + component: , + }); + expect(getByLabelText('Engine Option')).toBeInTheDocument(); + expect(getByTestId('engine-option')).toBeInTheDocument(); + }); + it('should render the options happy path', async () => { + const user = userEvent.setup(); + renderWithThemeAndHookFormContext({ + component: , + }); + user.click(screen.getByRole('button', { name: 'Open' })); + expect(await screen.findByRole('option', { name: 'MySQL' })); + expect(screen.getByRole('option', { name: 'PostgreSQL' })); + }); + it('should be able to select an option', async () => { + const user = userEvent.setup(); + renderWithThemeAndHookFormContext({ + component: , + }); + user.click(screen.getByRole('button', { name: 'Open' })); + await user.click(await screen.findByRole('option', { name: 'MySQL' })); + expect(screen.getByRole('combobox')).toHaveAttribute('value', 'MySQL'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.tsx new file mode 100644 index 00000000000..d0ee7684615 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.tsx @@ -0,0 +1,48 @@ +import { Autocomplete } from '@linode/ui'; +import * as React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { engineTypeOptions } from '../../constants'; + +import type { CreateAlertDefinitionForm } from '../types'; +import type { FieldPathByValue } from 'react-hook-form'; + +interface EngineOptionProps { + /** + * name used for the component to set in the form + */ + name: FieldPathByValue; +} +export const EngineOption = (props: EngineOptionProps) => { + const { name } = props; + const { control } = useFormContext(); + return ( + ( + { + if (reason === 'selectOption') { + field.onChange(selected.value); + } + if (reason === 'clear') { + field.onChange(null); + } + }} + value={ + field.value !== null + ? engineTypeOptions.find((option) => option.value === field.value) + : null + } + data-testid="engine-option" + errorText={fieldState.error?.message} + label="Engine Option" + onBlur={field.onBlur} + options={engineTypeOptions} + placeholder="Select an Engine" + /> + )} + control={control} + name={name} + /> + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.test.tsx new file mode 100644 index 00000000000..805b134507b --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.test.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; + +import * as regions from 'src/queries/regions/regions'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { CloudPulseRegionSelect } from './RegionSelect'; + +import type { Region } from '@linode/api-v4'; + +describe('RegionSelect', () => { + vi.spyOn(regions, 'useRegionsQuery').mockReturnValue({ + data: Array(), + } as ReturnType); + + it('should render a RegionSelect component', () => { + const { getByTestId } = renderWithThemeAndHookFormContext({ + component: , + }); + expect(getByTestId('region-select')).toBeInTheDocument(); + }); + it('should render a Region Select component with proper error message on api call failure', () => { + vi.spyOn(regions, 'useRegionsQuery').mockReturnValue({ + data: undefined, + isError: true, + isLoading: false, + } as ReturnType); + const { getByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByText('Failed to fetch Region.')); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.tsx new file mode 100644 index 00000000000..fba152700a6 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import type { CreateAlertDefinitionForm } from '../types'; +import type { FieldPathByValue } from 'react-hook-form'; + +export interface CloudViewRegionSelectProps { + /** + * name used for the component to set in the form + */ + name: FieldPathByValue; +} + +export const CloudPulseRegionSelect = (props: CloudViewRegionSelectProps) => { + const { name } = props; + const { data: regions, isError, isLoading } = useRegionsQuery(); + const { control } = useFormContext(); + return ( + ( + { + field.onChange(value?.id); + }} + currentCapability={undefined} + fullWidth + label="Region" + loading={isLoading} + placeholder="Select a Region" + regions={regions ?? []} + textFieldProps={{ onBlur: field.onBlur }} + value={field.value} + /> + )} + control={control} + name={name} + /> + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.test.tsx new file mode 100644 index 00000000000..d89fe9d3a24 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.test.tsx @@ -0,0 +1,234 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { linodeFactory } from 'src/factories'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { CloudPulseMultiResourceSelect } from './ResourceMultiSelect'; + +const queryMocks = vi.hoisted(() => ({ + useResourcesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/resources', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/resources'); + return { + ...actual, + useResourcesQuery: queryMocks.useResourcesQuery, + }; +}); +const SELECT_ALL = 'Select All'; +const ARIA_SELECTED = 'aria-selected'; +describe('ResourceMultiSelect component tests', () => { + it('should render disabled component if the props are undefined or regions and service type does not have any values', () => { + const mockLinodes = linodeFactory.buildList(2); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockLinodes, + isError: false, + isLoading: false, + status: 'success', + }); + const { + getByPlaceholderText, + getByTestId, + } = renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + expect(getByTestId('resource-select')).toBeInTheDocument(); + expect(getByPlaceholderText('Select Resources')).toBeInTheDocument(); + }); + + it('should render resources happy path', async () => { + const user = userEvent.setup(); + const mockLinodes = linodeFactory.buildList(2); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockLinodes, + isError: false, + isLoading: false, + status: 'success', + }); + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + user.click(screen.getByRole('button', { name: 'Open' })); + expect( + await screen.findByRole('option', { + name: mockLinodes[0].label, + }) + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { + name: mockLinodes[1].label, + }) + ).toBeInTheDocument(); + }); + + it('should be able to select all resources', async () => { + const user = userEvent.setup(); + const mockLinodes = linodeFactory.buildList(2); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockLinodes, + isError: false, + isLoading: false, + status: 'success', + }); + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + user.click(await screen.findByRole('button', { name: 'Open' })); + await user.click(await screen.findByRole('option', { name: SELECT_ALL })); + expect( + await screen.findByRole('option', { + name: mockLinodes[0].label, + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + screen.getByRole('option', { + name: mockLinodes[0].label, + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + }); + + it('should be able to deselect the selected resources', async () => { + const user = userEvent.setup(); + const mockLinodes = linodeFactory.buildList(2); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockLinodes, + isError: false, + isLoading: false, + status: 'success', + }); + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + user.click(screen.getByRole('button', { name: 'Open' })); + await user.click(await screen.findByRole('option', { name: SELECT_ALL })); + await user.click( + await screen.findByRole('option', { name: 'Deselect All' }) + ); + expect( + await screen.findByRole('option', { + name: mockLinodes[0].label, + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + expect( + screen.getByRole('option', { + name: mockLinodes[1].label, + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + }); + + it('should select multiple resources', async () => { + const user = userEvent.setup(); + const mockLinodes = linodeFactory.buildList(3); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockLinodes, + isError: false, + isLoading: false, + status: 'success', + }); + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + user.click(screen.getByRole('button', { name: 'Open' })); + await user.click( + await screen.findByRole('option', { name: mockLinodes[0].label }) + ); + await user.click( + await screen.findByRole('option', { name: mockLinodes[1].label }) + ); + + expect( + await screen.findByRole('option', { + name: mockLinodes[0].label, + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + screen.getByRole('option', { + name: mockLinodes[1].label, + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + screen.getByRole('option', { + name: mockLinodes[2].label, + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + expect( + screen.getByRole('option', { + name: 'Select All', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + }); + + it('should render the label as cluster when resource is of dbaas type', () => { + const { getByLabelText } = renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + expect(getByLabelText('Clusters')); + }); + + it('should render error messages when there is an API call failure', () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: undefined, + isError: true, + isLoading: false, + status: 'error', + }); + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + expect( + screen.getByText('Failed to fetch the resources.') + ).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx new file mode 100644 index 00000000000..aabe52f3b8b --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx @@ -0,0 +1,98 @@ +import { Autocomplete } from '@linode/ui'; +import * as React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; + +import type { Item } from '../../constants'; +import type { CreateAlertDefinitionForm } from '../types'; +import type { AlertServiceType } from '@linode/api-v4'; +import type { FieldPathByValue } from 'react-hook-form'; + +interface CloudPulseResourceSelectProps { + /** + * engine option type selected by the user + */ + engine: null | string; + /** + * name used for the component to set in the form + */ + name: FieldPathByValue; + /** + * region selected by the user + */ + region: string | undefined; + /** + * service type selected by the user + */ + serviceType: AlertServiceType | null; +} + +export const CloudPulseMultiResourceSelect = ( + props: CloudPulseResourceSelectProps +) => { + const { engine, name, region, serviceType } = { ...props }; + const { control, setValue } = useFormContext(); + + const { data: resources, isError, isLoading } = useResourcesQuery( + Boolean(region && serviceType), + serviceType?.toString(), + {}, + engine !== null ? { engine, region } : { region } + ); + + const getResourcesList = React.useMemo((): Item[] => { + return resources && resources.length > 0 + ? resources.map((resource) => ({ + label: resource.label, + value: resource.id, + })) + : []; + }, [resources]); + + /* useEffect is used here to reset the value of entity_ids back to [] when the region, engine, serviceType props are changed , + as the options to the Autocomplete component are dependent on those props , the values of the Autocomplete won't match with the given options that are passed + and this may raise a warning or error with the isOptionEqualToValue prop in the Autocomplete. + */ + React.useEffect(() => { + setValue(name, []); + }, [region, serviceType, engine, setValue, name]); + + return ( + ( + { + const resourceIds = resources.map((resource) => resource.value); + field.onChange(resourceIds); + }} + value={ + field.value + ? getResourcesList.filter((resource) => + field.value.includes(resource.value) + ) + : [] + } + autoHighlight + clearOnBlur + data-testid="resource-select" + disabled={!Boolean(region && serviceType)} + isOptionEqualToValue={(option, value) => option.value === value.value} + label={serviceType === 'dbaas' ? 'Clusters' : 'Resources'} + limitTags={2} + loading={isLoading && Boolean(region && serviceType)} + multiple + onBlur={field.onBlur} + options={getResourcesList} + placeholder="Select Resources" + /> + )} + control={control} + name={name} + /> + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx new file mode 100644 index 00000000000..42e3f9e1e68 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx @@ -0,0 +1,90 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { serviceTypesFactory } from 'src/factories'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { CloudPulseServiceSelect } from './ServiceTypeSelect'; + +const queryMocks = vi.hoisted(() => ({ + useCloudPulseServiceTypes: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/services', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/services'); + return { + ...actual, + useCloudPulseServiceTypes: queryMocks.useCloudPulseServiceTypes, + }; +}); + +const mockResponse = { + data: [ + serviceTypesFactory.build({ + label: 'Linode', + service_type: 'linode', + }), + serviceTypesFactory.build({ + label: 'Databases', + service_type: 'dbaas', + }), + ], +}; + +queryMocks.useCloudPulseServiceTypes.mockReturnValue({ + data: mockResponse, + isError: true, + isLoading: false, + status: 'success', +}); +describe('ServiceTypeSelect component tests', () => { + it('should render the Autocomplete component', () => { + const { getAllByText, getByTestId } = renderWithThemeAndHookFormContext({ + component: , + }); + expect(getByTestId('servicetype-select')).toBeInTheDocument(); + getAllByText('Service'); + }); + + it('should render service types happy path', async () => { + renderWithThemeAndHookFormContext({ + component: , + }); + userEvent.click(screen.getByRole('button', { name: 'Open' })); + expect( + await screen.findByRole('option', { + name: 'Linode', + }) + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { + name: 'Databases', + }) + ).toBeInTheDocument(); + }); + + it('should be able to select a service type', async () => { + renderWithThemeAndHookFormContext({ + component: , + }); + userEvent.click(screen.getByRole('button', { name: 'Open' })); + await userEvent.click( + await screen.findByRole('option', { name: 'Linode' }) + ); + expect(screen.getByRole('combobox')).toHaveAttribute('value', 'Linode'); + }); + it('should render error messages when there is an API call failure', () => { + queryMocks.useCloudPulseServiceTypes.mockReturnValue({ + data: undefined, + error: 'an error happened', + isLoading: false, + }); + renderWithThemeAndHookFormContext({ + component: , + }); + expect( + screen.getByText('Failed to fetch the service types.') + ).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx new file mode 100644 index 00000000000..8df9ba4851d --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx @@ -0,0 +1,81 @@ +import { Autocomplete } from '@linode/ui'; +import * as React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { useCloudPulseServiceTypes } from 'src/queries/cloudpulse/services'; + +import type { Item } from '../../constants'; +import type { CreateAlertDefinitionForm } from '../types'; +import type { AlertServiceType } from '@linode/api-v4'; +import type { FieldPathByValue } from 'react-hook-form'; + +interface CloudPulseServiceSelectProps { + /** + * name used for the component in the form + */ + name: FieldPathByValue; +} + +export const CloudPulseServiceSelect = ( + props: CloudPulseServiceSelectProps +) => { + const { name } = props; + const { + data: serviceOptions, + error: serviceTypesError, + isLoading: serviceTypesLoading, + } = useCloudPulseServiceTypes(true); + const { control } = useFormContext(); + + const getServicesList = React.useMemo((): Item< + string, + AlertServiceType + >[] => { + return serviceOptions && serviceOptions.data.length > 0 + ? serviceOptions.data.map((service) => ({ + label: service.label, + value: service.service_type as AlertServiceType, + })) + : []; + }, [serviceOptions]); + + return ( + ( + { + if (selected) { + field.onChange(selected.value); + } + if (reason === 'clear') { + field.onChange(null); + } + }} + value={ + field.value !== null + ? getServicesList.find((option) => option.value === field.value) + : null + } + data-testid="servicetype-select" + fullWidth + label="Service" + loading={serviceTypesLoading && !serviceTypesError} + onBlur={field.onBlur} + options={getServicesList} + placeholder="Select a Service" + sx={{ marginTop: '5px' }} + /> + )} + control={control} + name={name} + /> + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts new file mode 100644 index 00000000000..8b9301c3ebf --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts @@ -0,0 +1,16 @@ +import { createAlertDefinitionSchema } from '@linode/validation'; +import { object, string } from 'yup'; + +const engineOptionValidation = string().when('service_type', { + is: 'dbaas', + otherwise: (schema) => schema.notRequired().nullable(), + then: (schema) => schema.required('Engine type is required.').nullable(), +}); + +export const CreateAlertDefinitionFormSchema = createAlertDefinitionSchema.concat( + object({ + engineType: engineOptionValidation, + region: string().required('Region is required.'), + serviceType: string().required('Service is required.'), + }) +); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts new file mode 100644 index 00000000000..844b47639a0 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts @@ -0,0 +1,23 @@ +import type { + AlertServiceType, + AlertSeverityType, + CreateAlertDefinitionPayload, + MetricAggregationType, + MetricCriteria, + MetricOperatorType, +} from '@linode/api-v4'; + +export interface CreateAlertDefinitionForm + extends Omit { + engineType: null | string; + entity_ids: string[]; + region: string; + serviceType: AlertServiceType | null; + severity: AlertSeverityType | null; +} + +export interface MetricCriteriaForm + extends Omit { + aggregation_type: MetricAggregationType | null; + operator: MetricOperatorType | null; +} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts new file mode 100644 index 00000000000..7459ca1c5da --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts @@ -0,0 +1,32 @@ +import { omitProps } from '@linode/ui'; + +import type { CreateAlertDefinitionForm, MetricCriteriaForm } from './types'; +import type { + CreateAlertDefinitionPayload, + MetricCriteria, +} from '@linode/api-v4'; + +// filtering out the form properties which are not part of the payload +export const filterFormValues = ( + formValues: CreateAlertDefinitionForm +): CreateAlertDefinitionPayload => { + const values = omitProps(formValues, [ + 'serviceType', + 'region', + 'engineType', + 'severity', + ]); + // severity has a need for null in the form for edge-cases, so null-checking and returning it as an appropriate type + const severity = formValues.severity!; + const entityIds = formValues.entity_ids; + return { ...values, entity_ids: entityIds, severity }; +}; + +export const filterMetricCriteriaFormValues = ( + formValues: MetricCriteriaForm +): MetricCriteria[] => { + const aggregationType = formValues.aggregation_type!; + const operator = formValues.operator!; + const values = omitProps(formValues, ['aggregation_type', 'operator']); + return [{ ...values, aggregation_type: aggregationType, operator }]; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts new file mode 100644 index 00000000000..08f6223e263 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -0,0 +1,23 @@ +import type { AlertSeverityType } from '@linode/api-v4'; + +export interface Item { + label: L; + value: T; +} +export const alertSeverityOptions: Item[] = [ + { label: 'Info', value: 3 }, + { label: 'Low', value: 2 }, + { label: 'Medium', value: 1 }, + { label: 'Severe', value: 0 }, +]; + +export const engineTypeOptions: Item[] = [ + { + label: 'MySQL', + value: 'mysql', + }, + { + label: 'PostgreSQL', + value: 'postgresql', + }, +]; diff --git a/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx b/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx index 47e13529c3b..d303457db05 100644 --- a/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx +++ b/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx @@ -12,7 +12,7 @@ export const CloudPulseLanding = () => { }> @@ -23,8 +23,6 @@ export const CloudPulseLanding = () => { ); }; -export const cloudPulseLandingLazyRoute = createLazyRoute( - '/monitor/cloudpulse' -)({ +export const cloudPulseLandingLazyRoute = createLazyRoute('/monitor')({ component: CloudPulseLanding, }); diff --git a/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx b/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx index 418bee1322c..6d0a40ad654 100644 --- a/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx +++ b/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx @@ -3,6 +3,7 @@ import { Redirect, Route, Switch, + useHistory, useLocation, useRouteMatch, } from 'react-router-dom'; @@ -25,6 +26,7 @@ export const CloudPulseTabs = () => { const flags = useFlags(); const { url } = useRouteMatch(); const { pathname } = useLocation(); + const history = useHistory(); const alertTabs = React.useMemo( () => [ { @@ -63,22 +65,22 @@ export const CloudPulseTabs = () => { ), [accessibleTabs, pathname] ); + const handleChange = (index: number) => { + history.push(alertTabs[index].tab.routeName); + }; return ( - + }> - + diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index 0c7fcb6e4b1..623e12db24a 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -1,7 +1,7 @@ +import { CircleProgress } from '@linode/ui'; import { Grid } from '@mui/material'; import React from 'react'; -import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboards'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; @@ -67,7 +67,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { const getJweTokenPayload = (): JWETokenPayLoad => { return { - resource_ids: resourceList?.map((resource) => Number(resource.id)) ?? [], + resource_ids: resources?.map((resource) => Number(resource)) ?? [], }; }; @@ -100,11 +100,11 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { const { data: jweToken, isError: isJweTokenError, - isLoading: isJweTokenLoading, + isFetching: isJweTokenFetching, } = useCloudPulseJWEtokenQuery( dashboard?.service_type, getJweTokenPayload(), - Boolean(resourceList) + Boolean(resources) && !isDashboardLoading && !isDashboardApiError ); if (isDashboardApiError) { @@ -123,12 +123,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { return renderErrorState('Error loading the definitions of metrics.'); } - if ( - isMetricDefinitionLoading || - isDashboardLoading || - isResourcesLoading || - isJweTokenLoading - ) { + if (isMetricDefinitionLoading || isDashboardLoading || isResourcesLoading) { return ; } @@ -137,6 +132,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { additionalFilters={additionalFilters} dashboard={dashboard} duration={duration} + isJweTokenFetching={isJweTokenFetching} jweToken={jweToken} manualRefreshTimeStamp={manualRefreshTimeStamp} metricDefinitions={metricDefinitions} diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx index cb2ed55152e..00f73834381 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx @@ -1,8 +1,7 @@ -import { Paper } from '@linode/ui'; +import { CircleProgress, Paper } from '@linode/ui'; import { Grid } from '@mui/material'; import React from 'react'; -import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboards'; diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx index 5da22919834..b3ab3e678da 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx @@ -1,10 +1,10 @@ +import { Divider } from '@linode/ui'; import { IconButton, useTheme } from '@mui/material'; import { Grid } from '@mui/material'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import Reload from 'src/assets/icons/refresh.svg'; -import { Divider } from 'src/components/Divider'; import { CloudPulseDashboardFilterBuilder } from '../shared/CloudPulseDashboardFilterBuilder'; import { CloudPulseDashboardSelect } from '../shared/CloudPulseDashboardSelect'; diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetColorPalette.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetColorPalette.ts deleted file mode 100644 index 3b9325521d0..00000000000 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetColorPalette.ts +++ /dev/null @@ -1,111 +0,0 @@ -export const RED = [ - '#ee2c2c80', - '#ff633d80', - '#F27E7E80', - '#EA7C7280', - '#E2796580', - '#D9775980', - '#D1744D80', - '#C9724080', - '#C16F3480', - '3B86D2880', - '#B06A1B80', - '#A8680F80', -]; - -export const GREEN = [ - '#10a21d80', - '#31ce3e80', - '#d9b0d980', - '#ffdc7d80', - '#7EF29D80', - '#72E39E80', - '#65D3A080', - '#59C4A180', - '#4DB5A280', - '#40A5A480', - '#3496A580', - '#2887A680', - '#1B77A880', - '#0F68A980', -]; - -export const BLUE = [ - '#3683dc80', - '#0F91A880', - '#1B9CAC80', - '#28A7AF80', - '#34B1B380', - '#40BCB680', - '#4DC7BA80', - '#59D2BD80', - '#65DCC180', - '#72E7C480', - '#7EF2C880', -]; - -export const YELLOW = [ - '#ffb34d80', - '#F2EE7E80', - '#E6E67280', - '#DBDE6580', - '#CFD75980', - '#C4CF4D80', - '#B8C74080', - '#ADBF3480', - '#A1B82880', - '#96B01B80', - '#8AA80F80', -]; - -export const PINK = [ - '#F27EE180', - '#EA72D180', - '#E265C280', - '#D959B280', - '#D14DA280', - '#C9409380', - '#C1348380', - '#B8287380', - '#B01B6480', - '#A80F5480', -]; - -export const DEFAULT = [ - // thick colors from each... - '#4067E580', - '#FE993380', - '#12A59480', - '#AB4ABA80', - '#D63C4280', - '#05A2C280', - '#E043A780', - '#00B05080', - '#7259D680', - '#99D52A80', - '#71717880', - '#FFD70080', - '#40E0D080', - '#8DA4EF80', - '#C25D0580', - '#067A6F80', - '#CF91D880', - '#EB909180', - '#0C779280', - '#E38EC380', - '#97CF9C80', - '#AA99EC80', - '#94BA2C80', - '#4B4B5180', - '#FFE76680', - '#33B2A680', -]; - -export const COLOR_MAP = new Map([ - ['blue', BLUE], - ['default', DEFAULT], - ['green', GREEN], - ['pink', PINK], - ['red', RED], - ['yellow', YELLOW], -]); diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index 00719db57d0..f4066808b3d 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -1,8 +1,9 @@ -import { isToday } from 'src/utilities/isToday'; +import { Alias } from '@linode/design-language-system'; + import { getMetrics } from 'src/utilities/statMetrics'; -import { COLOR_MAP, DEFAULT } from './CloudPulseWidgetColorPalette'; import { + convertValueToUnit, formatToolTip, generateUnitByBaseUnit, transformData, @@ -13,7 +14,6 @@ import { } from './utils'; import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; -import type { LegendRow } from '../Widget/CloudPulseWidget'; import type { CloudPulseMetricsList, CloudPulseMetricsRequest, @@ -22,7 +22,9 @@ import type { Widgets, } from '@linode/api-v4'; import type { Theme } from '@mui/material'; -import type { DataSet } from 'src/components/LineGraph/LineGraph'; +import type { DataSet } from 'src/components/AreaChart/AreaChart'; +import type { AreaProps } from 'src/components/AreaChart/AreaChart'; +import type { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay'; import type { CloudPulseResourceTypeMapFlag, FlagSet } from 'src/featureFlags'; interface LabelNameOptionsProps { @@ -57,7 +59,7 @@ interface LabelNameOptionsProps { unit: string; } -interface graphDataOptionsProps { +interface GraphDataOptionsProps { /** * flags associated with metricsList */ @@ -84,24 +86,14 @@ interface graphDataOptionsProps { serviceType: string; /** - * status returned from react query ( loading | error | success) + * status returned from react query ( pending | error | success) */ - status: string | undefined; + status: 'error' | 'pending' | 'success'; /** * unit of the data */ unit: string; - - /** - * widget chart type - */ - widgetChartType: string; - - /** - * preferred color for the widget's graph - */ - widgetColor: string; } interface MetricRequestProps { @@ -143,11 +135,33 @@ interface DimensionNameProperties { resources: CloudPulseResources[]; } +interface GraphData { + /** + * array of area props to be shown on graph + */ + areas: AreaProps[]; + + /** + * plots to be shown of each dimension + */ + dimensions: DataSet[]; + + /** + * legends rows available for each dimension + */ + legendRowsData: MetricsDisplayRow[]; + + /** + * maximum possible rolled up unit for the data + */ + unit: string; +} + /** * * @returns parameters which will be necessary to populate graph & legends */ -export const generateGraphData = (props: graphDataOptionsProps) => { +export const generateGraphData = (props: GraphDataOptionsProps): GraphData => { const { flags, label, @@ -156,28 +170,22 @@ export const generateGraphData = (props: graphDataOptionsProps) => { serviceType, status, unit, - widgetChartType, - widgetColor, } = props; - - const dimensions: DataSet[] = []; - const legendRowsData: LegendRow[] = []; - - // If the color is not found in the map, fallback to default color theme - const colors = COLOR_MAP.get(widgetColor) ?? DEFAULT; - let today = false; - + const legendRowsData: MetricsDisplayRow[] = []; + const dimension: { [timestamp: number]: { [label: string]: number } } = {}; + const areas: AreaProps[] = []; + const colors = Object.values(Alias.Chart.Categorical); if (status === 'success') { metricsList?.data?.result?.forEach( (graphData: CloudPulseMetricsList, index) => { if (!graphData) { return; } + const transformedData = { metric: graphData.metric, values: transformData(graphData.values, unit), }; - const color = colors[index]; const { end, start } = convertTimeDurationToStartAndEndTimeRange({ unit: 'min', value: 30, @@ -194,33 +202,62 @@ export const generateGraphData = (props: graphDataOptionsProps) => { serviceType, unit, }; - - const dimension = { - backgroundColor: color, - borderColor: color, - data: seriesDataFormatter(transformedData.values, start, end), - fill: widgetChartType === 'area', - label: getLabelName(labelOptions), - }; + const labelName = getLabelName(labelOptions); + const data = seriesDataFormatter(transformedData.values, start, end); + const color = colors[index % 22].Primary; + areas.push({ + color, + dataKey: labelName, + }); + + // map each label & its data point to its timestamp + data.forEach((dataPoint) => { + const timestamp = dataPoint[0]; + const value = dataPoint[1]; + if (value !== null) { + dimension[timestamp] = { + ...dimension[timestamp], + [labelName]: value, + }; + } + }); // construct a legend row with the dimension - const legendRow = { - data: getMetrics(dimension.data as number[][]), + const legendRow: MetricsDisplayRow = { + data: getMetrics(data as number[][]), format: (value: number) => formatToolTip(value, unit), legendColor: color, - legendTitle: dimension.label, + legendTitle: labelName, }; legendRowsData.push(legendRow); - dimensions.push(dimension); - today ||= isToday(start, end); } ); } + const maxUnit = generateMaxUnit(legendRowsData, unit); + const dimensions = Object.entries(dimension) + .map( + ([timestamp, resource]): DataSet => { + const rolledUpData = Object.entries(resource).reduce( + (oldValue, newValue) => { + return { + ...oldValue, + [newValue[0]]: convertValueToUnit(newValue[1], maxUnit), + }; + }, + {} + ); + + return { timestamp: Number(timestamp), ...rolledUpData }; + } + ) + .sort( + (dimension1, dimension2) => dimension1.timestamp - dimension2.timestamp + ); return { + areas, dimensions, legendRowsData, - today, - unit: generateMaxUnit(legendRowsData, unit), + unit: maxUnit, }; }; @@ -230,7 +267,7 @@ export const generateGraphData = (props: graphDataOptionsProps) => { * @param unit base unit of the values * @returns maximum possible rolled up unit based on the unit */ -const generateMaxUnit = (legendRowsData: LegendRow[], unit: string) => { +const generateMaxUnit = (legendRowsData: MetricsDisplayRow[], unit: string) => { const maxValue = Math.max( 0, ...legendRowsData?.map((row) => row?.data.max ?? 0) @@ -319,20 +356,6 @@ export const mapResourceIdToName = ( return resourcesObj?.label ?? id ?? ''; }; -/** - * - * @param data data set to be checked for empty - * @returns true if data is not empty or contains all the null values otherwise false - */ -export const isDataEmpty = (data: DataSet[]): boolean => { - return data.every( - (thisSeries) => - thisSeries.data.length === 0 || - // If we've padded the data, every y value will be null - thisSeries.data.every((thisPoint) => thisPoint[1] === null) - ); -}; - /** * * @param theme mui theme @@ -347,37 +370,3 @@ export const getAutocompleteWidgetStyles = (theme: Theme) => ({ width: '90px', }, }); - -/** - * This method handles the existing issue in chart JS, and it will deleted when the recharts migration is completed - * @param arraysToBeFilled The list of dimension data to be filled - * @returns The list of dimension data filled with null values for missing timestamps - */ -// TODO: CloudPulse - delete when recharts migration completed -export const fillMissingTimeStampsAcrossDimensions = (...arraysToBeFilled: [number, number | null][][]): [number, number | null][][] => { - - if (arraysToBeFilled.length === 0) return []; - - // Step 1: Collect all unique keys from all arrays - const allTimestamps = new Set(); - - // Collect timestamps from each array, array[0], contains the number timestamp - arraysToBeFilled.forEach(array => { - array.forEach(([timeStamp]) => allTimestamps.add(timeStamp)); - }); - - // Step 2: Sort the timestamps to maintain chronological order - const sortedTimestamps = Array.from(allTimestamps).sort((a, b) => a - b); - - // Step 3: Synchronize the arrays to have null values for all missing timestamps - return arraysToBeFilled.map(array => { - // Step 3.1: Convert the array into a map for fast lookup - const map = new Map(array.map(([key, value]) => [key, value])); - - // Step 3.2: Build the synchronized array by checking if a key exists - return sortedTimestamps.map(key => { - // If the current array has the key, use its value; otherwise, set it to null, so that the gap is properly visible - return [key, map.get(key) ?? null] as [number, number | null]; - }); - }); -} diff --git a/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts b/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts index 422607f383a..a22a473c7e7 100644 --- a/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts +++ b/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts @@ -166,7 +166,7 @@ export const convertValueToUnit = (value: number, maxUnit: string) => { if (convertingValue === 1) { return roundTo(value); } - return value / convertingValue; + return roundTo(value / convertingValue); }; /** @@ -244,5 +244,8 @@ export const transformData = ( ): [number, number][] => { const unit: string = generateCurrentUnit(baseUnit); - return data.map((d) => [d[0], Number(d[1]) * (multiplier[unit] ?? 1)]); + return data.map((d) => [ + d[0], + d[1] !== null ? Number(d[1]) * (multiplier[unit] ?? 1) : d[1], + ]); }; diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 7c168f4e487..6801fd4b034 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -108,7 +108,7 @@ export const seriesDataFormatter = ( const formattedArray: StatWithDummyPoint[] = data.map(([x, y]) => ({ x: Number(x), - y: y ? Number(y) : null, + y: y !== null ? Number(y) : null, })); return convertData(formattedArray, startTime, endTime); diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index 73401fe86c4..26b82f1cfbf 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -10,15 +10,10 @@ import { useProfile } from 'src/queries/profile/profile'; import { generateGraphData, getCloudPulseMetricRequest, - fillMissingTimeStampsAcrossDimensions, } from '../Utils/CloudPulseWidgetUtils'; import { AGGREGATE_FUNCTION, SIZE, TIME_GRANULARITY } from '../Utils/constants'; import { constructAdditionalRequestFilters } from '../Utils/FilterBuilder'; -import { - convertValueToUnit, - formatToolTip, - generateCurrentUnit, -} from '../Utils/unitConversion'; +import { generateCurrentUnit } from '../Utils/unitConversion'; import { useAclpPreference } from '../Utils/UserPreference'; import { convertStringToCamelCasesWithSpaces } from '../Utils/utils'; import { CloudPulseAggregateFunction } from './components/CloudPulseAggregateFunction'; @@ -34,7 +29,12 @@ import type { TimeDuration, TimeGranularity, } from '@linode/api-v4'; -import type { DataSet } from 'src/components/LineGraph/LineGraph'; +import type { DataSet } from 'src/components/AreaChart/AreaChart'; +import type { + AreaProps, + ChartVariant, +} from 'src/components/AreaChart/AreaChart'; +import type { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay'; import type { Metrics } from 'src/utilities/statMetrics'; export interface CloudPulseWidgetProperties { @@ -51,7 +51,7 @@ export interface CloudPulseWidgetProperties { /** * token to fetch metrics data */ - authToken: string; + authToken?: string; /** * metrics defined of this widget @@ -68,6 +68,11 @@ export interface CloudPulseWidgetProperties { */ errorLabel?: string; + /** + * Jwe token fetching status check + */ + isJweTokenFetching: boolean; + /** * resources ids selected by user to show metrics for */ @@ -136,6 +141,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { authToken, availableMetrics, duration, + isJweTokenFetching, resourceIds, resources, savePref, @@ -232,17 +238,18 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { }, { authToken, - isFlags: Boolean(flags), + isFlags: Boolean(flags && !isJweTokenFetching), label: widget.label, timeStamp, url: flags.aclpReadEndpoint!, } ); - let data: DataSet[] = []; - let legendRows: LegendRow[] = []; - let today: boolean = false; + let legendRows: MetricsDisplayRow[] = []; + let currentUnit = unit; + let areas: AreaProps[] = []; + const variant: ChartVariant = widget.chart_type; if (!isLoading && metricsList) { const generatedData = generateGraphData({ flags, @@ -252,27 +259,19 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { serviceType, status, unit, - widgetChartType: widget.chart_type, - widgetColor: widget.color, }); data = generatedData.dimensions; - - // add missing timestamps across all the dimensions - const filledArrays = fillMissingTimeStampsAcrossDimensions(...data.map(data => data.data)); - - //update the chart data with updated arrays - filledArrays.forEach((arr, index) => { - data[index].data = arr; - }) - legendRows = generatedData.legendRowsData; - today = generatedData.today; scaledWidgetUnit.current = generatedData.unit; // here state doesn't matter, as this is always the latest re-render + currentUnit = generatedData.unit; + areas = generatedData.areas; } const metricsApiCallError = error?.[0]?.reason; + const tickFormat = + duration.unit === 'min' || duration.unit === 'hr' ? 'hh:mm a' : 'LLL dd'; return ( @@ -333,20 +332,23 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { ? metricsApiCallError ?? 'Error while rendering graph' : undefined } - formatData={(data: number | null) => - data === null ? data : convertValueToUnit(data, scaledWidgetUnit.current) - } - legendRows={ - legendRows && legendRows.length > 0 ? legendRows : undefined - } + loading={ + isLoading || + metricsApiCallError === jweTokenExpiryError || + isJweTokenFetching + } // keep loading until we are trying to fetch the refresh token + areas={areas} ariaLabel={ariaLabel ? ariaLabel : ''} data={data} - formatTooltip={(value: number) => formatToolTip(value, unit)} - gridSize={widget.size} - loading={isLoading || metricsApiCallError === jweTokenExpiryError} // keep loading until we fetch the refresh token - showToday={today} + dotRadius={1.5} + height={424} + legendRows={legendRows} + showDot + showLegend={data.length !== 0} timezone={timezone} - title={widget.label} + unit={currentUnit} + variant={variant} + xAxis={{ tickFormat, tickGap: 60 }} /> diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx index 56d27e950a2..b444754fb46 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx @@ -30,6 +30,7 @@ interface WidgetProps { additionalFilters?: CloudPulseMetricsAdditionalFilters[]; dashboard?: Dashboard | undefined; duration: TimeDuration; + isJweTokenFetching: boolean; jweToken?: JWEToken | undefined; manualRefreshTimeStamp?: number; metricDefinitions: MetricDefinitions | undefined; @@ -55,6 +56,7 @@ export const RenderWidgets = React.memo( additionalFilters, dashboard, duration, + isJweTokenFetching, jweToken, manualRefreshTimeStamp, metricDefinitions, @@ -74,6 +76,7 @@ export const RenderWidgets = React.memo( availableMetrics: undefined, duration, errorLabel: 'Error occurred while loading data.', + isJweTokenFetching: false, resourceIds: resources, resources: [], serviceType: dashboard?.service_type ?? '', @@ -123,7 +126,7 @@ export const RenderWidgets = React.memo( if ( !dashboard.service_type || !Boolean(resources.length > 0) || - !jweToken?.token || + (!isJweTokenFetching && !jweToken?.token) || !Boolean(resourceList?.length) ) { return renderPlaceHolder( @@ -162,6 +165,7 @@ export const RenderWidgets = React.memo( {...cloudPulseWidgetProperties} authToken={jweToken?.token} availableMetrics={availMetrics} + isJweTokenFetching={isJweTokenFetching} resources={resourceList!} savePref={savePref} /> diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.tsx index 2c989971fe3..066e6297f23 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.tsx @@ -1,7 +1,6 @@ +import { Autocomplete } from '@linode/ui'; import React from 'react'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; - import { CloudPulseTooltip } from '../../shared/CloudPulseTooltip'; import { getAutocompleteWidgetStyles } from '../../Utils/CloudPulseWidgetUtils'; import { convertStringToCamelCasesWithSpaces } from '../../Utils/utils'; diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx index e96abfb95b5..d560cb6ec1b 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx @@ -1,7 +1,6 @@ +import { Autocomplete } from '@linode/ui'; import React from 'react'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; - import { CloudPulseTooltip } from '../../shared/CloudPulseTooltip'; import { getAutocompleteWidgetStyles } from '../../Utils/CloudPulseWidgetUtils'; diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx index 5c3ce2fd3fd..d983bb332fb 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx @@ -1,30 +1,26 @@ -import { Box, Typography, useTheme } from '@mui/material'; +import { CircleProgress } from '@linode/ui'; +import { Box, Typography, useMediaQuery, useTheme } from '@mui/material'; import * as React from 'react'; -import { CircleProgress } from 'src/components/CircleProgress'; +import { AreaChart } from 'src/components/AreaChart/AreaChart'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; -import { LineGraph } from 'src/components/LineGraph/LineGraph'; +import { roundTo } from 'src/utilities/roundTo'; -import { isDataEmpty } from '../../Utils/CloudPulseWidgetUtils'; +import type { AreaChartProps } from 'src/components/AreaChart/AreaChart'; -import type { LegendRow } from '../CloudPulseWidget'; -import type { LineGraphProps } from 'src/components/LineGraph/LineGraph'; - -export interface CloudPulseLineGraph extends LineGraphProps { - ariaLabel?: string; +export interface CloudPulseLineGraph extends AreaChartProps { error?: string; - gridSize: number; - legendRows?: LegendRow[]; loading?: boolean; - subtitle?: string; - title: string; } export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { - const { ariaLabel, data, error, legendRows, loading, ...rest } = props; + const { error, loading, ...rest } = props; const theme = useTheme(); + // to reduce the x-axis tick count for small screen + const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); + if (loading) { return ; } @@ -34,7 +30,6 @@ export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { } const noDataMessage = 'No data to display'; - return ( {error ? ( @@ -42,29 +37,29 @@ export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { ) : ( - `${roundTo(value, 3)}`, }} - ariaLabel={ariaLabel} - data={data} - isLegendsFullSize={true} - legendRows={legendRows} + fillOpacity={0.5} + legendHeight="150px" /> )} - {isDataEmpty(data) && ( + {rest.data.length === 0 && ( diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx index 95c413c4adc..98338efb533 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx @@ -1,6 +1,6 @@ +import { Autocomplete } from '@linode/ui'; import React from 'react'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { useGetCustomFiltersQuery } from 'src/queries/cloudpulse/customfilters'; import { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index 4444f942a57..d836b73ec63 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -1,10 +1,10 @@ +import { Button } from '@linode/ui'; import { Grid, Typography, useTheme } from '@mui/material'; import * as React from 'react'; import KeyboardArrowDownIcon from 'src/assets/icons/arrow_down.svg'; import KeyboardArrowRightIcon from 'src/assets/icons/arrow_right.svg'; import InfoIcon from 'src/assets/icons/info.svg'; -import { Button } from 'src/components/Button/Button'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import NullComponent from 'src/components/NullComponent'; @@ -311,7 +311,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( container display={showFilter ? 'flex' : 'none'} item - maxHeight={theme.spacing(22)} + maxHeight={theme.spacing(23)} overflow={'auto'} rowGap={theme.spacing(2)} xs={12} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx index 7cf89192e76..0ba9ba9bb5d 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx @@ -1,8 +1,6 @@ -import { Box } from '@linode/ui'; +import { Autocomplete, Box, Typography } from '@linode/ui'; import React from 'react'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; -import { Typography } from 'src/components/Typography'; import { useCloudPulseDashboardsQuery } from 'src/queries/cloudpulse/dashboards'; import { useCloudPulseServiceTypes } from 'src/queries/cloudpulse/services'; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx index 7249de52a16..d6dc9f0c9e5 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { linodeFactory } from 'src/factories'; @@ -21,6 +22,7 @@ vi.mock('src/queries/cloudpulse/resources', async () => { const mockResourceHandler = vi.fn(); const SELECT_ALL = 'Select All'; const ARIA_SELECTED = 'aria-selected'; +const ARIA_DISABLED = 'aria-disabled'; describe('CloudPulseResourcesSelect component tests', () => { it('should render disabled component if the the props are undefined or regions and service type does not have any resources', () => { @@ -70,8 +72,7 @@ describe('CloudPulseResourcesSelect component tests', () => { }) ).toBeInTheDocument(); }); - - it('should be able to select all resources', () => { + it('should be able to select all resources if resource selection limit is higher than number of resources', () => { queryMocks.useResourcesQuery.mockReturnValue({ data: linodeFactory.buildList(2), isError: false, @@ -252,4 +253,81 @@ describe('CloudPulseResourcesSelect component tests', () => { ); expect(screen.getByText('Failed to fetch Resources.')).toBeInTheDocument(); }); + + it('should be able to select limited resources and select/deselect all will not be available if resource are more than max resource selection limit', async () => { + const user = userEvent.setup(); + + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodeFactory.buildList(12), + isError: false, + isLoading: false, + status: 'success', + }); + + const { queryByRole } = renderWithTheme( + + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + + expect(screen.getByLabelText('Resources')).toBeInTheDocument(); + expect(screen.getByText('Select up to 10 Resources')).toBeInTheDocument(); + + for (let i = 14; i <= 23; i++) { + // eslint-disable-next-line no-await-in-loop + const option = await screen.findByRole('option', { name: `linode-${i}` }); + // eslint-disable-next-line no-await-in-loop + await user.click(option); + } + + const selectedOptions = screen + .getAllByRole('option') + .filter((option) => option.getAttribute(ARIA_SELECTED) === 'true'); + + expect(selectedOptions.length).toBe(10); + + const isResourceWithExceededLimit = await screen.findByRole('option', { + name: 'linode-24', + }); + expect(isResourceWithExceededLimit).toHaveAttribute(ARIA_DISABLED, 'true'); + + expect(queryByRole('option', { name: SELECT_ALL })).not.toBeInTheDocument(); + }); + + it('should be able to select all and deselect all the resources when number of resources are equal to resource limit', async () => { + const user = userEvent.setup(); + + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodeFactory.buildList(10), + isError: false, + isLoading: false, + status: 'success', + }); + + renderWithTheme( + + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + await user.click(screen.getByRole('option', { name: SELECT_ALL })); + await user.click(screen.getByRole('option', { name: 'Deselect All' })); + + expect(screen.getByLabelText('Resources')).toBeInTheDocument(); + + for (let i = 26; i <= 35; i++) { + expect( + screen.getByRole('option', { name: `linode-${i}` }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + } + }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index ee2ac13dc0d..c14d8afe38f 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -1,6 +1,8 @@ +import { Autocomplete, SelectedIcon, StyledListItem } from '@linode/ui'; +import { Box } from '@mui/material'; import React from 'react'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { themes } from 'src/utilities/theme'; @@ -43,23 +45,29 @@ export const CloudPulseResourcesSelect = React.memo( xFilter, } = props; + const flags = useFlags(); + const resourceFilterMap: Record = { - dbaas: { '+order': 'asc', '+order_by': 'label', platform: 'rdbms-default' }, + dbaas: { + '+order': 'asc', + '+order_by': 'label', + platform: 'rdbms-default', + }, }; - const { data: resources, isLoading, isError } = useResourcesQuery( + const { data: resources, isError, isLoading } = useResourcesQuery( disabled !== undefined ? !disabled : Boolean(region && resourceType), resourceType, {}, xFilter ? { - ...(resourceFilterMap[resourceType ?? ''] ?? {}), - ...xFilter, // the usual xFilters - } + ...(resourceFilterMap[resourceType ?? ''] ?? {}), + ...xFilter, // the usual xFilters + } : { - ...(resourceFilterMap[resourceType ?? ''] ?? {}), - region, - } + ...(resourceFilterMap[resourceType ?? ''] ?? {}), + region, + } ); const [selectedResources, setSelectedResources] = React.useState< @@ -77,6 +85,18 @@ export const CloudPulseResourcesSelect = React.memo( return resources && resources.length > 0 ? resources : []; }, [resources]); + // Maximum resource selection limit is fetched from launchdarkly + const maxResourceSelectionLimit = React.useMemo(() => { + const obj = flags.aclpResourceTypeMap?.find( + (item) => item.serviceType === resourceType + ); + return obj?.maxResourceSelections || 10; + }, [resourceType, flags.aclpResourceTypeMap]); + + const resourcesLimitReached = React.useMemo(() => { + return getResourcesList.length > maxResourceSelectionLimit; + }, [getResourcesList.length, maxResourceSelectionLimit]); + // Once the data is loaded, set the state variable with value stored in preferences React.useEffect(() => { if (resources && savePreferences && !selectedResources) { @@ -102,6 +122,9 @@ export const CloudPulseResourcesSelect = React.memo( return ( { setSelectedResources(resourceSelections); @@ -119,11 +142,50 @@ export const CloudPulseResourcesSelect = React.memo( placeholder={ selectedResources?.length ? '' : placeholder || 'Select Resources' } + renderOption={(props, option) => { + // After selecting resources up to the max resource selection limit, rest of the unselected options will be disabled if there are any + const { key, ...rest } = props; + const isResourceSelected = selectedResources?.some( + (item) => item.label === option.label + ); + + const isSelectAllORDeslectAllOption = + option.label === 'Select All ' || option.label === 'Deselect All '; + + const isMaxSelectionsReached = + selectedResources && + selectedResources.length >= maxResourceSelectionLimit && + !isResourceSelected && + !isSelectAllORDeslectAllOption; + + const ListItem = isSelectAllORDeslectAllOption + ? StyledListItem + : 'li'; + + return ( + + <> + {option.label} + + + + ); + }} textFieldProps={{ InputProps: { sx: { + '::-webkit-scrollbar': { + display: 'none', + }, maxHeight: '55px', + msOverflowStyle: 'none', overflow: 'auto', + scrollbarWidth: 'none', svg: { color: themes.light.color.grey3, }, @@ -133,11 +195,12 @@ export const CloudPulseResourcesSelect = React.memo( autoHighlight clearOnBlur data-testid="resource-select" + disableSelectAll={resourcesLimitReached} // Select_All option will not be available if number of resources are higher than resource selection limit disabled={disabled} errorText={isError ? `Failed to fetch ${label || 'Resources'}.` : ''} isOptionEqualToValue={(option, value) => option.id === value.id} label={label || 'Resources'} - limitTags={2} + limitTags={1} loading={isLoading} multiple noMarginTop diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx index c0e74b2d2b6..cd908ba91b8 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx @@ -1,7 +1,6 @@ +import { Autocomplete } from '@linode/ui'; import * as React from 'react'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; - import type { FilterValue, TimeDuration } from '@linode/api-v4'; import type { BaseSelectProps, diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseTooltip.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTooltip.test.tsx index a113c38da40..9b76c08dfa9 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseTooltip.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTooltip.test.tsx @@ -1,6 +1,6 @@ +import { Typography } from '@linode/ui'; import * as React from 'react'; -import { Typography } from 'src/components/Typography'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { CloudPulseTooltip } from './CloudPulseTooltip'; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx index 0bfd67a0b85..4fa6bd6013a 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx @@ -1,21 +1,15 @@ +import { Divider, Typography } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2'; import React from 'react'; -import { Divider } from 'src/components/Divider'; -import Select from 'src/components/EnhancedSelect'; -import { _SingleValue } from 'src/components/EnhancedSelect/components/SingleValue'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; -import { Typography } from 'src/components/Typography'; import { StyledLabelTooltip, StyledTextField, } from 'src/features/Databases/DatabaseCreate/DatabaseCreate.style'; -import { EngineOption } from 'src/features/Databases/DatabaseCreate/EngineOption'; +import { DatabaseEngineSelect } from 'src/features/Databases/DatabaseCreate/DatabaseEngineSelect'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import { getSelectedOptionFromGroupedOptions } from 'src/utilities/getSelectedOptionFromGroupedOptions'; - -import { getEngineOptions } from './utilities'; import type { ClusterSize, @@ -27,7 +21,6 @@ import type { ReplicationCommitTypes, } from '@linode/api-v4'; import type { FormikErrors } from 'formik'; -import type { Item } from 'src/components/EnhancedSelect'; export interface DatabaseCreateValues { allow_list: { address: string; @@ -59,13 +52,6 @@ export const DatabaseClusterData = (props: Props) => { globalGrantType: 'add_databases', }); - const engineOptions = React.useMemo(() => { - if (!engines) { - return []; - } - return getEngineOptions(engines); - }, [engines]); - const labelToolTip = ( Label must: @@ -94,22 +80,11 @@ export const DatabaseClusterData = (props: Props) => { Select Engine and Region - {/* TODO: use Autocomplete instead of Select */} - option.value === 1 - )} - onChange={(e) => { + + option.value === value.value + } + onChange={(_, day) => { setFormTouched(true); - setFieldValue('day_of_week', e.value); - weekSelectionModifier(e.label, weekSelectionMap); - + setFieldValue('day_of_week', day.value); + weekSelectionModifier(day.label, weekSelectionMap); // If week_of_month is not null (i.e., the user has selected a value for "Repeats on" already), // refresh the field value so that the selected option displays the chosen day. if (values.week_of_month) { setFieldValue('week_of_month', values.week_of_month); } }} + renderOption={(props, option) => ( +
  • {option.label}
  • + )} textFieldProps={{ dataAttrs: { 'data-qa-weekday-select': true, @@ -226,12 +231,11 @@ export const MaintenanceWindow = (props: Props) => { value={daySelectionMap.find( (thisOption) => thisOption.value === values.day_of_week )} + autoHighlight + disableClearable disabled={disabled} errorText={touched.day_of_week ? errors.day_of_week : undefined} - isClearable={false} label="Day of Week" - menuPlacement="top" - name="Day of Week" noMarginTop options={daySelectionMap} placeholder="Choose a day" @@ -239,17 +243,20 @@ export const MaintenanceWindow = (props: Props) => {
    - { + onChange={(_, week) => { setFormTouched(true); - setFieldValue('week_of_month', e.value); + setFieldValue('week_of_month', week?.value); }} + renderOption={(props, option) => ( +
  • {option.label}
  • + )} textFieldProps={{ dataAttrs: { 'data-qa-week-in-month-select': true, @@ -347,11 +356,10 @@ export const MaintenanceWindow = (props: Props) => { value={modifiedWeekSelectionMap.find( (thisOption) => thisOption.value === values.week_of_month )} + autoHighlight defaultValue={modifiedWeekSelectionMap[0]} - isClearable={false} + disableClearable label="Repeats on" - menuPlacement="top" - name="Repeats on" noMarginTop options={modifiedWeekSelectionMap} placeholder="Repeats on" @@ -399,8 +407,8 @@ const daySelectionMap = [ const hourSelectionMap = [ { label: '00:00', value: 0 }, - { label: '01:00', value: 2 }, - { label: '02:00', value: 1 }, + { label: '01:00', value: 1 }, + { label: '02:00', value: 2 }, { label: '03:00', value: 3 }, { label: '04:00', value: 4 }, { label: '05:00', value: 5 }, diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx index d2907c5360b..5801fa42f61 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx @@ -1,7 +1,7 @@ +import { Typography } from '@linode/ui'; import React from 'react'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; -import { Typography } from 'src/components/Typography'; import { capitalize } from 'src/utilities/capitalize'; import type { Event } from '@linode/api-v4'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.test.tsx index 9d1251d312b..859fffcd0e9 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.test.tsx @@ -11,8 +11,8 @@ import { DatabaseSummary } from './DatabaseSummary'; import type { Database } from '@linode/api-v4'; const CLUSTER_CONFIGURATION = 'Cluster Configuration'; -const THREE_NODE = 'Primary +2 replicas'; -const TWO_NODE = 'Primary +1 replicas'; +const THREE_NODE = 'Primary (+2 Nodes)'; +const TWO_NODE = 'Primary (+1 Node)'; const VERSION = 'Version'; const CONNECTION_DETAILS = 'Connection Details'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx index 2e2768e6c05..b2dc91795f5 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx @@ -1,10 +1,8 @@ +import { Divider, Paper, Typography } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; -import { Divider } from 'src/components/Divider'; import { Link } from 'src/components/Link'; -import { Paper } from '@linode/ui'; -import { Typography } from 'src/components/Typography'; import AccessControls from 'src/features/Databases/DatabaseDetail/AccessControls'; import ClusterConfiguration from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration'; import ConnectionDetails from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts index a187dac8e51..fc98493f2d2 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts @@ -1,8 +1,7 @@ +import { Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import Grid2 from '@mui/material/Unstable_Grid2/Grid2'; -import { Typography } from 'src/components/Typography'; - export const StyledGridContainer = styled(Grid2, { label: 'StyledGridContainer', })(({ theme }) => ({ diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx index 94231d3d897..1131b04d64e 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx @@ -11,7 +11,7 @@ import type { Database, DatabaseStatus } from '@linode/api-v4/lib/databases'; const STATUS_VALUE = 'Active'; const PLAN_VALUE = 'New DBaaS - Dedicated 8 GB'; -const NODES_VALUE = 'Primary +1 replicas'; +const NODES_VALUE = 'Primary (+1 Node)'; const REGION_ID = 'us-east'; const REGION_LABEL = 'Newark, NJ'; @@ -150,7 +150,7 @@ describe('DatabaseSummaryClusterConfiguration', () => { expect(queryAllByText('Nanode 1 GB')).toHaveLength(1); expect(queryAllByText('Nodes')).toHaveLength(1); - expect(queryAllByText('Primary')).toHaveLength(1); + expect(queryAllByText('Primary (1 Node)')).toHaveLength(1); expect(queryAllByText('CPUs')).toHaveLength(1); expect(queryAllByText(1)).toHaveLength(1); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx index 54b733ed209..8420ffc1c3d 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx @@ -1,9 +1,8 @@ +import { TooltipIcon, Typography } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2/Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { TooltipIcon } from 'src/components/TooltipIcon'; -import { Typography } from 'src/components/Typography'; import { DatabaseStatusDisplay } from 'src/features/Databases/DatabaseDetail/DatabaseStatusDisplay'; import { StyledGridContainer, @@ -56,8 +55,10 @@ export const DatabaseSummaryClusterConfiguration = (props: Props) => { const configuration = database.cluster_size === 1 - ? 'Primary' - : `Primary +${database.cluster_size - 1} replicas`; + ? 'Primary (1 Node)' + : database.cluster_size > 2 + ? `Primary (+${database.cluster_size - 1} Nodes)` + : `Primary (+${database.cluster_size - 1} Node)`; const sxTooltipIcon = { marginLeft: '4px', @@ -72,26 +73,26 @@ export const DatabaseSummaryClusterConfiguration = (props: Props) => { Cluster Configuration - + Status - + - + Plan - + {formatStorageUnits(type.label)} Nodes - + {configuration} - + CPUs @@ -100,7 +101,7 @@ export const DatabaseSummaryClusterConfiguration = (props: Props) => { Engine - + { databaseVersion={database.version} /> - + Region - + {region?.label ?? database.region} RAM - + {type.memory / 1024} GB - + {database.total_disk_size_gb ? 'Total Disk Size' : 'Storage'} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts index a61db9661f8..582873cb1a6 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts @@ -19,7 +19,7 @@ export const useStyles = makeStyles()((theme: Theme) => ({ }, '&[disabled]': { '& g': { - stroke: '#cdd0d5', + stroke: theme.tokens.color.Neutrals[30], }, '&:hover': { backgroundColor: 'inherit', @@ -27,7 +27,7 @@ export const useStyles = makeStyles()((theme: Theme) => ({ }, // Override disabled background color defined for dark mode backgroundColor: 'transparent', - color: '#cdd0d5', + color: theme.tokens.color.Neutrals[30], cursor: 'default', }, color: theme.palette.primary.main, @@ -47,7 +47,11 @@ export const useStyles = makeStyles()((theme: Theme) => ({ fontFamily: theme.font.bold, }, background: theme.tokens.interaction.Background.Secondary, - border: `1px solid ${theme.name === 'light' ? '#ccc' : '#222'}`, + border: `1px solid ${ + theme.name === 'light' + ? theme.tokens.color.Neutrals[40] + : theme.tokens.color.Neutrals.Black + }`, padding: `${theme.spacing(1)} 15px`, }, copyToolTip: { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.test.tsx index 7b0e11c7009..9643fe9a5b0 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.test.tsx @@ -77,6 +77,27 @@ describe('DatabaseSummaryConnectionDetails', () => { }); }); + it('should display N/A for default DB with blank read-only Host field', async () => { + const database = databaseFactory.build({ + engine: POSTGRESQL, + hosts: { + primary: DEFAULT_PRIMARY, + secondary: undefined, + standby: undefined, + }, + id: 99, + platform: 'rdbms-default', + port: 22496, + ssl_connection: true, + }); + + const { queryAllByText } = renderWithTheme( + + ); + + expect(queryAllByText('N/A')).toHaveLength(1); + }); + it('should display correctly for legacy db', async () => { queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ data: { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx index 9ccfea88542..c077387da22 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx @@ -1,14 +1,11 @@ import { getSSLFields } from '@linode/api-v4/lib/databases/databases'; +import { Button, CircleProgress, TooltipIcon, Typography } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2/Grid2'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import DownloadIcon from 'src/assets/icons/lke-download.svg'; -import { Button } from 'src/components/Button/Button'; -import { CircleProgress } from 'src/components/CircleProgress'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; -import { TooltipIcon } from 'src/components/TooltipIcon'; -import { Typography } from 'src/components/Typography'; import { DB_ROOT_USERNAME } from 'src/constants'; import { useDatabaseCredentialsQuery } from 'src/queries/databases/databases'; import { downloadFile } from 'src/utilities/downloadFile'; @@ -116,16 +113,14 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { database?.hosts?.standby ?? database?.hosts?.secondary ?? ''; const readOnlyHost = () => { - const defaultValue = isLegacy ? '-' : 'not available'; - const value = readOnlyHostValue ?? defaultValue; + const defaultValue = isLegacy ? '-' : 'N/A'; + const value = readOnlyHostValue ? readOnlyHostValue : defaultValue; + const hasHost = value !== '-' && value !== 'N/A'; return ( <> {value} - {value && ( - + {value && hasHost && ( + )} {isLegacy && ( { text={privateHostCopy} /> )} + {!isLegacy && hasHost && ( + + )} ); }; @@ -225,6 +228,12 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { /> )} + + Database name + + + {isLegacy ? database.engine : 'defaultdb'} + Host @@ -238,9 +247,9 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { /> {!isLegacy && ( )} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx index dedbefe5070..db0c93946ce 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx @@ -1,9 +1,7 @@ -import { Box } from '@linode/ui'; +import { Box, TooltipIcon, Typography } from '@linode/ui'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { TooltipIcon } from 'src/components/TooltipIcon'; -import { Typography } from 'src/components/Typography'; import { DatabaseStatusDisplay } from 'src/features/Databases/DatabaseDetail/DatabaseStatusDisplay'; import { DatabaseEngineVersion } from 'src/features/Databases/DatabaseEngineVersion'; import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; @@ -69,8 +67,10 @@ export const DatabaseSummaryClusterConfigurationLegacy = (props: Props) => { const configuration = database.cluster_size === 1 - ? 'Primary' - : `Primary +${database.cluster_size - 1} replicas`; + ? 'Primary (1 Node)' + : database.cluster_size > 2 + ? `Primary (+${database.cluster_size - 1} Nodes)` + : `Primary (+${database.cluster_size - 1} Node)`; const sxTooltipIcon = { marginLeft: '4px', diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx index 2f9288bddd3..e9660172ab2 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx @@ -1,16 +1,18 @@ import { getSSLFields } from '@linode/api-v4/lib/databases/databases'; -import { Box } from '@linode/ui'; +import { + Box, + Button, + CircleProgress, + TooltipIcon, + Typography, +} from '@linode/ui'; import { useTheme } from '@mui/material'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import DownloadIcon from 'src/assets/icons/lke-download.svg'; -import { Button } from 'src/components/Button/Button'; -import { CircleProgress } from 'src/components/CircleProgress'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; -import { TooltipIcon } from 'src/components/TooltipIcon'; -import { Typography } from 'src/components/Typography'; import { DB_ROOT_USERNAME } from 'src/constants'; import { useDatabaseCredentialsQuery } from 'src/queries/databases/databases'; import { downloadFile } from 'src/utilities/downloadFile'; @@ -36,7 +38,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, '&[disabled]': { '& g': { - stroke: '#cdd0d5', + stroke: theme.tokens.color.Neutrals[30], }, '&:hover': { backgroundColor: 'inherit', @@ -44,7 +46,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, // Override disabled background color defined for dark mode backgroundColor: 'transparent', - color: '#cdd0d5', + color: theme.tokens.color.Neutrals[30], cursor: 'default', }, color: theme.palette.primary.main, @@ -64,7 +66,11 @@ const useStyles = makeStyles()((theme: Theme) => ({ fontFamily: theme.font.bold, }, background: theme.bg.bgAccessRowTransparentGradient, - border: `1px solid ${theme.name === 'light' ? '#ccc' : '#222'}`, + border: `1px solid ${ + theme.name === 'light' + ? theme.tokens.color.Neutrals[40] + : theme.tokens.color.Neutrals.Black + }`, padding: '8px 15px', }, copyToolTip: { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx index b8d125b0da4..367dbb113b4 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx @@ -1,13 +1,12 @@ +import { CircleProgress, Notice } from '@linode/ui'; import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; import { matchPath, useHistory, useParams } from 'react-router-dom'; import { BetaChip } from 'src/components/BetaChip/BetaChip'; -import { CircleProgress } from 'src/components/CircleProgress'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; -import { Notice } from 'src/components/Notice/Notice'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabLinkList } from 'src/components/Tabs/TabLinkList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseActionMenu.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseActionMenu.tsx index 0c23b42fe17..439cca75acc 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseActionMenu.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseActionMenu.tsx @@ -1,21 +1,22 @@ +import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { useResumeDatabaseMutation } from 'src/queries/databases/databases'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import { useIsDatabasesEnabled } from '../utilities'; import type { DatabaseStatus, Engine } from '@linode/api-v4'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; -import { useIsDatabasesEnabled } from '../utilities'; -import { useResumeDatabaseMutation } from 'src/queries/databases/databases'; -import { enqueueSnackbar } from 'notistack'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; interface Props { databaseEngine: Engine; databaseId: number; databaseLabel: string; - handlers: ActionHandlers; databaseStatus: DatabaseStatus; + handlers: ActionHandlers; } export interface ActionHandlers { @@ -30,8 +31,8 @@ export const DatabaseActionMenu = (props: Props) => { databaseEngine, databaseId, databaseLabel, - handlers, databaseStatus, + handlers, } = props; const { isDatabasesV2GA } = useIsDatabasesEnabled(); @@ -64,17 +65,17 @@ export const DatabaseActionMenu = (props: Props) => { const actions: Action[] = [ { - disabled: isDatabaseNotRunning, + disabled: isDatabaseNotRunning || isDatabaseSuspended, onClick: handlers.handleManageAccessControls, title: 'Manage Access Controls', }, { - disabled: isDatabaseNotRunning, + disabled: isDatabaseNotRunning || isDatabaseSuspended, onClick: handlers.handleResetPassword, title: 'Reset Root Password', }, { - disabled: isDatabaseNotRunning, + disabled: isDatabaseNotRunning || isDatabaseSuspended, onClick: () => { history.push({ pathname: `/databases/${databaseEngine}/${databaseId}/resize`, diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx index 47e5542387b..a459645c303 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx @@ -1,9 +1,9 @@ +import { CircleProgress } from '@linode/ui'; import { Box } from '@mui/material'; import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; @@ -37,8 +37,8 @@ const DatabaseLanding = () => { const { isDatabasesV2Enabled, - isUserExistingBeta, isDatabasesV2GA, + isUserExistingBeta, isUserNewBeta, } = useIsDatabasesEnabled(); @@ -138,12 +138,16 @@ const DatabaseLanding = () => { const showTabs = isV2Enabled && !!legacyDatabases?.data.length; const isNewDatabase = isV2Enabled && !!newDatabases?.data.length; const showSuspend = isDatabasesV2GA && !!newDatabases?.data.length; + const docsLink = isV2Enabled + ? 'https://techdocs.akamai.com/cloud-computing/docs/aiven-database-clusters' + : 'https://techdocs.akamai.com/cloud-computing/docs/managed-databases'; const legacyTable = () => { return ( @@ -153,6 +157,7 @@ const DatabaseLanding = () => { const defaultTable = () => { return ( { }} createButtonText="Create Database Cluster" disabledCreateButton={isRestricted} - docsLink="https://techdocs.akamai.com/cloud-computing/docs/managed-databases" + docsLink={docsLink} onButtonClick={() => history.push('/databases/create')} title="Database Clusters" /> diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingEmptyStateData.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingEmptyStateData.tsx index a27baa54d92..e1564b0c314 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingEmptyStateData.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingEmptyStateData.tsx @@ -22,17 +22,18 @@ export const gettingStartedGuides: ResourcesLinkSection = { links: [ { text: 'Overview of Managed Databases', - to: 'https://techdocs.akamai.com/cloud-computing/docs/managed-databases', + to: + 'https://techdocs.akamai.com/cloud-computing/docs/aiven-database-clusters', }, { text: 'Get Started with Managed Databases', to: - 'https://techdocs.akamai.com/cloud-computing/docs/getting-started-with-managed-databases', + 'https://techdocs.akamai.com/cloud-computing/docs/get-started-new-clusters', }, { text: 'Choosing a Database Engine', to: - 'https://techdocs.akamai.com/cloud-computing/docs/database-engines-and-plans', + 'https://techdocs.akamai.com/cloud-computing/docs/aiven-database-engines', }, ], moreInfo: { diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx index 37d8ffa67bc..6ebf42f0ff4 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx @@ -31,6 +31,7 @@ interface Props { order: 'asc' | 'desc'; orderBy: string; showSuspend?: boolean; + results: number | undefined; } const DatabaseLandingTable = ({ data, @@ -38,6 +39,7 @@ const DatabaseLandingTable = ({ isNewDatabase, order, orderBy, + results, showSuspend, }: Props) => { const { data: events } = useInProgressEvents(); @@ -190,7 +192,7 @@ const DatabaseLandingTable = ({