mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
Merge branch 'master' into feat/studio-nav-v2
This commit is contained in:
546 files changed
+160643
-54676
No files matched your search
@@ -2856,6 +2856,7 @@ export const self_hosting: NavMenuConstant = {
|
||||
name: 'Add Reverse Proxy with HTTPS',
|
||||
url: '/guides/self-hosting/self-hosted-proxy-https',
|
||||
},
|
||||
{ name: 'Upgrade to Postgres 17', url: '/guides/self-hosting/postgres-upgrade-17' },
|
||||
{
|
||||
name: 'Restore Project from Platform',
|
||||
url: '/guides/self-hosting/restore-from-platform',
|
||||
|
||||
@@ -43,3 +43,44 @@ This means a client that has been idle will tolerate a brief spike in traffic, b
|
||||
The table below shows the rate limit quotas and additional details for authentication endpoints.
|
||||
|
||||
<$Partial path="auth_rate_limits.mdx" />
|
||||
|
||||
## IP address forwarding
|
||||
|
||||
By default, Supabase Auth uses the IP address of the client for rate limiting. In certain cases, such as when using server-side frameworks or proxies in front of a project, it may be necessary to forward the end-user IP address to avoid being rate limited based on the address of the server-side client. To use a forwarded IP address for rate limiting in Supabase Auth, set the `Sb-Forwarded-For` header to the end-user IP address and make a request with a [secret API key](/docs/guides/api/api-keys). Publishable API keys and legacy `anon`/`service_role` API keys are not supported.
|
||||
|
||||
IP address forwarding must be explicitly enabled for new projects. You can enable this feature in your project under the **IP Address Forwarding** section of your project's rate limit settings at [**Authentication** > **Rate Limits**](/dashboard/project/_/auth/rate-limits).
|
||||
|
||||
You can also enable IP address forwarding using the management API:
|
||||
|
||||
```bash
|
||||
# Get your access token from https://supabase.com/dashboard/account/tokens
|
||||
export SUPABASE_ACCESS_TOKEN="your-access-token"
|
||||
export PROJECT_REF="your-project-ref"
|
||||
|
||||
# Update IP address forwarding settings
|
||||
curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \
|
||||
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"security_sb_forwarded_for_enabled": true
|
||||
}' \
|
||||
| jq '.security_sb_forwarded_for_enabled'
|
||||
```
|
||||
|
||||
Once IP address forwarding is enabled, set the `Sb-Forwarded-For` header using the Supabase SDK:
|
||||
|
||||
```typescript
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
|
||||
const supabase = createServerClient(
|
||||
'https://<your-project-id>.supabase.co',
|
||||
'<your-secret-key>', // Key should start with sb_secret
|
||||
{
|
||||
global: {
|
||||
headers: {
|
||||
'sb-forwarded-for': request.headers.get('x-forwarded-for'),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
@@ -121,12 +121,6 @@ The Dedicated Pooler ensures best performance and latency, while using up more o
|
||||
|
||||
Get your project's Dedicated pooler connection string from your project dashboard by clicking [Connect](/dashboard/project/_?showConnect=true&method=transaction).
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
PgBouncer always runs in Transaction mode and the current version does not support prepared statement (will be added in a few weeks).
|
||||
|
||||
</Admonition>
|
||||
|
||||
## More about connection pooling
|
||||
|
||||
Connection pooling improves database performance by reusing existing connections between queries. This reduces the overhead of establishing connections and improves scalability.
|
||||
|
||||
@@ -24,7 +24,7 @@ You can also install the skills as Claude Code plugins:
|
||||
|
||||
```bash
|
||||
/plugin marketplace add supabase/agent-skills
|
||||
/plugin install postgres-best-practices@supabase-agent-skills
|
||||
/plugin install supabase@supabase-agent-skills
|
||||
```
|
||||
|
||||
Skills work with 18+ AI agents including Claude Code, GitHub Copilot, Cursor, Cline, and many others.
|
||||
|
||||
@@ -344,6 +344,27 @@ When you are finished working on your Supabase project, you can stop the stack (
|
||||
supabase stop
|
||||
```
|
||||
|
||||
## Telemetry
|
||||
|
||||
The Supabase CLI collects telemetry data about general usage. Participating in this program is optional, and you can opt out at any time.
|
||||
|
||||
### How to opt out
|
||||
|
||||
You can disable telemetry by running:
|
||||
|
||||
```bash
|
||||
supabase telemetry disable
|
||||
```
|
||||
|
||||
You can check the current status and re-enable with:
|
||||
|
||||
```bash
|
||||
supabase telemetry status
|
||||
supabase telemetry enable
|
||||
```
|
||||
|
||||
You can also opt out using the `SUPABASE_TELEMETRY_DISABLED=1` environment variable. The broader `DO_NOT_TRACK=1` convention is also respected.
|
||||
|
||||
## Learn more
|
||||
|
||||
- [CLI configuration](/docs/guides/local-development/cli/config)
|
||||
|
||||
@@ -40,15 +40,16 @@ curl -X GET "https://api.supabase.com/v1/projects/$PROJECT_REF/billing/addons" \
|
||||
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN"
|
||||
|
||||
# Enable IPv4 add-on
|
||||
curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/addons" \
|
||||
curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/billing/addons" \
|
||||
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"addon_variant": "ipv4_default",
|
||||
"addon_type": "ipv4"
|
||||
}'
|
||||
|
||||
# Disable IPv4 add-on
|
||||
curl -X DELETE "https://api.supabase.com/v1/projects/$PROJECT_REF/billing/addons/ipv4" \
|
||||
curl -X DELETE "https://api.supabase.com/v1/projects/$PROJECT_REF/billing/addons/ipv4_default" \
|
||||
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
|
||||
@@ -37,12 +37,12 @@ We recommend using the In-place upgrade method, as it is faster, and more reliab
|
||||
|
||||
</Admonition>
|
||||
|
||||
When you pause and restore a project, the restored database includes the latest features. This method _does_ include downtime, so be aware that your project will be inaccessible for a short period of time.
|
||||
When you pause and restore a project, the restored database includes the latest features. **This method includes downtime**, so be aware that your project will be inaccessible for a short period of time.
|
||||
|
||||
1. On the [General Settings](/dashboard/project/_/settings/general) page in the Dashboard, click **Pause project**. You will be redirected to the home screen as your project is pausing. This process can take several minutes.
|
||||
1. After your project is paused, click **Restore project**. The restoration can take several minutes depending on how much data your database has. You will receive an email once the restoration is complete.
|
||||
1. On the [General Settings](/dashboard/project/_/settings/general) page in the Dashboard, click **Pause project**. You will be redirected to the home screen in the meantime.
|
||||
1. After this, click **Restore project**. Your project will be restored from the [physical backup](/guides/platform/backups). You should receive an email once the restoration is complete.
|
||||
|
||||
Note that a pause + restore upgrade involves tearing down your project's resources before bringing them back up again. If the restore process should fail, manual intervention from Supabase support will be required to bring your project back online.
|
||||
Pausing and restoring project will take some time depending on how much data your database has. If the restore process fails, [contact Supabase support](/dashboard/support/new?projectRef=) to bring your project back online.
|
||||
|
||||
## Caveats
|
||||
|
||||
|
||||
@@ -365,7 +365,7 @@ height={645}
|
||||
| Configure limits | Adjust "Max concurrent connections" or "Max events per second" settings if errors are related to quota limits being reached | [Realtime Settings Guide](/docs/guides/realtime/settings) |
|
||||
| Check logs | Investigate specific error messages, error codes, and request details in your project dashboard | [Realtime Logs Dashboard](/dashboard/project/_/database/realtime-logs) |
|
||||
| Review request volume | Compare error rates with total request volume to calculate error percentages and identify trends | [Total Requests Report](#total-requests) |
|
||||
| Understand error codes | Understand specific error codes and their resolutions | [Realtime Error Codes Reference](/docs/reference/realtime/error-codes) |
|
||||
| Understand error codes | Understand specific error codes and their resolutions | [Realtime Error Codes Reference](/docs/guides/realtime/error_codes) |
|
||||
| Learn HTTP status codes | Learn about HTTP status codes including 4XX client errors and 5XX server errors | [HTTP Status Codes Troubleshooting](/docs/troubleshooting/http-status-codes) |
|
||||
| Fix timeout errors | Resolve WebSocket timeout errors caused by Node.js version incompatibility | [TIMED_OUT Connection Errors Troubleshooting](/docs/troubleshooting/realtime-connections-timed_out-status) |
|
||||
| Understand heartbeats | Monitor heartbeat status to detect connection issues and handle timeouts | [Realtime Heartbeats Guide](/docs/troubleshooting/realtime-heartbeat-messages) |
|
||||
|
||||
@@ -66,7 +66,9 @@ Self-hosted Supabase is different from:
|
||||
|
||||
### Telemetry
|
||||
|
||||
Self-hosted Supabase does not phone home or collect any telemetry.
|
||||
Self-hosted Supabase (Docker) does not phone home or collect any telemetry.
|
||||
|
||||
The **Supabase CLI** is a [separate tool](/docs/guides/local-development/cli/getting-started) from self-hosted Supabase and collects usage telemetry to help improve the developer experience. You can opt out by running `supabase telemetry disable` or setting `SUPABASE_TELEMETRY_DISABLED=1`. See [CLI telemetry](/docs/guides/local-development/cli/getting-started#telemetry) for other opt-out methods.
|
||||
|
||||
### Your responsibilities when self-hosting
|
||||
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
---
|
||||
title: 'Upgrade to Postgres 17'
|
||||
description: 'Start a new self-hosted Supabase deployment with Postgres 17, or upgrade an existing Postgres 15 installation.'
|
||||
subtitle: 'Start a new self-hosted Supabase deployment with Postgres 17, or upgrade an existing Postgres 15 installation.'
|
||||
---
|
||||
|
||||
Self-hosted Supabase ships with Postgres 15 by default. This guide covers two scenarios:
|
||||
|
||||
- **New deployment** - start fresh with Postgres 17 (no existing data)
|
||||
- **Upgrade existing deployment** - migrate from Postgres 15 to Postgres 17 using `pg_upgrade`
|
||||
|
||||
## Before you begin
|
||||
|
||||
- Complete the [Self-Hosting with Docker](/docs/guides/self-hosting/docker) setup
|
||||
- Your current database image should be `supabase/postgres:15.x` (check with `docker inspect supabase-db --format '{{.Config.Image}}'`)
|
||||
|
||||
## New deployment with Postgres 17
|
||||
|
||||
If you are starting a new self-hosted Supabase instance with **no existing data**, use the Postgres 17 compose override:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d
|
||||
```
|
||||
|
||||
This uses the `docker-compose.pg17.yml` override file which swaps the database image:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
db:
|
||||
image: supabase/postgres:17.6.1.084
|
||||
```
|
||||
|
||||
<Admonition type="tip">
|
||||
|
||||
Always include both compose files when running commands. If you omit the override, Docker Compose falls back to the Postgres 15 image defined in `docker-compose.yml`.
|
||||
|
||||
</Admonition>
|
||||
|
||||
The rest of the setup is the same as the standard Docker guide. All init scripts (`roles.sql`, `jwt.sql`, `webhooks.sql`, etc.) are compatible with Postgres 17.
|
||||
|
||||
{/* supa-mdx-lint-disable-next-line Rule003Spelling */}
|
||||
If the new Postgres 17 container fails to start, make sure to check for an old `db-config` Docker volume. See [Postgres 17 fails to start with a leftover db-config volume](#postgres-17-fails-to-start-with-a-leftover-db-config-volume) for details.
|
||||
|
||||
## Upgrade an existing Postgres 15 deployment
|
||||
|
||||
Upgrading an existing deployment uses `pg_upgrade` to migrate data in place. The included upgrade scripts automates the full process.
|
||||
|
||||
### What the upgrade does
|
||||
|
||||
1. Pulls a specific Postgres 17 image and extracts upgrade binaries
|
||||
2. Pulls supplemental upgrade scripts from Supabase's [Postgres](https://github.com/supabase/postgres) repository
|
||||
3. Stops all self-hosted Supabase containers
|
||||
4. Runs `pg_upgrade` inside a temporary Postgres 15 container
|
||||
5. Runs additional tasks inside a temporary Postgres 17 container (re-enables extensions, applies patches, runs `VACUUM ANALYZE`)
|
||||
6. Swaps data directories (the original is kept as a backup)
|
||||
7. Starts self-hosted Supabase with Postgres 17
|
||||
8. Applies additional role migrations (new in Postgres 17)
|
||||
|
||||
### Create a backup
|
||||
|
||||
<Admonition type="danger" label="Back up your data before upgrading">
|
||||
|
||||
You should create your own independent backup in case of disk failure or other issues.
|
||||
|
||||
</Admonition>
|
||||
|
||||
The upgrade script automatically preserves the pgsodium key and original data directory as `./volumes/db/data.bak.pg15` as the final step. However, it is recommended to **always** create your own independent backup before starting:
|
||||
|
||||
Back up the database data directory:
|
||||
|
||||
```sh
|
||||
cp -a ./volumes/db/data ./volumes/db/data-manual-backup
|
||||
```
|
||||
|
||||
Back up the pgsodium encryption key (stored in a Docker named volume):
|
||||
|
||||
```sh
|
||||
docker compose run --rm db cat /etc/postgresql-custom/pgsodium_root.key > ./pgsodium_root.key.backup
|
||||
```
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
The `db-config` Docker named volume contains the pgsodium root encryption key. If you lose this key and have vault secrets, they become unrecoverable. The `cp -a` above backs up the data directory but NOT the named volume.
|
||||
|
||||
</Admonition>
|
||||
|
||||
Optionally, take a logical backup too:
|
||||
|
||||
```sh
|
||||
docker exec supabase-db pg_dumpall -h localhost -U supabase_admin > ./pg15_dump.sql
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
- At least **2x your current database size + 5 GB** of free disk space (`pg_upgrade` copies the data directory; the upgrade tarball is ~1.2 GB compressed)
|
||||
- The script prompts for confirmation at each major step (use `--yes` to skip prompts)
|
||||
- All self-hosted Supabase containers must be running before starting the upgrade
|
||||
- Requires `bash`
|
||||
- Must be run as root or using `sudo`
|
||||
|
||||
### Extensions removed in Postgres 17
|
||||
|
||||
The following extensions are **not available** in Postgres 17 builds. The upgrade script will prompt you to drop them if any of these are found:
|
||||
|
||||
| Extension | Notes |
|
||||
| ------------- | ------------------------- |
|
||||
| `timescaledb` | Not built for Postgres 17 |
|
||||
| `plv8` | Not built for Postgres 17 |
|
||||
| `plcoffee` | Companion to plv8 |
|
||||
| `plls` | Companion to plv8 |
|
||||
|
||||
None of the above extensions are installed by default in the self-hosted Supabase setup. If you have installed any of them manually and need to keep them, **do not proceed** with the upgrade.
|
||||
|
||||
### Run the upgrade
|
||||
|
||||
```sh
|
||||
sudo bash utils/upgrade-pg17.sh
|
||||
```
|
||||
|
||||
The script might require your confirmation at some steps (e.g., while checking for disk space, or whether to disable extensions, or remove previous backups).
|
||||
|
||||
### After the upgrade
|
||||
|
||||
After a successful upgrade, always use both compose files:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d
|
||||
```
|
||||
|
||||
To verify that Postgres 17 is running:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.pg17.yml exec db psql -U postgres -c "SELECT version();"
|
||||
```
|
||||
|
||||
The original Postgres 15 data is preserved at `./volumes/db/data.bak.pg15`. The pgsodium root key is saved as `./volumes/db/pgsodium_root.key.bak.pg15`. The upgrade binaries tarball is cached at `./volumes/db/pg17_upgrade_bin_*.tar.gz`. Once you have verified that everything works, you can reclaim disk space:
|
||||
|
||||
```sh
|
||||
rm -rf ./volumes/db/data.bak.pg15 ./volumes/db/pgsodium_root.key.bak.pg15 ./volumes/db/pg17_upgrade_bin_*.tar.gz
|
||||
```
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
Do not delete `data.bak.pg15` until you have verified the upgrade. Rollback is only possible while the backup exists.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Rollback
|
||||
|
||||
If you need to revert to Postgres 15 (run the following commands as root):
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.pg17.yml down && \
|
||||
rm -rf ./volumes/db/data && \
|
||||
mv ./volumes/db/data.bak.pg15 ./volumes/db/data && \
|
||||
docker compose run --rm db chown -R postgres:postgres /etc/postgresql-custom/ && \
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This restores the original data directory, fixes file ownership on the `db-config` volume (Supabase's Postgres 15 and 17 images use different user IDs), and starts with the old Postgres 15 image.
|
||||
|
||||
### Custom Postgres configuration
|
||||
|
||||
The Postgres 17 image loads any `.conf` files from `/etc/postgresql-custom/conf.d/` on startup. This directory is on the `db-config` named volume, so changes persist across restarts.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
This is a Supabase Postgres 17 image feature. The Postgres 15 image does not load files from `conf.d/`.
|
||||
|
||||
</Admonition>
|
||||
|
||||
To add custom Postgres settings, create a `.conf` file in the volume. Since `conf.d/` is on a Docker named volume (not a bind mount), you need to write through the container:
|
||||
|
||||
```sh
|
||||
docker exec supabase-db bash -c 'cat > /etc/postgresql-custom/conf.d/custom.conf << EOF
|
||||
max_connections = 200
|
||||
EOF'
|
||||
```
|
||||
|
||||
Restart to apply (`max_connections` requires a full restart):
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.pg17.yml restart db
|
||||
```
|
||||
|
||||
Verify the new settings:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.pg17.yml exec db psql -U postgres -c "SHOW max_connections;"
|
||||
```
|
||||
|
||||
### Upgrade process details
|
||||
|
||||
The upgrade script delegates the core migration work to two scripts from the [supabase/postgres](https://github.com/supabase/postgres) repository (`ansible/files/admin_api_scripts/pg_upgrade_scripts/`).
|
||||
|
||||
**Phase 1 - Migrate data (Postgres 15 container):**
|
||||
|
||||
1. Disables extensions that are incompatible with `pg_upgrade` (`pg_graphql`, `pg_stat_monitor`, `pg_backtrace`, `wrappers`, `pgrouting`) and generates SQL to re-enable them after the upgrade
|
||||
2. Temporarily grants superuser to the `postgres` role (required by `pg_upgrade`)
|
||||
3. Extracts the previously saved Postgres 17 binaries tarball and runs `initdb` to create a new empty database
|
||||
4. Runs `pg_upgrade --check` to verify the upgrade can succeed before making changes
|
||||
5. Stops Postgres 15 and runs `pg_upgrade` to migrate all data to the new database
|
||||
6. Copies Postgres configuration and the SQL scripts generated by `pg_upgrade` to a staging directory for the next phase
|
||||
|
||||
**Phase 2 - Finalize (Postgres 17 container):**
|
||||
|
||||
1. Moves the upgraded data directory into place and starts Postgres 17
|
||||
2. Applies extension compatibility patches for Wrappers, `pg_net`, `pg_cron`, and Vault (fixes ownership, grants, and foreign server options)
|
||||
3. Runs the SQL scripts generated by `pg_upgrade` to update system catalogs and extension versions
|
||||
4. Re-enables the extensions that were disabled in phase 1
|
||||
5. Grants predefined roles (`pg_monitor`, `pg_read_all_data`, `pg_signal_backend`, and on Postgres 16+ also `pg_create_subscription`) and revokes the temporary superuser grant
|
||||
6. Restarts Postgres and runs `vacuumdb --all --analyze-in-stages` to rebuild optimizer statistics
|
||||
|
||||
After both phases complete, the upgrade script preserves the original Postgres 15 data directory as a backup and starts the full Supabase stack with Postgres 17.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
||||
|
||||
### pg_upgrade fails with replication slot errors
|
||||
|
||||
`pg_upgrade` cannot proceed if there are active replication slots. Default self-hosted installs don't have any, but if you set up logical replication or have custom replication configurations, drop the slots before upgrading:
|
||||
|
||||
```sh
|
||||
docker exec supabase-db psql -h localhost -U supabase_admin -d postgres -c "
|
||||
SELECT pg_drop_replication_slot(slot_name)
|
||||
FROM pg_replication_slots;
|
||||
"
|
||||
```
|
||||
|
||||
Then re-run the upgrade script. Replication slots will need to be manually recreated after the upgrade.
|
||||
|
||||
### "Permission denied" on the data directory
|
||||
|
||||
The upgrade script fixes file ownership automatically (Postgres 15 and 17 use different UIDs). If you still see permission errors, run:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.pg17.yml run --rm db \
|
||||
chown -R postgres:postgres /var/lib/postgresql/data
|
||||
```
|
||||
|
||||
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
||||
|
||||
### pgsodium / Supabase Vault errors
|
||||
|
||||
The `db-config` named volume contains the pgsodium root encryption key at `/etc/postgresql-custom/pgsodium_root.key`. This volume is preserved during the upgrade. Never run `docker compose down -v` as this destroys named volumes and makes vault secrets unrecoverable.
|
||||
|
||||
### Services fail to connect after upgrade
|
||||
|
||||
Restart all services to pick up the new database:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.pg17.yml down && \
|
||||
docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d
|
||||
```
|
||||
|
||||
### Disk space issues during upgrade
|
||||
|
||||
The upgrade needs space for:
|
||||
|
||||
- The upgrade tarball (~1.2 GB compressed, cached for re-runs)
|
||||
- A full copy of your database (created by `pg_upgrade`)
|
||||
- The original data (kept as backup)
|
||||
|
||||
The script uses `/tmp` (or `TMPDIR` if set) for its staging directory, which holds the downloaded tarball and upgrade scripts. If your `/tmp` filesystem is small or mounted with limited space, you can point it to a different location, e.g.:
|
||||
|
||||
```sh
|
||||
sudo TMPDIR=/mnt/my-tmp bash utils/upgrade-pg17.sh
|
||||
```
|
||||
|
||||
If you run out of space mid-upgrade, the safest path is to roll back and free up disk space before retrying.
|
||||
|
||||
{/* supa-mdx-lint-disable-next-line Rule003Spelling */}
|
||||
|
||||
### Postgres 17 fails to start with a leftover db-config volume
|
||||
|
||||
If you are starting a **fresh** Postgres 17 deployment (not upgrading from Postgres 15) and the container fails to start, the most likely cause is a leftover `db-config` volume from a previous Postgres 15 installation. Try to start the containers without the `-d` option and/or check the logs for errors about `postgresql.conf` or other configuration mismatch.
|
||||
|
||||
To fix, remove the old volume and let Postgres 17 initialize a clean configuration:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.pg17.yml down && \
|
||||
docker volume rm $(docker volume ls --filter "name=db-config" --format '{{.Name}}') && \
|
||||
docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d
|
||||
```
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
Removing the `db-config` volume destroys any custom Postgres configuration and the pgsodium root key. Only do this for fresh installations with no existing data or vault secrets.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Restoring from a manual backup
|
||||
|
||||
If the upgrade fails and the script's built-in rollback isn't sufficient, restore from the manual backups created in the [Create a backup](#create-a-backup) step:
|
||||
|
||||
Restore the data:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.pg17.yml down && \
|
||||
rm -rf ./volumes/db/data && \
|
||||
cp -a ./volumes/db/data-manual-backup ./volumes/db/data
|
||||
```
|
||||
|
||||
Restore the pgsodium key to the `db-config` volume:
|
||||
|
||||
```sh
|
||||
docker compose run --rm db \
|
||||
sh -c 'cat > /etc/postgresql-custom/pgsodium_root.key' < ./pgsodium_root.key.backup && \
|
||||
docker compose run --rm db \
|
||||
chown postgres:postgres /etc/postgresql-custom/pgsodium_root.key && \
|
||||
docker compose run --rm db \
|
||||
chmod 600 /etc/postgresql-custom/pgsodium_root.key
|
||||
```
|
||||
|
||||
Start Postgres 15:
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title = "PostgREST not recognizing the objects(tables/functions/views) in a schema even after adding it to the exposed schemas"
|
||||
topics = [
|
||||
"database",
|
||||
"rest-api",
|
||||
"platform"
|
||||
]
|
||||
keywords = []
|
||||
---
|
||||
|
||||
PostgREST is returning errors by not recognizing the objects(tables/functions/views) in a schema and logging errors similar to:
|
||||
|
||||
```
|
||||
Could not find the table 'X' in the schema cache
|
||||
```
|
||||
|
||||
## Why this happens
|
||||
|
||||
When multiple schemas are exposed, the first entry in the list is treated as the default, so PostgREST resolves unqualified object names only against that schema.
|
||||
|
||||
This behavior is intentional, as it prevents conflicts when different schemas contain objects with the same name.
|
||||
|
||||
## How to fix this
|
||||
|
||||
If you want to use the objects without specifying a schema, you'd need to put it first. Else, you should initialise the Supabase client with the schema([”With custom schemas”](https://supabase.com/docs/reference/javascript/initializing)) or specify the schema on each request using ".schema('your_schema')" (["Switching schemas per query"](https://supabase.com/docs/reference/javascript/select))
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title = "schema \"pg_pgrst_no_exposed_schemas\" does not exist"
|
||||
topics = [
|
||||
"database",
|
||||
"rest-api",
|
||||
"platform"
|
||||
]
|
||||
[[errors]]
|
||||
message = "schema pg_pgrst_no_exposed_schemas does not exist"
|
||||
---
|
||||
|
||||
PostgREST logs showing the error:
|
||||
|
||||
- `schema "pg_pgrst_no_exposed_schemas" does not exist`
|
||||
|
||||
## Why this happens
|
||||
|
||||
- This error is seen when the Data API is disabled on the Supabase project.
|
||||
- Currently, we don’t really shutdown postgrest when data api is disabled. You will see a schema called `pg_pgrst_no_exposed_schemas` added in the exposed schemas.
|
||||
- This should not adversely affect the project, although it may result in additional entries in your logs.
|
||||
- We're aware of this issue and are working on it.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -145,6 +145,7 @@
|
||||
"@types/react-dom": "catalog:",
|
||||
"@types/unist": "^2.0.6",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@redocly/cli": "^2.25.3",
|
||||
"amaro": "^1.1.5",
|
||||
"api-types": "workspace:*",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
|
||||
@@ -53,16 +53,16 @@ download.analytics.v0:
|
||||
transform: dereference.api.v1 dereference.auth.v1 dereference.storage.v0 dereference.tsdoc.v2 combine.tsdoc.v2
|
||||
|
||||
dereference.api.v1:
|
||||
cd $(GENERATOR_DIR) && pnpm exec redocly bundle --dereferenced -o $(REPO_DIR)/transforms/api_v1_openapi_deparsed.json $(REPO_DIR)/api_v1_openapi.json
|
||||
pnpm exec redocly bundle --dereferenced -o $(REPO_DIR)/transforms/api_v1_openapi_deparsed.json $(REPO_DIR)/api_v1_openapi.json
|
||||
|
||||
dereference.auth.v1:
|
||||
cd $(GENERATOR_DIR) && pnpm exec redocly bundle --dereferenced -o $(REPO_DIR)/transforms/auth_v1_openapi_deparsed.json $(REPO_DIR)/auth_v1_openapi.json
|
||||
pnpm exec redocly bundle --dereferenced -o $(REPO_DIR)/transforms/auth_v1_openapi_deparsed.json $(REPO_DIR)/auth_v1_openapi.json
|
||||
|
||||
dereference.storage.v0:
|
||||
cd $(GENERATOR_DIR) && pnpm exec redocly bundle --dereferenced -o $(REPO_DIR)/transforms/storage_v0_openapi_deparsed.json $(REPO_DIR)/storage_v0_openapi.json
|
||||
pnpm exec redocly bundle --dereferenced -o $(REPO_DIR)/transforms/storage_v0_openapi_deparsed.json $(REPO_DIR)/storage_v0_openapi.json
|
||||
|
||||
dereference.analytics.v0:
|
||||
cd $(GENERATOR_DIR) && pnpm exec redocly bundle --dereferenced -o $(REPO_DIR)/transforms/analytics_v0_openapi_deparsed.json $(REPO_DIR)/analytics_v0_openapi.json
|
||||
pnpm exec redocly bundle --dereferenced -o $(REPO_DIR)/transforms/analytics_v0_openapi_deparsed.json $(REPO_DIR)/analytics_v0_openapi.json
|
||||
|
||||
# No longer updated
|
||||
# dereference.tsdoc.v1:
|
||||
@@ -125,7 +125,7 @@ generate.sections.api.v1:
|
||||
###############################################################################
|
||||
|
||||
validate.analytics.v0:
|
||||
cd $(GENERATOR_DIR) && pnpm exec redocly lint --extends=minimal $(REPO_DIR)/analytics_v0_openapi.json
|
||||
pnpm exec redocly lint --extends=minimal $(REPO_DIR)/analytics_v0_openapi.json
|
||||
|
||||
###############################################################################
|
||||
# Format everything - easier for git to track changes.
|
||||
|
||||
+1683
-327
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
clispec: '001'
|
||||
info:
|
||||
id: cli
|
||||
version: 2.84.2
|
||||
version: 2.88.0
|
||||
title: Supabase CLI
|
||||
language: sh
|
||||
source: https://github.com/supabase/cli
|
||||
@@ -275,6 +275,16 @@ commands:
|
||||
name: --local
|
||||
description: Runs pgTAP tests on the local database.
|
||||
default_value: 'true'
|
||||
- id: supabase-telemetry
|
||||
title: supabase telemetry
|
||||
summary: Manage CLI telemetry settings
|
||||
description: |
|
||||
The Supabase CLI collects telemetry data about general usage. Participating in this program is optional. You can opt out by running `supabase telemetry disable` or setting `SUPABASE_TELEMETRY_DISABLED=1`. Use `supabase telemetry status` to check your current setting and `supabase telemetry enable` to opt back in.
|
||||
tags:
|
||||
- local-dev
|
||||
links: []
|
||||
subcommands: []
|
||||
flags: []
|
||||
- id: supabase-storage
|
||||
title: supabase storage
|
||||
summary: Manage Supabase Storage objects
|
||||
|
||||
@@ -682,6 +682,12 @@
|
||||
"slug": "v1-get-an-organization",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-organization-entitlements",
|
||||
"title": "Get organization entitlements",
|
||||
"slug": "v1-get-organization-entitlements",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-all-organizations",
|
||||
"title": "List all organizations",
|
||||
@@ -696,6 +702,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Profile",
|
||||
"items": [
|
||||
{
|
||||
"id": "v1-get-profile",
|
||||
"title": "Get profile",
|
||||
"slug": "v1-get-profile",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Projects",
|
||||
|
||||
@@ -884,5 +884,17 @@
|
||||
"type": "cli-command"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Telemetry",
|
||||
"items": [
|
||||
{
|
||||
"id": "supabase-telemetry",
|
||||
"title": "Manage CLI telemetry settings",
|
||||
"slug": "supabase-telemetry",
|
||||
"type": "cli-command"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
+47940
-16446
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,9 @@
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { PropsWithChildren, useEffect, useRef, useState } from 'react'
|
||||
import { PropsWithChildren, useRef } from 'react'
|
||||
import { DataGridHandle } from 'react-data-grid'
|
||||
import { DndProvider } from 'react-dnd'
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { useIsTableFilterBarEnabled } from '../interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { Shortcuts } from './components/common/Shortcuts'
|
||||
@@ -12,7 +11,6 @@ import { Footer } from './components/footer/Footer'
|
||||
import { Grid } from './components/grid/Grid'
|
||||
import { Header, HeaderProps } from './components/header/Header'
|
||||
import { HeaderNew } from './components/header/HeaderNew'
|
||||
import { RowContextMenu } from './components/menu/RowContextMenu'
|
||||
import { useTableFilter } from './hooks/useTableFilter'
|
||||
import { useTableSort } from './hooks/useTableSort'
|
||||
import { validateMsSqlSorting } from './MsSqlValidation'
|
||||
@@ -46,7 +44,6 @@ export const SupabaseGrid = ({
|
||||
const preflightCheck = !tableEditorSnap.tablesToIgnorePreflightCheck.includes(tableId ?? -1)
|
||||
|
||||
const gridRef = useRef<DataGridHandle>(null)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
const newFilterBarEnabled = useIsTableFilterBarEnabled()
|
||||
|
||||
@@ -89,10 +86,6 @@ export const SupabaseGrid = ({
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) setMounted(true)
|
||||
}, [])
|
||||
|
||||
const operations = (tableEditorSnap.operationQueue.operations as QueuedOperation[]).filter(
|
||||
(op) => op.tableId === tableId
|
||||
)
|
||||
@@ -128,8 +121,6 @@ export const SupabaseGrid = ({
|
||||
<Shortcuts gridRef={gridRef} rows={rows} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{mounted && createPortal(<RowContextMenu rows={rows} />, document.body)}
|
||||
</div>
|
||||
</DndProvider>
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { GridProps, SupaRow } from '../../types'
|
||||
import { isPendingAddRow, isPendingDeleteRow } from '../../types'
|
||||
import { useOnRowsChange } from './Grid.utils'
|
||||
import { GridError } from './GridError'
|
||||
import { RowRenderer } from './RowRenderer'
|
||||
import { RowContextMenuProvider, RowRenderer } from './RowRenderer'
|
||||
import { useTableFilterNew } from '@/components/grid/hooks/useTableFilterNew'
|
||||
import { handleCopyCell } from '@/components/grid/SupabaseGrid.utils'
|
||||
import { useIsTableFilterBarEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
@@ -324,26 +324,28 @@ export const Grid = memo(
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
ref={ref}
|
||||
className={`${gridClass} flex-grow`}
|
||||
rowClass={computedRowClass}
|
||||
columns={columnsWithDirtyCellClass}
|
||||
rows={rows ?? []}
|
||||
renderers={{ renderRow: RowRenderer }}
|
||||
rowKeyGetter={rowKeyGetter}
|
||||
selectedRows={snap.selectedRows}
|
||||
onColumnResize={snap.updateColumnSize}
|
||||
onRowsChange={onRowsChange}
|
||||
onSelectedCellChange={onSelectedCellChange}
|
||||
onSelectedRowsChange={onSelectedRowsChange}
|
||||
onCellDoubleClick={(props) => {
|
||||
if (typeof props.column.name === 'string') {
|
||||
onRowDoubleClick(props.row, { name: props.column.name })
|
||||
}
|
||||
}}
|
||||
onCellKeyDown={handleCopyCell}
|
||||
/>
|
||||
<RowContextMenuProvider>
|
||||
<DataGrid
|
||||
ref={ref}
|
||||
className={`${gridClass} flex-grow`}
|
||||
rowClass={computedRowClass}
|
||||
columns={columnsWithDirtyCellClass}
|
||||
rows={rows ?? []}
|
||||
renderers={{ renderRow: RowRenderer }}
|
||||
rowKeyGetter={rowKeyGetter}
|
||||
selectedRows={snap.selectedRows}
|
||||
onColumnResize={snap.updateColumnSize}
|
||||
onRowsChange={onRowsChange}
|
||||
onSelectedCellChange={onSelectedCellChange}
|
||||
onSelectedRowsChange={onSelectedRowsChange}
|
||||
onCellDoubleClick={(props) => {
|
||||
if (typeof props.column.name === 'string') {
|
||||
onRowDoubleClick(props.row, { name: props.column.name })
|
||||
}
|
||||
}}
|
||||
onCellKeyDown={handleCopyCell}
|
||||
/>
|
||||
</RowContextMenuProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,66 @@
|
||||
import type { Key } from 'react'
|
||||
import { TriggerEvent, useContextMenu } from 'react-contexify'
|
||||
import { createContext, useCallback, useContext, useRef, useState } from 'react'
|
||||
import type { Key, ReactNode } from 'react'
|
||||
import { RenderRowProps, Row } from 'react-data-grid'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { ContextMenu_Shadcn_, ContextMenuTrigger_Shadcn_ } from 'ui'
|
||||
|
||||
import { ROW_CONTEXT_MENU_ID } from '@/components/grid/constants'
|
||||
import { RowContextMenuContent } from '../menu/RowContextMenu'
|
||||
import { SupaRow } from '@/components/grid/types'
|
||||
|
||||
/**
|
||||
* TODO: Refactor to use CellContextMenuWrapper instead of react-contexify
|
||||
*/
|
||||
export function RowRenderer(key: Key, props: RenderRowProps<SupaRow>) {
|
||||
const { show: showContextMenu } = useContextMenu()
|
||||
type RowContextMenuContextValue = {
|
||||
onRowContextMenu: (e: React.MouseEvent, row: SupaRow) => void
|
||||
}
|
||||
|
||||
const RowContextMenuContext = createContext<RowContextMenuContextValue | null>(null)
|
||||
|
||||
export function RowContextMenuProvider({ children }: { children: ReactNode }) {
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const [menuKey, setMenuKey] = useState(0)
|
||||
const [activeRow, setActiveRow] = useState<SupaRow | null>(null)
|
||||
|
||||
const onRowContextMenu = useCallback((e: React.MouseEvent, row: SupaRow) => {
|
||||
e.preventDefault()
|
||||
flushSync(() => setActiveRow(row))
|
||||
setMenuKey((prev) => prev + 1)
|
||||
const trigger = triggerRef.current
|
||||
if (!trigger) return
|
||||
trigger.style.left = `${e.clientX}px`
|
||||
trigger.style.top = `${e.clientY}px`
|
||||
trigger.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<RowContextMenuContext.Provider value={{ onRowContextMenu }}>
|
||||
<ContextMenu_Shadcn_ modal={false}>
|
||||
<ContextMenuTrigger_Shadcn_ asChild>
|
||||
<div ref={triggerRef} className="fixed pointer-events-none w-0 h-0" />
|
||||
</ContextMenuTrigger_Shadcn_>
|
||||
{activeRow && <RowContextMenuContent key={menuKey} row={activeRow} />}
|
||||
</ContextMenu_Shadcn_>
|
||||
{children}
|
||||
</RowContextMenuContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function RowWithContextMenu({ row, ...props }: RenderRowProps<SupaRow>) {
|
||||
const ctx = useContext(RowContextMenuContext)
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
ctx?.onRowContextMenu(e, row)
|
||||
},
|
||||
[ctx, row]
|
||||
)
|
||||
|
||||
function displayMenu(e: TriggerEvent) {
|
||||
showContextMenu(e, {
|
||||
id: ROW_CONTEXT_MENU_ID,
|
||||
props: { rowIdx: props.rowIdx },
|
||||
})
|
||||
}
|
||||
return <Row row={row} {...props} onContextMenu={handleContextMenu} />
|
||||
}
|
||||
|
||||
return <Row key={key} {...props} onContextMenu={displayMenu} />
|
||||
export function RowRenderer(key: Key, props: RenderRowProps<SupaRow>) {
|
||||
return <RowWithContextMenu key={key} {...props} />
|
||||
}
|
||||
@@ -1,3 +1,17 @@
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { THRESHOLD_COUNT } from '@supabase/pg-meta'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
@@ -214,6 +228,13 @@ export const SortPopoverPrimitive = ({
|
||||
}
|
||||
}, [sorts])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover_Shadcn_ modal={false} open={open} onOpenChange={setOpen}>
|
||||
@@ -224,17 +245,35 @@ export const SortPopoverPrimitive = ({
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ className="p-0 w-96" side="bottom" align="start">
|
||||
<div className="space-y-2 py-2">
|
||||
{localSorts.map((sort, index) => (
|
||||
<SortRow
|
||||
key={getSortRowKey(sort, index)}
|
||||
index={index}
|
||||
columnName={sort.column}
|
||||
sort={sort}
|
||||
onDelete={onDeleteSort}
|
||||
onToggle={onToggleSort}
|
||||
onDrag={onDragSort}
|
||||
/>
|
||||
))}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(result) => {
|
||||
if (result.over == null) return
|
||||
const activeIndex = localSorts.findIndex((sort) => sort.column === result.active.id)
|
||||
const overIndex = localSorts.findIndex((sort) => sort.column === result.over!.id)
|
||||
if (activeIndex === -1 || overIndex === -1) return
|
||||
|
||||
setLocalSorts(arrayMove(localSorts, activeIndex, overIndex))
|
||||
}}
|
||||
>
|
||||
<SortableContext
|
||||
items={localSorts.map((sort) => sort.column)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{localSorts.map((sort, index) => (
|
||||
<SortRow
|
||||
key={getSortRowKey(sort, index)}
|
||||
index={index}
|
||||
columnName={sort.column}
|
||||
sort={sort}
|
||||
onDelete={onDeleteSort}
|
||||
onToggle={onToggleSort}
|
||||
onDrag={onDragSort}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{localSorts.length === 0 && (
|
||||
<div className="space-y-1 px-3">
|
||||
<h5 className="text-xs text-foreground-light">No sorts applied to this view</h5>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { XYCoord } from 'dnd-core'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
import { memo, useRef } from 'react'
|
||||
import { useDrag, useDrop } from 'react-dnd'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { GripVertical, X } from 'lucide-react'
|
||||
import { memo } from 'react'
|
||||
import { Button, Switch } from 'ui'
|
||||
|
||||
import type { DragItem, Sort } from '@/components/grid/types'
|
||||
import type { Sort } from '@/components/grid/types'
|
||||
import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table'
|
||||
|
||||
export interface SortRowProps {
|
||||
@@ -20,94 +20,29 @@ const SortRow = ({ index, columnName, sort, onDelete, onToggle, onDrag }: SortRo
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
const column = snap.table.columns.find((x) => x.name === columnName)
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition } =
|
||||
useSortable({
|
||||
id: columnName,
|
||||
})
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: 'sort-row',
|
||||
item: () => {
|
||||
return { key: columnName, index }
|
||||
},
|
||||
collect: (monitor: any) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
})
|
||||
|
||||
const [{ handlerId }, drop] = useDrop({
|
||||
accept: 'sort-row',
|
||||
collect(monitor) {
|
||||
return {
|
||||
handlerId: monitor.getHandlerId(),
|
||||
}
|
||||
},
|
||||
hover(item, monitor) {
|
||||
if (!ref.current) {
|
||||
return
|
||||
}
|
||||
const dragIndex = (item as DragItem).index
|
||||
const hoverIndex = index
|
||||
|
||||
// Don't replace items with themselves
|
||||
if (dragIndex === hoverIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine rectangle on screen
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect()
|
||||
|
||||
// Get vertical middle
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2
|
||||
|
||||
// Determine mouse position
|
||||
const clientOffset = monitor.getClientOffset()
|
||||
|
||||
// Get pixels to the top
|
||||
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top
|
||||
|
||||
// Only perform the move when the mouse has crossed half of the items height
|
||||
// When dragging downwards, only move when the cursor is below 50%
|
||||
// When dragging upwards, only move when the cursor is above 50%
|
||||
|
||||
// Dragging downwards
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dragging upwards
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return
|
||||
}
|
||||
|
||||
// Time to actually perform the action
|
||||
moveSort(dragIndex, hoverIndex)
|
||||
|
||||
// Note: we're mutating the monitor item here!
|
||||
// Generally it's better to avoid mutations,
|
||||
// but it's good here for the sake of performance
|
||||
// to avoid expensive index searches.
|
||||
;(item as DragItem).index = hoverIndex
|
||||
},
|
||||
})
|
||||
|
||||
const moveSort = (dragIndex: number, hoverIndex: number) => {
|
||||
if (dragIndex == hoverIndex) return
|
||||
onDrag(dragIndex, hoverIndex)
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
const opacity = isDragging ? 0 : 1
|
||||
drag(drop(ref))
|
||||
|
||||
if (!column) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 px-3"
|
||||
ref={ref}
|
||||
style={{ opacity }}
|
||||
data-handler-id={handlerId}
|
||||
>
|
||||
<span className="transition-color text-foreground-lighter hover:text-foreground-light">
|
||||
<Menu strokeWidth={2} size={16} />
|
||||
</span>
|
||||
<div className="flex items-center gap-3 px-3" ref={setNodeRef} style={style}>
|
||||
<button
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="opacity-50 hover:opacity-100 transition cursor-grab text-foreground"
|
||||
type="button"
|
||||
>
|
||||
<GripVertical size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<div className="grow">
|
||||
<span className="flex grow items-center gap-1 truncate text-sm text-foreground">
|
||||
<span className="text-xs text-foreground-lighter">
|
||||
|
||||
@@ -1,154 +1,115 @@
|
||||
import { ContextMenuContent } from '@ui/components/shadcn/ui/context-menu'
|
||||
import { Copy, Edit, ListFilter, Trash } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { Item, ItemParams, Menu } from 'react-contexify'
|
||||
import { toast } from 'sonner'
|
||||
import { copyToClipboard, DialogSectionSeparator } from 'ui'
|
||||
import { ContextMenuItem_Shadcn_, ContextMenuSeparator_Shadcn_, copyToClipboard } from 'ui'
|
||||
|
||||
import { useTableRowOperations } from '../../hooks/useTableRowOperations'
|
||||
import { formatClipboardValue } from '../../utils/common'
|
||||
import { buildFilterFromCellValue, isComplexValue } from '../header/filter/FilterPopoverNew.utils'
|
||||
import { ROW_CONTEXT_MENU_ID } from '@/components/grid/constants'
|
||||
import type { SupaRow } from '@/components/grid/types'
|
||||
import { useIsTableFilterBarEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useTableEditorStateSnapshot } from '@/state/table-editor'
|
||||
import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table'
|
||||
|
||||
type RowContextMenuProps = {
|
||||
rows: SupaRow[]
|
||||
type RowContextMenuContentProps = {
|
||||
row: SupaRow
|
||||
}
|
||||
|
||||
type RowContextMenuItemProps = ItemParams<{ rowIdx: number }, string>
|
||||
|
||||
export const RowContextMenu = ({ rows }: RowContextMenuProps) => {
|
||||
export const RowContextMenuContent = ({ row }: RowContextMenuContentProps) => {
|
||||
const tableEditorSnap = useTableEditorStateSnapshot()
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
const isTableFilterBarEnabled = useIsTableFilterBarEnabled()
|
||||
const { deleteRows } = useTableRowOperations()
|
||||
|
||||
function onDeleteRow(p: RowContextMenuItemProps) {
|
||||
const rowIdx = p.props?.rowIdx
|
||||
if (rowIdx === undefined || rowIdx === null) return
|
||||
|
||||
const row = rows[rowIdx]
|
||||
const onDeleteRow = useCallback(() => {
|
||||
if (!row) {
|
||||
toast.error('Row not found')
|
||||
return
|
||||
}
|
||||
|
||||
deleteRows({ rows: [row], table: snap.originalTable })
|
||||
}
|
||||
}, [row, snap.originalTable, deleteRows])
|
||||
|
||||
function onEditRowClick(p: RowContextMenuItemProps) {
|
||||
const rowIdx = p.props?.rowIdx
|
||||
if (rowIdx === undefined || rowIdx === null) return
|
||||
|
||||
const row = rows[rowIdx]
|
||||
const onEditRowClick = useCallback(() => {
|
||||
tableEditorSnap.onEditRow(row)
|
||||
}
|
||||
}, [row, tableEditorSnap])
|
||||
|
||||
const onCopyCellContent = useCallback(
|
||||
(p: RowContextMenuItemProps) => {
|
||||
const rowIdx = p.props?.rowIdx
|
||||
if (!snap.selectedCellPosition || rowIdx === undefined || rowIdx === null) return
|
||||
const onCopyCellContent = useCallback(() => {
|
||||
if (!snap.selectedCellPosition) return
|
||||
|
||||
const row = rows[rowIdx]
|
||||
const columnKey = snap.gridColumns[snap.selectedCellPosition.idx as number].key
|
||||
const columnKey = snap.gridColumns[snap.selectedCellPosition.idx as number].key
|
||||
const value = row[columnKey]
|
||||
const text = formatClipboardValue(value)
|
||||
|
||||
const value = row[columnKey]
|
||||
const text = formatClipboardValue(value)
|
||||
copyToClipboard(text)
|
||||
toast.success('Copied cell value to clipboard')
|
||||
}, [row, snap.gridColumns, snap.selectedCellPosition])
|
||||
|
||||
copyToClipboard(text)
|
||||
toast.success('Copied cell value to clipboard')
|
||||
},
|
||||
[rows, snap.gridColumns, snap.selectedCellPosition]
|
||||
)
|
||||
const onCopyRowContent = useCallback(() => {
|
||||
copyToClipboard(JSON.stringify(row))
|
||||
toast.success('Copied row to clipboard')
|
||||
}, [row])
|
||||
|
||||
const onCopyRowContent = useCallback(
|
||||
(p: RowContextMenuItemProps) => {
|
||||
const rowIdx = p.props?.rowIdx
|
||||
if (rowIdx === undefined || rowIdx === null) return
|
||||
const getRowAndColumn = useCallback(() => {
|
||||
if (!snap.selectedCellPosition) return null
|
||||
|
||||
const row = rows[rowIdx]
|
||||
copyToClipboard(JSON.stringify(row))
|
||||
toast.success('Copied row to clipboard')
|
||||
},
|
||||
[rows]
|
||||
)
|
||||
const column = snap.gridColumns[snap.selectedCellPosition.idx as number]
|
||||
if (!row || !column) return null
|
||||
|
||||
const getRowAndColumn = useCallback(
|
||||
(rowIdx: number | undefined | null) => {
|
||||
if (!snap.selectedCellPosition || rowIdx === undefined || rowIdx === null) {
|
||||
return null
|
||||
}
|
||||
return { row, column }
|
||||
}, [row, snap.selectedCellPosition, snap.gridColumns])
|
||||
|
||||
const row = rows[rowIdx]
|
||||
const column = snap.gridColumns[snap.selectedCellPosition.idx as number]
|
||||
const onFilterByValue = useCallback(() => {
|
||||
const result = getRowAndColumn()
|
||||
if (!result) return
|
||||
|
||||
if (!row || !column) return null
|
||||
const { row, column } = result
|
||||
const newFilter = buildFilterFromCellValue(column.key, row[column.key])
|
||||
snap.setFilters([...snap.filters, newFilter])
|
||||
|
||||
return { row, column }
|
||||
},
|
||||
[rows, snap.selectedCellPosition, snap.gridColumns]
|
||||
)
|
||||
const displayValue = newFilter.value === 'null' ? 'NULL' : newFilter.value
|
||||
toast.success(`Filtering ${column.name} by ${displayValue}`)
|
||||
}, [getRowAndColumn, snap])
|
||||
|
||||
const onFilterByValue = useCallback(
|
||||
(p: RowContextMenuItemProps) => {
|
||||
const result = getRowAndColumn(p.props?.rowIdx)
|
||||
if (!result) return
|
||||
const isFilterByValueVisible = useCallback(() => {
|
||||
if (!isTableFilterBarEnabled) return false
|
||||
|
||||
const { row, column } = result
|
||||
const newFilter = buildFilterFromCellValue(column.key, row[column.key])
|
||||
snap.setFilters([...snap.filters, newFilter])
|
||||
const result = getRowAndColumn()
|
||||
if (!result) return false
|
||||
|
||||
const displayValue = newFilter.value === 'null' ? 'NULL' : newFilter.value
|
||||
toast.success(`Filtering ${column.name} by ${displayValue}`)
|
||||
},
|
||||
[getRowAndColumn, snap]
|
||||
)
|
||||
|
||||
const isFilterByValueHidden = useCallback(
|
||||
({ props: itemProps }: { props?: { rowIdx: number } }) => {
|
||||
if (!isTableFilterBarEnabled) return true
|
||||
|
||||
const result = getRowAndColumn(itemProps?.rowIdx)
|
||||
if (!result) return true
|
||||
|
||||
return isComplexValue(result.row[result.column.key])
|
||||
},
|
||||
[isTableFilterBarEnabled, getRowAndColumn]
|
||||
)
|
||||
return !isComplexValue(result.row[result.column.key])
|
||||
}, [isTableFilterBarEnabled, getRowAndColumn])
|
||||
|
||||
return (
|
||||
<Menu id={ROW_CONTEXT_MENU_ID} animation={false} className="!min-w-36">
|
||||
<Item onClick={onCopyCellContent}>
|
||||
<ContextMenuContent className="!min-w-36">
|
||||
<ContextMenuItem_Shadcn_ className="gap-x-2" onSelect={onCopyCellContent}>
|
||||
<Copy size={12} />
|
||||
<span className="ml-2 text-xs">Copy cell</span>
|
||||
</Item>
|
||||
<Item onClick={onCopyRowContent}>
|
||||
<span className="text-xs">Copy cell</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
<ContextMenuItem_Shadcn_ className="gap-x-2" onSelect={onCopyRowContent}>
|
||||
<Copy size={12} />
|
||||
<span className="ml-2 text-xs">Copy row</span>
|
||||
</Item>
|
||||
<Item onClick={onFilterByValue} hidden={isFilterByValueHidden}>
|
||||
<ListFilter size={12} />
|
||||
<span className="ml-2 text-xs">Filter by value</span>
|
||||
</Item>
|
||||
|
||||
{/* We can't just wrap this entire section in a fragment conditional
|
||||
on snap.editable because of a bug in react-contexify. Only the
|
||||
top-level children of Menu are cloned with the necessary bound props,
|
||||
so Items must be direct children of Menu:
|
||||
https://github.com/fkhadra/react-contexify/blob/8d9fc63ac13040d3250e8eefd593d50a3ebdd1e6/src/components/Menu.tsx#L295
|
||||
*/}
|
||||
{snap.editable && <DialogSectionSeparator className="my-1.5" />}
|
||||
<Item onClick={onEditRowClick} hidden={!snap.editable} data="edit">
|
||||
<Edit size={12} />
|
||||
<span className="ml-2 text-xs">Edit row</span>
|
||||
</Item>
|
||||
{snap.editable && <DialogSectionSeparator className="my-1.5" />}
|
||||
<Item onClick={onDeleteRow} hidden={!snap.editable} data="delete">
|
||||
<Trash size={12} />
|
||||
<span className="ml-2 text-xs">Delete row</span>
|
||||
</Item>
|
||||
</Menu>
|
||||
<span className="text-xs">Copy row</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
{isFilterByValueVisible() && (
|
||||
<ContextMenuItem_Shadcn_ className="gap-x-2" onSelect={onFilterByValue}>
|
||||
<ListFilter size={12} />
|
||||
<span className="text-xs">Filter by value</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
)}
|
||||
{snap.editable && (
|
||||
<>
|
||||
<ContextMenuSeparator_Shadcn_ />
|
||||
<ContextMenuItem_Shadcn_ className="gap-x-2" onSelect={onEditRowClick}>
|
||||
<Edit size={12} />
|
||||
<span className="text-xs">Edit row</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
<ContextMenuSeparator_Shadcn_ />
|
||||
<ContextMenuItem_Shadcn_ className="gap-x-2" onSelect={onDeleteRow}>
|
||||
<Trash size={12} />
|
||||
<span className="text-xs">Delete row</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
)
|
||||
}
|
||||
@@ -12,5 +12,3 @@ const RLS_ACKNOWLEDGED_KEY = 'supabase-acknowledge-rls-warning'
|
||||
|
||||
export const rlsAcknowledgedKey = (tableID?: string | number) =>
|
||||
`${RLS_ACKNOWLEDGED_KEY}-${String(tableID)}`
|
||||
|
||||
export const ROW_CONTEXT_MENU_ID = 'row-context-menu-id'
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useIsQueueOperationsEnabled } from 'components/interfaces/Account/Preferences/useDashboardSettings'
|
||||
|
||||
import { useOperationQueueActions } from './useOperationQueueActions'
|
||||
import { useIsQueueOperationsEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings'
|
||||
import { tableRowKeys } from '@/data/table-rows/keys'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useHotKey } from '@/hooks/ui/useHotKey'
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { Card, Form_Shadcn_ } from 'ui'
|
||||
@@ -8,6 +6,8 @@ import * as z from 'zod'
|
||||
|
||||
import { DashboardToggle } from './DashboardToggle'
|
||||
import { useIsInlineEditorSetting, useIsQueueOperationsSetting } from './useDashboardSettings'
|
||||
import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
|
||||
const DashboardSettingsSchema = z.object({
|
||||
inlineEditorEnabled: z.boolean(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
|
||||
export const useIsInlineEditorSetting = () => {
|
||||
const [inlineEditorEnabled, setInlineEditorEnabled] = useLocalStorageQuery(
|
||||
|
||||
@@ -3,20 +3,6 @@ import { loadStripe, StripeAddressElement, StripeElementsOptions } from '@stripe
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useFlag } from 'common'
|
||||
import {
|
||||
getAddressElementAppearanceOptions,
|
||||
STRIPE_ELEMENT_FONTS,
|
||||
} from 'components/interfaces/Billing/Payment/Payment.utils'
|
||||
import { BillingCustomerDataForm } from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm'
|
||||
import { useBillingCustomerDataForm } from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm'
|
||||
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
|
||||
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
|
||||
import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query'
|
||||
import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation'
|
||||
import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { IS_PLATFORM, STRIPE_PUBLIC_KEY } from 'lib/constants'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -33,6 +19,21 @@ import {
|
||||
} from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import {
|
||||
getAddressElementAppearanceOptions,
|
||||
STRIPE_ELEMENT_FONTS,
|
||||
} from '@/components/interfaces/Billing/Payment/Payment.utils'
|
||||
import { BillingCustomerDataForm } from '@/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm'
|
||||
import { useBillingCustomerDataForm } from '@/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm'
|
||||
import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organization-customer-profile-query'
|
||||
import { useOrganizationCustomerProfileUpdateMutation } from '@/data/organizations/organization-customer-profile-update-mutation'
|
||||
import { useOrganizationTaxIdQuery } from '@/data/organizations/organization-tax-id-query'
|
||||
import { useOrganizationTaxIdUpdateMutation } from '@/data/organizations/organization-tax-id-update-mutation'
|
||||
import { invalidateOrganizationsQuery } from '@/data/organizations/organizations-query'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { IS_PLATFORM, STRIPE_PUBLIC_KEY } from '@/lib/constants'
|
||||
|
||||
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
|
||||
|
||||
export function UpdateBillingAddressModal() {
|
||||
|
||||
+1
-1
@@ -170,7 +170,7 @@ export const PolicyDetailsV2 = ({
|
||||
>
|
||||
<Command_Shadcn_>
|
||||
<CommandInput_Shadcn_ placeholder="Find a table..." />
|
||||
<CommandList_Shadcn_>
|
||||
<CommandList_Shadcn_ onWheel={(event) => event.stopPropagation()}>
|
||||
<CommandEmpty_Shadcn_>No tables found</CommandEmpty_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
<ScrollArea className={(tables ?? []).length > 7 ? 'h-[200px]' : ''}>
|
||||
|
||||
@@ -17,21 +17,31 @@ import {
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
Switch,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
|
||||
import {
|
||||
PageSection,
|
||||
PageSectionContent,
|
||||
PageSectionDescription,
|
||||
PageSectionMeta,
|
||||
PageSectionSummary,
|
||||
PageSectionTitle,
|
||||
} from 'ui-patterns/PageSection'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { isSmtpEnabled } from '../SmtpForm/SmtpForm.utils'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import NoPermission from '@/components/ui/NoPermission'
|
||||
import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
export const RateLimits = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
@@ -65,7 +75,33 @@ export const RateLimits = () => {
|
||||
const canUpdateAnonymousUsersRateLimit = authConfig?.EXTERNAL_ANONYMOUS_USERS_ENABLED
|
||||
const canUpdateWeb3RateLimit = authConfig?.EXTERNAL_WEB3_SOLANA_ENABLED
|
||||
|
||||
const FormSchema = z.object({
|
||||
const IPForwardingFormSchema = z.object({
|
||||
SECURITY_SB_FORWARDED_FOR_ENABLED: z.coerce.boolean(),
|
||||
})
|
||||
|
||||
const ipForwardingForm = useForm<z.infer<typeof IPForwardingFormSchema>>({
|
||||
resolver: zodResolver(IPForwardingFormSchema),
|
||||
defaultValues: {
|
||||
SECURITY_SB_FORWARDED_FOR_ENABLED: false,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmitIPForwardingForm = (data: z.infer<typeof IPForwardingFormSchema>) => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
|
||||
const payload: Partial<z.infer<typeof IPForwardingFormSchema>> = {}
|
||||
|
||||
if (data.SECURITY_SB_FORWARDED_FOR_ENABLED !== authConfig?.SECURITY_SB_FORWARDED_FOR_ENABLED) {
|
||||
payload.SECURITY_SB_FORWARDED_FOR_ENABLED = data.SECURITY_SB_FORWARDED_FOR_ENABLED
|
||||
}
|
||||
|
||||
updateAuthConfig(
|
||||
{ projectRef, config: payload },
|
||||
{ onSuccess: () => ipForwardingForm.reset(data) }
|
||||
)
|
||||
}
|
||||
|
||||
const RateLimitFormSchema = z.object({
|
||||
RATE_LIMIT_TOKEN_REFRESH: z.coerce
|
||||
.number()
|
||||
.min(0, 'Must be not be lower than 0')
|
||||
@@ -96,8 +132,8 @@ export const RateLimits = () => {
|
||||
.max(32767, 'Must not be more than 32,767 an hour'),
|
||||
})
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
const rateLimitForm = useForm<z.infer<typeof RateLimitFormSchema>>({
|
||||
resolver: zodResolver(RateLimitFormSchema),
|
||||
defaultValues: {
|
||||
RATE_LIMIT_TOKEN_REFRESH: 0,
|
||||
RATE_LIMIT_VERIFY: 0,
|
||||
@@ -109,10 +145,10 @@ export const RateLimits = () => {
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = (data: z.infer<typeof FormSchema>) => {
|
||||
const onSubmitRateLimitForm = (data: z.infer<typeof RateLimitFormSchema>) => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
|
||||
const payload: Partial<z.infer<typeof FormSchema>> = {}
|
||||
const payload: Partial<z.infer<typeof RateLimitFormSchema>> = {}
|
||||
const params = [
|
||||
'RATE_LIMIT_TOKEN_REFRESH',
|
||||
'RATE_LIMIT_VERIFY',
|
||||
@@ -126,12 +162,15 @@ export const RateLimits = () => {
|
||||
if (data[param] !== authConfig?.[param]) payload[param] = data[param]
|
||||
})
|
||||
|
||||
updateAuthConfig({ projectRef, config: payload }, { onSuccess: () => form.reset(data) })
|
||||
updateAuthConfig(
|
||||
{ projectRef, config: payload },
|
||||
{ onSuccess: () => rateLimitForm.reset(data) }
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
form.reset({
|
||||
rateLimitForm.reset({
|
||||
RATE_LIMIT_TOKEN_REFRESH: authConfig.RATE_LIMIT_TOKEN_REFRESH,
|
||||
RATE_LIMIT_VERIFY: authConfig.RATE_LIMIT_VERIFY,
|
||||
RATE_LIMIT_EMAIL_SENT: authConfig.RATE_LIMIT_EMAIL_SENT,
|
||||
@@ -140,6 +179,10 @@ export const RateLimits = () => {
|
||||
RATE_LIMIT_OTP: authConfig.RATE_LIMIT_OTP,
|
||||
RATE_LIMIT_WEB3: authConfig.RATE_LIMIT_WEB3 ?? 0,
|
||||
})
|
||||
|
||||
ipForwardingForm.reset({
|
||||
SECURITY_SB_FORWARDED_FOR_ENABLED: authConfig.SECURITY_SB_FORWARDED_FOR_ENABLED,
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSuccess])
|
||||
@@ -175,385 +218,457 @@ export const RateLimits = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="RATE_LIMIT_EMAIL_SENT"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Rate limit for sending emails"
|
||||
description="Number of emails that can be sent per hour from your project"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>emails/h</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig || !canUpdateEmailLimit}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateConfig || !canUpdateEmailLimit ? (
|
||||
<TooltipContent side="left" className="w-80 p-4">
|
||||
{!authConfig.EXTERNAL_EMAIL_ENABLED ? (
|
||||
<>
|
||||
<p className="font-medium">
|
||||
Email-based logins are not enabled for your project
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
Enable email-based logins to update this rate limit
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild type="default" size="tiny">
|
||||
<Link href={`/project/${projectRef}/auth/providers`}>
|
||||
View auth providers
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium">
|
||||
Custom SMTP provider is required to update this configuration
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
The built-in email service has a fixed rate limit. You will need
|
||||
to set up your own custom SMTP provider to update your email rate
|
||||
limit
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild type="default" size="tiny">
|
||||
<Link href={`/project/${projectRef}/auth/smtp`}>
|
||||
View SMTP settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<>
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
<Form_Shadcn_ {...rateLimitForm}>
|
||||
<form onSubmit={rateLimitForm.handleSubmit(onSubmitRateLimitForm)}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={rateLimitForm.control}
|
||||
name="RATE_LIMIT_EMAIL_SENT"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Rate limit for sending emails"
|
||||
description="Number of emails that can be sent per hour from your project"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>emails/h</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig || !canUpdateEmailLimit}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateConfig || !canUpdateEmailLimit ? (
|
||||
<TooltipContent side="left" className="w-80 p-4">
|
||||
{!authConfig.EXTERNAL_EMAIL_ENABLED ? (
|
||||
<>
|
||||
<p className="font-medium">
|
||||
Email-based logins are not enabled for your project
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
Enable email-based logins to update this rate limit
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild type="default" size="tiny">
|
||||
<Link href={`/project/${projectRef}/auth/providers`}>
|
||||
View auth providers
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium">
|
||||
Custom SMTP provider is required to update this configuration
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
The built-in email service has a fixed rate limit. You will need
|
||||
to set up your own custom SMTP provider to update your email
|
||||
rate limit
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild type="default" size="tiny">
|
||||
<Link href={`/project/${projectRef}/auth/smtp`}>
|
||||
View SMTP settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="RATE_LIMIT_SMS_SENT"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Rate limit for sending SMS messages"
|
||||
description="Number of SMS messages that can be sent per hour from your project"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>sms/h</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig || !canUpdateSMSRateLimit}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateConfig || !canUpdateSMSRateLimit ? (
|
||||
<TooltipContent side="left" className="w-80 p-4">
|
||||
<p className="font-medium">
|
||||
Phone-based logins are not enabled for your project
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
Enable phone-based logins to update this rate limit
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild type="default" size="tiny">
|
||||
<Link href={`/project/${projectRef}/auth/providers`}>
|
||||
View auth providers
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={rateLimitForm.control}
|
||||
name="RATE_LIMIT_SMS_SENT"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Rate limit for sending SMS messages"
|
||||
description="Number of SMS messages that can be sent per hour from your project"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>sms/h</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig || !canUpdateSMSRateLimit}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateConfig || !canUpdateSMSRateLimit ? (
|
||||
<TooltipContent side="left" className="w-80 p-4">
|
||||
<p className="font-medium">
|
||||
Phone-based logins are not enabled for your project
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
Enable phone-based logins to update this rate limit
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild type="default" size="tiny">
|
||||
<Link href={`/project/${projectRef}/auth/providers`}>
|
||||
View auth providers
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="RATE_LIMIT_TOKEN_REFRESH"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Rate limit for token refreshes"
|
||||
description="Number of sessions that can be refreshed in a 5 minute interval per IP address"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>requests/5 min</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateConfig && (
|
||||
<TooltipContent side="left" className="w-80 p-4">
|
||||
<p className="font-medium">
|
||||
You don't have permission to update this setting
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
You need additional permissions to update auth configuration settings
|
||||
</p>
|
||||
</TooltipContent>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={rateLimitForm.control}
|
||||
name="RATE_LIMIT_TOKEN_REFRESH"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Rate limit for token refreshes"
|
||||
description="Number of sessions that can be refreshed in a 5 minute interval per IP address"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>requests/5 min</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateConfig && (
|
||||
<TooltipContent side="left" className="w-80 p-4">
|
||||
<p className="font-medium">
|
||||
You don't have permission to update this setting
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
You need additional permissions to update auth configuration
|
||||
settings
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
{rateLimitForm.watch('RATE_LIMIT_TOKEN_REFRESH') > 0 && (
|
||||
<p className="text-foreground-lighter text-sm mt-2">
|
||||
{rateLimitForm.watch('RATE_LIMIT_TOKEN_REFRESH') * 12} requests per hour
|
||||
</p>
|
||||
)}
|
||||
</Tooltip>
|
||||
{form.watch('RATE_LIMIT_TOKEN_REFRESH') > 0 && (
|
||||
<p className="text-foreground-lighter text-sm mt-2">
|
||||
{form.watch('RATE_LIMIT_TOKEN_REFRESH') * 12} requests per hour
|
||||
</p>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="RATE_LIMIT_VERIFY"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Rate limit for token verifications"
|
||||
description="Number of OTP/Magic link verifications that can be made in a 5 minute interval per IP address"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>requests/5 min</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateConfig && (
|
||||
<TooltipContent side="left" className="w-80 p-4">
|
||||
<p className="font-medium">
|
||||
You don't have permission to update this setting
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
You need additional permissions to update auth configuration settings
|
||||
</p>
|
||||
</TooltipContent>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={rateLimitForm.control}
|
||||
name="RATE_LIMIT_VERIFY"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Rate limit for token verifications"
|
||||
description="Number of OTP/Magic link verifications that can be made in a 5 minute interval per IP address"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>requests/5 min</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateConfig && (
|
||||
<TooltipContent side="left" className="w-80 p-4">
|
||||
<p className="font-medium">
|
||||
You don't have permission to update this setting
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
You need additional permissions to update auth configuration
|
||||
settings
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
{rateLimitForm.watch('RATE_LIMIT_VERIFY') > 0 && (
|
||||
<p className="text-foreground-lighter text-sm mt-2">
|
||||
{rateLimitForm.watch('RATE_LIMIT_VERIFY') * 12} requests per hour
|
||||
</p>
|
||||
)}
|
||||
</Tooltip>
|
||||
{form.watch('RATE_LIMIT_VERIFY') > 0 && (
|
||||
<p className="text-foreground-lighter text-sm mt-2">
|
||||
{form.watch('RATE_LIMIT_VERIFY') * 12} requests per hour
|
||||
</p>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="RATE_LIMIT_ANONYMOUS_USERS"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Rate limit for anonymous users"
|
||||
description="Number of anonymous sign-ins that can be made per hour per IP address"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>requests/h</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig || !canUpdateAnonymousUsersRateLimit}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateConfig || !canUpdateAnonymousUsersRateLimit ? (
|
||||
<TooltipContent side="left" className="w-80 p-4">
|
||||
<p className="font-medium">
|
||||
Anonymous sign-ins are not enabled for your project. Enable them to
|
||||
control this rate limit.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild type="default" size="tiny">
|
||||
<Link href={`/project/${projectRef}/auth/providers`}>
|
||||
View auth settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={rateLimitForm.control}
|
||||
name="RATE_LIMIT_ANONYMOUS_USERS"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Rate limit for anonymous users"
|
||||
description="Number of anonymous sign-ins that can be made per hour per IP address"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>requests/h</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig || !canUpdateAnonymousUsersRateLimit}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateConfig || !canUpdateAnonymousUsersRateLimit ? (
|
||||
<TooltipContent side="left" className="w-80 p-4">
|
||||
<p className="font-medium">
|
||||
Anonymous sign-ins are not enabled for your project. Enable them to
|
||||
control this rate limit.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild type="default" size="tiny">
|
||||
<Link href={`/project/${projectRef}/auth/providers`}>
|
||||
View auth settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="RATE_LIMIT_OTP"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Rate limit for sign-ups and sign-ins"
|
||||
description="Number of sign-up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users)"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>requests/5 min</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateConfig && (
|
||||
<TooltipContent side="left" className="w-80 p-4">
|
||||
<p className="font-medium">
|
||||
You don't have permission to update this setting
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
You need additional permissions to update auth configuration settings
|
||||
</p>
|
||||
</TooltipContent>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={rateLimitForm.control}
|
||||
name="RATE_LIMIT_OTP"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Rate limit for sign-ups and sign-ins"
|
||||
description="Number of sign-up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users)"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>requests/5 min</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateConfig && (
|
||||
<TooltipContent side="left" className="w-80 p-4">
|
||||
<p className="font-medium">
|
||||
You don't have permission to update this setting
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
You need additional permissions to update auth configuration
|
||||
settings
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
{rateLimitForm.watch('RATE_LIMIT_OTP') > 0 && (
|
||||
<p className="text-foreground-lighter text-sm mt-2">
|
||||
{rateLimitForm.watch('RATE_LIMIT_OTP') * 12} requests per hour
|
||||
</p>
|
||||
)}
|
||||
</Tooltip>
|
||||
{form.watch('RATE_LIMIT_OTP') > 0 && (
|
||||
<p className="text-foreground-lighter text-sm mt-2">
|
||||
{form.watch('RATE_LIMIT_OTP') * 12} requests per hour
|
||||
</p>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="RATE_LIMIT_WEB3"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Rate limit for Web3 sign-ups and sign-ins"
|
||||
description="Number of Web3 sign-up or sign-in requests that can be made per IP address in 5 minutes"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>requests/5 min</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig || !canUpdateWeb3RateLimit}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateConfig || !canUpdateWeb3RateLimit ? (
|
||||
<TooltipContent side="left" className="w-80 p-4">
|
||||
<p className="font-medium">
|
||||
Web3 auth provider is not enabled for this project. Enable it to
|
||||
control this rate limit.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild type="default" size="tiny">
|
||||
<Link href={`/project/${projectRef}/auth/providers`}>
|
||||
View Auth provider settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={rateLimitForm.control}
|
||||
name="RATE_LIMIT_WEB3"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Rate limit for Web3 sign-ups and sign-ins"
|
||||
description="Number of Web3 sign-up or sign-in requests that can be made per IP address in 5 minutes"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>requests/5 min</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig || !canUpdateWeb3RateLimit}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateConfig || !canUpdateWeb3RateLimit ? (
|
||||
<TooltipContent side="left" className="w-80 p-4">
|
||||
<p className="font-medium">
|
||||
Web3 auth provider is not enabled for this project. Enable it to
|
||||
control this rate limit.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild type="default" size="tiny">
|
||||
<Link href={`/project/${projectRef}/auth/providers`}>
|
||||
View Auth provider settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-end space-x-2">
|
||||
{form.formState.isDirty && (
|
||||
<Button type="default" onClick={() => form.reset()}>
|
||||
Cancel
|
||||
<CardFooter className="justify-end space-x-2">
|
||||
{rateLimitForm.formState.isDirty && (
|
||||
<Button type="default" onClick={() => rateLimitForm.reset()}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={
|
||||
!canUpdateConfig || isUpdatingConfig || !rateLimitForm.formState.isDirty
|
||||
}
|
||||
loading={isUpdatingConfig}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={!canUpdateConfig || isUpdatingConfig || !form.formState.isDirty}
|
||||
loading={isUpdatingConfig}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>IP Address Forwarding</PageSectionTitle>
|
||||
<PageSectionDescription>
|
||||
Control how Auth determines source IP address for rate limiting.
|
||||
</PageSectionDescription>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Form_Shadcn_ {...ipForwardingForm}>
|
||||
<form onSubmit={ipForwardingForm.handleSubmit(onSubmitIPForwardingForm)}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={ipForwardingForm.control}
|
||||
name="SECURITY_SB_FORWARDED_FOR_ENABLED"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Enable IP address forwarding"
|
||||
description=<>
|
||||
Clients can forward end-user IP addresses to Auth for rate limiting when
|
||||
using secret API keys.{' '}
|
||||
<InlineLink
|
||||
href={`${DOCS_URL}/guides/auth/rate-limits#ip-address-forwarding`}
|
||||
>
|
||||
Learn more
|
||||
</InlineLink>
|
||||
</>
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => field.onChange(value)}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end space-x-2">
|
||||
{ipForwardingForm.formState.isDirty && (
|
||||
<Button type="default" onClick={() => ipForwardingForm.reset()}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={
|
||||
!canUpdateConfig || isUpdatingConfig || !ipForwardingForm.formState.isDirty
|
||||
}
|
||||
loading={isUpdatingConfig}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { toast } from 'sonner'
|
||||
import { render } from 'tests/helpers'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AddNewURLModal } from './AddNewURLModal'
|
||||
import { render } from '@/tests/helpers'
|
||||
|
||||
const { mutateMock, useAuthConfigUpdateMutationMock } = vi.hoisted(() => ({
|
||||
mutateMock: vi.fn(),
|
||||
@@ -20,7 +20,7 @@ vi.mock(import('common'), async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('data/auth/auth-config-update-mutation', () => ({
|
||||
vi.mock('@/data/auth/auth-config-update-mutation', () => ({
|
||||
useAuthConfigUpdateMutation: useAuthConfigUpdateMutationMock,
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Appearance, CustomFontSource } from '@stripe/stripe-js'
|
||||
import { CUSTOM_FONT_BOOK_DATA_URL } from 'fonts/stripe-fonts'
|
||||
|
||||
import { CUSTOM_FONT_BOOK_DATA_URL } from '@/fonts/stripe-fonts'
|
||||
|
||||
/**
|
||||
* Custom font for Stripe Elements iframes.
|
||||
|
||||
+26
-2
@@ -85,12 +85,16 @@ export const NewPaymentMethodElement = forwardRef(
|
||||
currentAddress,
|
||||
currentTaxId,
|
||||
customerName,
|
||||
onAddressChange,
|
||||
onTaxIdChange,
|
||||
}: {
|
||||
email?: string | null | undefined
|
||||
readOnly: boolean
|
||||
currentAddress?: CustomerAddress | null
|
||||
currentTaxId?: CustomerTaxId | null
|
||||
customerName?: string | undefined
|
||||
onAddressChange?: (address: CustomerAddress) => void
|
||||
onTaxIdChange?: (taxId: CustomerTaxId | null) => void
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -123,13 +127,25 @@ export const NewPaymentMethodElement = forwardRef(
|
||||
form.setValue('tax_id_name', name)
|
||||
}
|
||||
|
||||
const { tax_id_name } = form.watch()
|
||||
const { tax_id_name, tax_id_value } = form.watch()
|
||||
const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name)
|
||||
|
||||
const [purchasingAsBusiness, setPurchasingAsBusiness] = useState(currentTaxId != null)
|
||||
const [stripeAddress, setStripeAddress] = useState<
|
||||
StripeAddressElementChangeEvent['value'] | undefined
|
||||
>(undefined)
|
||||
useEffect(() => {
|
||||
if (!onTaxIdChange) return
|
||||
if (purchasingAsBusiness && selectedTaxId && tax_id_value) {
|
||||
onTaxIdChange({
|
||||
country: getEffectiveTaxCountry(selectedTaxId),
|
||||
type: selectedTaxId.type,
|
||||
value: tax_id_value,
|
||||
})
|
||||
} else {
|
||||
onTaxIdChange(null)
|
||||
}
|
||||
}, [purchasingAsBusiness, selectedTaxId, tax_id_value, onTaxIdChange])
|
||||
|
||||
const availableTaxIds = useMemo(() => {
|
||||
const country = stripeAddress?.address.country || null
|
||||
@@ -272,7 +288,15 @@ export const NewPaymentMethodElement = forwardRef(
|
||||
options={addressOptions}
|
||||
// Force reload after changing purchasingAsBusiness setting, it seems like the element does not reload otherwise
|
||||
key={`address-elements-${purchasingAsBusiness}`}
|
||||
onChange={(evt) => setStripeAddress(evt.value)}
|
||||
onChange={(evt) => {
|
||||
setStripeAddress(evt.value)
|
||||
if (onAddressChange && evt.complete) {
|
||||
onAddressChange({
|
||||
...evt.value.address,
|
||||
line2: evt.value.address.line2 || undefined,
|
||||
})
|
||||
}
|
||||
}}
|
||||
onReady={() => setFullyLoaded(true)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -254,8 +254,9 @@ export const CreateBranchModal = () => {
|
||||
if (form.getValues('gitBranchName') !== branchName) return
|
||||
setIsGitBranchValid(false)
|
||||
form.setError('gitBranchName', {
|
||||
...error,
|
||||
message: `Unable to find branch "${branchName}" in ${repoOwner}/${repoName}`,
|
||||
message:
|
||||
error?.message ??
|
||||
`Unable to find branch "${branchName}" in ${repoOwner}/${repoName}`,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { type DatabaseExtension } from 'data/database-extensions/database-extensions-query'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -31,6 +30,7 @@ import * as z from 'zod'
|
||||
import { extensionsWithRecommendedSchemas } from './Extensions.constants'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { useDatabaseExtensionEnableMutation } from '@/data/database-extensions/database-extension-enable-mutation'
|
||||
import { type DatabaseExtension } from '@/data/database-extensions/database-extensions-query'
|
||||
import { useSchemasQuery } from '@/data/database/schemas-query'
|
||||
import { useIsOrioleDb, useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useProtectedSchemas } from '@/hooks/useProtectedSchemas'
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { SupportCategories } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { SupportLink } from 'components/interfaces/Support/SupportLink'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
|
||||
import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { ChevronRight, CpuIcon, Lock, Microchip } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
@@ -37,6 +27,16 @@ import {
|
||||
import { BillingChangeBadge } from '../ui/BillingChangeBadge'
|
||||
import FormMessage from '../ui/FormMessage'
|
||||
import { NoticeBar } from '../ui/NoticeBar'
|
||||
import { SupportLink } from '@/components/interfaces/Support/SupportLink'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
|
||||
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { getCloudProviderArchitecture } from '@/lib/cloudprovider-utils'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
const INITIALLY_VISIBLE_COUNT = 6
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@ import { ErrorMatcher } from './ErrorMatcher'
|
||||
import { ConnectionTimeoutError } from '@/types/api-errors'
|
||||
import { ResponseError } from '@/types/base'
|
||||
|
||||
vi.mock('lib/telemetry/track', () => ({ useTrack: () => vi.fn() }))
|
||||
vi.mock('state/ai-assistant-state', () => ({
|
||||
vi.mock('@/lib/telemetry/track', () => ({ useTrack: () => vi.fn() }))
|
||||
vi.mock('@/state/ai-assistant-state', () => ({
|
||||
useAiAssistantStateSnapshot: () => ({ newChat: vi.fn() }),
|
||||
}))
|
||||
vi.mock('state/sidebar-manager-state', () => ({
|
||||
vi.mock('@/state/sidebar-manager-state', () => ({
|
||||
useSidebarManagerSnapshot: () => ({ openSidebar: vi.fn() }),
|
||||
}))
|
||||
vi.mock('components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider', () => ({
|
||||
vi.mock('@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider', () => ({
|
||||
SIDEBAR_KEYS: { AI_ASSISTANT: 'ai-assistant' },
|
||||
}))
|
||||
vi.mock('./RestartProjectDialog', () => ({
|
||||
|
||||
+1
@@ -291,6 +291,7 @@ export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTester
|
||||
<Sheet open={visible} onOpenChange={onClose}>
|
||||
<SheetContent
|
||||
size="default"
|
||||
hasOverlay={false}
|
||||
className="flex flex-col gap-0 p-0"
|
||||
onPointerDownOutside={(e) => {
|
||||
// react-resizable-panels v4 registers document-level capture-phase pointer
|
||||
|
||||
+2
-2
@@ -1,7 +1,5 @@
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import type { ChartIntervals } from 'types'
|
||||
import { Button } from 'ui'
|
||||
import { Chart, ChartActions, ChartLoadingState, ChartMetric } from 'ui-patterns/Chart'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
@@ -24,6 +22,8 @@ import {
|
||||
} from './EdgeFunctionOverview.utils'
|
||||
import type { InvocationChartDatum, InvocationUpdateAnnotation } from './EdgeFunctionOverview.utils'
|
||||
import { toAlertError } from './EdgeFunctionRecentErrors.utils'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import type { ChartIntervals } from '@/types'
|
||||
|
||||
interface EdgeFunctionInvocationsSectionProps {
|
||||
interval: string
|
||||
|
||||
+9
-9
@@ -1,14 +1,5 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { IS_PLATFORM, useParams } from 'common'
|
||||
import { useUnifiedLogsPreview } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import {
|
||||
FunctionsCombinedStatsVariables,
|
||||
useFunctionsCombinedStatsQuery,
|
||||
} from 'data/analytics/functions-combined-stats-query'
|
||||
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
|
||||
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@@ -29,6 +20,15 @@ import type { EdgeFunctionChartRawDatum } from './EdgeFunctionOverview.utils'
|
||||
import { EdgeFunctionPerformanceSection } from './EdgeFunctionPerformanceSection'
|
||||
import { EdgeFunctionRecentErrors } from './EdgeFunctionRecentErrors'
|
||||
import { EdgeFunctionUsageSection } from './EdgeFunctionUsageSection'
|
||||
import { useUnifiedLogsPreview } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import NoPermission from '@/components/ui/NoPermission'
|
||||
import {
|
||||
FunctionsCombinedStatsVariables,
|
||||
useFunctionsCombinedStatsQuery,
|
||||
} from '@/data/analytics/functions-combined-stats-query'
|
||||
import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query'
|
||||
import { useFillTimeseriesSorted } from '@/hooks/analytics/useFillTimeseriesSorted'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
|
||||
export const EdgeFunctionOverview = () => {
|
||||
const router = useRouter()
|
||||
|
||||
+2
-1
@@ -2,9 +2,10 @@ import dayjs from 'dayjs'
|
||||
import maxBy from 'lodash/maxBy'
|
||||
import meanBy from 'lodash/meanBy'
|
||||
import sumBy from 'lodash/sumBy'
|
||||
import type { ChartIntervals } from 'types'
|
||||
import type { ChartConfig } from 'ui'
|
||||
|
||||
import type { ChartIntervals } from '@/types'
|
||||
|
||||
export type EdgeFunctionChartRawDatum = {
|
||||
timestamp: string | number
|
||||
success_count?: string | number
|
||||
|
||||
+4
-4
@@ -1,7 +1,3 @@
|
||||
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
||||
import { AiAssistantDropdown } from 'components/ui/AiAssistantDropdown'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import useLogsQuery from 'hooks/analytics/useLogsQuery'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
@@ -41,6 +37,10 @@ import {
|
||||
toAlertError,
|
||||
type RecentErrorGroup,
|
||||
} from './EdgeFunctionRecentErrors.utils'
|
||||
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
||||
import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import useLogsQuery from '@/hooks/analytics/useLogsQuery'
|
||||
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
|
||||
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
|
||||
|
||||
|
||||
+8
-8
@@ -1,15 +1,15 @@
|
||||
import { LOGS_TABLES } from 'components/interfaces/Settings/Logs/Logs.constants'
|
||||
import type { LogData } from 'components/interfaces/Settings/Logs/Logs.types'
|
||||
import {
|
||||
genDefaultQuery,
|
||||
isUnixMicro,
|
||||
unixMicroToIsoTimestamp,
|
||||
} from 'components/interfaces/Settings/Logs/Logs.utils'
|
||||
import type { AlertErrorProps } from 'components/ui/AlertError'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
|
||||
import { parseEdgeFunctionEventMessage } from '../EdgeFunctionRecentInvocations.utils'
|
||||
import { LOGS_TABLES } from '@/components/interfaces/Settings/Logs/Logs.constants'
|
||||
import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types'
|
||||
import {
|
||||
genDefaultQuery,
|
||||
isUnixMicro,
|
||||
unixMicroToIsoTimestamp,
|
||||
} from '@/components/interfaces/Settings/Logs/Logs.utils'
|
||||
import type { AlertErrorProps } from '@/components/ui/AlertError'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
import { screen } from '@testing-library/dom'
|
||||
import { mockAnimationsApi } from 'jsdom-testing-mocks'
|
||||
import { customRender } from 'tests/lib/custom-render'
|
||||
import { routerMock } from 'tests/lib/route-mock'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { IntegrationOverviewTab } from './IntegrationOverviewTab'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { routerMock } from '@/tests/lib/route-mock'
|
||||
|
||||
mockAnimationsApi()
|
||||
|
||||
@@ -31,7 +31,7 @@ vi.mock('framer-motion', async (importOriginal) => {
|
||||
|
||||
const mockExtensions = vi.fn()
|
||||
|
||||
vi.mock('data/database-extensions/database-extensions-query', () => ({
|
||||
vi.mock('@/data/database-extensions/database-extensions-query', () => ({
|
||||
useDatabaseExtensionsQuery: () => ({ data: mockExtensions() }),
|
||||
}))
|
||||
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/dom'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { mockAnimationsApi } from 'jsdom-testing-mocks'
|
||||
import { customRender } from 'tests/lib/custom-render'
|
||||
import { routerMock } from 'tests/lib/route-mock'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { IntegrationDefinition } from '../../Landing/Integrations.constants'
|
||||
import { InstallIntegrationSheet } from './InstallIntegrationSheet/InstallIntegrationSheet'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { routerMock } from '@/tests/lib/route-mock'
|
||||
|
||||
mockAnimationsApi()
|
||||
|
||||
|
||||
+5
-5
@@ -1,8 +1,3 @@
|
||||
import { extensionsWithRecommendedSchemas } from 'components/interfaces/Database/Extensions/Extensions.constants'
|
||||
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
|
||||
import { useSchemasQuery } from 'data/database/schemas-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useProtectedSchemas } from 'hooks/useProtectedSchemas'
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
import {
|
||||
Accordion_Shadcn_,
|
||||
@@ -23,6 +18,11 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import { getExtensionDefaultSchema } from '../IntegrationOverviewTabV2.utils'
|
||||
import { type ExtensionsSchema, type InstallIntegrationSheetProps } from './InstallIntegrationSheet'
|
||||
import { extensionsWithRecommendedSchemas } from '@/components/interfaces/Database/Extensions/Extensions.constants'
|
||||
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
|
||||
import { useSchemasQuery } from '@/data/database/schemas-query'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useProtectedSchemas } from '@/hooks/useProtectedSchemas'
|
||||
|
||||
type AdvancedSettingsProps = InstallIntegrationSheetProps & {
|
||||
extensionsSchema: ExtensionsSchema
|
||||
|
||||
+2
-2
@@ -1,6 +1,4 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { type IntegrationDefinition } from 'components/interfaces/Integrations/Landing/Integrations.constants'
|
||||
import { useTrack } from 'lib/telemetry/track'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
@@ -23,12 +21,14 @@ import { getExtensionDefaultSchema } from '../IntegrationOverviewTabV2.utils'
|
||||
import { AdvancedSettings } from './AdvancedSettings'
|
||||
import { InstallationOverview } from './InstallationOverview'
|
||||
import { InstallationSettings } from './InstallationSettings'
|
||||
import { type IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
|
||||
import { useDatabaseExtensionEnableMutation } from '@/data/database-extensions/database-extension-enable-mutation'
|
||||
import {
|
||||
DatabaseExtension,
|
||||
useDatabaseExtensionsQuery,
|
||||
} from '@/data/database-extensions/database-extensions-query'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
import { ResponseError } from '@/types'
|
||||
|
||||
export interface InstallIntegrationSheetProps {
|
||||
|
||||
+3
-3
@@ -1,6 +1,3 @@
|
||||
import { Markdown } from 'components/interfaces/Markdown'
|
||||
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
@@ -16,6 +13,9 @@ import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
|
||||
import { getEnableExtensionsSQL } from '../IntegrationOverviewTabV2.utils'
|
||||
import { type ExtensionsSchema, type InstallIntegrationSheetProps } from './InstallIntegrationSheet'
|
||||
import { Markdown } from '@/components/interfaces/Markdown'
|
||||
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
export const InstallationOverview = ({
|
||||
integration,
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ export const MarkdownContent = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!!integrationId && !content) {
|
||||
import(`static-data/integrations/${integrationId}/overview.md`)
|
||||
import(`@/static-data/integrations/${integrationId}/overview.md`)
|
||||
.then((module) => setLocalContent(String(module.default)))
|
||||
.catch((error) => console.error('Error loading markdown:', error))
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export const MarkdownContent = ({
|
||||
const [isExpanded, setIsExpanded] = useState(initiallyExpanded ?? false)
|
||||
|
||||
useEffect(() => {
|
||||
import(`static-data/integrations/${integrationId}/overview.md`)
|
||||
import(`@/static-data/integrations/${integrationId}/overview.md`)
|
||||
.then((module) => setContent(String(module.default)))
|
||||
.catch((error) => console.error('Error loading markdown:', error))
|
||||
}, [integrationId])
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { getEnableWebhooksSQL } from '@supabase/pg-meta'
|
||||
import { stripeSyncKeys } from 'data/database-integrations/stripe/keys'
|
||||
import { installStripeSync } from 'data/database-integrations/stripe/stripe-sync-install-mutation'
|
||||
import { databaseKeys } from 'data/database/keys'
|
||||
import { useTrack } from 'lib/telemetry/track'
|
||||
import { Clock5, Code2, Layers, Timer, Vault, Webhook } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Image from 'next/image'
|
||||
@@ -14,10 +10,14 @@ import { UpgradeDatabaseAlert } from '../Queues/UpgradeDatabaseAlert'
|
||||
import { getStripeSyncSchemaComment } from '../templates/StripeSyncEngine/useStripeSyncStatus'
|
||||
import { WRAPPERS } from '../Wrappers/Wrappers.constants'
|
||||
import { WrapperMeta } from '../Wrappers/Wrappers.types'
|
||||
import { stripeSyncKeys } from '@/data/database-integrations/stripe/keys'
|
||||
import { installStripeSync } from '@/data/database-integrations/stripe/stripe-sync-install-mutation'
|
||||
import { enableDatabaseWebhooks } from '@/data/database/hooks-enable-mutation'
|
||||
import { databaseKeys } from '@/data/database/keys'
|
||||
import { getSchemas, invalidateSchemasQuery } from '@/data/database/schemas-query'
|
||||
import { getQueryClient } from '@/data/query-client'
|
||||
import { BASE_PATH, DOCS_URL } from '@/lib/constants'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
|
||||
export type Navigation = {
|
||||
route: string
|
||||
@@ -528,7 +528,7 @@ const TEMPLATE_INTEGRATIONS: Array<IntegrationDefinition> = [
|
||||
case 'overview':
|
||||
return dynamic(
|
||||
() =>
|
||||
import('components/interfaces/Integrations/templates/StripeSyncEngine/OverviewTab').then(
|
||||
import('@/components/interfaces/Integrations/templates/StripeSyncEngine/OverviewTab').then(
|
||||
(mod) => mod.StripeSyncEngineOverviewTab
|
||||
),
|
||||
{ loading: Loading }
|
||||
|
||||
+1
-1
@@ -1,4 +1,3 @@
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useEffect } from 'react'
|
||||
import { getCurrentVersion, parseSchemaComment } from 'stripe-experiment-sync/supabase'
|
||||
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
} from '@/components/interfaces/Integrations/templates/StripeSyncEngine/stripe-sync-status'
|
||||
import { useStripeSyncingState } from '@/data/database-integrations/stripe/sync-state-query'
|
||||
import { Schema, useSchemasQuery } from '@/data/database/schemas-query'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
// Maximum time allowed for installation or uninstallation operations before the UI times out
|
||||
const OPERATION_TIME_OUT_MS: number = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
@@ -44,13 +44,13 @@ vi.mock('next-themes', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('state/app-state', () => ({
|
||||
vi.mock('@/state/app-state', () => ({
|
||||
useAppStateSnapshot: () => ({
|
||||
setLastRouteBeforeVisitingAccountPage: mockSetLastRoute,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('components/ui/ProfileImage', () => ({
|
||||
vi.mock('@/components/ui/ProfileImage', () => ({
|
||||
ProfileImage: () => <div>Avatar</div>,
|
||||
}))
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { FlaskConical, Settings } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
import { ButtonTooltip } from '../ui/ButtonTooltip'
|
||||
import { useFeaturePreviewModal } from './App/FeaturePreview/FeaturePreviewContext'
|
||||
import { ProfileImage } from '@/components/ui/ProfileImage'
|
||||
import { useAppStateSnapshot } from '@/state/app-state'
|
||||
|
||||
export const LocalDropdown = ({
|
||||
triggerClassName,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { render } from 'tests/helpers'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LogDrainDestinationSheetForm } from './LogDrainDestinationSheetForm'
|
||||
import { render } from '@/tests/helpers'
|
||||
|
||||
const { trackMock, useFlagMock, useLogDrainsQueryMock, useParamsMock, useTrackMock } = vi.hoisted(
|
||||
() => ({
|
||||
@@ -27,7 +27,7 @@ vi.mock(import('common'), async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock(import('data/log-drains/log-drains-query'), async (importOriginal) => {
|
||||
vi.mock(import('@/data/log-drains/log-drains-query'), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
|
||||
return {
|
||||
@@ -36,7 +36,7 @@ vi.mock(import('data/log-drains/log-drains-query'), async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('lib/telemetry/track', () => ({
|
||||
vi.mock('@/lib/telemetry/track', () => ({
|
||||
useTrack: useTrackMock,
|
||||
}))
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ export const AuditLogs = () => {
|
||||
})
|
||||
?.filter((log) => {
|
||||
if (filters.projects.length > 0) {
|
||||
return filters.projects.includes(log.target.metadata.project_ref || '')
|
||||
return filters.projects.includes(log.target.metadata.ref || '')
|
||||
} else {
|
||||
return log
|
||||
}
|
||||
|
||||
+20
-20
@@ -3,26 +3,6 @@ import { loadStripe, StripeAddressElement, StripeElementsOptions } from '@stripe
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
getAddressElementAppearanceOptions,
|
||||
STRIPE_ELEMENT_FONTS,
|
||||
} from 'components/interfaces/Billing/Payment/Payment.utils'
|
||||
import {
|
||||
ScaffoldSection,
|
||||
ScaffoldSectionContent,
|
||||
ScaffoldSectionDetail,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import PartnerManagedResource from 'components/ui/PartnerManagedResource'
|
||||
import { organizationKeys } from 'data/organizations/keys'
|
||||
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
|
||||
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
|
||||
import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query'
|
||||
import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { STRIPE_PUBLIC_KEY } from 'lib/constants'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -31,6 +11,26 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { BillingCustomerDataForm } from './BillingCustomerDataForm'
|
||||
import { useBillingCustomerDataForm } from './useBillingCustomerDataForm'
|
||||
import {
|
||||
getAddressElementAppearanceOptions,
|
||||
STRIPE_ELEMENT_FONTS,
|
||||
} from '@/components/interfaces/Billing/Payment/Payment.utils'
|
||||
import {
|
||||
ScaffoldSection,
|
||||
ScaffoldSectionContent,
|
||||
ScaffoldSectionDetail,
|
||||
} from '@/components/layouts/Scaffold'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import NoPermission from '@/components/ui/NoPermission'
|
||||
import PartnerManagedResource from '@/components/ui/PartnerManagedResource'
|
||||
import { organizationKeys } from '@/data/organizations/keys'
|
||||
import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organization-customer-profile-query'
|
||||
import { useOrganizationCustomerProfileUpdateMutation } from '@/data/organizations/organization-customer-profile-update-mutation'
|
||||
import { useOrganizationTaxIdQuery } from '@/data/organizations/organization-tax-id-query'
|
||||
import { useOrganizationTaxIdUpdateMutation } from '@/data/organizations/organization-tax-id-update-mutation'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { STRIPE_PUBLIC_KEY } from '@/lib/constants'
|
||||
|
||||
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
|
||||
|
||||
|
||||
+1
-1
@@ -4,13 +4,13 @@ import type {
|
||||
StripeAddressElementChangeEvent,
|
||||
StripeAddressElementOptions,
|
||||
} from '@stripe/stripe-js'
|
||||
import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
|
||||
import { TaxIdFormValues, TaxIdSchema } from './BillingCustomerDataForm'
|
||||
import { TAX_IDS } from './TaxID.constants'
|
||||
import { getEffectiveTaxCountry, resolveStoredTaxId, sanitizeTaxIdValue } from './TaxID.utils'
|
||||
import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
|
||||
|
||||
type StripeAddressValue = StripeAddressElementChangeEvent['value']
|
||||
type StripeAddressValidationState = 'unknown' | 'complete' | 'incomplete'
|
||||
|
||||
@@ -84,6 +84,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
|
||||
})
|
||||
|
||||
const [topUpModalVisible, setTopUpModalVisible] = useState(false)
|
||||
const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true)
|
||||
const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false)
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
|
||||
@@ -281,6 +282,8 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
|
||||
onSelectPaymentMethod={(pm) => form.setValue('paymentMethod', pm)}
|
||||
selectedPaymentMethod={form.getValues('paymentMethod')}
|
||||
readOnly={executingTopUp || paymentConfirmationLoading}
|
||||
useAsDefaultBillingAddress={useAsDefaultBillingAddress}
|
||||
onUseAsDefaultBillingAddressChange={setUseAsDefaultBillingAddress}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
+14
-2
@@ -26,6 +26,7 @@ import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organi
|
||||
import { useOrganizationPaymentMethodSetupIntent } from '@/data/organizations/organization-payment-method-setup-intent-mutation'
|
||||
import { useOrganizationPaymentMethodsQuery } from '@/data/organizations/organization-payment-methods-query'
|
||||
import { useOrganizationTaxIdQuery } from '@/data/organizations/organization-tax-id-query'
|
||||
import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
|
||||
import { SetupIntentResponse } from '@/data/stripe/setup-intent-mutation'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { BASE_PATH, STRIPE_PUBLIC_KEY } from '@/lib/constants'
|
||||
@@ -37,6 +38,10 @@ export interface PaymentMethodSelectionProps {
|
||||
onSelectPaymentMethod: (id: string) => void
|
||||
layout?: 'vertical' | 'horizontal'
|
||||
readOnly: boolean
|
||||
onAddressChange?: (address: CustomerAddress) => void
|
||||
onTaxIdChange?: (taxId: CustomerTaxId | null) => void
|
||||
useAsDefaultBillingAddress: boolean
|
||||
onUseAsDefaultBillingAddressChange: (useAsDefault: boolean) => void
|
||||
}
|
||||
|
||||
const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
||||
@@ -45,6 +50,10 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
||||
onSelectPaymentMethod,
|
||||
layout = 'vertical',
|
||||
readOnly,
|
||||
onAddressChange,
|
||||
onTaxIdChange,
|
||||
useAsDefaultBillingAddress,
|
||||
onUseAsDefaultBillingAddressChange,
|
||||
}: PaymentMethodSelectionProps,
|
||||
ref
|
||||
) {
|
||||
@@ -53,7 +62,6 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
|
||||
const [setupIntent, setSetupIntent] = useState<SetupIntentResponse | undefined>(undefined)
|
||||
const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true)
|
||||
const { resolvedTheme } = useTheme()
|
||||
const paymentRef = useRef<PaymentMethodElementRef | null>(null)
|
||||
const [setupNewPaymentMethod, setSetupNewPaymentMethod] = useState<boolean | null>(null)
|
||||
@@ -281,6 +289,8 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
||||
customerName={customerProfile?.billing_name}
|
||||
currentAddress={customerProfile?.address}
|
||||
currentTaxId={taxId}
|
||||
onAddressChange={onAddressChange}
|
||||
onTaxIdChange={onTaxIdChange}
|
||||
/>
|
||||
</Elements>
|
||||
|
||||
@@ -290,7 +300,9 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
||||
<Checkbox_Shadcn_
|
||||
id="defaultBillingAddress"
|
||||
checked={useAsDefaultBillingAddress}
|
||||
onCheckedChange={() => setUseAsDefaultBillingAddress(!useAsDefaultBillingAddress)}
|
||||
onCheckedChange={() => {
|
||||
onUseAsDefaultBillingAddressChange(!useAsDefaultBillingAddress)
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="defaultBillingAddress"
|
||||
|
||||
+38
-2
@@ -1,10 +1,11 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { useParams } from 'common'
|
||||
import { StudioPricingSidePanelOpenedEvent } from 'common/telemetry-constants'
|
||||
import { isArray } from 'lodash'
|
||||
import { Check, ExternalLink } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { plans as subscriptionsPlans } from 'shared-data/plans'
|
||||
import { Button, cn, SidePanel } from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
@@ -22,6 +23,7 @@ import { RequestUpgradeToBillingOwners } from '@/components/ui/RequestUpgradeToB
|
||||
import { useFreeProjectLimitCheckQuery } from '@/data/organizations/free-project-limit-check-query'
|
||||
import { useOrganizationBillingSubscriptionPreview } from '@/data/organizations/organization-billing-subscription-preview'
|
||||
import { useOrganizationQuery } from '@/data/organizations/organization-query'
|
||||
import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
|
||||
import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
|
||||
import { useOrgPlansQuery } from '@/data/subscriptions/org-plans-query'
|
||||
import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
|
||||
@@ -61,6 +63,26 @@ export const PlanUpdateSidePanel = () => {
|
||||
const [showUpgradeSurvey, setShowUpgradeSurvey] = useState(false)
|
||||
const [showDowngradeError, setShowDowngradeError] = useState(false)
|
||||
const [selectedTier, setSelectedTier] = useState<'tier_free' | 'tier_pro' | 'tier_team'>()
|
||||
const [latestAddress, setLatestAddress] = useState<CustomerAddress>()
|
||||
const [latestTaxId, setLatestTaxId] = useState<CustomerTaxId | null>()
|
||||
const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true)
|
||||
|
||||
const billingAddress = useAsDefaultBillingAddress ? latestAddress : undefined
|
||||
const billingTaxId = useAsDefaultBillingAddress ? latestTaxId : null
|
||||
const debouncedAddress = useDebounce(billingAddress, 1000)
|
||||
const debouncedTaxId = useDebounce(billingTaxId, 1000)
|
||||
|
||||
const handleAddressChange = useCallback(
|
||||
(address: CustomerAddress) => setLatestAddress(address),
|
||||
[]
|
||||
)
|
||||
|
||||
const handleTaxIdChange = useCallback((taxId: CustomerTaxId | null) => setLatestTaxId(taxId), [])
|
||||
|
||||
const handleUseAsDefaultBillingAddressChange = useCallback(
|
||||
(useAsDefault: boolean) => setUseAsDefaultBillingAddress(useAsDefault),
|
||||
[]
|
||||
)
|
||||
|
||||
const { can: canUpdateSubscription } = useAsyncCheckPermissions(
|
||||
PermissionAction.BILLING_WRITE,
|
||||
@@ -104,8 +126,14 @@ export const PlanUpdateSidePanel = () => {
|
||||
data: subscriptionPreview,
|
||||
error: subscriptionPreviewError,
|
||||
isPending: subscriptionPreviewIsLoading,
|
||||
isFetching: subscriptionPreviewIsFetching,
|
||||
isSuccess: subscriptionPreviewInitialized,
|
||||
} = useOrganizationBillingSubscriptionPreview({ tier: selectedTier, organizationSlug: slug })
|
||||
} = useOrganizationBillingSubscriptionPreview({
|
||||
tier: selectedTier,
|
||||
organizationSlug: slug,
|
||||
address: debouncedAddress,
|
||||
taxId: debouncedTaxId ?? undefined,
|
||||
})
|
||||
|
||||
const availablePlans: OrgPlan[] = plans?.plans ?? []
|
||||
const hasMembersExceedingFreeTierLimit =
|
||||
@@ -118,6 +146,9 @@ export const PlanUpdateSidePanel = () => {
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelectedTier(undefined)
|
||||
setLatestAddress(undefined)
|
||||
setLatestTaxId(undefined)
|
||||
setUseAsDefaultBillingAddress(true)
|
||||
const source = Array.isArray(router.query.source)
|
||||
? router.query.source[0]
|
||||
: router.query.source
|
||||
@@ -342,6 +373,7 @@ export const PlanUpdateSidePanel = () => {
|
||||
planMeta={planMeta}
|
||||
subscriptionPreviewError={subscriptionPreviewError}
|
||||
subscriptionPreviewIsLoading={subscriptionPreviewIsLoading}
|
||||
subscriptionPreviewIsFetching={subscriptionPreviewIsFetching}
|
||||
subscriptionPreviewInitialized={subscriptionPreviewInitialized}
|
||||
subscriptionPreview={subscriptionPreview}
|
||||
subscription={subscription}
|
||||
@@ -352,6 +384,10 @@ export const PlanUpdateSidePanel = () => {
|
||||
subscriptionsPlans.find((plan) => plan.id === `tier_${subscription?.plan?.id}`)
|
||||
?.features || [],
|
||||
}}
|
||||
onAddressChange={handleAddressChange}
|
||||
onTaxIdChange={handleTaxIdChange}
|
||||
useAsDefaultBillingAddress={useAsDefaultBillingAddress}
|
||||
onUseAsDefaultBillingAddressChange={handleUseAsDefaultBillingAddressChange}
|
||||
/>
|
||||
|
||||
<MembersExceedLimitModal
|
||||
|
||||
+35
-13
@@ -6,7 +6,7 @@ import Link from 'next/link'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { plans as subscriptionsPlans } from 'shared-data/plans'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui'
|
||||
import { Button, cn, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@/components/interfaces/Billing/Subscription/Subscription.utils'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import { OrganizationBillingSubscriptionPreviewData } from '@/data/organizations/organization-billing-subscription-preview'
|
||||
import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
|
||||
import { OrgProject } from '@/data/projects/org-projects-infinite-query'
|
||||
import { useConfirmPendingSubscriptionChangeMutation } from '@/data/subscriptions/org-subscription-confirm-pending-change'
|
||||
import { useOrgSubscriptionUpdateMutation } from '@/data/subscriptions/org-subscription-update-mutation'
|
||||
@@ -64,11 +65,16 @@ interface Props {
|
||||
planMeta: any
|
||||
subscriptionPreviewError: any
|
||||
subscriptionPreviewIsLoading: boolean
|
||||
subscriptionPreviewIsFetching: boolean
|
||||
subscriptionPreviewInitialized: boolean
|
||||
subscriptionPreview: OrganizationBillingSubscriptionPreviewData | undefined
|
||||
subscription: any
|
||||
currentPlanMeta: any
|
||||
projects: OrgProject[]
|
||||
onAddressChange?: (address: CustomerAddress) => void
|
||||
onTaxIdChange?: (taxId: CustomerTaxId | null) => void
|
||||
useAsDefaultBillingAddress: boolean
|
||||
onUseAsDefaultBillingAddressChange: (useAsDefault: boolean) => void
|
||||
}
|
||||
|
||||
export const SubscriptionPlanUpdateDialog = ({
|
||||
@@ -77,11 +83,16 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
planMeta,
|
||||
subscriptionPreviewError,
|
||||
subscriptionPreviewIsLoading,
|
||||
subscriptionPreviewIsFetching,
|
||||
subscriptionPreviewInitialized,
|
||||
subscriptionPreview,
|
||||
subscription,
|
||||
currentPlanMeta,
|
||||
projects,
|
||||
onAddressChange,
|
||||
onTaxIdChange,
|
||||
useAsDefaultBillingAddress,
|
||||
onUseAsDefaultBillingAddressChange,
|
||||
}: Props) => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||||
@@ -325,16 +336,22 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
<div className="p-8 pb-8 flex flex-col xl:col-span-3">
|
||||
<div className="flex-1">
|
||||
<div>
|
||||
{!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && (
|
||||
<div className="space-y-2 mb-4">
|
||||
<PaymentMethodSelection
|
||||
ref={paymentMethodSelectionRef}
|
||||
selectedPaymentMethod={selectedPaymentMethod}
|
||||
onSelectPaymentMethod={(pm) => setSelectedPaymentMethod(pm)}
|
||||
readOnly={paymentConfirmationLoading || isConfirming || isUpdating}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!billingViaPartner &&
|
||||
subscriptionPreviewInitialized &&
|
||||
changeType === 'upgrade' && (
|
||||
<div className="space-y-2 mb-4">
|
||||
<PaymentMethodSelection
|
||||
ref={paymentMethodSelectionRef}
|
||||
selectedPaymentMethod={selectedPaymentMethod}
|
||||
onSelectPaymentMethod={(pm) => setSelectedPaymentMethod(pm)}
|
||||
readOnly={paymentConfirmationLoading || isConfirming || isUpdating}
|
||||
onAddressChange={onAddressChange}
|
||||
onTaxIdChange={onTaxIdChange}
|
||||
useAsDefaultBillingAddress={useAsDefaultBillingAddress}
|
||||
onUseAsDefaultBillingAddressChange={onUseAsDefaultBillingAddressChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{billingViaPartner && (
|
||||
<div className="mb-4">
|
||||
@@ -368,7 +385,12 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
)}
|
||||
{subscriptionPreviewInitialized && (
|
||||
<>
|
||||
<div className="mt-2 mb-4 text-foreground-light text-sm">
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 mb-4 text-foreground-light text-sm transition-opacity',
|
||||
subscriptionPreviewIsFetching && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{breakdownItems.map((item, i) =>
|
||||
item.type === 'amount' ? (
|
||||
<div
|
||||
@@ -668,7 +690,7 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
loading={isUpdating || paymentConfirmationLoading || isConfirming}
|
||||
disabled={subscriptionPreviewIsLoading}
|
||||
disabled={subscriptionPreviewIsLoading || subscriptionPreviewIsFetching}
|
||||
type="primary"
|
||||
onClick={onUpdateSubscription}
|
||||
className="flex-1"
|
||||
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
import { getComputeSize } from 'data/projects/org-projects-infinite-query'
|
||||
import type { OrgProject } from 'data/projects/org-projects-infinite-query'
|
||||
import { Checkbox_Shadcn_ } from 'ui'
|
||||
|
||||
import { getComputeSize } from '@/data/projects/org-projects-infinite-query'
|
||||
import type { OrgProject } from '@/data/projects/org-projects-infinite-query'
|
||||
|
||||
type Props = {
|
||||
projects: OrgProject[]
|
||||
checkedProjects: Record<string, boolean>
|
||||
|
||||
+2
-2
@@ -6,11 +6,11 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { PLATFORM_WEBHOOKS_MOCK_DATA } from './PlatformWebhooks.mock'
|
||||
import { PlatformWebhooksEndpointDetails } from './PlatformWebhooksEndpointDetails'
|
||||
|
||||
vi.mock('components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode', () => ({
|
||||
vi.mock('@/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode', () => ({
|
||||
DataTableColumnStatusCode: ({ value }: { value: number }) => <span>{value}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('components/ui/ButtonTooltip', () => ({
|
||||
vi.mock('@/components/ui/ButtonTooltip', () => ({
|
||||
ButtonTooltip: ({
|
||||
icon,
|
||||
children,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { cn, HoverCard, HoverCardContent, HoverCardTrigger, Separator } from 'ui'
|
||||
|
||||
import { ServerLightGrid } from './ServerLightGrid'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
interface HighAvailabilityBadgeProps {
|
||||
size?: 'default' | 'small'
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { ReactFlowProvider } from '@xyflow/react'
|
||||
import { ActivityStats } from 'components/interfaces/ProjectHome/ActivityStats'
|
||||
import { ProjectConnectionPopover } from 'components/interfaces/ProjectHome/ProjectConnectionPopover'
|
||||
import { ProjectPausedState } from 'components/layouts/ProjectLayout/PausedState/ProjectPausedState'
|
||||
import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import { ProjectUpgradeFailedBanner } from 'components/ui/ProjectUpgradeFailedBanner'
|
||||
import { useBranchesQuery } from 'data/branches/branches-query'
|
||||
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
|
||||
import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useIsOrioleDb, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL, PROJECT_STATUS } from 'lib/constants'
|
||||
import Link from 'next/link'
|
||||
import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { InstanceConfiguration } from '../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration'
|
||||
import { HighAvailabilityBadge } from './HighAvailabilityBadge'
|
||||
import { ActivityStats } from '@/components/interfaces/ProjectHome/ActivityStats'
|
||||
import { ProjectConnectionPopover } from '@/components/interfaces/ProjectHome/ProjectConnectionPopover'
|
||||
import { ProjectPausedState } from '@/components/layouts/ProjectLayout/PausedState/ProjectPausedState'
|
||||
import { ComputeBadgeWrapper } from '@/components/ui/ComputeBadgeWrapper'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { ProjectUpgradeFailedBanner } from '@/components/ui/ProjectUpgradeFailedBanner'
|
||||
import { useBranchesQuery } from '@/data/branches/branches-query'
|
||||
import { useProjectDetailQuery } from '@/data/projects/project-detail-query'
|
||||
import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { useIsOrioleDb, useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL, PROJECT_STATUS } from '@/lib/constants'
|
||||
|
||||
export const TopSection = () => {
|
||||
const isOrioleDb = useIsOrioleDb()
|
||||
|
||||
@@ -12,7 +12,9 @@ import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import NoDataPlaceholder from '@/components/ui/Charts/NoDataPlaceholder'
|
||||
import {
|
||||
checkHasNonPositiveValues,
|
||||
computeYAxisWidth,
|
||||
formatLogTick,
|
||||
formatYAxisTick,
|
||||
} from '@/components/ui/QueryBlock/QueryBlock.utils'
|
||||
import { AnalyticsInterval } from '@/data/analytics/constants'
|
||||
import { mapMultiResponseToAnalyticsData } from '@/data/analytics/infra-monitoring-queries'
|
||||
@@ -176,6 +178,11 @@ export const ChartBlock = ({
|
||||
|
||||
const effectiveLogScale = logScale && !hasNonPositiveValues
|
||||
|
||||
const yAxisWidth = computeYAxisWidth(data, metricLabel, {
|
||||
isLogScale: effectiveLogScale,
|
||||
isPercentage,
|
||||
})
|
||||
|
||||
const getInitialHighlightedValue = useCallback(() => {
|
||||
if (!chartData?.data?.length) return undefined
|
||||
const lastDataPoint = chartData.data[chartData.data.length - 1]
|
||||
@@ -301,13 +308,13 @@ export const ChartBlock = ({
|
||||
scale={effectiveLogScale ? 'log' : 'auto'}
|
||||
domain={effectiveLogScale ? [1, 'auto'] : isPercentage ? [0, 100] : undefined}
|
||||
allowDataOverflow={effectiveLogScale}
|
||||
width={effectiveLogScale ? 52 : undefined}
|
||||
tickFormatter={effectiveLogScale ? formatLogTick : undefined}
|
||||
width={yAxisWidth}
|
||||
tickFormatter={effectiveLogScale ? formatLogTick : formatYAxisTick}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[200px]"
|
||||
className="min-w-[200px]"
|
||||
labelSuffix={isPercentage ? '%' : ''}
|
||||
labelFormatter={(x) => dayjs(x).format('DD MMM YYYY')}
|
||||
/>
|
||||
@@ -329,13 +336,12 @@ export const ChartBlock = ({
|
||||
scale={effectiveLogScale ? 'log' : 'auto'}
|
||||
domain={effectiveLogScale ? [1, 'auto'] : isPercentage ? [0, 100] : undefined}
|
||||
allowDataOverflow={effectiveLogScale}
|
||||
width={effectiveLogScale ? 52 : undefined}
|
||||
tickFormatter={effectiveLogScale ? formatLogTick : undefined}
|
||||
width={yAxisWidth}
|
||||
tickFormatter={effectiveLogScale ? formatLogTick : formatYAxisTick}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[200px]"
|
||||
labelSuffix={chartData?.format === '%' ? '%' : ''}
|
||||
labelFormatter={(x) => dayjs(x).format('DD MMM YYYY')}
|
||||
/>
|
||||
|
||||
@@ -56,7 +56,7 @@ export const ReportBlockContainer = ({
|
||||
<h3 className="heading-meta truncate">{label}</h3>
|
||||
{badge && <div className="flex items-center shrink-0">{badge}</div>}
|
||||
</div>
|
||||
<div className="flex items-center">{actions}</div>
|
||||
<div className="flex items-center shrink-0">{actions}</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{tooltip && (
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { ContextMenuContent } from '@ui/components/shadcn/ui/context-menu'
|
||||
import { IS_PLATFORM, useParams } from 'common'
|
||||
import { isEqual } from 'lodash'
|
||||
import { Copy, Eye, EyeOff, Play, X as XIcon } from 'lucide-react'
|
||||
import { Copy, Eye, EyeOff, Play } from 'lucide-react'
|
||||
import { Key, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Item, Menu, useContextMenu } from 'react-contexify'
|
||||
import DataGrid, { Column, RenderRowProps, Row } from 'react-data-grid'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
Checkbox_Shadcn_,
|
||||
cn,
|
||||
ContextMenu_Shadcn_,
|
||||
ContextMenuItem_Shadcn_,
|
||||
ContextMenuTrigger_Shadcn_,
|
||||
copyToClipboard,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
@@ -92,13 +94,30 @@ export const LogTable = ({
|
||||
const { ref } = useParams()
|
||||
const { profile } = useProfile()
|
||||
const [selectedLogId] = useSelectedLog()
|
||||
const { show: showContextMenu } = useContextMenu()
|
||||
|
||||
const [cellPosition, setCellPosition] = useState<any>()
|
||||
const [selectedRow, setSelectedRow] = useState<LogData | null>(null)
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
|
||||
const [anchorRowId, setAnchorRowId] = useState<string | null>(null)
|
||||
const [copiedFormat, setCopiedFormat] = useState<'json' | 'markdown' | null>(null)
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const [activeRow, setActiveRow] = useState<LogData | null>(null)
|
||||
const [contextMenuKey, setContextMenuKey] = useState(0)
|
||||
|
||||
const handleRowContextMenu = useCallback((e: React.MouseEvent, row: LogData) => {
|
||||
e.preventDefault()
|
||||
setActiveRow(row)
|
||||
// Force re-render of ContextMenuContent to update the current position.
|
||||
setContextMenuKey((prev) => prev + 1)
|
||||
const trigger = triggerRef.current
|
||||
if (!trigger) return
|
||||
trigger.style.left = `${e.clientX}px`
|
||||
trigger.style.top = `${e.clientY}px`
|
||||
trigger.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
|
||||
const { can: canCreateLogQuery } = useAsyncCheckPermissions(
|
||||
PermissionAction.CREATE,
|
||||
@@ -125,8 +144,6 @@ export const LogTable = ({
|
||||
const panelContentMinSize = 40
|
||||
const panelContentMaxSize = 60
|
||||
|
||||
const LOGS_EXPLORER_CONTEXT_MENU_ID = 'logs-explorer-context-menu'
|
||||
|
||||
const getRowKey = useCallback(
|
||||
(row: LogData): string => {
|
||||
if (!hasId) return JSON.stringify(row)
|
||||
@@ -177,7 +194,6 @@ export const LogTable = ({
|
||||
next.add(key)
|
||||
}
|
||||
setSelectedRows(next)
|
||||
setAnchorRowId(key)
|
||||
if (next.size > 0) {
|
||||
setSelectedRow(null)
|
||||
onSelectedLogChange?.(null)
|
||||
@@ -209,17 +225,7 @@ export const LogTable = ({
|
||||
name: v as string,
|
||||
resizable: true,
|
||||
renderCell: ({ row }) => {
|
||||
return (
|
||||
<span
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
setCellPosition({ row, column: { name: v } })
|
||||
showContextMenu(e, { id: LOGS_EXPLORER_CONTEXT_MENU_ID })
|
||||
}}
|
||||
>
|
||||
{formatCellValue(row?.[v])}
|
||||
</span>
|
||||
)
|
||||
return <span>{formatCellValue(row?.[v])}</span>
|
||||
},
|
||||
renderHeaderCell: () => {
|
||||
return <div className="flex items-center">{v}</div>
|
||||
@@ -267,15 +273,18 @@ export const LogTable = ({
|
||||
columns = [checkboxColumn, ...columns]
|
||||
}
|
||||
|
||||
const onRowClick = useCallback(
|
||||
(row: LogData) => {
|
||||
// Regular single click — clear multi-select, open side panel
|
||||
setSelectedRows(new Set())
|
||||
setSelectedRow(row)
|
||||
onSelectedLogChange?.(row)
|
||||
},
|
||||
[onSelectedLogChange]
|
||||
)
|
||||
|
||||
const RowRenderer = useCallback<(key: Key, props: RenderRowProps<LogData, unknown>) => ReactNode>(
|
||||
(key, props) => {
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
const firstDataColumn = columns.find((c) => c.key !== 'multi-select')
|
||||
if (firstDataColumn) {
|
||||
setCellPosition({ row: props.row, column: firstDataColumn })
|
||||
}
|
||||
showContextMenu(e, { id: LOGS_EXPLORER_CONTEXT_MENU_ID })
|
||||
}
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Check if clicking on the checkbox column - let that handler handle it
|
||||
const target = e.target as HTMLElement
|
||||
@@ -288,12 +297,12 @@ export const LogTable = ({
|
||||
{...props}
|
||||
isRowSelected={false}
|
||||
selectedCellIdx={undefined}
|
||||
onContextMenu={handleContextMenu}
|
||||
onClick={handleClick}
|
||||
onContextMenu={(e) => handleRowContextMenu(e, props.row)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[columns, showContextMenu]
|
||||
[handleRowContextMenu, onRowClick]
|
||||
)
|
||||
|
||||
const formatCellValue = (value: any) => {
|
||||
@@ -304,29 +313,10 @@ export const LogTable = ({
|
||||
: String(value)
|
||||
}
|
||||
|
||||
const onCopyCell = () => {
|
||||
if (!cellPosition) return
|
||||
const eventMessage = cellPosition.row.event_message
|
||||
copyToClipboard(eventMessage, () => {
|
||||
toast.success('Copied to clipboard')
|
||||
})
|
||||
}
|
||||
|
||||
function onRowClick(row: LogData) {
|
||||
const key = getRowKey(row)
|
||||
|
||||
// Regular single click — clear multi-select, open side panel
|
||||
setSelectedRows(new Set())
|
||||
setAnchorRowId(key)
|
||||
setSelectedRow(row)
|
||||
onSelectedLogChange?.(row)
|
||||
}
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setSelectedRows(new Set())
|
||||
setAnchorRowId(null)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -371,7 +361,6 @@ export const LogTable = ({
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
setSelectedRows(new Set())
|
||||
setAnchorRowId(null)
|
||||
}
|
||||
}, [isLoading])
|
||||
|
||||
@@ -510,10 +499,30 @@ export const LogTable = ({
|
||||
sqlQuery={sqlQuery}
|
||||
onClear={() => {
|
||||
setSelectedRows(new Set())
|
||||
setAnchorRowId(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ContextMenu_Shadcn_ modal={false}>
|
||||
<ContextMenuTrigger_Shadcn_ asChild>
|
||||
<div ref={triggerRef} className="fixed pointer-events-none w-0 h-0" />
|
||||
</ContextMenuTrigger_Shadcn_>
|
||||
<ContextMenuContent key={contextMenuKey}>
|
||||
<ContextMenuItem_Shadcn_
|
||||
className="gap-x-2"
|
||||
onSelect={() => {
|
||||
const eventMessage = activeRow?.event_message
|
||||
if (eventMessage) {
|
||||
copyToClipboard(eventMessage, () => {
|
||||
toast.success('Copied to clipboard')
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy size={14} />
|
||||
<span className="text-xs">Copy event message</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu_Shadcn_>
|
||||
<DataGrid
|
||||
role="table"
|
||||
style={{ flex: '1 1 0%', minHeight: 0 }}
|
||||
@@ -523,9 +532,6 @@ export const LogTable = ({
|
||||
})}
|
||||
rowHeight={40}
|
||||
headerRowHeight={queryType ? 0 : 28}
|
||||
onSelectedCellChange={(row) => {
|
||||
setCellPosition(row)
|
||||
}}
|
||||
columns={columns}
|
||||
rowClass={(row: LogData) => {
|
||||
const key = getRowKey(row)
|
||||
@@ -557,16 +563,6 @@ export const LogTable = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{typeof window !== 'undefined' &&
|
||||
createPortal(
|
||||
<Menu id={LOGS_EXPLORER_CONTEXT_MENU_ID} animation={false}>
|
||||
<Item onClick={onCopyCell}>
|
||||
<Copy size={14} />
|
||||
<span className="ml-2 text-xs">Copy event message</span>
|
||||
</Item>
|
||||
</Menu>,
|
||||
document.body
|
||||
)}
|
||||
</ResizablePanel>
|
||||
|
||||
{selectionOpen && (
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { ident } from '@supabase/pg-meta/src/pg-format'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
import { databasePoliciesKeys } from '@/data/database-policies/keys'
|
||||
import { executeSql } from '@/data/sql/execute-sql-query'
|
||||
import { storageKeys } from '@/data/storage/keys'
|
||||
import { usePublicBucketsWithSelectPoliciesQuery } from '@/data/storage/public-buckets-with-select-policies-query'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
function generatePolicyRemovalSql(policyName: string) {
|
||||
return `DROP POLICY IF EXISTS ${ident(policyName)} ON storage.objects;`
|
||||
}
|
||||
|
||||
export interface PublicBucketWarningProps {
|
||||
projectRef: string
|
||||
bucketId: string
|
||||
}
|
||||
|
||||
export function PublicBucketWarning({ projectRef, bucketId }: PublicBucketWarningProps): ReactNode {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const { data } = usePublicBucketsWithSelectPoliciesQuery({
|
||||
projectRef,
|
||||
connectionString: project?.connectionString,
|
||||
bucketId,
|
||||
})
|
||||
const policyToRemove = data?.[0]
|
||||
|
||||
const { mutate: removePolicy, isPending: isRemovingPolicy } = useMutation({
|
||||
mutationFn: async (policyName: string) => {
|
||||
await executeSql({
|
||||
projectRef,
|
||||
connectionString: project?.connectionString,
|
||||
sql: generatePolicyRemovalSql(policyName),
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: storageKeys.publicBucketsWithSelectPolicies(projectRef, bucketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: databasePoliciesKeys.list(projectRef, 'storage'),
|
||||
}),
|
||||
])
|
||||
setShowModal(false)
|
||||
toast.success('Policy removed successfully')
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to remove policy', error)
|
||||
toast.error(`Failed to remove policy: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
return policyToRemove ? (
|
||||
<PublicBucketWarningView
|
||||
_tag="policy-to-remove"
|
||||
policyName={policyToRemove.policyname}
|
||||
isRemovingPolicy={isRemovingPolicy}
|
||||
onRemovePolicy={() => removePolicy(policyToRemove.policyname)}
|
||||
isModalVisible={showModal}
|
||||
onShowModal={() => setShowModal(true)}
|
||||
onHideModal={() => setShowModal(false)}
|
||||
/>
|
||||
) : (
|
||||
<PublicBucketWarningView _tag="no-policy-to-remove" />
|
||||
)
|
||||
}
|
||||
|
||||
type PublicBucketWarningViewProps_NoPolicyToRemove = {
|
||||
_tag: 'no-policy-to-remove'
|
||||
}
|
||||
|
||||
type PublicBucketWarningViewProps_PolicyToRemove = {
|
||||
_tag: 'policy-to-remove'
|
||||
policyName: string
|
||||
isRemovingPolicy: boolean
|
||||
onRemovePolicy: () => void
|
||||
isModalVisible: boolean
|
||||
onShowModal: () => void
|
||||
onHideModal: () => void
|
||||
}
|
||||
|
||||
type PublicBucketWarningViewProps =
|
||||
| PublicBucketWarningViewProps_NoPolicyToRemove
|
||||
| PublicBucketWarningViewProps_PolicyToRemove
|
||||
|
||||
function PublicBucketWarningView(props: PublicBucketWarningViewProps): ReactNode {
|
||||
if (props._tag === 'no-policy-to-remove') {
|
||||
return null
|
||||
}
|
||||
|
||||
const { policyName, isRemovingPolicy, onRemovePolicy, isModalVisible, onShowModal, onHideModal } =
|
||||
props
|
||||
|
||||
return (
|
||||
<>
|
||||
<Admonition
|
||||
type="warning"
|
||||
layout="horizontal"
|
||||
title="Anyone can list all files in this bucket"
|
||||
description="A SELECT policy on storage.objects allows clients to retrieve a full list of files. Public buckets don’t need this and it may expose more data than intended."
|
||||
actions={
|
||||
<Button type="warning" size="tiny" onClick={onShowModal}>
|
||||
Remove policy
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
visible={isModalVisible}
|
||||
variant="destructive"
|
||||
title="Remove SELECT policy"
|
||||
confirmLabel="Remove policy"
|
||||
loading={isRemovingPolicy}
|
||||
onCancel={onHideModal}
|
||||
onConfirm={onRemovePolicy}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-foreground-light">
|
||||
This will drop the <code>SELECT</code> policy that makes the bucket's contents
|
||||
listable. Object URLs will continue to work.
|
||||
</p>
|
||||
<div className="-mx-4 md:-mx-5 -mb-4 border-t">
|
||||
<CodeBlock
|
||||
hideLineNumbers
|
||||
language="sql"
|
||||
value={generatePolicyRemovalSql(policyName)}
|
||||
wrapperClassName="[&_pre]:px-4 [&_pre]:py-3 [&>pre]:rounded-none [&>pre]:border-0 [&_pre>*]:!whitespace-pre-wrap"
|
||||
className="[&_code]:text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -54,12 +54,6 @@ export const STORAGE_CLIENT_LIBRARY_MAPPINGS = {
|
||||
getPublicUrl: [],
|
||||
}
|
||||
|
||||
export const CONTEXT_MENU_KEYS = {
|
||||
STORAGE_COLUMN: 'STORAGE_COLUMN',
|
||||
STORAGE_ITEM: 'STORAGE_ITEM',
|
||||
STORAGE_FOLDER: 'STORAGE_FOLDER',
|
||||
}
|
||||
|
||||
export const BUCKET_TYPES = {
|
||||
files: {
|
||||
displayName: 'Files',
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { compact, uniqBy } from 'lodash'
|
||||
import { Item, Menu, Separator, Submenu } from 'react-contexify'
|
||||
|
||||
import 'react-contexify/dist/ReactContexify.css'
|
||||
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { ChevronRight, ChevronsDown, ChevronsUp, Copy, Eye, FolderPlus } from 'lucide-react'
|
||||
|
||||
import {
|
||||
STORAGE_ROW_TYPES,
|
||||
STORAGE_SORT_BY,
|
||||
STORAGE_SORT_BY_ORDER,
|
||||
STORAGE_VIEWS,
|
||||
} from '../Storage.constants'
|
||||
import { useStoragePreference } from './useStoragePreference'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer'
|
||||
|
||||
interface ColumnContextMenuProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const ColumnContextMenu = ({ id = '' }: ColumnContextMenuProps) => {
|
||||
const {
|
||||
projectRef,
|
||||
columns,
|
||||
selectedItems,
|
||||
setSelectedItems,
|
||||
setSelectedFilePreview,
|
||||
refetchAllOpenedFolders,
|
||||
addNewFolderPlaceholder,
|
||||
} = useStorageExplorerStateSnapshot()
|
||||
const {
|
||||
setView,
|
||||
setSortBy: setPreferenceSortBy,
|
||||
setSortByOrder: setPreferenceSortByOrder,
|
||||
} = useStoragePreference(projectRef)
|
||||
|
||||
const setSortBy = async (value: STORAGE_SORT_BY) => {
|
||||
setPreferenceSortBy(value)
|
||||
setSelectedFilePreview(undefined)
|
||||
await refetchAllOpenedFolders()
|
||||
}
|
||||
|
||||
const setSortByOrder = async (value: STORAGE_SORT_BY_ORDER) => {
|
||||
setPreferenceSortByOrder(value)
|
||||
setSelectedFilePreview(undefined)
|
||||
await refetchAllOpenedFolders()
|
||||
}
|
||||
|
||||
const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
||||
|
||||
const onSelectCreateFolder = (columnIndex = -1) => {
|
||||
addNewFolderPlaceholder(columnIndex)
|
||||
}
|
||||
|
||||
const onSelectAllItemsInColumn = (columnIndex: number) => {
|
||||
const columnFiles = columns[columnIndex].items
|
||||
.filter((item) => item.type === STORAGE_ROW_TYPES.FILE)
|
||||
.map((item) => {
|
||||
return { ...item, columnIndex }
|
||||
})
|
||||
const columnFilesId = compact(columnFiles.map((item) => item.id))
|
||||
const selectedItemsFromColumn = selectedItems.filter(
|
||||
(item) => item.id && columnFilesId.includes(item.id)
|
||||
)
|
||||
|
||||
if (selectedItemsFromColumn.length === columnFiles.length) {
|
||||
// Deselect all items from column
|
||||
const updatedSelectedItems = selectedItems.filter(
|
||||
(item) => item.id && !columnFilesId.includes(item.id)
|
||||
)
|
||||
setSelectedItems(updatedSelectedItems)
|
||||
} else {
|
||||
// Select all items from column
|
||||
const updatedSelectedItems = uniqBy(selectedItems.concat(columnFiles), 'id')
|
||||
setSelectedItems(updatedSelectedItems)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu id={id} animation="fade">
|
||||
{canUpdateFiles && [
|
||||
<Item key="create-folder" onClick={({ props }) => onSelectCreateFolder(props.index)}>
|
||||
<FolderPlus size={14} />
|
||||
<span className="ml-2 text-xs">New folder</span>
|
||||
</Item>,
|
||||
<Separator key="create-folder-separator" />,
|
||||
]}
|
||||
<Item onClick={({ props }) => onSelectAllItemsInColumn(props.index)}>
|
||||
<Copy size={14} />
|
||||
<span className="ml-2 text-xs">Select all items</span>
|
||||
</Item>
|
||||
<Submenu
|
||||
label={
|
||||
<div className="flex items-center space-x-2">
|
||||
<Eye size={14} />
|
||||
<span className="text-xs">View</span>
|
||||
</div>
|
||||
}
|
||||
arrow={<ChevronRight size={14} />}
|
||||
>
|
||||
<Item onClick={() => setView(STORAGE_VIEWS.COLUMNS)}>
|
||||
<span className="ml-2 text-xs">As columns</span>
|
||||
</Item>
|
||||
<Item onClick={() => setView(STORAGE_VIEWS.LIST)}>
|
||||
<span className="ml-2 text-xs">As list</span>
|
||||
</Item>
|
||||
</Submenu>
|
||||
<Submenu
|
||||
label={
|
||||
<div className="flex items-center space-x-2">
|
||||
<ChevronsDown size={14} />
|
||||
<span className="ml-2 text-xs">Sort by</span>
|
||||
</div>
|
||||
}
|
||||
arrow={<ChevronRight size={14} />}
|
||||
>
|
||||
<Item onClick={() => setSortBy(STORAGE_SORT_BY.NAME)}>
|
||||
<span className="ml-2 text-xs">Name</span>
|
||||
</Item>
|
||||
<Item onClick={() => setSortBy(STORAGE_SORT_BY.CREATED_AT)}>
|
||||
<span className="ml-2 text-xs">Last created</span>
|
||||
</Item>
|
||||
<Item onClick={() => setSortBy(STORAGE_SORT_BY.UPDATED_AT)}>
|
||||
<span className="ml-2 text-xs">Last modified</span>
|
||||
</Item>
|
||||
<Item onClick={() => setSortBy(STORAGE_SORT_BY.LAST_ACCESSED_AT)}>
|
||||
<span className="ml-2 text-xs">Last accessed</span>
|
||||
</Item>
|
||||
</Submenu>
|
||||
<Submenu
|
||||
label={
|
||||
<div className="flex items-center space-x-2">
|
||||
<ChevronsUp size={14} />
|
||||
<span className="ml-2 text-xs">Sort by order</span>
|
||||
</div>
|
||||
}
|
||||
arrow={<ChevronRight size={14} />}
|
||||
>
|
||||
<Item onClick={() => setSortByOrder(STORAGE_SORT_BY_ORDER.ASC)}>
|
||||
<span className="ml-2 text-xs">Ascending</span>
|
||||
</Item>
|
||||
<Item onClick={() => setSortByOrder(STORAGE_SORT_BY_ORDER.DESC)}>
|
||||
<span className="ml-2 text-xs">Descending</span>
|
||||
</Item>
|
||||
</Submenu>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
@@ -2,12 +2,9 @@ import { noop } from 'lodash'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { CONTEXT_MENU_KEYS, STORAGE_ROW_STATUS, STORAGE_VIEWS } from '../Storage.constants'
|
||||
import { STORAGE_ROW_STATUS, STORAGE_VIEWS } from '../Storage.constants'
|
||||
import type { StorageColumn, StorageItemWithColumn } from '../Storage.types'
|
||||
import { ColumnContextMenu } from './ColumnContextMenu'
|
||||
import { FileExplorerColumn } from './FileExplorerColumn'
|
||||
import { FolderContextMenu } from './FolderContextMenu'
|
||||
import { ItemContextMenu } from './ItemContextMenu'
|
||||
import { useStoragePreference } from './useStoragePreference'
|
||||
import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer'
|
||||
|
||||
@@ -53,10 +50,6 @@ export const FileExplorer = ({
|
||||
view === STORAGE_VIEWS.LIST && 'flex-col'
|
||||
)}
|
||||
>
|
||||
<ColumnContextMenu id={CONTEXT_MENU_KEYS.STORAGE_COLUMN} />
|
||||
<ItemContextMenu id={CONTEXT_MENU_KEYS.STORAGE_ITEM} />
|
||||
<FolderContextMenu id={CONTEXT_MENU_KEYS.STORAGE_FOLDER} />
|
||||
|
||||
{isLoading ? (
|
||||
<FileExplorerColumn
|
||||
column={{ id: '', name: '', path: '', items: [], status: STORAGE_ROW_STATUS.LOADING }}
|
||||
|
||||
+226
-134
@@ -1,21 +1,33 @@
|
||||
import { Transition } from '@headlessui/react'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { get, noop, sum } from 'lodash'
|
||||
import { Upload } from 'lucide-react'
|
||||
import { get, noop, sum, uniqBy } from 'lodash'
|
||||
import { ChevronsDown, ChevronsUp, Copy, Eye, FolderPlus, Upload } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useContextMenu } from 'react-contexify'
|
||||
import { toast } from 'sonner'
|
||||
import { Checkbox, cn } from 'ui'
|
||||
import {
|
||||
Checkbox,
|
||||
cn,
|
||||
ContextMenu_Shadcn_,
|
||||
ContextMenuContent_Shadcn_,
|
||||
ContextMenuItem_Shadcn_,
|
||||
ContextMenuSeparator_Shadcn_,
|
||||
ContextMenuSub_Shadcn_,
|
||||
ContextMenuSubContent_Shadcn_,
|
||||
ContextMenuSubTrigger_Shadcn_,
|
||||
ContextMenuTrigger_Shadcn_,
|
||||
} from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import {
|
||||
CONTEXT_MENU_KEYS,
|
||||
STORAGE_ROW_STATUS,
|
||||
STORAGE_ROW_TYPES,
|
||||
STORAGE_SORT_BY,
|
||||
STORAGE_SORT_BY_ORDER,
|
||||
STORAGE_VIEWS,
|
||||
} from '../Storage.constants'
|
||||
import type { StorageColumn, StorageItemWithColumn } from '../Storage.types'
|
||||
import { FileExplorerRow } from './FileExplorerRow'
|
||||
import { FileExplorerRowContextMenuProvider } from './FileExplorerRowContextMenu'
|
||||
import { useStoragePreference } from './useStoragePreference'
|
||||
import { InfiniteListDefault, LoaderForIconMenuItems } from '@/components/ui/InfiniteList'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
@@ -84,7 +96,7 @@ export const FileExplorerColumn = ({
|
||||
const fileExplorerColumnRef = useRef<any>(null)
|
||||
|
||||
const snap = useStorageExplorerStateSnapshot()
|
||||
const { view } = useStoragePreference(snap.projectRef)
|
||||
const { view, setSortByOrder, setSortBy, setView } = useStoragePreference(snap.projectRef)
|
||||
const { can: canUpdateStorage } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -110,14 +122,6 @@ export const FileExplorerColumn = ({
|
||||
const isEmpty =
|
||||
column.items.filter((item) => item.status !== STORAGE_ROW_STATUS.LOADING).length === 0
|
||||
|
||||
const { show } = useContextMenu()
|
||||
const displayMenu = (event: any) => {
|
||||
show(event, {
|
||||
id: CONTEXT_MENU_KEYS.STORAGE_COLUMN,
|
||||
props: { index },
|
||||
})
|
||||
}
|
||||
|
||||
const onDragOver = (event: any) => {
|
||||
if (event) {
|
||||
event.stopPropagation()
|
||||
@@ -165,133 +169,221 @@ export const FileExplorerColumn = ({
|
||||
[view, index, selectedItems]
|
||||
)
|
||||
|
||||
const onSelectCreateFolder = () => {
|
||||
snap.addNewFolderPlaceholder(index)
|
||||
}
|
||||
|
||||
const onSelectAllItems = () => {
|
||||
const colFiles = snap.columns[index].items
|
||||
.filter((item) => item.type === STORAGE_ROW_TYPES.FILE)
|
||||
.map((item) => ({ ...item, columnIndex: index }))
|
||||
const colFilesId = colFiles.map((item) => item.id).filter(Boolean)
|
||||
const selectedFromCol = selectedItems.filter((item) => item.id && colFilesId.includes(item.id))
|
||||
|
||||
if (selectedFromCol.length === colFiles.length) {
|
||||
snap.setSelectedItems(
|
||||
selectedItems.filter((item) => item.id && !colFilesId.includes(item.id))
|
||||
)
|
||||
} else {
|
||||
snap.setSelectedItems(uniqBy(selectedItems.concat(colFiles), 'id'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={fileExplorerColumnRef}
|
||||
className={cn(
|
||||
fullWidth ? 'w-full' : 'w-64 border-r border-overlay',
|
||||
view === STORAGE_VIEWS.LIST && 'h-full',
|
||||
'hide-scrollbar relative flex flex-shrink-0 flex-col overflow-auto'
|
||||
)}
|
||||
onContextMenu={displayMenu}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onClick={(event) => {
|
||||
const eventTarget = get(event.target, ['className'], '')
|
||||
if (typeof eventTarget === 'string' && eventTarget.includes('react-contexify')) return
|
||||
onSelectColumnEmptySpace(index)
|
||||
}}
|
||||
>
|
||||
{/* Checkbox selection for select all */}
|
||||
{view === STORAGE_VIEWS.COLUMNS && (
|
||||
<ContextMenu_Shadcn_ modal={false}>
|
||||
<ContextMenuTrigger_Shadcn_ asChild>
|
||||
<div
|
||||
ref={fileExplorerColumnRef}
|
||||
className={cn(
|
||||
'sticky top-0 z-10 mb-0 flex items-center bg-table-header-light px-2.5 [[data-theme*=dark]_&]:bg-table-header-dark',
|
||||
haveSelectedItems ? 'h-10 py-3 opacity-100' : 'h-0 py-0 opacity-0',
|
||||
'transition-all duration-200'
|
||||
fullWidth ? 'w-full' : 'w-64 border-r border-overlay',
|
||||
view === STORAGE_VIEWS.LIST && 'h-full',
|
||||
'hide-scrollbar relative flex flex-shrink-0 flex-col overflow-auto'
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onClick={() => {
|
||||
onSelectColumnEmptySpace(index)
|
||||
}}
|
||||
>
|
||||
{columnFiles.length > 0 ? (
|
||||
<>
|
||||
<SelectAllCheckbox />
|
||||
<p className="text-sm text-foreground-light">Select all {columnFiles.length} files</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-foreground-light">No files available for selection</p>
|
||||
{/* Checkbox selection for select all */}
|
||||
{view === STORAGE_VIEWS.COLUMNS && (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 z-10 mb-0 flex items-center bg-table-header-light px-2.5 [[data-theme*=dark]_&]:bg-table-header-dark',
|
||||
haveSelectedItems ? 'h-10 py-3 opacity-100' : 'h-0 py-0 opacity-0',
|
||||
'transition-all duration-200'
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{columnFiles.length > 0 ? (
|
||||
<>
|
||||
<SelectAllCheckbox />
|
||||
<p className="text-sm text-foreground-light">
|
||||
Select all {columnFiles.length} files
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-foreground-light">No files available for selection</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List Interface Header */}
|
||||
{view === STORAGE_VIEWS.LIST && (
|
||||
<div className="sticky top-0 py-2 z-10 flex min-w-min items-center border-b border-overlay bg-surface-100 px-2.5">
|
||||
<div className="flex w-[40%] min-w-[250px] items-center">
|
||||
<SelectAllCheckbox />
|
||||
<p className="text-sm">Name</p>
|
||||
</div>
|
||||
<p className="w-[11%] min-w-[100px] text-sm">Size</p>
|
||||
<p className="w-[14%] min-w-[100px] text-sm">Type</p>
|
||||
<p className="w-[15%] min-w-[160px] text-sm">Created at</p>
|
||||
<p className="w-[15%] min-w-[160px] text-sm">Last modified at</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shimmering loaders while fetching contents */}
|
||||
{column.status === STORAGE_ROW_STATUS.LOADING && (
|
||||
<div
|
||||
className={`
|
||||
${fullWidth ? 'w-full' : 'w-64 border-r border-default'}
|
||||
px-2 py-1 my-1 flex flex-shrink-0 flex-col space-y-2 overflow-auto
|
||||
`}
|
||||
>
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column Interface */}
|
||||
{columnItems.length > 0 && (
|
||||
<FileExplorerRowContextMenuProvider>
|
||||
<InfiniteListDefault
|
||||
className="h-full"
|
||||
items={columnItems}
|
||||
itemProps={itemProps}
|
||||
getItemKey={getItemKey}
|
||||
getItemSize={(index) => (index !== 0 && index === columnItems.length ? 85 : 37)}
|
||||
ItemComponent={FileExplorerRow}
|
||||
LoaderComponent={LoaderForIconMenuItems}
|
||||
hasNextPage={column.status !== STORAGE_ROW_STATUS.LOADING && column.hasMoreItems}
|
||||
isLoadingNextPage={column.isLoadingMoreItems}
|
||||
onLoadNextPage={() => onColumnLoadMore(index, column)}
|
||||
/>
|
||||
</FileExplorerRowContextMenuProvider>
|
||||
)}
|
||||
|
||||
{/* Drag drop upload CTA for when column is empty */}
|
||||
{!(snap.isSearching && itemSearchString.length > 0) &&
|
||||
column.items.length === 0 &&
|
||||
column.status !== STORAGE_ROW_STATUS.LOADING && (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center">
|
||||
<img
|
||||
alt="storage-placeholder"
|
||||
src={`${BASE_PATH}/img/storage-placeholder.svg`}
|
||||
className="opacity-75 pointer-events-none"
|
||||
/>
|
||||
<p className="text-sm my-3 opacity-75">Drop your files here</p>
|
||||
<p className="w-40 text-center text-xs text-foreground-light">
|
||||
Or upload them via the "Upload file" button above
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snap.isSearching &&
|
||||
itemSearchString.length > 0 &&
|
||||
isEmpty &&
|
||||
column.status !== STORAGE_ROW_STATUS.LOADING && (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center">
|
||||
<p className="text-sm my-3 text-foreground">No results found in this folder</p>
|
||||
<p className="w-40 text-center text-sm text-foreground-light">
|
||||
Your search for "{itemSearchString}" did not return any results
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drag drop upload CTA for when column has files */}
|
||||
<DragOverOverlay
|
||||
isOpen={isDraggedOver}
|
||||
folderIsEmpty={isEmpty}
|
||||
onDragLeave={() => setIsDraggedOver(false)}
|
||||
onDrop={() => setIsDraggedOver(false)}
|
||||
/>
|
||||
|
||||
{/* List interface footer */}
|
||||
{view === STORAGE_VIEWS.LIST && (
|
||||
<div className="shrink-0 rounded-b-md z-10 flex min-w-min items-center bg-panel-footer-light px-2.5 py-2 [[data-theme*=dark]_&]:bg-panel-footer-dark w-full">
|
||||
<p className="text-sm">
|
||||
{formatBytes(columnItemsSize)} for {columnItems.length} items
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List Interface Header */}
|
||||
{view === STORAGE_VIEWS.LIST && (
|
||||
<div className="sticky top-0 py-2 z-10 flex min-w-min items-center border-b border-overlay bg-surface-100 px-2.5">
|
||||
<div className="flex w-[40%] min-w-[250px] items-center">
|
||||
<SelectAllCheckbox />
|
||||
<p className="text-sm">Name</p>
|
||||
</div>
|
||||
<p className="w-[11%] min-w-[100px] text-sm">Size</p>
|
||||
<p className="w-[14%] min-w-[100px] text-sm">Type</p>
|
||||
<p className="w-[15%] min-w-[160px] text-sm">Created at</p>
|
||||
<p className="w-[15%] min-w-[160px] text-sm">Last modified at</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shimmering loaders while fetching contents */}
|
||||
{column.status === STORAGE_ROW_STATUS.LOADING && (
|
||||
<div
|
||||
className={`
|
||||
${fullWidth ? 'w-full' : 'w-64 border-r border-default'}
|
||||
px-2 py-1 my-1 flex flex-shrink-0 flex-col space-y-2 overflow-auto
|
||||
`}
|
||||
>
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column Interface */}
|
||||
{columnItems.length > 0 && (
|
||||
<InfiniteListDefault
|
||||
className="h-full"
|
||||
items={columnItems}
|
||||
itemProps={itemProps}
|
||||
getItemKey={getItemKey}
|
||||
getItemSize={(index) => (index !== 0 && index === columnItems.length ? 85 : 37)}
|
||||
ItemComponent={FileExplorerRow}
|
||||
LoaderComponent={LoaderForIconMenuItems}
|
||||
hasNextPage={column.status !== STORAGE_ROW_STATUS.LOADING && column.hasMoreItems}
|
||||
isLoadingNextPage={column.isLoadingMoreItems}
|
||||
onLoadNextPage={() => onColumnLoadMore(index, column)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drag drop upload CTA for when column is empty */}
|
||||
{!(snap.isSearching && itemSearchString.length > 0) &&
|
||||
column.items.length === 0 &&
|
||||
column.status !== STORAGE_ROW_STATUS.LOADING && (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center">
|
||||
<img
|
||||
alt="storage-placeholder"
|
||||
src={`${BASE_PATH}/img/storage-placeholder.svg`}
|
||||
className="opacity-75 pointer-events-none"
|
||||
/>
|
||||
<p className="text-sm my-3 opacity-75">Drop your files here</p>
|
||||
<p className="w-40 text-center text-xs text-foreground-light">
|
||||
Or upload them via the "Upload file" button above
|
||||
</p>
|
||||
</div>
|
||||
</ContextMenuTrigger_Shadcn_>
|
||||
<ContextMenuContent_Shadcn_>
|
||||
{canUpdateStorage && (
|
||||
<>
|
||||
<ContextMenuItem_Shadcn_ className="gap-x-2" onSelect={onSelectCreateFolder}>
|
||||
<FolderPlus size={14} />
|
||||
<span className="text-xs">New folder</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
<ContextMenuSeparator_Shadcn_ />
|
||||
</>
|
||||
)}
|
||||
|
||||
{snap.isSearching &&
|
||||
itemSearchString.length > 0 &&
|
||||
isEmpty &&
|
||||
column.status !== STORAGE_ROW_STATUS.LOADING && (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center">
|
||||
<p className="text-sm my-3 text-foreground">No results found in this folder</p>
|
||||
<p className="w-40 text-center text-sm text-foreground-light">
|
||||
Your search for "{itemSearchString}" did not return any results
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drag drop upload CTA for when column has files */}
|
||||
<DragOverOverlay
|
||||
isOpen={isDraggedOver}
|
||||
folderIsEmpty={isEmpty}
|
||||
onDragLeave={() => setIsDraggedOver(false)}
|
||||
onDrop={() => setIsDraggedOver(false)}
|
||||
/>
|
||||
|
||||
{/* List interface footer */}
|
||||
{view === STORAGE_VIEWS.LIST && (
|
||||
<div className="shrink-0 rounded-b-md z-10 flex min-w-min items-center bg-panel-footer-light px-2.5 py-2 [[data-theme*=dark]_&]:bg-panel-footer-dark w-full">
|
||||
<p className="text-sm">
|
||||
{formatBytes(columnItemsSize)} for {columnItems.length} items
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ContextMenuItem_Shadcn_ className="gap-x-2" onSelect={onSelectAllItems}>
|
||||
<Copy size={14} />
|
||||
<span className="text-xs">Select all items</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
<ContextMenuSub_Shadcn_>
|
||||
<ContextMenuSubTrigger_Shadcn_ className="gap-x-2">
|
||||
<Eye size={14} />
|
||||
<span className="text-xs">View</span>
|
||||
</ContextMenuSubTrigger_Shadcn_>
|
||||
<ContextMenuSubContent_Shadcn_>
|
||||
<ContextMenuItem_Shadcn_ onSelect={() => setView(STORAGE_VIEWS.COLUMNS)}>
|
||||
<span className="text-xs">As columns</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
<ContextMenuItem_Shadcn_ onSelect={() => setView(STORAGE_VIEWS.LIST)}>
|
||||
<span className="text-xs">As list</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
</ContextMenuSubContent_Shadcn_>
|
||||
</ContextMenuSub_Shadcn_>
|
||||
<ContextMenuSub_Shadcn_>
|
||||
<ContextMenuSubTrigger_Shadcn_ className="gap-x-2">
|
||||
<ChevronsDown size={14} />
|
||||
<span className="text-xs">Sort by</span>
|
||||
</ContextMenuSubTrigger_Shadcn_>
|
||||
<ContextMenuSubContent_Shadcn_>
|
||||
<ContextMenuItem_Shadcn_ onSelect={() => setSortBy(STORAGE_SORT_BY.NAME)}>
|
||||
<span className="text-xs">Name</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
<ContextMenuItem_Shadcn_ onSelect={() => setSortBy(STORAGE_SORT_BY.CREATED_AT)}>
|
||||
<span className="text-xs">Last created</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
<ContextMenuItem_Shadcn_ onSelect={() => setSortBy(STORAGE_SORT_BY.UPDATED_AT)}>
|
||||
<span className="text-xs">Last modified</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
<ContextMenuItem_Shadcn_ onSelect={() => setSortBy(STORAGE_SORT_BY.LAST_ACCESSED_AT)}>
|
||||
<span className="text-xs">Last accessed</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
</ContextMenuSubContent_Shadcn_>
|
||||
</ContextMenuSub_Shadcn_>
|
||||
<ContextMenuSub_Shadcn_>
|
||||
<ContextMenuSubTrigger_Shadcn_ className="gap-x-2">
|
||||
<ChevronsUp size={14} />
|
||||
<span className="text-xs">Sort by order</span>
|
||||
</ContextMenuSubTrigger_Shadcn_>
|
||||
<ContextMenuSubContent_Shadcn_>
|
||||
<ContextMenuItem_Shadcn_ onSelect={() => setSortByOrder(STORAGE_SORT_BY_ORDER.ASC)}>
|
||||
<span className="text-xs">Ascending</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
<ContextMenuItem_Shadcn_ onSelect={() => setSortByOrder(STORAGE_SORT_BY_ORDER.DESC)}>
|
||||
<span className="text-xs">Descending</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
</ContextMenuSubContent_Shadcn_>
|
||||
</ContextMenuSub_Shadcn_>
|
||||
</ContextMenuContent_Shadcn_>
|
||||
</ContextMenu_Shadcn_>
|
||||
)
|
||||
}
|
||||
+5
-5
@@ -20,17 +20,17 @@ const {
|
||||
mockUseStoragePreference: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('lib/telemetry/track', () => ({ useTrack: () => mockTrack }))
|
||||
vi.mock('state/storage-explorer', () => ({
|
||||
vi.mock('@/lib/telemetry/track', () => ({ useTrack: () => mockTrack }))
|
||||
vi.mock('@/state/storage-explorer', () => ({
|
||||
useStorageExplorerStateSnapshot: () => mockUseStorageExplorerStateSnapshot(),
|
||||
}))
|
||||
vi.mock('hooks/misc/useCheckPermissions', () => ({
|
||||
vi.mock('@/hooks/misc/useCheckPermissions', () => ({
|
||||
useAsyncCheckPermissions: () => mockUseAsyncCheckPermissions(),
|
||||
}))
|
||||
vi.mock('components/interfaces/App/FeaturePreview/FeaturePreviewContext', () => ({
|
||||
vi.mock('@/components/interfaces/App/FeaturePreview/FeaturePreviewContext', () => ({
|
||||
useIsAPIDocsSidePanelEnabled: () => mockIsAPIDocsSidePanelEnabled(),
|
||||
}))
|
||||
vi.mock('components/ui/APIDocsButton', () => ({
|
||||
vi.mock('@/components/ui/APIDocsButton', () => ({
|
||||
APIDocsButton: () => null,
|
||||
}))
|
||||
vi.mock('./useStoragePreference', () => ({
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { useContextMenu } from 'react-contexify'
|
||||
import {
|
||||
Checkbox,
|
||||
cn,
|
||||
@@ -36,13 +35,13 @@ import {
|
||||
} from 'ui'
|
||||
|
||||
import {
|
||||
CONTEXT_MENU_KEYS,
|
||||
STORAGE_ROW_STATUS,
|
||||
STORAGE_ROW_TYPES,
|
||||
STORAGE_VIEWS,
|
||||
URL_EXPIRY_DURATION,
|
||||
} from '../Storage.constants'
|
||||
import { StorageItemWithColumn, type StorageItem } from '../Storage.types'
|
||||
import { useFileExplorerContextMenu } from './FileExplorerRowContextMenu'
|
||||
import { FileExplorerRowEditing } from './FileExplorerRowEditing'
|
||||
import { copyPathToFolder } from './StorageExplorer.utils'
|
||||
import { useCopyUrl } from './useCopyUrl'
|
||||
@@ -127,8 +126,8 @@ export const FileExplorerRow = ({
|
||||
downloadFolder,
|
||||
selectRangeItems,
|
||||
} = useStorageExplorerStateSnapshot()
|
||||
const { show } = useContextMenu()
|
||||
const { onCopyUrl } = useCopyUrl()
|
||||
const ctx = useFileExplorerContextMenu()
|
||||
|
||||
const isPublic = selectedBucket.public
|
||||
const itemWithColumnIndex = { ...item, columnIndex }
|
||||
@@ -272,18 +271,6 @@ export const FileExplorerRow = ({
|
||||
const createdAt = item.created_at ? new Date(item.created_at).toLocaleString() : '-'
|
||||
const updatedAt = item.updated_at ? new Date(item.updated_at).toLocaleString() : '-'
|
||||
|
||||
const displayMenu = (event: any, rowType: STORAGE_ROW_TYPES) => {
|
||||
show(event, {
|
||||
id:
|
||||
rowType === STORAGE_ROW_TYPES.FILE
|
||||
? CONTEXT_MENU_KEYS.STORAGE_ITEM
|
||||
: CONTEXT_MENU_KEYS.STORAGE_FOLDER,
|
||||
props: {
|
||||
item: itemWithColumnIndex,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const nameWidth =
|
||||
view === STORAGE_VIEWS.LIST && item.isCorrupted
|
||||
? `calc(100% - 60px)`
|
||||
@@ -301,12 +288,7 @@ export const FileExplorerRow = ({
|
||||
<div
|
||||
style={style}
|
||||
className="h-full border-b border-default"
|
||||
onContextMenu={(event) => {
|
||||
event.stopPropagation()
|
||||
item.type === STORAGE_ROW_TYPES.FILE
|
||||
? displayMenu(event, STORAGE_ROW_TYPES.FILE)
|
||||
: displayMenu(event, STORAGE_ROW_TYPES.FOLDER)
|
||||
}}
|
||||
onContextMenu={(e) => ctx?.onRowContextMenu(e, rowOptions)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import {
|
||||
ContextMenu_Shadcn_,
|
||||
ContextMenuContent_Shadcn_,
|
||||
ContextMenuItem_Shadcn_,
|
||||
ContextMenuSeparator_Shadcn_,
|
||||
ContextMenuSub_Shadcn_,
|
||||
ContextMenuSubContent_Shadcn_,
|
||||
ContextMenuSubTrigger_Shadcn_,
|
||||
ContextMenuTrigger_Shadcn_,
|
||||
} from 'ui'
|
||||
|
||||
export type RowOptionChild = { name: string; onClick: () => void }
|
||||
export type RowOption = {
|
||||
name: string
|
||||
icon?: ReactNode
|
||||
onClick?: () => void
|
||||
children?: RowOptionChild[]
|
||||
}
|
||||
|
||||
type FileExplorerContextMenuContextValue = {
|
||||
onRowContextMenu: (e: React.MouseEvent, options: RowOption[]) => void
|
||||
}
|
||||
|
||||
const FileExplorerContextMenuContext = createContext<FileExplorerContextMenuContextValue | null>(
|
||||
null
|
||||
)
|
||||
|
||||
export function useFileExplorerContextMenu() {
|
||||
return useContext(FileExplorerContextMenuContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Context provider for the file explorer row context menu. This allows us to reuse a single context menu and event handlers.
|
||||
*/
|
||||
export function FileExplorerRowContextMenuProvider({ children }: { children: ReactNode }) {
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const [contextMenuKey, setContextMenuKey] = useState(0)
|
||||
const [activeOptions, setActiveOptions] = useState<RowOption[]>([])
|
||||
|
||||
const onRowContextMenu = useCallback((e: React.MouseEvent, options: RowOption[]) => {
|
||||
e.preventDefault()
|
||||
setActiveOptions(options)
|
||||
setContextMenuKey((prev) => prev + 1)
|
||||
const trigger = triggerRef.current
|
||||
if (!trigger) return
|
||||
trigger.style.left = `${e.clientX}px`
|
||||
trigger.style.top = `${e.clientY}px`
|
||||
trigger.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<FileExplorerContextMenuContext.Provider value={{ onRowContextMenu }}>
|
||||
<ContextMenu_Shadcn_ modal={false}>
|
||||
<ContextMenuTrigger_Shadcn_ asChild>
|
||||
<div ref={triggerRef} className="fixed pointer-events-none w-0 h-0" />
|
||||
</ContextMenuTrigger_Shadcn_>
|
||||
<ContextMenuContent_Shadcn_
|
||||
key={contextMenuKey}
|
||||
onCloseAutoFocus={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{activeOptions.map((option) => {
|
||||
if ((option.children ?? []).length > 0) {
|
||||
return (
|
||||
<ContextMenuSub_Shadcn_ key={option.name}>
|
||||
<ContextMenuSubTrigger_Shadcn_ className="gap-x-2">
|
||||
{option.icon || <></>}
|
||||
<span className="text-xs">{option.name}</span>
|
||||
</ContextMenuSubTrigger_Shadcn_>
|
||||
<ContextMenuSubContent_Shadcn_>
|
||||
{(option.children ?? []).map((child) => (
|
||||
<ContextMenuItem_Shadcn_ key={child.name} onSelect={child.onClick}>
|
||||
<span className="text-xs">{child.name}</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
))}
|
||||
</ContextMenuSubContent_Shadcn_>
|
||||
</ContextMenuSub_Shadcn_>
|
||||
)
|
||||
} else if (option.name === 'Separator') {
|
||||
return <ContextMenuSeparator_Shadcn_ key={option.name} />
|
||||
} else {
|
||||
return (
|
||||
<ContextMenuItem_Shadcn_
|
||||
className="gap-x-2"
|
||||
key={option.name}
|
||||
onSelect={option.onClick}
|
||||
>
|
||||
{option.icon || <></>}
|
||||
<span className="text-xs">{option.name}</span>
|
||||
</ContextMenuItem_Shadcn_>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</ContextMenuContent_Shadcn_>
|
||||
</ContextMenu_Shadcn_>
|
||||
{children}
|
||||
</FileExplorerContextMenuContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Copy, Download, Edit, Trash2 } from 'lucide-react'
|
||||
import { Item, Menu, Separator } from 'react-contexify'
|
||||
|
||||
import 'react-contexify/dist/ReactContexify.css'
|
||||
|
||||
import { copyPathToFolder } from './StorageExplorer.utils'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer'
|
||||
|
||||
interface FolderContextMenuProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const FolderContextMenu = ({ id = '' }: FolderContextMenuProps) => {
|
||||
const { openedFolders, downloadFolder, setSelectedItemToRename, setSelectedItemsToDelete } =
|
||||
useStorageExplorerStateSnapshot()
|
||||
const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
||||
|
||||
return (
|
||||
<Menu id={id} animation="fade">
|
||||
{canUpdateFiles && (
|
||||
<Item onClick={({ props }) => setSelectedItemToRename(props.item)}>
|
||||
<Edit size={12} />
|
||||
<span className="ml-2 text-xs">Rename</span>
|
||||
</Item>
|
||||
)}
|
||||
<Item onClick={({ props }) => downloadFolder(props.item)}>
|
||||
<Download size={12} />
|
||||
<span className="ml-2 text-xs">Download</span>
|
||||
</Item>
|
||||
<Item onClick={({ props }) => copyPathToFolder(openedFolders, props.item)}>
|
||||
<Copy size={12} />
|
||||
<span className="ml-2 text-xs">Copy path to folder</span>
|
||||
</Item>
|
||||
{canUpdateFiles && [
|
||||
<Separator key="separator" />,
|
||||
<Item key="delete" onClick={({ props }) => setSelectedItemsToDelete([props.item])}>
|
||||
<Trash2 size={12} />
|
||||
<span className="ml-2 text-xs">Delete</span>
|
||||
</Item>,
|
||||
]}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { ChevronRight, Copy, Download, Edit, Move, Trash2 } from 'lucide-react'
|
||||
import { Item, Menu, Separator, Submenu } from 'react-contexify'
|
||||
|
||||
import 'react-contexify/dist/ReactContexify.css'
|
||||
|
||||
import { useParams } from 'common'
|
||||
|
||||
import { URL_EXPIRY_DURATION } from '../Storage.constants'
|
||||
import { StorageItemWithColumn } from '../Storage.types'
|
||||
import { useCopyUrl } from './useCopyUrl'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer'
|
||||
|
||||
interface ItemContextMenuProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const ItemContextMenu = ({ id = '' }: ItemContextMenuProps) => {
|
||||
const snap = useStorageExplorerStateSnapshot()
|
||||
const { setSelectedFileCustomExpiry } = snap
|
||||
|
||||
const {
|
||||
selectedBucket,
|
||||
setSelectedItemsToDelete,
|
||||
setSelectedItemToRename,
|
||||
setSelectedItemsToMove,
|
||||
downloadFile,
|
||||
} = useStorageExplorerStateSnapshot()
|
||||
const { onCopyUrl } = useCopyUrl()
|
||||
const isPublic = selectedBucket.public
|
||||
const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
||||
|
||||
const onHandleClick = async (event: any, item: StorageItemWithColumn, expiresIn?: number) => {
|
||||
if (item.isCorrupted) return
|
||||
switch (event) {
|
||||
case 'copy':
|
||||
if (expiresIn !== undefined && expiresIn < 0) return setSelectedFileCustomExpiry(item)
|
||||
if (item.path) return onCopyUrl(item.path, expiresIn)
|
||||
case 'rename':
|
||||
return setSelectedItemToRename(item)
|
||||
case 'move':
|
||||
return setSelectedItemsToMove([item])
|
||||
case 'download':
|
||||
return await downloadFile(item)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu id={id} animation="fade">
|
||||
{isPublic ? (
|
||||
<Item onClick={({ props }) => onHandleClick('copy', props.item)}>
|
||||
<Copy size={14} />
|
||||
<span className="ml-2 text-xs">Get URL</span>
|
||||
</Item>
|
||||
) : (
|
||||
<Submenu
|
||||
label={
|
||||
<div className="flex items-center space-x-2">
|
||||
<Copy size={14} />
|
||||
<span className="text-xs">Get URL</span>
|
||||
</div>
|
||||
}
|
||||
arrow={<ChevronRight size={14} />}
|
||||
>
|
||||
<Item
|
||||
onClick={({ props }) => onHandleClick('copy', props.item, URL_EXPIRY_DURATION.WEEK)}
|
||||
>
|
||||
<span className="ml-2 text-xs">Expire in 1 week</span>
|
||||
</Item>
|
||||
<Item
|
||||
onClick={({ props }) => onHandleClick('copy', props.item, URL_EXPIRY_DURATION.MONTH)}
|
||||
>
|
||||
<span className="ml-2 text-xs">Expire in 1 month</span>
|
||||
</Item>
|
||||
<Item
|
||||
onClick={({ props }) => onHandleClick('copy', props.item, URL_EXPIRY_DURATION.YEAR)}
|
||||
>
|
||||
<span className="ml-2 text-xs">Expire in 1 year</span>
|
||||
</Item>
|
||||
<Item onClick={({ props }) => onHandleClick('copy', props.item, -1)}>
|
||||
<span className="ml-2 text-xs">Custom expiry</span>
|
||||
</Item>
|
||||
</Submenu>
|
||||
)}
|
||||
<Item onClick={({ props }) => onHandleClick('download', props.item)}>
|
||||
<Download size={14} strokeWidth={1} />
|
||||
<span className="ml-2 text-xs">Download</span>
|
||||
</Item>
|
||||
{canUpdateFiles && [
|
||||
<Item key="rename-file" onClick={({ props }) => onHandleClick('rename', props.item)}>
|
||||
<Edit size={14} strokeWidth={1} />
|
||||
<span className="ml-2 text-xs">Rename</span>
|
||||
</Item>,
|
||||
<Item key="move-file" onClick={({ props }) => onHandleClick('move', props.item)}>
|
||||
<Move size={14} strokeWidth={1} />
|
||||
<span className="ml-2 text-xs">Move</span>
|
||||
</Item>,
|
||||
<Separator key="file-separator" />,
|
||||
<Item key="delete-file" onClick={({ props }) => setSelectedItemsToDelete([props.item])}>
|
||||
<Trash2 size={14} strokeWidth={1} stroke="red" />
|
||||
<span className="ml-2 text-xs">Delete</span>
|
||||
</Item>,
|
||||
]}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ vi.mock('sonner', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('data/utils/deployment-commit-query', () => ({
|
||||
vi.mock('@/data/utils/deployment-commit-query', () => ({
|
||||
useDeploymentCommitQuery: mockUseDeploymentCommitQuery,
|
||||
}))
|
||||
|
||||
|
||||
+1
-5
@@ -2,8 +2,6 @@ import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { Loader2, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { DndProvider } from 'react-dnd'
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend'
|
||||
import { Button, SidePanel } from 'ui'
|
||||
|
||||
import { ForeignKey } from '../../ForeignKeySelector/ForeignKeySelector.types'
|
||||
@@ -216,9 +214,7 @@ export const ForeignRowSelector = ({
|
||||
<div className="flex items-center">
|
||||
<RefreshButton tableId={table?.id} isRefetching={isRefetching} />
|
||||
<FilterPopoverPrimitive filters={filters} onApplyFilters={onApplyFilters} />
|
||||
<DndProvider backend={HTML5Backend} context={window}>
|
||||
<SortPopoverPrimitive sorts={sorts} onApplySorts={onApplySorts} />
|
||||
</DndProvider>
|
||||
<SortPopoverPrimitive sorts={sorts} onApplySorts={onApplySorts} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-3 divide-x">
|
||||
|
||||
+6
-6
@@ -16,26 +16,26 @@ const mockToastError = vi.fn()
|
||||
const mockFetchQuery = vi.fn()
|
||||
|
||||
// Setup mocks before imports
|
||||
vi.mock('data/query-client', () => ({
|
||||
vi.mock('@/data/query-client', () => ({
|
||||
getQueryClient: () => ({
|
||||
fetchQuery: mockFetchQuery,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('data/sql/execute-sql-query', () => ({
|
||||
vi.mock('@/data/sql/execute-sql-query', () => ({
|
||||
executeSql: (...args: unknown[]) => mockExecuteSql(...args),
|
||||
}))
|
||||
|
||||
vi.mock('data/tables/table-retrieve-query', () => ({
|
||||
vi.mock('@/data/tables/table-retrieve-query', () => ({
|
||||
getTable: (...args: unknown[]) => mockGetTable(...args),
|
||||
getTableQuery: (...args: unknown[]) => mockGetTable(...args),
|
||||
}))
|
||||
|
||||
vi.mock('data/telemetry/send-event-mutation', () => ({
|
||||
vi.mock('@/data/telemetry/send-event-mutation', () => ({
|
||||
sendEvent: (...args: unknown[]) => mockSendEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('data/prefetchers/project.$ref.editor.$id', () => ({
|
||||
vi.mock('@/data/prefetchers/project.$ref.editor.$id', () => ({
|
||||
prefetchEditorTablePage: (...args: unknown[]) => mockPrefetchEditorTablePage(...args),
|
||||
}))
|
||||
|
||||
@@ -48,7 +48,7 @@ vi.mock('sonner', () => ({
|
||||
}))
|
||||
|
||||
// Mock SparkBar component used in toast
|
||||
vi.mock('components/ui/SparkBar', () => ({
|
||||
vi.mock('@/components/ui/SparkBar', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
|
||||
+23
-8
@@ -1,6 +1,7 @@
|
||||
import { Link, Menu, Plus, Settings, X } from 'lucide-react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { GripVertical, Link, Plus, Settings, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { DraggableProvidedDragHandleProps } from 'react-beautiful-dnd'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -56,7 +57,6 @@ interface ColumnProps {
|
||||
isNewRecord: boolean
|
||||
hasForeignKeys: boolean
|
||||
hasImportContent: boolean
|
||||
dragHandleProps?: DraggableProvidedDragHandleProps | null
|
||||
onUpdateColumn: (changes: Partial<ColumnField>) => void
|
||||
onRemoveColumn: () => void
|
||||
onEditForeignKey: (relation?: ForeignKey) => void
|
||||
@@ -69,7 +69,6 @@ export const Column = ({
|
||||
isNewRecord = false,
|
||||
hasForeignKeys = false,
|
||||
hasImportContent = false,
|
||||
dragHandleProps,
|
||||
onUpdateColumn,
|
||||
onRemoveColumn,
|
||||
onEditForeignKey,
|
||||
@@ -108,12 +107,28 @@ export const Column = ({
|
||||
.map((r) => getRelationStatus(r))
|
||||
.some((x) => x !== undefined)
|
||||
|
||||
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition } =
|
||||
useSortable({
|
||||
id: column.id,
|
||||
})
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center">
|
||||
<div className="flex w-full items-center" ref={setNodeRef} style={style}>
|
||||
<div className={`w-[5%] ${!isNewRecord ? 'hidden' : ''}`}>
|
||||
<div className="cursor-drag" {...dragHandleProps}>
|
||||
<Menu strokeWidth={1} size={16} />
|
||||
</div>
|
||||
<button
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="opacity-50 hover:opacity-100 disabled:hover:opacity-50 transition cursor-grab text-foreground"
|
||||
type="button"
|
||||
>
|
||||
<GripVertical size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-[25%]">
|
||||
<div className="flex w-[95%] items-center justify-between">
|
||||
|
||||
+93
-95
@@ -1,15 +1,22 @@
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { useParams } from 'common'
|
||||
import { isEmpty, noop, partition } from 'lodash'
|
||||
import { Edit, ExternalLink, HelpCircle, Key, Trash } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
DragDropContext,
|
||||
Draggable,
|
||||
Droppable,
|
||||
type DraggableProvided,
|
||||
type DroppableProvided,
|
||||
type DropResult,
|
||||
} from 'react-beautiful-dnd'
|
||||
import {
|
||||
Alert_Shadcn_,
|
||||
AlertDescription_Shadcn_,
|
||||
@@ -115,29 +122,38 @@ export const ColumnManagement = ({
|
||||
onColumnsUpdated(updatedColumns)
|
||||
}
|
||||
|
||||
const onSortColumns = (result: DropResult, type: 'pks' | 'others') => {
|
||||
const onSortColumns = (result: DragEndEvent, type: 'pks' | 'others') => {
|
||||
// Dropped outside of the list
|
||||
if (!result.destination) {
|
||||
if (result.over == null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'pks') {
|
||||
const updatedPrimaryKeyColumns = primaryKeyColumns.slice()
|
||||
const [removed] = updatedPrimaryKeyColumns.splice(result.source.index, 1)
|
||||
updatedPrimaryKeyColumns.splice(result.destination.index, 0, removed)
|
||||
const activeIndex = primaryKeyColumns.findIndex((item) => item.id === result.active.id)
|
||||
const overIndex = primaryKeyColumns.findIndex((item) => item.id === result.over!.id)
|
||||
if (activeIndex === -1 || overIndex === -1) return
|
||||
const updatedPrimaryKeyColumns = arrayMove(primaryKeyColumns, activeIndex, overIndex)
|
||||
const updatedColumns = updatedPrimaryKeyColumns.concat(otherColumns)
|
||||
return onColumnsUpdated(updatedColumns)
|
||||
}
|
||||
|
||||
if (type === 'others') {
|
||||
const updatedOtherColumns = otherColumns.slice()
|
||||
const [removed] = updatedOtherColumns.splice(result.source.index, 1)
|
||||
updatedOtherColumns.splice(result.destination.index, 0, removed)
|
||||
const activeIndex = otherColumns.findIndex((item) => item.id === result.active.id)
|
||||
const overIndex = otherColumns.findIndex((item) => item.id === result.over!.id)
|
||||
if (activeIndex === -1 || overIndex === -1) return
|
||||
const updatedOtherColumns = arrayMove(otherColumns, activeIndex, overIndex)
|
||||
const updatedColumns = primaryKeyColumns.concat(updatedOtherColumns)
|
||||
return onColumnsUpdated(updatedColumns)
|
||||
}
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full space-y-4 table-editor-columns">
|
||||
@@ -259,89 +275,71 @@ export const ColumnManagement = ({
|
||||
</div>
|
||||
|
||||
{primaryKeyColumns.length > 0 && (
|
||||
<DragDropContext onDragEnd={(result) => onSortColumns(result, 'pks')}>
|
||||
<Droppable droppableId="pk_columns_droppable">
|
||||
{(droppableProvided: DroppableProvided) => (
|
||||
<div
|
||||
ref={droppableProvided.innerRef}
|
||||
className={`space-y-2 rounded-md bg-surface-200 px-3 py-2 ${
|
||||
isNewRecord ? '' : '-mx-3'
|
||||
}`}
|
||||
>
|
||||
{primaryKeyColumns.map((column: ColumnField, index: number) => (
|
||||
<Draggable key={column.id} draggableId={column.id} index={index}>
|
||||
{(draggableProvided: DraggableProvided) => (
|
||||
<div
|
||||
ref={draggableProvided.innerRef}
|
||||
{...draggableProvided.draggableProps}
|
||||
>
|
||||
<Column
|
||||
column={column}
|
||||
relations={relations.filter((relation) => {
|
||||
return relation.columns.some((x) => x.source === column.name)
|
||||
})}
|
||||
enumTypes={enumTypes}
|
||||
hasForeignKeys={checkIfHaveForeignKeys(column)}
|
||||
isNewRecord={isNewRecord}
|
||||
hasImportContent={hasImportContent}
|
||||
dragHandleProps={draggableProvided.dragHandleProps}
|
||||
onUpdateColumn={(changes) => onUpdateColumn(column, changes)}
|
||||
onRemoveColumn={() => onRemoveColumn(column)}
|
||||
onEditForeignKey={(fk) => {
|
||||
setOpen(true)
|
||||
setSelectedColumn(column)
|
||||
if (fk) setSelectedFk(fk)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{droppableProvided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)}
|
||||
|
||||
<DragDropContext onDragEnd={(result) => onSortColumns(result, 'others')}>
|
||||
<Droppable droppableId="other_columns_droppable">
|
||||
{(droppableProvided: DroppableProvided) => (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(result) => onSortColumns(result, 'pks')}
|
||||
>
|
||||
<SortableContext items={primaryKeyColumns} strategy={verticalListSortingStrategy}>
|
||||
<div
|
||||
ref={droppableProvided.innerRef}
|
||||
className={`space-y-2 py-2 ${isNewRecord ? 'px-3 ' : ''}`}
|
||||
className={`space-y-2 rounded-md bg-surface-200 px-3 py-2 ${
|
||||
isNewRecord ? '' : '-mx-3'
|
||||
}`}
|
||||
>
|
||||
{otherColumns.map((column: ColumnField, index: number) => (
|
||||
<Draggable key={column.id} draggableId={column.id} index={index}>
|
||||
{(draggableProvided: DraggableProvided) => (
|
||||
<div ref={draggableProvided.innerRef} {...draggableProvided.draggableProps}>
|
||||
<Column
|
||||
column={column}
|
||||
relations={relations.filter((relation) => {
|
||||
return relation.columns.some((x) => x.source === column.name)
|
||||
})}
|
||||
enumTypes={enumTypes}
|
||||
isNewRecord={isNewRecord}
|
||||
hasForeignKeys={checkIfHaveForeignKeys(column)}
|
||||
hasImportContent={hasImportContent}
|
||||
dragHandleProps={draggableProvided.dragHandleProps}
|
||||
onUpdateColumn={(changes) => onUpdateColumn(column, changes)}
|
||||
onRemoveColumn={() => onRemoveColumn(column)}
|
||||
onEditForeignKey={(fk) => {
|
||||
setOpen(true)
|
||||
setSelectedColumn(column)
|
||||
if (fk) setSelectedFk(fk)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
{primaryKeyColumns.map((column: ColumnField, index: number) => (
|
||||
<Column
|
||||
key={column.id}
|
||||
column={column}
|
||||
relations={relations.filter((relation) => {
|
||||
return relation.columns.some((x) => x.source === column.name)
|
||||
})}
|
||||
enumTypes={enumTypes}
|
||||
hasForeignKeys={checkIfHaveForeignKeys(column)}
|
||||
isNewRecord={isNewRecord}
|
||||
hasImportContent={hasImportContent}
|
||||
onUpdateColumn={(changes) => onUpdateColumn(column, changes)}
|
||||
onRemoveColumn={() => onRemoveColumn(column)}
|
||||
onEditForeignKey={(fk) => {
|
||||
setOpen(true)
|
||||
setSelectedColumn(column)
|
||||
if (fk) setSelectedFk(fk)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{droppableProvided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(result) => onSortColumns(result, 'others')}
|
||||
>
|
||||
<SortableContext items={otherColumns} strategy={verticalListSortingStrategy}>
|
||||
<div className={`space-y-2 py-2 ${isNewRecord ? 'px-3 ' : ''}`}>
|
||||
{otherColumns.map((column: ColumnField, index: number) => (
|
||||
<Column
|
||||
key={column.id}
|
||||
column={column}
|
||||
relations={relations.filter((relation) => {
|
||||
return relation.columns.some((x) => x.source === column.name)
|
||||
})}
|
||||
enumTypes={enumTypes}
|
||||
isNewRecord={isNewRecord}
|
||||
hasForeignKeys={checkIfHaveForeignKeys(column)}
|
||||
hasImportContent={hasImportContent}
|
||||
onUpdateColumn={(changes) => onUpdateColumn(column, changes)}
|
||||
onRemoveColumn={() => onRemoveColumn(column)}
|
||||
onEditForeignKey={(fk) => {
|
||||
setOpen(true)
|
||||
setSelectedColumn(column)
|
||||
if (fk) setSelectedFk(fk)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{!hasImportContent && (
|
||||
|
||||
Loaded 100 of 546 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user