feat: allow using pro features in builder for free users (#5039)

We decided to not block testing of pro features in builder and block
only publishing projects with these features.
<img width="562" alt="Screenshot 2025-03-24 at 13 38 21"
src="https://github.com/user-attachments/assets/ba796d3f-9e7d-43ca-8939-01423b83c340"
/>
<img width="492" alt="image"
src="https://github.com/user-attachments/assets/8d8ccaa1-1126-470a-bc2a-6e75940c7e6e"
/>
<img width="973" alt="Screenshot 2025-03-24 at 13 04 07"
src="https://github.com/user-attachments/assets/21a8a40c-937a-4845-9481-302a8272d8db"
/>
<img width="573" alt="Screenshot 2025-03-24 at 12 56 07"
src="https://github.com/user-attachments/assets/e22e57c4-e134-4835-8dd4-24ba54faa30d"
/>
This commit is contained in:
Bogdan Chadkin authored and GitHub committed 2025-03-25 10:44:19 +07:00
1 parent 5b3144d656
commit ad114eb37f
6 files changed
+192 -247

No files matched your search

@@ -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<string, unknown>,
userPlanFeatures: UserPlanFeatures
variableValues: Map<string, unknown>
): 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 (
<Grid gap={1}>
{allowDynamicData ? (
<Flex align="center" gap={1}>
<Label htmlFor={id}>Dynamic Path</Label>
<Tooltip
content="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."
variant="wrapped"
>
<InfoCircleIcon
color={rawTheme.colors.foregroundSubtle}
tabIndex={-1}
/>
</Tooltip>
</Flex>
) : (
<Flex align="center" gap={1}>
<Label htmlFor={id}>Path</Label>
<Tooltip
content={
<>
<Text>
Path is a subset of the URL that looks like this:
&quot;/blog&quot;.
</Text>
{allowDynamicData === false && (
<>
<Text>
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.
</Text>
<Link
className={buttonStyle({ color: "gradient" })}
css={{ marginTop: theme.spacing[5], width: "100%" }}
color="contrast"
underline="none"
target="_blank"
href="https://webstudio.is/pricing"
>
Upgrade
</Link>
</>
)}
</>
}
variant="wrapped"
>
<InfoCircleIcon
color={rawTheme.colors.foregroundSubtle}
tabIndex={-1}
/>
</Tooltip>
</Flex>
)}
<Flex align="center" gap={1}>
<Label htmlFor={id}>Path</Label>
{allowDynamicData === false && <ProBadge>PRO</ProBadge>}
<Tooltip
content={
<>
<Text>
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.
</Text>
{allowDynamicData === false && (
<>
<br />
<Text>
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.
</Text>
<Link
className={buttonStyle({ color: "gradient" })}
css={{ marginTop: theme.spacing[5], width: "100%" }}
color="contrast"
underline="none"
target="_blank"
href="https://webstudio.is/pricing"
>
Upgrade
</Link>
</>
)}
</>
}
variant="wrapped"
>
<InfoCircleIcon
color={rawTheme.colors.foregroundSubtle}
tabIndex={-1}
/>
</Tooltip>
</Flex>
<InputErrorsTooltip errors={errors}>
<InputField
color={errors && "error"}
@@ -379,12 +357,12 @@ const PathField = ({
const StatusField = ({
errors,
value,
value = `undefined`,
onChange,
}: {
errors?: string[];
value: string;
onChange: (value: string) => void;
value: undefined | string;
onChange: (value: undefined | string) => void;
}) => {
const id = useId();
const { allowDynamicData } = useStore($userPlanFeatures);
@@ -393,6 +371,7 @@ const StatusField = ({
<Grid gap={1}>
<Flex align="center" gap={1}>
<Label htmlFor={id}>Status Code </Label>
{allowDynamicData === false && <ProBadge>PRO</ProBadge>}
<Tooltip
content={
<Text>
@@ -417,35 +396,35 @@ const StatusField = ({
</Tooltip>
</Flex>
<BindingControl>
{allowDynamicData && (
<BindingPopover
scope={scope}
aliases={aliases}
variant={isLiteralExpression(value) ? "default" : "bound"}
value={value}
onChange={onChange}
onRemove={(evaluatedValue) =>
onChange(JSON.stringify(evaluatedValue ?? ""))
}
/>
)}
<BindingPopover
scope={scope}
aliases={aliases}
variant={isLiteralExpression(value) ? "default" : "bound"}
value={value}
onChange={onChange}
onRemove={(evaluatedValue) =>
onChange(JSON.stringify(evaluatedValue ?? ""))
}
/>
<InputErrorsTooltip errors={errors}>
<InputField
inputMode="numeric"
color={errors && "error"}
id={id}
placeholder="200"
disabled={
allowDynamicData === false || isLiteralExpression(value) === false
}
value={String(computeExpression(value, variableValues))}
disabled={isLiteralExpression(value) === false}
value={String(computeExpression(value, variableValues) ?? "")}
onChange={(event) => {
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));
}
}}
/>
</InputErrorsTooltip>
@@ -470,6 +449,7 @@ const RedirectField = ({
<Grid gap={1}>
<Flex align="center" gap={1}>
<Label htmlFor={id}>Redirect </Label>
{allowDynamicData === false && <ProBadge>PRO</ProBadge>}
<Tooltip
content="Redirect value can be a path or an expression that returns a path for dynamic response handling."
variant="wrapped"
@@ -482,26 +462,22 @@ const RedirectField = ({
</Flex>
<BindingControl>
{allowDynamicData && (
<BindingPopover
scope={scope}
aliases={aliases}
variant={isLiteralExpression(value) ? "default" : "bound"}
value={value}
onChange={onChange}
onRemove={(evaluatedValue) =>
onChange(JSON.stringify(evaluatedValue ?? ""))
}
/>
)}
<BindingPopover
scope={scope}
aliases={aliases}
variant={isLiteralExpression(value) ? "default" : "bound"}
value={value}
onChange={onChange}
onRemove={(evaluatedValue) =>
onChange(JSON.stringify(evaluatedValue ?? ""))
}
/>
<InputErrorsTooltip errors={errors}>
<InputField
color={errors && "error"}
id={id}
placeholder="/another-path"
disabled={
allowDynamicData === false || isLiteralExpression(value) === false
}
disabled={isLiteralExpression(value) === false}
value={String(computeExpression(value, variableValues))}
onChange={(event) => 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>) => {
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 (
@@ -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 ?? ""}
@@ -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<VariableType>(() => {
if (variable?.type === "resource") {
@@ -588,60 +587,37 @@ const VariablePanel = forwardRef<
return "string";
});
let fields: ReactNode;
if (variableType === "parameter") {
return (
<>
<NameField
variableId={variable?.id}
defaultValue={variable?.name ?? ""}
/>
<ParameterForm ref={ref} variable={variable} />
</>
);
fields = <ParameterForm ref={ref} variable={variable} />;
}
if (variableType === "string") {
return (
fields = (
<>
<NameField
variableId={variable?.id}
defaultValue={variable?.name ?? ""}
/>
<TypeField value={variableType} onChange={setVariableType} />
<StringForm ref={ref} variable={variable} />
</>
);
}
if (variableType === "number") {
return (
fields = (
<>
<NameField
variableId={variable?.id}
defaultValue={variable?.name ?? ""}
/>
<TypeField value={variableType} onChange={setVariableType} />
<NumberForm ref={ref} variable={variable} />
</>
);
}
if (variableType === "boolean") {
return (
fields = (
<>
<NameField
variableId={variable?.id}
defaultValue={variable?.name ?? ""}
/>
<TypeField value={variableType} onChange={setVariableType} />
<BooleanForm ref={ref} variable={variable} />
</>
);
}
if (variableType === "json") {
return (
fields = (
<>
<NameField
variableId={variable?.id}
defaultValue={variable?.name ?? ""}
/>
<TypeField value={variableType} onChange={setVariableType} />
<JsonForm ref={ref} variable={variable} />
</>
@@ -649,47 +625,70 @@ const VariablePanel = forwardRef<
}
if (variableType === "resource") {
return (
fields = (
<>
<NameField
variableId={variable?.id}
defaultValue={variable?.name ?? ""}
/>
<TypeField value={variableType} onChange={setVariableType} />
<ResourceForm ref={ref} variable={variable} />
</>
);
}
if (variableType === "graphql-resource") {
return (
fields = (
<>
<NameField
variableId={variable?.id}
defaultValue={variable?.name ?? ""}
/>
<TypeField value={variableType} onChange={setVariableType} />
<GraphqlResourceForm ref={ref} variable={variable} />
</>
);
}
if (variableType === "system-resource") {
return (
fields = (
<>
<NameField
variableId={variable?.id}
defaultValue={variable?.name ?? ""}
/>
<TypeField value={variableType} onChange={setVariableType} />
<SystemResourceForm ref={ref} variable={variable} />
</>
);
}
variableType satisfies never;
const isResource =
variableType === "resource" ||
variableType === "graphql-resource" ||
variableType === "system-resource";
const requiresUpgrade = allowDynamicData === false && isResource;
return (
<>
{requiresUpgrade && (
<PanelBanner>
<Text>Resource fetching is part of the CMS functionality.</Text>
<Flex align="center" gap={1}>
<UpgradeIcon />
<Link
color="inherit"
target="_blank"
href="https://webstudio.is/pricing"
>
Upgrade to Pro
</Link>
</Flex>
</PanelBanner>
)}
<Flex
direction="column"
css={{
overflow: "hidden",
gap: theme.spacing[7],
p: theme.panel.padding,
}}
>
<NameField
variableId={variable?.id}
defaultValue={variable?.name ?? ""}
/>
{fields}
</Flex>
</>
);
});
VariablePanel.displayName = "VariablePanel";
VariablePanel.displayName = "VariableForm";
const VariablePopoverContext = createContext<{
containerRef?: RefObject<null | HTMLElement>;
@@ -733,9 +732,6 @@ export const VariablePopoverTrigger = ({
const panelRef = useRef<undefined | PanelApi>(undefined);
const formRef = useRef<HTMLFormElement>(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],
}}
>
<Flex
direction="column"
css={{
overflow: "hidden",
gap: theme.spacing[7],
p: theme.panel.padding,
<form
ref={formRef}
noValidate={true}
// exclude from the flow
style={{ display: "contents" }}
onSubmit={(event) => {
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 && (
<PanelBanner>
<Text>Resource fetching is part of the CMS functionality.</Text>
<Flex align="center" gap={1}>
<UpgradeIcon />
<Link
color="inherit"
target="_blank"
href="https://webstudio.is/pricing"
>
Upgrade to Pro
</Link>
</Flex>
</PanelBanner>
)}
<form
ref={formRef}
noValidate={true}
// exclude from the flow
{/* submit is not triggered when press enter on input without submit button */}
<button hidden></button>
<fieldset
style={{ display: "contents" }}
onChange={(event) => {
const { name, value } = event.target as HTMLSelectElement;
// When type is changing, we need to show the upgrade banner.
if (name === "type") {
setIsResource(value.includes("resource"));
}
}}
onSubmit={(event) => {
event.preventDefault();
if (requiresUpgrade || 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);
}
}
}}
// forbid editing system variable
disabled={isSystemVariable}
>
{/* submit is not triggered when press enter on input without submit button */}
<button hidden></button>
<fieldset
style={{ display: "contents" }}
// forbid editing system variable
disabled={isSystemVariable}
<BindingPopoverProvider
value={{ containerRef: bindingPopoverContainerRef }}
>
<BindingPopoverProvider
value={{ containerRef: bindingPopoverContainerRef }}
>
<VariablePanel ref={panelRef} variable={variable} />
</BindingPopoverProvider>
</fieldset>
</form>
</Flex>
<VariablePanel ref={panelRef} variable={variable} />
</BindingPopoverProvider>
</fieldset>
</form>
</ScrollArea>
}
>
@@ -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()) {
+1
View File
@@ -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);
+3
View File
@@ -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;
}