Merge pull request #20 from webstudio-is/props-transactions

Props update/delete using transactions
This commit is contained in:
Oleg Isonen authored and GitHub committed 2022-04-01 22:21:20 +01:00
commit 417afbe230
19 files changed
+371 -101

No files matched your search

+2
View File
@@ -32,6 +32,7 @@ import { WrapperComponentDev } from "./features/wrapper-component";
import { rootInstanceContainer, useRootInstance } from "./shared/nano-values";
import { usePeriodicSync } from "./shared/use-periodic-sync";
import { createTransaction } from "~/lib/sync-engine";
import { useManageProps } from "./shared/props";
const useElementsTree = () => {
const [rootInstance] = useRootInstance();
@@ -81,6 +82,7 @@ type DesignModeProps = {
const DesignMode = ({ treeId }: DesignModeProps) => {
useDragDropHandlers();
useUpdateStyle();
useManageProps();
usePublishSelectedInstance({ treeId });
useInsertInstance();
useReparentInstance();
+30
View File
@@ -0,0 +1,30 @@
import {
type UserPropsUpdates,
useSubscribe,
allUserPropsContainer,
DeleteProp,
} from "@webstudio-is/sdk";
import { createTransaction, register } from "~/lib/sync-engine";
import {
updateAllUserPropsMutable,
deletePropMutable,
} from "~/shared/props-utils";
register("props", allUserPropsContainer);
export const useManageProps = () => {
useSubscribe<"updateProps", UserPropsUpdates>(
"updateProps",
(userPropsUpdates) => {
createTransaction([allUserPropsContainer], (allUserProps) => {
updateAllUserPropsMutable(allUserProps, userPropsUpdates);
});
}
);
useSubscribe<"deleteProp", DeleteProp>("deleteProp", (deleteProp) => {
createTransaction([allUserPropsContainer], (allUserProps) => {
deletePropMutable(allUserProps, deleteProp);
});
});
};
@@ -69,7 +69,7 @@ export const usePropsLogic = ({
publish<"deleteProp", DeleteProp>({
type: "deleteProp",
payload: {
propsId: selectedInstanceData.props.id,
instanceId: selectedInstanceData.id,
propId: id,
},
});
+10 -28
View File
@@ -1,8 +1,4 @@
import {
type DeleteProp,
type UserPropsUpdates,
type Project,
} from "@webstudio-is/sdk";
import { type Project } from "@webstudio-is/sdk";
import type { Config } from "~/config";
import { useSubscribe } from "~/designer/features/canvas-iframe";
import { enqueue } from "./queue";
@@ -13,33 +9,19 @@ import { type SyncItem } from "~/lib/sync-engine";
// and backend fetches and updates big objects, so if we send quickly,
// we end up overwriting things
export const useSync = ({ project }: { config: Config; project: Project }) => {
useSubscribe<"updateProps", UserPropsUpdates>("updateProps", (update) => {
enqueue(() =>
fetch(`/rest/props/update`, {
method: "post",
body: JSON.stringify(update),
})
);
});
useSubscribe<"deleteProp", DeleteProp>(
"deleteProp",
({ propsId, propId }) => {
useSubscribe<"syncChanges", Array<SyncItem>>(
"syncChanges",
(transactions) => {
enqueue(() =>
fetch(`/rest/props/delete-prop`, {
fetch(`/rest/patch`, {
method: "post",
body: JSON.stringify({ propsId, propId }),
body: JSON.stringify({
transactions,
treeId: project.devTreeId,
projectId: project.id,
}),
})
);
}
);
useSubscribe<"syncChanges", Array<SyncItem>>("syncChanges", (queue) => {
enqueue(() =>
fetch(`/rest/patch/${project.devTreeId}`, {
method: "post",
body: JSON.stringify(queue),
})
);
});
};
@@ -1,14 +1,25 @@
import { type ActionFunction } from "remix";
import * as db from "~/shared/db";
import { type SyncItem } from "~/lib/sync-engine";
import { type Project, type Tree } from "@webstudio-is/sdk";
const updaters = { root: db.tree.patchRoot };
const updaters = {
root: db.tree.patchRoot,
props: db.props.patch,
};
type UpdaterKey = keyof typeof updaters;
export const action: ActionFunction = async ({ request, params }) => {
if (params.treeId === undefined) return { errors: "Tree id required" };
const transactions: Array<SyncItem> = await request.json();
type PatchData = {
transactions: Array<SyncItem>;
treeId: Tree["id"];
projectId: Project["id"];
};
export const action: ActionFunction = async ({ request }) => {
const { treeId, projectId, transactions }: PatchData = await request.json();
if (treeId === undefined) return { errors: "Tree id required" };
if (projectId === undefined) return { errors: "Project id required" };
// @todo parallelize the updates
// currently not possible because we fetch the entire tree
// and parallelized updates will cause unpredictable side effects
@@ -18,7 +29,7 @@ export const action: ActionFunction = async ({ request, params }) => {
if (namespace in updaters === false) {
return { errors: `Unknown namespace "${namespace}"` };
}
await updaters[namespace as UpdaterKey](params.treeId, patches);
await updaters[namespace as UpdaterKey]({ treeId, projectId }, patches);
}
}
return { status: "ok" };
-8
View File
@@ -1,8 +0,0 @@
import { type ActionFunction } from "remix";
import * as db from "~/shared/db";
export const action: ActionFunction = async ({ request }) => {
const data = await request.json();
await db.props.deleteProp(data);
return { status: "ok" };
};
-9
View File
@@ -1,9 +0,0 @@
import { type ActionFunction } from "remix";
import * as db from "~/shared/db";
export const action: ActionFunction = async ({ request }) => {
// @todo types
const update = await request.json();
await db.props.update(update);
return { status: "ok" };
};
+34 -20
View File
@@ -5,7 +5,9 @@ import {
type Project,
type InstanceProps,
type Tree,
type AllUserProps,
} from "@webstudio-is/sdk";
import { applyPatches, type Patch } from "immer";
import { prisma, PrismaClientKnownRequestError } from "./prisma.server";
export const loadByProject = async (
@@ -68,26 +70,6 @@ export const update = async ({
});
};
export const deleteProp = async ({
propsId,
propId,
}: {
propsId: InstanceProps["id"];
propId: UserProp["id"];
}) => {
// @todo update in one command, remove queueing logic on the ui
// as of prisma client v3.10.0 updating composite types like in this doc didn't work
// https://www.prisma.io/docs/concepts/components/prisma-client/composite-types
const props = (await loadById(propsId))?.props ?? [];
const index = props.findIndex((prop) => prop.id == propId);
if (index === -1) return;
props.splice(index, 1);
await prisma.instanceProps.update({
where: { id: propsId },
data: { props },
});
};
export const deleteProps = async ({
instanceId,
treeId,
@@ -130,3 +112,35 @@ export const clone = async ({
data,
});
};
export const patch = async (
{ treeId }: { treeId: Tree["id"]; projectId: Project["id"] },
patches: Array<Patch>
) => {
const allProps = await loadByTreeId(treeId);
// We should get rid of this by applying patches on the client to the original
// model instead of the instanceId map.
// The map is handy for accessing props, but probably should be just cached interface, not the main data structure.
const allPropsMapByInstanceId = allProps.reduce((acc, prop) => {
acc[prop.instanceId] = prop;
return acc;
}, {} as AllUserProps);
const nextProps = applyPatches<AllUserProps>(
allPropsMapByInstanceId,
patches
);
await Promise.all(
Object.values(nextProps).map(
async ({ id, instanceId, treeId, props }) =>
await prisma.instanceProps.upsert({
where: { id: id },
create: { id: id, instanceId, treeId, props },
update: {
props,
},
})
)
);
};
+4 -1
View File
@@ -84,7 +84,10 @@ export const clone = async (id: string) => {
return await create(tree.root);
};
export const patchRoot = async (treeId: Tree["id"], patches: Array<Patch>) => {
export const patchRoot = async (
{ treeId }: { treeId: Tree["id"] },
patches: Array<Patch>
) => {
const tree = await loadById(treeId);
const root = applyPatches(tree.root, patches);
await prisma.tree.update({
@@ -0,0 +1,40 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Delete prop found and deleted 1`] = `
Object {
"instanceId": Object {
"id": "id",
"instanceId": "instanceId",
"props": Array [
Object {
"id": "propId1",
"prop": "a",
"value": "1",
},
],
"treeId": "treeId",
},
}
`;
exports[`Delete prop not found and not deleted 1`] = `
Object {
"instanceId": Object {
"id": "id",
"instanceId": "instanceId",
"props": Array [
Object {
"id": "propId",
"prop": "b",
"value": "1",
},
Object {
"id": "propId1",
"prop": "a",
"value": "1",
},
],
"treeId": "treeId",
},
}
`;
@@ -0,0 +1,63 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Update props with added prop 1`] = `
Object {
"instanceId": Object {
"id": "id",
"instanceId": "instanceId",
"props": Array [
Object {
"id": "propId",
"prop": "a",
"value": "1",
},
],
"treeId": "treeId",
},
}
`;
exports[`Update props with empty updates 1`] = `
Object {
"instanceId": Object {
"id": "id",
"instanceId": "instanceId",
"props": Array [],
"treeId": "treeId",
},
}
`;
exports[`Update props with renamed prop name 1`] = `
Object {
"instanceId": Object {
"id": "id",
"instanceId": "instanceId",
"props": Array [
Object {
"id": "propId",
"prop": "b",
"value": "1",
},
],
"treeId": "treeId",
},
}
`;
exports[`Update props with updated prop value 1`] = `
Object {
"instanceId": Object {
"id": "id",
"instanceId": "instanceId",
"props": Array [
Object {
"id": "propId",
"prop": "a",
"value": "2",
},
],
"treeId": "treeId",
},
}
`;
@@ -0,0 +1,46 @@
import { type AllUserProps } from "@webstudio-is/sdk";
import { deletePropMutable } from "./delete-prop";
describe("Delete prop", () => {
test("found and deleted", () => {
const propsMap: AllUserProps = {
instanceId: {
id: "id",
instanceId: "instanceId",
treeId: "treeId",
props: [
{ id: "propId", prop: "b", value: "1" },
{ id: "propId1", prop: "a", value: "1" },
],
},
};
const isDeleted = deletePropMutable(propsMap, {
instanceId: "instanceId",
propId: "propId",
});
expect(propsMap).toMatchSnapshot();
expect(isDeleted).toBeTruthy();
});
test("not found and not deleted", () => {
const propsMap: AllUserProps = {
instanceId: {
id: "id",
instanceId: "instanceId",
treeId: "treeId",
props: [
{ id: "propId", prop: "b", value: "1" },
{ id: "propId1", prop: "a", value: "1" },
],
},
};
const isDeleted = deletePropMutable(propsMap, {
instanceId: "instanceId",
propId: "notFound",
});
expect(propsMap).toMatchSnapshot();
expect(isDeleted).toBeFalsy();
});
});
+13
View File
@@ -0,0 +1,13 @@
import { type AllUserProps, type DeleteProp } from "@webstudio-is/sdk";
export const deletePropMutable = (
allProps: AllUserProps,
deleteProp: DeleteProp
) => {
const prop = allProps[deleteProp.instanceId];
if (prop === undefined) return false;
const index = prop.props.findIndex(({ id }) => id === deleteProp.propId);
if (index === -1) return false;
prop.props.splice(index, 1);
return true;
};
+2
View File
@@ -0,0 +1,2 @@
export * from "./update-props";
export * from "./delete-prop";
@@ -0,0 +1,80 @@
import { type AllUserProps, type UserPropsUpdates } from "@webstudio-is/sdk";
import { updateAllUserPropsMutable } from "./update-props";
describe("Update props", () => {
test("with empty updates", () => {
const propsMap: AllUserProps = {
instanceId: {
id: "id",
instanceId: "instanceId",
treeId: "treeId",
props: [],
},
};
const update: UserPropsUpdates = {
propsId: "id",
instanceId: "instanceId",
treeId: "treeId",
updates: [],
};
updateAllUserPropsMutable(propsMap, update);
expect(propsMap).toMatchSnapshot();
});
test("with added prop", () => {
const propsMap: AllUserProps = {
instanceId: {
id: "id",
instanceId: "instanceId",
treeId: "treeId",
props: [],
},
};
const update: UserPropsUpdates = {
propsId: "id",
instanceId: "instanceId",
treeId: "treeId",
updates: [{ id: "propId", prop: "a", value: "1" }],
};
updateAllUserPropsMutable(propsMap, update);
expect(propsMap).toMatchSnapshot();
});
test("with updated prop value", () => {
const propsMap: AllUserProps = {
instanceId: {
id: "id",
instanceId: "instanceId",
treeId: "treeId",
props: [{ id: "propId", prop: "a", value: "1" }],
},
};
const update: UserPropsUpdates = {
propsId: "id",
instanceId: "instanceId",
treeId: "treeId",
updates: [{ id: "propId", prop: "a", value: "2" }],
};
updateAllUserPropsMutable(propsMap, update);
expect(propsMap).toMatchSnapshot();
});
test("with renamed prop name", () => {
const propsMap: AllUserProps = {
instanceId: {
id: "id",
instanceId: "instanceId",
treeId: "treeId",
props: [{ id: "propId", prop: "a", value: "1" }],
},
};
const update: UserPropsUpdates = {
propsId: "id",
instanceId: "instanceId",
treeId: "treeId",
updates: [{ id: "propId", prop: "b", value: "1" }],
};
updateAllUserPropsMutable(propsMap, update);
expect(propsMap).toMatchSnapshot();
});
});
+25
View File
@@ -0,0 +1,25 @@
import { type AllUserProps, type UserPropsUpdates } from "@webstudio-is/sdk";
export const updateAllUserPropsMutable = (
allUserProps: AllUserProps,
{ instanceId, propsId, treeId, updates }: UserPropsUpdates
) => {
if (instanceId in allUserProps === false) {
allUserProps[instanceId] = {
id: propsId,
instanceId,
treeId,
props: [],
};
}
const instanceProps = allUserProps[instanceId];
for (const update of updates) {
const prop = instanceProps.props.find(({ id }) => id === update.id);
if (prop === undefined) {
instanceProps.props.push(update);
} else {
prop.prop = update.prop;
prop.value = update.value;
}
}
};
@@ -1,24 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Reparent instance div 1 > div 2, div 3 => div 1 > div 2 > div 3 1`] = `
Object {
"children": Array [
Object {
"children": Array [
Object {
"children": Array [],
"component": "Box",
"id": "3",
"style": Object {},
},
],
"component": "Box",
"id": "2",
"style": Object {},
},
],
"component": "Box",
"id": "1",
"style": Object {},
}
`;
+1 -1
View File
@@ -43,7 +43,7 @@
"@remix-run/vercel": "^1.2.3",
"@stitches/react": "^1.2.6",
"@stitches/stringify": "^1.2.6",
"@webstudio-is/sdk": "^0.1.20",
"@webstudio-is/sdk": "^0.1.21",
"bson-objectid": "^2.0.2",
"camelcase": "^6.3.0",
"hyphenate-style-name": "^1.0.4",
+4 -4
View File
@@ -1820,10 +1820,10 @@
dependencies:
web-streams-polyfill "^3.1.1"
"@webstudio-is/sdk@^0.1.20":
version "0.1.20"
resolved "https://registry.yarnpkg.com/@webstudio-is/sdk/-/sdk-0.1.20.tgz#134fcc2eb166a0c0b648fac4fb008c98666f88c6"
integrity sha512-WI379pIabj6WU05qaP9MeUFE+9uCQu8uoEAXkxQ9mjkFpQUNY8lIn3ncsvmxZvyoKCaFIZ2UBAs2Pb7Q6fwSHQ==
"@webstudio-is/sdk@^0.1.21":
version "0.1.21"
resolved "https://registry.yarnpkg.com/@webstudio-is/sdk/-/sdk-0.1.21.tgz#245f48b4be4a72524338c9017afecba0ff8bfaef"
integrity sha512-pafxgtw6YwxOUKh1cyazurFA565xBjqJMpb6h6AaBNErbwJtTzTb7QFVz04skBdEMOnuhCACvPqp+tJU2XEP+w==
dependencies:
"@prisma/client" "^3.10.0"
"@stitches/react" "^1.2.7"