diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b7074fd4b1a..40167c5036e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,6 +3,9 @@ /apps/studio/ @supabase/Dashboard +# temporary until we've ironed out all the problems with sync workflow +/apps/docs/content/troubleshooting/ @charislam + /apps/www/ @supabase/marketing /apps/www/public/images/blog @supabase/marketing /apps/www/lib/redirects.js diff --git a/.github/workflows/docs-sync-troubleshooting.yml b/.github/workflows/docs-sync-troubleshooting.yml new file mode 100644 index 00000000000..0b2d3d04e86 --- /dev/null +++ b/.github/workflows/docs-sync-troubleshooting.yml @@ -0,0 +1,51 @@ +name: '[Docs] Sync troubleshooting guides to GitHub Discussions' + +on: + push: + branches: + - master + paths: + - 'apps/docs/content/troubleshooting/**' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-troubleshooting: + runs-on: ubuntu-latest + + env: + SEARCH_GITHUB_APP_ID: ${{ secrets.SEARCH_GITHUB_APP_ID }} + SEARCH_GITHUB_APP_INSTALLATION_ID: ${{ secrets.SEARCH_GITHUB_APP_INSTALLATION_ID }} + SEARCH_GITHUB_APP_PRIVATE_KEY: ${{ secrets.SEARCH_GITHUB_APP_PRIVATE_KEY }} + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.SEARCH_SUPABASE_URL }} + SUPABASE_SECRET_KEY: ${{ secrets.SEARCH_SUPABASE_SERVICE_ROLE_KEY }} + + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + apps/docs + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run Troubleshooting script + run: npm --prefix=apps/docs run troubleshooting:sync + + - name: Create Pull Request + uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f # v7.0.5 + with: + commit-message: '[bot] sync troubleshooting guides to db' + title: '[bot] sync troubleshooting guides to db' + author: 'github-docs-sync-bot ' + branch: 'bot/docs-sync-troubleshooting' + branch-suffix: 'random' diff --git a/DEVELOPERS.md b/DEVELOPERS.md index 6aab339f865..0c39efba502 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -39,6 +39,7 @@ You will need to install and configure the following dependencies on your machin - [Git](http://git-scm.com/) - [Node.js v20.x (LTS)](http://nodejs.org) - [npm](https://www.npmjs.com/) version 10.x.x or higher +- [make](https://www.gnu.org/software/make/) or the equivalent to `build-essentials` for your OS - [Docker](https://docs.docker.com/get-docker/) (to run studio locally) ## Local development diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index 57cf860d20b..ba7caca35fa 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -654,6 +654,17 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "dialog-centered-off": { + name: "dialog-centered-off", + type: "components:example", + registryDependencies: ["dialog","button"], + component: React.lazy(() => import("@/registry/default/example/dialog-centered-off")), + source: "", + files: ["registry/default/example/dialog-centered-off.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "drawer-demo": { name: "drawer-demo", type: "components:example", @@ -1787,6 +1798,72 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "inner-side-menu-demo": { + name: "inner-side-menu-demo", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/inner-side-menu-demo")), + source: "", + files: ["registry/default/example/inner-side-menu-demo.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "inner-side-menu-static-titles": { + name: "inner-side-menu-static-titles", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/inner-side-menu-static-titles")), + source: "", + files: ["registry/default/example/inner-side-menu-static-titles.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "inner-side-menu-loading": { + name: "inner-side-menu-loading", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/inner-side-menu-loading")), + source: "", + files: ["registry/default/example/inner-side-menu-loading.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "inner-side-menu-multiple-sections": { + name: "inner-side-menu-multiple-sections", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/inner-side-menu-multiple-sections")), + source: "", + files: ["registry/default/example/inner-side-menu-multiple-sections.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "inner-side-menu-empty": { + name: "inner-side-menu-empty", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/inner-side-menu-empty")), + source: "", + files: ["registry/default/example/inner-side-menu-empty.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "inner-side-menu-with-search": { + name: "inner-side-menu-with-search", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/inner-side-menu-with-search")), + source: "", + files: ["registry/default/example/inner-side-menu-with-search.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "multi-select-demo": { name: "multi-select-demo", type: "components:example", @@ -1798,13 +1875,68 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, - "multi-select-form": { - name: "multi-select-form", + "multi-select-disabled": { + name: "multi-select-disabled", type: "components:example", registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/multi-select-form")), + component: React.lazy(() => import("@/registry/default/example/multi-select-disabled")), source: "", - files: ["registry/default/example/multi-select-form.tsx"], + files: ["registry/default/example/multi-select-disabled.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "multi-select-badge-limit-wrap": { + name: "multi-select-badge-limit-wrap", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/multi-select-badge-limit-wrap")), + source: "", + files: ["registry/default/example/multi-select-badge-limit-wrap.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "multi-select-badge-limit": { + name: "multi-select-badge-limit", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/multi-select-badge-limit")), + source: "", + files: ["registry/default/example/multi-select-badge-limit.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "multi-select-combobox": { + name: "multi-select-combobox", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/multi-select-combobox")), + source: "", + files: ["registry/default/example/multi-select-combobox.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "multi-select-deletable-badge": { + name: "multi-select-deletable-badge", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/multi-select-deletable-badge")), + source: "", + files: ["registry/default/example/multi-select-deletable-badge.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "multi-select-inline-search-input": { + name: "multi-select-inline-search-input", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/multi-select-inline-search-input")), + source: "", + files: ["registry/default/example/multi-select-inline-search-input.tsx"], category: "undefined", subcategory: "undefined", chunks: [] diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index ad2619adf12..fa2e91a1c20 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -102,6 +102,11 @@ export const docsConfig: DocsConfig = { href: '/docs/fragments/info-tooltip', items: [], }, + { + title: 'Inner Side Menu', + href: '/docs/fragments/inner-side-menu', + items: [], + }, { title: 'Form Item Layout', href: '/docs/fragments/form-item-layout', diff --git a/apps/design-system/content/docs/changelog.mdx b/apps/design-system/content/docs/changelog.mdx index cbee68f1521..1ae4506b73a 100644 --- a/apps/design-system/content/docs/changelog.mdx +++ b/apps/design-system/content/docs/changelog.mdx @@ -4,6 +4,14 @@ description: Latest updates and announcements. toc: false --- +## 18th Oct 2024 - Update multi-select + +Improved Multi Select with combobox, badge limit, and more examples. + +- [Multi Select](/design-system/components/multi-select) + +[PR](https://github.com/supabase/supabase/pull/29695) + ## 12th June 2024 - Button styles updated - [` + } +/> +``` + +You can also use the prop `illustration` to pass in a custom illustration, it accets any React Node. + +```jsx {4-7} + + ../ + + } +/> +``` + + diff --git a/apps/design-system/content/docs/fragments/multi-select.mdx b/apps/design-system/content/docs/fragments/multi-select.mdx index d1566b198ac..8425bb4a988 100644 --- a/apps/design-system/content/docs/fragments/multi-select.mdx +++ b/apps/design-system/content/docs/fragments/multi-select.mdx @@ -12,31 +12,62 @@ fragment: true ```tsx import { MultiSelector, - MultiSelectorContent, - MultiSelectorInput, - MultiSelectorItem, - MultiSelectorList, MultiSelectorTrigger, + MultiSelectorContent, + MultiSelectorItem, } from 'ui-patterns/multi-select' ``` ```tsx - - - - - - - Item 1 - Item 2 - Item 3 - + + + + Apple + Banana + Grape + Strawberry ``` ## Examples -### Form +### Disabled - + + +### Badge Combo Box + +Use `MultiSelectorInput`to add a search input. + + + +### Badge Limit + +badgeLimit: `number` | `"wrap"`. +`badgeLimit` prop on the `MultiSelectorTrigger` component can be used to limit the number of badges displayed. + + + +### Badge Limit="wrap" + +`badgeLimit` prop can also be "wrap" to wrap the badges to the next line. + + + +### Deletable Badge + +Add the `deletableBadge` prop on the `MultiSelectorTrigger` component to enable badge removal directly from the badge. + + + +### Inline search input + +Use `mode="inline-combobox"` on the `MultiSelectorTrigger` component to add an inline search input. + + diff --git a/apps/design-system/package.json b/apps/design-system/package.json index 156079b3a62..01cac2a43e7 100644 --- a/apps/design-system/package.json +++ b/apps/design-system/package.json @@ -10,6 +10,7 @@ "start": "next start", "lint": "next lint", "content:build": "contentlayer2 build", + "clean": "rimraf node_modules .next .turbo", "typecheck": "contentlayer2 build && tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/apps/design-system/registry/default/example/dialog-centered-off.tsx b/apps/design-system/registry/default/example/dialog-centered-off.tsx new file mode 100644 index 00000000000..54713321a25 --- /dev/null +++ b/apps/design-system/registry/default/example/dialog-centered-off.tsx @@ -0,0 +1,46 @@ +import { Button, DialogSection, DialogSectionSeparator } from 'ui' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from 'ui' +import { Input_Shadcn_ } from 'ui' +import { Label_Shadcn_ } from 'ui' + +export default function DialogDemo() { + return ( + + + + + + + This dialog is not centered. + This dialog is not centered. + + + +
+ + Name + + +
+
+ + Username + + +
+
+ + + +
+
+ ) +} diff --git a/apps/design-system/registry/default/example/inner-side-menu-demo.tsx b/apps/design-system/registry/default/example/inner-side-menu-demo.tsx new file mode 100644 index 00000000000..f8a12c28e29 --- /dev/null +++ b/apps/design-system/registry/default/example/inner-side-menu-demo.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { Card } from 'ui' +import { + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleTrigger, + InnerSideMenuCollapsibleContent, + InnerSideMenuItem, + InnerSideMenuSeparator, +} from 'ui-patterns/InnerSideMenu' + +export default function InnerSideMenuDemo() { + return ( + + + Hello + + Dashboard + Team + Settings + + + + + Hello + + + Dashboard + + Team + Settings + + + + Hello + + + Dashboard + + Team + Settings + + + + ) +} diff --git a/apps/design-system/registry/default/example/inner-side-menu-empty.tsx b/apps/design-system/registry/default/example/inner-side-menu-empty.tsx new file mode 100644 index 00000000000..b9abf8bf2d1 --- /dev/null +++ b/apps/design-system/registry/default/example/inner-side-menu-empty.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleTrigger, + InnerSideMenuCollapsibleContent, + InnerSideBarShimmeringLoaders, + InnerSideBarEmptyPanel, + InnerSideMenuItem, + InnerSideMenuSeparator, +} from 'ui-patterns/InnerSideMenu' +import { Button, Card } from 'ui' +import { Heart, Pointer } from 'lucide-react' + +export default function InnerSideMenuEmpty() { + const [isLoading, setIsLoading] = React.useState(true) + const [hasItems, setHasItems] = React.useState(false) + + React.useEffect(() => { + // Simulate loading + const timer = setTimeout(() => { + setIsLoading(false) + }, 2000) + return () => clearTimeout(timer) + }, []) + + return ( + + + + + 🚀} + actions={ + + } + /> + + + + + + + +
+ +
+ + + } + actions={ + + } + /> +
+
+
+ ) +} diff --git a/apps/design-system/registry/default/example/inner-side-menu-loading.tsx b/apps/design-system/registry/default/example/inner-side-menu-loading.tsx new file mode 100644 index 00000000000..46e096f1c09 --- /dev/null +++ b/apps/design-system/registry/default/example/inner-side-menu-loading.tsx @@ -0,0 +1,32 @@ +import { Card } from 'ui' +import { + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleContent, + InnerSideMenuCollapsibleTrigger, + InnerSideMenuItemLoading, + InnerSideMenuSeparator, +} from 'ui-patterns/InnerSideMenu' + +export default function InnerSideMenuLoading() { + return ( + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/design-system/registry/default/example/inner-side-menu-multiple-sections.tsx b/apps/design-system/registry/default/example/inner-side-menu-multiple-sections.tsx new file mode 100644 index 00000000000..c31c62c0b78 --- /dev/null +++ b/apps/design-system/registry/default/example/inner-side-menu-multiple-sections.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleTrigger, + InnerSideMenuCollapsibleContent, + InnerSideMenuItem, + InnerSideMenuSeparator, +} from 'ui-patterns/InnerSideMenu' + +export default function InnerSideMenuMultipleSections() { + return ( +
+ + + + + API + + Database + + + + + + + Reports + Usage + + +
+ ) +} diff --git a/apps/design-system/registry/default/example/inner-side-menu-static-titles.tsx b/apps/design-system/registry/default/example/inner-side-menu-static-titles.tsx new file mode 100644 index 00000000000..12df6a102e2 --- /dev/null +++ b/apps/design-system/registry/default/example/inner-side-menu-static-titles.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { Card } from 'ui' +import { + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleTrigger, + InnerSideMenuCollapsibleContent, + InnerSideMenuItem, + InnerSideMenuSeparator, + InnerSideBarTitle, +} from 'ui-patterns/InnerSideMenu' + +export default function InnerSideMenuBasic() { + return ( + +
+ Projects + Dashboard + Team + Settings +
+ +
+ Projects + + Dashboard + + Team + Settings +
+
+ Projects + + Dashboard + + Team + Settings +
+
+ ) +} diff --git a/apps/design-system/registry/default/example/inner-side-menu-with-search.tsx b/apps/design-system/registry/default/example/inner-side-menu-with-search.tsx new file mode 100644 index 00000000000..1fdbac66c4f --- /dev/null +++ b/apps/design-system/registry/default/example/inner-side-menu-with-search.tsx @@ -0,0 +1,92 @@ +import React, { useState, useMemo } from 'react' +import { Card } from 'ui' +import { + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleTrigger, + InnerSideMenuCollapsibleContent, + InnerSideMenuItem, + InnerSideBarFilters, + InnerSideBarFilterSearchInput, + InnerSideBarFilterSortDropdown, + InnerSideBarFilterSortDropdownItem, + InnerSideBarEmptyPanel, +} from 'ui-patterns/InnerSideMenu' + +export default function InnerSideMenuWithSearch() { + const [sort, setSort] = useState('alphabetical') + const [searchTerm, setSearchTerm] = useState('') + + const foodCategories = { + Fruits: ['Apple', 'Banana', 'Orange', 'Strawberry'], + Vegetables: ['Carrot', 'Broccoli', 'Spinach', 'Tomato'], + Grains: ['Rice', 'Wheat', 'Oats', 'Quinoa'], + Proteins: ['Chicken', 'Beef', 'Fish', 'Tofu'], + } + + const filteredAndSortedCategories = useMemo(() => { + return Object.entries(foodCategories).reduce( + (acc, [category, items]) => { + let filteredItems = items.filter((item) => + item.toLowerCase().includes(searchTerm.toLowerCase()) + ) + + // Sort the filtered items + filteredItems.sort((a, b) => { + if (sort === 'alphabetical') { + return a.localeCompare(b) + } else { + return b.localeCompare(a) + } + }) + + acc[category] = filteredItems + return acc + }, + {} as Record + ) + }, [foodCategories, searchTerm, sort]) + + return ( + + + setSearchTerm(e.target.value)} + aria-labelledby="Search items" + > + setSort(value)}> + + Sort Alphabetically + + + Sort Reverse Alphabetically + + + + + + {Object.entries(filteredAndSortedCategories).map(([category, items]) => ( + + + + {items.length > 0 ? ( + items.map((item) => ( + + {item} + + )) + ) : ( + + )} + + + ))} + + ) +} diff --git a/apps/design-system/registry/default/example/multi-select-badge-limit-wrap.tsx b/apps/design-system/registry/default/example/multi-select-badge-limit-wrap.tsx new file mode 100644 index 00000000000..a16ac5b47d4 --- /dev/null +++ b/apps/design-system/registry/default/example/multi-select-badge-limit-wrap.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' + +export default function MultiSelectDemo() { + const [selectedValues, setSelectedValues] = useState([ + 'Apple', + 'Banana', + 'Cherry', + 'Date', + 'Elderberrie', + ]) + + return ( + + + + + Apple + Banana + Cherry + Date + Elderberrie + Fig + Grape + Kiwi + Mango + Strawberry + + + + ) +} diff --git a/apps/design-system/registry/default/example/multi-select-badge-limit.tsx b/apps/design-system/registry/default/example/multi-select-badge-limit.tsx new file mode 100644 index 00000000000..7081eb253dc --- /dev/null +++ b/apps/design-system/registry/default/example/multi-select-badge-limit.tsx @@ -0,0 +1,48 @@ +import { Minus, Plus } from 'lucide-react' +import { useState } from 'react' +import { Button } from 'ui' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' + +export default function MultiSelectDemo() { + const [selectedValues, setSelectedValues] = useState(['Apple', 'Banana', 'Cherry']) + const [limit, setLimit] = useState(2) + + return ( +
+
+ + + Limit: {limit} + + +
+ + + + + Apple + Banana + Cherry + Date + Elderberrie + Fig + Grape + Kiwi + Mango + Strawberry + + + +
+ ) +} diff --git a/apps/design-system/registry/default/example/multi-select-combobox.tsx b/apps/design-system/registry/default/example/multi-select-combobox.tsx new file mode 100644 index 00000000000..5a79a6057b1 --- /dev/null +++ b/apps/design-system/registry/default/example/multi-select-combobox.tsx @@ -0,0 +1,34 @@ +import { useState } from 'react' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorInput, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' + +export default function MultiSelectDemo() { + const [selectedValues, setSelectedValues] = useState([]) + + return ( + + + + + + Apple + Banana + Cherry + Date + Elderberrie + Fig + Grape + Kiwi + Mango + Strawberry + + + + ) +} diff --git a/apps/design-system/registry/default/example/multi-select-deletable-badge.tsx b/apps/design-system/registry/default/example/multi-select-deletable-badge.tsx new file mode 100644 index 00000000000..daf693d3edf --- /dev/null +++ b/apps/design-system/registry/default/example/multi-select-deletable-badge.tsx @@ -0,0 +1,37 @@ +import { useState } from 'react' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' + +export default function MultiSelectDemo() { + const [selectedValues, setSelectedValues] = useState(['Apple', 'Banana']) + + return ( + + + + + Banana + Cherry + Date + Elderberrie + Fig + Grape + Kiwi + Mango + Strawberry + + + + ) +} diff --git a/apps/design-system/registry/default/example/multi-select-demo.tsx b/apps/design-system/registry/default/example/multi-select-demo.tsx index faa684876f7..fafed2a1c4c 100644 --- a/apps/design-system/registry/default/example/multi-select-demo.tsx +++ b/apps/design-system/registry/default/example/multi-select-demo.tsx @@ -2,25 +2,42 @@ import { useState } from 'react' import { MultiSelector, MultiSelectorContent, - MultiSelectorInput, MultiSelectorItem, MultiSelectorList, MultiSelectorTrigger, } from 'ui-patterns/multi-select' export default function MultiSelectDemo() { - const [value, setValue] = useState([]) + const [selectedValues, setSelectedValues] = useState([]) + + const fruits = [ + { value: 'Apple', isDisabled: false }, + { value: 'Banana', isDisabled: false }, + { value: 'Cherry', isDisabled: false }, + { value: 'Date', isDisabled: false }, + { value: 'Elderberrie', isDisabled: false }, + { value: 'Fig', isDisabled: false }, + { value: 'Grape', isDisabled: false }, + { value: 'Kiwi', isDisabled: true }, + { value: 'Mango', isDisabled: false }, + { value: 'Strawberry', isDisabled: false }, + ] return ( - - - - + + - Item 1 - Item 2 - Item 3 + {fruits.map(({ value, isDisabled }) => ( + + {value} + + ))} diff --git a/apps/design-system/registry/default/example/multi-select-disabled.tsx b/apps/design-system/registry/default/example/multi-select-disabled.tsx new file mode 100644 index 00000000000..16a4ba9aad8 --- /dev/null +++ b/apps/design-system/registry/default/example/multi-select-disabled.tsx @@ -0,0 +1,18 @@ +import { useState } from 'react' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' + +export default function MultiSelectDemo() { + const [selectedValues, setSelectedValues] = useState([]) + + return ( + + + + ) +} diff --git a/apps/design-system/registry/default/example/multi-select-form.tsx b/apps/design-system/registry/default/example/multi-select-form.tsx deleted file mode 100644 index 83892ee5bab..00000000000 --- a/apps/design-system/registry/default/example/multi-select-form.tsx +++ /dev/null @@ -1,105 +0,0 @@ -'use client' - -import { zodResolver } from '@hookform/resolvers/zod' -import Image from 'next/image' -import { useForm } from 'react-hook-form' -import { toast } from 'sonner' -import z from 'zod' - -import { - Button, - Form_Shadcn_, - FormDescription_Shadcn_, - FormField_Shadcn_, - FormItem_Shadcn_, - FormLabel_Shadcn_, - FormMessage_Shadcn_, -} from 'ui' -import { - MultiSelector, - MultiSelectorContent, - MultiSelectorInput, - MultiSelectorItem, - MultiSelectorList, - MultiSelectorTrigger, -} from 'ui-patterns/multi-select' - -const form = z.object({ - value: z.array(z.string()).nonempty('Please select at least one person'), -}) - -type Form = z.infer - -const users = [ - { - name: 'ThePrimeagen', - picture: 'https://pbs.twimg.com/profile_images/1759330620160049152/2i_wkOoK_400x400.jpg', - }, - { - name: 'Shadcn', - picture: 'https://pbs.twimg.com/profile_images/1593304942210478080/TUYae5z7_400x400.jpg', - }, - { - name: 'Theo', - picture: 'https://pbs.twimg.com/profile_images/1605762947686375425/lsoGWWty_400x400.jpg', - }, -] - -const MultiSelectZod = () => { - const multiForm = useForm
({ - resolver: zodResolver(form), - defaultValues: form.parse({ value: [users[0].name] }), - }) - - const onSubmit = (data: Form) => { - toast('Form submitted ', { description: JSON.stringify(data, null, 2) }) - } - - return ( - - - ( - - Invite people - - - - - - - {users.map((user) => ( - -
- {user.name} - {user.name} -
-
- ))} -
-
-
- - Select people to invite to this event - - -
- )} - /> - - -
- ) -} - -export default MultiSelectZod diff --git a/apps/design-system/registry/default/example/multi-select-inline-search-input.tsx b/apps/design-system/registry/default/example/multi-select-inline-search-input.tsx new file mode 100644 index 00000000000..775762a39f5 --- /dev/null +++ b/apps/design-system/registry/default/example/multi-select-inline-search-input.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' + +export default function MultiSelectDemo() { + const [selectedValues, setSelectedValues] = useState(['Apple']) + + return ( + + + + + Apple + Banana + Cherry + Date + Elderberrie + Fig + Grape + Kiwi + Mango + Strawberry + + + + ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index a7e7a595bb7..eab719afe0e 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -431,6 +431,12 @@ export const examples: Registry = [ registryDependencies: ['dialog', 'button'], files: ['example/dialog-close-button.tsx'], }, + { + name: 'dialog-centered-off', + type: 'components:example', + registryDependencies: ['dialog', 'button'], + files: ['example/dialog-centered-off.tsx'], + }, { name: 'drawer-demo', type: 'components:example', @@ -1030,15 +1036,70 @@ export const examples: Registry = [ type: 'components:example', files: ['example/info-tooltip-demo.tsx'], }, + { + name: 'inner-side-menu-demo', + type: 'components:example', + files: ['example/inner-side-menu-demo.tsx'], + }, + { + name: 'inner-side-menu-static-titles', + type: 'components:example', + files: ['example/inner-side-menu-static-titles.tsx'], + }, + { + name: 'inner-side-menu-loading', + type: 'components:example', + files: ['example/inner-side-menu-loading.tsx'], + }, + { + name: 'inner-side-menu-multiple-sections', + type: 'components:example', + files: ['example/inner-side-menu-multiple-sections.tsx'], + }, + { + name: 'inner-side-menu-empty', + type: 'components:example', + files: ['example/inner-side-menu-empty.tsx'], + }, + { + name: 'inner-side-menu-with-search', + type: 'components:example', + files: ['example/inner-side-menu-with-search.tsx'], + }, { name: 'multi-select-demo', type: 'components:example', files: ['example/multi-select-demo.tsx'], }, { - name: 'multi-select-form', + name: 'multi-select-disabled', type: 'components:example', - files: ['example/multi-select-form.tsx'], + files: ['example/multi-select-disabled.tsx'], + }, + { + name: 'multi-select-badge-limit-wrap', + type: 'components:example', + files: ['example/multi-select-badge-limit-wrap.tsx'], + }, + { + name: 'multi-select-badge-limit', + type: 'components:example', + files: ['example/multi-select-badge-limit.tsx'], + }, + { + name: 'multi-select-combobox', + type: 'components:example', + files: ['example/multi-select-combobox.tsx'], + }, + { + name: 'multi-select-deletable-badge', + type: 'components:example', + files: ['example/multi-select-deletable-badge.tsx'], + }, + { + name: 'multi-select-inline-search-input', + type: 'components:example', + files: ['example/multi-select-inline-search-input.tsx'], }, { name: 'tree-view-demo', diff --git a/apps/docs/.env.development b/apps/docs/.env.development new file mode 100644 index 00000000000..7c41f0565e4 --- /dev/null +++ b/apps/docs/.env.development @@ -0,0 +1,6 @@ +# IMPORTANT: This file is checked into version control. +# Only publicly available environment variables should be listed here. +# Do not add secrets here. Use .env.local instead. + +NEXT_PUBLIC_MISC_URL="https://obuldanrptloktxcffvn.supabase.co" +NEXT_PUBLIC_MISC_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTg2MTQ2ODUsImV4cCI6MjAzNDE5MDY4NX0.NFt49g6DFkc1X5khCzN5p01iAVo2TMxlx88cY1V0E2M" diff --git a/apps/docs/DEVELOPERS.md b/apps/docs/DEVELOPERS.md index ed8fa91080d..2d9f7cb3684 100644 --- a/apps/docs/DEVELOPERS.md +++ b/apps/docs/DEVELOPERS.md @@ -14,7 +14,7 @@ For a complete run-down on how all of our tools work together, see the main DEVE ## Local setup -[supabase.com/docs](https://supabase.com/docs) is a Next.JS site. You can get setup by following the same steps for all of our other Next.JS projects: +[supabase.com/docs](https://supabase.com/docs) is a Next.js site. You can get setup by following the same steps for all of our other Next.js projects: 1. Follow the steps outlined in the Local Development section of the main [DEVELOPERS.md](https://github.com/supabase/supabase/blob/master/DEVELOPERS.md) 2. If you work at Supabase, run `dev:secrets:pull` to pull down the internal environment variables. If you're a community member, create a `.env` file and add this line to it: `NEXT_PUBLIC_IS_PLATFORM=false` diff --git a/apps/docs/README.md b/apps/docs/README.md index c8a0cd91609..7803882d6c7 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -31,7 +31,7 @@ We use documentation specifications which can be used to generate human-readable - CLISpec (custom to Supabase): for CLI commands and usage. The benefit of using custom specifications is that we can generate many other types from a strict schema (eg, HTML and manpages). -It also means that we can switch to any documentation system we want. On this site we use Next.JS, but on Supabase's official website, we use a custom React site and expose only a subset of the available API for each tool. +It also means that we can switch to any documentation system we want. On this site we use Next.js, but on Supabase's official website, we use a custom React site and expose only a subset of the available API for each tool. ## Contributing diff --git a/apps/docs/app/guides/(with-sidebar)/ai/python/[slug]/page.tsx b/apps/docs/app/guides/(with-sidebar)/ai/python/[slug]/page.tsx index 578df9c3119..d6cc049c849 100644 --- a/apps/docs/app/guides/(with-sidebar)/ai/python/[slug]/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/ai/python/[slug]/page.tsx @@ -1,9 +1,9 @@ -import { type SerializeOptions } from 'next-mdx-remote/dist/types' +import type { SerializeOptions } from 'next-mdx-remote/dist/types' import { notFound } from 'next/navigation' import { relative } from 'path' import rehypeSlug from 'rehype-slug' -import { genGuideMeta } from '~/features/docs/GuidesMdx.utils' +import { genGuideMeta, removeRedundantH1 } from '~/features/docs/GuidesMdx.utils' import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' import { fetchRevalidatePerDay } from '~/features/helpers.fetch' import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform' @@ -84,7 +84,8 @@ const getContent = async ({ slug }: Params) => { `https://raw.githubusercontent.com/${org}/${repo}/${branch}/${docsDir}/${remoteFile}` ) - const content = await response.text() + let content = await response.text() + content = removeRedundantH1(content) return { pathname: `/guides/ai/python/${slug}` satisfies `/${string}`, diff --git a/apps/docs/app/guides/(with-sidebar)/layout.tsx b/apps/docs/app/guides/(with-sidebar)/layout.tsx index b67a47c2fda..452ee294b26 100644 --- a/apps/docs/app/guides/(with-sidebar)/layout.tsx +++ b/apps/docs/app/guides/(with-sidebar)/layout.tsx @@ -1,10 +1,12 @@ import { type PropsWithChildren } from 'react' +import { IS_PLATFORM } from 'common' + import { supabaseMisc } from '~/lib/supabaseMisc' import Layout from '~/layouts/guides' const GuidesLayout = async ({ children }: PropsWithChildren) => { - const partners = await getPartners() + const partners = IS_PLATFORM ? await getPartners() : [] const partnerNavItems = partners.map((partner) => ({ name: partner.title, url: `https://supabase.com/partners/integrations/${partner.slug}` as `https://${string}`, diff --git a/apps/docs/app/guides/(with-sidebar)/self-hosting/analytics/config/page.tsx b/apps/docs/app/guides/(with-sidebar)/self-hosting/analytics/config/page.tsx index d397c0003f0..03a88cc171f 100644 --- a/apps/docs/app/guides/(with-sidebar)/self-hosting/analytics/config/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/self-hosting/analytics/config/page.tsx @@ -21,7 +21,7 @@ const AnalyticsConfigPage = async () => { diff --git a/apps/docs/app/guides/(with-sidebar)/self-hosting/auth/config/page.tsx b/apps/docs/app/guides/(with-sidebar)/self-hosting/auth/config/page.tsx index d8a33d04694..9d6ff028b51 100644 --- a/apps/docs/app/guides/(with-sidebar)/self-hosting/auth/config/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/self-hosting/auth/config/page.tsx @@ -21,7 +21,7 @@ const AuthConfigPage = async () => { diff --git a/apps/docs/app/guides/(with-sidebar)/self-hosting/realtime/config/page.tsx b/apps/docs/app/guides/(with-sidebar)/self-hosting/realtime/config/page.tsx index 01180ba859c..d28ca1077a7 100644 --- a/apps/docs/app/guides/(with-sidebar)/self-hosting/realtime/config/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/self-hosting/realtime/config/page.tsx @@ -21,7 +21,7 @@ const RealtimeConfigPage = async () => { diff --git a/apps/docs/app/guides/(with-sidebar)/self-hosting/storage/config/page.tsx b/apps/docs/app/guides/(with-sidebar)/self-hosting/storage/config/page.tsx index 766bc4d190a..1201543f943 100644 --- a/apps/docs/app/guides/(with-sidebar)/self-hosting/storage/config/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/self-hosting/storage/config/page.tsx @@ -21,7 +21,7 @@ const StorageConfigPage = async () => { diff --git a/apps/docs/app/guides/troubleshooting/[slug]/page.tsx b/apps/docs/app/guides/troubleshooting/[slug]/page.tsx index 428a2e84adc..01d40abeef7 100644 --- a/apps/docs/app/guides/troubleshooting/[slug]/page.tsx +++ b/apps/docs/app/guides/troubleshooting/[slug]/page.tsx @@ -4,6 +4,10 @@ import TroubleshootingPage from '~/features/docs/Troubleshooting.page' import { getAllTroubleshootingEntries, getArticleSlug } from '~/features/docs/Troubleshooting.utils' import { PROD_URL } from '~/lib/constants' +// 60 seconds/minute * 60 minutes/hour * 24 hours/day +export const revalidate = 86_400 +export const dynamicParams = false + export default async function TroubleshootingEntryPage({ params: { slug }, }: { diff --git a/apps/docs/app/guides/troubleshooting/page.tsx b/apps/docs/app/guides/troubleshooting/page.tsx index e5e96fe66df..ac0be072bfe 100644 --- a/apps/docs/app/guides/troubleshooting/page.tsx +++ b/apps/docs/app/guides/troubleshooting/page.tsx @@ -16,6 +16,9 @@ import { TROUBLESHOOTING_CONTAINER_ID } from '~/features/docs/Troubleshooting.ut import { SidebarSkeleton } from '~/layouts/MainSkeleton' import { PROD_URL } from '~/lib/constants' +// 60 seconds/minute * 60 minutes/hour * 24 hours/day +export const revalidate = 86_400 + export default async function GlobalTroubleshootingPage() { const troubleshootingEntries = await getAllTroubleshootingEntries() const keywords = await getAllTroubleshootingKeywords() @@ -23,7 +26,7 @@ export default async function GlobalTroubleshootingPage() { const errors = await getAllTroubleshootingErrors() return ( - +

Troubleshooting

diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx index ad7a21f787b..b01885c6b6d 100644 --- a/apps/docs/app/page.tsx +++ b/apps/docs/app/page.tsx @@ -4,6 +4,7 @@ import { IconBackground, TextLink } from 'ui' import { IconPanel } from 'ui-patterns/IconPanel' import MenuIconPicker from '~/components/Navigation/NavigationMenu/MenuIconPicker' +import { MIGRATION_PAGES } from '~/components/Navigation/NavigationMenu/NavigationMenu.constants' import { GlassPanelWithIconPicker } from '~/features/ui/GlassPanelWithIconPicker' import { IconPanelWithIconPicker } from '~/features/ui/IconPanelWithIconPicker' import HomeLayout from '~/layouts/HomeLayout' @@ -70,60 +71,6 @@ const products = [ }, ] -const migrationGuides = [ - { - title: 'Auth0', - icon: '/docs/img/icons/auth0-icon', - href: '/guides/resources/migrating-to-supabase/auth0', - hasLightIcon: true, - }, - { - title: 'Firebase Auth', - icon: '/docs/img/icons/firebase-icon', - href: '/guides/resources/migrating-to-supabase/firebase-auth', - }, - { - title: 'Firestore Data', - icon: '/docs/img/icons/firebase-icon', - href: '/guides/resources/migrating-to-supabase/firestore-data', - }, - { - title: 'Firebase Storage', - icon: '/docs/img/icons/firebase-icon', - href: '/guides/resources/migrating-to-supabase/firebase-storage', - }, - { - title: 'Heroku', - icon: '/docs/img/icons/heroku-icon', - href: '/guides/resources/migrating-to-supabase/heroku', - }, - { - title: 'Render', - icon: '/docs/img/icons/render-icon', - href: '/guides/resources/migrating-to-supabase/render', - }, - { - title: 'Amazon RDS', - icon: '/docs/img/icons/aws-rds-icon', - href: '/guides/resources/migrating-to-supabase/amazon-rds', - }, - { - title: 'Postgres', - icon: '/docs/img/icons/postgres-icon', - href: '/guides/resources/migrating-to-supabase/postgres', - }, - { - title: 'MySQL', - icon: '/docs/img/icons/mysql-icon', - href: '/guides/resources/migrating-to-supabase/mysql', - }, - { - title: 'MSSQL', - icon: '/docs/img/icons/mssql-icon', - href: '/guides/resources/migrating-to-supabase/mssql', - }, -] - const selfHostingOptions = [ { title: 'Auth', @@ -268,11 +215,11 @@ const HomePage = () => (

    - {migrationGuides.map((guide) => { + {MIGRATION_PAGES.map((guide) => { return ( -
  • - - +
  • + +
  • ) diff --git a/apps/docs/app/reference/[...slug]/page.tsx b/apps/docs/app/reference/[...slug]/page.tsx index 5bc1ec9fa27..f1e0798591f 100644 --- a/apps/docs/app/reference/[...slug]/page.tsx +++ b/apps/docs/app/reference/[...slug]/page.tsx @@ -17,6 +17,7 @@ export default async function ReferencePage({ }: { params: { slug: Array } }) { + console.log("Generating reference page for '%o'", slug) if (!Object.keys(REFERENCES).includes(slug[0].replaceAll('-', '_'))) { notFound() } @@ -38,6 +39,7 @@ export default async function ReferencePage({ return } else if (isCliReference) { + console.log('Returning CLI reference page: %o', parsedPath) return } else if (isApiReference) { return diff --git a/apps/docs/components/JwtGenerator/JwtGenerator.tsx b/apps/docs/components/JwtGenerator/JwtGenerator.tsx index fceaab1c44d..189c466f0dd 100644 --- a/apps/docs/components/JwtGenerator/JwtGenerator.tsx +++ b/apps/docs/components/JwtGenerator/JwtGenerator.tsx @@ -75,7 +75,7 @@ export default function JwtGenerator({}) { } return ( -
    +
    - + -
    +
    {customHeader ? ( <>{customHeader} ) : ( @@ -113,7 +113,7 @@ const DefaultHeader = ({ table, onAddColumn, onAddRow, onImportData }: DefaultHe
    {canAddNew && ( <> -
    +
    {canCreateColumns && ( @@ -195,7 +195,7 @@ const DefaultHeader = ({ table, onAddColumn, onAddRow, onImportData }: DefaultHe className="-translate-x-[2px]" /> { connectionString: project?.connectionString, table, filters, + enforceExactCount: snap.enforceExactCount, impersonatedRole: roleImpersonationState.role, }, { keepPreviousData: true } @@ -375,82 +376,61 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => { }) return ( -
    -
    - + + + + Export to CSV + + Export to SQL + + + + {!allRowsSelected && totalRows > allRows.length && ( + <> +
    + +
    + - )} -
    -
    -
    - - - - - - - Export to CSV - - Export to SQL - - - - {editable && ( - - - - - - {allRowsSelected && isImpersonatingRole && ( - - - -
    - - Table truncation is not supported when impersonating a role - -
    -
    -
    - )} -
    - )} -
    + + )}
    ) } diff --git a/apps/studio/components/grid/components/header/RefreshButton.tsx b/apps/studio/components/grid/components/header/RefreshButton.tsx index 9c557e2b43c..0b43a2aa0b2 100644 --- a/apps/studio/components/grid/components/header/RefreshButton.tsx +++ b/apps/studio/components/grid/components/header/RefreshButton.tsx @@ -4,11 +4,11 @@ import { RefreshCw } from 'lucide-react' import { useParams } from 'common' import type { SupaTable } from 'components/grid/types' import { sqlKeys } from 'data/sql/keys' -import type { TableLike } from 'hooks/misc/useTable' +import { Entity } from 'data/table-editor/table-editor-types' import { Button } from 'ui' export type RefreshButtonProps = { - table: TableLike | SupaTable + table: Entity | SupaTable isRefetching?: boolean } diff --git a/apps/studio/components/grid/types/base.ts b/apps/studio/components/grid/types/base.ts index 262d4c81e3c..29e899223c8 100644 --- a/apps/studio/components/grid/types/base.ts +++ b/apps/studio/components/grid/types/base.ts @@ -58,6 +58,7 @@ export interface GridForeignKey { targetTableName?: string | null targetColumnName?: string | null deletionAction?: string + updateAction?: string } export interface ColumnHeaderProps extends RenderHeaderCellProps { diff --git a/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx index 779e48e5c07..81c22e953ea 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx @@ -1,8 +1,8 @@ import { Toggle } from 'ui' import Panel from 'components/ui/Panel' -import { useAppStateSnapshot } from 'state/app-state' import { useSendResetMutation } from 'data/telemetry/send-reset-mutation' +import { useAppStateSnapshot } from 'state/app-state' const AnalyticsSettings = () => { const snap = useAppStateSnapshot() diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx index a3c3f2909a8..c86051bba3f 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx @@ -1,7 +1,7 @@ import { Key } from 'lucide-react' import { useMemo } from 'react' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { copyToClipboard } from 'lib/helpers' import { Badge } from 'ui' @@ -23,14 +23,13 @@ export function useApiKeysCommands() { const setPage = useSetPage() const project = useSelectedProject() - const { data: settings } = useProjectApiQuery( + const ref = project?.ref || '_' + + const { data: settings } = useProjectSettingsV2Query( { projectRef: project?.ref }, { enabled: !!project } ) - const ref = project?.ref || '_' - - const anonKey = settings?.autoApiService?.defaultApiKey ?? undefined - const serviceKey = settings?.autoApiService?.serviceApiKey ?? undefined + const { anonKey, serviceKey } = getAPIKeys(settings) const commands = useMemo( () => @@ -40,7 +39,7 @@ export function useApiKeysCommands() { id: 'anon-key', name: `Copy anonymous API key`, action: () => { - copyToClipboard(anonKey ?? '') + copyToClipboard(anonKey.api_key ?? '') setIsOpen(false) }, badge: () => ( @@ -56,7 +55,7 @@ export function useApiKeysCommands() { id: 'service-key', name: `Copy service API key`, action: () => { - copyToClipboard(serviceKey ?? '') + copyToClipboard(serviceKey.api_key ?? '') setIsOpen(false) }, badge: () => ( diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx index 28b36682981..50a6fb33c3b 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx @@ -1,10 +1,10 @@ import { Link } from 'lucide-react' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { copyToClipboard } from 'lib/helpers' import { Badge } from 'ui' import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { COMMAND_MENU_SECTIONS } from './CommandMenu.utils' import { orderCommandSectionsByPriority } from './ordering' @@ -12,14 +12,14 @@ export function useApiUrlCommand() { const setIsOpen = useSetCommandMenuOpen() const project = useSelectedProject() - const { data: settings } = useProjectApiQuery( + const { data: settings } = useProjectSettingsV2Query( { projectRef: project?.ref }, { enabled: !!project } ) - const apiUrl = settings?.autoApiService.endpoint - ? `${settings.autoApiService.protocol ?? 'https'}://${settings.autoApiService.endpoint}` - : undefined + const protocol = settings?.app_config?.protocol ?? 'https' + const endpoint = settings?.app_config?.endpoint + const apiUrl = endpoint ? `${protocol}://${endpoint}` : undefined useRegisterCommands( COMMAND_MENU_SECTIONS.ACTIONS, diff --git a/apps/studio/components/interfaces/App/FeaturePreview/APISidePanelPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/APISidePanelPreview.tsx index 5e96abfb824..9a4bde83334 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/APISidePanelPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/APISidePanelPreview.tsx @@ -3,7 +3,7 @@ import { Markdown } from 'components/interfaces/Markdown' import { BASE_PATH } from 'lib/constants' import Image from 'next/image' -const APISidePanelPreview = () => { +export const APISidePanelPreview = () => { const { ref } = useParams() return ( @@ -38,5 +38,3 @@ const APISidePanelPreview = () => {
    ) } - -export default APISidePanelPreview diff --git a/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx index 56293f7b9b0..a88c1eddc96 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx @@ -5,7 +5,7 @@ import { Markdown } from 'components/interfaces/Markdown' import { BASE_PATH } from 'lib/constants' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, WarningIcon } from 'ui' -const CLSPreview = () => { +export const CLSPreview = () => { const { ref } = useParams() return ( @@ -53,5 +53,3 @@ const CLSPreview = () => {
    ) } - -export default CLSPreview diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index a77d22b887d..84aa5f6c7fe 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -3,6 +3,33 @@ import { PropsWithChildren, createContext, useContext, useEffect, useState } fro import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { EMPTY_OBJ } from 'lib/void' +import { APISidePanelPreview } from './APISidePanelPreview' +import { CLSPreview } from './CLSPreview' +import { FunctionsAssistantPreview } from './FunctionsAssistantPreview' + +export const FEATURE_PREVIEWS = [ + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_FUNCTIONS_ASSISTANT, + name: 'Database Functions Assistant', + content: , + discussionsUrl: undefined, + isNew: true, + }, + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, + name: 'Project API documentation', + content: , + discussionsUrl: 'https://github.com/orgs/supabase/discussions/18038', + isNew: false, + }, + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS, + name: 'Column-level privileges', + content: , + discussionsUrl: 'https://github.com/orgs/supabase/discussions/20295', + isNew: false, + }, +] type FeaturePreviewContextType = { flags: { [key: string]: boolean } @@ -17,22 +44,19 @@ const FeaturePreviewContext = createContext({ export const useFeaturePreviewContext = () => useContext(FeaturePreviewContext) export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{}>) => { - const [flags, setFlags] = useState({ - [LOCAL_STORAGE_KEYS.UI_PREVIEW_NAVIGATION_LAYOUT]: false, - [LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: false, - [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: false, - }) + const [flags, setFlags] = useState(() => + FEATURE_PREVIEWS.reduce((a, b) => { + return { ...a, [b.key]: false } + }, {}) + ) useEffect(() => { if (typeof window !== 'undefined') { - setFlags({ - [LOCAL_STORAGE_KEYS.UI_PREVIEW_NAVIGATION_LAYOUT]: - localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_NAVIGATION_LAYOUT) === 'true', - [LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: - localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL) === 'true', - [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: - localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS) === 'true', - }) + setFlags( + FEATURE_PREVIEWS.reduce((a, b) => { + return { ...a, [b.key]: localStorage.getItem(b.key) === 'true' } + }, {}) + ) } }, []) @@ -61,3 +85,8 @@ export const useIsColumnLevelPrivilegesEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS] } + +export const useIsDatabaseFunctionsAssistantEnabled = () => { + const { flags } = useFeaturePreviewContext() + return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_FUNCTIONS_ASSISTANT] +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index de6979679e8..01cbee09063 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -3,38 +3,23 @@ import Link from 'next/link' import { useEffect, useState } from 'react' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' -import { Button, Modal, ScrollArea, cn } from 'ui' -import APISidePanelPreview from './APISidePanelPreview' -import CLSPreview from './CLSPreview' -import { useFeaturePreviewContext } from './FeaturePreviewContext' +import { Badge, Button, Modal, ScrollArea, cn } from 'ui' +import { FEATURE_PREVIEWS, useFeaturePreviewContext } from './FeaturePreviewContext' +import { useFlag } from 'hooks/ui/useFlag' const FeaturePreviewModal = () => { - // [Ivan] We should probably move this to a separate file, together with LOCAL_STORAGE_KEYS. We should make adding new feature previews as simple as possible. - const FEATURE_PREVIEWS: { key: string; name: string; content: any; discussionsUrl?: string }[] = [ - { - key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, - name: 'Project API documentation', - content: , - discussionsUrl: 'https://github.com/orgs/supabase/discussions/18038', - }, - { - key: LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS, - name: 'Column-level privileges', - content: , - discussionsUrl: 'https://github.com/orgs/supabase/discussions/20295', - }, - ] - const snap = useAppStateSnapshot() const featurePreviewContext = useFeaturePreviewContext() const { mutate: sendEvent } = useSendEventMutation() + const enableFunctionsAssistant = useFlag('functionsAssistantV2') const selectedFeaturePreview = snap.selectedFeaturePreview === '' ? FEATURE_PREVIEWS[0].key : snap.selectedFeaturePreview const [selectedFeatureKey, setSelectedFeatureKey] = useState(selectedFeaturePreview) + const isNotReleased = + selectedFeatureKey === 'supabase-ui-functions-assistant' && !enableFunctionsAssistant // this modal can be triggered on other pages // Update local state when valtio state changes @@ -103,8 +88,11 @@ const FeaturePreviewModal = () => {
    -

    {selectedFeature?.name}

    -
    +
    +

    {selectedFeature?.name}

    + {selectedFeature?.isNew && New} +
    +
    {selectedFeature?.discussionsUrl !== undefined && ( )} - + {isNotReleased ? ( + + ) : ( + + )}
    {selectedFeature?.content} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx new file mode 100644 index 00000000000..513715133e7 --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx @@ -0,0 +1,55 @@ +import Image from 'next/image' + +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +import { BASE_PATH } from 'lib/constants' +import { detectOS } from 'lib/helpers' + +export const FunctionsAssistantPreview = () => { + const os = detectOS() + const { ref } = useParams() + + return ( +
    +

    + We're providing an additional alternative UX to creating database functions through the + dashboard with the integration of our AI Assistant that you might have seen in the Auth + Policies section. +

    + api-docs-side-panel-preview +

    + This preview also shares an improved Assistant interface where you may provide the Assistant + with contexts in hopes to generate more relevant and higher quality outputs. Contexts that + you may provide include specific schemas, and / or specific tables from your database. +

    +

    + We'd hope to use this as a consistent pattern throughout the dashboard eventually if this + feature preview proves itself to benefit most of our users, so as usual please do feel free + to let us know what you think if the attached GitHub discussion above! +

    +
    +

    Enabling this preview will:

    +
      +
    • + +
    • +
    • + +
    • +
    +
    +
    + ) +} diff --git a/apps/studio/components/interfaces/Auth/Auth.constants.ts b/apps/studio/components/interfaces/Auth/Auth.constants.ts index 1708eb6dce0..a970de63739 100644 --- a/apps/studio/components/interfaces/Auth/Auth.constants.ts +++ b/apps/studio/components/interfaces/Auth/Auth.constants.ts @@ -26,8 +26,18 @@ const customSchemeRegex = /^([a-zA-Z][a-zA-Z0-9+.-]*):(?:\/{1,3})?([a-zA-Z0-9_.- // Exclude simple domain names without protocol const excludeSimpleDomainRegex = /^[a-zA-Z0-9-]+\.[a-zA-Z]{2,}$/ -// combine the above regexes -export const urlRegex = new RegExp( - `(?!${excludeSimpleDomainRegex.source})((${baseUrlRegex.source})|(${localhostRegex.source})|(${appRegex.source})|(${chromeExtensionRegex.source})|(${customSchemeRegex.source}))`, - 'i' -) +// combine the above regexes, with optional exclusion of options +// usage: urlRegex() or urlRegex({ excludeSimpleDomains: false }) +export function urlRegex( + options: { excludeSimpleDomains?: boolean } = { excludeSimpleDomains: true } +): RegExp { + const { excludeSimpleDomains } = options + const excludeSimpleDomainPart = excludeSimpleDomains + ? `(?!${excludeSimpleDomainRegex.source})` + : '' + + return new RegExp( + `${excludeSimpleDomainPart}((${baseUrlRegex.source})|(${localhostRegex.source})|(${appRegex.source})|(${chromeExtensionRegex.source})|(${customSchemeRegex.source}))`, + 'i' + ) +} diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index 69fa28ff76a..1718070d3a5 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -1,18 +1,19 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useParams } from 'common' import { Check, ChevronUp, ExternalLink } from 'lucide-react' +import Link from 'next/link' import { useEffect, useRef, useState } from 'react' import ReactMarkdown from 'react-markdown' import { toast } from 'sonner' +import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { DocsButton } from 'components/ui/DocsButton' import type { components } from 'data/api' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { BASE_PATH } from 'lib/constants' -import Link from 'next/link' import { Alert, Alert_Shadcn_, @@ -109,8 +110,10 @@ const ProviderForm = ({ config, provider }: ProviderFormProps) => { } } - const { data: settings } = useProjectApiQuery({ projectRef }) - const apiUrl = `${settings?.autoApiService.protocol}://${settings?.autoApiService.endpoint}` + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const protocol = settings?.app_config?.protocol ?? 'https' + const endpoint = settings?.app_config?.endpoint + const apiUrl = `${protocol}://${endpoint}` const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) @@ -286,11 +289,7 @@ const ProviderForm = ({ config, provider }: ProviderFormProps) => { )}
    - +
    +
    {isError && ( - - - Failed to retrieve auth configuration - {authConfigError.message} - + )} {isLoading && } {isSuccess && ( - + + + {TEMPLATES_SCHEMAS.map((template) => { + return ( + + {template.title} + + ) + })} + + {TEMPLATES_SCHEMAS.map((template) => { const panelId = template.title.trim().replace(/\s+/g, '-') return ( - + {builtInSMTP ? (
    ) : null} - -
    + + ) })} -
    +
    )}
    diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx new file mode 100644 index 00000000000..670921a211e --- /dev/null +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx @@ -0,0 +1,64 @@ +import { Markdown } from 'components/interfaces/Markdown' +import InformationBox from 'components/ui/InformationBox' +import { ValidateSpamResponse } from 'data/auth/validate-spam-mutation' +import { cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, WarningIcon } from 'ui' + +interface SpamValidationProps { + validationResult?: ValidateSpamResponse +} + +// [Joshen] According to API, we label as a spam risk as long as there are spam +// rules identified with scores above 0. Scores are irrelevant in our context and +// are hence not visualized in the UI + +export const SpamValidation = ({ validationResult }: SpamValidationProps) => { + const spamRules = (validationResult?.rules ?? []).filter((rule) => rule.score >= 0) + const hasSpamWarning = spamRules.length > 0 + + return ( + } + title={ + hasSpamWarning + ? 'Email has a high probability of being marked as spam and deliverability may be affected' + : 'Email content is unlikely to be marked as spam' + } + description={ + hasSpamWarning ? ( + <> + {spamRules.length > 0 && ( +
    +

    + {hasSpamWarning + ? ` Rectify the following issues to improve your email's deliverability in order of priority:` + : ` Address the following issues to improve your email's deliverability:`} +

    + + + + Warning + Description + + + + {spamRules.map((rule) => ( + + {rule.name} + {rule.desc} + + ))} + +
    + +
    + )} + + ) : null + } + /> + ) +} diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx index b0bb7cbaf96..f26e612eb74 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx @@ -1,5 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useEffect, useMemo, useState } from 'react' +import { debounce } from 'lodash' +import { Code, Monitor } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' import ReactMarkdown from 'react-markdown' import { toast } from 'sonner' @@ -8,19 +10,37 @@ import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import { FormActions } from 'components/ui/Forms/FormActions' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' import InformationBox from 'components/ui/InformationBox' +import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' +import { useValidateSpamMutation, ValidateSpamResponse } from 'data/auth/validate-spam-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { FormSchema } from 'types' -import { AlertTitle_Shadcn_, Alert_Shadcn_, Form, Input, Tabs } from 'ui' -import { Code, Monitor, Info } from 'lucide-react' +import { + Form, + Input, + Tabs_Shadcn_, + TabsContent_Shadcn_, + TabsList_Shadcn_, + TabsTrigger_Shadcn_, +} from 'ui' +import { Admonition } from 'ui-patterns' +import { SpamValidation } from './SpamValidation' interface TemplateEditorProps { template: FormSchema - authConfig: Record } -const TemplateEditor = ({ template, authConfig }: TemplateEditorProps) => { +const TemplateEditor = ({ template }: TemplateEditorProps) => { const { ref: projectRef } = useParams() + const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + + // [Joshen] Error state is handled in the parent + const { data: authConfig, isSuccess } = useAuthConfigQuery({ projectRef }) + + const { mutate: validateSpam } = useValidateSpamMutation({ + onSuccess: (res) => setValidationResult(res), + }) + const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation({ onError: (error) => toast.error(`Failed to update email templates: ${error.message}`), }) @@ -31,17 +51,27 @@ const TemplateEditor = ({ template, authConfig }: TemplateEditorProps) => { const INITIAL_VALUES = useMemo(() => { const result: { [x: string]: string } = {} Object.keys(properties).forEach((key) => { - result[key] = (authConfig && authConfig[key]) ?? '' + result[key] = ((authConfig && authConfig[key as keyof typeof authConfig]) ?? '') as string }) return result }, [authConfig, properties]) - const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') - const messageSlug = `MAILER_TEMPLATES_${id}_CONTENT` + const messageSlug = `MAILER_TEMPLATES_${id}_CONTENT` as keyof typeof authConfig const messageProperty = properties[messageSlug] + const builtInSMTP = + isSuccess && + authConfig && + (!authConfig.SMTP_HOST || !authConfig.SMTP_USER || !authConfig.SMTP_PASS) + + const [validationResult, setValidationResult] = useState() const [bodyValue, setBodyValue] = useState((authConfig && authConfig[messageSlug]) ?? '') const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + const debounceValidateSpam = useCallback(debounce(validateSpam, 1000), []) + const spamRules = (validationResult?.rules ?? []).filter((rule) => rule.score > 0) + const preventSaveFromSpamCheck = builtInSMTP && spamRules.length > 0 + const onSubmit = (values: any, { resetForm }: any) => { const payload = { ...values } @@ -80,6 +110,21 @@ const TemplateEditor = ({ template, authConfig }: TemplateEditorProps) => { } }, [hasUnsavedChanges]) + useEffect(() => { + if (projectRef && id && !!authConfig) { + const [subjectKey] = Object.keys(properties) + + validateSpam({ + projectRef, + template: { + subject: authConfig[subjectKey as keyof typeof authConfig] as string, + content: authConfig[messageSlug], + }, + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]) + return (
    {({ resetForm, values, initialValues }: any) => { @@ -119,6 +164,14 @@ const TemplateEditor = ({ template, authConfig }: TemplateEditorProps) => { ) : null } + onChange={(e) => { + if (projectRef) { + debounceValidateSpam({ + projectRef, + template: { subject: e.target.value, content: bodyValue }, + }) + } + }} disabled={!canUpdateConfig} />
    @@ -127,7 +180,7 @@ const TemplateEditor = ({ template, authConfig }: TemplateEditorProps) => { })} - + {messageProperty && ( <> @@ -146,40 +199,52 @@ const TemplateEditor = ({ template, authConfig }: TemplateEditorProps) => { } />
    - - } label="Source"> -
    - { - setBodyValue(e ?? '') - if (bodyValue !== e) { - setHasUnsavedChanges(true) - } - }} - options={{ wordWrap: 'off', contextmenu: false }} - value={bodyValue} - /> -
    -
    - } label="Preview"> - - - - The preview may differ slightly from the actual rendering in the email - client. - - + + + + + Source + + + + Preview + + + + { + setBodyValue(e ?? '') + if (bodyValue !== e) setHasUnsavedChanges(true) + + if (projectRef) { + const [subjectKey] = Object.keys(values) + debounceValidateSpam({ + projectRef, + template: { subject: values[subjectKey], content: e ?? '' }, + }) + } + }} + options={{ wordWrap: 'on', contextmenu: false }} + value={bodyValue} + /> + + + +