mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 20:35:07 +03:00
App-level fixes that reproduce on BOTH the Next and TanStack builds — split out of #47657 (which stays TanStack-only) for reviewability. All were found by a full-site click-through of the dashboard. ## Invalid HTML nesting (React 19 "will cause a hydration error" console errors) - **FormLayout description rendered in a `<p>`** (`packages/ui-patterns`): consumers pass arbitrary JSX (the RowEditor's `created_at` timezone note passes a `<div>` with `<p>`s) → `<p>`-in-`<p>` / `<div>`-in-`<p>`. Container is now a `<div>` with identical classes (Tailwind preflight makes them render the same). - **Switch toggles nested inside Tooltip trigger buttons** (button-in-button) in ColumnEditor ("Allow Nullable" + "Is Unique"), ExtensionRow, and PublicationsTableItem → repo-standard `TooltipTrigger asChild` + `<div>` wrapper. - **Saved log queries rendered a `<div>` directly inside `<tbody>`** (`/logs/explorer/saved`) → rows are now proper `<tr><td colSpan>` wrappers; the component itself is untouched (it's valid in its sidebar usage). - **Nested anchors in observability metric cards**: a card-level `<Link>` wrapped MetricCard's "More information" `<Link>` (identical URLs) → the chevron affordance renders as a `<span>` when no `href` is passed; clicks bubble to the card link, tooltips preserved. Design-system standalone usage unaffected. - **`objectFit="cover"` passed to modern `next/image`** on the featured integration card (unknown-prop warning) — the className already had `object-cover`; prop dropped. ## Ghost dead-snippet after deletion Deleting the active SQL snippet left its id in `useDashboardHistory` (`history.sql`), so the "SQL Editor" nav item navigated to `/sql/<deleted-id>` — content fetch 404s, no editor pane renders, and a phantom tab reappears. Fixed both ends: delete flows now purge dashboard history (and the tabs store clears a stale `previewTabId`), and `/sql/[id]` treats a snippet 404 as "clean up + `router.replace` to `/sql/new` + toast" instead of rendering the dead state. Unit tests for the store/history cleanup. ## `pg-meta` migrations query 400s on every project load `ActivityStats` on project home runs the migrations list query, whose SQL was a bare `select * from supabase_migrations.schema_migrations` — that table only exists once a migration has run, so every other project logged a failed `?key=migrations` request on every load (visible in production consoles too). The SQL is now guarded with `to_regclass` + `query_to_xml` (same pattern as the advisor lints' `storage.buckets` guard), returning zero rows instead of erroring; legacy version-only tables still work. Tested against real dockerized Postgres (absent table, populated ordering, special chars, legacy schema) + MSW hook tests. Found and verified via /test-supabase-local (browser click-through + console audit on both builds). ## To test Console must stay free of React DOM-nesting errors ("cannot be a descendant of" / "cannot contain a nested") on each surface: 1. Table editor → Insert row panel (`created_at` field renders its timezone note) and Edit column panel ("Allow Nullable"/"Is Unique" tooltips still hover). 2. `/database/extensions` and `/database/publications` → toggle switches render, tooltips hover. 3. `/logs/explorer/saved` (with ≥1 saved query) → rows render full-width inside the table, hover shows Actions. 4. `/observability` → no nested-anchor error on load; card body click and the chevron both navigate; label help-icons still show tooltips. 5. `/integrations` → no `objectFit` unknown-prop warning; featured card images still cover. 6. **Ghost snippet**: open a SQL snippet → delete it via the sidebar → click the "SQL Editor" nav item → lands on `/sql/new` (no phantom tab, no 404 content fetch). Direct-load `/sql/<random-uuid>` → toast + redirect to `/sql/new`. 7. **Migrations 400**: load project home with a project that has never run a migration → the `pg-meta/<ref>/query?key=migrations` request returns **200** with `[]` (previously a 400 on every load). Database → Migrations still lists real migrations when they exist. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **Bug Fixes** * Deleted SQL snippets are fully removed from dashboard history and stale editor/tab state; users are redirected with a toast. * Closing preview tabs no longer leaves stale references. * Improved toggle/tooltip/dialog interactions to avoid broken UI, including metric headers showing tooltips even without direct links. * Migrations display safely when migration tables/relations are missing. * **UI Improvements** * Refreshed layout for saved queries, form descriptions, and integration imagery. * **Tests** * Added coverage for snippet history cleanup, tab removal, migrations SQL behavior, and query edge cases. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --- ### Review feedback: `query_to_xml` breaks on Multigres (Ivan) The defensive migrations query (added here to stop the `?key=migrations` 400 when the table doesn't exist yet) originally guarded with `query_to_xml`, which is forbidden through Multigres's pooler (MUL-736 / PSQL-1318). Rewritten without `query_to_xml`/`xmltable` using the splinter#170 pattern: a PL/pgSQL `do` block guarded by `to_regclass` (PL/pgSQL defers planning, so a missing table never errors) stashes the rows into a transaction-local GUC via `set_config`, and a trailing `select` reads them back with `jsonb_array_elements`. Verified that postgres-meta sends the whole SQL as one simple-query string → single implicit transaction → the local GUC survives to the `select` and doesn't leak into the pooled connection. 6/6 dockerized-Postgres tests (absent table → `[]`, populated/ordered/special-chars, legacy version-only table, full pg-meta-shaped multi-statement string, GUC non-leakage). Note (out of scope, pre-existing): `packages/pg-meta/src/sql/studio/advisor/lints.ts` still uses `query_to_xml` — a separate pre-existing Multigres risk that should get its own splinter-pattern sync. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> Co-authored-by: Joshen Lim <joshenlimek@gmail.com> Co-authored-by: Saxon Fletcher <saxonafletcher@gmail.com>
212 lines
7.7 KiB
TypeScript
212 lines
7.7 KiB
TypeScript
import { useFlag, useParams } from 'common'
|
|
import { useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Button,
|
|
cn,
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogSection,
|
|
DialogSectionSeparator,
|
|
DialogTitle,
|
|
TextArea,
|
|
} from 'ui'
|
|
import { Admonition } from 'ui-patterns/admonition'
|
|
|
|
import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip'
|
|
import { CANCELLATION_REASONS } from '@/components/interfaces/Billing/Billing.constants'
|
|
import { useSendDowngradeFeedbackMutation } from '@/data/feedback/exit-survey-send'
|
|
import { getComputeSize, OrgProject } from '@/data/projects/org-projects-infinite-query'
|
|
import { useOrgSubscriptionUpdateMutation } from '@/data/subscriptions/org-subscription-update-mutation'
|
|
|
|
export interface ExitSurveyModalProps {
|
|
visible: boolean
|
|
projects: OrgProject[]
|
|
onClose: (success?: boolean) => void
|
|
}
|
|
|
|
// [Joshen] For context - Exit survey is only when going to Free Plan from a paid plan
|
|
export const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalProps) => {
|
|
const { slug } = useParams()
|
|
|
|
const [message, setMessage] = useState('')
|
|
const [selectedReason, setSelectedReason] = useState<string[]>([])
|
|
|
|
const subscriptionUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
|
|
const { mutate: updateOrgSubscription, isPending: isUpdating } = useOrgSubscriptionUpdateMutation(
|
|
{
|
|
onError: (error) => {
|
|
toast.error(`Failed to downgrade project: ${error.message}`)
|
|
},
|
|
}
|
|
)
|
|
const { mutateAsync: sendExitSurvey, isPending: isSubmittingFeedback } =
|
|
useSendDowngradeFeedbackMutation()
|
|
const isSubmitting = isUpdating || isSubmittingFeedback
|
|
|
|
const projectsWithComputeDowngrade = projects.filter((project) => {
|
|
const computeSize = getComputeSize(project)
|
|
return computeSize !== 'nano'
|
|
})
|
|
|
|
const hasProjectsWithComputeDowngrade = projectsWithComputeDowngrade.length > 0
|
|
|
|
const [shuffledReasons] = useState(() => [
|
|
...CANCELLATION_REASONS.sort(() => Math.random() - 0.5),
|
|
{ value: 'None of the above' },
|
|
])
|
|
|
|
const onSelectCancellationReason = (reason: string) => {
|
|
setSelectedReason([reason])
|
|
}
|
|
|
|
// Helper to get label for selected reason
|
|
const getReasonLabel = (reason: string | undefined) => {
|
|
const found = CANCELLATION_REASONS.find((r) => r.value === reason)
|
|
return found?.label || 'What can we improve on?'
|
|
}
|
|
|
|
const textareaLabel = getReasonLabel(selectedReason[0])
|
|
|
|
const onSubmit = async () => {
|
|
if (selectedReason.length === 0) {
|
|
return toast.error('Please select a reason for canceling your subscription')
|
|
}
|
|
|
|
await downgradeOrganization()
|
|
}
|
|
|
|
const downgradeOrganization = async () => {
|
|
// Update the subscription first, followed by posting the exit survey if successful
|
|
// If compute instance is present within the existing subscription, then a restart will be triggered
|
|
if (!slug) return console.error('Slug is required')
|
|
|
|
updateOrgSubscription(
|
|
{ slug, tier: 'tier_free' },
|
|
{
|
|
onSuccess: async () => {
|
|
try {
|
|
await sendExitSurvey({
|
|
orgSlug: slug,
|
|
reasons: selectedReason.reduce((a, b) => `${a}- ${b}\n`, ''),
|
|
message,
|
|
exitAction: 'downgrade',
|
|
})
|
|
} catch (error) {
|
|
// [Joshen] In this case we don't raise any errors if the exit survey fails to send since it shouldn't block the user
|
|
} finally {
|
|
toast.success(
|
|
hasProjectsWithComputeDowngrade
|
|
? 'Successfully downgraded organization to the Free Plan. Your projects are currently restarting to update their compute instances.'
|
|
: 'Successfully downgraded organization to the Free Plan',
|
|
{ duration: hasProjectsWithComputeDowngrade ? 8000 : 4000 }
|
|
)
|
|
onClose(true)
|
|
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
|
|
}
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Dialog open={visible} onOpenChange={onClose}>
|
|
<DialogContent size="xlarge">
|
|
<DialogHeader>
|
|
<DialogTitle>Help us improve</DialogTitle>
|
|
</DialogHeader>
|
|
<DialogSectionSeparator />
|
|
<DialogSection>
|
|
<div className="flex flex-col space-y-4">
|
|
<p className="text-sm text-foreground-light">
|
|
What made you decide to downgrade your plan?
|
|
</p>
|
|
<div className="space-y-8 mt-6">
|
|
<div className="flex flex-wrap gap-2" data-toggle="buttons">
|
|
{shuffledReasons.map((option) => {
|
|
const active = selectedReason[0] === option.value
|
|
return (
|
|
<label
|
|
key={option.value}
|
|
className={cn(
|
|
'flex cursor-pointer items-center space-x-2 rounded-md py-1',
|
|
'pl-2 pr-3 text-center text-sm',
|
|
'shadow-xs transition-all duration-100',
|
|
active
|
|
? `bg-foreground text-background opacity-100 hover:bg-foreground/75`
|
|
: `bg-border-strong text-foreground opacity-75 hover:opacity-100`
|
|
)}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="options"
|
|
value={option.value}
|
|
className="hidden"
|
|
checked={active}
|
|
onChange={() => onSelectCancellationReason(option.value)}
|
|
/>
|
|
<div>{option.value}</div>
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
<div className="text-area-text-sm flex flex-col gap-y-2">
|
|
<label htmlFor="message" className="text-sm whitespace-pre-line wrap-break-word">
|
|
{textareaLabel}
|
|
</label>
|
|
<TextArea
|
|
id="message"
|
|
name="message"
|
|
value={message}
|
|
onChange={(event) => setMessage(event.target.value)}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{hasProjectsWithComputeDowngrade && (
|
|
<Admonition
|
|
type="warning"
|
|
layout="horizontal"
|
|
title={`${projectsWithComputeDowngrade.length} of your projects will be restarted upon clicking confirm,`}
|
|
description={
|
|
<>
|
|
This is due to changes in compute instances from the downgrade. Affected
|
|
projects include{' '}
|
|
{projectsWithComputeDowngrade.map((project) => project.name).join(', ')}.
|
|
</>
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between border-t px-4 py-4">
|
|
<p className="text-xs text-foreground-lighter">
|
|
The unused amount for the remaining time of your billing cycle will be refunded as
|
|
credits
|
|
</p>
|
|
</div>
|
|
</DialogSection>
|
|
|
|
<DialogFooter>
|
|
<Button variant="default" onClick={() => onClose()}>
|
|
Cancel
|
|
</Button>
|
|
<ProjectUpdateDisabledTooltip projectUpdateDisabled={subscriptionUpdateDisabled}>
|
|
<Button
|
|
variant="danger"
|
|
className="pointer-events-auto"
|
|
loading={isSubmitting}
|
|
disabled={subscriptionUpdateDisabled || isSubmitting}
|
|
onClick={onSubmit}
|
|
>
|
|
Downgrade Now
|
|
</Button>
|
|
</ProjectUpdateDisabledTooltip>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|