Skip to content

Commit

Permalink
merge: #3075
Browse files Browse the repository at this point in the history
3075: Initial copy-paste implementation r=paulocsanz a=paulocsanz



Co-authored-by: Paulo Cabral <[email protected]>
si-bors-ng[bot] and paulocsanz authored Dec 19, 2023

Verified

This commit was signed with the committer’s verified signature.
michaelsproul Michael Sproul
2 parents 546c816 + d101386 commit c06ab47
Showing 12 changed files with 572 additions and 21 deletions.
10 changes: 8 additions & 2 deletions app/web/src/components/ComponentOutline/ComponentOutline.vue
Original file line number Diff line number Diff line change
@@ -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();
27 changes: 27 additions & 0 deletions app/web/src/components/ModelingDiagram/ModelingDiagram.vue
Original file line number Diff line number Diff line change
@@ -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<MouseEvent>) {
// 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(() => {
35 changes: 34 additions & 1 deletion app/web/src/components/ModelingView/ModelingRightClickMenu.vue
Original file line number Diff line number Diff line change
@@ -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<InstanceType<typeof DropdownMenu>>();
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 });
21 changes: 17 additions & 4 deletions app/web/src/components/Workspace/WorkspaceModelAndView.vue
Original file line number Diff line number Diff line change
@@ -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);
}
</script>
24 changes: 24 additions & 0 deletions app/web/src/store/components.store.ts
Original file line number Diff line number Diff line change
@@ -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");
1 change: 1 addition & 0 deletions app/web/src/store/feature_flags.store.ts
Original file line number Diff line number Diff line change
@@ -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;
239 changes: 233 additions & 6 deletions lib/dal/src/component.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<AttributeValueId>>(),
],
)
.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::<Vec<AttributeValueId>>(),
&attribute_prototypes
.iter()
.map(|av| *av.id())
.collect::<Vec<AttributePrototypeId>>(),
],
)
.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)]
11 changes: 10 additions & 1 deletion lib/dal/src/component/resource.rs
Original file line number Diff line number Diff line change
@@ -94,10 +94,19 @@ impl Component {
&self,
ctx: &DalContext,
result: ActionRunResult,
) -> ComponentResult<bool> {
self.set_resource_raw(ctx, result, true).await
}

pub async fn set_resource_raw(
&self,
ctx: &DalContext,
result: ActionRunResult,
check_change_set: bool,
) -> ComponentResult<bool> {
let ctx = &ctx.clone_without_deleted_visibility();

if !ctx.visibility().is_head() {
if check_change_set && !ctx.visibility().is_head() {
return Err(ComponentError::CannotUpdateResourceTreeInChangeSet);
}

2 changes: 1 addition & 1 deletion lib/dal/src/diagram/connection.rs
Original file line number Diff line number Diff line change
@@ -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(),
4 changes: 3 additions & 1 deletion lib/dal/src/edge.rs
Original file line number Diff line number Diff line change
@@ -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 {
19 changes: 14 additions & 5 deletions lib/sdf-server/src/server/service/diagram.rs
Original file line number Diff line number Diff line change
@@ -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<AppState> {
"/delete_components",
post(delete_component::delete_components),
)
.route("/paste_components", post(paste_component::paste_components))
.route(
"/restore_component",
post(restore_component::restore_component),
200 changes: 200 additions & 0 deletions lib/sdf-server/src/server/service/diagram/paste_component.rs
Original file line number Diff line number Diff line change
@@ -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<Node> {
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<ComponentId>,
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<PasteComponentsRequest>,
) -> DiagramResult<impl IntoResponse> {
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())?)
}

0 comments on commit c06ab47

Please sign in to comment.