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 36d112fe18d..e7aa782d53b 100644 --- a/apps/builder/app/builder/features/settings-panel/variable-popover.tsx +++ b/apps/builder/app/builder/features/settings-panel/variable-popover.tsx @@ -4,6 +4,8 @@ import { type ReactNode, type Ref, type RefObject, + type ForwardedRef, + type FocusEvent, forwardRef, useId, useState, @@ -11,6 +13,7 @@ import { useRef, createContext, useContext, + useCallback, } from "react"; import { mergeRefs } from "@react-aria/utils"; import { CopyIcon, RefreshIcon } from "@webstudio-is/icons"; @@ -22,6 +25,7 @@ import { FloatingPanelPopoverContent, FloatingPanelPopoverTitle, FloatingPanelPopoverTrigger, + Grid, InputErrorsTooltip, InputField, Label, @@ -53,6 +57,9 @@ import { type Field, composeFields, type ComposedFields, + useFormField, + Form, + checkCanRequestSubmit, } from "~/shared/form-utils"; import { $userPlanFeatures } from "~/builder/shared/nano-states"; import { BindingPopoverProvider } from "~/builder/shared/binding-popover"; @@ -65,6 +72,69 @@ import { import { ResourceForm, SystemResourceForm } from "./resource-panel"; import { generateCurl } from "./curl"; +const NameField = ({ + defaultValue, + onBlur, +}: { + defaultValue: string; + onBlur?: (event: FocusEvent) => void; +}) => { + const nameId = useId(); + const { ref, error, props } = useFormField({ + defaultValue, + validate: useCallback( + (value: string) => (value.trim().length === 0 ? "Name is required" : ""), + [] + ), + }); + return ( + + + + + + + ); +}; + +const ParameterForm = forwardRef< + HTMLFormElement, + { + variable?: DataSource; + } +>(({ variable }, ref) => { + return ( +
{ + const formData = new FormData(event.currentTarget); + const name = String(formData.get("name")); + // only existing parameter variables can be renamed + if (variable === undefined) { + return; + } + serverSyncStore.createTransaction([$dataSources], (dataSources) => { + dataSources.set(variable.id, { ...variable, name }); + }); + }} + > + event.target.form?.requestSubmit()} + /> + + ); +}); +ParameterForm.displayName = "ParameterForm"; + /** * convert value expression to js value * validating out accessing any identifier @@ -115,28 +185,6 @@ type PanelApi = ComposedFields & { save: () => void; }; -const ParameterForm = forwardRef< - undefined | PanelApi, - { variable?: DataSource; nameField: Field } ->(({ variable, nameField }, ref) => { - const form = composeFields(nameField); - useImperativeHandle(ref, () => ({ - ...form, - save: () => { - // only existing parameter variables can be renamed - if (variable === undefined) { - return; - } - const name = nameField.value; - serverSyncStore.createTransaction([$dataSources], (dataSources) => { - dataSources.set(variable.id, { ...variable, name }); - }); - }, - })); - return <>; -}); -ParameterForm.displayName = "ParameterForm"; - const useValuePanelRef = ({ ref, variable, @@ -356,9 +404,11 @@ JsonForm.displayName = "JsonForm"; const VariablePanel = forwardRef< undefined | PanelApi, { + formRef: ForwardedRef; variable?: DataSource; + onSubmit: () => void; } ->(({ variable }, ref) => { +>(({ formRef, variable, onSubmit }, ref) => { const { allowDynamicData } = useStore($userPlanFeatures); const resources = useStore($resources); @@ -474,63 +524,58 @@ const VariablePanel = forwardRef< ); if (variableType === "parameter") { - return ( - <> - {nameFieldElement} - - - ); + return ; } if (variableType === "string") { return ( - <> +
{nameFieldElement} {typeFieldElement} - + ); } if (variableType === "number") { return ( - <> +
{nameFieldElement} {typeFieldElement} - + ); } if (variableType === "boolean") { return ( - <> +
{nameFieldElement} {typeFieldElement} - + ); } if (variableType === "json") { return ( - <> +
{nameFieldElement} {typeFieldElement} - + ); } if (variableType === "resource") { return ( - <> +
{nameFieldElement} {typeFieldElement} - + ); } if (variableType === "system-resource") { return ( - <> +
{nameFieldElement} {typeFieldElement} - + ); } @@ -563,8 +608,15 @@ export const VariablePopoverTrigger = forwardRef< const bindingPopoverContainerRef = useRef(null); const panelRef = useRef(); const resources = useStore($resources); + const form = useRef(null); const saveAndClose = () => { + if (form.current) { + if (checkCanRequestSubmit(form.current) === false) { + return; + } + form.current.requestSubmit(); + } if (panelRef.current) { if (panelRef.current.allErrorsVisible === false) { panelRef.current.showAllErrors(); @@ -615,22 +667,16 @@ export const VariablePopoverTrigger = forwardRef< pb: theme.spacing[9], }} > -
{ - event.preventDefault(); - saveAndClose(); - }} + - {/* submit is not triggered when press enter on input without submit button */} - - - - - + +
{/* put after content to avoid auto focusing "Close" button */} diff --git a/apps/builder/app/builder/features/settings-panel/variables-section.stories.tsx b/apps/builder/app/builder/features/settings-panel/variables-section.stories.tsx index ecbee4b99de..f2e9b072b31 100644 --- a/apps/builder/app/builder/features/settings-panel/variables-section.stories.tsx +++ b/apps/builder/app/builder/features/settings-panel/variables-section.stories.tsx @@ -6,6 +6,7 @@ import { $selectedInstanceSelector, $selectedPageId, $instances, + $dataSources, } from "~/shared/nano-states"; import { registerContainers } from "~/shared/sync"; import { createDefaultPages } from "@webstudio-is/project-build"; @@ -26,6 +27,19 @@ $selectedPageId.set("home"); $pages.set( createDefaultPages({ rootInstanceId: "root", systemDataSourceId: "system" }) ); +$dataSources.set( + new Map([ + [ + "systemId", + { + id: "systemId", + scopeInstanceId: "root", + name: "system", + type: "parameter", + }, + ], + ]) +); export const VariablesSection: StoryObj = { render: () => ( diff --git a/apps/builder/app/shared/form-utils/form-utils.ts b/apps/builder/app/shared/form-utils/form-utils.ts deleted file mode 100644 index 8c83d325eb3..00000000000 --- a/apps/builder/app/shared/form-utils/form-utils.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useState } from "react"; - -export type Field = { - value: Type; - error: undefined | string; - isErrorVisible: boolean; - valid: boolean; - onChange: (value: Type) => void; - onBlur: () => void; -}; - -export const useField = ({ - initialValue, - validate, -}: { - initialValue: Type; - validate: (value: Type) => undefined | string; -}): Field => { - const [value, setValue] = useState(initialValue); - const [touched, setTouched] = useState(false); - const error = validate?.(value); - return { - value, - // show error only when user stop interactinig with control - error: touched ? error : undefined, - // inform when user see the error and not need to trigger it - isErrorVisible: error === undefined || touched, - valid: error === undefined, - // hide error when user is typing - onChange: (value) => { - setValue(value); - setTouched(false); - }, - // show error when user focus on another control - onBlur: () => { - setTouched(true); - }, - }; -}; - -export type ComposedFields = { - valid: boolean; - allErrorsVisible: boolean; - showAllErrors: () => void; -}; - -export const composeFields = ( - // allow passing fields with more tight types - // by ensuring their onChange will not refine - ...fields: Omit, "onChange">[] -): ComposedFields => { - return { - valid: fields.every((field) => field.valid), - allErrorsVisible: fields.every((field) => field.isErrorVisible), - showAllErrors: () => { - for (const field of fields) { - field.onBlur(); - } - }, - }; -}; diff --git a/apps/builder/app/shared/form-utils/form-utils.tsx b/apps/builder/app/shared/form-utils/form-utils.tsx new file mode 100644 index 00000000000..39d03ad8dd7 --- /dev/null +++ b/apps/builder/app/shared/form-utils/form-utils.tsx @@ -0,0 +1,170 @@ +import type { + ChangeEvent, + FocusEvent, + FormEvent, + InvalidEvent, + KeyboardEvent, + ReactNode, +} from "react"; +import { forwardRef, useEffect, useRef, useState } from "react"; + +export type Field = { + value: Type; + error: undefined | string; + isErrorVisible: boolean; + valid: boolean; + onChange: (value: Type) => void; + onBlur: () => void; +}; + +/** + * @deprecated switch to useFormField or any other native validation + */ +export const useField = ({ + initialValue, + validate, +}: { + initialValue: Type; + validate: (value: Type) => undefined | string; +}): Field => { + const [value, setValue] = useState(initialValue); + const [touched, setTouched] = useState(false); + const error = validate?.(value); + return { + value, + // show error only when user stop interactinig with control + error: touched ? error : undefined, + // inform when user see the error and not need to trigger it + isErrorVisible: error === undefined || touched, + valid: error === undefined, + // hide error when user is typing + onChange: (value) => { + setValue(value); + setTouched(false); + }, + // show error when user focus on another control + onBlur: () => { + setTouched(true); + }, + }; +}; + +export type ComposedFields = { + valid: boolean; + allErrorsVisible: boolean; + showAllErrors: () => void; +}; + +/** + * @deprecated switch to useFormField or any other native validation + */ +export const composeFields = ( + // allow passing fields with more tight types + // by ensuring their onChange will not refine + ...fields: Omit, "onChange">[] +): ComposedFields => { + return { + valid: fields.every((field) => field.valid), + allErrorsVisible: fields.every((field) => field.isErrorVisible), + showAllErrors: () => { + for (const field of fields) { + field.onBlur(); + } + }, + }; +}; + +export const useFormField = ({ + defaultValue, + validate, +}: { + defaultValue: string; + validate: (value: string) => string; +}) => { + const [error, setError] = useState(""); + const ref = useRef(null); + // validate initial value + useEffect(() => { + ref.current?.setCustomValidity(validate(defaultValue)); + }, [defaultValue, validate]); + const props = { + onChange: (event: ChangeEvent) => { + setError(""); + event.target.setCustomValidity(validate(event.target.value)); + }, + onBlur: (event: FocusEvent) => { + event.target.checkValidity(); + }, + onInvalid: (event: InvalidEvent) => { + setError(event.target.validationMessage); + }, + onKeyDown: (event: KeyboardEvent) => { + const input = event.currentTarget; + // reset on escape when default value is different + if (event.key === "Escape" && input.value !== input.defaultValue) { + // prevent propagating escape to dialog or popover + event.stopPropagation(); + input.value = input.defaultValue; + // revalidate after reset + setError(""); + input.setCustomValidity(validate(input.value)); + } + }, + }; + return { + ref, + error, + props, + }; +}; + +/** + * prevents default navigation + * supports submit on enter + * avoids submitting when invalid + * resets attempts + * disables native tooltips + * does not affect layout + */ +export const Form = forwardRef< + HTMLFormElement, + { onSubmit: (event: FormEvent) => void; children: ReactNode } +>(({ onSubmit, children }, ref) => { + return ( +
{ + event.preventDefault(); + if (event.currentTarget.checkValidity() === false) { + return; + } + onSubmit(event); + }} + onChange={(event) => { + delete event.currentTarget.dataset.attemptedSubmit; + }} + > + {/* submit is not triggered when press enter on input without submit button */} + + {children} +
+ ); +}); + +/** + * checks validity and allow request a submit on second attempt to not block user + * attempts are reset when change event is propagated + */ +export const checkCanRequestSubmit = (form: HTMLFormElement) => { + if ( + form.checkValidity() === false && + form.dataset.attemptedSubmit === undefined + ) { + form.dataset.attemptedSubmit = "true"; + return false; + } + return true; +};