Add format options in number input block (#2080)

This commit is contained in:
Alexis Falaise 2025-03-19 12:29:18 +01:00 committed by GitHub
parent ee0aaa9715
commit f515ef108e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 465 additions and 99 deletions

View File

@ -14,6 +14,7 @@ import {
Stack,
chakra,
} from "@chakra-ui/react";
import { useTranslate } from "@tolgee/react";
import type { ReactElement, ReactNode } from "react";
import React from "react";
import { MoreInfoTooltip } from "./MoreInfoTooltip";
@ -54,6 +55,7 @@ export const DropdownList = <T extends Item>({
moreInfoTooltip,
...props
}: Props<T> & ButtonProps) => {
const { t } = useTranslate();
const handleMenuItemClick = (item: T) => () => {
if (typeof item === "string" || typeof item === "number")
onItemSelect(
@ -109,7 +111,8 @@ export const DropdownList = <T extends Item>({
: currentItem === item.value,
),
)
: (placeholder ?? "Select an item")}
: (placeholder ??
t("components.dropdownList.defaultPlaceholder"))}
</chakra.span>
</MenuButton>
<Portal>

View File

@ -1,11 +1,36 @@
import { DropdownList } from "@/components/DropdownList";
import { NumberInput, TextInput } from "@/components/inputs";
import { Select } from "@/components/inputs/Select";
import { VariableSearchInput } from "@/components/inputs/VariableSearchInput";
import { FormLabel, Stack } from "@chakra-ui/react";
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
FormControl,
FormLabel,
Stack,
Text,
} from "@chakra-ui/react";
import { useTranslate } from "@tolgee/react";
import { defaultNumberInputOptions } from "@typebot.io/blocks-inputs/number/constants";
import type { NumberInputBlock } from "@typebot.io/blocks-inputs/number/schema";
import {
NumberInputStyle,
NumberInputUnit,
defaultNumberInputButtonLabel,
defaultNumberInputPlaceholder,
defaultNumberInputStyle,
localeRegex,
numberStyleTranslationKeys,
unitTranslationKeys,
} from "@typebot.io/blocks-inputs/number/constants";
import {
type NumberInputBlock,
numberInputOptionsSchema,
} from "@typebot.io/blocks-inputs/number/schema";
import type { Variable } from "@typebot.io/variables/schemas";
import React from "react";
import React, { useEffect } from "react";
import { currencies } from "../../payment/currencies";
type Props = {
options: NumberInputBlock["options"];
@ -14,6 +39,16 @@ type Props = {
export const NumberInputSettings = ({ options, onOptionsChange }: Props) => {
const { t } = useTranslate();
useEffect(() => {
if (!options?.locale) {
const browserLocale = navigator.language;
if (browserLocale.match(localeRegex)) {
onOptionsChange({ ...options, locale: browserLocale });
}
}
});
const handlePlaceholderChange = (placeholder: string) =>
onOptionsChange({
...options,
@ -21,6 +56,11 @@ export const NumberInputSettings = ({ options, onOptionsChange }: Props) => {
});
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, button } });
const handleCurrencyChange = (currency: string) =>
onOptionsChange({
...options,
currency,
});
const handleMinChange = (
min?: NonNullable<NumberInputBlock["options"]>["min"],
) => onOptionsChange({ ...options, min });
@ -30,6 +70,21 @@ export const NumberInputSettings = ({ options, onOptionsChange }: Props) => {
const handleStepChange = (
step?: NonNullable<NumberInputBlock["options"]>["step"],
) => onOptionsChange({ ...options, step });
const handleStyleChange = (style: NumberInputStyle) =>
onOptionsChange({
...options,
style,
});
const handleUnitChange = (unit: NumberInputUnit) =>
onOptionsChange({ ...options, unit });
const handleLocaleChange = (locale: string) => {
const savableLocale = numberInputOptionsSchema.shape.locale.safeParse(
locale,
).success
? locale
: (options?.locale ?? navigator.language);
onOptionsChange({ ...options, locale: savableLocale });
};
const handleVariableChange = (variable?: Variable) => {
onOptionsChange({ ...options, variableId: variable?.id });
};
@ -39,16 +94,13 @@ export const NumberInputSettings = ({ options, onOptionsChange }: Props) => {
<TextInput
label={t("blocks.inputs.settings.placeholder.label")}
defaultValue={
options?.labels?.placeholder ??
defaultNumberInputOptions.labels.placeholder
options?.labels?.placeholder ?? defaultNumberInputPlaceholder
}
onChange={handlePlaceholderChange}
/>
<TextInput
label={t("blocks.inputs.settings.button.label")}
defaultValue={
options?.labels?.button ?? defaultNumberInputOptions.labels.button
}
defaultValue={options?.labels?.button ?? defaultNumberInputButtonLabel}
onChange={handleButtonLabelChange}
/>
<NumberInput
@ -66,6 +118,75 @@ export const NumberInputSettings = ({ options, onOptionsChange }: Props) => {
defaultValue={options?.step}
onValueChange={handleStepChange}
/>
<Accordion allowToggle>
<AccordionItem>
<AccordionButton>
<Text w="full" textAlign="left">
{t("blocks.inputs.number.settings.format.label")}
</Text>
<AccordionIcon />
</AccordionButton>
<AccordionPanel>
<DropdownList
items={Object.values(NumberInputStyle).map((style) => ({
label: t(numberStyleTranslationKeys[style]),
value: style,
}))}
currentItem={options?.style ?? defaultNumberInputStyle}
onItemSelect={(value) =>
handleStyleChange(value as NumberInputStyle)
}
/>
{options?.style === NumberInputStyle.CURRENCY && (
<FormControl mt={4}>
<FormLabel>
{t("blocks.inputs.number.settings.currency.label")}
</FormLabel>
<Select
items={currencies.map(({ code, description }) => ({
label: description,
value: code,
}))}
onSelect={(value) => handleCurrencyChange(value as string)}
placeholder={t(
"blocks.inputs.number.settings.currency.label",
)}
selectedItem={options?.currency}
/>
</FormControl>
)}
{options?.style === NumberInputStyle.UNIT && (
<FormControl mt={4}>
<FormLabel>
{t("blocks.inputs.number.settings.unit.label")}
</FormLabel>
<Select
items={Object.values(NumberInputUnit).map((unit) => ({
label: t(unitTranslationKeys[unit]),
value: unit,
}))}
onSelect={(value) =>
handleUnitChange(value as NumberInputUnit)
}
placeholder={t("blocks.inputs.number.settings.unit.label")}
selectedItem={options?.unit}
/>
</FormControl>
)}
<FormControl mt={4}>
<FormLabel>
{t("blocks.inputs.number.settings.locale.label")}
</FormLabel>
<TextInput
defaultValue={options?.locale}
helperText={t("blocks.inputs.number.settings.locale.helper")}
placeholder="en-US"
onChange={handleLocaleChange}
/>
</FormControl>
</AccordionPanel>
</AccordionItem>
</Accordion>
<Stack>
<FormLabel mb="0" htmlFor="variable">
{t("blocks.inputs.settings.saveAnswer.label")}

View File

@ -1,6 +1,6 @@
import { WithVariableContent } from "@/features/graph/components/nodes/block/WithVariableContent";
import { Text } from "@chakra-ui/react";
import { defaultNumberInputOptions } from "@typebot.io/blocks-inputs/number/constants";
import { defaultNumberInputPlaceholder } from "@typebot.io/blocks-inputs/number/constants";
import type { NumberInputBlock } from "@typebot.io/blocks-inputs/number/schema";
import React from "react";
@ -15,6 +15,6 @@ export const NumberNodeContent = ({
<WithVariableContent variableId={variableId} />
) : (
<Text color={"gray.500"}>
{labels?.placeholder ?? defaultNumberInputOptions.labels.placeholder}
{labels?.placeholder ?? defaultNumberInputPlaceholder}
</Text>
);

View File

@ -1,7 +1,7 @@
import { createId } from "@paralleldrive/cuid2";
import test, { expect } from "@playwright/test";
import { InputBlockType } from "@typebot.io/blocks-inputs/constants";
import { defaultNumberInputOptions } from "@typebot.io/blocks-inputs/number/constants";
import { defaultNumberInputPlaceholder } from "@typebot.io/blocks-inputs/number/constants";
import { createTypebots } from "@typebot.io/playwright/databaseActions";
import { parseDefaultGroupWithBlock } from "@typebot.io/playwright/databaseHelpers";
@ -21,12 +21,10 @@ test.describe("Number input block", () => {
await page.click("text=Test");
await expect(
page.locator(
`input[placeholder="${defaultNumberInputOptions.labels.placeholder}"]`,
),
page.locator(`input[placeholder="${defaultNumberInputPlaceholder}"]`),
).toHaveAttribute("type", "number");
await page.click(`text=${defaultNumberInputOptions.labels.placeholder}`);
await page.click(`text=${defaultNumberInputPlaceholder}`);
await page.getByLabel("Placeholder:").fill("Your number...");
await expect(page.locator("text=Your number...")).toBeVisible();
await page.getByLabel("Button label:").fill("Go");

View File

@ -1,7 +1,12 @@
// The STRIPE-supported currencies, sorted by code
// https://gist.github.com/chrisdavies/9e3f00889fb764013339632bd3f2a71b
export const currencies = [
export type Currency = {
code: string;
description: string;
};
export const currencies: Currency[] = [
{
code: "AED",
description: "United Arab Emirates Dirham",

View File

@ -147,7 +147,61 @@
"blocks.inputs.file.settings.saveSingleUpload.label": "Save upload URL in a variable:",
"blocks.inputs.file.settings.skip.label": "Skip button label:",
"blocks.inputs.fileUpload.blockCard.tooltip": "Upload Files",
"blocks.inputs.number.settings.currency.label": "Currency:",
"blocks.inputs.number.settings.format.label": "Format",
"blocks.inputs.number.settings.locale.label": "Locale:",
"blocks.inputs.number.settings.locale.helper": "Locale codes follow the format 'en' or 'en-US' (language-region)",
"blocks.inputs.number.settings.step.label": "Step:",
"blocks.inputs.number.settings.unit.label": "Unit:",
"blocks.inputs.number.style.decimal": "Decimal",
"blocks.inputs.number.style.currency": "Currency",
"blocks.inputs.number.style.percent": "Percent",
"blocks.inputs.number.style.unit": "Unit",
"blocks.inputs.number.unit.acre": "acre",
"blocks.inputs.number.unit.bit": "bit",
"blocks.inputs.number.unit.byte": "byte",
"blocks.inputs.number.unit.celsius": "celsius",
"blocks.inputs.number.unit.centimeter": "centimeter",
"blocks.inputs.number.unit.day": "day",
"blocks.inputs.number.unit.degree": "degree",
"blocks.inputs.number.unit.fahrenheit": "fahrenheit",
"blocks.inputs.number.unit.fluidOunce": "fluid ounce",
"blocks.inputs.number.unit.foot": "foot",
"blocks.inputs.number.unit.gallon": "gallon",
"blocks.inputs.number.unit.gigabit": "gigabit",
"blocks.inputs.number.unit.gigabyte": "gigabyte",
"blocks.inputs.number.unit.gram": "gram",
"blocks.inputs.number.unit.hectare": "hectare",
"blocks.inputs.number.unit.hour": "hour",
"blocks.inputs.number.unit.inch": "inch",
"blocks.inputs.number.unit.kilobit": "kilobit",
"blocks.inputs.number.unit.kilobyte": "kilobyte",
"blocks.inputs.number.unit.kilogram": "kilogram",
"blocks.inputs.number.unit.kilometer": "kilometer",
"blocks.inputs.number.unit.liter": "liter",
"blocks.inputs.number.unit.megabit": "megabit",
"blocks.inputs.number.unit.megabyte": "megabyte",
"blocks.inputs.number.unit.meter": "meter",
"blocks.inputs.number.unit.microsecond": "microsecond",
"blocks.inputs.number.unit.mile": "mile",
"blocks.inputs.number.unit.mileScandinavian": "Scandinavian mile",
"blocks.inputs.number.unit.milliliter": "milliliter",
"blocks.inputs.number.unit.millimeter": "millimeter",
"blocks.inputs.number.unit.millisecond": "millisecond",
"blocks.inputs.number.unit.minute": "minute",
"blocks.inputs.number.unit.month": "month",
"blocks.inputs.number.unit.nanosecond": "nanosecond",
"blocks.inputs.number.unit.ounce": "ounce",
"blocks.inputs.number.unit.percent": "percent",
"blocks.inputs.number.unit.petabyte": "petabyte",
"blocks.inputs.number.unit.pound": "pound",
"blocks.inputs.number.unit.second": "second",
"blocks.inputs.number.unit.stone": "stone",
"blocks.inputs.number.unit.terabit": "terabit",
"blocks.inputs.number.unit.terabyte": "terabyte",
"blocks.inputs.number.unit.week": "week",
"blocks.inputs.number.unit.yard": "yard",
"blocks.inputs.number.unit.year": "year",
"blocks.inputs.payment.collect.label": "Collect",
"blocks.inputs.payment.placeholder.label": "Configure...",
"blocks.inputs.payment.settings.account.label": "Account:",
@ -224,6 +278,7 @@
"colorPicker.advancedColors": "Advanced colors",
"colorPicker.colorValue.ariaLabel": "Color value",
"colorPicker.pickColor.ariaLabel": "Pick a color",
"components.dropdownList.defaultPlaceholder": "Select an item",
"confirmModal.defaultTitle": "Are you sure?",
"connect": "Connect",
"connectNew": "Connect new",

View File

@ -1,6 +1,120 @@
import { defaultButtonLabel } from "../constants";
import type { NumberInputBlock } from "./schema";
export const defaultNumberInputOptions = {
labels: { button: defaultButtonLabel, placeholder: "Type a number..." },
} as const satisfies NumberInputBlock["options"];
export enum NumberInputStyle {
DECIMAL = "decimal",
CURRENCY = "currency",
PERCENT = "percent",
UNIT = "unit",
}
export const localeRegex = /^[a-z]{2}(-[A-Z]{2})?$/;
export const numberStyleTranslationKeys: Record<NumberInputStyle, string> = {
[NumberInputStyle.DECIMAL]: "blocks.inputs.number.style.decimal",
[NumberInputStyle.CURRENCY]: "blocks.inputs.number.style.currency",
[NumberInputStyle.PERCENT]: "blocks.inputs.number.style.percent",
[NumberInputStyle.UNIT]: "blocks.inputs.number.style.unit",
};
export enum NumberInputUnit {
ACRE = "acre",
BIT = "bit",
BYTE = "byte",
CELSIUS = "celsius",
CENTIMETER = "centimeter",
DAY = "day",
DEGREE = "degree",
FAHRENHEIT = "fahrenheit",
FLUID_OUNCE = "fluid-ounce",
FOOT = "foot",
GALLON = "gallon",
GIGABIT = "gigabit",
GIGABYTE = "gigabyte",
GRAM = "gram",
HECTARE = "hectare",
HOUR = "hour",
INCH = "inch",
KILOBIT = "kilobit",
KILOBYTE = "kilobyte",
KILOGRAM = "kilogram",
KILOMETER = "kilometer",
LITER = "liter",
MEGABIT = "megabit",
MEGABYTE = "megabyte",
METER = "meter",
MICROSECOND = "microsecond",
MILE = "mile",
MILE_SCANDINAVIAN = "mile-scandinavian",
MILLILITER = "milliliter",
MILLIMETER = "millimeter",
MILLISECOND = "millisecond",
MINUTE = "minute",
MONTH = "month",
NANOSECOND = "nanosecond",
OUNCE = "ounce",
PERCENT = "percent",
PETABYTE = "petabyte",
POUND = "pound",
SECOND = "second",
STONE = "stone",
TERABIT = "terabit",
TERABYTE = "terabyte",
WEEK = "week",
YARD = "yard",
YEAR = "year",
}
export const defaultNumberInputStyle = NumberInputStyle.DECIMAL;
export const defaultNumberInputButtonLabel = defaultButtonLabel;
export const defaultNumberInputPlaceholder = "Type a number...";
// Map unit types to translation keys
export const unitTranslationKeys: Record<NumberInputUnit, string> = {
[NumberInputUnit.ACRE]: "blocks.inputs.number.unit.acre",
[NumberInputUnit.BIT]: "blocks.inputs.number.unit.bit",
[NumberInputUnit.BYTE]: "blocks.inputs.number.unit.byte",
[NumberInputUnit.CELSIUS]: "blocks.inputs.number.unit.celsius",
[NumberInputUnit.CENTIMETER]: "blocks.inputs.number.unit.centimeter",
[NumberInputUnit.DAY]: "blocks.inputs.number.unit.day",
[NumberInputUnit.DEGREE]: "blocks.inputs.number.unit.degree",
[NumberInputUnit.FAHRENHEIT]: "blocks.inputs.number.unit.fahrenheit",
[NumberInputUnit.FLUID_OUNCE]: "blocks.inputs.number.unit.fluidOunce",
[NumberInputUnit.FOOT]: "blocks.inputs.number.unit.foot",
[NumberInputUnit.GALLON]: "blocks.inputs.number.unit.gallon",
[NumberInputUnit.GIGABIT]: "blocks.inputs.number.unit.gigabit",
[NumberInputUnit.GIGABYTE]: "blocks.inputs.number.unit.gigabyte",
[NumberInputUnit.GRAM]: "blocks.inputs.number.unit.gram",
[NumberInputUnit.HECTARE]: "blocks.inputs.number.unit.hectare",
[NumberInputUnit.HOUR]: "blocks.inputs.number.unit.hour",
[NumberInputUnit.INCH]: "blocks.inputs.number.unit.inch",
[NumberInputUnit.KILOBIT]: "blocks.inputs.number.unit.kilobit",
[NumberInputUnit.KILOBYTE]: "blocks.inputs.number.unit.kilobyte",
[NumberInputUnit.KILOGRAM]: "blocks.inputs.number.unit.kilogram",
[NumberInputUnit.KILOMETER]: "blocks.inputs.number.unit.kilometer",
[NumberInputUnit.LITER]: "blocks.inputs.number.unit.liter",
[NumberInputUnit.MEGABIT]: "blocks.inputs.number.unit.megabit",
[NumberInputUnit.MEGABYTE]: "blocks.inputs.number.unit.megabyte",
[NumberInputUnit.METER]: "blocks.inputs.number.unit.meter",
[NumberInputUnit.MICROSECOND]: "blocks.inputs.number.unit.microsecond",
[NumberInputUnit.MILE]: "blocks.inputs.number.unit.mile",
[NumberInputUnit.MILE_SCANDINAVIAN]:
"blocks.inputs.number.unit.mileScandinavian",
[NumberInputUnit.MILLILITER]: "blocks.inputs.number.unit.milliliter",
[NumberInputUnit.MILLIMETER]: "blocks.inputs.number.unit.millimeter",
[NumberInputUnit.MILLISECOND]: "blocks.inputs.number.unit.millisecond",
[NumberInputUnit.MINUTE]: "blocks.inputs.number.unit.minute",
[NumberInputUnit.MONTH]: "blocks.inputs.number.unit.month",
[NumberInputUnit.NANOSECOND]: "blocks.inputs.number.unit.nanosecond",
[NumberInputUnit.OUNCE]: "blocks.inputs.number.unit.ounce",
[NumberInputUnit.PERCENT]: "blocks.inputs.number.unit.percent",
[NumberInputUnit.PETABYTE]: "blocks.inputs.number.unit.petabyte",
[NumberInputUnit.POUND]: "blocks.inputs.number.unit.pound",
[NumberInputUnit.SECOND]: "blocks.inputs.number.unit.second",
[NumberInputUnit.STONE]: "blocks.inputs.number.unit.stone",
[NumberInputUnit.TERABIT]: "blocks.inputs.number.unit.terabit",
[NumberInputUnit.TERABYTE]: "blocks.inputs.number.unit.terabyte",
[NumberInputUnit.WEEK]: "blocks.inputs.number.unit.week",
[NumberInputUnit.YARD]: "blocks.inputs.number.unit.yard",
[NumberInputUnit.YEAR]: "blocks.inputs.number.unit.year",
};

View File

@ -6,6 +6,7 @@ import { singleVariableOrNumberSchema } from "@typebot.io/variables/schemas";
import { z } from "@typebot.io/zod";
import { InputBlockType } from "../constants";
import { textInputOptionsBaseSchema } from "../text/schema";
import { NumberInputStyle, NumberInputUnit, localeRegex } from "./constants";
export const numberInputOptionsSchema = optionBaseSchema
.merge(textInputOptionsBaseSchema)
@ -14,6 +15,15 @@ export const numberInputOptionsSchema = optionBaseSchema
min: singleVariableOrNumberSchema.optional(),
max: singleVariableOrNumberSchema.optional(),
step: singleVariableOrNumberSchema.optional(),
locale: z
.string()
.regex(localeRegex, {
message: "Invalid locale format. Expected format: 'en' or 'en-US'",
})
.optional(),
style: z.nativeEnum(NumberInputStyle).optional(),
currency: z.string().optional(),
unit: z.nativeEnum(NumberInputUnit).optional(),
}),
);

View File

@ -0,0 +1,24 @@
import {
NumberInputStyle,
defaultNumberInputStyle,
} from "@typebot.io/blocks-inputs/number/constants";
import type { NumberInputBlock } from "@typebot.io/blocks-inputs/number/schema";
export const parseFormatOptions = (
options: NumberInputBlock["options"],
): Intl.NumberFormatOptions => {
const defaultFormat = {
style: defaultNumberInputStyle,
};
if (options?.style === NumberInputStyle.CURRENCY && options.currency)
return {
style: options.style,
currency: options.currency,
};
if (options?.style === NumberInputStyle.UNIT && options.unit)
return {
style: options.style,
unit: options.unit,
};
return defaultFormat;
};

View File

@ -1,4 +1,47 @@
export const parseNumber = (value: string) => {
if (value.startsWith("0")) return value;
return Number.parseFloat(value).toString();
import type { NumberInputBlock } from "@typebot.io/blocks-inputs/number/schema";
import { safeParseFloat } from "@typebot.io/lib/safeParseFloat";
import type { SessionStore } from "@typebot.io/runtime-session-store";
import { parseVariables } from "@typebot.io/variables/parseVariables";
import type { Variable } from "@typebot.io/variables/schemas";
import type { ParsedReply } from "../../../types";
import { parseFormatOptions } from "./parseFormatOptions";
export const parseNumber = (
inputValue: string,
{
options,
variables,
sessionStore,
}: {
options?: NumberInputBlock["options"];
variables: Variable[];
sessionStore: SessionStore;
},
): ParsedReply => {
if (inputValue === "") return { status: "fail" };
const inputValueAsNumber = safeParseFloat(inputValue);
if (!inputValueAsNumber) return { status: "fail" };
const min = safeParseFloat(
parseVariables(options?.min?.toString(), { variables, sessionStore }),
);
const max = safeParseFloat(
parseVariables(options?.max?.toString(), { variables, sessionStore }),
);
if (min && inputValueAsNumber < min) return { status: "fail" };
if (max && inputValueAsNumber > max) return { status: "fail" };
// Edge case, return the inputValue as is if starting with 0
if (inputValue.startsWith("0"))
return { status: "success", content: inputValue };
return {
status: "success",
content: Intl.NumberFormat(
options?.locale,
parseFormatOptions(options),
).format(inputValueAsNumber),
};
};

View File

@ -1,38 +0,0 @@
import type { NumberInputBlock } from "@typebot.io/blocks-inputs/number/schema";
import { isNotDefined } from "@typebot.io/lib/utils";
import type { SessionStore } from "@typebot.io/runtime-session-store";
import { parseVariables } from "@typebot.io/variables/parseVariables";
import type { Variable } from "@typebot.io/variables/schemas";
export const validateNumber = (
inputValue: string,
{
options,
variables,
sessionStore,
}: {
options: NumberInputBlock["options"];
variables: Variable[];
sessionStore: SessionStore;
},
) => {
if (inputValue === "") return false;
const parsedNumber = Number(
inputValue.includes(",") ? inputValue.replace(",", ".") : inputValue,
);
if (isNaN(parsedNumber)) return false;
const min =
options?.min && typeof options.min === "string"
? Number(parseVariables(options.min, { variables, sessionStore }))
: undefined;
const max =
options?.max && typeof options.max === "string"
? Number(parseVariables(options.max, { variables, sessionStore }))
: undefined;
return (
(isNotDefined(min) || parsedNumber >= min) &&
(isNotDefined(max) || parsedNumber <= max)
);
};

View File

@ -40,7 +40,6 @@ import { parseButtonsReply } from "./blocks/inputs/buttons/parseButtonsReply";
import { parseDateReply } from "./blocks/inputs/date/parseDateReply";
import { formatEmail } from "./blocks/inputs/email/formatEmail";
import { parseNumber } from "./blocks/inputs/number/parseNumber";
import { validateNumber } from "./blocks/inputs/number/validateNumber";
import { formatPhoneNumber } from "./blocks/inputs/phone/formatPhoneNumber";
import { parsePictureChoicesReply } from "./blocks/inputs/pictureChoice/parsePictureChoicesReply";
import { validateRatingReply } from "./blocks/inputs/rating/validateRatingReply";
@ -760,13 +759,11 @@ const parseReply = async (
}
case InputBlockType.NUMBER: {
if (!reply || reply.type !== "text") return { status: "fail" };
const isValid = validateNumber(reply.text, {
return parseNumber(reply.text, {
options: block.options,
variables: state.typebotsQueue[0].typebot.variables,
sessionStore,
});
if (!isValid) return { status: "fail" };
return { status: "success", content: parseNumber(reply.text) };
}
case InputBlockType.DATE: {
if (!reply || reply.type !== "text") return { status: "fail" };

View File

@ -0,0 +1,16 @@
import type { JSX } from "solid-js/jsx-runtime";
export const ChevronUpIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
{...props}
>
<path d="M18 15l-6-6-6 6" />
</svg>
);

View File

@ -1,11 +1,18 @@
import { SendButton } from "@/components/SendButton";
import { ChevronDownIcon } from "@/components/icons/ChevronDownIcon";
import { ChevronUpIcon } from "@/components/icons/ChevronUpIcon";
import type { CommandData } from "@/features/commands/types";
import type { InputSubmitContent } from "@/types";
import { isMobile } from "@/utils/isMobileSignal";
import { defaultNumberInputOptions } from "@typebot.io/blocks-inputs/number/constants";
import { NumberInput as ArkNumberInput, useNumberInput } from "@ark-ui/solid";
import {
defaultNumberInputButtonLabel,
defaultNumberInputPlaceholder,
} from "@typebot.io/blocks-inputs/number/constants";
import type { NumberInputBlock } from "@typebot.io/blocks-inputs/number/schema";
import { createSignal, onCleanup, onMount } from "solid-js";
import { numberInputHelper } from "../numberInputHelper";
import { parseFormatOptions } from "@typebot.io/bot-engine/blocks/inputs/number/parseFormatOptions";
import { safeParseFloat } from "@typebot.io/lib/safeParseFloat";
import { onCleanup, onMount } from "solid-js";
type NumberInputProps = {
block: NumberInputBlock;
@ -14,24 +21,30 @@ type NumberInputProps = {
};
export const NumberInput = (props: NumberInputProps) => {
const [inputValue, setInputValue] = createSignal<string | number>(
props.defaultValue ?? "",
);
const [staticValue, bindValue, targetValue] = numberInputHelper(() =>
inputValue(),
);
const numberInput = useNumberInput({
locale: props.block.options?.locale,
formatOptions: parseFormatOptions(props.block.options),
min: safeParseFloat(props.block.options?.min),
max: safeParseFloat(props.block.options?.max),
step: safeParseFloat(props.block.options?.step),
});
let inputRef: HTMLInputElement | undefined;
const checkIfInputIsValid = () =>
inputRef?.value !== "" && inputRef?.reportValidity();
const isInputValid = () => {
if (numberInput().invalid) {
inputRef?.reportValidity();
return false;
}
return true;
};
const submit = () => {
if (checkIfInputIsValid())
if (isInputValid()) {
props.onSubmit({
type: "text",
value: inputRef?.value ?? inputValue().toString(),
value: numberInput().valueAsNumber.toString(),
});
else inputRef?.focus();
} else numberInput().focus();
};
const submitWhenEnter = (e: KeyboardEvent) => {
@ -50,7 +63,8 @@ export const NumberInput = (props: NumberInputProps) => {
const processIncomingEvent = (event: MessageEvent<CommandData>) => {
const { data } = event;
if (!data.isFromTypebot) return;
if (data.command === "setInputValue") setInputValue(data.value);
if (data.command === "setInputValue")
numberInput().setValue(Number(data.value));
};
return (
@ -58,30 +72,30 @@ export const NumberInput = (props: NumberInputProps) => {
class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
onKeyDown={submitWhenEnter}
>
<div class={"flex typebot-input w-full"}>
<input
<ArkNumberInput.RootProvider
value={numberInput}
class="flex typebot-input w-full"
>
<ArkNumberInput.Input
ref={inputRef}
class="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
style={{ "font-size": "16px", appearance: "auto" }}
value={staticValue}
// @ts-expect-error not defined
// eslint-disable-next-line solid/jsx-no-undef
use:bindValue
placeholder={
props.block.options?.labels?.placeholder ??
defaultNumberInputOptions.labels.placeholder
defaultNumberInputPlaceholder
}
onInput={(e) => {
setInputValue(targetValue(e.currentTarget));
}}
type="number"
min={props.block.options?.min}
max={props.block.options?.max}
step={props.block.options?.step ?? "any"}
/>
</div>
<ArkNumberInput.Control class="flex flex-col rounded-r-md overflow-hidden divide-y h-[56px]">
<ArkNumberInput.IncrementTrigger class="flex items-center justify-center h-7 w-8 border-input-border border-l">
<ChevronUpIcon class="size-4" />
</ArkNumberInput.IncrementTrigger>
<ArkNumberInput.DecrementTrigger class="flex items-center justify-center h-7 w-8 border-input-border border-l">
<ChevronDownIcon class="size-4" />
</ArkNumberInput.DecrementTrigger>
</ArkNumberInput.Control>
</ArkNumberInput.RootProvider>
<SendButton type="button" class="h-[56px]" on:click={submit}>
{props.block.options?.labels?.button}
{props.block.options?.labels?.button ?? defaultNumberInputButtonLabel}
</SendButton>
</div>
);

View File

@ -52,6 +52,8 @@ const config = {
"rgba(var(--typebot-host-bubble-bg-rgb), var(--typebot-host-bubble-opacity));",
"host-bubble-border":
"rgba(var(--typebot-host-bubble-border-rgb), var(--typebot-host-bubble-border-opacity));",
"input-border":
"rgba(var(--typebot-input-border-rgb), var(--typebot-input-border-opacity));",
},
extend: {
blur: {

View File

@ -1,4 +1,6 @@
export const safeParseFloat = (value: string) => {
const parsedValue = Number.parseFloat(value);
export const safeParseFloat = (value: string | number | undefined) => {
if (typeof value === "number") return value;
if (!value) return undefined;
const parsedValue = Number.parseFloat(value.toString().replace(",", "."));
return isNaN(parsedValue) ? undefined : parsedValue;
};