add diff mode (#614)

* initial diff viewer

* test

* test

* name change

* new table show

* diff viewer
This commit is contained in:
Guy Ben-Aharon
2025-03-13 11:12:11 +02:00
committed by GitHub
parent bd67ccfbcf
commit 52d2ea596c
14 changed files with 1276 additions and 46 deletions

View File

@@ -22,6 +22,8 @@ import { defaultSchemas } from '@/lib/data/default-schemas';
import { useEventEmitter } from 'ahooks';
import type { DBDependency } from '@/lib/domain/db-dependency';
import { storageInitialValue } from '../storage-context/storage-context';
import { useDiff } from '../diff-context/use-diff';
import type { DiffCalculatedEvent } from '../diff-context/diff-context';
export interface ChartDBProviderProps {
diagram?: Diagram;
@@ -30,7 +32,8 @@ export interface ChartDBProviderProps {
export const ChartDBProvider: React.FC<
React.PropsWithChildren<ChartDBProviderProps>
> = ({ children, diagram, readonly }) => {
> = ({ children, diagram, readonly: readonlyProp }) => {
const { hasDiff } = useDiff();
let db = useStorage();
const events = useEventEmitter<ChartDBEvent>();
const { setSchemasFilter, schemasFilter } = useLocalConfig();
@@ -53,9 +56,33 @@ export const ChartDBProvider: React.FC<
const [dependencies, setDependencies] = useState<DBDependency[]>(
diagram?.dependencies ?? []
);
const { events: diffEvents } = useDiff();
const diffCalculatedHandler = useCallback((event: DiffCalculatedEvent) => {
const { tablesAdded, fieldsAdded, relationshipsAdded } = event.data;
setTables((tables) =>
[...tables, ...(tablesAdded ?? [])].map((table) => {
const fields = fieldsAdded.get(table.id);
return fields
? { ...table, fields: [...table.fields, ...fields] }
: table;
})
);
setRelationships((relationships) => [
...relationships,
...(relationshipsAdded ?? []),
]);
}, []);
diffEvents.useSubscription(diffCalculatedHandler);
const defaultSchemaName = defaultSchemas[databaseType];
const readonly = useMemo(
() => readonlyProp ?? hasDiff ?? false,
[readonlyProp, hasDiff]
);
if (readonly) {
db = storageInitialValue;
}

View File

@@ -0,0 +1,433 @@
import type { Diagram } from '@/lib/domain/diagram';
import type {
ChartDBDiff,
DiffMap,
DiffObject,
FieldDiffAttribute,
} from '../types';
import type { DBField } from '@/lib/domain/db-field';
import type { DBIndex } from '@/lib/domain/db-index';
export function getDiffMapKey({
diffObject,
objectId,
attribute,
}: {
diffObject: DiffObject;
objectId: string;
attribute?: string;
}): string {
return attribute
? `${diffObject}-${attribute}-${objectId}`
: `${diffObject}-${objectId}`;
}
export function generateDiff({
diagram,
newDiagram,
}: {
diagram: Diagram;
newDiagram: Diagram;
}): {
diffMap: DiffMap;
changedTables: Map<string, boolean>;
changedFields: Map<string, boolean>;
} {
const newDiffs = new Map<string, ChartDBDiff>();
const changedTables = new Map<string, boolean>();
const changedFields = new Map<string, boolean>();
// Compare tables
compareTables({ diagram, newDiagram, diffMap: newDiffs, changedTables });
// Compare fields and indexes for matching tables
compareTableContents({
diagram,
newDiagram,
diffMap: newDiffs,
changedTables,
changedFields,
});
// Compare relationships
compareRelationships({ diagram, newDiagram, diffMap: newDiffs });
return { diffMap: newDiffs, changedTables, changedFields };
}
// Compare tables between diagrams
function compareTables({
diagram,
newDiagram,
diffMap,
changedTables,
}: {
diagram: Diagram;
newDiagram: Diagram;
diffMap: DiffMap;
changedTables: Map<string, boolean>;
}) {
const oldTables = diagram.tables || [];
const newTables = newDiagram.tables || [];
// Check for added tables
for (const newTable of newTables) {
if (!oldTables.find((t) => t.id === newTable.id)) {
diffMap.set(
getDiffMapKey({ diffObject: 'table', objectId: newTable.id }),
{
object: 'table',
type: 'added',
tableId: newTable.id,
}
);
changedTables.set(newTable.id, true);
}
}
// Check for removed tables
for (const oldTable of oldTables) {
if (!newTables.find((t) => t.id === oldTable.id)) {
diffMap.set(
getDiffMapKey({ diffObject: 'table', objectId: oldTable.id }),
{
object: 'table',
type: 'removed',
tableId: oldTable.id,
}
);
changedTables.set(oldTable.id, true);
}
}
// Check for table name and comments changes
for (const oldTable of oldTables) {
const newTable = newTables.find((t) => t.id === oldTable.id);
if (!newTable) continue;
if (oldTable.name !== newTable.name) {
diffMap.set(
getDiffMapKey({
diffObject: 'table',
objectId: oldTable.id,
attribute: 'name',
}),
{
object: 'table',
type: 'changed',
tableId: oldTable.id,
attributes: 'name',
newValue: newTable.name,
oldValue: oldTable.name,
}
);
changedTables.set(oldTable.id, true);
}
if (oldTable.comments !== newTable.comments) {
diffMap.set(
getDiffMapKey({
diffObject: 'table',
objectId: oldTable.id,
attribute: 'comments',
}),
{
object: 'table',
type: 'changed',
tableId: oldTable.id,
attributes: 'comments',
newValue: newTable.comments,
oldValue: oldTable.comments,
}
);
changedTables.set(oldTable.id, true);
}
}
}
// Compare fields and indexes for matching tables
function compareTableContents({
diagram,
newDiagram,
diffMap,
changedTables,
changedFields,
}: {
diagram: Diagram;
newDiagram: Diagram;
diffMap: DiffMap;
changedTables: Map<string, boolean>;
changedFields: Map<string, boolean>;
}) {
const oldTables = diagram.tables || [];
const newTables = newDiagram.tables || [];
// For each table that exists in both diagrams
for (const oldTable of oldTables) {
const newTable = newTables.find((t) => t.id === oldTable.id);
if (!newTable) continue;
// Compare fields
compareFields({
tableId: oldTable.id,
oldFields: oldTable.fields,
newFields: newTable.fields,
diffMap,
changedTables,
changedFields,
});
// Compare indexes
compareIndexes({
tableId: oldTable.id,
oldIndexes: oldTable.indexes,
newIndexes: newTable.indexes,
diffMap,
changedTables,
});
}
}
// Compare fields between tables
function compareFields({
tableId,
oldFields,
newFields,
diffMap,
changedTables,
changedFields,
}: {
tableId: string;
oldFields: DBField[];
newFields: DBField[];
diffMap: DiffMap;
changedTables: Map<string, boolean>;
changedFields: Map<string, boolean>;
}) {
// Check for added fields
for (const newField of newFields) {
if (!oldFields.find((f) => f.id === newField.id)) {
diffMap.set(
getDiffMapKey({
diffObject: 'field',
objectId: newField.id,
}),
{
object: 'field',
type: 'added',
fieldId: newField.id,
tableId,
}
);
changedTables.set(tableId, true);
changedFields.set(newField.id, true);
}
}
// Check for removed fields
for (const oldField of oldFields) {
if (!newFields.find((f) => f.id === oldField.id)) {
diffMap.set(
getDiffMapKey({
diffObject: 'field',
objectId: oldField.id,
}),
{
object: 'field',
type: 'removed',
fieldId: oldField.id,
tableId,
}
);
changedTables.set(tableId, true);
changedFields.set(oldField.id, true);
}
}
// Check for field changes
for (const oldField of oldFields) {
const newField = newFields.find((f) => f.id === oldField.id);
if (!newField) continue;
// Compare basic field properties
compareFieldProperties({
tableId,
oldField,
newField,
diffMap,
changedTables,
changedFields,
});
}
}
// Compare field properties
function compareFieldProperties({
tableId,
oldField,
newField,
diffMap,
changedTables,
changedFields,
}: {
tableId: string;
oldField: DBField;
newField: DBField;
diffMap: DiffMap;
changedTables: Map<string, boolean>;
changedFields: Map<string, boolean>;
}) {
const changedAttributes: FieldDiffAttribute[] = [];
if (oldField.name !== newField.name) {
changedAttributes.push('name');
}
if (oldField.type.id !== newField.type.id) {
changedAttributes.push('type');
}
if (oldField.primaryKey !== newField.primaryKey) {
changedAttributes.push('primaryKey');
}
if (oldField.unique !== newField.unique) {
changedAttributes.push('unique');
}
if (oldField.nullable !== newField.nullable) {
changedAttributes.push('nullable');
}
if (oldField.comments !== newField.comments) {
changedAttributes.push('comments');
}
if (changedAttributes.length > 0) {
for (const attribute of changedAttributes) {
diffMap.set(
getDiffMapKey({
diffObject: 'field',
objectId: oldField.id,
attribute: attribute,
}),
{
object: 'field',
type: 'changed',
fieldId: oldField.id,
tableId,
attributes: attribute,
oldValue: oldField[attribute],
newValue: newField[attribute],
}
);
}
changedTables.set(tableId, true);
changedFields.set(oldField.id, true);
}
}
// Compare indexes between tables
function compareIndexes({
tableId,
oldIndexes,
newIndexes,
diffMap,
changedTables,
}: {
tableId: string;
oldIndexes: DBIndex[];
newIndexes: DBIndex[];
diffMap: DiffMap;
changedTables: Map<string, boolean>;
}) {
// Check for added indexes
for (const newIndex of newIndexes) {
if (!oldIndexes.find((i) => i.id === newIndex.id)) {
diffMap.set(
getDiffMapKey({
diffObject: 'index',
objectId: newIndex.id,
}),
{
object: 'index',
type: 'added',
indexId: newIndex.id,
tableId,
}
);
changedTables.set(tableId, true);
}
}
// Check for removed indexes
for (const oldIndex of oldIndexes) {
if (!newIndexes.find((i) => i.id === oldIndex.id)) {
diffMap.set(
getDiffMapKey({
diffObject: 'index',
objectId: oldIndex.id,
}),
{
object: 'index',
type: 'removed',
indexId: oldIndex.id,
tableId,
}
);
changedTables.set(tableId, true);
}
}
}
// Compare relationships between diagrams
function compareRelationships({
diagram,
newDiagram,
diffMap,
}: {
diagram: Diagram;
newDiagram: Diagram;
diffMap: DiffMap;
}) {
const oldRelationships = diagram.relationships || [];
const newRelationships = newDiagram.relationships || [];
// Check for added relationships
for (const newRelationship of newRelationships) {
if (!oldRelationships.find((r) => r.id === newRelationship.id)) {
diffMap.set(
getDiffMapKey({
diffObject: 'relationship',
objectId: newRelationship.id,
}),
{
object: 'relationship',
type: 'added',
relationshipId: newRelationship.id,
}
);
}
}
// Check for removed relationships
for (const oldRelationship of oldRelationships) {
if (!newRelationships.find((r) => r.id === oldRelationship.id)) {
diffMap.set(
getDiffMapKey({
diffObject: 'relationship',
objectId: oldRelationship.id,
}),
{
object: 'relationship',
type: 'removed',
relationshipId: oldRelationship.id,
}
);
}
}
}

View File

@@ -0,0 +1,75 @@
import { createContext } from 'react';
import type { DiffMap } from './types';
import type { Diagram } from '@/lib/domain/diagram';
import type { DBTable } from '@/lib/domain/db-table';
import type { EventEmitter } from 'ahooks/lib/useEventEmitter';
import type { DBField } from '@/lib/domain/db-field';
import type { DataType } from '@/lib/data/data-types/data-types';
import type { DBRelationship } from '@/lib/domain/db-relationship';
export type DiffEventType = 'diff_calculated';
export type DiffEventBase<T extends DiffEventType, D> = {
action: T;
data: D;
};
export type DiffCalculatedEvent = DiffEventBase<
'diff_calculated',
{
tablesAdded: DBTable[];
fieldsAdded: Map<string, DBField[]>;
relationshipsAdded: DBRelationship[];
}
>;
export type DiffEvent = DiffCalculatedEvent;
export interface DiffContext {
newDiagram: Diagram | null;
diffMap: DiffMap;
hasDiff: boolean;
calculateDiff: ({
diagram,
newDiagram,
}: {
diagram: Diagram;
newDiagram: Diagram;
}) => void;
// table diff
checkIfTableHasChange: ({ tableId }: { tableId: string }) => boolean;
checkIfNewTable: ({ tableId }: { tableId: string }) => boolean;
checkIfTableRemoved: ({ tableId }: { tableId: string }) => boolean;
getTableNewName: ({ tableId }: { tableId: string }) => string | null;
// field diff
checkIfFieldHasChange: ({
tableId,
fieldId,
}: {
tableId: string;
fieldId: string;
}) => boolean;
checkIfFieldRemoved: ({ fieldId }: { fieldId: string }) => boolean;
checkIfNewField: ({ fieldId }: { fieldId: string }) => boolean;
getFieldNewName: ({ fieldId }: { fieldId: string }) => string | null;
getFieldNewType: ({ fieldId }: { fieldId: string }) => DataType | null;
// relationship diff
checkIfNewRelationship: ({
relationshipId,
}: {
relationshipId: string;
}) => boolean;
checkIfRelationshipRemoved: ({
relationshipId,
}: {
relationshipId: string;
}) => boolean;
events: EventEmitter<DiffEvent>;
}
export const diffContext = createContext<DiffContext | undefined>(undefined);

View File

@@ -0,0 +1,327 @@
import React, { useCallback } from 'react';
import type { DiffContext, DiffEvent } from './diff-context';
import { diffContext } from './diff-context';
import type { ChartDBDiff, DiffMap } from './types';
import { generateDiff, getDiffMapKey } from './diff-check/diff-check';
import type { Diagram } from '@/lib/domain/diagram';
import { useEventEmitter } from 'ahooks';
import type { DBField } from '@/lib/domain/db-field';
import type { DataType } from '@/lib/data/data-types/data-types';
import type { DBRelationship } from '@/lib/domain/db-relationship';
export const DiffProvider: React.FC<React.PropsWithChildren> = ({
children,
}) => {
const [newDiagram, setNewDiagram] = React.useState<Diagram | null>(null);
const [diffMap, setDiffMap] = React.useState<DiffMap>(
new Map<string, ChartDBDiff>()
);
const [tablesChanged, setTablesChanged] = React.useState<
Map<string, boolean>
>(new Map<string, boolean>());
const [fieldsChanged, setFieldsChanged] = React.useState<
Map<string, boolean>
>(new Map<string, boolean>());
const events = useEventEmitter<DiffEvent>();
const generateNewFieldsMap = useCallback(
({
diffMap,
newDiagram,
}: {
diffMap: DiffMap;
newDiagram: Diagram;
}) => {
const newFieldsMap = new Map<string, DBField[]>();
diffMap.forEach((diff) => {
if (diff.object === 'field' && diff.type === 'added') {
const field = newDiagram?.tables
?.find((table) => table.id === diff.tableId)
?.fields.find((f) => f.id === diff.fieldId);
if (field) {
newFieldsMap.set(diff.tableId, [
...(newFieldsMap.get(diff.tableId) ?? []),
field,
]);
}
}
});
return newFieldsMap;
},
[]
);
const findNewRelationships = useCallback(
({
diffMap,
newDiagram,
}: {
diffMap: DiffMap;
newDiagram: Diagram;
}) => {
const relationships: DBRelationship[] = [];
diffMap.forEach((diff) => {
if (diff.object === 'relationship' && diff.type === 'added') {
const relationship = newDiagram?.relationships?.find(
(rel) => rel.id === diff.relationshipId
);
if (relationship) {
relationships.push(relationship);
}
}
});
return relationships;
},
[]
);
const calculateDiff: DiffContext['calculateDiff'] = useCallback(
({ diagram, newDiagram: newDiagramArg }) => {
const {
diffMap: newDiffs,
changedTables: newChangedTables,
changedFields: newChangedFields,
} = generateDiff({ diagram, newDiagram: newDiagramArg });
setDiffMap(newDiffs);
setTablesChanged(newChangedTables);
setFieldsChanged(newChangedFields);
setNewDiagram(newDiagramArg);
events.emit({
action: 'diff_calculated',
data: {
tablesAdded:
newDiagramArg?.tables?.filter((table) => {
const tableKey = getDiffMapKey({
diffObject: 'table',
objectId: table.id,
});
return (
newDiffs.has(tableKey) &&
newDiffs.get(tableKey)?.type === 'added'
);
}) ?? [],
fieldsAdded: generateNewFieldsMap({
diffMap: newDiffs,
newDiagram: newDiagramArg,
}),
relationshipsAdded: findNewRelationships({
diffMap: newDiffs,
newDiagram: newDiagramArg,
}),
},
});
},
[setDiffMap, events, generateNewFieldsMap, findNewRelationships]
);
const getTableNewName = useCallback<DiffContext['getTableNewName']>(
({ tableId }) => {
const tableNameKey = getDiffMapKey({
diffObject: 'table',
objectId: tableId,
attribute: 'name',
});
if (diffMap.has(tableNameKey)) {
const diff = diffMap.get(tableNameKey);
if (diff?.type === 'changed') {
return diff.newValue as string;
}
}
return null;
},
[diffMap]
);
const checkIfTableHasChange = useCallback<
DiffContext['checkIfTableHasChange']
>(({ tableId }) => tablesChanged.get(tableId) ?? false, [tablesChanged]);
const checkIfNewTable = useCallback<DiffContext['checkIfNewTable']>(
({ tableId }) => {
const tableKey = getDiffMapKey({
diffObject: 'table',
objectId: tableId,
});
return (
diffMap.has(tableKey) && diffMap.get(tableKey)?.type === 'added'
);
},
[diffMap]
);
const checkIfTableRemoved = useCallback<DiffContext['checkIfTableRemoved']>(
({ tableId }) => {
const tableKey = getDiffMapKey({
diffObject: 'table',
objectId: tableId,
});
return (
diffMap.has(tableKey) &&
diffMap.get(tableKey)?.type === 'removed'
);
},
[diffMap]
);
const checkIfFieldHasChange = useCallback<
DiffContext['checkIfFieldHasChange']
>(
({ fieldId }) => {
return fieldsChanged.get(fieldId) ?? false;
},
[fieldsChanged]
);
const checkIfFieldRemoved = useCallback<DiffContext['checkIfFieldRemoved']>(
({ fieldId }) => {
const fieldKey = getDiffMapKey({
diffObject: 'field',
objectId: fieldId,
});
return (
diffMap.has(fieldKey) &&
diffMap.get(fieldKey)?.type === 'removed'
);
},
[diffMap]
);
const checkIfNewField = useCallback<DiffContext['checkIfNewField']>(
({ fieldId }) => {
const fieldKey = getDiffMapKey({
diffObject: 'field',
objectId: fieldId,
});
return (
diffMap.has(fieldKey) && diffMap.get(fieldKey)?.type === 'added'
);
},
[diffMap]
);
const getFieldNewName = useCallback<DiffContext['getFieldNewName']>(
({ fieldId }) => {
const fieldKey = getDiffMapKey({
diffObject: 'field',
objectId: fieldId,
attribute: 'name',
});
if (diffMap.has(fieldKey)) {
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as string;
}
}
return null;
},
[diffMap]
);
const getFieldNewType = useCallback<DiffContext['getFieldNewType']>(
({ fieldId }) => {
const fieldKey = getDiffMapKey({
diffObject: 'field',
objectId: fieldId,
attribute: 'type',
});
if (diffMap.has(fieldKey)) {
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as DataType;
}
}
return null;
},
[diffMap]
);
const checkIfNewRelationship = useCallback<
DiffContext['checkIfNewRelationship']
>(
({ relationshipId }) => {
const relationshipKey = getDiffMapKey({
diffObject: 'relationship',
objectId: relationshipId,
});
return (
diffMap.has(relationshipKey) &&
diffMap.get(relationshipKey)?.type === 'added'
);
},
[diffMap]
);
const checkIfRelationshipRemoved = useCallback<
DiffContext['checkIfRelationshipRemoved']
>(
({ relationshipId }) => {
const relationshipKey = getDiffMapKey({
diffObject: 'relationship',
objectId: relationshipId,
});
return (
diffMap.has(relationshipKey) &&
diffMap.get(relationshipKey)?.type === 'removed'
);
},
[diffMap]
);
return (
<diffContext.Provider
value={{
newDiagram,
diffMap,
hasDiff: diffMap.size > 0,
calculateDiff,
// table diff
getTableNewName,
checkIfNewTable,
checkIfTableRemoved,
checkIfTableHasChange,
// field diff
checkIfFieldHasChange,
checkIfFieldRemoved,
checkIfNewField,
getFieldNewName,
getFieldNewType,
// relationship diff
checkIfNewRelationship,
checkIfRelationshipRemoved,
events,
}}
>
{children}
</diffContext.Provider>
);
};

View File

@@ -0,0 +1,53 @@
import type { DataType } from '@/lib/data/data-types/data-types';
export type TableDiffAttribute = 'name' | 'comments';
export interface TableDiff {
object: 'table';
type: 'added' | 'removed' | 'changed';
tableId: string;
attributes?: TableDiffAttribute;
oldValue?: string;
newValue?: string;
}
export interface RelationshipDiff {
object: 'relationship';
type: 'added' | 'removed';
relationshipId: string;
}
export type FieldDiffAttribute =
| 'name'
| 'type'
| 'primaryKey'
| 'unique'
| 'nullable'
| 'comments';
export interface FieldDiff {
object: 'field';
type: 'added' | 'removed' | 'changed';
fieldId: string;
tableId: string;
attributes?: FieldDiffAttribute;
oldValue?: string | boolean | DataType;
newValue?: string | boolean | DataType;
}
export interface IndexDiff {
object: 'index';
type: 'added' | 'removed';
indexId: string;
tableId: string;
}
export type ChartDBDiff = TableDiff | FieldDiff | IndexDiff | RelationshipDiff;
export type DiffMap = Map<string, ChartDBDiff>;
export type DiffObject =
| TableDiff['object']
| FieldDiff['object']
| IndexDiff['object']
| RelationshipDiff['object'];

View File

@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { diffContext } from './diff-context';
export const useDiff = () => {
const context = useContext(diffContext);
if (context === undefined) {
throw new Error('useDiff must be used within an DiffProvider');
}
return context;
};

View File

@@ -115,8 +115,22 @@ export const exportBaseSQL = (diagram: Diagram): string => {
// Remove the type cast part after :: if it exists
if (fieldDefault.includes('::')) {
const endedWithParentheses = fieldDefault.endsWith(')');
fieldDefault = fieldDefault.split('::')[0];
if (
(fieldDefault.startsWith('(') &&
!fieldDefault.endsWith(')')) ||
endedWithParentheses
) {
fieldDefault += ')';
}
}
if (fieldDefault === `('now')`) {
fieldDefault = `now()`;
}
sqlScript += ` DEFAULT ${fieldDefault}`;
}

View File

@@ -103,10 +103,9 @@ const tableToTableNode = (
export interface CanvasProps {
initialTables: DBTable[];
readonly?: boolean;
}
export const Canvas: React.FC<CanvasProps> = ({ initialTables, readonly }) => {
export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
const { getEdge, getInternalNode, getEdges, getNode } = useReactFlow();
const [selectedTableIds, setSelectedTableIds] = useState<string[]>([]);
const [selectedRelationshipIds, setSelectedRelationshipIds] = useState<
@@ -127,6 +126,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables, readonly }) => {
filteredSchemas,
events,
dependencies,
readonly,
} = useChartDB();
const { showSidePanel } = useLayout();
const { effectiveTheme } = useTheme();

View File

@@ -7,6 +7,7 @@ import { useChartDB } from '@/hooks/use-chartdb';
import { useLayout } from '@/hooks/use-layout';
import { cn } from '@/lib/utils';
import { getCardinalityMarkerId } from './canvas-utils';
import { useDiff } from '@/context/diff-context/use-diff';
export type RelationshipEdgeType = Edge<
{
@@ -29,6 +30,7 @@ export const RelationshipEdge: React.FC<EdgeProps<RelationshipEdgeType>> = ({
}) => {
const { getInternalNode, getEdge } = useReactFlow();
const { openRelationshipFromSidebar, selectSidebarSection } = useLayout();
const { checkIfRelationshipRemoved, checkIfNewRelationship } = useDiff();
const { relationships } = useChartDB();
@@ -149,6 +151,25 @@ export const RelationshipEdge: React.FC<EdgeProps<RelationshipEdgeType>> = ({
}),
[relationship?.targetCardinality, selected, targetSide]
);
const isDiffNewRelationship = useMemo(
() =>
relationship?.id
? checkIfNewRelationship({ relationshipId: relationship.id })
: false,
[checkIfNewRelationship, relationship?.id]
);
const isDiffRelationshipRemoved = useMemo(
() =>
relationship?.id
? checkIfRelationshipRemoved({
relationshipId: relationship.id,
})
: false,
[checkIfRelationshipRemoved, relationship?.id]
);
return (
<>
<path
@@ -160,6 +181,10 @@ export const RelationshipEdge: React.FC<EdgeProps<RelationshipEdgeType>> = ({
className={cn([
'react-flow__edge-path',
`!stroke-2 ${selected ? '!stroke-pink-600' : '!stroke-slate-400'}`,
{
'!stroke-green-500': isDiffNewRelationship,
'!stroke-red-500': isDiffRelationshipRemoved,
},
])}
onClick={(e) => {
if (e.detail === 2) {

View File

@@ -12,7 +12,15 @@ import {
useUpdateNodeInternals,
} from '@xyflow/react';
import { Button } from '@/components/button/button';
import { Check, KeyRound, MessageCircleMore, Trash2 } from 'lucide-react';
import {
Check,
KeyRound,
MessageCircleMore,
SquareDot,
SquareMinus,
SquarePlus,
Trash2,
} from 'lucide-react';
import type { DBField } from '@/lib/domain/db-field';
import { useChartDB } from '@/hooks/use-chartdb';
import { cn } from '@/lib/utils';
@@ -23,6 +31,7 @@ import {
} from '@/components/tooltip/tooltip';
import { useClickAway, useKeyPressEvent } from 'react-use';
import { Input } from '@/components/input/input';
import { useDiff } from '@/context/diff-context/use-diff';
export const LEFT_HANDLE_ID_PREFIX = 'left_rel_';
export const RIGHT_HANDLE_ID_PREFIX = 'right_rel_';
@@ -95,6 +104,43 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
useKeyPressEvent('Enter', editFieldName);
useKeyPressEvent('Escape', abortEdit);
const {
checkIfFieldRemoved,
checkIfNewField,
getFieldNewName,
getFieldNewType,
checkIfFieldHasChange,
} = useDiff();
const isDiffFieldRemoved = useMemo(
() => checkIfFieldRemoved({ fieldId: field.id }),
[checkIfFieldRemoved, field.id]
);
const isDiffNewField = useMemo(
() => checkIfNewField({ fieldId: field.id }),
[checkIfNewField, field.id]
);
const fieldDiffChangedName = useMemo(
() => getFieldNewName({ fieldId: field.id }),
[getFieldNewName, field.id]
);
const fieldDiffChangedType = useMemo(
() => getFieldNewType({ fieldId: field.id }),
[getFieldNewType, field.id]
);
const isDiffFieldChanged = useMemo(
() =>
checkIfFieldHasChange({
fieldId: field.id,
tableId: tableNodeId,
}),
[checkIfFieldHasChange, field.id, tableNodeId]
);
const enterEditMode = (e: React.MouseEvent) => {
e.stopPropagation();
setEditMode(true);
@@ -102,13 +148,23 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
return (
<div
className={`group relative flex h-8 items-center justify-between gap-1 border-t px-3 text-sm last:rounded-b-[6px] hover:bg-slate-100 dark:hover:bg-slate-800 ${
highlighted ? 'bg-pink-100 dark:bg-pink-900' : ''
} transition-all duration-200 ease-in-out ${
visible
? 'max-h-8 opacity-100'
: 'z-0 max-h-0 overflow-hidden opacity-0'
}`}
className={cn(
'group relative flex h-8 items-center justify-between gap-1 border-t px-3 text-sm last:rounded-b-[6px] hover:bg-slate-100 dark:hover:bg-slate-800',
'transition-all duration-200 ease-in-out',
{
'bg-pink-100 dark:bg-pink-900': highlighted,
'max-h-8 opacity-100': visible,
'z-0 max-h-0 overflow-hidden opacity-0': !visible,
'bg-sky-200 dark:bg-sky-800 hover:bg-sky-100 dark:hover:bg-sky-900 border-sky-300 dark:border-sky-700':
isDiffFieldChanged &&
!isDiffFieldRemoved &&
!isDiffNewField,
'bg-red-200 dark:bg-red-800 hover:bg-red-100 dark:hover:bg-red-900 border-red-300 dark:border-red-700':
isDiffFieldRemoved,
'bg-green-200 dark:bg-green-800 hover:bg-green-100 dark:hover:bg-green-900 border-green-300 dark:border-green-700':
isDiffNewField,
}
)}
>
{isConnectable ? (
<>
@@ -161,7 +217,14 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
}
)}
>
{editMode ? (
{isDiffFieldRemoved ? (
<SquareMinus className="size-3.5 text-red-800 dark:text-red-200" />
) : isDiffNewField ? (
<SquarePlus className="size-3.5 text-green-800 dark:text-green-200" />
) : isDiffFieldChanged ? (
<SquareDot className="size-3.5 shrink-0 text-sky-800 dark:text-sky-200" />
) : null}
{editMode && !readonly ? (
<>
<Input
ref={inputRef}
@@ -190,10 +253,27 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
// {field.name}
// </span>
<span
className="truncate"
className={cn('truncate', {
'text-red-800 font-normal dark:text-red-200':
isDiffFieldRemoved,
'text-green-800 font-normal dark:text-green-200':
isDiffNewField,
'text-sky-800 font-normal dark:text-sky-200':
isDiffFieldChanged &&
!isDiffFieldRemoved &&
!isDiffNewField,
})}
onDoubleClick={enterEditMode}
>
{field.name}
{fieldDiffChangedName ? (
<>
{field.name}{' '}
<span className="font-medium"></span>{' '}
{fieldDiffChangedName}
</>
) : (
field.name
)}
</span>
)}
{/* <span className="truncate">{field.name}</span> */}
@@ -214,7 +294,18 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
<div
className={cn(
'text-muted-foreground',
!readonly ? 'group-hover:hidden' : ''
!readonly ? 'group-hover:hidden' : '',
isDiffFieldRemoved
? 'text-red-800 dark:text-red-200'
: '',
isDiffNewField
? 'text-green-800 dark:text-green-200'
: '',
isDiffFieldChanged &&
!isDiffFieldRemoved &&
!isDiffNewField
? 'text-sky-800 dark:text-sky-200'
: ''
)}
>
<KeyRound size={14} />
@@ -223,11 +314,31 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
<div
className={cn(
'content-center truncate text-right text-xs text-muted-foreground shrink-0',
!readonly ? 'group-hover:hidden' : ''
'content-center truncate text-right text-xs text-muted-foreground',
!readonly ? 'group-hover:hidden' : '',
isDiffFieldRemoved
? 'text-red-800 dark:text-red-200'
: '',
isDiffNewField
? 'text-green-800 dark:text-green-200'
: '',
isDiffFieldChanged &&
!isDiffFieldRemoved &&
!isDiffNewField
? 'text-sky-800 dark:text-sky-200'
: ''
)}
>
{field.type.name.split(' ')[0]}
{fieldDiffChangedType ? (
<>
<span className="line-through">
{field.type.name.split(' ')[0]}
</span>{' '}
{fieldDiffChangedType.name.split(' ')[0]}
</>
) : (
field.type.name.split(' ')[0]
)}
{field.nullable ? '?' : ''}
</div>
{readonly ? null : (

View File

@@ -0,0 +1,35 @@
import { cn } from '@/lib/utils';
import React from 'react';
export interface TableNodeStatusProps {
status: 'new' | 'changed' | 'removed' | 'none';
}
export const TableNodeStatus: React.FC<TableNodeStatusProps> = ({ status }) => {
if (status === 'none') {
return null;
}
return (
<div className="absolute left-1/2 top-0 z-10 -translate-x-1/2 -translate-y-1/2">
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-white',
{
'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100':
status === 'new',
'bg-sky-100 text-sky-800 dark:bg-sky-800 dark:text-sky-100':
status === 'changed',
'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100':
status === 'removed',
}
)}
>
{status === 'new'
? 'New'
: status === 'changed'
? 'Modified'
: 'Deleted'}
</span>
</div>
);
};

View File

@@ -10,6 +10,9 @@ import {
ChevronUp,
Check,
CircleDotDashed,
SquareDot,
SquarePlus,
SquareMinus,
} from 'lucide-react';
import { Label } from '@/components/label/label';
import type { DBTable } from '@/lib/domain/db-table';
@@ -30,6 +33,8 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/tooltip/tooltip';
import { useDiff } from '@/context/diff-context/use-diff';
import { TableNodeStatus } from './table-node-status/table-node-status';
export type TableNodeType = Node<
{
@@ -61,6 +66,35 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
const [tableName, setTableName] = useState(table.name);
const inputRef = React.useRef<HTMLInputElement>(null);
const {
getTableNewName,
checkIfTableHasChange,
checkIfNewTable,
checkIfTableRemoved,
} = useDiff();
const fields = useMemo(() => table.fields, [table.fields]);
const tableChangedName = useMemo(
() => getTableNewName({ tableId: table.id }),
[getTableNewName, table.id]
);
const isDiffTableChanged = useMemo(
() => checkIfTableHasChange({ tableId: table.id }),
[checkIfTableHasChange, table.id]
);
const isDiffNewTable = useMemo(
() => checkIfNewTable({ tableId: table.id }),
[checkIfNewTable, table.id]
);
const isDiffTableRemoved = useMemo(
() => checkIfTableRemoved({ tableId: table.id }),
[checkIfTableRemoved, table.id]
);
const selectedRelEdges = edges.filter(
(edge) =>
(edge.source === id || edge.target === id) &&
@@ -109,13 +143,13 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
const visibleFields = useMemo(() => {
if (expanded) {
return table.fields;
return fields;
}
const mustDisplayedFields = table.fields.filter((field: DBField) =>
const mustDisplayedFields = fields.filter((field: DBField) =>
isMustDisplayedField(field)
);
const nonMustDisplayedFields = table.fields.filter(
const nonMustDisplayedFields = fields.filter(
(field: DBField) => !isMustDisplayedField(field)
);
@@ -133,8 +167,8 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
return [
...visibleMustDisplayedFields,
...visibleNonMustDisplayedFields,
].sort((a, b) => table.fields.indexOf(a) - table.fields.indexOf(b));
}, [expanded, table.fields, isMustDisplayedField]);
].sort((a, b) => fields.indexOf(a) - fields.indexOf(b));
}, [expanded, fields, isMustDisplayedField]);
const editTableName = useCallback(() => {
if (!editMode) return;
@@ -174,6 +208,17 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
: '',
highlightOverlappingTables && isOverlapping
? 'animate-scale-2'
: '',
isDiffTableChanged &&
!isDiffNewTable &&
!isDiffTableRemoved
? 'outline outline-[3px] outline-sky-500 dark:outline-sky-900 outline-offset-[5px]'
: '',
isDiffNewTable
? 'outline outline-[3px] outline-green-500 dark:outline-green-900 outline-offset-[5px]'
: '',
isDiffTableRemoved
? 'outline outline-[3px] outline-red-500 dark:outline-red-900 outline-offset-[5px]'
: ''
)}
onClick={(e) => {
@@ -194,14 +239,87 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
table={table}
focused={focused}
/>
{/* Badge added here */}
<TableNodeStatus
status={
isDiffNewTable
? 'new'
: isDiffTableRemoved
? 'removed'
: isDiffTableChanged
? 'changed'
: 'none'
}
/>
<div
className="h-2 rounded-t-[6px]"
style={{ backgroundColor: table.color }}
></div>
<div className="group flex h-9 items-center justify-between bg-slate-200 px-2 dark:bg-slate-900">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Table2 className="size-3.5 shrink-0 text-gray-600 dark:text-primary" />
{editMode ? (
{isDiffNewTable ? (
<Tooltip>
<TooltipTrigger asChild>
<SquarePlus
className="size-3.5 shrink-0 text-green-600"
strokeWidth={2.5}
/>
</TooltipTrigger>
<TooltipContent>New Table</TooltipContent>
</Tooltip>
) : isDiffTableRemoved ? (
<Tooltip>
<TooltipTrigger asChild>
<SquareMinus
className="size-3.5 shrink-0 text-red-600"
strokeWidth={2.5}
/>
</TooltipTrigger>
<TooltipContent>
Table Removed
</TooltipContent>
</Tooltip>
) : isDiffTableChanged ? (
<Tooltip>
<TooltipTrigger asChild>
<SquareDot
className="size-3.5 shrink-0 text-sky-600"
strokeWidth={2.5}
/>
</TooltipTrigger>
<TooltipContent>
Table Changed
</TooltipContent>
</Tooltip>
) : (
<Table2 className="size-3.5 shrink-0 text-gray-600 dark:text-primary" />
)}
{tableChangedName ? (
<Label className="flex h-5 items-center justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200">
<span className="truncate">
{table.name}
</span>
<span className="mx-1 font-semibold">
</span>
<span className="truncate">
{tableChangedName}
</span>
</Label>
) : isDiffNewTable ? (
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-green-200 px-2 py-0.5 text-sm font-normal text-green-900 dark:bg-green-800 dark:text-green-200">
{table.name}
</Label>
) : isDiffTableRemoved ? (
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-red-200 px-2 py-0.5 text-sm font-normal text-red-900 dark:bg-red-800 dark:text-red-200">
{table.name}
</Label>
) : isDiffTableChanged ? (
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200">
{table.name}
</Label>
) : editMode && !readonly ? (
<>
<Input
ref={inputRef}
@@ -273,11 +391,11 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
className="transition-[max-height] duration-200 ease-in-out"
style={{
maxHeight: expanded
? `${table.fields.length * 2}rem` // h-8 per field
? `${fields.length * 2}rem` // h-8 per field
: `${TABLE_MINIMIZED_FIELDS * 2}rem`, // h-8 per field
}}
>
{table.fields.map((field: DBField) => (
{fields.map((field: DBField) => (
<TableNodeField
key={field.id}
focused={focused}
@@ -295,7 +413,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
/>
))}
</div>
{table.fields.length > TABLE_MINIMIZED_FIELDS && (
{fields.length > TABLE_MINIMIZED_FIELDS && (
<div
className="z-10 flex h-8 cursor-pointer items-center justify-center rounded-b-md border-t text-xs text-muted-foreground transition-colors duration-200 hover:bg-slate-100 dark:hover:bg-slate-800"
onClick={toggleExpand}

View File

@@ -29,6 +29,7 @@ import { AlertProvider } from '@/context/alert-context/alert-provider';
import { CanvasProvider } from '@/context/canvas-context/canvas-provider';
import { HIDE_BUCKLE_DOT_DEV } from '@/lib/env';
import { useDiagramLoader } from './use-diagram-loader';
import { DiffProvider } from '@/context/diff-context/diff-provider';
const OPEN_STAR_US_AFTER_SECONDS = 30;
const SHOW_STAR_US_AGAIN_AFTER_DAYS = 1;
@@ -235,23 +236,25 @@ export const EditorPage: React.FC = () => (
<StorageProvider>
<ConfigProvider>
<RedoUndoStackProvider>
<ChartDBProvider>
<HistoryProvider>
<ReactFlowProvider>
<CanvasProvider>
<ExportImageProvider>
<AlertProvider>
<DialogProvider>
<KeyboardShortcutsProvider>
<EditorPageComponent />
</KeyboardShortcutsProvider>
</DialogProvider>
</AlertProvider>
</ExportImageProvider>
</CanvasProvider>
</ReactFlowProvider>
</HistoryProvider>
</ChartDBProvider>
<DiffProvider>
<ChartDBProvider>
<HistoryProvider>
<ReactFlowProvider>
<CanvasProvider>
<ExportImageProvider>
<AlertProvider>
<DialogProvider>
<KeyboardShortcutsProvider>
<EditorPageComponent />
</KeyboardShortcutsProvider>
</DialogProvider>
</AlertProvider>
</ExportImageProvider>
</CanvasProvider>
</ReactFlowProvider>
</HistoryProvider>
</ChartDBProvider>
</DiffProvider>
</RedoUndoStackProvider>
</ConfigProvider>
</StorageProvider>

View File

@@ -289,7 +289,6 @@ const TemplatePageComponent: React.FC = () => {
readonly
>
<Canvas
readonly
initialTables={
template.diagram.tables ?? []
}