No files matched your search
@@ -107,6 +107,10 @@ http://localhost:54321/auth/v1/ # Auth (GoTrue)
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::note
|
||||
To access the database from an edge function in your local Supabase setup, replace `localhost` with `host.docker.internal`.
|
||||
:::
|
||||
|
||||
## Database migrations
|
||||
|
||||
Database changes are managed through "migrations." Database migrations are a common way of tracking changes to your database over time.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
id: polyscale
|
||||
title: 'PolyScale'
|
||||
description: 'The easiest way to get low-latency reads from your Supabase database for multi-region applications is by using PolyScale, a code-free global caching service.'
|
||||
---
|
||||
|
||||
[PolyScale](https://polyscale.ai) is an intelligent, serverless database caching engine which allows low-latency reads from Supabase globally, no coding required. Supabase can be connected to PolyScale in minutes, providing you fast access to your Supabase data around the globe.
|
||||
|
||||
This guide explains how to connect Supabase to a PolyScale cache.
|
||||
|
||||
The video below illustrates how to get connected. Or you can read the steps below.
|
||||
|
||||
<div class="video-container">
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/ZQ8TG-A-CIw?rel=0"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; modestbranding; showinfo=0"
|
||||
allowfullscreen
|
||||
width="100%"
|
||||
height="400px"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
## Step 0: Create a PolyScale account
|
||||
|
||||
If you do not already have a PolyScale account, you can create an account [here](https://app.polyscale.ai/signup). PolyScale offers a free tier and no credit card is required.
|
||||
|
||||
## Step 1: Create your PolyScale Cache
|
||||
|
||||
### 1.1 Retrieve your Supabase Host
|
||||
|
||||
In your Supabase project, click on `Settings > Database` and scroll down to the `Connection info` section to copy your database `Host`.
|
||||
|
||||

|
||||
|
||||
### 1.2 Configure your PolyScale Cache
|
||||
|
||||
- In your PolyScale account, click on the **New Cache** button
|
||||
- Give the cache a **Name**
|
||||
- Select **PostgreSQL** for the **Type**
|
||||
- Enter the **Host** from Step 1.1 above
|
||||
- Enter `5432` for the **Port**
|
||||
- Click **Create**
|
||||
|
||||

|
||||
|
||||
Your cache is now created. PolyScale automatically checks to see that your database is accessible from all our global PoPs.
|
||||
|
||||
## Step 2: Connect to your PolyScale Cache
|
||||
|
||||
Using your PolyScale cache is simple -- instead of connecting to your Supabase database directly, you'll replace your orignal connection string with the PolyScale connection string in your application.
|
||||
|
||||
For example, if your original connection string was: `postgres://postgres:zqSPGHFAbPLvVCKw@db.rogpiubvixysbakciwqz.supabase.co:5432`
|
||||
|
||||
Your PolyScale connection string would be: `postgres://postgres:zqSPGHFAbPLvVCKw@psedge.global:5432?application_name=a645cb93-fa53-46b2-9d6c-227e357e5bfb`
|
||||
|
||||
You can read more about connecting to PolyScale [here](https://docs.polyscale.ai/connecting-to-polyscale#postgresql)
|
||||
|
||||
That's it.
|
||||
|
||||
## All done!
|
||||
|
||||
You can read more about PolyScale [here](https://www.polyscale.ai/) or check out our [documentation](https://docs.polyscale.ai/).
|
||||
@@ -220,6 +220,7 @@ const sidebars = {
|
||||
'guides/integrations/draftbit',
|
||||
'guides/integrations/pgmustard',
|
||||
'guides/integrations/plasmic',
|
||||
'guides/integrations/polyscale',
|
||||
'guides/integrations/prisma',
|
||||
'guides/integrations/sequin',
|
||||
'guides/integrations/snaplet',
|
||||
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 199 KiB |
@@ -0,0 +1,243 @@
|
||||
---
|
||||
title: Postgres WASM by Snaplet and Supabase
|
||||
description: We're open sourcing postgres-wasm, a PostgresQL server that runs inside a browser, with our friends at Snaplet.
|
||||
author: burggraf
|
||||
image: postgres-wasm/postgres-wasm.jpg
|
||||
thumb: postgres-wasm/postgres-wasm.jpg
|
||||
tags:
|
||||
- postgres
|
||||
date: '2022-10-03'
|
||||
toc_depth: 3
|
||||
---
|
||||
|
||||
Today we're open sourcing [`postgres-wasm`](https://github.com/snaplet/postgres-wasm) with our friends at [Snaplet](https://www.snaplet.dev/).
|
||||
|
||||
postgres-wasm is a PostgreSQL server that runs inside a browser. It provides a full suite of features, including persisting state to browser,
|
||||
restoring from pg_dump, and logical replication from a remote database.
|
||||
|
||||
We're not the first to run Postgres in the browser - that title belongs to the team at [Crunchy Data](https://www.crunchydata.com/) who shared their
|
||||
version of this [on HN](https://news.ycombinator.com/item?id=32498435) a month ago. It's awesome, and we wanted
|
||||
an [open source version](https://staltz.com/time-till-open-source-alternative.html), so we teamed up with Snaplet build it.
|
||||
Let's explore how it's built, and some extra features we've added.
|
||||
|
||||
<div className="bg-gray-300 rounded-lg p-6 italic">
|
||||
|
||||
🐈 Who's Snaplet?
|
||||
<br />
|
||||
Snaplet gives developers production-accurate data and preview databases that they can code against, so they can focus on shipping.
|
||||
<br />
|
||||
Checkout out how you can <a href="https://docs.snaplet.dev/tutorials/supabase-clone-environments">use Snaplet to clone Supabase environments</a>.
|
||||
|
||||
</div>
|
||||
|
||||
## Demo
|
||||
|
||||
Before we get into the technical details, try it out yourself (warning: it will download ~30MB):
|
||||
|
||||
[wasm.supabase.com](https://wasm.supabase.com)
|
||||
|
||||

|
||||
|
||||
To run it locally:
|
||||
|
||||
```bash
|
||||
# From Snaplet Repo
|
||||
git clone git@github.com:snaplet/postgres-wasm.git
|
||||
cd postgres-browser/packages/pg-browser
|
||||
npx serve
|
||||
|
||||
# From Supabase Fork
|
||||
git clone git@github.com:supabase-community/postgres-wasm.git
|
||||
cd postgres-wasm
|
||||
git checkout web
|
||||
cd packages/supabrowser
|
||||
npx serve
|
||||
```
|
||||
|
||||
And open a browser at [localhost:3000](http://localhost:3000).
|
||||
|
||||
## Features
|
||||
|
||||
Our demo version has a few neat features!
|
||||
|
||||
- Postgres 14.5, psql, pg_dump, etc.
|
||||
- Save & restore state to/from a file.
|
||||
- Save & restore Postgres state to/from the browser storage (IndexedDB).
|
||||
- Quick start from a state file or fully reboot the emulator.
|
||||
- Memory configuration options from 128MB to 1024MB.
|
||||
- Adjust the font size for the terminal.
|
||||
- Upload files to the emulator (including database dumps and CSVs).
|
||||
- Download files from the emulator.
|
||||
- Outgoing network connectivity from the emulator to the internet.
|
||||
- Incoming network tunnel to postgres port 5432 inside the emulator.
|
||||
|
||||
## Why?
|
||||
|
||||
That's a good question. `postgres-wasm` is currently about 30mb. So at this stage, running Postgres in the browser isn't great for general use-cases.
|
||||
It has a lot of potential though. Some ideas we'll be playing with over the next few months:
|
||||
|
||||
- **Documentation:** for tutorials and demos.
|
||||
- **Offline data:** running it in the browser for an offline cache, similar to [sql.js](https://sql.js.org/#/) or [absurd-sql](https://github.com/jlongster/absurd-sql).
|
||||
- **Offline data analysis:** using it in a dashboard for offline data analysis and charts.
|
||||
- **Testing:** testing PostgresSQL functions, triggers, data modeling, logical replication, etc.
|
||||
- **Dev environments:** use it as a development environment — pull data from production or push new data, functions, triggers, views up to production.
|
||||
- **Snapshots:** create a test version of your database with sample data, then take a snapshot to send to other developers.
|
||||
- **Support:** send snapshots of your database to support personnel to demonstrate an issue you're having.
|
||||
|
||||
## Repository Overview
|
||||
|
||||
We've divided in our repository into three folders: a virtual machine, a web application, and a network proxy.
|
||||
|
||||
### Virtual Machine (VM)
|
||||
|
||||
We create an embeddable Virtual Machine (VM) using [Buildroot](https://en.wikipedia.org/wiki/Buildroot).
|
||||
Our VM is a stripped-down Linux build with Postgres installed.
|
||||
|
||||
See [source code](https://github.com/snaplet/postgres-browser/tree/main/packages/buildroot).
|
||||
|
||||
### Web application
|
||||
|
||||
Next, we need to run the the VM inside our browser. How? [WASM](https://en.wikipedia.org/wiki/WebAssembly).
|
||||
We use [v86](https://github.com/copy/v86) to run our VM inside the browser. Our demo application is very simple - plain HTML and some basic styling.
|
||||
|
||||
See [source code](https://github.com/snaplet/postgres-browser/tree/main/packages/pg-browser).
|
||||
|
||||
### Network proxy
|
||||
|
||||
Running Postgres in a browser is great, but connecting to it with PgAdmin is even better. Unfortunately browsers block TCP network access to the VM.
|
||||
To circumvent this, we proxy the traffic through websockets. We run a fork of [Websockproxy](https://github.com/benjamincburns/websockproxy)
|
||||
which allows the emulator to talk to the internet by converting data sent over a websocket port into TCP packets.
|
||||
Our fork of Websockproxy adds the ability to tunnel into the Postgres server.
|
||||
|
||||
See [source code](https://github.com/snaplet/postgres-browser/tree/main/packages/websockproxy).
|
||||
|
||||
## Technical deep-dive
|
||||
|
||||
There were a lot of hurdles that we discovered throughout the development of postgres-wasm.
|
||||
|
||||
### WASM
|
||||
|
||||
The first thing to point out is that our implementation isn't _pure_ WASM. We attempted to compile Postgres for WASM directly from source,
|
||||
but it was more complicated that we anticipated.
|
||||
|
||||
Crunchy's HN post provided some hints about the approach they took, which was to virtualize a machine in the browser. We pursued this strategy too, settling on v86
|
||||
which emulates an x86-compatible CPU and hardware in the browser.
|
||||
|
||||
### PostgreSQL 14 segfault errors
|
||||
|
||||
We quickly had PostgreSQL 13.3 running using an outdated version of Buildroot. But version PG14+ wouldn't start, giving a segfault during initialization. We tried:
|
||||
|
||||
- manually copying build files for PG14 into the older version(s) of Buildroot
|
||||
- building with (many) newer copies of Buildroot
|
||||
- adjusting kernel parameters and environment settings such as the amount of memory allocated to the emulator, etc.
|
||||
|
||||
Eventually, Fabian, the creator of v86 suggested we turn off JIT compilation for v86 and that solved the issue.
|
||||
He narrowed it down to a bug in v86 and is pushing an update that will fix it.
|
||||
Switching Postgres from posix to sysv memory management also solved the issue for the current release of V86.
|
||||
|
||||
### Optimizing startup time and image size
|
||||
|
||||
With PG14 running in the emulator, we shifted our focus to performance. The image size for the emulator was too big for a browser-based tool.
|
||||
Even with our best efforts, a compressed snapshot was over 30mb - a fairly large payload to download before you can see any interaction.
|
||||
|
||||
We solved this by booting only a minimal Linux image and then dynamically loading the rest of the VM over HTTPS after initialization.
|
||||
|
||||
We achieve this by mounting a compressed [9P filesystem](<https://en.wikipedia.org/wiki/9P_(protocol)>) in the VM.
|
||||
9P provides a [Python script](https://github.com/supabase-community/postgres-wasm/tree/main/packages/buildroot/tools) which takes a filesystem folder,
|
||||
renames every file an 8-character name and produces a `filesystem.json` file representing a nested structure with files, original file names, sizes, etc.
|
||||
We then [copy this compressed output](https://github.com/supabase-community/postgres-wasm/blob/main/packages/buildroot/config/board/pg-browser/post-image.sh) to the VM.
|
||||
We modified the kernel command line and v86 boot parameters to boot directly from the 9P filesystem, and even put our kernel file into the p9 filesystem.
|
||||
All non-essential files are loaded asyncrhonously over HTTPS in the browser as needed.
|
||||
|
||||
The initial state was smaller, but still 15-20mb in size. We discussed this with Fabian who pointed us towards the `page_poison=on` kernel parameter.
|
||||
This parameter allowed us to clear caches before creating the snapshots, by forcing Linux to write arbitrary bytes on freed memory instead of random bytes,
|
||||
so unused memory is compressed much more efficiently.
|
||||
|
||||
The end result of all these changes? The compressed initial state file is about 12mb - including a _running network state and Postgres 14.4 running with psql loaded._
|
||||
|
||||
We experimented without Postgres started too - this produced an even smaller initial state but the time to initialize
|
||||
Postgres was usually longer than the time to download a few extra megabytes. We opted for a faster startup time.
|
||||
|
||||
Additionally, without a running state you'd hit a performance penalty every time you refreshed the page,
|
||||
since Postgres would need to be initialized each time. In our current version, after the initial state is
|
||||
downloaded and cached by the browser the VM feels almost instant on refresh.
|
||||
|
||||
### Networking
|
||||
|
||||
Networking was particularly difficult to solve. For security reasons, browsers only support access to a nominal set of ports.
|
||||
In short, browsers speak “80/443” and Postgres speaks “5432” so there was no way for our Postgres to communicate with the outside world.
|
||||
|
||||
WASM isn't much help here - a WebAssembly module running in the browser is generally not able to do anything outside itself,
|
||||
except for calling functions exposed from JavaScript.
|
||||
|
||||
Browsers provide another connection option for us however: Websockets. The nice thing about websocket connections is that they are _persistent_,
|
||||
so all we needed was to figure out how to proxy the virtual machine traffic through a websocket connection.
|
||||
|
||||
The VM includes an emulated network card (`ne2k-pci`) and a `proxy_url` startup parameter which points to an external server
|
||||
running a websocket proxy (i.e. on the internet). The websocket proxy establishes connections via a websocket port, then accepts raw ethernet packets.
|
||||
It turns those raw packets into TCP/IP packets and routes them between the Internet and the VM.
|
||||
|
||||

|
||||
|
||||
With our tunnel established, we could now send network traffic to the outside world. For example, try “starting” the network on the VM,
|
||||
"exiting" out of psql (`cmd`+`D`), and then run `ping 1.1.1.1`.
|
||||
|
||||

|
||||
|
||||
Most v86 VM's use an open proxy at `wss://relay.widgetry.org/` based on [websockproxy](https://github.com/benjamincburns/websockproxy).
|
||||
This proxy allows the VM to communicate with the outside world. It can handle all the basic use-cases like loading
|
||||
data from other PostgreSQL databases using `pg_dump` and `psql`, or operating as read-only replica of another database.
|
||||
|
||||
However, this proxy doesn't allow incoming traffic to be routed into a VM running locally on a laptop.
|
||||
For instance, you can't connect a local `PgAdmin4` to the PostgreSQL instance running inside your browser.
|
||||
So we [forked](https://github.com/snaplet/postgres-wasm/tree/main/packages/websockproxy) the [proxy](https://github.com/benjamincburns/websockproxy) and
|
||||
added nginx to provide a reverse proxy.
|
||||
|
||||

|
||||
|
||||
The reverse proxy listens on a range of ports, mapping each port to the VM at a specific private address you want to reach.
|
||||
The simple solution was to take the last 2 segments of the private IP and map it to a port number. So if your private IP is 10.5.6.123,
|
||||
you connect to the proxy on port 6123 and that points you to PostgreSQL (running on port 5432) on private IP 10.5.6.123. Shortened addresses are padded,
|
||||
so 10.5.6.2 maps to port 6002 and 10.5.6.44 to port 6044.
|
||||
|
||||

|
||||
|
||||
### Sending commands to the emulator from the browser UI
|
||||
|
||||
Once the emulator is running, in the words of Fabian, “it runs as a black box process”, and the only way to communicate between the
|
||||
UI and the operating system running in the emulator is through `serial0_send` to send keystrokes over the emulated serial port,
|
||||
or through the `create_file` function which uploads a file to the running emulator.
|
||||
|
||||
To restart the network after restoring a state file, we need to run a short script to unbind and re-bind the emulated network card
|
||||
(so it gets a new mac address and thus a unique private IP address at the proxy), and then a standard network restart command.
|
||||
We could run this script by sending keystrokes to the emulator, but that's clumsy. What if the user is at a `psql` prompt? Then we'd send `\! script_command`.
|
||||
But if they're at an OS prompt, that won't work.
|
||||
|
||||
We solved the issue by creating a folder called `\inbox` and added a listener to the folder. When files arrive there, if the file ends in `.sh` (a shell script),
|
||||
we `chmod +x` that file, execute it, then delete it. That allows us to run commands “in the background” without upsetting the UI.
|
||||
|
||||
## Just for fun
|
||||
|
||||
If you want to do something very cool, you can connect to another user's Postgres instance that they are running in their browser.
|
||||
Here are the steps:
|
||||
|
||||
1. Find a friend (sometimes the first step is the hardest)
|
||||
2. Tell them to go to [wasm.supabase.com](https://wasm.supabase.com/)
|
||||
3. Tell them to “Start Network” and get their Network URL
|
||||
4. Have them set a password on their database `ALTER ROLE postgres WITH PASSWORD 'my_password';`
|
||||
5. On your own machine, open a terminal and run
|
||||
`psql postgres://postgres:my_password@proxy.wasm.supabase.com:<PORT>`
|
||||
6. And you're in! You're now connecting to a remote Postgres instance, inside a VM, inside a browser, from a terminal on your own computer.
|
||||
|
||||
**Database Replication**
|
||||
|
||||
Even more mind-blowing - it's possible to replicate data from an online PostgreSQL database (for example, a Supabase project) to PostgreSQL running inside your browser,
|
||||
and vice-versa using Postgres's logical replication. The steps are no different than any other Postgres database - just use the proxy URL as the target.
|
||||
|
||||
## What's next?
|
||||
|
||||
For now, this is very experimental - but it has a lot of potential. If you want to get involved, please reach out to us or
|
||||
the team at [Snaplet](https://www.snaplet.dev/). The work they're doing over at Snaplet is incredible, and we've had a blast collaborating with them.
|
||||
|
||||
- Snaplet repo: [https://github.com/snaplet/postgres-wasm](https://github.com/snaplet/postgres-wasm)
|
||||
- Supabase fork: [https://github.com/supabase-community/postgres-wasm](https://github.com/supabase-community/postgres-wasm)
|
||||
@@ -42,7 +42,7 @@ const data: CommunityItem[] = [
|
||||
},
|
||||
{
|
||||
title: 'Twitter',
|
||||
stat: '40,000+',
|
||||
stat: '44,000+',
|
||||
statLabel: 'Followers',
|
||||
img: 'twitter.png',
|
||||
detail: () => (
|
||||
|
||||
@@ -213,5 +213,13 @@
|
||||
"position": "Guest Author",
|
||||
"author_url": "https://twitter.com/schlimmson",
|
||||
"author_image_url": "https://github.com/saimon24.png"
|
||||
},
|
||||
{
|
||||
"author_id": "burggraf",
|
||||
"author": "Mark Burggraf",
|
||||
"username": "burggraf",
|
||||
"position": "Engineering",
|
||||
"author_url": "https://github.com/burggraf",
|
||||
"author_image_url": "https://github.com/burggraf.png"
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 41 KiB |
@@ -5,9 +5,16 @@
|
||||
<link>https://supabase.com</link>
|
||||
<description>Latest news from Supabase</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>Wed, 07 Sep 2022 16:04:00 +0000</lastBuildDate>
|
||||
<lastBuildDate>Sun, 02 Oct 2022 16:04:00 +0000</lastBuildDate>
|
||||
<atom:link href="https://supabase.com/rss.xml" rel="self" type="application/rss+xml"/>
|
||||
<item>
|
||||
<guid>https://supabase.com/blog/postgres-wasm</guid>
|
||||
<title>Postgres WASM by Snaplet and Supabase</title>
|
||||
<link>https://supabase.com/blog/postgres-wasm</link>
|
||||
<description>We're open sourcing postgres-wasm, a PostgresQL server that runs inside a browser, with our friends at Snaplet.</description>
|
||||
<pubDate>Sun, 02 Oct 2022 16:04:00 +0000</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<guid>https://supabase.com/blog/choosing-a-postgres-primary-key</guid>
|
||||
<title>Choosing a Postgres Primary Key</title>
|
||||
<link>https://supabase.com/blog/choosing-a-postgres-primary-key</link>
|
||||
|
||||
@@ -108,6 +108,7 @@ module.exports = ui({
|
||||
backgroundColor: theme('colors.scale[400]'),
|
||||
border: '1px solid ' + theme('colors.scale[500]'),
|
||||
borderRadius: theme('borderRadius.lg'),
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
a: {
|
||||
transition: 'box-shadow 0.1s ease-in-out',
|
||||
|
||||
@@ -496,9 +496,9 @@ pages:
|
||||
const { data, error } = await supabase.auth.api.resetPasswordForEmail(
|
||||
'user@email.com'
|
||||
)
|
||||
|
||||
|
||||
/**
|
||||
* Step 2: Once the user is redirected back to your application,
|
||||
* Step 2: Once the user is redirected back to your application,
|
||||
* ask the user to reset their password.
|
||||
*/
|
||||
useEffect(() => {
|
||||
@@ -508,10 +508,10 @@ pages:
|
||||
const { data, error } = await supabase.auth.update({
|
||||
password: newPassword,
|
||||
})
|
||||
|
||||
|
||||
if (data) alert("Password updated successfully!")
|
||||
if (error) alert("There was an error updating your password.")
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
```
|
||||
@@ -989,6 +989,20 @@ pages:
|
||||
.rpc('hello_world', {}, { count: 'exact' })
|
||||
```
|
||||
|
||||
privileges:
|
||||
description: |
|
||||
By default, functions can be executed by any role. You can restrict this by
|
||||
sql: |
|
||||
```sql
|
||||
ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
|
||||
```
|
||||
Then, you can choose which roles can execute functions
|
||||
sql: |
|
||||
```sql
|
||||
GRANT EXECUTE ON FUNCTION hello_world TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION hello_world TO service_role;
|
||||
```
|
||||
|
||||
subscribe():
|
||||
title: 'on().subscribe()'
|
||||
$ref: '@supabase/supabase-js.lib/SupabaseQueryBuilder.SupabaseQueryBuilder.on'
|
||||
|
||||
@@ -3,13 +3,21 @@
|
||||
// Last updated as of 29th March 2022.
|
||||
// The code may not necessarily match with the name (ref SE_VAT)
|
||||
// https://stripe.com/docs/api/customer_tax_ids/create
|
||||
interface TaxId {
|
||||
export interface TaxId {
|
||||
name: string
|
||||
code: string
|
||||
country: string
|
||||
placeholder: string
|
||||
}
|
||||
|
||||
export interface StripeTaxId {
|
||||
id: string
|
||||
type: string
|
||||
value: string
|
||||
name: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
export const TAX_IDS: TaxId[] = [
|
||||
{
|
||||
name: 'AE TRN',
|
||||
@@ -174,7 +182,7 @@ export const TAX_IDS: TaxId[] = [
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
name: 'GR VAT',
|
||||
name: 'EL VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Greece',
|
||||
placeholder: 'EL123456789',
|
||||
@@ -187,7 +195,7 @@ export const TAX_IDS: TaxId[] = [
|
||||
},
|
||||
{
|
||||
name: 'HR VAT',
|
||||
code: 'hr_vat',
|
||||
code: 'eu_vat',
|
||||
country: 'Croatia',
|
||||
placeholder: 'HR12345678912',
|
||||
},
|
||||
@@ -207,7 +215,7 @@ export const TAX_IDS: TaxId[] = [
|
||||
name: 'IE VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Ireland',
|
||||
placeholder: 'IE12345678AB',
|
||||
placeholder: 'IE1234567AB',
|
||||
},
|
||||
{
|
||||
name: 'IL VAT',
|
||||
@@ -415,8 +423,8 @@ export const TAX_IDS: TaxId[] = [
|
||||
},
|
||||
{
|
||||
name: 'XI VAT',
|
||||
code: 'xi_vat',
|
||||
country: 'United Kingdom',
|
||||
code: 'eu_vat',
|
||||
country: 'United Kingdom (Northern Ireland)',
|
||||
placeholder: 'XI123456789',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,8 +8,9 @@ import { uuidv4 } from 'lib/helpers'
|
||||
import { post, delete_ } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { TAX_IDS } from './TaxID.constants'
|
||||
import { StripeTaxId, TAX_IDS } from './TaxID.constants'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { sanitizeTaxID } from './TaxID.utils'
|
||||
|
||||
interface Props {
|
||||
loading: boolean
|
||||
@@ -26,8 +27,8 @@ const TaxID: FC<Props> = ({ loading, taxIds, onTaxIdsUpdated }) => {
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [errors, setErrors] = useState<string[]>([])
|
||||
const [taxIdValues, setTaxIdValues] = useState(taxIds)
|
||||
const formattedTaxIds = taxIds.map((taxId: any) => {
|
||||
const [taxIdValues, setTaxIdValues] = useState<StripeTaxId[]>(taxIds)
|
||||
const formattedTaxIds: StripeTaxId[] = taxIds.map((taxId: StripeTaxId) => {
|
||||
return {
|
||||
id: taxId.id,
|
||||
type: taxId.type,
|
||||
@@ -78,7 +79,7 @@ const TaxID: FC<Props> = ({ loading, taxIds, onTaxIdsUpdated }) => {
|
||||
}
|
||||
|
||||
const onRemoveTaxId = (id: string) => {
|
||||
const updatedTaxIds = taxIdValues.filter((taxId: any) => {
|
||||
const updatedTaxIds = taxIdValues.filter((taxId: StripeTaxId) => {
|
||||
return taxId.id !== id
|
||||
})
|
||||
setTaxIdValues(updatedTaxIds)
|
||||
@@ -93,21 +94,21 @@ const TaxID: FC<Props> = ({ loading, taxIds, onTaxIdsUpdated }) => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const deletedIds = await Promise.all(
|
||||
taxIds.map(async (taxId: any) => {
|
||||
taxIds.map(async (taxId: StripeTaxId) => {
|
||||
return await delete_(`${API_URL}/organizations/${slug}/tax-ids`, { id: taxId.id })
|
||||
})
|
||||
)
|
||||
|
||||
const newIds = await Promise.all(
|
||||
taxIdValues.map(async (taxId: any) => {
|
||||
taxIdValues.map(async (taxId: StripeTaxId) => {
|
||||
const sanitizedID = sanitizeTaxID(taxId)
|
||||
const result = await post(`${API_URL}/organizations/${slug}/tax-ids`, {
|
||||
type: taxId.type,
|
||||
value: taxId.value,
|
||||
type: sanitizedID.type,
|
||||
value: sanitizedID.value,
|
||||
})
|
||||
return { id: taxId.id, result }
|
||||
return { id: sanitizedID.id, result }
|
||||
})
|
||||
)
|
||||
|
||||
const taxIdsWithErrors = newIds.filter((taxId: any) => {
|
||||
if (taxId.result.error) return taxId
|
||||
})
|
||||
@@ -137,7 +138,8 @@ const TaxID: FC<Props> = ({ loading, taxIds, onTaxIdsUpdated }) => {
|
||||
<div>
|
||||
<h4>Tax ID</h4>
|
||||
<p className="text-sm opacity-50">
|
||||
If you would like to include specific tax ID(s) to your invoices
|
||||
If you would like to include specific tax ID(s) to your invoices. <br />
|
||||
Make sure the tax ID looks exactly like the placeholder text.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -150,7 +152,7 @@ const TaxID: FC<Props> = ({ loading, taxIds, onTaxIdsUpdated }) => {
|
||||
loading={loading}
|
||||
footer={
|
||||
!loading && (
|
||||
<div className="flex justify-between w-full">
|
||||
<div className="flex w-full justify-between">
|
||||
{!canUpdateTaxIds ? (
|
||||
<p className="text-sm text-scale-1000">
|
||||
You need additional permissions to update this organization's tax IDs
|
||||
@@ -183,15 +185,15 @@ const TaxID: FC<Props> = ({ loading, taxIds, onTaxIdsUpdated }) => {
|
||||
>
|
||||
{loading && taxIdValues.length === 0 ? (
|
||||
<div className="flex flex-col justify-between space-y-2 py-4 px-4">
|
||||
<div className="shimmering-loader rounded py-3 mx-1 w-2/3" />
|
||||
<div className="shimmering-loader rounded py-3 mx-1 w-1/2" />
|
||||
<div className="shimmering-loader rounded py-3 mx-1 w-1/3" />
|
||||
<div className="shimmering-loader mx-1 w-2/3 rounded py-3" />
|
||||
<div className="shimmering-loader mx-1 w-1/2 rounded py-3" />
|
||||
<div className="shimmering-loader mx-1 w-1/3 rounded py-3" />
|
||||
</div>
|
||||
) : (
|
||||
<Panel.Content className="w-3/5 space-y-4">
|
||||
<Panel.Content className="w-8/12 space-y-4">
|
||||
{taxIdValues.length >= 1 ? (
|
||||
<div className="w-full space-y-2">
|
||||
{taxIdValues.map((taxId: any, idx: number) => {
|
||||
{taxIdValues.map((taxId: StripeTaxId, idx: number) => {
|
||||
const selectedTaxId = TAX_IDS.find((option) => option.name === taxId.name)
|
||||
return (
|
||||
<div key={`tax-id-${idx}`} className="flex items-center space-x-2">
|
||||
@@ -200,11 +202,13 @@ const TaxID: FC<Props> = ({ loading, taxIds, onTaxIdsUpdated }) => {
|
||||
onChange={(e: any) => onUpdateTaxId(taxId.id, 'name', e.target.value)}
|
||||
disabled={!canUpdateTaxIds}
|
||||
>
|
||||
{TAX_IDS.map((option) => (
|
||||
<Select.Option key={option.name} value={option.name}>
|
||||
{option.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
{TAX_IDS.sort((a, b) => a.country.localeCompare(b.country)).map(
|
||||
(option) => (
|
||||
<Select.Option key={option.name} value={option.name}>
|
||||
{option.country} - {option.name}
|
||||
</Select.Option>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
<Input
|
||||
value={taxId.value}
|
||||
@@ -230,7 +234,7 @@ const TaxID: FC<Props> = ({ loading, taxIds, onTaxIdsUpdated }) => {
|
||||
)}
|
||||
{canUpdateTaxIds && (
|
||||
<div
|
||||
className="flex items-center space-x-2 opacity-50 hover:opacity-100 transition cursor-pointer"
|
||||
className="flex cursor-pointer items-center space-x-2 opacity-50 transition hover:opacity-100"
|
||||
onClick={() => onAddNewTaxId()}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { StripeTaxId, TAX_IDS } from './TaxID.constants'
|
||||
|
||||
/**
|
||||
* Sanitize EU VAT ids so they get prepended with the country code
|
||||
*
|
||||
* Ex: Belgium's VAT ID should be BE0123456789 not 0123456789
|
||||
* Only EU VAT ids are affected
|
||||
*/
|
||||
export const sanitizeTaxID = (taxId: StripeTaxId) => {
|
||||
let newID = taxId
|
||||
|
||||
const selectedTaxId = TAX_IDS.find((option) => option.name === taxId.name)
|
||||
|
||||
// The prefix of the country name (ex: "BE" for Belgium, "ESA" for Spain)")
|
||||
const countryPrefix = selectedTaxId?.placeholder.match(/[a-z]+|[^a-z]+/gi)?.[0]
|
||||
|
||||
// if the value doesn't start with the country code, prepend them
|
||||
if (taxId.type === 'eu_vat' && countryPrefix && !taxId.value.startsWith(countryPrefix)) {
|
||||
newID.value = `${countryPrefix}${taxId.value}`
|
||||
}
|
||||
|
||||
return newID
|
||||
}
|
||||
@@ -60,8 +60,9 @@ const ColumnDefaultValue: FC<Props> = ({
|
||||
}
|
||||
value={columnFields?.defaultValue ?? ''}
|
||||
format={columnFields?.format}
|
||||
suggestionsHeader="Suggested expressions"
|
||||
suggestions={suggestions}
|
||||
suggestionsHeader="Suggested expressions"
|
||||
suggestionsTooltip="Suggested expressions"
|
||||
onChange={(event: any) => onUpdateField({ defaultValue: event.target.value })}
|
||||
onSelectSuggestion={(suggestion: Suggestion) =>
|
||||
onUpdateField({ defaultValue: suggestion.value })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { isUndefined, isEmpty } from 'lodash'
|
||||
import { Dictionary } from 'components/grid'
|
||||
import { Checkbox, SidePanel, Input } from '@supabase/ui'
|
||||
import { Checkbox, SidePanel, Input, Button, IconExternalLink } from '@supabase/ui'
|
||||
import {
|
||||
PostgresColumn,
|
||||
PostgresRelationship,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from './ColumnEditor.utils'
|
||||
import { TEXT_TYPES } from '../SidePanelEditor.constants'
|
||||
import { ColumnField, CreateColumnPayload, UpdateColumnPayload } from '../SidePanelEditor.types'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Props {
|
||||
column?: PostgresColumn
|
||||
@@ -54,7 +55,6 @@ const ColumnEditor: FC<Props> = ({
|
||||
updateEditorDirty = () => {},
|
||||
}) => {
|
||||
const isNewRecord = isUndefined(column)
|
||||
const hasPrimaryKey = (selectedTable?.primary_keys ?? []).length > 0
|
||||
const originalForeignKey = column ? getColumnForeignKey(column, selectedTable) : undefined
|
||||
|
||||
const [errors, setErrors] = useState<Dictionary<any>>({})
|
||||
@@ -210,21 +210,24 @@ const ColumnEditor: FC<Props> = ({
|
||||
enumTypes={enumTypes}
|
||||
error={errors.format}
|
||||
disabled={!isUndefined(columnFields?.foreignKey)}
|
||||
description={
|
||||
<Link href="https://supabase.com/docs/guides/database/tables#data-types">
|
||||
<a>
|
||||
<Button
|
||||
as="span"
|
||||
type="text"
|
||||
size="small"
|
||||
className="text-scale-1000 hover:text-scale-1200"
|
||||
icon={<IconExternalLink size={14} strokeWidth={2} />}
|
||||
>
|
||||
Learn more about data types
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
}
|
||||
onOptionSelect={(format: string) => onUpdateField({ format, defaultValue: null })}
|
||||
/>
|
||||
|
||||
<div className="text-sm grid md:grid-cols-12 md:gap-x-4 ">
|
||||
<p className="text-scale-1100 col-span-7 col-start-5 -mt-4">
|
||||
<a
|
||||
href="https://supabase.com/docs/guides/database/tables#data-types"
|
||||
target="_blank"
|
||||
className="underline inline-block gap-1 p-2 rounded-sm"
|
||||
>
|
||||
Learn more about data types
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isUndefined(columnFields.foreignKey) && (
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{columnFields.format.includes('int') && (
|
||||
|
||||
@@ -11,6 +11,8 @@ import { uuidv4 } from 'lib/helpers'
|
||||
import { ColumnField, CreateColumnPayload, UpdateColumnPayload } from '../SidePanelEditor.types'
|
||||
|
||||
const isSQLExpression = (input: string) => {
|
||||
if (['CURRENT_DATE'].includes(input)) return true
|
||||
|
||||
if (input[0] === '(' && input[input.length - 1] === ')') {
|
||||
return true
|
||||
}
|
||||
@@ -60,7 +62,7 @@ export const generateColumnFieldFromPostgresColumn = (
|
||||
name: column.name,
|
||||
comment: column?.comment ?? '',
|
||||
format: isArray ? column.format.slice(1) : column.format,
|
||||
defaultValue: unescapeLiteral(column?.default_value as string),
|
||||
defaultValue: column?.default_value as string | null,
|
||||
isArray: isArray,
|
||||
isNullable: column.is_nullable,
|
||||
isIdentity: column.is_identity,
|
||||
@@ -125,7 +127,7 @@ export const generateUpdateColumnPayload = (
|
||||
if (!isEqual(originalColumn.format, type)) {
|
||||
payload.type = type
|
||||
}
|
||||
if (!isEqual(unescapeLiteral(originalColumn.default_value as string), defaultValue)) {
|
||||
if (!isEqual(originalColumn.default_value as string, defaultValue)) {
|
||||
payload.defaultValue = defaultValue
|
||||
payload.defaultValueFormat =
|
||||
isNull(defaultValue) || isSQLExpression(defaultValue)
|
||||
@@ -203,64 +205,3 @@ const formatArrayToPostgresArray = (arrayString: string) => {
|
||||
if (!arrayString) return null
|
||||
return arrayString.replaceAll('[', '{').replaceAll(']', '}')
|
||||
}
|
||||
|
||||
export const unescapeLiteral = (value: string) => {
|
||||
if (!value) return value
|
||||
|
||||
// Handle combinations - this is really not the best solution imo
|
||||
// It's a log(n) attempt to strip off the ::type strings
|
||||
if (value.includes('||')) {
|
||||
const formattedValue = []
|
||||
let found = false
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value[i] === ':') found = true
|
||||
if (((value[i] === ' ' || value[i] === ')') && found) || i === value.length - 1) found = false
|
||||
if (!found) formattedValue.push(value[i])
|
||||
}
|
||||
return formattedValue.join('')
|
||||
}
|
||||
|
||||
const splits = value.split("'::")
|
||||
if (splits.length <= 1) return value
|
||||
|
||||
// Handle timezones
|
||||
if (value.toLowerCase().includes('time zone')) {
|
||||
return `${splits[0].toLowerCase()}')`
|
||||
}
|
||||
|
||||
// Handle nextval
|
||||
if (value.toLowerCase().includes('nextval')) {
|
||||
return `${splits[0]}')`
|
||||
}
|
||||
|
||||
// Handle json
|
||||
if (value.toLowerCase().includes('json')) {
|
||||
return splits[0].slice(1)
|
||||
}
|
||||
|
||||
let temp = splits[0].slice(1).replace("'{", '{')
|
||||
if (
|
||||
value.endsWith('integer[]') ||
|
||||
value.endsWith('real[]') ||
|
||||
value.endsWith('bigint[]') ||
|
||||
value.endsWith('smallint[]')
|
||||
) {
|
||||
temp = temp.replace(/{/g, '[')
|
||||
temp = temp.replace(/}/g, ']')
|
||||
return temp
|
||||
} else {
|
||||
const matches = temp.match(/\{([^{}]+)\}/g)
|
||||
if (matches) {
|
||||
const array = [...matches]
|
||||
array.forEach((x) => {
|
||||
let _x = x.replace(/{/g, '{"')
|
||||
_x = _x.replace(/}/g, '"}')
|
||||
_x = _x.replace(/,/g, '","')
|
||||
temp = temp.replace(x, _x)
|
||||
})
|
||||
temp = temp.replace(/{/g, '[')
|
||||
temp = temp.replace(/}/g, ']')
|
||||
}
|
||||
return temp
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { FC } from 'react'
|
||||
import React, { FC, ReactNode } from 'react'
|
||||
import { IconCalendar, IconType, IconHash, Listbox, IconToggleRight, Input } from '@supabase/ui'
|
||||
import { PostgresType } from '@supabase/postgres-meta'
|
||||
import { POSTGRES_DATA_TYPES, POSTGRES_DATA_TYPE_OPTIONS } from '../SidePanelEditor.constants'
|
||||
@@ -12,6 +12,7 @@ interface Props {
|
||||
error?: any
|
||||
disabled?: boolean
|
||||
showLabel?: boolean
|
||||
description?: ReactNode
|
||||
onOptionSelect: (value: string) => void
|
||||
}
|
||||
|
||||
@@ -23,6 +24,7 @@ const ColumnType: FC<Props> = ({
|
||||
error,
|
||||
disabled = false,
|
||||
showLabel = true,
|
||||
description,
|
||||
onOptionSelect = () => {},
|
||||
}) => {
|
||||
// @ts-ignore
|
||||
@@ -68,73 +70,75 @@ const ColumnType: FC<Props> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
label={showLabel ? 'Type' : ''}
|
||||
layout={showLabel ? 'horizontal' : 'vertical'}
|
||||
value={value}
|
||||
size={size}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
className={`${className} ${disabled ? 'column-type-disabled' : ''} rounded-md`}
|
||||
onChange={(value: string) => onOptionSelect(value)}
|
||||
optionsWidth={480}
|
||||
>
|
||||
<Listbox.Option key="empty" value="" label="---">
|
||||
---
|
||||
</Listbox.Option>
|
||||
<Listbox
|
||||
label={showLabel ? 'Type' : ''}
|
||||
layout={showLabel ? 'horizontal' : 'vertical'}
|
||||
value={value}
|
||||
size={size}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
// @ts-ignore
|
||||
descriptionText={description}
|
||||
className={`${className} ${disabled ? 'column-type-disabled' : ''} rounded-md`}
|
||||
onChange={(value: string) => onOptionSelect(value)}
|
||||
optionsWidth={480}
|
||||
>
|
||||
<Listbox.Option key="empty" value="" label="---">
|
||||
---
|
||||
</Listbox.Option>
|
||||
|
||||
{/*
|
||||
{/*
|
||||
Weird issue with Listbox here
|
||||
1. Can't do render conditionally (&&) within Listbox hence why using Fragment
|
||||
2. Can't wrap these 2 components within a Fragment conditional (enumTypes.length)
|
||||
as selecting the enumType option will not render it in the Listbox component
|
||||
*/}
|
||||
{enumTypes.length > 0 ? (
|
||||
<Listbox.Option disabled key="header-1" value="header-1" label="header-1">
|
||||
User-defined Enumerated Types
|
||||
</Listbox.Option>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{enumTypes.length > 0 ? (
|
||||
// @ts-ignore
|
||||
enumTypes.map((enumType: PostgresType) => (
|
||||
<Listbox.Option
|
||||
key={enumType.name}
|
||||
value={enumType.name}
|
||||
label={enumType.name}
|
||||
addOnBefore={() => {
|
||||
return <div className="bg-scale-1200 mx-1 h-2 w-2 rounded-full" />
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<p>{enumType.name}</p>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<Listbox.Option disabled value="header-2" label="header-2">
|
||||
PostgreSQL Data Types
|
||||
{enumTypes.length > 0 ? (
|
||||
<Listbox.Option disabled key="header-1" value="header-1" label="header-1">
|
||||
User-defined Enumerated Types
|
||||
</Listbox.Option>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{POSTGRES_DATA_TYPE_OPTIONS.map((option: PostgresDataTypeOption) => (
|
||||
{enumTypes.length > 0 ? (
|
||||
// @ts-ignore
|
||||
enumTypes.map((enumType: PostgresType) => (
|
||||
<Listbox.Option
|
||||
key={option.name}
|
||||
value={option.name}
|
||||
label={option.name}
|
||||
addOnBefore={() => inferIcon(option.type)}
|
||||
key={enumType.name}
|
||||
value={enumType.name}
|
||||
label={enumType.name}
|
||||
addOnBefore={() => {
|
||||
return <div className="bg-scale-1200 mx-1 h-2 w-2 rounded-full" />
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-scale-1200">{option.name}</span>
|
||||
<span className="text-scale-900">{option.description}</span>
|
||||
<p>{enumType.name}</p>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
))
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<Listbox.Option disabled value="header-2" label="header-2">
|
||||
PostgreSQL Data Types
|
||||
</Listbox.Option>
|
||||
|
||||
{POSTGRES_DATA_TYPE_OPTIONS.map((option: PostgresDataTypeOption) => (
|
||||
<Listbox.Option
|
||||
key={option.name}
|
||||
value={option.name}
|
||||
label={option.name}
|
||||
addOnBefore={() => inferIcon(option.type)}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-scale-1200">{option.name}</span>
|
||||
<span className="text-scale-900">{option.description}</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
// the component over to the UI library
|
||||
|
||||
import { FC, useEffect, useRef, useState } from 'react'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { Button, Dropdown, IconList, Input } from '@supabase/ui'
|
||||
import { Suggestion } from './ColumnEditor.types'
|
||||
|
||||
const MAX_SUGGESTIONS = 3
|
||||
const DEFAULT_SUGGESTIONS_WIDTH = 400
|
||||
|
||||
interface Props {
|
||||
label?: string
|
||||
@@ -22,9 +22,9 @@ interface Props {
|
||||
className?: string
|
||||
value: string
|
||||
format?: string
|
||||
suggestionsHeader?: string
|
||||
suggestionsWidth?: number
|
||||
suggestions: Suggestion[]
|
||||
suggestionsTooltip?: string
|
||||
suggestionsHeader?: string
|
||||
onChange: (event: any) => void
|
||||
onSelectSuggestion: (suggestion: Suggestion) => void
|
||||
}
|
||||
@@ -40,6 +40,8 @@ const InputWithSuggestions: FC<Props> = ({
|
||||
value = '',
|
||||
format,
|
||||
suggestions = [],
|
||||
suggestionsTooltip,
|
||||
suggestionsHeader,
|
||||
onChange = () => {},
|
||||
onSelectSuggestion = () => {},
|
||||
}) => {
|
||||
@@ -75,7 +77,7 @@ const InputWithSuggestions: FC<Props> = ({
|
||||
size={size}
|
||||
layout={layout}
|
||||
disabled={disabled}
|
||||
className={`${className} ${format?.includes('json') ? 'input-mono' : ''}`}
|
||||
className={className}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onInputChange}
|
||||
@@ -87,7 +89,7 @@ const InputWithSuggestions: FC<Props> = ({
|
||||
side="bottom"
|
||||
overlay={
|
||||
<>
|
||||
<Dropdown.Label>Suggestions</Dropdown.Label>
|
||||
<Dropdown.Label>{suggestionsHeader || 'Suggestions'}</Dropdown.Label>
|
||||
<Dropdown.Seperator />
|
||||
{filteredSuggestions.map((suggestion: Suggestion) => (
|
||||
<Dropdown.Item
|
||||
@@ -101,7 +103,29 @@ const InputWithSuggestions: FC<Props> = ({
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button as="span" type="default" icon={<IconList strokeWidth={1.5} />}></Button>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
as="span"
|
||||
type="default"
|
||||
className="!px-1 mr-1"
|
||||
icon={<IconList strokeWidth={1.5} />}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'bg-scale-100 rounded py-1 px-2 leading-none shadow',
|
||||
'border-scale-200 border',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-scale-1200 text-xs">
|
||||
{suggestionsTooltip || 'Suggestions'}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,9 +100,11 @@ const InputField: FC<Props> = ({
|
||||
rows={5}
|
||||
value={field.value ?? ''}
|
||||
placeholder={
|
||||
typeof field.defaultValue === 'string' && field.defaultValue.length === 0
|
||||
field.defaultValue === null
|
||||
? ''
|
||||
: typeof field.defaultValue === 'string' && field.defaultValue.length === 0
|
||||
? 'Default: Empty string'
|
||||
: field.defaultValue
|
||||
: `Default: ${field.defaultValue}`
|
||||
}
|
||||
onChange={(event: any) => onUpdateField({ [field.name]: event.target.value })}
|
||||
/>
|
||||
@@ -119,7 +121,7 @@ const InputField: FC<Props> = ({
|
||||
descriptionText={field.comment}
|
||||
labelOptional={field.format}
|
||||
disabled={!isEditable}
|
||||
placeholder={field.defaultValue}
|
||||
placeholder={field?.defaultValue ?? ''}
|
||||
error={errors[field.name]}
|
||||
onChange={(event: any) => onUpdateField({ [field.name]: event.target.value })}
|
||||
actions={
|
||||
@@ -156,7 +158,13 @@ const InputField: FC<Props> = ({
|
||||
labelOptional={field.format}
|
||||
error={errors[field.name]}
|
||||
value={field.value ?? ''}
|
||||
placeholder={field.isIdentity ? 'Automatically generated as identity' : field.defaultValue}
|
||||
placeholder={
|
||||
field.isIdentity
|
||||
? 'Automatically generated as identity'
|
||||
: field.defaultValue !== null
|
||||
? `Default: ${field.defaultValue}`
|
||||
: ''
|
||||
}
|
||||
disabled={!isEditable}
|
||||
onChange={(event: any) => onUpdateField({ [field.name]: event.target.value })}
|
||||
/>
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface RowField {
|
||||
format: string
|
||||
enums: string[]
|
||||
value: string | null
|
||||
defaultValue: string
|
||||
defaultValue: string | null
|
||||
foreignKey?: PostgresRelationship
|
||||
isNullable: boolean
|
||||
isIdentity: boolean
|
||||
|
||||
@@ -5,13 +5,7 @@ import { PostgresTable } from '@supabase/postgres-meta'
|
||||
|
||||
import { uuidv4, minifyJSON, tryParseJson } from 'lib/helpers'
|
||||
import { RowField } from './RowEditor.types'
|
||||
import {
|
||||
JSON_TYPES,
|
||||
NUMERICAL_TYPES,
|
||||
DATETIME_TYPES,
|
||||
TIME_TYPES,
|
||||
TIMESTAMP_TYPES,
|
||||
} from '../SidePanelEditor.constants'
|
||||
import { DATETIME_TYPES, TIME_TYPES, TIMESTAMP_TYPES } from '../SidePanelEditor.constants'
|
||||
|
||||
export const generateRowFields = (
|
||||
row: Dictionary<any> | undefined,
|
||||
@@ -24,14 +18,13 @@ export const generateRowFields = (
|
||||
|
||||
return table.columns.map((column) => {
|
||||
const defaultValue = column?.default_value as string | null
|
||||
const value =
|
||||
isNewRecord && defaultValue?.includes('now()')
|
||||
? nowDateTimeValue(column.format)
|
||||
: isUndefined(row)
|
||||
? ''
|
||||
: DATETIME_TYPES.includes(column.format)
|
||||
? convertPostgresDatetimeToInputDatetime(column.format, row[column.name])
|
||||
: parseValue(row[column.name], column.format, column.data_type)
|
||||
const value = isUndefined(row)
|
||||
? ''
|
||||
: isNewRecord && defaultValue?.includes('now()')
|
||||
? nowDateTimeValue(column.format)
|
||||
: DATETIME_TYPES.includes(column.format)
|
||||
? convertPostgresDatetimeToInputDatetime(column.format, row[column.name])
|
||||
: parseValue(row[column.name], column.format, column.data_type)
|
||||
|
||||
const foreignKey = find(relationships, (relationship) => {
|
||||
return (
|
||||
@@ -49,7 +42,7 @@ export const generateRowFields = (
|
||||
comment: parseDescription(column.comment),
|
||||
format: column.format,
|
||||
enums: column.enums as any,
|
||||
defaultValue: parseValue(column.default_value as string, column.format, column.data_type),
|
||||
defaultValue: column?.default_value as string | null,
|
||||
isNullable: column.is_nullable,
|
||||
isIdentity: column.is_identity,
|
||||
isPrimaryKey: primaryKeyColumns.includes(column.name),
|
||||
@@ -109,26 +102,6 @@ const parseValue = (originalValue: string, format: string, dataType: string) =>
|
||||
return JSON.stringify(originalValue)
|
||||
} else if (typeof originalValue === 'boolean') {
|
||||
return (originalValue as any).toString()
|
||||
} else if (includes(JSON_TYPES, format)) {
|
||||
const value = _unescapeLiteral(dataType, originalValue, format)
|
||||
return minifyJSON(value)
|
||||
}
|
||||
|
||||
// escape literal format from postgres-meta
|
||||
const value = _unescapeLiteral(dataType, originalValue, format)
|
||||
if (dataType && dataType.toLowerCase() == 'array') {
|
||||
if (originalValue && originalValue.includes("}'::") && originalValue.endsWith('[]')) {
|
||||
// for array default value, we need to use this method to parse literal format
|
||||
// TODO: should merge with above method... if we can
|
||||
return _unescapeLiteralArray(originalValue)
|
||||
} else if (typeof value === 'string') {
|
||||
const parsedValue = JSON.parse(value)
|
||||
return JSON.parse(parsedValue)
|
||||
} else {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
} catch (error) {
|
||||
return originalValue
|
||||
@@ -201,68 +174,6 @@ const convertInputDatetimeToPostgresDatetime = (format: string, value: string |
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* postgres-meta can return default value with format like
|
||||
* 'hello world'::character varying
|
||||
* '232.34'::double precision
|
||||
* 'USER'::"Role" user-defined type
|
||||
* this method will help convert them to valid default value
|
||||
*/
|
||||
const _unescapeLiteral = (dataType: string, value: string, format: string): any => {
|
||||
// unEscape format literal
|
||||
let temp = `::${dataType}`
|
||||
let tempWithQuotes = `::"${dataType}"`
|
||||
// for user-defined type, need to use format instead
|
||||
if (dataType?.toLowerCase() == 'user-defined') {
|
||||
temp = `::${format}`
|
||||
tempWithQuotes = `::"${format}"`
|
||||
}
|
||||
|
||||
if (value && value.includes(temp)) {
|
||||
value = value.replace(temp, '')
|
||||
// remove quotes
|
||||
value = value.slice(1, value.length - 1)
|
||||
} else if (value && value.includes(tempWithQuotes)) {
|
||||
value = value.replace(tempWithQuotes, '')
|
||||
// remove quotes
|
||||
value = value.slice(1, value.length - 1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* postgres-meta can return default value for array like
|
||||
* ex: '{apple,banana}'::text[] => ["apple","banana"]
|
||||
* ex: '{1,2,3,4,5,6}'::integer[] => [1,2,3,4,5,6]
|
||||
* ex: '{{meeting,lunch},{training,presentation}}'::character varying[]
|
||||
* this method will help convert them to valid default value
|
||||
*/
|
||||
const _unescapeLiteralArray = (value: any) => {
|
||||
const splits = value.split("'::")
|
||||
if (splits.length < 1) return value
|
||||
|
||||
let temp = splits[0].replace("'{", '{')
|
||||
if (value.endsWith('integer[]') || value.endsWith('real[]')) {
|
||||
temp = temp.replaceAll('{', '[')
|
||||
temp = temp.replaceAll('}', ']')
|
||||
return temp
|
||||
} else {
|
||||
const matches = temp.match(/\{([^{}]+)\}/g)
|
||||
if (matches) {
|
||||
const array = [...matches]
|
||||
array.forEach((x) => {
|
||||
let _x = x.replaceAll('{', '{"')
|
||||
_x = _x.replaceAll('}', '"}')
|
||||
_x = _x.replaceAll(',', '","')
|
||||
temp = temp.replace(x, _x)
|
||||
})
|
||||
temp = temp.replaceAll('{', '[')
|
||||
temp = temp.replaceAll('}', ']')
|
||||
}
|
||||
return temp
|
||||
}
|
||||
}
|
||||
|
||||
export const generateRowObjectFromFields = (
|
||||
fields: RowField[],
|
||||
includeNullProperties = false
|
||||
|
||||
@@ -128,7 +128,7 @@ const Column: FC<Props> = ({
|
||||
}`}
|
||||
suggestions={suggestions}
|
||||
suggestionsHeader="Suggested expressions"
|
||||
suggestionsWidth={410}
|
||||
suggestionsTooltip="Suggested expressions"
|
||||
onChange={(event: any) => onUpdateColumn({ defaultValue: event.target.value })}
|
||||
onSelectSuggestion={(suggestion: Suggestion) =>
|
||||
onUpdateColumn({ defaultValue: suggestion.value })
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { sanitizeTaxID } from 'components/interfaces/Organization/BillingSettings/TaxID/TaxID.utils'
|
||||
|
||||
/**
|
||||
* We're sanitizing EU tax ids. Stripe expects a prefixed tax id (ATU12345678),
|
||||
* but users might not realize this and enter only the numbers (12345678) without the
|
||||
* country code prefix.
|
||||
*
|
||||
* Our sanitize function should handle 3 cases:
|
||||
* 1. take an un-prefixed tax id (12345678) and add the country prefix to it (ATU12345678)
|
||||
* 2. take a correctly prefixed tax id (ATU12345678) and just pass it through
|
||||
* 3. take a non-EU tax id and just pass it through
|
||||
*/
|
||||
describe('TaxID utils: sanitizeTaxID', () => {
|
||||
test('should prefix an EU tax ID correctly', () => {
|
||||
const austriaTaxID = {
|
||||
id: 'txi_1LoPlYJDPojXS6LNVd1FOTk2',
|
||||
type: 'eu_vat',
|
||||
value: '12345678',
|
||||
name: 'AT VAT',
|
||||
}
|
||||
|
||||
const sanitizedID = sanitizeTaxID(austriaTaxID)
|
||||
expect(sanitizedID.value).toBe('ATU12345678')
|
||||
})
|
||||
|
||||
test('should check that EU prefix is correct', () => {
|
||||
const austriaTaxID = {
|
||||
id: 'txi_1LoPlYJDPojXS6LNVd1FOTk2',
|
||||
type: 'eu_vat',
|
||||
value: 'ATU12345678',
|
||||
name: 'AT VAT',
|
||||
}
|
||||
|
||||
const sanitizedID = sanitizeTaxID(austriaTaxID)
|
||||
expect(sanitizedID.value).toBe('ATU12345678')
|
||||
})
|
||||
|
||||
test('should not prefix an non-EU tax ID', () => {
|
||||
const unitedStatesID = {
|
||||
id: 'txi_1LoPlYJDPojXS6LNVd1FOTk2',
|
||||
type: 'us_ein',
|
||||
value: '12-3456789',
|
||||
name: 'US EIN',
|
||||
}
|
||||
|
||||
const sanitizedID = sanitizeTaxID(unitedStatesID)
|
||||
expect(sanitizedID.value).toBe('12-3456789')
|
||||
})
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
import { unescapeLiteral } from 'components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils'
|
||||
|
||||
describe('ColumnEditor.utils: unescapeLiteral', () => {
|
||||
test('should return varchar properly', () => {
|
||||
const mockInput = `nextval('"Test_id_seq"'::regclass)`
|
||||
const value = unescapeLiteral(mockInput)
|
||||
expect(value).toStrictEqual(`nextval('"Test_id_seq"')`)
|
||||
})
|
||||
test('should return varchar properly', () => {
|
||||
const mockInput = "'potato'::character varying"
|
||||
const value = unescapeLiteral(mockInput)
|
||||
expect(value).toStrictEqual('potato')
|
||||
})
|
||||
test('should return text properly', () => {
|
||||
const mockInput = "'tomato'::text"
|
||||
const value = unescapeLiteral(mockInput)
|
||||
expect(value).toStrictEqual('tomato')
|
||||
})
|
||||
test('should return null properly', () => {
|
||||
const mockInput = null
|
||||
const value = unescapeLiteral(mockInput)
|
||||
expect(value).toStrictEqual(null)
|
||||
})
|
||||
test('should return empty string properly', () => {
|
||||
const mockInput = `''::text`
|
||||
const value = unescapeLiteral(mockInput)
|
||||
expect(value).toStrictEqual('')
|
||||
})
|
||||
test('should return numerical arrays properly', () => {
|
||||
const mockInput = "'{1,2,3,4}'::integer[]"
|
||||
const value = unescapeLiteral(mockInput)
|
||||
expect(value).toStrictEqual('[1,2,3,4]')
|
||||
})
|
||||
test('should return string arrays properly', () => {
|
||||
const mockInput = "'{apple,orange}'::text[]"
|
||||
const value = unescapeLiteral(mockInput)
|
||||
expect(value).toStrictEqual('["apple","orange"]')
|
||||
})
|
||||
test('should return single word functions as they are', () => {
|
||||
const mockInput = 'uuid_generate_v4()'
|
||||
const value = unescapeLiteral(mockInput)
|
||||
expect(value).toStrictEqual(mockInput)
|
||||
})
|
||||
test('should return functions beyond one word properly', () => {
|
||||
const mockInput = "(now() AT TIME ZONE 'utc'::text)"
|
||||
const value = unescapeLiteral(mockInput)
|
||||
expect(value).toStrictEqual("(now() at time zone 'utc')")
|
||||
})
|
||||
test('should return expressions that combine values properly', () => {
|
||||
const mockInput1 = "('hi_'::text || uuid_generate_v4())"
|
||||
const value1 = unescapeLiteral(mockInput1)
|
||||
expect(value1).toStrictEqual("('hi_' || uuid_generate_v4())")
|
||||
|
||||
const mockInput2 = "(('hi'::text || 'bye'::text) || 'hello'::text)"
|
||||
const value2 = unescapeLiteral(mockInput2)
|
||||
expect(value2).toStrictEqual("(('hi' || 'bye') || 'hello')")
|
||||
|
||||
const mockInput3 = "(uuid_generate_v4() || 'hello'::text)"
|
||||
const value3 = unescapeLiteral(mockInput3)
|
||||
expect(value3).toStrictEqual("(uuid_generate_v4() || 'hello')")
|
||||
})
|
||||
test('should return json object properly', () => {
|
||||
const mockInput = `'{\"version\": 10, \"dimensions\": {\"width\": 50, \"height\": 20}}'::jsonb`
|
||||
const value = unescapeLiteral(mockInput)
|
||||
expect(value).toStrictEqual('{"version": 10, "dimensions": {"width": 50, "height": 20}}')
|
||||
})
|
||||
test('should return json array properly', () => {
|
||||
const mockInput = `'[{\"version\": 10}, {\"version\": 11}]'::jsonb`
|
||||
const value = unescapeLiteral(mockInput)
|
||||
expect(value).toStrictEqual('[{"version": 10}, {"version": 11}]')
|
||||
})
|
||||
})
|
||||