From 3a0be853c9c9c84d580cc174bc7b0513150fd882 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 6 Feb 2025 13:42:59 +0900 Subject: [PATCH 01/57] chore: rework and extend database testing docs and examples (#33261) * chore: rework and extend database testing docs and exemples --- .../NavigationMenu.constants.ts | 16 + .../local-development/testing/overview.mdx | 328 +++++++++ .../testing/pgtap-extended.mdx | 665 ++++++++++++++++++ .../config/vocabularies/Supabase/accept.txt | 2 + 4 files changed, 1011 insertions(+) create mode 100644 apps/docs/content/guides/local-development/testing/overview.mdx create mode 100644 apps/docs/content/guides/local-development/testing/pgtap-extended.mdx diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index c3fccd5395e..4440a3a331c 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1920,6 +1920,22 @@ export const local_development: NavMenuConstant = { }, ], }, + { + name: 'Testing', + url: undefined, + items: [ + { name: 'Getting started', url: '/guides/local-development/testing/overview' }, + { + name: 'pgTAP advanced guide', + url: '/guides/local-development/testing/pgtap-extended', + }, + { name: 'Database testing', url: '/guides/database/testing' }, + { + name: 'RLS policies testing', + url: '/guides/database/extensions/pgtap#testing-rls-policies', + }, + ], + }, ], } diff --git a/apps/docs/content/guides/local-development/testing/overview.mdx b/apps/docs/content/guides/local-development/testing/overview.mdx new file mode 100644 index 00000000000..eca62644d1c --- /dev/null +++ b/apps/docs/content/guides/local-development/testing/overview.mdx @@ -0,0 +1,328 @@ +--- +id: 'local-development-testing-overview' +title: 'Testing Overview' +description: 'Learn how to develop and test database schemas, tables, functions, and Row Level Security (RLS) policies.' +--- + +Testing is a critical part of database development, especially when working with features like Row Level Security (RLS) policies. This guide provides a comprehensive approach to testing your Supabase database. + +## Testing approaches + +### Database unit testing with pgTAP + +[pgTAP](https://pgtap.org) is a unit testing framework for PostgreSQL that allows testing: + +- Database structure: tables, columns, constraints +- Row Level Security (RLS) policies +- Functions and procedures +- Data integrity + +This example demonstrates setting up and testing RLS policies for a simple todo application: + +1. Create a test table with RLS enabled: + + ```sql + -- Create a simple todos table + create table todos ( + id uuid primary key default gen_random_uuid(), + task text not null, + user_id uuid references auth.users not null, + completed boolean default false + ); + + -- Enable RLS + alter table todos enable row level security; + + -- Create a policy + create policy "Users can only access their own todos" + on todos for all -- this policy applies to all operations + to authenticated + using ((select auth.uid()) = user_id); + ``` + +2. Set up your testing environment: + + ```bash + # Create a new test for our policies using supabase cli + supabase test new todos_rls.test + ``` + +3. Write your RLS tests: + + ```sql + begin; + -- install tests utilities + -- install pgtap extension for testing + create extension if not exists pgtap with schema extensions; + -- Start declare we'll have 6 test cases in our test suite + select plan(4); + + -- Setup our testing data + -- Set up auth.users entries + insert into auth.users (id, email) values + ('123e4567-e89b-12d3-a456-426614174000', 'user1@test.com'), + ('987fcdeb-51a2-43d7-9012-345678901234', 'user2@test.com'); + + -- Create test todos + insert into public.todos (task, user_id) values + ('User 1 Task 1', '123e4567-e89b-12d3-a456-426614174000'), + ('User 1 Task 2', '123e4567-e89b-12d3-a456-426614174000'), + ('User 2 Task 1', '987fcdeb-51a2-43d7-9012-345678901234'); + + -- as User 1 + set local role authenticated; + set local request.jwt.claim.sub = '123e4567-e89b-12d3-a456-426614174000'; + + -- Test 1: User 1 should only see their own todos + select results_eq( + 'select count(*) from todos', + ARRAY[2::bigint], + 'User 1 should only see their 2 todos' + ); + + -- Test 2: User 1 can create their own todo + select lives_ok( + $$insert into todos (task, user_id) values ('New Task', '123e4567-e89b-12d3-a456-426614174000'::uuid)$$, + 'User 1 can create their own todo' + ); + + -- as User 2 + set local request.jwt.claim.sub = '987fcdeb-51a2-43d7-9012-345678901234'; + + -- Test 3: User 2 should only see their own todos + select results_eq( + 'select count(*) from todos', + ARRAY[1::bigint], + 'User 2 should only see their 1 todo' + ); + + -- Test 4: User 2 cannot modify User 1's todo + SELECT results_ne( + $$ update todos set task = 'Hacked!' where user_id = '123e4567-e89b-12d3-a456-426614174000'::uuid returning 1 $$, + $$ values(1) $$, + 'User 2 cannot modify User 1 todos' + ); + + select * from finish(); + rollback; + ``` + +4. Run the tests: + + ```bash + supabase test db + psql:todos_rls.test.sql:4: NOTICE: extension "pgtap" already exists, skipping + ./todos_rls.test.sql .. ok + All tests successful. + Files=1, Tests=6, 0 wallclock secs ( 0.01 usr + 0.00 sys = 0.01 CPU) + Result: PASS + ``` + +### Application-Level testing + +Testing through application code provides end-to-end verification. Unlike database-level testing with pgTAP, application-level tests cannot use transactions for isolation. + + + +Application-level tests should not rely on a clean database state, as resetting the database before each test can be slow and makes tests difficult to parallelize. +Instead, design your tests to be independent by using unique user IDs for each test case. + + + +Here's an example using TypeScript that mirrors the pgTAP tests above: + +```typescript +import { createClient } from '@supabase/supabase-js' +import { beforeAll, describe, expect, it } from 'vitest' +import crypto from 'crypto' + +describe('Todos RLS', () => { + // Generate unique IDs for this test suite to avoid conflicts with other tests + const USER_1_ID = crypto.randomUUID() + const USER_2_ID = crypto.randomUUID() + + const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!) + + beforeAll(async () => { + // Setup test data specific to this test suite + const adminSupabase = createClient(process.env.SUPABASE_URL!, process.env.SERVICE_ROLE_KEY!) + + // Create test users with unique IDs + await adminSupabase.auth.admin.createUser({ + id: USER_1_ID, + email: `user1-${USER_1_ID}@test.com`, + password: 'password123', + // We want the user to be usable right away without email confirmation + email_confirm: true, + }) + await adminSupabase.auth.admin.createUser({ + id: USER_2_ID, + email: `user2-${USER_2_ID}@test.com`, + password: 'password123', + email_confirm: true, + }) + + // Create initial todos + await adminSupabase.from('todos').insert([ + { task: 'User 1 Task 1', user_id: USER_1_ID }, + { task: 'User 1 Task 2', user_id: USER_1_ID }, + { task: 'User 2 Task 1', user_id: USER_2_ID }, + ]) + }) + + it('should allow User 1 to only see their own todos', async () => { + // Sign in as User 1 + await supabase.auth.signInWithPassword({ + email: `user1-${USER_1_ID}@test.com`, + password: 'password123', + }) + + const { data: todos } = await supabase.from('todos').select('*') + + expect(todos).toHaveLength(2) + todos?.forEach((todo) => { + expect(todo.user_id).toBe(USER_1_ID) + }) + }) + + it('should allow User 1 to create their own todo', async () => { + await supabase.auth.signInWithPassword({ + email: `user1-${USER_1_ID}@test.com`, + password: 'password123', + }) + + const { error } = await supabase.from('todos').insert({ task: 'New Task', user_id: USER_1_ID }) + + expect(error).toBeNull() + }) + + it('should allow User 2 to only see their own todos', async () => { + // Sign in as User 2 + await supabase.auth.signInWithPassword({ + email: `user2-${USER_2_ID}@test.com`, + password: 'password123', + }) + + const { data: todos } = await supabase.from('todos').select('*') + expect(todos).toHaveLength(1) + todos?.forEach((todo) => { + expect(todo.user_id).toBe(USER_2_ID) + }) + }) + + it('should prevent User 2 from modifying User 1 todos', async () => { + await supabase.auth.signInWithPassword({ + email: `user2-${USER_2_ID}@test.com`, + password: 'password123', + }) + + // Attempt to update the todos we shouldn't have access to + // result will be a no-op + await supabase.from('todos').update({ task: 'Hacked!' }).eq('user_id', USER_1_ID) + + // Log back in as User 1 to verify their todos weren't changed + await supabase.auth.signInWithPassword({ + email: `user1-${USER_1_ID}@test.com`, + password: 'password123', + }) + + // Fetch User 1's todos + const { data: todos } = await supabase.from('todos').select('*') + + // Verify that none of the todos were changed to "Hacked!" + expect(todos).toBeDefined() + todos?.forEach((todo) => { + expect(todo.task).not.toBe('Hacked!') + }) + }) +}) +``` + +#### Test isolation strategies + +For application-level testing, consider these approaches for test isolation: + +1. **Unique Identifiers**: Generate unique IDs for each test suite to prevent data conflicts +2. **Cleanup After Tests**: If necessary, clean up created data in an `afterAll` or `afterEach` hook +3. **Isolated Data Sets**: Use prefixes or namespaces in data to separate test cases + +### Continuous integration testing + +Set up automated database testing in your CI pipeline: + +1. Create a GitHub Actions workflow `.github/workflows/db-tests.yml`: + +```yaml +name: Database Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Setup Supabase CLI + uses: supabase/setup-cli@v1 + + - name: Start Supabase + run: supabase start + + - name: Run Tests + run: supabase test db +``` + +## Best practices + +1. **Test Data Setup** + + - Use begin and rollback to ensure test isolation + - Create realistic test data that covers edge cases + - Use different user roles and permissions in tests + +2. **RLS Policy Testing** + + - Test Create, Read, Update, Delete operations + - Test with different user roles: anonymous and authenticated + - Test edge cases and potential security bypasses + - Always test negative cases: what users should not be able to do + +3. **CI/CD Integration** + - Run tests automatically on every pull request + - Include database tests in deployment pipeline + - Keep test runs fast using transactions + +## Real-World examples + +For more complex, real-world examples of database testing, check out: + +- [Database Tests Example Repository](https://github.com/usebasejump/basejump/tree/main/supabase/tests/database) - A production-grade example of testing RLS policies +- [RLS Guide and Best Practices](https://github.com/orgs/supabase/discussions/14576) + +## Troubleshooting + +Common issues and solutions: + +1. **Test Failures Due to RLS** + + - Ensure you've set the correct role `set local role authenticated;` + - Verify JWT claims are set `set local "request.jwt.claims"` + - Check policy definitions match your test assumptions + +2. **CI Pipeline Issues** + - Verify Supabase CLI is properly installed + - Ensure database migrations are run before tests + - Check for proper test isolation using transactions + +## Additional resources + +- [pgTAP Documentation](https://pgtap.org) +- [Supabase CLI Reference](https://supabase.com/docs/reference/cli/supabase-test) +- [pgTAP Supabase reference](https://supabase.com/docs/guides/database/extensions/pgtap?queryGroups=database-method&database-method=sql#testing-rls-policies) +- [Database testing reference](https://supabase.com/docs/guides/database/testing) diff --git a/apps/docs/content/guides/local-development/testing/pgtap-extended.mdx b/apps/docs/content/guides/local-development/testing/pgtap-extended.mdx new file mode 100644 index 00000000000..bf3b5c38e71 --- /dev/null +++ b/apps/docs/content/guides/local-development/testing/pgtap-extended.mdx @@ -0,0 +1,665 @@ +--- +id: 'local-development-testing-pgtap-extended' +title: 'Advanced pgTAP Testing' +description: 'Learn how to leverage dbdev and test helpers for advanced database testing.' +--- + +While basic pgTAP provides excellent testing capabilities, you can enhance the testing workflow using database development tools and helper packages. This guide covers advanced testing techniques using database.dev and community-maintained test helpers. + +## Using database.dev + +[Database.dev](https://database.dev) is a package manager for PostgreSQL that allows installation and use of community-maintained packages, including testing utilities. + +### Setting up dbdev + +To use database development tools and packages, install some prerequisites: + +```sql +create extension if not exists http with schema extensions; +create extension if not exists pg_tle; +drop extension if exists "supabase-dbdev"; +select pgtle.uninstall_extension_if_exists('supabase-dbdev'); +select + pgtle.install_extension( + 'supabase-dbdev', + resp.contents ->> 'version', + 'PostgreSQL package manager', + resp.contents ->> 'sql' + ) +from http( + ( + 'GET', + 'https://api.database.dev/rest/v1/' + || 'package_versions?select=sql,version' + || '&package_name=eq.supabase-dbdev' + || '&order=version.desc' + || '&limit=1', + array[ + ('apiKey', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhtdXB0cHBsZnZpaWZyYndtbXR2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2ODAxMDczNzIsImV4cCI6MTk5NTY4MzM3Mn0.z2CN0mvO2No8wSi46Gw59DFGCTJrzM0AQKsu_5k134s')::http_header + ], + null, + null + ) +) x, +lateral ( + select + ((row_to_json(x) -> 'content') #>> '{}')::json -> 0 +) resp(contents); +create extension "supabase-dbdev"; +select dbdev.install('supabase-dbdev'); + +-- Drop and recreate the extension to ensure a clean installation +drop extension if exists "supabase-dbdev"; +create extension "supabase-dbdev"; +``` + +### Installing test helpers + +The Test Helpers package provides utilities that simplify testing Supabase-specific features: + +```sql +select dbdev.install('basejump-supabase_test_helpers'); +create extension if not exists "basejump-supabase_test_helpers" version '0.0.6'; +``` + +## Test helper benefits + +The test helpers package provides several advantages over writing raw pgTAP tests: + +1. **Simplified User Management** + + - Create test users with `tests.create_supabase_user()` + - Switch contexts with `tests.authenticate_as()` + - Retrieve user IDs using `tests.get_supabase_uid()` + +2. **Row Level Security (RLS) Testing Utilities** + + - Verify RLS status with `tests.rls_enabled()` + - Test policy enforcement + - Simulate different user contexts + +3. **Reduced Boilerplate** + - No need to manually insert auth.users + - Simplified JWT claim management + - Clean test setup and cleanup + +## Schema-wide Row Level Security testing + +When working with Row Level Security, it's crucial to ensure that RLS is enabled on all tables that need it. Create a simple test to verify RLS is enabled across an entire schema: + +```sql +begin; +select plan(1); + +-- Verify RLS is enabled on all tables in the public schema +select tests.rls_enabled('public'); + +select * from finish(); +rollback; +``` + +## Test file organization + +When working with multiple test files that share common setup requirements, it's beneficial to create a single "pre-test" file that handles the global environment setup. This approach reduces duplication and ensures consistent test environments. + +### Creating a pre-test hook + +Since pgTAP test files are executed in alphabetical order, create a setup file that runs first by using a naming convention like `000-setup-tests-hooks.sql`: + +```bash +supabase test new 000-setup-tests-hooks +``` + +This setup file should contain: + +1. All shared extensions and dependencies +2. Common test utilities +3. A simple always green test to verify the setup + +Here's an example setup file: + +```sql +-- install tests utilities +-- install pgtap extension for testing +create extension if not exists pgtap with schema extensions; +/* +--------------------- +---- install dbdev ---- +---------------------- +Requires: + - pg_tle: https://github.com/aws/pg_tle + - pgsql-http: https://github.com/pramsey/pgsql-http +*/ +create extension if not exists http with schema extensions; +create extension if not exists pg_tle; +drop extension if exists "supabase-dbdev"; +select pgtle.uninstall_extension_if_exists('supabase-dbdev'); +select + pgtle.install_extension( + 'supabase-dbdev', + resp.contents ->> 'version', + 'PostgreSQL package manager', + resp.contents ->> 'sql' + ) +from http( + ( + 'GET', + 'https://api.database.dev/rest/v1/' + || 'package_versions?select=sql,version' + || '&package_name=eq.supabase-dbdev' + || '&order=version.desc' + || '&limit=1', + array[ + ('apiKey', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhtdXB0cHBsZnZpaWZyYndtbXR2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2ODAxMDczNzIsImV4cCI6MTk5NTY4MzM3Mn0.z2CN0mvO2No8wSi46Gw59DFGCTJrzM0AQKsu_5k134s')::http_header + ], + null, + null + ) +) x, +lateral ( + select + ((row_to_json(x) -> 'content') #>> '{}')::json -> 0 +) resp(contents); +create extension "supabase-dbdev"; +select dbdev.install('supabase-dbdev'); +drop extension if exists "supabase-dbdev"; +create extension "supabase-dbdev"; +-- Install test helpers +select dbdev.install('basejump-supabase_test_helpers'); +create extension if not exists "basejump-supabase_test_helpers" version '0.0.6'; + +-- Verify setup with a no-op test +begin; +select plan(1); +select ok(true, 'Pre-test hook completed successfully'); +select * from finish(); +rollback; +``` + +### Benefits + +This approach provides several advantages: + +- Reduces code duplication across test files +- Ensures consistent test environment setup +- Makes it easier to maintain and update shared dependencies +- Provides immediate feedback if the setup process fails + +Your subsequent test files (`001-auth-tests.sql`, `002-rls-tests.sql`) can focus solely on their specific test cases, knowing that the environment is properly configured. + +## Example: Advanced RLS testing + +Here's a complete example using test helpers to verify RLS policies putting it all together: + +```sql +begin; +-- Assuming 000-setup-tests-hooks.sql file is present to use tests helpers +select plan(4); + +-- Set up test data + +-- Create test supabase users +select tests.create_supabase_user('user1@test.com'); +select tests.create_supabase_user('user2@test.com'); + +-- Create test data +insert into public.todos (task, user_id) values + ('User 1 Task 1', tests.get_supabase_uid('user1@test.com')), + ('User 1 Task 2', tests.get_supabase_uid('user1@test.com')), + ('User 2 Task 1', tests.get_supabase_uid('user2@test.com')); + +-- Test as User 1 +select tests.authenticate_as('user1@test.com'); + +-- Test 1: User 1 should only see their own todos +select results_eq( + 'select count(*) from todos', + ARRAY[2::bigint], + 'User 1 should only see their 2 todos' +); + +-- Test 2: User 1 can create their own todo +select lives_ok( + $$insert into todos (task, user_id) values ('New Task', tests.get_supabase_uid('user1@test.com'))$$, + 'User 1 can create their own todo' +); + +-- Test as User 2 +select tests.authenticate_as('user2@test.com'); + +-- Test 3: User 2 should only see their own todos +select results_eq( + 'select count(*) from todos', + ARRAY[1::bigint], + 'User 2 should only see their 1 todo' +); + +-- Test 4: User 2 cannot modify User 1's todo +SELECT results_ne( + $$ update todos set task = 'Hacked!' where user_id = tests.get_supabase_uid('user1@test.com') returning 1 $$, + $$ values(1) $$, + 'User 2 cannot modify User 1 todos' +); + +select * from finish(); +rollback; +``` + +## Not another todo app: Testing complex organizations + +Todo apps are great for learning, but this section explores testing a more realistic scenario: a multi-tenant content publishing platform. This example demonstrates testing complex permissions, plan restrictions, and content management. + +### System overview + +This demo app implements: + +- Organizations with tiered plans (free/pro/enterprise) +- Role-based access (owner/admin/editor/viewer) +- Content management (posts/comments) +- Premium content restrictions +- Plan-based limitations + +### What makes this complex? + +1. **Layered Permissions** + + - Role hierarchies affect access rights + - Plan types influence user capabilities + - Content state (draft/published) affects permissions + +2. **Business Rules** + - Free plan post limits + - Premium content visibility + - Cross-organization security + +### Testing focus areas + +When writing tests, verify: + +- Organization member access control +- Content visibility across roles +- Plan limitation enforcement +- Cross-organization data isolation + +#### 1. App schema definitions + +The app schema tables are defined like this: + +```sql +create table public.profiles ( + id uuid references auth.users(id) primary key, + username text unique not null, + full_name text, + bio text, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +create table public.organizations ( + id bigint primary key generated always as identity, + name text not null, + slug text unique not null, + plan_type text not null check (plan_type in ('free', 'pro', 'enterprise')), + max_posts int not null default 5, + created_at timestamptz default now() +); + +create table public.org_members ( + org_id bigint references public.organizations(id) on delete cascade, + user_id uuid references auth.users(id) on delete cascade, + role text not null check (role in ('owner', 'admin', 'editor', 'viewer')), + created_at timestamptz default now(), + primary key (org_id, user_id) +); + +create table public.posts ( + id bigint primary key generated always as identity, + title text not null, + content text not null, + author_id uuid references public.profiles(id) not null, + org_id bigint references public.organizations(id), + status text not null check (status in ('draft', 'published', 'archived')), + is_premium boolean default false, + scheduled_for timestamptz, + category text, + view_count int default 0, + published_at timestamptz, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +create table public.comments ( + id bigint primary key generated always as identity, + post_id bigint references public.posts(id) on delete cascade, + author_id uuid references public.profiles(id), + content text not null, + is_deleted boolean default false, + created_at timestamptz default now(), + updated_at timestamptz default now() +); +``` + +#### 2. RLS policies declaration + +Now to setup the RLS policies for each tables: + +```sql +-- Create a private schema to store all security definer functions utils +-- As such functions should never be in a API exposed schema +create schema if not exists private; +-- Helper function for role checks +create or replace function private.get_user_org_role(org_id bigint, user_id uuid) +returns text +set search_path = '' +as $$ + select role from public.org_members + where org_id = $1 and user_id = $2; +-- Note the use of security definer to avoid RLS checking recursion issue +-- see: https://supabase.com/docs/guides/database/postgres/row-level-security#use-security-definer-functions +$$ language sql security definer; +-- Helper utils to check if an org is below the max post limit +create or replace function private.can_add_post(org_id bigint) +returns boolean +set search_path = '' +as $$ + select (select count(*) + from public.posts p + where p.org_id = $1) < o.max_posts + from public.organizations o + where o.id = $1 +$$ language sql security definer; + + +-- Enable RLS for all tables +alter table public.profiles enable row level security; +alter table public.organizations enable row level security; +alter table public.org_members enable row level security; +alter table public.posts enable row level security; +alter table public.comments enable row level security; + +-- Profiles policies +create policy "Public profiles are viewable by everyone" + on public.profiles for select using (true); + +create policy "Users can insert their own profile" + on public.profiles for insert with check ((select auth.uid()) = id); + +create policy "Users can update their own profile" + on public.profiles for update using ((select auth.uid()) = id) + with check ((select auth.uid()) = id); + +-- Organizations policies +create policy "Public org info visible to all" + on public.organizations for select using (true); + +create policy "Org management restricted to owners" + on public.organizations for all using ( + private.get_user_org_role(id, (select auth.uid())) = 'owner' + ); + +-- Org Members policies +create policy "Members visible to org members" + on public.org_members for select using ( + private.get_user_org_role(org_id, (select auth.uid())) is not null + ); + +create policy "Member management restricted to admins and owners" + on public.org_members for all using ( + private.get_user_org_role(org_id, (select auth.uid())) in ('owner', 'admin') + ); + +-- Posts policies +create policy "Complex post visibility" + on public.posts for select using ( + -- Published non-premium posts are visible to all + (status = 'published' and not is_premium) + or + -- Premium posts visible to org members only + (status = 'published' and is_premium and + private.get_user_org_role(org_id, (select auth.uid())) is not null) + or + -- All posts visible to editors and above + private.get_user_org_role(org_id, (select auth.uid())) in ('owner', 'admin', 'editor') + ); + +create policy "Post creation rules" + on public.posts for insert with check ( + -- Must be org member with appropriate role + private.get_user_org_role(org_id, (select auth.uid())) in ('owner', 'admin', 'editor') + and + -- Check org post limits for free plans + ( + (select o.plan_type != 'free' + from organizations o + where o.id = org_id) + or + (select private.can_add_post(org_id)) + ) + ); + +create policy "Post update rules" + on public.posts for update using ( + exists ( + select 1 + where + -- Editors can update non-published posts + (private.get_user_org_role(org_id, (select auth.uid())) = 'editor' and status != 'published') + or + -- Admins and owners can update any post + private.get_user_org_role(org_id, (select auth.uid())) in ('owner', 'admin') + ) + ); + +-- Comments policies +create policy "Comments on published posts are viewable by everyone" + on public.comments for select using ( + exists ( + select 1 from public.posts + where id = post_id + and status = 'published' + ) + and not is_deleted + ); + +create policy "Authenticated users can create comments" + on public.comments for insert with check ((select auth.uid()) = author_id); + +create policy "Users can update their own comments" + on public.comments for update using (author_id = (select auth.uid())); +``` + +#### 3. Test cases: + +Now everything is setup, let's write RLS test cases, note that each section could be in its own test: + +```sql +-- Assuming we already have: 000-setup-tests-hooks.sql file we can use tests helpers +begin; +-- Declare total number of tests +select plan(10); + +-- Create test users +select tests.create_supabase_user('org_owner', 'owner@test.com'); +select tests.create_supabase_user('org_admin', 'admin@test.com'); +select tests.create_supabase_user('org_editor', 'editor@test.com'); +select tests.create_supabase_user('premium_user', 'premium@test.com'); +select tests.create_supabase_user('free_user', 'free@test.com'); +select tests.create_supabase_user('scheduler', 'scheduler@test.com'); +select tests.create_supabase_user('free_author', 'free_author@test.com'); + +-- Create profiles for test users +insert into profiles (id, username, full_name) +values + (tests.get_supabase_uid('org_owner'), 'org_owner', 'Organization Owner'), + (tests.get_supabase_uid('org_admin'), 'org_admin', 'Organization Admin'), + (tests.get_supabase_uid('org_editor'), 'org_editor', 'Organization Editor'), + (tests.get_supabase_uid('premium_user'), 'premium_user', 'Premium User'), + (tests.get_supabase_uid('free_user'), 'free_user', 'Free User'), + (tests.get_supabase_uid('scheduler'), 'scheduler', 'Scheduler User'), + (tests.get_supabase_uid('free_author'), 'free_author', 'Free Author'); + +-- First authenticate as service role to bypass RLS for initial setup +select tests.authenticate_as_service_role(); + +-- Create test organizations and setup data +with new_org as ( + insert into organizations (name, slug, plan_type, max_posts) + values + ('Test Org', 'test-org', 'pro', 100), + ('Premium Org', 'premium-org', 'enterprise', 1000), + ('Schedule Org', 'schedule-org', 'pro', 100), + ('Free Org', 'free-org', 'free', 2) + returning id, slug +), +-- Setup members and posts +member_setup as ( + insert into org_members (org_id, user_id, role) + select + org.id, + user_id, + role + from new_org org cross join ( + values + (tests.get_supabase_uid('org_owner'), 'owner'), + (tests.get_supabase_uid('org_admin'), 'admin'), + (tests.get_supabase_uid('org_editor'), 'editor'), + (tests.get_supabase_uid('premium_user'), 'viewer'), + (tests.get_supabase_uid('scheduler'), 'editor'), + (tests.get_supabase_uid('free_author'), 'editor') + ) as members(user_id, role) + where org.slug = 'test-org' + or (org.slug = 'premium-org' and role = 'viewer') + or (org.slug = 'schedule-org' and role = 'editor') + or (org.slug = 'free-org' and role = 'editor') +) +-- Setup initial posts +insert into posts (title, content, org_id, author_id, status, is_premium, scheduled_for) +select + title, + content, + org.id, + author_id, + status, + is_premium, + scheduled_for +from new_org org cross join ( + values + ('Premium Post', 'Premium content', tests.get_supabase_uid('premium_user'), 'published', true, null), + ('Free Post', 'Free content', tests.get_supabase_uid('premium_user'), 'published', false, null), + ('Future Post', 'Future content', tests.get_supabase_uid('scheduler'), 'published', false, '2024-01-02 12:00:00+00'::timestamptz) +) as posts(title, content, author_id, status, is_premium, scheduled_for) +where org.slug in ('premium-org', 'schedule-org'); + +-- Test owner privileges +select tests.authenticate_as('org_owner'); +select lives_ok( + $$ + update organizations + set name = 'Updated Org' + where id = (select id from organizations limit 1) + $$, + 'Owner can update organization' +); + +-- Test admin privileges +select tests.authenticate_as('org_admin'); +select results_eq( + $$select count(*) from org_members$$, + ARRAY[6::bigint], + 'Admin can view all members' +); + +-- Test editor restrictions +select tests.authenticate_as('org_editor'); +select throws_ok( + $$ + insert into org_members (org_id, user_id, role) + values ( + (select id from organizations limit 1), + (select tests.get_supabase_uid('org_editor')), + 'viewer' + ) + $$, + '42501', + 'new row violates row-level security policy for table "org_members"', + 'Editor cannot manage members' +); + +-- Premium Content Access Tests +select tests.authenticate_as('premium_user'); +select results_eq( + $$select count(*) from posts where org_id = (select id from organizations where slug = 'premium-org')$$, + ARRAY[3::bigint], + 'Premium user can see all posts' +); + +select tests.clear_authentication(); +select results_eq( + $$select count(*) from posts where org_id = (select id from organizations where slug = 'premium-org')$$, + ARRAY[2::bigint], + 'Anonymous users can only see free posts' +); + +-- Time-Based Publishing Tests +select tests.authenticate_as('scheduler'); +select tests.freeze_time('2024-01-01 12:00:00+00'::timestamptz); + +select results_eq( + $$select count(*) from posts where scheduled_for > now() and org_id = (select id from organizations where slug = 'schedule-org')$$, + ARRAY[1::bigint], + 'Can see scheduled posts' +); + +select tests.freeze_time('2024-01-02 13:00:00+00'::timestamptz); + +select results_eq( + $$select count(*) from posts where scheduled_for < now() and org_id = (select id from organizations where slug = 'schedule-org')$$, + ARRAY[1::bigint], + 'Can see posts after schedule time' +); + +select tests.unfreeze_time(); + +-- Plan Limit Tests +select tests.authenticate_as('free_author'); + +select lives_ok( + $$ + insert into posts (title, content, org_id, author_id, status) + select 'Post 1', 'Content 1', id, auth.uid(), 'draft' + from organizations where slug = 'free-org' limit 1 + $$, + 'First post creates successfully' +); + +select lives_ok( + $$ + insert into posts (title, content, org_id, author_id, status) + select 'Post 2', 'Content 2', id, auth.uid(), 'draft' + from organizations where slug = 'free-org' limit 1 + $$, + 'Second post creates successfully' +); + +select throws_ok( + $$ + insert into posts (title, content, org_id, author_id, status) + select 'Post 3', 'Content 3', id, auth.uid(), 'draft' + from organizations where slug = 'free-org' limit 1 + $$, + '42501', + 'new row violates row-level security policy for table "posts"', + 'Cannot exceed free plan post limit' +); + +select * from finish(); +rollback; +``` + +## Additional resources + +- [Test Helpers Documentation](https://database.dev/basejump/supabase_test_helpers) +- [Test Helpers Reference](https://github.com/usebasejump/supabase-test-helpers) +- [Row Level Security Writing Guide](https://usebasejump.com/blog/testing-on-supabase-with-pgtap) +- [Database.dev Package Registry](https://database.dev) +- [Row Level Security Performance and Best Practices](https://github.com/orgs/supabase/discussions/14576) diff --git a/vale/styles/config/vocabularies/Supabase/accept.txt b/vale/styles/config/vocabularies/Supabase/accept.txt index 842775644aa..97f4fb8b291 100644 --- a/vale/styles/config/vocabularies/Supabase/accept.txt +++ b/vale/styles/config/vocabularies/Supabase/accept.txt @@ -107,3 +107,5 @@ Vue [Ww]eb[Ss]ockets? WhatsApp Xcode +pgTAP +dbdev \ No newline at end of file From a6c8165abf8410c81807984a864cb47cc8819512 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Thu, 6 Feb 2025 14:17:31 +0800 Subject: [PATCH 02/57] chore(studio): cache favicon (#33372) * chore(studio): cache favicon * updates rules * add to docs and www --- apps/docs/next.config.mjs | 4 ++++ apps/studio/next.config.js | 12 ++++++++++-- apps/www/next.config.mjs | 4 ++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs index b4572b5640f..18267dfd271 100644 --- a/apps/docs/next.config.mjs +++ b/apps/docs/next.config.mjs @@ -115,6 +115,10 @@ const nextConfig = { }, ], }, + { + source: '/favicon/:slug*', + headers: [{ key: 'cache-control', value: 'public, max-age=86400' }], + }, ] }, diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index c20e9860915..a0b80c406bb 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -506,11 +506,19 @@ const nextConfig = { }, { source: '/img/:slug*', - headers: [{ key: 'cache-control', value: 'max-age=2592000' }], + headers: [{ key: 'cache-control', value: 'public, max-age=2592000' }], + }, + { + source: '/css/fonts.css', + headers: [{ key: 'cache-control', value: 'public, max-age=86400' }], }, { source: '/fonts/:slug*', - headers: [{ key: 'cache-control', value: 'max-age=2592000' }], + headers: [{ key: 'cache-control', value: 'public, max-age=2592000' }], + }, + { + source: '/favicon/:slug*', + headers: [{ key: 'cache-control', value: 'public, max-age=86400' }], }, ] }, diff --git a/apps/www/next.config.mjs b/apps/www/next.config.mjs index d39ad2b0fba..64cd7f84bfd 100644 --- a/apps/www/next.config.mjs +++ b/apps/www/next.config.mjs @@ -80,6 +80,10 @@ const nextConfig = { }, ], }, + { + source: '/favicon/:slug*', + headers: [{ key: 'cache-control', value: 'public, max-age=86400' }], + }, ] }, async rewrites() { From 898fd44ecacfc5adea96e7cff495c4accc81a468 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 6 Feb 2025 14:26:48 +0800 Subject: [PATCH 03/57] Support resetting query performance report on read replica (#33346) --- .../QueryPerformance/QueryPerformance.tsx | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx index c9fdfb6778a..ed45378c08c 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx @@ -6,10 +6,12 @@ import { toast } from 'sonner' import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' +import { formatDatabaseID } from 'data/read-replicas/replicas.utils' import { executeSql } from 'data/sql/execute-sql-query' import { DbQueryHook } from 'hooks/analytics/useDbQuery' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import { LOCAL_STORAGE_KEYS } from 'lib/constants' +import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { Button, @@ -22,7 +24,7 @@ import { TooltipTrigger, cn, } from 'ui' -import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' import { Markdown } from '../Markdown' import { useQueryPerformanceQuery } from '../Reports/Reports.queries' @@ -46,6 +48,8 @@ export const QueryPerformance = ({ const state = useDatabaseSelectorStateSnapshot() const { isLoading, isRefetching } = queryPerformanceQuery + const isPrimaryDatabase = state.selectedDatabaseId === ref + const formattedDatabaseId = formatDatabaseID(state.selectedDatabaseId ?? '') const [page, setPage] = useState( (preset as QUERY_PERFORMANCE_REPORT_TYPES) ?? QUERY_PERFORMANCE_REPORT_TYPES.MOST_TIME_CONSUMING @@ -62,6 +66,8 @@ export const QueryPerformance = ({ queryHitRate.runQuery() } + const { data: databases } = useReadReplicasQuery({ projectRef: ref }) + const { data: mostTimeConsumingQueries, isLoading: isLoadingMTC } = useQueryPerformanceQuery({ preset: 'mostTimeConsuming', }) @@ -246,21 +252,27 @@ export const QueryPerformance = ({ - setShowResetgPgStatStatements(false)} - onSelectConfirm={async () => { + confirmLabel="Reset report" + confirmLabelLoading="Resetting report" + onCancel={() => setShowResetgPgStatStatements(false)} + onConfirm={async () => { + const connectionString = databases?.find( + (db) => db.identifier === state.selectedDatabaseId + )?.connectionString + + if (IS_PLATFORM && !connectionString) { + return toast.error('Unable to run query: Connection string is missing') + } + try { await executeSql({ projectRef: project?.ref, - connectionString: project?.connectionString, + connectionString, sql: `SELECT pg_stat_statements_reset();`, }) handleRefresh() @@ -269,7 +281,16 @@ export const QueryPerformance = ({ toast.error(`Failed to reset analysis: ${error.message}`) } }} - /> + > +

+ This will reset the pg_stat_statements table in the extensions schema on your{' '} + + {isPrimaryDatabase ? 'primary database' : `read replica (ID: ${formattedDatabaseId})`} + + , which is used to calculate query performance. This data will repopulate immediately + after. +

+ ) } From c2547eec4f5352032ecdc02eb57c8add7535ffb7 Mon Sep 17 00:00:00 2001 From: Beng Eu Date: Thu, 6 Feb 2025 14:57:01 +0800 Subject: [PATCH 04/57] fix: hide pnpm update message, it's confusing since the pnpm version is locked; update to pnpm 9.15.4 (#33232) --- .github/workflows/ai-tests.yml | 1 - .github/workflows/autofix_linters.yml | 1 - .github/workflows/docs-last-changed.yml | 1 - .github/workflows/docs-mgmt-api-update.yml | 1 - .github/workflows/docs-tests-smoke.yml | 1 - .github/workflows/docs-tests.yml | 1 - .github/workflows/local-studio-tests.yml | 1 - .github/workflows/prettier.yml | 3 --- .github/workflows/search.yml | 1 - .github/workflows/studio-tests.yml | 1 - .github/workflows/typecheck.yml | 1 - .github/workflows/ui-patterns-tests.yml | 1 - .github/workflows/ui-tests.yml | 1 - .npmrc | 3 ++- apps/studio/Dockerfile | 3 ++- package.json | 6 +++--- 16 files changed, 7 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ai-tests.yml b/.github/workflows/ai-tests.yml index 90f94476155..64314d44fe7 100644 --- a/.github/workflows/ai-tests.yml +++ b/.github/workflows/ai-tests.yml @@ -36,7 +36,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/autofix_linters.yml b/.github/workflows/autofix_linters.yml index 650d5776a82..548b97912d5 100644 --- a/.github/workflows/autofix_linters.yml +++ b/.github/workflows/autofix_linters.yml @@ -26,7 +26,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - uses: actions/setup-node@v4 diff --git a/.github/workflows/docs-last-changed.yml b/.github/workflows/docs-last-changed.yml index d8d88c53af5..94967a403f2 100644 --- a/.github/workflows/docs-last-changed.yml +++ b/.github/workflows/docs-last-changed.yml @@ -29,7 +29,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Setup node diff --git a/.github/workflows/docs-mgmt-api-update.yml b/.github/workflows/docs-mgmt-api-update.yml index 47508dc49c2..f395be7acad 100644 --- a/.github/workflows/docs-mgmt-api-update.yml +++ b/.github/workflows/docs-mgmt-api-update.yml @@ -20,7 +20,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js diff --git a/.github/workflows/docs-tests-smoke.yml b/.github/workflows/docs-tests-smoke.yml index 930406127c1..c5a38916a46 100644 --- a/.github/workflows/docs-tests-smoke.yml +++ b/.github/workflows/docs-tests-smoke.yml @@ -25,7 +25,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js diff --git a/.github/workflows/docs-tests.yml b/.github/workflows/docs-tests.yml index e1de7081e50..f9a36f3cf20 100644 --- a/.github/workflows/docs-tests.yml +++ b/.github/workflows/docs-tests.yml @@ -26,7 +26,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js diff --git a/.github/workflows/local-studio-tests.yml b/.github/workflows/local-studio-tests.yml index 9c64715b1e0..24ccc70cce5 100644 --- a/.github/workflows/local-studio-tests.yml +++ b/.github/workflows/local-studio-tests.yml @@ -28,7 +28,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index 0886ee2337a..9381cd0e98e 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -21,7 +21,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Setup node uses: actions/setup-node@v4 @@ -47,7 +46,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Setup node uses: actions/setup-node@v4 @@ -73,7 +71,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Setup node uses: actions/setup-node@v4 diff --git a/.github/workflows/search.yml b/.github/workflows/search.yml index 70148e80480..60385047ea4 100644 --- a/.github/workflows/search.yml +++ b/.github/workflows/search.yml @@ -41,7 +41,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Setup node diff --git a/.github/workflows/studio-tests.yml b/.github/workflows/studio-tests.yml index 7390691e03d..0f41b9e49cf 100644 --- a/.github/workflows/studio-tests.yml +++ b/.github/workflows/studio-tests.yml @@ -34,7 +34,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 42e5e2c0cb5..76e474371a1 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -22,7 +22,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js diff --git a/.github/workflows/ui-patterns-tests.yml b/.github/workflows/ui-patterns-tests.yml index 2209729286c..1f5b93696d8 100644 --- a/.github/workflows/ui-patterns-tests.yml +++ b/.github/workflows/ui-patterns-tests.yml @@ -24,7 +24,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index ba4861cfb9b..80b75b974cd 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -24,7 +24,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js diff --git a/.npmrc b/.npmrc index 4fd021952d5..a4047535344 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ -engine-strict=true \ No newline at end of file +engine-strict=true +update-notifier=false diff --git a/apps/studio/Dockerfile b/apps/studio/Dockerfile index 23dfdc12c5b..586be9e9177 100644 --- a/apps/studio/Dockerfile +++ b/apps/studio/Dockerfile @@ -11,7 +11,6 @@ FROM node:20-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable # Fixes issues with Sentry CLI and SSL certificates during build # TODO: Git is added because it's needed to build libpg, remove it once they publish a binary on the S3 bucket @@ -24,6 +23,8 @@ RUN apt-get update -qq && \ rm -rf /var/lib/apt/lists/* && \ update-ca-certificates +RUN npm install -g pnpm@9.15.5 + WORKDIR /app # Prune unneeded dependencies with turbo (from apps/ for example) diff --git a/package.json b/package.json index 79ad24c9290..b088280717b 100644 --- a/package.json +++ b/package.json @@ -55,9 +55,9 @@ } }, "engines": { - "pnpm": "^9.0.0", - "node": ">= 20" + "pnpm": ">=9", + "node": ">=20" }, "keywords": ["postgres", "firebase", "storage", "functions", "database", "auth"], - "packageManager": "pnpm@9.10.0" + "packageManager": "pnpm@9.15.5" } From 3dd5bd8b2502a22f348796f03cc151bb70e29858 Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Thu, 6 Feb 2025 15:10:35 +0800 Subject: [PATCH 05/57] Chore/mobile sheet app state (#33361) * init * Update app-state.ts * Update ProjectLayout.tsx --- .../layouts/AccountLayout/WithSidebar.tsx | 20 ++++++------------- .../LayoutHeader/LayoutHeader.tsx | 6 +++--- .../NavigationBar/MobileViewNav.tsx | 8 +++++--- .../layouts/ProjectLayout/ProjectLayout.tsx | 17 +++++----------- apps/studio/state/app-state.ts | 7 +++++++ 5 files changed, 26 insertions(+), 32 deletions(-) diff --git a/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx b/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx index 4e0c983b360..16ef1db313f 100644 --- a/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx +++ b/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx @@ -2,11 +2,11 @@ import { isUndefined } from 'lodash' import { ArrowUpRight, LogOut } from 'lucide-react' import Link from 'next/link' import { PropsWithChildren, ReactNode, useState } from 'react' - -import { Badge, cn, Menu, Sheet, SheetContent } from 'ui' +import { Badge, cn, Menu } from 'ui' +import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav' import { LayoutHeader } from '../ProjectLayout/LayoutHeader' import type { SidebarLink, SidebarSection } from './AccountLayout.types' -import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav' +import { useAppStateSnapshot } from 'state/app-state' interface WithSidebarProps { title: string @@ -31,11 +31,7 @@ const WithSidebar = ({ customSidebarContent, }: PropsWithChildren) => { const noContent = !sections && !customSidebarContent - const [isSheetOpen, setIsSheetOpen] = useState(false) - - const handleMobileMenu = () => { - setIsSheetOpen(true) - } + const { mobileMenuOpen, setMobileMenuOpen } = useAppStateSnapshot() return (
@@ -51,14 +47,10 @@ const WithSidebar = ({ /> )}
- +
{children}
- + ( @@ -44,7 +45,6 @@ interface LayoutHeaderProps { headerBorder?: boolean showProductMenu?: boolean customSidebarContent?: ReactNode - handleMobileMenu: Function } const LayoutHeader = ({ @@ -52,11 +52,11 @@ const LayoutHeader = ({ breadcrumbs = [], headerBorder = true, showProductMenu, - handleMobileMenu, }: LayoutHeaderProps) => { const { ref: projectRef } = useParams() const selectedProject = useSelectedProject() const selectedOrganization = useSelectedOrganization() + const { mobileMenuOpen, setMobileMenuOpen } = useAppStateSnapshot() const isBranchingEnabled = selectedProject?.is_branch_enabled === true const { data: subscription } = useOrgSubscriptionQuery({ @@ -91,7 +91,7 @@ const LayoutHeader = ({ className={cn( 'group/view-toggle ml-4 flex justify-center flex-col border-none space-x-0 items-start gap-1 !bg-transparent rounded-md min-w-[30px] w-[30px] h-[30px]' )} - onClick={() => handleMobileMenu()} + onClick={() => setMobileMenuOpen(!mobileMenuOpen)} >
diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileViewNav.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileViewNav.tsx index 8cf6394923e..b1f0a90e730 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileViewNav.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileViewNav.tsx @@ -1,4 +1,5 @@ import { PropsWithChildren } from 'react' +import { useAppStateSnapshot } from 'state/app-state' import { cn } from 'ui' export const ICON_SIZE = 20 @@ -6,10 +7,11 @@ export const ICON_STROKE_WIDTH = 1.5 interface Props { title?: string - handleMobileMenu: Function } -const MobileViewNav = ({ title, handleMobileMenu }: PropsWithChildren) => { +const MobileViewNav = ({ title }: PropsWithChildren) => { + const { mobileMenuOpen, setMobileMenuOpen } = useAppStateSnapshot() + return (