Merge branch 'master' into chore/page-layout-consistent

This commit is contained in:
Saxon Fletcher committed 2025-11-10 11:09:14 +10:00
commit 09669484f9
80 files changed
+2553 -1793

No files matched your search

+4
View File
@@ -116,6 +116,10 @@ next-env.d.ts
.idea
.vercel
# AI assistant local files
.serena/
CLAUDE.md
#include template .env file for docker-compose
!docker/.env
!docker/supabase/.env
@@ -1,44 +1,16 @@
---
title: Table
description: A responsive table component.
description: A responsive table component for basic data.
component: true
source:
shadcn: true
---
Table presents basic tabular data presentation within a [Card](/docs/components/card).
<ComponentPreview name="table-demo" peekCode wide />
## Installation
Columns will naturally take the width of their content. Width specification is therefore not required unless you require columns to span a specific width. An example use-case may be when placing multiple Table instances on the one page, where columns on each table should optically align.
<Tabs defaultValue="cli">
<TabsList>
<TabsTrigger value="cli">CLI</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="cli">
```bash
npx shadcn-ui@latest add table
```
</TabsContent>
<TabsContent value="manual">
<Steps>
<Step>Copy and paste the following code into your project.</Step>
<ComponentSource name="table" />
<Step>Update the import paths to match your project setup.</Step>
</Steps>
</TabsContent>
</Tabs>
`TableHead` includes a `whitespace-nowrap` utility class to prevent its text from wrapping. `TableCell` must have any custom width specified, as there are legitimate use cases for both wrapping (or truncated) text.
## Usage
@@ -59,7 +31,7 @@ import {
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Invoice</TableHead>
<TableHead>Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead className="text-right">Amount</TableHead>
@@ -1,4 +1,5 @@
import {
Card,
Table,
TableBody,
TableCaption,
@@ -14,74 +15,87 @@ const invoices = [
invoice: 'INV001',
paymentStatus: 'Paid',
totalAmount: '$250.00',
paymentMethod: 'Credit Card',
paymentMethod: 'Credit card',
description: 'Website design services',
},
{
invoice: 'INV002',
paymentStatus: 'Pending',
totalAmount: '$150.00',
paymentMethod: 'PayPal',
description: 'Monthly subscription fee',
},
{
invoice: 'INV003',
paymentStatus: 'Unpaid',
totalAmount: '$350.00',
paymentMethod: 'Bank Transfer',
paymentMethod: 'Bank transfer',
description: 'Consulting hours',
},
{
invoice: 'INV004',
paymentStatus: 'Paid',
totalAmount: '$450.00',
paymentMethod: 'Credit Card',
paymentMethod: 'Credit card',
description: 'Software license renewal',
},
{
invoice: 'INV005',
paymentStatus: 'Paid',
totalAmount: '$550.00',
paymentMethod: 'PayPal',
description: 'Custom development work',
},
{
invoice: 'INV006',
paymentStatus: 'Pending',
totalAmount: '$200.00',
paymentMethod: 'Bank Transfer',
paymentMethod: 'Bank transfer',
description: 'Hosting and maintenance',
},
{
invoice: 'INV007',
paymentStatus: 'Unpaid',
totalAmount: '$300.00',
paymentMethod: 'Credit Card',
paymentMethod: 'Credit card',
description: 'Training session package',
},
]
export default function TableDemo() {
return (
<Table>
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((invoice) => (
<TableRow key={invoice.invoice}>
<TableCell className="font-medium">{invoice.invoice}</TableCell>
<TableCell>{invoice.paymentStatus}</TableCell>
<TableCell>{invoice.paymentMethod}</TableCell>
<TableCell className="text-right">{invoice.totalAmount}</TableCell>
<Card className="w-full pb-4">
<Table>
<TableCaption>A list of your recent invoices</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead className="hidden md:table-cell">Description</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={3}>Total</TableCell>
<TableCell className="text-right">$2,500.00</TableCell>
</TableRow>
</TableFooter>
</Table>
</TableHeader>
<TableBody>
{invoices.map((invoice) => (
<TableRow key={invoice.invoice}>
<TableCell className="text-foreground">{invoice.invoice}</TableCell>
<TableCell className="text-foreground-lighter">{invoice.paymentStatus}</TableCell>
<TableCell className="text-foreground-lighter">{invoice.paymentMethod}</TableCell>
<TableCell className="hidden md:table-cell text-foreground-muted">
{invoice.description}
</TableCell>
<TableCell className="text-right">{invoice.totalAmount}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter className="border-t">
<TableRow>
<TableCell colSpan={4}>Total</TableCell>
<TableCell className="text-right">$2,250.00</TableCell>
</TableRow>
</TableFooter>
</Table>
</Card>
)
}
@@ -1045,6 +1045,10 @@ export const database: NavMenuConstant = {
name: 'Managing connections',
url: '/guides/database/connection-management' as `/${string}`,
},
{
name: 'Managing event triggers',
url: '/guides/database/postgres/event-triggers' as `/${string}`,
},
],
},
{
@@ -20,18 +20,25 @@ import {
themes,
} from 'ui'
import MenuIconPicker from './MenuIconPicker'
import { isFeatureEnabled } from 'common'
const menu = [
[
{
label: 'Supabase.com',
icon: 'home',
href: 'https://supabase.com',
otherProps: {
target: '_blank',
rel: 'noreferrer noopener',
},
},
isFeatureEnabled('docs:navigation_dropdown_links_home')
? {
label: 'Supabase.com',
icon: 'home',
href: 'https://supabase.com',
otherProps: {
target: '_blank',
rel: 'noreferrer noopener',
},
}
: {
label: 'Dashboard',
icon: 'home',
href: '../dashboard',
},
{
label: 'GitHub',
icon: 'github',
@@ -1,8 +1,8 @@
'use client'
import type { User } from '@supabase/supabase-js'
import { LogOut, Globe, LifeBuoy, Settings, UserIcon, Database } from 'lucide-react'
import { logOut } from 'common'
import { isFeatureEnabled, logOut } from 'common'
import { Database, Globe, Home, LifeBuoy, LogOut, Settings, UserIcon } from 'lucide-react'
import type { menuItem } from 'ui-patterns/AuthenticatedDropdownMenu'
import { IconGitHub } from './MenuIcons'
@@ -27,15 +27,21 @@ const useDropdownMenu = (user: User | null) => {
},
],
[
{
label: 'Supabase.com',
icon: Globe,
href: 'https://supabase.com',
otherProps: {
target: '_blank',
rel: 'noreferrer noopener',
},
},
isFeatureEnabled('docs:navigation_dropdown_links_home')
? {
label: 'Supabase.com',
icon: Globe,
href: 'https://supabase.com',
otherProps: {
target: '_blank',
rel: 'noreferrer noopener',
},
}
: {
label: 'Dashboard',
icon: Home,
href: '../dashboard',
},
{
label: 'GitHub',
icon: IconGitHub as any,
@@ -39,12 +39,12 @@ It's strongly recommended you set up a custom domain and optionally verify your
</Admonition>
Google's consent screen is shown to users when they sign in. Optionally configure the following to improve the appearance of the screen, increasing the perception of trust by your users:
Google's consent screen is shown to users when they sign in. Optionally configure one of the following to improve the appearance of the screen, increasing the perception of trust by your users:
1. Verify your application's brand (logo and name) by configuring it in the [Branding](https://console.cloud.google.com/auth/branding) section of the Google Auth Platform console. Brand verification is not automatic and may take a few business days.
1. Set up a [custom domain for your project](/docs/guides/platform/custom-domains) to present the user with a clear relationship to the website they clicked Sign in with Google on.
- A good approach is to use `auth.example.com` or `api.example.com`, if your application is hosted on `example.com`.
- If you don't set this up, users will see `<project-id>.supabase.co` which does not inspire trust and can make your application more susceptible to successful phishing attempts.
1. Verify your application's brand (logo and name) by configuring it in the [Branding](https://console.cloud.google.com/auth/branding) section of the Google Auth Platform console. Brand verification is not automatic and may take a few business days.
## Project setup
@@ -109,36 +109,38 @@ If a setting you need is not yet configurable, [share your use case with us](/da
The following parameters are available for overrides:
1. [effective_cache_size](https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE)
1. [logical_decoding_work_mem](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-LOGICAL-DECODING-WORK-MEM) (CLI only)
1. [maintenance_work_mem](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAINTENANCE-WORK-MEM)
1. [max_connections](https://www.postgresql.org/docs/current/runtime-config-connection.html#GUC-MAX-CONNECTIONS) (CLI only)
1. [max_locks_per_transaction](https://www.postgresql.org/docs/current/runtime-config-locks.html#GUC-MAX-LOCKS-PER-TRANSACTION) (CLI only)
1. [max_parallel_maintenance_workers](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-MAINTENANCE-WORKERS)
1. [max_parallel_workers_per_gather](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS-PER-GATHER)
1. [max_parallel_workers](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS)
1. [max_replication_slots](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-REPLICATION-SLOTS) (CLI only)
1. [max_slot_wal_keep_size](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-SLOT-WAL-KEEP-SIZE) (CLI only)
1. [max_standby_archive_delay](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-STANDBY-ARCHIVE-DELAY) (CLI only)
1. [max_standby_streaming_delay](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-STANDBY-STREAMING-DELAY) (CLI only)
1. [max_wal_size](https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-MAX-WAL-SIZE) (CLI only)
1. [max_wal_senders](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-WAL-SENDERS) (CLI only)
1. [max_worker_processes](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-WORKER-PROCESSES) (CLI only)
1. [session_replication_role](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-SESSION-REPLICATION-ROLE)
1. [shared_buffers](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS) (CLI only)
1. [statement_timeout](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT)
1. [track_activity_query_size](https://www.postgresql.org/docs/current/runtime-config-statistics.html#GUC-TRACK-ACTIVITY-QUERY-SIZE)
1. [track_commit_timestamp](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-TRACK-COMMIT-TIMESTAMP)
1. [wal_keep_size](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-WAL-KEEP-SIZE) (CLI only)
1. [wal_sender_timeout](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-WAL-SENDER-TIMEOUT) (CLI only)
1. [work_mem](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM)
1. [checkpoint_timeout](https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-CHECKPOINT-TIMEOUT)
2. [effective_cache_size](https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE)
3. [hot_standby_feedback](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-HOT-STANDBY-FEEDBACK)
4. [logical_decoding_work_mem](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-LOGICAL-DECODING-WORK-MEM) (CLI only)
5. [maintenance_work_mem](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAINTENANCE-WORK-MEM)
6. [max_connections](https://www.postgresql.org/docs/current/runtime-config-connection.html#GUC-MAX-CONNECTIONS) (CLI only)
7. [max_locks_per_transaction](https://www.postgresql.org/docs/current/runtime-config-locks.html#GUC-MAX-LOCKS-PER-TRANSACTION) (CLI only)
8. [max_parallel_maintenance_workers](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-MAINTENANCE-WORKERS)
9. [max_parallel_workers_per_gather](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS-PER-GATHER)
10. [max_parallel_workers](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS)
11. [max_replication_slots](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-REPLICATION-SLOTS) (CLI only)
12. [max_slot_wal_keep_size](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-SLOT-WAL-KEEP-SIZE) (CLI only)
13. [max_standby_archive_delay](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-STANDBY-ARCHIVE-DELAY) (CLI only)
14. [max_standby_streaming_delay](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-STANDBY-STREAMING-DELAY) (CLI only)
15. [max_wal_size](https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-MAX-WAL-SIZE) (CLI only)
16. [max_wal_senders](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-WAL-SENDERS) (CLI only)
17. [max_worker_processes](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-WORKER-PROCESSES) (CLI only)
18. [session_replication_role](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-SESSION-REPLICATION-ROLE)
19. [shared_buffers](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS) (CLI only)
20. [statement_timeout](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT)
21. [track_activity_query_size](https://www.postgresql.org/docs/current/runtime-config-statistics.html#GUC-TRACK-ACTIVITY-QUERY-SIZE)
22. [track_commit_timestamp](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-TRACK-COMMIT-TIMESTAMP)
23. [wal_keep_size](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-WAL-KEEP-SIZE) (CLI only)
24. [wal_sender_timeout](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-WAL-SENDER-TIMEOUT) (CLI only)
25. [work_mem](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM)
#### Managing Postgres configuration with the CLI
To start:
1. [Install](/docs/guides/resources/supabase-cli) Supabase CLI 1.69.0+.
1. [Log in](/docs/guides/cli/local-development#log-in-to-the-supabase-cli) to your Supabase account using the CLI.
2. [Log in](/docs/guides/cli/local-development#log-in-to-the-supabase-cli) to your Supabase account using the CLI.
To update Postgres configurations, use the [`postgres config`](/docs/reference/cli/supabase-postgres-config) command:
@@ -227,5 +229,5 @@ alter role "<role_name>" set "<setting_name>" to default;
### Considerations
1. Changes through the CLI might restart the database causing momentary disruption to existing database connections; in most cases this should not take more than a few seconds. However, you can use the --no-restart flag to bypass the restart and keep the connections intact. Keep in mind that this depends on the specific configuration changes you're making. if the change requires a restart, using the --no-restart flag will prevent the restart but you won't see those changes take effect until a restart is manually triggered. Additionally, some parameters are required to be the same on Primary and Read Replicas; not restarting in these cases can result in read replica failure if the Primary/Read Replicas restart in isolation.
1. Custom Postgres Config will always override the default optimizations generated by Supabase. When changing compute add-ons, you should also review and update your custom Postgres Config to ensure they remain compatible and effective with the updated compute.
1. Some parameters (e.g. `wal_keep_size`) can increase disk utilization, triggering disk expansion, which in turn can lead to [increases in your bill](/docs/guides/platform/compute-add-ons#disk-io).
2. Custom Postgres Config will always override the default optimizations generated by Supabase. When changing compute add-ons, you should also review and update your custom Postgres Config to ensure they remain compatible and effective with the updated compute.
3. Some parameters (e.g. `wal_keep_size`) can increase disk utilization, triggering disk expansion, which in turn can lead to [increases in your bill](/docs/guides/platform/compute-add-ons#disk-io).
@@ -0,0 +1,135 @@
---
id: 'postgres-event-triggers'
title: 'Event Triggers'
description: 'Automatically execute SQL on database events.'
subtitle: 'Automatically execute SQL on database events.'
---
In Postgres, an [event trigger](https://www.postgresql.org/docs/current/event-triggers.html) is similar to a [trigger](/docs/guides/database/postgres/triggers), except that it is triggered by database level events (and is usually reserved for [superusers](/docs/guides/database/postgres/roles-superuser))
With our `Supautils` extension (installed automatically for all Supabase projects), the `postgres` user has the ability to create and manage event triggers.
Some use cases for event triggers are:
- Capturing Data Definition Language (DDL) changes - these are changes to your database schema (though the [pgAudit](/docs/guides/database/extensions/pgaudit) extension provides a more complete solution)
- Enforcing/monitoring/preventing actions - such as preventing tables from being dropped in Production or enforcing RLS on all new tables
The guide covers two example event triggers:
1. Preventing accidental dropping of a table
2. Automatically enabling Row Level Security on new tables in the `public` schema
## Creating an event trigger
Only the `postgres` user can create event triggers, so make sure you are authenticated as them. As with triggers, event triggers consist of 2 parts
1. A [Function](/docs/guides/database/functions) which will be executed when the triggering event occurs
2. The actual Event Trigger object, with parameters around when the trigger should be run
### Example trigger function - prevent dropping tables
This example protects any table from being dropped. You can override it by temporarily disabling the event trigger: `ALTER EVENT TRIGGER dont_drop_trigger DISABLE;`
```sql
-- Function
CREATE OR REPLACE FUNCTION dont_drop_function()
RETURNS event_trigger LANGUAGE plpgsql AS $$
DECLARE
obj record;
tbl_name text;
BEGIN
FOR obj IN SELECT * FROM pg_event_trigger_dropped_objects()
LOOP
IF obj.object_type = 'table' THEN
RAISE EXCEPTION 'ERROR: All tables in this schema are protected and cannot be dropped';
END IF;
END LOOP;
END;
$$;
-- Event trigger
CREATE EVENT TRIGGER dont_drop_trigger
ON sql_drop
EXECUTE FUNCTION dont_drop_function();
```
### Example trigger function - auto enable Row Level Security
```sql
CREATE OR REPLACE FUNCTION rls_auto_enable()
RETURNS EVENT_TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog
AS $$
DECLARE
cmd record;
BEGIN
FOR cmd IN
SELECT *
FROM pg_event_trigger_ddl_commands()
WHERE command_tag IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
AND object_type IN ('table','partitioned table')
LOOP
IF cmd.schema_name IS NOT NULL AND cmd.schema_name IN ('public') AND cmd.schema_name NOT IN ('pg_catalog','information_schema') AND cmd.schema_name NOT LIKE 'pg_toast%' AND cmd.schema_name NOT LIKE 'pg_temp%' THEN
BEGIN
EXECUTE format('alter table if exists %s enable row level security', cmd.object_identity);
RAISE LOG 'rls_auto_enable: enabled RLS on %', cmd.object_identity;
EXCEPTION
WHEN OTHERS THEN
RAISE LOG 'rls_auto_enable: failed to enable RLS on %', cmd.object_identity;
END;
ELSE
RAISE LOG 'rls_auto_enable: skip % (either system schema or not in enforced list: %.)', cmd.object_identity, cmd.schema_name;
END IF;
END LOOP;
END;
$$;
DROP EVENT TRIGGER IF EXISTS ensure_rls;
CREATE EVENT TRIGGER ensure_rls
ON ddl_command_end
WHEN TAG IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
EXECUTE FUNCTION rls_auto_enable();
```
### Event trigger Functions and firing events
Event triggers can be triggered on:
- `ddl_command_start` - occurs just before a DDL command for almost all objects within a schema
- `ddl_command_end` - occurs just after a DDL command for almost all objects within a schema
- `sql_drop` - occurs just before `ddl_command_end` for any DDL commands that `DROP` a database object (note that altering a table can cause it to be dropped)
- `table_rewrite` - occurs just before a table is rewritten using the `ALTER TABLE` command
<Admonition type="caution">
Event triggers run for each DDL command specified above and can consume resources which may cause performance issues if not used carefully.
</Admonition>
Within each event trigger, helper functions exist to view the objects being modified or the command being run. For example, our example calls `pg_event_trigger_dropped_objects()` to view the object(s) being dropped. For a more comprehensive overview of these functions, read the [official event trigger definition documentation](https://www.postgresql.org/docs/current/event-trigger-definition.html)
To view the matrix commands that cause an event trigger to fire, read the [official event trigger matrix documentation](https://www.postgresql.org/docs/current/event-trigger-matrix.html)
## Disabling an event trigger
You can disable an event trigger using the `alter event trigger` command:
```sql
ALTER EVENT TRIGGER dont_drop_trigger DISABLE;
```
## Dropping an event trigger
You can delete a trigger using the `drop event trigger` command:
```sql
DROP EVENT TRIGGER dont_drop_trigger;
```
## Resources
- Official Postgres Docs: [Event Trigger Behaviours](https://www.postgresql.org/docs/current/event-trigger-definition.html)
- Official Postgres Docs: [Event Trigger Firing Matrix](https://www.postgresql.org/docs/current/event-trigger-matrix.html)
- Supabase blog: [Postgres Event Triggers without superuser access](/blog/event-triggers-wo-superuser)
@@ -12,7 +12,5 @@ However, this does mean that some operations, that typically require `superuser`
## Unsupported operations
- `CREATE SUBSCRIPTION`
- `CREATE EVENT TRIGGER`
- `COPY ... FROM PROGRAM`
- `ALTER USER ... WITH SUPERUSER`
+1
View File
@@ -80,6 +80,7 @@ Jeff Smick
Jenny Kibiri
Jess Shears
Jim Chanco Jr
Joakim Ahrlin
John Pena
John Schaeffer
Jon M
+1 -6
View File
@@ -7375,12 +7375,7 @@ functions:
```js
supabase
.channel('room1')
.send({
type: 'broadcast',
event: 'cursor-pos',
payload: { x: Math.random(), y: Math.random()
},
})
.httpSend('cursor-pos', { x: Math.random(), y: Math.random() })
```
- id: get-channels
title: getChannels()
@@ -117,8 +117,6 @@ export const Destinations = () => {
</div>
<div className="w-full overflow-hidden overflow-x-auto">
{(isSourcesLoading || isDestinationsLoading) && <GenericSkeletonLoader />}
{(isSourcesError || isDestinationsError) && (
<AlertError
error={sourcesError || destinationsError}
@@ -126,7 +124,9 @@ export const Destinations = () => {
/>
)}
{replicationNotEnabled ? (
{isSourcesLoading || isDestinationsLoading ? (
<GenericSkeletonLoader />
) : replicationNotEnabled ? (
<div className="border rounded-md p-4 md:p-12 flex flex-col gap-y-4">
<div className="flex flex-col gap-y-1">
<h3>Stream data to external destinations in real-time</h3>
@@ -5,7 +5,7 @@ import { toast } from 'sonner'
import { z } from 'zod'
import { useParams } from 'common'
import { useCreatePublicationMutation } from 'data/etl/create-publication-mutation'
import { useCreatePublicationMutation } from 'data/etl/publication-create-mutation'
import { useReplicationTablesQuery } from 'data/etl/tables-query'
import {
Button,
@@ -139,7 +139,7 @@ export const NewPublicationPanel = ({ visible, sourceId, onClose }: NewPublicati
</MultiSelector.Trigger>
<MultiSelector.Content>
<MultiSelector.List>
{tables?.tables.map((table) => (
{tables?.map((table) => (
<MultiSelector.Item
key={`${table.schema}.${table.name}`}
value={`${table.schema}.${table.name}`}
@@ -147,25 +147,27 @@ export const PublicationsList = () => {
{isSuccess &&
publications.map((x) => (
<TableRow key={x.name}>
<TableCell className="flex items-center gap-x-2 items-center">
{x.name}
{/* [Joshen] Making this tooltip very specific for these 2 publications */}
{['supabase_realtime', 'supabase_realtime_messages_publication'].includes(
x.name
) && (
<Tooltip>
<TooltipTrigger>
<Info size={14} className="text-foreground-light" />
</TooltipTrigger>
<TooltipContent side="bottom">
{x.name === 'supabase_realtime'
? 'This publication is managed by Supabase and handles Postgres changes'
: x.name === 'supabase_realtime_messages_publication'
? 'This publication is managed by Supabase and handles broadcasts from the database'
: undefined}
</TooltipContent>
</Tooltip>
)}
<TableCell>
<div className="flex items-center gap-x-2">
{x.name}
{/* [Joshen] Making this tooltip very specific for these 2 publications */}
{['supabase_realtime', 'supabase_realtime_messages_publication'].includes(
x.name
) && (
<Tooltip>
<TooltipTrigger>
<Info size={14} className="text-foreground-light" />
</TooltipTrigger>
<TooltipContent side="bottom">
{x.name === 'supabase_realtime'
? 'Managed by Supabase and handles Postgres changes'
: x.name === 'supabase_realtime_messages_publication'
? 'Managed by Supabase and handles broadcasts from the database'
: undefined}
</TooltipContent>
</Tooltip>
)}
</div>
</TableCell>
<TableCell>{x.id}</TableCell>
{publicationEvents.map((event) => (
@@ -78,8 +78,8 @@ export const PublicationsTableItem = ({
return (
<TableRow key={table.id}>
<TableCell className="py-3 whitespace-nowrap">{table.name}</TableCell>
<TableCell className="py-3 whitespace-nowrap">{table.schema}</TableCell>
<TableCell className="py-3 hidden max-w-sm truncate whitespace-nowrap lg:table-cell">
<TableCell className="py-3 whitespace-nowrap text-foreground-light">{table.schema}</TableCell>
<TableCell className="py-3 whitespace-nowrap hidden lg:table-cell max-w-sm truncate text-foreground-light">
{table.comment}
</TableCell>
<TableCell className="py-3">
@@ -102,7 +102,7 @@ export const PublicationsTables = () => {
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Schema</TableHead>
<TableHead>Description</TableHead>
<TableHead className="hidden lg:table-cell">Description</TableHead>
{/*
We've disabled All tables toggle for publications.
See https://github.com/supabase/supabase/pull/7233.
@@ -20,6 +20,7 @@ import { useRouter } from 'next/router'
import { useState } from 'react'
import { useParams } from 'common'
import { LOAD_TAB_FROM_CACHE_PARAM } from 'components/grid/SupabaseGrid.utils'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip'
@@ -63,7 +64,6 @@ import {
} from 'ui'
import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning'
import { formatAllEntities } from './Tables.utils'
import { LOAD_TAB_FROM_CACHE_PARAM } from 'components/grid/SupabaseGrid.utils'
interface TableListProps {
onAddTable: () => void
@@ -325,7 +325,7 @@ export const TableList = ({
<TableHead key="size" className="hidden text-right xl:table-cell">
Size (Estimated)
</TableHead>
<TableHead key="realtime" className="hidden xl:table-cell text-center">
<TableHead key="realtime" className="hidden xl:table-cell text-right">
Realtime Enabled
</TableHead>
<TableHead key="buttons"></TableHead>
@@ -457,11 +457,11 @@ export const TableList = ({
{(realtimePublication?.tables ?? []).find(
(table) => table.id === x.id
) ? (
<div className="flex justify-center">
<div className="flex justify-end">
<Check size={18} strokeWidth={2} className="text-brand" />
</div>
) : (
<div className="flex justify-center">
<div className="flex justify-end">
<X size={18} strokeWidth={2} className="text-foreground-lighter" />
</div>
)}
@@ -168,7 +168,7 @@ const AddNewSecretForm = () => {
<form className="w-full" onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle>Add new secrets</CardTitle>
<CardTitle>Add or replace secrets</CardTitle>
</CardHeader>
<CardContent>
{fields.map((fieldItem, index) => (
@@ -178,7 +178,7 @@ const AddNewSecretForm = () => {
name={`secrets.${index}.name`}
render={({ field }) => (
<FormItem_Shadcn_ className="w-full">
<FormLabel_Shadcn_>Key</FormLabel_Shadcn_>
<FormLabel_Shadcn_>Name</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input
{...field}
@@ -246,9 +246,13 @@ const AddNewSecretForm = () => {
Add another
</Button>
</CardContent>
<CardFooter className="justify-end space-x-2">
<CardFooter className="justify-between space-x-2">
<p className="text-sm text-foreground-lighter">
Insert or update multiple secrets at once by pasting key-value pairs
</p>
<Button type="primary" htmlType="submit" disabled={isCreating} loading={isCreating}>
{isCreating ? 'Saving...' : 'Save'}
{isCreating ? 'Saving...' : fields.length > 1 ? 'Bulk save' : 'Save'}
</Button>
</CardFooter>
</Card>
@@ -1,18 +1,28 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Trash } from 'lucide-react'
import { Edit2, MoreVertical, Trash } from 'lucide-react'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import type { ProjectSecret } from 'data/secrets/secrets-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { TableCell, TableRow } from 'ui'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
TableCell,
TableRow,
} from 'ui'
import { TimestampInfo } from 'ui-patterns'
interface EdgeFunctionSecretProps {
secret: ProjectSecret
onSelectDelete: () => void
onSelectEdit: () => void
}
const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps) => {
const EdgeFunctionSecret = ({ secret, onSelectEdit, onSelectDelete }: EdgeFunctionSecretProps) => {
const { can: canUpdateSecrets } = useAsyncCheckPermissions(PermissionAction.SECRETS_WRITE, '*')
// [Joshen] Following API's validation:
// https://github.com/supabase/infrastructure/blob/develop/api/src/routes/v1/projects/ref/secrets/secrets.controller.ts#L106
@@ -37,7 +47,7 @@ const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps)
displayAs="utc"
utcTimestamp={secret.updated_at}
labelFormat="DD MMM YYYY HH:mm:ss (ZZ)"
className="!text-sm text-foreground-light"
className="!text-sm text-foreground-light whitespace-nowrap"
/>
) : (
'-'
@@ -45,23 +55,63 @@ const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps)
</TableCell>
<TableCell>
<div className="flex items-center justify-end">
<ButtonTooltip
type="text"
icon={<Trash />}
className="px-1"
disabled={!canUpdateSecrets || isReservedSecret}
onClick={() => onSelectDelete()}
tooltip={{
content: {
side: 'bottom',
text: isReservedSecret
? 'This is a reserved secret and cannot be deleted'
: !canUpdateSecrets
? 'You need additional permissions to delete edge function secrets'
: undefined,
},
}}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More options"
type="default"
className="px-1"
icon={<MoreVertical />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-52">
<DropdownMenuItem asChild>
<ButtonTooltip
type="text"
icon={<Edit2 size={14} />}
className="w-full justify-start group text-inherit"
disabled={!canUpdateSecrets || isReservedSecret}
onClick={() => onSelectEdit()}
tooltip={{
content: {
side: 'bottom',
text: isReservedSecret
? 'This is a reserved secret and cannot be changed'
: !canUpdateSecrets
? 'You need additional permissions to edit edge function secrets'
: undefined,
},
}}
>
Edit secret
</ButtonTooltip>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<ButtonTooltip
type="text"
icon={<Trash size={14} className="group-[&:not(:disabled)]:text-destructive" />}
className="w-full justify-start group text-inherit"
disabled={!canUpdateSecrets || isReservedSecret}
onClick={() => onSelectDelete()}
tooltip={{
content: {
side: 'bottom',
text: isReservedSecret
? 'This is a reserved secret and cannot be deleted'
: !canUpdateSecrets
? 'You need additional permissions to delete edge function secrets'
: undefined,
},
}}
>
Delete secret
</ButtonTooltip>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
@@ -15,11 +15,17 @@ import { Input } from 'ui-patterns/DataInputs/Input'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import AddNewSecretForm from './AddNewSecretForm'
import EdgeFunctionSecret from './EdgeFunctionSecret'
import { EditSecretSheet } from './EditSecretSheet'
type SelectedProjectSecret = {
secret: ProjectSecret
op: 'delete' | 'edit'
}
const EdgeFunctionSecrets = () => {
const { ref: projectRef } = useParams()
const [searchString, setSearchString] = useState('')
const [selectedSecret, setSelectedSecret] = useState<ProjectSecret>()
const [selectedSecret, setSelectedSecret] = useState<SelectedProjectSecret>()
const { can: canReadSecrets, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
PermissionAction.SECRETS_READ,
@@ -33,7 +39,7 @@ const EdgeFunctionSecrets = () => {
const { mutate: deleteSecret, isLoading: isDeleting } = useSecretsDeleteMutation({
onSuccess: () => {
toast.success(`Successfully deleted ${selectedSecret?.name}`)
toast.success(`Successfully deleted ${selectedSecret?.secret.name}`)
setSelectedSecret(undefined)
},
})
@@ -99,7 +105,8 @@ const EdgeFunctionSecrets = () => {
<EdgeFunctionSecret
key={secret.name}
secret={secret}
onSelectDelete={() => setSelectedSecret(secret)}
onSelectEdit={() => setSelectedSecret({ secret, op: 'edit' })}
onSelectDelete={() => setSelectedSecret({ secret, op: 'delete' })}
/>
))
) : secrets.length === 0 && searchString.length > 0 ? (
@@ -131,17 +138,23 @@ const EdgeFunctionSecrets = () => {
</>
)}
<EditSecretSheet
secret={selectedSecret?.secret}
visible={selectedSecret?.op === 'edit'}
onClose={() => setSelectedSecret(undefined)}
/>
<ConfirmationModal
variant="destructive"
loading={isDeleting}
visible={selectedSecret !== undefined}
visible={selectedSecret?.op === 'delete'}
confirmLabel="Delete secret"
confirmLabelLoading="Deleting secret"
title={`Confirm to delete secret "${selectedSecret?.name}"`}
title={`Confirm to delete secret "${selectedSecret?.secret.name}"`}
onCancel={() => setSelectedSecret(undefined)}
onConfirm={() => {
if (selectedSecret !== undefined) {
deleteSecret({ projectRef, secrets: [selectedSecret.name] })
deleteSecret({ projectRef, secrets: [selectedSecret.secret.name] })
}
}}
>
@@ -0,0 +1,280 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { SubmitHandler, useForm, type UseFormReturn } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import { useSecretsCreateMutation } from 'data/secrets/secrets-create-mutation'
import { ProjectSecret } from 'data/secrets/secrets-query'
import { Eye, EyeOff, X } from 'lucide-react'
import { useLatest } from 'react-use'
import {
Button,
cn,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input,
Input_Shadcn_,
Separator,
Sheet,
SheetClose,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
const FORM_ID = 'edit-secret-sidepanel'
const FormSchema = z.object({
name: z.string().min(1, 'Please provide a name for your secret'),
value: z.string().min(1, 'Please provide a value for your secret'),
})
type FormSchemaType = z.infer<typeof FormSchema>
interface EditSecretSheetProps {
secret?: ProjectSecret
visible: boolean
onClose: () => void
}
export function EditSecretSheet({ secret, visible, onClose }: EditSecretSheetProps) {
const secretName = useLatest(secret?.name)
const form = useForm<FormSchemaType>({
resolver: zodResolver(FormSchema),
})
useEffect(() => {
if (visible) {
form.reset({
name: secretName.current ?? '',
value: '',
})
}
}, [form, secretName, visible])
const isValid = form.formState.isValid
const { ref: projectRef } = useParams()
const { mutate: updateSecret, isLoading: isUpdating } = useSecretsCreateMutation({
onSuccess: (_, variables) => {
toast.success(`Successfully updated secret "${variables.secrets[0].name}"`)
onClose()
},
})
const onSubmit: SubmitHandler<FormSchemaType> = async ({ name, value }) => {
updateSecret({
projectRef,
secrets: [{ name, value }],
})
}
const { confirmOnClose, modal: closeConfirmationModal } = useConfirmOnClose({
checkIsDirty: () => form.formState.isDirty,
onClose,
})
return (
<Sheet open={visible} onOpenChange={confirmOnClose}>
<SheetContent
showClose={false}
size={'default'}
className={'!min-w-screen lg:!min-w-[600px] flex flex-col'}
>
<Header />
<Separator />
<FormBody form={form} onSubmit={onSubmit} />
<SheetFooter>
<Button disabled={isUpdating} type="default" onClick={confirmOnClose}>
Cancel
</Button>
<Button form={FORM_ID} htmlType="submit" disabled={!isValid} loading={isUpdating}>
Save
</Button>
</SheetFooter>
</SheetContent>
{closeConfirmationModal}
</Sheet>
)
}
const Header = (): ReactNode => {
return (
<SheetHeader className="py-3 flex flex-row gap-3 items-center border-b-0">
<SheetClose
className={cn(
'text-muted hover:text ring-offset-background hover:opacity-100',
'focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none data-[state=open]:bg-secondary',
'transition'
)}
>
<X className="h-3 w-3" />
<span className="sr-only">Close</span>
</SheetClose>
<SheetTitle>Edit secret</SheetTitle>
</SheetHeader>
)
}
type FormBodyProps = {
form: UseFormReturn<FormSchemaType>
onSubmit: SubmitHandler<FormSchemaType>
}
const FormBody = ({ form, onSubmit }: FormBodyProps): ReactNode => {
return (
<Form_Shadcn_ {...form}>
<form id={FORM_ID} className="flex-grow overflow-auto" onSubmit={form.handleSubmit(onSubmit)}>
<SheetSection>
<NameField form={form} />
</SheetSection>
<Separator />
<SheetSection className="space-y-4">
<SecretField form={form} />
</SheetSection>
<Separator />
</form>
</Form_Shadcn_>
)
}
type NameFieldProps = {
form: UseFormReturn<FormSchemaType>
}
const NameField = ({ form }: NameFieldProps): ReactNode => {
return (
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="Name" layout="horizontal">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
readOnly
className="!text-foreground-light cursor-not-allowed"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)
}
type SecretFieldProps = {
form: UseFormReturn<FormSchemaType>
}
const SecretField = ({ form }: SecretFieldProps): ReactNode => {
const [showSecretValue, setShowSecretValue] = useState(false)
return (
<FormField_Shadcn_
control={form.control}
name="value"
render={({ field }) => (
<FormItemLayout
label="Value"
layout="horizontal"
description="Secrets can’t be retrieved once saved. Enter a new value to overwrite the existing value."
>
<FormControl_Shadcn_>
<Input
{...field}
type={showSecretValue ? 'text' : 'password'}
placeholder="my-secret-value"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
actions={
<div className="mr-1">
<Button
type="text"
className="px-1"
icon={showSecretValue ? <EyeOff /> : <Eye />}
onClick={() => setShowSecretValue(!showSecretValue)}
/>
</div>
}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)
}
type UseConfirmOnCloseParams = {
checkIsDirty: () => boolean
onClose: () => void
}
type ConfirmOnCloseReturn = {
confirmOnClose: () => void
modal: ReactNode
}
const useConfirmOnClose = ({
checkIsDirty,
onClose,
}: UseConfirmOnCloseParams): ConfirmOnCloseReturn => {
const [visible, setVisible] = useState(false)
const confirmOnClose = useCallback(() => {
if (checkIsDirty()) {
setVisible(true)
} else {
onClose()
}
}, [checkIsDirty, onClose])
const onConfirm = () => {
setVisible(false)
onClose()
}
return {
confirmOnClose,
modal: (
<CloseConfirmationModal
visible={visible}
onClose={onConfirm}
onCancel={() => setVisible(false)}
/>
),
}
}
type CloseConfirmationModalProps = {
visible: boolean
onClose: () => void
onCancel: () => void
}
const CloseConfirmationModal = ({
visible,
onClose,
onCancel,
}: CloseConfirmationModalProps): ReactNode => {
return (
<ConfirmationModal
visible={visible}
title="Discard changes"
confirmLabel="Discard"
onCancel={onCancel}
onConfirm={onClose}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
)
}
@@ -37,9 +37,7 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
className="cursor-pointer"
>
<TableCell>
<div className="flex items-center gap-2">
<p className="text-sm text-foreground">{item.name}</p>
</div>
<p className="text-sm text-foreground whitespace-nowrap">{item.name}</p>
</TableCell>
<TableCell>
<div className="text-xs text-foreground-light flex gap-2 items-center truncate">
@@ -80,7 +78,7 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
</TableCell>
<TableCell className="lg:table-cell">
<Tooltip>
<TooltipTrigger>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2">
<p className="text-sm text-foreground-light">{dayjs(item.updated_at).fromNow()}</p>
</div>
@@ -49,7 +49,7 @@ export const AdvisorWidget = () => {
)
const snap = useAiAssistantStateSnapshot()
const { openSidebar } = useSidebarManagerSnapshot()
const { setSelectedItemId } = useAdvisorStateSnapshot()
const { setSelectedItem } = useAdvisorStateSnapshot()
const securityLints = useMemo(
() => (lints ?? []).filter((lint: Lint) => lint.categories.includes('SECURITY')),
@@ -80,10 +80,10 @@ export const AdvisorWidget = () => {
const handleLintClick = useCallback(
(lint: Lint) => {
setSelectedItemId(lint.cache_key)
setSelectedItem(lint.cache_key, 'lint')
openSidebar(SIDEBAR_KEYS.ADVISOR_PANEL)
},
[setSelectedItemId, openSidebar]
[setSelectedItem, openSidebar]
)
const totalIssues =
@@ -30,7 +30,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo
const { mutate: sendEvent } = useSendEventMutation()
const { data: organization } = useSelectedOrganizationQuery()
const { openSidebar } = useSidebarManagerSnapshot()
const { setSelectedItemId } = useAdvisorStateSnapshot()
const { setSelectedItem } = useAdvisorStateSnapshot()
const errorLints: Lint[] = useMemo(() => {
return lints?.filter((lint) => lint.level === LINTER_LEVELS.ERROR) ?? []
@@ -67,7 +67,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo
const handleCardClick = useCallback(
(lint: Lint) => {
setSelectedItemId(lint.cache_key)
setSelectedItem(lint.cache_key, 'lint')
openSidebar(SIDEBAR_KEYS.ADVISOR_PANEL)
if (projectRef && organization?.slug) {
sendEvent({
@@ -84,7 +84,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo
})
}
},
[sendEvent, setSelectedItemId, openSidebar, projectRef, organization, totalErrors]
[sendEvent, setSelectedItem, openSidebar, projectRef, organization, totalErrors]
)
if (showEmptyState) {
@@ -19,7 +19,7 @@ export const IndexImprovementText = ({
const improvement = calculateImprovement(totalCostBefore, totalCostAfter)
return (
<p className={cn('text-sm text-foreground-light', className)} {...props}>
<p className={cn('text-sm text-foreground-light mb-3', className)} {...props}>
Query's performance can be improved by{' '}
<span className="text-brand">{improvement.toFixed(2)}%</span> by creating this{' '}
{indexStatements.length > 1 ? 'indexes' : 'index'}:
@@ -124,9 +124,9 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
return (
<QueryPanelContainer className="h-full">
<QueryPanelSection>
<div>
<p className="text-sm">Indexes in use</p>
<QueryPanelSection className="pt-2 mb-6">
<div className="mb-4 flex flex-col gap-y-1">
<h4>Indexes in use</h4>
<p className="text-sm text-foreground-light">
This query is using the following index{(usedIndexes ?? []).length > 1 ? 's' : ''}:
</p>
@@ -171,12 +171,9 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
</div>
)}
</QueryPanelSection>
<div className="border-t" />
<QueryPanelSection className="flex flex-col gap-y-6">
<div className="flex flex-col gap-y-2">
<p className="text-sm">New index recommendations</p>
<QueryPanelSection className="flex flex-col gap-y-6 py-6 border-t">
<div className="flex flex-col gap-y-1">
<h4>New index recommendations</h4>
{isLoadingExtensions ? (
<GenericSkeletonLoader />
) : !isIndexAdvisorEnabled ? (
@@ -206,7 +203,7 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
{isLinterWarning ? (
<Alert_Shadcn_
variant="default"
className="border-brand-400 bg-alternative [&>svg]:p-0.5 [&>svg]:bg-transparent [&>svg]:text-brand"
className="border-brand-400 bg-alternative [&>svg]:p-0.5 [&>svg]:bg-transparent [&>svg]:text-brand my-3"
>
<Lightbulb />
<AlertTitle_Shadcn_>
@@ -238,10 +235,10 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
'[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap'
)}
/>
<p className="text-sm text-foreground-light">
<p className="text-sm text-foreground-light mt-3">
This recommendation serves to prevent your queries from slowing down as your
application grows, and hence the index may not be used immediately after
it's created. (e.g If your table is still small at this time)
it's created (e.g If your table is still small at this time).
</p>
</>
)}
@@ -250,11 +247,13 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
</>
)}
</div>
{isIndexAdvisorEnabled && hasIndexRecommendation && (
<>
<div className="flex flex-col gap-y-2">
<p className="text-sm">Query costs</p>
<div className="border rounded-md flex flex-col bg-surface-100">
</QueryPanelSection>
{isIndexAdvisorEnabled && hasIndexRecommendation && (
<>
<QueryPanelSection className="py-6 border-t">
<div className="flex flex-col gap-y-1">
<h4>Query costs</h4>
<div className="border rounded-md flex flex-col bg-surface-100 mt-3">
<QueryPanelScoreSection
name="Total cost of query"
description="An estimate of how long it will take to return all the rows (Includes start up cost)"
@@ -278,8 +277,10 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
</Collapsible_Shadcn_>
</div>
</div>
</QueryPanelSection>
<QueryPanelSection className="py-6 border-t">
<div className="flex flex-col gap-y-2">
<p className="text-sm">FAQ</p>
<h4>FAQ</h4>
<Accordion_Shadcn_ collapsible type="single" className="border rounded-md">
<AccordionItem_Shadcn_ value="1">
<AccordionTrigger className="px-4 py-3 text-sm font-normal text-foreground-light hover:text-foreground transition [&[data-state=open]]:text-foreground">
@@ -311,13 +312,13 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
</AccordionItem_Shadcn_>
</Accordion_Shadcn_>
</div>
</>
)}
</QueryPanelSection>
</QueryPanelSection>
</>
)}
{isIndexAdvisorEnabled && hasIndexRecommendation && (
<div className="bg-studio sticky bottom-0 border-t py-3 flex items-center justify-between px-5">
<div className="flex flex-col gap-y-1 text-sm">
<div className="flex flex-col gap-y-0.5 text-xs">
<span>Apply index to database</span>
<span className="text-xs text-foreground-light">
This will run the SQL that is shown above
@@ -44,7 +44,9 @@ export const ReportChartUpsell = ({ report, orgSlug }: ReportsChartUpsellProps)
<p className="text-sm text-foreground-light">
This chart is available from{' '}
<span className="capitalize">
{!!report.availableIn?.length ? report.availableIn[0] : 'Pro'}
{Array.isArray(report.availableIn) && report.availableIn.length > 0
? report.availableIn[0]
: 'Pro'}
</span>{' '}
plan and above
</p>
@@ -58,7 +60,9 @@ export const ReportChartUpsell = ({ report, orgSlug }: ReportsChartUpsellProps)
<Link href={`/org/${orgSlug || '_'}/billing?panel=subscriptionPlan&source=reports`}>
Upgrade to{' '}
<span className="capitalize">
{!!report.availableIn?.length ? report.availableIn[0] : 'Pro'}
{Array.isArray(report.availableIn) && report.availableIn.length > 0
? report.availableIn[0]
: 'Pro'}
</span>
</Link>
</Button>
@@ -68,8 +68,7 @@ export const ReportChartV2 = ({
const { plan: orgPlan } = useCurrentOrgPlan()
const orgPlanId = orgPlan?.id
const isAvailable =
report.availableIn === undefined || (orgPlanId && report.availableIn.includes(orgPlanId))
const isAvailable = !report?.availableIn || (orgPlanId && report.availableIn?.includes(orgPlanId))
const canFetch = orgPlanId !== undefined && isAvailable
@@ -1,29 +1,31 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { AnimatePresence, motion } from 'framer-motion'
import { snakeCase } from 'lodash'
import { Plus } from 'lucide-react'
import { useState } from 'react'
import { Loader2, Plus } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import { useFlag, useParams } from 'common'
import { convertKVStringArrayToJson } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
import { getCatalogURI } from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useCreateDestinationPipelineMutation } from 'data/etl/create-destination-pipeline-mutation'
import { useCreatePublicationMutation } from 'data/etl/create-publication-mutation'
import { useCreateTenantSourceMutation } from 'data/etl/create-tenant-source-mutation'
import { useCreatePublicationMutation } from 'data/etl/publication-create-mutation'
import { useUpdatePublicationMutation } from 'data/etl/publication-update-mutation'
import { useReplicationSourcesQuery } from 'data/etl/sources-query'
import { useStartPipelineMutation } from 'data/etl/start-pipeline-mutation'
import { useIcebergNamespaceCreateMutation } from 'data/storage/iceberg-namespace-create-mutation'
import { useTablesQuery } from 'data/tables/tables-query'
import { useReplicationTablesQuery } from 'data/etl/tables-query'
import { getDecryptedValues } from 'data/vault/vault-secret-decrypted-value-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
@@ -33,10 +35,13 @@ import {
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Progress,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { MultiSelector } from 'ui-patterns/multi-select'
import { getAnalyticsBucketPublicationName } from './AnalyticsBucketDetails.utils'
import { useAnalyticsBucketAssociatedEntities } from './useAnalyticsBucketAssociatedEntities'
import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance'
/**
@@ -52,24 +57,100 @@ import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperIn
* - Error handling due to multiple async processes
*/
const FormSchema = z.object({
tables: z.array(z.string()).min(1, 'At least one table is required'),
})
const formId = 'connect-tables-form'
const isEnabled = false // Kill switch if we wanna hold off supporting connecting tables
const FormSchema = z.object({
tables: z.array(z.string()).min(1, 'Select at least one table'),
})
type ConnectTablesForm = z.infer<typeof FormSchema>
export const ConnectTablesDialog = ({ bucketId }: { bucketId?: string }) => {
enum PROGRESS_STAGE {
CREATE_PUBLICATION = 'CREATE_PUBLICATION',
CREATE_PIPELINE = 'CREATE_PIPELINE',
START_PIPELINE = 'START_PIPELINE',
UPDATE_PUBLICATION = 'CREATE_REPLICATION',
}
const PROGRESS_INDICATORS = {
CREATE: [
{
step: PROGRESS_STAGE.CREATE_PUBLICATION,
getDescription: (numTables: number) =>
`Creating replication publication with ${numTables} table${numTables > 1 ? 's' : ''}...`,
},
{ step: PROGRESS_STAGE.CREATE_PIPELINE, description: `Creating replication pipeline` },
{ step: PROGRESS_STAGE.START_PIPELINE, description: `Starting replication pipeline` },
],
UPDATE: [
{
step: PROGRESS_STAGE.UPDATE_PUBLICATION,
getDescription: (numTables: number) =>
`Updating replication publication with ${numTables} table${numTables > 1 ? 's' : ''}...`,
},
{ step: PROGRESS_STAGE.START_PIPELINE, description: 'Restarting replication pipeline...' },
],
}
interface ConnectTablesDialogProps {
onSuccessConnectTables: (tables: { schema: string; name: string }[]) => void
}
export const ConnectTablesDialog = ({ onSuccessConnectTables }: ConnectTablesDialogProps) => {
const { ref: projectRef, bucketId } = useParams()
const [visible, setVisible] = useState(false)
const isEnabled = useFlag('storageAnalyticsVector') // Kill switch if we wanna hold off supporting connecting tables
const { sourceId, pipeline, publication } = useAnalyticsBucketAssociatedEntities({
projectRef,
bucketId,
})
const isEditingExistingPublication = !!publication && !!pipeline
return (
<Dialog open={visible} onOpenChange={setVisible}>
<DialogTrigger asChild>
<ButtonTooltip
disabled={!isEnabled}
size="tiny"
type="primary"
icon={<Plus />}
onClick={() => setVisible(true)}
tooltip={{ content: { side: 'bottom', text: !isEnabled ? 'Coming soon' : undefined } }}
>
{isEditingExistingPublication ? 'Add tables' : 'Connect tables'}
</ButtonTooltip>
</DialogTrigger>
{!sourceId ? (
<EnableReplicationDialogContent onClose={() => setVisible(false)} />
) : (
<ConnectTablesDialogContent
visible={visible}
onClose={() => setVisible(false)}
onSuccessConnectTables={onSuccessConnectTables}
/>
)}
</Dialog>
)
}
export const ConnectTablesDialogContent = ({
visible,
onClose,
onSuccessConnectTables,
}: ConnectTablesDialogProps & { visible: boolean; onClose: () => void }) => {
const { ref: projectRef, bucketId } = useParams()
const { data: project } = useSelectedProjectQuery()
const [isConnecting, setIsConnecting] = useState(false)
const [connectingStep, setConnectingStep] = useState<PROGRESS_STAGE>()
const form = useForm<ConnectTablesForm>({
resolver: zodResolver(FormSchema),
defaultValues: { tables: [] },
})
const [visible, setVisible] = useState(false)
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const { tables: selectedTables } = form.watch()
const { data: wrapperInstance } = useAnalyticsBucketWrapperInstance({ bucketId: bucketId })
const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
@@ -78,52 +159,54 @@ export const ConnectTablesDialog = ({ bucketId }: { bucketId?: string }) => {
const { data: apiKeys } = useAPIKeysQuery({ projectRef, reveal: true })
const { serviceKey } = getKeys(apiKeys)
const { data: tables } = useTablesQuery({
const { sourceId, pipeline, publication } = useAnalyticsBucketAssociatedEntities({
projectRef,
connectionString: project?.connectionString,
includeColumns: false,
bucketId,
})
const isEditingExistingPublication = !!publication && !!pipeline
const { data: sourcesData } = useReplicationSourcesQuery({ projectRef })
const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id
const { mutateAsync: createNamespace, isLoading: isCreatingNamespace } =
useIcebergNamespaceCreateMutation()
const { mutateAsync: createPublication, isLoading: isCreatingPublication } =
useCreatePublicationMutation()
const { mutateAsync: createDestinationPipeline, isLoading: creatingDestinationPipeline } =
useCreateDestinationPipelineMutation({
onSuccess: () => {},
})
const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId })
const { mutateAsync: createPublication } = useCreatePublicationMutation()
const { mutateAsync: updatePublication } = useUpdatePublicationMutation()
const { mutateAsync: createDestinationPipeline } = useCreateDestinationPipelineMutation()
const { mutateAsync: startPipeline } = useStartPipelineMutation()
const isConnecting = isCreatingNamespace || creatingDestinationPipeline || isCreatingPublication
const onSubmit: SubmitHandler<ConnectTablesForm> = async (values) => {
// [Joshen] Currently creates the destination for the analytics bucket here
// Which also involves creating a namespace + publication
// Publication name is automatically generated as {bucketId}_publication
// Destination name is automatically generated as {bucketId}_destination
const progressIndicator = useMemo(
() => (isEditingExistingPublication ? PROGRESS_INDICATORS.UPDATE : PROGRESS_INDICATORS.CREATE),
// [Joshen] This is to prevent the progressIndicator from flipping to UPDATE in the middle of CREATE
// since the publication and pipelines are getting created in the middle of CREATE
// eslint-disable-next-line react-hooks/exhaustive-deps
[isConnecting]
)
const totalProgressSteps = progressIndicator.length
const currentStep = progressIndicator.findIndex((x) => x.step === connectingStep) + 1
const progressDescription = progressIndicator.find((x) => x.step === connectingStep)
const onSubmitNewPublication: SubmitHandler<ConnectTablesForm> = async (values) => {
if (!projectRef) return console.error('Project ref is required')
if (!sourceId) return toast.error('Source ID is required')
if (!bucketId) return toast.error('Bucket ID is required')
if (!sourceId) return toast.error('Replication has not been enabled for your project')
try {
setIsConnecting(true)
// Step 1: Create publication
setConnectingStep(PROGRESS_STAGE.CREATE_PUBLICATION)
const publicationName = getAnalyticsBucketPublicationName(bucketId)
const publicationTables = values.tables.map((table) => {
const [schema, name] = table.split('.')
return { schema, name }
})
await createPublication({
projectRef,
sourceId,
name: publicationName,
tables: values.tables.map((table) => {
const [schema, name] = table.split('.')
return { schema, name }
}),
tables: publicationTables,
})
// Step 2: Create destination pipeline
setConnectingStep(PROGRESS_STAGE.CREATE_PIPELINE)
const keysToDecrypt = Object.entries(wrapperValues)
.filter(([name]) =>
['vault_aws_access_key_id', 'vault_aws_secret_access_key'].includes(name)
@@ -141,29 +224,15 @@ export const ConnectTablesDialog = ({ bucketId }: { bucketId?: string }) => {
const s3SecretAccessKey = decryptedValues[wrapperValues['vault_aws_secret_access_key']]
const s3Region = projectSettings?.region ?? ''
const protocol = projectSettings?.app_config?.protocol ?? 'https'
const endpoint =
projectSettings?.app_config?.storage_endpoint || projectSettings?.app_config?.endpoint
const catalogUri = getCatalogURI(project?.ref ?? '', protocol, endpoint)
const namespace = `${bucketId}_namespace`
await createNamespace({
catalogUri,
warehouse: warehouseName,
token: catalogToken,
namespace,
})
const icebergConfiguration = {
projectRef,
warehouseName,
namespace,
catalogToken,
s3AccessKeyId,
s3SecretAccessKey,
s3Region,
}
const destinationName = `${snakeCase(bucketId)}_destination`
const { pipeline_id: pipelineId } = await createDestinationPipeline({
projectRef,
destinationName,
@@ -172,104 +241,241 @@ export const ConnectTablesDialog = ({ bucketId }: { bucketId?: string }) => {
pipelineConfig: { publicationName },
})
// Pipeline can start behind the scenes, don't need to await
startPipeline({ projectRef, pipelineId })
// Step 3: Start the destination pipeline
setConnectingStep(PROGRESS_STAGE.START_PIPELINE)
await startPipeline({ projectRef, pipelineId })
onSuccessConnectTables?.(publicationTables)
toast.success(`Connected ${values.tables.length} tables to Analytics bucket!`)
form.reset()
setVisible(false)
onClose()
} catch (error: any) {
// [Joshen] JFYI there's several async processes here so if something goes wrong midway - we need to figure out how to roll back cleanly
// e.g publication gets created, but namespace creation fails -> should the old publication get deleted?
// Another question is probably whether all of these step by step logic should be at the API level instead of client level
// Same issue present within DestinationPanel - it's alright for now as we do an Alpha but this needs to be addressed before GA
toast.error(`Failed to connect tables: ${error.message}`)
} finally {
setIsConnecting(false)
setConnectingStep(undefined)
}
}
const handleClose = () => {
form.reset()
setVisible(false)
const onSubmitUpdatePublication: SubmitHandler<ConnectTablesForm> = async (values) => {
if (!projectRef) return console.error('Project ref is required')
if (!sourceId) return toast.error('Replication has not been enabled on this project')
if (!bucketId) return toast.error('Bucket ID is required')
if (!publication) return toast.error('Unable to find existing publication')
if (!pipeline) return toast.error('Unable to find existing pipeline')
try {
setIsConnecting(true)
const tablesToBeAdded = values.tables.map((table) => {
const [schema, name] = table.split('.')
return { schema, name }
})
setConnectingStep(PROGRESS_STAGE.UPDATE_PUBLICATION)
const publicationTables = publication.tables.concat(tablesToBeAdded)
await updatePublication({
projectRef,
sourceId,
publicationName: publication.name,
tables: publicationTables,
})
setConnectingStep(PROGRESS_STAGE.START_PIPELINE)
await startPipeline({ projectRef, pipelineId: pipeline.id })
onSuccessConnectTables?.(tablesToBeAdded)
toast.success('Successfully updated connected tables! Pipeline is being restarted')
onClose()
} catch (error: any) {
toast.error(`Failed to update tables: ${error.message}`)
} finally {
setIsConnecting(false)
setConnectingStep(undefined)
}
}
const onSubmit: SubmitHandler<ConnectTablesForm> = async (values) => {
if (isEditingExistingPublication) {
onSubmitUpdatePublication(values)
} else {
onSubmitNewPublication(values)
}
}
useEffect(() => {
form.reset()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible])
return (
<Dialog
open={visible}
onOpenChange={(open) => {
if (!open) handleClose()
}}
>
<DialogTrigger asChild>
<ButtonTooltip
disabled={!isEnabled}
size="tiny"
type="primary"
icon={<Plus size={14} />}
onClick={() => setVisible(true)}
tooltip={{ content: { side: 'bottom', text: !isEnabled ? 'Coming soon' : undefined } }}
>
Connect tables
</ButtonTooltip>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEditingExistingPublication ? 'Connect more tables' : 'Connect tables'}
</DialogTitle>
</DialogHeader>
<DialogContent>
<DialogHeader>
<DialogTitle>Connect tables</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSectionSeparator />
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<DialogSection className="flex flex-col gap-y-4">
<p className="text-sm">
Select the database tables to send data from. A destination analytics table will be
created for each, and data will replicate automatically.
</p>
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="overflow-visible">
<FormField_Shadcn_
control={form.control}
name="tables"
render={({ field }) => (
<FormItemLayout label="Tables">
<FormControl_Shadcn_>
<MultiSelector
values={field.value}
onValuesChange={field.onChange}
disabled={isConnecting}
>
<MultiSelector.Trigger label="Select tables..." badgeLimit="wrap" />
<MultiSelector.Content>
<MultiSelector.List>
{tables?.map((table) => (
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<DialogSection className="flex flex-col gap-y-4">
<p className="text-sm">
Select the database tables to send data from. A destination analytics table will be
created for each, and data will replicate automatically.
</p>
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="overflow-visible">
<FormField_Shadcn_
control={form.control}
name="tables"
render={({ field }) => (
<FormItemLayout label="Tables">
<FormControl_Shadcn_>
<MultiSelector
values={field.value}
onValuesChange={field.onChange}
disabled={isConnecting}
>
<MultiSelector.Trigger
deletableBadge
badgeLimit="wrap"
mode="inline-combobox"
label="Select tables..."
/>
<MultiSelector.Content>
<MultiSelector.List>
{tables?.map((table) => {
const alreadyConnected = (publication?.tables ?? []).some(
(x) => x.schema === table.schema && x.name === table.name
)
return (
<MultiSelector.Item
disabled={alreadyConnected}
className="[&>div]:flex [&>div]:items-center [&>div]:justify-between"
key={`${table.schema}.${table.name}`}
value={`${table.schema}.${table.name}`}
>
{`${table.schema}.${table.name}`}
<span>{`${table.schema}.${table.name}`}</span>
{alreadyConnected && <span>Connected to analytics bucket</span>}
</MultiSelector.Item>
))}
</MultiSelector.List>
</MultiSelector.Content>
</MultiSelector>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</DialogSection>
</form>
</Form_Shadcn_>
)
})}
</MultiSelector.List>
</MultiSelector.Content>
</MultiSelector>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</DialogSection>
</form>
</Form_Shadcn_>
<DialogFooter>
<Button type="default" disabled={isConnecting} onClick={() => setVisible(false)}>
Cancel
<AnimatePresence mode="wait">
{isConnecting && !!progressDescription && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<DialogSectionSeparator />
<DialogSection>
<div className="flex items-center gap-x-2 mb-2">
<p className="text-sm text-foreground-light">
{'getDescription' in progressDescription
? progressDescription.getDescription?.(selectedTables.length)
: progressDescription.description}
</p>
<Loader2 size={14} className="animate-spin" />
</div>
<Progress value={(currentStep / (totalProgressSteps + 1)) * 100} />
</DialogSection>
</motion.div>
)}
</AnimatePresence>
<DialogFooter>
<Button type="default" disabled={isConnecting} onClick={onClose}>
Cancel
</Button>
<Button form={formId} htmlType="submit" disabled={isConnecting}>
Connect
</Button>
</DialogFooter>
</DialogContent>
)
}
const EnableReplicationDialogContent = ({ onClose }: { onClose: () => void }) => {
const { ref: projectRef } = useParams()
const enablePgReplicate = useFlag('enablePgReplicate')
const { error } = useReplicationSourcesQuery({ projectRef })
const noAccessToReplication =
!enablePgReplicate || error?.message.includes('feature flag is required')
const { mutateAsync: createTenantSource, isLoading: creatingTenantSource } =
useCreateTenantSourceMutation()
const onEnableReplication = async () => {
if (!projectRef) return console.error('Project ref is required')
await createTenantSource({ projectRef })
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle>Database replication needs to be enabled</DialogTitle>
<DialogDescription>
Replication is used to sync data from your Postgres tables
</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="flex flex-col gap-y-2 !p-0">
<Admonition
type="warning"
className="rounded-none border-0 mb-0"
title={
noAccessToReplication
? 'Replication is currently unavailable for your project'
: 'Replication is currently in Alpha'
}
>
{noAccessToReplication ? (
<p className="text-sm !leading-normal">
Access to database replication is currently not available yet for public use. If
you're interested, do reach out to us via support!
</p>
) : (
<>
<p className="text-sm !leading-normal">
This feature is in active development and may change as we gather feedback.
Availability and behavior can evolve while in Alpha.
</p>
<p className="text-sm !leading-normal">
Pricing has not been finalized yet. You can enable replication now; we’ll announce
pricing later and notify you before any charges apply.
</p>
</>
)}
</Admonition>
</DialogSection>
<DialogFooter>
<Button type="default" disabled={creatingTenantSource} onClick={() => onClose()}>
{noAccessToReplication ? 'Understood' : 'Cancel'}
</Button>
{!noAccessToReplication && (
<Button type="primary" loading={creatingTenantSource} onClick={onEnableReplication}>
Enable replication
</Button>
<Button form={formId} htmlType="submit" loading={isConnecting} disabled={isConnecting}>
Connect
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</DialogFooter>
</DialogContent>
)
}
@@ -1,130 +0,0 @@
import { RefreshCw, SquareArrowOutUpRight } from 'lucide-react'
import { useMemo, useState } from 'react'
import type { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types'
import { FormattedWrapperTable } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
import { ImportForeignSchemaDialog } from 'components/interfaces/Storage/ImportForeignSchemaDialog'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useFDWImportForeignSchemaMutation } from 'data/fdw/fdw-import-foreign-schema-mutation'
import { FDW } from 'data/fdw/fdws-query'
import { useIcebergNamespaceTablesQuery } from 'data/storage/iceberg-namespace-tables-query'
import { BASE_PATH } from 'lib/constants'
import { Button, cn, TableCell, TableRow } from 'ui'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
type NamespaceRowProps = {
bucketName: string
namespace: string
schema: string
tables: (FormattedWrapperTable & { id: number })[]
token: string
wrapperInstance: FDW
wrapperValues: Record<string, string>
wrapperMeta: WrapperMeta
}
export const NamespaceRow = ({
bucketName,
namespace,
schema,
tables,
token,
wrapperInstance,
wrapperValues,
wrapperMeta,
}: NamespaceRowProps) => {
const { data: project } = useSelectedProjectQuery()
const [importForeignSchemaShown, setImportForeignSchemaShown] = useState(false)
const { data: tablesData, isLoading: isLoadingNamespaceTables } = useIcebergNamespaceTablesQuery(
{
catalogUri: wrapperValues.catalog_uri,
warehouse: wrapperValues.warehouse,
token: token,
namespace: namespace,
},
{ enabled: !!token }
)
const { mutateAsync: importForeignSchema, isLoading: isImportingForeignSchema } =
useFDWImportForeignSchemaMutation()
const rescanNamespace = async () => {
await importForeignSchema({
projectRef: project?.ref,
connectionString: project?.connectionString,
serverName: wrapperInstance.server_name,
sourceSchema: namespace,
targetSchema: schema,
})
}
const missingTables = useMemo(() => {
return (tablesData || []).filter(
(t) => !tables.find((table) => table.table.split('.')[1] === t)
)
}, [tablesData, tables])
let scanTooltip = useMemo(() => {
if (isImportingForeignSchema) return 'Scanning for new tables...'
if (isLoadingNamespaceTables) return 'Loading tables...'
if (missingTables.length > 0) return `Found ${missingTables.length} new tables`
if (tables.length === 0) return 'No tables found'
return 'All tables are up to date'
}, [isImportingForeignSchema, isLoadingNamespaceTables, missingTables.length, tables.length])
return (
<TableRow key={namespace}>
<TableCell>{namespace}</TableCell>
<TableCell className={cn(!schema ? 'text-foreground-lighter' : '')}>
{schema ?? 'No schema'}
</TableCell>
<TableCell>
{tablesData ? `${tables.length}/${tablesData.length} connected tables` : ``}
</TableCell>
<TableCell className="text-right">
<div className="flex gap-2 justify-end">
<ButtonTooltip
type={missingTables.length > 0 ? 'primary' : 'default'}
icon={<RefreshCw className="h-3 w-3" />}
loading={isImportingForeignSchema || isLoadingNamespaceTables}
onClick={() => (schema ? rescanNamespace() : setImportForeignSchemaShown(true))}
disabled={missingTables.length === 0}
tooltip={{ content: { text: scanTooltip } }}
>
Sync
</ButtonTooltip>
{schema ? (
<Button asChild type="default" icon={<SquareArrowOutUpRight />}>
<a
target="_blank"
rel="noreferrer"
href={`${BASE_PATH}/project/${project?.ref}/editor?schema=${schema}`}
>
Open in Table Editor
</a>
</Button>
) : (
<ButtonTooltip
type="default"
icon={<SquareArrowOutUpRight />}
disabled
tooltip={{
content: { text: 'There are no tables connected.' },
}}
>
Open in Table Editor
</ButtonTooltip>
)}
</div>
</TableCell>
<ImportForeignSchemaDialog
bucketName={bucketName}
namespace={namespace}
wrapperMeta={wrapperMeta}
visible={importForeignSchemaShown}
onClose={() => setImportForeignSchemaShown(false)}
/>
</TableRow>
)
}
@@ -0,0 +1,240 @@
import { snakeCase } from 'lodash'
import { MoreVertical, Pause, Play } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useUpdatePublicationMutation } from 'data/etl/publication-update-mutation'
import { useStartPipelineMutation } from 'data/etl/start-pipeline-mutation'
import { useReplicationTablesQuery } from 'data/etl/tables-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { SqlEditor } from 'icons'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
TableCell,
TableRow,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { useAnalyticsBucketAssociatedEntities } from '../useAnalyticsBucketAssociatedEntities'
export const TableRowComponent = ({
index,
table,
schema,
isLoading,
}: {
index: number
table: { id: number; name: string; isConnected: boolean }
schema: string
isLoading?: boolean
}) => {
const { ref: projectRef, bucketId } = useParams()
const { data: project } = useSelectedProjectQuery()
const [showStopReplicationModal, setShowStopReplicationModal] = useState(false)
const [showStartReplicationModal, setShowStartReplicationModal] = useState(false)
const [isUpdatingReplication, setIsUpdatingReplication] = useState(false)
const { sourceId, publication, pipeline } = useAnalyticsBucketAssociatedEntities({
projectRef,
bucketId,
})
const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId })
const { mutateAsync: updatePublication } = useUpdatePublicationMutation()
const { mutateAsync: startPipeline } = useStartPipelineMutation()
const isReplicating = publication?.tables.find(
(x) => table.name === snakeCase(`${x.schema}.${x.name}_changelog`)
)
const onConfirmStopReplication = async () => {
if (!projectRef) return console.error('Project ref is required')
if (!bucketId) return console.error('Bucket ID is required')
if (!sourceId) return toast.error('Source ID is required')
if (!publication) return toast.error('Unable to find existing publication')
if (!pipeline) return toast.error('Unable to find existing pipeline')
try {
setIsUpdatingReplication(true)
// [Joshen ALPHA] Assumption here is that all the namespace tables have _changelog as suffix
// May need to update if that assumption falls short (e.g for those dealing with iceberg APIs directly)
const updatedTables = publication.tables.filter(
(x) => table.name !== snakeCase(`${x.schema}.${x.name}_changelog`)
)
await updatePublication({
projectRef,
sourceId,
publicationName: publication.name,
tables: updatedTables,
})
await startPipeline({ projectRef, pipelineId: pipeline.id })
setShowStopReplicationModal(false)
toast.success('Successfully stopped replication for table! Pipeline is being restarted.')
} catch (error: any) {
toast.error(`Failed to stop replication for table: ${error.message}`)
} finally {
setIsUpdatingReplication(false)
}
}
const onConfirmStartReplication = async () => {
if (!projectRef) return console.error('Project ref is required')
if (!bucketId) return console.error('Bucket ID is required')
if (!sourceId) return toast.error('Source ID is required')
if (!publication) return toast.error('Unable to find existing publication')
if (!pipeline) return toast.error('Unable to find existing pipeline')
// [Joshen ALPHA] This has potential to be flaky - we should see how we can get the table name and schema better
const pgTable = tables?.find((t) => snakeCase(`${t.schema}.${t.name}_changelog`) === table.name)
if (!pgTable) return toast.error('Unable to find corresponding Postgres table')
try {
setIsUpdatingReplication(true)
const updatedTables = publication.tables.concat([
{ schema: pgTable.schema, name: pgTable.name },
])
await updatePublication({
projectRef,
sourceId,
publicationName: publication.name,
tables: updatedTables,
})
await startPipeline({ projectRef, pipelineId: pipeline.id })
setShowStartReplicationModal(false)
toast.success('Successfully stopped replication for table! Pipeline is being restarted.')
} catch (error: any) {
toast.error(`Failed to stop replication for table: ${error.message}`)
} finally {
setIsUpdatingReplication(false)
}
}
return (
<>
<TableRow>
<TableCell className="min-w-[120px]">{table.name}</TableCell>
{!!publication && (
<TableCell colSpan={table.isConnected ? 1 : 2} className="min-w-[150px]">
<div className="flex flex-row items-center text-foregroung-lighter">
<div className="relative mr-2 align-middle w-3 h-3">
<span
className={`absolute inset-0 rounded-full ${
isReplicating
? isLoading
? 'bg-brand/20 animate-ping'
: 'bg-brand/20 animate-ping'
: isLoading
? 'bg-selection/20 animate-ping'
: 'hidden'
}`}
style={{
animationDelay: `${1 + index * 0.15}s`,
animationDuration: '2s',
}}
/>
<span
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 inline-block w-2 h-2 rounded-full ${
isReplicating ? 'bg-brand' : 'bg-selection'
}`}
/>
</div>
<span className="text-foreground-lighter">
{isLoading && !isReplicating
? '-'
: isReplicating
? 'Replicating'
: 'Not replicating'}
</span>
</div>
</TableCell>
)}
{table.isConnected && (
<TableCell className="text-right flex flex-row items-center gap-x-2 justify-end">
<>
<Button asChild type="default" size="tiny">
<Link href={`/project/${project?.ref}/editor/${table.id}?schema=${schema}`}>
<p>View table</p>
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-fit min-w-[180px]">
<DropdownMenuItem asChild className="flex items-center gap-x-2">
<Link
href={`/project/${project?.ref}/sql/new?content=${encodeURIComponent(`select * from ${schema}.${table.name};`)}`}
>
<SqlEditor size={12} className="text-foreground-lighter" />
<p>Query in SQL Editor</p>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{isReplicating ? (
<DropdownMenuItem
className="flex items-center gap-x-2"
onClick={() => setShowStopReplicationModal(true)}
>
<Pause size={12} className="text-foreground-lighter" />
<p>Stop replication</p>
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="flex items-center gap-x-2"
onClick={() => setShowStartReplicationModal(true)}
>
<Play size={12} className="text-foreground-lighter" />
<p>Start replication</p>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
</TableCell>
)}
</TableRow>
<ConfirmationModal
size="medium"
variant="warning"
visible={showStopReplicationModal}
loading={isUpdatingReplication}
title="Confirm to stop replication for table"
confirmLabel="Stop replication"
onCancel={() => setShowStopReplicationModal(false)}
onConfirm={() => onConfirmStopReplication()}
>
<p className="text-sm text-foreground-light">
Data within the "{table.name}" table will stop replicating. However do note that,
restarting replication on the table will clear and re-sync all data in it. Are you sure?
</p>
</ConfirmationModal>
<ConfirmationModal
size="medium"
variant="warning"
visible={showStartReplicationModal}
loading={isUpdatingReplication}
title="Confirm to start replication for table"
confirmLabel="Start replication"
onCancel={() => setShowStartReplicationModal(false)}
onConfirm={() => onConfirmStartReplication()}
>
<p className="text-sm text-foreground-light">
Restarting replication on the "{table.name}" table will clear and re-sync all data in it.
Are you sure?
</p>
</ConfirmationModal>
</>
)
}
@@ -1,7 +1,7 @@
import { ChevronRight, Code, Info, MoreVertical, Plus, Replace, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { ChevronRight, Info, Loader2, Plus, RefreshCw } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useParams } from 'common'
import type { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types'
import { FormattedWrapperTable } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
import { ImportForeignSchemaDialog } from 'components/interfaces/Storage/ImportForeignSchemaDialog'
@@ -14,10 +14,7 @@ import {
Card,
CardHeader,
CardTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
LoadingLine,
Table,
TableBody,
TableCell,
@@ -26,9 +23,10 @@ import {
TableRow,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from 'ui'
import { useAnalyticsBucketAssociatedEntities } from '../useAnalyticsBucketAssociatedEntities'
import { TableRowComponent } from './TableRowComponent'
type NamespaceWithTablesProps = {
bucketName?: string
@@ -40,108 +38,9 @@ type NamespaceWithTablesProps = {
wrapperInstance: FDW
wrapperValues: Record<string, string>
wrapperMeta: WrapperMeta
}
// Component for individual table rows within a namespace
const TableRowComponent = ({
index,
tableName,
isConnected,
isLoading,
}: {
index: number
tableName: string
isConnected: boolean
isLoading?: boolean
}) => {
const { data: project } = useSelectedProjectQuery()
const handleQueryTable = () => {
// TODO: Implement query table functionality
console.log('Query table:', tableName)
}
const handleDeleteTable = () => {
// TODO: Implement delete table functionality
console.log('Delete table:', tableName)
}
return (
<TableRow>
<TableCell className="min-w-[120px]">{tableName}</TableCell>
<TableCell colSpan={isConnected ? 1 : 2} className="min-w-[150px]">
<div className="flex flex-row items-center text-foregroung-lighter">
<div className="relative mr-2 align-middle w-3 h-3">
{/* Outer faded dot with pulsing background */}
<span
className={`absolute inset-0 rounded-full ${
isConnected
? isLoading
? 'bg-brand/20 animate-ping'
: 'bg-brand/20 animate-ping'
: isLoading
? 'bg-warning-500/20 animate-ping'
: 'hidden'
}`}
style={{
// Stagger the animation delay for each table
animationDelay: `${1 + index * 0.15}s`,
// Slow the animation
animationDuration: '2s',
}}
/>
{/* Inner colored dot */}
<span
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 inline-block w-2 h-2 rounded-full ${
isConnected ? 'bg-brand' : 'bg-warning-500'
}`}
/>
</div>
<span className="text-foreground-lighter">
{isLoading && !isConnected
? 'Connecting...'
: isConnected
? 'Connected'
: 'Not connected'}
</span>
</div>
</TableCell>
{isConnected && (
<TableCell className="text-right flex flex-row items-center gap-x-2 justify-end">
<>
<Button asChild type="default" size="tiny">
<Link href={`/project/${project?.ref}/editor/${tableName}`}>
<p>View table</p>
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-fit min-w-[180px]">
<DropdownMenuItem className="flex items-center gap-x-2" onClick={handleQueryTable}>
<Code size={12} className="text-foreground-lighter" />
<p>Query in SQL Editor</p>
</DropdownMenuItem>
<DropdownMenuItem asChild className="flex items-center gap-x-2">
<Link href={`/project/${project?.ref}/database/etl/${tableName}`}>
<Replace size={12} className="text-foreground-lighter" />
<p>View replication status</p>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="flex items-center gap-x-2" onClick={handleDeleteTable}>
<Trash2 size={12} className="text-foreground-lighter" />
<p>Delete table</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
</TableCell>
)}
</TableRow>
)
tablesToPoll: { schema: string; name: string }[]
pollIntervalNamespaceTables: number
setPollIntervalNamespaceTables: (value: number) => void
}
export const NamespaceWithTables = ({
@@ -154,10 +53,16 @@ export const NamespaceWithTables = ({
wrapperInstance,
wrapperValues,
wrapperMeta,
tablesToPoll,
pollIntervalNamespaceTables,
setPollIntervalNamespaceTables,
}: NamespaceWithTablesProps) => {
const { ref: projectRef, bucketId } = useParams()
const { data: project } = useSelectedProjectQuery()
const [importForeignSchemaShown, setImportForeignSchemaShown] = useState(false)
const { publication } = useAnalyticsBucketAssociatedEntities({ projectRef, bucketId })
const { data: tablesData, isLoading: isLoadingNamespaceTables } = useIcebergNamespaceTablesQuery(
{
catalogUri: wrapperValues.catalog_uri,
@@ -165,7 +70,23 @@ export const NamespaceWithTables = ({
token: token,
namespace: namespace,
},
{ enabled: !!token }
{
enabled: !!token,
refetchInterval: (data) => {
if (pollIntervalNamespaceTables === 0) return false
if (tablesToPoll.length > 0) {
const hasMissingTables =
(data ?? []).filter((t) => !tables.find((table) => table.table.split('.')[1] === t))
.length > 0
if (hasMissingTables) {
setPollIntervalNamespaceTables(0)
return false
}
}
return pollIntervalNamespaceTables
},
}
)
const { mutateAsync: importForeignSchema, isLoading: isImportingForeignSchema } =
@@ -187,21 +108,13 @@ export const NamespaceWithTables = ({
)
}, [tablesData, tables])
let scanTooltip = useMemo(() => {
if (isImportingForeignSchema) return 'Looking for new tables...'
if (isLoadingNamespaceTables) return 'Loading new tables...'
if (missingTables.length > 0)
return `${missingTables.length} new table${missingTables.length > 1 ? 's' : ''} found`
if (tables.length === 0) return 'No new tables found'
return 'All tables are up to date'
}, [isImportingForeignSchema, isLoadingNamespaceTables, missingTables.length, tables.length])
// Get all tables (connected + missing) for display
const allTables = useMemo(() => {
const connectedTableNames = tables.map((table) => table.table.split('.')[1])
const allTableNames = [...new Set([...connectedTableNames, ...missingTables])]
return allTableNames.map((tableName) => ({
id: tables.find((t) => t.table_name === tableName)?.id ?? 0,
name: tableName,
isConnected: connectedTableNames.includes(tableName),
}))
@@ -234,19 +147,17 @@ export const NamespaceWithTables = ({
<Card>
<CardHeader className="flex flex-row justify-between items-center px-4 py-5 space-y-0">
<CardTitle className="text-sm font-normal font-sans normal-case leading-none flex flex-row items-center gap-x-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex flex-row items-center gap-x-1 text-foreground-lighter">
{sourceType === 'direct' ? namespace : 'public'}
<ChevronRight size={12} className="text-foreground-muted" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>{sourceType === 'direct' ? 'Analytics' : 'Postgres'} schema</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex flex-row items-center gap-x-1 text-foreground-lighter">
{sourceType === 'direct' ? namespace : 'public'}
<ChevronRight size={12} className="text-foreground-muted" />
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{sourceType === 'direct' ? 'Analytics' : 'Postgres'} schema</p>
</TooltipContent>
</Tooltip>
{validSchema && (
<Tooltip>
<TooltipTrigger
@@ -264,7 +175,7 @@ export const NamespaceWithTables = ({
{tables.length === 0 && <Info size={12} />}
</span>
</TooltipTrigger>
<TooltipContent>
<TooltipContent side="bottom">
<p>Postgres schema{tables.length === 0 && ' that will be created'}</p>
</TooltipContent>
</Tooltip>
@@ -272,29 +183,41 @@ export const NamespaceWithTables = ({
</CardTitle>
<div className="flex flex-row gap-x-2">
{/* [Joshen] Temporarily commented out, will be introduced once language and UI details are finalized */}
{/* {tables.length > 0 && <p className="text-sm text-foreground-muted">{scanTooltip}</p>} */}
{/* {scanTooltip && <p className="text-sm text-foreground-muted">{scanTooltip}</p>} */}
{tablesToPoll.length > 0 && (
<div className="flex items-center gap-x-2 ml-6 text-foreground-lighter">
<Loader2 size={14} className="animate-spin" />
<p className="text-sm">
Connecting {tablesToPoll.length} table{tablesToPoll.length > 1 ? 's' : ''}
</p>
</div>
)}
{missingTables.length > 0 && (
<Button
type={missingTables.length > 0 ? 'warning' : 'default'}
type={schema ? 'default' : 'warning'}
size="tiny"
icon={<Plus size={14} />}
icon={schema ? <RefreshCw /> : <Plus size={14} />}
onClick={() => (schema ? rescanNamespace() : setImportForeignSchemaShown(true))}
loading={isImportingForeignSchema || isLoadingNamespaceTables}
>
Connect to table{missingTables.length > 1 ? 's' : ''}
{schema ? 'Sync tables' : `Connect to table${missingTables.length > 1 ? 's' : ''}`}
</Button>
)}
</div>
</CardHeader>
{tablesToPoll.length > 0 && <LoadingLine loading />}
<Table>
<TableHeader>
<TableRow>
<TableHead className={allTables.length === 0 ? 'text-foreground-muted' : undefined}>
Table name
</TableHead>
<TableHead className={allTables.length === 0 ? 'hidden' : undefined}>Status</TableHead>
{!!publication && (
<TableHead className={allTables.length === 0 ? 'hidden' : undefined}>
Replication Status
</TableHead>
)}
<TableHead />
</TableRow>
</TableHeader>
@@ -311,11 +234,11 @@ export const NamespaceWithTables = ({
</TableCell>
</TableRow>
) : (
allTables.map(({ name, isConnected }, index) => (
allTables.map((table, index) => (
<TableRowComponent
key={name}
tableName={name}
isConnected={isConnected}
key={table.name}
table={table}
schema={displaySchema}
isLoading={isImportingForeignSchema || isLoadingNamespaceTables}
index={index}
/>
@@ -1,8 +1,8 @@
import { uniq } from 'lodash'
import { SquarePlus } from 'lucide-react'
import { Loader2, SquarePlus } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'common'
import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants'
@@ -38,6 +38,7 @@ import { DeleteAnalyticsBucketModal } from '../DeleteAnalyticsBucketModal'
import { ConnectTablesDialog } from './ConnectTablesDialog'
import { NamespaceWithTables } from './NamespaceWithTables'
import { SimpleConfigurationDetails } from './SimpleConfigurationDetails'
import { useAnalyticsBucketAssociatedEntities } from './useAnalyticsBucketAssociatedEntities'
import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance'
import { useIcebergWrapperExtension } from './useIcebergWrapper'
@@ -55,14 +56,38 @@ export const AnalyticBucketDetails = () => {
const bucket = _bucket as undefined | AnalyticsBucket
const [modal, setModal] = useState<'delete' | null>(null)
// [Joshen] Namespaces are now created asynchronously when the pipeline is started, so long poll after
// updating connected tables until namespaces are updated
// Namespace would just be the schema (Which is currently limited to public)
// Wrapper table would be {schema}_{table}_changelog
const [tablesToPoll, setTablesToPoll] = useState<{ schema: string; name: string }[]>([])
const [pollIntervalNamespaces, setPollIntervalNamespaces] = useState(0)
const [pollIntervalNamespaceTables, setPollIntervalNamespaceTables] = useState(0)
/** The wrapper instance is the wrapper that is installed for this Analytics bucket. */
const { data: wrapperInstance, isLoading } = useAnalyticsBucketWrapperInstance({
bucketId: bucket?.id,
})
const { pipeline } = useAnalyticsBucketAssociatedEntities({
projectRef,
bucketId: bucket?.id,
})
const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper')
const wrapperMeta = (integration?.type === 'wrapper' && integration.meta) as WrapperMeta
const state = isLoading
? 'loading'
: extensionState === 'installed'
? wrapperInstance
? 'added'
: 'missing'
: extensionState
const wrapperTables = useMemo(() => {
if (!wrapperInstance) return []
return formatWrapperTables(wrapperInstance, wrapperMeta!)
}, [wrapperInstance, wrapperMeta])
const { data: extensionsData } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
@@ -85,15 +110,24 @@ export const AnalyticBucketDetails = () => {
warehouse: wrapperValues.warehouse,
token: token!,
},
{ enabled: isSuccessToken }
{
enabled: isSuccessToken,
refetchInterval: (data) => {
if (pollIntervalNamespaces === 0) return false
if (tablesToPoll.length > 0) {
const schemas = [...new Set(tablesToPoll.map((x) => x.schema))]
const hasSchemaMissingFromNamespace = !schemas.some((x) => (data ?? []).includes(x))
if (!hasSchemaMissingFromNamespace) {
setPollIntervalNamespaces(0)
return false
}
}
return pollIntervalNamespaces
},
}
)
const wrapperTables = useMemo(() => {
if (!wrapperInstance) return []
return formatWrapperTables(wrapperInstance, wrapperMeta!)
}, [wrapperInstance, wrapperMeta])
const namespaces = useMemo(() => {
const fdwNamespaces = wrapperTables.map((t) => t.table.split('.')[0]) as string[]
const namespaces = uniq([...fdwNamespaces, ...(namespacesData ?? [])])
@@ -110,13 +144,11 @@ export const AnalyticBucketDetails = () => {
})
}, [wrapperTables, namespacesData])
const state = isLoading
? 'loading'
: extensionState === 'installed'
? wrapperInstance
? 'added'
: 'missing'
: extensionState
useEffect(() => {
if (pollIntervalNamespaces === 0 && pollIntervalNamespaceTables === 0) {
setTablesToPoll([])
}
}, [pollIntervalNamespaces, pollIntervalNamespaceTables])
return (
<>
@@ -160,22 +192,68 @@ export const AnalyticBucketDetails = () => {
Analytics tables stored in this bucket
</ScaffoldSectionDescription>
</div>
{namespaces.length > 0 && <ConnectTablesDialog bucketId={bucket?.id} />}
<div className="flex items-center gap-x-2">
{!!pipeline && (
<Button asChild type="default">
<Link
href={`/project/${projectRef}/database/etl/${pipeline.replicator_id}`}
>
View replication
</Link>
</Button>
)}
{namespaces.length > 0 && (
<ConnectTablesDialog
onSuccessConnectTables={(tables) => {
setTablesToPoll(tables)
setPollIntervalNamespaces(4000)
setPollIntervalNamespaceTables(4000)
}}
/>
)}
</div>
</ScaffoldHeader>
{isLoadingNamespaces || isLoading ? (
<GenericSkeletonLoader />
) : namespaces.length === 0 ? (
<aside className="border border-dashed w-full bg-surface-100 rounded-lg px-4 py-10 flex flex-col gap-y-3 items-center text-center gap-1 text-balance">
<SquarePlus size={24} strokeWidth={1.5} className="text-foreground-muted" />
<div className="flex flex-col items-center text-center">
<h3>Connect database tables</h3>
<p className="text-foreground-light text-sm">
Stream data from tables for archival, backups, or analytical queries.
</p>
</div>
<ConnectTablesDialog bucketId={bucket?.id} />
</aside>
<>
{tablesToPoll.length > 0 ? (
<aside className="border border-dashed w-full bg-surface-100 rounded-lg px-4 py-10 flex flex-col gap-y-3 items-center text-center gap-1 text-balance">
<Loader2
size={24}
strokeWidth={1.5}
className="animate-spin text-foreground-muted"
/>
<div className="flex flex-col items-center text-center">
<h3>
Connecting {tablesToPoll.length} table
{tablesToPoll.length > 1 ? 's' : ''} to bucket
</h3>
<p className="text-foreground-light text-sm">
Tables will be shown here once the connection is complete
</p>
</div>
</aside>
) : (
<aside className="border border-dashed w-full bg-surface-100 rounded-lg px-4 py-10 flex flex-col gap-y-3 items-center text-center gap-1 text-balance">
<SquarePlus size={24} strokeWidth={1.5} className="text-foreground-muted" />
<div className="flex flex-col items-center text-center">
<h3>Connect database tables</h3>
<p className="text-foreground-light text-sm">
Stream data from tables for archival, backups, or analytical queries.
</p>
</div>
<ConnectTablesDialog
onSuccessConnectTables={(tables) => {
setTablesToPoll(tables)
setPollIntervalNamespaces(4000)
setPollIntervalNamespaceTables(4000)
}}
/>
</aside>
)}
</>
) : (
<div className="flex flex-col gap-y-10">
{namespaces.map(({ namespace, schema, tables }) => (
@@ -190,6 +268,9 @@ export const AnalyticBucketDetails = () => {
wrapperInstance={wrapperInstance}
wrapperValues={wrapperValues}
wrapperMeta={wrapperMeta}
tablesToPoll={tablesToPoll}
pollIntervalNamespaceTables={pollIntervalNamespaceTables}
setPollIntervalNamespaceTables={setPollIntervalNamespaceTables}
/>
))}
</div>
@@ -1,13 +1,14 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types'
import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation'
import { FDW } from 'data/fdw/fdws-query'
import { useReplicationPipelinesQuery } from 'data/etl/pipelines-query'
import {
ReplicationPublication,
useReplicationPublicationsQuery,
} from 'data/etl/publications-query'
import { useReplicationSourcesQuery } from 'data/etl/sources-query'
import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation'
import { FDW } from 'data/fdw/fdws-query'
import { useS3AccessKeyDeleteMutation } from 'data/storage/s3-access-key-delete-mutation'
import { S3AccessKey, useStorageCredentialsQuery } from 'data/storage/s3-access-key-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
@@ -57,7 +58,10 @@ export const useAnalyticsBucketAssociatedEntities = (
(p) => p.name === getAnalyticsBucketPublicationName(bucketId ?? '')
)
return { icebergWrapper, icebergWrapperMeta, s3AccessKey, publication }
const { data: pipelines } = useReplicationPipelinesQuery({ projectRef })
const pipeline = pipelines?.pipelines.find((x) => x.config.publication_name === publication?.name)
return { icebergWrapper, icebergWrapperMeta, s3AccessKey, sourceId, publication, pipeline }
}
export const useAnalyticsBucketDeleteCleanUp = () => {
@@ -6,12 +6,12 @@ import {
getWrapperMetaForWrapper,
wrapperMetaComparator,
} from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
import { useFDWsQuery } from 'data/fdw/fdws-query'
import { type FDW, useFDWsQuery } from 'data/fdw/fdws-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
export const useAnalyticsBucketWrapperInstance = (
{ bucketId }: { bucketId?: string },
options?: { enabled?: boolean }
options?: { enabled?: boolean; refetchInterval?: (data: FDW[] | undefined) => number | false }
) => {
const { data: project, isLoading: isLoadingProject } = useSelectedProjectQuery()
@@ -21,7 +21,11 @@ export const useAnalyticsBucketWrapperInstance = (
projectRef: project?.ref,
connectionString: project?.connectionString,
},
{ enabled: defaultEnabled && !!bucketId }
{
enabled: defaultEnabled && !!bucketId,
refetchInterval: (data) =>
!!options?.refetchInterval ? options.refetchInterval(data) : false,
}
)
const icebergWrapper = useMemo(() => {
@@ -164,8 +164,8 @@ export const CreateAnalyticsBucketModal = ({
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
toast.success(`Created bucket “${values.name}”`)
form.reset()
toast.success(`Created bucket “${values.name}”`)
setVisible(false)
router.push(`/project/${ref}/storage/analytics/buckets/${values.name}`)
} catch (error: any) {
@@ -36,11 +36,14 @@ export const ImportForeignSchemaDialog = ({
onClose,
circumstance = 'fresh',
}: ImportForeignSchemaDialogProps) => {
const { data: project } = useSelectedProjectQuery()
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const [loading, setLoading] = useState(false)
const [createSchemaSheetOpen, setCreateSchemaSheetOpen] = useState(false)
const { data: schemas } = useSchemasQuery({ projectRef: project?.ref! })
const { mutateAsync: createSchema } = useSchemaCreateMutation()
const { mutateAsync: importForeignSchema } = useFDWImportForeignSchemaMutation({})
const { mutateAsync: updateFDW } = useFDWUpdateMutation({
onSuccess: () => {
@@ -49,8 +52,6 @@ export const ImportForeignSchemaDialog = ({
},
})
const { data: schemas } = useSchemasQuery({ projectRef: project?.ref! })
const FormSchema = z.object({
bucketName: z.string().trim(),
sourceNamespace: z.string().trim(),
@@ -73,12 +74,10 @@ export const ImportForeignSchemaDialog = ({
defaultValues: {
bucketName,
sourceNamespace: namespace,
targetSchema: '',
targetSchema: `fdw_analytics_${namespace}`,
},
})
const { mutateAsync: createSchema } = useSchemaCreateMutation()
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
const serverName = `${snakeCase(values.bucketName)}_fdw_server`
@@ -150,7 +149,7 @@ export const ImportForeignSchemaDialog = ({
form.reset({
bucketName,
sourceNamespace: namespace,
targetSchema: '',
targetSchema: `fdw_analytics_${namespace}`,
})
}
}, [visible, form, bucketName, namespace])
@@ -1,8 +1,8 @@
import { PostgresPolicy } from '@supabase/postgres-meta'
import { noop } from 'lodash'
import { Bucket } from 'data/storage/buckets-query'
import { PolicyRow } from 'components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow'
import { Bucket } from 'data/storage/buckets-query'
import { Bucket as BucketIcon } from 'icons'
import {
@@ -40,11 +40,13 @@ export const StoragePoliciesBucketRow = ({
}: StoragePoliciesBucketRowProps) => {
return (
<Card>
<CardHeader className="flex flex-row w-full items-center justify-between gap-0 space-y-0">
<div className="flex items-center gap-3">
<CardHeader className="flex flex-row w-full items-center justify-between gap-2 space-y-0">
<div className="flex flex-1 min-w-0 items-center gap-3">
<BucketIcon className="text-foreground-muted" size={16} strokeWidth={1.5} />
<CardTitle>{label}</CardTitle>
{bucket?.public && <Badge variant="warning">Public</Badge>}
<div className="flex flex-1 min-w-0 items-center gap-1.5">
<CardTitle className="truncate">{label}</CardTitle>
{bucket?.public && <Badge variant="warning">Public</Badge>}
</div>
</div>
<Button type="outline" onClick={() => onSelectPolicyAdd(bucket?.name, table)}>
New policy
@@ -274,15 +274,9 @@ export const S3Connection = () => {
<Table>
<TableHeader>
<TableRow>
<TableHead key="description" className="min-w-40 lg:min-w-64">
Description
</TableHead>
<TableHead key="access-key-id" className="min-w-64 w-full">
Access key ID
</TableHead>
<TableHead key="created-at" className="min-w-48">
Created at
</TableHead>
<TableHead key="description">Name</TableHead>
<TableHead key="access-key-id">Key ID</TableHead>
<TableHead key="created-at">Created at</TableHead>
<TableHead key="actions" />
</TableRow>
</TableHeader>
@@ -53,7 +53,9 @@ export const StorageCredItem = ({
<TableCell>
<Input readOnly copy value={access_key} className="font-mono" />
</TableCell>
<TableCell className="text-foreground-lighter">{daysSince(created_at)}</TableCell>
<TableCell className="text-foreground-lighter whitespace-nowrap">
{daysSince(created_at)}
</TableCell>
<TableCell className="text-right">
{canRemoveAccessKey && (
<DropdownMenu>
@@ -1,14 +1,11 @@
import { Lightbulb } from 'lucide-react'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useProjectLintsQuery } from 'data/lint/lint-query'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { cn } from 'ui'
export const AdvisorButton = () => {
const { ref: projectRef } = useParams()
export const AdvisorButton = ({ projectRef }: { projectRef?: string }) => {
const { toggleSidebar, activeSidebar } = useSidebarManagerSnapshot()
const { data: lints } = useProjectLintsQuery({ projectRef })
@@ -8,10 +8,11 @@ import { Sidebar } from 'components/interfaces/Sidebar'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useCheckLatestDeploy } from 'hooks/use-check-latest-deploy'
import { useAppStateSnapshot } from 'state/app-state'
import { SidebarProvider } from 'ui'
import { ResizablePanel, ResizablePanelGroup, SidebarProvider } from 'ui'
import { LayoutHeader } from './ProjectLayout/LayoutHeader/LayoutHeader'
import MobileNavigationBar from './ProjectLayout/NavigationBar/MobileNavigationBar'
import { ProjectContextProvider } from './ProjectLayout/ProjectContext'
import { LayoutSidebar } from './ProjectLayout/LayoutSidebar'
import { LayoutSidebarProvider } from './ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
export interface DefaultLayoutProps {
@@ -55,8 +56,8 @@ const DefaultLayout = ({
return (
<SidebarProvider defaultOpen={false}>
<ProjectContextProvider projectRef={ref}>
<LayoutSidebarProvider>
<LayoutSidebarProvider>
<ProjectContextProvider projectRef={ref}>
<AppBannerContextProvider>
<div className="flex flex-col h-screen w-screen">
{/* Top Banner */}
@@ -75,13 +76,22 @@ const DefaultLayout = ({
<div className="flex flex-1 w-full overflow-y-hidden">
{/* Sidebar - Only show for project pages, not account pages */}
{!router.pathname.startsWith('/account') && <Sidebar />}
{/* Main Content */}
<div className="flex-grow h-full overflow-y-auto">{children}</div>
{/* Main Content with Layout Sidebar */}
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full overflow-x-hidden flex-1 flex flex-row gap-0"
autoSaveId="default-layout-content"
>
<ResizablePanel id="panel-content" defaultSize={1} className="w-full">
<div className="h-full overflow-y-auto">{children}</div>
</ResizablePanel>
<LayoutSidebar />
</ResizablePanelGroup>
</div>
</div>
</AppBannerContextProvider>
</LayoutSidebarProvider>
</ProjectContextProvider>
</ProjectContextProvider>
</LayoutSidebarProvider>
</SidebarProvider>
)
}
@@ -27,8 +27,6 @@ import { HelpPopover } from './HelpPopover'
import { HomeIcon } from './HomeIcon'
import { LocalVersionPopover } from './LocalVersionPopover'
import MergeRequestButton from './MergeRequestButton'
import { NotificationsPopoverV2 } from './NotificationsPopoverV2/NotificationsPopover'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { AdvisorButton } from 'components/layouts/AppLayout/AdvisorButton'
const LayoutHeaderDivider = ({ className, ...props }: React.HTMLProps<HTMLSpanElement>) => (
@@ -212,11 +210,10 @@ export const LayoutHeader = ({
<div className="overflow-hidden flex items-center gap-2">
<HelpPopover />
<NotificationsPopoverV2 />
<AdvisorButton projectRef={projectRef} />
<AnimatePresence initial={false}>
{!!projectRef && (
<>
<AdvisorButton />
<InlineEditorButton />
<AssistantButton />
</>
@@ -229,10 +226,10 @@ export const LayoutHeader = ({
<>
<LocalVersionPopover />
<div className="overflow-hidden flex items-center gap-2">
<AdvisorButton projectRef={projectRef} />
<AnimatePresence initial={false}>
{!!projectRef && (
<>
<AdvisorButton />
<InlineEditorButton />
<AssistantButton />
</>
@@ -1,189 +0,0 @@
import dayjs from 'dayjs'
import { Archive, ArchiveRestoreIcon, ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { useEffect } from 'react'
import { useInView } from 'react-intersection-observer'
import { Button, cn } from 'ui'
import { Markdown } from 'components/interfaces/Markdown'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useVirtualizerContext } from 'components/ui/InfiniteList'
import { NotificationData, type NotificationsData } from 'data/notifications/notifications-v2-query'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import type { Organization } from 'types'
import { CriticalIcon, WarningIcon } from 'ui'
interface NotificationRowProps {
index: number
item: NotificationsData[number]
getOrganizationById: (id: number) => Organization
getOrganizationBySlug: (slug: string) => Organization
onUpdateNotificationStatus: (id: string, status: 'archived' | 'seen') => void
queueMarkRead: (id: string) => void
}
const NotificationRow = ({
index,
item: notification,
getOrganizationById,
getOrganizationBySlug,
onUpdateNotificationStatus,
queueMarkRead,
}: NotificationRowProps) => {
const { virtualizer } = useVirtualizerContext()
const { ref: viewRef, inView } = useInView()
const { status, priority } = notification
const data = notification.data as NotificationData
const { data: project } = useProjectDetailQuery({ ref: data.project_ref })
const organization =
data.org_slug !== undefined
? getOrganizationBySlug(data.org_slug)
: project !== undefined
? getOrganizationById(project.organization_id)
: undefined
const daysFromNow = dayjs().diff(dayjs(notification.inserted_at), 'day')
const formattedTimeFromNow = dayjs(notification.inserted_at).fromNow()
const formattedInsertedAt = dayjs(notification.inserted_at).format('MMM DD, YYYY')
const onButtonAction = (type?: string) => {
// [Joshen] Implement accordingly - BE team will need to give us a heads up on this
console.log('Action', type)
}
useEffect(() => {
if (inView && notification.status === 'new') {
queueMarkRead(notification.id)
}
}, [inView])
return (
<div
ref={virtualizer.measureElement}
className={cn(
`p-4 flex justify-between gap-x-3 group`,
index !== 0 ? 'border-t border-overlay' : '',
status === 'new' ? 'bg-surface-100/50' : 'bg-background'
)}
>
<div ref={viewRef} className="flex flex-col gap-y-2.5 w-full py-0.5">
{(project !== undefined || organization !== undefined) && (
<div className="flex items-center max-w-[350px]">
{organization !== undefined && (
<Link
title={organization.name}
href={`/org/${organization.slug}/general`}
className="text-xs transition text-foreground-light hover:text-foreground underline truncate"
>
{organization.name}
</Link>
)}
{organization !== undefined && project !== undefined && (
<span className="text-foreground-lighter">
<svg
viewBox="0 0 24 24"
width="16"
height="16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision"
>
<path d="M16 3.549L7.12 20.600"></path>
</svg>
</span>
)}
{project !== undefined && (
<Link
title={project.name}
href={`/project/${project.ref}`}
className="text-xs transition text-foreground-light hover:text-foreground underline truncate"
>
{project.name}
</Link>
)}
</div>
)}
<div className="flex items-center gap-x-2">
<p className="text-sm break-word">
{data.title}{' '}
<span className="ml-1 text-xs text-foreground-light capitalize-sentence">
{daysFromNow > 1 ? formattedInsertedAt : formattedTimeFromNow}
</span>
</p>
</div>
{data.message !== undefined && (
<Markdown className="text-foreground-light text-xs" content={data.message} />
)}
{(data.actions ?? []).length > 0 && (
<div className="flex items-center gap-x-2">
{data.actions.map((action, idx) => {
const key = `${notification.id}-action-${idx}`
if (action.url !== undefined) {
const url = action.url.includes('[ref]')
? action.url.replace('[ref]', project?.ref ?? '_')
: action.url.includes('[slug]')
? action.url.replace('[slug]', organization?.slug ?? '_')
: action.url
return (
<Button
key={key}
type="default"
icon={<ExternalLink strokeWidth={1.5} />}
asChild
>
<Link href={url} target="_blank" rel="noreferrer">
{action.label}
</Link>
</Button>
)
} else if (action.action_type !== undefined) {
return (
<Button
key={key}
type="default"
onClick={() => onButtonAction(action.action_type)}
>
{action.label}
</Button>
)
} else {
return null
}
})}
</div>
)}
</div>
<div className="flex flex-col items-center gap-y-2">
{priority === 'Warning' && <WarningIcon className="w-5 h-5" />}
{priority === 'Critical' && <CriticalIcon className="w-5 h-5" />}
{notification.status === 'archived' ? (
<ButtonTooltip
type="outline"
icon={
<ArchiveRestoreIcon size={13} strokeWidth={2} className="text-foreground-light" />
}
className="p-1.5 group-hover:opacity-100 opacity-0 transition rounded-full"
onClick={() => onUpdateNotificationStatus(notification.id, 'seen')}
tooltip={{ content: { text: 'Unarchive', side: 'bottom' } }}
/>
) : (
<ButtonTooltip
type="outline"
icon={<Archive size={13} strokeWidth={2} className="text-foreground-light" />}
className="p-1.5 group-hover:opacity-100 opacity-0 transition rounded-full"
onClick={() => onUpdateNotificationStatus(notification.id, 'archived')}
tooltip={{ content: { text: 'Archive', side: 'bottom' } }}
/>
)}
</div>
</div>
)
}
export default NotificationRow
@@ -1,245 +0,0 @@
import { RotateCcw, Settings2Icon, X } from 'lucide-react'
import { useMemo, useState } from 'react'
import {
Button,
Checkbox_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
CommandSeparator_Shadcn_,
Command_Shadcn_,
DropdownMenuLabel,
Label_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
ScrollArea,
cn,
} from 'ui'
import { CommandGroup } from '@ui/components/shadcn/ui/command'
import { useDebounce } from '@uidotdev/usehooks'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useProjectsInfiniteQuery } from 'data/projects/projects-infinite-query'
import { useNotificationsStateSnapshot } from 'state/notifications'
import { CriticalIcon, WarningIcon } from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
// [Joshen] Opting to not use infinite loading for projects in this UI specifically
// since the UX feels quite awkward having infinite loading for just a specific section in this Popover
export const NotificationsFilter = ({ activeTab }: { activeTab: 'inbox' | 'archived' }) => {
const [open, setOpen] = useState(false)
const snap = useNotificationsStateSnapshot()
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 500)
const { data: organizations } = useOrganizationsQuery()
const { data } = useProjectsInfiniteQuery(
{ search: search.length === 0 ? search : debouncedSearch },
{ keepPreviousData: true, enabled: open }
)
const projects = useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || []
const projectCount = data?.pages[0].pagination.count ?? 0
const pageLimit = data?.pages[0].pagination.limit ?? 0
return (
<Popover_Shadcn_ modal={true} open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button
type={snap.numFiltersApplied > 0 ? 'default' : 'text'}
icon={<Settings2Icon strokeWidth={1} />}
className="px-1 h-[26px]"
>
{snap.numFiltersApplied > 0 &&
`${snap.numFiltersApplied} filter${snap.numFiltersApplied > 1 ? 's' : ''} applied`}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-64" side="bottom" align="end">
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Find filter..." />
<CommandEmpty_Shadcn_>No filters found that match your search</CommandEmpty_Shadcn_>
<CommandList_Shadcn_>
<ScrollArea className="max-h-[240px] py-1 overflow-y-auto">
<CommandGroup_Shadcn_>
<DropdownMenuLabel>Status</DropdownMenuLabel>
<CommandItem_Shadcn_
disabled={activeTab === 'archived'}
onSelect={() => {
snap.setFilters('unread', 'status')
}}
>
<Label_Shadcn_
htmlFor={'unread'}
className={cn(
'flex items-center gap-x-2 text-xs text-foreground-light transition-colors',
snap.filterStatuses.includes('unread') && 'text-foreground'
)}
>
<Checkbox_Shadcn_
name="unread"
checked={snap.filterStatuses.includes('unread')}
/>
Unread
</Label_Shadcn_>
</CommandItem_Shadcn_>
</CommandGroup_Shadcn_>
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_>
<DropdownMenuLabel>Priority</DropdownMenuLabel>
<CommandItem_Shadcn_
onSelect={() => {
snap.setFilters('Warning', 'priority')
}}
className="flex items-center gap-x-2"
>
<Label_Shadcn_
htmlFor={'warning'}
className={cn(
'flex items-center gap-x-2 text-xs text-foreground-light transition-colors',
snap.filterPriorities.includes('Warning') && 'text-foreground'
)}
>
<Checkbox_Shadcn_
name="warning"
checked={snap.filterPriorities.includes('Warning')}
/>
<WarningIcon className="size-4" />
Warning
</Label_Shadcn_>
</CommandItem_Shadcn_>
<CommandItem_Shadcn_
key={'critical'}
onSelect={() => {
snap.setFilters('Critical', 'priority')
}}
>
<Label_Shadcn_
htmlFor={'critical'}
className={cn(
'flex items-center gap-x-2 text-xs text-foreground-light transition-colors',
snap.filterPriorities.includes('Critical') && 'text-foreground'
)}
>
<Checkbox_Shadcn_
name="critical"
checked={snap.filterPriorities.includes('Critical')}
/>
<CriticalIcon className="size-4" />
Critical
</Label_Shadcn_>
</CommandItem_Shadcn_>
</CommandGroup_Shadcn_>
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_>
<DropdownMenuLabel>Organizations</DropdownMenuLabel>
{(organizations ?? []).map((org) => (
<CommandItem_Shadcn_
key={org.slug}
value={org.name.replaceAll('"', '')}
className="flex items-center gap-x-2"
onSelect={() => {
snap.setFilters(org.slug, 'organizations')
}}
>
<Label_Shadcn_
htmlFor={`${org.slug}`}
className={cn(
'flex items-center gap-x-2 text-xs text-foreground-light transition-colors',
snap.filterOrganizations.includes(org.slug) && 'text-foreground'
)}
>
<Checkbox_Shadcn_
name={`${org.slug}`}
checked={snap.filterOrganizations.includes(org.slug)}
/>
{org.name}
</Label_Shadcn_>
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_>
<DropdownMenuLabel>Projects</DropdownMenuLabel>
{/*
[Joshen] Adding a separate search input field here for projects as the
top level CommandInput doesn't work well with a mix of sync and async data
*/}
<div className="px-2 mb-2">
<Input
size="tiny"
className="rounded"
placeholder="Search for a project"
value={search}
onChange={(e) => setSearch(e.target.value)}
actions={
search.length > 0 ? (
<X
size={14}
className="cursor-pointer mr-1"
onClick={() => setSearch('')}
/>
) : null
}
/>
</div>
{(projects ?? []).map((project) => (
<CommandItem_Shadcn_
key={project.ref}
value={project.ref}
className="flex items-center gap-x-2"
onSelect={() => {
snap.setFilters(project.ref, 'projects')
}}
>
<Label_Shadcn_
htmlFor={`${project.ref}`}
className={cn(
'flex items-center gap-x-2 text-xs text-foreground-light transition-colors',
snap.filterProjects.includes(project.ref) && 'text-foreground'
)}
>
<Checkbox_Shadcn_
name={`${project.ref}`}
checked={snap.filterProjects.includes(project.ref)}
/>
{project.name}
</Label_Shadcn_>
</CommandItem_Shadcn_>
))}
{projectCount > pageLimit && (
<p className="text-foreground-lighter text-xs pt-2 px-2">
Not all projects are shown here. Try searching to find a specific project.
</p>
)}
</CommandGroup_Shadcn_>
</ScrollArea>
<CommandSeparator_Shadcn_ />
<CommandGroup>
<CommandItem_Shadcn_
onSelect={() => snap.resetFilters()}
className="flex gap-x-2 items-center"
>
<RotateCcw size={12} />
Reset filters
</CommandItem_Shadcn_>
</CommandGroup>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
@@ -1,269 +0,0 @@
import { ArchiveIcon, InboxIcon } from 'lucide-react'
import { useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { InfiniteListDefault } from 'components/ui/InfiniteList'
import ShimmeringLoader, { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useNotificationsArchiveAllMutation } from 'data/notifications/notifications-v2-archive-all-mutation'
import { useNotificationsV2Query } from 'data/notifications/notifications-v2-query'
import { useNotificationsSummaryQuery } from 'data/notifications/notifications-v2-summary-query'
import { useNotificationsV2UpdateMutation } from 'data/notifications/notifications-v2-update-mutation'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useNotificationsStateSnapshot } from 'state/notifications'
import {
Button,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
TabsList_Shadcn_,
TabsTrigger_Shadcn_,
Tabs_Shadcn_,
cn,
} from 'ui'
import NotificationRow from './NotificationRow'
import { NotificationsFilter } from './NotificationsFilter'
export const NotificationsPopoverV2 = () => {
const [open, setOpen] = useState(false)
const [activeTab, setActiveTab] = useState<'inbox' | 'archived'>('inbox')
const snap = useNotificationsStateSnapshot()
// Storing in ref as no re-rendering required
const markedRead = useRef<string[]>([])
const { data: organizations } = useOrganizationsQuery({ enabled: open })
const {
data,
error,
isLoading,
isError,
isSuccess,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useNotificationsV2Query(
{
status:
activeTab === 'archived'
? 'archived'
: snap.filterStatuses.includes('unread')
? 'new'
: undefined,
filters: {
priority: snap.filterPriorities,
organizations: snap.filterOrganizations,
projects: snap.filterProjects,
},
},
{ enabled: open }
)
const { data: summary } = useNotificationsSummaryQuery()
const { mutate: updateNotifications } = useNotificationsV2UpdateMutation()
const { mutate: archiveAllNotifications, isLoading: isArchiving } =
useNotificationsArchiveAllMutation({
onSuccess: () => toast.success('Successfully archived all notifications'),
})
const notifications = useMemo(() => data?.pages.flatMap((page) => page) ?? [], [data?.pages])
const hasNewNotifications = summary?.unread_count ?? 0 > 0
const hasWarning = summary?.has_warning
const hasCritical = summary?.has_critical
const markNotificationsRead = () => {
if (markedRead.current.length > 0) {
updateNotifications({ ids: markedRead.current, status: 'seen' })
}
}
return (
<Popover_Shadcn_
modal={false}
open={open}
onOpenChange={(open) => {
setOpen(open)
if (!open) markNotificationsRead()
}}
>
<PopoverTrigger_Shadcn_ asChild>
<ButtonTooltip
tooltip={{
content: {
text: 'Notifications',
},
}}
type="outline"
size="tiny"
className={cn(
'rounded-full w-[32px] h-[32px] flex items-center justify-center p-0 group',
open && 'bg-foreground text-background'
)}
icon={
<div className="relative">
<InboxIcon
size={18}
strokeWidth={1.5}
className={cn(
'text-foreground-light group-hover:text-foreground',
open && 'text-background group-hover:text-background'
)}
/>
{hasCritical && (
<div className="absolute -top-1.5 -right-2 w-3.5 h-3.5 z-10 flex items-center justify-center">
<div className="w-0 h-0 border-l-[6px] border-r-[6px] border-b-[10px] border-l-transparent border-r-transparent border-b-destructive" />
</div>
)}
{hasWarning && !hasCritical && (
<div className="absolute -top-1.5 -right-2 w-3.5 h-3.5 z-10 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-warning" />
</div>
)}
{!!hasNewNotifications && !hasCritical && !hasWarning && (
<div className="absolute -top-1.5 -right-2 w-3.5 h-3.5 z-10 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-brand" />
</div>
)}
</div>
}
/>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_
className="p-0 w-screen md:w-[450px] overflow-hidden"
side="bottom"
align="end"
>
<div className="px-4">
<p className="pt-4 pb-1 text-sm">Notifications</p>
<div className="flex items-center">
<Tabs_Shadcn_
className="w-full"
onValueChange={(tab: string) => {
setActiveTab(tab as 'inbox' | 'archived')
if (tab === 'archived' && snap.filterStatuses.includes('unread')) {
snap.setFilters('unread', 'status')
}
}}
value={activeTab}
>
<div className="flex items-center">
<TabsList_Shadcn_ className="flex gap-5 grow border-none">
<TabsTrigger_Shadcn_
id="inbox"
value="inbox"
className="px-0 data-[state=active]:bg-transparent flex gap-2"
>
Inbox
<div
className={cn(
'flex items-center justify-center text-xs rounded-full bg-surface-300 h-4',
(summary?.unread_count ?? 0) > 9 ? 'px-0.5 w-auto' : 'w-4'
)}
>
{summary?.unread_count}
</div>
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_
id="archived"
value="archived"
className="px-0 data-[state=active]:bg-transparent"
>
Archived
</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
<NotificationsFilter activeTab={activeTab} />
</div>
</Tabs_Shadcn_>
</div>
</div>
<div className="border-t">
{isLoading && (
<div className="p-4">
<GenericSkeletonLoader />
</div>
)}
{isError && (
<div className="p-4">
<AlertError subject="Failed to retrieve notifications" error={error} />
</div>
)}
{isSuccess && (
<div className="flex flex-1 h-[400px] bg-background">
{notifications.length > 0 &&
!(activeTab === 'archived' && snap.filterStatuses.includes('unread')) ? (
<InfiniteListDefault
className="w-full"
items={notifications}
ItemComponent={NotificationRow}
LoaderComponent={({ style }) => (
<div style={style} className="p-4">
<ShimmeringLoader />
</div>
)}
itemProps={{
getOrganizationById: (id: number) =>
organizations?.find((org) => org.id === id)!,
getOrganizationBySlug: (slug: string) =>
organizations?.find((org) => org.slug === slug)!,
onUpdateNotificationStatus: (id: string, status: 'archived' | 'seen') => {
updateNotifications({ ids: [id], status })
},
queueMarkRead: (id: string) => {
if (markedRead.current && !markedRead.current.includes(id)) {
markedRead.current.push(id)
}
},
}}
getItemSize={() => 56}
hasNextPage={hasNextPage}
isLoadingNextPage={isFetchingNextPage}
onLoadNextPage={() => fetchNextPage()}
/>
) : (
<div className="flex flex-col gap-y-4 items-center flex-grow justify-center">
<InboxIcon size={32} className="text-foreground-light" />
<div className="flex flex-col gap-y-1">
<p className="text-foreground-light text-sm mx-auto text-center">
{activeTab === 'archived'
? `No archived notifications${
snap.numFiltersApplied > 0
? ` based on the ${snap.numFiltersApplied} filter${
snap.numFiltersApplied > 1 ? 's' : ''
} applied`
: ''
}`
: snap.numFiltersApplied > 0
? `No notifications based on the ${snap.numFiltersApplied} filter${
snap.numFiltersApplied > 1 ? 's' : ''
} applied`
: 'All caught up'}
</p>
<p className="text-foreground-lighter text-xs w-60 mx-auto text-center">
{activeTab === 'archived'
? 'Notifications that you have previously archived will be shown here'
: 'You will be notified here for any notices on your organizations and projects'}
</p>
</div>
</div>
)}
</div>
)}
</div>
{notifications.length > 0 && activeTab === 'inbox' && (
<div className="flex items-center justify-center p-1.5 border-t">
<Button
disabled={isArchiving}
loading={isArchiving}
type="text"
icon={<ArchiveIcon />}
onClick={() => archiveAllNotifications()}
>
Archive all
</Button>
</div>
)}
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
@@ -1,13 +1,12 @@
import { useRouter } from 'next/router'
import { PropsWithChildren, useEffect } from 'react'
import { AdvisorPanel } from 'components/ui/AdvisorPanel/AdvisorPanel'
import { useRegisterSidebar, useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { AIAssistant } from 'components/ui/AIAssistantPanel/AIAssistant'
import { EditorPanel } from 'components/ui/EditorPanel/EditorPanel'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { AdvisorPanel } from 'components/ui/AdvisorPanel/AdvisorPanel'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useRegisterSidebar, useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
export const SIDEBAR_KEYS = {
AI_ASSISTANT: 'ai-assistant',
@@ -15,16 +14,14 @@ export const SIDEBAR_KEYS = {
ADVISOR_PANEL: 'advisor-panel',
} as const
// LayoutSidebars are meant to be used within a project, but rendered within DefaultLayout
// to prevent unnecessary registering / unregistering of sidebars with every route change
export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => {
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const { mutate: sendEvent } = useSendEventMutation()
useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => <AIAssistant />, {}, 'i')
useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => <EditorPanel />, {}, 'e')
useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => <AdvisorPanel />)
useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => <AIAssistant />, {}, 'i', !!project)
useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => <EditorPanel />, {}, 'e', !!project)
useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => <AdvisorPanel />, {}, undefined, true)
const router = useRouter()
const { openSidebar, activeSidebar } = useSidebarManagerSnapshot()
@@ -12,6 +12,70 @@ vi.mock('components/ui/AIAssistantPanel/AIAssistant', () => ({
AIAssistant: () => <div data-testid="ai-assistant-sidebar">AI Assistant</div>,
}))
vi.mock('components/ui/EditorPanel/EditorPanel', () => ({
EditorPanel: () => <div data-testid="editor-panel-sidebar">Editor Panel</div>,
}))
vi.mock('components/ui/AdvisorPanel/AdvisorPanel', () => ({
AdvisorPanel: () => <div data-testid="advisor-panel-sidebar">Advisor Panel</div>,
}))
const mockProject = {
id: 1,
ref: 'default',
name: 'Project 1',
status: 'ACTIVE_HEALTHY' as const,
organization_id: 1,
cloud_provider: 'AWS',
region: 'us-east-1',
inserted_at: new Date().toISOString(),
subscription_id: 'subscription-1',
db_host: 'db.supabase.co',
is_branch_enabled: false,
is_physical_backups_enabled: false,
restUrl: 'https://project-1.supabase.co',
}
let mockProjectData: typeof mockProject | undefined = mockProject
vi.mock('hooks/misc/useSelectedProject', () => ({
useSelectedProjectQuery: () => {
// Access the variable at runtime when the function is called
return {
data: mockProjectData,
}
},
}))
vi.mock('hooks/misc/useSelectedOrganization', () => ({
useSelectedOrganizationQuery: () => ({
data: {
id: 1,
name: 'Organization 1',
slug: 'test-org',
plan: { id: 'free', name: 'Free' },
managed_by: 'supabase',
is_owner: true,
billing_email: 'billing@example.com',
billing_partner: null,
usage_billing_enabled: false,
stripe_customer_id: 'stripe-1',
subscription_id: 'subscription-1',
organization_requires_mfa: false,
opt_in_tags: [],
restriction_status: null,
restriction_data: null,
organization_missing_address: false,
},
}),
}))
vi.mock('data/telemetry/send-event-mutation', () => ({
useSendEventMutation: () => ({
mutate: vi.fn(),
}),
}))
const resetSidebarManagerState = () => {
Object.keys(sidebarManagerState.sidebars).forEach((id) => {
sidebarManagerState.unregisterSidebar(id)
@@ -59,7 +123,8 @@ describe('LayoutSidebar', () => {
sidebarManagerState.toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
})
expect(await screen.findByTestId('ai-assistant-sidebar')).toBeInTheDocument()
const sidebar = await screen.findByTestId('ai-assistant-sidebar')
expect(sidebar).toBeTruthy()
})
it('auto-opens when sidebar query param matches a registered sidebar', async () => {
@@ -69,4 +134,54 @@ describe('LayoutSidebar', () => {
await screen.findByTestId('ai-assistant-sidebar')
})
describe('at organization level', () => {
beforeEach(() => {
routerMock.setCurrentUrl('/org/default')
// Set project to undefined to simulate org-level (no project)
mockProjectData = undefined
})
afterEach(() => {
// Reset to project data for other tests
mockProjectData = mockProject
})
it('does not register project-related sidebars when no project is available', async () => {
renderSidebar()
// Wait a bit to ensure sidebars have been registered
await waitFor(() => {
// Project-related sidebars should not be registered
expect(sidebarManagerState.sidebars[SIDEBAR_KEYS.AI_ASSISTANT]).toBeUndefined()
expect(sidebarManagerState.sidebars[SIDEBAR_KEYS.EDITOR_PANEL]).toBeUndefined()
// Advisor panel should still be available (doesn't require project)
expect(sidebarManagerState.sidebars[SIDEBAR_KEYS.ADVISOR_PANEL]).toBeDefined()
})
})
it('does not render project-related sidebars even when toggled', async () => {
renderSidebar()
await waitFor(() => {
expect(sidebarManagerState.sidebars[SIDEBAR_KEYS.ADVISOR_PANEL]).toBeDefined()
})
// Try to toggle AI_ASSISTANT - should not work since it's not registered
act(() => {
sidebarManagerState.toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
})
// Should not render since it's not registered
expect(screen.queryByTestId('ai-assistant-sidebar')).toBeNull()
expect(screen.queryByTestId('editor-panel-sidebar')).toBeNull()
// Advisor panel should work
act(() => {
sidebarManagerState.toggleSidebar(SIDEBAR_KEYS.ADVISOR_PANEL)
})
expect(await screen.findByTestId('advisor-panel-sidebar')).toBeTruthy()
})
})
})
@@ -19,7 +19,6 @@ import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav'
import { useEditorType } from '../editors/EditorsLayout.hooks'
import BuildingState from './BuildingState'
import ConnectingState from './ConnectingState'
import { LayoutSidebar } from './LayoutSidebar'
import { LoadingState } from './LoadingState'
import { ProjectPausedState } from './PausedState/ProjectPausedState'
import { PauseFailedState } from './PauseFailedState'
@@ -182,39 +181,26 @@ export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<Projec
<ResizablePanel
defaultSize={1}
order={2}
id="panel-right"
className="h-full flex flex-col w-full"
id="panel-project-content"
className={cn('h-full flex flex-col w-full xl:min-w-[600px] bg-dash-sidebar')}
>
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full overflow-x-hidden flex-1 flex flex-row gap-0"
autoSaveId="project-layout-content"
<main
className="h-full flex flex-col flex-1 w-full overflow-y-auto overflow-x-hidden @container"
ref={ref}
>
<ResizablePanel
id="panel-content"
defaultSize={1}
className={cn('w-full xl:min-w-[600px] bg-dash-sidebar')}
>
<main
className="h-full flex flex-col flex-1 w-full overflow-y-auto overflow-x-hidden @container"
ref={ref}
>
{showPausedState ? (
<div className="mx-auto my-16 w-full h-full max-w-7xl flex items-center">
<div className="w-full">
<ProjectPausedState product={product} />
</div>
</div>
) : (
<ContentWrapper isLoading={isLoading} isBlocking={isBlocking}>
<ResourceExhaustionWarningBanner />
{children}
</ContentWrapper>
)}
</main>
</ResizablePanel>
<LayoutSidebar />
</ResizablePanelGroup>
{showPausedState ? (
<div className="mx-auto my-16 w-full h-full max-w-7xl flex items-center">
<div className="w-full">
<ProjectPausedState product={product} />
</div>
</div>
) : (
<ContentWrapper isLoading={isLoading} isBlocking={isBlocking}>
<ResourceExhaustionWarningBanner />
{children}
</ContentWrapper>
)}
</main>
</ResizablePanel>
</ResizablePanelGroup>
</div>
@@ -0,0 +1,41 @@
import LintDetail from 'components/interfaces/Linter/LintDetail'
import { Lint } from 'data/lint/lint-query'
import { Notification } from 'data/notifications/notifications-v2-query'
import { noop } from 'lodash'
import { AdvisorItem } from './AdvisorPanelHeader'
import { NotificationDetail } from './NotificationDetail'
interface AdvisorDetailProps {
item: AdvisorItem
projectRef: string
onUpdateNotificationStatus?: (id: string, status: 'archived' | 'seen') => void
}
export const AdvisorDetail = ({
item,
projectRef,
onUpdateNotificationStatus = noop,
}: AdvisorDetailProps) => {
if (item.source === 'lint') {
const lint = item.original as Lint
return (
<div className="px-6 py-6">
<LintDetail lint={lint} projectRef={projectRef} />
</div>
)
}
if (item.source === 'notification') {
const notification = item.original as Notification
return (
<div className="px-6 py-6">
<NotificationDetail
notification={notification}
onUpdateStatus={onUpdateNotificationStatus}
/>
</div>
)
}
return null
}
@@ -0,0 +1,100 @@
import { X } from 'lucide-react'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { FilterPopover } from 'components/ui/FilterPopover'
import { AdvisorSeverity, AdvisorTab } from 'state/advisor-state'
import { TabsList_Shadcn_, TabsTrigger_Shadcn_, Tabs_Shadcn_ } from 'ui'
const severityOptions = [
{ label: 'Critical', value: 'critical' },
{ label: 'Warning', value: 'warning' },
{ label: 'Info', value: 'info' },
]
const statusOptions = [
{ label: 'Unread', value: 'unread' },
{ label: 'Archived', value: 'archived' },
]
interface AdvisorFiltersProps {
activeTab: AdvisorTab
onTabChange: (tab: string) => void
severityFilters: AdvisorSeverity[]
onSeverityFiltersChange: (filters: AdvisorSeverity[]) => void
statusFilters: string[]
onStatusFiltersChange: (filters: string[]) => void
hasProjectRef?: boolean
onClose: () => void
isPlatform?: boolean
}
export const AdvisorFilters = ({
activeTab,
onTabChange,
severityFilters,
onSeverityFiltersChange,
statusFilters,
onStatusFiltersChange,
hasProjectRef = true,
onClose,
isPlatform = false,
}: AdvisorFiltersProps) => {
return (
<div className="border-b">
<div className="flex items-center justify-between gap-3 px-4 h-[46px]">
<Tabs_Shadcn_ value={activeTab} onValueChange={onTabChange} className="h-full">
<TabsList_Shadcn_ className="border-b-0 gap-4 h-full">
<TabsTrigger_Shadcn_ value="all" className="h-full text-xs">
All
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="security" className="h-full text-xs">
Security
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="performance" className="h-full text-xs">
Performance
</TabsTrigger_Shadcn_>
{isPlatform && (
<TabsTrigger_Shadcn_
value="messages"
className="h-full text-xs flex items-center gap-2"
>
Messages
</TabsTrigger_Shadcn_>
)}
</TabsList_Shadcn_>
</Tabs_Shadcn_>
<div className="flex items-center gap-2">
{isPlatform && (
<FilterPopover
name="Status"
options={statusOptions}
activeOptions={[...statusFilters]}
valueKey="value"
labelKey="label"
isMinimized={true}
onSaveFilters={onStatusFiltersChange}
/>
)}
<FilterPopover
name="Severity"
options={severityOptions}
activeOptions={[...severityFilters]}
valueKey="value"
labelKey="label"
isMinimized={true}
onSaveFilters={(values) => {
onSeverityFiltersChange(values as AdvisorSeverity[])
}}
/>
<ButtonTooltip
type="text"
className="w-7 h-7 p-0"
icon={<X strokeWidth={1.5} />}
onClick={onClose}
tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
/>
</div>
</div>
</div>
)
}
@@ -1,33 +1,23 @@
import { AlertTriangle, ChevronLeft, ChevronRight, Gauge, Inbox, Shield, X } from 'lucide-react'
import { useMemo } from 'react'
import dayjs from 'dayjs'
import { useMemo, useRef } from 'react'
import LintDetail from 'components/interfaces/Linter/LintDetail'
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { FilterPopover } from 'components/ui/FilterPopover'
import { Lint, useProjectLintsQuery } from 'data/lint/lint-query'
import {
Notification,
NotificationData,
useNotificationsV2Query,
} from 'data/notifications/notifications-v2-query'
import { useNotificationsV2UpdateMutation } from 'data/notifications/notifications-v2-update-mutation'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { IS_PLATFORM } from 'lib/constants'
import { AdvisorSeverity, AdvisorTab, useAdvisorStateSnapshot } from 'state/advisor-state'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { Badge, Button, TabsList_Shadcn_, TabsTrigger_Shadcn_, Tabs_Shadcn_, cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import { EmptyAdvisor } from './EmptyAdvisor'
type AdvisorItem = {
id: string
title: string
severity: AdvisorSeverity
createdAt?: number
tab: Exclude<AdvisorTab, 'all'>
source: 'lint'
original: Lint
}
const severityOptions = [
{ label: 'Critical', value: 'critical' },
{ label: 'Warning', value: 'warning' },
{ label: 'Info', value: 'info' },
]
import { AdvisorDetail } from './AdvisorDetail'
import { AdvisorFilters } from './AdvisorFilters'
import { AdvisorPanelBody } from './AdvisorPanelBody'
import { AdvisorItem, AdvisorPanelHeader } from './AdvisorPanelHeader'
const severityOrder: Record<AdvisorSeverity, number> = {
critical: 0,
@@ -35,30 +25,6 @@ const severityOrder: Record<AdvisorSeverity, number> = {
info: 2,
}
const severityLabels: Record<AdvisorSeverity, string> = {
critical: 'Critical',
warning: 'Warning',
info: 'Info',
}
const severityBadgeVariants: Record<AdvisorSeverity, 'destructive' | 'warning' | 'default'> = {
critical: 'destructive',
warning: 'warning',
info: 'default',
}
const severityColorClasses: Record<AdvisorSeverity, string> = {
critical: 'text-destructive',
warning: 'text-warning',
info: 'text-foreground-light',
}
const tabIconMap: Record<Exclude<AdvisorTab, 'all'>, React.ElementType> = {
security: Shield,
performance: Gauge,
messages: Inbox,
}
const lintLevelToSeverity = (level: Lint['level']): AdvisorSeverity => {
switch (level) {
case 'ERROR':
@@ -70,20 +36,39 @@ const lintLevelToSeverity = (level: Lint['level']): AdvisorSeverity => {
}
}
const notificationPriorityToSeverity = (priority: string | null | undefined): AdvisorSeverity => {
switch (priority) {
case 'Critical':
return 'critical'
case 'Warning':
return 'warning'
default:
return 'info'
}
}
export const AdvisorPanel = () => {
const {
activeTab,
severityFilters,
selectedItemId,
selectedItemSource,
setActiveTab,
setSeverityFilters,
clearSeverityFilters,
setSelectedItemId,
setSelectedItem,
notificationFilterStatuses,
notificationFilterPriorities,
setNotificationFilters,
resetNotificationFilters,
} = useAdvisorStateSnapshot()
const { data: project } = useSelectedProjectQuery()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { activeSidebar, closeSidebar } = useSidebarManagerSnapshot()
const isSidebarOpen = activeSidebar?.id === SIDEBAR_KEYS.ADVISOR_PANEL
const markedRead = useRef<string[]>([])
const hasProjectRef = !!project?.ref
const {
data: lintData,
@@ -91,9 +76,58 @@ export const AdvisorPanel = () => {
isError: isLintsError,
} = useProjectLintsQuery(
{ projectRef: project?.ref },
{ enabled: isSidebarOpen && !!project?.ref }
{ enabled: isSidebarOpen && hasProjectRef && activeTab !== 'messages' }
)
// Notifications should always load when sidebar is open (shown in both 'all' and 'messages' tabs)
const shouldLoadNotifications = isSidebarOpen && IS_PLATFORM
const notificationStatus = useMemo(() => {
if (notificationFilterStatuses.includes('archived')) {
return 'archived'
}
if (notificationFilterStatuses.includes('unread')) {
return 'new'
}
return undefined
}, [notificationFilterStatuses])
// Memoize filters to prevent query key changes on every render
// Use selected organization and project if they exist
const notificationFilters = useMemo(
() => ({
priority: notificationFilterPriorities,
organizations: selectedOrganization?.slug ? [selectedOrganization.slug] : [],
projects: project?.ref ? [project.ref] : [],
}),
[notificationFilterPriorities, selectedOrganization?.slug, project?.ref]
)
const {
data: notificationsData,
isLoading: isNotificationsLoading,
isError: isNotificationsError,
} = useNotificationsV2Query(
{
status: notificationStatus,
filters: notificationFilters,
limit: 20,
},
{ enabled: shouldLoadNotifications }
)
const { mutate: updateNotifications } = useNotificationsV2UpdateMutation()
const notifications = useMemo(() => {
return notificationsData?.pages.flatMap((page) => page) ?? []
}, [notificationsData?.pages])
const markNotificationsRead = () => {
if (markedRead.current.length > 0) {
updateNotifications({ ids: markedRead.current, status: 'seen' })
}
}
const lintItems = useMemo<AdvisorItem[]>(() => {
if (!lintData) return []
@@ -121,8 +155,24 @@ export const AdvisorPanel = () => {
.filter((item): item is AdvisorItem => item !== null)
}, [lintData])
const notificationItems = useMemo<AdvisorItem[]>(() => {
if (!IS_PLATFORM) return []
return notifications?.map((notification): AdvisorItem => {
const data = notification.data as NotificationData
return {
id: notification.id,
title: data.title,
severity: notificationPriorityToSeverity(notification.priority),
createdAt: dayjs(notification.inserted_at).valueOf(),
tab: 'messages' as const,
source: 'notification' as const,
original: notification,
}
})
}, [notifications])
const combinedItems = useMemo<AdvisorItem[]>(() => {
const all = [...lintItems]
const all = [...lintItems, ...notificationItems]
return all.sort((a, b) => {
const severityDiff = severityOrder[a.severity] - severityOrder[b.severity]
@@ -133,80 +183,107 @@ export const AdvisorPanel = () => {
return a.title.localeCompare(b.title)
})
}, [lintItems])
}, [lintItems, notificationItems])
const filteredItems = useMemo<AdvisorItem[]>(() => {
return combinedItems.filter((item) => {
// Filter by severity
if (severityFilters.length > 0 && !severityFilters.includes(item.severity)) {
return false
}
if (activeTab === 'all') return true
// Filter by tab
if (activeTab === 'all') {
// When no projectRef, only show notifications in 'all' tab
if (!hasProjectRef && item.source !== 'notification') {
return false
}
return true
}
return item.tab === activeTab
})
}, [combinedItems, severityFilters, activeTab])
}, [combinedItems, severityFilters, activeTab, hasProjectRef])
const itemsFilteredByTabOnly = useMemo<AdvisorItem[]>(() => {
return combinedItems.filter((item) => {
if (activeTab === 'all') return true
if (activeTab === 'all') {
// When no projectRef, only show notifications in 'all' tab
if (!hasProjectRef && item.source !== 'notification') {
return false
}
return true
}
return item.tab === activeTab
})
}, [combinedItems, activeTab])
}, [combinedItems, activeTab, hasProjectRef])
const hiddenItemsCount = itemsFilteredByTabOnly.length - filteredItems.length
const selectedItem = combinedItems.find((item) => item.id === selectedItemId)
const selectedItem = combinedItems.find(
(item) => item.id === selectedItemId && item.source === selectedItemSource
)
const isDetailView = !!selectedItem
const isLoading = isLintsLoading
const isError = isLintsError
// Only show loading state if the query is actually enabled
const isLintsActuallyLoading =
isSidebarOpen && hasProjectRef && activeTab !== 'messages' && isLintsLoading
const isNotificationsActuallyLoading = shouldLoadNotifications && isNotificationsLoading
const isLoading = isLintsActuallyLoading || isNotificationsActuallyLoading
const isError = isLintsError || isNotificationsError
const handleTabChange = (tab: string) => {
setActiveTab(tab as AdvisorTab)
setSelectedItem(undefined)
}
const handleBackToList = () => {
setSelectedItemId(undefined)
setSelectedItem(undefined)
markNotificationsRead()
}
const handleClose = () => {
markNotificationsRead()
closeSidebar(SIDEBAR_KEYS.ADVISOR_PANEL)
}
const handleItemClick = (item: AdvisorItem) => {
setSelectedItem(item.id, item.source)
if (item.source === 'notification') {
const notification = item.original as Notification
if (notification.status === 'new' && !markedRead.current.includes(notification.id)) {
markedRead.current.push(notification.id)
}
}
}
const handleUpdateNotificationStatus = (id: string, status: 'archived' | 'seen') => {
updateNotifications({ ids: [id], status })
}
const handleClearAllFilters = () => {
clearSeverityFilters()
resetNotificationFilters()
}
const hasAnyFilters = severityFilters.length > 0 || notificationFilterStatuses.length > 0
return (
<div className="flex h-full flex-col bg-background">
{isDetailView ? (
<>
<div className="border-b px-4 py-3 flex items-center gap-3">
<ButtonTooltip
type="text"
className="w-7 h-7 p-0 flex justify-center items-center"
icon={<ChevronLeft size={16} strokeWidth={1.5} aria-hidden={true} />}
onClick={handleBackToList}
tooltip={{ content: { side: 'bottom', text: 'Back to list' } }}
/>
<div className="flex items-center gap-2 overflow-hidden flex-1">
<div className="flex-1">
<span className="heading-default">{selectedItem?.title}</span>
</div>
{selectedItem && (
<Badge variant={severityBadgeVariants[selectedItem.severity]}>
{severityLabels[selectedItem.severity]}
</Badge>
)}
</div>
<ButtonTooltip
type="text"
className="w-7 h-7 p-0"
icon={<X strokeWidth={1.5} />}
onClick={handleClose}
tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
/>
</div>
<AdvisorPanelHeader
selectedItem={selectedItem}
onBack={handleBackToList}
onClose={handleClose}
/>
<div className="flex-1 overflow-y-auto">
{selectedItem ? (
<AdvisorDetail item={selectedItem} projectRef={project?.ref ?? ''} />
<AdvisorDetail
item={selectedItem}
projectRef={project?.ref ?? ''}
onUpdateNotificationStatus={handleUpdateNotificationStatus}
/>
) : (
<div className="px-6 py-8">
<p className="text-sm text-foreground-light">
@@ -218,118 +295,40 @@ export const AdvisorPanel = () => {
</>
) : (
<>
<div className="border-b">
<div className="flex items-center justify-between gap-3 px-4 h-[46px]">
<Tabs_Shadcn_ value={activeTab} onValueChange={handleTabChange} className="h-full">
<TabsList_Shadcn_ className="border-b-0 gap-4 h-full">
<TabsTrigger_Shadcn_ value="all" className="h-full text-xs">
All
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="security" className="h-full text-xs">
Security
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="performance" className="h-full text-xs">
Performance
</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
</Tabs_Shadcn_>
<div className="flex items-center gap-2">
<FilterPopover
name="Severity"
options={severityOptions}
activeOptions={[...severityFilters]}
valueKey="value"
labelKey="label"
onSaveFilters={(values) => setSeverityFilters(values as AdvisorSeverity[])}
/>
<ButtonTooltip
type="text"
className="w-7 h-7 p-0"
icon={<X strokeWidth={1.5} />}
onClick={handleClose}
tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
/>
</div>
</div>
</div>
<AdvisorFilters
activeTab={activeTab}
onTabChange={handleTabChange}
severityFilters={[...severityFilters]}
onSeverityFiltersChange={setSeverityFilters}
statusFilters={[...notificationFilterStatuses]}
onStatusFiltersChange={(values) => {
notificationFilterStatuses
.filter((status) => !values.includes(status))
.forEach((status) => setNotificationFilters(status, 'status'))
values
.filter((status) => !notificationFilterStatuses.includes(status))
.forEach((status) => setNotificationFilters(status, 'status'))
}}
hasProjectRef={hasProjectRef}
onClose={handleClose}
isPlatform={IS_PLATFORM}
/>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div>
<GenericSkeletonLoader className="w-full p-4" />
</div>
) : isError ? (
<div className="my-8 mx-4 flex flex-col items-center gap-2">
<AlertTriangle className="text-destructive" />
<h2 className="text-base text-foreground-light">Error loading advisories</h2>
<p className="text-sm text-foreground-lighter">Please try again later.</p>
</div>
) : filteredItems.length === 0 ? (
<EmptyAdvisor
activeTab={activeTab}
hasFilters={severityFilters.length > 0}
onClearFilters={clearSeverityFilters}
/>
) : (
<>
<div className="flex flex-col">
{filteredItems.map((item) => {
const SeverityIcon = tabIconMap[item.tab]
const severityClass = severityColorClasses[item.severity]
return (
<div key={item.id} className="border-b">
<Button
type="text"
className="justify-start w-full block rounded-none h-auto py-3 px-4 text-foreground-light hover:text-foreground"
onClick={() => setSelectedItemId(item.id)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 overflow-hidden">
<SeverityIcon
size={16}
strokeWidth={1.5}
className={cn('flex-shrink-0', severityClass)}
/>
<span className="truncate">{item.title}</span>
</div>
<ChevronRight
size={16}
strokeWidth={1.5}
className="flex-shrink-0 text-foreground-lighter"
/>
</div>
</Button>
</div>
)
})}
</div>
{severityFilters.length > 0 && hiddenItemsCount > 0 && (
<div className="px-4 py-3">
<Button type="text" className="w-full" onClick={clearSeverityFilters}>
Show {hiddenItemsCount} more issue{hiddenItemsCount !== 1 ? 's' : ''}
</Button>
</div>
)}
</>
)}
<AdvisorPanelBody
isLoading={isLoading}
isError={isError}
filteredItems={filteredItems}
activeTab={activeTab}
severityFilters={[...severityFilters]}
onItemClick={handleItemClick}
onClearFilters={handleClearAllFilters}
hiddenItemsCount={hiddenItemsCount}
hasAnyFilters={hasAnyFilters}
hasProjectRef={hasProjectRef}
/>
</div>
</>
)}
</div>
)
}
interface AdvisorDetailProps {
item: AdvisorItem
projectRef: string
}
const AdvisorDetail = ({ item, projectRef }: AdvisorDetailProps) => {
if (item.source === 'lint') {
const lint = item.original as Lint
return (
<div className="px-6 py-6">
<LintDetail lint={lint} projectRef={projectRef} />
</div>
)
}
}
@@ -0,0 +1,157 @@
import dayjs from 'dayjs'
import { AlertTriangle, ChevronRight, Gauge, Inbox, Shield } from 'lucide-react'
import { Notification } from 'data/notifications/notifications-v2-query'
import { AdvisorSeverity, AdvisorTab } from 'state/advisor-state'
import { Button, cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import { AdvisorItem } from './AdvisorPanelHeader'
import { EmptyAdvisor } from './EmptyAdvisor'
const NoProjectNotice = () => {
return (
<div className="absolute top-28 px-6 flex flex-col items-center justify-center w-full gap-y-2">
<Inbox className="text-foreground-muted" strokeWidth={1} />
<div className="text-center">
<p className="heading-default">Project required</p>
<p className="text-foreground-light text-sm">
Select a project to view security and performance advisories
</p>
</div>
</div>
)
}
const tabIconMap: Record<Exclude<AdvisorTab, 'all'>, React.ElementType> = {
security: Shield,
performance: Gauge,
messages: Inbox,
}
const severityColorClasses: Record<AdvisorSeverity, string> = {
critical: 'text-destructive',
warning: 'text-warning',
info: 'text-foreground-light',
}
interface AdvisorPanelBodyProps {
isLoading: boolean
isError: boolean
filteredItems: AdvisorItem[]
activeTab: AdvisorTab
severityFilters: AdvisorSeverity[]
onItemClick: (item: AdvisorItem) => void
onClearFilters: () => void
hiddenItemsCount: number
hasAnyFilters: boolean
hasProjectRef?: boolean
}
export const AdvisorPanelBody = ({
isLoading,
isError,
filteredItems,
activeTab,
severityFilters,
onItemClick,
onClearFilters,
hiddenItemsCount,
hasAnyFilters,
hasProjectRef = true,
}: AdvisorPanelBodyProps) => {
// Show notice if no project ref and trying to view project-specific tabs
if (!hasProjectRef && activeTab !== 'messages') {
return <NoProjectNotice />
}
if (isLoading) {
return (
<div>
<GenericSkeletonLoader className="w-full p-4" />
</div>
)
}
if (isError) {
return (
<div className="my-8 mx-4 flex flex-col items-center gap-2">
<AlertTriangle className="text-destructive" />
<h2 className="text-base text-foreground-light">Error loading advisories</h2>
<p className="text-sm text-foreground-lighter">Please try again later.</p>
</div>
)
}
if (filteredItems.length === 0) {
return (
<EmptyAdvisor
activeTab={activeTab}
hasFilters={hasAnyFilters}
onClearFilters={onClearFilters}
/>
)
}
return (
<>
<div className="flex flex-col">
{filteredItems.map((item) => {
const SeverityIcon = tabIconMap[item.tab]
const severityClass = severityColorClasses[item.severity]
const isNotification = item.source === 'notification'
const notification = isNotification ? (item.original as Notification) : null
const isUnread = notification?.status === 'new'
return (
<div key={`${item.source}-${item.id}`} className="border-b">
<Button
type="text"
className={cn(
'justify-start w-full block rounded-none h-auto py-3 px-4 hover:text-foreground',
isUnread && 'bg-surface-100/50'
)}
onClick={() => onItemClick(item)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 overflow-hidden">
<SeverityIcon
size={16}
strokeWidth={1.5}
className={cn('flex-shrink-0', severityClass)}
/>
<div className="text-left flex flex-col gap-0.5 truncate flex-1 min-w-0">
<div className="truncate">{item.title.replace(/[`\\]/g, '')}</div>
{item.createdAt && (
<span className="text-xs text-foreground-light capitalize-sentence">
{(() => {
const insertedAt = item.createdAt
const daysFromNow = dayjs().diff(dayjs(insertedAt), 'day')
const formattedTimeFromNow = dayjs(insertedAt).fromNow()
const formattedInsertedAt = dayjs(insertedAt).format('MMM DD, YYYY')
return daysFromNow > 1 ? formattedInsertedAt : formattedTimeFromNow
})()}
</span>
)}
</div>
</div>
<ChevronRight
size={16}
strokeWidth={1.5}
className="flex-shrink-0 text-foreground-lighter"
/>
</div>
</Button>
</div>
)
})}
</div>
{severityFilters.length > 0 && hiddenItemsCount > 0 && (
<div className="px-4 py-3">
<Button type="text" className="w-full" onClick={onClearFilters}>
Show {hiddenItemsCount} more issue{hiddenItemsCount !== 1 ? 's' : ''}
</Button>
</div>
)}
</>
)
}
@@ -0,0 +1,77 @@
import dayjs from 'dayjs'
import { ChevronLeft, X } from 'lucide-react'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { AdvisorItemSource, AdvisorSeverity } from 'state/advisor-state'
import { Badge } from 'ui'
export type AdvisorItem = {
id: string
title: string
severity: AdvisorSeverity
createdAt?: number
tab: 'security' | 'performance' | 'messages'
source: AdvisorItemSource
original: any
}
export const severityBadgeVariants: Record<AdvisorSeverity, 'destructive' | 'warning' | 'default'> =
{
critical: 'destructive',
warning: 'warning',
info: 'default',
}
export const severityLabels: Record<AdvisorSeverity, string> = {
critical: 'Critical',
warning: 'Warning',
info: 'Info',
}
interface AdvisorPanelHeaderProps {
selectedItem: AdvisorItem | undefined
onBack: () => void
onClose: () => void
}
export const AdvisorPanelHeader = ({ selectedItem, onBack, onClose }: AdvisorPanelHeaderProps) => {
return (
<div className="border-b px-4 py-3 flex items-center gap-3">
<ButtonTooltip
type="text"
className="w-7 h-7 p-0 flex justify-center items-center"
icon={<ChevronLeft size={16} strokeWidth={1.5} aria-hidden={true} />}
onClick={onBack}
tooltip={{ content: { side: 'bottom', text: 'Back to list' } }}
/>
<div className="flex items-center gap-2 overflow-hidden flex-1">
<div className="flex-1 flex flex-col gap-0.5">
<span className="heading-default">{selectedItem?.title?.replace(/[`\\]/g, '')}</span>
{selectedItem?.createdAt && (
<span className="text-xs text-foreground-light capitalize-sentence">
{(() => {
const insertedAt = selectedItem.createdAt
const daysFromNow = dayjs().diff(dayjs(insertedAt), 'day')
const formattedTimeFromNow = dayjs(insertedAt).fromNow()
const formattedInsertedAt = dayjs(insertedAt).format('MMM DD, YYYY')
return daysFromNow > 1 ? formattedInsertedAt : formattedTimeFromNow
})()}
</span>
)}
</div>
{selectedItem && (
<Badge variant={severityBadgeVariants[selectedItem.severity]}>
{severityLabels[selectedItem.severity]}
</Badge>
)}
</div>
<ButtonTooltip
type="text"
className="w-7 h-7 p-0"
icon={<X strokeWidth={1.5} />}
onClick={onClose}
tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
/>
</div>
)
}
@@ -33,9 +33,9 @@ export const EmptyAdvisor = ({ activeTab, hasFilters, onClearFilters }: EmptyAdv
case 'performance':
return 'Congrats! There are no performance issues detected for this project'
case 'messages':
return 'There are no messages for this project'
return 'Messages alert you of upcoming changes or potential issues with your project'
default:
return 'Congrats! There are no issues detected for this project'
return 'Congrats! There are no issues detected'
}
}
@@ -0,0 +1,113 @@
import { Archive, ArchiveRestoreIcon, ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { Button } from 'ui'
import { Markdown } from 'components/interfaces/Markdown'
import { Notification, NotificationData } from 'data/notifications/notifications-v2-query'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
interface NotificationDetailProps {
notification: Notification
onUpdateStatus: (id: string, status: 'archived' | 'seen') => void
}
export const NotificationDetail = ({ notification, onUpdateStatus }: NotificationDetailProps) => {
const data = notification.data as NotificationData
const { data: project } = useProjectDetailQuery({ ref: data.project_ref })
const { data: organizations } = useOrganizationsQuery()
const organization =
data.org_slug !== undefined
? organizations?.find((org) => org.slug === data.org_slug)
: project !== undefined
? organizations?.find((org) => org.id === project.organization_id)
: undefined
const onButtonAction = (type?: string) => {
// [Joshen] Implement accordingly - BE team will need to give us a heads up on this
console.log('Action', type)
}
return (
<div>
{(project !== undefined || organization !== undefined) && (
<>
<h3 className="text-sm mb-2">Context</h3>
<div className="flex items-center gap-2 flex-wrap mb-6">
{organization !== undefined && (
<Link
title={organization.name}
href={`/org/${organization.slug}/general`}
className="text-link"
>
{organization.name}
</Link>
)}
{project !== undefined && (
<Link title={project.name} href={`/project/${project.ref}`} className="text-link">
{project.name}
</Link>
)}
</div>
</>
)}
{data.message !== undefined && (
<>
<h3 className="text-sm mb-2">Message</h3>
<Markdown
className="leading-6 text-sm text-foreground-light mb-6"
content={data.message}
/>
</>
)}
<h3 className="text-sm mb-2">Actions</h3>
<div className="flex items-center gap-2">
{(data.actions ?? []).map((action, idx) => {
const key = `${notification.id}-action-${idx}`
if (action.url !== undefined) {
const url = action.url.includes('[ref]')
? action.url.replace('[ref]', project?.ref ?? '_')
: action.url.includes('[slug]')
? action.url.replace('[slug]', organization?.slug ?? '_')
: action.url
return (
<Button key={key} type="default" icon={<ExternalLink strokeWidth={1.5} />} asChild>
<Link href={url} target="_blank" rel="noreferrer">
{action.label}
</Link>
</Button>
)
} else if (action.action_type !== undefined) {
return (
<Button key={key} type="default" onClick={() => onButtonAction(action.action_type)}>
{action.label}
</Button>
)
} else {
return null
}
})}
{notification.status === 'archived' ? (
<Button
type="default"
icon={<ArchiveRestoreIcon size={14} strokeWidth={1.5} />}
onClick={() => onUpdateStatus(notification.id, 'seen')}
>
Unarchive
</Button>
) : (
<Button
type="default"
icon={<Archive size={14} strokeWidth={1.5} />}
onClick={() => onUpdateStatus(notification.id, 'archived')}
>
Archive
</Button>
)}
</div>
</div>
)
}
+16 -8
View File
@@ -30,6 +30,7 @@ interface FilterPopoverProps<T> {
maxHeightClass?: string
clearButtonText?: string
className?: string
isMinimized?: boolean
onSaveFilters: (options: string[]) => void
// [Joshen] These props are to support async data with infinite loading if applicable
@@ -60,6 +61,7 @@ export const FilterPopover = <T extends Record<string, any>>({
className,
maxHeightClass = 'h-[205px]',
clearButtonText = 'Clear',
isMinimized = false,
onSaveFilters,
search,
@@ -127,14 +129,20 @@ export const FilterPopover = <T extends Record<string, any>>({
>
<div>
<span>{name}</span>
{activeOptions.length > 0 && <span className="mr-1">:</span>}
{activeOptions.length >= 3 ? (
<span>
{formattedOptions[0]} and {activeOptions.length - 1} others
</span>
) : activeOptions.length > 0 ? (
<span>{formattedOptions.join(', ')}</span>
) : null}
{activeOptions.length > 0 && (
<>
<span className="mr-1">:</span>
{isMinimized ? (
<span>{activeOptions.length}</span>
) : activeOptions.length >= 3 ? (
<span>
{formattedOptions[0]} and {activeOptions.length - 1} others
</span>
) : (
<span>{formattedOptions.join(', ')}</span>
)}
</>
)}
</div>
</Button>
</PopoverTrigger_Shadcn_>
@@ -16,7 +16,7 @@ export type BigQueryDestinationConfig = {
export type IcebergDestinationConfig = {
projectRef: string
warehouseName: string
namespace: string
namespace?: string
catalogToken: string
s3AccessKeyId: string
s3SecretAccessKey: string
@@ -59,6 +59,7 @@ async function createDestinationPipeline(
if ('bigQuery' in destinationConfig) {
const { projectId, datasetId, serviceAccountKey, maxStalenessMins } = destinationConfig.bigQuery
destination_config = {
big_query: {
project_id: projectId,
@@ -70,19 +71,20 @@ async function createDestinationPipeline(
} else if ('iceberg' in destinationConfig) {
const {
projectRef: icebergProjectRef,
warehouseName,
namespace,
warehouseName,
catalogToken,
s3AccessKeyId,
s3SecretAccessKey,
s3Region,
} = destinationConfig.iceberg
destination_config = {
iceberg: {
supabase: {
namespace,
project_ref: icebergProjectRef,
warehouse_name: warehouseName,
namespace: namespace,
catalog_token: catalogToken,
s3_access_key_id: s3AccessKeyId,
s3_secret_access_key: s3SecretAccessKey,
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { replicationKeys } from './keys'
import { checkReplicationFeatureFlagRetry } from './utils'
type ReplicationDestinationsParams = { projectRef?: string }
@@ -36,5 +37,8 @@ export const useReplicationDestinationsQuery = <TData = ReplicationDestinationsD
queryKey: replicationKeys.destinations(projectRef),
queryFn: ({ signal }) => fetchReplicationDestinations({ projectRef }, signal),
enabled: enabled && typeof projectRef !== 'undefined',
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: checkReplicationFeatureFlagRetry,
...options,
})
+5 -3
View File
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { replicationKeys } from './keys'
import { checkReplicationFeatureFlagRetry } from './utils'
type ReplicationPipelinesParams = { projectRef?: string }
@@ -16,10 +17,8 @@ async function fetchReplicationPipelines(
params: { path: { ref: projectRef } },
signal,
})
if (error) {
handleError(error)
}
if (error) handleError(error)
return data
}
@@ -37,5 +36,8 @@ export const useReplicationPipelinesQuery = <TData = ReplicationPipelinesData>(
queryKey: replicationKeys.pipelines(projectRef),
queryFn: ({ signal }) => fetchReplicationPipelines({ projectRef }, signal),
enabled: enabled && typeof projectRef !== 'undefined',
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: checkReplicationFeatureFlagRetry,
...options,
})
@@ -0,0 +1,66 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { handleError, post } from 'data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from 'types'
import { replicationKeys } from './keys'
export type UpdatePublicationParams = {
projectRef: string
sourceId: number
publicationName: string
tables: { schema: string; name: string }[]
}
async function updatePublication(
{ projectRef, sourceId, publicationName, tables }: UpdatePublicationParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await post(
'/platform/replication/{ref}/sources/{source_id}/publications/{publication_name}',
{
params: { path: { ref: projectRef, source_id: sourceId, publication_name: publicationName } },
body: { tables },
signal,
}
)
if (error) {
handleError(error)
}
return data
}
type UpdatePublicationData = Awaited<ReturnType<typeof updatePublication>>
export const useUpdatePublicationMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<UpdatePublicationData, ResponseError, UpdatePublicationParams>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<UpdatePublicationData, ResponseError, UpdatePublicationParams>({
mutationFn: (vars) => updatePublication(vars),
async onSuccess(data, variables, context) {
const { projectRef, sourceId } = variables
await queryClient.invalidateQueries({
queryKey: replicationKeys.publications(projectRef, sourceId),
})
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to update publication: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
+4
View File
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { replicationKeys } from './keys'
import { checkReplicationFeatureFlagRetry } from './utils'
type ReplicationSourcesParams = { projectRef?: string }
@@ -36,5 +37,8 @@ export const useReplicationSourcesQuery = <TData = ReplicationSourcesData>(
queryKey: replicationKeys.sources(projectRef),
queryFn: ({ signal }) => fetchReplicationSources({ projectRef }, signal),
enabled: enabled && typeof projectRef !== 'undefined',
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: checkReplicationFeatureFlagRetry,
...options,
})
+1 -1
View File
@@ -21,7 +21,7 @@ async function fetchReplicationTables(
handleError(error)
}
return data
return data.tables
}
export type ReplicationTablesData = Awaited<ReturnType<typeof fetchReplicationTables>>
+22
View File
@@ -0,0 +1,22 @@
import { MAX_RETRY_FAILURE_COUNT } from 'data/query-client'
import { ResponseError } from 'types'
export const checkReplicationFeatureFlagRetry = (
failureCount: number,
error: ResponseError
): boolean => {
const isFeatureFlagRequiredError =
error instanceof ResponseError &&
error.code === 503 &&
error.message.includes('feature flag is required')
if (isFeatureFlagRequiredError) {
return false
}
if (failureCount < MAX_RETRY_FAILURE_COUNT) {
return true
}
return false
}
@@ -88,21 +88,4 @@ export const useOrganizationBillingSubscriptionPreview = <
queryFn: () => previewOrganizationBillingSubscription({ organizationSlug, tier }),
enabled: enabled && typeof organizationSlug !== 'undefined' && typeof tier !== 'undefined',
...options,
retry: (failureCount, error) => {
// Don't retry on 400s
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as any).code === 400
) {
return false
}
if (failureCount < 3) {
return true
}
return false
},
})
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { handleError, post } from 'data/fetchers'
import { projectKeys } from './keys'
import { UseCustomQueryOptions } from 'types'
import { projectKeys } from './keys'
export type ProjectTransferPreviewVariables = {
projectRef?: string
@@ -44,21 +44,4 @@ export const useProjectTransferPreviewQuery = <TData = ProjectTransferPreviewDat
enabled:
enabled && typeof projectRef !== 'undefined' && typeof targetOrganizationSlug !== 'undefined',
...options,
retry: (failureCount, error) => {
// Don't retry on 400s
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as any).code === 400
) {
return false
}
if (failureCount < 3) {
return true
}
return false
},
})
+3 -1
View File
@@ -18,6 +18,8 @@ const SKIP_RETRY_PATHNAME_MATCHERS = [
'/v1/projects/:ref/analytics/endpoints/logs.all',
].map((pathname) => match(pathname))
export const MAX_RETRY_FAILURE_COUNT = 3
let queryClient: QueryClient | undefined
export function getQueryClient() {
@@ -55,7 +57,7 @@ export function getQueryClient() {
return false
}
if (failureCount < 3) {
if (failureCount < MAX_RETRY_FAILURE_COUNT) {
return true
}
+4 -7
View File
@@ -2,9 +2,10 @@ import { useQuery } from '@tanstack/react-query'
import { components } from 'api-types'
import { get, handleError } from 'data/fetchers'
import { MAX_RETRY_FAILURE_COUNT } from 'data/query-client'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from 'lib/constants'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { ResponseError, type UseCustomQueryOptions } from 'types'
import { storageKeys } from './keys'
export type BucketsVariables = { projectRef?: string }
@@ -41,15 +42,11 @@ export const useBucketsQuery = <TData = BucketsData>(
enabled: enabled && typeof projectRef !== 'undefined' && isActive,
...options,
retry: (failureCount, error) => {
if (
typeof error === 'object' &&
error !== null &&
error.message.includes('Missing tenant config')
) {
if (error instanceof ResponseError && error.message.includes('Missing tenant config')) {
return false
}
if (failureCount < 3) {
if (failureCount < MAX_RETRY_FAILURE_COUNT) {
return true
}
@@ -90,7 +90,7 @@ export const useIcebergNamespaceCreateMutation = ({
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if ((data.message = 'Request failed with status code 409')) {
if (data.message === 'Request failed with status code 409') {
toast.error(`A namespace named ${variables.namespace} already exists in the catalog.`)
return
}
+41 -3
View File
@@ -2,11 +2,19 @@ import { proxy, snapshot, useSnapshot } from 'valtio'
export type AdvisorTab = 'all' | 'security' | 'performance' | 'messages'
export type AdvisorSeverity = 'critical' | 'warning' | 'info'
export type AdvisorItemSource = 'lint' | 'notification'
const initialState = {
activeTab: 'all' as AdvisorTab,
severityFilters: ['critical'] as AdvisorSeverity[],
severityFilters: ['critical', 'warning'] as AdvisorSeverity[],
selectedItemId: undefined as string | undefined,
selectedItemSource: undefined as AdvisorItemSource | undefined,
// Notification filters
notificationFilterStatuses: [] as string[],
notificationFilterPriorities: [] as string[],
get numNotificationFiltersApplied() {
return [...this.notificationFilterStatuses, ...this.notificationFilterPriorities].length
},
}
export const advisorState = proxy({
@@ -20,14 +28,44 @@ export const advisorState = proxy({
clearSeverityFilters() {
advisorState.severityFilters = []
},
setSelectedItemId(id: string | undefined) {
setSelectedItem(id: string | undefined, source?: AdvisorItemSource) {
advisorState.selectedItemId = id
advisorState.selectedItemSource = source
},
focusItem({ id, tab }: { id: string; tab?: AdvisorTab }) {
focusItem({ id, tab, source }: { id: string; tab?: AdvisorTab; source?: AdvisorItemSource }) {
if (tab) {
advisorState.activeTab = tab
}
advisorState.selectedItemId = id
advisorState.selectedItemSource = source
},
setNotificationFilters: (value: string, type: 'status' | 'priority') => {
switch (type) {
case 'status':
if (advisorState.notificationFilterStatuses.includes(value)) {
advisorState.notificationFilterStatuses = advisorState.notificationFilterStatuses.filter(
(x) => x !== value
)
} else {
advisorState.notificationFilterStatuses = advisorState.notificationFilterStatuses.concat([
value,
])
}
break
case 'priority':
if (advisorState.notificationFilterPriorities.includes(value)) {
advisorState.notificationFilterPriorities =
advisorState.notificationFilterPriorities.filter((x) => x !== value)
} else {
advisorState.notificationFilterPriorities =
advisorState.notificationFilterPriorities.concat([value])
}
break
}
},
resetNotificationFilters() {
advisorState.notificationFilterStatuses = []
advisorState.notificationFilterPriorities = []
},
reset() {
Object.assign(advisorState, initialState)
-69
View File
@@ -1,69 +0,0 @@
import { proxy, snapshot, useSnapshot } from 'valtio'
export const notificationsState = proxy({
filterStatuses: [] as string[],
filterPriorities: [] as string[],
filterOrganizations: [] as string[],
filterProjects: [] as string[],
get numFiltersApplied() {
return [
...this.filterStatuses,
...this.filterPriorities,
...this.filterOrganizations,
...this.filterProjects,
].length
},
setFilters: (value: string, type: 'status' | 'priority' | 'organizations' | 'projects') => {
switch (type) {
case 'status':
if (notificationsState.filterStatuses.includes(value)) {
notificationsState.filterStatuses = notificationsState.filterStatuses.filter(
(x) => x !== value
)
} else {
notificationsState.filterStatuses = notificationsState.filterStatuses.concat([value])
}
break
case 'priority':
if (notificationsState.filterPriorities.includes(value)) {
notificationsState.filterPriorities = notificationsState.filterPriorities.filter(
(x) => x !== value
)
} else {
notificationsState.filterPriorities = notificationsState.filterPriorities.concat([value])
}
break
case 'organizations':
if (notificationsState.filterOrganizations.includes(value)) {
notificationsState.filterOrganizations = notificationsState.filterOrganizations.filter(
(x) => x !== value
)
} else {
notificationsState.filterOrganizations = notificationsState.filterOrganizations.concat([
value,
])
}
break
case 'projects':
if (notificationsState.filterProjects.includes(value)) {
notificationsState.filterProjects = notificationsState.filterProjects.filter(
(x) => x !== value
)
} else {
notificationsState.filterProjects = notificationsState.filterProjects.concat([value])
}
break
}
},
resetFilters: () => {
notificationsState.filterStatuses = []
notificationsState.filterPriorities = []
notificationsState.filterOrganizations = []
notificationsState.filterProjects = []
},
})
export const getNotificationsStateSnapshot = () => snapshot(notificationsState)
export const useNotificationsStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) =>
useSnapshot(notificationsState, options)
+11 -10
View File
@@ -1,6 +1,7 @@
import { LOCAL_STORAGE_KEYS } from 'common/constants'
import useLatest from 'hooks/misc/useLatest'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { ReactNode, useEffect } from 'react'
import { ReactNode, useEffect, useRef } from 'react'
import { proxy, snapshot, useSnapshot } from 'valtio'
type SidebarHandlers = {
@@ -143,26 +144,26 @@ export const useRegisterSidebar = (
id: string,
component: () => ReactNode,
handlers: SidebarHandlers = {},
hotKey?: string
hotKey?: string,
enabled?: boolean
) => {
const [isSidebarHotkeyEnabled] = useLocalStorageQuery<boolean>(
LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(id),
true
)
useEffect(() => {
const { registerSidebar, unregisterSidebar, sidebars } = sidebarManagerState
const componentRef = useLatest(component)
const handlersRef = useLatest(handlers)
if (!sidebars[id]) {
registerSidebar(id, component, handlers)
useEffect(() => {
if (enabled) {
sidebarManagerState.registerSidebar(id, () => componentRef.current(), handlersRef.current)
}
return () => {
if (sidebars[id]) {
unregisterSidebar(id)
}
sidebarManagerState.unregisterSidebar(id)
}
}, [id])
}, [id, enabled])
useEffect(() => {
if (!hotKey) return
@@ -21,13 +21,15 @@ speakers: 'dave_wilson,harry_roper,shahed_islam,omar_moulani'
## Stop Competing on Speed
Anyone can build an MVP in a week now.
AI tools like Lovable, Bolt, Cursor, and Claude can help anyone build an MVP in a week.
That's not your edge. It's the baseline.
That speed used to be your edge. Now it’s the baseline.
AI tools have made speed universal. Every agency can promise "we build fast." The agencies that thrive are the ones that sell what comes after the build.
The difference between a fast build and a successful product is the foundation it’s built on.
In this 45-minute session, three agencies running 40% or higher margins share how they position expertise as value. You'll learn how to turn technical depth, data architecture, and product strategy into the work clients actually pay for.
Supabase provides that foundation. Giving agencies the flexibility, scalability, and security they need to turn rapid prototypes into reliable, production-grade applications.
In this 45-minute session, three agencies running 40%+ margins share how they combine AI development tech with Supabase to move beyond speed and deliver long-term client value. You’ll learn how to position technical depth, data architecture, and product strategy as the differentiators clients actually pay for.
### What You'll Learn
@@ -52,6 +52,7 @@
"docs:contribution": true,
"docs:fdw": true,
"docs:footer": true,
"docs:navigation_dropdown_links_home": true,
"docs:self-hosting": true,
"docs:framework_quickstarts": true,
"docs:full_getting_started": true,
@@ -181,6 +181,10 @@
"type": "boolean",
"description": "Enable footer on docs site"
},
"docs:navigation_dropdown_links_home": {
"type": "boolean",
"description": "Should the navigation dropdown homepage link go to Supabase.com or the dashboard"
},
"docs:framework_quickstarts": {
"type": "boolean",
"description": "Enable framework quickstarts documentation"
+12 -2
View File
@@ -172,6 +172,9 @@ export const PageTelemetry = ({
// Handle initial page telemetry event
const hasSentInitialPageTelemetryRef = useRef(false)
// Track previous pathname for App Router to detect actual changes
const previousAppPathnameRef = useRef<string | null>(null)
// Initialize PostHog client when consent is accepted
useEffect(() => {
if (hasAcceptedConsent && IS_PLATFORM) {
@@ -239,10 +242,17 @@ export const PageTelemetry = ({
// For app router
if (router !== null) return
// Wait until we've sent the initial page telemetry event
if (appPathname && !hasSentInitialPageTelemetryRef.current) {
// Only track if pathname actually changed (not initial mount)
if (
appPathname &&
previousAppPathnameRef.current !== null &&
previousAppPathnameRef.current !== appPathname
) {
sendPageTelemetry()
}
// Update previous pathname
previousAppPathnameRef.current = appPathname
}, [appPathname, router, sendPageTelemetry])
useEffect(() => {
@@ -4,7 +4,7 @@ import * as React from 'react'
import { cn } from '../../../lib/utils/cn'
const badgeVariants = cva(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs bg-opacity-10',
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs bg-opacity-10 whitespace-nowrap',
{
variants: {
variant: {
@@ -68,7 +68,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
'h-10 px-4 text-left align-middle heading-meta text-foreground-lighter [&:has([role=checkbox])]:pr-0',
'h-10 px-4 text-left align-middle heading-meta whitespace-nowrap text-foreground-lighter [&:has([role=checkbox])]:pr-0',
className
)}
{...props}