Compare commits
77 Commits
@nhost/nho
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b24807562 | ||
|
|
15421321f4 | ||
|
|
a4790c6eac | ||
|
|
992aa997d5 | ||
|
|
382dc11aaa | ||
|
|
1976bc48a5 | ||
|
|
38696f5e88 | ||
|
|
064ea6a337 | ||
|
|
0d323e10f5 | ||
|
|
6921526cf5 | ||
|
|
ad57a9e473 | ||
|
|
b40d375039 | ||
|
|
af61cb737a | ||
|
|
f84cd550d9 | ||
|
|
1986178f7a | ||
|
|
aa9210c838 | ||
|
|
695466df95 | ||
|
|
fe23bde306 | ||
|
|
ea2dbf8734 | ||
|
|
f4167e328c | ||
|
|
56ebb1f719 | ||
|
|
2b6a4adf40 | ||
|
|
6cc8f954e1 | ||
|
|
1821df7a96 | ||
|
|
ab8a55ede4 | ||
|
|
39eb70678b | ||
|
|
e3cd5f858f | ||
|
|
69d9ab60c8 | ||
|
|
a8961c0ab0 | ||
|
|
6b8163d21f | ||
|
|
a21553c774 | ||
|
|
2dd4df5170 | ||
|
|
403a45d2cf | ||
|
|
05f063b8e2 | ||
|
|
09f5bed1e8 | ||
|
|
91d5fbba42 | ||
|
|
498363db25 | ||
|
|
8c2779930b | ||
|
|
0aa27a2fd1 | ||
|
|
8656749e5a | ||
|
|
d097eb8feb | ||
|
|
dd04c3df43 | ||
|
|
70b2d5a3ec | ||
|
|
3b12dc425e | ||
|
|
e3146a30af | ||
|
|
eb7f09e485 | ||
|
|
9084222a1c | ||
|
|
4815c7c580 | ||
|
|
3c3fa8953a | ||
|
|
cb632337f9 | ||
|
|
d8747139ab | ||
|
|
aecbec643b | ||
|
|
196cd38018 | ||
|
|
fd5991845b | ||
|
|
2e3357b7a3 | ||
|
|
4385524311 | ||
|
|
9e404c8fc9 | ||
|
|
f8e6b615dd | ||
|
|
ac4aa01ec9 | ||
|
|
e515e71c8b | ||
|
|
1246e0024a | ||
|
|
81cc9b3810 | ||
|
|
5c6ff6efc8 | ||
|
|
1956ed23f8 | ||
|
|
af34015dbe | ||
|
|
88919a3d99 | ||
|
|
ab26a57d05 | ||
|
|
f1052a8826 | ||
|
|
30daa4146e | ||
|
|
7537237465 | ||
|
|
76e77da5de | ||
|
|
04d2ce110a | ||
|
|
b2755045c9 | ||
|
|
d43931e761 | ||
|
|
44c1e17fd5 | ||
|
|
5df6fa2d0b | ||
|
|
1fa6cc47ec |
5
.changeset/neat-pillows-vanish.md
Normal file
5
.changeset/neat-pillows-vanish.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@nhost/dashboard': patch
|
||||
---
|
||||
|
||||
chore: fix link to PiTR documentation
|
||||
5
.changeset/quick-keys-carry.md
Normal file
5
.changeset/quick-keys-carry.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@nhost/dashboard': minor
|
||||
---
|
||||
|
||||
fix: update babel dependencies to address security audit vulnerabilities
|
||||
10
.github/actions/install-dependencies/action.yaml
vendored
10
.github/actions/install-dependencies/action.yaml
vendored
@@ -14,7 +14,7 @@ runs:
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.0
|
||||
version: 10.1.0
|
||||
run_install: false
|
||||
- name: Get pnpm cache directory
|
||||
id: pnpm-cache-dir
|
||||
@@ -30,6 +30,14 @@ runs:
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
- shell: bash
|
||||
name: Use Latest Corepack
|
||||
run: |
|
||||
echo "Before: corepack version => $(corepack --version || echo 'not installed')"
|
||||
npm install -g corepack@latest
|
||||
echo "After : corepack version => $(corepack --version)"
|
||||
corepack enable
|
||||
pnpm --version
|
||||
- shell: bash
|
||||
name: Install packages
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
20
.github/actions/nhost-cli/action.yaml
vendored
20
.github/actions/nhost-cli/action.yaml
vendored
@@ -1,6 +1,9 @@
|
||||
name: Nhost CLI
|
||||
description: 'Action to install the Nhost CLI and to run an application'
|
||||
inputs:
|
||||
init:
|
||||
description: 'Initialize the application'
|
||||
default: 'false'
|
||||
start:
|
||||
description: "Start the application. If false, the application won't be started"
|
||||
default: 'false'
|
||||
@@ -16,6 +19,9 @@ inputs:
|
||||
version:
|
||||
description: 'Version of the Nhost CLI'
|
||||
default: 'latest'
|
||||
dashboard-image:
|
||||
description: 'Image of the dashboard'
|
||||
default: 'nhost/dashboard:latest'
|
||||
config:
|
||||
description: 'Values to be injected into nhost/config.yaml'
|
||||
|
||||
@@ -40,6 +46,13 @@ runs:
|
||||
timeout_minutes: 3
|
||||
max_attempts: 10
|
||||
command: bash <(curl --silent -L https://raw.githubusercontent.com/nhost/cli/main/get.sh) ${{ inputs.version }}
|
||||
- name: Initialize a new project from scratch
|
||||
if: ${{ inputs.init == 'true' }}
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.path }}
|
||||
run: |
|
||||
rm -rf ./*
|
||||
nhost init
|
||||
- name: Set custom configuration
|
||||
if: ${{ inputs.config }}
|
||||
shell: bash
|
||||
@@ -50,7 +63,12 @@ runs:
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.path }}
|
||||
run: |
|
||||
cp .secrets.example .secrets
|
||||
if [ -n "${{ inputs.dashboard-image }}" ]; then
|
||||
export NHOST_DASHBOARD_VERSION=${{ inputs.dashboard-image }}
|
||||
fi
|
||||
if [ -f .secrets.example ]; then
|
||||
cp .secrets.example .secrets
|
||||
fi
|
||||
nhost up
|
||||
- name: Log on failure
|
||||
if: steps.wait.outcome == 'failure'
|
||||
|
||||
35
.github/workflows/ci.yaml
vendored
35
.github/workflows/ci.yaml
vendored
@@ -107,6 +107,9 @@ jobs:
|
||||
with:
|
||||
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
||||
- name: Enforce Prettier formatting in dashboard
|
||||
working-directory: ./dashboard
|
||||
run: pnpm prettier --check "./**/*.tsx" --config prettier.config.js
|
||||
# * Run every `lint` script in the workspace . Dependencies build is cached by Turborepo
|
||||
- name: Lint
|
||||
run: pnpm run lint:all
|
||||
@@ -131,10 +134,27 @@ jobs:
|
||||
with:
|
||||
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
||||
# * Build Dashboard image to test it locally
|
||||
- name: Build Dashboard local image
|
||||
if: matrix.package.path == 'dashboard'
|
||||
run: |
|
||||
docker build -t nhost/dashboard:0.0.0-dev -f ${{ matrix.package.path }}/Dockerfile .
|
||||
mkdir -p nhost-test-project
|
||||
# * Install Nhost CLI if a `nhost/config.yaml` file is found
|
||||
- name: Install Nhost CLI
|
||||
if: hashFiles(format('{0}/nhost/config.yaml', matrix.package.path)) != ''
|
||||
if: hashFiles(format('{0}/nhost/config.yaml', matrix.package.path)) != '' && matrix.package.path != 'dashboard'
|
||||
uses: ./.github/actions/nhost-cli
|
||||
# * Install Nhost CLI to test Dashboard locally
|
||||
- name: Install Nhost CLI (Local Dashboard tests)
|
||||
timeout-minutes: 5
|
||||
if: matrix.package.path == 'dashboard'
|
||||
uses: ./.github/actions/nhost-cli
|
||||
with:
|
||||
init: 'true' # Initialize the application
|
||||
start: 'true' # Start the application
|
||||
path: ./nhost-test-project
|
||||
wait: 'true' # Wait until the application is ready
|
||||
dashboard-image: 'nhost/dashboard:0.0.0-dev'
|
||||
- name: Fetch Dashboard Preview URL
|
||||
id: fetch-dashboard-preview-url
|
||||
uses: zentered/vercel-preview-url@v1.1.9
|
||||
@@ -154,6 +174,17 @@ jobs:
|
||||
- name: Run e2e tests
|
||||
timeout-minutes: 20
|
||||
run: pnpm --filter="${{ matrix.package.name }}" run e2e
|
||||
# * Run the `e2e-local` script of the dashboard
|
||||
- name: Run Local Dashboard e2e tests
|
||||
if: matrix.package.path == 'dashboard'
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
pnpm --filter="${{ matrix.package.name }}" run e2e-local
|
||||
|
||||
- name: Stop Nhost CLI
|
||||
if: matrix.package.path == 'dashboard'
|
||||
working-directory: ./nhost-test-project
|
||||
run: nhost down
|
||||
- id: file-name
|
||||
if: ${{ failure() }}
|
||||
name: Transform package name into a valid file name
|
||||
@@ -163,7 +194,7 @@ jobs:
|
||||
# * Run this step only if the previous step failed, and Playwright generated a report
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ failure() && hashFiles(format('{0}/playwright-report/**', matrix.package.path)) != ''}}
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-${{ steps.file-name.outputs.fileName }}
|
||||
path: ${{format('{0}/playwright-report/**', matrix.package.path)}}
|
||||
|
||||
17
.github/workflows/test-nhost-cli-action.yaml
vendored
17
.github/workflows/test-nhost-cli-action.yaml
vendored
@@ -25,10 +25,10 @@ jobs:
|
||||
- name: Install the Nhost CLI and start the application
|
||||
uses: ./.github/actions/nhost-cli
|
||||
with:
|
||||
path: packages/nhost-js
|
||||
init: true
|
||||
start: true
|
||||
- name: should be running
|
||||
run: curl -sSf 'https://local.hasura.nhost.run' > /dev/null
|
||||
run: curl -sSf 'https://local.hasura.local.nhost.run/' > /dev/null
|
||||
|
||||
stop:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
- name: Install the Nhost CLI, start and stop the application
|
||||
uses: ./.github/actions/nhost-cli
|
||||
with:
|
||||
path: packages/nhost-js
|
||||
init: true
|
||||
start: true
|
||||
stop: true
|
||||
- name: should have no live docker container
|
||||
@@ -55,12 +55,13 @@ jobs:
|
||||
- name: Install the Nhost CLI and run the application
|
||||
uses: ./.github/actions/nhost-cli
|
||||
with:
|
||||
path: packages/nhost-js
|
||||
init: true
|
||||
version: v1.29.3
|
||||
start: true
|
||||
- name: should find the injected hasura-auth version
|
||||
run: |
|
||||
VERSION=$(curl -sSf 'https://local.auth.nhost.run/v1/version')
|
||||
EXPECTED_VERSION='{"version":"v0.20.1"}'
|
||||
VERSION=$(curl -sSf 'https://local.auth.local.nhost.run/v1/version')
|
||||
EXPECTED_VERSION='{"version":"0.36.1"}'
|
||||
if [ "$VERSION" != "$EXPECTED_VERSION" ]; then
|
||||
echo "Expected version $EXPECTED_VERSION but got $VERSION"
|
||||
exit 1
|
||||
@@ -73,6 +74,6 @@ jobs:
|
||||
- name: Install the Nhost CLI
|
||||
uses: ./.github/actions/nhost-cli
|
||||
with:
|
||||
version: v1.0.1
|
||||
version: v1.27.2
|
||||
- name: should find the correct version
|
||||
run: nhost --version | head -n 1 | grep v1.0.1 || exit 1
|
||||
run: nhost --version | head -n 1 | grep v1.27.2 || exit 1
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -63,3 +63,5 @@ out/
|
||||
# Nix
|
||||
.envrc
|
||||
.direnv/
|
||||
|
||||
/.vscode/
|
||||
|
||||
16
README.md
16
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||
# Nhost
|
||||
|
||||
<a href="https://docs.nhost.io/#quickstart">Quickstart</a>
|
||||
<a href="https://docs.nhost.io/introduction#quick-start-guides">Quickstart</a>
|
||||
<span> • </span>
|
||||
<a href="http://nhost.io/">Website</a>
|
||||
<span> • </span>
|
||||
@@ -36,7 +36,7 @@ Nhost consists of open source software:
|
||||
- Authentication: [Hasura Auth](https://github.com/nhost/hasura-auth/)
|
||||
- Storage: [Hasura Storage](https://github.com/nhost/hasura-storage)
|
||||
- Serverless Functions: Node.js (JavaScript and TypeScript)
|
||||
- [Nhost CLI](https://docs.nhost.io/cli) for local development
|
||||
- [Nhost CLI](https://docs.nhost.io/development/cli/overview) for local development
|
||||
|
||||
## Architecture of Nhost
|
||||
|
||||
@@ -73,7 +73,7 @@ const nhost = new NhostClient({
|
||||
region: '<your-region>'
|
||||
})
|
||||
|
||||
await nhost.auth.signIn({ email: 'elon@musk.com', password: 'spaceX' })
|
||||
await nhost.auth.signIn({ email: 'user@domain.com', password: 'userPassword' })
|
||||
|
||||
await nhost.graphql.request(`{
|
||||
users {
|
||||
@@ -89,12 +89,12 @@ await nhost.graphql.request(`{
|
||||
Nhost is frontend agnostic, which means Nhost works with all frontend frameworks.
|
||||
|
||||
<div align="center">
|
||||
<a href="https://docs.nhost.io/platform/quickstarts/nextjs"><img src="assets/nextjs.svg"/></a>
|
||||
<a href="https://docs.nhost.io/guides/quickstarts/nextjs"><img src="assets/nextjs.svg"/></a>
|
||||
<a href="https://docs.nhost.io/reference/javascript"><img src="assets/nuxtjs.svg"/></a>
|
||||
<a href="https://docs.nhost.io/platform/quickstarts/react"><img src="assets/react.svg"/></a>
|
||||
<a href="https://docs.nhost.io/guides/quickstarts/react"><img src="assets/react.svg"/></a>
|
||||
<a href="https://docs.nhost.io/reference/javascript"><img src="assets/react-native.svg"/></a>
|
||||
<a href="https://docs.nhost.io/reference/javascript"><img src="assets/svelte.svg"/></a>
|
||||
<a href="https://docs.nhost.io/platform/quickstarts/vue"><img src="assets/vuejs.svg"/></a>
|
||||
<a href="https://docs.nhost.io/guides/quickstarts/vue"><img src="assets/vuejs.svg"/></a>
|
||||
</div>
|
||||
|
||||
# Resources
|
||||
@@ -140,7 +140,7 @@ This repository, and most of our other open source projects, are licensed under
|
||||
|
||||
Here are some ways of contributing to making Nhost better:
|
||||
|
||||
- **[Try out Nhost](https://docs.nhost.io/get-started/quick-start)**, and think of ways to make the service better. Let us know here on GitHub.
|
||||
- **[Try out Nhost](https://docs.nhost.io/introduction)**, and think of ways to make the service better. Let us know here on GitHub.
|
||||
- Join our [Discord](https://discord.com/invite/9V7Qb2U) and connect with other members to share and learn from.
|
||||
- Send a pull request to any of our [open source repositories](https://github.com/nhost) on Github. Check our [contribution guide](https://github.com/nhost/nhost/blob/main/CONTRIBUTING.md) and our [developers guide](https://github.com/nhost/nhost/blob/main/DEVELOPERS.md) for more details about how to contribute. We're looking forward to your contribution!
|
||||
|
||||
@@ -150,4 +150,4 @@ Here are some ways of contributing to making Nhost better:
|
||||
<p align="center">
|
||||
<img width="720" src="https://contrib.rocks/image?repo=nhost/nhost" alt="A table of avatars from the project's contributors" />
|
||||
</p>
|
||||
</a>
|
||||
</a>
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
// $schema provides code completion hints to IDEs.
|
||||
"$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json",
|
||||
"moderate": true,
|
||||
"allowlist": ["vue-template-compiler", "micromatch", "path-to-regexp"]
|
||||
"allowlist": ["vue-template-compiler"]
|
||||
}
|
||||
|
||||
@@ -3,18 +3,19 @@ NEXT_PUBLIC_ENV=dev
|
||||
NEXT_PUBLIC_NHOST_PLATFORM=false
|
||||
|
||||
# Environment Variables for Self Hosting and Local Development
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.nhost.run
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.nhost.run/v1/migrations
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.nhost.run
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.local.run/v1
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.local.nhost.run
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.local.nhost.run/v1/migrations
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.local.nhost.run
|
||||
|
||||
# Environment Variables when running the Nhost Dashboard against the Nhost Backend
|
||||
NEXT_PUBLIC_STRIPE_PK=<nhost_stripe_public_key>
|
||||
NEXT_PUBLIC_GITHUB_APP_INSTALL_URL=<github_app_install_url>
|
||||
NEXT_PUBLIC_ANALYTICS_WRITE_KEY=<analytics_write_key>
|
||||
NEXT_PUBLIC_SEGMENT_CDN_URL=<segment_cdn_url>
|
||||
NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET=<nhost_bragi_websocket>
|
||||
|
||||
NEXT_PUBLIC_ZENDESK_URL=
|
||||
@@ -22,6 +23,6 @@ NEXT_PUBLIC_ZENDESK_API_KEY=
|
||||
NEXT_PUBLIC_ZENDESK_USER_EMAIL=
|
||||
|
||||
|
||||
CODEGEN_GRAPHQL_URL=https://local.graphql.nhost.run/v1
|
||||
CODEGEN_GRAPHQL_URL=https://local.graphql.local.nhost.run/v1
|
||||
CODEGEN_HASURA_ADMIN_SECRET=nhost-admin-secret
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=FIXME
|
||||
2
dashboard/.vscode/settings.json
vendored
2
dashboard/.vscode/settings.json
vendored
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": true
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 2.17.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- fd59918: fix: redirect to 404 with nhost cli dashboard
|
||||
|
||||
## 2.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- f8e6b61: fix: can add rule groups in table permissions
|
||||
- 9e404c8: fix: not redirect to 404 page if using local Nhost backend
|
||||
- ac4aa01: fix: can delete column in database page
|
||||
- 4385524: fix: update url to check service health in local dashboard
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@16.0.1
|
||||
- @nhost/nextjs@2.2.2
|
||||
|
||||
## 2.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- f1052a8: fix: improve stability of the dashboard when pausing projects
|
||||
- 30daa41: fix: update links to docs in overview page
|
||||
- 7537237: feat: add image preview toggle in storage
|
||||
|
||||
## 2.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d43931e: fix: invalid organization slug/project subdomain doesn't open 404 page
|
||||
- 5df6fa2: feat: add unencrypted disk warning in storage capacity settings
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 44c1e17: chore: update `msw` to v1.3.5 to fix vulnerabilities
|
||||
- @nhost/react-apollo@16.0.0
|
||||
- @nhost/nextjs@2.2.1
|
||||
|
||||
## 2.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -33,7 +33,7 @@ ENV NEXT_PUBLIC_NHOST_CONFIGSERVER_URL=__NEXT_PUBLIC_NHOST_CONFIGSERVER_URL__
|
||||
RUN yarn global add pnpm@9.15.0
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-*.yaml .
|
||||
COPY --from=pruner /app/out/pnpm-*.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
@@ -51,13 +51,13 @@ You can connect the Nhost Dashboard to your locally running backend by setting t
|
||||
```bash
|
||||
NEXT_PUBLIC_ENV=dev
|
||||
NEXT_PUBLIC_NHOST_PLATFORM=false
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.nhost.run
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.nhost.run/v1/migrations
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.nhost.run
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.local.nhost.run
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.local.nhost.run/v1/migrations
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.local.nhost.run
|
||||
```
|
||||
|
||||
This will connect the Nhost Dashboard to your locally running Nhost backend.
|
||||
|
||||
43
dashboard/e2e/auth/edit-user.test.ts
Normal file
43
dashboard/e2e/auth/edit-user.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import test, { expect } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||
await page.goto(authUrl);
|
||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should be able to edit user roles from the details page', async () => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
await createUser({ page, email, password });
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: `View ${email}`, exact: true })
|
||||
.click();
|
||||
|
||||
await page.locator('#defaultRole').click();
|
||||
await page.getByRole('option', { name: /anonymous/i }).click();
|
||||
|
||||
await page.getByLabel('anonymous').click();
|
||||
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText('User settings have been updated successfully.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Local Dashboard CLI e2e tests', () => {
|
||||
test('should redirect / to the correct project URL', async ({ page }) => {
|
||||
await page.goto('https://local.dashboard.local.nhost.run/');
|
||||
await page.waitForURL(
|
||||
'https://local.dashboard.local.nhost.run/orgs/local/projects/local',
|
||||
);
|
||||
expect(page.url()).toBe(
|
||||
'https://local.dashboard.local.nhost.run/orgs/local/projects/local',
|
||||
);
|
||||
});
|
||||
|
||||
test('should load the project URL correctly', async ({ page }) => {
|
||||
const projectUrl =
|
||||
'https://local.dashboard.local.nhost.run/orgs/local/projects/local';
|
||||
await page.goto(projectUrl);
|
||||
await expect(page).toHaveURL(projectUrl);
|
||||
await expect(page.getByText(/Subdomain/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,7 @@ test('should delete a table', async () => {
|
||||
});
|
||||
|
||||
test('should not be able to delete a table if other tables have foreign keys referencing it', async () => {
|
||||
test.setTimeout(60000);
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
|
||||
149
dashboard/e2e/database/permissions-table.test.ts
Normal file
149
dashboard/e2e/database/permissions-table.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import {
|
||||
clickPermissionButton,
|
||||
navigateToProject,
|
||||
prepareTable,
|
||||
} from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { snakeCase } from 'snake-case';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
||||
});
|
||||
|
||||
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||
await page.goto(databaseRoute);
|
||||
await page.waitForURL(databaseRoute);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create a table with role permissions to select row', async () => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
const tableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text' },
|
||||
],
|
||||
});
|
||||
|
||||
// create table
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: tableName, exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// Press three horizontal dots more options button next to the table name
|
||||
await page
|
||||
.locator(`li:has-text("${tableName}") #table-management-menu button`)
|
||||
.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: /edit permissions/i }).click();
|
||||
|
||||
await clickPermissionButton({ page, role: 'user', permission: 'Select' });
|
||||
|
||||
await page.getByLabel('Without any checks').click();
|
||||
await page.getByRole('button', { name: /select all/i }).click();
|
||||
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/permission has been saved successfully/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a table with role permissions and a custom check to select rows', async () => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
const tableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text' },
|
||||
],
|
||||
});
|
||||
|
||||
// create table
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: tableName, exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// Press three horizontal dots more options button next to the table name
|
||||
await page
|
||||
.locator(`li:has-text("${tableName}") #table-management-menu button`)
|
||||
.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: /edit permissions/i }).click();
|
||||
|
||||
await clickPermissionButton({ page, role: 'user', permission: 'Select' });
|
||||
|
||||
await page.getByLabel('With custom check').click();
|
||||
|
||||
// await page.getByRole('combobox', { name: /select a column/i }).click();
|
||||
await page.getByText('Select a column', { exact: true }).click();
|
||||
|
||||
const columnSelector = page.locator('input[role="combobox"]');
|
||||
|
||||
await columnSelector.fill('id');
|
||||
|
||||
await columnSelector.press('Enter');
|
||||
|
||||
await expect(page.getByText(/_eq/i)).toBeVisible();
|
||||
|
||||
// limit on number of rows fetched per request.
|
||||
await page.locator('#limit').fill('100');
|
||||
|
||||
await page.getByText('Select variable...', { exact: true }).click();
|
||||
|
||||
const variableSelector = await page.locator('input[role="combobox"]');
|
||||
|
||||
await variableSelector.fill('X-Hasura-User-Id');
|
||||
|
||||
await variableSelector.press('Enter');
|
||||
|
||||
await page.getByRole('button', { name: /select all/i }).click();
|
||||
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/permission has been saved successfully/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
61
dashboard/e2e/teardown/database.teardown.ts
Normal file
61
dashboard/e2e/teardown/database.teardown.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
TEST_DASHBOARD_URL,
|
||||
TEST_ORGANIZATION_SLUG,
|
||||
TEST_PROJECT_SUBDOMAIN,
|
||||
} from '@/e2e/env';
|
||||
import { navigateToProject } from '@/e2e/utils';
|
||||
import { type Page, expect, test as teardown } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
teardown.beforeAll(async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
baseURL: TEST_DASHBOARD_URL,
|
||||
storageState: 'e2e/.auth/user.json',
|
||||
});
|
||||
|
||||
page = await context.newPage();
|
||||
});
|
||||
|
||||
teardown.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
||||
});
|
||||
|
||||
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||
await page.goto(databaseRoute);
|
||||
await page.waitForURL(databaseRoute);
|
||||
});
|
||||
|
||||
teardown.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
teardown('clean up database tables', async () => {
|
||||
await page.getByRole('link', { name: /sql editor/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/editor`,
|
||||
);
|
||||
|
||||
const inputField = page.locator('[contenteditable]');
|
||||
await inputField.fill(`
|
||||
DO $$ DECLARE
|
||||
tablename text;
|
||||
BEGIN
|
||||
FOR tablename IN
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await page.locator('button[type="button"]', { hasText: /run/i }).click();
|
||||
await expect(page.getByText(/success/i)).toBeVisible();
|
||||
});
|
||||
@@ -191,3 +191,23 @@ export function generateTestEmail(prefix: string = 'Nhost_Test_') {
|
||||
|
||||
return [prefix, email].join('');
|
||||
}
|
||||
|
||||
export async function clickPermissionButton({
|
||||
page,
|
||||
role,
|
||||
permission,
|
||||
}: {
|
||||
page: Page;
|
||||
role: string;
|
||||
permission: 'Insert' | 'Select' | 'Update' | 'Delete';
|
||||
}) {
|
||||
const permissionIndex =
|
||||
['Insert', 'Select', 'Update', 'Delete'].indexOf(permission) + 1;
|
||||
|
||||
await page
|
||||
.locator('tr', { hasText: role })
|
||||
.locator('td')
|
||||
.nth(permissionIndex)
|
||||
.locator('button')
|
||||
.click();
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import {
|
||||
TEST_DASHBOARD_URL,
|
||||
TEST_ORGANIZATION_SLUG,
|
||||
TEST_PROJECT_ADMIN_SECRET,
|
||||
TEST_PROJECT_SUBDOMAIN,
|
||||
} from '@/e2e/env';
|
||||
import { navigateToProject } from '@/e2e/utils';
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
async function globalTeardown() {
|
||||
const browser = await chromium.launch({ slowMo: 1000 });
|
||||
|
||||
const context = await browser.newContext({
|
||||
baseURL: TEST_DASHBOARD_URL,
|
||||
storageState: 'e2e/.auth/user.json',
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
||||
});
|
||||
|
||||
const pagePromise = context.waitForEvent('page');
|
||||
|
||||
await page.getByRole('link', { name: /hasura/i }).click();
|
||||
await page.getByRole('link', { name: /open hasura/i }).click();
|
||||
|
||||
const hasuraPage = await pagePromise;
|
||||
await hasuraPage.waitForLoadState();
|
||||
|
||||
const adminSecretInput = hasuraPage.getByPlaceholder(/enter admin-secret/i);
|
||||
|
||||
// note: a more ideal way would be to paste from clipboard, but Playwright
|
||||
// doesn't support that yet
|
||||
await adminSecretInput.fill(TEST_PROJECT_ADMIN_SECRET);
|
||||
await adminSecretInput.press('Enter');
|
||||
|
||||
// note: getByRole doesn't work here
|
||||
await hasuraPage.locator('a', { hasText: /data/i }).nth(0).click();
|
||||
await hasuraPage.locator('[data-test="sql-link"]').click();
|
||||
|
||||
// Set the value of the Ace code editor using JavaScript evaluation in the browser context
|
||||
await hasuraPage.evaluate(() => {
|
||||
const editor = ace.edit('raw_sql');
|
||||
|
||||
editor.setValue(`
|
||||
DO $$ DECLARE
|
||||
tablename text;
|
||||
BEGIN
|
||||
FOR tablename IN
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
});
|
||||
|
||||
await hasuraPage.getByRole('button', { name: /run!/i }).click();
|
||||
await hasuraPage.getByText(/sql executed!/i).waitFor();
|
||||
}
|
||||
|
||||
export default globalTeardown;
|
||||
@@ -1,5 +1,5 @@
|
||||
schema:
|
||||
- https://local.graphql.nhost.run/v1:
|
||||
- https://local.graphql.local.nhost.run/v1:
|
||||
headers:
|
||||
x-hasura-admin-secret: nhost-admin-secret
|
||||
generates:
|
||||
|
||||
@@ -7,7 +7,7 @@ const { version } = require('./package.json');
|
||||
const cspHeader = `
|
||||
default-src 'self' *.nhost.run ws://*.nhost.run nhost.run ws://nhost.run;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' cdn.segment.com js.stripe.com;
|
||||
connect-src 'self' *.nhost.run ws://*.nhost.run nhost.run ws://nhost.run discord.com;
|
||||
connect-src 'self' *.nhost.run ws://*.nhost.run nhost.run ws://nhost.run discord.com api.segment.io api.segment.com cdn.segment.com nhost.zendesk.com;
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' blob: data: avatars.githubusercontent.com s.gravatar.com *.nhost.run nhost.run;
|
||||
font-src 'self' data:;
|
||||
@@ -16,6 +16,8 @@ const cspHeader = `
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
frame-src 'self' js.stripe.com;
|
||||
block-all-mixed-content;
|
||||
upgrade-insecure-requests;
|
||||
`;
|
||||
|
||||
module.exports = withBundleAnalyzer({
|
||||
@@ -36,9 +38,13 @@ module.exports = withBundleAnalyzer({
|
||||
{
|
||||
source: '/(.*)',
|
||||
headers: [
|
||||
// {
|
||||
// key: 'Content-Security-Policy',
|
||||
// hgvalue: cspHeader.replace(/\s+/g, ' ').trim(),
|
||||
// },
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'SAMEORIGIN',
|
||||
value: 'DENY',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "2.13.0",
|
||||
"version": "2.24.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -16,13 +16,15 @@
|
||||
"storybook": "start-storybook -p 6006 -s public",
|
||||
"build-storybook": "build-storybook",
|
||||
"install-browsers": "pnpm playwright install && pnpm playwright install-deps",
|
||||
"e2e": "pnpm install-browsers && pnpm playwright test"
|
||||
"e2e": "pnpm install-browsers && pnpm playwright test --config=playwright.config.ts",
|
||||
"e2e-local": "pnpm install-browsers && pnpm playwright test --config=playwright.local.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.9.9",
|
||||
"@codemirror/lang-sql": "^6.6.2",
|
||||
"@codemirror/language": "^6.10.1",
|
||||
"@codemirror/legacy-modes": "^6.4.0",
|
||||
"@date-fns/tz": "^1.2.0",
|
||||
"@emotion/cache": "^11.11.0",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/server": "^11.11.0",
|
||||
@@ -55,24 +57,25 @@
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@segment/snippet": "^4.16.2",
|
||||
"@segment/analytics-next": "^1.77.0",
|
||||
"@stripe/react-stripe-js": "^2.6.2",
|
||||
"@stripe/stripe-js": "^1.54.2",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-table": "^8.15.3",
|
||||
"@tanstack/react-virtual": "^3.2.0",
|
||||
"@tanstack/react-virtual": "^3.5.0",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@uiw/codemirror-theme-bbedit": "^4.22.2",
|
||||
"@uiw/codemirror-theme-github": "^4.21.25",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"analytics-node": "^6.2.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^1.2.1",
|
||||
"cmdk": "1.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"date-fns-v4": "npm:date-fns@4.1.0",
|
||||
"dequal": "^2.0.3",
|
||||
"framer-motion": "^10.18.0",
|
||||
"generate-password": "^1.7.1",
|
||||
@@ -93,6 +96,7 @@
|
||||
"react": "18.2.0",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-complex-tree": "^2.4.5",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-hook-form": "^7.53.0",
|
||||
@@ -113,6 +117,7 @@
|
||||
"stripe": "^10.17.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"timezones-list": "^3.1.0",
|
||||
"utility-types": "^3.11.0",
|
||||
"uuid": "^9.0.1",
|
||||
"validator": "^13.11.0",
|
||||
@@ -177,13 +182,13 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"lint-staged": "^15.2.2",
|
||||
"msw": "^1.3.3",
|
||||
"msw": "^1.3.5",
|
||||
"msw-storybook-addon": "^1.10.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"react-date-fns-hooks": "^0.9.4",
|
||||
"require-from-string": "^2.0.2",
|
||||
"snake-case": "^3.0.4",
|
||||
@@ -191,7 +196,7 @@
|
||||
"tailwindcss": "^3.4.12",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths-webpack-plugin": "^4.1.0",
|
||||
"vite": "^5.4.6",
|
||||
"vite": "^5.4.12",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^0.32.4"
|
||||
},
|
||||
|
||||
@@ -6,16 +6,15 @@ dotenv.config({ path: path.resolve(__dirname, '.env.test') });
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 40 * 1000,
|
||||
timeout: 60 * 1000,
|
||||
expect: {
|
||||
timeout: 5000,
|
||||
timeout: 10000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
globalTeardown: require.resolve('./global-teardown'),
|
||||
use: {
|
||||
actionTimeout: 0,
|
||||
trace: 'on-first-retry',
|
||||
@@ -28,6 +27,11 @@ export default defineConfig({
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: ['**/setup/*.setup.ts'],
|
||||
teardown: 'teardown',
|
||||
},
|
||||
{
|
||||
name: 'teardown',
|
||||
testMatch: ['**/teardown/*.teardown.ts'],
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
@@ -36,6 +40,7 @@ export default defineConfig({
|
||||
storageState: 'e2e/.auth/user.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
grepInvert: [/Local Dashboard CLI e2e tests/],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
31
dashboard/playwright.local.config.ts
Normal file
31
dashboard/playwright.local.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
timeout: 5000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
actionTimeout: 0,
|
||||
trace: 'on-first-retry',
|
||||
baseURL: '', // Local dashboard URL
|
||||
launchOptions: {
|
||||
slowMo: 500,
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
testMatch: ['**/e2e/cli-local-dashboard/**'],
|
||||
},
|
||||
],
|
||||
});
|
||||
30
dashboard/src/components/analytics/analytics.tsx
Normal file
30
dashboard/src/components/analytics/analytics.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { analytics } from '@/lib/segment';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function Analytics() {
|
||||
const router = useRouter();
|
||||
const { org } = useCurrentOrg();
|
||||
const { project } = useProject();
|
||||
|
||||
useEffect(() => {
|
||||
const customProperties = {
|
||||
organizationSlug: org?.slug || '',
|
||||
projectSubdomain: project?.subdomain || '',
|
||||
};
|
||||
|
||||
analytics.page(customProperties);
|
||||
|
||||
const handleRouteChange = () => analytics.page(customProperties);
|
||||
|
||||
router.events.on('routeChangeComplete', handleRouteChange);
|
||||
|
||||
return () => {
|
||||
router.events.off('routeChangeComplete', handleRouteChange);
|
||||
};
|
||||
}, [router.events, org?.slug, project?.subdomain]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export default function ApplyLocalSettingsDialog() {
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text color="secondary">
|
||||
Run{' '}
|
||||
<code className="px-1 py-px mx-1 rounded-md bg-slate-500 text-slate-100">
|
||||
<code className="mx-1 rounded-md bg-slate-500 px-1 py-px text-slate-100">
|
||||
$ nhost up
|
||||
</code>{' '}
|
||||
using the cli to apply your changes
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function FeedbackForm({
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'grid max-w-md grid-flow-row gap-2 py-4 px-5',
|
||||
'grid max-w-md grid-flow-row gap-2 px-5 py-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import { TimePicker } from '@/components/common/TimePicker';
|
||||
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { Calendar } from '@/components/ui/v3/calendar';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/v3/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { guessTimezone } from '@/utils/timezoneUtils';
|
||||
import { TZDate } from '@date-fns/tz';
|
||||
import { add, format, parseISO } from 'date-fns-v4';
|
||||
import { Calendar as CalendarIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import TimezoneSettings from './TimezoneSettings';
|
||||
|
||||
export interface DateTimePickerProps {
|
||||
dateTime: string;
|
||||
onDateTimeChange: (newDate: string) => void;
|
||||
withTimezone?: boolean;
|
||||
defaultTimezone?: string;
|
||||
formatDateFn?: (date: Date | string) => string;
|
||||
isCalendarDayDisabled?: (date: Date) => boolean;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
validateDateFn?: (date: Date) => string;
|
||||
}
|
||||
// in: UTC datetime
|
||||
// out: UTC dateTime
|
||||
|
||||
function DateTimePicker({
|
||||
dateTime,
|
||||
withTimezone = false,
|
||||
defaultTimezone,
|
||||
formatDateFn,
|
||||
onDateTimeChange,
|
||||
isCalendarDayDisabled,
|
||||
align = 'start',
|
||||
validateDateFn,
|
||||
}: DateTimePickerProps) {
|
||||
const [date, setDate] = useState(() => {
|
||||
if (withTimezone) {
|
||||
const tz = defaultTimezone || guessTimezone();
|
||||
return new TZDate(dateTime, tz);
|
||||
}
|
||||
return parseISO(dateTime);
|
||||
});
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
function emitNewDateTime() {
|
||||
onDateTimeChange(new Date(date.getTime()).toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* carry over the current time when a user clicks a new day
|
||||
* instead of resetting to 00:00
|
||||
*/
|
||||
function handleSelect(newDay: Date | undefined) {
|
||||
if (!newDay) {
|
||||
return;
|
||||
}
|
||||
if (!date) {
|
||||
setDate(newDay);
|
||||
return;
|
||||
}
|
||||
const diff = newDay.getTime() - date.getTime();
|
||||
const diffInDays = diff / (1000 * 60 * 60 * 24);
|
||||
const newDateFull = add(date, { days: Math.ceil(diffInDays) });
|
||||
setDate(newDateFull);
|
||||
}
|
||||
|
||||
function handleTimezoneChange(newTimezone: string) {
|
||||
const newDateWithTimezone = new TZDate(date.toISOString(), newTimezone);
|
||||
setDate(newDateWithTimezone);
|
||||
}
|
||||
|
||||
function handleOpenChange(newOpenState: boolean) {
|
||||
if (!newOpenState) {
|
||||
if (withTimezone) {
|
||||
const tz = defaultTimezone || guessTimezone();
|
||||
setDate(new TZDate(dateTime, tz));
|
||||
}
|
||||
setDate(parseISO(dateTime));
|
||||
}
|
||||
setOpen(newOpenState);
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
emitNewDateTime();
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const dateString = formatDateFn?.(date) || format(date, 'PPP HH:mm:ss');
|
||||
|
||||
const errorText = validateDateFn?.(date);
|
||||
const hasError = !!errorText;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'w-full justify-between text-left font-normal',
|
||||
!date && 'text-muted-foreground',
|
||||
{ 'border-destructive': hasError },
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{date ? dateString : <span>Pick a date</span>}
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align={align}>
|
||||
<div className="flex">
|
||||
<div className="flex">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={(d) => handleSelect(d)}
|
||||
initialFocus
|
||||
disabled={isCalendarDayDisabled}
|
||||
/>
|
||||
<div className="flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="border-t border-border p-3">
|
||||
<TimePicker setDate={setDate} date={date} />
|
||||
</div>
|
||||
{withTimezone && (
|
||||
<div className="border-t border-border p-3">
|
||||
<TimezoneSettings
|
||||
dateTime={dateTime}
|
||||
onTimezoneChange={handleTimezoneChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row justify-between gap-5 p-3">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={onSelect}
|
||||
disabled={hasError}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn('p-3 text-center text-[11px] text-destructive', {
|
||||
invisible: !hasError,
|
||||
})}
|
||||
>
|
||||
{errorText}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default DateTimePicker;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { TimezonePicker } from '@/components/common/TimezonePicker';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { getUTCOffsetInHours, guessTimezone } from '@/utils/timezoneUtils';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
dateTime: string;
|
||||
onTimezoneChange: (timezone: string) => void;
|
||||
}
|
||||
|
||||
function TimezoneSettings({ dateTime, onTimezoneChange }: Props) {
|
||||
const [selectedTimezone, setTimezone] = useState<string>(() =>
|
||||
guessTimezone(),
|
||||
);
|
||||
|
||||
function handleTimezoneSelect(tz: { value: string; label: string }) {
|
||||
setTimezone(tz.value);
|
||||
onTimezoneChange?.(tz.value);
|
||||
}
|
||||
const utcOffset = getUTCOffsetInHours(selectedTimezone, dateTime, 'OOOO');
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
Timezone: {utcOffset}{' '}
|
||||
<TimezonePicker
|
||||
dateTime={dateTime}
|
||||
selectedTimezone={selectedTimezone}
|
||||
onTimezoneSelect={handleTimezoneSelect}
|
||||
button={
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings2 className="h-4 w-4 dark:text-foreground" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimezoneSettings;
|
||||
1
dashboard/src/components/common/DateTimePicker/index.ts
Normal file
1
dashboard/src/components/common/DateTimePicker/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as DateTimePicker } from './DateTimePicker';
|
||||
@@ -24,7 +24,7 @@ function IconLink(
|
||||
return (
|
||||
<span
|
||||
className={twMerge(
|
||||
'grid cursor-default grid-flow-row justify-items-center gap-1 rounded-md py-2.5 px-0.5 text-center text-[10px] font-medium opacity-40',
|
||||
'grid cursor-default grid-flow-row justify-items-center gap-1 rounded-md px-0.5 py-2.5 text-center text-[10px] font-medium opacity-40',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -68,7 +68,7 @@ function IconLink(
|
||||
href={href}
|
||||
underline="none"
|
||||
className={twMerge(
|
||||
'grid grid-flow-row justify-items-center gap-1 rounded-md py-2.5 px-0.5 text-center font-medium motion-safe:transition-colors',
|
||||
'grid grid-flow-row justify-items-center gap-1 rounded-md px-0.5 py-2.5 text-center font-medium motion-safe:transition-colors',
|
||||
className,
|
||||
)}
|
||||
sx={{
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function InviteNotification() {
|
||||
|
||||
const handleInviteAccept = async (
|
||||
_event: React.SyntheticEvent<HTMLButtonElement>,
|
||||
invite: typeof data.workspaceMemberInvites[number],
|
||||
invite: (typeof data.workspaceMemberInvites)[number],
|
||||
) => {
|
||||
setSubmitState({
|
||||
error: null,
|
||||
@@ -99,7 +99,7 @@ export default function InviteNotification() {
|
||||
};
|
||||
|
||||
async function handleIgnoreInvitation(
|
||||
inviteId: typeof data.workspaceMemberInvites[number]['id'],
|
||||
inviteId: (typeof data.workspaceMemberInvites)[number]['id'],
|
||||
) {
|
||||
setIgnoreState({
|
||||
loading: true,
|
||||
@@ -151,7 +151,7 @@ export default function InviteNotification() {
|
||||
}}
|
||||
>
|
||||
{data?.workspaceMemberInvites?.map(
|
||||
(invite: typeof data.workspaceMemberInvites[number]) => (
|
||||
(invite: (typeof data.workspaceMemberInvites)[number]) => (
|
||||
<div key={invite.id} className="grid grid-flow-row gap-4 text-center">
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text variant="h3" component="h2" sx={{ color: 'common.white' }}>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useIsCurrentUserOwner } from '@/features/orgs/projects/common/hooks/useIsCurrentUserOwner';
|
||||
|
||||
interface Props {
|
||||
buttonText?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function OpenTransferDialogButton({ buttonText, onClick }: Props) {
|
||||
const text = buttonText ?? 'Transfer Project';
|
||||
const isOwner = useIsCurrentUserOwner();
|
||||
const { openAlertDialog } = useDialog();
|
||||
const handleClick = () => {
|
||||
if (isOwner) {
|
||||
onClick();
|
||||
} else {
|
||||
openAlertDialog({
|
||||
title: "You can't migrate this project",
|
||||
payload: (
|
||||
<Text variant="subtitle1" component="span">
|
||||
Ask an owner of this organization to migrate the project.
|
||||
</Text>
|
||||
),
|
||||
props: {
|
||||
secondaryButtonText: 'I understand',
|
||||
hidePrimaryAction: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Button className="max-w-xs lg:w-auto" onClick={handleClick}>
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default OpenTransferDialogButton;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as OpenTransferDialogButton } from './OpenTransferDialogButton';
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
@@ -8,7 +7,7 @@ import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { } from '@/utils/__generated__/graphql';
|
||||
import {} from '@/utils/__generated__/graphql';
|
||||
import { Divider } from '@mui/material';
|
||||
import debounce from 'lodash.debounce';
|
||||
import Image from 'next/image';
|
||||
@@ -37,20 +36,15 @@ export default function SelectOrganizationAndProject() {
|
||||
|
||||
useEffect(() => () => handleFilterChange.cancel(), [handleFilterChange]);
|
||||
|
||||
const goToOrgPage = async (org: {
|
||||
name: string;
|
||||
value: string;
|
||||
}) => {
|
||||
const goToOrgPage = async (org: { name: string; value: string }) => {
|
||||
const { slug } = router.query;
|
||||
await router.push({
|
||||
pathname: `${org.value}/${
|
||||
(() => {
|
||||
if (!slug) {
|
||||
return '';
|
||||
}
|
||||
return Array.isArray(slug) ? slug.join('/') : slug;
|
||||
})()
|
||||
}`,
|
||||
pathname: `${org.value}/${(() => {
|
||||
if (!slug) {
|
||||
return '';
|
||||
}
|
||||
return Array.isArray(slug) ? slug.join('/') : slug;
|
||||
})()}`,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -63,16 +57,13 @@ export default function SelectOrganizationAndProject() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
label="Loading organizations..."
|
||||
/>
|
||||
<ActivityIndicator delay={500} label="Loading organizations..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start w-full h-full px-5 py-4 mx-auto bg-background">
|
||||
<div className="mx-auto flex h-full w-full flex-col items-start bg-background px-5 py-4">
|
||||
<div className="mx-auto flex h-full w-full max-w-[760px] flex-col gap-4 py-6 sm:py-14">
|
||||
<Text variant="h2" component="h1" className="">
|
||||
Select an Organization
|
||||
@@ -97,7 +88,7 @@ export default function SelectOrganizationAndProject() {
|
||||
{orgsToDisplay.map((org, index) => (
|
||||
<Fragment key={org.value}>
|
||||
<ListItem.Root
|
||||
className="grid grid-flow-col justify-start gap-2 py-2.5"
|
||||
className="grid grid-flow-col justify-start gap-2 py-2.5"
|
||||
secondaryAction={
|
||||
<Button
|
||||
variant="borderless"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
@@ -8,7 +7,7 @@ import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { } from '@/utils/__generated__/graphql';
|
||||
import {} from '@/utils/__generated__/graphql';
|
||||
import { Divider } from '@mui/material';
|
||||
import debounce from 'lodash.debounce';
|
||||
import Image from 'next/image';
|
||||
@@ -47,14 +46,12 @@ export default function SelectOrganizationAndProject() {
|
||||
}) => {
|
||||
const { slug } = router.query;
|
||||
await router.push({
|
||||
pathname: `${project.value}/${
|
||||
(() => {
|
||||
if (!slug) {
|
||||
return '';
|
||||
}
|
||||
return Array.isArray(slug) ? slug.join('/') : slug;
|
||||
})()
|
||||
}`,
|
||||
pathname: `${project.value}/${(() => {
|
||||
if (!slug) {
|
||||
return '';
|
||||
}
|
||||
return Array.isArray(slug) ? slug.join('/') : slug;
|
||||
})()}`,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -76,7 +73,7 @@ export default function SelectOrganizationAndProject() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start w-full h-full px-5 py-4 mx-auto bg-background">
|
||||
<div className="mx-auto flex h-full w-full flex-col items-start bg-background px-5 py-4">
|
||||
<div className="mx-auto flex h-full w-full max-w-[760px] flex-col gap-4 py-6 sm:py-14">
|
||||
<Text variant="h2" component="h1" className="">
|
||||
Select a Project
|
||||
@@ -101,7 +98,7 @@ export default function SelectOrganizationAndProject() {
|
||||
{projectsToDisplay.map((project, index) => (
|
||||
<Fragment key={project.value}>
|
||||
<ListItem.Root
|
||||
className="grid grid-flow-col justify-start gap-2 py-2.5"
|
||||
className="grid grid-flow-col justify-start gap-2 py-2.5"
|
||||
secondaryAction={
|
||||
<Button
|
||||
variant="borderless"
|
||||
@@ -138,4 +135,4 @@ export default function SelectOrganizationAndProject() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
105
dashboard/src/components/common/TimePicker/TimePicker.test.tsx
Normal file
105
dashboard/src/components/common/TimePicker/TimePicker.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { render, screen } from '@/tests/orgs/testUtils';
|
||||
import { guessTimezone } from '@/utils/timezoneUtils';
|
||||
import { TZDate } from '@date-fns/tz';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { format } from 'date-fns-v4';
|
||||
import { useState } from 'react';
|
||||
import TimePicker from './TimePicker';
|
||||
|
||||
function TestComponent({
|
||||
dateTime,
|
||||
withTimezone,
|
||||
}: {
|
||||
dateTime: string;
|
||||
withTimezone?: boolean;
|
||||
}) {
|
||||
const [date, setDate] = useState(() => {
|
||||
if (withTimezone) {
|
||||
const tz = guessTimezone();
|
||||
return new TZDate(dateTime, tz);
|
||||
}
|
||||
return parseISO(dateTime);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Time: {format(date, 'HH:mm:ss')}</h1>
|
||||
<h1>Date class: {date instanceof TZDate ? 'TZDate' : 'Date'}</h1>
|
||||
<TimePicker date={date} setDate={setDate} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
describe('TimePicker', () => {
|
||||
test('Updates only the hour of the date object', async () => {
|
||||
render(<TestComponent dateTime="2025-03-10T03:00:05" />);
|
||||
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||
'Time: 03:00:05',
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const hoursInput = await screen.getByLabelText('Hours');
|
||||
await user.type(hoursInput, '18');
|
||||
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||
'Time: 18:00:05',
|
||||
);
|
||||
});
|
||||
|
||||
test('only valid hours(0-23), minutes(0-59) and seconds(0-59) are allowed', async () => {
|
||||
render(<TestComponent dateTime="2025-03-10T03:00:05" />);
|
||||
const user = userEvent.setup();
|
||||
const hoursInput = await screen.getByLabelText('Hours');
|
||||
await user.type(hoursInput, '30');
|
||||
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||
'Time: 23:00:05',
|
||||
);
|
||||
const minutesInput = await screen.getByLabelText('Minutes');
|
||||
await user.type(minutesInput, '66');
|
||||
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||
'Time: 23:59:05',
|
||||
);
|
||||
});
|
||||
|
||||
test('Updates only the minutes of the date object', async () => {
|
||||
render(<TestComponent dateTime="2025-03-10T03:00:05" />);
|
||||
const user = userEvent.setup();
|
||||
const minutesInput = await screen.getByLabelText('Minutes');
|
||||
await user.type(minutesInput, '44');
|
||||
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||
'Time: 03:44:05',
|
||||
);
|
||||
});
|
||||
|
||||
test('Updates only the seconds of the date object', async () => {
|
||||
render(<TestComponent dateTime="2025-03-10T03:00:05" />);
|
||||
const user = userEvent.setup();
|
||||
const secondsInput = await screen.getByLabelText('Seconds');
|
||||
await user.type(secondsInput, '11');
|
||||
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||
'Time: 03:00:11',
|
||||
);
|
||||
});
|
||||
|
||||
test("will preserve the date's class after changing the date", async () => {
|
||||
render(<TestComponent dateTime="2025-03-10T03:00:05" withTimezone />);
|
||||
expect(await screen.getByText(/Date class:/i)).toHaveTextContent(
|
||||
'Date class: TZDate',
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
const hoursInput = await screen.getByLabelText('Hours');
|
||||
await user.type(hoursInput, '18');
|
||||
expect(await screen.getByText(/Date class:/i)).toHaveTextContent(
|
||||
'Date class: TZDate',
|
||||
);
|
||||
const secondsInput = await screen.getByLabelText('Seconds');
|
||||
await user.type(secondsInput, '11');
|
||||
expect(await screen.getByText(/Date class:/i)).toHaveTextContent(
|
||||
'Date class: TZDate',
|
||||
);
|
||||
const minutesInput = await screen.getByLabelText('Minutes');
|
||||
await user.type(minutesInput, '44');
|
||||
expect(await screen.getByText(/Date class:/i)).toHaveTextContent(
|
||||
'Date class: TZDate',
|
||||
);
|
||||
});
|
||||
});
|
||||
64
dashboard/src/components/common/TimePicker/TimePicker.tsx
Normal file
64
dashboard/src/components/common/TimePicker/TimePicker.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { Label } from '@/components/ui/v3/label';
|
||||
import { Clock } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { TimePickerInput } from './TimePickerInput';
|
||||
|
||||
interface TimePickerProps {
|
||||
date: Date | undefined;
|
||||
setDate: (date: Date | undefined) => void;
|
||||
}
|
||||
|
||||
function TimePicker({ date, setDate }: TimePickerProps) {
|
||||
const minuteRef = React.useRef<HTMLInputElement>(null);
|
||||
const hourRef = React.useRef<HTMLInputElement>(null);
|
||||
const secondRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="grid gap-1 text-center">
|
||||
<Label htmlFor="hours" className="text-xs">
|
||||
Hours
|
||||
</Label>
|
||||
<TimePickerInput
|
||||
picker="hours"
|
||||
date={date}
|
||||
setDate={setDate}
|
||||
ref={hourRef}
|
||||
onRightFocus={() => minuteRef.current?.focus()}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1 text-center">
|
||||
<Label htmlFor="minutes" className="text-xs">
|
||||
Minutes
|
||||
</Label>
|
||||
<TimePickerInput
|
||||
picker="minutes"
|
||||
date={date}
|
||||
setDate={setDate}
|
||||
ref={minuteRef}
|
||||
onLeftFocus={() => hourRef.current?.focus()}
|
||||
onRightFocus={() => secondRef.current?.focus()}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1 text-center">
|
||||
<Label htmlFor="seconds" className="text-xs">
|
||||
Seconds
|
||||
</Label>
|
||||
<TimePickerInput
|
||||
picker="seconds"
|
||||
date={date}
|
||||
setDate={setDate}
|
||||
ref={secondRef}
|
||||
onLeftFocus={() => minuteRef.current?.focus()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-10 items-center">
|
||||
<Clock className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimePicker;
|
||||
148
dashboard/src/components/common/TimePicker/TimePickerInput.tsx
Normal file
148
dashboard/src/components/common/TimePicker/TimePickerInput.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Input } from '@/components/ui/v3/input';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import React from 'react';
|
||||
import {
|
||||
type Period,
|
||||
type TimePickerType,
|
||||
copyDate,
|
||||
getArrowByType,
|
||||
getDateByType,
|
||||
setDateByType,
|
||||
} from './time-picker-utils';
|
||||
|
||||
export interface TimePickerInputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
picker: TimePickerType;
|
||||
date: Date | undefined;
|
||||
setDate: (date: Date | undefined) => void;
|
||||
period?: Period;
|
||||
onRightFocus?: () => void;
|
||||
onLeftFocus?: () => void;
|
||||
}
|
||||
|
||||
const TimePickerInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
TimePickerInputProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
type = 'tel',
|
||||
value,
|
||||
id,
|
||||
name,
|
||||
date = new Date(new Date().setHours(0, 0, 0, 0)),
|
||||
setDate,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
picker,
|
||||
period,
|
||||
onLeftFocus,
|
||||
onRightFocus,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [flag, setFlag] = React.useState<boolean>(false);
|
||||
const [prevIntKey, setPrevIntKey] = React.useState<string>('0');
|
||||
|
||||
/**
|
||||
* allow the user to enter the second digit within 2 seconds
|
||||
* otherwise start again with entering first digit
|
||||
*/
|
||||
// eslint-disable-next-line consistent-return
|
||||
React.useEffect(() => {
|
||||
if (flag) {
|
||||
const timer = setTimeout(() => {
|
||||
setFlag(false);
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [flag]);
|
||||
|
||||
const calculatedValue = React.useMemo(
|
||||
() => getDateByType(date, picker),
|
||||
[date, picker],
|
||||
);
|
||||
|
||||
const calculateNewValue = (key: string) => {
|
||||
/*
|
||||
* If picker is '12hours' and the first digit is 0, then the second digit is automatically set to 1.
|
||||
* The second entered digit will break the condition and the value will be set to 10-12.
|
||||
*/
|
||||
if (picker === '12hours') {
|
||||
if (flag && calculatedValue.slice(1, 2) === '1' && prevIntKey === '0') {
|
||||
return `0${key}`;
|
||||
}
|
||||
}
|
||||
|
||||
return !flag ? `0${key}` : calculatedValue.slice(1, 2) + key;
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Tab') {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (e.key === 'ArrowRight') {
|
||||
onRightFocus?.();
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
onLeftFocus?.();
|
||||
}
|
||||
if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
|
||||
const step = e.key === 'ArrowUp' ? 1 : -1;
|
||||
const newValue = getArrowByType(calculatedValue, step, picker);
|
||||
if (flag) {
|
||||
setFlag(false);
|
||||
}
|
||||
|
||||
const tempDate = copyDate(date);
|
||||
setDate(setDateByType(tempDate, newValue, picker, period));
|
||||
}
|
||||
if (e.key >= '0' && e.key <= '9') {
|
||||
if (picker === '12hours') {
|
||||
setPrevIntKey(e.key);
|
||||
}
|
||||
|
||||
const newValue = calculateNewValue(e.key);
|
||||
if (flag) {
|
||||
onRightFocus?.();
|
||||
}
|
||||
setFlag((prev) => !prev);
|
||||
const tempDate = copyDate(date);
|
||||
setDate(setDateByType(tempDate, newValue, picker, period));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
id={id || picker}
|
||||
name={name || picker}
|
||||
className={cn(
|
||||
'w-[48px] text-center font-mono text-base tabular-nums focus:bg-accent focus:text-accent-foreground [&::-webkit-inner-spin-button]:appearance-none',
|
||||
className,
|
||||
)}
|
||||
value={value || calculatedValue}
|
||||
onChange={(e) => {
|
||||
e.preventDefault();
|
||||
onChange?.(e);
|
||||
}}
|
||||
type={type}
|
||||
inputMode="decimal"
|
||||
onKeyDown={(e) => {
|
||||
onKeyDown?.(e);
|
||||
handleKeyDown(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TimePickerInput.displayName = 'TimePickerInput';
|
||||
|
||||
export { TimePickerInput };
|
||||
1
dashboard/src/components/common/TimePicker/index.ts
Normal file
1
dashboard/src/components/common/TimePicker/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as TimePicker } from './TimePicker';
|
||||
@@ -0,0 +1,336 @@
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
convert12HourTo24Hour,
|
||||
display12HourValue,
|
||||
getArrowByType,
|
||||
getDateByType,
|
||||
getValid12Hour,
|
||||
getValidArrow12Hour,
|
||||
getValidArrowHour,
|
||||
getValidArrowMinuteOrSecond,
|
||||
getValidArrowNumber,
|
||||
getValidHour,
|
||||
getValidMinuteOrSecond,
|
||||
getValidNumber,
|
||||
isValid12Hour,
|
||||
isValidHour,
|
||||
isValidMinuteOrSecond,
|
||||
set12Hours,
|
||||
setDateByType,
|
||||
setHours,
|
||||
setMinutes,
|
||||
setSeconds,
|
||||
type TimePickerType,
|
||||
} from './time-picker-utils';
|
||||
|
||||
// Mock TZDate if needed
|
||||
vi.mock('@date-fns/tz', () => ({
|
||||
TZDate: class MockTZDate extends Date {
|
||||
timeZone: string;
|
||||
|
||||
constructor(date: string | Date, timeZone: string) {
|
||||
super(date);
|
||||
this.timeZone = timeZone;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe('time-picker-utils', () => {
|
||||
describe('validation functions', () => {
|
||||
test('isValidHour validates hour format correctly', () => {
|
||||
// Valid hours
|
||||
expect(isValidHour('00')).toBe(true);
|
||||
expect(isValidHour('01')).toBe(true);
|
||||
expect(isValidHour('12')).toBe(true);
|
||||
expect(isValidHour('23')).toBe(true);
|
||||
|
||||
// Invalid hours
|
||||
expect(isValidHour('24')).toBe(false);
|
||||
expect(isValidHour('-1')).toBe(false);
|
||||
expect(isValidHour('1')).toBe(false); // not padded
|
||||
expect(isValidHour('ab')).toBe(false);
|
||||
});
|
||||
|
||||
test('isValid12Hour validates 12-hour format correctly', () => {
|
||||
// Valid 12-hour values
|
||||
expect(isValid12Hour('01')).toBe(true);
|
||||
expect(isValid12Hour('09')).toBe(true);
|
||||
expect(isValid12Hour('12')).toBe(true);
|
||||
|
||||
// Invalid 12-hour values
|
||||
expect(isValid12Hour('00')).toBe(false);
|
||||
expect(isValid12Hour('13')).toBe(false);
|
||||
expect(isValid12Hour('1')).toBe(false); // not padded
|
||||
expect(isValid12Hour('ab')).toBe(false);
|
||||
});
|
||||
|
||||
test('isValidMinuteOrSecond validates minute/second format correctly', () => {
|
||||
// Valid minutes/seconds
|
||||
expect(isValidMinuteOrSecond('00')).toBe(true);
|
||||
expect(isValidMinuteOrSecond('01')).toBe(true);
|
||||
expect(isValidMinuteOrSecond('30')).toBe(true);
|
||||
expect(isValidMinuteOrSecond('59')).toBe(true);
|
||||
|
||||
// Invalid minutes/seconds
|
||||
expect(isValidMinuteOrSecond('60')).toBe(false);
|
||||
expect(isValidMinuteOrSecond('-1')).toBe(false);
|
||||
expect(isValidMinuteOrSecond('1')).toBe(false); // not padded
|
||||
expect(isValidMinuteOrSecond('ab')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('number validation and correction functions', () => {
|
||||
test('getValidNumber handles number validation correctly', () => {
|
||||
// Basic validation
|
||||
expect(getValidNumber('5', { max: 10 })).toBe('05');
|
||||
expect(getValidNumber('15', { max: 10 })).toBe('10');
|
||||
expect(getValidNumber('-1', { max: 10, min: 0 })).toBe('00');
|
||||
|
||||
// With looping
|
||||
expect(getValidNumber('15', { max: 10, min: 0, loop: true })).toBe('00');
|
||||
expect(getValidNumber('-1', { max: 10, min: 0, loop: true })).toBe('10');
|
||||
|
||||
// Invalid input
|
||||
expect(getValidNumber('abc', { max: 10 })).toBe('00');
|
||||
});
|
||||
|
||||
test('getValidHour returns valid 24-hour format', () => {
|
||||
expect(getValidHour('12')).toBe('12');
|
||||
expect(getValidHour('23')).toBe('23');
|
||||
expect(getValidHour('24')).toBe('23'); // Capped at 23
|
||||
expect(getValidHour('-1')).toBe('00'); // Min is 0
|
||||
expect(getValidHour('abc')).toBe('00'); // Invalid input
|
||||
});
|
||||
|
||||
test('getValid12Hour returns valid 12-hour format', () => {
|
||||
// expect(getValid12Hour('06')).toBe('06');
|
||||
// expect(getValid12Hour('12')).toBe('12');
|
||||
expect(getValid12Hour('00')).toBe('01'); // Min is 1
|
||||
expect(getValid12Hour('13')).toBe('12'); // Capped at 12
|
||||
expect(getValid12Hour('abc')).toBe('00'); // Invalid input defaults to 00
|
||||
});
|
||||
|
||||
test('getValidMinuteOrSecond returns valid minute/second format', () => {
|
||||
expect(getValidMinuteOrSecond('30')).toBe('30');
|
||||
expect(getValidMinuteOrSecond('59')).toBe('59');
|
||||
expect(getValidMinuteOrSecond('60')).toBe('59'); // Capped at 59
|
||||
expect(getValidMinuteOrSecond('-1')).toBe('00'); // Min is 0
|
||||
expect(getValidMinuteOrSecond('abc')).toBe('00'); // Invalid input
|
||||
});
|
||||
});
|
||||
|
||||
describe('arrow navigation functions', () => {
|
||||
test('getValidArrowNumber handles arrow navigation with looping', () => {
|
||||
// Incrementing
|
||||
expect(getValidArrowNumber('05', { min: 0, max: 10, step: 1 })).toBe(
|
||||
'06',
|
||||
);
|
||||
expect(getValidArrowNumber('10', { min: 0, max: 10, step: 1 })).toBe(
|
||||
'00',
|
||||
); // Loops back to min
|
||||
|
||||
// Decrementing
|
||||
expect(getValidArrowNumber('05', { min: 0, max: 10, step: -1 })).toBe(
|
||||
'04',
|
||||
);
|
||||
expect(getValidArrowNumber('00', { min: 0, max: 10, step: -1 })).toBe(
|
||||
'10',
|
||||
); // Loops to max
|
||||
|
||||
// Invalid input
|
||||
expect(getValidArrowNumber('abc', { min: 0, max: 10, step: 1 })).toBe(
|
||||
'00',
|
||||
);
|
||||
});
|
||||
|
||||
test('getValidArrowHour handles hour navigation correctly', () => {
|
||||
expect(getValidArrowHour('05', 1)).toBe('06');
|
||||
expect(getValidArrowHour('23', 1)).toBe('00'); // Loops to 0
|
||||
expect(getValidArrowHour('00', -1)).toBe('23'); // Loops to 23
|
||||
});
|
||||
|
||||
test('getValidArrow12Hour handles 12-hour navigation correctly', () => {
|
||||
expect(getValidArrow12Hour('05', 1)).toBe('06');
|
||||
expect(getValidArrow12Hour('12', 1)).toBe('01'); // Loops to 1
|
||||
expect(getValidArrow12Hour('01', -1)).toBe('12'); // Loops to 12
|
||||
});
|
||||
|
||||
test('getValidArrowMinuteOrSecond handles minute/second navigation correctly', () => {
|
||||
expect(getValidArrowMinuteOrSecond('30', 1)).toBe('31');
|
||||
expect(getValidArrowMinuteOrSecond('59', 1)).toBe('00'); // Loops to 0
|
||||
expect(getValidArrowMinuteOrSecond('00', -1)).toBe('59'); // Loops to 59
|
||||
});
|
||||
});
|
||||
|
||||
describe('date manipulation functions', () => {
|
||||
test('setMinutes sets minutes correctly on a Date object', () => {
|
||||
const date = new Date(2023, 0, 1, 12, 0, 0);
|
||||
setMinutes(date, '30');
|
||||
expect(date.getMinutes()).toBe(30);
|
||||
|
||||
// Invalid values are corrected
|
||||
setMinutes(date, '60');
|
||||
expect(date.getMinutes()).toBe(59);
|
||||
});
|
||||
|
||||
test('setSeconds sets seconds correctly on a Date object', () => {
|
||||
const date = new Date(2023, 0, 1, 12, 30, 0);
|
||||
setSeconds(date, '45');
|
||||
expect(date.getSeconds()).toBe(45);
|
||||
|
||||
// Invalid values are corrected
|
||||
setSeconds(date, '60');
|
||||
expect(date.getSeconds()).toBe(59);
|
||||
});
|
||||
|
||||
test('setHours sets hours correctly on a Date object', () => {
|
||||
const date = new Date(2023, 0, 1, 12, 30, 0);
|
||||
setHours(date, '14');
|
||||
expect(date.getHours()).toBe(14);
|
||||
|
||||
// Invalid values are corrected
|
||||
setHours(date, '24');
|
||||
expect(date.getHours()).toBe(23);
|
||||
});
|
||||
|
||||
test('convert12HourTo24Hour converts 12-hour to 24-hour format correctly', () => {
|
||||
// AM conversions
|
||||
expect(convert12HourTo24Hour(1, 'AM')).toBe(1);
|
||||
expect(convert12HourTo24Hour(11, 'AM')).toBe(11);
|
||||
expect(convert12HourTo24Hour(12, 'AM')).toBe(0); // 12 AM is 00:00
|
||||
|
||||
// PM conversions
|
||||
expect(convert12HourTo24Hour(1, 'PM')).toBe(13);
|
||||
expect(convert12HourTo24Hour(11, 'PM')).toBe(23);
|
||||
expect(convert12HourTo24Hour(12, 'PM')).toBe(12); // 12 PM is 12:00
|
||||
});
|
||||
|
||||
test('set12Hours sets 12-hour format correctly on a Date object', () => {
|
||||
const date = new Date(2023, 0, 1, 0, 0, 0);
|
||||
|
||||
// Morning hours (AM)
|
||||
set12Hours(date, '09', 'AM');
|
||||
expect(date.getHours()).toBe(9);
|
||||
|
||||
// 12 AM
|
||||
set12Hours(date, '12', 'AM');
|
||||
expect(date.getHours()).toBe(0);
|
||||
|
||||
// Afternoon/evening hours (PM)
|
||||
set12Hours(date, '03', 'PM');
|
||||
expect(date.getHours()).toBe(15);
|
||||
|
||||
// 12 PM
|
||||
set12Hours(date, '12', 'PM');
|
||||
expect(date.getHours()).toBe(12);
|
||||
});
|
||||
|
||||
test('display12HourValue converts 24-hour to 12-hour display format', () => {
|
||||
expect(display12HourValue(0)).toBe('12'); // 00:00 -> 12 AM
|
||||
expect(display12HourValue(1)).toBe('01'); // 01:00 -> 1 AM
|
||||
expect(display12HourValue(11)).toBe('11'); // 11:00 -> 11 AM
|
||||
expect(display12HourValue(12)).toBe('12'); // 12:00 -> 12 PM
|
||||
expect(display12HourValue(13)).toBe('01'); // 13:00 -> 1 PM
|
||||
expect(display12HourValue(23)).toBe('11'); // 23:00 -> 11 PM
|
||||
expect(display12HourValue(22)).toBe('10'); // 22:00 -> 10 PM
|
||||
});
|
||||
});
|
||||
describe('integrated date manipulation functions', () => {
|
||||
test('getDateByType returns date component according to the picker type', () => {
|
||||
const date = new Date(2023, 0, 1, 14, 30, 45);
|
||||
|
||||
// Test hours
|
||||
expect(getDateByType(date, 'hours')).toBe('14');
|
||||
|
||||
// Test minutes
|
||||
expect(getDateByType(date, 'minutes')).toBe('30');
|
||||
|
||||
// Test seconds
|
||||
expect(getDateByType(date, 'seconds')).toBe('45');
|
||||
|
||||
// Test 12-hour format
|
||||
expect(getDateByType(date, '12hours')).toBe('02'); // 14:00 -> 2 PM
|
||||
|
||||
// Test 12 noon and midnight special cases
|
||||
const noon = new Date(2023, 0, 1, 12, 0, 0);
|
||||
expect(getDateByType(noon, '12hours')).toBe('12');
|
||||
|
||||
const midnight = new Date(2023, 0, 1, 0, 0, 0);
|
||||
expect(getDateByType(midnight, '12hours')).toBe('12');
|
||||
|
||||
// Test with invalid picker type
|
||||
expect(getDateByType(date, 'invalid' as TimePickerType)).toBe('00');
|
||||
});
|
||||
|
||||
test('getArrowByType handles arrow navigation based on picker type', () => {
|
||||
// Test hours
|
||||
expect(getArrowByType('14', 1, 'hours')).toBe('15');
|
||||
expect(getArrowByType('23', 1, 'hours')).toBe('00'); // Loops back to 00
|
||||
|
||||
// Test minutes
|
||||
expect(getArrowByType('30', 1, 'minutes')).toBe('31');
|
||||
expect(getArrowByType('59', 1, 'minutes')).toBe('00'); // Loops back to 00
|
||||
|
||||
// Test seconds
|
||||
expect(getArrowByType('45', 1, 'seconds')).toBe('46');
|
||||
expect(getArrowByType('59', 1, 'seconds')).toBe('00'); // Loops back to 00
|
||||
|
||||
// Test 12-hour format
|
||||
expect(getArrowByType('09', 1, '12hours')).toBe('10');
|
||||
expect(getArrowByType('12', 1, '12hours')).toBe('01'); // Loops back to 01
|
||||
|
||||
// Test with invalid picker type
|
||||
expect(getArrowByType('14', 1, 'invalid' as TimePickerType)).toBe('00');
|
||||
});
|
||||
|
||||
test('setDateByType updates date according to the picker type', () => {
|
||||
const date = new Date(2023, 0, 1, 12, 30, 45);
|
||||
|
||||
// Test updating hours
|
||||
const hourDate = setDateByType(date, '14', 'hours');
|
||||
expect(hourDate.getHours()).toBe(14);
|
||||
expect(hourDate.getMinutes()).toBe(30); // Other fields unchanged
|
||||
expect(hourDate.getSeconds()).toBe(45); // Other fields unchanged
|
||||
|
||||
// Test updating minutes
|
||||
const minuteDate = setDateByType(date, '15', 'minutes');
|
||||
expect(minuteDate.getHours()).toBe(14); // Other fields unchanged
|
||||
expect(minuteDate.getMinutes()).toBe(15);
|
||||
expect(minuteDate.getSeconds()).toBe(45); // Other fields unchanged
|
||||
|
||||
// Test updating seconds
|
||||
const secondDate = setDateByType(date, '20', 'seconds');
|
||||
expect(secondDate.getHours()).toBe(14); // Other fields unchanged
|
||||
expect(secondDate.getMinutes()).toBe(15); // Other fields unchanged
|
||||
expect(secondDate.getSeconds()).toBe(20);
|
||||
|
||||
// Test updating 12-hour format with AM
|
||||
const amDate = setDateByType(date, '09', '12hours', 'AM');
|
||||
expect(amDate.getHours()).toBe(9);
|
||||
|
||||
// Test updating 12-hour format with PM
|
||||
const pmDate = setDateByType(date, '09', '12hours', 'PM');
|
||||
expect(pmDate.getHours()).toBe(21);
|
||||
|
||||
// Test 12 AM (midnight)
|
||||
const midnightDate = setDateByType(date, '12', '12hours', 'AM');
|
||||
expect(midnightDate.getHours()).toBe(0);
|
||||
|
||||
// Test 12 PM (noon)
|
||||
const noonDate = setDateByType(date, '12', '12hours', 'PM');
|
||||
expect(noonDate.getHours()).toBe(12);
|
||||
|
||||
// Test with missing period for 12-hour format
|
||||
const missingPeriodDate = setDateByType(date, '09', '12hours');
|
||||
expect(missingPeriodDate).toBe(date); // Should return original date unchanged
|
||||
|
||||
// Test with invalid picker type
|
||||
const invalidTypeDate = setDateByType(
|
||||
date,
|
||||
'14',
|
||||
'invalid' as TimePickerType,
|
||||
);
|
||||
expect(invalidTypeDate).toBe(date); // Should return original date unchanged
|
||||
});
|
||||
});
|
||||
});
|
||||
244
dashboard/src/components/common/TimePicker/time-picker-utils.ts
Normal file
244
dashboard/src/components/common/TimePicker/time-picker-utils.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { TZDate } from '@date-fns/tz';
|
||||
|
||||
/**
|
||||
* regular expression to check for valid hour format (01-23)
|
||||
*/
|
||||
export function isValidHour(value: string) {
|
||||
return /^(0[0-9]|1[0-9]|2[0-3])$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* regular expression to check for valid 12 hour format (01-12)
|
||||
*/
|
||||
export function isValid12Hour(value: string) {
|
||||
return /^(0[1-9]|1[0-2])$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* regular expression to check for valid minute format (00-59)
|
||||
*/
|
||||
export function isValidMinuteOrSecond(value: string) {
|
||||
return /^[0-5][0-9]$/.test(value);
|
||||
}
|
||||
|
||||
type GetValidNumberConfig = { max: number; min?: number; loop?: boolean };
|
||||
|
||||
export function getValidNumber(
|
||||
value: string,
|
||||
{ max, min = 0, loop = false }: GetValidNumberConfig,
|
||||
) {
|
||||
let numericValue = parseInt(value, 10);
|
||||
|
||||
if (!Number.isNaN(numericValue)) {
|
||||
if (!loop) {
|
||||
if (numericValue > max) {
|
||||
numericValue = max;
|
||||
}
|
||||
if (numericValue < min) {
|
||||
numericValue = min;
|
||||
}
|
||||
} else {
|
||||
if (numericValue > max) {
|
||||
numericValue = min;
|
||||
}
|
||||
if (numericValue < min) {
|
||||
numericValue = max;
|
||||
}
|
||||
}
|
||||
return numericValue.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
return '00';
|
||||
}
|
||||
|
||||
export function getValidHour(value: string) {
|
||||
if (isValidHour(value)) {
|
||||
return value;
|
||||
}
|
||||
return getValidNumber(value, { max: 23 });
|
||||
}
|
||||
|
||||
export function getValid12Hour(value: string) {
|
||||
if (isValid12Hour(value)) {
|
||||
return value;
|
||||
}
|
||||
return getValidNumber(value, { min: 1, max: 12 });
|
||||
}
|
||||
|
||||
export function getValidMinuteOrSecond(value: string) {
|
||||
if (isValidMinuteOrSecond(value)) {
|
||||
return value;
|
||||
}
|
||||
return getValidNumber(value, { max: 59 });
|
||||
}
|
||||
|
||||
type GetValidArrowNumberConfig = {
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
};
|
||||
|
||||
export function getValidArrowNumber(
|
||||
value: string,
|
||||
{ min, max, step }: GetValidArrowNumberConfig,
|
||||
) {
|
||||
let numericValue = parseInt(value, 10);
|
||||
if (!Number.isNaN(numericValue)) {
|
||||
numericValue += step;
|
||||
return getValidNumber(String(numericValue), { min, max, loop: true });
|
||||
}
|
||||
return '00';
|
||||
}
|
||||
|
||||
export function getValidArrowHour(value: string, step: number) {
|
||||
return getValidArrowNumber(value, { min: 0, max: 23, step });
|
||||
}
|
||||
|
||||
export function getValidArrow12Hour(value: string, step: number) {
|
||||
return getValidArrowNumber(value, { min: 1, max: 12, step });
|
||||
}
|
||||
|
||||
export function getValidArrowMinuteOrSecond(value: string, step: number) {
|
||||
return getValidArrowNumber(value, { min: 0, max: 59, step });
|
||||
}
|
||||
|
||||
export function setMinutes(date: Date, value: string) {
|
||||
const minutes = getValidMinuteOrSecond(value);
|
||||
date.setMinutes(parseInt(minutes, 10));
|
||||
return date;
|
||||
}
|
||||
|
||||
export function setSeconds(date: Date, value: string) {
|
||||
const seconds = getValidMinuteOrSecond(value);
|
||||
date.setSeconds(parseInt(seconds, 10));
|
||||
return date;
|
||||
}
|
||||
|
||||
export function setHours(date: Date, value: string) {
|
||||
const hours = getValidHour(value);
|
||||
date.setHours(parseInt(hours, 10));
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* handles value change of 12-hour input
|
||||
* 12:00 PM is 12:00
|
||||
* 12:00 AM is 00:00
|
||||
*/
|
||||
export function convert12HourTo24Hour(hour: number, period: Period) {
|
||||
if (period === 'PM') {
|
||||
if (hour <= 11) {
|
||||
return hour + 12;
|
||||
}
|
||||
return hour;
|
||||
}
|
||||
if (period === 'AM') {
|
||||
if (hour === 12) {
|
||||
return 0;
|
||||
}
|
||||
return hour;
|
||||
}
|
||||
return hour;
|
||||
}
|
||||
|
||||
export function set12Hours(date: Date, value: string, period: Period) {
|
||||
const hours = parseInt(getValid12Hour(value), 10);
|
||||
const convertedHours = convert12HourTo24Hour(hours, period);
|
||||
date.setHours(convertedHours);
|
||||
return date;
|
||||
}
|
||||
|
||||
export type TimePickerType = 'minutes' | 'seconds' | 'hours' | '12hours';
|
||||
export type Period = 'AM' | 'PM';
|
||||
|
||||
export function setDateByType(
|
||||
date: Date,
|
||||
value: string,
|
||||
type: TimePickerType,
|
||||
period?: Period,
|
||||
) {
|
||||
switch (type) {
|
||||
case 'minutes':
|
||||
return setMinutes(date, value);
|
||||
case 'seconds':
|
||||
return setSeconds(date, value);
|
||||
case 'hours':
|
||||
return setHours(date, value);
|
||||
case '12hours': {
|
||||
if (!period) {
|
||||
return date;
|
||||
}
|
||||
return set12Hours(date, value, period);
|
||||
}
|
||||
default:
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* time is stored in the 24-hour form,
|
||||
* but needs to be displayed to the user
|
||||
* in its 12-hour representation
|
||||
*/
|
||||
export function display12HourValue(hours: number) {
|
||||
if (hours === 0 || hours === 12) {
|
||||
return '12';
|
||||
}
|
||||
if (hours >= 22) {
|
||||
return `${hours - 12}`;
|
||||
}
|
||||
if (hours % 12 > 9) {
|
||||
return `${hours}`;
|
||||
}
|
||||
return `0${hours % 12}`;
|
||||
}
|
||||
|
||||
export function getDateByType(date: Date, type: TimePickerType) {
|
||||
switch (type) {
|
||||
case 'minutes':
|
||||
return getValidMinuteOrSecond(String(date.getMinutes()));
|
||||
case 'seconds':
|
||||
return getValidMinuteOrSecond(String(date.getSeconds()));
|
||||
case 'hours':
|
||||
return getValidHour(String(date.getHours()));
|
||||
case '12hours': {
|
||||
const hours = display12HourValue(date.getHours());
|
||||
return getValid12Hour(String(hours));
|
||||
}
|
||||
default:
|
||||
return '00';
|
||||
}
|
||||
}
|
||||
|
||||
export function getArrowByType(
|
||||
value: string,
|
||||
step: number,
|
||||
type: TimePickerType,
|
||||
) {
|
||||
switch (type) {
|
||||
case 'minutes':
|
||||
return getValidArrowMinuteOrSecond(value, step);
|
||||
case 'seconds':
|
||||
return getValidArrowMinuteOrSecond(value, step);
|
||||
case 'hours':
|
||||
return getValidArrowHour(value, step);
|
||||
case '12hours':
|
||||
return getValidArrow12Hour(value, step);
|
||||
default:
|
||||
return '00';
|
||||
}
|
||||
}
|
||||
|
||||
function isTZDate(date: Date | TZDate): date is TZDate {
|
||||
return date instanceof TZDate;
|
||||
}
|
||||
|
||||
export function copyDate(date: Date | TZDate) {
|
||||
if (isTZDate(date)) {
|
||||
const { timeZone } = date;
|
||||
const dateTime = date.toISOString();
|
||||
return new TZDate(dateTime, timeZone);
|
||||
}
|
||||
|
||||
return new Date(date);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { VirtualizedCombobox } from '@/components/common/VirtualizedCombobox';
|
||||
import { createTimezoneOptions } from '@/utils/timezoneUtils';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
interface Props {
|
||||
selectedTimezone: string;
|
||||
onTimezoneSelect: (timezone: { value: string; label: string }) => void;
|
||||
button?: React.JSX.Element;
|
||||
dateTime: string;
|
||||
}
|
||||
|
||||
function TimezonePicker({
|
||||
selectedTimezone,
|
||||
onTimezoneSelect,
|
||||
button,
|
||||
dateTime,
|
||||
}: Props) {
|
||||
const timezoneOptions = useMemo(
|
||||
() => createTimezoneOptions(dateTime),
|
||||
[dateTime],
|
||||
);
|
||||
return (
|
||||
<VirtualizedCombobox
|
||||
options={timezoneOptions}
|
||||
selectedOption={selectedTimezone}
|
||||
onSelectOption={onTimezoneSelect}
|
||||
searchPlaceholder="Search timezones..."
|
||||
button={button}
|
||||
side="right"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TimezonePicker);
|
||||
1
dashboard/src/components/common/TimezonePicker/index.ts
Normal file
1
dashboard/src/components/common/TimezonePicker/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as TimezonePicker } from './TimezonePicker';
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { NhostIcon } from '@/components/presentational/NhostIcon';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { TransferProjectDialog } from '@/features/orgs/components/common/TransferProjectDialog';
|
||||
import { useIsCurrentUserOwner } from '@/features/orgs/projects/common/hooks/useIsCurrentUserOwner';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { OpenTransferDialogButton } from '@/components/common/OpenTransferDialogButton';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
@@ -21,11 +20,11 @@ export default function UpgradeToProBanner({
|
||||
title,
|
||||
description,
|
||||
}: UpgradeToProBannerProps) {
|
||||
const isOwner = useIsCurrentUserOwner();
|
||||
const { openAlertDialog } = useDialog();
|
||||
const [transferProjectDialogOpen, setTransferProjectDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const handleTransferDialogOpen = () => setTransferProjectDialogOpen(true);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ backgroundColor: 'primary.light' }}
|
||||
@@ -51,29 +50,7 @@ export default function UpgradeToProBanner({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 space-y-2 lg:flex-row lg:items-center lg:space-x-2 lg:space-y-0">
|
||||
<Button
|
||||
className="max-w-xs lg:w-auto"
|
||||
onClick={() => {
|
||||
if (isOwner) {
|
||||
setTransferProjectDialogOpen(true);
|
||||
} else {
|
||||
openAlertDialog({
|
||||
title: "You can't migrate this project",
|
||||
payload: (
|
||||
<Text variant="subtitle1" component="span">
|
||||
Ask an owner of this organization to migrate the project.
|
||||
</Text>
|
||||
),
|
||||
props: {
|
||||
secondaryButtonText: 'I understand',
|
||||
hidePrimaryAction: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Transfer Project
|
||||
</Button>
|
||||
<OpenTransferDialogButton onClick={handleTransferDialogOpen} />
|
||||
<TransferProjectDialog
|
||||
open={transferProjectDialogOpen}
|
||||
setOpen={setTransferProjectDialogOpen}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/v3/command';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/v3/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
type Option = {
|
||||
value: string;
|
||||
label: string;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
interface VirtualizedCommandProps<O extends Option> {
|
||||
height: string;
|
||||
options: O[];
|
||||
placeholder: string;
|
||||
selectedOption: string;
|
||||
onSelectOption?: (option: O) => void;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
function VirtualizedCommand<O extends Option>({
|
||||
height,
|
||||
options,
|
||||
placeholder,
|
||||
selectedOption,
|
||||
onSelectOption,
|
||||
emptyText,
|
||||
}: VirtualizedCommandProps<O>) {
|
||||
const [filteredOptions, setFilteredOptions] = React.useState<O[]>(options);
|
||||
const [focusedIndex, setFocusedIndex] = React.useState(0);
|
||||
const [isKeyboardNavActive, setIsKeyboardNavActive] = React.useState(false);
|
||||
|
||||
const parentRef = React.useRef(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filteredOptions.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 35,
|
||||
});
|
||||
|
||||
const virtualOptions = virtualizer.getVirtualItems();
|
||||
|
||||
const scrollToIndex = (index: number) => {
|
||||
virtualizer.scrollToIndex(index, {
|
||||
align: 'center',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = (search: string) => {
|
||||
setIsKeyboardNavActive(false);
|
||||
setFilteredOptions(
|
||||
options.filter((option) =>
|
||||
option.label.toLowerCase().includes(search.toLowerCase()),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
setIsKeyboardNavActive(true);
|
||||
setFocusedIndex((prev) => {
|
||||
const newIndex =
|
||||
prev === -1 ? 0 : Math.min(prev + 1, filteredOptions.length - 1);
|
||||
scrollToIndex(newIndex);
|
||||
return newIndex;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
setIsKeyboardNavActive(true);
|
||||
setFocusedIndex((prev) => {
|
||||
const newIndex =
|
||||
prev === -1 ? filteredOptions.length - 1 : Math.max(prev - 1, 0);
|
||||
scrollToIndex(newIndex);
|
||||
return newIndex;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'Enter': {
|
||||
event.preventDefault();
|
||||
if (filteredOptions[focusedIndex]) {
|
||||
onSelectOption?.(filteredOptions[focusedIndex]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedOption) {
|
||||
const option = filteredOptions.find(
|
||||
(opt) => opt.value === selectedOption,
|
||||
);
|
||||
if (option) {
|
||||
const index = filteredOptions.indexOf(option);
|
||||
setFocusedIndex(index);
|
||||
}
|
||||
}
|
||||
}, [selectedOption, filteredOptions, virtualizer]);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false} onKeyDown={handleKeyDown}>
|
||||
<CommandInput onValueChange={handleSearch} placeholder={placeholder} />
|
||||
<CommandList
|
||||
ref={parentRef}
|
||||
style={{
|
||||
height,
|
||||
width: '100%',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
onMouseDown={() => setIsKeyboardNavActive(false)}
|
||||
onMouseMove={() => setIsKeyboardNavActive(false)}
|
||||
>
|
||||
<CommandEmpty>{emptyText || 'No item found.'}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualOptions.map((virtualOption) => (
|
||||
<CommandItem
|
||||
key={
|
||||
filteredOptions[virtualOption.index].key ??
|
||||
filteredOptions[virtualOption.index].value
|
||||
}
|
||||
disabled={isKeyboardNavActive}
|
||||
className={cn(
|
||||
'absolute left-0 top-0 w-full bg-transparent',
|
||||
focusedIndex === virtualOption.index &&
|
||||
'bg-accent text-accent-foreground',
|
||||
isKeyboardNavActive &&
|
||||
focusedIndex !== virtualOption.index &&
|
||||
'aria-selected:bg-transparent aria-selected:text-primary',
|
||||
)}
|
||||
style={{
|
||||
height: `${virtualOption.size}px`,
|
||||
transform: `translateY(${virtualOption.start}px)`,
|
||||
}}
|
||||
value={filteredOptions[virtualOption.index].value}
|
||||
onMouseEnter={() =>
|
||||
!isKeyboardNavActive && setFocusedIndex(virtualOption.index)
|
||||
}
|
||||
onMouseLeave={() => !isKeyboardNavActive && setFocusedIndex(-1)}
|
||||
onSelect={() => onSelectOption?.(filteredOptions[focusedIndex])}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedOption ===
|
||||
filteredOptions[virtualOption.index].value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{filteredOptions[virtualOption.index].label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
|
||||
interface VirtualizedComboboxProps<O extends Option> {
|
||||
options: O[];
|
||||
searchPlaceholder?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
button?: React.JSX.Element;
|
||||
onSelectOption?: (option: O) => void;
|
||||
selectedOption: string;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
side?: 'right' | 'top' | 'bottom' | 'left';
|
||||
}
|
||||
|
||||
function VirtualizedCombobox<O extends Option>({
|
||||
options,
|
||||
searchPlaceholder = 'Search items...',
|
||||
width,
|
||||
height,
|
||||
button,
|
||||
onSelectOption,
|
||||
selectedOption,
|
||||
align = 'start',
|
||||
side,
|
||||
}: VirtualizedComboboxProps<O>) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const defaultButton = (
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="justify-between"
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
>
|
||||
{selectedOption
|
||||
? options.find((option) => option.value === selectedOption).value
|
||||
: searchPlaceholder}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{button || defaultButton}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width }}
|
||||
align={align}
|
||||
side={side}
|
||||
>
|
||||
<VirtualizedCommand
|
||||
height={height}
|
||||
options={options}
|
||||
placeholder={searchPlaceholder}
|
||||
selectedOption={selectedOption}
|
||||
onSelectOption={(currentValue) => {
|
||||
onSelectOption(currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default VirtualizedCombobox;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VirtualizedCombobox } from './VirtualizedCombobox';
|
||||
@@ -2,7 +2,7 @@ import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell
|
||||
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||
import { ReadOnlyToggle } from '@/components/presentational/ReadOnlyToggle';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent } from 'react';
|
||||
import type { MouseEvent, KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export type DataGridBooleanCellProps<TData extends object> =
|
||||
|
||||
@@ -267,7 +267,7 @@ export default function DataGridPreviewCell<TData extends object>({
|
||||
aria-label="Close"
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className="absolute top-2 right-2 z-50 p-2"
|
||||
className="absolute right-2 top-2 z-50 p-2"
|
||||
sx={{
|
||||
[`&:hover, &:active, &:focus`]: {
|
||||
backgroundColor: (theme) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/v3/popover';
|
||||
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -40,6 +41,8 @@ export default function OrgPagesComboBox() {
|
||||
asPath,
|
||||
} = useRouter();
|
||||
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const pathSegments = useMemo(() => asPath.split('/'), [asPath]);
|
||||
const orgPageFromUrl = pathSegments[3] || null;
|
||||
|
||||
@@ -64,7 +67,7 @@ export default function OrgPagesComboBox() {
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTrigger disabled={!isPlatform} asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -75,7 +78,7 @@ export default function OrgPagesComboBox() {
|
||||
) : (
|
||||
<>Select a page</>
|
||||
)}
|
||||
<ChevronsUpDown className="w-5 h-5 text-muted-foreground" />
|
||||
<ChevronsUpDown className="h-5 w-5 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" side="bottom" align="start">
|
||||
@@ -103,7 +106,7 @@ export default function OrgPagesComboBox() {
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span className="truncate max-w-52">{option.label}</span>
|
||||
<span className="max-w-52 truncate">{option.label}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function OrgsComboBox() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="justify-between w-full gap-2 bg-background text-foreground hover:bg-accent dark:hover:bg-muted"
|
||||
className="w-full justify-between gap-2 bg-background text-foreground hover:bg-accent dark:hover:bg-muted"
|
||||
>
|
||||
{selectedItem ? (
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
@@ -115,7 +115,7 @@ export default function OrgsComboBox() {
|
||||
) : (
|
||||
'Select organization / workspace'
|
||||
)}
|
||||
<ChevronsUpDown className="w-5 h-5 text-muted-foreground" />
|
||||
<ChevronsUpDown className="h-5 w-5 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" side="bottom" align="start">
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/v3/popover';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useMemo, useState, type ReactElement } from 'react';
|
||||
@@ -40,88 +41,10 @@ type Option = {
|
||||
value: string;
|
||||
label: string;
|
||||
icon: ReactElement;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
const projectPages = [
|
||||
{
|
||||
label: 'Overview',
|
||||
value: 'overview',
|
||||
icon: <HomeIcon className="w-4 h-4" />,
|
||||
slug: '',
|
||||
},
|
||||
{
|
||||
label: 'Database',
|
||||
value: 'database',
|
||||
icon: <DatabaseIcon className="w-4 h-4" />,
|
||||
slug: '/database/browser/default',
|
||||
},
|
||||
{
|
||||
label: 'GraphQL',
|
||||
value: 'graphql',
|
||||
icon: <GraphQLIcon className="w-4 h-4" />,
|
||||
slug: 'graphql',
|
||||
},
|
||||
{
|
||||
label: 'Hasura',
|
||||
value: 'hasura',
|
||||
icon: <HasuraIcon className="w-4 h-4" />,
|
||||
slug: 'hasura',
|
||||
},
|
||||
{
|
||||
label: 'Auth',
|
||||
value: 'users',
|
||||
icon: <UserIcon className="w-4 h-4" />,
|
||||
slug: 'users',
|
||||
},
|
||||
{
|
||||
label: 'Storage',
|
||||
value: 'storage',
|
||||
icon: <StorageIcon className="w-4 h-4" />,
|
||||
slug: 'storage',
|
||||
},
|
||||
{
|
||||
label: 'Run',
|
||||
value: 'run',
|
||||
icon: <ServicesIcon className="w-4 h-4" />,
|
||||
slug: 'run',
|
||||
},
|
||||
{
|
||||
label: 'AI',
|
||||
value: 'ai',
|
||||
icon: <AIIcon className="w-4 h-4" />,
|
||||
slug: 'ai/auto-embeddings',
|
||||
},
|
||||
{
|
||||
label: 'Deployments',
|
||||
value: 'deployments',
|
||||
icon: <RocketIcon className="w-4 h-4" />,
|
||||
slug: 'deployments',
|
||||
},
|
||||
{
|
||||
label: 'Backups',
|
||||
value: 'backups',
|
||||
icon: <CloudIcon className="w-4 h-4" />,
|
||||
slug: 'backups',
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
value: 'logs',
|
||||
icon: <FileTextIcon className="w-4 h-4" />,
|
||||
slug: 'logs',
|
||||
},
|
||||
{
|
||||
label: 'Metrics',
|
||||
value: 'metrics',
|
||||
icon: <GaugeIcon className="w-4 h-4" />,
|
||||
slug: 'metrics',
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
value: 'settings',
|
||||
icon: <CogIcon className="w-4 h-4" />,
|
||||
slug: 'settings',
|
||||
},
|
||||
];
|
||||
type SelectedOption = Omit<Option, 'disabled'>;
|
||||
|
||||
export default function ProjectPagesComboBox() {
|
||||
const {
|
||||
@@ -130,6 +53,105 @@ export default function ProjectPagesComboBox() {
|
||||
asPath,
|
||||
} = useRouter();
|
||||
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const projectPages = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: 'Overview',
|
||||
value: 'overview',
|
||||
icon: <HomeIcon className="h-4 w-4" />,
|
||||
slug: '',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: 'Database',
|
||||
value: 'database',
|
||||
icon: <DatabaseIcon className="h-4 w-4" />,
|
||||
slug: '/database/browser/default',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: 'GraphQL',
|
||||
value: 'graphql',
|
||||
icon: <GraphQLIcon className="h-4 w-4" />,
|
||||
slug: 'graphql',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: 'Hasura',
|
||||
value: 'hasura',
|
||||
icon: <HasuraIcon className="h-4 w-4" />,
|
||||
slug: 'hasura',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: 'Auth',
|
||||
value: 'users',
|
||||
icon: <UserIcon className="h-4 w-4" />,
|
||||
slug: 'users',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: 'Storage',
|
||||
value: 'storage',
|
||||
icon: <StorageIcon className="h-4 w-4" />,
|
||||
slug: 'storage',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: 'Run',
|
||||
value: 'run',
|
||||
icon: <ServicesIcon className="h-4 w-4" />,
|
||||
slug: 'run',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: 'AI',
|
||||
value: 'ai',
|
||||
icon: <AIIcon className="h-4 w-4" />,
|
||||
slug: 'ai/auto-embeddings',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: 'Deployments',
|
||||
value: 'deployments',
|
||||
icon: <RocketIcon className="h-4 w-4" />,
|
||||
slug: 'deployments',
|
||||
disabled: !isPlatform,
|
||||
},
|
||||
{
|
||||
label: 'Backups',
|
||||
value: 'backups',
|
||||
icon: <CloudIcon className="h-4 w-4" />,
|
||||
slug: 'backups',
|
||||
disabled: !isPlatform,
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
value: 'logs',
|
||||
icon: <FileTextIcon className="h-4 w-4" />,
|
||||
slug: 'logs',
|
||||
disabled: !isPlatform,
|
||||
},
|
||||
{
|
||||
label: 'Metrics',
|
||||
value: 'metrics',
|
||||
icon: <GaugeIcon className="h-4 w-4" />,
|
||||
slug: 'metrics',
|
||||
disabled: !isPlatform,
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
value: 'settings',
|
||||
icon: <CogIcon className="h-4 w-4" />,
|
||||
slug: 'settings',
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
[isPlatform],
|
||||
);
|
||||
|
||||
const pathSegments = useMemo(() => asPath.split('/'), [asPath]);
|
||||
const projectPageFromUrl = appSubdomain
|
||||
? pathSegments[5] || 'overview'
|
||||
@@ -137,9 +159,8 @@ export default function ProjectPagesComboBox() {
|
||||
const selectedProjectPageFromUrl = projectPages.find(
|
||||
(item) => item.value === projectPageFromUrl,
|
||||
);
|
||||
const [selectedProjectPage, setSelectedProjectPage] = useState<Option | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedProjectPage, setSelectedProjectPage] =
|
||||
useState<SelectedOption | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProjectPageFromUrl) {
|
||||
@@ -155,6 +176,7 @@ export default function ProjectPagesComboBox() {
|
||||
label: app.label,
|
||||
value: app.slug,
|
||||
icon: app.icon,
|
||||
disabled: app.disabled,
|
||||
}));
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -175,7 +197,7 @@ export default function ProjectPagesComboBox() {
|
||||
) : (
|
||||
<>Select a page</>
|
||||
)}
|
||||
<ChevronsUpDown className="w-5 h-5 text-muted-foreground" />
|
||||
<ChevronsUpDown className="h-5 w-5 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" side="bottom" align="start">
|
||||
@@ -188,6 +210,7 @@ export default function ProjectPagesComboBox() {
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
disabled={option.disabled}
|
||||
onSelect={() => {
|
||||
setSelectedProjectPage(option);
|
||||
setOpen(false);
|
||||
@@ -206,7 +229,7 @@ export default function ProjectPagesComboBox() {
|
||||
/>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{option.icon}
|
||||
<span className="truncate max-w-52">{option.label}</span>
|
||||
<span className="max-w-52 truncate">{option.label}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function MainNav({ container }: MainNavProps) {
|
||||
className="min- absolute left-0 z-50 flex h-full w-6 justify-center border-r-[1px] bg-background pt-1 hover:bg-accent"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
<Menu className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<SheetContent
|
||||
@@ -65,16 +65,16 @@ export default function MainNav({ container }: MainNavProps) {
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-row items-center justify-end w-full h-12 px-1 border-b bg-background">
|
||||
<div className="flex h-12 w-full flex-row items-center justify-end border-b bg-background px-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hidden sm:flex"
|
||||
onClick={() => setMainNavPinned(!mainNavPinned)}
|
||||
>
|
||||
{mainNavPinned ? (
|
||||
<PinOff className="w-5 h-5" />
|
||||
<PinOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Pin className="w-5 h-5" />
|
||||
<Pin className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function MainNav({ container }: MainNavProps) {
|
||||
className="flex sm:hidden"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -54,15 +54,15 @@ export default function PinnedMainNav() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-shrink-0 flex-col border-r p-0 sm:max-w-[310px]">
|
||||
<div className="flex justify-end w-full h-12 p-1 border-b bg-background">
|
||||
<div className="flex h-12 w-full justify-end border-b bg-background p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setMainNavPinned(!mainNavPinned)}
|
||||
>
|
||||
{mainNavPinned ? (
|
||||
<PinOff className="w-5 h-5" />
|
||||
<PinOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Pin className="w-5 h-5" />
|
||||
<Pin className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -17,18 +17,18 @@ export default function SettingsLayout({ children }: SettingsLayoutProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex flex-col flex-auto w-full overflow-x-hidden overflow-y-auto"
|
||||
className="flex w-full flex-auto flex-col overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex flex-col h-full"
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
<RetryableErrorBoundary>
|
||||
<div className="flex flex-col space-y-2">
|
||||
{hasGitRepo && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="grid grid-flow-row gap-2 place-content-center"
|
||||
className="grid grid-flow-row place-content-center gap-2"
|
||||
>
|
||||
<Text color="warning" className="text-sm">
|
||||
As you have a connected repository, make sure to synchronize
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function LoadingScreen({
|
||||
return (
|
||||
<Box
|
||||
className={twMerge(
|
||||
'absolute top-0 left-0 bottom-0 right-0 z-50 flex h-full w-full items-center justify-center',
|
||||
'absolute bottom-0 left-0 right-0 top-0 z-50 flex h-full w-full items-center justify-center',
|
||||
className,
|
||||
slotProps?.root?.className,
|
||||
)}
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function Modal({
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex min-h-screen items-center justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0',
|
||||
'flex min-h-screen items-center justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0',
|
||||
wrapperClassName,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -8,9 +8,9 @@ import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import { OptionBase } from '@/components/ui/v2/Option';
|
||||
import { OptionGroupBase } from '@/components/ui/v2/OptionGroup';
|
||||
import type { StyledComponent } from '@emotion/styled';
|
||||
import { Popper } from '@mui/base';
|
||||
import type { UseAutocompleteProps } from '@mui/base/useAutocomplete';
|
||||
import { createFilterOptions } from '@mui/base/useAutocomplete';
|
||||
import { Popper } from '@mui/base'
|
||||
import { styled } from '@mui/material';
|
||||
import type { AutocompleteProps as MaterialAutocompleteProps } from '@mui/material/Autocomplete';
|
||||
import MaterialAutocomplete, {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import type { SxProps, Theme } from '@mui/material';
|
||||
import { alpha, styled } from '@mui/material';
|
||||
import type {
|
||||
ButtonProps as MaterialButtonProps,
|
||||
ButtonTypeMap,
|
||||
ButtonProps as MaterialButtonProps,
|
||||
} from '@mui/material/Button';
|
||||
import MaterialButton, { buttonClasses } from '@mui/material/Button';
|
||||
import type { ForwardedRef } from 'react';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { styled } from '@mui/material';
|
||||
import type {
|
||||
DividerProps as MaterialDividerProps,
|
||||
DividerTypeMap,
|
||||
DividerProps as MaterialDividerProps,
|
||||
} from '@mui/material/Divider';
|
||||
import MaterialDivider from '@mui/material/Divider';
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ const AccordionContent = React.forwardRef<
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm transition-all"
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pb-4 pt-0', className)}>{children}</div>
|
||||
@@ -55,4 +55,4 @@ const AccordionContent = React.forwardRef<
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||
|
||||
@@ -126,14 +126,14 @@ AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
};
|
||||
|
||||
@@ -58,4 +58,4 @@ const AlertDescription = React.forwardRef<
|
||||
));
|
||||
AlertDescription.displayName = 'AlertDescription';
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
export { Alert, AlertDescription, AlertTitle };
|
||||
|
||||
@@ -98,7 +98,7 @@ const BreadcrumbEllipsis = ({
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
@@ -106,10 +106,10 @@ BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
@@ -53,4 +54,16 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
const ButtonWithLoading = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ButtonProps & { loading?: boolean }
|
||||
>(({ loading, disabled, children, ...props }, ref) => {
|
||||
return (
|
||||
<Button disabled={loading || disabled} ref={ref} {...props}>
|
||||
{loading && <Loader2 className="mr-2 animate-spin" />}
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
export { Button, buttonVariants, ButtonWithLoading };
|
||||
|
||||
80
dashboard/src/components/ui/v3/calendar.tsx
Normal file
80
dashboard/src/components/ui/v3/calendar.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
DayPicker,
|
||||
type DayPickerProps,
|
||||
type StyledComponent,
|
||||
} from 'react-day-picker';
|
||||
|
||||
import { buttonVariants } from '@/components/ui/v3/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const IconLeft = ({ className, ...props }: StyledComponent) => (
|
||||
<ChevronLeft className={cn('h-4 w-4', className)} {...props} />
|
||||
);
|
||||
const IconRight = ({ className, ...props }: StyledComponent) => (
|
||||
<ChevronRight className={cn('h-4 w-4', className)} {...props} />
|
||||
);
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: DayPickerProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn('p-3', className)}
|
||||
classNames={{
|
||||
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
|
||||
month: 'space-y-4',
|
||||
caption: 'flex justify-center pt-1 relative items-center',
|
||||
caption_label: 'text-sm font-medium',
|
||||
nav: 'space-x-1 flex items-center',
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
),
|
||||
nav_button_previous: 'absolute left-1',
|
||||
nav_button_next: 'absolute right-1',
|
||||
table: 'w-full border-collapse space-y-1',
|
||||
head_row: 'flex',
|
||||
head_cell:
|
||||
'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
|
||||
row: 'flex w-full mt-2',
|
||||
cell: cn(
|
||||
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md',
|
||||
props.mode === 'range'
|
||||
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
|
||||
: '[&:has([aria-selected])]:rounded-md',
|
||||
),
|
||||
day: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-8 w-8 p-0 font-normal aria-selected:opacity-100',
|
||||
),
|
||||
day_range_start: 'day-range-start',
|
||||
day_range_end: 'day-range-end',
|
||||
day_selected:
|
||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||
day_today: 'bg-accent text-accent-foreground',
|
||||
day_outside:
|
||||
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
|
||||
day_disabled: 'text-muted-foreground opacity-50',
|
||||
day_range_middle:
|
||||
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
day_hidden: 'invisible',
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft,
|
||||
IconRight,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Calendar.displayName = 'Calendar';
|
||||
|
||||
export { Calendar };
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
@@ -11,18 +11,18 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
className={cn('flex items-center justify-center text-current')}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox }
|
||||
export { Checkbox };
|
||||
|
||||
@@ -169,13 +169,13 @@ CommandCreateItem.displayName = 'CommandCreateItem';
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandCreateItem,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandCreateItem,
|
||||
CommandShortcut,
|
||||
};
|
||||
|
||||
@@ -27,10 +27,15 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface DialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
disableOutsideClick?: boolean;
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
DialogContentProps
|
||||
>(({ className, children, disableOutsideClick, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay>
|
||||
<DialogPrimitive.Content
|
||||
@@ -39,6 +44,11 @@ const DialogContent = React.forwardRef<
|
||||
'relative z-50 grid w-full max-w-lg gap-4 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg md:w-full',
|
||||
className,
|
||||
)}
|
||||
onInteractOutside={
|
||||
disableOutsideClick
|
||||
? (e) => e.preventDefault()
|
||||
: props.onInteractOutside
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -109,13 +119,13 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
@@ -45,14 +45,14 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
@@ -63,32 +63,32 @@ const DropdownMenuContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
@@ -97,8 +97,8 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
@@ -110,9 +110,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
@@ -121,8 +121,8 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -133,26 +133,26 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
'px-2 py-1.5 text-sm font-semibold',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
@@ -160,11 +160,11 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
@@ -172,27 +172,27 @@ const DropdownMenuShortcut = ({
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
|
||||
@@ -164,12 +164,12 @@ const FormMessage = React.forwardRef<
|
||||
FormMessage.displayName = 'FormMessage';
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useFormField,
|
||||
};
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
));
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
export { HoverCard, HoverCardContent, HoverCardTrigger };
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
@@ -18,7 +18,7 @@ const Label = React.forwardRef<
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
export { Popover, PopoverContent, PopoverTrigger };
|
||||
|
||||
@@ -22,7 +22,7 @@ const Progress = React.forwardRef<
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn(
|
||||
'h-full w-full flex-1 rounded-full bg-primary transition-all',
|
||||
indeterminate && 'animate-progress origin-left',
|
||||
indeterminate && 'origin-left animate-progress',
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
|
||||
import { Circle } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
@@ -10,13 +10,13 @@ const RadioGroup = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
);
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
@@ -26,8 +26,8 @@ const RadioGroupItem = React.forwardRef<
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -35,8 +35,8 @@ const RadioGroupItem = React.forwardRef<
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
|
||||
@@ -24,7 +24,7 @@ const SelectTrigger = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="w-4 h-4 opacity-50" />
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
@@ -42,7 +42,7 @@ const SelectScrollUpButton = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
@@ -59,7 +59,7 @@ const SelectScrollDownButton = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
@@ -123,7 +123,7 @@ const SelectItem = React.forwardRef<
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="w-4 h-4" />
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
@@ -146,13 +146,13 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
||||
@@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -80,7 +80,7 @@ const SheetContent = React.forwardRef<
|
||||
{children}
|
||||
{!hideCloseButton && (
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="w-4 h-4" />
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
@@ -150,13 +150,13 @@ SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetOverlay,
|
||||
SheetPortal,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
};
|
||||
|
||||
56
dashboard/src/components/ui/v3/spinner.tsx
Normal file
56
dashboard/src/components/ui/v3/spinner.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
const spinnerVariants = cva('flex-col items-center justify-center', {
|
||||
variants: {
|
||||
show: {
|
||||
true: 'flex',
|
||||
false: 'hidden',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
show: true,
|
||||
},
|
||||
});
|
||||
|
||||
const loaderVariants = cva('animate-spin text-primary', {
|
||||
variants: {
|
||||
size: {
|
||||
small: 'size-6',
|
||||
medium: 'size-8',
|
||||
large: 'size-12',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
});
|
||||
|
||||
interface SpinnerContentProps
|
||||
extends VariantProps<typeof spinnerVariants>,
|
||||
VariantProps<typeof loaderVariants> {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Spinner({
|
||||
size,
|
||||
show,
|
||||
children,
|
||||
className,
|
||||
}: SpinnerContentProps) {
|
||||
return (
|
||||
<span className={spinnerVariants({ show })}>
|
||||
<Loader2
|
||||
className={cn(
|
||||
loaderVariants({ size }),
|
||||
className,
|
||||
'stroke-[#1e324b] dark:stroke-[#dfecf5]',
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
@@ -9,20 +9,20 @@ const Table = React.forwardRef<
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
));
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
@@ -30,11 +30,11 @@ const TableBody = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
));
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
@@ -43,13 +43,13 @@ const TableFooter = React.forwardRef<
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
));
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
@@ -58,13 +58,13 @@ const TableRow = React.forwardRef<
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
));
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
@@ -73,13 +73,13 @@ const TableHead = React.forwardRef<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
));
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
@@ -87,11 +87,11 @@ const TableCell = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
));
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
@@ -99,19 +99,19 @@ const TableCaption = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
));
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
};
|
||||
|
||||
53
dashboard/src/components/ui/v3/tabs.tsx
Normal file
53
dashboard/src/components/ui/v3/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
@@ -17,12 +17,12 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
|
||||
@@ -11,10 +11,10 @@ import { Input } from '@/components/ui/v2/Input';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { GetPersonalAccessTokensDocument } from '@/utils/__generated__/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { getDateComponents } from '@/utils/getDateComponents';
|
||||
import { GetPersonalAccessTokensDocument } from '@/utils/__generated__/graphql';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useNhostClient } from '@nhost/nextjs';
|
||||
|
||||
@@ -4,11 +4,11 @@ import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Checkbox } from '@/components/ui/v2/Checkbox';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import {
|
||||
useDeleteUserAccountMutation,
|
||||
useGetAllWorkspacesAndProjectsQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { useSignOut, useUserData } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { useUpdateUserDisplayNameMutation } from '@/utils/__generated__/graphql';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
@@ -15,12 +15,12 @@ import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { CreatePATForm } from '@/features/account/settings/components/CreatePATForm';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import {
|
||||
GetPersonalAccessTokensDocument,
|
||||
useDeletePersonalAccessTokenMutation,
|
||||
useGetPersonalAccessTokensQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { Fragment } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function SocialProvidersSettings() {
|
||||
className="flex flex-row items-center justify-start space-x-2 rounded-md p-2"
|
||||
>
|
||||
<GitHubIcon />
|
||||
<Text className="font-medium ">Connected</Text>
|
||||
<Text className="font-medium">Connected</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
|
||||
@@ -11,13 +11,13 @@ import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { GraphqlDataSourcesFormSection } from '@/features/ai/AssistantForm/components/GraphqlDataSourcesFormSection';
|
||||
import { WebhooksDataSourcesFormSection } from '@/features/ai/AssistantForm/components/WebhooksDataSourcesFormSection';
|
||||
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient'
|
||||
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import {
|
||||
useInsertAssistantMutation,
|
||||
useUpdateAssistantMutation,
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { removeTypename, type DeepRequired } from '@/utils/helpers';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect } from 'react';
|
||||
@@ -73,7 +73,7 @@ export interface AssistantFormProps extends DialogFormProps {
|
||||
/**
|
||||
* if there is initialData then it's an update operation
|
||||
*/
|
||||
initialData?: AssistantFormValues
|
||||
initialData?: AssistantFormValues;
|
||||
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function ArgumentsFormSection({
|
||||
|
||||
return (
|
||||
<Box className="space-y-4">
|
||||
<div className="flex flex-row items-center justify-between ">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Arguments
|
||||
|
||||
@@ -12,11 +12,11 @@ import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useAdminApolloClient } from '@/features/projects/common/hooks/useAdminApolloClient';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import {
|
||||
useInsertGraphiteAutoEmbeddingsConfigurationMutation,
|
||||
useUpdateGraphiteAutoEmbeddingsConfigurationMutation,
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -143,9 +143,9 @@ export default function AutoEmbeddingsForm({
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col h-full gap-4 overflow-hidden"
|
||||
className="flex h-full flex-col gap-4 overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col flex-1 px-6 space-y-6 overflow-auto">
|
||||
<div className="flex flex-1 flex-col space-y-6 overflow-auto px-6">
|
||||
<Input
|
||||
{...register('name')}
|
||||
id="name"
|
||||
@@ -155,7 +155,7 @@ export default function AutoEmbeddingsForm({
|
||||
<Tooltip title="Name of the Auto-Embeddings">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -182,7 +182,7 @@ export default function AutoEmbeddingsForm({
|
||||
<Tooltip title="Auto-Embeddings Model">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -208,7 +208,7 @@ export default function AutoEmbeddingsForm({
|
||||
<Tooltip title={<span>Schema where the table belongs to</span>}>
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -230,7 +230,7 @@ export default function AutoEmbeddingsForm({
|
||||
<Tooltip title="Table Name">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -252,7 +252,7 @@ export default function AutoEmbeddingsForm({
|
||||
<Tooltip title="Column name">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -274,7 +274,7 @@ export default function AutoEmbeddingsForm({
|
||||
<Tooltip title="Query">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -298,7 +298,7 @@ export default function AutoEmbeddingsForm({
|
||||
<Tooltip title="Mutation">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -315,7 +315,7 @@ export default function AutoEmbeddingsForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Box className="flex flex-row justify-between w-full px-6 py-4 border-t rounded">
|
||||
<Box className="flex w-full flex-row justify-between rounded border-t px-6 py-4">
|
||||
<Button variant="outlined" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -176,7 +176,7 @@ export default function DevAssistant() {
|
||||
) {
|
||||
return (
|
||||
<Box className="p-4">
|
||||
<Alert className="grid items-center w-full grid-flow-col gap-2 place-content-between">
|
||||
<Alert className="grid w-full grid-flow-col place-content-between items-center gap-2">
|
||||
<Text className="grid grid-flow-row justify-items-start gap-0.5">
|
||||
<Text component="span">
|
||||
To enable graphite, configure the service first in{' '}
|
||||
@@ -197,11 +197,11 @@ export default function DevAssistant() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto">
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
<MessagesList loading={loading} />
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Box className="relative flex flex-row justify-between w-full p-2">
|
||||
<Box className="relative flex w-full flex-row justify-between p-2">
|
||||
<Input
|
||||
value={userInput}
|
||||
onChange={(event) => {
|
||||
@@ -224,7 +224,7 @@ export default function DevAssistant() {
|
||||
color="primary"
|
||||
aria-label="Send"
|
||||
type="submit"
|
||||
className="absolute self-end w-12 h-10 right-2 rounded-xl"
|
||||
className="absolute right-2 h-10 w-12 self-end rounded-xl"
|
||||
>
|
||||
{loading ? <ActivityIndicator /> : <ArrowUpIcon />}
|
||||
</IconButton>
|
||||
|
||||
@@ -24,7 +24,7 @@ function PreComponent(
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div className="group relative">
|
||||
<pre>{children}</pre>
|
||||
<IconButton
|
||||
sx={{
|
||||
@@ -34,13 +34,13 @@ function PreComponent(
|
||||
}}
|
||||
color="warning"
|
||||
variant="contained"
|
||||
className="absolute hidden top-2 right-2 group-hover:flex"
|
||||
className="absolute right-2 top-2 hidden group-hover:flex"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy(onlyText(children), 'Snippet');
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="w-5 h-5" />
|
||||
<CopyIcon className="h-5 w-5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
@@ -53,7 +53,7 @@ export default function MessageBox({ message }: { message: Message }) {
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="flex flex-col p-4 space-y-4 border-t first:border-t-0"
|
||||
className="flex flex-col space-y-4 border-t p-4 first:border-t-0"
|
||||
sx={{
|
||||
backgroundColor: isUserMessage && 'background.default',
|
||||
}}
|
||||
@@ -67,7 +67,7 @@ export default function MessageBox({ message }: { message: Message }) {
|
||||
) : (
|
||||
<>
|
||||
<Avatar
|
||||
className="rounded-full h-7 w-7"
|
||||
className="h-7 w-7 rounded-full"
|
||||
alt={user?.displayName}
|
||||
src={user?.avatarUrl}
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/v2/Input';
|
||||
import { Switch } from '@/components/ui/v2/Switch';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { isPostgresVersionValidForAI } from '@/features/ai/settings/utils/isPostgresVersionValidForAI';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { COST_PER_VCPU } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
@@ -32,7 +33,6 @@ import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
import { DisableAIServiceConfirmationDialog } from './DisableAIServiceConfirmationDialog';
|
||||
import { isPostgresVersionValidForAI } from '@/features/ai/settings/utils/isPostgresVersionValidForAI';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
version: Yup.object({
|
||||
@@ -267,7 +267,7 @@ export default function AISettings() {
|
||||
|
||||
return (
|
||||
<Box className="space-y-4" sx={{ backgroundColor: 'background.default' }}>
|
||||
<Box className="flex flex-row items-center justify-between p-4 rounded-lg border-1">
|
||||
<Box className="flex flex-row items-center justify-between rounded-lg border-1 p-4">
|
||||
<Text className="text-lg font-semibold">Enable AI service</Text>
|
||||
<Switch
|
||||
checked={aiServiceEnabled}
|
||||
@@ -296,7 +296,7 @@ export default function AISettings() {
|
||||
<Tooltip title="Version of the service to use.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -351,7 +351,7 @@ export default function AISettings() {
|
||||
<Tooltip title="Used to validate requests between postgres and the AI service. The AI service will also include the header X-Graphite-Webhook-Secret with this value set when calling external webhooks so the source of the request can be validated.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -375,7 +375,7 @@ export default function AISettings() {
|
||||
<Tooltip title="Dedicated resources allocated for the service.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -415,7 +415,7 @@ export default function AISettings() {
|
||||
<Tooltip title="Key to use for authenticating API requests to OpenAI">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -438,7 +438,7 @@ export default function AISettings() {
|
||||
<Tooltip title="Optional. OpenAI organization to use.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -466,7 +466,7 @@ export default function AISettings() {
|
||||
<Tooltip title="How often to run the job that keeps embeddings up to date.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -494,4 +494,3 @@ export default function AISettings() {
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { useUpdateConfigMutation } from '@/utils/__generated__/graphql';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
|
||||
@@ -269,7 +269,7 @@ export default function AppleProviderSettings() {
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ export default function AuthServiceVersionSettings() {
|
||||
}}
|
||||
docsLink="https://github.com/nhost/hasura-auth/releases"
|
||||
docsTitle="the latest releases"
|
||||
className="grid grid-flow-row px-4 gap-x-4 gap-y-2 lg:grid-cols-5"
|
||||
className="grid grid-flow-row gap-x-4 gap-y-2 px-4 lg:grid-cols-5"
|
||||
>
|
||||
<ControlledAutocomplete
|
||||
id="version"
|
||||
|
||||
@@ -214,7 +214,7 @@ export default function AzureADProviderSettings() {
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user