mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
fix(studio): prompt on unsaved changes in foreign key selector (#43888)
## What kind of change does this PR introduce? Bug fix. Resolves FE-2802. Fixes #43882. ## What is the current behavior? Second-level foreign key side panels in Table Editor can be dismissed while dirty without a discard confirmation. That makes accidental dismissal easy and is inconsistent with the shared dirty-form dismissal pattern from FE-2802. ## What is the new behavior? - Adds `DiscardChangesConfirmationDialog` handling to `ForeignKeySelector` - Guards all selector close paths behind a local dirty-state check - Normalizes foreign key state so derived type metadata does not create false dirty prompts - Clarifies in `modality.mdx` that route-driven/page-unload flows should use a navigation guard rather than the dialog/sheet close guard | Dirty State | Dialog | | --- | --- | | <img width="1502" height="899" alt="test Table Editor AWS Healthy Toolshed Supabase-D1EA9D5F-46EB-4DB5-830D-7CEF36A55B7E" src="https://github.com/user-attachments/assets/3d866850-d062-4fb7-9949-2fd713e6088f" /> | <img width="1502" height="899" alt="test Table Editor AWS Healthy Toolshed Supabase" src="https://github.com/user-attachments/assets/1396361d-1d44-4206-862f-3379ea22be30" /> | ## Additional context Includes a focused unit test for foreign key dirty-state normalization.
This commit is contained in:
1 parent
fd17b246e1
commit
676c237422
4 files changed
+462
-319
No files matched your search
@@ -96,6 +96,7 @@ Implementation checklist:
|
||||
- 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.
|
||||
- If dismissal is route-driven or tied to page unload, use a navigation guard that composes the same discard-confirmation UI instead of extending the dialog/sheet close guard.
|
||||
|
||||
Studio implementation (preferred in Studio code):
|
||||
|
||||
|
||||
+343
-319
@@ -1,5 +1,6 @@
|
||||
import { FOREIGN_KEY_CASCADE_ACTION } from '@supabase/pg-meta'
|
||||
import type { PostgresTable } from '@supabase/postgres-meta'
|
||||
import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import { useSchemasQuery } from 'data/database/schemas-query'
|
||||
@@ -7,6 +8,7 @@ import { useTableQuery } from 'data/tables/table-retrieve-query'
|
||||
import { useTablesQuery } from 'data/tables/tables-query'
|
||||
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { sortBy } from 'lodash'
|
||||
@@ -26,7 +28,12 @@ import { NUMERICAL_TYPES, TEXT_TYPES } from '../SidePanelEditor.constants'
|
||||
import type { ColumnField } from '../SidePanelEditor.types'
|
||||
import { FOREIGN_KEY_CASCADE_OPTIONS } from './ForeignKeySelector.constants'
|
||||
import type { ForeignKey, SelectorErrors, SelectorTypeError } from './ForeignKeySelector.types'
|
||||
import { generateCascadeActionDescription } from './ForeignKeySelector.utils'
|
||||
import {
|
||||
generateCascadeActionDescription,
|
||||
hasForeignKeySelectorChanges,
|
||||
normalizeForeignKeyForDirtyCheck,
|
||||
type ForeignKeyDirtyState,
|
||||
} from './ForeignKeySelector.utils'
|
||||
|
||||
const EMPTY_STATE: ForeignKey = {
|
||||
id: undefined,
|
||||
@@ -63,6 +70,7 @@ export const ForeignKeySelector = ({
|
||||
|
||||
const [fk, setFk] = useState(EMPTY_STATE)
|
||||
const [errors, setErrors] = useState<SelectorErrors>({})
|
||||
const [initialSnapshot, setInitialSnapshot] = useState<ForeignKeyDirtyState>()
|
||||
const hasTypeErrors = (errors.types ?? []).length > 0
|
||||
const hasTypeNotices = (errors.typeNotice ?? []).length > 0
|
||||
|
||||
@@ -93,6 +101,11 @@ export const ForeignKeySelector = ({
|
||||
|
||||
const disableApply = isLoadingSelectedTable || selectedTable === undefined || hasTypeErrors
|
||||
|
||||
const { confirmOnClose, modalProps } = useConfirmOnClose({
|
||||
checkIsDirty: () => hasForeignKeySelectorChanges(initialSnapshot, fk),
|
||||
onClose,
|
||||
})
|
||||
|
||||
const updateSelectedSchema = (schema: string) => {
|
||||
const updatedFk = { ...EMPTY_STATE, id: fk.id, schema }
|
||||
setFk(updatedFk)
|
||||
@@ -203,8 +216,11 @@ export const ForeignKeySelector = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (foreignKey !== undefined) setFk(foreignKey)
|
||||
else setFk({ ...EMPTY_STATE, id: uuidv4() })
|
||||
const initialFk = foreignKey !== undefined ? foreignKey : { ...EMPTY_STATE, id: uuidv4() }
|
||||
|
||||
setErrors({})
|
||||
setFk(initialFk)
|
||||
setInitialSnapshot(normalizeForeignKeyForDirtyCheck(initialFk))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible])
|
||||
@@ -215,137 +231,176 @@ export const ForeignKeySelector = ({
|
||||
}, [fk, visible])
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
className="max-w-[480px]"
|
||||
header={`${foreignKey === undefined ? 'Add' : 'Manage'} foreign key relationship${foreignKey === undefined ? ' to' : 's for'} ${table.name.length > 0 ? table.name : 'new table'}`}
|
||||
customFooter={
|
||||
<ActionBar
|
||||
backButtonLabel="Cancel"
|
||||
disableApply={disableApply}
|
||||
applyButtonLabel="Save"
|
||||
closePanel={onClose}
|
||||
applyFunction={(resolve) => validateSelection(resolve)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SidePanel.Content>
|
||||
<div className="py-6 space-y-6">
|
||||
<InformationBox
|
||||
icon={<HelpCircle size={20} strokeWidth={1.5} />}
|
||||
title="What are foreign keys?"
|
||||
description={`Foreign keys help maintain referential integrity of your data by ensuring that no
|
||||
<>
|
||||
<SidePanel
|
||||
visible={visible}
|
||||
onCancel={confirmOnClose}
|
||||
className="max-w-[480px]"
|
||||
header={`${foreignKey === undefined ? 'Add' : 'Manage'} foreign key relationship${foreignKey === undefined ? ' to' : 's for'} ${table.name.length > 0 ? table.name : 'new table'}`}
|
||||
customFooter={
|
||||
<ActionBar
|
||||
backButtonLabel="Cancel"
|
||||
disableApply={disableApply}
|
||||
applyButtonLabel="Save"
|
||||
closePanel={confirmOnClose}
|
||||
applyFunction={(resolve) => validateSelection(resolve)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SidePanel.Content>
|
||||
<div className="py-6 space-y-6">
|
||||
<InformationBox
|
||||
icon={<HelpCircle size={20} strokeWidth={1.5} />}
|
||||
title="What are foreign keys?"
|
||||
description={`Foreign keys help maintain referential integrity of your data by ensuring that no
|
||||
one can insert rows into the table that do not have a matching entry to another
|
||||
table.`}
|
||||
url="https://www.postgresql.org/docs/current/tutorial-fk.html"
|
||||
urlLabel="Postgres Foreign Key Documentation"
|
||||
/>
|
||||
url="https://www.postgresql.org/docs/current/tutorial-fk.html"
|
||||
urlLabel="Postgres Foreign Key Documentation"
|
||||
/>
|
||||
|
||||
<Listbox
|
||||
id="schema"
|
||||
label="Select a schema"
|
||||
value={fk.schema}
|
||||
onChange={(value: string) => updateSelectedSchema(value)}
|
||||
>
|
||||
{sortedSchemas.map((schema) => {
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={schema.id}
|
||||
value={schema.name}
|
||||
label={schema.name}
|
||||
className="min-w-96"
|
||||
addOnBefore={() => <Database size={16} strokeWidth={1.5} />}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* For aria searching to target the schema name instead of schema */}
|
||||
<span className="hidden">{schema.name}</span>
|
||||
<span className="text-foreground">{schema.name}</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</Listbox>
|
||||
|
||||
<Listbox
|
||||
id="table"
|
||||
label="Select a table to reference to"
|
||||
value={selectedTable?.id ?? 1}
|
||||
onChange={(value: string) => updateSelectedTable(Number(value))}
|
||||
disabled={isLoadingSelectedTable}
|
||||
>
|
||||
<Listbox.Option key="empty" className="min-w-96" value={1} label="---">
|
||||
---
|
||||
</Listbox.Option>
|
||||
{sortBy(tables, ['schema']).map((table) => {
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={table.id}
|
||||
value={table.id}
|
||||
label={table.name}
|
||||
className="min-w-96"
|
||||
addOnBefore={() => <Table size={16} strokeWidth={1.5} />}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* For aria searching to target the table name instead of schema */}
|
||||
<span className="hidden">{table.name}</span>
|
||||
<span className="text-foreground-lighter">{table.schema}</span>
|
||||
<span className="text-foreground">{table.name}</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</Listbox>
|
||||
|
||||
{fk.schema && fk.table && (
|
||||
<>
|
||||
{isLoadingSelectedTable ? (
|
||||
<div className="flex py-6 flex-col items-center justify-center space-y-2">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<p className="text-sm text-foreground-light">Loading table columns</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<label className="text-foreground-light text-sm">
|
||||
Select columns from{' '}
|
||||
<code className="text-code-inline">
|
||||
{fk.schema}.{fk.table}
|
||||
</code>{' '}
|
||||
to reference to
|
||||
</label>
|
||||
<div className="grid grid-cols-10 gap-y-2">
|
||||
<div className="col-span-5 text-xs text-foreground-lighter">
|
||||
{selectedSchema}.{table.name.length > 0 ? table.name : '[unnamed table]'}
|
||||
<Listbox
|
||||
id="schema"
|
||||
label="Select a schema"
|
||||
value={fk.schema}
|
||||
onChange={(value: string) => updateSelectedSchema(value)}
|
||||
>
|
||||
{sortedSchemas.map((schema) => {
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={schema.id}
|
||||
value={schema.name}
|
||||
label={schema.name}
|
||||
className="min-w-96"
|
||||
addOnBefore={() => <Database size={16} strokeWidth={1.5} />}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* For aria searching to target the schema name instead of schema */}
|
||||
<span className="hidden">{schema.name}</span>
|
||||
<span className="text-foreground">{schema.name}</span>
|
||||
</div>
|
||||
<div className="col-span-4 text-xs text-foreground-lighter text-right">
|
||||
{fk.schema}.{fk.table}
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</Listbox>
|
||||
|
||||
<Listbox
|
||||
id="table"
|
||||
label="Select a table to reference to"
|
||||
value={selectedTable?.id ?? 1}
|
||||
onChange={(value: string) => updateSelectedTable(Number(value))}
|
||||
disabled={isLoadingSelectedTable}
|
||||
>
|
||||
<Listbox.Option key="empty" className="min-w-96" value={1} label="---">
|
||||
---
|
||||
</Listbox.Option>
|
||||
{sortBy(tables, ['schema']).map((table) => {
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={table.id}
|
||||
value={table.id}
|
||||
label={table.name}
|
||||
className="min-w-96"
|
||||
addOnBefore={() => <Table size={16} strokeWidth={1.5} />}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* For aria searching to target the table name instead of schema */}
|
||||
<span className="hidden">{table.name}</span>
|
||||
<span className="text-foreground-lighter">{table.schema}</span>
|
||||
<span className="text-foreground">{table.name}</span>
|
||||
</div>
|
||||
{fk.columns.length === 0 && (
|
||||
<Alert_Shadcn_ className="col-span-10 py-2 px-3">
|
||||
<AlertDescription_Shadcn_>
|
||||
There are no foreign key relations between the tables
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{fk.columns.map((_, idx) => (
|
||||
<Fragment key={`${fk.schema}-${fk.table}-${idx}`}>
|
||||
<div className="col-span-4">
|
||||
<Listbox
|
||||
id="column"
|
||||
value={fk.columns[idx].source}
|
||||
onChange={(value: string) => updateSelectedColumn(idx, 'source', value)}
|
||||
>
|
||||
<Listbox.Option
|
||||
key="empty"
|
||||
value={''}
|
||||
label="---"
|
||||
className="!w-[170px]"
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</Listbox>
|
||||
|
||||
{fk.schema && fk.table && (
|
||||
<>
|
||||
{isLoadingSelectedTable ? (
|
||||
<div className="flex py-6 flex-col items-center justify-center space-y-2">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<p className="text-sm text-foreground-light">Loading table columns</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<label className="text-foreground-light text-sm">
|
||||
Select columns from{' '}
|
||||
<code className="text-code-inline">
|
||||
{fk.schema}.{fk.table}
|
||||
</code>{' '}
|
||||
to reference to
|
||||
</label>
|
||||
<div className="grid grid-cols-10 gap-y-2">
|
||||
<div className="col-span-5 text-xs text-foreground-lighter">
|
||||
{selectedSchema}.{table.name.length > 0 ? table.name : '[unnamed table]'}
|
||||
</div>
|
||||
<div className="col-span-4 text-xs text-foreground-lighter text-right">
|
||||
{fk.schema}.{fk.table}
|
||||
</div>
|
||||
{fk.columns.length === 0 && (
|
||||
<Alert_Shadcn_ className="col-span-10 py-2 px-3">
|
||||
<AlertDescription_Shadcn_>
|
||||
There are no foreign key relations between the tables
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{fk.columns.map((_, idx) => (
|
||||
<Fragment key={`${fk.schema}-${fk.table}-${idx}`}>
|
||||
<div className="col-span-4">
|
||||
<Listbox
|
||||
id="column"
|
||||
value={fk.columns[idx].source}
|
||||
onChange={(value: string) =>
|
||||
updateSelectedColumn(idx, 'source', value)
|
||||
}
|
||||
>
|
||||
---
|
||||
</Listbox.Option>
|
||||
{(table?.columns ?? [])
|
||||
.filter((x) => x.name.length !== 0)
|
||||
.map((column) => (
|
||||
<Listbox.Option
|
||||
key="empty"
|
||||
value={''}
|
||||
label="---"
|
||||
className="!w-[170px]"
|
||||
>
|
||||
---
|
||||
</Listbox.Option>
|
||||
{(table?.columns ?? [])
|
||||
.filter((x) => x.name.length !== 0)
|
||||
.map((column) => (
|
||||
<Listbox.Option
|
||||
key={column.id}
|
||||
value={column.name}
|
||||
label={column.name}
|
||||
className="!w-[170px]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-foreground">{column.name}</span>
|
||||
<span className="text-foreground-lighter">
|
||||
{column.format === '' ? '-' : column.format}
|
||||
</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-center items-center">
|
||||
<ArrowRight />
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<Listbox
|
||||
id="column"
|
||||
value={fk.columns[idx].target}
|
||||
onChange={(value: string) =>
|
||||
updateSelectedColumn(idx, 'target', value)
|
||||
}
|
||||
>
|
||||
<Listbox.Option
|
||||
key="empty"
|
||||
value={''}
|
||||
label="---"
|
||||
className="!w-[170px]"
|
||||
>
|
||||
---
|
||||
</Listbox.Option>
|
||||
{(selectedTable?.columns ?? []).map((column) => (
|
||||
<Listbox.Option
|
||||
key={column.id}
|
||||
value={column.name}
|
||||
@@ -354,208 +409,177 @@ export const ForeignKeySelector = ({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-foreground">{column.name}</span>
|
||||
<span className="text-foreground-lighter">
|
||||
{column.format === '' ? '-' : column.format}
|
||||
</span>
|
||||
<span className="text-foreground-lighter">{column.format}</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-center items-center">
|
||||
<ArrowRight />
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<Listbox
|
||||
id="column"
|
||||
value={fk.columns[idx].target}
|
||||
onChange={(value: string) => updateSelectedColumn(idx, 'target', value)}
|
||||
>
|
||||
<Listbox.Option
|
||||
key="empty"
|
||||
value={''}
|
||||
label="---"
|
||||
className="!w-[170px]"
|
||||
>
|
||||
---
|
||||
</Listbox.Option>
|
||||
{(selectedTable?.columns ?? []).map((column) => (
|
||||
<Listbox.Option
|
||||
key={column.id}
|
||||
value={column.name}
|
||||
label={column.name}
|
||||
className="!w-[170px]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-foreground">{column.name}</span>
|
||||
<span className="text-foreground-lighter">{column.format}</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-end items-center">
|
||||
<Button
|
||||
type="default"
|
||||
className="px-1"
|
||||
icon={<X />}
|
||||
disabled={fk.columns.length === 1}
|
||||
onClick={() => onRemoveColumn(idx)}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-end items-center">
|
||||
<Button
|
||||
type="default"
|
||||
className="px-1"
|
||||
icon={<X />}
|
||||
disabled={fk.columns.length === 1}
|
||||
onClick={() => onRemoveColumn(idx)}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Button type="default" onClick={addColumn}>
|
||||
Add another column
|
||||
</Button>
|
||||
{errors.columns && <p className="text-red-900 text-sm">{errors.columns}</p>}
|
||||
{hasTypeErrors && (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<AlertTitle_Shadcn_>Column types do not match</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
The following columns cannot be referenced as they are not of the same
|
||||
type:
|
||||
</AlertDescription_Shadcn_>
|
||||
<ul className="list-disc pl-5 mt-2 text-foreground-light">
|
||||
{(errors?.types ?? []).map((x, idx: number) => {
|
||||
if (x === undefined) return null
|
||||
return (
|
||||
<li key={`type-error-${idx}`}>
|
||||
<code className="text-code-inline">{x.source}</code> (
|
||||
{x.sourceType}) and{' '}
|
||||
<code className="text-code-inline">{x.target}</code>(
|
||||
{x.targetType})
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{hasTypeNotices && (
|
||||
<Alert_Shadcn_>
|
||||
<AlertTitle_Shadcn_>Column types will be updated</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
The following columns will have their types updated to match their
|
||||
referenced column
|
||||
</AlertDescription_Shadcn_>
|
||||
<ul className="list-disc pl-5 mt-2 text-foreground-light">
|
||||
{(errors?.typeNotice ?? []).map((x, idx: number) => {
|
||||
if (x === undefined) return null
|
||||
return (
|
||||
<li key={`type-error-${idx}`}>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<code className="text-code-inline">{x.source}</code>{' '}
|
||||
<ArrowRight size={14} /> {x.targetType}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Button type="default" onClick={addColumn}>
|
||||
Add another column
|
||||
</Button>
|
||||
{errors.columns && <p className="text-red-900 text-sm">{errors.columns}</p>}
|
||||
{hasTypeErrors && (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<AlertTitle_Shadcn_>Column types do not match</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
The following columns cannot be referenced as they are not of the same
|
||||
type:
|
||||
</AlertDescription_Shadcn_>
|
||||
<ul className="list-disc pl-5 mt-2 text-foreground-light">
|
||||
{(errors?.types ?? []).map((x, idx: number) => {
|
||||
if (x === undefined) return null
|
||||
return (
|
||||
<li key={`type-error-${idx}`}>
|
||||
<code className="text-code-inline">{x.source}</code> ({x.sourceType}
|
||||
) and <code className="text-code-inline">{x.target}</code>(
|
||||
{x.targetType})
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{hasTypeNotices && (
|
||||
<Alert_Shadcn_>
|
||||
<AlertTitle_Shadcn_>Column types will be updated</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
The following columns will have their types updated to match their
|
||||
referenced column
|
||||
</AlertDescription_Shadcn_>
|
||||
<ul className="list-disc pl-5 mt-2 text-foreground-light">
|
||||
{(errors?.typeNotice ?? []).map((x, idx: number) => {
|
||||
if (x === undefined) return null
|
||||
return (
|
||||
<li key={`type-error-${idx}`}>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<code className="text-code-inline">{x.source}</code>{' '}
|
||||
<ArrowRight size={14} /> {x.targetType}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{!isLoadingSelectedTable && (
|
||||
<>
|
||||
<SidePanel.Separator />
|
||||
{!isLoadingSelectedTable && (
|
||||
<>
|
||||
<SidePanel.Separator />
|
||||
|
||||
<InformationBox
|
||||
icon={<HelpCircle size="20" strokeWidth={1.5} />}
|
||||
title="Which action is most appropriate?"
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
The choice of the action depends on what kinds of objects the related
|
||||
tables represent:
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-4 space-y-1">
|
||||
<li>
|
||||
<code className="text-code-inline">Cascade</code>: if the referencing
|
||||
table represents something that is a component of what is represented by
|
||||
the referenced table and cannot exist independently
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-code-inline">Restrict</code> or{' '}
|
||||
<code className="text-code-inline">No action</code>: if the two tables
|
||||
represent independent objects
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-code-inline">Set NULL</code> or{' '}
|
||||
<code className="text-code-inline">Set default</code>: if a foreign-key
|
||||
relationship represents optional information
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-2">
|
||||
Typically, restricting and cascading deletes are the most common options,
|
||||
but the default behavior is no action
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
url="https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK"
|
||||
urlLabel="More information"
|
||||
/>
|
||||
<InformationBox
|
||||
icon={<HelpCircle size="20" strokeWidth={1.5} />}
|
||||
title="Which action is most appropriate?"
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
The choice of the action depends on what kinds of objects the related
|
||||
tables represent:
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-4 space-y-1">
|
||||
<li>
|
||||
<code className="text-code-inline">Cascade</code>: if the referencing
|
||||
table represents something that is a component of what is represented
|
||||
by the referenced table and cannot exist independently
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-code-inline">Restrict</code> or{' '}
|
||||
<code className="text-code-inline">No action</code>: if the two tables
|
||||
represent independent objects
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-code-inline">Set NULL</code> or{' '}
|
||||
<code className="text-code-inline">Set default</code>: if a
|
||||
foreign-key relationship represents optional information
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-2">
|
||||
Typically, restricting and cascading deletes are the most common
|
||||
options, but the default behavior is no action
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
url="https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK"
|
||||
urlLabel="More information"
|
||||
/>
|
||||
|
||||
<Listbox
|
||||
id="updateAction"
|
||||
value={fk.updateAction}
|
||||
label="Action if referenced row is updated"
|
||||
descriptionText={
|
||||
<p>
|
||||
{generateCascadeActionDescription(
|
||||
'update',
|
||||
fk.updateAction,
|
||||
`${fk.schema}.${fk.table}`
|
||||
)}
|
||||
</p>
|
||||
}
|
||||
onChange={(value: string) => updateCascadeAction('updateAction', value)}
|
||||
>
|
||||
{FOREIGN_KEY_CASCADE_OPTIONS.filter((option) =>
|
||||
['no-action', 'cascade', 'restrict'].includes(option.key)
|
||||
).map((option) => (
|
||||
<Listbox.Option key={option.key} value={option.value} label={option.label}>
|
||||
<p className="text-foreground">{option.label}</p>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
|
||||
<Listbox
|
||||
id="deletionAction"
|
||||
value={fk.deletionAction}
|
||||
className="[&>div>label]:flex [&>div>label]:items-center"
|
||||
label="Action if referenced row is removed"
|
||||
// @ts-ignore
|
||||
labelOptional={
|
||||
<DocsButton href={`${DOCS_URL}/guides/database/postgres/cascade-deletes`} />
|
||||
}
|
||||
descriptionText={
|
||||
<>
|
||||
<Listbox
|
||||
id="updateAction"
|
||||
value={fk.updateAction}
|
||||
label="Action if referenced row is updated"
|
||||
descriptionText={
|
||||
<p>
|
||||
{generateCascadeActionDescription(
|
||||
'delete',
|
||||
fk.deletionAction,
|
||||
'update',
|
||||
fk.updateAction,
|
||||
`${fk.schema}.${fk.table}`
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
onChange={(value: string) => updateCascadeAction('deletionAction', value)}
|
||||
>
|
||||
{FOREIGN_KEY_CASCADE_OPTIONS.map((option) => (
|
||||
<Listbox.Option key={option.key} value={option.value} label={option.label}>
|
||||
<p className="text-foreground">{option.label}</p>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
</SidePanel>
|
||||
}
|
||||
onChange={(value: string) => updateCascadeAction('updateAction', value)}
|
||||
>
|
||||
{FOREIGN_KEY_CASCADE_OPTIONS.filter((option) =>
|
||||
['no-action', 'cascade', 'restrict'].includes(option.key)
|
||||
).map((option) => (
|
||||
<Listbox.Option key={option.key} value={option.value} label={option.label}>
|
||||
<p className="text-foreground">{option.label}</p>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
|
||||
<Listbox
|
||||
id="deletionAction"
|
||||
value={fk.deletionAction}
|
||||
className="[&>div>label]:flex [&>div>label]:items-center"
|
||||
label="Action if referenced row is removed"
|
||||
// @ts-ignore
|
||||
labelOptional={
|
||||
<DocsButton href={`${DOCS_URL}/guides/database/postgres/cascade-deletes`} />
|
||||
}
|
||||
descriptionText={
|
||||
<>
|
||||
<p>
|
||||
{generateCascadeActionDescription(
|
||||
'delete',
|
||||
fk.deletionAction,
|
||||
`${fk.schema}.${fk.table}`
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
onChange={(value: string) => updateCascadeAction('deletionAction', value)}
|
||||
>
|
||||
{FOREIGN_KEY_CASCADE_OPTIONS.map((option) => (
|
||||
<Listbox.Option key={option.key} value={option.value} label={option.label}>
|
||||
<p className="text-foreground">{option.label}</p>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
</SidePanel>
|
||||
<DiscardChangesConfirmationDialog {...modalProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ForeignKey } from './ForeignKeySelector.types'
|
||||
import {
|
||||
hasForeignKeySelectorChanges,
|
||||
normalizeForeignKeyForDirtyCheck,
|
||||
} from './ForeignKeySelector.utils'
|
||||
|
||||
const BASE_FOREIGN_KEY: ForeignKey = {
|
||||
id: 'fk-1',
|
||||
name: 'messages_author_id_fkey',
|
||||
tableId: 42,
|
||||
schema: 'public',
|
||||
table: 'profiles',
|
||||
columns: [{ source: 'author_id', target: 'id' }],
|
||||
deletionAction: 'NO ACTION',
|
||||
updateAction: 'NO ACTION',
|
||||
}
|
||||
|
||||
describe('ForeignKeySelector dirty state', () => {
|
||||
it('is clean on first open', () => {
|
||||
const initialState = normalizeForeignKeyForDirtyCheck(BASE_FOREIGN_KEY)
|
||||
|
||||
expect(hasForeignKeySelectorChanges(initialState, BASE_FOREIGN_KEY)).toBe(false)
|
||||
})
|
||||
|
||||
it('is dirty after semantic foreign key changes', () => {
|
||||
const initialState = normalizeForeignKeyForDirtyCheck(BASE_FOREIGN_KEY)
|
||||
|
||||
expect(
|
||||
hasForeignKeySelectorChanges(initialState, {
|
||||
...BASE_FOREIGN_KEY,
|
||||
schema: 'storage',
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
hasForeignKeySelectorChanges(initialState, {
|
||||
...BASE_FOREIGN_KEY,
|
||||
table: 'users',
|
||||
tableId: 99,
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
hasForeignKeySelectorChanges(initialState, {
|
||||
...BASE_FOREIGN_KEY,
|
||||
columns: [{ source: 'owner_id', target: 'id' }],
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
hasForeignKeySelectorChanges(initialState, {
|
||||
...BASE_FOREIGN_KEY,
|
||||
deletionAction: 'CASCADE',
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
hasForeignKeySelectorChanges(initialState, {
|
||||
...BASE_FOREIGN_KEY,
|
||||
updateAction: 'CASCADE',
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores derived column type metadata when checking dirty state', () => {
|
||||
const initialState = normalizeForeignKeyForDirtyCheck(BASE_FOREIGN_KEY)
|
||||
|
||||
expect(
|
||||
hasForeignKeySelectorChanges(initialState, {
|
||||
...BASE_FOREIGN_KEY,
|
||||
columns: [
|
||||
{
|
||||
source: 'author_id',
|
||||
sourceType: 'uuid',
|
||||
target: 'id',
|
||||
targetType: 'uuid',
|
||||
},
|
||||
],
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
+34
@@ -1,11 +1,23 @@
|
||||
import { FOREIGN_KEY_CASCADE_ACTION } from '@supabase/pg-meta'
|
||||
import type { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query'
|
||||
import { isEqual } from 'lodash'
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { getForeignKeyCascadeAction } from '../ColumnEditor/ColumnEditor.utils'
|
||||
import type { ForeignKey } from './ForeignKeySelector.types'
|
||||
|
||||
export interface ForeignKeyDirtyState {
|
||||
id?: number | string
|
||||
name?: string
|
||||
tableId?: number
|
||||
schema: string
|
||||
table: string
|
||||
columns: { source: string; target: string }[]
|
||||
deletionAction: string
|
||||
updateAction: string
|
||||
}
|
||||
|
||||
export const formatForeignKeys = (fks: ForeignKeyConstraint[]): ForeignKey[] => {
|
||||
return fks.map((x) => {
|
||||
return {
|
||||
@@ -21,6 +33,28 @@ export const formatForeignKeys = (fks: ForeignKeyConstraint[]): ForeignKey[] =>
|
||||
})
|
||||
}
|
||||
|
||||
export const normalizeForeignKeyForDirtyCheck = (foreignKey: ForeignKey): ForeignKeyDirtyState => {
|
||||
return {
|
||||
id: foreignKey.id,
|
||||
name: foreignKey.name,
|
||||
tableId: foreignKey.tableId,
|
||||
schema: foreignKey.schema,
|
||||
table: foreignKey.table,
|
||||
columns: foreignKey.columns.map(({ source, target }) => ({ source, target })),
|
||||
deletionAction: foreignKey.deletionAction,
|
||||
updateAction: foreignKey.updateAction,
|
||||
}
|
||||
}
|
||||
|
||||
export const hasForeignKeySelectorChanges = (
|
||||
initialState: ForeignKeyDirtyState | undefined,
|
||||
foreignKey: ForeignKey
|
||||
) => {
|
||||
if (initialState === undefined) return false
|
||||
|
||||
return !isEqual(initialState, normalizeForeignKeyForDirtyCheck(foreignKey))
|
||||
}
|
||||
|
||||
export const generateCascadeActionDescription = (
|
||||
action: 'update' | 'delete',
|
||||
cascadeAction: string,
|
||||
|
||||
Reference in new issue
Block a user