Skip to content

Commit 01b28f6

Browse files
authored
feat: field plan (#317)
* refactor: move graph to standalone module * feat: create field plain api * refactor: optimize graph component * feat: field graph ui * feat: optimize graph style * feat: plan field update api * feat: ui for plan field update * fix: crash when number formula without formatting * feat: create field plan api * feat: show dependancies graph * chore: update shadcn/ui to latest * feat: polish field graph ux * fix: test fail * fix: pg test unstable
1 parent 13ad8a4 commit 01b28f6

File tree

70 files changed

+2546
-688
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

70 files changed

+2546
-688
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"signin",
1717
"signout",
1818
"sonarjs",
19+
"sonner",
1920
"teable",
2021
"testid",
2122
"topo",

apps/nestjs-backend/src/features/calculation/field-calculation.service.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { nameConsole } from './utils/name-console';
1818

1919
export interface ITopoOrdersContext {
2020
fieldMap: IFieldMap;
21+
allFieldIds: string[];
2122
startFieldIds: string[];
2223
directedGraph: IGraphItem[];
2324
fieldId2DbTableName: { [fieldId: string]: string };
@@ -36,7 +37,7 @@ export class FieldCalculationService {
3637
@InjectModel('CUSTOM_KNEX') private readonly knex: Knex
3738
) {}
3839

39-
private async getSelfOriginRecords(dbTableName: string) {
40+
async getSelfOriginRecords(dbTableName: string) {
4041
const nativeSql = this.knex.queryBuilder().select('__id').from(dbTableName).toSQL().toNative();
4142

4243
const results = await this.prismaService
@@ -92,8 +93,11 @@ export class FieldCalculationService {
9293
await this.batchService.updateRecords(opsMap, fieldMap, tableId2DbTableName);
9394
}
9495

95-
async getTopoOrdersContext(fieldIds: string[]): Promise<ITopoOrdersContext> {
96-
const directedGraph = await this.referenceService.getFieldGraphItems(fieldIds);
96+
async getTopoOrdersContext(
97+
fieldIds: string[],
98+
customGraph?: IGraphItem[]
99+
): Promise<ITopoOrdersContext> {
100+
const directedGraph = customGraph || (await this.referenceService.getFieldGraphItems(fieldIds));
97101

98102
// get all related field by undirected graph
99103
const allFieldIds = uniq(this.referenceService.flatGraph(directedGraph).concat(fieldIds));
@@ -113,6 +117,7 @@ export class FieldCalculationService {
113117

114118
return {
115119
startFieldIds: fieldIds,
120+
allFieldIds,
116121
fieldMap,
117122
directedGraph,
118123
topoOrdersByFieldId,
@@ -123,7 +128,7 @@ export class FieldCalculationService {
123128
};
124129
}
125130

126-
private async getRecordItems(params: {
131+
async getRecordItems(params: {
127132
tableId: string;
128133
startFieldIds: string[];
129134
itemsToCalculate: string[];

apps/nestjs-backend/src/features/calculation/reference.service.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ import type { ICellChange } from './utils/changes';
2929
import { formatChangesToOps, mergeDuplicateChange } from './utils/changes';
3030
import { isLinkCellValue } from './utils/detect-link';
3131
import type { IAdjacencyMap } from './utils/dfs';
32-
import { buildCompressedAdjacencyMap, filterDirectedGraph, getTopologicalOrder } from './utils/dfs';
32+
import {
33+
buildCompressedAdjacencyMap,
34+
filterDirectedGraph,
35+
topoOrderWithDepends,
36+
} from './utils/dfs';
3337

3438
// topo item is for field level reference, all id stands for fieldId;
3539
export interface ITopoItem {
@@ -143,7 +147,11 @@ export class ReferenceService {
143147
return fieldIds.reduce<{
144148
[fieldId: string]: ITopoItem[];
145149
}>((pre, fieldId) => {
146-
pre[fieldId] = getTopologicalOrder(fieldId, directedGraph);
150+
try {
151+
pre[fieldId] = topoOrderWithDepends(fieldId, directedGraph);
152+
} catch (e) {
153+
throw new BadRequestException((e as { message: string }).message);
154+
}
147155
return pre;
148156
}, {});
149157
}
@@ -709,18 +717,16 @@ export class ReferenceService {
709717
return newOrder;
710718
}
711719

712-
async getRecordMapBatch(params: {
720+
getRecordIdsByTableName(params: {
713721
fieldMap: IFieldMap;
714722
fieldId2DbTableName: Record<string, string>;
715-
dbTableName2fields: Record<string, IFieldInstance[]>;
716723
initialRecordIdMap?: { [dbTableName: string]: Set<string> };
717724
modifiedRecords: IRecordData[];
718725
relatedRecordItems: IRelatedRecordItem[];
719726
}) {
720727
const {
721728
fieldMap,
722729
fieldId2DbTableName,
723-
dbTableName2fields,
724730
initialRecordIdMap,
725731
modifiedRecords,
726732
relatedRecordItems,
@@ -756,6 +762,21 @@ export class ReferenceService {
756762
insertId(options.lookupFieldId, item.fromId);
757763
insertId(item.fieldId, item.toId);
758764
});
765+
766+
return recordIdsByTableName;
767+
}
768+
769+
async getRecordMapBatch(params: {
770+
fieldMap: IFieldMap;
771+
fieldId2DbTableName: Record<string, string>;
772+
dbTableName2fields: Record<string, IFieldInstance[]>;
773+
initialRecordIdMap?: { [dbTableName: string]: Set<string> };
774+
modifiedRecords: IRecordData[];
775+
relatedRecordItems: IRelatedRecordItem[];
776+
}) {
777+
const { fieldId2DbTableName, dbTableName2fields, modifiedRecords } = params;
778+
779+
const recordIdsByTableName = this.getRecordIdsByTableName(params);
759780
const recordMap = await this.getRecordMap(recordIdsByTableName, dbTableName2fields);
760781
this.coverRecordData(fieldId2DbTableName, modifiedRecords, recordMap);
761782

apps/nestjs-backend/src/features/calculation/utils/dfs.spec.ts

Lines changed: 194 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
import { buildAdjacencyMap, buildCompressedAdjacencyMap } from './dfs';
1+
import type { IGraphItem } from './dfs';
2+
import {
3+
buildAdjacencyMap,
4+
buildCompressedAdjacencyMap,
5+
hasCycle,
6+
pruneGraph,
7+
topoOrderWithDepends,
8+
topoOrderWithStart,
9+
topologicalSort,
10+
} from './dfs';
211

312
describe('Graph Processing Functions', () => {
413
describe('buildAdjacencyMap', () => {
@@ -82,4 +91,188 @@ describe('Graph Processing Functions', () => {
8291
expect(buildCompressedAdjacencyMap(graph, linkIdSet)).toEqual(expected);
8392
});
8493
});
94+
95+
describe('topologicalSort', () => {
96+
it('should perform a basic topological sort', () => {
97+
const graph: IGraphItem[] = [
98+
{ fromFieldId: 'a', toFieldId: 'b' },
99+
{ fromFieldId: 'b', toFieldId: 'c' },
100+
];
101+
expect(topologicalSort(graph)).toEqual(['a', 'b', 'c']);
102+
});
103+
104+
it('should perform a branched topological sort', () => {
105+
const graph: IGraphItem[] = [
106+
{ fromFieldId: 'a', toFieldId: 'b' },
107+
{ fromFieldId: 'a', toFieldId: 'c' },
108+
{ fromFieldId: 'b', toFieldId: 'c' },
109+
{ fromFieldId: 'b', toFieldId: 'd' },
110+
];
111+
expect(topologicalSort(graph)).toEqual(['a', 'b', 'd', 'c']);
112+
});
113+
114+
it('should handle an empty graph', () => {
115+
const graph: IGraphItem[] = [];
116+
expect(topologicalSort(graph)).toEqual([]);
117+
});
118+
119+
it('should handle a graph with a single circular node', () => {
120+
const graph: IGraphItem[] = [{ fromFieldId: 'a', toFieldId: 'a' }];
121+
expect(() => topologicalSort(graph)).toThrowError();
122+
});
123+
124+
it('should handle graphs with circular dependencies', () => {
125+
const graph: IGraphItem[] = [
126+
{ fromFieldId: 'a', toFieldId: 'b' },
127+
{ fromFieldId: 'b', toFieldId: 'a' },
128+
];
129+
expect(() => topologicalSort(graph)).toThrowError();
130+
});
131+
});
132+
133+
describe('topoOrderWithDepends', () => {
134+
it('should return an empty array for an empty graph', () => {
135+
const result = topoOrderWithDepends('anyNodeId', []);
136+
expect(result).toEqual([
137+
{
138+
id: 'anyNodeId',
139+
dependencies: [],
140+
},
141+
]);
142+
});
143+
144+
it('should handle circular single node graph correctly', () => {
145+
const graph: IGraphItem[] = [{ fromFieldId: '1', toFieldId: '1' }];
146+
expect(() => topoOrderWithDepends('1', graph)).toThrowError();
147+
});
148+
149+
it('should handle circular node graph correctly', () => {
150+
const graph: IGraphItem[] = [
151+
{ fromFieldId: '1', toFieldId: '2' },
152+
{ fromFieldId: '2', toFieldId: '1' },
153+
];
154+
expect(() => topoOrderWithDepends('1', graph)).toThrowError();
155+
});
156+
157+
it('should return correct order for a normal DAG', () => {
158+
const graph: IGraphItem[] = [
159+
{ fromFieldId: '1', toFieldId: '2' },
160+
{ fromFieldId: '2', toFieldId: '3' },
161+
];
162+
const result = topoOrderWithDepends('1', graph);
163+
expect(result).toEqual([
164+
{ id: '1', dependencies: [] },
165+
{ id: '2', dependencies: ['1'] },
166+
{ id: '3', dependencies: ['2'] },
167+
]);
168+
});
169+
170+
it('should return correct order for a complex DAG', () => {
171+
const graph: IGraphItem[] = [
172+
{ fromFieldId: '1', toFieldId: '2' },
173+
{ fromFieldId: '2', toFieldId: '3' },
174+
{ fromFieldId: '1', toFieldId: '3' },
175+
{ fromFieldId: '3', toFieldId: '4' },
176+
];
177+
const result = topoOrderWithDepends('1', graph);
178+
expect(result).toEqual([
179+
{ id: '1', dependencies: [] },
180+
{ id: '2', dependencies: ['1'] },
181+
{ id: '3', dependencies: ['2', '1'] },
182+
{ id: '4', dependencies: ['3'] },
183+
]);
184+
});
185+
});
186+
187+
describe('hasCycle', () => {
188+
it('should return false for an empty graph', () => {
189+
expect(hasCycle([])).toBe(false);
190+
});
191+
192+
it('should return true for a single node graph link to self', () => {
193+
const graph = [{ fromFieldId: '1', toFieldId: '1' }];
194+
expect(hasCycle(graph)).toBe(true);
195+
});
196+
197+
it('should return false for a normal DAG without cycles', () => {
198+
const graph = [
199+
{ fromFieldId: '1', toFieldId: '2' },
200+
{ fromFieldId: '2', toFieldId: '3' },
201+
];
202+
expect(hasCycle(graph)).toBe(false);
203+
});
204+
205+
it('should return true for a graph with a cycle', () => {
206+
const graph = [
207+
{ fromFieldId: '1', toFieldId: '2' },
208+
{ fromFieldId: '2', toFieldId: '3' },
209+
{ fromFieldId: '3', toFieldId: '1' }, // creates a cycle
210+
];
211+
expect(hasCycle(graph)).toBe(true);
212+
});
213+
});
214+
215+
describe('topoOrderWithStart', () => {
216+
it('should return correct order for a normal DAG', () => {
217+
const graph: IGraphItem[] = [
218+
{ fromFieldId: '1', toFieldId: '2' },
219+
{ fromFieldId: '2', toFieldId: '3' },
220+
];
221+
const result = topoOrderWithStart('1', graph);
222+
expect(result).toEqual(['1', '2', '3']);
223+
});
224+
225+
it('should return correct order for a complex DAG', () => {
226+
const graph: IGraphItem[] = [
227+
{ fromFieldId: '1', toFieldId: '2' },
228+
{ fromFieldId: '2', toFieldId: '3' },
229+
{ fromFieldId: '1', toFieldId: '3' },
230+
{ fromFieldId: '3', toFieldId: '4' },
231+
];
232+
const result = topoOrderWithStart('1', graph);
233+
expect(result).toEqual(['1', '2', '3', '4']);
234+
});
235+
});
236+
237+
describe('pruneGraph', () => {
238+
test('returns an empty array for an empty graph', () => {
239+
expect(pruneGraph('A', [])).toEqual([]);
240+
});
241+
242+
test('returns correct graph for a single-node graph', () => {
243+
const graph: IGraphItem[] = [{ fromFieldId: 'A', toFieldId: 'B' }];
244+
expect(pruneGraph('A', graph)).toEqual(graph);
245+
});
246+
247+
test('returns correct graph for a tow-node graph', () => {
248+
const graph: IGraphItem[] = [
249+
{ fromFieldId: 'A', toFieldId: 'C' },
250+
{ fromFieldId: 'B', toFieldId: 'C' },
251+
];
252+
expect(pruneGraph('C', graph)).toEqual(graph);
253+
});
254+
255+
test('returns correct graph for a multi-node graph', () => {
256+
const graph: IGraphItem[] = [
257+
{ fromFieldId: 'A', toFieldId: 'B' },
258+
{ fromFieldId: 'B', toFieldId: 'C' },
259+
{ fromFieldId: 'C', toFieldId: 'D' },
260+
{ fromFieldId: 'E', toFieldId: 'F' },
261+
];
262+
const expectedResult: IGraphItem[] = [
263+
{ fromFieldId: 'A', toFieldId: 'B' },
264+
{ fromFieldId: 'B', toFieldId: 'C' },
265+
{ fromFieldId: 'C', toFieldId: 'D' },
266+
];
267+
expect(pruneGraph('A', graph)).toEqual(expectedResult);
268+
});
269+
270+
test('returns an empty array for a graph with unrelated node', () => {
271+
const graph: IGraphItem[] = [
272+
{ fromFieldId: 'B', toFieldId: 'C' },
273+
{ fromFieldId: 'C', toFieldId: 'D' },
274+
];
275+
expect(pruneGraph('A', graph)).toEqual([]);
276+
});
277+
});
85278
});

0 commit comments

Comments
 (0)