Replace UserProp with PropsItem (#833)

Here is migration to new props type with more safe conversion to db
format. Added two versions StoredPropsItem for and PropsItem for client
and got rid of "as" casting.
This commit is contained in:
Bogdan Chadkin authored and GitHub committed 2023-01-22 15:57:37 +03:00
1 parent ca08925962
commit bb288f309b
20 files changed
+147 -158

No files matched your search

@@ -1,5 +1,5 @@
import { useEffect } from "react";
import type { Instance, Styles, UserProp } from "@webstudio-is/react-sdk";
import type { Instance, PropsItem, Styles } from "@webstudio-is/react-sdk";
import { getBrowserStyle } from "@webstudio-is/react-sdk";
import { publish, subscribe, subscribeAll } from "~/shared/pubsub";
import {
@@ -53,7 +53,7 @@ export const SelectedInstanceConnector = ({
instanceElementRef: { current: undefined | HTMLElement };
instance: Instance;
instanceStyles: Styles;
instanceProps: undefined | UserProp[];
instanceProps: undefined | PropsItem[];
}) => {
useEffect(() => {
const element = instanceElementRef.current;
@@ -5,7 +5,7 @@ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext
import {
type Instance,
type OnChangeChildren,
type UserProp,
type PropsItem,
renderWrapperComponentChildren,
getComponent,
idAttribute,
@@ -44,7 +44,7 @@ const ContentEditable = ({
return <Component ref={ref} {...props} contentEditable={true} />;
};
type UserProps = Record<UserProp["prop"], string | number | boolean>;
type UserProps = Record<PropsItem["name"], string | number | boolean>;
type WrapperComponentDevProps = {
instance: Instance;
@@ -81,7 +81,7 @@ export const WrapperComponentDev = ({
}
for (const item of instanceProps) {
if (item.type !== "asset") {
result[item.prop] = item.value;
result[item.name] = item.value;
}
}
return result;
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect } from "react";
import {
type Instance,
type UserProp,
type PropsItem,
getComponentMeta,
allUserPropsContainer,
} from "@webstudio-is/react-sdk";
@@ -31,7 +31,7 @@ declare module "~/shared/pubsub" {
insertInstance: {
instance: Instance;
dropTarget?: { parentId: Instance["id"]; position: number };
props?: Array<UserProp>;
props?: Array<PropsItem>;
};
unselectInstance: undefined;
}
@@ -9,8 +9,9 @@ export const Primitive: ComponentStoryObj<typeof Control> = {
args: {
component: "Image",
userProp: {
prop: "src",
name: "src",
id: "ddf20afe-65a8-478e-a52d-1be2d2f70830",
instanceId: "instanceId",
type: "string",
value: "",
},
@@ -21,8 +22,9 @@ export const Options: ComponentStoryObj<typeof Control> = {
args: {
component: "Image",
userProp: {
prop: "loading",
name: "loading",
id: "ddf20afe-65a8-478e-a52d-1be2d2f70830",
instanceId: "instanceId",
type: "string",
value: "",
},
@@ -1,7 +1,7 @@
import {
getComponentMetaProps,
type Instance,
type UserProp,
type PropsItem,
} from "@webstudio-is/react-sdk";
import warnOnce from "warn-once";
import {
@@ -179,7 +179,7 @@ const assertUnreachable = (_arg: never, errorMessage: string) => {
type ControlProps = {
component: Instance["component"];
userProp: UserProp;
userProp: PropsItem;
onChangePropValue: (value: UserPropValue) => void;
setCssProperty: SetProperty;
};
@@ -193,7 +193,7 @@ export function Control({
}: ControlProps) {
const meta = getComponentMetaProps(component);
const argType = meta[userProp.prop];
const argType = meta[userProp.name];
// argType can be undefined in case of new property created
const defaultValue = argType?.defaultValue ?? "";
@@ -202,7 +202,7 @@ export function Control({
if (type == null) {
warnOnce(
true,
`No control type for prop "${userProp.prop}" component "${component}" found`
`No control type for prop "${userProp.name}" component "${component}" found`
);
return <NotImplemented />;
}
@@ -211,14 +211,14 @@ export function Control({
warnOnce(
true,
`Control type "${typeof type}" for prop "${
userProp.prop
userProp.name
}" component "${component}" is not a string`
);
return <NotImplemented />;
}
if (component === "Image" && userProp.prop === "src") {
if (component === "Image" && userProp.name === "src") {
const asset = userProp.type === "asset" ? userProp.value : null;
return (
@@ -298,7 +298,7 @@ export function Control({
warnOnce(
options == null,
`options is not an array of strings for prop: ${userProp.prop} component: ${component}`
`options is not an array of strings for prop: ${userProp.name} component: ${component}`
);
const DEFAULT_OPTIONS: string[] = [];
@@ -356,7 +356,7 @@ export function Control({
warnOnce(
true,
`Control type "${type}" is not implemented for prop: "${userProp.prop}" in component "${component}"`
`Control type "${type}" is not implemented for prop: "${userProp.name}" in component "${component}"`
);
return <NotImplemented />;
@@ -2,7 +2,7 @@ import type { ComponentStory } from "@storybook/react";
import {
allUserPropsContainer,
getComponentMetaProps,
type UserProp,
type PropsItem,
} from "@webstudio-is/react-sdk";
import { PropsPanel } from "./props-panel";
@@ -16,7 +16,8 @@ export const NoProps: ComponentStory<typeof PropsPanel> = () => {
"1": [
{
id: "disabled",
prop: "disabled",
instanceId: "instanceId",
name: "disabled",
type: "boolean",
value: true,
},
@@ -73,12 +74,12 @@ const meta = getComponentMetaProps("Button");
export const AllProps: ComponentStory<typeof PropsPanel> = () => {
allUserPropsContainer.set({
"3": Object.entries(meta).map(([prop, value]) => {
"3": Object.entries(meta).map(([name, value]) => {
return {
id: prop,
prop,
id: name,
name,
value: value?.defaultValue ?? "",
} as UserProp;
} as PropsItem;
}),
});
return (
@@ -5,7 +5,7 @@ import {
getComponentMetaProps,
useAllUserProps,
type Instance,
type UserProp,
type PropsItem,
} from "@webstudio-is/react-sdk";
import { type Publish } from "~/shared/pubsub";
import { Control } from "./control";
@@ -124,7 +124,7 @@ const Combobox = ({
};
type PropertyProps = {
userProp: UserProp;
userProp: PropsItem;
component: Instance["component"];
onChangePropName: (name: string) => void;
onChangePropValue: (value: UserPropValue) => void;
@@ -146,12 +146,12 @@ const Property = ({
}: PropertyProps) => {
const metaProps = getComponentMetaProps(component);
const argType = metaProps[userProp.prop as keyof typeof metaProps];
const argType = metaProps[userProp.name as keyof typeof metaProps];
const isInvalid =
userProp.prop != null &&
userProp.prop.length > 0 &&
userProp.name != null &&
userProp.name.length > 0 &&
typeof argType === "undefined" &&
!userProp.prop.match(/^data-(.)+/);
!userProp.name.match(/^data-(.)+/);
const allProps = Object.keys(metaProps).filter(
(propName) => existingProps.includes(propName) === false
@@ -167,12 +167,12 @@ const Property = ({
placeholder="Property"
readOnly={true}
state={isInvalid ? "invalid" : undefined}
value={userProp.prop}
value={userProp.name}
/>
) : (
<Combobox
items={allProps}
value={userProp.prop}
value={userProp.name}
onItemSelect={(name) => {
if (name != null) {
setError(undefined);
@@ -195,7 +195,7 @@ const Property = ({
/>
)}
{isInvalid || error !== undefined ? (
<Tooltip content={error ?? `Invalid property name: ${userProp.prop}`}>
<Tooltip content={error ?? `Invalid property name: ${userProp.name}`}>
<ExclamationTriangleIcon width={12} height={12} />
</Tooltip>
) : (
@@ -282,7 +282,7 @@ export const PropsPanel = ({
/>
);
const existingProps = userProps.map((userProp) => userProp.prop);
const existingProps = userProps.map((userProp) => userProp.name);
return (
<Box>
@@ -27,7 +27,7 @@ describe("usePropsLogic", () => {
);
expect(result.current.userProps.length).toEqual(1);
expect(result.current.userProps[0]).toMatchObject({
prop: "href",
name: "href",
value: "",
});
});
@@ -50,11 +50,11 @@ describe("usePropsLogic", () => {
})
);
expect(res1.current.userProps[0]).toMatchObject({
prop: "tag",
name: "tag",
value: "h1",
});
expect(res2.current.userProps[0]).toMatchObject({
prop: "type",
name: "type",
value: "submit",
});
});
@@ -70,7 +70,7 @@ describe("usePropsLogic", () => {
);
expect(result.current.userProps.length).toEqual(1);
expect(result.current.userProps[0]).toMatchObject({
prop: "type",
name: "type",
value: "submit",
});
});
@@ -81,7 +81,8 @@ describe("usePropsLogic", () => {
props: [
{
id: "default",
prop: "type",
instanceId: "instanceId",
name: "type",
type: "string",
value: "submit",
},
@@ -93,7 +94,7 @@ describe("usePropsLogic", () => {
);
expect(result.current.userProps.length).toEqual(1);
expect(result.current.userProps[0]).toMatchObject({
prop: "type",
name: "type",
value: "submit",
});
});
@@ -115,7 +116,7 @@ describe("usePropsLogic", () => {
});
expect(result.current.userProps.length).toEqual(2);
expect(result.current.userProps[1]).toMatchObject({ prop: "", value: "" });
expect(result.current.userProps[1]).toMatchObject({ name: "", value: "" });
});
test("should respect initialProps ordering", () => {
@@ -124,35 +125,40 @@ describe("usePropsLogic", () => {
props: [
{
id: "22",
prop: "aria-label",
instanceId: "instanceId",
name: "aria-label",
type: "string",
value: "https://example.com",
required: false,
},
{
id: "0",
prop: "alt",
instanceId: "instanceId",
name: "alt",
type: "string",
value: "alt text",
required: true,
},
{
id: "1",
prop: "width",
instanceId: "instanceId",
name: "width",
type: "number",
value: 101,
required: false,
},
{
id: "33",
prop: "title",
instanceId: "instanceId",
name: "title",
type: "string",
value: "title text",
required: true,
},
{
id: "2",
prop: "src",
instanceId: "instanceId",
name: "src",
type: "string",
value: "https://example.com",
required: true,
@@ -166,7 +172,7 @@ describe("usePropsLogic", () => {
const imgMeta = getComponentMeta("Image");
const propNames = result.current.userProps.map((userProp) => userProp.prop);
const propNames = result.current.userProps.map((userProp) => userProp.name);
expect(imgMeta.initialProps).toEqual(
propNames.slice(0, imgMeta.initialProps?.length ?? 0)
@@ -175,9 +181,9 @@ describe("usePropsLogic", () => {
expect(propNames.slice(imgMeta.initialProps?.length ?? 0)).toEqual(
result.current.userProps
.filter(
(userProp) => imgMeta.initialProps?.includes(userProp.prop) === false
(userProp) => imgMeta.initialProps?.includes(userProp.name) === false
)
.map((userProp) => userProp.prop)
.map((userProp) => userProp.name)
);
});
@@ -194,7 +200,8 @@ describe("usePropsLogic", () => {
expect(
result.current.isRequired({
id: "2",
prop: "src",
instanceId: "instanceId",
name: "src",
type: "string",
value: "https://example.com",
required: false,
@@ -215,7 +222,8 @@ describe("usePropsLogic", () => {
expect(
result.current.isRequired({
id: "2",
prop: "arial-label",
instanceId: "instanceId",
name: "arial-label",
type: "string",
value: "https://example.com",
required: true,
@@ -225,7 +233,8 @@ describe("usePropsLogic", () => {
expect(
result.current.isRequired({
id: "2",
prop: "arial-label",
instanceId: "instanceId",
name: "arial-label",
type: "string",
value: "https://example.com",
required: false,
@@ -2,7 +2,8 @@ import { useMemo, useState } from "react";
import ObjectId from "bson-objectid";
import warnOnce from "warn-once";
import type {
UserProp,
Instance,
PropsItem,
MetaProps,
ComponentName,
} from "@webstudio-is/react-sdk";
@@ -12,7 +13,7 @@ import {
} from "@webstudio-is/react-sdk";
import type { SelectedInstanceData } from "@webstudio-is/project";
export type UserPropValue = UserProp extends infer T
export type UserPropValue = PropsItem extends infer T
? T extends { value: unknown; type: unknown }
? { value: T["value"]; type: T["type"] }
: never
@@ -73,20 +74,25 @@ const getRequiredPropsList = (component: ComponentName) => {
};
type UsePropsLogic = {
props: UserProp[];
props: PropsItem[];
selectedInstanceData: SelectedInstanceData;
updateProps: (update: UserProp) => void;
deleteProp: (id: UserProp["id"]) => void;
updateProps: (update: PropsItem) => void;
deleteProp: (id: PropsItem["id"]) => void;
};
const getPropsItemFromMetaProps = (metaProps: MetaProps, name: string) => {
const getPropsItemFromMetaProps = (
instanceId: Instance["id"],
metaProps: MetaProps,
name: string
) => {
const metaPropValue = metaProps[name];
if (metaPropValue === undefined) {
return undefined;
}
return {
id: ObjectId().toString(),
prop: name,
instanceId,
name,
...getValueFromPropMeta(metaPropValue),
};
};
@@ -100,7 +106,7 @@ export const usePropsLogic = ({
updateProps,
deleteProp,
}: UsePropsLogic) => {
const { component } = selectedInstanceData;
const { id: instanceId, component } = selectedInstanceData;
const metaProps = useMemo(
() => getComponentMetaProps(component),
@@ -110,24 +116,24 @@ export const usePropsLogic = ({
() => getRequiredPropsList(component),
[component]
);
const requiredPropsByName = new Map<string, UserProp>();
const requiredPropsByName = new Map<string, PropsItem>();
for (const name of requiredPropsList) {
const propsItem = getPropsItemFromMetaProps(metaProps, name);
const propsItem = getPropsItemFromMetaProps(instanceId, metaProps, name);
if (propsItem) {
requiredPropsByName.set(name, propsItem);
}
}
const [addedProps, setAddedProps] = useState<UserProp[]>([]);
const addedPropsById = new Map<string, UserProp>();
const [addedProps, setAddedProps] = useState<PropsItem[]>([]);
const addedPropsById = new Map<string, PropsItem>();
for (const propsItem of addedProps) {
addedPropsById.set(propsItem.id, propsItem);
}
const propsById = new Map<string, UserProp>();
const propsById = new Map<string, PropsItem>();
for (const propsItem of props) {
if (requiredPropsByName.has(propsItem.prop)) {
requiredPropsByName.set(propsItem.prop, propsItem);
if (requiredPropsByName.has(propsItem.name)) {
requiredPropsByName.set(propsItem.name, propsItem);
continue;
}
if (addedPropsById.has(propsItem.id)) {
@@ -145,20 +151,23 @@ export const usePropsLogic = ({
...addedPropsById.values(),
];
const handleChangePropName = (propsItem: UserProp, name: string) => {
const handleChangePropName = (propsItem: PropsItem, name: string) => {
// prevent changing name of required props
if (isRequired(propsItem)) {
return;
}
const typedValue = getValueFromPropMeta(metaProps[name]);
updateProps({ id: propsItem.id, prop: name, ...typedValue });
updateProps({ id: propsItem.id, instanceId, name, ...typedValue });
};
const handleChangePropValue = (propsItem: UserProp, value: UserPropValue) => {
const handleChangePropValue = (
propsItem: PropsItem,
value: UserPropValue
) => {
updateProps({ ...propsItem, ...value });
};
const handleDeleteProp = (propsItem: UserProp) => {
const handleDeleteProp = (propsItem: PropsItem) => {
// required prop should never be deleted
if (isRequired(propsItem)) {
return;
@@ -173,15 +182,16 @@ export const usePropsLogic = ({
...prev,
{
id: ObjectId().toString(),
prop: "",
instanceId,
name: "",
type: "string",
value: "",
},
]);
};
const isRequired = (propsItem: UserProp) => {
return propsItem.required || requiredPropsList.includes(propsItem.prop);
const isRequired = (propsItem: PropsItem) => {
return propsItem.required || requiredPropsList.includes(propsItem.name);
};
return {
@@ -1,11 +1,11 @@
import { Instance, UserProps } from "@webstudio-is/react-sdk";
import { Instance, Props } from "@webstudio-is/react-sdk";
import { z } from "zod";
const TYPE = "@webstudio/instance/v0.1" as const;
const InstanceCopyData = z.object({
instance: Instance,
props: UserProps.optional(),
props: Props.optional(),
});
export type InstanceCopyData = z.infer<typeof InstanceCopyData>;
@@ -1,5 +1,5 @@
import { utils } from "@webstudio-is/project";
import type { Instance, UserProp } from "@webstudio-is/react-sdk";
import type { Instance, PropsItem } from "@webstudio-is/react-sdk";
import { useEffect, useMemo } from "react";
import { type InstanceCopyData, serialize, deserialize } from "./serialize";
@@ -38,7 +38,7 @@ const isInstanceClipboardEvent = (
type Props = {
selectedInstanceData?: InstanceCopyData | undefined;
allowAnyTarget?: boolean;
onPaste: (instance: Instance, props?: UserProp[]) => void;
onPaste: (instance: Instance, props?: PropsItem[]) => void;
onCut: (instance: Instance) => void;
};
+14 -27
View File
@@ -3,8 +3,7 @@ import {
type Tree,
type AllUserProps,
type InstanceProps,
type PropsItem,
Props,
StoredProps,
} from "@webstudio-is/react-sdk";
import { applyPatches, type Patch } from "immer";
import { prisma } from "@webstudio-is/prisma-client";
@@ -19,7 +18,7 @@ export const loadByTreeId = async (treeId: Tree["id"]) => {
return [];
}
const props = Props.parse(JSON.parse(tree.props));
const props = StoredProps.parse(JSON.parse(tree.props));
// Fin all assetId in all props
const assetIds: string[] = [];
@@ -64,7 +63,8 @@ export const loadByTreeId = async (treeId: Tree["id"]) => {
if (asset) {
instanceProps.props.push({
id: propsItem.id,
prop: propsItem.name,
instanceId: propsItem.instanceId,
name: propsItem.name,
required: propsItem.required,
type: propsItem.type,
value: asset,
@@ -77,13 +77,7 @@ export const loadByTreeId = async (treeId: Tree["id"]) => {
continue;
}
instanceProps.props.push({
id: propsItem.id,
prop: propsItem.name,
required: propsItem.required,
type: propsItem.type,
value: propsItem.value,
} as InstanceProps["props"][number]);
instanceProps.props.push(propsItem);
}
return Array.from(instancePropsMap.values());
@@ -107,28 +101,21 @@ export const patch = async (
patches
);
const props: Props = [];
const props: StoredProps = [];
for (const [instanceId, instanceProps] of Object.entries(nextProps)) {
for (const prop of instanceProps) {
if (prop.type === "asset") {
for (const propsItem of instanceProps) {
if (propsItem.type === "asset") {
props.push({
id: prop.id,
id: propsItem.id,
instanceId,
name: prop.prop,
required: prop.required,
type: prop.type,
value: prop.value.id,
name: propsItem.name,
required: propsItem.required,
type: propsItem.type,
value: propsItem.value.id,
});
continue;
}
props.push({
id: prop.id,
instanceId,
name: prop.prop,
required: prop.required,
type: prop.type,
value: prop.value,
} as PropsItem);
props.push(propsItem);
}
}
@@ -1,5 +1,5 @@
import type { UserProp } from "@webstudio-is/react-sdk";
import type { PropsItem } from "@webstudio-is/react-sdk";
import ObjectId from "bson-objectid";
export const cloneUserProps = (props: Array<UserProp>): Array<UserProp> =>
export const cloneUserProps = (props: Array<PropsItem>): Array<PropsItem> =>
props.map((prop) => ({ ...prop, id: ObjectId().toString() }));
+22 -2
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { Asset } from "@webstudio-is/asset-uploader";
const basePropsItem = {
id: z.string(),
@@ -7,7 +8,7 @@ const basePropsItem = {
required: z.optional(z.boolean()),
};
export const PropsItem = z.discriminatedUnion("type", [
const SharedPropsItem = z.union([
z.object({
...basePropsItem,
type: z.literal("number"),
@@ -23,14 +24,33 @@ export const PropsItem = z.discriminatedUnion("type", [
type: z.literal("boolean"),
value: z.boolean(),
}),
]);
export const StoredPropsItem = z.union([
SharedPropsItem,
z.object({
...basePropsItem,
type: z.literal("asset"),
// In database we hold asset.id
// asset.id is stored in database
value: z.string(),
}),
]);
export type StoredPropsItem = z.infer<typeof StoredPropsItem>;
export const StoredProps = z.array(StoredPropsItem);
export type StoredProps = z.infer<typeof StoredProps>;
export const PropsItem = z.union([
SharedPropsItem,
z.object({
...basePropsItem,
type: z.literal("asset"),
value: Asset,
}),
]);
export type PropsItem = z.infer<typeof PropsItem>;
export const Props = z.array(PropsItem);
+2 -3
View File
@@ -1,7 +1,6 @@
import type { InstanceProps as DbInstanceProps } from "@webstudio-is/prisma-client";
import type { UserProp } from "../user-props";
import type { Instance } from "./instance";
import type { Props } from "./props";
import type { Props, PropsItem } from "./props";
import type { PresetStyles, Styles } from "./style";
export type Tree = {
@@ -13,5 +12,5 @@ export type Tree = {
};
export type InstanceProps = Omit<DbInstanceProps, "props"> & {
props: Array<UserProp>;
props: Array<PropsItem>;
};
@@ -1,11 +1,10 @@
import { useRef } from "react";
import { atom } from "nanostores";
import { useStore } from "@nanostores/react";
import type { InstanceProps, Instance } from "../db";
import { UserProp } from "./schema";
import type { InstanceProps, Instance, PropsItem } from "../db";
export type AllUserProps = {
[id: Instance["id"]]: UserProp[];
[id: Instance["id"]]: PropsItem[];
};
export const allUserPropsContainer = atom<AllUserProps>({});
@@ -1,3 +1,2 @@
export * from "./all-user-props";
export * from "./use-user-props";
export * from "./schema";
@@ -1,35 +0,0 @@
import { z } from "zod";
import { Asset } from "@webstudio-is/asset-uploader";
const baseUserProps = {
id: z.string(),
prop: z.string(),
required: z.optional(z.boolean()),
};
export const UserProp = z.discriminatedUnion("type", [
z.object({
...baseUserProps,
type: z.literal("number"),
value: z.number(),
}),
z.object({
...baseUserProps,
type: z.literal("string"),
value: z.string(),
}),
z.object({
...baseUserProps,
type: z.literal("boolean"),
value: z.boolean(),
}),
z.object({
...baseUserProps,
type: z.literal("asset"),
value: Asset,
}),
]);
export const UserProps = z.array(UserProp);
export type UserProp = z.infer<typeof UserProp>;
@@ -1,6 +1,5 @@
import { useMemo } from "react";
import { type Instance } from "../db";
import { type UserProp } from "./schema";
import { PropsItem, type Instance } from "../db";
import { useAllUserProps } from "./all-user-props";
import type { Asset } from "@webstudio-is/asset-uploader";
@@ -9,7 +8,7 @@ import type { Asset } from "@webstudio-is/asset-uploader";
*/
export const useUserPropsAsset = (
instanceId: Instance["id"],
propName: UserProp["prop"]
propName: PropsItem["name"]
): Asset | undefined => {
const allUserProps = useAllUserProps();
@@ -18,7 +17,7 @@ export const useUserPropsAsset = (
if (propsData == null) {
return undefined;
}
const prop = propsData.find((prop) => prop.prop === propName);
const prop = propsData.find((prop) => prop.name === propName);
if (prop == null) {
return undefined;
@@ -1,9 +1,8 @@
import { useMemo } from "react";
import { type Instance } from "../db";
import { type UserProp } from "./schema";
import type { Instance, PropsItem } from "../db";
import { useAllUserProps } from "./all-user-props";
type UserProps = { [prop: UserProp["prop"]]: string | number | boolean };
type UserProps = { [name: PropsItem["name"]]: string | number | boolean };
/**
* User props mapped in prop:value format,
@@ -21,7 +20,7 @@ export const useUserProps = (instanceId: Instance["id"]) => {
for (const userProp of propsData) {
if (userProp.type !== "asset") {
result[userProp.prop] = userProp.value;
result[userProp.name] = userProp.value;
}
}