diff --git a/app/web/src/components/ComponentOutline/ComponentOutline.vue b/app/web/src/components/ComponentOutline/ComponentOutline.vue index cf643e3983..ef7dabadde 100644 --- a/app/web/src/components/ComponentOutline/ComponentOutline.vue +++ b/app/web/src/components/ComponentOutline/ComponentOutline.vue @@ -117,7 +117,10 @@ const emit = defineEmits<{ // while we've avoided events for most things (selection, panning, etc) // we still have an emit for this one because the parent (WorkspaceModelAndView) owns the right click menu // and needs the raw MouseEvent - (e: "right-click-item", ev: MouseEvent): void; + ( + e: "right-click-item", + ev: { mouse: MouseEvent; component: FullComponent }, + ): void; }>(); const componentsStore = useComponentsStore(); @@ -168,6 +171,9 @@ function onSearchUpdated(newFilterString: string) { } function itemClickHandler(e: MouseEvent, id: ComponentId, tabSlug?: string) { + const component = componentsStore.componentsById[id]; + if (!component) throw new Error("component not found"); + const shiftKeyBehavior = () => { const selectedComponentIds = componentsStore.selectedComponentIds; @@ -223,7 +229,7 @@ function itemClickHandler(e: MouseEvent, id: ComponentId, tabSlug?: string) { componentsStore.setSelectedComponentId(id); } } - emit("right-click-item", e); + emit("right-click-item", { mouse: e, component }); } else if (e.shiftKey) { e.preventDefault(); shiftKeyBehavior(); diff --git a/app/web/src/components/ModelingDiagram/ModelingDiagram.vue b/app/web/src/components/ModelingDiagram/ModelingDiagram.vue index 85c3276820..571d6921e1 100644 --- a/app/web/src/components/ModelingDiagram/ModelingDiagram.vue +++ b/app/web/src/components/ModelingDiagram/ModelingDiagram.vue @@ -571,6 +571,7 @@ function onKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { clearSelection(); if (insertElementActive.value) componentsStore.cancelInsert(); + componentsStore.copyingFrom = null; if (dragSelectActive.value) endDragSelect(false); } if (!props.readOnly && (e.key === "Delete" || e.key === "Backspace")) { @@ -623,6 +624,7 @@ function onMouseDown(ke: KonvaEventObject) { // in order to ignore clicks with a tiny bit of movement if (dragToPanArmed.value || e.button === 1) beginDragToPan(); else if (insertElementActive.value) triggerInsertElement(); + else if (pasteElementsActive.value) triggerPasteElements(); else handleMouseDownSelection(); } @@ -640,6 +642,8 @@ function onMouseUp(e: MouseEvent) { // TODO: probably change this - its a bit hacky... else if (insertElementActive.value && pointerIsWithinGrid.value) triggerInsertElement(); + else if (pasteElementsActive.value && pointerIsWithinGrid.value) + triggerPasteElements(); else handleMouseUpSelection(); } @@ -780,6 +784,7 @@ const cursor = computed(() => { if (drawEdgeActive.value) return "cell"; if (dragElementsActive.value) return "move"; if (insertElementActive.value) return "copy"; // not sure about this... + if (pasteElementsActive.value) return "copy"; if ( resizeElementActive.value || hoveredElementMeta.value?.type === "resize" @@ -2128,6 +2133,27 @@ async function endDrawEdge() { } } +const pasteElementsActive = computed(() => { + return ( + componentsStore.copyingFrom && + componentsStore.selectedComponentIds.length > 0 + ); +}); +async function triggerPasteElements() { + if (!pasteElementsActive.value) + throw new Error("paste element mode must be active"); + if (!gridPointerPos.value) + throw new Error("Cursor must be in grid to paste element"); + if (!componentsStore.copyingFrom) + throw new Error("Copy cursor must be in grid to paste element"); + + componentsStore.PASTE_COMPONENTS(componentsStore.selectedComponentIds, { + x: gridPointerPos.value.x - componentsStore.copyingFrom.x, + y: gridPointerPos.value.y - componentsStore.copyingFrom.y, + }); + componentsStore.copyingFrom = null; +} + // ELEMENT ADDITION const insertElementActive = computed( () => !!componentsStore.selectedInsertSchemaId, @@ -2327,6 +2353,7 @@ function recenterOnElement(panTarget: DiagramElementData) { const helpModalRef = ref(); onMounted(() => { + componentsStore.copyingFrom = null; componentsStore.eventBus.on("panToComponent", panToComponent); }); onBeforeUnmount(() => { diff --git a/app/web/src/components/ModelingView/ModelingRightClickMenu.vue b/app/web/src/components/ModelingView/ModelingRightClickMenu.vue index 74beae0bfa..e12acbed54 100644 --- a/app/web/src/components/ModelingView/ModelingRightClickMenu.vue +++ b/app/web/src/components/ModelingView/ModelingRightClickMenu.vue @@ -12,11 +12,13 @@ import { computed, ref } from "vue"; import plur from "plur"; import { useComponentsStore } from "@/store/components.store"; import { useFixesStore } from "@/store/fixes.store"; +import { useFeatureFlagsStore } from "@/store/feature_flags.store"; const contextMenuRef = ref>(); const componentsStore = useComponentsStore(); const fixesStore = useFixesStore(); +const featureFlagsStore = useFeatureFlagsStore(); const { selectedComponentId, @@ -69,6 +71,15 @@ const rightClickMenuItems = computed(() => { }); } } else if (selectedComponentId.value && selectedComponent.value) { + if (featureFlagsStore.COPY_PASTE) { + items.push({ + label: `Copy`, + icon: "clipboard-copy", + onSelect: triggerCopySelection, + disabled, + }); + } + // single selected component if (selectedComponent.value.changeStatus === "deleted") { items.push({ @@ -90,6 +101,15 @@ const rightClickMenuItems = computed(() => { }); } } else if (selectedComponentIds.value.length) { + if (featureFlagsStore.COPY_PASTE) { + items.push({ + label: `Copy ${selectedComponentIds.value.length} Components`, + icon: "clipboard-copy", + onSelect: triggerCopySelection, + disabled, + }); + } + // Multiple selected components if (deletableSelectedComponents.value.length > 0) { items.push({ @@ -129,15 +149,28 @@ const rightClickMenuItems = computed(() => { return items; }); +function triggerCopySelection() { + componentsStore.copyingFrom = elementPos.value; + elementPos.value = null; +} + const modelingEventBus = componentsStore.eventBus; function triggerDeleteSelection() { modelingEventBus.emit("deleteSelection"); + elementPos.value = null; } function triggerRestoreSelection() { modelingEventBus.emit("restoreSelection"); + elementPos.value = null; } -function open(e?: MouseEvent, anchorToMouse?: boolean) { +const elementPos = ref<{ x: number; y: number } | null>(null); +function open( + e: MouseEvent, + anchorToMouse: boolean, + elementPosition?: { x: number; y: number }, +) { + if (elementPosition) elementPos.value = elementPosition; contextMenuRef.value?.open(e, anchorToMouse); } defineExpose({ open }); diff --git a/app/web/src/components/Workspace/WorkspaceModelAndView.vue b/app/web/src/components/Workspace/WorkspaceModelAndView.vue index ec8195fae7..739ff16122 100644 --- a/app/web/src/components/Workspace/WorkspaceModelAndView.vue +++ b/app/web/src/components/Workspace/WorkspaceModelAndView.vue @@ -72,7 +72,7 @@ import * as _ from "lodash-es"; import { computed, onMounted, ref } from "vue"; import { ResizablePanel } from "@si/vue-lib/design-system"; import ComponentDetails from "@/components/ComponentDetails.vue"; -import { useComponentsStore } from "@/store/components.store"; +import { useComponentsStore, FullComponent } from "@/store/components.store"; import { useFixesStore } from "@/store/fixes.store"; import { useChangeSetsStore } from "@/store/change_sets.store"; import FixProgressOverlay from "@/components/FixProgressOverlay.vue"; @@ -130,11 +130,24 @@ const selectedComponent = computed(() => componentsStore.selectedComponent); // }); // }); +// Nodes that are not resizable have dynamic height based on its rendering objects, we cannot infer that here and honestly it's not a big deal +// So let's hardcode something reasonable that doesn't make the user too much confused when they paste a copy +const NODE_HEIGHT = 200; + function onRightClickElement(rightClickEventInfo: RightClickElementEvent) { - contextMenuRef.value?.open(rightClickEventInfo.e, true); + let position; + if ("position" in rightClickEventInfo.element.def) { + position = _.cloneDeep(rightClickEventInfo.element.def.position); + position.y += + (rightClickEventInfo.element.def.size?.height ?? NODE_HEIGHT) / 2; + } + contextMenuRef.value?.open(rightClickEventInfo.e, true, position); } -function onOutlineRightClick(e: MouseEvent) { - contextMenuRef.value?.open(e, true); +function onOutlineRightClick(ev: { + mouse: MouseEvent; + component: FullComponent; +}) { + contextMenuRef.value?.open(ev.mouse, true, ev.component.position); } diff --git a/app/web/src/store/components.store.ts b/app/web/src/store/components.store.ts index ece4186508..07b78e151d 100644 --- a/app/web/src/store/components.store.ts +++ b/app/web/src/store/components.store.ts @@ -242,6 +242,7 @@ export const useComponentsStore = (forceChangeSetId?: ChangeSetId) => { >, rawNodeAddMenu: [] as MenuItem[], + copyingFrom: null as { x: number; y: number } | null, selectedComponentIds: [] as ComponentId[], selectedEdgeId: null as EdgeId | null, selectedComponentDetailsTab: null as string | null, @@ -1049,6 +1050,29 @@ export const useComponentsStore = (forceChangeSetId?: ChangeSetId) => { }); }, + async PASTE_COMPONENTS( + componentIds: ComponentId[], + offset: { x: number; y: number }, + ) { + if (changeSetsStore.creatingChangeSet) + throw new Error("race, wait until the change set is created"); + if (changeSetId === nilId()) + changeSetsStore.creatingChangeSet = true; + + return new ApiRequest({ + method: "post", + url: "diagram/paste_components", + keyRequestStatusBy: componentIds, + params: { + componentIds, + offsetX: offset.x, + offsetY: offset.y, + ...visibilityParams, + }, + onSuccess: (response) => {}, + }); + }, + async DELETE_COMPONENTS(componentIds: ComponentId[]) { if (changeSetsStore.creatingChangeSet) throw new Error("race, wait until the change set is created"); diff --git a/app/web/src/store/feature_flags.store.ts b/app/web/src/store/feature_flags.store.ts index 16a9d50d24..ea2fa5f7d1 100644 --- a/app/web/src/store/feature_flags.store.ts +++ b/app/web/src/store/feature_flags.store.ts @@ -15,6 +15,7 @@ const FLAG_MAPPING = { MUTLIPLAYER_CHANGESET_APPLY: "multiplayer_changeset_apply_flow", ABANDON_CHANGESET: "abandon_changeset", CONNECTION_ANNOTATIONS: "socket_connection_annotations", + COPY_PASTE: "copy_paste", }; type FeatureFlags = keyof typeof FLAG_MAPPING; diff --git a/lib/dal/src/component.rs b/lib/dal/src/component.rs index 2a44d06b36..a42349064e 100644 --- a/lib/dal/src/component.rs +++ b/lib/dal/src/component.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use si_data_nats::NatsError; use si_data_pg::PgError; +use std::collections::HashMap; use strum::{AsRefStr, Display, EnumIter, EnumString}; use telemetry::prelude::*; use thiserror::Error; @@ -28,12 +29,13 @@ use crate::{ impl_standard_model, node::NodeId, pk, provider::internal::InternalProviderError, standard_model, standard_model_accessor, standard_model_belongs_to, standard_model_has_many, ActionPrototypeError, AttributeContext, AttributeContextBuilderError, AttributeContextError, - AttributePrototypeArgumentError, AttributePrototypeError, AttributePrototypeId, - AttributeReadContext, ComponentType, DalContext, EdgeError, ExternalProviderError, FixError, - FixId, Func, FuncBackendKind, FuncError, HistoryActor, HistoryEventError, Node, NodeError, - PropError, RootPropChild, Schema, SchemaError, SchemaId, Socket, StandardModel, - StandardModelError, Tenancy, Timestamp, TransactionsError, UserPk, ValidationPrototypeError, - ValidationResolverError, Visibility, WorkspaceError, WsEvent, WsEventResult, WsPayload, + AttributePrototype, AttributePrototypeArgumentError, AttributePrototypeError, + AttributePrototypeId, AttributeReadContext, ComponentType, DalContext, EdgeError, + ExternalProviderError, FixError, FixId, Func, FuncBackendKind, FuncError, HistoryActor, + HistoryEventError, IndexMap, Node, NodeError, PropError, RootPropChild, Schema, SchemaError, + SchemaId, Socket, StandardModel, StandardModelError, Tenancy, Timestamp, TransactionsError, + UserPk, ValidationPrototypeError, ValidationResolverError, Visibility, WorkspaceError, WsEvent, + WsEventResult, WsPayload, }; use crate::{AttributeValueId, QualificationError}; use crate::{Edge, FixResolverError, NodeKind}; @@ -62,8 +64,12 @@ pub enum ComponentError { /// Found an [`AttributePrototypeArgumentError`](crate::AttributePrototypeArgumentError). #[error("attribute prototype argument error: {0}")] AttributePrototypeArgument(#[from] AttributePrototypeArgumentError), + #[error("attribute prototype not found")] + AttributePrototypeNotFound, #[error("attribute value error: {0}")] AttributeValue(#[from] AttributeValueError), + #[error("attribute value not found")] + AttributeValueNotFound, #[error("attribute value not found for context: {0:?}")] AttributeValueNotFoundForContext(AttributeReadContext), #[error("cannot update the resource tree when in a change set")] @@ -978,6 +984,227 @@ impl Component { pub fn is_destroyed(&self) -> bool { self.visibility.deleted_at.is_some() && !self.needs_destroy() } + + pub async fn clone_attributes_from( + &self, + ctx: &DalContext, + component_id: ComponentId, + ) -> ComponentResult<()> { + let attribute_values = + AttributeValue::find_by_attr(ctx, "attribute_context_component_id", &component_id) + .await?; + + let mut pasted_attribute_values_by_original = HashMap::new(); + for copied_av in &attribute_values { + let context = AttributeContextBuilder::from(copied_av.context) + .set_component_id(*self.id()) + .to_context()?; + + // TODO: should we clone the fb and fbrv? + let mut pasted_av = if let Some(mut av) = + AttributeValue::find_for_context(ctx, context.into()).await? + { + av.set_func_binding_id(ctx, copied_av.func_binding_id()) + .await?; + av.set_func_binding_return_value_id(ctx, copied_av.func_binding_return_value_id()) + .await?; + av.set_key(ctx, copied_av.key()).await?; + av + } else { + dbg!( + AttributeValue::new( + ctx, + copied_av.func_binding_id(), + copied_av.func_binding_return_value_id(), + context, + copied_av.key(), + ) + .await + )? + }; + + pasted_av + .set_proxy_for_attribute_value_id(ctx, copied_av.proxy_for_attribute_value_id()) + .await?; + pasted_av + .set_sealed_proxy(ctx, copied_av.sealed_proxy()) + .await?; + + pasted_attribute_values_by_original.insert(*copied_av.id(), *pasted_av.id()); + } + + for copied_av in &attribute_values { + if let Some(copied_index_map) = copied_av.index_map() { + let pasted_id = pasted_attribute_values_by_original + .get(copied_av.id()) + .ok_or(ComponentError::AttributeValueNotFound)?; + + let mut index_map = IndexMap::new(); + for (key, copied_id) in copied_index_map.order_as_map() { + let pasted_id = *pasted_attribute_values_by_original + .get(&copied_id) + .ok_or(ComponentError::AttributeValueNotFound)?; + index_map.push(pasted_id, Some(key)); + } + + ctx.txns() + .await? + .pg() + .query( + "UPDATE attribute_values_v1($1, $2) SET index_map = $3 WHERE id = $4", + &[ + ctx.tenancy(), + ctx.visibility(), + &serde_json::to_value(&index_map)?, + &pasted_id, + ], + ) + .await?; + } + } + + let attribute_prototypes = + AttributePrototype::find_by_attr(ctx, "attribute_context_component_id", &component_id) + .await?; + + let mut pasted_attribute_prototypes_by_original = HashMap::new(); + for copied_ap in &attribute_prototypes { + let context = AttributeContextBuilder::from(copied_ap.context) + .set_component_id(*self.id()) + .to_context()?; + + let id = if let Some(mut ap) = AttributePrototype::find_for_context_and_key( + ctx, + context, + &copied_ap.key().map(ToOwned::to_owned), + ) + .await? + .pop() + { + ap.set_func_id(ctx, copied_ap.func_id()).await?; + ap.set_key(ctx, copied_ap.key()).await?; + *ap.id() + } else { + let row = ctx + .txns() + .await? + .pg() + .query_one( + "SELECT object FROM attribute_prototype_create_v1($1, $2, $3, $4, $5) AS ap", + &[ + ctx.tenancy(), + ctx.visibility(), + &serde_json::to_value(context)?, + &copied_ap.func_id(), + &copied_ap.key(), + ], + ) + .await?; + let object: AttributePrototype = standard_model::object_from_row(row)?; + *object.id() + }; + + pasted_attribute_prototypes_by_original.insert(*copied_ap.id(), id); + } + + let rows = ctx + .txns() + .await? + .pg() + .query( + "SELECT object_id, belongs_to_id + FROM attribute_value_belongs_to_attribute_value_v1($1, $2) + WHERE object_id = ANY($3) AND belongs_to_id = ANY($3)", + &[ + ctx.tenancy(), + ctx.visibility(), + &attribute_values + .iter() + .map(|av| *av.id()) + .collect::>(), + ], + ) + .await?; + + for row in rows { + let original_object_id: AttributeValueId = row.try_get("object_id")?; + let original_belongs_to_id: AttributeValueId = row.try_get("belongs_to_id")?; + + let object_id = pasted_attribute_values_by_original + .get(&original_object_id) + .ok_or(ComponentError::AttributeValueNotFound)?; + let belongs_to_id = pasted_attribute_values_by_original + .get(&original_belongs_to_id) + .ok_or(ComponentError::AttributeValueNotFound)?; + + ctx + .txns() + .await? + .pg() + .query("INSERT INTO attribute_value_belongs_to_attribute_value + (object_id, belongs_to_id, tenancy_workspace_pk, visibility_change_set_pk) + VALUES ($1, $2, $3, $4) + ON CONFLICT (object_id, tenancy_workspace_pk, visibility_change_set_pk) DO NOTHING", + &[ + &object_id, + &belongs_to_id, + &ctx.tenancy().workspace_pk(), + &ctx.visibility().change_set_pk, + ], + ).await?; + } + + let rows = ctx + .txns() + .await? + .pg() + .query( + "SELECT object_id, belongs_to_id + FROM attribute_value_belongs_to_attribute_prototype_v1($1, $2) + WHERE object_id = ANY($3) AND belongs_to_id = ANY($4)", + &[ + ctx.tenancy(), + ctx.visibility(), + &attribute_values + .iter() + .map(|av| *av.id()) + .collect::>(), + &attribute_prototypes + .iter() + .map(|av| *av.id()) + .collect::>(), + ], + ) + .await?; + for row in rows { + let original_object_id: AttributeValueId = row.try_get("object_id")?; + let original_belongs_to_id: AttributePrototypeId = row.try_get("belongs_to_id")?; + + let object_id = pasted_attribute_values_by_original + .get(&original_object_id) + .ok_or(ComponentError::AttributeValueNotFound)?; + let belongs_to_id = pasted_attribute_prototypes_by_original + .get(&original_belongs_to_id) + .ok_or(ComponentError::AttributePrototypeNotFound)?; + + ctx + .txns() + .await? + .pg() + .query("INSERT INTO attribute_value_belongs_to_attribute_prototype + (object_id, belongs_to_id, tenancy_workspace_pk, visibility_change_set_pk) + VALUES ($1, $2, $3, $4) + ON CONFLICT (object_id, tenancy_workspace_pk, visibility_change_set_pk) DO NOTHING", + &[ + &object_id, + &belongs_to_id, + &ctx.tenancy().workspace_pk(), + &ctx.visibility().change_set_pk, + ], + ).await?; + } + Ok(()) + } } #[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq)] diff --git a/lib/dal/src/component/resource.rs b/lib/dal/src/component/resource.rs index b23c6ff7fa..6a5cf16373 100644 --- a/lib/dal/src/component/resource.rs +++ b/lib/dal/src/component/resource.rs @@ -94,10 +94,19 @@ impl Component { &self, ctx: &DalContext, result: ActionRunResult, + ) -> ComponentResult { + self.set_resource_raw(ctx, result, true).await + } + + pub async fn set_resource_raw( + &self, + ctx: &DalContext, + result: ActionRunResult, + check_change_set: bool, ) -> ComponentResult { let ctx = &ctx.clone_without_deleted_visibility(); - if !ctx.visibility().is_head() { + if check_change_set && !ctx.visibility().is_head() { return Err(ComponentError::CannotUpdateResourceTreeInChangeSet); } diff --git a/lib/dal/src/diagram/connection.rs b/lib/dal/src/diagram/connection.rs index 729f8ad148..f0237b781f 100644 --- a/lib/dal/src/diagram/connection.rs +++ b/lib/dal/src/diagram/connection.rs @@ -123,7 +123,7 @@ impl Connection { pub fn from_edge(edge: &Edge) -> Self { Self { id: *edge.id(), - classification: edge.kind().clone(), + classification: *edge.kind(), source: Vertex { node_id: edge.tail_node_id(), socket_id: edge.tail_socket_id(), diff --git a/lib/dal/src/edge.rs b/lib/dal/src/edge.rs index a9c3bab93f..cd5fc88a4c 100644 --- a/lib/dal/src/edge.rs +++ b/lib/dal/src/edge.rs @@ -108,7 +108,9 @@ pub enum VertexObjectKind { /// The kind of an [`Edge`](Edge). This provides the ability to categorize [`Edges`](Edge) /// and create [`EdgeKind`](Self)-specific graphs. #[remain::sorted] -#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone, Display, EnumString, AsRefStr)] +#[derive( + Deserialize, Serialize, Debug, PartialEq, Eq, Clone, Display, EnumString, AsRefStr, Copy, +)] #[serde(rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] pub enum EdgeKind { diff --git a/lib/sdf-server/src/server/service/diagram.rs b/lib/sdf-server/src/server/service/diagram.rs index 8905080cb1..daf508b483 100644 --- a/lib/sdf-server/src/server/service/diagram.rs +++ b/lib/sdf-server/src/server/service/diagram.rs @@ -6,13 +6,14 @@ use axum::Router; use dal::provider::external::ExternalProviderError as DalExternalProviderError; use dal::socket::{SocketError, SocketId}; use dal::{ - node::NodeId, schema::variant::SchemaVariantError, ActionError, ActionPrototypeError, - AttributeValueError, ChangeSetError, ComponentError, ComponentType, - DiagramError as DalDiagramError, EdgeError, InternalProviderError, NodeError, NodeKind, - NodeMenuError, SchemaError as DalSchemaError, SchemaVariantId, StandardModelError, - TransactionsError, + component::ComponentViewError, node::NodeId, schema::variant::SchemaVariantError, ActionError, + ActionPrototypeError, AttributeContextBuilderError, AttributeValueError, ChangeSetError, + ComponentError, ComponentType, DiagramError as DalDiagramError, EdgeError, + InternalProviderError, NodeError, NodeKind, NodeMenuError, SchemaError as DalSchemaError, + SchemaVariantId, StandardModelError, TransactionsError, }; use dal::{AttributeReadContext, WsEventError}; +use std::num::ParseFloatError; use thiserror::Error; use crate::server::state::AppState; @@ -26,6 +27,7 @@ pub mod delete_connection; pub mod get_diagram; pub mod get_node_add_menu; pub mod list_schema_variants; +pub mod paste_component; mod restore_component; pub mod restore_connection; pub mod set_node_position; @@ -37,6 +39,8 @@ pub enum DiagramError { ActionError(#[from] ActionError), #[error("action prototype error: {0}")] ActionPrototype(#[from] ActionPrototypeError), + #[error("attribute context builder: {0}")] + AttributeContextBuilder(#[from] AttributeContextBuilderError), #[error("attribute value error: {0}")] AttributeValue(#[from] AttributeValueError), #[error("attribute value not found for context: {0:?}")] @@ -49,6 +53,8 @@ pub enum DiagramError { Component(#[from] ComponentError), #[error("component not found")] ComponentNotFound, + #[error("component view error: {0}")] + ComponentView(#[from] ComponentViewError), #[error(transparent)] ContextTransaction(#[from] TransactionsError), #[error("dal schema error: {0}")] @@ -93,6 +99,8 @@ pub enum DiagramError { NotAuthorized, #[error("parent node not found {0}")] ParentNodeNotFound(NodeId), + #[error("parse int: {0}")] + ParseFloat(#[from] ParseFloatError), #[error(transparent)] Pg(#[from] si_data_pg::PgError), #[error(transparent)] @@ -166,6 +174,7 @@ pub fn routes() -> Router { "/delete_components", post(delete_component::delete_components), ) + .route("/paste_components", post(paste_component::paste_components)) .route( "/restore_component", post(restore_component::restore_component), diff --git a/lib/sdf-server/src/server/service/diagram/paste_component.rs b/lib/sdf-server/src/server/service/diagram/paste_component.rs new file mode 100644 index 0000000000..4ae480acf9 --- /dev/null +++ b/lib/sdf-server/src/server/service/diagram/paste_component.rs @@ -0,0 +1,200 @@ +use axum::{extract::OriginalUri, http::uri::Uri}; +use axum::{response::IntoResponse, Json}; +use chrono::Utc; +use dal::{ + action_prototype::ActionPrototypeContextField, func::backend::js_action::ActionRunResult, + Action, ActionKind, ActionPrototype, ActionPrototypeContext, ChangeSet, Component, + ComponentError, ComponentId, Connection, DalContext, Edge, Node, StandardModel, Visibility, + WsEvent, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use veritech_client::ResourceStatus; + +use super::{DiagramError, DiagramResult}; +use crate::server::extract::{AccessBuilder, HandlerContext, PosthogClient}; +use crate::server::tracking::track; + +async fn paste_single_component( + ctx: &DalContext, + component_id: ComponentId, + offset_x: f64, + offset_y: f64, + original_uri: &Uri, + PosthogClient(posthog_client): &PosthogClient, +) -> DiagramResult { + let original_comp = Component::get_by_id(ctx, &component_id) + .await? + .ok_or(DiagramError::ComponentNotFound)?; + let original_node = original_comp + .node(ctx) + .await? + .pop() + .ok_or(ComponentError::NodeNotFoundForComponent(component_id))?; + + let schema_variant = original_comp + .schema_variant(ctx) + .await? + .ok_or(DiagramError::SchemaNotFound)?; + + let (pasted_comp, mut pasted_node) = + Component::new(ctx, original_comp.name(ctx).await?, *schema_variant.id()).await?; + + let x: f64 = original_node.x().parse()?; + let y: f64 = original_node.y().parse()?; + pasted_node + .set_geometry( + ctx, + (x + offset_x).to_string(), + (y + offset_y).to_string(), + original_node.width(), + original_node.height(), + ) + .await?; + + pasted_comp + .clone_attributes_from(ctx, *original_comp.id()) + .await?; + + pasted_comp + .set_resource_raw( + ctx, + ActionRunResult { + status: ResourceStatus::Ok, + payload: None, + message: None, + logs: Vec::new(), + last_synced: Some(Utc::now().to_rfc3339()), + }, + false, + ) + .await?; + + for prototype in ActionPrototype::find_for_context_and_kind( + ctx, + ActionKind::Create, + ActionPrototypeContext::new_for_context_field(ActionPrototypeContextField::SchemaVariant( + *schema_variant.id(), + )), + ) + .await? + { + let action = Action::new(ctx, *prototype.id(), *pasted_comp.id()).await?; + let prototype = action.prototype(ctx).await?; + + track( + posthog_client, + ctx, + original_uri, + "create_action", + serde_json::json!({ + "how": "/diagram/paste_components", + "prototype_id": prototype.id(), + "prototype_kind": prototype.kind(), + "component_id": pasted_comp.id(), + "component_name": pasted_comp.name(ctx).await?, + "change_set_pk": ctx.visibility().change_set_pk, + }), + ); + } + + let schema = pasted_comp + .schema(ctx) + .await? + .ok_or(DiagramError::SchemaNotFound)?; + track( + posthog_client, + ctx, + original_uri, + "paste_component", + serde_json::json!({ + "component_id": pasted_comp.id(), + "component_schema_name": schema.name(), + }), + ); + + WsEvent::change_set_written(ctx) + .await? + .publish_on_commit(ctx) + .await?; + + Ok(pasted_node) +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PasteComponentsRequest { + pub component_ids: Vec, + pub offset_x: f64, + pub offset_y: f64, + #[serde(flatten)] + pub visibility: Visibility, +} + +/// Paste a set of [`Component`](dal::Component)s via their componentId. Creates change-set if on head +pub async fn paste_components( + HandlerContext(builder): HandlerContext, + AccessBuilder(request_ctx): AccessBuilder, + posthog_client: PosthogClient, + OriginalUri(original_uri): OriginalUri, + Json(request): Json, +) -> DiagramResult { + let mut ctx = builder.build(request_ctx.build(request.visibility)).await?; + + let mut force_changeset_pk = None; + if ctx.visibility().is_head() { + let change_set = ChangeSet::new(&ctx, ChangeSet::generate_name(), None).await?; + + let new_visibility = Visibility::new(change_set.pk, request.visibility.deleted_at); + + ctx.update_visibility(new_visibility); + + force_changeset_pk = Some(change_set.pk); + + WsEvent::change_set_created(&ctx, change_set.pk) + .await? + .publish_on_commit(&ctx) + .await?; + }; + + let mut pasted_components_by_original = HashMap::new(); + for component_id in &request.component_ids { + let pasted_node_id = paste_single_component( + &ctx, + *component_id, + request.offset_x, + request.offset_y, + &original_uri, + &posthog_client, + ) + .await?; + pasted_components_by_original.insert(*component_id, pasted_node_id); + } + + for component_id in &request.component_ids { + let edges = Edge::list_for_component(&ctx, *component_id).await?; + for edge in edges { + let tail_node = pasted_components_by_original.get(&edge.tail_component_id()); + let head_node = pasted_components_by_original.get(&edge.head_component_id()); + if let (Some(tail_node), Some(head_node)) = (tail_node, head_node) { + Connection::new( + &ctx, + *tail_node.id(), + edge.tail_socket_id(), + *head_node.id(), + edge.head_socket_id(), + *edge.kind(), + ) + .await?; + } + } + } + + ctx.commit().await?; + + let mut response = axum::response::Response::builder(); + if let Some(force_changeset_pk) = force_changeset_pk { + response = response.header("force_changeset_pk", force_changeset_pk.to_string()); + } + Ok(response.body(axum::body::Empty::new())?) +}