import {useState} from 'react'; import {base} from '@airtable/blocks'; import _ from 'lodash'; import {useWatchable, useGlobalConfig} from '@airtable/blocks/ui'; import {FieldType} from '@airtable/blocks/models'; import parseSchema from './parseSchema'; import { calculateLinkPaths, getUpdatedTableCoords, getInitialTableCoords, } from './coordinateHelpers'; export const ConfigKeys = Object.freeze({ ENABLED_LINKS_BY_TYPE: 'enabledLinksByType', TABLE_COORDS_BY_TABLE_ID: 'tableCoordsByTableId', }); /** * Reads values from GlobalConfig and calculates relevant positioning information for the nodes * and links. * * A node represents either a "row" in the visualization - either a table header or a field. A link * represents a relationship between two nodes. We persist two types of information in globalConfig: * (1) whether a certain link type should be shown; and (2) the x,y position for each table, where * position indicates the top-left corner of the table. * * Positioning calculation takes place as follows: * (1) Parse the schema of the base (ie, what tables exist, what fields exist on those tables, * and what are the relationships/links between fields & tables). * (2) Lookup the persisted position for each table, and check for any recently-created tables that * are not accounted for in these persisted settings. Assign positions for any new tables. * (3) Using the table & link configurations from step 1 and table coordinates from step 2, * calculate the paths (ie, the `d` attribute for SVG element) for the links. Because the row widths * & heights are constant, we can infer coordinates by adding offsets to the table coordinates. * * When dragging a table and updating positions on `mousemove`, it is inefficient to go through this * calculation process / rely on React state updates to propagate down to the child components. * Instead, we only calculate required changes and directly manipulate the DOM (@see DragWrapper). * The new table coordinates are persisted to globalConfig when dragging is finished, but positions * and paths for nodes and links are only recalculated from scratch when the base schema changes. * Otherwise, we just rely on the current DOM position. * * @returns {{ * enabledLinksByType: { ['multipleRecordLinks' | 'formula' | 'multipleLookupValues' | 'rollup' | 'count']: boolean }, * tableCoordsByTableId: { TableId: { x: number, y: number }}, * tableConfigsByTableId: { TableId: { tableNode: Node, fieldNodes: Node[] }}, * nodesById: { NodeId: Node }, * linksById: { LinkId: Link }, * linkPathsByLinkId: { LinkId: string }, * dependentLinksByNodeId: { NodeId: Link[] } * }} */ export default function useSettings() { const [baseSchema, setBaseSchema] = useState(() => parseSchema(base)); const {nodesById, linksById, tableConfigsByTableId, dependentLinksByNodeId} = baseSchema; const globalConfig = useGlobalConfig(); let tableCoordsByTableId; if (!globalConfig.get(ConfigKeys.TABLE_COORDS_BY_TABLE_ID)) { // First time run, determine initial table coords tableCoordsByTableId = getInitialTableCoords(tableConfigsByTableId); globalConfig.setPathsAsync([ { path: [ConfigKeys.TABLE_COORDS_BY_TABLE_ID], value: tableCoordsByTableId, }, { path: [ConfigKeys.ENABLED_LINKS_BY_TYPE], value: { [FieldType.MULTIPLE_RECORD_LINKS]: true, [FieldType.FORMULA]: true, [FieldType.ROLLUP]: true, [FieldType.COUNT]: true, [FieldType.MULTIPLE_LOOKUP_VALUES]: true, }, }, ]); } else { // Non-first time run, check for any new tables missing from the old saved coords tableCoordsByTableId = globalConfig.get(ConfigKeys.TABLE_COORDS_BY_TABLE_ID); if ( _.difference(Object.keys(tableConfigsByTableId), Object.keys(tableCoordsByTableId)) .length > 0 ) { tableCoordsByTableId = getUpdatedTableCoords( tableConfigsByTableId, tableCoordsByTableId, ); if (globalConfig.hasPermissionToSet()) { globalConfig.setAsync(ConfigKeys.TABLE_COORDS_BY_TABLE_ID, tableCoordsByTableId); } } } const [linkPathsByLinkId, setLinkPathsByLinkId] = useState(() => calculateLinkPaths(linksById, tableConfigsByTableId, tableCoordsByTableId), ); // Only re-perform these potentially expensive calclulations when required, when the base // schema changes (ie, table added/removed/renamed, field added/removed/renamed). useWatchable(base, ['schema'], () => { const newSchema = parseSchema(base); const newTableCoords = getUpdatedTableCoords( newSchema.tableConfigsByTableId, tableCoordsByTableId, ); const newLinkPaths = calculateLinkPaths( newSchema.linksById, newSchema.tableConfigsByTableId, newTableCoords, ); if (globalConfig.hasPermissionToSet()) { globalConfig.setAsync(ConfigKeys.TABLE_COORDS_BY_TABLE_ID, newTableCoords); } setBaseSchema(newSchema); setLinkPathsByLinkId(newLinkPaths); }); const enabledLinksByType = { [FieldType.MULTIPLE_RECORD_LINKS]: globalConfig.get([ ConfigKeys.ENABLED_LINKS_BY_TYPE, FieldType.MULTIPLE_RECORD_LINKS, ]), [FieldType.FORMULA]: globalConfig.get([ ConfigKeys.ENABLED_LINKS_BY_TYPE, FieldType.FORMULA, ]), [FieldType.ROLLUP]: globalConfig.get([ConfigKeys.ENABLED_LINKS_BY_TYPE, FieldType.ROLLUP]), [FieldType.MULTIPLE_LOOKUP_VALUES]: globalConfig.get([ ConfigKeys.ENABLED_LINKS_BY_TYPE, FieldType.MULTIPLE_LOOKUP_VALUES, ]), [FieldType.COUNT]: globalConfig.get([ConfigKeys.ENABLED_LINKS_BY_TYPE, FieldType.COUNT]), }; return { enabledLinksByType, tableCoordsByTableId, tableConfigsByTableId, nodesById, linksById, linkPathsByLinkId, dependentLinksByNodeId, }; }