From b274baf67e27d79fd4e764607ded7c5aa755ee8b Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 19 Jun 2024 09:06:48 -0500 Subject: [PATCH 01/35] unit test coverage for HostNameTableCell --- .../AccessKeyTable/HostNameTableCell.test.tsx | 115 ++++++++++++++++++ .../AccessKeyTable/HostNameTableCell.tsx | 17 +-- 2 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx new file mode 100644 index 00000000000..92c072ca4e9 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx @@ -0,0 +1,115 @@ +import '@testing-library/jest-dom'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; + +import { regionFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { HostNameTableCell } from './HostNameTableCell'; + +const storageKeyData = { + access_key: 'test_key', + bucket_access: null, + id: 12345, + label: 'this is regular key', + limited: false, + regions: [ + { + id: 'us-east', + s3_endpoint: 'alpha.test.com', + }, + ], + secret_key: '[test]', +}; + +describe('HostNameTableCell', () => { + it('should render "None" when there are no regions', () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + + expect(getByText('None')).toBeInTheDocument(); + }); + + test('should render "Regions/S3 Hostnames" cell when there are regions', async () => { + const region = regionFactory.build({ + capabilities: ['Object Storage'], + id: 'us-east', + label: 'Newark, NJ', + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + const { findByText } = renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + + const hostname = await findByText('Newark, NJ: alpha.test.com'); + + await waitFor(() => expect(hostname).toBeInTheDocument()); + }); + test('should render all "Regions/S3 Hostnames" in the cell when there are multiple regions', async () => { + const region = regionFactory.build({ + capabilities: ['Object Storage'], + id: 'us-east', + label: 'Newark, NJ', + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + const { findByText } = renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + const hostname = await findByText('Newark, NJ: alpha.test.com'); + const moreButton = await findByText(/and\s+1\s+more\.\.\./); + await waitFor(() => expect(hostname).toBeInTheDocument()); + + await expect(moreButton).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx index e5fb3ce88db..644e2558d5d 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx @@ -1,7 +1,3 @@ -import { - ObjectStorageKey, - RegionS3EndpointAndID, -} from '@linode/api-v4/lib/object-storage'; import { styled } from '@mui/material/styles'; import React from 'react'; @@ -11,6 +7,11 @@ import { TableCell } from 'src/components/TableCell'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { getRegionsByRegionId } from 'src/utilities/regions'; +import type { + ObjectStorageKey, + RegionS3EndpointAndID, +} from '@linode/api-v4/lib/object-storage'; + type Props = { setHostNames: (hostNames: RegionS3EndpointAndID[]) => void; setShowHostNamesDrawers: (show: boolean) => void; @@ -31,14 +32,14 @@ export const HostNameTableCell = ({ if (!regionsLookup || !regionsData || !regions || regions.length === 0) { return None; } + const label = regionsLookup[storageKeyData.regions[0].id]?.label; + const s3endPoint = storageKeyData?.regions[0]?.s3_endpoint; return ( - {`${regionsLookup[storageKeyData.regions[0].id].label}: ${ - storageKeyData?.regions[0]?.s3_endpoint - } `} + {`${label}: ${s3endPoint} `} {storageKeyData?.regions?.length === 1 && ( - + )} {storageKeyData.regions.length > 1 && ( Date: Wed, 19 Jun 2024 09:09:10 -0500 Subject: [PATCH 02/35] Revert "unit test coverage for HostNameTableCell" This reverts commit b274baf67e27d79fd4e764607ded7c5aa755ee8b. --- .../AccessKeyTable/HostNameTableCell.test.tsx | 115 ------------------ .../AccessKeyTable/HostNameTableCell.tsx | 17 ++- 2 files changed, 8 insertions(+), 124 deletions(-) delete mode 100644 packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx deleted file mode 100644 index 92c072ca4e9..00000000000 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import '@testing-library/jest-dom'; -import { waitFor } from '@testing-library/react'; -import React from 'react'; - -import { regionFactory } from 'src/factories'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { HttpResponse, http, server } from 'src/mocks/testServer'; -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; - -import { HostNameTableCell } from './HostNameTableCell'; - -const storageKeyData = { - access_key: 'test_key', - bucket_access: null, - id: 12345, - label: 'this is regular key', - limited: false, - regions: [ - { - id: 'us-east', - s3_endpoint: 'alpha.test.com', - }, - ], - secret_key: '[test]', -}; - -describe('HostNameTableCell', () => { - it('should render "None" when there are no regions', () => { - const { getByText } = renderWithThemeAndHookFormContext({ - component: ( - - ), - }); - - expect(getByText('None')).toBeInTheDocument(); - }); - - test('should render "Regions/S3 Hostnames" cell when there are regions', async () => { - const region = regionFactory.build({ - capabilities: ['Object Storage'], - id: 'us-east', - label: 'Newark, NJ', - }); - - server.use( - http.get('*/v4/regions', () => { - return HttpResponse.json(makeResourcePage([region])); - }) - ); - const { findByText } = renderWithThemeAndHookFormContext({ - component: ( - - ), - }); - - const hostname = await findByText('Newark, NJ: alpha.test.com'); - - await waitFor(() => expect(hostname).toBeInTheDocument()); - }); - test('should render all "Regions/S3 Hostnames" in the cell when there are multiple regions', async () => { - const region = regionFactory.build({ - capabilities: ['Object Storage'], - id: 'us-east', - label: 'Newark, NJ', - }); - - server.use( - http.get('*/v4/regions', () => { - return HttpResponse.json(makeResourcePage([region])); - }) - ); - const { findByText } = renderWithThemeAndHookFormContext({ - component: ( - - ), - }); - const hostname = await findByText('Newark, NJ: alpha.test.com'); - const moreButton = await findByText(/and\s+1\s+more\.\.\./); - await waitFor(() => expect(hostname).toBeInTheDocument()); - - await expect(moreButton).toBeInTheDocument(); - }); -}); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx index 644e2558d5d..e5fb3ce88db 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx @@ -1,3 +1,7 @@ +import { + ObjectStorageKey, + RegionS3EndpointAndID, +} from '@linode/api-v4/lib/object-storage'; import { styled } from '@mui/material/styles'; import React from 'react'; @@ -7,11 +11,6 @@ import { TableCell } from 'src/components/TableCell'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { getRegionsByRegionId } from 'src/utilities/regions'; -import type { - ObjectStorageKey, - RegionS3EndpointAndID, -} from '@linode/api-v4/lib/object-storage'; - type Props = { setHostNames: (hostNames: RegionS3EndpointAndID[]) => void; setShowHostNamesDrawers: (show: boolean) => void; @@ -32,14 +31,14 @@ export const HostNameTableCell = ({ if (!regionsLookup || !regionsData || !regions || regions.length === 0) { return None; } - const label = regionsLookup[storageKeyData.regions[0].id]?.label; - const s3endPoint = storageKeyData?.regions[0]?.s3_endpoint; return ( - {`${label}: ${s3endPoint} `} + {`${regionsLookup[storageKeyData.regions[0].id].label}: ${ + storageKeyData?.regions[0]?.s3_endpoint + } `} {storageKeyData?.regions?.length === 1 && ( - + )} {storageKeyData.regions.length > 1 && ( Date: Thu, 26 Sep 2024 14:38:58 -0400 Subject: [PATCH 03/35] chore: [M3-8662] - Update Github Actions actions (#11009) * update actions * add changeset --------- Co-authored-by: Banks Nussman --- .github/workflows/ci.yml | 161 ++++++++---------- .github/workflows/coverage.yml | 16 +- .github/workflows/coverage_badge.yml | 6 +- .github/workflows/coverage_comment.yml | 4 +- .github/workflows/docs.yml | 10 +- .github/workflows/e2e_schedule_and_push.yml | 8 +- .github/workflows/security_scan.yml | 2 +- .../pr-11009-tech-stories-1727310949305.md | 5 + 8 files changed, 102 insertions(+), 110 deletions(-) create mode 100644 packages/manager/.changeset/pr-11009-tech-stories-1727310949305.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 014410c88cb..962897a5748 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,11 +15,11 @@ jobs: package: ["linode-manager", "@linode/api-v4", "@linode/validation"] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | **/node_modules @@ -30,35 +30,32 @@ jobs: build-validation: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | **/node_modules key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - run: yarn --frozen-lockfile - run: yarn workspace @linode/validation run build - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: packages-validation-lib - path: | - packages/validation/index.js - packages/validation/lib + path: packages/validation/lib publish-validation: runs-on: ubuntu-latest if: github.ref == 'refs/heads/master' - needs: - - build-validation + needs: build-validation steps: - - uses: actions/checkout@v2 - - uses: actions/download-artifact@v2 + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: name: packages-validation-lib - path: packages/validation + path: packages/validation/lib - uses: JS-DevTools/npm-publish@v1 id: npm-publish with: @@ -80,69 +77,64 @@ jobs: runs-on: ubuntu-latest needs: build-validation steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | **/node_modules key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - run: yarn --frozen-lockfile - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: packages-validation-lib - path: packages/validation + path: packages/validation/lib - run: yarn workspace @linode/api-v4 run test build-sdk: runs-on: ubuntu-latest - needs: - - build-validation + needs: build-validation steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | **/node_modules key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: packages-validation-lib - path: packages/validation + path: packages/validation/lib - run: yarn --frozen-lockfile - run: yarn workspace @linode/api-v4 run build - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: packages-api-v4-lib - path: | - packages/api-v4/index.js - packages/api-v4/index.node.js - packages/api-v4/lib + path: packages/api-v4/lib validate-sdk: runs-on: ubuntu-latest - needs: - - build-sdk + needs: build-sdk steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: "20.17" # Download the validation and api-v4 artifacts (built packages) - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: packages-validation-lib - path: packages/validation - - uses: actions/download-artifact@v3 + path: packages/validation/lib + - uses: actions/download-artifact@v4 with: name: packages-api-v4-lib - path: packages/api-v4 + path: packages/api-v4/lib # Create an api-v4 tarball - run: cd packages/api-v4 && npm pack --pack-destination ../../ @@ -162,37 +154,36 @@ jobs: test-manager: runs-on: ubuntu-latest - needs: - - build-sdk + needs: build-sdk steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | **/node_modules key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: packages-validation-lib - path: packages/validation - - uses: actions/download-artifact@v3 + path: packages/validation/lib + - uses: actions/download-artifact@v4 with: name: packages-api-v4-lib - path: packages/api-v4 + path: packages/api-v4/lib - run: yarn --frozen-lockfile - run: yarn workspace linode-manager run test test-search: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | **/node_modules @@ -202,55 +193,53 @@ jobs: typecheck-manager: runs-on: ubuntu-latest - needs: - - build-sdk + needs: build-sdk steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | **/node_modules key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: packages-validation-lib - path: packages/validation - - uses: actions/download-artifact@v3 + path: packages/validation/lib + - uses: actions/download-artifact@v4 with: name: packages-api-v4-lib - path: packages/api-v4 + path: packages/api-v4/lib - run: yarn --frozen-lockfile - run: yarn workspace linode-manager run typecheck build-manager: runs-on: ubuntu-latest if: github.ref == 'refs/heads/master' - needs: - - build-sdk + needs: build-sdk steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | **/node_modules key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: packages-validation-lib - path: packages/validation + path: packages/validation/lib - uses: actions/download-artifact@v3 with: name: packages-api-v4-lib - path: packages/api-v4 + path: packages/api-v4/lib - run: yarn --frozen-lockfile - run: yarn workspace linode-manager run build - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: packages-manager-build path: packages/manager/build @@ -264,11 +253,11 @@ jobs: # If the validation publish failed we could have mismatched versions and a broken JS client - publish-validation steps: - - uses: actions/checkout@v3 - - uses: actions/download-artifact@v3 + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: name: packages-api-v4-lib - path: packages/api-v4 + path: packages/api-v4/lib - uses: JS-DevTools/npm-publish@v1 id: npm-publish with: @@ -288,31 +277,30 @@ jobs: build-storybook: runs-on: ubuntu-latest - needs: - - build-sdk + needs: build-sdk env: NODE_OPTIONS: --max-old-space-size=4096 steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | **/node_modules key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: packages-validation-lib - path: packages/validation - - uses: actions/download-artifact@v3 + path: packages/validation/lib + - uses: actions/download-artifact@v4 with: name: packages-api-v4-lib - path: packages/api-v4 + path: packages/api-v4/lib - run: yarn --frozen-lockfile - run: yarn workspace linode-manager run build-storybook - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: storybook-build path: packages/manager/storybook-static @@ -320,11 +308,10 @@ jobs: publish-storybook: runs-on: ubuntu-latest if: github.ref == 'refs/heads/master' - needs: - - build-storybook + needs: build-storybook steps: - - uses: actions/checkout@v3 - - uses: actions/download-artifact@v3 + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: name: storybook-build path: storybook/build diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 84399b8323f..144f802594d 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -7,16 +7,16 @@ jobs: if: github.event.pull_request.draft == false runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: ${{ github.base_ref }} # The base branch of the PR (develop) - name: Use Node.js v20.17 LTS - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | **/node_modules @@ -52,14 +52,14 @@ jobs: needs: base_branch steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js v20.17 LTS - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | **/node_modules @@ -88,13 +88,13 @@ jobs: echo "$pct" > current_code_coverage.txt - name: Upload PR Number Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: pr_number path: pr_number.txt - name: Upload Current Coverage Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: current_code_coverage path: current_code_coverage.txt diff --git a/.github/workflows/coverage_badge.yml b/.github/workflows/coverage_badge.yml index 0a73b196138..ca07bfd7f27 100644 --- a/.github/workflows/coverage_badge.yml +++ b/.github/workflows/coverage_badge.yml @@ -11,14 +11,14 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Use Node.js v20.17 LTS - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | **/node_modules diff --git a/.github/workflows/coverage_comment.yml b/.github/workflows/coverage_comment.yml index 07fe3682a9b..8ddd29c1ce6 100644 --- a/.github/workflows/coverage_comment.yml +++ b/.github/workflows/coverage_comment.yml @@ -15,10 +15,10 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Use Node.js v20.17 LTS - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: "20.17" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0f73e3e58b6..4a81cf374f9 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -13,12 +13,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Pages - uses: actions/configure-pages@v3 + uses: actions/configure-pages@v5 - - uses: oven-sh/setup-bun@v1 + - uses: oven-sh/setup-bun@v2 with: bun-version: 1.0.21 @@ -26,7 +26,7 @@ jobs: run: bunx vitepress@1.0.0-rc.35 build docs - name: Upload artifact - uses: actions/upload-pages-artifact@v2 + uses: actions/upload-pages-artifact@v3 with: path: docs/.vitepress/dist @@ -40,4 +40,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v2 \ No newline at end of file + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/e2e_schedule_and_push.yml b/.github/workflows/e2e_schedule_and_push.yml index 61176d8fb15..63578c1a25c 100644 --- a/.github/workflows/e2e_schedule_and_push.yml +++ b/.github/workflows/e2e_schedule_and_push.yml @@ -26,11 +26,11 @@ jobs: steps: - name: install command line utilities run: sudo apt-get install -y expect - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | node_modules @@ -60,7 +60,7 @@ jobs: yarn build yarn start:manager:ci & - name: Run tests - uses: cypress-io/github-action@v5 + uses: cypress-io/github-action@v6 with: working-directory: packages/manager wait-on: "http://localhost:3000" diff --git a/.github/workflows/security_scan.yml b/.github/workflows/security_scan.yml index a7cb1b14fbe..37ed12a241f 100644 --- a/.github/workflows/security_scan.yml +++ b/.github/workflows/security_scan.yml @@ -12,7 +12,7 @@ jobs: container: image: returntocorp/semgrep steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 # Perform scanning using Semgrep # Pass even when it identifies issues or encounters errors. diff --git a/packages/manager/.changeset/pr-11009-tech-stories-1727310949305.md b/packages/manager/.changeset/pr-11009-tech-stories-1727310949305.md new file mode 100644 index 00000000000..4ad6da2dfc5 --- /dev/null +++ b/packages/manager/.changeset/pr-11009-tech-stories-1727310949305.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Update Github Actions actions ([#11009](https://github.com/linode/manager/pull/11009)) From 4e25a942a09fef0e1ab56ea976ed8d6a8e39aef9 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 23 Oct 2024 11:37:28 -0500 Subject: [PATCH 04/35] Basci date picker component --- .../src/components/DatePicker/DatePicker.tsx | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 packages/manager/src/components/DatePicker/DatePicker.tsx diff --git a/packages/manager/src/components/DatePicker/DatePicker.tsx b/packages/manager/src/components/DatePicker/DatePicker.tsx new file mode 100644 index 00000000000..9a1aa35f26d --- /dev/null +++ b/packages/manager/src/components/DatePicker/DatePicker.tsx @@ -0,0 +1,81 @@ +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { DatePicker as MuiDatePicker } from '@mui/x-date-pickers/DatePicker'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import React, { useState } from 'react'; + +import { TextField } from 'src/components/TextField'; + +import type { DateTime } from 'luxon'; +import type { TextFieldProps } from 'src/components/TextField'; + +interface DatePickerProps { + /** If true, disables selecting future dates. Defaults to false. */ + disableFuture?: boolean; + /** If true, disables selecting past dates. Defaults to false. */ + disablePast?: boolean; + /** Error text to display below the input */ + errorText?: string; + /** Helper text to display below the input */ + helperText?: string; + /** Label to display for the date picker input */ + label?: string; + /** Callback function fired when the value changes */ + onChange?: (newDate: DateTime | null) => void; + /** Placeholder text for the date picker input */ + placeholder?: string; + /** Additional props to pass to the underlying TextField component */ + textFieldProps?: Omit; + /** The currently selected date */ + value?: DateTime | null; +} + +export const DatePicker = ({ + disableFuture = false, + disablePast = false, + errorText, + helperText, + label = 'Select a date', + onChange, + placeholder = 'Select a date', + textFieldProps, + value, + ...props +}: DatePickerProps) => { + const [selectedDate, setSelectedDate] = useState( + value || null + ); + + const onChangeHandler = (newDate: DateTime | null) => { + setSelectedDate(newDate); + if (onChange) { + onChange(newDate); + } + }; + + return ( + + ( + + ), + }, + }} + /> + + ); +}; From 423f4b7d1643c4d0a91ca4932f3ac40765d45f59 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Fri, 25 Oct 2024 14:19:53 -0500 Subject: [PATCH 05/35] Test coverage for date picker component --- .../components/DatePicker/DatePicker.test.tsx | 80 +++++++++++++++++++ .../src/components/DatePicker/DatePicker.tsx | 53 ++++++------ 2 files changed, 103 insertions(+), 30 deletions(-) create mode 100644 packages/manager/src/components/DatePicker/DatePicker.test.tsx diff --git a/packages/manager/src/components/DatePicker/DatePicker.test.tsx b/packages/manager/src/components/DatePicker/DatePicker.test.tsx new file mode 100644 index 00000000000..e051d160ec5 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DatePicker.test.tsx @@ -0,0 +1,80 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DatePicker } from './DatePicker'; + +import type { DatePickerProps } from './DatePicker'; + +const props: DatePickerProps = { + onChange: vi.fn(), + placeholder: 'Pick a date', + textFieldProps: { errorText: 'Invalid date', label: 'Select a date' }, + value: null, +}; + +describe('DatePicker', () => { + it('should render the DatePicker component', () => { + renderWithTheme(); + const DatePickerField = screen.getByRole('textbox', { + name: 'Select a date', + }); + + expect(DatePickerField).toBeVisible(); + }); + + it('should handle value changes', async () => { + renderWithTheme(); + + const calendarButton = screen.getByRole('button', { name: 'Choose date' }); + + // Click the calendar button to open the date picker + await userEvent.click(calendarButton); + + // Find a date button to click (e.g., the 15th of the month) + const dateToSelect = screen.getByRole('gridcell', { name: '15' }); + await userEvent.click(dateToSelect); + + // Check if onChange was called after selecting a date + expect(props.onChange).toHaveBeenCalled(); + }); + + it('should display the error text when provided', () => { + renderWithTheme(); + const errorMessage = screen.getByText('Invalid date'); + expect(errorMessage).toBeVisible(); + }); + + it('should display the helper text when provided', () => { + renderWithTheme(); + const helperText = screen.getByText('Choose a valid date'); + expect(helperText).toBeVisible(); + }); + + it('should use the default format when no format is specified', () => { + renderWithTheme( + + ); + const datePickerField = screen.getByRole('textbox', { + name: 'Select a date', + }); + expect(datePickerField).toHaveValue('2024-10-25'); + }); + + it('should handle the custom format correctly', () => { + renderWithTheme( + + ); + const datePickerField = screen.getByRole('textbox', { + name: 'Select a date', + }); + expect(datePickerField).toHaveValue('25/10/2024'); + }); +}); diff --git a/packages/manager/src/components/DatePicker/DatePicker.tsx b/packages/manager/src/components/DatePicker/DatePicker.tsx index 9a1aa35f26d..89e26d7afff 100644 --- a/packages/manager/src/components/DatePicker/DatePicker.tsx +++ b/packages/manager/src/components/DatePicker/DatePicker.tsx @@ -1,20 +1,20 @@ import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { DatePicker as MuiDatePicker } from '@mui/x-date-pickers/DatePicker'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import React, { useState } from 'react'; +import React from 'react'; import { TextField } from 'src/components/TextField'; +import type { DatePickerProps as MuiDatePickerProps } from '@mui/x-date-pickers/DatePicker'; import type { DateTime } from 'luxon'; import type { TextFieldProps } from 'src/components/TextField'; -interface DatePickerProps { - /** If true, disables selecting future dates. Defaults to false. */ - disableFuture?: boolean; - /** If true, disables selecting past dates. Defaults to false. */ - disablePast?: boolean; +export interface DatePickerProps + extends Omit, 'onChange' | 'value'> { /** Error text to display below the input */ errorText?: string; + /** Format of the date when rendered in the input field. */ + format?: string; /** Helper text to display below the input */ helperText?: string; /** Label to display for the date picker input */ @@ -30,23 +30,17 @@ interface DatePickerProps { } export const DatePicker = ({ - disableFuture = false, - disablePast = false, errorText, - helperText, + format = 'yyyy-MM-dd', + helperText = '', label = 'Select a date', onChange, - placeholder = 'Select a date', + placeholder = 'Pick a date', textFieldProps, - value, + value = null, ...props }: DatePickerProps) => { - const [selectedDate, setSelectedDate] = useState( - value || null - ); - const onChangeHandler = (newDate: DateTime | null) => { - setSelectedDate(newDate); if (onChange) { onChange(newDate); } @@ -55,26 +49,25 @@ export const DatePicker = ({ return ( ( - - ), + ...textFieldProps, + InputProps: { + ...textFieldProps?.InputProps, + }, + error: Boolean(errorText), + helperText, + label, + placeholder, }, }} + slots={{ + textField: TextField, // Use custom TextField + }} /> ); From f87fbce3cbbd325a357dfe466d56670644e6ae64 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Thu, 31 Oct 2024 09:54:24 -0500 Subject: [PATCH 06/35] DatePicker Stories --- .../DatePicker/DatePicker.stories.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 packages/manager/src/components/DatePicker/DatePicker.stories.tsx diff --git a/packages/manager/src/components/DatePicker/DatePicker.stories.tsx b/packages/manager/src/components/DatePicker/DatePicker.stories.tsx new file mode 100644 index 00000000000..db3925b03eb --- /dev/null +++ b/packages/manager/src/components/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,43 @@ +import { action } from '@storybook/addon-actions'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { DatePicker } from './DatePicker'; + +import type { Meta, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +export const ControlledExample: Story = { + render: () => { + const ControlledDatePicker = () => { + const [selectedDate, setSelectedDate] = React.useState( + DateTime.now() + ); + + const handleChange = (newDate: DateTime | null) => { + setSelectedDate(newDate); + action('Controlled date change')(newDate?.toISO()); + }; + + return ( + + ); + }; + + return ; + }, +}; + +const meta: Meta = { + component: DatePicker, + title: 'Components/DatePicker', +}; + +export default meta; From f47f91948da1fe48fc73890e90f9edfbf9232316 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Thu, 31 Oct 2024 10:00:05 -0500 Subject: [PATCH 07/35] Custom DateTimePicker component --- .../src/components/DatePicker/DatePicker.tsx | 4 +- .../DatePicker/DateTimePicker.test.tsx | 138 ++++++++++++ .../components/DatePicker/DateTimePicker.tsx | 199 ++++++++++++++++++ 3 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 packages/manager/src/components/DatePicker/DateTimePicker.test.tsx create mode 100644 packages/manager/src/components/DatePicker/DateTimePicker.tsx diff --git a/packages/manager/src/components/DatePicker/DatePicker.tsx b/packages/manager/src/components/DatePicker/DatePicker.tsx index 89e26d7afff..006bcb426d9 100644 --- a/packages/manager/src/components/DatePicker/DatePicker.tsx +++ b/packages/manager/src/components/DatePicker/DatePicker.tsx @@ -30,7 +30,6 @@ export interface DatePickerProps } export const DatePicker = ({ - errorText, format = 'yyyy-MM-dd', helperText = '', label = 'Select a date', @@ -59,14 +58,15 @@ export const DatePicker = ({ InputProps: { ...textFieldProps?.InputProps, }, - error: Boolean(errorText), helperText, label, placeholder, }, + ...props?.slotProps, }} slots={{ textField: TextField, // Use custom TextField + ...props?.slots, }} /> diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx new file mode 100644 index 00000000000..bc19b643ad3 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx @@ -0,0 +1,138 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DateTimePicker } from './DateTimePicker'; + +import type { DateTimePickerProps } from './DateTimePicker'; + +const defaultProps: DateTimePickerProps = { + label: 'Select Date and Time', + onApply: vi.fn(), + onCancel: vi.fn(), + onChange: vi.fn(), + placeholder: 'yyyy-MM-dd HH:mm', + value: DateTime.fromISO('2024-10-25T15:30:00'), +}; + +describe('DateTimePicker Component', () => { + it('should render the DateTimePicker component with the correct label and placeholder', () => { + renderWithTheme(); + const textField = screen.getByRole('textbox', { + name: 'Select Date and Time', + }); + expect(textField).toBeVisible(); + expect(textField).toHaveAttribute('placeholder', 'yyyy-MM-dd HH:mm'); + }); + + it('should open the Popover when the TextField is clicked', async () => { + renderWithTheme(); + const textField = screen.getByRole('textbox', { + name: 'Select Date and Time', + }); + await userEvent.click(textField); + expect(screen.getByRole('dialog')).toBeVisible(); // Verifying the Popover is open + }); + + it('should call onCancel when the Cancel button is clicked', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + const cancelButton = screen.getByRole('button', { name: /Cancel/i }); + await userEvent.click(cancelButton); + expect(defaultProps.onCancel).toHaveBeenCalled(); + }); + + it('should call onApply when the Apply button is clicked', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + const applyButton = screen.getByRole('button', { name: /Apply/i }); + await userEvent.click(applyButton); + expect(defaultProps.onApply).toHaveBeenCalled(); + expect(defaultProps.onChange).toHaveBeenCalledWith(expect.any(DateTime)); // Ensuring onChange was called with a DateTime object + }); + + it('should handle date changes correctly', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + + // Simulate selecting a date (e.g., 15th of the month) + const dateButton = screen.getByRole('gridcell', { name: '15' }); + await userEvent.click(dateButton); + + // Check that the displayed value has been updated correctly (this assumes the date format) + expect(defaultProps.onChange).toHaveBeenCalled(); + }); + + it.skip('should handle time changes correctly', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + + // Simulate changing time in the TimePicker (e.g., setting time to 10:45) + const timeInput = screen.getByLabelText(/Select Time/i); + await userEvent.type(timeInput, '10:45'); + expect(defaultProps.onChange).toHaveBeenCalled(); + }); + + it('should handle timezone changes correctly', async () => { + const timezoneChangeMock = vi.fn(); // Create a mock function + + const updatedProps = { + ...defaultProps, + timeZoneSelectProps: { onChange: timezoneChangeMock, value: 'UTC' }, + }; + + renderWithTheme(); + + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + + // Simulate selecting a timezone from the TimeZoneSelect + const timezoneInput = screen.getByPlaceholderText(/Choose a Timezone/i); + await userEvent.click(timezoneInput); + + // Select a timezone from the dropdown options + await userEvent.click( + screen.getByRole('option', { name: '(GMT -11:00) Niue Time' }) + ); + + // Click the Apply button to trigger the change + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Verify that the onChange function was called with the expected value + expect(timezoneChangeMock).toHaveBeenCalledWith('Pacific/Niue'); + }); + + it('should display the error text when provided', () => { + renderWithTheme( + + ); + expect(screen.getByText(/Invalid date-time/i)).toBeVisible(); + }); + + it('should format the date-time correctly when a custom format is provided', () => { + renderWithTheme( + + ); + const textField = screen.getByRole('textbox', { + name: 'Select Date and Time', + }); + + expect(textField).toHaveValue('25/10/2024 15:30'); + }); +}); diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx new file mode 100644 index 00000000000..a77c4eae60d --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -0,0 +1,199 @@ +import { Grid, Popover } from '@mui/material'; +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { TimePicker } from '@mui/x-date-pickers/TimePicker'; +import React, { useState } from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Box } from 'src/components/Box'; +import { Divider } from 'src/components/Divider'; +import { TextField } from 'src/components/TextField'; + +import { TimeZoneSelect } from './TimeZoneSelect'; + +import type { DateCalendarProps } from '@mui/x-date-pickers/DateCalendar'; +import type { DateTime } from 'luxon'; + +export interface DateTimePickerProps { + /** Additional props for the DateCalendar */ + dateCalendarProps?: Partial>; + /** Error text for the date picker filed */ + errorText?: string; + /** Format for displaying the date-time */ + format?: string; + /** Label for the input field */ + label?: string; + /** Callback when the "Apply" button is clicked */ + onApply?: () => void; + /** Callback when the "Cancel" button is clicked */ + onCancel?: () => void; + /** Callback when date-time changes */ + onChange?: (dateTime: DateTime | null) => void; + /** Placeholder text for the input field */ + placeholder?: string; + timeSelectProps?: { + label?: string; + onChange?: (time: string) => void; + value?: null | string; + }; + /** Props for customizing the TimeZoneSelect component */ + timeZoneSelectProps?: { + label?: string; + onChange?: (timezone: string) => void; + value?: null | string; + }; + /** Initial or controlled dateTime value */ + value?: DateTime | null; +} + +export const DateTimePicker = ({ + dateCalendarProps = {}, + errorText = '', + format = 'yyyy-MM-dd HH:mm', + label = 'Select Date and Time', + onApply, + onCancel, + onChange, + placeholder = 'yyyy-MM-dd HH:mm', + timeSelectProps, + timeZoneSelectProps, + value = null, +}: DateTimePickerProps) => { + const [anchorEl, setAnchorEl] = useState(null); + const [dateTime, setDateTime] = useState(value); + const [timezone, setTimezone] = useState( + timeZoneSelectProps?.value || null + ); + + const handleOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + if (onCancel) { + onCancel(); + } + }; + + const handleApply = () => { + setAnchorEl(null); + if (onChange) { + onChange(dateTime); + } + if (onApply) { + onApply(); + } + }; + + const handleDateChange = (newDate: DateTime | null) => { + setDateTime((prev) => + newDate + ? newDate.set({ + hour: prev?.hour || 0, + minute: prev?.minute || 0, + }) + : null + ); + }; + + const handleTimeChange = (newTime: DateTime | null) => { + if (newTime) { + setDateTime((prev) => { + if (prev) { + // Ensure hour and minute are valid numbers + const newHour = newTime.hour; + const newMinute = newTime.minute; + + if (typeof newHour === 'number' && typeof newMinute === 'number') { + return prev.set({ hour: newHour, minute: newMinute }); + } + } + // Return the current `prev` value if newTime is invalid + return prev; + }); + } + }; + + const handleTimezoneChange = (newTimezone: string) => { + setTimezone(newTimezone); + setDateTime((prev) => (prev ? prev.setZone(newTimezone) : null)); + if (timeZoneSelectProps?.onChange) { + timeZoneSelectProps.onChange(newTimezone); + } + }; + + return ( + + + + + + + + + + + + + + + + + + + ({ + marginBottom: theme.spacing(1), + marginRight: theme.spacing(2), + })} + /> + + + + ); +}; From 2578b837a0656cee6829eb95d2d2718643a21e11 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Thu, 31 Oct 2024 10:01:06 -0500 Subject: [PATCH 08/35] Reusable TimeZone Select Component --- .../components/DatePicker/TimeZoneSelect.tsx | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 packages/manager/src/components/DatePicker/TimeZoneSelect.tsx diff --git a/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx b/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx new file mode 100644 index 00000000000..3ec726b6ef6 --- /dev/null +++ b/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx @@ -0,0 +1,63 @@ +import { DateTime } from 'luxon'; +import React from 'react'; + +import { timezones } from 'src/assets/timezones/timezones'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; + +type Timezone = typeof timezones[number]; + +interface TimeZoneSelectProps { + disabled?: boolean; + errorText?: string; + label?: string; + onChange: (timezone: string) => void; + value: null | string; +} + +const getOptionLabel = ({ label, offset }: Timezone) => { + const minutes = (Math.abs(offset) % 60).toLocaleString(undefined, { + minimumIntegerDigits: 2, + useGrouping: false, + }); + const hours = Math.floor(Math.abs(offset) / 60); + const isPositive = Math.abs(offset) === offset ? '+' : '-'; + + return `(GMT ${isPositive}${hours}:${minutes}) ${label}`; +}; + +const getTimezoneOptions = () => { + return timezones + .map((tz) => { + const offset = DateTime.now().setZone(tz.name).offset; + const label = getOptionLabel({ ...tz, offset }); + return { label, offset, value: tz.name }; + }) + .sort((a, b) => a.offset - b.offset); +}; + +const timezoneOptions = getTimezoneOptions(); + +export const TimeZoneSelect = ({ + disabled = false, + errorText, + label = 'Timezone', + onChange, + value, +}: TimeZoneSelectProps) => { + return ( + option.value === value) ?? undefined + } + autoHighlight + disableClearable + disabled={disabled} + errorText={errorText} + fullWidth + label={label} + onChange={(e, option) => onChange(option?.value || '')} + options={timezoneOptions} + placeholder="Choose a Timezone" + /> + ); +}; From 43810e75d75ec4417799ce779e8620de0ce16f5d Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Thu, 31 Oct 2024 10:18:17 -0500 Subject: [PATCH 09/35] Create custom DateTimeRangePicker component --- .../DateTimeRangePicker.stories.tsx | 77 ++++++++++++ .../DatePicker/DateTimeRangePicker.test.tsx | 112 ++++++++++++++++++ .../DatePicker/DateTimeRangePicker.tsx | 89 ++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx create mode 100644 packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx create mode 100644 packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx new file mode 100644 index 00000000000..70ad6b2a39d --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx @@ -0,0 +1,77 @@ +import { action } from '@storybook/addon-actions'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { DateTimeRangePicker } from './DateTimeRangePicker'; + +import type { Meta, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +export const Default: Story = { + args: { + endLabel: 'End Date and Time', + format: 'yyyy-MM-dd HH:mm', + onChange: action('DateTime range changed'), + startLabel: 'Start Date and Time', + }, + render: (args) => , +}; + +export const WithInitialValues: Story = { + render: () => { + const ComponentWithState: React.FC = () => { + const [start, setStart] = React.useState( + DateTime.now().minus({ days: 1 }) + ); + const [end, setEnd] = React.useState(DateTime.now()); + + const handleDateChange = ( + newStart: DateTime | null, + newEnd: DateTime | null + ) => { + setStart(newStart); + setEnd(newEnd); + action('DateTime range changed')(newStart?.toISO(), newEnd?.toISO()); + }; + + return ( + + ); + }; + + return ; + }, +}; + +const meta: Meta = { + argTypes: { + endLabel: { + control: 'text', + description: 'Label for the end date picker', + }, + format: { + control: 'text', + description: 'Format for displaying the date-time', + }, + onChange: { + action: 'DateTime range changed', + description: 'Callback when the date-time range changes', + }, + startLabel: { + control: 'text', + description: 'Label for the start date picker', + }, + }, + component: DateTimeRangePicker, + title: 'Components/DateTimeRangePicker', +}; + +export default meta; diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx new file mode 100644 index 00000000000..843678ac4b9 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx @@ -0,0 +1,112 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DateTimeRangePicker } from './DateTimeRangePicker'; + +describe('DateTimeRangePicker Component', () => { + const onChangeMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render start and end DateTimePickers with correct labels', () => { + renderWithTheme(); + + expect(screen.getByLabelText('Start Date')).toBeVisible(); + expect(screen.getByLabelText('End Date and Time')).toBeVisible(); + }); + + it('should call onChange when start date is changed', async () => { + renderWithTheme(); + + // Open start date picker + await userEvent.click(screen.getByLabelText('Start Date')); + + await userEvent.click(screen.getByRole('gridcell', { name: '10' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Check if the onChange function is called with the expected DateTime value + expect(onChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ day: 10 }), + null + ); + }); + + it.skip('should call onChange when end date is changed', async () => { + renderWithTheme( + + ); + + // Open the end date picker + await userEvent.click( + screen.getByRole('textbox', { name: 'End Date and Time' }) + ); + + // Confirm the dialog is visible and wait for it + expect(screen.getByRole('dialog')).toBeVisible(); + + // Simulate selecting a date (e.g., 15th of the month) + const element = screen.getByRole('gridcell', { name: '2' }); + + await userEvent.click(element); + // await userEvent.click(screen.getByRole('gridcell', { name: '1' })); + + // Click the 'Apply' button and wait for onChange to be called + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Verify onChange is called with the appropriate arguments + expect(onChangeMock).toHaveBeenCalledWith(null, expect.any(DateTime)); + }); + + it.skip('should show error when end date-time is before start date-time', async () => { + renderWithTheme(); + + // Set start date-time to the 15th + const startDateField = screen.getByLabelText('Start Date'); + await userEvent.click(startDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '15' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Set end date-time to the 10th (before start date-time) + const endDateField = screen.getByLabelText('End Date and Time'); + await userEvent.click(endDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '10' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + expect(screen.getAllByText('Invalid dae and Time')).toHaveLength(2); + }); + + it.skip('should clear the error when a valid end date-time is selected', async () => { + renderWithTheme(); + + // Set start date-time to the 10th + const startDateField = screen.getByLabelText('Start Date'); + await userEvent.click(startDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '10' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Set an invalid end date-time (e.g., 5th) + const endDateField = screen.getByLabelText('End Date and Time'); + await userEvent.click(endDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '5' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + expect(screen.getAllByText('Invalid dae and Time')).toHaveLength(2); + + // Select a valid end date-time (e.g., 15th) + await userEvent.click(endDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '15' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + expect(screen.queryByText('Invalid dae and Time')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx new file mode 100644 index 00000000000..95d9e12a45e --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx @@ -0,0 +1,89 @@ +import { DateTime } from 'luxon'; +import React, { useState } from 'react'; + +import { Box } from 'src/components/Box'; + +import { DateTimePicker } from './DateTimePicker'; + +interface DateTimeRangePickerProps { + /** Initial or controlled value for the end date-time */ + endDateTimeValue?: DateTime | null; + /** Custom labels for the start and end date/time fields */ + endLabel?: string; + /** Format for displaying the date-time */ + format?: string; + /** Callback when the date-time range changes */ + onChange?: (start: DateTime | null, end: DateTime | null) => void; + /** Initial or controlled value for the start date-time */ + startDateTimeValue?: DateTime | null; + /** Custom labels for the start and end date/time fields */ + startLabel?: string; +} + +export const DateTimeRangePicker = ({ + endDateTimeValue = null, + endLabel = 'End Date and Time', + format = 'yyyy-MM-dd HH:mm', + onChange, + startDateTimeValue = null, + startLabel = 'Start Date', +}: DateTimeRangePickerProps) => { + const [startDateTime, setStartDateTime] = useState( + startDateTimeValue + ); + const [endDateTime, setEndDateTime] = useState( + endDateTimeValue + ); + const [error, setError] = useState(); + + const handleStartDateTimeChange = (newStart: DateTime | null) => { + setStartDateTime(newStart); + + // Reset error if the selection is valid + if (endDateTime && newStart && endDateTime >= newStart) { + setError(undefined); + } + + if (onChange) { + onChange(newStart, endDateTime); + } + }; + + const handleEndDateTimeChange = (newEnd: DateTime | null) => { + // Check if the end date is before the start date + if (startDateTime && newEnd && newEnd < startDateTime) { + setError('Invalid dae and Time'); + } else { + setEndDateTime(newEnd); + setError(undefined); // Clear the error if the selection is valid + } + + if (onChange) { + onChange(startDateTime, newEnd); + } + }; + + return ( + + + + + ); +}; From 3b6f981363089f989663e16e3e4cfd97b420cc18 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Tue, 5 Nov 2024 19:45:30 -0600 Subject: [PATCH 10/35] Storybook for DateTimePicker --- .../DatePicker/DateTimePicker.stories.tsx | 93 +++++++++++++++++++ .../components/DatePicker/DateTimePicker.tsx | 2 +- .../DatePicker/DateTimeRangePicker.tsx | 3 +- 3 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx new file mode 100644 index 00000000000..32d65dfa0b7 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx @@ -0,0 +1,93 @@ +import { action } from '@storybook/addon-actions'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { DateTimePicker } from './DateTimePicker'; + +import type { Meta, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +export const ControlledExample: Story = { + render: () => { + const ControlledDateTimePicker = () => { + const [ + selectedDateTime, + setSelectedDateTime, + ] = React.useState(DateTime.now()); + + const handleChange = (newDateTime: DateTime | null) => { + setSelectedDateTime(newDateTime); + action('Controlled dateTime change')(newDateTime?.toISO()); + }; + + return ( + + ); + }; + + return ; + }, +}; + +export const DefaultExample: Story = { + args: { + label: 'Default Date-Time Picker', + onApply: action('Apply clicked'), + onCancel: action('Cancel clicked'), + onChange: action('Date-Time selected'), + placeholder: 'yyyy-MM-dd HH:mm', + }, +}; + +export const WithErrorText: Story = { + args: { + errorText: 'This field is required', + label: 'Date-Time Picker with Error', + onApply: action('Apply clicked with error'), + onCancel: action('Cancel clicked with error'), + onChange: action('Date-Time selected with error'), + placeholder: 'yyyy-MM-dd HH:mm', + }, +}; + +const meta: Meta = { + argTypes: { + errorText: { + control: { type: 'text' }, + }, + format: { + control: { type: 'text' }, + }, + label: { + control: { type: 'text' }, + }, + onApply: { action: 'applyClicked' }, + onCancel: { action: 'cancelClicked' }, + onChange: { action: 'dateTimeChanged' }, + placeholder: { + control: { type: 'text' }, + }, + }, + args: { + format: 'yyyy-MM-dd HH:mm', + label: 'Date-Time Picker', + placeholder: 'Select a date and time', + }, + component: DateTimePicker, + title: 'Components/DateTimePicker', +}; + +export default meta; diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx index a77c4eae60d..2fac07dfaf3 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -1,3 +1,4 @@ +import { Box } from '@linode/ui'; import { Grid, Popover } from '@mui/material'; import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'; @@ -6,7 +7,6 @@ import { TimePicker } from '@mui/x-date-pickers/TimePicker'; import React, { useState } from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Box } from 'src/components/Box'; import { Divider } from 'src/components/Divider'; import { TextField } from 'src/components/TextField'; diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx index 95d9e12a45e..f7a658178b3 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx @@ -1,8 +1,7 @@ +import { Box } from '@linode/ui'; import { DateTime } from 'luxon'; import React, { useState } from 'react'; -import { Box } from 'src/components/Box'; - import { DateTimePicker } from './DateTimePicker'; interface DateTimeRangePickerProps { From 9b10623bdcb63b88c3c9c00ca56e9bfff503d492 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Thu, 7 Nov 2024 12:20:29 -0600 Subject: [PATCH 11/35] Fix tests and remove console warnings --- .../components/DatePicker/DateTimePicker.tsx | 2 +- .../DatePicker/DateTimeRangePicker.test.tsx | 67 ++----------------- 2 files changed, 8 insertions(+), 61 deletions(-) diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx index 2fac07dfaf3..1d7e05b8237 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -133,7 +133,7 @@ export const DateTimePicker = ({ ? `${dateTime.toFormat(format)}${ timezone ? ` (${timezone})` : '' }` - : null + : '' } InputProps={{ readOnly: true }} errorText={errorText} diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx index 843678ac4b9..f3421b684c3 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx @@ -1,6 +1,5 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { DateTime } from 'luxon'; import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -37,37 +36,7 @@ describe('DateTimeRangePicker Component', () => { ); }); - it.skip('should call onChange when end date is changed', async () => { - renderWithTheme( - - ); - - // Open the end date picker - await userEvent.click( - screen.getByRole('textbox', { name: 'End Date and Time' }) - ); - - // Confirm the dialog is visible and wait for it - expect(screen.getByRole('dialog')).toBeVisible(); - - // Simulate selecting a date (e.g., 15th of the month) - const element = screen.getByRole('gridcell', { name: '2' }); - - await userEvent.click(element); - // await userEvent.click(screen.getByRole('gridcell', { name: '1' })); - - // Click the 'Apply' button and wait for onChange to be called - await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - - // Verify onChange is called with the appropriate arguments - expect(onChangeMock).toHaveBeenCalledWith(null, expect.any(DateTime)); - }); - - it.skip('should show error when end date-time is before start date-time', async () => { + it('should show error when end date-time is before start date-time', async () => { renderWithTheme(); // Set start date-time to the 15th @@ -76,37 +45,15 @@ describe('DateTimeRangePicker Component', () => { await userEvent.click(screen.getByRole('gridcell', { name: '15' })); await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - // Set end date-time to the 10th (before start date-time) - const endDateField = screen.getByLabelText('End Date and Time'); - await userEvent.click(endDateField); - await userEvent.click(screen.getByRole('gridcell', { name: '10' })); - await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - - expect(screen.getAllByText('Invalid dae and Time')).toHaveLength(2); - }); - - it.skip('should clear the error when a valid end date-time is selected', async () => { - renderWithTheme(); - - // Set start date-time to the 10th - const startDateField = screen.getByLabelText('Start Date'); - await userEvent.click(startDateField); - await userEvent.click(screen.getByRole('gridcell', { name: '10' })); - await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - - // Set an invalid end date-time (e.g., 5th) + // Open the end date picker const endDateField = screen.getByLabelText('End Date and Time'); await userEvent.click(endDateField); - await userEvent.click(screen.getByRole('gridcell', { name: '5' })); - await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - expect(screen.getAllByText('Invalid dae and Time')).toHaveLength(2); - - // Select a valid end date-time (e.g., 15th) - await userEvent.click(endDateField); - await userEvent.click(screen.getByRole('gridcell', { name: '15' })); - await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + // Check if the date before the start date is disabled via a class or attribute + const invalidDate = screen.getByRole('gridcell', { name: '10' }); + expect(invalidDate).toHaveClass('Mui-disabled'); // or check for the specific attribute used - expect(screen.queryByText('Invalid dae and Time')).not.toBeInTheDocument(); + // Confirm error message is not shown since the click was blocked + expect(screen.queryByText('Invalid date and Time')).not.toBeInTheDocument(); }); }); From 11d878004ebca1ebea35a703bb4d6a96797e3a4d Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Mon, 18 Nov 2024 09:38:27 -0600 Subject: [PATCH 12/35] changeset --- packages/manager/.changeset/pr-11151-added-1731944151381.md | 5 +++++ .../manager/src/components/DatePicker/DateTimePicker.tsx | 2 +- .../features/GlobalNotifications/CreditCardExpiredBanner.tsx | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-11151-added-1731944151381.md diff --git a/packages/manager/.changeset/pr-11151-added-1731944151381.md b/packages/manager/.changeset/pr-11151-added-1731944151381.md new file mode 100644 index 00000000000..1a64466eea4 --- /dev/null +++ b/packages/manager/.changeset/pr-11151-added-1731944151381.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +New DatePicker Component ([#11151](https://github.com/linode/manager/pull/11151)) diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx index 1d7e05b8237..2fca86ab17f 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -1,3 +1,4 @@ +import { Divider } from '@linode/ui'; import { Box } from '@linode/ui'; import { Grid, Popover } from '@mui/material'; import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; @@ -7,7 +8,6 @@ import { TimePicker } from '@mui/x-date-pickers/TimePicker'; import React, { useState } from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Divider } from 'src/components/Divider'; import { TextField } from 'src/components/TextField'; import { TimeZoneSelect } from './TimeZoneSelect'; diff --git a/packages/manager/src/features/GlobalNotifications/CreditCardExpiredBanner.tsx b/packages/manager/src/features/GlobalNotifications/CreditCardExpiredBanner.tsx index dbbe22436e8..c386feb9b4c 100644 --- a/packages/manager/src/features/GlobalNotifications/CreditCardExpiredBanner.tsx +++ b/packages/manager/src/features/GlobalNotifications/CreditCardExpiredBanner.tsx @@ -1,8 +1,8 @@ +import { Button } from '@linode/ui'; import { Typography } from '@mui/material'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { Button } from 'src/components/Button/Button'; import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; import { useAccount } from 'src/queries/account/account'; import { isCreditCardExpired } from 'src/utilities/creditCard'; From 7aec8c958694af09bdf801c1d97f2d75bfad4127 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 20 Nov 2024 05:17:53 -0600 Subject: [PATCH 13/35] Update packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> --- .../manager/src/components/DatePicker/DateTimeRangePicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx index f7a658178b3..ed57af7c37c 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx @@ -51,7 +51,7 @@ export const DateTimeRangePicker = ({ const handleEndDateTimeChange = (newEnd: DateTime | null) => { // Check if the end date is before the start date if (startDateTime && newEnd && newEnd < startDateTime) { - setError('Invalid dae and Time'); + setError('Invalid date and time'); } else { setEndDateTime(newEnd); setError(undefined); // Clear the error if the selection is valid From 0c379a2f692ba5256aef483a5152845b08d467b1 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Thu, 21 Nov 2024 13:02:31 -0600 Subject: [PATCH 14/35] Adjust styles for DatePicker --- .../src/components/DatePicker/DatePicker.tsx | 31 +++++++++++++++---- .../DatePicker/DateTimePicker.test.tsx | 12 ------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/packages/manager/src/components/DatePicker/DatePicker.tsx b/packages/manager/src/components/DatePicker/DatePicker.tsx index 006bcb426d9..20573d41f7f 100644 --- a/packages/manager/src/components/DatePicker/DatePicker.tsx +++ b/packages/manager/src/components/DatePicker/DatePicker.tsx @@ -1,3 +1,4 @@ +import { useTheme } from '@mui/material/styles'; import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { DatePicker as MuiDatePicker } from '@mui/x-date-pickers/DatePicker'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; @@ -39,6 +40,8 @@ export const DatePicker = ({ value = null, ...props }: DatePickerProps) => { + const theme = useTheme(); + const onChangeHandler = (newDate: DateTime | null) => { if (onChange) { onChange(newDate); @@ -50,23 +53,39 @@ export const DatePicker = ({ diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx index bc19b643ad3..ca9bc882340 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx @@ -72,18 +72,6 @@ describe('DateTimePicker Component', () => { expect(defaultProps.onChange).toHaveBeenCalled(); }); - it.skip('should handle time changes correctly', async () => { - renderWithTheme(); - await userEvent.click( - screen.getByRole('textbox', { name: 'Select Date and Time' }) - ); - - // Simulate changing time in the TimePicker (e.g., setting time to 10:45) - const timeInput = screen.getByLabelText(/Select Time/i); - await userEvent.type(timeInput, '10:45'); - expect(defaultProps.onChange).toHaveBeenCalled(); - }); - it('should handle timezone changes correctly', async () => { const timezoneChangeMock = vi.fn(); // Create a mock function From 194b0d2a4526ec4b42edf3fefd54e3b5195365a8 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Fri, 22 Nov 2024 06:51:32 -0600 Subject: [PATCH 15/35] Adjust styles for DateTimePicker --- .../components/DatePicker/DateTimePicker.tsx | 46 +++++++++++++++++-- .../components/DatePicker/TimeZoneSelect.tsx | 5 +- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx index 2fca86ab17f..2f01eabb20a 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -1,5 +1,5 @@ -import { Divider } from '@linode/ui'; import { Box } from '@linode/ui'; +import { Divider } from '@linode/ui'; import { Grid, Popover } from '@mui/material'; import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'; @@ -14,6 +14,7 @@ import { TimeZoneSelect } from './TimeZoneSelect'; import type { DateCalendarProps } from '@mui/x-date-pickers/DateCalendar'; import type { DateTime } from 'luxon'; +import type { TextFieldProps } from 'src/components/TextField'; export interface DateTimePickerProps { /** Additional props for the DateCalendar */ @@ -66,6 +67,11 @@ export const DateTimePicker = ({ timeZoneSelectProps?.value || null ); + const textFieldProps: TextFieldProps = { + label: timeSelectProps?.label ?? 'Select Time', + noMarginTop: true, + }; + const handleOpen = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); }; @@ -151,24 +157,56 @@ export const DateTimePicker = ({ > ({ + '& .MuiDayCalendar-weekContainer, & .MuiDayCalendar-header': { + justifyContent: 'space-between', + }, + '& .MuiDayCalendar-weekDayLabel': { + fontSize: '0.875rem', + }, + '& .MuiPickersCalendarHeader-label': { + fontFamily: theme.font.bold, + }, + '& .MuiPickersCalendarHeader-root': { + borderBottom: `1px solid ${theme.borderColors.divider}`, + fontSize: '0.875rem', + paddingBottom: theme.spacing(1), + }, + '& .MuiPickersDay-root': { + fontSize: '0.875rem', + margin: `${theme.spacing(0.5)}px`, + }, + borderRadius: `${theme.spacing(2)}`, + borderWidth: '0px', + })} onChange={handleDateChange} value={dateTime} {...dateCalendarProps} /> - + - + diff --git a/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx b/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx index 3ec726b6ef6..19312098007 100644 --- a/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx +++ b/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx @@ -10,6 +10,7 @@ interface TimeZoneSelectProps { disabled?: boolean; errorText?: string; label?: string; + noMarginTop?: boolean; onChange: (timezone: string) => void; value: null | string; } @@ -41,6 +42,7 @@ export const TimeZoneSelect = ({ disabled = false, errorText, label = 'Timezone', + noMarginTop = false, onChange, value, }: TimeZoneSelectProps) => { @@ -50,11 +52,10 @@ export const TimeZoneSelect = ({ timezoneOptions.find((option) => option.value === value) ?? undefined } autoHighlight - disableClearable disabled={disabled} errorText={errorText} - fullWidth label={label} + noMarginTop={noMarginTop} onChange={(e, option) => onChange(option?.value || '')} options={timezoneOptions} placeholder="Choose a Timezone" From b2d591f58823c508e76ff21e6d0cf7dd88190916 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Mon, 2 Dec 2024 11:21:40 -0600 Subject: [PATCH 16/35] update imports --- packages/manager/src/components/DatePicker/DatePicker.tsx | 5 ++--- .../manager/src/components/DatePicker/DateTimePicker.tsx | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/manager/src/components/DatePicker/DatePicker.tsx b/packages/manager/src/components/DatePicker/DatePicker.tsx index 20573d41f7f..a3a5a722e82 100644 --- a/packages/manager/src/components/DatePicker/DatePicker.tsx +++ b/packages/manager/src/components/DatePicker/DatePicker.tsx @@ -1,14 +1,13 @@ +import { TextField } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { DatePicker as MuiDatePicker } from '@mui/x-date-pickers/DatePicker'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import React from 'react'; -import { TextField } from 'src/components/TextField'; - +import type { TextFieldProps } from '@linode/ui'; import type { DatePickerProps as MuiDatePickerProps } from '@mui/x-date-pickers/DatePicker'; import type { DateTime } from 'luxon'; -import type { TextFieldProps } from 'src/components/TextField'; export interface DatePickerProps extends Omit, 'onChange' | 'value'> { diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx index 2f01eabb20a..bc37175a636 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -1,3 +1,4 @@ +import { TextField } from '@linode/ui'; import { Box } from '@linode/ui'; import { Divider } from '@linode/ui'; import { Grid, Popover } from '@mui/material'; @@ -8,13 +9,12 @@ import { TimePicker } from '@mui/x-date-pickers/TimePicker'; import React, { useState } from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { TextField } from 'src/components/TextField'; import { TimeZoneSelect } from './TimeZoneSelect'; +import type { TextFieldProps } from '@linode/ui'; import type { DateCalendarProps } from '@mui/x-date-pickers/DateCalendar'; import type { DateTime } from 'luxon'; -import type { TextFieldProps } from 'src/components/TextField'; export interface DateTimePickerProps { /** Additional props for the DateCalendar */ From efcfe5ce5a926a9b12fbc7c5eb6c3379122e3366 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Mon, 2 Dec 2024 13:35:44 -0600 Subject: [PATCH 17/35] Render time and timezone conditionally in DateTimePicker component --- .../DatePicker/DateTimePicker.test.tsx | 17 ++++++ .../components/DatePicker/DateTimePicker.tsx | 55 +++++++++++-------- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx index ca9bc882340..12f1795a747 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx @@ -123,4 +123,21 @@ describe('DateTimePicker Component', () => { expect(textField).toHaveValue('25/10/2024 15:30'); }); + it('should not render the time selector when showTime is false', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + const timePicker = screen.queryByLabelText(/Select Time/i); // Label from timeSelectProps + expect(timePicker).not.toBeInTheDocument(); + }); + + it('should not render the timezone selector when showTimeZone is false', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + const timeZoneSelect = screen.queryByLabelText(/Timezone/i); // Label from timeZoneSelectProps + expect(timeZoneSelect).not.toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx index bc37175a636..c6ac1834166 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -33,6 +33,11 @@ export interface DateTimePickerProps { onChange?: (dateTime: DateTime | null) => void; /** Placeholder text for the input field */ placeholder?: string; + /** Whether to show the time selector */ + showTime?: boolean; + /** Whether to show the timezone selector */ + showTimeZone?: boolean; + /** Props for customizing the TimePicker component */ timeSelectProps?: { label?: string; onChange?: (time: string) => void; @@ -57,6 +62,8 @@ export const DateTimePicker = ({ onCancel, onChange, placeholder = 'yyyy-MM-dd HH:mm', + showTime = true, + showTimeZone = true, timeSelectProps, timeZoneSelectProps, value = null, @@ -188,29 +195,33 @@ export const DateTimePicker = ({ spacing={2} sx={{ display: 'flex', justifyContent: 'space-between' }} > - - + - - - - + textField: textFieldProps, + }} + onChange={handleTimeChange} + slots={{ textField: TextField }} + value={dateTime} + /> + + )} + {showTimeZone && ( + + + + )} From 2ca7adf92d8b9c40d9c7b0d751e8dbea45b3832d Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Tue, 3 Dec 2024 10:46:16 -0600 Subject: [PATCH 18/35] Move DatePicker to UI package --- .../src/components/DatePicker/DatePicker.stories.tsx | 0 .../src/components/DatePicker/DatePicker.test.tsx | 0 .../{manager => ui}/src/components/DatePicker/DatePicker.tsx | 0 .../src/components/DatePicker/DateTimePicker.stories.tsx | 0 .../src/components/DatePicker/DateTimePicker.test.tsx | 0 .../{manager => ui}/src/components/DatePicker/DateTimePicker.tsx | 0 .../src/components/DatePicker/DateTimeRangePicker.stories.tsx | 0 .../src/components/DatePicker/DateTimeRangePicker.test.tsx | 0 .../src/components/DatePicker/DateTimeRangePicker.tsx | 0 .../{manager => ui}/src/components/DatePicker/TimeZoneSelect.tsx | 0 packages/ui/src/components/DatePicker/index.ts | 1 + packages/ui/src/components/index.ts | 1 + 12 files changed, 2 insertions(+) rename packages/{manager => ui}/src/components/DatePicker/DatePicker.stories.tsx (100%) rename packages/{manager => ui}/src/components/DatePicker/DatePicker.test.tsx (100%) rename packages/{manager => ui}/src/components/DatePicker/DatePicker.tsx (100%) rename packages/{manager => ui}/src/components/DatePicker/DateTimePicker.stories.tsx (100%) rename packages/{manager => ui}/src/components/DatePicker/DateTimePicker.test.tsx (100%) rename packages/{manager => ui}/src/components/DatePicker/DateTimePicker.tsx (100%) rename packages/{manager => ui}/src/components/DatePicker/DateTimeRangePicker.stories.tsx (100%) rename packages/{manager => ui}/src/components/DatePicker/DateTimeRangePicker.test.tsx (100%) rename packages/{manager => ui}/src/components/DatePicker/DateTimeRangePicker.tsx (100%) rename packages/{manager => ui}/src/components/DatePicker/TimeZoneSelect.tsx (100%) create mode 100644 packages/ui/src/components/DatePicker/index.ts diff --git a/packages/manager/src/components/DatePicker/DatePicker.stories.tsx b/packages/ui/src/components/DatePicker/DatePicker.stories.tsx similarity index 100% rename from packages/manager/src/components/DatePicker/DatePicker.stories.tsx rename to packages/ui/src/components/DatePicker/DatePicker.stories.tsx diff --git a/packages/manager/src/components/DatePicker/DatePicker.test.tsx b/packages/ui/src/components/DatePicker/DatePicker.test.tsx similarity index 100% rename from packages/manager/src/components/DatePicker/DatePicker.test.tsx rename to packages/ui/src/components/DatePicker/DatePicker.test.tsx diff --git a/packages/manager/src/components/DatePicker/DatePicker.tsx b/packages/ui/src/components/DatePicker/DatePicker.tsx similarity index 100% rename from packages/manager/src/components/DatePicker/DatePicker.tsx rename to packages/ui/src/components/DatePicker/DatePicker.tsx diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx b/packages/ui/src/components/DatePicker/DateTimePicker.stories.tsx similarity index 100% rename from packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx rename to packages/ui/src/components/DatePicker/DateTimePicker.stories.tsx diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx b/packages/ui/src/components/DatePicker/DateTimePicker.test.tsx similarity index 100% rename from packages/manager/src/components/DatePicker/DateTimePicker.test.tsx rename to packages/ui/src/components/DatePicker/DateTimePicker.test.tsx diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/ui/src/components/DatePicker/DateTimePicker.tsx similarity index 100% rename from packages/manager/src/components/DatePicker/DateTimePicker.tsx rename to packages/ui/src/components/DatePicker/DateTimePicker.tsx diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx b/packages/ui/src/components/DatePicker/DateTimeRangePicker.stories.tsx similarity index 100% rename from packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx rename to packages/ui/src/components/DatePicker/DateTimeRangePicker.stories.tsx diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx b/packages/ui/src/components/DatePicker/DateTimeRangePicker.test.tsx similarity index 100% rename from packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx rename to packages/ui/src/components/DatePicker/DateTimeRangePicker.test.tsx diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx b/packages/ui/src/components/DatePicker/DateTimeRangePicker.tsx similarity index 100% rename from packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx rename to packages/ui/src/components/DatePicker/DateTimeRangePicker.tsx diff --git a/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx b/packages/ui/src/components/DatePicker/TimeZoneSelect.tsx similarity index 100% rename from packages/manager/src/components/DatePicker/TimeZoneSelect.tsx rename to packages/ui/src/components/DatePicker/TimeZoneSelect.tsx diff --git a/packages/ui/src/components/DatePicker/index.ts b/packages/ui/src/components/DatePicker/index.ts new file mode 100644 index 00000000000..a48b62e4dfc --- /dev/null +++ b/packages/ui/src/components/DatePicker/index.ts @@ -0,0 +1 @@ +export * from './DatePicker'; diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index db690f6940e..f64e7b4105a 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -26,3 +26,4 @@ export * from './Tooltip'; export * from './TooltipIcon'; export * from './Typography'; export * from './VisibilityTooltip'; +export * from './DatePicker'; From 31dc03ea9c5625f56cd0496721c84d8d10969a37 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Tue, 3 Dec 2024 14:43:53 -0600 Subject: [PATCH 19/35] Add DatePicker dependencies --- packages/ui/package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 342fef3d1ba..3a85d11a12a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -20,7 +20,9 @@ "@emotion/styled": "^11.11.0", "@mui/material": "^5.14.7", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@mui/x-date-pickers": "^7.12.0", + "luxon": "3.4.4" }, "scripts": { "start": "tsc -w --preserveWatchOutput", @@ -57,4 +59,4 @@ "prettier": "~2.2.1", "vite-plugin-svgr": "^3.2.0" } -} +} \ No newline at end of file From cd875be5b91f4337892e2af4091731fb38f80f77 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Tue, 3 Dec 2024 18:15:21 -0600 Subject: [PATCH 20/35] Code cleanup --- .../components/DatePicker/DateTimePicker.tsx | 75 ++++++++++++------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/packages/ui/src/components/DatePicker/DateTimePicker.tsx b/packages/ui/src/components/DatePicker/DateTimePicker.tsx index c6ac1834166..4d65dc6f1b9 100644 --- a/packages/ui/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/ui/src/components/DatePicker/DateTimePicker.tsx @@ -1,25 +1,26 @@ import { TextField } from '@linode/ui'; -import { Box } from '@linode/ui'; import { Divider } from '@linode/ui'; +import { Box } from '@linode/ui'; import { Grid, Popover } from '@mui/material'; import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { TimePicker } from '@mui/x-date-pickers/TimePicker'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { TimeZoneSelect } from './TimeZoneSelect'; import type { TextFieldProps } from '@linode/ui'; +import type { Theme } from '@mui/material/styles'; import type { DateCalendarProps } from '@mui/x-date-pickers/DateCalendar'; import type { DateTime } from 'luxon'; export interface DateTimePickerProps { /** Additional props for the DateCalendar */ dateCalendarProps?: Partial>; - /** Error text for the date picker filed */ + /** Error text for the date picker field */ errorText?: string; /** Format for displaying the date-time */ format?: string; @@ -64,21 +65,35 @@ export const DateTimePicker = ({ placeholder = 'yyyy-MM-dd HH:mm', showTime = true, showTimeZone = true, - timeSelectProps, - timeZoneSelectProps, + timeSelectProps = {}, + timeZoneSelectProps = {}, value = null, }: DateTimePickerProps) => { const [anchorEl, setAnchorEl] = useState(null); - const [dateTime, setDateTime] = useState(value); - const [timezone, setTimezone] = useState( - timeZoneSelectProps?.value || null + const [selectedDateTime, setSelectedDateTime] = useState( + value + ); + const [selectedTimeZone, setSelectedTimeZone] = useState( + timeZoneSelectProps.value || null ); - const textFieldProps: TextFieldProps = { + const TimePickerFieldProps: TextFieldProps = { label: timeSelectProps?.label ?? 'Select Time', noMarginTop: true, }; + useEffect(() => { + if (value) { + setSelectedDateTime(value); + } + }, [value]); + + useEffect(() => { + if (timeZoneSelectProps.value) { + setSelectedTimeZone(timeZoneSelectProps.value); + } + }, [timeZoneSelectProps.value]); + const handleOpen = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); }; @@ -93,7 +108,7 @@ export const DateTimePicker = ({ const handleApply = () => { setAnchorEl(null); if (onChange) { - onChange(dateTime); + onChange(selectedDateTime); } if (onApply) { onApply(); @@ -101,7 +116,7 @@ export const DateTimePicker = ({ }; const handleDateChange = (newDate: DateTime | null) => { - setDateTime((prev) => + setSelectedDateTime((prev) => newDate ? newDate.set({ hour: prev?.hour || 0, @@ -113,7 +128,7 @@ export const DateTimePicker = ({ const handleTimeChange = (newTime: DateTime | null) => { if (newTime) { - setDateTime((prev) => { + setSelectedDateTime((prev) => { if (prev) { // Ensure hour and minute are valid numbers const newHour = newTime.hour; @@ -127,13 +142,15 @@ export const DateTimePicker = ({ return prev; }); } + if (timeSelectProps.onChange && newTime) { + timeSelectProps.onChange(newTime.toISOTime()); + } }; - const handleTimezoneChange = (newTimezone: string) => { - setTimezone(newTimezone); - setDateTime((prev) => (prev ? prev.setZone(newTimezone) : null)); - if (timeZoneSelectProps?.onChange) { - timeZoneSelectProps.onChange(newTimezone); + const handleTimeZoneChange = (newTimeZone: string) => { + setSelectedTimeZone(newTimeZone); + if (timeZoneSelectProps.onChange) { + timeZoneSelectProps.onChange(newTimeZone); } }; @@ -142,9 +159,11 @@ export const DateTimePicker = ({ ({ + sx={(theme: Theme) => ({ '& .MuiDayCalendar-weekContainer, & .MuiDayCalendar-header': { justifyContent: 'space-between', }, @@ -187,7 +206,7 @@ export const DateTimePicker = ({ borderWidth: '0px', })} onChange={handleDateChange} - value={dateTime} + value={selectedDateTime} {...dateCalendarProps} /> )} @@ -217,8 +236,8 @@ export const DateTimePicker = ({ )} @@ -236,7 +255,7 @@ export const DateTimePicker = ({ label: 'Cancel', onClick: handleClose, }} - sx={(theme) => ({ + sx={(theme: Theme) => ({ marginBottom: theme.spacing(1), marginRight: theme.spacing(2), })} From 5f35d7082e4aa0b8fbbf60f8a1625b45073f13de Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Tue, 3 Dec 2024 19:25:50 -0600 Subject: [PATCH 21/35] PR feedback --- .../components/DatePicker/DateTimePicker.tsx | 4 +- .../DatePicker/DateTimeRangePicker.tsx | 51 ++++++++++++++----- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/components/DatePicker/DateTimePicker.tsx b/packages/ui/src/components/DatePicker/DateTimePicker.tsx index 4d65dc6f1b9..ebe97d07071 100644 --- a/packages/ui/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/ui/src/components/DatePicker/DateTimePicker.tsx @@ -161,9 +161,7 @@ export const DateTimePicker = ({ value={ selectedDateTime ? `${selectedDateTime.toFormat(format)}${ - showTimeZone && selectedTimeZone - ? ` (${selectedTimeZone})` - : '' + selectedTimeZone ? ` (${selectedTimeZone})` : '' }` : '' } diff --git a/packages/ui/src/components/DatePicker/DateTimeRangePicker.tsx b/packages/ui/src/components/DatePicker/DateTimeRangePicker.tsx index ed57af7c37c..49b155b9e39 100644 --- a/packages/ui/src/components/DatePicker/DateTimeRangePicker.tsx +++ b/packages/ui/src/components/DatePicker/DateTimeRangePicker.tsx @@ -12,11 +12,19 @@ interface DateTimeRangePickerProps { /** Format for displaying the date-time */ format?: string; /** Callback when the date-time range changes */ - onChange?: (start: DateTime | null, end: DateTime | null) => void; + onChange?: ( + start: DateTime | null, + end: DateTime | null, + startTimeZone?: null | string + ) => void; + /** Whether to show the timezone field for the end date picker */ + showEndTimeZone?: boolean; /** Initial or controlled value for the start date-time */ startDateTimeValue?: DateTime | null; /** Custom labels for the start and end date/time fields */ startLabel?: string; + /** Initial or controlled value for the start timezone */ + startTimeZoneValue?: null | string; } export const DateTimeRangePicker = ({ @@ -25,7 +33,8 @@ export const DateTimeRangePicker = ({ format = 'yyyy-MM-dd HH:mm', onChange, startDateTimeValue = null, - startLabel = 'Start Date', + startLabel = 'Start Date and Time', + startTimeZoneValue = null, }: DateTimeRangePickerProps) => { const [startDateTime, setStartDateTime] = useState( startDateTimeValue @@ -33,54 +42,72 @@ export const DateTimeRangePicker = ({ const [endDateTime, setEndDateTime] = useState( endDateTimeValue ); + const [startTimeZone, setStartTimeZone] = useState( + startTimeZoneValue + ); const [error, setError] = useState(); const handleStartDateTimeChange = (newStart: DateTime | null) => { setStartDateTime(newStart); - // Reset error if the selection is valid if (endDateTime && newStart && endDateTime >= newStart) { - setError(undefined); + setError(undefined); // Clear error if valid } if (onChange) { - onChange(newStart, endDateTime); + onChange(newStart, endDateTime, startTimeZone); } }; const handleEndDateTimeChange = (newEnd: DateTime | null) => { - // Check if the end date is before the start date if (startDateTime && newEnd && newEnd < startDateTime) { - setError('Invalid date and time'); + setError('End date/time must be after the start date/time.'); } else { setEndDateTime(newEnd); - setError(undefined); // Clear the error if the selection is valid + setError(undefined); + } + + if (onChange) { + onChange(startDateTime, newEnd, startTimeZone); } + }; + + const handleStartTimeZoneChange = (newTimeZone: null | string) => { + setStartTimeZone(newTimeZone); if (onChange) { - onChange(startDateTime, newEnd); + onChange(startDateTime, endDateTime, newTimeZone); } }; return ( + {/* Start DateTime Picker */} + + {/* End DateTime Picker */} From 208b2a07a2b35361a7aa14ed7b5101ccd29d9a03 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Tue, 3 Dec 2024 19:27:51 -0600 Subject: [PATCH 22/35] code cleanup --- packages/ui/src/components/DatePicker/DateTimeRangePicker.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/DatePicker/DateTimeRangePicker.tsx b/packages/ui/src/components/DatePicker/DateTimeRangePicker.tsx index 49b155b9e39..f4cb77b175a 100644 --- a/packages/ui/src/components/DatePicker/DateTimeRangePicker.tsx +++ b/packages/ui/src/components/DatePicker/DateTimeRangePicker.tsx @@ -32,6 +32,7 @@ export const DateTimeRangePicker = ({ endLabel = 'End Date and Time', format = 'yyyy-MM-dd HH:mm', onChange, + showEndTimeZone = false, startDateTimeValue = null, startLabel = 'Start Date and Time', startTimeZoneValue = null, @@ -106,7 +107,7 @@ export const DateTimeRangePicker = ({ format={format} label={endLabel} onChange={handleEndDateTimeChange} - showTimeZone={false} + showTimeZone={showEndTimeZone} timeSelectProps={{ label: 'End Time' }} value={endDateTime} /> From d1c3fa1528d70ebef0508b4888a297965b9768c8 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 4 Dec 2024 09:24:03 -0600 Subject: [PATCH 23/35] Move DatePicker back to src/components --- .../DatePicker/DatePicker.stories.tsx | 43 +++ .../components/DatePicker/DatePicker.test.tsx | 80 ++++++ .../src/components/DatePicker/DatePicker.tsx | 92 ++++++ .../DatePicker/DateTimePicker.stories.tsx | 93 ++++++ .../DatePicker/DateTimePicker.test.tsx | 143 ++++++++++ .../components/DatePicker/DateTimePicker.tsx | 265 ++++++++++++++++++ .../DateTimeRangePicker.stories.tsx | 77 +++++ .../DatePicker/DateTimeRangePicker.test.tsx | 59 ++++ .../DatePicker/DateTimeRangePicker.tsx | 116 ++++++++ .../components/DatePicker/TimeZoneSelect.tsx | 64 +++++ packages/ui/src/components/index.ts | 1 - 11 files changed, 1032 insertions(+), 1 deletion(-) create mode 100644 packages/manager/src/components/DatePicker/DatePicker.stories.tsx create mode 100644 packages/manager/src/components/DatePicker/DatePicker.test.tsx create mode 100644 packages/manager/src/components/DatePicker/DatePicker.tsx create mode 100644 packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx create mode 100644 packages/manager/src/components/DatePicker/DateTimePicker.test.tsx create mode 100644 packages/manager/src/components/DatePicker/DateTimePicker.tsx create mode 100644 packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx create mode 100644 packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx create mode 100644 packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx create mode 100644 packages/manager/src/components/DatePicker/TimeZoneSelect.tsx diff --git a/packages/manager/src/components/DatePicker/DatePicker.stories.tsx b/packages/manager/src/components/DatePicker/DatePicker.stories.tsx new file mode 100644 index 00000000000..db3925b03eb --- /dev/null +++ b/packages/manager/src/components/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,43 @@ +import { action } from '@storybook/addon-actions'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { DatePicker } from './DatePicker'; + +import type { Meta, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +export const ControlledExample: Story = { + render: () => { + const ControlledDatePicker = () => { + const [selectedDate, setSelectedDate] = React.useState( + DateTime.now() + ); + + const handleChange = (newDate: DateTime | null) => { + setSelectedDate(newDate); + action('Controlled date change')(newDate?.toISO()); + }; + + return ( + + ); + }; + + return ; + }, +}; + +const meta: Meta = { + component: DatePicker, + title: 'Components/DatePicker', +}; + +export default meta; diff --git a/packages/manager/src/components/DatePicker/DatePicker.test.tsx b/packages/manager/src/components/DatePicker/DatePicker.test.tsx new file mode 100644 index 00000000000..e051d160ec5 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DatePicker.test.tsx @@ -0,0 +1,80 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DatePicker } from './DatePicker'; + +import type { DatePickerProps } from './DatePicker'; + +const props: DatePickerProps = { + onChange: vi.fn(), + placeholder: 'Pick a date', + textFieldProps: { errorText: 'Invalid date', label: 'Select a date' }, + value: null, +}; + +describe('DatePicker', () => { + it('should render the DatePicker component', () => { + renderWithTheme(); + const DatePickerField = screen.getByRole('textbox', { + name: 'Select a date', + }); + + expect(DatePickerField).toBeVisible(); + }); + + it('should handle value changes', async () => { + renderWithTheme(); + + const calendarButton = screen.getByRole('button', { name: 'Choose date' }); + + // Click the calendar button to open the date picker + await userEvent.click(calendarButton); + + // Find a date button to click (e.g., the 15th of the month) + const dateToSelect = screen.getByRole('gridcell', { name: '15' }); + await userEvent.click(dateToSelect); + + // Check if onChange was called after selecting a date + expect(props.onChange).toHaveBeenCalled(); + }); + + it('should display the error text when provided', () => { + renderWithTheme(); + const errorMessage = screen.getByText('Invalid date'); + expect(errorMessage).toBeVisible(); + }); + + it('should display the helper text when provided', () => { + renderWithTheme(); + const helperText = screen.getByText('Choose a valid date'); + expect(helperText).toBeVisible(); + }); + + it('should use the default format when no format is specified', () => { + renderWithTheme( + + ); + const datePickerField = screen.getByRole('textbox', { + name: 'Select a date', + }); + expect(datePickerField).toHaveValue('2024-10-25'); + }); + + it('should handle the custom format correctly', () => { + renderWithTheme( + + ); + const datePickerField = screen.getByRole('textbox', { + name: 'Select a date', + }); + expect(datePickerField).toHaveValue('25/10/2024'); + }); +}); diff --git a/packages/manager/src/components/DatePicker/DatePicker.tsx b/packages/manager/src/components/DatePicker/DatePicker.tsx new file mode 100644 index 00000000000..a3a5a722e82 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DatePicker.tsx @@ -0,0 +1,92 @@ +import { TextField } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { DatePicker as MuiDatePicker } from '@mui/x-date-pickers/DatePicker'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import React from 'react'; + +import type { TextFieldProps } from '@linode/ui'; +import type { DatePickerProps as MuiDatePickerProps } from '@mui/x-date-pickers/DatePicker'; +import type { DateTime } from 'luxon'; + +export interface DatePickerProps + extends Omit, 'onChange' | 'value'> { + /** Error text to display below the input */ + errorText?: string; + /** Format of the date when rendered in the input field. */ + format?: string; + /** Helper text to display below the input */ + helperText?: string; + /** Label to display for the date picker input */ + label?: string; + /** Callback function fired when the value changes */ + onChange?: (newDate: DateTime | null) => void; + /** Placeholder text for the date picker input */ + placeholder?: string; + /** Additional props to pass to the underlying TextField component */ + textFieldProps?: Omit; + /** The currently selected date */ + value?: DateTime | null; +} + +export const DatePicker = ({ + format = 'yyyy-MM-dd', + helperText = '', + label = 'Select a date', + onChange, + placeholder = 'Pick a date', + textFieldProps, + value = null, + ...props +}: DatePickerProps) => { + const theme = useTheme(); + + const onChangeHandler = (newDate: DateTime | null) => { + if (onChange) { + onChange(newDate); + } + }; + + return ( + + + + ); +}; diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx new file mode 100644 index 00000000000..32d65dfa0b7 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx @@ -0,0 +1,93 @@ +import { action } from '@storybook/addon-actions'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { DateTimePicker } from './DateTimePicker'; + +import type { Meta, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +export const ControlledExample: Story = { + render: () => { + const ControlledDateTimePicker = () => { + const [ + selectedDateTime, + setSelectedDateTime, + ] = React.useState(DateTime.now()); + + const handleChange = (newDateTime: DateTime | null) => { + setSelectedDateTime(newDateTime); + action('Controlled dateTime change')(newDateTime?.toISO()); + }; + + return ( + + ); + }; + + return ; + }, +}; + +export const DefaultExample: Story = { + args: { + label: 'Default Date-Time Picker', + onApply: action('Apply clicked'), + onCancel: action('Cancel clicked'), + onChange: action('Date-Time selected'), + placeholder: 'yyyy-MM-dd HH:mm', + }, +}; + +export const WithErrorText: Story = { + args: { + errorText: 'This field is required', + label: 'Date-Time Picker with Error', + onApply: action('Apply clicked with error'), + onCancel: action('Cancel clicked with error'), + onChange: action('Date-Time selected with error'), + placeholder: 'yyyy-MM-dd HH:mm', + }, +}; + +const meta: Meta = { + argTypes: { + errorText: { + control: { type: 'text' }, + }, + format: { + control: { type: 'text' }, + }, + label: { + control: { type: 'text' }, + }, + onApply: { action: 'applyClicked' }, + onCancel: { action: 'cancelClicked' }, + onChange: { action: 'dateTimeChanged' }, + placeholder: { + control: { type: 'text' }, + }, + }, + args: { + format: 'yyyy-MM-dd HH:mm', + label: 'Date-Time Picker', + placeholder: 'Select a date and time', + }, + component: DateTimePicker, + title: 'Components/DateTimePicker', +}; + +export default meta; diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx new file mode 100644 index 00000000000..12f1795a747 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimePicker.test.tsx @@ -0,0 +1,143 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DateTimePicker } from './DateTimePicker'; + +import type { DateTimePickerProps } from './DateTimePicker'; + +const defaultProps: DateTimePickerProps = { + label: 'Select Date and Time', + onApply: vi.fn(), + onCancel: vi.fn(), + onChange: vi.fn(), + placeholder: 'yyyy-MM-dd HH:mm', + value: DateTime.fromISO('2024-10-25T15:30:00'), +}; + +describe('DateTimePicker Component', () => { + it('should render the DateTimePicker component with the correct label and placeholder', () => { + renderWithTheme(); + const textField = screen.getByRole('textbox', { + name: 'Select Date and Time', + }); + expect(textField).toBeVisible(); + expect(textField).toHaveAttribute('placeholder', 'yyyy-MM-dd HH:mm'); + }); + + it('should open the Popover when the TextField is clicked', async () => { + renderWithTheme(); + const textField = screen.getByRole('textbox', { + name: 'Select Date and Time', + }); + await userEvent.click(textField); + expect(screen.getByRole('dialog')).toBeVisible(); // Verifying the Popover is open + }); + + it('should call onCancel when the Cancel button is clicked', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + const cancelButton = screen.getByRole('button', { name: /Cancel/i }); + await userEvent.click(cancelButton); + expect(defaultProps.onCancel).toHaveBeenCalled(); + }); + + it('should call onApply when the Apply button is clicked', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + const applyButton = screen.getByRole('button', { name: /Apply/i }); + await userEvent.click(applyButton); + expect(defaultProps.onApply).toHaveBeenCalled(); + expect(defaultProps.onChange).toHaveBeenCalledWith(expect.any(DateTime)); // Ensuring onChange was called with a DateTime object + }); + + it('should handle date changes correctly', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + + // Simulate selecting a date (e.g., 15th of the month) + const dateButton = screen.getByRole('gridcell', { name: '15' }); + await userEvent.click(dateButton); + + // Check that the displayed value has been updated correctly (this assumes the date format) + expect(defaultProps.onChange).toHaveBeenCalled(); + }); + + it('should handle timezone changes correctly', async () => { + const timezoneChangeMock = vi.fn(); // Create a mock function + + const updatedProps = { + ...defaultProps, + timeZoneSelectProps: { onChange: timezoneChangeMock, value: 'UTC' }, + }; + + renderWithTheme(); + + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + + // Simulate selecting a timezone from the TimeZoneSelect + const timezoneInput = screen.getByPlaceholderText(/Choose a Timezone/i); + await userEvent.click(timezoneInput); + + // Select a timezone from the dropdown options + await userEvent.click( + screen.getByRole('option', { name: '(GMT -11:00) Niue Time' }) + ); + + // Click the Apply button to trigger the change + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Verify that the onChange function was called with the expected value + expect(timezoneChangeMock).toHaveBeenCalledWith('Pacific/Niue'); + }); + + it('should display the error text when provided', () => { + renderWithTheme( + + ); + expect(screen.getByText(/Invalid date-time/i)).toBeVisible(); + }); + + it('should format the date-time correctly when a custom format is provided', () => { + renderWithTheme( + + ); + const textField = screen.getByRole('textbox', { + name: 'Select Date and Time', + }); + + expect(textField).toHaveValue('25/10/2024 15:30'); + }); + it('should not render the time selector when showTime is false', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + const timePicker = screen.queryByLabelText(/Select Time/i); // Label from timeSelectProps + expect(timePicker).not.toBeInTheDocument(); + }); + + it('should not render the timezone selector when showTimeZone is false', async () => { + renderWithTheme(); + await userEvent.click( + screen.getByRole('textbox', { name: 'Select Date and Time' }) + ); + const timeZoneSelect = screen.queryByLabelText(/Timezone/i); // Label from timeZoneSelectProps + expect(timeZoneSelect).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx new file mode 100644 index 00000000000..027b25822d2 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -0,0 +1,265 @@ +import { TextField } from '@linode/ui'; +import { Divider } from '@linode/ui'; +import { Box } from '@linode/ui'; +import { Grid, Popover } from '@mui/material'; +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { TimePicker } from '@mui/x-date-pickers/TimePicker'; +import React, { useEffect, useState } from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; + +import { TimeZoneSelect } from './TimeZoneSelect'; + +import type { TextFieldProps } from '@linode/ui'; +import type { Theme } from '@mui/material/styles'; +import type { DateCalendarProps } from '@mui/x-date-pickers/DateCalendar'; +import type { DateTime } from 'luxon'; + +export interface DateTimePickerProps { + /** Additional props for the DateCalendar */ + dateCalendarProps?: Partial>; + /** Error text for the date picker field */ + errorText?: string; + /** Format for displaying the date-time */ + format?: string; + /** Label for the input field */ + label?: string; + /** Callback when the "Apply" button is clicked */ + onApply?: () => void; + /** Callback when the "Cancel" button is clicked */ + onCancel?: () => void; + /** Callback when date-time changes */ + onChange?: (dateTime: DateTime | null) => void; + /** Placeholder text for the input field */ + placeholder?: string; + /** Whether to show the time selector */ + showTime?: boolean; + /** Whether to show the timezone selector */ + showTimeZone?: boolean; + /** Props for customizing the TimePicker component */ + timeSelectProps?: { + label?: string; + onChange?: (time: null | string) => void; + value?: null | string; + }; + /** Props for customizing the TimeZoneSelect component */ + timeZoneSelectProps?: { + label?: string; + onChange?: (timezone: string) => void; + value?: null | string; + }; + /** Initial or controlled dateTime value */ + value?: DateTime | null; +} + +export const DateTimePicker = ({ + dateCalendarProps = {}, + errorText = '', + format = 'yyyy-MM-dd HH:mm', + label = 'Select Date and Time', + onApply, + onCancel, + onChange, + placeholder = 'yyyy-MM-dd HH:mm', + showTime = true, + showTimeZone = true, + timeSelectProps = {}, + timeZoneSelectProps = {}, + value = null, +}: DateTimePickerProps) => { + const [anchorEl, setAnchorEl] = useState(null); + const [selectedDateTime, setSelectedDateTime] = useState( + value + ); + const [selectedTimeZone, setSelectedTimeZone] = useState( + timeZoneSelectProps.value || null + ); + + const TimePickerFieldProps: TextFieldProps = { + label: timeSelectProps?.label ?? 'Select Time', + noMarginTop: true, + }; + + useEffect(() => { + if (value) { + setSelectedDateTime(value); + } + }, [value]); + + useEffect(() => { + if (timeZoneSelectProps.value) { + setSelectedTimeZone(timeZoneSelectProps.value); + } + }, [timeZoneSelectProps.value]); + + const handleOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + if (onCancel) { + onCancel(); + } + }; + + const handleApply = () => { + setAnchorEl(null); + if (onChange) { + onChange(selectedDateTime); + } + if (onApply) { + onApply(); + } + }; + + const handleDateChange = (newDate: DateTime | null) => { + setSelectedDateTime((prev) => + newDate + ? newDate.set({ + hour: prev?.hour || 0, + minute: prev?.minute || 0, + }) + : null + ); + }; + + const handleTimeChange = (newTime: DateTime | null) => { + if (newTime) { + setSelectedDateTime((prev) => { + if (prev) { + // Ensure hour and minute are valid numbers + const newHour = newTime.hour; + const newMinute = newTime.minute; + + if (typeof newHour === 'number' && typeof newMinute === 'number') { + return prev.set({ hour: newHour, minute: newMinute }); + } + } + // Return the current `prev` value if newTime is invalid + return prev; + }); + } + if (timeSelectProps.onChange && newTime) { + timeSelectProps.onChange(newTime.toISOTime()); + } + }; + + const handleTimeZoneChange = (newTimeZone: string) => { + setSelectedTimeZone(newTimeZone); + if (timeZoneSelectProps.onChange) { + timeZoneSelectProps.onChange(newTimeZone); + } + }; + + return ( + + + + + + + ({ + '& .MuiDayCalendar-weekContainer, & .MuiDayCalendar-header': { + justifyContent: 'space-between', + }, + '& .MuiDayCalendar-weekDayLabel': { + fontSize: '0.875rem', + }, + '& .MuiPickersCalendarHeader-label': { + fontFamily: theme.font.bold, + }, + '& .MuiPickersCalendarHeader-root': { + borderBottom: `1px solid ${theme.borderColors.divider}`, + fontSize: '0.875rem', + paddingBottom: theme.spacing(1), + }, + '& .MuiPickersDay-root': { + fontSize: '0.875rem', + margin: `${theme.spacing(0.5)}px`, + }, + borderRadius: `${theme.spacing(2)}`, + borderWidth: '0px', + })} + onChange={handleDateChange} + value={selectedDateTime} + {...dateCalendarProps} + /> + + {showTime && ( + + + + )} + {showTimeZone && ( + + + + )} + + + + + ({ + marginBottom: theme.spacing(1), + marginRight: theme.spacing(2), + })} + /> + + + + ); +}; diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx new file mode 100644 index 00000000000..70ad6b2a39d --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx @@ -0,0 +1,77 @@ +import { action } from '@storybook/addon-actions'; +import { DateTime } from 'luxon'; +import * as React from 'react'; + +import { DateTimeRangePicker } from './DateTimeRangePicker'; + +import type { Meta, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +export const Default: Story = { + args: { + endLabel: 'End Date and Time', + format: 'yyyy-MM-dd HH:mm', + onChange: action('DateTime range changed'), + startLabel: 'Start Date and Time', + }, + render: (args) => , +}; + +export const WithInitialValues: Story = { + render: () => { + const ComponentWithState: React.FC = () => { + const [start, setStart] = React.useState( + DateTime.now().minus({ days: 1 }) + ); + const [end, setEnd] = React.useState(DateTime.now()); + + const handleDateChange = ( + newStart: DateTime | null, + newEnd: DateTime | null + ) => { + setStart(newStart); + setEnd(newEnd); + action('DateTime range changed')(newStart?.toISO(), newEnd?.toISO()); + }; + + return ( + + ); + }; + + return ; + }, +}; + +const meta: Meta = { + argTypes: { + endLabel: { + control: 'text', + description: 'Label for the end date picker', + }, + format: { + control: 'text', + description: 'Format for displaying the date-time', + }, + onChange: { + action: 'DateTime range changed', + description: 'Callback when the date-time range changes', + }, + startLabel: { + control: 'text', + description: 'Label for the start date picker', + }, + }, + component: DateTimeRangePicker, + title: 'Components/DateTimeRangePicker', +}; + +export default meta; diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx new file mode 100644 index 00000000000..f3421b684c3 --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx @@ -0,0 +1,59 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DateTimeRangePicker } from './DateTimeRangePicker'; + +describe('DateTimeRangePicker Component', () => { + const onChangeMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render start and end DateTimePickers with correct labels', () => { + renderWithTheme(); + + expect(screen.getByLabelText('Start Date')).toBeVisible(); + expect(screen.getByLabelText('End Date and Time')).toBeVisible(); + }); + + it('should call onChange when start date is changed', async () => { + renderWithTheme(); + + // Open start date picker + await userEvent.click(screen.getByLabelText('Start Date')); + + await userEvent.click(screen.getByRole('gridcell', { name: '10' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Check if the onChange function is called with the expected DateTime value + expect(onChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ day: 10 }), + null + ); + }); + + it('should show error when end date-time is before start date-time', async () => { + renderWithTheme(); + + // Set start date-time to the 15th + const startDateField = screen.getByLabelText('Start Date'); + await userEvent.click(startDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '15' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Open the end date picker + const endDateField = screen.getByLabelText('End Date and Time'); + await userEvent.click(endDateField); + + // Check if the date before the start date is disabled via a class or attribute + const invalidDate = screen.getByRole('gridcell', { name: '10' }); + expect(invalidDate).toHaveClass('Mui-disabled'); // or check for the specific attribute used + + // Confirm error message is not shown since the click was blocked + expect(screen.queryByText('Invalid date and Time')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx new file mode 100644 index 00000000000..f4cb77b175a --- /dev/null +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx @@ -0,0 +1,116 @@ +import { Box } from '@linode/ui'; +import { DateTime } from 'luxon'; +import React, { useState } from 'react'; + +import { DateTimePicker } from './DateTimePicker'; + +interface DateTimeRangePickerProps { + /** Initial or controlled value for the end date-time */ + endDateTimeValue?: DateTime | null; + /** Custom labels for the start and end date/time fields */ + endLabel?: string; + /** Format for displaying the date-time */ + format?: string; + /** Callback when the date-time range changes */ + onChange?: ( + start: DateTime | null, + end: DateTime | null, + startTimeZone?: null | string + ) => void; + /** Whether to show the timezone field for the end date picker */ + showEndTimeZone?: boolean; + /** Initial or controlled value for the start date-time */ + startDateTimeValue?: DateTime | null; + /** Custom labels for the start and end date/time fields */ + startLabel?: string; + /** Initial or controlled value for the start timezone */ + startTimeZoneValue?: null | string; +} + +export const DateTimeRangePicker = ({ + endDateTimeValue = null, + endLabel = 'End Date and Time', + format = 'yyyy-MM-dd HH:mm', + onChange, + showEndTimeZone = false, + startDateTimeValue = null, + startLabel = 'Start Date and Time', + startTimeZoneValue = null, +}: DateTimeRangePickerProps) => { + const [startDateTime, setStartDateTime] = useState( + startDateTimeValue + ); + const [endDateTime, setEndDateTime] = useState( + endDateTimeValue + ); + const [startTimeZone, setStartTimeZone] = useState( + startTimeZoneValue + ); + const [error, setError] = useState(); + + const handleStartDateTimeChange = (newStart: DateTime | null) => { + setStartDateTime(newStart); + + if (endDateTime && newStart && endDateTime >= newStart) { + setError(undefined); // Clear error if valid + } + + if (onChange) { + onChange(newStart, endDateTime, startTimeZone); + } + }; + + const handleEndDateTimeChange = (newEnd: DateTime | null) => { + if (startDateTime && newEnd && newEnd < startDateTime) { + setError('End date/time must be after the start date/time.'); + } else { + setEndDateTime(newEnd); + setError(undefined); + } + + if (onChange) { + onChange(startDateTime, newEnd, startTimeZone); + } + }; + + const handleStartTimeZoneChange = (newTimeZone: null | string) => { + setStartTimeZone(newTimeZone); + + if (onChange) { + onChange(startDateTime, endDateTime, newTimeZone); + } + }; + + return ( + + {/* Start DateTime Picker */} + + + {/* End DateTime Picker */} + + + ); +}; diff --git a/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx b/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx new file mode 100644 index 00000000000..19312098007 --- /dev/null +++ b/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx @@ -0,0 +1,64 @@ +import { DateTime } from 'luxon'; +import React from 'react'; + +import { timezones } from 'src/assets/timezones/timezones'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; + +type Timezone = typeof timezones[number]; + +interface TimeZoneSelectProps { + disabled?: boolean; + errorText?: string; + label?: string; + noMarginTop?: boolean; + onChange: (timezone: string) => void; + value: null | string; +} + +const getOptionLabel = ({ label, offset }: Timezone) => { + const minutes = (Math.abs(offset) % 60).toLocaleString(undefined, { + minimumIntegerDigits: 2, + useGrouping: false, + }); + const hours = Math.floor(Math.abs(offset) / 60); + const isPositive = Math.abs(offset) === offset ? '+' : '-'; + + return `(GMT ${isPositive}${hours}:${minutes}) ${label}`; +}; + +const getTimezoneOptions = () => { + return timezones + .map((tz) => { + const offset = DateTime.now().setZone(tz.name).offset; + const label = getOptionLabel({ ...tz, offset }); + return { label, offset, value: tz.name }; + }) + .sort((a, b) => a.offset - b.offset); +}; + +const timezoneOptions = getTimezoneOptions(); + +export const TimeZoneSelect = ({ + disabled = false, + errorText, + label = 'Timezone', + noMarginTop = false, + onChange, + value, +}: TimeZoneSelectProps) => { + return ( + option.value === value) ?? undefined + } + autoHighlight + disabled={disabled} + errorText={errorText} + label={label} + noMarginTop={noMarginTop} + onChange={(e, option) => onChange(option?.value || '')} + options={timezoneOptions} + placeholder="Choose a Timezone" + /> + ); +}; diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index f64e7b4105a..db690f6940e 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -26,4 +26,3 @@ export * from './Tooltip'; export * from './TooltipIcon'; export * from './Typography'; export * from './VisibilityTooltip'; -export * from './DatePicker'; From 6186d267968389e09591eb4e497248b07391288c Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 4 Dec 2024 10:11:33 -0600 Subject: [PATCH 24/35] Reverting changes --- packages/ui/package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 3a85d11a12a..a0926371058 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -20,9 +20,7 @@ "@emotion/styled": "^11.11.0", "@mui/material": "^5.14.7", "react": "^18.2.0", - "react-dom": "^18.2.0", - "@mui/x-date-pickers": "^7.12.0", - "luxon": "3.4.4" + "react-dom": "^18.2.0" }, "scripts": { "start": "tsc -w --preserveWatchOutput", From 2cd83660d85c758088aa3b1e2c8c86f9d19a7045 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 4 Dec 2024 10:30:39 -0600 Subject: [PATCH 25/35] Code cleanup --- .../DatePicker/DatePicker.stories.tsx | 43 --- .../components/DatePicker/DatePicker.test.tsx | 80 ------ .../src/components/DatePicker/DatePicker.tsx | 92 ------ .../DatePicker/DateTimePicker.stories.tsx | 93 ------ .../DatePicker/DateTimePicker.test.tsx | 143 ---------- .../components/DatePicker/DateTimePicker.tsx | 265 ------------------ .../DateTimeRangePicker.stories.tsx | 77 ----- .../DatePicker/DateTimeRangePicker.test.tsx | 59 ---- .../DatePicker/DateTimeRangePicker.tsx | 116 -------- .../components/DatePicker/TimeZoneSelect.tsx | 64 ----- .../ui/src/components/DatePicker/index.ts | 1 - 11 files changed, 1033 deletions(-) delete mode 100644 packages/ui/src/components/DatePicker/DatePicker.stories.tsx delete mode 100644 packages/ui/src/components/DatePicker/DatePicker.test.tsx delete mode 100644 packages/ui/src/components/DatePicker/DatePicker.tsx delete mode 100644 packages/ui/src/components/DatePicker/DateTimePicker.stories.tsx delete mode 100644 packages/ui/src/components/DatePicker/DateTimePicker.test.tsx delete mode 100644 packages/ui/src/components/DatePicker/DateTimePicker.tsx delete mode 100644 packages/ui/src/components/DatePicker/DateTimeRangePicker.stories.tsx delete mode 100644 packages/ui/src/components/DatePicker/DateTimeRangePicker.test.tsx delete mode 100644 packages/ui/src/components/DatePicker/DateTimeRangePicker.tsx delete mode 100644 packages/ui/src/components/DatePicker/TimeZoneSelect.tsx delete mode 100644 packages/ui/src/components/DatePicker/index.ts diff --git a/packages/ui/src/components/DatePicker/DatePicker.stories.tsx b/packages/ui/src/components/DatePicker/DatePicker.stories.tsx deleted file mode 100644 index db3925b03eb..00000000000 --- a/packages/ui/src/components/DatePicker/DatePicker.stories.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { action } from '@storybook/addon-actions'; -import { DateTime } from 'luxon'; -import * as React from 'react'; - -import { DatePicker } from './DatePicker'; - -import type { Meta, StoryObj } from '@storybook/react'; - -type Story = StoryObj; - -export const ControlledExample: Story = { - render: () => { - const ControlledDatePicker = () => { - const [selectedDate, setSelectedDate] = React.useState( - DateTime.now() - ); - - const handleChange = (newDate: DateTime | null) => { - setSelectedDate(newDate); - action('Controlled date change')(newDate?.toISO()); - }; - - return ( - - ); - }; - - return ; - }, -}; - -const meta: Meta = { - component: DatePicker, - title: 'Components/DatePicker', -}; - -export default meta; diff --git a/packages/ui/src/components/DatePicker/DatePicker.test.tsx b/packages/ui/src/components/DatePicker/DatePicker.test.tsx deleted file mode 100644 index e051d160ec5..00000000000 --- a/packages/ui/src/components/DatePicker/DatePicker.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { DateTime } from 'luxon'; -import * as React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { DatePicker } from './DatePicker'; - -import type { DatePickerProps } from './DatePicker'; - -const props: DatePickerProps = { - onChange: vi.fn(), - placeholder: 'Pick a date', - textFieldProps: { errorText: 'Invalid date', label: 'Select a date' }, - value: null, -}; - -describe('DatePicker', () => { - it('should render the DatePicker component', () => { - renderWithTheme(); - const DatePickerField = screen.getByRole('textbox', { - name: 'Select a date', - }); - - expect(DatePickerField).toBeVisible(); - }); - - it('should handle value changes', async () => { - renderWithTheme(); - - const calendarButton = screen.getByRole('button', { name: 'Choose date' }); - - // Click the calendar button to open the date picker - await userEvent.click(calendarButton); - - // Find a date button to click (e.g., the 15th of the month) - const dateToSelect = screen.getByRole('gridcell', { name: '15' }); - await userEvent.click(dateToSelect); - - // Check if onChange was called after selecting a date - expect(props.onChange).toHaveBeenCalled(); - }); - - it('should display the error text when provided', () => { - renderWithTheme(); - const errorMessage = screen.getByText('Invalid date'); - expect(errorMessage).toBeVisible(); - }); - - it('should display the helper text when provided', () => { - renderWithTheme(); - const helperText = screen.getByText('Choose a valid date'); - expect(helperText).toBeVisible(); - }); - - it('should use the default format when no format is specified', () => { - renderWithTheme( - - ); - const datePickerField = screen.getByRole('textbox', { - name: 'Select a date', - }); - expect(datePickerField).toHaveValue('2024-10-25'); - }); - - it('should handle the custom format correctly', () => { - renderWithTheme( - - ); - const datePickerField = screen.getByRole('textbox', { - name: 'Select a date', - }); - expect(datePickerField).toHaveValue('25/10/2024'); - }); -}); diff --git a/packages/ui/src/components/DatePicker/DatePicker.tsx b/packages/ui/src/components/DatePicker/DatePicker.tsx deleted file mode 100644 index a3a5a722e82..00000000000 --- a/packages/ui/src/components/DatePicker/DatePicker.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { TextField } from '@linode/ui'; -import { useTheme } from '@mui/material/styles'; -import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; -import { DatePicker as MuiDatePicker } from '@mui/x-date-pickers/DatePicker'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import React from 'react'; - -import type { TextFieldProps } from '@linode/ui'; -import type { DatePickerProps as MuiDatePickerProps } from '@mui/x-date-pickers/DatePicker'; -import type { DateTime } from 'luxon'; - -export interface DatePickerProps - extends Omit, 'onChange' | 'value'> { - /** Error text to display below the input */ - errorText?: string; - /** Format of the date when rendered in the input field. */ - format?: string; - /** Helper text to display below the input */ - helperText?: string; - /** Label to display for the date picker input */ - label?: string; - /** Callback function fired when the value changes */ - onChange?: (newDate: DateTime | null) => void; - /** Placeholder text for the date picker input */ - placeholder?: string; - /** Additional props to pass to the underlying TextField component */ - textFieldProps?: Omit; - /** The currently selected date */ - value?: DateTime | null; -} - -export const DatePicker = ({ - format = 'yyyy-MM-dd', - helperText = '', - label = 'Select a date', - onChange, - placeholder = 'Pick a date', - textFieldProps, - value = null, - ...props -}: DatePickerProps) => { - const theme = useTheme(); - - const onChangeHandler = (newDate: DateTime | null) => { - if (onChange) { - onChange(newDate); - } - }; - - return ( - - - - ); -}; diff --git a/packages/ui/src/components/DatePicker/DateTimePicker.stories.tsx b/packages/ui/src/components/DatePicker/DateTimePicker.stories.tsx deleted file mode 100644 index 32d65dfa0b7..00000000000 --- a/packages/ui/src/components/DatePicker/DateTimePicker.stories.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { action } from '@storybook/addon-actions'; -import { DateTime } from 'luxon'; -import * as React from 'react'; - -import { DateTimePicker } from './DateTimePicker'; - -import type { Meta, StoryObj } from '@storybook/react'; - -type Story = StoryObj; - -export const ControlledExample: Story = { - render: () => { - const ControlledDateTimePicker = () => { - const [ - selectedDateTime, - setSelectedDateTime, - ] = React.useState(DateTime.now()); - - const handleChange = (newDateTime: DateTime | null) => { - setSelectedDateTime(newDateTime); - action('Controlled dateTime change')(newDateTime?.toISO()); - }; - - return ( - - ); - }; - - return ; - }, -}; - -export const DefaultExample: Story = { - args: { - label: 'Default Date-Time Picker', - onApply: action('Apply clicked'), - onCancel: action('Cancel clicked'), - onChange: action('Date-Time selected'), - placeholder: 'yyyy-MM-dd HH:mm', - }, -}; - -export const WithErrorText: Story = { - args: { - errorText: 'This field is required', - label: 'Date-Time Picker with Error', - onApply: action('Apply clicked with error'), - onCancel: action('Cancel clicked with error'), - onChange: action('Date-Time selected with error'), - placeholder: 'yyyy-MM-dd HH:mm', - }, -}; - -const meta: Meta = { - argTypes: { - errorText: { - control: { type: 'text' }, - }, - format: { - control: { type: 'text' }, - }, - label: { - control: { type: 'text' }, - }, - onApply: { action: 'applyClicked' }, - onCancel: { action: 'cancelClicked' }, - onChange: { action: 'dateTimeChanged' }, - placeholder: { - control: { type: 'text' }, - }, - }, - args: { - format: 'yyyy-MM-dd HH:mm', - label: 'Date-Time Picker', - placeholder: 'Select a date and time', - }, - component: DateTimePicker, - title: 'Components/DateTimePicker', -}; - -export default meta; diff --git a/packages/ui/src/components/DatePicker/DateTimePicker.test.tsx b/packages/ui/src/components/DatePicker/DateTimePicker.test.tsx deleted file mode 100644 index 12f1795a747..00000000000 --- a/packages/ui/src/components/DatePicker/DateTimePicker.test.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { DateTime } from 'luxon'; -import * as React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { DateTimePicker } from './DateTimePicker'; - -import type { DateTimePickerProps } from './DateTimePicker'; - -const defaultProps: DateTimePickerProps = { - label: 'Select Date and Time', - onApply: vi.fn(), - onCancel: vi.fn(), - onChange: vi.fn(), - placeholder: 'yyyy-MM-dd HH:mm', - value: DateTime.fromISO('2024-10-25T15:30:00'), -}; - -describe('DateTimePicker Component', () => { - it('should render the DateTimePicker component with the correct label and placeholder', () => { - renderWithTheme(); - const textField = screen.getByRole('textbox', { - name: 'Select Date and Time', - }); - expect(textField).toBeVisible(); - expect(textField).toHaveAttribute('placeholder', 'yyyy-MM-dd HH:mm'); - }); - - it('should open the Popover when the TextField is clicked', async () => { - renderWithTheme(); - const textField = screen.getByRole('textbox', { - name: 'Select Date and Time', - }); - await userEvent.click(textField); - expect(screen.getByRole('dialog')).toBeVisible(); // Verifying the Popover is open - }); - - it('should call onCancel when the Cancel button is clicked', async () => { - renderWithTheme(); - await userEvent.click( - screen.getByRole('textbox', { name: 'Select Date and Time' }) - ); - const cancelButton = screen.getByRole('button', { name: /Cancel/i }); - await userEvent.click(cancelButton); - expect(defaultProps.onCancel).toHaveBeenCalled(); - }); - - it('should call onApply when the Apply button is clicked', async () => { - renderWithTheme(); - await userEvent.click( - screen.getByRole('textbox', { name: 'Select Date and Time' }) - ); - const applyButton = screen.getByRole('button', { name: /Apply/i }); - await userEvent.click(applyButton); - expect(defaultProps.onApply).toHaveBeenCalled(); - expect(defaultProps.onChange).toHaveBeenCalledWith(expect.any(DateTime)); // Ensuring onChange was called with a DateTime object - }); - - it('should handle date changes correctly', async () => { - renderWithTheme(); - await userEvent.click( - screen.getByRole('textbox', { name: 'Select Date and Time' }) - ); - - // Simulate selecting a date (e.g., 15th of the month) - const dateButton = screen.getByRole('gridcell', { name: '15' }); - await userEvent.click(dateButton); - - // Check that the displayed value has been updated correctly (this assumes the date format) - expect(defaultProps.onChange).toHaveBeenCalled(); - }); - - it('should handle timezone changes correctly', async () => { - const timezoneChangeMock = vi.fn(); // Create a mock function - - const updatedProps = { - ...defaultProps, - timeZoneSelectProps: { onChange: timezoneChangeMock, value: 'UTC' }, - }; - - renderWithTheme(); - - await userEvent.click( - screen.getByRole('textbox', { name: 'Select Date and Time' }) - ); - - // Simulate selecting a timezone from the TimeZoneSelect - const timezoneInput = screen.getByPlaceholderText(/Choose a Timezone/i); - await userEvent.click(timezoneInput); - - // Select a timezone from the dropdown options - await userEvent.click( - screen.getByRole('option', { name: '(GMT -11:00) Niue Time' }) - ); - - // Click the Apply button to trigger the change - await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - - // Verify that the onChange function was called with the expected value - expect(timezoneChangeMock).toHaveBeenCalledWith('Pacific/Niue'); - }); - - it('should display the error text when provided', () => { - renderWithTheme( - - ); - expect(screen.getByText(/Invalid date-time/i)).toBeVisible(); - }); - - it('should format the date-time correctly when a custom format is provided', () => { - renderWithTheme( - - ); - const textField = screen.getByRole('textbox', { - name: 'Select Date and Time', - }); - - expect(textField).toHaveValue('25/10/2024 15:30'); - }); - it('should not render the time selector when showTime is false', async () => { - renderWithTheme(); - await userEvent.click( - screen.getByRole('textbox', { name: 'Select Date and Time' }) - ); - const timePicker = screen.queryByLabelText(/Select Time/i); // Label from timeSelectProps - expect(timePicker).not.toBeInTheDocument(); - }); - - it('should not render the timezone selector when showTimeZone is false', async () => { - renderWithTheme(); - await userEvent.click( - screen.getByRole('textbox', { name: 'Select Date and Time' }) - ); - const timeZoneSelect = screen.queryByLabelText(/Timezone/i); // Label from timeZoneSelectProps - expect(timeZoneSelect).not.toBeInTheDocument(); - }); -}); diff --git a/packages/ui/src/components/DatePicker/DateTimePicker.tsx b/packages/ui/src/components/DatePicker/DateTimePicker.tsx deleted file mode 100644 index ebe97d07071..00000000000 --- a/packages/ui/src/components/DatePicker/DateTimePicker.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { TextField } from '@linode/ui'; -import { Divider } from '@linode/ui'; -import { Box } from '@linode/ui'; -import { Grid, Popover } from '@mui/material'; -import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; -import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import { TimePicker } from '@mui/x-date-pickers/TimePicker'; -import React, { useEffect, useState } from 'react'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; - -import { TimeZoneSelect } from './TimeZoneSelect'; - -import type { TextFieldProps } from '@linode/ui'; -import type { Theme } from '@mui/material/styles'; -import type { DateCalendarProps } from '@mui/x-date-pickers/DateCalendar'; -import type { DateTime } from 'luxon'; - -export interface DateTimePickerProps { - /** Additional props for the DateCalendar */ - dateCalendarProps?: Partial>; - /** Error text for the date picker field */ - errorText?: string; - /** Format for displaying the date-time */ - format?: string; - /** Label for the input field */ - label?: string; - /** Callback when the "Apply" button is clicked */ - onApply?: () => void; - /** Callback when the "Cancel" button is clicked */ - onCancel?: () => void; - /** Callback when date-time changes */ - onChange?: (dateTime: DateTime | null) => void; - /** Placeholder text for the input field */ - placeholder?: string; - /** Whether to show the time selector */ - showTime?: boolean; - /** Whether to show the timezone selector */ - showTimeZone?: boolean; - /** Props for customizing the TimePicker component */ - timeSelectProps?: { - label?: string; - onChange?: (time: string) => void; - value?: null | string; - }; - /** Props for customizing the TimeZoneSelect component */ - timeZoneSelectProps?: { - label?: string; - onChange?: (timezone: string) => void; - value?: null | string; - }; - /** Initial or controlled dateTime value */ - value?: DateTime | null; -} - -export const DateTimePicker = ({ - dateCalendarProps = {}, - errorText = '', - format = 'yyyy-MM-dd HH:mm', - label = 'Select Date and Time', - onApply, - onCancel, - onChange, - placeholder = 'yyyy-MM-dd HH:mm', - showTime = true, - showTimeZone = true, - timeSelectProps = {}, - timeZoneSelectProps = {}, - value = null, -}: DateTimePickerProps) => { - const [anchorEl, setAnchorEl] = useState(null); - const [selectedDateTime, setSelectedDateTime] = useState( - value - ); - const [selectedTimeZone, setSelectedTimeZone] = useState( - timeZoneSelectProps.value || null - ); - - const TimePickerFieldProps: TextFieldProps = { - label: timeSelectProps?.label ?? 'Select Time', - noMarginTop: true, - }; - - useEffect(() => { - if (value) { - setSelectedDateTime(value); - } - }, [value]); - - useEffect(() => { - if (timeZoneSelectProps.value) { - setSelectedTimeZone(timeZoneSelectProps.value); - } - }, [timeZoneSelectProps.value]); - - const handleOpen = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); - if (onCancel) { - onCancel(); - } - }; - - const handleApply = () => { - setAnchorEl(null); - if (onChange) { - onChange(selectedDateTime); - } - if (onApply) { - onApply(); - } - }; - - const handleDateChange = (newDate: DateTime | null) => { - setSelectedDateTime((prev) => - newDate - ? newDate.set({ - hour: prev?.hour || 0, - minute: prev?.minute || 0, - }) - : null - ); - }; - - const handleTimeChange = (newTime: DateTime | null) => { - if (newTime) { - setSelectedDateTime((prev) => { - if (prev) { - // Ensure hour and minute are valid numbers - const newHour = newTime.hour; - const newMinute = newTime.minute; - - if (typeof newHour === 'number' && typeof newMinute === 'number') { - return prev.set({ hour: newHour, minute: newMinute }); - } - } - // Return the current `prev` value if newTime is invalid - return prev; - }); - } - if (timeSelectProps.onChange && newTime) { - timeSelectProps.onChange(newTime.toISOTime()); - } - }; - - const handleTimeZoneChange = (newTimeZone: string) => { - setSelectedTimeZone(newTimeZone); - if (timeZoneSelectProps.onChange) { - timeZoneSelectProps.onChange(newTimeZone); - } - }; - - return ( - - - - - - - ({ - '& .MuiDayCalendar-weekContainer, & .MuiDayCalendar-header': { - justifyContent: 'space-between', - }, - '& .MuiDayCalendar-weekDayLabel': { - fontSize: '0.875rem', - }, - '& .MuiPickersCalendarHeader-label': { - fontFamily: theme.font.bold, - }, - '& .MuiPickersCalendarHeader-root': { - borderBottom: `1px solid ${theme.borderColors.divider}`, - fontSize: '0.875rem', - paddingBottom: theme.spacing(1), - }, - '& .MuiPickersDay-root': { - fontSize: '0.875rem', - margin: `${theme.spacing(0.5)}px`, - }, - borderRadius: `${theme.spacing(2)}`, - borderWidth: '0px', - })} - onChange={handleDateChange} - value={selectedDateTime} - {...dateCalendarProps} - /> - - {showTime && ( - - - - )} - {showTimeZone && ( - - - - )} - - - - - ({ - marginBottom: theme.spacing(1), - marginRight: theme.spacing(2), - })} - /> - - - - ); -}; diff --git a/packages/ui/src/components/DatePicker/DateTimeRangePicker.stories.tsx b/packages/ui/src/components/DatePicker/DateTimeRangePicker.stories.tsx deleted file mode 100644 index 70ad6b2a39d..00000000000 --- a/packages/ui/src/components/DatePicker/DateTimeRangePicker.stories.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { action } from '@storybook/addon-actions'; -import { DateTime } from 'luxon'; -import * as React from 'react'; - -import { DateTimeRangePicker } from './DateTimeRangePicker'; - -import type { Meta, StoryObj } from '@storybook/react'; - -type Story = StoryObj; - -export const Default: Story = { - args: { - endLabel: 'End Date and Time', - format: 'yyyy-MM-dd HH:mm', - onChange: action('DateTime range changed'), - startLabel: 'Start Date and Time', - }, - render: (args) => , -}; - -export const WithInitialValues: Story = { - render: () => { - const ComponentWithState: React.FC = () => { - const [start, setStart] = React.useState( - DateTime.now().minus({ days: 1 }) - ); - const [end, setEnd] = React.useState(DateTime.now()); - - const handleDateChange = ( - newStart: DateTime | null, - newEnd: DateTime | null - ) => { - setStart(newStart); - setEnd(newEnd); - action('DateTime range changed')(newStart?.toISO(), newEnd?.toISO()); - }; - - return ( - - ); - }; - - return ; - }, -}; - -const meta: Meta = { - argTypes: { - endLabel: { - control: 'text', - description: 'Label for the end date picker', - }, - format: { - control: 'text', - description: 'Format for displaying the date-time', - }, - onChange: { - action: 'DateTime range changed', - description: 'Callback when the date-time range changes', - }, - startLabel: { - control: 'text', - description: 'Label for the start date picker', - }, - }, - component: DateTimeRangePicker, - title: 'Components/DateTimeRangePicker', -}; - -export default meta; diff --git a/packages/ui/src/components/DatePicker/DateTimeRangePicker.test.tsx b/packages/ui/src/components/DatePicker/DateTimeRangePicker.test.tsx deleted file mode 100644 index f3421b684c3..00000000000 --- a/packages/ui/src/components/DatePicker/DateTimeRangePicker.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import * as React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { DateTimeRangePicker } from './DateTimeRangePicker'; - -describe('DateTimeRangePicker Component', () => { - const onChangeMock = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should render start and end DateTimePickers with correct labels', () => { - renderWithTheme(); - - expect(screen.getByLabelText('Start Date')).toBeVisible(); - expect(screen.getByLabelText('End Date and Time')).toBeVisible(); - }); - - it('should call onChange when start date is changed', async () => { - renderWithTheme(); - - // Open start date picker - await userEvent.click(screen.getByLabelText('Start Date')); - - await userEvent.click(screen.getByRole('gridcell', { name: '10' })); - await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - - // Check if the onChange function is called with the expected DateTime value - expect(onChangeMock).toHaveBeenCalledWith( - expect.objectContaining({ day: 10 }), - null - ); - }); - - it('should show error when end date-time is before start date-time', async () => { - renderWithTheme(); - - // Set start date-time to the 15th - const startDateField = screen.getByLabelText('Start Date'); - await userEvent.click(startDateField); - await userEvent.click(screen.getByRole('gridcell', { name: '15' })); - await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - - // Open the end date picker - const endDateField = screen.getByLabelText('End Date and Time'); - await userEvent.click(endDateField); - - // Check if the date before the start date is disabled via a class or attribute - const invalidDate = screen.getByRole('gridcell', { name: '10' }); - expect(invalidDate).toHaveClass('Mui-disabled'); // or check for the specific attribute used - - // Confirm error message is not shown since the click was blocked - expect(screen.queryByText('Invalid date and Time')).not.toBeInTheDocument(); - }); -}); diff --git a/packages/ui/src/components/DatePicker/DateTimeRangePicker.tsx b/packages/ui/src/components/DatePicker/DateTimeRangePicker.tsx deleted file mode 100644 index f4cb77b175a..00000000000 --- a/packages/ui/src/components/DatePicker/DateTimeRangePicker.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { Box } from '@linode/ui'; -import { DateTime } from 'luxon'; -import React, { useState } from 'react'; - -import { DateTimePicker } from './DateTimePicker'; - -interface DateTimeRangePickerProps { - /** Initial or controlled value for the end date-time */ - endDateTimeValue?: DateTime | null; - /** Custom labels for the start and end date/time fields */ - endLabel?: string; - /** Format for displaying the date-time */ - format?: string; - /** Callback when the date-time range changes */ - onChange?: ( - start: DateTime | null, - end: DateTime | null, - startTimeZone?: null | string - ) => void; - /** Whether to show the timezone field for the end date picker */ - showEndTimeZone?: boolean; - /** Initial or controlled value for the start date-time */ - startDateTimeValue?: DateTime | null; - /** Custom labels for the start and end date/time fields */ - startLabel?: string; - /** Initial or controlled value for the start timezone */ - startTimeZoneValue?: null | string; -} - -export const DateTimeRangePicker = ({ - endDateTimeValue = null, - endLabel = 'End Date and Time', - format = 'yyyy-MM-dd HH:mm', - onChange, - showEndTimeZone = false, - startDateTimeValue = null, - startLabel = 'Start Date and Time', - startTimeZoneValue = null, -}: DateTimeRangePickerProps) => { - const [startDateTime, setStartDateTime] = useState( - startDateTimeValue - ); - const [endDateTime, setEndDateTime] = useState( - endDateTimeValue - ); - const [startTimeZone, setStartTimeZone] = useState( - startTimeZoneValue - ); - const [error, setError] = useState(); - - const handleStartDateTimeChange = (newStart: DateTime | null) => { - setStartDateTime(newStart); - - if (endDateTime && newStart && endDateTime >= newStart) { - setError(undefined); // Clear error if valid - } - - if (onChange) { - onChange(newStart, endDateTime, startTimeZone); - } - }; - - const handleEndDateTimeChange = (newEnd: DateTime | null) => { - if (startDateTime && newEnd && newEnd < startDateTime) { - setError('End date/time must be after the start date/time.'); - } else { - setEndDateTime(newEnd); - setError(undefined); - } - - if (onChange) { - onChange(startDateTime, newEnd, startTimeZone); - } - }; - - const handleStartTimeZoneChange = (newTimeZone: null | string) => { - setStartTimeZone(newTimeZone); - - if (onChange) { - onChange(startDateTime, endDateTime, newTimeZone); - } - }; - - return ( - - {/* Start DateTime Picker */} - - - {/* End DateTime Picker */} - - - ); -}; diff --git a/packages/ui/src/components/DatePicker/TimeZoneSelect.tsx b/packages/ui/src/components/DatePicker/TimeZoneSelect.tsx deleted file mode 100644 index 19312098007..00000000000 --- a/packages/ui/src/components/DatePicker/TimeZoneSelect.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { DateTime } from 'luxon'; -import React from 'react'; - -import { timezones } from 'src/assets/timezones/timezones'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; - -type Timezone = typeof timezones[number]; - -interface TimeZoneSelectProps { - disabled?: boolean; - errorText?: string; - label?: string; - noMarginTop?: boolean; - onChange: (timezone: string) => void; - value: null | string; -} - -const getOptionLabel = ({ label, offset }: Timezone) => { - const minutes = (Math.abs(offset) % 60).toLocaleString(undefined, { - minimumIntegerDigits: 2, - useGrouping: false, - }); - const hours = Math.floor(Math.abs(offset) / 60); - const isPositive = Math.abs(offset) === offset ? '+' : '-'; - - return `(GMT ${isPositive}${hours}:${minutes}) ${label}`; -}; - -const getTimezoneOptions = () => { - return timezones - .map((tz) => { - const offset = DateTime.now().setZone(tz.name).offset; - const label = getOptionLabel({ ...tz, offset }); - return { label, offset, value: tz.name }; - }) - .sort((a, b) => a.offset - b.offset); -}; - -const timezoneOptions = getTimezoneOptions(); - -export const TimeZoneSelect = ({ - disabled = false, - errorText, - label = 'Timezone', - noMarginTop = false, - onChange, - value, -}: TimeZoneSelectProps) => { - return ( - option.value === value) ?? undefined - } - autoHighlight - disabled={disabled} - errorText={errorText} - label={label} - noMarginTop={noMarginTop} - onChange={(e, option) => onChange(option?.value || '')} - options={timezoneOptions} - placeholder="Choose a Timezone" - /> - ); -}; diff --git a/packages/ui/src/components/DatePicker/index.ts b/packages/ui/src/components/DatePicker/index.ts deleted file mode 100644 index a48b62e4dfc..00000000000 --- a/packages/ui/src/components/DatePicker/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './DatePicker'; From b8f2d739124874ee61e827beb3a7fa7c5f6347d9 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:51:16 -0600 Subject: [PATCH 26/35] Adjust broken tests --- .../src/components/DatePicker/DateTimeRangePicker.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx index f3421b684c3..c1303d84437 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx @@ -16,7 +16,7 @@ describe('DateTimeRangePicker Component', () => { it('should render start and end DateTimePickers with correct labels', () => { renderWithTheme(); - expect(screen.getByLabelText('Start Date')).toBeVisible(); + expect(screen.getByLabelText('Start Date and Time')).toBeVisible(); expect(screen.getByLabelText('End Date and Time')).toBeVisible(); }); @@ -24,7 +24,7 @@ describe('DateTimeRangePicker Component', () => { renderWithTheme(); // Open start date picker - await userEvent.click(screen.getByLabelText('Start Date')); + await userEvent.click(screen.getByLabelText('Start Date and Time')); await userEvent.click(screen.getByRole('gridcell', { name: '10' })); await userEvent.click(screen.getByRole('button', { name: 'Apply' })); @@ -32,6 +32,7 @@ describe('DateTimeRangePicker Component', () => { // Check if the onChange function is called with the expected DateTime value expect(onChangeMock).toHaveBeenCalledWith( expect.objectContaining({ day: 10 }), + null, null ); }); @@ -40,7 +41,7 @@ describe('DateTimeRangePicker Component', () => { renderWithTheme(); // Set start date-time to the 15th - const startDateField = screen.getByLabelText('Start Date'); + const startDateField = screen.getByLabelText('Start Date and Time'); await userEvent.click(startDateField); await userEvent.click(screen.getByRole('gridcell', { name: '15' })); await userEvent.click(screen.getByRole('button', { name: 'Apply' })); From e52304339f8884bbbc4d3ea21ea2fd06554b0040 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:58:14 -0600 Subject: [PATCH 27/35] Update TimeZoneSelect.tsx --- packages/manager/src/components/DatePicker/TimeZoneSelect.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx b/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx index 19312098007..f4bd68c97a3 100644 --- a/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx +++ b/packages/manager/src/components/DatePicker/TimeZoneSelect.tsx @@ -1,8 +1,8 @@ +import { Autocomplete } from '@linode/ui'; import { DateTime } from 'luxon'; import React from 'react'; import { timezones } from 'src/assets/timezones/timezones'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; type Timezone = typeof timezones[number]; From f0dd5c09520d39c3c8b5e802ef1e41a75e63305c Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Mon, 9 Dec 2024 11:50:57 -0600 Subject: [PATCH 28/35] Code cleanup --- .../DatePicker/DatePicker.stories.tsx | 7 +- .../DatePicker/DateTimePicker.stories.tsx | 5 +- .../components/DatePicker/DateTimePicker.tsx | 77 +++++++------------ .../DatePicker/DateTimeRangePicker.tsx | 6 +- 4 files changed, 40 insertions(+), 55 deletions(-) diff --git a/packages/manager/src/components/DatePicker/DatePicker.stories.tsx b/packages/manager/src/components/DatePicker/DatePicker.stories.tsx index db3925b03eb..6e9d4408783 100644 --- a/packages/manager/src/components/DatePicker/DatePicker.stories.tsx +++ b/packages/manager/src/components/DatePicker/DatePicker.stories.tsx @@ -1,19 +1,17 @@ import { action } from '@storybook/addon-actions'; -import { DateTime } from 'luxon'; import * as React from 'react'; import { DatePicker } from './DatePicker'; import type { Meta, StoryObj } from '@storybook/react'; +import type { DateTime } from 'luxon'; type Story = StoryObj; export const ControlledExample: Story = { render: () => { const ControlledDatePicker = () => { - const [selectedDate, setSelectedDate] = React.useState( - DateTime.now() - ); + const [selectedDate, setSelectedDate] = React.useState(); const handleChange = (newDate: DateTime | null) => { setSelectedDate(newDate); @@ -26,6 +24,7 @@ export const ControlledExample: Story = { helperText="This is a controlled DatePicker" label="Controlled Date Picker" onChange={handleChange} + placeholder="yyyy-MM-dd" value={selectedDate} /> ); diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx index 32d65dfa0b7..bb3f0c48233 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx @@ -1,10 +1,10 @@ import { action } from '@storybook/addon-actions'; -import { DateTime } from 'luxon'; import * as React from 'react'; import { DateTimePicker } from './DateTimePicker'; import type { Meta, StoryObj } from '@storybook/react'; +import type { DateTime } from 'luxon'; type Story = StoryObj; @@ -14,7 +14,7 @@ export const ControlledExample: Story = { const [ selectedDateTime, setSelectedDateTime, - ] = React.useState(DateTime.now()); + ] = React.useState(null); // Start with null const handleChange = (newDateTime: DateTime | null) => { setSelectedDateTime(newDateTime); @@ -32,6 +32,7 @@ export const ControlledExample: Story = { onCancel={action('Cancel clicked')} onChange={handleChange} placeholder="yyyy-MM-dd HH:mm" + showTimeZone={false} timeSelectProps={{ label: 'Select Time' }} value={selectedDateTime} /> diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx index 027b25822d2..a67b5ccb155 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -82,39 +82,6 @@ export const DateTimePicker = ({ noMarginTop: true, }; - useEffect(() => { - if (value) { - setSelectedDateTime(value); - } - }, [value]); - - useEffect(() => { - if (timeZoneSelectProps.value) { - setSelectedTimeZone(timeZoneSelectProps.value); - } - }, [timeZoneSelectProps.value]); - - const handleOpen = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); - if (onCancel) { - onCancel(); - } - }; - - const handleApply = () => { - setAnchorEl(null); - if (onChange) { - onChange(selectedDateTime); - } - if (onApply) { - onApply(); - } - }; - const handleDateChange = (newDate: DateTime | null) => { setSelectedDateTime((prev) => newDate @@ -130,7 +97,6 @@ export const DateTimePicker = ({ if (newTime) { setSelectedDateTime((prev) => { if (prev) { - // Ensure hour and minute are valid numbers const newHour = newTime.hour; const newMinute = newTime.minute; @@ -138,13 +104,9 @@ export const DateTimePicker = ({ return prev.set({ hour: newHour, minute: newMinute }); } } - // Return the current `prev` value if newTime is invalid return prev; }); } - if (timeSelectProps.onChange && newTime) { - timeSelectProps.onChange(newTime.toISOTime()); - } }; const handleTimeZoneChange = (newTimeZone: string) => { @@ -154,6 +116,29 @@ export const DateTimePicker = ({ } }; + const handleApply = () => { + setAnchorEl(null); + if (onChange) { + onChange(selectedDateTime); + } + if (onApply) { + onApply(); + } + }; + + const handleClose = () => { + setAnchorEl(null); + if (onCancel) { + onCancel(); + } + }; + + useEffect(() => { + if (timeZoneSelectProps.value) { + setSelectedTimeZone(timeZoneSelectProps.value); + } + }, [timeZoneSelectProps.value]); + return ( @@ -168,7 +153,7 @@ export const DateTimePicker = ({ InputProps={{ readOnly: true }} errorText={errorText} label={label} - onClick={handleOpen} + onClick={(event) => setAnchorEl(event.currentTarget)} placeholder={placeholder} /> @@ -181,6 +166,9 @@ export const DateTimePicker = ({ > ({ '& .MuiDayCalendar-weekContainer, & .MuiDayCalendar-header': { justifyContent: 'space-between', @@ -203,9 +191,6 @@ export const DateTimePicker = ({ borderRadius: `${theme.spacing(2)}`, borderWidth: '0px', })} - onChange={handleDateChange} - value={selectedDateTime} - {...dateCalendarProps} /> )} diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx index f4cb77b175a..0d973178715 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx @@ -17,8 +17,10 @@ interface DateTimeRangePickerProps { end: DateTime | null, startTimeZone?: null | string ) => void; - /** Whether to show the timezone field for the end date picker */ + /** Whether to show the end timezone field for the end date picker */ showEndTimeZone?: boolean; + /** Whether to show the start timezone field for the end date picker */ + showStartTimeZone?: boolean; /** Initial or controlled value for the start date-time */ startDateTimeValue?: DateTime | null; /** Custom labels for the start and end date/time fields */ @@ -33,6 +35,7 @@ export const DateTimeRangePicker = ({ format = 'yyyy-MM-dd HH:mm', onChange, showEndTimeZone = false, + showStartTimeZone = false, startDateTimeValue = null, startLabel = 'Start Date and Time', startTimeZoneValue = null, @@ -94,6 +97,7 @@ export const DateTimeRangePicker = ({ format={format} label={startLabel} onChange={handleStartDateTimeChange} + showTimeZone={showStartTimeZone} timeSelectProps={{ label: 'Start Time' }} value={startDateTime} /> From 9abec60802f44ab47f9d5e972437a87548257639 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Mon, 9 Dec 2024 14:16:07 -0600 Subject: [PATCH 29/35] Add validation for start date agains end date. --- .../DatePicker/DateTimeRangePicker.test.tsx | 53 +++++++++++++++++++ .../DatePicker/DateTimeRangePicker.tsx | 42 ++++++++++----- 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx index c1303d84437..568e9559e06 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx @@ -57,4 +57,57 @@ describe('DateTimeRangePicker Component', () => { // Confirm error message is not shown since the click was blocked expect(screen.queryByText('Invalid date and Time')).not.toBeInTheDocument(); }); + + it('should show error when start date-time is after end date-time', async () => { + renderWithTheme( + + ); + + // Set the end date-time to the 15th + const endDateField = screen.getByLabelText('End Date and Time'); + await userEvent.click(endDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '15' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Set the start date-time to the 10th (which is earlier than the end date-time) + const startDateField = screen.getByLabelText('Start Date and Time'); + await userEvent.click(startDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '20' })); // Invalid date + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Confirm the error message is displayed + expect( + screen.getByText('Start date/time cannot be after the end date/time.') + ).toBeInTheDocument(); + }); + + it('should display custom error messages when start date-time is after end date-time', async () => { + renderWithTheme( + + ); + + // Set the end date-time to the 15th + const endDateField = screen.getByLabelText('End Date and Time'); + await userEvent.click(endDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '15' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Set the start date-time to the 20th (which is after the end date-time) + const startDateField = screen.getByLabelText('Start Date and Time'); + await userEvent.click(startDateField); + await userEvent.click(screen.getByRole('gridcell', { name: '20' })); // Invalid date + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // Confirm the custom error message is displayed for the start date + expect(screen.getByText('Custom start date error')).toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx index 0d973178715..d5fa4989b71 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx @@ -1,10 +1,13 @@ import { Box } from '@linode/ui'; -import { DateTime } from 'luxon'; import React, { useState } from 'react'; import { DateTimePicker } from './DateTimePicker'; +import type { DateTime } from 'luxon'; + interface DateTimeRangePickerProps { + /** Custom error message for invalid end date */ + endDateErrorMessage?: string; /** Initial or controlled value for the end date-time */ endDateTimeValue?: DateTime | null; /** Custom labels for the start and end date/time fields */ @@ -21,6 +24,8 @@ interface DateTimeRangePickerProps { showEndTimeZone?: boolean; /** Whether to show the start timezone field for the end date picker */ showStartTimeZone?: boolean; + /** Custom error message for invalid start date */ + startDateErrorMessage?: string; /** Initial or controlled value for the start date-time */ startDateTimeValue?: DateTime | null; /** Custom labels for the start and end date/time fields */ @@ -30,12 +35,14 @@ interface DateTimeRangePickerProps { } export const DateTimeRangePicker = ({ + endDateErrorMessage = 'End date/time cannot be before the start date/time.', endDateTimeValue = null, endLabel = 'End Date and Time', format = 'yyyy-MM-dd HH:mm', onChange, showEndTimeZone = false, showStartTimeZone = false, + startDateErrorMessage = 'Start date/time cannot be after the end date/time.', startDateTimeValue = null, startLabel = 'Start Date and Time', startTimeZoneValue = null, @@ -51,12 +58,27 @@ export const DateTimeRangePicker = ({ ); const [error, setError] = useState(); + const validateDates = ( + start: DateTime | null, + end: DateTime | null, + source: 'end' | 'start' + ) => { + if (start && end) { + if (source === 'start' && start > end) { + setError(startDateErrorMessage); + return; + } + if (source === 'end' && end < start) { + setError(endDateErrorMessage); + return; + } + } + setError(undefined); // Clear error if valid + }; + const handleStartDateTimeChange = (newStart: DateTime | null) => { setStartDateTime(newStart); - - if (endDateTime && newStart && endDateTime >= newStart) { - setError(undefined); // Clear error if valid - } + validateDates(newStart, endDateTime, 'start'); if (onChange) { onChange(newStart, endDateTime, startTimeZone); @@ -64,12 +86,8 @@ export const DateTimeRangePicker = ({ }; const handleEndDateTimeChange = (newEnd: DateTime | null) => { - if (startDateTime && newEnd && newEnd < startDateTime) { - setError('End date/time must be after the start date/time.'); - } else { - setEndDateTime(newEnd); - setError(undefined); - } + setEndDateTime(newEnd); + validateDates(startDateTime, newEnd, 'end'); if (onChange) { onChange(startDateTime, newEnd, startTimeZone); @@ -107,7 +125,7 @@ export const DateTimeRangePicker = ({ timeZoneSelectProps={{ value: startTimeZone, // Automatically reflect the start timezone }} - dateCalendarProps={{ minDate: startDateTime ?? DateTime.now() }} + dateCalendarProps={{ minDate: startDateTime ?? undefined }} format={format} label={endLabel} onChange={handleEndDateTimeChange} From fa3e1861c5b2888ef496027503fdf6aaff1a8c09 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Tue, 10 Dec 2024 08:42:00 -0600 Subject: [PATCH 30/35] Adjust styles for TimePicker component. --- .../src/components/DatePicker/DatePicker.tsx | 1 + .../components/DatePicker/DateTimePicker.tsx | 28 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/manager/src/components/DatePicker/DatePicker.tsx b/packages/manager/src/components/DatePicker/DatePicker.tsx index a3a5a722e82..f8fc1965ac8 100644 --- a/packages/manager/src/components/DatePicker/DatePicker.tsx +++ b/packages/manager/src/components/DatePicker/DatePicker.tsx @@ -56,6 +56,7 @@ export const DatePicker = ({ value={value} {...props} slotProps={{ + // TODO: Move styling customization to global theme styles. popper: { sx: { '& .MuiDayCalendar-weekDayLabel': { diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx index a67b5ccb155..0bbe09e5f21 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -169,6 +169,7 @@ export const DateTimePicker = ({ onChange={handleDateChange} value={selectedDateTime || null} {...dateCalendarProps} + // TODO: Move styling customization to global theme styles. sx={(theme: Theme) => ({ '& .MuiDayCalendar-weekContainer, & .MuiDayCalendar-header': { justifyContent: 'space-between', @@ -201,7 +202,32 @@ export const DateTimePicker = ({ ({ + justifyContent: 'center', + marginBottom: theme.spacing(1 / 2), + marginTop: theme.spacing(1 / 2), + padding: 0, + }), + }, + layout: { + sx: (theme: Theme) => ({ + '& .MuiPickersLayout-contentWrapper': { + borderBottom: `1px solid ${theme.borderColors.divider}`, + }, + border: `1px solid ${theme.borderColors.divider}`, + }), + }, + openPickerButton: { + sx: { padding: 0 }, + }, + popper: { + sx: (theme: Theme) => ({ + ul: { + borderColor: `${theme.borderColors.divider} !important`, + }, + }), + }, textField: TimePickerFieldProps, }} onChange={handleTimeChange} From 1ab71f926c03b8777a9768f2baf0caf14736134c Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Tue, 10 Dec 2024 23:23:09 -0600 Subject: [PATCH 31/35] PR feedback - @jaalah-akamai --- .../src/components/DatePicker/DateTimePicker.tsx | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx index 0bbe09e5f21..6b38ab243fc 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -95,17 +95,9 @@ export const DateTimePicker = ({ const handleTimeChange = (newTime: DateTime | null) => { if (newTime) { - setSelectedDateTime((prev) => { - if (prev) { - const newHour = newTime.hour; - const newMinute = newTime.minute; - - if (typeof newHour === 'number' && typeof newMinute === 'number') { - return prev.set({ hour: newHour, minute: newMinute }); - } - } - return prev; - }); + setSelectedDateTime((prev) => + prev ? prev.set({ hour: newTime.hour, minute: newTime.minute }) : prev + ); } }; From ee326f1281e36643d76b7d95c987de4748a02751 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:47:32 -0600 Subject: [PATCH 32/35] allow error messages from props. --- .../components/DatePicker/DateTimePicker.tsx | 11 +++++-- .../DatePicker/DateTimeRangePicker.test.tsx | 22 +++++--------- .../DatePicker/DateTimeRangePicker.tsx | 30 +++++++++++++------ 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx index 6b38ab243fc..d7b138e5d8e 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -1,6 +1,6 @@ -import { TextField } from '@linode/ui'; import { Divider } from '@linode/ui'; import { Box } from '@linode/ui'; +import { TextField } from '@linode/ui'; import { Grid, Popover } from '@mui/material'; import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'; @@ -13,7 +13,7 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { TimeZoneSelect } from './TimeZoneSelect'; import type { TextFieldProps } from '@linode/ui'; -import type { Theme } from '@mui/material/styles'; +import type { SxProps, Theme } from '@mui/material/styles'; import type { DateCalendarProps } from '@mui/x-date-pickers/DateCalendar'; import type { DateTime } from 'luxon'; @@ -38,6 +38,10 @@ export interface DateTimePickerProps { showTime?: boolean; /** Whether to show the timezone selector */ showTimeZone?: boolean; + /** + * Any additional styles to apply to the root element. + */ + sx?: SxProps; /** Props for customizing the TimePicker component */ timeSelectProps?: { label?: string; @@ -65,6 +69,7 @@ export const DateTimePicker = ({ placeholder = 'yyyy-MM-dd HH:mm', showTime = true, showTimeZone = true, + sx, timeSelectProps = {}, timeZoneSelectProps = {}, value = null, @@ -133,7 +138,7 @@ export const DateTimePicker = ({ return ( - + { await userEvent.click(endDateField); // Check if the date before the start date is disabled via a class or attribute - const invalidDate = screen.getByRole('gridcell', { name: '10' }); - expect(invalidDate).toHaveClass('Mui-disabled'); // or check for the specific attribute used + await userEvent.click(screen.getByRole('gridcell', { name: '10' })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); // Confirm error message is not shown since the click was blocked - expect(screen.queryByText('Invalid date and Time')).not.toBeInTheDocument(); + expect( + screen.getByText('End date/time cannot be before the start date/time.') + ).toBeInTheDocument(); }); it('should show error when start date-time is after end date-time', async () => { @@ -88,6 +90,7 @@ describe('DateTimeRangePicker Component', () => { it('should display custom error messages when start date-time is after end date-time', async () => { renderWithTheme( { /> ); - // Set the end date-time to the 15th - const endDateField = screen.getByLabelText('End Date and Time'); - await userEvent.click(endDateField); - await userEvent.click(screen.getByRole('gridcell', { name: '15' })); - await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - - // Set the start date-time to the 20th (which is after the end date-time) - const startDateField = screen.getByLabelText('Start Date and Time'); - await userEvent.click(startDateField); - await userEvent.click(screen.getByRole('gridcell', { name: '20' })); // Invalid date - await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - // Confirm the custom error message is displayed for the start date expect(screen.getByText('Custom start date error')).toBeInTheDocument(); + expect(screen.getByText('Custom end date error')).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx index d5fa4989b71..a2a0408947e 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx @@ -3,6 +3,7 @@ import React, { useState } from 'react'; import { DateTimePicker } from './DateTimePicker'; +import type { SxProps, Theme } from '@mui/material/styles'; import type { DateTime } from 'luxon'; interface DateTimeRangePickerProps { @@ -32,20 +33,25 @@ interface DateTimeRangePickerProps { startLabel?: string; /** Initial or controlled value for the start timezone */ startTimeZoneValue?: null | string; + /** + * Any additional styles to apply to the root element. + */ + sx?: SxProps; } export const DateTimeRangePicker = ({ - endDateErrorMessage = 'End date/time cannot be before the start date/time.', + endDateErrorMessage, endDateTimeValue = null, endLabel = 'End Date and Time', format = 'yyyy-MM-dd HH:mm', onChange, showEndTimeZone = false, showStartTimeZone = false, - startDateErrorMessage = 'Start date/time cannot be after the end date/time.', + startDateErrorMessage, startDateTimeValue = null, startLabel = 'Start Date and Time', startTimeZoneValue = null, + sx, }: DateTimeRangePickerProps) => { const [startDateTime, setStartDateTime] = useState( startDateTimeValue @@ -56,7 +62,9 @@ export const DateTimeRangePicker = ({ const [startTimeZone, setStartTimeZone] = useState( startTimeZoneValue ); - const [error, setError] = useState(); + + const [startDateError, setStartDateError] = useState(); + const [endDateError, setEndDateError] = useState(); const validateDates = ( start: DateTime | null, @@ -65,15 +73,19 @@ export const DateTimeRangePicker = ({ ) => { if (start && end) { if (source === 'start' && start > end) { - setError(startDateErrorMessage); + setStartDateError('Start date/time cannot be after the end date/time.'); + setEndDateError(undefined); return; } if (source === 'end' && end < start) { - setError(endDateErrorMessage); + setEndDateError('End date/time cannot be before the start date/time.'); + setStartDateError(undefined); return; } } - setError(undefined); // Clear error if valid + // Reset validation errors if valid + setStartDateError(undefined); + setEndDateError(undefined); }; const handleStartDateTimeChange = (newStart: DateTime | null) => { @@ -103,7 +115,7 @@ export const DateTimeRangePicker = ({ }; return ( - + {/* Start DateTime Picker */} Date: Wed, 11 Dec 2024 16:08:17 -0600 Subject: [PATCH 33/35] Update storybook components with args --- .../DatePicker/DatePicker.stories.tsx | 100 ++++++++++++++++-- .../DatePicker/DateTimePicker.stories.tsx | 81 +++++++++++--- .../DateTimeRangePicker.stories.tsx | 100 ++++++++++++------ 3 files changed, 227 insertions(+), 54 deletions(-) diff --git a/packages/manager/src/components/DatePicker/DatePicker.stories.tsx b/packages/manager/src/components/DatePicker/DatePicker.stories.tsx index 6e9d4408783..6b3891e9889 100644 --- a/packages/manager/src/components/DatePicker/DatePicker.stories.tsx +++ b/packages/manager/src/components/DatePicker/DatePicker.stories.tsx @@ -8,8 +8,63 @@ import type { DateTime } from 'luxon'; type Story = StoryObj; +export const Default: Story = { + argTypes: { + errorText: { + control: 'text', + description: 'Error text to display below the input', + }, + format: { + control: 'text', + description: 'Format of the date when rendered in the input field', + }, + helperText: { + control: 'text', + description: 'Helper text to display below the input', + }, + label: { + control: 'text', + description: 'Label to display for the date picker input', + }, + onChange: { + action: 'date-changed', + description: 'Callback function fired when the value changes', + }, + placeholder: { + control: 'text', + description: 'Placeholder text for the date picker input', + }, + textFieldProps: { + control: 'object', + description: + 'Additional props to pass to the underlying TextField component', + }, + value: { + control: 'date', + description: 'The currently selected date', + }, + }, + args: { + errorText: '', + format: 'yyyy-MM-dd', + label: 'Select a Date', + onChange: action('date-changed'), + placeholder: 'yyyy-MM-dd', + textFieldProps: { label: 'Select a Date' }, + value: null, + }, +}; + export const ControlledExample: Story = { - render: () => { + args: { + errorText: '', + format: 'yyyy-MM-dd', + helperText: 'This is a controlled DatePicker', + label: 'Controlled Date Picker', + placeholder: 'yyyy-MM-dd', + value: null, + }, + render: (args) => { const ControlledDatePicker = () => { const [selectedDate, setSelectedDate] = React.useState(); @@ -19,14 +74,7 @@ export const ControlledExample: Story = { }; return ( - + ); }; @@ -35,6 +83,40 @@ export const ControlledExample: Story = { }; const meta: Meta = { + argTypes: { + errorText: { + control: 'text', + }, + format: { + control: 'text', + }, + helperText: { + control: 'text', + }, + label: { + control: 'text', + }, + onChange: { + action: 'date-changed', + }, + placeholder: { + control: 'text', + }, + textFieldProps: { + control: 'object', + }, + value: { + control: 'date', + }, + }, + args: { + errorText: '', + format: 'yyyy-MM-dd', + helperText: '', + label: 'Select a Date', + placeholder: 'yyyy-MM-dd', + value: null, + }, component: DatePicker, title: 'Components/DatePicker', }; diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx index bb3f0c48233..9af57ff82fe 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx @@ -9,12 +9,27 @@ import type { DateTime } from 'luxon'; type Story = StoryObj; export const ControlledExample: Story = { - render: () => { + args: { + label: 'Controlled Date-Time Picker', + onApply: action('Apply clicked'), + onCancel: action('Cancel clicked'), + placeholder: 'yyyy-MM-dd HH:mm', + showTime: true, + showTimeZone: true, + timeSelectProps: { + label: 'Select Time', + }, + timeZoneSelectProps: { + label: 'Timezone', + onChange: action('Timezone changed'), + }, + }, + render: (args) => { const ControlledDateTimePicker = () => { const [ selectedDateTime, setSelectedDateTime, - ] = React.useState(null); // Start with null + ] = React.useState(args.value || null); const handleChange = (newDateTime: DateTime | null) => { setSelectedDateTime(newDateTime); @@ -23,17 +38,8 @@ export const ControlledExample: Story = { return ( ); @@ -50,6 +56,8 @@ export const DefaultExample: Story = { onCancel: action('Cancel clicked'), onChange: action('Date-Time selected'), placeholder: 'yyyy-MM-dd HH:mm', + showTime: true, + showTimeZone: true, }, }; @@ -61,25 +69,68 @@ export const WithErrorText: Story = { onCancel: action('Cancel clicked with error'), onChange: action('Date-Time selected with error'), placeholder: 'yyyy-MM-dd HH:mm', + showTime: true, + showTimeZone: true, }, }; const meta: Meta = { argTypes: { + dateCalendarProps: { + control: { type: 'object' }, + description: 'Additional props for the DateCalendar component.', + }, errorText: { control: { type: 'text' }, + description: 'Error text for the date picker field.', }, format: { control: { type: 'text' }, + description: 'Format for displaying the date-time.', }, label: { control: { type: 'text' }, + description: 'Label for the input field.', + }, + onApply: { + action: 'applyClicked', + description: 'Callback when the "Apply" button is clicked.', + }, + onCancel: { + action: 'cancelClicked', + description: 'Callback when the "Cancel" button is clicked.', + }, + onChange: { + action: 'dateTimeChanged', + description: 'Callback when the date-time changes.', }, - onApply: { action: 'applyClicked' }, - onCancel: { action: 'cancelClicked' }, - onChange: { action: 'dateTimeChanged' }, placeholder: { control: { type: 'text' }, + description: 'Placeholder text for the input field.', + }, + showTime: { + control: { type: 'boolean' }, + description: 'Whether to show the time selector.', + }, + showTimeZone: { + control: { type: 'boolean' }, + description: 'Whether to show the timezone selector.', + }, + sx: { + control: { type: 'object' }, + description: 'Styles to apply to the root element.', + }, + timeSelectProps: { + control: { type: 'object' }, + description: 'Props for customizing the TimePicker component.', + }, + timeZoneSelectProps: { + control: { type: 'object' }, + description: 'Props for customizing the TimeZoneSelect component.', + }, + value: { + control: { type: 'date' }, + description: 'Initial or controlled dateTime value.', }, }, args: { diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx index 70ad6b2a39d..23c1fe11f3d 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx @@ -10,52 +10,61 @@ type Story = StoryObj; export const Default: Story = { args: { + endDateErrorMessage: '', + endDateTimeValue: null, endLabel: 'End Date and Time', format: 'yyyy-MM-dd HH:mm', onChange: action('DateTime range changed'), + showEndTimeZone: true, + showStartTimeZone: true, + startDateErrorMessage: '', + startDateTimeValue: null, startLabel: 'Start Date and Time', + startTimeZoneValue: null, }, render: (args) => , }; export const WithInitialValues: Story = { - render: () => { - const ComponentWithState: React.FC = () => { - const [start, setStart] = React.useState( - DateTime.now().minus({ days: 1 }) - ); - const [end, setEnd] = React.useState(DateTime.now()); - - const handleDateChange = ( - newStart: DateTime | null, - newEnd: DateTime | null - ) => { - setStart(newStart); - setEnd(newEnd); - action('DateTime range changed')(newStart?.toISO(), newEnd?.toISO()); - }; - - return ( - - ); - }; + args: { + endDateTimeValue: DateTime.now(), + endLabel: 'End Date and Time', + format: 'yyyy-MM-dd HH:mm', + onChange: action('DateTime range changed'), + showEndTimeZone: true, + showStartTimeZone: true, + startDateTimeValue: DateTime.now().minus({ days: 1 }), + startLabel: 'Start Date and Time', + startTimeZoneValue: 'America/New_York', + }, +}; - return ; +export const WithCustomErrors: Story = { + args: { + endDateErrorMessage: 'End date must be after the start date.', + endDateTimeValue: DateTime.now().minus({ days: 1 }), + endLabel: 'Custom End Label', + format: 'yyyy-MM-dd HH:mm', + onChange: action('DateTime range changed'), + startDateErrorMessage: 'Start date must be before the end date.', + startDateTimeValue: DateTime.now().minus({ days: 2 }), + startLabel: 'Custom Start Label', }, }; const meta: Meta = { argTypes: { + endDateErrorMessage: { + control: 'text', + description: 'Custom error message for invalid end date', + }, + endDateTimeValue: { + control: 'date', + description: 'Initial or controlled value for the end date-time', + }, endLabel: { control: 'text', - description: 'Label for the end date picker', + description: 'Custom label for the end date-time picker', }, format: { control: 'text', @@ -65,11 +74,42 @@ const meta: Meta = { action: 'DateTime range changed', description: 'Callback when the date-time range changes', }, + showEndTimeZone: { + control: 'boolean', + description: + 'Whether to show the timezone selector for the end date picker', + }, + showStartTimeZone: { + control: 'boolean', + description: + 'Whether to show the timezone selector for the start date picker', + }, + startDateErrorMessage: { + control: 'text', + description: 'Custom error message for invalid start date', + }, + startDateTimeValue: { + control: 'date', + description: 'Initial or controlled value for the start date-time', + }, startLabel: { control: 'text', - description: 'Label for the start date picker', + description: 'Custom label for the start date-time picker', + }, + startTimeZoneValue: { + control: 'text', + description: 'Initial or controlled value for the start timezone', + }, + sx: { + control: 'object', + description: 'Styles to apply to the root element', }, }, + args: { + endLabel: 'End Date and Time', + format: 'yyyy-MM-dd HH:mm', + startLabel: 'Start Date and Time', + }, component: DateTimeRangePicker, title: 'Components/DateTimeRangePicker', }; From dc5291f4f1bcd3f11ebe7efbf1288e39ac675acf Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Thu, 12 Dec 2024 11:29:22 -0600 Subject: [PATCH 34/35] Update props --- .../src/components/DatePicker/DatePicker.tsx | 6 ++---- .../src/components/DatePicker/DateTimePicker.tsx | 7 +++---- .../components/DatePicker/DateTimeRangePicker.tsx | 14 ++++---------- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/packages/manager/src/components/DatePicker/DatePicker.tsx b/packages/manager/src/components/DatePicker/DatePicker.tsx index f8fc1965ac8..25fb95ff048 100644 --- a/packages/manager/src/components/DatePicker/DatePicker.tsx +++ b/packages/manager/src/components/DatePicker/DatePicker.tsx @@ -20,7 +20,7 @@ export interface DatePickerProps /** Label to display for the date picker input */ label?: string; /** Callback function fired when the value changes */ - onChange?: (newDate: DateTime | null) => void; + onChange: (newDate: DateTime | null) => void; /** Placeholder text for the date picker input */ placeholder?: string; /** Additional props to pass to the underlying TextField component */ @@ -42,9 +42,7 @@ export const DatePicker = ({ const theme = useTheme(); const onChangeHandler = (newDate: DateTime | null) => { - if (onChange) { - onChange(newDate); - } + onChange(newDate); }; return ( diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx index d7b138e5d8e..b503ba37674 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -31,7 +31,7 @@ export interface DateTimePickerProps { /** Callback when the "Cancel" button is clicked */ onCancel?: () => void; /** Callback when date-time changes */ - onChange?: (dateTime: DateTime | null) => void; + onChange: (dateTime: DateTime | null) => void; /** Placeholder text for the input field */ placeholder?: string; /** Whether to show the time selector */ @@ -115,9 +115,8 @@ export const DateTimePicker = ({ const handleApply = () => { setAnchorEl(null); - if (onChange) { - onChange(selectedDateTime); - } + onChange(selectedDateTime); + if (onApply) { onApply(); } diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx index a2a0408947e..66170f05586 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx @@ -16,7 +16,7 @@ interface DateTimeRangePickerProps { /** Format for displaying the date-time */ format?: string; /** Callback when the date-time range changes */ - onChange?: ( + onChange: ( start: DateTime | null, end: DateTime | null, startTimeZone?: null | string @@ -92,26 +92,20 @@ export const DateTimeRangePicker = ({ setStartDateTime(newStart); validateDates(newStart, endDateTime, 'start'); - if (onChange) { - onChange(newStart, endDateTime, startTimeZone); - } + onChange(newStart, endDateTime, startTimeZone); }; const handleEndDateTimeChange = (newEnd: DateTime | null) => { setEndDateTime(newEnd); validateDates(startDateTime, newEnd, 'end'); - if (onChange) { - onChange(startDateTime, newEnd, startTimeZone); - } + onChange(startDateTime, newEnd, startTimeZone); }; const handleStartTimeZoneChange = (newTimeZone: null | string) => { setStartTimeZone(newTimeZone); - if (onChange) { - onChange(startDateTime, endDateTime, newTimeZone); - } + onChange(startDateTime, endDateTime, newTimeZone); }; return ( From 8e3c8c13b5c0aef1b62cf56046a947e5d873d3d6 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Thu, 12 Dec 2024 14:40:44 -0600 Subject: [PATCH 35/35] PR feedback - @hana-akamai --- .../manager/src/components/DatePicker/DatePicker.stories.tsx | 2 +- .../src/components/DatePicker/DateTimePicker.stories.tsx | 2 +- .../src/components/DatePicker/DateTimeRangePicker.stories.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/manager/src/components/DatePicker/DatePicker.stories.tsx b/packages/manager/src/components/DatePicker/DatePicker.stories.tsx index 6b3891e9889..640fae75510 100644 --- a/packages/manager/src/components/DatePicker/DatePicker.stories.tsx +++ b/packages/manager/src/components/DatePicker/DatePicker.stories.tsx @@ -118,7 +118,7 @@ const meta: Meta = { value: null, }, component: DatePicker, - title: 'Components/DatePicker', + title: 'Components/DatePicker/DatePicker', }; export default meta; diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx index 9af57ff82fe..7df04ed26cd 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.stories.tsx @@ -139,7 +139,7 @@ const meta: Meta = { placeholder: 'Select a date and time', }, component: DateTimePicker, - title: 'Components/DateTimePicker', + title: 'Components/DatePicker/DateTimePicker', }; export default meta; diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx index 23c1fe11f3d..f34ac6b190b 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.stories.tsx @@ -111,7 +111,7 @@ const meta: Meta = { startLabel: 'Start Date and Time', }, component: DateTimeRangePicker, - title: 'Components/DateTimeRangePicker', + title: 'Components/DatePicker/DateTimeRangePicker', }; export default meta;