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:
Danny White authored and GitHub committed 2026-03-19 00:56:40 +00:00
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):
@@ -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} />
</>
)
}
@@ -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)
})
})
@@ -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,