diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index a4e25e570bffc..412a1c269442a 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -1292,6 +1292,17 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "sheet-confirm-on-close-demo": { + name: "sheet-confirm-on-close-demo", + type: "components:example", + registryDependencies: ["alert-dialog","button","input","label","separator","sheet"], + component: React.lazy(() => import("@/registry/default/example/sheet-confirm-on-close-demo")), + source: "", + files: ["registry/default/example/sheet-confirm-on-close-demo.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "sheet-demo": { name: "sheet-demo", type: "components:example", diff --git a/apps/design-system/content/docs/fragments/confirmation-modal.mdx b/apps/design-system/content/docs/fragments/confirmation-modal.mdx index e613c83444f9d..ac52d9beb116c 100644 --- a/apps/design-system/content/docs/fragments/confirmation-modal.mdx +++ b/apps/design-system/content/docs/fragments/confirmation-modal.mdx @@ -10,7 +10,7 @@ Use Confirmation Modal when the user needs extra context to make a decision, suc If the confirmation can be expressed as a single short paragraph, use [Alert Dialog](../components/alert-dialog). If the action is highly destructive and requires explicit typed intent, use [Text Confirm Dialog](../fragments/text-confirm-dialog). See [Modality](../ui-patterns/modality) for broader guidance on choosing the appropriate pattern. -For dirty-form dismissal in dialogs/sheets, prefer the dedicated discard-confirmation pattern (`DiscardChangesConfirmationDialog` + `useConfirmOnClose`) rather than creating new local `CloseConfirmationModal` wrappers. The ad-hoc `CloseConfirmationModal` wrapper pattern is deprecated for new implementations. +For dirty-form dismissal in dialogs/sheets, use the dedicated discard-confirmation pattern (`DiscardChangesConfirmationDialog` + `useConfirmOnClose`) instead of `ConfirmationModal`. Avoid creating custom wrapper components for this flow; wire `modalProps` from `useConfirmOnClose` directly into `DiscardChangesConfirmationDialog`. diff --git a/apps/design-system/content/docs/ui-patterns/modality.mdx b/apps/design-system/content/docs/ui-patterns/modality.mdx index 83683b108749b..eca2d40bdce2b 100644 --- a/apps/design-system/content/docs/ui-patterns/modality.mdx +++ b/apps/design-system/content/docs/ui-patterns/modality.mdx @@ -18,18 +18,6 @@ We have two main ways of handling modality: As a general rule: use dialogs for short, focused tasks and use sheets for longer forms or more detailed views. -### Dirty form dismissal pattern - -When a dialog or sheet contains a form, users should generally be allowed to attempt dismissal via all normal affordances (backdrop click, Escape key, close icon, and footer `Cancel` button). - -If the form is clean, close immediately. If the form has unsaved changes, show a discard-confirmation dialog instead of closing immediately. - -This pattern is implemented in Studio with `useConfirmOnClose` plus `DiscardChangesConfirmationDialog`. - -- **Prompt on footer `Cancel` too:** If a button is labeled `Cancel`, users expect it to stop the current action, not silently discard edits. Prompting keeps behavior consistent with backdrop/Escape dismissal and prevents accidental loss. -- **Use explicit labels for no-prompt discard:** If you intentionally want a one-click destructive exit, label the action `Discard` (or `Discard changes`) rather than `Cancel`. -- **Guard close attempts, not unmounts:** This pattern should intercept controlled modal/sheet close attempts (`onOpenChange`, close buttons, footer actions). It should not attempt to block route changes or arbitrary component unmounts. - ## Dialogs Dialogs are centered overlays used for short, focused tasks. All dialogs should follow these best practices: @@ -53,22 +41,6 @@ There are quite a few dialog components, each suited to a different task or cont -#### Discard changes confirmation dialog (pattern) - -For dirty form dismissal, use a short discard-confirmation dialog after a close attempt instead of disabling dismissal entirely. - -- The primary form remains in a `Dialog` or `Sheet` (dismissible). -- Closing is intercepted only when the form is dirty. -- The follow-up confirmation is an `AlertDialog` pattern (`DiscardChangesConfirmationDialog` in Studio). - -Typical flow: - -1. User attempts to close the dialog/sheet (backdrop, Escape, close icon, or `Cancel`) -2. If the form is clean, close immediately -3. If the form is dirty, show discard confirmation -4. `Keep editing` returns to the form -5. `Discard changes` closes and resets the form - #### Text Confirm Dialog [Text Confirm Dialog](../fragments/text-confirm-dialog) adds a deliberate speed bump for highly destructive actions by requiring the user to type an exact confirmation string before proceeding. The confirm action remains disabled until the input matches. @@ -102,3 +74,53 @@ Sheets are dialogs presented as side panels. Use them for content that is larger [Sheet](../components/sheet) is modal by default, blocking interaction with the underlying page. + +## Best practices + +### Dirty form dismissal + +When a dialog or sheet contains a form, keep all normal dismissal affordances enabled (backdrop click, Escape key, close icon, and footer `Cancel` button). + +Decision flow: + +1. User attempts to close the dialog/sheet. +2. If the form is clean, close immediately. +3. If the form is dirty, show a discard-confirmation dialog. +4. `Keep editing` returns to the form. +5. `Discard changes` closes and resets the form. + +Implementation checklist: + +- Intercept close attempts from `onOpenChange`. +- Route footer `Cancel` through the same close guard. +- Render a separate discard confirmation dialog when dirty. +- Keep `Cancel` non-destructive; use `Discard`/`Discard changes` for one-click destructive exits. +- Guard controlled close attempts only; do not try to block route changes or arbitrary unmounts. + +Studio implementation (preferred in Studio code): + +```tsx +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' + +const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ + checkIsDirty: () => form.formState.isDirty, + onClose, +}) + + + ... + + ... + + +``` + +Generic implementation (outside Studio): + +- If Studio-only helpers are unavailable, recreate the same behavior with `AlertDialog`. +- The demo below shows the same flow and API shape (`confirmOnClose`, `handleOpenChange`, `modalProps`). + + diff --git a/apps/design-system/registry/default/example/sheet-confirm-on-close-demo.tsx b/apps/design-system/registry/default/example/sheet-confirm-on-close-demo.tsx new file mode 100644 index 0000000000000..a82d115797abf --- /dev/null +++ b/apps/design-system/registry/default/example/sheet-confirm-on-close-demo.tsx @@ -0,0 +1,225 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Input_Shadcn_ as Input, + Label_Shadcn_ as Label, + Separator, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, +} from 'ui' + +interface EndpointValues { + endpointUrl: string + secretHeader: string +} + +interface ConfirmOnCloseModalProps { + visible: boolean + onClose: () => void + onCancel: () => void +} + +const defaultValues: EndpointValues = { + endpointUrl: '', + secretHeader: '', +} + +const useConfirmOnClose = ({ + checkIsDirty, + onClose, +}: { + checkIsDirty: () => boolean + onClose: () => void +}) => { + const [visible, setVisible] = useState(false) + + const confirmOnClose = useCallback(() => { + if (checkIsDirty()) { + setVisible(true) + return + } + + onClose() + }, [checkIsDirty, onClose]) + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + confirmOnClose() + } + }, + [confirmOnClose] + ) + + const onConfirm = useCallback(() => { + setVisible(false) + onClose() + }, [onClose]) + + const onCancel = useCallback(() => { + setVisible(false) + }, []) + + const modalProps: ConfirmOnCloseModalProps = useMemo( + () => ({ + visible, + onClose: onConfirm, + onCancel, + }), + [visible, onConfirm, onCancel] + ) + + return { + confirmOnClose, + handleOpenChange, + modalProps, + } +} + +const DiscardChangesAlertDialog = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => { + const isConfirmingRef = useRef(false) + + useEffect(() => { + if (visible) { + isConfirmingRef.current = false + } + }, [visible]) + + const handleConfirm = useCallback(() => { + isConfirmingRef.current = true + onClose() + }, [onClose]) + + const handleOpenChange = useCallback( + (open: boolean) => { + if (open) return + + if (isConfirmingRef.current) { + isConfirmingRef.current = false + return + } + + onCancel() + }, + [onCancel] + ) + + return ( + + + + Discard changes? + + Any unsaved changes to this endpoint will be lost. + + + + Keep editing + + Discard changes + + + + + ) +} + +export default function SheetConfirmOnCloseDemo() { + const [open, setOpen] = useState(false) + const [savedValues, setSavedValues] = useState(defaultValues) + const [draftValues, setDraftValues] = useState(defaultValues) + + const isDirty = useMemo( + () => + draftValues.endpointUrl !== savedValues.endpointUrl || + draftValues.secretHeader !== savedValues.secretHeader, + [draftValues, savedValues] + ) + + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ + checkIsDirty: () => isDirty, + onClose: () => { + setDraftValues(savedValues) + setOpen(false) + }, + }) + + const openSheet = () => { + setDraftValues(savedValues) + setOpen(true) + } + + const saveChanges = () => { + setSavedValues(draftValues) + setOpen(false) + } + + return ( + <> + + + + + + Edit endpoint + + + +
+ + + setDraftValues((current) => ({ + ...current, + endpointUrl: event.target.value, + })) + } + /> +
+
+ + + setDraftValues((current) => ({ + ...current, + secretHeader: event.target.value, + })) + } + /> +
+
+ + + + + +
+
+ + + ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 905e07d95cf74..3a236c19096d2 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -761,6 +761,12 @@ export const examples: Registry = [ registryDependencies: ['separator'], files: ['example/separator-demo.tsx'], }, + { + name: 'sheet-confirm-on-close-demo', + type: 'components:example', + registryDependencies: ['alert-dialog', 'button', 'input', 'label', 'separator', 'sheet'], + files: ['example/sheet-confirm-on-close-demo.tsx'], + }, { name: 'sheet-demo', type: 'components:example', diff --git a/apps/docs/components/GuidesSidebar.tsx b/apps/docs/components/GuidesSidebar.tsx index 0fc561ff94f43..3a7737ece39ad 100644 --- a/apps/docs/components/GuidesSidebar.tsx +++ b/apps/docs/components/GuidesSidebar.tsx @@ -9,6 +9,8 @@ import { ExpandableVideo } from 'ui-patterns/ExpandableVideo' import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns/Toc' import { Feedback } from '~/components/Feedback' import { useTocAnchors } from '../features/docs/GuidesMdx.state' +import { Chatgpt } from 'icons' +import { Claude } from 'icons' interface TOCHeader { id?: string @@ -61,7 +63,7 @@ function AiTools({ className }: { className?: string }) { rel="noreferrer noopener" className="flex items-center gap-1.5 text-xs text-foreground-lighter hover:text-foreground transition-colors" > - + Ask ChatGPT - + Ask Claude diff --git a/apps/docs/content/guides/auth/oauth-server/getting-started.mdx b/apps/docs/content/guides/auth/oauth-server/getting-started.mdx index b65a671d19888..0efdc4068e226 100644 --- a/apps/docs/content/guides/auth/oauth-server/getting-started.mdx +++ b/apps/docs/content/guides/auth/oauth-server/getting-started.mdx @@ -495,11 +495,11 @@ Store the client secret securely. It will only be shown once. If you lose it, yo When a client exchanges an authorization code or refreshes a token, it must authenticate with the token endpoint. The `token_endpoint_auth_method` controls how this authentication happens: -| Method | Description | Used by | -| --- | --- | --- | -| `none` | No client authentication. Only `client_id` is sent in the request body. | Public clients (required) | -| `client_secret_basic` | Client credentials sent via HTTP Basic auth (`Authorization: Basic `). **This is the default for confidential clients.** | Confidential clients | -| `client_secret_post` | Client credentials sent in the request body (`client_id` and `client_secret` as form parameters). | Confidential clients | +| Method | Description | Used by | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| `none` | No client authentication. Only `client_id` is sent in the request body. | Public clients (required) | +| `client_secret_basic` | Client credentials sent via HTTP Basic auth (`Authorization: Basic `). **This is the default for confidential clients.** | Confidential clients | +| `client_secret_post` | Client credentials sent in the request body (`client_id` and `client_secret` as form parameters). | Confidential clients | **Defaults:** Public clients default to `none`. Confidential clients default to `client_secret_basic` (per [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591#section-2)). diff --git a/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx b/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx index f8707dc48ac73..02eaefaa097b3 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx @@ -125,14 +125,14 @@ const customVar = Deno.env.get('MY_CUSTOM_VAR') The functions service is pre-configured with the following environment variables: -| Variable | Value | Purpose | -| --- | --- | --- | -| `SUPABASE_URL` | `http://kong:8000` | Internal API gateway URL | -| `SUPABASE_PUBLIC_URL` | `http://:8000` | Base URL for accessing Supabase from the Internet | -| `JWT_SECRET` | Your secret key | Legacy symmetric encryption key used to sign and verify JWTs | -| `SUPABASE_ANON_KEY` | Your anon key | Client-side API key with limited permissions (`anon` role). | -| `SUPABASE_SERVICE_ROLE_KEY` | Your service role key | Server-side API key with full database access (`service_role` role) | -| `SUPABASE_DB_URL` | Postgres connection string | Can be used for direct database access | +| Variable | Value | Purpose | +| --------------------------- | --------------------------- | ------------------------------------------------------------------- | +| `SUPABASE_URL` | `http://kong:8000` | Internal API gateway URL | +| `SUPABASE_PUBLIC_URL` | `http://:8000` | Base URL for accessing Supabase from the Internet | +| `JWT_SECRET` | Your secret key | Legacy symmetric encryption key used to sign and verify JWTs | +| `SUPABASE_ANON_KEY` | Your anon key | Client-side API key with limited permissions (`anon` role). | +| `SUPABASE_SERVICE_ROLE_KEY` | Your service role key | Server-side API key with full database access (`service_role` role) | +| `SUPABASE_DB_URL` | Postgres connection string | Can be used for direct database access | Here's an example function that queries a table using `@supabase/supabase-js`: diff --git a/apps/docs/public/img/mcp-clients/claude-dark-icon.svg b/apps/docs/public/img/mcp-clients/claude-dark-icon.svg deleted file mode 100644 index b68b27324e2cc..0000000000000 --- a/apps/docs/public/img/mcp-clients/claude-dark-icon.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - diff --git a/apps/docs/public/img/mcp-clients/claude-icon.svg b/apps/docs/public/img/mcp-clients/claude-icon.svg index 135a8b9f33f30..7b9c0252ed711 100644 --- a/apps/docs/public/img/mcp-clients/claude-icon.svg +++ b/apps/docs/public/img/mcp-clients/claude-icon.svg @@ -1,16 +1,3 @@ - - - - - - - - - - - - - \ No newline at end of file + + + diff --git a/apps/docs/public/img/mcp-clients/cursor-dark-icon.svg b/apps/docs/public/img/mcp-clients/cursor-dark-icon.svg deleted file mode 100644 index 48f85eb4d5ae0..0000000000000 --- a/apps/docs/public/img/mcp-clients/cursor-dark-icon.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/docs/public/img/mcp-clients/factory-dark-icon.svg b/apps/docs/public/img/mcp-clients/factory-icon-dark.svg similarity index 100% rename from apps/docs/public/img/mcp-clients/factory-dark-icon.svg rename to apps/docs/public/img/mcp-clients/factory-icon-dark.svg diff --git a/apps/docs/public/img/mcp-clients/gemini-cli-dark-icon.svg b/apps/docs/public/img/mcp-clients/gemini-cli-dark-icon.svg deleted file mode 100644 index 7e457a00a77d9..0000000000000 --- a/apps/docs/public/img/mcp-clients/gemini-cli-dark-icon.svg +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/docs/public/img/mcp-clients/goose-dark-icon.svg b/apps/docs/public/img/mcp-clients/goose-icon-dark.svg similarity index 100% rename from apps/docs/public/img/mcp-clients/goose-dark-icon.svg rename to apps/docs/public/img/mcp-clients/goose-icon-dark.svg diff --git a/apps/docs/public/img/mcp-clients/kiro-dark-icon.svg b/apps/docs/public/img/mcp-clients/kiro-dark-icon.svg deleted file mode 100644 index ef4abd2d85d25..0000000000000 --- a/apps/docs/public/img/mcp-clients/kiro-dark-icon.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/apps/docs/public/img/mcp-clients/openai-dark-icon.svg b/apps/docs/public/img/mcp-clients/openai-icon-dark.svg similarity index 100% rename from apps/docs/public/img/mcp-clients/openai-dark-icon.svg rename to apps/docs/public/img/mcp-clients/openai-icon-dark.svg diff --git a/apps/docs/public/img/mcp-clients/openai-icon.svg b/apps/docs/public/img/mcp-clients/openai-icon.svg index 2838e13817b36..316440a338d7e 100644 --- a/apps/docs/public/img/mcp-clients/openai-icon.svg +++ b/apps/docs/public/img/mcp-clients/openai-icon.svg @@ -1,3 +1,3 @@ - + diff --git a/apps/docs/public/img/mcp-clients/opencode-dark-icon.svg b/apps/docs/public/img/mcp-clients/opencode-icon-dark.svg similarity index 100% rename from apps/docs/public/img/mcp-clients/opencode-dark-icon.svg rename to apps/docs/public/img/mcp-clients/opencode-icon-dark.svg diff --git a/apps/docs/public/img/mcp-clients/vscode-dark-icon.svg b/apps/docs/public/img/mcp-clients/vscode-dark-icon.svg deleted file mode 100644 index c453e633f349c..0000000000000 --- a/apps/docs/public/img/mcp-clients/vscode-dark-icon.svg +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/docs/public/img/mcp-clients/windsurf-dark-icon.svg b/apps/docs/public/img/mcp-clients/windsurf-icon-dark.svg similarity index 100% rename from apps/docs/public/img/mcp-clients/windsurf-dark-icon.svg rename to apps/docs/public/img/mcp-clients/windsurf-icon-dark.svg diff --git a/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.utils.tsx b/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.utils.tsx index 4c7adb5bdeacb..c3493239a28bf 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.utils.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.utils.tsx @@ -74,7 +74,7 @@ export function getIntegrationCommandName(integration: IntegrationDefinition): s case 'cron': return 'Create Cron Job' case 'webhooks': - return 'Create Webhook' + return 'Create Database Webhook' case 'queues': return 'Create Queue' default: diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 0ec643b302975..7fbd506af9fde 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -112,6 +112,12 @@ export const useIsQueueOperationsEnabled = () => { return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS] } +export const useIsPlatformWebhooksEnabled = () => { + const { flags } = useFeaturePreviewContext() + const platformWebhooksEnabled = useFlag('platformWebhooks') + return platformWebhooksEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS] +} + export const useIsTableFilterBarEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR] diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 2c4c5f7a2bab1..faaeae557e0ae 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -24,6 +24,7 @@ import { APISidePanelPreview } from './APISidePanelPreview' import { Branching2Preview } from './Branching2Preview' import { CLSPreview } from './CLSPreview' import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext' +import { PlatformWebhooksPreview } from './PlatformWebhooksPreview' import { PgDeltaDiffPreview } from './PgDeltaDiffPreview' import { QueueOperationsPreview } from './QueueOperationsPreview' import { TableFilterBarPreview } from './TableFilterBarPreview' @@ -42,6 +43,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: { [LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR]: , + [LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]: , } export const FeaturePreviewModal = () => { diff --git a/apps/studio/components/interfaces/App/FeaturePreview/PlatformWebhooksPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/PlatformWebhooksPreview.tsx new file mode 100644 index 0000000000000..a0824e8024636 --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/PlatformWebhooksPreview.tsx @@ -0,0 +1,26 @@ +import { useParams } from 'common' + +import { InlineLink } from '@/components/ui/InlineLink' + +export const PlatformWebhooksPreview = () => { + const { slug = '_', ref = '_' } = useParams() + + return ( +
+

+ Configure webhook endpoints and review deliveries from both project and organization + settings pages. +

+
    +
  • + Project scope:{' '} + Project Webhooks +
  • +
  • + Organization scope:{' '} + Organization Webhooks +
  • +
+
+ ) +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index c0c957bce911d..df3f9535d6c37 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -17,6 +17,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => { const isUnifiedLogsPreviewAvailable = useFlag('unifiedLogs') const tableEditorNewFilterBar = useFlag('tableEditorNewFilterBar') const pgDeltaDiffEnabled = useFlag('pgdeltaDiff') + const platformWebhooksEnabled = useFlag('platformWebhooks') return [ { @@ -56,6 +57,15 @@ export const useFeaturePreviews = (): FeaturePreview[] => { isDefaultOptIn: true, enabled: pgDeltaDiffEnabled, }, + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS, + name: 'Platform webhooks', + discussionsUrl: undefined, + isNew: true, + isPlatformOnly: true, + isDefaultOptIn: false, + enabled: platformWebhooksEnabled, + }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, name: 'Project API documentation', diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts b/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts index c741b45ab0755..f96ba267ee199 100644 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts +++ b/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts @@ -1,10 +1,10 @@ -import type { PostgresPolicy } from '@supabase/postgres-meta' -import { has, isEmpty, isEqual } from 'lodash' - import { ident } from '@supabase/pg-meta/src/pg-format' +import type { PostgresPolicy } from '@supabase/postgres-meta' import { generateSqlPolicy } from 'data/ai/sql-policy-mutation' import type { CreatePolicyBody } from 'data/database-policies/database-policy-create-mutation' import type { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query' +import { has, isEmpty, isEqual } from 'lodash' + import { PolicyFormField, PolicyForReview, @@ -19,7 +19,7 @@ import { export const createSQLPolicy = ( policyFormFields: PolicyFormField, - originalPolicyFormFields: PostgresPolicy + originalPolicyFormFields?: PostgresPolicy ) => { const { definition, check } = policyFormFields const formattedPolicyFormFields = { @@ -32,7 +32,7 @@ export const createSQLPolicy = ( check: check ? check.replace(/\s+/g, ' ').trim() : check === undefined ? null : check, } - if (isEmpty(originalPolicyFormFields)) { + if (!originalPolicyFormFields || isEmpty(originalPolicyFormFields)) { return createSQLStatementForCreatePolicy(formattedPolicyFormFields) } diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditor/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditor/index.tsx index a1ec84172dcb0..e004ff673b062 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditor/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditor/index.tsx @@ -14,7 +14,7 @@ interface PolicyEditorProps { onReviewPolicy: () => void } -const PolicyEditor = ({ +export const PolicyEditor = ({ isNewPolicy = true, policyFormFields = {}, onUpdatePolicyFormFields = () => {}, @@ -75,5 +75,3 @@ const PolicyEditor = ({ ) } - -export default PolicyEditor diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx index 8460189bed375..5fbd96084c3f1 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx @@ -1,15 +1,17 @@ +import { PostgresPolicy } from '@supabase/postgres-meta' import { useFeaturePreviewModal } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import useLatest from 'hooks/misc/useLatest' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { isEmpty, noop } from 'lodash' import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' import { Modal } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { POLICY_MODAL_VIEWS } from '../Policies.constants' import { PolicyFormField, + PolicyForReview, PostgresPolicyCreatePayload, PostgresPolicyUpdatePayload, } from '../Policies.types' @@ -18,7 +20,7 @@ import { createPayloadForUpdatePolicy, createSQLPolicy, } from '../Policies.utils' -import PolicyEditor from '../PolicyEditor' +import { PolicyEditor } from '../PolicyEditor' import { PolicyReview } from '../PolicyReview' import PolicySelection from '../PolicySelection' import PolicyTemplates from '../PolicyTemplates' @@ -30,7 +32,7 @@ interface PolicyEditorModalProps { visible?: boolean schema?: string table?: string - selectedPolicyToEdit: any + selectedPolicyToEdit?: PostgresPolicy showAssistantPreview?: boolean onSelectCancel: () => void onCreatePolicy: (payload: PostgresPolicyCreatePayload) => Promise @@ -38,11 +40,11 @@ interface PolicyEditorModalProps { onSaveSuccess: () => void } -const PolicyEditorModal = ({ +export const PolicyEditorModal = ({ visible = false, schema = '', table = '', - selectedPolicyToEdit = {}, + selectedPolicyToEdit, showAssistantPreview = false, onSelectCancel = noop, onCreatePolicy, @@ -71,10 +73,10 @@ const PolicyEditorModal = ({ const [policyFormFields, setPolicyFormFields] = useState( initializedPolicyFormFields ) - const [policyStatementForReview, setPolicyStatementForReview] = useState('') + const [policyStatementForReview, setPolicyStatementForReview] = useState() const [isDirty, setIsDirty] = useState(false) - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + const { confirmOnClose, modalProps } = useConfirmOnClose({ checkIsDirty: () => isDirty, onClose: () => { onSelectCancel() @@ -198,7 +200,7 @@ const PolicyEditorModal = ({ onCancel={confirmOnClose} >
- + {view === POLICY_MODAL_VIEWS.SELECTION ? ( - ) : view === POLICY_MODAL_VIEWS.REVIEW ? ( + ) : view === POLICY_MODAL_VIEWS.REVIEW && !!policyStatementForReview ? ( ) } - -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the editor? Your changes will be - lost. -

-
-) - -export default PolicyEditorModal diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx index 27ff753378a6c..511257ab827ba 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx @@ -12,12 +12,13 @@ import * as z from 'zod' import { useParams } from 'common' import { IStandaloneCodeEditor } from 'components/interfaces/SQLEditor/SQLEditor.types' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { useDatabasePolicyUpdateMutation } from 'data/database-policies/database-policy-update-mutation' import { databasePoliciesKeys } from 'data/database-policies/keys' import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { Button, Checkbox_Shadcn_, @@ -33,7 +34,6 @@ import { Tabs_Shadcn_, cn, } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { LockedCreateQuerySection, LockedRenameQuerySection } from './LockedQuerySection' import { PolicyDetailsV2 } from './PolicyDetailsV2' import { checkIfPolicyHasChanged, generateCreatePolicyQuery } from './PolicyEditorPanel.utils' @@ -165,7 +165,7 @@ export const PolicyEditorPanel = memo(function ({ return policyCreateUnsaved || policyUpdateUnsaved }, [command, name, roles, selectedPolicy]) - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ checkIsDirty: hasUnsavedChanges, onClose: onSelectCancel, }) @@ -292,7 +292,7 @@ export const PolicyEditorPanel = memo(function ({ <>
- + - + ) }) PolicyEditorPanel.displayName = 'PolicyEditorPanel' - -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- Are you sure you want to close the editor? Any unsaved changes on your policy and - conversations with the Assistant will be lost. -

-
-) diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyReview.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyReview.tsx index f6cdc8f45ecad..955566e39044b 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyReview.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyReview.tsx @@ -64,5 +64,3 @@ export const PolicyReview = ({ ) } - -export default PolicyReview diff --git a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx index b28b5eb7221e6..77727b3a45483 100644 --- a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx +++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx @@ -13,7 +13,7 @@ import { useDatabaseFunctionCreateMutation } from 'data/database-functions/datab import { DatabaseFunction } from 'data/database-functions/database-functions-query' import { useDatabaseFunctionUpdateMutation } from 'data/database-functions/database-functions-update-mutation' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { useProtectedSchemas } from 'hooks/useProtectedSchemas' import type { FormSchema } from 'types' import { @@ -41,7 +41,7 @@ import { Toggle, cn, } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { convertArgumentTypes, convertConfigParams } from '../Functions.utils' import { CreateFunctionHeader } from './CreateFunctionHeader' @@ -87,7 +87,7 @@ export const CreateFunction = ({ }) const language = form.watch('language') - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ checkIsDirty: () => form.formState.isDirty, onClose, }) @@ -158,7 +158,7 @@ export const CreateFunction = ({ const { data: protectedSchemas } = useProtectedSchemas() return ( - +
- + ) } -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) - interface FormFieldConfigParamsProps { readonly?: boolean } diff --git a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx index 9c078d71c8892..11646d724e7ad 100644 --- a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx @@ -8,16 +8,16 @@ import { useEffect, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, Form_Shadcn_, SidePanel } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormSchema, WebhookFormValues } from './EditHookPanel.constants' import { FormContents } from './FormContents' +import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { useDatabaseTriggerCreateMutation } from '@/data/database-triggers/database-trigger-create-mutation' import { useDatabaseTriggerUpdateMutation } from '@/data/database-triggers/database-trigger-update-transaction-mutation' import { useDatabaseHooksQuery } from '@/data/database-triggers/database-triggers-query' import { tableEditorQueryOptions } from '@/data/table-editor/table-editor-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from '@/hooks/ui/useConfirmOnClose' +import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' import { uuidv4 } from '@/lib/helpers' export type HTTPArgument = { id: string; name: string; value: string } @@ -288,7 +288,7 @@ export const EditHookPanel = () => { // This is intentionally kept outside of the useConfirmOnClose hook to force RHF to update the isDirty state. const isDirty = form.formState.isDirty - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + const { confirmOnClose, modalProps } = useConfirmOnClose({ checkIsDirty: () => isDirty, onClose: () => onClose(), }) @@ -340,22 +340,7 @@ export const EditHookPanel = () => { - + ) } - -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx index c14145482fd01..28fe906b51ae9 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx @@ -1,18 +1,17 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { Terminal } from 'lucide-react' -import { useEffect, useState } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' -import { toast } from 'sonner' -import * as z from 'zod' - import { PostgresTrigger } from '@supabase/postgres-meta' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import FormBoxEmpty from 'components/ui/FormBoxEmpty' import { useDatabaseTriggerCreateMutation } from 'data/database-triggers/database-trigger-create-mutation' import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/database-trigger-update-mutation' import { useTablesQuery } from 'data/tables/tables-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { useProtectedSchemas } from 'hooks/useProtectedSchemas' +import { Terminal } from 'lucide-react' +import { useEffect, useState } from 'react' +import { SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' import { Button, Checkbox_Shadcn_, @@ -33,8 +32,9 @@ import { SheetHeader, SheetTitle, } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import * as z from 'zod' + import ChooseFunctionForm from './ChooseFunctionForm' import { TRIGGER_ENABLED_MODES, @@ -135,7 +135,7 @@ export const TriggerSheet = ({ }) const { function_name, function_schema } = form.watch() - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ checkIsDirty: () => form.formState.isDirty, onClose, }) @@ -189,7 +189,7 @@ export const TriggerSheet = ({ return ( <> - + @@ -481,7 +481,7 @@ export const TriggerSheet = ({ - + @@ -489,25 +489,10 @@ export const TriggerSheet = ({ visible={showFunctionSelector} setVisible={setShowFunctionSelector} onChange={(fn) => { - form.setValue('function_name', fn.name) - form.setValue('function_schema', fn.schema) + form.setValue('function_name', fn.name, { shouldDirty: true }) + form.setValue('function_schema', fn.schema, { shouldDirty: true }) }} /> ) } - -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx index a099b64198be9..8863864d593d9 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx @@ -1,27 +1,22 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { useEffect, useState, type ReactNode } from 'react' -import { SubmitHandler, useForm, type UseFormReturn } from 'react-hook-form' -import { toast } from 'sonner' -import z from 'zod' - import { useParams } from 'common' import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { useSecretsCreateMutation } from 'data/secrets/secrets-create-mutation' import { ProjectSecret } from 'data/secrets/secrets-query' import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' -import { Eye, EyeOff, X } from 'lucide-react' +import { Eye, EyeOff } from 'lucide-react' +import { useEffect, useState } from 'react' +import { SubmitHandler, useForm } from 'react-hook-form' import { useLatest } from 'react-use' +import { toast } from 'sonner' import { Button, - cn, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input, Input_Shadcn_, - Separator, Sheet, - SheetClose, SheetContent, SheetFooter, SheetHeader, @@ -29,6 +24,7 @@ import { SheetTitle, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import z from 'zod' const FORM_ID = 'edit-secret-sidepanel' @@ -46,22 +42,17 @@ interface EditSecretSheetProps { } export function EditSecretSheet({ secret, visible, onClose }: EditSecretSheetProps) { + const { ref: projectRef } = useParams() const secretName = useLatest(secret?.name) + const [showSecretValue, setShowSecretValue] = useState(false) + const form = useForm({ resolver: zodResolver(FormSchema), }) - useEffect(() => { - if (visible) { - form.reset({ - name: secretName.current ?? '', - value: '', - }) - } - }, [form, secretName, visible]) + const isValid = form.formState.isValid const isDirty = form.formState.isDirty - const { ref: projectRef } = useParams() const { mutate: updateSecret, isPending: isUpdating } = useSecretsCreateMutation({ onSuccess: (_, variables) => { toast.success(`Successfully updated secret "${variables.secrets[0].name}"`) @@ -75,25 +66,83 @@ export function EditSecretSheet({ secret, visible, onClose }: EditSecretSheetPro }) } - const { - confirmOnClose, - handleOpenChange, - modalProps: closeConfirmationModalProps, - } = useConfirmOnClose({ + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ checkIsDirty: () => isDirty, onClose, }) + useEffect(() => { + if (visible) { + form.reset({ name: secretName.current ?? '', value: '' }) + } + }, [form, secretName, visible]) + return ( - -
- - + + + Edit secret + + + + +
+ ( + + + + + + )} + /> + ( + + + +
- - - ) -} -const Header = (): ReactNode => { - return ( - - - - Close - - Edit secret - - ) -} - -type FormBodyProps = { - form: UseFormReturn - onSubmit: SubmitHandler -} - -const FormBody = ({ form, onSubmit }: FormBodyProps): ReactNode => { - return ( - -
- - - - - - - - - -
- ) -} - -type NameFieldProps = { - form: UseFormReturn -} - -const NameField = ({ form }: NameFieldProps): ReactNode => { - return ( - ( - - - - - - )} - /> - ) -} - -type SecretFieldProps = { - form: UseFormReturn -} - -const SecretField = ({ form }: SecretFieldProps): ReactNode => { - const [showSecretValue, setShowSecretValue] = useState(false) - - return ( - ( - - - - - - - + {cronType === 'http_request' && ( + <> + + + + + + + )} + {cronType === 'edge_function' && ( + <> + + + + + + + )} + {cronType === 'sql_function' && } + {cronType === 'sql_snippet' && } + + + + + + + + + + + {pgNetExtension && ( { const edgeFunctionSlug = edgeFunction?.split('/functions/v1/').pop() const isValidEdgeFunction = edgeFunctions.some((x) => x.slug === edgeFunctionSlug) - const [isDirty, setIsDirty] = useState(false) - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ - checkIsDirty: () => isDirty, - onClose: () => { - setIsDirty(false) - setIsEditSheetOpen(false) - }, - }) - const pageTitle = childLabel || childId || 'Cron Job' const pageSubtitle = job ? ( @@ -192,40 +179,13 @@ export const CronJobPage = () => { - - - {job && ( - setIsEditSheetOpen(false)} - onCloseWithConfirmation={confirmOnClose} - /> - )} - - - + {job && ( + setIsEditSheetOpen(false)} + /> + )} ) } - -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx index 807d37b8a8e0e..b3547a4d70ed5 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx @@ -5,7 +5,6 @@ import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-ex import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers' import { createNavigationHandler } from 'lib/navigation' import { isGreaterThanOrEqual } from 'lib/semver' @@ -14,8 +13,7 @@ import { useRouter } from 'next/router' import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' import { MouseEvent, UIEvent, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' -import { LoadingLine, Sheet, SheetContent } from 'ui' -import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' +import { LoadingLine } from 'ui' import { formatCronJobColumns } from './CronJobs.utils' import { CronJobRunDetailsOverflowNotice } from './CronJobsTab.CleanupNotice' @@ -34,7 +32,6 @@ export const CronjobsTab = () => { const [searchQuery, setSearchQuery] = useQueryState('search', parseAsString.withDefault('')) - const [isDirty, setIsDirty] = useState(false) const [search, setSearch] = useState(searchQuery) const handleSearchSubmit = () => { @@ -142,13 +139,6 @@ export const CronjobsTab = () => { setCreateCronJobSheetShown(false) cleanPointerEventsNoneOnBody(500) } - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ - checkIsDirty: () => isDirty, - onClose: () => { - setIsDirty(false) - onClose() - }, - }) useEffect(() => { if (grid.isSuccess && !!cronJobIdForEditing && !cronJobForEditing) { @@ -192,18 +182,11 @@ export const CronjobsTab = () => { - - - - - - + ) } @@ -228,19 +211,3 @@ const CronJobsFooter = ({ count }: CronJobsFooterProps) => ( )} ) - -// Confirmation modal for unsaved changes -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) diff --git a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx index 95bc4ddc6962b..b01387918a180 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx @@ -8,7 +8,7 @@ import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-ex import { useDatabaseQueueCreateMutation } from 'data/database-queues/database-queues-create-mutation' import { useQueuesExposePostgrestStatusQuery } from 'data/database-queues/database-queues-expose-postgrest-status-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { useRouter } from 'next/router' import { useEffect } from 'react' import { @@ -31,7 +31,7 @@ import { SheetTitle, } from 'ui' import { Admonition } from 'ui-patterns' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { QUEUE_TYPES } from './Queues.constants' import { QueryNameSchema } from './Queues.utils' @@ -102,7 +102,7 @@ export const CreateQueueSheet = ({ visible, onClose }: CreateQueueSheetProps) => const checkIsDirty = () => form.formState.isDirty - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ checkIsDirty, onClose, }) @@ -144,7 +144,7 @@ export const CreateQueueSheet = ({ visible, onClose }: CreateQueueSheetProps) => const queueType = form.watch('values.type') return ( - +
@@ -322,23 +322,8 @@ export const CreateQueueSheet = ({ visible, onClose }: CreateQueueSheetProps) =>
- +
) } - -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx index 42cf788d56051..85cbb6afd22d8 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx @@ -11,8 +11,9 @@ import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation' import { FDW } from 'data/fdw/fdws-query' import { getDecryptedValues } from 'data/vault/vault-secret-decrypted-value-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { Button, Form, Input, SheetFooter, SheetHeader, SheetTitle } from 'ui' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import InputField from './InputField' import { WrapperMeta } from './Wrappers.types' @@ -108,7 +109,7 @@ export const EditWrapperSheet = ({ const checkIsDirty = useCallback(() => hasChangesRef.current, []) - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + const { confirmOnClose, modalProps } = useConfirmOnClose({ checkIsDirty, onClose, }) @@ -397,7 +398,7 @@ export const EditWrapperSheet = ({

Are you sure you want to continue?

- + ) } - -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx index ecc85736351c0..6560414d39223 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx @@ -5,10 +5,11 @@ import { parseAsBoolean, useQueryState } from 'nuqs' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { Alert_Shadcn_, AlertDescription_Shadcn_, @@ -19,7 +20,6 @@ import { SheetContent, WarningIcon, } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' import { CreateIcebergWrapperSheet } from './CreateIcebergWrapperSheet' import { CreateWrapperSheet } from './CreateWrapperSheet' @@ -45,7 +45,7 @@ export const WrapperOverviewTab = () => { }) const [isDirty, setIsDirty] = useState(false) - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ checkIsDirty: () => isDirty, onClose: () => { setCreateWrapperShown(false) @@ -141,7 +141,7 @@ export const WrapperOverviewTab = () => { - + { /> - + ) } - -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx index 50eacdefe4c70..2db4fa85d349f 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx @@ -4,10 +4,10 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useFDWsQuery } from 'data/fdw/fdws-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { HTMLProps, ReactNode, useCallback, useState } from 'react' import { Sheet, SheetContent } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { CreateWrapperSheet } from './CreateWrapperSheet' import { WRAPPERS } from './Wrappers.constants' @@ -38,7 +38,7 @@ export const WrappersTab = () => { : [] const [isDirty, setIsDirty] = useState(false) - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ checkIsDirty: useCallback(() => isDirty, [isDirty]), onClose: useCallback(() => { setCreateWrapperShown(false) @@ -50,7 +50,7 @@ export const WrappersTab = () => { ({ ...props }: { children: ReactNode } & HTMLProps) => (
{props.children} - + {wrapperMeta && ( {
), - [createWrapperShown, wrapperMeta, confirmOnClose] + [createWrapperShown, handleOpenChange, wrapperMeta, confirmOnClose] ) if (!wrapperMeta) { @@ -102,22 +102,7 @@ export const WrappersTab = () => { return ( - + ) } - -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx index f09b319cd5def..52d6e9546108d 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx @@ -1,9 +1,8 @@ import { Trash } from 'lucide-react' -import Table from 'components/to-be-cleaned/Table' import CopyButton from 'components/ui/CopyButton' import type { AuthorizedApp } from 'data/oauth/authorized-apps-query' -import { Button } from 'ui' +import { Button, TableCell, TableRow } from 'ui' import { TimestampInfo } from 'ui-patterns' export interface AuthorizedAppRowProps { @@ -13,35 +12,39 @@ export interface AuthorizedAppRowProps { export const AuthorizedAppRow = ({ app, onSelectRevoke }: AuthorizedAppRowProps) => { return ( - - + +
{!!app.icon ? '' : `${app.name[0]}`}
-
- {app.name} - {app.created_by} - + + +

+ {app.name} +

+
+ {app.created_by} +
-

+

{app.app_id}

-
- + + - - + + + +
+
+                        {deliveryEventPayload}
+                      
+
+ + + + +
+

Status

+ + {formatDeliveryStatus(selectedDelivery.status)} + +
+
+

Response code

+ {selectedDelivery.responseCode ? ( + + ) : ( + – + )} +
+
+
+

Response payload

+ +
+
+
+                        {deliveryResponsePayload}
+                      
+
+
+
+ + + + )} + +
+ ) +} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx new file mode 100644 index 0000000000000..6d84c04bcee82 --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx @@ -0,0 +1,175 @@ +import { Search } from 'lucide-react' + +import { getStatusLevel } from 'components/interfaces/UnifiedLogs/UnifiedLogs.utils' +import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { + Badge, + Card, + CardContent, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from 'ui' +import { TimestampInfo } from 'ui-patterns' +import { Input } from 'ui-patterns/DataInputs/Input' +import type { WebhookDelivery, WebhookEndpoint } from './PlatformWebhooks.types' +import { statusBadgeVariant } from './PlatformWebhooksView.utils' + +interface DetailItemProps { + label: string + children: React.ReactNode + ddClassName?: string +} + +const DetailItem = ({ label, children, ddClassName = 'text-sm' }: DetailItemProps) => ( +
+
{label}
+
{children}
+
+) + +interface PlatformWebhooksEndpointDetailsProps { + deliverySearch: string + filteredDeliveries: WebhookDelivery[] + selectedEndpoint: WebhookEndpoint + onDeliverySearchChange: (value: string) => void + onOpenDelivery: (deliveryId: string) => void +} + +export const PlatformWebhooksEndpointDetails = ({ + deliverySearch, + filteredDeliveries, + selectedEndpoint, + onDeliverySearchChange, + onOpenDelivery, +}: PlatformWebhooksEndpointDetailsProps) => { + const hasCustomHeaders = selectedEndpoint.customHeaders.length > 0 + + return ( +
+
+

Overview

+ + +
+ + {selectedEndpoint.url} + + + {selectedEndpoint.description || '-'} + + + {(selectedEndpoint.eventTypes.includes('*') + ? ['All events (*)'] + : selectedEndpoint.eventTypes + ).map((eventType) => ( + + {eventType} + + ))} + + + {hasCustomHeaders && ( + +
+ {selectedEndpoint.customHeaders.map((header) => ( +
+ {header.key}: + {header.value} +
+ ))} +
+
+ )} + + {selectedEndpoint.createdBy} + + + + +
+
+
+
+ +
+

Deliveries

+
+ } + value={deliverySearch} + className="w-full lg:w-52" + onChange={(event) => onDeliverySearchChange(event.target.value)} + /> +

{filteredDeliveries.length} total

+
+ + + + + Status + Event type + Response + Attempted + + + + {filteredDeliveries.length > 0 ? ( + filteredDeliveries.map((delivery) => ( + onOpenDelivery(delivery.id)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onOpenDelivery(delivery.id) + } + }} + tabIndex={0} + > + + {delivery.status} + + + {delivery.eventType} + + + {delivery.responseCode ? ( + + ) : ( + – + )} + + + + + + )) + ) : ( + + No deliveries found + + )} + +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx new file mode 100644 index 0000000000000..41015c161ef78 --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx @@ -0,0 +1,232 @@ +import { ChevronRight, Eye, MoreVertical, Plus, Search, Trash2 } from 'lucide-react' +import { useRouter } from 'next/router' +import { useMemo, useState } from 'react' + +import { createNavigationHandler } from 'lib/navigation' +import { + Badge, + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Table, + TableBody, + TableCell, + TableHead, + TableHeadSort, + TableHeader, + TableRow, +} from 'ui' +import { EmptyStatePresentational, TimestampInfo } from 'ui-patterns' +import { Input } from 'ui-patterns/DataInputs/Input' +import type { WebhookEndpoint } from './PlatformWebhooks.types' + +interface PlatformWebhooksEndpointListProps { + filteredEndpoints: WebhookEndpoint[] + search: string + webhooksHref: string + onCreateEndpoint: () => void + onDeleteEndpoint: (endpointId: string) => void + onSearchChange: (value: string) => void + onViewEndpoint: (endpointId: string) => void +} + +export const PlatformWebhooksEndpointList = ({ + filteredEndpoints, + search, + webhooksHref, + onCreateEndpoint, + onDeleteEndpoint, + onSearchChange, + onViewEndpoint, +}: PlatformWebhooksEndpointListProps) => { + const router = useRouter() + const formatEventCount = (eventTypes: string[]) => { + if (eventTypes.includes('*')) return 'All events' + if (eventTypes.length === 1) return '1 event' + return `${eventTypes.length} events` + } + const [sort, setSort] = useState<'status:asc' | 'status:desc' | 'created:asc' | 'created:desc'>( + 'created:desc' + ) + + const [sortColumn, sortDirection] = sort.split(':') as ['status' | 'created', 'asc' | 'desc'] + + const getAriaSort = (column: 'status' | 'created') => { + if (sortColumn !== column) return 'none' + return sortDirection === 'asc' ? 'ascending' : 'descending' + } + + const handleSortChange = (column: 'status' | 'created') => { + if (sortColumn !== column) { + setSort(`${column}:asc`) + return + } + + setSort(`${column}:${sortDirection === 'asc' ? 'desc' : 'asc'}`) + } + + const sortedEndpoints = useMemo(() => { + const items = [...filteredEndpoints] + + items.sort((a, b) => { + if (sortColumn === 'status') { + const statusA = a.enabled ? 'enabled' : 'disabled' + const statusB = b.enabled ? 'enabled' : 'disabled' + const comparison = statusA.localeCompare(statusB) + + if (comparison !== 0) return sortDirection === 'asc' ? comparison : -comparison + } + + const createdComparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + return sortDirection === 'asc' ? createdComparison : -createdComparison + }) + + return items + }, [filteredEndpoints, sortColumn, sortDirection]) + + return ( +
+
+

Endpoints

+
+
+ } + value={search} + className="w-full lg:w-52" + onChange={(event) => onSearchChange(event.target.value)} + /> + +
+ + {filteredEndpoints.length === 0 ? ( + + + + ) : ( + + + + + + + Status + + + URL + Events + + + Created + + + + + + + {sortedEndpoints.map((endpoint) => ( + + + + {endpoint.enabled ? 'Enabled' : 'Disabled'} + + + +

{endpoint.url}

+ {endpoint.description && ( +

+ {endpoint.description} +

+ )} +
+ + {formatEventCount(endpoint.eventTypes)} + + + +

+ by {endpoint.createdBy} +

+
+ +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + + +
+
+
+ ))} +
+
+
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx new file mode 100644 index 0000000000000..42bd837657fdc --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx @@ -0,0 +1,581 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { InlineLink } from 'components/ui/InlineLink' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' +import { ChevronDown, Trash2 } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { useFieldArray, useForm } from 'react-hook-form' +import { + Accordion_Shadcn_ as Accordion, + AccordionContent_Shadcn_ as AccordionContent, + AccordionItem_Shadcn_ as AccordionItem, + AccordionTrigger_Shadcn_ as AccordionTrigger, + Button, + Checkbox_Shadcn_ as Checkbox, + cn, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_ as InputField, + Label_Shadcn_ as Label, + Separator, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, + Switch, + TextArea_Shadcn_ as Textarea, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import * as z from 'zod' + +import type { + UpsertWebhookEndpointInput, + WebhookEndpoint, + WebhookScope, +} from './PlatformWebhooks.types' + +const endpointFormSchema = z + .object({ + url: z.string().trim().url('Please enter a valid URL'), + description: z.string().trim().max(512, 'Description cannot exceed 512 characters'), + enabled: z.boolean().default(true), + subscribeAll: z.boolean().default(false), + eventTypes: z.array(z.string()).default([]), + customHeaders: z + .array( + z.object({ + key: z.string().trim(), + value: z.string().trim(), + }) + ) + .default([]), + }) + .superRefine((data, ctx) => { + if (!data.subscribeAll && data.eventTypes.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Select at least one event type', + path: ['eventTypes'], + }) + } + }) + +export type EndpointFormValues = z.infer + +const toEventTypes = (values: EndpointFormValues) => + values.subscribeAll ? ['*'] : values.eventTypes + +type EventTypeGroup = { + id: string + label: string + eventTypes: string[] +} + +const buildEventTypeGroups = (scope: WebhookScope, eventTypes: string[]): EventTypeGroup[] => { + if (scope === 'project') { + return [{ id: 'project', label: 'Project events', eventTypes }] + } + + const organizationEvents = eventTypes.filter((eventType) => eventType.startsWith('organization.')) + const projectEvents = eventTypes.filter((eventType) => eventType.startsWith('project.')) + const ungroupedEvents = eventTypes.filter( + (eventType) => !eventType.startsWith('organization.') && !eventType.startsWith('project.') + ) + + return [ + { id: 'organization', label: 'Organization events', eventTypes: organizationEvents }, + { id: 'project', label: 'Project events', eventTypes: projectEvents }, + { id: 'other', label: 'Other events', eventTypes: ungroupedEvents }, + ].filter((group) => group.eventTypes.length > 0) +} + +const toggleEventType = (selectedEventTypes: string[], eventType: string, checked: boolean) => { + if (checked) return [...new Set([...selectedEventTypes, eventType])] + return selectedEventTypes.filter((value) => value !== eventType) +} + +const toggleEventTypeGroup = ( + selectedEventTypes: string[], + groupedEventTypes: string[], + checked: boolean +) => { + if (checked) return [...new Set([...selectedEventTypes, ...groupedEventTypes])] + return selectedEventTypes.filter((value) => !groupedEventTypes.includes(value)) +} + +const toControlId = (prefix: string, value: string) => + `${prefix}-${value.replace(/[^a-zA-Z0-9_-]/g, '-')}` + +export const toEndpointPayload = (values: EndpointFormValues): UpsertWebhookEndpointInput => ({ + name: '', + url: values.url, + description: values.description, + enabled: values.enabled, + eventTypes: toEventTypes(values), + customHeaders: values.customHeaders, +}) + +interface EndpointSheetProps { + visible: boolean + mode: 'create' | 'edit' + scope: WebhookScope + orgSlug?: string + endpoint?: WebhookEndpoint + enabledOverride?: boolean | null + eventTypes: string[] + onClose: () => void + onSubmit: (values: EndpointFormValues) => void +} + +export const PlatformWebhooksEndpointSheet = ({ + visible, + mode, + scope, + orgSlug, + endpoint, + enabledOverride, + eventTypes, + onClose, + onSubmit, +}: EndpointSheetProps) => { + const form = useForm({ + resolver: zodResolver(endpointFormSchema), + defaultValues: { + url: '', + description: '', + enabled: true, + subscribeAll: false, + eventTypes: [], + customHeaders: [], + }, + }) + const isDirty = form.formState.isDirty + const { + confirmOnClose, + handleOpenChange, + modalProps: discardChangesModalProps, + } = useConfirmOnClose({ + checkIsDirty: () => isDirty, + onClose, + }) + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: 'customHeaders', + }) + + const subscribeAll = form.watch('subscribeAll') + const selectedEventTypes = form.watch('eventTypes') + const groupedEventTypes = useMemo( + () => buildEventTypeGroups(scope, eventTypes), + [scope, eventTypes] + ) + const [openEventGroups, setOpenEventGroups] = useState([]) + + useEffect(() => { + if (!visible) return + + if (!endpoint) { + form.reset({ + url: '', + description: '', + enabled: true, + subscribeAll: false, + eventTypes: [], + customHeaders: [], + }) + return + } + + form.reset({ + url: endpoint.url, + description: endpoint.description, + enabled: enabledOverride ?? endpoint.enabled, + subscribeAll: endpoint.eventTypes.includes('*'), + eventTypes: endpoint.eventTypes.includes('*') ? eventTypes : endpoint.eventTypes, + customHeaders: endpoint.customHeaders.map((header) => ({ + key: header.key, + value: header.value, + })), + }) + }, [enabledOverride, endpoint, eventTypes, form, visible]) + + useEffect(() => { + if (!visible) return + setOpenEventGroups(groupedEventTypes.map((group) => group.id)) + }, [groupedEventTypes, visible]) + + useEffect(() => { + if (!visible) return + const allSelected = + eventTypes.length > 0 && + eventTypes.every((eventType) => selectedEventTypes.includes(eventType)) + + if (subscribeAll !== allSelected) { + form.setValue('subscribeAll', allSelected, { + shouldDirty: true, + shouldValidate: true, + }) + } + }, [eventTypes, form, selectedEventTypes, subscribeAll, visible]) + + return ( + + + + {mode === 'create' ? 'Create endpoint' : 'Edit endpoint'} + + + + +
+
+ ( + + + + + + )} + /> + + ( + + Description (optional) + + } + layout="vertical" + className="gap-1" + > + +