Skip to main content
Glama
views.store.ts75.1 kB
import { defineStore } from "pinia"; import * as _ from "lodash-es"; import { addStoreHooks, ApiRequest } from "@si/vue-lib/pinia"; import { IRect, Vector2d } from "konva/lib/types"; import { useToast } from "vue-toastification"; import { IconNames } from "@si/vue-lib/design-system"; import { URLPattern } from "@si/vue-lib"; import { ChangeSetId } from "@/api/sdf/dal/change_set"; import { ApprovalRequirementDefinitionId, Components, Groups, Sockets, StringGeometry, View, ViewApprovalRequirementDefinition, ViewDescription, ViewId, ViewNodes, } from "@/api/sdf/dal/views"; import { DiagramGroupData, DiagramNodeData, DiagramViewData, ElementHoverMeta, Size2D, } from "@/components/ModelingDiagram/diagram_types"; import { ComponentId, Edge, EdgeId, RawComponent, ViewNodeGeometry, } from "@/api/sdf/dal/component"; import { GROUP_BOTTOM_INTERNAL_PADDING, GROUP_INTERNAL_PADDING, NODE_WIDTH, SOCKET_SIZE, } from "@/components/ModelingDiagram/diagram_constants"; import { DefaultMap } from "@/utils/defaultmap"; import { ComponentType } from "@/api/sdf/dal/schema"; import { nonNullable } from "@/utils/typescriptLinter"; import router from "@/router"; import handleStoreError from "./errors"; import { diagramUlid as clientUlid, useChangeSetsStore, } from "./change_sets.store"; import { ComponentsAndEdges, processRawComponent, useComponentsStore, } from "./components.store"; import { useRealtimeStore } from "./realtime/realtime.store"; import { useWorkspacesStore } from "./workspaces.store"; import { useRouterStore } from "./router.store"; import { useQualificationsStore } from "./qualifications.store"; import { useAuthStore, UserId } from "./auth.store"; import { useFeatureFlagsStore } from "./feature_flags.store"; function vectorAdd(v1: Vector2d, v2: Vector2d) { return { x: v1.x + v2.x, y: v1.y + v2.y, } as Vector2d; } const MAX_RETRIES = 5; type PendingComponent = { tempId: string; position: Vector2d; }; type RequestUlid = string; type VectorWithRadius = Vector2d & { radius: number }; class UniqueStack<T> { items: T[]; constructor() { this.items = []; } idx(i: T) { return this.items.findIndex((_i) => _i === i); } push(i: T) { if (this.idx(i) === -1) this.items.push(i); } remove(i: T) { const idx = this.idx(i); if (idx !== -1) this.items.splice(idx, 1); } pop() { if (this.items.length === 0) { return null; } return this.items.pop(); } // implements "recent -> older" ordering while looping *[Symbol.iterator](): Iterator<T> { for (const i of this.items.reverse()) yield i; } } const setSockets = ( component: DiagramGroupData | DiagramNodeData, geometry: Vector2d & Partial<Size2D>, ) => { const sockets: Sockets = {}; const width = geometry.width || NODE_WIDTH; const left = component.layoutLeftSockets(width); left.sockets.forEach((s) => { const center = { x: geometry.x + left.x + s.position.x - SOCKET_SIZE, y: geometry.y + left.y + s.position.y, }; sockets[s.uniqueKey] = { center, }; }); const right = component.layoutRightSockets(width); right.sockets.forEach((s) => { const center = { x: geometry.x + right.x + width - // we add the full width to get to the right side... SOCKET_SIZE + // minus the size of the socket, b/c later code adds half the size of the socket s.position.x, y: geometry.y + right.y + s.position.y, }; sockets[s.uniqueKey] = { center, }; }); return sockets; }; export const VIEW_DEFAULTS = { icon: "create" as IconNames, color: "#9d00ff", schemaName: "View", }; export interface ComponentStats { components: number; resources: number; failed: number; } /** * In general we treat the front end POSITION data as truth * And push it to the backend, retries, last wins, etc */ export const useViewsStore = (forceChangeSetId?: ChangeSetId) => { const workspacesStore = useWorkspacesStore(); const workspaceId = workspacesStore.selectedWorkspacePk; const changeSetsStore = useChangeSetsStore(); const componentsStore = useComponentsStore(forceChangeSetId); const qualStore = useQualificationsStore(); const routerStore = useRouterStore(); const authStore = useAuthStore(); const realtimeStore = useRealtimeStore(); const toast = useToast(); const featureFlagsStore = useFeatureFlagsStore(); let changeSetId: ChangeSetId | undefined; if (forceChangeSetId) { changeSetId = forceChangeSetId; } else { changeSetId = changeSetsStore.selectedChangeSetId; } const visibilityParams = { visibility_change_set_pk: changeSetId, workspaceId, }; const API_PREFIX = [ "v2", "workspaces", { workspaceId }, "change-sets", { changeSetId }, "views", ] as URLPattern; const APPROVAL_REQUIREMENTS_API_PREFIX = [ "v2", "workspaces", { workspaceId }, "change-sets", { changeSetId }, "approval-requirement-definitions", ] as URLPattern; return addStoreHooks( workspaceId, changeSetId, defineStore(`ws${workspaceId || "NONE"}/cs${changeSetId || "NONE"}/views`, { state: () => ({ activatedAndFetched: false, selectedViewId: null as ViewId | null, outlinerViewId: null as ViewId | null, recentViews: new UniqueStack() as UniqueStack<ViewId>, // every views data goes here viewsById: {} as Record<ViewId, View>, viewList: [] as ViewDescription[], requirementDefinitionsById: {} as Record< ApprovalRequirementDefinitionId, ViewApprovalRequirementDefinition >, /* * * these hold the data for everything on the diagram in the SELECTED view * as selectedView changes we repopulate all this data * this is pushing together `movedElementPositions` and `resizedElementSizes` * and can make `renderedGeometriesByComponentId` unnecessary */ components: {} as Components, groups: {} as Groups, sockets: {} as Sockets, viewNodes: {} as ViewNodes, // size of components when dragged to the stage inflightElementSizes: {} as Record<RequestUlid, ComponentId[]>, // prevents run away retries, unknown what circumstances could lead to this, but protecting ourselves inflightRetryCounter: new DefaultMap<string, number>(() => 0), pendingInsertedComponents: {} as Record<string, PendingComponent>, // componentId we drag from outliner into the selectedView addComponentId: null as ComponentId | null, // viewId we drag from left panel into the selectedView addViewId: null as ViewId | null, // these are components, groups, or viewNodes selectedComponentIds: [] as ComponentId[], selectedEdgeId: null as EdgeId | null, selectedDisplayEdgeId: null as EdgeId | null, selectedComponentDetailsTab: null as string | null, selectedViewDetailsId: null as ViewId | null, hoveredComponentId: null as ComponentId | null, hoveredEdgeId: null as EdgeId | null, hoveredComponentMeta: null as ElementHoverMeta | null, }), getters: { // this could get expensive, might want to stop computing this viewStats() { const stats: Record<ViewId, ComponentStats> = {}; Object.values(this.viewsById).forEach((view) => { const ids = Object.keys(view.components).concat( Object.keys(view.groups), ); const stat = { components: ids.length, failed: 0, resources: 0, }; ids.forEach((id) => { const c = componentsStore.allComponentsById[id]; if (!c) return; if (c.def.hasResource) stat.resources++; const qual = qualStore.qualificationStatsByComponentId[id]; if (qual?.failed) stat.failed++; }); stats[view.id] = stat; }); return stats; }, hoveredComponent(): DiagramNodeData | DiagramGroupData | undefined { return componentsStore.allComponentsById[ this.hoveredComponentId || 0 ]; }, selectedComponentId: (state) => { return state.selectedComponentIds.length === 1 ? state.selectedComponentIds[0] : null; }, viewListCount: (state) => { return state.viewList.length; }, selectedComponent(): | DiagramNodeData | DiagramGroupData | DiagramViewData | undefined { return ( componentsStore.allComponentsById[this.selectedComponentId || 0] ?? this.viewNodes[this.selectedComponentId || 0] ); }, selectedComponents(): ( | DiagramNodeData | DiagramGroupData | DiagramViewData )[] { return _.compact( _.map( this.selectedComponentIds, (id) => componentsStore.allComponentsById[id] ?? this.viewNodes[id], ), ); }, selectedEdge(): Edge | undefined { return componentsStore.rawEdgesById[this.selectedEdgeId || 0]; }, selectedComponentsAndChildren(): ( | DiagramNodeData | DiagramGroupData | DiagramViewData )[] { const selectedAndChildren: Record< string, DiagramNodeData | DiagramGroupData | DiagramViewData > = {}; Object.values(componentsStore.allComponentsById).forEach( (component) => { this.selectedComponents?.forEach((el) => { if (component.def.ancestorIds?.includes(el.def.id)) { selectedAndChildren[component.def.id] = component; } }); }, ); this.selectedComponents?.forEach((el) => { selectedAndChildren[el.def.id] = el; }); return Object.values(selectedAndChildren); }, deletableSelectedComponents(): (DiagramNodeData | DiagramGroupData)[] { const components: (DiagramNodeData | DiagramGroupData)[] = []; this.selectedComponentsAndChildren.forEach((c) => { if ( !(c instanceof DiagramViewData) && "changeStatus" in c.def && c.def.changeStatus !== "deleted" ) { components.push(c); } }); return components; }, restorableSelectedComponents(): (DiagramNodeData | DiagramGroupData)[] { const components: (DiagramNodeData | DiagramGroupData)[] = []; this.selectedComponentsAndChildren.forEach((c) => { if ( !(c instanceof DiagramViewData) && "changeStatus" in c.def && c.def.changeStatus === "deleted" ) { components.push(c); } }); return components; }, erasableSelectedComponents(): (DiagramNodeData | DiagramGroupData)[] { const components: (DiagramNodeData | DiagramGroupData)[] = []; this.selectedComponentsAndChildren.forEach((c) => { if ( !(c instanceof DiagramViewData) && "changeStatus" in c.def && c.def.changeStatus !== "deleted" ) { components.push(c); } }); return components; }, detailsTabSlugs: (state) => { const slug = state.selectedComponentDetailsTab; // root level tabs if (["resource", "management", "component"].includes(slug || "")) { return [slug, undefined]; } // subtabs if (slug?.startsWith("management-")) return ["management", slug]; if (slug?.startsWith("resource-")) return ["resource", slug]; // all other subtabs (currently) are in the component tab return ["component", slug]; }, diagramIsEmpty(state): boolean { return ( Object.keys(state.components).length === 0 && Object.keys(state.groups).length === 0 && Object.keys(state.viewNodes).length === 0 ); }, defaultViewId: (state) => { const idx = state.viewList.findIndex((v) => v.isDefault); return idx !== -1 ? state.viewList[idx]?.id : state.viewList[0]?.id; }, edges: (state) => { const subscriptions = featureFlagsStore.SIMPLE_SOCKET_UI ? _.values(componentsStore.diagramSubscriptionEdgesById) : []; const edges = Object.values(componentsStore.diagramEdgesById) .concat(subscriptions) .filter((e) => { const to = e.toNodeKey.substring(2); const from = e.fromNodeKey.substring(2); const componentIds = Object.keys(state.components).concat( Object.keys(state.groups), ); return componentIds.includes(to) && componentIds.includes(from); }); return edges; }, selectedView: (state) => state.viewsById[state.selectedViewId || ""], outlinerView: (state) => state.viewsById[state.outlinerViewId || ""], // NOTE: this is computed for now, but we could easily make this state // and re-compute it for only which elements get moved (if it becomes a bottleneck) contentBoundingBoxesByGroupId(state): Record<ComponentId, IRect> { const boxDictionary: Record<string, IRect> = {}; const groups = Object.keys(state.groups) .map((c) => componentsStore.groupsById[c]) .filter((c): c is DiagramGroupData => !!c); for (const group of groups) { const childIds = group.def.childIds; if (!childIds) continue; let top; let bottom; let left; let right; for (const childId of childIds) { const geometry = state.groups[childId] || state.components[childId]; if (!geometry) continue; if (!top || geometry.y < top) top = geometry.y; const thisLeft = geometry.x - geometry.width / 2; if (!left || thisLeft < left) left = thisLeft; const thisRight = geometry.x + geometry.width / 2; if (!right || thisRight > right) right = thisRight; const thisBottom = geometry.y + geometry.height; if (!bottom || thisBottom > bottom) bottom = thisBottom; } if ( left === undefined || right === undefined || top === undefined || bottom === undefined ) { continue; } // i dont know if i need these paddings yet boxDictionary[group.def.id] = { x: left - GROUP_INTERNAL_PADDING, y: top - GROUP_INTERNAL_PADDING, width: right - left + GROUP_INTERNAL_PADDING * 2, height: bottom - top + GROUP_INTERNAL_PADDING + GROUP_BOTTOM_INTERNAL_PADDING, }; } return boxDictionary; }, requirementDefintionsByViewId: (state) => { const out = {} as Record<ViewId, ViewApprovalRequirementDefinition[]>; for (const requirement of Object.values( state.requirementDefinitionsById, )) { if (!out[requirement.entityId]) { out[requirement.entityId] = []; } out[requirement.entityId]?.push(requirement); } return out; }, viewNamesByComponentId: (state) => { const record = {} as Record<ComponentId, string[]>; const views = _.values(state.viewsById); for (const view of views) { for (const componentId of _.keys(view.components)) { record[componentId] ??= []; record[componentId]?.push(view.name); } for (const groupId of _.keys(view.groups)) { record[groupId] ??= []; record[groupId]?.push(view.name); } } return record; }, }, actions: { geoFrom( el: DiagramNodeData | DiagramGroupData | DiagramViewData, ): IRect | undefined { let geo: IRect | undefined; if (el.def.componentType === ComponentType.View) { const v = this.viewNodes[el.def.id]; if (v) { geo = { x: v.def.x, y: v.def.y, width: v.def.width, height: v.def.height, }; } } else { geo = el.def.isGroup ? this.groups[el.def.id] : this.components[el.def.id]; } return geo; }, setHoveredComponentId(id: ComponentId | null, meta?: ElementHoverMeta) { this.hoveredComponentId = id; this.hoveredComponentMeta = meta || null; this.hoveredEdgeId = null; }, setHoveredEdgeId(id: ComponentId | null) { this.hoveredComponentId = null; this.hoveredEdgeId = id; }, setComponentDetailsTab(tabSlug: string | null) { // we ignore the top level "component" and "actions" tabs // since we always need a child selected, and setting these // would overwrite the child being selected if (["component", "actions"].includes(tabSlug || "")) return; this.selectedComponentDetailsTab = tabSlug; this.syncSelectionIntoUrl(); }, syncSelectionIntoUrl() { let selectedIds: string[] = []; if (this.selectedEdgeId) { selectedIds = [`e_${this.selectedEdgeId}`]; } else if (this.selectedComponentIds.length) { selectedIds = _.map(this.selectedComponentIds, (id) => `c_${id}`); } const newQueryObj = { ...(selectedIds.length && { s: selectedIds.join("|") }), ...(this.selectedComponentDetailsTab && { t: this.selectedComponentDetailsTab, }), }; if (!_.isEqual(routerStore.currentRoute?.query, newQueryObj)) { routerStore.replace(changeSetId, { params: { ...routerStore.currentRoute?.params }, query: newQueryObj, }); } }, syncUrlIntoSelection() { const ids = ( (router.currentRoute.value.query?.s as string) || "" ).split("|"); if (!ids.length) { this.selectedComponentIds = []; this.selectedEdgeId = null; this.selectedViewDetailsId = null; } else if (ids.length === 1 && ids[0]?.startsWith("e_")) { this.selectedComponentIds = []; this.selectedEdgeId = ids[0].substring(2); this.selectedViewDetailsId = null; } else { this.selectedComponentIds = ids.map((id) => id.substring(2)); this.selectedEdgeId = null; this.selectedViewDetailsId = null; } const tabSlug = (router.currentRoute.value.query?.t as string) || null; if (this.selectedComponentIds.length === 1) { this.selectedComponentDetailsTab = tabSlug; } else { this.selectedComponentDetailsTab = null; } }, setSelectedViewDetails(id: ViewId) { this.selectedViewDetailsId = id; this.selectedComponentIds = []; this.selectedEdgeId = null; this.selectedDisplayEdgeId = null; }, setSelectedEdgeId( selection: EdgeId | null, displayEdgeId?: EdgeId | null, ) { // clear component selection this.selectedViewDetailsId = null; this.selectedComponentIds = []; this.selectedEdgeId = selection; this.selectedComponentDetailsTab = null; if (displayEdgeId) this.selectedDisplayEdgeId = displayEdgeId; this.syncSelectionIntoUrl(); }, setSelectedComponentId( selection: ComponentId | ComponentId[] | null, opts?: { toggle?: boolean; detailsTab?: string }, ) { const key = `${changeSetId}_selected_component`; this.selectedViewDetailsId = null; this.selectedEdgeId = null; this.selectedDisplayEdgeId = null; if (!selection || !selection.length) { this.selectedComponentIds = []; // forget which details tab is active when selection is cleared this.selectedComponentDetailsTab = null; if (router.currentRoute.value.name === "workspace-compose") { window.localStorage.removeItem(key); } } else { const validSelectionArray = _.reject( _.isArray(selection) ? selection : [selection], (id) => !Object.keys(componentsStore.allComponentsById).includes(id) && !Object.keys(this.viewNodes).includes(id), ); if (opts?.toggle) { this.selectedComponentIds = _.xor( this.selectedComponentIds, validSelectionArray, ); } else { this.selectedComponentIds = validSelectionArray; } } if (opts?.detailsTab) { this.selectedComponentDetailsTab = opts.detailsTab; } if (this.selectedComponentIds.length === 1) { const _id = this.selectedComponentIds[0]; if (_id) window.localStorage.setItem(key, _id); } this.syncSelectionIntoUrl(); }, setOutlinerView(id: ViewId) { this.outlinerViewId = id; this._enforceSelectedComponents(); }, // cannot select components that are not in either selected view _enforceSelectedComponents() { const v1 = this.viewsById[this.outlinerViewId || ""]; const v2 = this.viewsById[this.selectedViewId || ""]; const ids: ComponentId[] = []; if (v1) { ids.push(...Object.keys(v1.components)); ids.push(...Object.keys(v1.groups)); } if (v2) { ids.push(...Object.keys(v2.components)); ids.push(...Object.keys(v2.groups)); } const valid = this.selectedComponentIds.filter((cId) => ids.includes(cId), ); this.selectedComponentIds = valid; this.syncSelectionIntoUrl(); }, viewAssignment(view: View) { /* * * i think i want to set these as in-memory references * that way i don't have to do two writes for incoming WsEvents * or two writes for user actions * * this does mean that `draggedElementsPositionsPreDrag` and * `resizedElementSizesPreResize` need to be populated * but those could just be a `structuredClone` of this data */ this.components = view.components; this.groups = view.groups; this.viewNodes = view.viewNodes; // derive the socket position from the component position // to begin, and then adjust it via delta when things move this.sockets = view.sockets; }, clearSelectedView() { this.selectedViewId = null; this.sockets = {}; this.components = {}; this.groups = {}; this.viewNodes = {}; }, selectView(id: ViewId, navigable = true) { const view = this.viewsById[id]; if (view) { if (navigable) { const route = router.currentRoute; const params = { ...route.value.params, viewId: id, }; routerStore.push(changeSetId, { name: "workspace-compose-view", params, }); } // move the currently selected view to the top of the if (this.selectedViewId) { this.pushRecentView(this.selectedViewId); } this.selectedViewId = id; this.outlinerViewId = id; this._enforceSelectedComponents(); this.viewAssignment(view); this.setGroupZIndex(); } else { this.FETCH_VIEW(id); } }, closeRecentView(id: ViewId) { this.recentViews.remove(id); }, pushRecentView(id: ViewId) { this.recentViews.push(id); }, async LIST_VIEWS() { return new ApiRequest<ViewDescription[]>({ method: "get", url: API_PREFIX, onSuccess: async (views) => { this.viewList = views; this.SORT_LIST_VIEWS(); }, }); }, async FETCH_COMPLETE_DATA() { await componentsStore.FETCH_ALL_COMPONENTS(); const ids = this.viewList.map((v) => v.id); await Promise.all([...ids.map((id) => this.FETCH_VIEW_GEOMETRY(id))]); }, SORT_LIST_VIEWS() { this.viewList = this.viewList.sort((a, b) => { if (a.isDefault) return -1; if (b.isDefault) return 1; return a.name.localeCompare(b.name); }); }, async FETCH_VIEW_GEOMETRY(viewId: ViewId) { // requires all components to be in place! return new ApiRequest<{ viewId: ViewId; name: string; components: Record<ComponentId, Vector2d & Partial<Size2D>>; views: Record<ViewId, Vector2d & Size2D>; }>({ method: "get", url: API_PREFIX.concat([{ viewId }, "get_geometry"]), onSuccess: (response) => { const view: View = { id: response.viewId, name: response.name, components: {}, groups: {}, sockets: {}, viewNodes: {}, }; this.viewsById[response.viewId] = view; Object.entries(response.views).forEach(([viewId, geo]) => { const v = this.viewList.find((_v) => _v.id === viewId); if (!v) return; view.viewNodes[v.id] = new DiagramViewData({ ...VIEW_DEFAULTS, ...geo, // if geo has properties like "id" we don't want them to overwrite view props // so we spread v last ...v, componentType: ComponentType.View, }); }); Object.entries(response.components).forEach( ([componentId, geo]) => { const node = componentsStore.allComponentsById[componentId]; if (!node) return; let geometry: IRect; if ("width" in node) { geo.width = node.width; geo.height = node.height; geometry = { ...(geo as IRect) }; view.components[componentId] = geometry; } else { geometry = { ...(geo as IRect) }; if (!geometry.width) geometry.width = 500; if (!geometry.height) geometry.height = 500; view.groups[componentId] = { // this one is actually an IRect ...geometry, size: geometry.width * geometry.height, zIndex: 0, }; } for (const [key, loc] of Object.entries( setSockets(node, geometry), )) { view.sockets[key] = loc; } }, ); if (this.selectedViewId === view.id) this.viewAssignment(view); this.setGroupZIndex(); }, }); }, setAddComponentId(id: ComponentId) { this.addComponentId = id; this.setSelectedComponentId(null); }, cancelAdd() { this.addComponentId = null; }, removeSelectedViewComponentFromCurrentView() { if (this.selectedViewId) { const componentIds = this.selectedComponents .filter((c) => c.def.componentType !== ComponentType.View) .map((c) => c.def.id); if (componentIds.length > 0) { this.REMOVE_FROM(this.selectedViewId, componentIds); } const viewIds = this.selectedComponents .filter((c) => c.def.componentType === ComponentType.View) .map((c) => c.def.id); if (viewIds.length > 0) { this.REMOVE_VIEW_FROM(this.selectedViewId, viewIds); } } }, async CREATE_VIEW(name: string) { return new ApiRequest<ViewDescription>({ method: "post", url: API_PREFIX, params: { name, clientUlid }, }); }, async UPDATE_VIEW_NAME(view_id: ViewId, name: string) { return new ApiRequest<null>({ method: "put", url: API_PREFIX.concat([view_id]), params: { name, clientUlid }, onSuccess: () => { componentsStore.eventBus.emit("renameView", view_id); }, }); }, async FETCH_VIEW(viewId?: ViewId) { let url = API_PREFIX.concat(["default", "get_diagram"]); if (viewId) url = API_PREFIX.concat([{ viewId }, "get_diagram"]); return new ApiRequest<{ view: ViewDescription; diagram: ComponentsAndEdges & { views: ViewNodeGeometry[]; }; }>({ url, params: { ...visibilityParams, }, onFail: () => { // getting the view id from URL can fail if you've moved changesets const route = useRouterStore().currentRoute; if ( !this.selectedViewId && route?.name === "workspace-compose-view" ) { const params = { ...route.params }; if ("viewId" in params) delete params.viewId; routerStore.push(changeSetId, { name: "workspace-compose", params, }); } }, onSuccess: (response) => { componentsStore.SET_COMPONENTS_FROM_VIEW(response.diagram, { representsAllComponents: false, }); // remove invalid component IDs from the selection const validComponentIds = _.intersection( this.selectedComponentIds, _.keys(componentsStore.rawComponentsById), ); this.setSelectedComponentId(validComponentIds); const components: RawComponent[] = []; const groups: RawComponent[] = []; for (const component of response.diagram.components) { if (component) { if (component.componentType === ComponentType.Component) { components.push(component); } else groups.push(component); } } const views: (ViewDescription & IRect)[] = []; response.diagram.views.forEach((v) => { views.push({ ...v.view, ...v.geometry }); }); this.SET_COMPONENTS_FROM_VIEW(response.view, { components, groups, views, }); this.selectView(response.view.id, false); this.setGroupZIndex(); }, }); }, SET_COMPONENTS_FROM_VIEW( view: ViewDescription, response: { components: RawComponent[]; groups: RawComponent[]; views: (ViewDescription & IRect)[]; }, ) { const components: Components = {}; const groups: Groups = {}; const sockets: Sockets = {}; for (const component of response.components) { if (!component.viewData?.geometry) continue; // Note: its actually a Vector2D, but type guarding it so i can set the width and height below const geo = { ...component.viewData.geometry } as IRect; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const node = componentsStore.allComponentsById[ component.id ]! as DiagramNodeData; geo.width = node.width; geo.height = node.height; components[component.id] = geo; for (const [key, loc] of Object.entries(setSockets(node, geo))) { sockets[key] = loc; } } for (const group of response.groups) { const geo = group.viewData?.geometry; if (!geo) continue; const geometry: IRect = { ...(geo as IRect) }; if (!geometry.width) geometry.width = 500; if (!geometry.height) geometry.height = 500; groups[group.id] = { // this one is actually an IRect ...geometry, size: geometry.width * geometry.height, zIndex: 0, }; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const node = componentsStore.allComponentsById[ group.id ]! as DiagramGroupData; for (const [key, loc] of Object.entries( setSockets(node, geometry), )) { sockets[key] = loc; } } const viewNodes: ViewNodes = {}; response.views.forEach((v) => { viewNodes[v.id] = new DiagramViewData({ ...v, ...VIEW_DEFAULTS, componentType: ComponentType.View, }); }); this.viewsById[view.id] = { id: view.id, name: view.name, components, groups, sockets, viewNodes, }; }, /** * @param clientUlid whoami * @param components the selected components acted upon * @param positionDelta the vector to adjust all elements * @param opts broadcast and persistence */ // REDO the 409 conflicts and retry logic async MOVE_COMPONENTS( components: (DiagramGroupData | DiagramNodeData)[], positionDelta: Vector2d, opts: { writeToChangeSet?: boolean; broadcastToClients?: boolean }, ) { if (positionDelta.x !== 0 || positionDelta.y !== 0) { components.forEach((c) => { const orig = c.def.isGroup ? this.groups[c.def.id] : this.components[c.def.id]; if (!orig) return; const newPos = vectorAdd({ ...orig }, positionDelta); orig.x = newPos.x; orig.y = newPos.y; if (featureFlagsStore.SIMPLE_SOCKET_UI) { const width = c.def.isGroup ? this.groups[c.def.id]?.width : this.components[c.def.id]?.width; if (!width) return; const displaySockets = [ ...c.layoutLeftSockets(width).sockets, ...c.layoutRightSockets(width).sockets, ]; new Set(displaySockets.map((s) => s.uniqueKey)).forEach( (uniqueKey) => { const geo = this.sockets[uniqueKey]; if (!geo) return; geo.center.x += positionDelta.x; geo.center.y += positionDelta.y; }, ); } else { c.sockets.forEach((s) => { const geo = this.sockets[s.uniqueKey]; if (!geo) return; geo.center.x += positionDelta.x; geo.center.y += positionDelta.y; }); } }); } if (!opts.broadcastToClients && !opts.writeToChangeSet) return; if (opts.broadcastToClients && changeSetsStore.selectedChangeSetId) { const payload: Record<ComponentId, IRect> = {}; components.forEach((c) => { const geo = c.def.isGroup ? this.groups[c.def.id] : this.components[c.def.id]; if (geo) { if (c.def.isGroup) { if (!geo.width) geo.width = 500; if (!geo.height) geo.height = 500; } payload[c.def.id] = { x: Math.round(geo.x), y: Math.round(geo.y), width: Math.round(geo.width), height: Math.round(geo.height), }; } }); const realtimeStore = useRealtimeStore(); const positions = Object.entries(payload).map( ([componentId, geo]) => ({ componentId, ...geo }), ); realtimeStore.sendMessage({ kind: "ComponentSetPosition", data: { positions, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion viewId: this.selectedViewId!, clientUlid, changeSetId: changeSetsStore.selectedChangeSetId, }, }); } if (opts.writeToChangeSet) { const payload: Record<ComponentId, StringGeometry> = {}; components.forEach((c) => { const geo = c.def.isGroup ? this.groups[c.def.id] : this.components[c.def.id]; if (geo) { payload[c.def.id] = { x: Math.round(geo.x).toString(), y: Math.round(geo.y).toString(), width: Math.round(geo.width).toString(), height: Math.round(geo.height).toString(), }; } }); return new ApiRequest<{ requestUlid: RequestUlid }>({ method: "put", url: API_PREFIX.concat([ { viewId: this.selectedViewId }, "component/set_geometry", ]), params: { clientUlid, dataByComponentId: payload, ...visibilityParams, }, optimistic: (requestUlid: RequestUlid) => { this.inflightElementSizes[requestUlid] = Object.keys(payload); }, onFail: (err) => { // only handle conflicts here if (err.response.status !== 409) { return; } const reqPayload = JSON.parse(err.config.data); // are the components that failed currently inflight? // if not, resend their latest data const failed = this.inflightElementSizes[reqPayload.requestUlid]; if (!failed) return; delete this.inflightElementSizes[reqPayload.requestUlid]; const all_inflight_components = new Set( Object.values(this.inflightElementSizes).flat(), ); const maybe_retry: ComponentId[] = ( failed as ComponentId[] ).filter((x) => !all_inflight_components.has(x)); const prevent = new Set(); for (const componentId of maybe_retry) { const cnt = (this.inflightRetryCounter.get(componentId) || 0) + 1; if (cnt > MAX_RETRIES) prevent.add(componentId); else this.inflightRetryCounter.set(componentId, cnt); } if (prevent.size > 0) throw Error("Too many retries"); const retry = maybe_retry.filter((x) => !prevent.has(x)); if (retry.length > 0) { const components = retry .map( (componentId) => componentsStore.allComponentsById[componentId], ) .filter(nonNullable); this.MOVE_COMPONENTS( components, { x: 0, y: 0 }, { writeToChangeSet: true }, ); } }, onSuccess: (response) => { delete this.inflightElementSizes[response.requestUlid]; }, }); } }, async MOVE_VIEWS( components: DiagramViewData[], positionDelta: Vector2d, opts: { writeToChangeSet?: boolean; broadcastToClients?: boolean }, ) { if (positionDelta.x !== 0 || positionDelta.y !== 0) { components.forEach((c) => { const v = this.viewNodes[c.def.id]; if (!v) return; const newPos = vectorAdd( { x: v.def.x, y: v.def.y }, positionDelta, ); v.def.x = newPos.x; v.def.y = newPos.y; }); } if (!opts.broadcastToClients && !opts.writeToChangeSet) return; if (opts.broadcastToClients && changeSetsStore.selectedChangeSetId) { const payload: Record<ComponentId, IRect> = {}; components.forEach((c) => { const v = this.viewNodes[c.def.id]; if (!v) return; payload[c.def.id] = { x: Math.round(v.def.x), y: Math.round(v.def.y), width: Math.round(v.def.width), height: Math.round(v.def.height), }; }); const realtimeStore = useRealtimeStore(); const positions = Object.entries(payload).map( ([componentId, geo]) => ({ componentId, ...geo }), ); realtimeStore.sendMessage({ kind: "ComponentSetPosition", data: { positions, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion viewId: this.selectedViewId!, clientUlid, changeSetId: changeSetsStore.selectedChangeSetId, }, }); } if (opts.writeToChangeSet) { const payload: Record<ComponentId, StringGeometry> = {}; components.forEach((c) => { const geo = this.viewNodes[c.def.id]?.def; if (geo) { payload[c.def.id] = { x: Math.round(geo.x).toString(), y: Math.round(geo.y).toString(), width: Math.round(geo.width).toString(), height: Math.round(geo.height).toString(), }; } }); return new ApiRequest<{ requestUlid: RequestUlid }>({ method: "put", url: API_PREFIX.concat([ { viewId: this.selectedViewId }, "view_object/set_geometry", ]), params: { clientUlid, dataByViewId: payload, ...visibilityParams, }, optimistic: (requestUlid: RequestUlid) => { this.inflightElementSizes[requestUlid] = Object.keys(payload); }, onFail: (err) => { // only handle conflicts here if (err.response.status !== 409) { return; } const reqPayload = JSON.parse(err.config.data); // are the components that failed currently inflight? // if not, resend their latest data const failed = this.inflightElementSizes[reqPayload.requestUlid]; if (!failed) return; delete this.inflightElementSizes[reqPayload.requestUlid]; const all_inflight_components = new Set( Object.values(this.inflightElementSizes).flat(), ); const maybe_retry: ComponentId[] = ( failed as ComponentId[] ).filter((x) => !all_inflight_components.has(x)); const prevent = new Set(); for (const componentId of maybe_retry) { const cnt = (this.inflightRetryCounter.get(componentId) || 0) + 1; if (cnt > MAX_RETRIES) prevent.add(componentId); else this.inflightRetryCounter.set(componentId, cnt); } if (prevent.size > 0) throw Error("Too many retries"); const retry = maybe_retry.filter((x) => !prevent.has(x)); if (retry.length > 0) { const components = retry .map((componentId) => this.viewNodes[componentId]) .filter(nonNullable); this.MOVE_VIEWS( components, { x: 0, y: 0 }, { writeToChangeSet: true }, ); } }, onSuccess: (response) => { delete this.inflightElementSizes[response.requestUlid]; }, }); } }, async RESIZE_COMPONENT( component: DiagramGroupData, geometry: IRect, opts: { writeToChangeSet?: boolean; broadcastToClients?: boolean }, ) { geometry.x = Math.round(geometry.x); geometry.y = Math.round(geometry.y); geometry.width = Math.round(geometry.width); geometry.height = Math.round(geometry.height); this.groups[component.def.id] = { ...geometry, size: geometry.width * geometry.height, zIndex: 0, }; this.setGroupZIndex(); for (const [key, loc] of Object.entries( setSockets(component, geometry), )) { this.sockets[key] = loc; } if (opts.broadcastToClients && changeSetsStore.selectedChangeSetId) { const realtimeStore = useRealtimeStore(); const positions = [{ componentId: component.def.id, ...geometry }]; realtimeStore.sendMessage({ kind: "ComponentSetPosition", data: { positions, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion viewId: this.selectedViewId!, clientUlid, changeSetId: changeSetsStore.selectedChangeSetId, }, }); } if (opts.writeToChangeSet) { const payload: Record<ComponentId, StringGeometry> = {}; payload[component.def.id] = { x: Math.round(geometry.x).toString(), y: Math.round(geometry.y).toString(), width: Math.round(geometry.width).toString(), height: Math.round(geometry.height).toString(), }; return new ApiRequest<{ requestUlid: RequestUlid }>({ method: "put", url: API_PREFIX.concat([ { viewId: this.selectedViewId }, "component/set_geometry", ]), params: { clientUlid, dataByComponentId: payload, ...visibilityParams, }, }); } }, async REMOVE_FROM(viewId: ViewId, componentIds: ComponentId[]) { return new ApiRequest({ method: "delete", url: API_PREFIX.concat([{ viewId }, "erase_components"]), params: { clientUlid, componentIds, ...visibilityParams, }, onFail: (err) => { if (err.response.status === 403) { toast( "Error: Could not remove component(s) from this View. They do not exist in any other view, and would be orphaned.", ); } }, }); }, async ADD_TO( sourceViewId: ViewId, components: Record<ComponentId, IRect>, destinationViewId: ViewId, removeFromOriginalView = false, ) { const stringGeometries: Record<ComponentId, StringGeometry> = {}; Object.entries(components).forEach(([componentId, geo]) => { stringGeometries[componentId] = { x: Math.round(geo.x).toString(), y: Math.round(geo.y).toString(), width: Math.round(geo.width).toString(), height: Math.round(geo.height).toString(), }; }); return new ApiRequest({ method: "post", url: "diagram/add_components_to_view", params: { clientUlid, sourceViewId, destinationViewId, geometriesByComponentId: stringGeometries, removeFromOriginalView, ...visibilityParams, }, onFail: (err) => { if (err.response.status === 422) { toast( "Error: One or more of the selected components already exists in this view", ); } }, }); }, async ADD_VIEW_TO( viewId: ViewId, viewIdToHexagon: ViewId, geo: VectorWithRadius, ) { const sGeo = { x: Math.round(geo.x).toString(), y: Math.round(geo.y).toString(), radius: Math.round(geo.radius).toString(), }; return new ApiRequest({ method: "post", url: API_PREFIX.concat([{ viewId }, "view_object"]), params: { viewObjectId: viewIdToHexagon, ...sGeo, }, }); }, async REMOVE_VIEW_FROM(viewId: ViewId, viewIdsToHexagon: ViewId[]) { return new ApiRequest({ method: "delete", url: API_PREFIX.concat([{ viewId }, "view_object"]), params: { viewIds: viewIdsToHexagon, }, }); }, async DELETE_VIEW(viewId: ViewId) { return new ApiRequest({ method: "delete", url: API_PREFIX.concat([{ viewId }]), params: { viewId, }, }); }, async CREATE_VIEW_AND_MOVE( name: string, sourceViewId: ViewId, components: Record<ComponentId, IRect>, ) { let sumX = 0; let sumY = 0; const stringGeometries: Record<ComponentId, StringGeometry> = {}; Object.entries(components).forEach(([componentId, geo]) => { // sum the center coordinates separately sumX += geo.x; sumY += geo.y + geo.height / 2; stringGeometries[componentId] = { x: Math.round(geo.x).toString(), y: Math.round(geo.y).toString(), width: Math.round(geo.width).toString(), height: Math.round(geo.height).toString(), }; }); const viewToHexagonGeo = { x: Math.round( sumX / Object.keys(stringGeometries).length, ).toString(), y: Math.round( sumY / Object.keys(stringGeometries).length, ).toString(), radius: "250", }; return new ApiRequest({ method: "post", url: API_PREFIX.concat(["create_and_move"]), params: { name, sourceViewId, geometriesByComponentId: stringGeometries, removeFromOriginalView: true, placeViewAt: viewToHexagonGeo, }, }); }, // view approval requirement endpoints async CREATE_VIEW_APPROVAL_REQUIREMENT(viewId: ViewId, userId: UserId) { return new ApiRequest({ method: "put", url: APPROVAL_REQUIREMENTS_API_PREFIX, params: { entityId: viewId, users: [userId], }, }); }, async REMOVE_VIEW_APPROVAL_REQUIREMENT( definitionId: ApprovalRequirementDefinitionId, ) { return new ApiRequest({ method: "delete", url: APPROVAL_REQUIREMENTS_API_PREFIX.concat([definitionId]), }); }, async LIST_VIEW_APPROVAL_REQUIREMENTS(viewId: ViewId) { return new ApiRequest({ method: "get", url: APPROVAL_REQUIREMENTS_API_PREFIX.concat(["entity", viewId]), onSuccess: (response: ViewApprovalRequirementDefinition[]) => { this.requirementDefinitionsById = {} as Record< ApprovalRequirementDefinitionId, ViewApprovalRequirementDefinition >; for (const def of response) { this.requirementDefinitionsById[def.id] = def; } }, }); }, async ADD_INDIVIDUAL_APPROVER_TO_REQUIREMENT( definitionId: ApprovalRequirementDefinitionId, userId: UserId, ) { return new ApiRequest({ method: "put", url: APPROVAL_REQUIREMENTS_API_PREFIX.concat([ definitionId, "individual-approver", userId, ]), }); }, async REMOVE_INDIVIDUAL_APPROVER_FROM_REQUIREMENT( definitionId: ApprovalRequirementDefinitionId, userId: UserId, ) { return new ApiRequest({ method: "delete", url: APPROVAL_REQUIREMENTS_API_PREFIX.concat([ definitionId, "individual-approver", userId, ]), }); }, setGroupZIndex() { const groupSizes: { id: string; size: number; }[] = Object.entries(this.groups).map(([id, g]) => { return { id, size: g.size }; }); groupSizes.sort((a, b) => b.size - a.size); for (const [idx, groupSize] of groupSizes.entries()) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const g = this.groups[groupSize.id]!; g.zIndex = idx; } }, registerRequestsBegin(requestUlid: string, actionName: string) { realtimeStore.inflightRequests.set(requestUlid, actionName); }, registerRequestsEnd(requestUlid: string) { realtimeStore.inflightRequests.delete(requestUlid); }, }, async onActivated() { realtimeStore.subscribe( `${this.$id}-changeset`, `changeset/${changeSetId}`, [ { eventType: "ComponentCreated", callback: (data) => { // If the component that updated wasn't in this change set, // don't update if (data.changeSetId !== changeSetId) return; const { viewId, geometry } = { ...data.component.viewData }; if (!viewId || !geometry) { throw new Error("Expected view geometry on new component"); } const view = this.viewsById[viewId]; if (!view) return; // FIXME later when we have full WsEvents if (data.component.componentType === ComponentType.Component) { const node = processRawComponent( data.component, componentsStore.rawComponentsById, ) as DiagramNodeData; geometry.height = node.height; geometry.width = node.width; view.components[data.component.id] = geometry as IRect; for (const [key, loc] of Object.entries( setSockets(node, geometry), )) { view.sockets[key] = loc; } } else { if (!geometry.width) geometry.width = 500; if (!geometry.height) geometry.height = 500; view.groups[data.component.id] = { ...(geometry as IRect), size: geometry.width * geometry.height, zIndex: 0, }; this.setGroupZIndex(); const node = processRawComponent( data.component, componentsStore.rawComponentsById, ) as DiagramGroupData; for (const [key, loc] of Object.entries( setSockets(node, geometry), )) { view.sockets[key] = loc; } } }, }, { eventType: "ResourceRefreshed", callback: (data) => { // If the component that updated wasn't in this change set, // don't update if (data.changeSetId !== changeSetId) return; }, }, { eventType: "ComponentUpgraded", callback: (data, metadata) => { // If the component that updated wasn't in this change set, // don't update if (data.changeSetId !== changeSetId) return; const oldId = data.originalComponentId; delete componentsStore.rawComponentsById[oldId]; delete componentsStore.allComponentsById[oldId]; delete componentsStore.nodesById[oldId]; delete componentsStore.groupsById[oldId]; componentsStore.rawComponentsById[data.component.id] = data.component; componentsStore.processAndStoreRawComponent( data.component.id, {}, ); // now the component exists in the component store const node = // eslint-disable-next-line @typescript-eslint/no-non-null-assertion componentsStore.allComponentsById[data.component.id]!; // upgrades can change the sockets on a component // so we need to go through all the views this component is on // and re-set their socket data const viewIds = Object.keys(this.viewsById); viewIds.forEach((viewId) => { const view = this.viewsById[viewId]; if (!view) return; let geo = view.components[data.component.id]; if (!geo) geo = view.groups[data.component.id]; if (geo) { if ("height" in node) { // this covers components geo.height = node.height; geo.width = node.width; // note: if a person added a hundred sockets to a component // and that component was a frame, it would not resize itself // and the sockets would appear outside the frame } for (const [key, loc] of Object.entries( setSockets(node, geo), )) { view.sockets[key] = loc; } } }); if ( metadata.actor !== "System" && metadata.actor.User === authStore.userPk ) { this.setSelectedComponentId(data.component.id); } }, }, { eventType: "ComponentUpdated", callback: (data, metadata) => { // If the component that updated wasn't in this change set, // don't update if (metadata.change_set_id !== changeSetId) return; const { viewId, geometry } = { ...data.component.viewData }; // changed component type means book-keeping for all views // PSA: currently SDF does not change any geometry when you change component types // if it starts to change geometry, it must return new geometry for every view // the structure doesn't currently support that Object.values(this.viewsById).forEach((view) => { const groupGeo = view.groups[data.component.id]; const componentGeo = view.components[data.component.id]; const thisGeo = groupGeo ?? componentGeo; // I don't exist in this view, and I am not being added to this view, return if (viewId && viewId !== view.id) { return; } const finalGeo = geometry ?? thisGeo; if (!finalGeo) return; delete view.components[data.component.id]; delete view.groups[data.component.id]; if ( data.component.componentType === ComponentType.Component ) { const node = processRawComponent( data.component, componentsStore.rawComponentsById, ) as DiagramNodeData; finalGeo.height = node.height; finalGeo.width = node.width; view.components[data.component.id] = finalGeo as IRect; for (const [key, loc] of Object.entries( setSockets(node, finalGeo), )) { view.sockets[key] = loc; } } else { if (!finalGeo.width) finalGeo.width = 500; if (!finalGeo.height) finalGeo.height = 500; view.groups[data.component.id] = { ...(finalGeo as IRect), size: finalGeo.width * finalGeo.height, zIndex: 0, }; const node = processRawComponent( data.component, componentsStore.rawComponentsById, ) as DiagramGroupData; for (const [key, loc] of Object.entries( setSockets(node, finalGeo), )) { view.sockets[key] = loc; } } }); this.setGroupZIndex(); }, }, { eventType: "ComponentDeleted", callback: (data) => { if (data.changeSetId !== changeSetId) return; // TODO "detach component from view" // this is "delete from all views" const viewIds = Object.keys(this.viewsById); viewIds.forEach((viewId) => { const view = this.viewsById[viewId]; delete view?.components[data.componentId]; delete view?.groups[data.componentId]; }); this.setGroupZIndex(); // remove invalid component IDs from the selection const validComponentIds = _.intersection( this.selectedComponentIds, _.keys(componentsStore.rawComponentsById), ); this.setSelectedComponentId(validComponentIds); }, }, { eventType: "SetComponentPosition", callback: ({ changeSetId, clientUlid: _clientUlid, viewId, positions, }) => { if (changeSetId !== changeSetsStore.selectedChangeSetId) return; if (clientUlid === _clientUlid) return; const _view = this.viewsById[viewId]; if (!_view) return; // TODO: make sure to update the correct view based on ID for (const geo of positions) { const component = componentsStore.allComponentsById[geo.componentId]; if (component) { let viewComponent; if (component.def.isGroup) { viewComponent = _view.groups[geo.componentId]; if (viewComponent && geo.height && geo.width) { viewComponent.height = geo.height; viewComponent.width = geo.width; } } else { viewComponent = _view.components[geo.componentId]; } if (viewComponent) { viewComponent.x = geo.x; viewComponent.y = geo.y; } for (const [key, loc] of Object.entries( setSockets(component, geo), )) { _view.sockets[key] = loc; } } else { const node = _view.viewNodes[geo.componentId]; if (node) { node.def.width = geo.width; node.def.height = geo.height; node.def.x = geo.x; node.def.y = geo.y; } } } }, }, { eventType: "ViewDeleted", callback: ({ viewId }, metadata) => { if (metadata.change_set_id !== changeSetId) return; const idx = this.viewList.findIndex((v) => v.id === viewId); if (idx !== -1) this.viewList.splice(idx, 1); delete this.viewsById[viewId]; this.SORT_LIST_VIEWS(); Object.values(this.viewsById).forEach((view) => { delete view.viewNodes[viewId]; }); const route = router.currentRoute; if (route.value.params.viewId === viewId) { const defaultView = this.viewList.find((v) => v.isDefault); if (defaultView) this.selectView(defaultView.id); else { const v = this.viewList[0]; if (v) this.selectView(v.id); else { router.push({ name: "workspace-single", params: { workspacePk: workspaceId }, }); } } } }, }, { eventType: "ViewUpdated", callback: ({ view }, metadata) => { if (metadata.change_set_id !== changeSetId) return; const idx = this.viewList.findIndex((v) => v.id === view.id); if (idx !== -1) this.viewList.splice(idx, 1, view); else { this.viewList.push(view); } // right now the name is the only thing you can update const v = this.viewsById[view.id]; if (v) v.name = view.name; const _v = this.viewNodes[view.id]; if (_v) _v.def.name = view.name; this.SORT_LIST_VIEWS(); }, }, { eventType: "ViewCreated", callback: async ({ view }, metadata) => { if (metadata.change_set_id !== changeSetId) return; const idx = this.viewList.findIndex((v) => v.id === view.id); if (idx !== -1) this.viewList.splice(idx, 1, view); else { this.viewList.push(view); } this.SORT_LIST_VIEWS(); await this.FETCH_VIEW_GEOMETRY(view.id); const actionWhichCreatedView = realtimeStore.inflightRequests.get(metadata.request_ulid); if ( metadata.actor !== "System" && metadata.actor.User === authStore.userPk && actionWhichCreatedView !== "RUN_MGMT_PROTOTYPE" ) { this.selectView(view.id); } }, }, { eventType: "ViewObjectCreated", callback: (payload, metadata) => { if (metadata.change_set_id !== changeSetId) return; const view = this.viewsById[payload.viewId]; const v = this.viewList.find( (_v) => _v.id === payload.viewObjectId, ); if (v) { const node = new DiagramViewData({ ...v, ...payload.geometry, ...VIEW_DEFAULTS, componentType: ComponentType.View, }); if (view) view.viewNodes[payload.viewObjectId] = node; } }, }, { eventType: "ViewObjectRemoved", callback: (payload, metadata) => { if (metadata.change_set_id !== changeSetId) return; const view = this.viewsById[payload.viewId]; if (view) delete view.viewNodes[payload.viewObjectId]; }, }, { eventType: "ViewComponentsUpdate", callback: (payload, metadata) => { if (metadata.change_set_id !== changeSetId) return; Object.entries(payload.updatesByView).forEach( ([viewId, { added, removed }]) => { const view = this.viewsById[viewId]; if (!view) return; Object.entries(added).forEach(([componentId, geo]) => { const c = componentsStore.allComponentsById[componentId]; if (!c) return; if (c.def.isGroup) { view.groups[componentId] = { ...geo, size: geo.height * geo.width, zIndex: 0, }; } else view.components[componentId] = geo; if (geo) { for (const [key, loc] of Object.entries( setSockets(c, geo), )) { view.sockets[key] = loc; } } }); removed.forEach((r) => { delete view.components[r]; delete view.groups[r]; }); }, ); this.setGroupZIndex(); }, }, // events for approval requirements { eventType: "ApprovalRequirementAddIndividualApprover", callback: (payload) => { const viewId = this.requirementDefinitionsById[ payload.approvalRequirementDefinitionId ]?.entityId; if (viewId) { this.LIST_VIEW_APPROVAL_REQUIREMENTS(viewId); } }, }, { eventType: "ApprovalRequirementDefinitionCreated", callback: (payload) => { const viewId = payload.entityId; this.LIST_VIEW_APPROVAL_REQUIREMENTS(viewId); }, }, { eventType: "ApprovalRequirementDefinitionRemoved", callback: (payload) => { const viewId = this.requirementDefinitionsById[ payload.approvalRequirementDefinitionId ]?.entityId; if (viewId) { this.LIST_VIEW_APPROVAL_REQUIREMENTS(viewId); } }, }, { eventType: "ApprovalRequirementRemoveIndividualApprover", callback: (payload) => { const viewId = this.requirementDefinitionsById[ payload.approvalRequirementDefinitionId ]?.entityId; if (viewId) { this.LIST_VIEW_APPROVAL_REQUIREMENTS(viewId); } }, }, ], ); realtimeStore.subscribe( `${this.$id}-workspace`, `workspace/${workspaceId}`, [ { eventType: "ChangeSetApplied", callback: async (data) => { // If the applied change set has rebased into this change set, // then refetch (i.e. there might be updates!) if (data.toRebaseChangeSetId === changeSetId) { this.FETCH_COMPLETE_DATA(); } }, }, ], ); const actionUnsub = this.$onAction(handleStoreError); return () => { // clear selection without triggering url stuff this.selectedComponentIds = []; this.selectedViewDetailsId = null; this.selectedEdgeId = null; this.selectedDisplayEdgeId = null; actionUnsub(); realtimeStore.unsubscribe(`${this.$id}-changeset`); realtimeStore.unsubscribe(`${this.$id}-workspace`); }; }, }), )(); };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/systeminit/si'

If you have feedback or need assistance with the MCP directory API, please join our Discord server