From 7b083786f2b21fc6134c9700f3f41e498a3f30c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dcaro=20Guerra?= Date: Tue, 17 Dec 2024 16:36:26 -0300 Subject: [PATCH] feat(server-actions): add UI for editing server actions in studio GitOrigin-RevId: 9683b8caa9c7535416b01f7fb397ad9bc4c1f1c9 --- .../wab/client/components/canvas/site-ops.tsx | 45 +- .../sidebar-tabs/PageTab/PageTab.tsx | 22 +- .../ServerQuery/ServerQueryBottomModal.tsx | 124 +++++ .../ServerQueryOpPicker.module.scss | 50 ++ .../ServerQuery/ServerQueryOpPicker.tsx | 491 ++++++++++++++++++ .../sidebar-tabs/server-queries-section.tsx | 182 +++++++ platform/wab/src/wab/shared/TplMgr.ts | 9 + .../src/wab/shared/codegen/react-p/index.ts | 308 ++++++----- .../shared/codegen/react-p/serialize-utils.ts | 11 + platform/wab/src/wab/shared/refactoring.ts | 25 +- 10 files changed, 1120 insertions(+), 147 deletions(-) create mode 100644 platform/wab/src/wab/client/components/sidebar-tabs/ServerQuery/ServerQueryBottomModal.tsx create mode 100644 platform/wab/src/wab/client/components/sidebar-tabs/ServerQuery/ServerQueryOpPicker.module.scss create mode 100644 platform/wab/src/wab/client/components/sidebar-tabs/ServerQuery/ServerQueryOpPicker.tsx create mode 100644 platform/wab/src/wab/client/components/sidebar-tabs/server-queries-section.tsx diff --git a/platform/wab/src/wab/client/components/canvas/site-ops.tsx b/platform/wab/src/wab/client/components/canvas/site-ops.tsx index 37f3e6d032d..a6d0901d570 100644 --- a/platform/wab/src/wab/client/components/canvas/site-ops.tsx +++ b/platform/wab/src/wab/client/components/canvas/site-ops.tsx @@ -142,6 +142,7 @@ import { Component, ComponentArena, ComponentDataQuery, + ComponentServerQuery, ComponentVariantGroup, GlobalVariantGroup, ImageAsset, @@ -1224,7 +1225,7 @@ export class SiteOps { async removeComponentQuery(component: Component, query: ComponentDataQuery) { const refs = findExprsInComponent(component).filter(({ expr }) => - isQueryUsedInExpr(query, expr) + isQueryUsedInExpr(query.name, expr) ); if (refs.length > 0) { const viewCtx = this.studioCtx.focusedViewCtx(); @@ -1264,6 +1265,48 @@ export class SiteOps { ); } + async removeComponentServerQuery( + component: Component, + query: ComponentServerQuery + ) { + const refs = findExprsInComponent(component).filter(({ expr }) => + isQueryUsedInExpr(query.name, expr) + ); + if (refs.length > 0) { + const viewCtx = this.studioCtx.focusedViewCtx(); + const maybeNode = refs.find((r) => r.node)?.node; + const key = mkUuid(); + notification.error({ + key, + message: `Cannot delete server query`, + description: ( + <> + It is referenced in the current component.{" "} + {viewCtx?.component === component && maybeNode ? ( + { + viewCtx.setStudioFocusByTpl(maybeNode); + notification.close(key); + }} + > + [Go to reference] + + ) : null} + + ), + }); + return; + } + + await this.studioCtx.changeObserved( + () => [], + ({ success }) => { + this.tplMgr.removeComponentServerQuery(component, query); + return success(); + } + ); + } + async removeVariantGroup(component: Component, group: ComponentVariantGroup) { const really = await this.confirmDeleteVariantGroup(group, component, { confirm: "if-referenced", diff --git a/platform/wab/src/wab/client/components/sidebar-tabs/PageTab/PageTab.tsx b/platform/wab/src/wab/client/components/sidebar-tabs/PageTab/PageTab.tsx index ee168ae912b..d1f35188f7d 100644 --- a/platform/wab/src/wab/client/components/sidebar-tabs/PageTab/PageTab.tsx +++ b/platform/wab/src/wab/client/components/sidebar-tabs/PageTab/PageTab.tsx @@ -1,14 +1,15 @@ /** @format */ import PageSettings from "@/wab/client/components/PageSettings"; -import { ComponentDataQueriesSection } from "@/wab/client/components/sidebar-tabs/component-data-queries-section"; import S from "@/wab/client/components/sidebar-tabs/ComponentTab/ComponentTab.module.scss"; import PageMetaPanel from "@/wab/client/components/sidebar-tabs/PageMetaPanel"; import { PageMinRoleSection } from "@/wab/client/components/sidebar-tabs/PageMinRoleSection"; import PageURLParametersSection from "@/wab/client/components/sidebar-tabs/PageURLParametersSection"; import VariablesSection from "@/wab/client/components/sidebar-tabs/StateManagement/VariablesSection"; -import { NamedPanelHeader } from "@/wab/client/components/sidebar/sidebar-helpers"; +import { ComponentDataQueriesSection } from "@/wab/client/components/sidebar-tabs/component-data-queries-section"; +import { ServerQueriesSection } from "@/wab/client/components/sidebar-tabs/server-queries-section"; import { SidebarSection } from "@/wab/client/components/sidebar/SidebarSection"; +import { NamedPanelHeader } from "@/wab/client/components/sidebar/sidebar-helpers"; import { TopModal } from "@/wab/client/components/studio/TopModal"; import { VariantsPanel } from "@/wab/client/components/variants/VariantsPanel"; import { Icon } from "@/wab/client/components/widgets/Icon"; @@ -17,8 +18,8 @@ import GearIcon from "@/wab/client/plasmic/plasmic_kit/PlasmicIcon__Gear"; import PageIcon from "@/wab/client/plasmic/plasmic_kit_design_system/icons/PlasmicIcon__Page"; import { StudioCtx } from "@/wab/client/studio-ctx/StudioCtx"; import { ViewCtx } from "@/wab/client/studio-ctx/view-ctx"; -import { PageComponent } from "@/wab/shared/core/components"; import { PublicStyleSection } from "@/wab/shared/ApiSchema"; +import { PageComponent } from "@/wab/shared/core/components"; import { canEditStyleSection } from "@/wab/shared/ui-config-utils"; import { observer } from "mobx-react"; import React from "react"; @@ -123,11 +124,18 @@ export const PageTab = observer(function PageTab(props: { )} {canEdit(PublicStyleSection.DataQueries) && ( - + <> + + + )} + {canEdit(PublicStyleSection.States) && ( )} diff --git a/platform/wab/src/wab/client/components/sidebar-tabs/ServerQuery/ServerQueryBottomModal.tsx b/platform/wab/src/wab/client/components/sidebar-tabs/ServerQuery/ServerQueryBottomModal.tsx new file mode 100644 index 00000000000..f31a9b0c933 --- /dev/null +++ b/platform/wab/src/wab/client/components/sidebar-tabs/ServerQuery/ServerQueryBottomModal.tsx @@ -0,0 +1,124 @@ +import { useBottomModalActions } from "@/wab/client/components/BottomModal"; +import { DataPickerTypesSchema } from "@/wab/client/components/sidebar-tabs/DataBinding/DataPicker"; +import { ServerQueryOpExprFormAndPreview } from "@/wab/client/components/sidebar-tabs/ServerQuery/ServerQueryOpPicker"; +import { extractDataCtx } from "@/wab/client/state-management/interactions-meta"; +import { ViewCtx } from "@/wab/client/studio-ctx/view-ctx"; +import { ExprCtx } from "@/wab/shared/core/exprs"; +import { EventHandlerKeyType } from "@/wab/shared/core/tpls"; +import { + ComponentServerQuery, + CustomFunctionExpr, + Interaction, + TplNode, +} from "@/wab/shared/model/classes"; +import { observer } from "mobx-react"; +import * as React from "react"; + +interface ServerQueryOpExprBottomModalContentProps { + value?: CustomFunctionExpr; + onSave: (expr: CustomFunctionExpr, opExprName?: string) => unknown; + onCancel: () => unknown; + readOnly?: boolean; + readOpsOnly?: boolean; + env?: Record; + allowedOps?: string[]; + livePreview?: boolean; + exprCtx: ExprCtx; + interaction?: Interaction; + viewCtx?: ViewCtx; + tpl?: TplNode; + schema?: DataPickerTypesSchema; + parent?: ComponentServerQuery | TplNode; + eventHandlerKey?: EventHandlerKeyType; +} + +/** For managing a single query modal with a known query key. */ +export function useServerQueryBottomModal(queryKey: string) { + const serverQueryModals = useServerQueryBottomModals(); + return { + open: ( + props: { + title?: string; + } & ServerQueryOpExprBottomModalContentProps + ) => { + serverQueryModals.open(queryKey, props); + }, + close: () => { + serverQueryModals.close(queryKey); + }, + }; +} + +/** For managing multiple query modals or an unknown/dynamic query. */ +export function useServerQueryBottomModals() { + // const ctx = useDataSourceOpPickerContext(); + const modalActions = useBottomModalActions(); + return { + open: ( + queryKey: string, + { + title, + ...props + }: { + title?: string; + } & ServerQueryOpExprBottomModalContentProps + ) => { + modalActions.open(queryKey, { + title: title || `Configure server query`, + children: , + }); + }, + close: (queryKey: string) => { + modalActions.close(queryKey); + }, + }; +} + +const ServerQueryOpExprBottomModalContent = observer( + function ServerQueryOpExprBottomModalContent({ + value, + onSave, + onCancel, + readOnly, + readOpsOnly, + schema, + parent, + allowedOps, + livePreview, + interaction, + exprCtx, + viewCtx, + tpl, + eventHandlerKey, + ...rest + }: ServerQueryOpExprBottomModalContentProps) { + const wrappedOnSave = React.useCallback( + (newExpr: CustomFunctionExpr, opExprName?: string) => { + onSave(newExpr, opExprName); + }, + [onSave] + ); + + const env = rest.env + ? rest.env + : viewCtx && tpl + ? extractDataCtx(viewCtx, tpl, undefined, interaction, eventHandlerKey) + : undefined; + + return ( + + ); + } +); diff --git a/platform/wab/src/wab/client/components/sidebar-tabs/ServerQuery/ServerQueryOpPicker.module.scss b/platform/wab/src/wab/client/components/sidebar-tabs/ServerQuery/ServerQueryOpPicker.module.scss new file mode 100644 index 00000000000..7882f193247 --- /dev/null +++ b/platform/wab/src/wab/client/components/sidebar-tabs/ServerQuery/ServerQueryOpPicker.module.scss @@ -0,0 +1,50 @@ +@import "src/wab/styles/tokens"; +@import "../../../../styles/_vars.sass"; + +.trashIcon { + color: $neutral-secondary; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + cursor: pointer; + width: 15px; + height: 15px; + + &:hover { + color: $neutral-primary; + } +} + +.stringDictKeyInput { + max-width: 34%; +} +.dataSourceExprValue { + // !important to override `.flex > *` + min-width: 100% !important; + max-width: 4vw; + text-overflow: ellipsis; +} +.container { + user-select: text; + background: #fafafa; +} + +.blueIndicatorContainer { + width: 12px; + height: 12px; + display: grid; + justify-items: center; + align-items: center; + margin-right: 4px; + flex-shrink: 0; +} + +.blueIndicator { + border-radius: 50%; + height: 4px; + width: 4px; + z-index: 10; + box-shadow: 0 0 0 2px white; + background: $indicator-set; +} diff --git a/platform/wab/src/wab/client/components/sidebar-tabs/ServerQuery/ServerQueryOpPicker.tsx b/platform/wab/src/wab/client/components/sidebar-tabs/ServerQuery/ServerQueryOpPicker.tsx new file mode 100644 index 00000000000..61fba78bd2f --- /dev/null +++ b/platform/wab/src/wab/client/components/sidebar-tabs/ServerQuery/ServerQueryOpPicker.tsx @@ -0,0 +1,491 @@ +import { BottomModalButtons } from "@/wab/client/components/BottomModal"; +import { StringPropEditor } from "@/wab/client/components/sidebar-tabs/ComponentProps/StringPropEditor"; +import { DataPickerTypesSchema } from "@/wab/client/components/sidebar-tabs/DataBinding/DataPicker"; +import { + InnerPropEditorRow, + PropValueEditorContext, +} from "@/wab/client/components/sidebar-tabs/PropEditorRow"; +import { LabeledItemRow } from "@/wab/client/components/sidebar/sidebar-helpers"; +import StyleSelect from "@/wab/client/components/style-controls/StyleSelect"; +import Button from "@/wab/client/components/widgets/Button"; +import { Icon } from "@/wab/client/components/widgets/Icon"; +import SearchIcon from "@/wab/client/plasmic/plasmic_kit/PlasmicIcon__Search"; +import { StudioCtx, useStudioCtx } from "@/wab/client/studio-ctx/StudioCtx"; +import { TutorialEventsType } from "@/wab/client/tours/tutorials/tutorials-events"; +import { + customFunctionId, + wabTypeToPropType, +} from "@/wab/shared/code-components/code-components"; +import { + cx, + ensure, + mkShortId, + spawn, + swallow, + withoutFalsy, +} from "@/wab/shared/common"; +import { ExprCtx, codeLit, getRawCode } from "@/wab/shared/core/exprs"; +import { + ComponentServerQuery, + CustomFunctionExpr, + FunctionArg, + Interaction, + TplNode, + isKnownComponentServerQuery, + isKnownExpr, +} from "@/wab/shared/model/classes"; +import { smartHumanize } from "@/wab/shared/strs"; +import { notification } from "antd"; +import { groupBy } from "lodash"; +import { observer } from "mobx-react"; +import * as React from "react"; +import { useMountedState } from "react-use"; + +import styles from "@/wab/client/components/sidebar-tabs/ServerQuery/ServerQueryOpPicker.module.scss"; +import { Tab, Tabs } from "@/wab/client/components/widgets"; +import { tryEvalExpr } from "@/wab/shared/eval"; + +const LazyCodePreview = React.lazy( + () => import("@/wab/client/components/coding/CodePreview") +); + +interface CustomFunctionExprDraft extends Partial { + queryName?: string; +} + +export function ServerQueryOpDraftForm(props: { + value?: CustomFunctionExprDraft; + onChange: React.Dispatch>; // (value: DataSourceOpDraftValue) => void; + readOnly?: boolean; + env: Record | undefined; + schema?: DataPickerTypesSchema; + onUpdateSourceFetchError?: (error: Error | undefined) => void; + isDisabled?: boolean; + showQueryName?: boolean; + /** + * Whether only read operations are allowed + */ + readOpsOnly?: boolean; + allowedOps?: string[]; + exprCtx: ExprCtx; +}) { + const { + value, + isDisabled, + onChange, + readOnly, + env: data, + schema, + readOpsOnly, + onUpdateSourceFetchError, + allowedOps, + showQueryName, + exprCtx, + } = props; + const studioCtx = useStudioCtx(); + const availableCustomFunction = studioCtx.site.customFunctions; + const propValueEditorContext = React.useMemo(() => { + return { + componentPropValues: {}, + ccContextData: {}, + exprCtx, + schema, + env: data, + }; + }, [schema, data, exprCtx]); + + React.useEffect(() => { + if (availableCustomFunction.length > 0 && !value?.func) { + onChange({ + ...value, + func: availableCustomFunction[0], + args: [], + }); + } + }, [value, availableCustomFunction]); + + const argsMap = groupBy(value?.args ?? [], (arg) => arg.argType.argName); + + return ( +
+ {showQueryName && ( + + onChange({ ...value, queryName: newName })} + /> + + )} + + { + if (value?.func && id === customFunctionId(value.func)) { + return; + } + onChange({ + ...value, + func: availableCustomFunction.find( + (fn) => customFunctionId(fn) === id + ), + args: [], + }); + }} + > + {availableCustomFunction.map((fn) => { + const functionId = customFunctionId(fn); + return ( + + {smartHumanize(functionId)} + + ); + })} + + + {value?.func && ( + <> + {value.func.params.map((param) => { + const argLabel = param.displayName ?? smartHumanize(param.argName); + const curArg = + param.argName in argsMap ? argsMap[param.argName][0] : undefined; + const curExpr = curArg?.expr; + + return ( + + { + if (expr == null) { + return; + } + const newExpr = isKnownExpr(expr) ? expr : codeLit(expr); + const newArgs = [...(value?.args ?? [])]; + const changedArg = newArgs.find( + (arg) => arg.argType === curArg?.argType + ); + if (changedArg) { + changedArg.expr = newExpr; + } else { + newArgs.push( + new FunctionArg({ + uuid: mkShortId(), + expr: newExpr, + argType: param, + }) + ); + } + + onChange({ + ...value, + args: newArgs, + }); + }} + /> + + ); + })} + + )} +
+ ); +} + +function _ServerQueryOpPreview(props: { + executeQueue: CustomFunctionExpr[]; + setExecuteQueue: React.Dispatch>; + env?: Record; + exprCtx: ExprCtx; +}) { + const { executeQueue, setExecuteQueue, env, exprCtx } = props; + const studioCtx = useStudioCtx(); + const [mutateOpResults, setMutateOpResults] = React.useState( + undefined + ); + const [expandLevel, setExpandLevel] = React.useState(3); + + const opResults = mutateOpResults; + + const popExecuteQueue = React.useCallback(async () => { + if (executeQueue.length > 0) { + const [nextOp, ...rest] = executeQueue; + try { + const result = await executeCustomFunctionOp( + studioCtx, + nextOp, + env, + exprCtx + ); + setMutateOpResults(result); + } catch (err) { + notification.error({ + message: `Operation failed`, + description: err.message, + }); + } + setExecuteQueue(rest); + } + }, [executeQueue, setExecuteQueue]); + + React.useEffect(() => { + spawn(popExecuteQueue()); + }, [executeQueue, setExecuteQueue]); + + const extraContent = React.useMemo(() => { + return ( + + ); + }, []); + + return ( +
+ ( + + {opResults != null ? ( + + ) : ( + "Waiting for execution" + )} + + ), + }), + ])} + /> +
+ ); +} + +export const ServerQueryOpPreview = React.memo(_ServerQueryOpPreview); + +export const ServerQueryOpExprFormAndPreview = observer( + function ServerQueryOpExprFormAndPreview(props: { + value?: CustomFunctionExpr; + onSave: (value: CustomFunctionExpr, opExprName?: string) => void; + onCancel: () => void; + readOnly?: boolean; + env: Record | undefined; + schema?: DataPickerTypesSchema; + parent?: ComponentServerQuery | TplNode; + /** + * Whether only read operations are allowed + */ + readOpsOnly?: boolean; + allowedOps?: string[]; + exprCtx: ExprCtx; + interaction?: Interaction; + }) { + const { + value, + onSave, + onCancel, + readOnly, + env, + schema, + parent, + readOpsOnly, + allowedOps, + exprCtx, + } = props; + const studioCtx = useStudioCtx(); + const isMounted = useMountedState(); + const [draft, setDraft] = React.useState(() => ({ + queryName: isKnownComponentServerQuery(parent) ? parent.name : undefined, + ...(value ?? {}), + })); + const [sourceFetchError, setSourceFetchError] = React.useState< + Error | undefined + >(undefined); + const [isExecuting, setIsExecuting] = React.useState(false); + const [executeQueue, setExecuteQueue] = React.useState< + CustomFunctionExpr[] + >([]); + + const missingRequiredArgs = []; + // const missingRequiredArgs = getMissingRequiredArgsFromDraft( + // draft, + // exprCtx + // ).map(([argName, argMeta]) => getArgLabel(argMeta, argName)); + + const saveOpExpr = async () => { + if (isMounted()) { + if (missingRequiredArgs.length === 0) { + onSave( + new CustomFunctionExpr({ + func: draft.func!, + args: draft.args!, + }), + draft.queryName + ); + + studioCtx.tourActionEvents.dispatch({ + type: TutorialEventsType.SaveDataSourceQuery, + }); + } else { + notification.error({ + message: `Missing required fields: ${missingRequiredArgs.join( + ", " + )}`, + }); + } + } + }; + + const contents = ( +
+
+
+
+ { + setDraft((old) => { + const newDraft = + typeof getNewDraft === "function" + ? getNewDraft(old) + : getNewDraft; + return newDraft; + }); + }} + readOnly={readOnly} + env={env} + schema={schema} + isDisabled={readOnly} + readOpsOnly={readOpsOnly} + onUpdateSourceFetchError={setSourceFetchError} + allowedOps={allowedOps} + showQueryName={isKnownComponentServerQuery(parent)} + exprCtx={exprCtx} + /> +
+ + + + + +
+ +
+
+ ); + return contents; + } +); + +export async function executeCustomFunctionOp( + studioCtx: StudioCtx, + expr: CustomFunctionExpr, + env: Record | undefined, + exprCtx: ExprCtx +) { + const { func, args } = expr; + const functionId = customFunctionId(func); + const argsMap = groupBy(args, (arg) => arg.argType.argName); + const regFunc = ensure( + studioCtx.getRegisteredFunctionsMap().get(functionId), + "Missing registered function for server query" + ); + const argLits = + func.params.map((param) => { + if (argsMap[param.argName]) { + return ( + swallow( + () => + tryEvalExpr( + getRawCode(argsMap[param.argName][0].expr, exprCtx), + env ?? {} + )?.val + ) ?? undefined + ); + } + return undefined; + }) ?? []; + try { + const serverData = await regFunc.function(...argLits); + + return serverData; + } catch (err) { + return { error: err }; + } +} diff --git a/platform/wab/src/wab/client/components/sidebar-tabs/server-queries-section.tsx b/platform/wab/src/wab/client/components/sidebar-tabs/server-queries-section.tsx new file mode 100644 index 00000000000..fe1434ec5c1 --- /dev/null +++ b/platform/wab/src/wab/client/components/sidebar-tabs/server-queries-section.tsx @@ -0,0 +1,182 @@ +import { WithContextMenu } from "@/wab/client/components/ContextMenu"; +import { useServerQueryBottomModal } from "@/wab/client/components/sidebar-tabs/ServerQuery/ServerQueryBottomModal"; +import { SidebarSection } from "@/wab/client/components/sidebar/SidebarSection"; +import { IconLinkButton } from "@/wab/client/components/widgets"; +import { DataQueriesTooltip } from "@/wab/client/components/widgets/DetailedTooltips"; +import { Icon } from "@/wab/client/components/widgets/Icon"; +import LabeledListItem from "@/wab/client/components/widgets/LabeledListItem"; +import { LabelWithDetailedTooltip } from "@/wab/client/components/widgets/LabelWithDetailedTooltip"; +import PlusIcon from "@/wab/client/plasmic/plasmic_kit/PlasmicIcon__Plus"; +import { useStudioCtx } from "@/wab/client/studio-ctx/StudioCtx"; +import { ViewCtx } from "@/wab/client/studio-ctx/view-ctx"; +import { toVarName } from "@/wab/shared/codegen/util"; +import { mkShortId, spawn, uniqueName } from "@/wab/shared/common"; +import { isPageComponent } from "@/wab/shared/core/components"; +import { + Component, + ComponentServerQuery, + CustomFunctionExpr, +} from "@/wab/shared/model/classes"; +import { renameServerQueryAndFixExprs } from "@/wab/shared/refactoring"; +import { Menu } from "antd"; +import { observer } from "mobx-react"; +import React from "react"; + +const ServerQueryRow = observer( + (props: { + component: Component; + query: ComponentServerQuery; + viewCtx: ViewCtx; + }) => { + const { component, query, viewCtx } = props; + const studioCtx = viewCtx.studioCtx; + const exprCtx = { + projectFlags: studioCtx.projectFlags(), + component, + inStudio: true, + }; + // For some reason calling `omit` tries to read from the query data, + // throwing `PlasmicUndefinedDataError` + const env = { + ...viewCtx.getCanvasEnvForTpl(viewCtx.currentCtxTplRoot(), { + forDataRepCollection: true, + }), + }; + if (env.$queries) { + env.$queries = { ...env.$queries }; + delete env.$queries[toVarName(query.name)]; + } + const schema = viewCtx.customFunctionsSchema(); + + const serverQueryModal = useServerQueryBottomModal(query.uuid); + const openServerQueryModal = () => { + serverQueryModal.open({ + value: query.op ?? undefined, + onSave: handleDataSourceOpChange, + onCancel: serverQueryModal.close, + env, + schema, + exprCtx, + parent: query, + }); + }; + + const handleDataSourceOpChange = async ( + newOp: CustomFunctionExpr, + opExprName?: string + ) => { + await studioCtx.change(({ success }) => { + query.op = newOp; + if (opExprName && opExprName !== query.name) { + renameServerQueryAndFixExprs(component, query, opExprName); + } + return success(); + }); + serverQueryModal.close(); + }; + + const menu = () => { + return ( + + openServerQueryModal()}> + Configure server query + + + + studioCtx.siteOps().removeComponentServerQuery(component, query) + } + > + Remove server query + + + ); + }; + + return ( + + openServerQueryModal()} + > + {query.op ? ( +
+ {query.name} + {/* */} + {/* */} +
+ ) : ( +
Click to configure...
+ )} +
+
+ ); + } +); + +function ServerQueriesSection_(props: { + component: Component; + viewCtx: ViewCtx; +}) { + const { component, viewCtx } = props; + const studioCtx = useStudioCtx(); + + const componentType = isPageComponent(component) ? "page" : "component"; + + const handleAddDataQuery = () => { + spawn( + studioCtx.change(({ success }) => { + const serverQuery = new ComponentServerQuery({ + uuid: mkShortId(), + name: toVarName( + uniqueName( + component.serverQueries.map((q) => q.name), + "query", + { + normalize: toVarName, + } + ) + ), + op: undefined, + }); + + component.serverQueries.push(serverQuery); + return success(); + }) + ); + }; + + return ( + + Server queries + + } + emptyBody={component.serverQueries.length === 0} + zeroBodyPadding + controls={ + + + + } + > + {component.serverQueries.map((query) => ( + + ))} + + ); +} + +export const ServerQueriesSection = observer(ServerQueriesSection_); diff --git a/platform/wab/src/wab/shared/TplMgr.ts b/platform/wab/src/wab/shared/TplMgr.ts index 22e3c679d2f..3b54ca596f7 100644 --- a/platform/wab/src/wab/shared/TplMgr.ts +++ b/platform/wab/src/wab/shared/TplMgr.ts @@ -211,6 +211,7 @@ import { Component, ComponentArena, ComponentDataQuery, + ComponentServerQuery, ComponentVariantGroup, Expr, GlobalVariantGroup, @@ -1261,6 +1262,14 @@ export class TplMgr { this.clearReferencesToRemovedQueries(query.uuid); } + removeComponentServerQuery( + component: Component, + query: ComponentServerQuery + ) { + removeFromArray(component.serverQueries, query); + this.clearReferencesToRemovedQueries(query.uuid); + } + renameArena(arena: Arena, name: string) { arena.name = this.getUniqueArenaName(name, arena); } diff --git a/platform/wab/src/wab/shared/codegen/react-p/index.ts b/platform/wab/src/wab/shared/codegen/react-p/index.ts index 9421ed2263e..2a836ec26a6 100644 --- a/platform/wab/src/wab/shared/codegen/react-p/index.ts +++ b/platform/wab/src/wab/shared/codegen/react-p/index.ts @@ -21,7 +21,10 @@ import { getBuiltinComponentRegistrations, isBuiltinCodeComponent, } from "@/wab/shared/code-components/builtin-code-components"; -import { isCodeComponentWithHelpers } from "@/wab/shared/code-components/code-components"; +import { + customFunctionId, + isCodeComponentWithHelpers, +} from "@/wab/shared/code-components/code-components"; import { isTplRootWithCodeComponentVariants } from "@/wab/shared/code-components/variants"; import { ComponentGenHelper } from "@/wab/shared/codegen/codegen-helpers"; import { @@ -128,6 +131,7 @@ import { makeVariantsArgTypeName, makeWabHtmlTextClassName, maybeCondExpr, + serializeServerQueries, wrapGlobalContexts, wrapGlobalProvider, wrapInDataCtxReader, @@ -2371,6 +2375,164 @@ export function serializeDefaultExternalProps( }`; } +function serializePageAwareSkeletonWrapperTs( + ctx: SerializerBaseContext, + opts: ExportOpts, + componentName: string, + nodeComponentName: string, + plasmicComponentName: string, + componentSubstitutionApi: string +) { + const component = ctx.component; + const isNextjsAppDir = opts.platformOptions?.nextjs?.appDir || false; + + const globalGroups = ctx.site.globalVariantGroups.filter((g) => { + // If we do have splits provider bundle we skip all the global groups associated with splits + if (ctx.projectConfig.splitsProviderBundle) { + return ( + g.variants.length > 0 && + !ctx.site.splits.some((split) => { + return split.slices.some((slice) => { + return slice.contents.some( + (content) => + isKnownGlobalVariantSplitContent(content) && content.group === g + ); + }); + }) + ); + } + return g.variants.length > 0; + }); + + const plasmicModuleImports = [nodeComponentName]; + let content = `<${nodeComponentName} />`, + getStaticProps = "", + componentPropsDecl = "", + componentPropsSig = ""; + if (opts.platform === "nextjs") { + if (isNextjsAppDir) { + componentPropsSig = `{ params, searchParams }: { +params?: Record; +searchParams?: Record; +}`; + content = ` + ${content} + `; + } else { + content = ` + ${content} + `; + } + } else if (opts.platform === "gatsby") { + plasmicModuleImports.push("Head"); + componentPropsSig = `{ location, path, params }: PageProps`; + content = ` + ${content} + `; + } + + let globalContextsImport = ""; + if (opts.wrapPagesWithGlobalContexts && ctx.site.globalContexts.length > 0) { + globalContextsImport = makeGlobalContextsImport(ctx.projectConfig); + content = wrapGlobalContexts(content); + } + + for (const globalGroup of globalGroups) { + content = wrapGlobalProvider(globalGroup, content, false, []); + } + + let globalGroupsComment = ""; + if (globalGroups.length > 0) { + globalGroupsComment = `// + // By default, ${nodeComponentName} is wrapped by your project's global + // variant context providers. These wrappers may be moved to + ${ + opts.platform === "nextjs" + ? `// Next.js Custom App component + // (https://nextjs.org/docs/advanced-features/custom-app).` + : opts.platform === "gatsby" + ? `// Gatsby "wrapRootElement" function + // (https://www.gatsbyjs.com/docs/reference/config-files/gatsby-ssr#wrapRootElement).` + : `// a component that wraps all page components of your application.` + }`; + } + + return ` + // This is a skeleton starter React page generated by Plasmic. + // This file is owned by you, feel free to edit as you see fit. + // plasmic-unformatted + import * as React from "react"; + import { ${getHostNamedImportsForSkeleton()} } from "${getHostPackageName( + opts + )}"; + ${globalContextsImport} + ${makeGlobalGroupImports(globalGroups, opts)} + import {${plasmicModuleImports.join(", ")}} from "${ + opts.relPathFromImplToManagedDir + }/${plasmicComponentName}"; // plasmic-import: ${component.uuid}/render + ${ + isPageComponent(component) && + opts.platform === "nextjs" && + !isNextjsAppDir + ? `import { useRouter } from "next/router";` + : isPageComponent(component) && opts.platform === "gatsby" + ? `import type { PageProps } from "gatsby"; + export { Head };` + : "" + } + + ${component.serverQueries + .map((q) => { + return `import { ${ + q.op!.func.importName + } } from "./importPath__${customFunctionId( + q.op!.func + )}"; // plasmic-import: ${customFunctionId(q.op!.func)}/customFunction`; + }) + .join("\n")} + + ${componentSubstitutionApi} + + ${getStaticProps} + + ${componentPropsDecl} + + ${ + isNextjsAppDir && isPageComponent(component) ? "async " : "" + }function ${componentName}(${componentPropsSig}) { + // Use ${nodeComponentName} to render this component as it was + // designed in Plasmic, by activating the appropriate variants, + // attaching the appropriate event handlers, etc. You + // can also install whatever React hooks you need here to manage state or + // fetch data. + // + // Props you can pass into ${nodeComponentName} are: + // 1. Variants you want to activate, + // 2. Contents for slots you want to fill, + // 3. Overrides for any named node in the component to attach behavior and data, + // 4. Props to set on the root node. + ${globalGroupsComment} + + ${serializeServerQueries(component)} + + return (${content}); + } + + export default ${componentName}; + `; +} + function serializeSkeletonWrapperTs( ctx: SerializerBaseContext, opts: ExportOpts @@ -2427,142 +2589,14 @@ function serializeSkeletonWrapperTs( : ""; if (isPageComponent(component) && isPageAwarePlatform(opts.platform)) { - const isNextjsAppDir = opts.platformOptions?.nextjs?.appDir || false; - - const globalGroups = ctx.site.globalVariantGroups.filter((g) => { - // If we do have splits provider bundle we skip all the global groups associated with splits - if (ctx.projectConfig.splitsProviderBundle) { - return ( - g.variants.length > 0 && - !ctx.site.splits.some((split) => { - return split.slices.some((slice) => { - return slice.contents.some( - (content) => - isKnownGlobalVariantSplitContent(content) && - content.group === g - ); - }); - }) - ); - } - return g.variants.length > 0; - }); - - const plasmicModuleImports = [nodeComponentName]; - let content = `<${nodeComponentName} />`, - getStaticProps = "", - componentPropsDecl = "", - componentPropsSig = ""; - if (opts.platform === "nextjs") { - if (isNextjsAppDir) { - componentPropsSig = `{ params, searchParams }: { - params?: Record; - searchParams?: Record; -}`; - content = ` - ${content} - `; - } else { - content = ` - ${content} - `; - } - } else if (opts.platform === "gatsby") { - plasmicModuleImports.push("Head"); - componentPropsSig = `{ location, path, params }: PageProps`; - content = ` - ${content} - `; - } - - let globalContextsImport = ""; - if ( - opts.wrapPagesWithGlobalContexts && - ctx.site.globalContexts.length > 0 - ) { - globalContextsImport = makeGlobalContextsImport(ctx.projectConfig); - content = wrapGlobalContexts(content); - } - - for (const globalGroup of globalGroups) { - content = wrapGlobalProvider(globalGroup, content, false, []); - } - - let globalGroupsComment = ""; - if (globalGroups.length > 0) { - globalGroupsComment = `// - // By default, ${nodeComponentName} is wrapped by your project's global - // variant context providers. These wrappers may be moved to - ${ - opts.platform === "nextjs" - ? `// Next.js Custom App component - // (https://nextjs.org/docs/advanced-features/custom-app).` - : opts.platform === "gatsby" - ? `// Gatsby "wrapRootElement" function - // (https://www.gatsbyjs.com/docs/reference/config-files/gatsby-ssr#wrapRootElement).` - : `// a component that wraps all page components of your application.` - }`; - } - - return ` - // This is a skeleton starter React page generated by Plasmic. - // This file is owned by you, feel free to edit as you see fit. - // plasmic-unformatted - import * as React from "react"; - import { ${getHostNamedImportsForSkeleton()} } from "${getHostPackageName( - opts - )}"; - ${globalContextsImport} - ${makeGlobalGroupImports(globalGroups, opts)} - import {${plasmicModuleImports.join(", ")}} from "${ - opts.relPathFromImplToManagedDir - }/${plasmicComponentName}"; // plasmic-import: ${component.uuid}/render - ${ - isPageComponent(component) && - opts.platform === "nextjs" && - !isNextjsAppDir - ? `import { useRouter } from "next/router";` - : isPageComponent(component) && opts.platform === "gatsby" - ? `import type { PageProps } from "gatsby"; - export { Head };` - : "" - } - - ${componentSubstitutionApi} - - ${getStaticProps} - - ${componentPropsDecl} - - function ${componentName}(${componentPropsSig}) { - // Use ${nodeComponentName} to render this component as it was - // designed in Plasmic, by activating the appropriate variants, - // attaching the appropriate event handlers, etc. You - // can also install whatever React hooks you need here to manage state or - // fetch data. - // - // Props you can pass into ${nodeComponentName} are: - // 1. Variants you want to activate, - // 2. Contents for slots you want to fill, - // 3. Overrides for any named node in the component to attach behavior and data, - // 4. Props to set on the root node. - ${globalGroupsComment} - return (${content}); - } - - export default ${componentName}; - `; + return serializePageAwareSkeletonWrapperTs( + ctx, + opts, + componentName, + nodeComponentName, + plasmicComponentName, + componentSubstitutionApi + ); } const rootTag = isTplTag(component.tplTree) diff --git a/platform/wab/src/wab/shared/codegen/react-p/serialize-utils.ts b/platform/wab/src/wab/shared/codegen/react-p/serialize-utils.ts index 2f39878e44f..33349ab9619 100644 --- a/platform/wab/src/wab/shared/codegen/react-p/serialize-utils.ts +++ b/platform/wab/src/wab/shared/codegen/react-p/serialize-utils.ts @@ -29,6 +29,7 @@ import { import { CssProjectDependencies } from "@/wab/shared/core/sites"; import { Component, + ComponentServerQuery, ImageAsset, TplNode, Variant, @@ -622,3 +623,13 @@ export const maybeCondExpr = (maybeCond: string, expr: string) => { } return `(${maybeCond}) ? (${expr}) : null`; }; + +export function serializeServerQuery(serverQuery: ComponentServerQuery) { + return `const ${serverQuery.name} = await ${ + serverQuery.op!.func.importName + }();`; +} + +export function serializeServerQueries(component: Component) { + return component.serverQueries.map((q) => serializeServerQuery(q)).join("\n"); +} diff --git a/platform/wab/src/wab/shared/refactoring.ts b/platform/wab/src/wab/shared/refactoring.ts index f0873c591db..4fed7326dbd 100644 --- a/platform/wab/src/wab/shared/refactoring.ts +++ b/platform/wab/src/wab/shared/refactoring.ts @@ -15,6 +15,7 @@ import { import { Component, ComponentDataQuery, + ComponentServerQuery, Expr, Interaction, isKnownCustomCode, @@ -55,7 +56,7 @@ export function isParamUsedInExpr( * Returns boolean indicating whether `expr` is referencing `query`. */ export function isQueryUsedInExpr( - query: ComponentDataQuery, + queryName: string, expr: Expr | null | undefined ) { if (Exprs.isRealCodeExpr(expr)) { @@ -64,7 +65,7 @@ export function isQueryUsedInExpr( "Real code expression must be CustomCode or ObjectPath" ); const info = parseExpr(expr); - const varName = toVarName(query.name); + const varName = toVarName(queryName); return info.usedDollarVarKeys.$queries.has(varName); } return false; @@ -329,3 +330,23 @@ export function renameQueryAndFixExprs( renameObjectInExpr(expr, "$queries", "$queries", oldVarName, newVarName); } } + +export function renameServerQueryAndFixExprs( + component: Component, + query: ComponentServerQuery, + wantedNewName: string +) { + const oldVarName = toVarName(query.name); + query.name = uniqueName( + component.serverQueries.filter((q) => q !== query).map((q) => q.name), + wantedNewName, + { + normalize: toVarName, + } + ); + const newVarName = toVarName(query.name); + const refs = Tpls.findExprsInComponent(component); + for (const { expr } of refs) { + renameObjectInExpr(expr, "$queries", "$queries", oldVarName, newVarName); + } +}