diff --git a/apps/studio/components/grid/components/grid/RowRenderer.tsx b/apps/studio/components/grid/components/grid/RowRenderer.tsx index c814617905742..05e082f725451 100644 --- a/apps/studio/components/grid/components/grid/RowRenderer.tsx +++ b/apps/studio/components/grid/components/grid/RowRenderer.tsx @@ -2,8 +2,8 @@ import type { Key } from 'react' import { TriggerEvent, useContextMenu } from 'react-contexify' import { RenderRowProps, Row } from 'react-data-grid' +import { ROW_CONTEXT_MENU_ID } from 'components/grid/constants' import { SupaRow } from 'components/grid/types' -import { ROW_CONTEXT_MENU_ID } from '../menu' export default function RowRenderer(key: Key, props: RenderRowProps) { const { show: showContextMenu } = useContextMenu() diff --git a/apps/studio/components/grid/components/menu/RowContextMenu.tsx b/apps/studio/components/grid/components/menu/RowContextMenu.tsx index 1e90055e4e2ac..4460cc0314019 100644 --- a/apps/studio/components/grid/components/menu/RowContextMenu.tsx +++ b/apps/studio/components/grid/components/menu/RowContextMenu.tsx @@ -3,11 +3,11 @@ import { useCallback } from 'react' import { Item, ItemParams, Menu } from 'react-contexify' import { toast } from 'sonner' +import { ROW_CONTEXT_MENU_ID } from 'components/grid/constants' import type { SupaRow } from 'components/grid/types' import { useTableEditorStateSnapshot } from 'state/table-editor' import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import { copyToClipboard, DialogSectionSeparator } from 'ui' -import { ROW_CONTEXT_MENU_ID } from '.' import { formatClipboardValue } from '../../utils/common' export type RowContextMenuProps = { diff --git a/apps/studio/components/grid/components/menu/index.ts b/apps/studio/components/grid/components/menu/index.ts index d205906f8112f..cd53af00a3334 100644 --- a/apps/studio/components/grid/components/menu/index.ts +++ b/apps/studio/components/grid/components/menu/index.ts @@ -1,4 +1,2 @@ export { default as ColumnMenu } from './ColumnMenu' export { default as RowContextMenu } from './RowContextMenu' - -export const ROW_CONTEXT_MENU_ID = 'row-context-menu-id' diff --git a/apps/studio/components/grid/constants.ts b/apps/studio/components/grid/constants.ts index cc99ab0013761..4494d5bd573dd 100644 --- a/apps/studio/components/grid/constants.ts +++ b/apps/studio/components/grid/constants.ts @@ -12,3 +12,5 @@ const RLS_ACKNOWLEDGED_KEY = 'supabase-acknowledge-rls-warning' export const rlsAcknowledgedKey = (tableID?: string | number) => `${RLS_ACKNOWLEDGED_KEY}-${String(tableID)}` + +export const ROW_CONTEXT_MENU_ID = 'row-context-menu-id' diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx index 90a76a0c5f279..05e57b5345b80 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx @@ -22,8 +22,8 @@ import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' import { DeleteDestination } from './DeleteDestination' import { DestinationPanel } from './DestinationPanel' import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' -import { PipelineStatus, PipelineStatusName } from './PipelineStatus' -import { STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants' +import { PipelineStatus } from './PipelineStatus' +import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants' import { RowMenu } from './RowMenu' import { UpdateVersionModal } from './UpdateVersionModal' diff --git a/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts index 8a4733175b727..1dcefc55ca656 100644 --- a/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts @@ -1,6 +1,6 @@ import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query' import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status' -import { PipelineStatusName } from './PipelineStatus' +import { PipelineStatusName } from './Replication.constants' export const PIPELINE_ERROR_MESSAGES = { RETRIEVE_PIPELINE: 'Failed to retrieve pipeline information', diff --git a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx index 16c666ba212fc..5e7bec9d0e20d 100644 --- a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx @@ -8,14 +8,7 @@ import { ResponseError } from 'types' import { cn, Tooltip, TooltipContent, TooltipTrigger, WarningIcon } from 'ui' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' import { getPipelineStateMessages } from './Pipeline.utils' - -export enum PipelineStatusName { - FAILED = 'failed', - STARTING = 'starting', - STARTED = 'started', - STOPPED = 'stopped', - UNKNOWN = 'unknown', -} +import { PipelineStatusName } from './Replication.constants' interface PipelineStatusProps { pipelineStatus: ReplicationPipelineStatusData['status'] | undefined diff --git a/apps/studio/components/interfaces/Database/Replication/Replication.constants.ts b/apps/studio/components/interfaces/Database/Replication/Replication.constants.ts index 273adac9f5bf6..687e7307483d6 100644 --- a/apps/studio/components/interfaces/Database/Replication/Replication.constants.ts +++ b/apps/studio/components/interfaces/Database/Replication/Replication.constants.ts @@ -1 +1,9 @@ export const STATUS_REFRESH_FREQUENCY_MS: number = 5000 + +export enum PipelineStatusName { + FAILED = 'failed', + STARTING = 'starting', + STARTED = 'started', + STOPPED = 'stopped', + UNKNOWN = 'unknown', +} diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx index ec36254a3e7db..1095fd08a770e 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx @@ -39,8 +39,8 @@ import { PIPELINE_ERROR_MESSAGES, getStatusName, } from '../Pipeline.utils' -import { PipelineStatus, PipelineStatusName } from '../PipelineStatus' -import { STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants' +import { PipelineStatus } from '../PipelineStatus' +import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants' import { UpdateVersionModal } from '../UpdateVersionModal' import { SlotLagMetrics, TableState } from './ReplicationPipelineStatus.types' import { getDisabledStateConfig, getStatusConfig } from './ReplicationPipelineStatus.utils' diff --git a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx index 81f82343cc941..5974fe6f98ae8 100644 --- a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx +++ b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx @@ -1,4 +1,4 @@ -import { Edit, MoreVertical, Pause, Play, RotateCcw, Trash, ArrowUpCircle } from 'lucide-react' +import { ArrowUpCircle, Edit, MoreVertical, Pause, Play, RotateCcw, Trash } from 'lucide-react' import { toast } from 'sonner' import { useParams } from 'common' @@ -28,7 +28,7 @@ import { PIPELINE_ERROR_MESSAGES, getStatusName, } from './Pipeline.utils' -import { PipelineStatusName } from './PipelineStatus' +import { PipelineStatusName } from './Replication.constants' interface RowMenuProps { pipeline: Pipeline | undefined diff --git a/apps/studio/components/interfaces/Database/Replication/UpdateVersionModal.tsx b/apps/studio/components/interfaces/Database/Replication/UpdateVersionModal.tsx index 8bcdcf0c2a599..bcf0a86e7896e 100644 --- a/apps/studio/components/interfaces/Database/Replication/UpdateVersionModal.tsx +++ b/apps/studio/components/interfaces/Database/Replication/UpdateVersionModal.tsx @@ -12,8 +12,7 @@ import { } from 'state/replication-pipeline-request-status' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { getStatusName } from './Pipeline.utils' -import { PipelineStatusName } from './PipelineStatus' -import { STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants' +import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants' interface UpdateVersionModalProps { visible: boolean @@ -26,8 +25,6 @@ interface UpdateVersionModalProps { export const UpdateVersionModal = ({ visible, pipeline, - // currentVersionName, - // newVersionName, confirmLabel = 'Update and restart', confirmLabelLoading = 'Updating', onClose, diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx index 3d6f2287f0a1b..9160a211ca5d4 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx @@ -1,7 +1,7 @@ import { PostgresTrigger } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' import { noop } from 'lodash' -import { Plus, Search } from 'lucide-react' +import { DatabaseZap, FunctionSquare, Plus, Search, Shield } from 'lucide-react' import { useState } from 'react' import AlphaPreview from 'components/to-be-cleaned/AlphaPreview' @@ -19,7 +19,10 @@ import { useIsProtectedSchema, useProtectedSchemas } from 'hooks/useProtectedSch import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { AiIconAnimation, + Button, Card, + CardContent, + cn, Input, Table, TableBody, @@ -29,6 +32,7 @@ import { } from 'ui' import { ProtectedSchemaWarning } from '../../ProtectedSchemaWarning' import TriggerList from './TriggerList' +import Link from 'next/link' interface TriggersListProps { createTrigger: () => void @@ -79,146 +83,159 @@ const TriggersList = ({ return } + const schemaTriggers = triggers.filter((x) => x.schema == selectedSchema) + return ( - <> - {(triggers ?? []).length === 0 ? ( -
- createTrigger()} - > - -

- A PostgreSQL trigger is a function invoked automatically whenever an event associated - with a table occurs. -

-

- An event could be any of the following: INSERT, UPDATE, DELETE. A trigger is a special - user-defined function associated with a table. -

-
+
+
+
+ + } + value={filterString} + className="w-full lg:w-52" + onChange={(e) => setFilterString(e.target.value)} + />
- ) : ( -
-
-
- - } - value={filterString} - className="w-full lg:w-52" - onChange={(e) => setFilterString(e.target.value)} - /> -
- {!isSchemaLocked && ( -
- } - onClick={() => createTrigger()} - className="flex-grow" - tooltip={{ - content: { - side: 'bottom', - text: !hasTables - ? 'Create a table first before creating triggers' - : !canCreateTriggers - ? 'You need additional permissions to create triggers' - : undefined, - }, - }} - > - New trigger - - - {hasTables && ( - } - onClick={() => - aiSnap.newChat({ - name: 'Create new trigger', - open: true, - initialInput: `Create a new trigger for the schema ${selectedSchema} that does ...`, - suggestions: { - title: - 'I can help you create a new trigger, here are a few example prompts to get you started:', - prompts: [ - { - label: 'Log Changes', - description: 'Create a trigger that logs changes to the users table', - }, - { - label: 'Update Timestamp', - description: 'Create a trigger that updates updated_at timestamp', - }, - { - label: 'Validate Email', - description: - 'Create a trigger that validates email format before insert', - }, - ], + {!isSchemaLocked && ( +
+ } + onClick={() => createTrigger()} + className="flex-grow" + tooltip={{ + content: { + side: 'bottom', + text: !hasTables + ? 'Create a table first before creating triggers' + : !canCreateTriggers + ? 'You need additional permissions to create triggers' + : undefined, + }, + }} + > + New trigger + + + {hasTables && ( + } + onClick={() => + aiSnap.newChat({ + name: 'Create new trigger', + open: true, + initialInput: `Create a new trigger for the schema ${selectedSchema} that does ...`, + suggestions: { + title: + 'I can help you create a new trigger, here are a few example prompts to get you started:', + prompts: [ + { + label: 'Log Changes', + description: 'Create a trigger that logs changes to the users table', + }, + { + label: 'Update Timestamp', + description: 'Create a trigger that updates updated_at timestamp', }, - }) - } - tooltip={{ - content: { - side: 'bottom', - text: !canCreateTriggers - ? 'You need additional permissions to create triggers' - : 'Create with Supabase Assistant', - }, - }} - /> - )} -
+ { + label: 'Validate Email', + description: 'Create a trigger that validates email format before insert', + }, + ], + }, + }) + } + tooltip={{ + content: { + side: 'bottom', + text: !canCreateTriggers + ? 'You need additional permissions to create triggers' + : 'Create with Supabase Assistant', + }, + }} + /> )}
+ )} +
- {isSchemaLocked && } - -
- - - - - Name - Table - Function - Events - Orientation - - Enabled - - - - - - - -
-
+ {isSchemaLocked && } + + {!isSchemaLocked && (schemaTriggers ?? []).length === 0 ? ( + +
+
+ +

Create realtime experiences

+
+

+ Keep your application in sync by automatically updating when data changes +

+
+ +
+
+ +

Trigger an edge function

+
+

+ Automatically invoke edge functions when database events occur +

+
+ +
+
+ +

Validate data

+
+

+ Ensure data meets your requirements before it is inserted into the database +

+
+ ) : ( +
+ + + + + Name + Table + Function + Events + Orientation + + Enabled + + + + + + + +
+
)} - +
) } diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.ts new file mode 100644 index 0000000000000..a9f70ce8c2a92 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.ts @@ -0,0 +1,122 @@ +import { toString as CronToString } from 'cronstrue' +import z from 'zod' + +import { urlRegex } from 'components/interfaces/Auth/Auth.constants' +import { cronPattern, secondsPattern } from '../CronJobs.constants' + +const convertCronToString = (schedule: string) => { + // pg_cron can also use "30 seconds" format for schedule. Cronstrue doesn't understand that format so just use the + // original schedule when cronstrue throws + try { + return CronToString(schedule) + } catch (error) { + return schedule + } +} + +const edgeFunctionSchema = z.object({ + type: z.literal('edge_function'), + method: z.enum(['GET', 'POST']), + edgeFunctionName: z.string().trim().min(1, 'Please select one of the listed Edge Functions'), + timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000), + httpHeaders: z.array(z.object({ name: z.string(), value: z.string() })), + httpBody: z + .string() + .trim() + .optional() + .refine((value) => { + if (!value) return true + try { + JSON.parse(value) + return true + } catch { + return false + } + }, 'Input must be valid JSON'), + // When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it + snippet: z.string().trim(), +}) + +const httpRequestSchema = z.object({ + type: z.literal('http_request'), + method: z.enum(['GET', 'POST']), + endpoint: z + .string() + .trim() + .min(1, 'Please provide a URL') + .regex(urlRegex(), 'Please provide a valid URL') + .refine((value) => value.startsWith('http'), 'Please include HTTP/HTTPs to your URL'), + timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000), + httpHeaders: z.array(z.object({ name: z.string(), value: z.string() })), + httpBody: z + .string() + .trim() + .optional() + .refine((value) => { + if (!value) return true + try { + JSON.parse(value) + return true + } catch { + return false + } + }, 'Input must be valid JSON'), + // When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it + snippet: z.string().trim(), +}) + +const sqlFunctionSchema = z.object({ + type: z.literal('sql_function'), + schema: z.string().trim().min(1, 'Please select one of the listed database schemas'), + functionName: z.string().trim().min(1, 'Please select one of the listed database functions'), + // When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it + snippet: z.string().trim(), +}) + +const sqlSnippetSchema = z.object({ + type: z.literal('sql_snippet'), + snippet: z.string().trim().min(1), +}) + +export const FormSchema = z + .object({ + name: z.string().trim().min(1, 'Please provide a name for your cron job'), + supportsSeconds: z.boolean(), + schedule: z + .string() + .trim() + .min(1) + .refine((value) => { + if (cronPattern.test(value)) { + try { + convertCronToString(value) + return true + } catch { + return false + } + } else if (secondsPattern.test(value)) { + return true + } + return false + }, 'Invalid Cron format'), + values: z.discriminatedUnion('type', [ + edgeFunctionSchema, + httpRequestSchema, + sqlFunctionSchema, + sqlSnippetSchema, + ]), + }) + .superRefine((data, ctx) => { + if (!cronPattern.test(data.schedule)) { + if (!(data.supportsSeconds && secondsPattern.test(data.schedule))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Seconds are supported only in pg_cron v1.5.0+. Please use a valid Cron format.', + path: ['schedule'], + }) + } + } + }) + +export type CreateCronJobForm = z.infer +export type CronJobType = CreateCronJobForm['values'] diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx similarity index 79% rename from apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx index e7a42f67760fd..057ba40cb9c49 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx @@ -1,15 +1,12 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { toString as CronToString } from 'cronstrue' import { parseAsString, useQueryState } from 'nuqs' import { useEffect, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' -import z from 'zod' import { useWatch } from '@ui/components/shadcn/ui/form' import { useParams } from 'common' -import { urlRegex } from 'components/interfaces/Auth/Auth.constants' import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { getDatabaseCronJob } from 'data/database-cron-jobs/database-cron-job-query' @@ -38,23 +35,22 @@ import { import { Admonition } from 'ui-patterns/admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { CRONJOB_DEFINITIONS } from './CronJobs.constants' +import { CRONJOB_DEFINITIONS } from '../CronJobs.constants' +import { buildCronQuery, buildHttpRequestCommand, parseCronJobCommand } from '../CronJobs.utils' +import { EdgeFunctionSection } from '../EdgeFunctionSection' +import { HttpBodyFieldSection } from '../HttpBodyFieldSection' +import { HTTPHeaderFieldsSection } from '../HttpHeaderFieldsSection' +import { HttpRequestSection } from '../HttpRequestSection' +import { SqlFunctionSection } from '../SqlFunctionSection' +import { SqlSnippetSection } from '../SqlSnippetSection' import { - buildCronQuery, - buildHttpRequestCommand, - cronPattern, - parseCronJobCommand, - secondsPattern, -} from './CronJobs.utils' + FormSchema, + type CreateCronJobForm, + type CronJobType, +} from './CreateCronJobSheet.constants' import { CronJobScheduleSection } from './CronJobScheduleSection' -import { EdgeFunctionSection } from './EdgeFunctionSection' -import { HttpBodyFieldSection } from './HttpBodyFieldSection' -import { HTTPHeaderFieldsSection } from './HttpHeaderFieldsSection' -import { HttpRequestSection } from './HttpRequestSection' -import { SqlFunctionSection } from './SqlFunctionSection' -import { SqlSnippetSection } from './SqlSnippetSection' -export interface CreateCronJobSheetProps { +interface CreateCronJobSheetProps { selectedCronJob?: Pick supportsSeconds: boolean isClosing: boolean @@ -62,113 +58,6 @@ export interface CreateCronJobSheetProps { onClose: () => void } -const edgeFunctionSchema = z.object({ - type: z.literal('edge_function'), - method: z.enum(['GET', 'POST']), - edgeFunctionName: z.string().trim().min(1, 'Please select one of the listed Edge Functions'), - timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000), - httpHeaders: z.array(z.object({ name: z.string(), value: z.string() })), - httpBody: z - .string() - .trim() - .optional() - .refine((value) => { - if (!value) return true - try { - JSON.parse(value) - return true - } catch { - return false - } - }, 'Input must be valid JSON'), - // When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it - snippet: z.string().trim(), -}) - -const httpRequestSchema = z.object({ - type: z.literal('http_request'), - method: z.enum(['GET', 'POST']), - endpoint: z - .string() - .trim() - .min(1, 'Please provide a URL') - .regex(urlRegex(), 'Please provide a valid URL') - .refine((value) => value.startsWith('http'), 'Please include HTTP/HTTPs to your URL'), - timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000), - httpHeaders: z.array(z.object({ name: z.string(), value: z.string() })), - httpBody: z - .string() - .trim() - .optional() - .refine((value) => { - if (!value) return true - try { - JSON.parse(value) - return true - } catch { - return false - } - }, 'Input must be valid JSON'), - // When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it - snippet: z.string().trim(), -}) - -const sqlFunctionSchema = z.object({ - type: z.literal('sql_function'), - schema: z.string().trim().min(1, 'Please select one of the listed database schemas'), - functionName: z.string().trim().min(1, 'Please select one of the listed database functions'), - // When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it - snippet: z.string().trim(), -}) - -const sqlSnippetSchema = z.object({ - type: z.literal('sql_snippet'), - snippet: z.string().trim().min(1), -}) - -const FormSchema = z - .object({ - name: z.string().trim().min(1, 'Please provide a name for your cron job'), - supportsSeconds: z.boolean(), - schedule: z - .string() - .trim() - .min(1) - .refine((value) => { - if (cronPattern.test(value)) { - try { - CronToString(value) - return true - } catch { - return false - } - } else if (secondsPattern.test(value)) { - return true - } - return false - }, 'Invalid Cron format'), - values: z.discriminatedUnion('type', [ - edgeFunctionSchema, - httpRequestSchema, - sqlFunctionSchema, - sqlSnippetSchema, - ]), - }) - .superRefine((data, ctx) => { - if (!cronPattern.test(data.schedule)) { - if (!(data.supportsSeconds && secondsPattern.test(data.schedule))) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Seconds are supported only in pg_cron v1.5.0+. Please use a valid Cron format.', - path: ['schedule'], - }) - } - } - }) - -export type CreateCronJobForm = z.infer -export type CronJobType = CreateCronJobForm['values'] - const FORM_ID = 'create-cron-job-sidepanel' const buildCommand = (values: CronJobType) => { diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CronJobScheduleSection.tsx similarity index 97% rename from apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CronJobScheduleSection.tsx index eb5ba94810480..f911e90b625aa 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CronJobScheduleSection.tsx @@ -23,9 +23,9 @@ import { Switch, } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' -import { CreateCronJobForm } from './CreateCronJobSheet' -import { formatScheduleString, getScheduleMessage } from './CronJobs.utils' -import CronSyntaxChart from './CronSyntaxChart' +import { formatScheduleString, getScheduleMessage } from '../CronJobs.utils' +import CronSyntaxChart from '../CronSyntaxChart' +import { type CreateCronJobForm } from './CreateCronJobSheet.constants' interface CronJobScheduleSectionProps { form: UseFormReturn diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx index 02de19a27d94a..eb0622f44301c 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx @@ -20,7 +20,7 @@ import { TooltipTrigger, } from 'ui' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' -import { CreateCronJobSheet } from './CreateCronJobSheet' +import { CreateCronJobSheet } from './CreateCronJobSheet/CreateCronJobSheet' import { isSecondsFormat, parseCronJobCommand } from './CronJobs.utils' import { PreviousRunsTab } from './PreviousRunsTab' diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx index ea33c32f9285e..b7017e8684a20 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx @@ -1,3 +1,4 @@ +import parser from 'cron-parser' import dayjs from 'dayjs' import { Clipboard, Edit, MoreVertical, Play, Trash } from 'lucide-react' import { parseAsString, useQueryState } from 'nuqs' @@ -41,7 +42,34 @@ import { TooltipTrigger, } from 'ui' import { TimestampInfo } from 'ui-patterns' -import { getNextRun } from './CronJobs.utils' + +const getNextRun = (schedule: string, lastRun?: string) => { + // cron-parser can only deal with the traditional cron syntax but technically users can also + // use strings like "30 seconds" now, For the latter case, we try our best to parse the next run + // (can't guarantee as scope is quite big) + if (schedule.includes('*')) { + try { + const interval = parser.parseExpression(schedule, { tz: 'UTC' }) + return interval.next().getTime() + } catch (error) { + return undefined + } + } else { + // [Joshen] Only going to attempt to parse if the schedule is as simple as "n second" or "n seconds" + // Returned undefined otherwise - we can revisit this perhaps if we get feedback about this + const [value, unit] = schedule.toLocaleLowerCase().split(' ') + if ( + ['second', 'seconds'].includes(unit) && + !Number.isNaN(Number(value)) && + lastRun !== undefined + ) { + const parsedLastRun = dayjs(lastRun).add(Number(value), unit as dayjs.ManipulateType) + return parsedLastRun.valueOf() + } else { + return undefined + } + } +} interface CronJobTableCellProps { col: any diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.constants.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.constants.tsx index 3399942fd4573..c830c8afc9e09 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.constants.tsx @@ -1,6 +1,12 @@ import { EdgeFunctions, RESTApi, SqlEditor } from 'icons' import { ScrollText } from 'lucide-react' +export const cronPattern = + /^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/ + +// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *" +export const secondsPattern = /^\d+\s+seconds*$/ + export const CRONJOB_TYPES = [ 'http_request', 'edge_function', diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts index 08827113b6076..75a87094ea69f 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { cronPattern, parseCronJobCommand, secondsPattern } from './CronJobs.utils' +import { cronPattern, secondsPattern } from './CronJobs.constants' +import { parseCronJobCommand } from './CronJobs.utils' describe('parseCronJobCommand', () => { it('should return a default object when the command is null', () => { diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx index c17360b7a0f2b..926d9990349df 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx @@ -1,12 +1,10 @@ -import parser from 'cron-parser' import { toString as CronToString } from 'cronstrue' -import dayjs from 'dayjs' import { Column } from 'react-data-grid' import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-infinite-query' import { cn } from 'ui' -import { CronJobType } from './CreateCronJobSheet' -import { CRON_TABLE_COLUMNS, HTTPHeader } from './CronJobs.constants' +import { CronJobType } from './CreateCronJobSheet/CreateCronJobSheet.constants' +import { CRON_TABLE_COLUMNS, HTTPHeader, secondsPattern } from './CronJobs.constants' import { CronJobTableCell } from './CronJobTableCell' export function buildCronQuery(name: string, schedule: string, command: string) { @@ -186,12 +184,6 @@ export function formatDate(dateString: string): string { return date.toLocaleString(undefined, options) } -export const cronPattern = - /^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/ - -// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *" -export const secondsPattern = /^\d+\s+seconds*$/ - export function isSecondsFormat(schedule: string): boolean { return secondsPattern.test(schedule.trim().toLocaleLowerCase()) } @@ -230,44 +222,6 @@ export const formatScheduleString = (value: string) => { } } -export const convertCronToString = (schedule: string) => { - // pg_cron can also use "30 seconds" format for schedule. Cronstrue doesn't understand that format so just use the - // original schedule when cronstrue throws - try { - return CronToString(schedule) - } catch (error) { - return schedule - } -} - -export const getNextRun = (schedule: string, lastRun?: string) => { - // cron-parser can only deal with the traditional cron syntax but technically users can also - // use strings like "30 seconds" now, For the latter case, we try our best to parse the next run - // (can't guarantee as scope is quite big) - if (schedule.includes('*')) { - try { - const interval = parser.parseExpression(schedule, { tz: 'UTC' }) - return interval.next().getTime() - } catch (error) { - return undefined - } - } else { - // [Joshen] Only going to attempt to parse if the schedule is as simple as "n second" or "n seconds" - // Returned undefined otherwise - we can revisit this perhaps if we get feedback about this - const [value, unit] = schedule.toLocaleLowerCase().split(' ') - if ( - ['second', 'seconds'].includes(unit) && - !Number.isNaN(Number(value)) && - lastRun !== undefined - ) { - const parsedLastRun = dayjs(lastRun).add(Number(value), unit as dayjs.ManipulateType) - return parsedLastRun.valueOf() - } else { - return undefined - } - } -} - export const formatCronJobColumns = ({ onSelectEdit, onSelectDelete, diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx index 65fb83d93a4ab..8a55a9b29e525 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx @@ -5,7 +5,7 @@ import { UIEvent, useMemo, useRef, useState } from 'react' import DataGrid, { DataGridHandle, Row } from 'react-data-grid' import { useParams } from 'common' -import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet' +import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet' import AlertError from 'components/ui/AlertError' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useCronJobsCountQuery } from 'data/database-cron-jobs/database-cron-jobs-count-query' diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/EdgeFunctionSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/EdgeFunctionSection.tsx index 6883555c086fa..5a23112951e96 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/EdgeFunctionSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/EdgeFunctionSection.tsx @@ -21,7 +21,7 @@ import { SheetSection, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { CreateCronJobForm } from './CreateCronJobSheet' +import { CreateCronJobForm } from './CreateCronJobSheet/CreateCronJobSheet.constants' interface HTTPRequestFieldsProps { form: UseFormReturn diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/HttpBodyFieldSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/HttpBodyFieldSection.tsx index efda8511f72bd..4ba3b23f081fc 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/HttpBodyFieldSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/HttpBodyFieldSection.tsx @@ -2,7 +2,6 @@ import { UseFormReturn } from 'react-hook-form' import { FormControl_Shadcn_, - FormDescription_Shadcn_, FormField_Shadcn_, FormItem_Shadcn_, FormLabel_Shadcn_, @@ -10,7 +9,7 @@ import { SheetSection, TextArea_Shadcn_, } from 'ui' -import { CreateCronJobForm } from './CreateCronJobSheet' +import { CreateCronJobForm } from './CreateCronJobSheet/CreateCronJobSheet.constants' interface HttpBodyFieldSectionProps { form: UseFormReturn diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx index 536fd8905013a..fb4cb9998131c 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx @@ -19,7 +19,7 @@ import { Input_Shadcn_, SheetSection, } from 'ui' -import { CreateCronJobForm } from './CreateCronJobSheet' +import { CreateCronJobForm } from './CreateCronJobSheet/CreateCronJobSheet.constants' interface HTTPHeaderFieldsSectionProps { variant: 'edge_function' | 'http_request' diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/HttpRequestSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/HttpRequestSection.tsx index 03d522eb6263a..3107f0b1258ac 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/HttpRequestSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/HttpRequestSection.tsx @@ -15,7 +15,7 @@ import { SheetSection, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { CreateCronJobForm } from './CreateCronJobSheet' +import { CreateCronJobForm } from './CreateCronJobSheet/CreateCronJobSheet.constants' interface HttpRequestSectionProps { form: UseFormReturn diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/SqlFunctionSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/SqlFunctionSection.tsx index ffbbc7d720bbd..3d25f88e61e05 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/SqlFunctionSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/SqlFunctionSection.tsx @@ -4,7 +4,7 @@ import FunctionSelector from 'components/ui/FunctionSelector' import SchemaSelector from 'components/ui/SchemaSelector' import { FormField_Shadcn_, SheetSection } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { CreateCronJobForm } from './CreateCronJobSheet' +import { CreateCronJobForm } from './CreateCronJobSheet/CreateCronJobSheet.constants' interface SqlFunctionSectionProps { form: UseFormReturn diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/SqlSnippetSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/SqlSnippetSection.tsx index 8d3bb465d783a..25f14eb141b78 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/SqlSnippetSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/SqlSnippetSection.tsx @@ -3,7 +3,7 @@ import { UseFormReturn } from 'react-hook-form' import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import { FormField_Shadcn_, SheetSection } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { CreateCronJobForm } from './CreateCronJobSheet' +import { CreateCronJobForm } from './CreateCronJobSheet/CreateCronJobSheet.constants' interface SqlSnippetSectionProps { form: UseFormReturn diff --git a/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretRow.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretRow.tsx index b0b738b858e6d..133bbe9ef6679 100644 --- a/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretRow.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretRow.tsx @@ -18,7 +18,7 @@ import { Edit3, Eye, EyeOff, Key, Loader, MoreVertical, Trash } from 'lucide-rea import type { VaultSecret } from 'types' import { Input } from 'ui-patterns/DataInputs/Input' import EditSecretModal from './EditSecretModal' -import type { SecretTableColumn } from './Secrets.utils' +import { SecretTableColumn } from './Secrets.types' interface SecretRowProps { row: VaultSecret diff --git a/apps/studio/components/interfaces/Integrations/Vault/Secrets/Secrets.types.ts b/apps/studio/components/interfaces/Integrations/Vault/Secrets/Secrets.types.ts new file mode 100644 index 0000000000000..6cddb9c8291ff --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/Secrets.types.ts @@ -0,0 +1,7 @@ +export interface SecretTableColumn { + id: 'secret' | 'id' | 'secret_value' | 'updated_at' | 'actions' + name: string + minWidth?: number + width?: number + maxWidth?: number +} diff --git a/apps/studio/components/interfaces/Integrations/Vault/Secrets/Secrets.utils.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/Secrets.utils.tsx index a1bec3a55dbc1..ccb7f07405e3a 100644 --- a/apps/studio/components/interfaces/Integrations/Vault/Secrets/Secrets.utils.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/Secrets.utils.tsx @@ -3,16 +3,7 @@ import type { Column } from 'react-data-grid' import type { VaultSecret } from 'types' import { cn } from 'ui' import SecretRow from './SecretRow' - -export type SecretColumnId = 'secret' | 'id' | 'secret_value' | 'updated_at' | 'actions' - -export interface SecretTableColumn { - id: SecretColumnId - name: string - minWidth?: number - width?: number - maxWidth?: number -} +import { SecretTableColumn } from './Secrets.types' export const SECRET_TABLE_COLUMNS: SecretTableColumn[] = [ { id: 'secret', name: 'Secret', minWidth: 300, width: 360 }, diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx index a2c7e0de25e8c..e3dcb01cd3e39 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx @@ -30,6 +30,8 @@ import { WrapperMeta } from './Wrappers.types' import { makeValidateRequired } from './Wrappers.utils' import WrapperTableEditor from './WrapperTableEditor' +const FORM_ID = 'create-wrapper-form' + export interface CreateWrapperSheetProps { isClosing: boolean wrapperMeta: WrapperMeta @@ -37,8 +39,6 @@ export interface CreateWrapperSheetProps { onClose: () => void } -const FORM_ID = 'create-wrapper-form' - export const CreateWrapperSheet = ({ wrapperMeta, isClosing, diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx index 6d422b8dfe8aa..14ccc25b99df8 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx @@ -18,6 +18,7 @@ import { WarningIcon, } from 'ui' import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' +import { CreateIcebergWrapperSheet } from './CreateIcebergWrapperSheet' import { CreateWrapperSheet } from './CreateWrapperSheet' import { WRAPPERS } from './Wrappers.constants' import { WrapperTable } from './WrapperTable' @@ -53,7 +54,13 @@ export const WrapperOverviewTab = () => { const databaseNeedsUpgrading = wrappersExtension?.installed_version === wrappersExtension?.default_version - const CreateWrapperSheetComponent = wrapperMeta.createComponent || CreateWrapperSheet + // [Joshen] Opting to declare custom wrapper sheets here instead of within Wrappers.constants.ts + // as we'll easily run into circular dependencies doing so unfortunately + const CreateWrapperSheetComponent = wrapperMeta.customComponent + ? wrapperMeta.name === 'iceberg_wrapper' + ? CreateIcebergWrapperSheet + : ({}) => null + : CreateWrapperSheet return ( + customComponent?: boolean // If true, the wrapper can target a schema which will be populated with tables specified by the wrapper.. canTargetSchema?: boolean sourceSchemaOption?: ServerOption diff --git a/apps/studio/components/interfaces/Realtime/Inspector/AnimatedCursors.tsx b/apps/studio/components/interfaces/Realtime/Inspector/AnimatedCursors.tsx new file mode 100644 index 0000000000000..de4bd3abaed2a --- /dev/null +++ b/apps/studio/components/interfaces/Realtime/Inspector/AnimatedCursors.tsx @@ -0,0 +1,61 @@ +import { useState, useEffect } from 'react' +import { motion } from 'framer-motion' +import { MousePointer2 } from 'lucide-react' + +export const AnimatedCursors = () => { + const [cursor1Position, setCursor1Position] = useState({ x: 20, y: 20 }) + const [cursor2Position, setCursor2Position] = useState({ x: 180, y: 80 }) + + useEffect(() => { + const animateCursors = () => { + const newCursor1Position = { + x: Math.random() * 160 + 20, + y: Math.random() * 80 + 20, + } + const newCursor2Position = { + x: Math.random() * 160 + 20, + y: Math.random() * 80 + 20, + } + + setCursor1Position(newCursor1Position) + setCursor2Position(newCursor2Position) + } + + const initialTimer = setTimeout(animateCursors, 1000) + + const interval = setInterval(animateCursors, 3000) + + return () => { + clearTimeout(initialTimer) + clearInterval(interval) + } + }, []) + + return ( +
+ + + + + + +
+ ) +} diff --git a/apps/studio/components/interfaces/Realtime/Inspector/EmptyRealtime.tsx b/apps/studio/components/interfaces/Realtime/Inspector/EmptyRealtime.tsx new file mode 100644 index 0000000000000..8bed6a1596cf6 --- /dev/null +++ b/apps/studio/components/interfaces/Realtime/Inspector/EmptyRealtime.tsx @@ -0,0 +1,101 @@ +import { AiIconAnimation, Button, Card, cn } from 'ui' +import Link from 'next/link' +import { AnimatedCursors } from './AnimatedCursors' +import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' + +/** + * Acts as a container component for the entire log display + */ +export const EmptyRealtime = ({ projectRef }: { projectRef: string }) => { + const aiSnap = useAiAssistantStateSnapshot() + + const handleCreateTriggerWithAssistant = () => { + aiSnap.newChat({ + name: `Realtime`, + open: true, + initialInput: `Help me set up a realtime experience for my project`, + }) + } + + return ( +
+
+
+ +

Create realtime experiences

+

+ Send your first realtime message from your database, application code or edge function +

+ +
+ + +
+
+ + 1 + +

Broadcast messages

+
+

+ Send messages to a channel from your client application or database via triggers. +

+ +
+ +
+
+ + 2 + +

Write policies

+
+

+ Set up Row Level Security policies to control who can see messages within a channel +

+ +
+ +
+
+ + 3 + +

Subscribe to a channel

+
+

+ Receive realtime messages in your application by listening to a channel +

+ +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Realtime/Inspector/index.tsx b/apps/studio/components/interfaces/Realtime/Inspector/index.tsx index 28b616b773593..116ce210093a3 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/index.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/index.tsx @@ -1,5 +1,7 @@ import { useParams } from 'common' -import { useState } from 'react' +import { useState, useEffect } from 'react' +import { motion } from 'framer-motion' +import { MousePointer2 } from 'lucide-react' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' @@ -7,6 +9,7 @@ import { Header } from './Header' import MessagesTable from './MessagesTable' import { SendMessageModal } from './SendMessageModal' import { RealtimeConfig, useRealtimeMessages } from './useRealtimeMessages' +import { EmptyRealtime } from './EmptyRealtime' /** * Acts as a container component for the entire log display @@ -40,12 +43,16 @@ export const RealtimeInspector = () => {
- 0} - enabled={realtimeConfig.enabled} - data={logData} - showSendMessage={() => setSendMessageShown(true)} - /> + {(logData ?? []).length > 0 ? ( + 0} + enabled={realtimeConfig.enabled} + data={logData} + showSendMessage={() => setSendMessageShown(true)} + /> + ) : ( + + )}
diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx index 1ff808b861436..343fbc73e6af0 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx @@ -95,7 +95,7 @@ const InfrastructureInfo = () => {

Service Versions

- Information on your provisioned instance. + Service versions and upgrade eligibility for your provisioned instance.

diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx index 1e147771370a1..81874dbe478e9 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx @@ -24,36 +24,37 @@ export const ObjectsToBeDroppedWarning = ({ }: { objectsToBeDropped: string[] }) => { + const { ref } = useParams() return ( - - A new version of Postgres is available + + A newer version of Postgres is available -
-

The following objects have to be removed before upgrading:

+ <> +

+ The following objects are not supported and must be removed before upgrading.{' '} + + Learn more + +

-
    + {/* Old */} +
      {objectsToBeDropped.map((obj) => (
    • {obj}
    • ))}
    -
-

Check the docs for which objects need to be removed.

-
- -
+
) @@ -73,15 +74,15 @@ export const UnsupportedExtensionsWarning = ({ <>

The following extensions are not supported in newer versions of Postgres and must be - removed before you can upgrade.{' '} + removed before upgrading.{' '} Learn more - .

    @@ -107,15 +108,13 @@ export const UnsupportedExtensionsWarning = ({ export const UserDefinedObjectsInInternalSchemasWarning = ({ objects }: { objects: string[] }) => { return ( - - A new version of Postgres is available + + A newer version of Postgres is available

    - You'll need to move these objects out of auth/realtime/storage schemas before upgrading: + The following objects must be removed from the auth/realtime/storage schemas before + upgrading:

      diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index 1d17ad0eb9fa9..a2a99dfcd3e22 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -1,14 +1,15 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { Lock, MousePointer2, PlusCircle, Unlock } from 'lucide-react' import Link from 'next/link' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { toast } from 'sonner' -import { useParams } from 'common' +import { useParams, useFlag } from 'common' import { RefreshButton } from 'components/grid/components/header/RefreshButton' import { getEntityLintDetails } from 'components/interfaces/TableGridEditor/TableEntity.utils' import { APIDocsButton } from 'components/ui/APIDocsButton' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useDatabaseTriggersQuery } from 'data/database-triggers/database-triggers-query' import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query' import { useDatabasePublicationUpdateMutation } from 'data/database-publications/database-publications-update-mutation' @@ -68,6 +69,7 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp const isView = isTableLikeView(table) const isMaterializedView = isTableLikeMaterializedView(table) + const triggersInsteadOfRealtime = useFlag('triggersInsteadOfRealtime') const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all']) const { isSchemaLocked } = useIsProtectedSchema({ schema: table.schema }) @@ -116,6 +118,21 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp }, }) + const { data: triggersData } = useDatabaseTriggersQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + { + enabled: isTable, + } + ) + const tableTriggers = (triggersData ?? []).filter( + (trigger) => trigger.schema === table.schema && trigger.table === table.name + ) + + const tableTriggersCount = tableTriggers.length + const { can: canSqlWriteTables, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables' @@ -147,6 +164,8 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp const { mutate: sendEvent } = useSendEventMutation() + const manageTriggersHref = `/project/${ref}/database/triggers?schema=${table.schema}` + const toggleRealtime = async () => { if (!project) return console.error('Project is required') if (!realtimePublication) return console.error('Unable to find realtime publication') @@ -325,6 +344,55 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp ) ) : null} + {isTable && triggersInsteadOfRealtime ? ( +
    + } + > + + {tableTriggersCount === 1 ? 'Trigger' : 'Triggers'} + + + ) : ( + realtimeEnabled && ( + + } + onClick={() => setShowEnableRealtime(true)} + className={cn(isRealtimeEnabled && 'w-7 h-7 p-0 text-brand hover:text-brand-hover')} + tooltip={{ + content: { + side: 'bottom', + text: isRealtimeEnabled + ? 'Click to disable realtime for this table' + : 'Click to enable realtime for this table', + }, + }} + > + {!isRealtimeEnabled && 'Enable Realtime'} + + ) + )} + {isView && viewHasLints && ( @@ -459,31 +527,6 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp - {isTable && realtimeEnabled && ( - - } - onClick={() => setShowEnableRealtime(true)} - className={cn(isRealtimeEnabled && 'w-7 h-7 p-0 text-brand hover:text-brand-hover')} - tooltip={{ - content: { - side: 'bottom', - text: isRealtimeEnabled - ? 'Click to disable realtime for this table' - : 'Click to enable realtime for this table', - }, - }} - > - {!isRealtimeEnabled && 'Enable Realtime'} - - )} - {doesHaveAutoGeneratedAPIDocs && } diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx index d320ad58872cd..8ad58dc0c7f8d 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx @@ -15,23 +15,6 @@ import { SLIDER_DELIMITER, SORT_DELIMITER, } from 'components/ui/DataTable/DataTable.constants' -import { ChartConfig } from 'ui' -import { TooltipLabel } from './components/TooltipLabel' - -export const CHART_CONFIG = { - success: { - label: , - color: 'hsl(var(--foreground-muted))', - }, - warning: { - label: , - color: 'hsl(var(--warning-default))', - }, - error: { - label: , - color: 'hsl(var(--destructive-default))', - }, -} satisfies ChartConfig export const REGIONS = ['ams', 'fra', 'gru', 'hkg', 'iad', 'syd'] as const export const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] as const diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx index 20050a0693e87..d2250d0576ab9 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -44,13 +44,29 @@ import { RefreshButton } from '../../ui/DataTable/RefreshButton' import { generateDynamicColumns, UNIFIED_LOGS_COLUMNS } from './components/Columns' import { DownloadLogsButton } from './components/DownloadLogsButton' import { LogsListPanel } from './components/LogsListPanel' +import { TooltipLabel } from './components/TooltipLabel' import { ServiceFlowPanel } from './ServiceFlowPanel' -import { CHART_CONFIG, SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' +import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' import { filterFields as defaultFilterFields } from './UnifiedLogs.fields' import { useLiveMode, useResetFocus } from './UnifiedLogs.hooks' import { QuerySearchParamsType } from './UnifiedLogs.types' import { getFacetedUniqueValues, getLevelRowClassName } from './UnifiedLogs.utils' +export const CHART_CONFIG = { + success: { + label: , + color: 'hsl(var(--foreground-muted))', + }, + warning: { + label: , + color: 'hsl(var(--warning-default))', + }, + error: { + label: , + color: 'hsl(var(--destructive-default))', + }, +} satisfies ChartConfig + export const UnifiedLogs = () => { useResetFocus() diff --git a/apps/studio/data/content/keys.ts b/apps/studio/data/content/keys.ts index 2c803f01a3fa1..5becc1afcbcca 100644 --- a/apps/studio/data/content/keys.ts +++ b/apps/studio/data/content/keys.ts @@ -1,12 +1,9 @@ -import type { ContentType } from './content-query' -import type { SqlSnippet } from './sql-snippets-query' - export const contentKeys = { allContentLists: (projectRef: string | undefined) => ['projects', projectRef, 'content'] as const, infiniteList: ( projectRef: string | undefined, options?: { - type: ContentType | undefined + type: string name: string | undefined limit?: number sort?: string @@ -14,14 +11,14 @@ export const contentKeys = { ) => ['projects', projectRef, 'content-infinite', options].filter(Boolean), list: ( projectRef: string | undefined, - options: { type?: ContentType; name?: string; limit?: number } + options: { type?: string; name?: string; limit?: number } ) => ['projects', projectRef, 'content', options] as const, sqlSnippets: ( projectRef: string | undefined, options?: { sort?: 'inserted_at' | 'name' name?: string - visibility?: SqlSnippet['visibility'] + visibility?: string favorite?: boolean } ) => ['projects', projectRef, 'content', 'sql', options].filter(Boolean), @@ -41,7 +38,7 @@ export const contentKeys = { type?: string, options?: { cumulative?: boolean - visibility?: SqlSnippet['visibility'] + visibility?: string favorite?: boolean name?: string } diff --git a/apps/studio/data/table-rows/keys.ts b/apps/studio/data/table-rows/keys.ts index ba972f722ee64..27163fb5c7e75 100644 --- a/apps/studio/data/table-rows/keys.ts +++ b/apps/studio/data/table-rows/keys.ts @@ -1,12 +1,5 @@ -import type { GetTableRowsArgs } from './table-rows-query' - -type TableRowKeyArgs = Omit & { table?: { id?: number } } - export const tableRowKeys = { - tableRows: ( - projectRef?: string, - { table, roleImpersonationState, ...args }: TableRowKeyArgs = {} - ) => + tableRows: (projectRef?: string, { table, roleImpersonationState, ...args }: any = {}) => [ 'projects', projectRef, @@ -15,7 +8,7 @@ export const tableRowKeys = { 'rows', { roleImpersonation: roleImpersonationState?.role, ...args }, ] as const, - tableRowsCount: (projectRef?: string, { table, ...args }: TableRowKeyArgs = {}) => + tableRowsCount: (projectRef?: string, { table, ...args }: any = {}) => ['projects', projectRef, 'table-rows', table?.id, 'count', args] as const, tableRowsAndCount: (projectRef?: string, tableId?: number) => ['projects', projectRef, 'table-rows', tableId] as const, diff --git a/apps/studio/lib/ai/prompts.ts b/apps/studio/lib/ai/prompts.ts index 83b00581dc0d1..887832b66a3c7 100644 --- a/apps/studio/lib/ai/prompts.ts +++ b/apps/studio/lib/ai/prompts.ts @@ -287,6 +287,281 @@ export const PG_BEST_PRACTICES = ` - Use \`create or replace function\` whenever possible. ` +export const REALTIME_PROMPT = ` +# Supabase Realtime Implementation Guide + +## Core Rules + +### Do +- Use \`broadcast\` for all realtime events (database changes via triggers, messaging, notifications, game state) +- Use \`presence\` sparingly for user state tracking (online status, user counters) +- Create indexes for all columns used in RLS policies +- Use topic names that correlate with concepts and tables: \`scope:entity\` (e.g., \`room:123:messages\`) +- Use snake_case for event names: \`entity_action\` (e.g., \`message_created\`) +- Include unsubscribe/cleanup logic in all implementations +- Set \`private: true\` for channels using database triggers or RLS policies +- Prefer private channels over public channels for better security and control +- Implement proper error handling and reconnection logic + +### Don't +- Use \`postgres_changes\` for new applications (single-threaded, doesn't scale well) +- Create multiple subscriptions without proper cleanup +- Write complex RLS queries without proper indexing +- Use generic event names like "update" or "change" +- Subscribe directly in render functions without state management +- Use database functions (\`realtime.send\`, \`realtime.broadcast_changes\`) in client code + +## Function Selection +- **Custom payloads with business logic:** Use \`broadcast\` +- **Database change notifications:** Use \`broadcast\` via database triggers +- **High-frequency updates:** Use \`broadcast\` with minimal payload +- **User presence/status tracking:** Use \`presence\` (sparingly) +- **Client to client communication:** Use \`broadcast\` without triggers + +**Note:** Avoid \`postgres_changes\` due to scalability limitations. Use \`broadcast\` with database triggers for all database change notifications. + +## Naming Conventions + +### Topics (Channels) +- **Pattern:** \`scope:entity\` or \`scope:entity:id\` +- **Examples:** \`room:123:messages\`, \`game:456:moves\`, \`user:789:notifications\` +- **One topic per room/user/organization for better performance and scalability** + +### Events +- **Pattern:** \`entity_action\` (snake_case) +- **Examples:** \`message_created\`, \`user_joined\`, \`game_ended\`, \`status_changed\` + +## Database Triggers + +### Using realtime.broadcast_changes (Recommended for database changes) +\`\`\`sql +CREATE OR REPLACE FUNCTION room_messages_broadcast_trigger() +RETURNS TRIGGER AS $$ +SECURITY DEFINER +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM realtime.broadcast_changes( + 'room:' || COALESCE(NEW.room_id, OLD.room_id)::text, + TG_OP, + TG_OP, + TG_TABLE_NAME, + TG_TABLE_SCHEMA, + NEW, + OLD + ); + RETURN COALESCE(NEW, OLD); +END; +$$; + +CREATE TRIGGER messages_broadcast_trigger + AFTER INSERT OR UPDATE OR DELETE ON messages + FOR EACH ROW EXECUTE FUNCTION room_messages_broadcast_trigger(); +\`\`\` + +**Note:** \`realtime.broadcast_changes\` requires private channels by default. + +### Using realtime.send (For custom messages) +\`\`\`sql +CREATE OR REPLACE FUNCTION notify_custom_event() +RETURNS TRIGGER AS $$ +SECURITY DEFINER +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM realtime.send( + 'room:' || NEW.room_id::text, + 'status_changed', + jsonb_build_object('id', NEW.id, 'status', NEW.status), + false -- set to true for private channels + ); + RETURN NEW; +END; +$$; +\`\`\` + +### Conditional Broadcasting +\`\`\`sql +-- Only broadcast significant changes +IF TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM NEW.status THEN + PERFORM realtime.broadcast_changes( + 'room:' || NEW.room_id::text, + TG_OP, + TG_OP, + TG_TABLE_NAME, + TG_TABLE_SCHEMA, + NEW, + OLD + ); +END IF; +\`\`\` + +## Authorization Setup + +### RLS Policies on realtime.messages + +#### Allow Users to Receive Broadcasts (SELECT) +\`\`\`sql +CREATE POLICY "room_members_can_read" ON realtime.messages +FOR SELECT TO authenticated +USING ( + topic LIKE 'room:%' AND + EXISTS ( + SELECT 1 FROM room_members + WHERE user_id = auth.uid() + AND room_id = SPLIT_PART(topic, ':', 2)::uuid + ) +); + +-- Required index for performance +CREATE INDEX idx_room_members_user_room ON room_members(user_id, room_id); +\`\`\` + +#### Allow Users to Send Broadcasts (INSERT) +\`\`\`sql +CREATE POLICY "room_members_can_write" ON realtime.messages +FOR INSERT TO authenticated +WITH CHECK ( + topic LIKE 'room:%' AND + EXISTS ( + SELECT 1 FROM room_members + WHERE user_id = auth.uid() + AND room_id = SPLIT_PART(topic, ':', 2)::uuid + ) +); +\`\`\` + +## Client Implementation + +### Broadcasting from Client +You can send broadcast messages using the Supabase client libraries: + +\`\`\`javascript +const myChannel = supabase.channel('room:123:messages', { + config: { private: true } +}) + +// Sending before subscribing uses HTTP +myChannel.send({ + type: 'broadcast', + event: 'message_created', + payload: { message: 'Hello', user_id: 123 }, +}) + +// Sending after subscribing uses WebSockets (recommended) +myChannel.subscribe((status) => { + if (status !== 'SUBSCRIBED') return + + myChannel.send({ + type: 'broadcast', + event: 'message_created', + payload: { message: 'Hello', user_id: 123 }, + }) +}) +\`\`\` + +**Note:** Sending messages after subscribing uses WebSockets and is more efficient than HTTP for real-time communication. + +### React Pattern +\`\`\`javascript +const channelRef = useRef(null) + +useEffect(() => { + // Check if already subscribed to prevent multiple subscriptions + if (channelRef.current?.state === 'subscribed') return + + const channel = supabase.channel('room:123:messages', { + config: { private: true } + }) + channelRef.current = channel + + // Set auth before subscribing + await supabase.realtime.setAuth() + + channel + .on('broadcast', { event: 'message_created' }, handleMessage) + .subscribe() + + return () => { + if (channelRef.current) { + supabase.removeChannel(channelRef.current) + channelRef.current = null + } + } +}, [roomId]) +\`\`\` + +### Channel Configuration +\`\`\`javascript +const channel = supabase.channel('room:123:messages', { + config: { + broadcast: { self: true, ack: true }, + presence: { key: 'user-session-id' }, + private: true // Required for RLS authorization + } +}) +\`\`\` + +## Best Practices + +### Scalability +- **Use dedicated, granular topics** - Messages only reach interested clients +- **One topic per room:** \`room:123:messages\` +- **One topic per user:** \`user:456:notifications\` +- **Avoid broad topics** that broadcast to all users + +### Security +- **Enable private-only channels** in Realtime Settings for production +- **Always use \`private: true\`** for database-triggered channels +- **Create separate RLS policies** for SELECT (receive) and INSERT (send) operations +- **Index columns used in RLS policies** for performance + +### Performance +- **Check channel state before subscribing** to prevent duplicate subscriptions +- **Include cleanup logic** - Always unsubscribe when component unmounts +- **Use \`SECURITY DEFINER\`** for trigger functions +- **Add conditional logic** to broadcast only significant changes + +## Migration from postgres_changes + +### Replace Client Code +\`\`\`javascript +// ❌ Old: postgres_changes +const oldChannel = supabase + .channel('changes') + .on('postgres_changes', { event: '*', schema: 'public', table: 'messages' }, callback) + +// ✅ New: broadcast +const newChannel = supabase + .channel(\`messages:\${room_id}:changes\`, { config: { private: true } }) + .on('broadcast', { event: 'INSERT' }, callback) + .on('broadcast', { event: 'UPDATE' }, callback) + .on('broadcast', { event: 'DELETE' }, callback) +\`\`\` + +### Add Database Trigger +\`\`\`sql +CREATE TRIGGER messages_broadcast_trigger + AFTER INSERT OR UPDATE OR DELETE ON messages + FOR EACH ROW EXECUTE FUNCTION room_messages_broadcast_trigger(); +\`\`\` + +### Setup Authorization +\`\`\`sql +CREATE POLICY "users_can_receive_broadcasts" ON realtime.messages + FOR SELECT TO authenticated USING (true); +\`\`\` + +## Implementation Workflow +1. Understand the use case (messaging, notifications, game state, etc.) +2. Determine if database triggers are needed or client-only messaging +3. Create RLS policies on \`realtime.messages\` for SELECT and INSERT +4. If using database triggers, create trigger functions using \`realtime.broadcast_changes\` or \`realtime.send\` +5. Add indexes for columns used in RLS policies +6. Implement client code with proper cleanup and state management +7. Enable private-only channels in Realtime Settings for production +` + export const GENERAL_PROMPT = ` # Role and Objective Act as a Supabase Postgres expert to assist users in efficiently managing their Supabase projects. diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index e67c7d2ffc33b..006da3a2ccdef 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -15,6 +15,7 @@ import { GENERAL_PROMPT, PG_BEST_PRACTICES, RLS_PROMPT, + REALTIME_PROMPT, SECURITY_PROMPT, LIMITATIONS_PROMPT, } from 'lib/ai/prompts' @@ -177,6 +178,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { ${PG_BEST_PRACTICES} ${RLS_PROMPT} ${EDGE_FUNCTION_PROMPT} + ${REALTIME_PROMPT} ${SECURITY_PROMPT} ${LIMITATIONS_PROMPT} ` diff --git a/apps/studio/styles/typography.scss b/apps/studio/styles/typography.scss index 55a5ca29acef7..ab28b715c7131 100644 --- a/apps/studio/styles/typography.scss +++ b/apps/studio/styles/typography.scss @@ -72,6 +72,6 @@ /* Link */ .text-link { - @apply text-foreground-light underline underline-offset-4 decoration-border hover:decoration-foreground transition-colors hover:text-foreground; + @apply text-foreground-light underline underline-offset-4 decoration-inherit hover:decoration-foreground transition-colors hover:text-foreground; } } diff --git a/apps/www/components/Blog/BlogPostRenderer.tsx b/apps/www/components/Blog/BlogPostRenderer.tsx index 95cb9021e8ad8..aa9864665f0c2 100644 --- a/apps/www/components/Blog/BlogPostRenderer.tsx +++ b/apps/www/components/Blog/BlogPostRenderer.tsx @@ -127,9 +127,11 @@ const BlogPostRenderer = ({

    {label}

    -
    +
    {'title' in post && ( -

    {(post as { title?: string }).title}

    +

    + {(post as { title?: string }).title} +

    )} {'formattedDate' in post && (

    {(post as { formattedDate?: string }).formattedDate}

    @@ -306,7 +308,7 @@ const BlogPostRenderer = ({ formattedDate: string } } - label="Last post" + label="Previous post" /> )}