diff --git a/apps/builder/app/builder/features/pages/page-settings.tsx b/apps/builder/app/builder/features/pages/page-settings.tsx index 3dec539ba8..09648fd71a 100644 --- a/apps/builder/app/builder/features/pages/page-settings.tsx +++ b/apps/builder/app/builder/features/pages/page-settings.tsx @@ -55,6 +55,7 @@ import { PanelTitle, TitleSuffixSpacer, FloatingPanelProvider, + ProBadge, } from "@webstudio-is/design-system"; import { ChevronsLeftIcon, @@ -86,11 +87,9 @@ import { ImageControl } from "~/builder/features/project-settings"; import { useEffectEvent } from "~/shared/hook-utils/effect-event"; import { compilePathnamePattern, - isPathnamePattern, tokenizePathnamePattern, validatePathnamePattern, } from "~/builder/shared/url-pattern"; -import type { UserPlanFeatures } from "~/shared/db/user-plan-features.server"; import { useUnmount } from "~/shared/hook-utils/use-mount"; import { selectInstance } from "~/shared/awareness"; import { computeExpression } from "~/shared/data-variables"; @@ -120,7 +119,7 @@ const fieldDefaultValues = { language: `""`, socialImageUrl: `""`, socialImageAssetId: "", - status: `200`, + status: undefined as string | undefined, redirect: `""`, documentType: "html" as (typeof documentTypes)[number], customMetas: [{ property: "", content: `""` }], @@ -201,8 +200,7 @@ const validateValues = ( // undefined page id means new page pageId: undefined | Page["id"], values: Values, - variableValues: Map, - userPlanFeatures: UserPlanFeatures + variableValues: Map ): Errors => { // Null or undefined in the field value is only possible if it’s a dynamic (expression) value. // We do not validate dynamic values but instead provide a default value for validation. @@ -221,7 +219,7 @@ const validateValues = ( ), language: computeExpression(values.language, variableValues), socialImageUrl: computeExpression(values.socialImageUrl, variableValues), - status: computeExpression(values.status, variableValues), + status: computeExpression(values.status ?? `undefined`, variableValues), redirect: computeExpression(values.redirect, variableValues), customMetas: values.customMetas.map((item) => ({ property: item.property, @@ -252,14 +250,6 @@ const validateValues = ( errors.path = errors.path ?? []; errors.path.push(...messages); } - - if ( - userPlanFeatures.allowDynamicData === false && - isPathnamePattern(values.path) - ) { - errors.path = errors.path ?? []; - errors.path.push("Dynamic path is supported only in Pro"); - } } return errors; }; @@ -311,59 +301,47 @@ const PathField = ({ const id = useId(); return ( - {allowDynamicData ? ( - - - - - - - ) : ( - - - - - Path is a subset of the URL that looks like this: - "/blog". - - {allowDynamicData === false && ( - <> - - To make the path dynamic and use it with CMS, you can use - parameters and other features. CMS features are part of - the Pro plan. - - - Upgrade - - - )} - - } - variant="wrapped" - > - - - - )} + + + {allowDynamicData === false && PRO} + + + The path can include dynamic parameters like :name, which could + be made optional using :name?, or have a wildcard such as /* or + /:name* to store whole remaining part at the end of the URL. + + {allowDynamicData === false && ( + <> +
+ + To make the path dynamic and use it with CMS, you can use + parameters and other features. CMS features are part of the + Pro plan. + + + Upgrade + + + )} + + } + variant="wrapped" + > + +
+
void; + value: undefined | string; + onChange: (value: undefined | string) => void; }) => { const id = useId(); const { allowDynamicData } = useStore($userPlanFeatures); @@ -393,6 +371,7 @@ const StatusField = ({ + {allowDynamicData === false && PRO} @@ -417,35 +396,35 @@ const StatusField = ({ - {allowDynamicData && ( - - onChange(JSON.stringify(evaluatedValue ?? "")) - } - /> - )} + + onChange(JSON.stringify(evaluatedValue ?? "")) + } + /> { - const number = Number(event.target.value); - const status = - Number.isNaN(number) || String(number) !== event.target.value - ? event.target.value - : number; - onChange(JSON.stringify(status)); + if (event.target.value === "") { + onChange(undefined); + } else { + const number = Number(event.target.value); + const status = + Number.isNaN(number) || String(number) !== event.target.value + ? event.target.value + : number; + onChange(JSON.stringify(status)); + } }} /> @@ -470,6 +449,7 @@ const RedirectField = ({ + {allowDynamicData === false && PRO} - {allowDynamicData && ( - - onChange(JSON.stringify(evaluatedValue ?? "")) - } - /> - )} + + onChange(JSON.stringify(evaluatedValue ?? "")) + } + /> onChange(JSON.stringify(event.target.value))} /> @@ -1224,15 +1200,8 @@ export const NewPageSettings = ({ ...fieldDefaultValues, path: nameToPath(pages, fieldDefaultValues.name), }); - const userPlanFeatures = useStore($userPlanFeatures); const { variableValues } = useStore($pageRootScope); - const errors = validateValues( - pages, - undefined, - values, - variableValues, - userPlanFeatures - ); + const errors = validateValues(pages, undefined, values, variableValues); const handleSubmit = () => { if (Object.keys(errors).length === 0) { @@ -1376,7 +1345,7 @@ const updatePage = (pageId: Page["id"], values: Partial) => { values.language.length > 0 ? values.language : undefined; } - if (values.status !== undefined) { + if ("status" in values) { page.meta.status = values.status; } @@ -1514,14 +1483,7 @@ export const PageSettings = ({ }; const { variableValues } = useStore($pageRootScope); - const userPlanFeatures = useStore($userPlanFeatures); - const errors = validateValues( - pages, - pageId, - values, - variableValues, - userPlanFeatures - ); + const errors = validateValues(pages, pageId, values, variableValues); const debouncedFn = useEffectEvent(() => { if ( diff --git a/apps/builder/app/builder/features/project-settings/section-general.tsx b/apps/builder/app/builder/features/project-settings/section-general.tsx index dd63eb643a..23fe7518df 100644 --- a/apps/builder/app/builder/features/project-settings/section-general.tsx +++ b/apps/builder/app/builder/features/project-settings/section-general.tsx @@ -61,6 +61,9 @@ const validateContactEmail = ( } const emails = contactEmail.split(/\s*,\s*/); if (emails.length > maxContactEmails) { + if (maxContactEmails === 0) { + return `Upgrade to PRO to customize the contact email.`; + } return `Only ${maxContactEmails} emails are allowed.`; } if (emails.every((email) => Email.safeParse(email).success) === false) { @@ -170,7 +173,6 @@ export const SectionGeneral = () => { id={contactEmailId} color={contactEmailError ? "error" : undefined} placeholder="john@company.com, jane@company.com" - disabled={allowContactEmail === false} autoGrow={true} rows={1} value={meta.contactEmail ?? ""} diff --git a/apps/builder/app/builder/features/settings-panel/variable-popover.tsx b/apps/builder/app/builder/features/settings-panel/variable-popover.tsx index 5df809cec1..d3ae0c3ad8 100644 --- a/apps/builder/app/builder/features/settings-panel/variable-popover.tsx +++ b/apps/builder/app/builder/features/settings-panel/variable-popover.tsx @@ -558,11 +558,10 @@ JsonForm.displayName = "JsonForm"; const VariablePanel = forwardRef< undefined | PanelApi, - { - variable?: DataSource; - } + { variable?: DataSource } >(({ variable }, ref) => { const resources = useStore($resources); + const { allowDynamicData } = useStore($userPlanFeatures); const [variableType, setVariableType] = useState(() => { if (variable?.type === "resource") { @@ -588,60 +587,37 @@ const VariablePanel = forwardRef< return "string"; }); + let fields: ReactNode; if (variableType === "parameter") { - return ( - <> - - - - ); + fields = ; } if (variableType === "string") { - return ( + fields = ( <> - ); } if (variableType === "number") { - return ( + fields = ( <> - ); } if (variableType === "boolean") { - return ( + fields = ( <> - ); } if (variableType === "json") { - return ( + fields = ( <> - @@ -649,47 +625,70 @@ const VariablePanel = forwardRef< } if (variableType === "resource") { - return ( + fields = ( <> - ); } - if (variableType === "graphql-resource") { - return ( + fields = ( <> - ); } - if (variableType === "system-resource") { - return ( + fields = ( <> - ); } - variableType satisfies never; + const isResource = + variableType === "resource" || + variableType === "graphql-resource" || + variableType === "system-resource"; + const requiresUpgrade = allowDynamicData === false && isResource; + return ( + <> + {requiresUpgrade && ( + + Resource fetching is part of the CMS functionality. + + + + Upgrade to Pro + + + + )} + + + {fields} + + + ); }); -VariablePanel.displayName = "VariablePanel"; +VariablePanel.displayName = "VariableForm"; const VariablePopoverContext = createContext<{ containerRef?: RefObject; @@ -733,9 +732,6 @@ export const VariablePopoverTrigger = ({ const panelRef = useRef(undefined); const formRef = useRef(null); const resources = useStore($resources); - const { allowDynamicData } = useStore($userPlanFeatures); - const [isResource, setIsResource] = useState(variable?.type === "resource"); - const requiresUpgrade = allowDynamicData === false && isResource; const isSystemVariable = variable?.id === SYSTEM_VARIABLE_ID; return ( @@ -821,79 +817,48 @@ export const VariablePopoverTrigger = ({ width: theme.spacing[30], }} > - { + event.preventDefault(); + if (isSystemVariable) { + return; + } + const nameElement = + event.currentTarget.elements.namedItem("name"); + // make sure only name is valid and allow to save everything else + // to avoid loosing complex configuration when closed accidentally + if ( + nameElement instanceof HTMLInputElement && + nameElement.checkValidity() + ) { + const formData = new FormData(event.currentTarget); + panelRef.current?.save(formData); + // close popover whenever new variable is created + // to prevent creating duplicated variable + if (variable === undefined) { + setOpen(false); + } + } }} > - {requiresUpgrade && ( - - Resource fetching is part of the CMS functionality. - - - - Upgrade to Pro - - - - )} - + } > diff --git a/apps/builder/app/builder/features/topbar/publish.tsx b/apps/builder/app/builder/features/topbar/publish.tsx index 224315c882..f23eac27dd 100644 --- a/apps/builder/app/builder/features/topbar/publish.tsx +++ b/apps/builder/app/builder/features/topbar/publish.tsx @@ -235,6 +235,18 @@ const $usedProFeatures = computed( instanceSelector: [page.rootInstanceId], }); } + if (page.meta.status && page.meta.status !== `200`) { + features.set("Page status code", { + pageId: page.id, + instanceSelector: [page.rootInstanceId], + }); + } + if (page.meta.redirect && page.meta.redirect !== `""`) { + features.set("Redirect", { + pageId: page.id, + instanceSelector: [page.rootInstanceId], + }); + } } // has resource variables for (const dataSource of dataSources.values()) { diff --git a/packages/sdk/src/expression.test.ts b/packages/sdk/src/expression.test.ts index 083039b267..4c0d966031 100644 --- a/packages/sdk/src/expression.test.ts +++ b/packages/sdk/src/expression.test.ts @@ -229,6 +229,7 @@ test("check simple literals", () => { expect(isLiteralExpression(`true`)).toEqual(true); expect(isLiteralExpression(`[]`)).toEqual(true); expect(isLiteralExpression(`{}`)).toEqual(true); + expect(isLiteralExpression(`undefined`)).toEqual(true); expect(isLiteralExpression(`"" + ""`)).toEqual(false); expect(isLiteralExpression(`{}.field`)).toEqual(false); expect(isLiteralExpression(`variable`)).toEqual(false); diff --git a/packages/sdk/src/expression.ts b/packages/sdk/src/expression.ts index aa719938ed..f1c1e91108 100644 --- a/packages/sdk/src/expression.ts +++ b/packages/sdk/src/expression.ts @@ -138,6 +138,9 @@ export const lintExpression = ({ }; const isLiteralNode = (node: Expression): boolean => { + if (node.type === "Identifier" && node.name === "undefined") { + return true; + } if (node.type === "Literal") { return true; }