mirror of
https://github.com/baptisteArno/typebot.io.git
synced 2026-06-13 21:02:56 +08:00
✨ Add format options in number input block (#2080)
This commit is contained in:
parent
ee0aaa9715
commit
f515ef108e
@ -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>
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@ -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)
|
||||
);
|
||||
};
|
||||
@ -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" };
|
||||
|
||||
16
packages/embeds/js/src/components/icons/ChevronUpIcon.tsx
Normal file
16
packages/embeds/js/src/components/icons/ChevronUpIcon.tsx
Normal 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>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user