Repository: metabase/metabase
Pull request #54502: 🤖 backported "Convert components of RuleEditor to Mantine"
Author: login: github-automation-metabase
Labels: name: was-backported
description: apply this to PRs that are themselves backports
Created: 2025-02-28T14:25:17Z | Merged: 2025-03-06T22:43:51Z

#54104

I needed to revert the `MultiSelect` that we're using in favor of the `DeprecatedSelect` component for v53 due to issues with Mantine v6. I'm not going to touch anything field values related since we've gotten this in a better state in v54. ([Context here](https://metaboat.slack.com/archives/C0645JP1W81/p1733841447645079?thread_ts=1733452591.247459&cid=C0645JP1W81), [and here](https://metaboat.slack.com/archives/C0645JP1W81/p1719927607282739?thread_ts=1719922533.008439&cid=C0645JP1W81)) 

Things will look a little disjointed, but it's growing pains with Mantine for now:

<img width="337" alt="image" src="https://github.com/user-attachments/assets/b8d0ed43-c8cc-4c1c-a27c-47f3aa1d4e1f" />


Tests and everything still work, and it should work in the SDK. I tested things manually and it seems to work. I'll fix the unit test tomorrow morning


Diff:
diff --git a/e2e/test/scenarios/visualizations-tabular/pivot_tables.cy.spec.js b/e2e/test/scenarios/visualizations-tabular/pivot_tables.cy.spec.js
index 0adc505589a9..2c712f25c04e 100644
--- a/e2e/test/scenarios/visualizations-tabular/pivot_tables.cy.spec.js
+++ b/e2e/test/scenarios/visualizations-tabular/pivot_tables.cy.spec.js
@@ -929,11 +929,10 @@ describe("scenarios > visualizations > pivot tables", { tags: "@slow" }, () => {
 
     // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
     cy.findByText("Add a rule").click();
-    cy.findByTestId("conditional-formatting-value-input").type("70");
+    cy.findByTestId("conditional-formatting-value-input").type("70").blur();
+    cy.findByTestId("conditional-formatting-value-operator-button").click();
     // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
-    cy.findByText("is equal to").click();
-    // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
-    cy.findByText("is less than or equal to").click();
+    cy.findByText("is less than or equal to").click({ force: true });
 
     cy.contains("[data-testid=pivot-table-cell]", "65.09").should(
       "have.css",
diff --git a/e2e/test/scenarios/visualizations-tabular/table.cy.spec.js b/e2e/test/scenarios/visualizations-tabular/table.cy.spec.js
index a9746f9483dc..7479e8a7cf45 100644
--- a/e2e/test/scenarios/visualizations-tabular/table.cy.spec.js
+++ b/e2e/test/scenarios/visualizations-tabular/table.cy.spec.js
@@ -415,7 +415,9 @@ describe("scenarios > visualizations > table > conditional formatting", () => {
       // popover should open automatically
       H.popover().findByText("Subtotal").click();
       cy.realPress("Escape");
-      cy.findByRole("button", { name: /is equal to/i }).click();
+      cy.findByTestId("conditional-formatting-value-operator-button").click({
+        force: true,
+      });
       H.popover().findByText("is less than").click();
 
       cy.findByTestId("conditional-formatting-value-input").type("20");
@@ -496,7 +498,7 @@ describe("scenarios > visualizations > table > conditional formatting", () => {
 
       //Check that is-true was applied by default to boolean field rule
       cy.findByTestId("conditional-formatting-value-operator-button").should(
-        "contain.text",
+        "have.value",
         "is true",
       );
 
diff --git a/frontend/src/metabase/ui/components/inputs/TextInputBlurChange/TextInputBlurChange.tsx b/frontend/src/metabase/ui/components/inputs/TextInputBlurChange/TextInputBlurChange.tsx
index a3f9034511f2..28df5d0dda99 100644
--- a/frontend/src/metabase/ui/components/inputs/TextInputBlurChange/TextInputBlurChange.tsx
+++ b/frontend/src/metabase/ui/components/inputs/TextInputBlurChange/TextInputBlurChange.tsx
@@ -6,28 +6,29 @@ import _ from "underscore";
 
 import { useUnmountLayout } from "metabase/hooks/use-unmount-layout";
 
-type Value = string | undefined;
+type TextInputRestProps = Omit<TextInputProps, "onBlur" | "ref">;
 
-export interface TextInputBlurChangeProps
-  extends Omit<TextInputProps, "value" | "onBlur" | "ref"> {
-  value: Value;
+export type TextInputBlurChangeProps<
+  T extends TextInputRestProps = TextInputRestProps,
+> = T & {
+  value: T["value"] | undefined;
   onBlurChange: (event: { target: HTMLInputElement }) => void;
-  normalize?: (value: Value) => Value;
-}
+  normalize?: (value?: T["value"] | undefined) => T["value"] | undefined;
+};
 
 /**
  * A wrapper around TextInput to be used with onBlurChange prop.
  *
  * In case you don't need it, use TextInput directly.
  */
-export function TextInputBlurChange({
+export function TextInputBlurChange<T extends TextInputProps = TextInputProps>({
   value,
   onChange,
   onBlurChange,
   normalize = value => value,
   ...restProps
-}: TextInputBlurChangeProps) {
-  const [internalValue, setInternalValue] = useState<Value>();
+}: TextInputBlurChangeProps<T>) {
+  const [internalValue, setInternalValue] = useState<T["value"]>();
   const ref = useRef<HTMLInputElement>(null);
 
   useLayoutEffect(() => setInternalValue(value), [value]);
diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingInputNumeric.tsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingInputNumeric.tsx
index ac308756c8ae..e055b4c719ef 100644
--- a/frontend/src/metabase/visualizations/components/settings/ChartSettingInputNumeric.tsx
+++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingInputNumeric.tsx
@@ -22,7 +22,8 @@ const ALLOWED_CHARS = new Set([
 
 // Note: there are more props than these that are provided by the viz settings
 // code, we just don't have types for them here.
-interface ChartSettingInputProps extends ChartSettingWidgetProps<number> {
+interface ChartSettingInputProps
+  extends Omit<ChartSettingWidgetProps<number>, "onChangeSettings"> {
   options?: {
     isInteger?: boolean;
     isNonNegative?: boolean;
@@ -30,6 +31,7 @@ interface ChartSettingInputProps extends ChartSettingWidgetProps<number> {
   id?: string;
   placeholder?: string;
   getDefault?: () => string;
+  className?: string;
 }
 
 export const ChartSettingInputNumeric = ({
@@ -39,6 +41,7 @@ export const ChartSettingInputNumeric = ({
   options,
   id,
   getDefault,
+  className,
 }: ChartSettingInputProps) => {
   const [inputValue, setInputValue] = useState<string>(value?.toString() ?? "");
   const defaultValueProps = getDefault ? { defaultValue: getDefault() } : {};
@@ -72,6 +75,7 @@ export const ChartSettingInputNumeric = ({
           setInputValue(String(num));
         }
       }}
+      className={className}
     />
   );
 };
diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingInputNumeric.unit.spec.tsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingInputNumeric.unit.spec.tsx
index 093ca9b0ce69..796e8bc0171b 100644
--- a/frontend/src/metabase/visualizations/components/settings/ChartSettingInputNumeric.unit.spec.tsx
+++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingInputNumeric.unit.spec.tsx
@@ -20,7 +20,6 @@ function setup({
     <ChartSettingInputNumeric
       value={value}
       onChange={onChange}
-      onChangeSettings={() => null}
       options={options}
     />,
   );
diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingToggle.tsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingToggle.tsx
index 4b49fdf1e487..434a6e5b2535 100644
--- a/frontend/src/metabase/visualizations/components/settings/ChartSettingToggle.tsx
+++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingToggle.tsx
@@ -7,7 +7,7 @@ export const ChartSettingToggle = ({
 }: {
   value: boolean;
   onChange: (value: boolean) => void;
-  id: string;
+  id?: string;
 }) => (
   <Switch
     labelPosition="left"
diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting/ChartSettingsTableFormatting.unit.spec.tsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting/ChartSettingsTableFormatting.unit.spec.tsx
index 57ad5927c2a6..47b1db5deb98 100644
--- a/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting/ChartSettingsTableFormatting.unit.spec.tsx
+++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting/ChartSettingsTableFormatting.unit.spec.tsx
@@ -1,7 +1,7 @@
 import userEvent from "@testing-library/user-event";
 import { useState } from "react";
 
-import { render, screen } from "__support__/ui";
+import { mockScrollIntoView, render, screen } from "__support__/ui";
 import type { ColumnFormattingSetting } from "metabase-types/api";
 import { createMockColumn } from "metabase-types/api/mocks";
 
@@ -98,6 +98,8 @@ const setup = (props = {}) => {
   render(<Wrapper {...props} />);
 };
 
+mockScrollIntoView();
+
 describe("ChartSettingsTableFormatting", () => {
   it("should allow you to add a rule", async () => {
     setup();
@@ -109,8 +111,9 @@ describe("ChartSettingsTableFormatting", () => {
       await screen.findByText("Which columns should be affected?"),
     );
 
-    expect(await screen.findByText("is equal to")).toBeInTheDocument();
-
+    expect(
+      await screen.findByTestId("conditional-formatting-value-operator-button"),
+    ).toHaveValue("is equal to");
     await userEvent.type(
       await screen.findByTestId("conditional-formatting-value-input"),
       "toucan",
diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting/RuleEditor.tsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting/RuleEditor.tsx
index 3353fc3952b4..5cd2d6c60d5c 100644
--- a/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting/RuleEditor.tsx
+++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting/RuleEditor.tsx
@@ -3,25 +3,37 @@ import { useMemo } from "react";
 import { msgid, ngettext, t } from "ttag";
 import _ from "underscore";
 
-import NumericInput from "metabase/components/NumericInput";
-import Button from "metabase/core/components/Button";
 import ColorRangeSelector from "metabase/core/components/ColorRangeSelector";
 import { ColorSelector } from "metabase/core/components/ColorSelector";
-import Input from "metabase/core/components/Input";
-import Radio from "metabase/core/components/Radio";
-import Select, { Option } from "metabase/core/components/Select";
-import Toggle from "metabase/core/components/Toggle";
+import {
+  default as DeprecatedSelect,
+  Option,
+} from "metabase/core/components/Select";
 import CS from "metabase/css/core/index.css";
-import { Flex } from "metabase/ui";
+import {
+  Box,
+  Button,
+  Select,
+  Stack,
+  Text,
+  TextInputBlurChange,
+} from "metabase/ui";
+import type { TextInputBlurChangeProps } from "metabase/ui/components/inputs/TextInputBlurChange/TextInputBlurChange";
 import { isBoolean } from "metabase-lib/v1/types/utils/isa";
 import type {
   ColumnFormattingOperator,
   ColumnFormattingSetting,
+  ColumnRangeFormattingSetting,
+  ColumnSingleFormattingSetting,
   ConditionalFormattingBooleanOperator,
   ConditionalFormattingComparisonOperator,
   DatasetColumn,
 } from "metabase-types/api";
 
+import { ChartSettingInputNumeric } from "../ChartSettingInputNumeric";
+import { ChartSettingRadio } from "../ChartSettingRadio";
+import { ChartSettingToggle } from "../ChartSettingToggle";
+
 import { COLORS, COLOR_RANGES, DEFAULTS_BY_TYPE } from "./constants";
 import { getOperatorsForColumns } from "./get-operators-for-columns";
 
@@ -34,7 +46,6 @@ interface RuleEditorProps {
   onRemove: () => void;
   canHighlightRow?: boolean;
 }
-
 interface SelectMultipleItemsReturned extends Array<string> {
   changedItem: string;
 }
@@ -66,204 +77,253 @@ export const RuleEditor = ({
     rule.operator !== "is-false";
 
   const handleColumnChange = (columns: SelectMultipleItemsReturned) => {
-    const _cols = columns.map(name => _.findWhere(cols, { name }));
+    const isFirstColumnAdd =
+      columns.length === 1 && columns[0] === columns.changedItem;
     const operatorUpdate: {
       operator?:
         | ConditionalFormattingBooleanOperator
         | ConditionalFormattingComparisonOperator;
-    } =
-      columns.length === 1 && columns[0] === columns.changedItem
-        ? {
-            operator: _cols.every(isBoolean) ? "is-true" : "=",
-          }
-        : {};
+    } = isFirstColumnAdd
+      ? {
+          operator: isBoolean(_.findWhere(cols, { name: columns[0] }))
+            ? "is-true"
+            : "=",
+        }
+      : {};
+
     onChange({ ...rule, columns, ...operatorUpdate });
   };
 
   return (
-    <div>
-      <h3 className={CS.mb1}>{t`Which columns should be affected?`}</h3>
-      <Select
-        value={rule.columns}
-        onChange={(e: { target: { value: SelectMultipleItemsReturned } }) =>
-          handleColumnChange(e.target.value)
-        }
-        isInitiallyOpen={rule.columns.length === 0}
-        placeholder={t`Choose a column`}
-        multiple
-      >
-        {cols.map(col => (
-          <Option
-            key={col.name}
-            value={col.name}
-            disabled={isFieldDisabled(col)}
-          >
-            {col.display_name}
-          </Option>
-        ))}
-      </Select>
+    <Stack spacing="lg">
+      <Stack spacing="xs">
+        <Text fw="bold" fz="lg">{t`Which columns should be affected?`}</Text>
+        <DeprecatedSelect
+          value={rule.columns}
+          onChange={(e: { target: { value: SelectMultipleItemsReturned } }) =>
+            handleColumnChange(e.target.value)
+          }
+          isInitiallyOpen={rule.columns.length === 0}
+          placeholder={t`Choose a column`}
+          multiple
+        >
+          {cols.map(col => (
+            <Option
+              key={col.name}
+              value={col.name}
+              disabled={isFieldDisabled(col)}
+            >
+              {col.display_name}
+            </Option>
+          ))}
+        </DeprecatedSelect>
+      </Stack>
       {isNumericRule && !isKeyRule && (
-        <div>
-          <h3 className={cx(CS.mt3, CS.mb1)}>{t`Formatting style`}</h3>
-          <Radio
-            value={rule.type}
+        <Stack spacing="xs">
+          <Text fw="bold" fz="lg">{t`Formatting style`}</Text>
+          <ChartSettingRadio
             options={[
               { name: t`Single color`, value: "single" },
               { name: t`Color range`, value: "range" },
             ]}
+            value={rule.type}
             onChange={type =>
               onChange({
-                ...DEFAULTS_BY_TYPE[type],
+                ...DEFAULTS_BY_TYPE[type as "single" | "range"],
                 columns: rule.columns,
               })
             }
-            vertical
           />
-        </div>
+        </Stack>
       )}
       {rule.type === "single" ? (
-        <div>
-          <h3 className={cx(CS.mt3, CS.mb1)}>
-            {ngettext(
-              msgid`When a cell in this column…`,
-              `When any cell in these columns…`,
-              selectedColumns.length,
-            )}
-          </h3>
-          <Select
-            disabled={selectedColumns.length === 0}
-            value={rule.operator}
-            onChange={(e: { target: { value: ColumnFormattingOperator } }) =>
-              onChange({ ...rule, operator: e.target.value })
-            }
-            buttonProps={{
-              "data-testid": "conditional-formatting-value-operator-button",
-            }}
-          >
-            {Object.entries(operators).map(([operator, operatorName]) => (
-              <Option key={operatorName} value={operator}>
-                {operatorName}
-              </Option>
-            ))}
-          </Select>
-          {hasOperand && isNumericRule && !isKeyRule ? (
-            <NumericInput
-              disabled={selectedColumns.length === 0}
-              data-testid="conditional-formatting-value-input"
-              className={INPUT_CLASSNAME}
-              type="number"
-              value={rule.value}
-              onChange={(value: string | number) =>
-                onChange({ ...rule, value })
-              }
-              placeholder="0"
-            />
-          ) : hasOperand ? (
-            <Input
-              disabled={selectedColumns.length === 0}
-              data-testid="conditional-formatting-value-input"
-              className={INPUT_CLASSNAME}
-              value={rule.value}
-              onChange={e => onChange({ ...rule, value: e.target.value })}
-              placeholder={t`Column value`}
-            />
-          ) : null}
-          <h3
-            className={cx(CS.mt3, CS.mb1)}
-          >{t`…turn its background this color:`}</h3>
-          <Flex align="left">
+        <>
+          <Stack spacing="xs">
+            <Text fw="bold" fz="lg">
+              {ngettext(
+                msgid`When a cell in this column…`,
+                `When any cell in these columns…`,
+                selectedColumns.length,
+              )}
+            </Text>
+            <Box>
+              <Select
+                value={rule.operator}
+                onChange={(operator: ColumnFormattingOperator) =>
+                  onChange({ ...rule, operator })
+                }
+                data={_.pairs(operators).map(([value, label]) => ({
+                  value,
+                  label,
+                }))}
+                data-testid="conditional-formatting-value-operator-button"
+              />
+              <RuleEditorValueInput
+                hasOperand={hasOperand}
+                isNumericRule={isNumericRule}
+                isKeyRule={isKeyRule}
+                rule={rule}
+                onChange={onChange}
+              />
+            </Box>
+          </Stack>
+          <Stack spacing="xs" align="flex-start">
+            <Text fw="bold" fz="lg">{t`…turn its background this color:`}</Text>
+
             <ColorSelector
               data-testid="conditional-formatting-color-selector"
               value={rule.color}
               colors={COLORS}
               onChange={color => onChange({ ...rule, color })}
             />
-          </Flex>
+          </Stack>
           {canHighlightRow && (
-            <>
-              <h3
-                className={cx(CS.mt3, CS.mb1)}
-              >{t`Highlight the whole row`}</h3>
+            <Stack spacing="xs">
+              <Text fw="bold" fz="lg">{t`Highlight the whole row`}</Text>
 
-              <Toggle
+              <ChartSettingToggle
                 value={rule.highlight_row}
-                onChange={highlight_row => onChange({ ...rule, highlight_row })}
+                onChange={value =>
+                  onChange({
+                    ...rule,
+                    highlight_row: value,
+                  })
+                }
               />
-            </>
+            </Stack>
           )}
-        </div>
+        </>
       ) : rule.type === "range" ? (
-        <div>
-          <h3 className={cx(CS.mt3, CS.mb1)}>{t`Colors`}</h3>
-          <ColorRangeSelector
-            value={rule.colors}
-            onChange={colors => {
-              onChange({ ...rule, colors });
-            }}
-            colors={COLORS}
-            colorRanges={COLOR_RANGES}
-          />
-          <h3 className={cx(CS.mt3, CS.mb1)}>{t`Start the range at`}</h3>
-          <Radio
-            value={rule.min_type}
-            onChange={min_type => onChange({ ...rule, min_type })}
-            options={(rule.columns.length <= 1
-              ? [{ name: t`Smallest value in this column`, value: null }]
-              : [
-                  { name: t`Smallest value in each column`, value: null },
-                  {
-                    name: t`Smallest value in all of these columns`,
-                    value: "all",
-                  },
-                ]
-            ).concat([{ name: t`Custom value`, value: "custom" }])}
-            vertical
-          />
-          {rule.min_type === "custom" && (
-            <NumericInput
-              className={INPUT_CLASSNAME}
-              type="number"
-              value={rule.min_value}
-              onChange={(min_value: number) => onChange({ ...rule, min_value })}
+        <>
+          <Stack spacing="xs">
+            <Text fw="bold" fz="lg">{t`Colors`}</Text>
+            <ColorRangeSelector
+              value={rule.colors}
+              onChange={colors => {
+                onChange({ ...rule, colors });
+              }}
+              colors={COLORS}
+              colorRanges={COLOR_RANGES}
             />
-          )}
-          <h3 className={cx(CS.mt3, CS.mb1)}>{t`End the range at`}</h3>
-          <Radio
-            value={rule.max_type}
-            onChange={max_type => onChange({ ...rule, max_type })}
-            options={(rule.columns.length <= 1
-              ? [{ name: t`Largest value in this column`, value: null }]
-              : [
-                  { name: t`Largest value in each column`, value: null },
-                  {
-                    name: t`Largest value in all of these columns`,
-                    value: "all",
-                  },
-                ]
-            ).concat([{ name: t`Custom value`, value: "custom" }])}
-            vertical
-          />
-          {rule.max_type === "custom" && (
-            <NumericInput
-              className={INPUT_CLASSNAME}
-              type="number"
-              value={rule.max_value}
-              onChange={(max_value: number) => onChange({ ...rule, max_value })}
+          </Stack>
+          <Stack spacing="xs">
+            <Text fw="bold" fz="lg">{t`Start the range at`}</Text>
+            <ChartSettingRadio
+              value={rule.min_type}
+              onChange={min_type =>
+                onChange({
+                  ...rule,
+                  min_type:
+                    min_type as ColumnRangeFormattingSetting["min_type"],
+                })
+              }
+              options={(rule.columns.length <= 1
+                ? [{ name: t`Smallest value in this column`, value: null }]
+                : [
+                    { name: t`Smallest value in each column`, value: null },
+                    {
+                      name: t`Smallest value in all of these columns`,
+                      value: "all",
+                    },
+                  ]
+              ).concat([{ name: t`Custom value`, value: "custom" }])}
             />
-          )}
-        </div>
+            {rule.min_type === "custom" && (
+              <ChartSettingInputNumeric
+                className={INPUT_CLASSNAME}
+                value={rule.min_value}
+                onChange={min_value =>
+                  onChange({ ...rule, min_value: min_value ?? undefined })
+                }
+              />
+            )}
+          </Stack>
+          <Stack spacing="xs">
+            <Text fw="bold" fz="lg">{t`End the range at`}</Text>
+            <ChartSettingRadio
+              value={rule.max_type}
+              onChange={max_type =>
+                onChange({
+                  ...rule,
+                  max_type:
+                    max_type as ColumnRangeFormattingSetting["max_type"],
+                })
+              }
+              options={(rule.columns.length <= 1
+                ? [{ name: t`Largest value in this column`, value: null }]
+                : [
+                    { name: t`Largest value in each column`, value: null },
+                    {
+                      name: t`Largest value in all of these columns`,
+                      value: "all",
+                    },
+                  ]
+              ).concat([{ name: t`Custom value`, value: "custom" }])}
+            />
+            {rule.max_type === "custom" && (
+              <ChartSettingInputNumeric
+                className={INPUT_CLASSNAME}
+                value={rule.max_value}
+                onChange={max_value =>
+                  onChange({ ...rule, max_value: max_value ?? undefined })
+                }
+              />
+            )}
+          </Stack>
+        </>
       ) : null}
-      <div className={CS.mt4}>
+      <Box>
         {rule.columns.length === 0 ? (
-          <Button primary onClick={onRemove}>
+          <Button variant="filled" onClick={onRemove}>
             {isNew ? t`Cancel` : t`Delete`}
           </Button>
         ) : (
-          <Button primary onClick={onDone}>
+          <Button variant="filled" onClick={onDone}>
             {isNew ? t`Add rule` : t`Update rule`}
           </Button>
         )}
-      </div>
-    </div>
+      </Box>
+    </Stack>
+  );
+};
+
+const RuleEditorValueInput = ({
+  hasOperand,
+  isNumericRule,
+  isKeyRule,
+  rule,
+  onChange,
+}: {
+  hasOperand: boolean;
+  isNumericRule: boolean;
+  isKeyRule: boolean;
+  rule: ColumnSingleFormattingSetting;
+  onChange: (rule: ColumnFormattingSetting) => void;
+}) => {
+  if (!hasOperand) {
+    return null;
+  }
+
+  const inputProps: Partial<TextInputBlurChangeProps> =
+    isNumericRule && !isKeyRule
+      ? {
+          type: "number",
+          placeholder: "0",
+          onBlurChange: e =>
+            onChange({ ...rule, value: e.target.valueAsNumber ?? "" }),
+        }
+      : {
+          type: "text",
+          placeholder: t`Column value`,
+        };
+
+  return (
+    <TextInputBlurChange
+      data-testid="conditional-formatting-value-input"
+      className={INPUT_CLASSNAME}
+      value={rule.value}
+      onBlurChange={e => onChange({ ...rule, value: e.target.value })}
+      {...inputProps}
+    />
   );
 };
diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting/RulePreview.tsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting/RulePreview.tsx
index 95e7f4904a08..bda53e68500e 100644
--- a/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting/RulePreview.tsx
+++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting/RulePreview.tsx
@@ -7,6 +7,7 @@ import CS from "metabase/css/core/index.css";
 import {
   ActionIcon,
   Divider,
+  Flex,
   Group,
   Icon,
   Paper,
@@ -40,7 +41,7 @@ export const RulePreview = ({
     data-testid="formatting-rule-preview"
     {...paperProps}
   >
-    <Group noWrap px="md" bg="bg-light">
+    <Flex wrap="nowrap" px="md" align="center" bg="bg-light">
       <Text className={CS.flex1} fw="bold" fz="md">
         {rule.columns.length > 0 ? (
           rule.columns
@@ -58,7 +59,7 @@ export const RulePreview = ({
       >
         <Icon name="close" />
       </ActionIcon>
-    </Group>
+    </Flex>
     <Divider></Divider>
     <Group noWrap p="md" spacing="xs">
       <RuleBackground


Reviews:
user: login: github-actions[bot]
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-02-28T14:25:20Z
user: login: npretto
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T16:37:25Z
user: login: oisincoveney
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-04T20:07:25Z

Review comments:
diff_hunk: @@ -129,15 +132,15 @@ describe("ChartSettingsTableFormatting", () => {
 
     expect(
       await screen.findByRole("option", { name: "Number Column" }),
-    ).toHaveAttribute("aria-disabled", "true");
+    ).toHaveAttribute("data-disabled", "true");
 
     expect(
       await screen.findByRole("option", { name: "Boolean Column" }),
path: frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting/ChartSettingsTableFormatting.unit.spec.tsx
user: login: npretto
body: https://github.com/metabase/metabase/actions/runs/13644204767/job/38140839105?pr=54502#step:6:1111
@oisincoveney looks like one of those tests are failing
created_at: 2025-03-04T16:37:25Z
author_association: MEMBER
diff_hunk: @@ -129,15 +132,15 @@ describe("ChartSettingsTableFormatting", () => {
 
     expect(
       await screen.findByRole("option", { name: "Number Column" }),
-    ).toHaveAttribute("aria-disabled", "true");
+    ).toHaveAttribute("data-disabled", "true");
 
     expect(
       await screen.findByRole("option", { name: "Boolean Column" }),
path: frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting/ChartSettingsTableFormatting.unit.spec.tsx
user: login: oisincoveney
body: Yep, mentioned it in the description, just wanted to get some eyes on this thing first.
created_at: 2025-03-04T20:07:24Z
author_association: NONE

PR comments:
user: login: trunk-io[bot]
created_at: 2025-02-28T15:21:49Z
body: <!-- Trunk Test Analytics -->


[![Static Badge](https://img.shields.io/badge/23246_tests-5d4e7ce7-purple)](https://app.trunk.io/metabase/flaky-tests/pr/54502?repo=metabase/metabase&commitHash=5d4e7ce7553ca9a457198e23ab1e5c30e17c7152&utm_source=gh-comment) &ensp; [![Static Badge](https://img.shields.io/badge/0-failed-crimson)](https://app.trunk.io/metabase/flaky-tests/pr/54502?repo=metabase/metabase&commitHash=5d4e7ce7553ca9a457198e23ab1e5c30e17c7152&utm_source=gh-comment&runConclusion=FAILURE) &ensp; [![Static Badge](https://img.shields.io/badge/0-flaky-red)](https://app.trunk.io/metabase/flaky-tests/pr/54502?repo=metabase/metabase&commitHash=5d4e7ce7553ca9a457198e23ab1e5c30e17c7152&utm_source=gh-comment&runConclusion=FAILURE) &ensp; [![Static Badge](https://img.shields.io/badge/0-quarantined-yellow)](https://app.trunk.io/metabase/flaky-tests/pr/54502?repo=metabase/metabase&commitHash=5d4e7ce7553ca9a457198e23ab1e5c30e17c7152&utm_source=gh-comment&runConclusion=QUARANTINED)


<sub>

[View Full Report ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/54502?repo=metabase/metabase&commitHash=5d4e7ce7553ca9a457198e23ab1e5c30e17c7152&utm_source=gh-comment) ⋅ [Docs](https://docs.trunk.io/flaky-tests?utm_source=gh-comment)

</sub>
author_association: NONE
user: login: trunk-io[bot]
created_at: 2025-02-28T15:21:50Z
body: <!-- Trunk Test Analytics -->


[![Static Badge](https://img.shields.io/badge/13198_tests-e089a203-purple)](https://app.trunk.io/metabase/flaky-tests/pr/54502?repo=metabase/metabase&commitHash=e089a20331e45e9ee2ed778844719905cf9b5872&utm_source=gh-comment) &ensp; [![Static Badge](https://img.shields.io/badge/3-failed-crimson)](https://app.trunk.io/metabase/flaky-tests/pr/54502?repo=metabase/metabase&commitHash=e089a20331e45e9ee2ed778844719905cf9b5872&utm_source=gh-comment&runConclusion=FAILURE) &ensp; [![Static Badge](https://img.shields.io/badge/0-flaky-red)](https://app.trunk.io/metabase/flaky-tests/pr/54502?repo=metabase/metabase&commitHash=e089a20331e45e9ee2ed778844719905cf9b5872&utm_source=gh-comment&runConclusion=FAILURE) &ensp; [![Static Badge](https://img.shields.io/badge/0-quarantined-yellow)](https://app.trunk.io/metabase/flaky-tests/pr/54502?repo=metabase/metabase&commitHash=e089a20331e45e9ee2ed778844719905cf9b5872&utm_source=gh-comment&runConclusion=QUARANTINED)


| Failed Test | Failure Summary | Logs |
| --- | --- | --- |
| <code>scenarios > visualizations > table > conditional formatting operators should work with boolean columns</code> | The expected element \`.popover[data-state~='visible'],[data-element-id=mantine-popover]\` was not found within 4000ms. | [Logs ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/54502?repo=metabase/metabase&commitHash=e089a20331e45e9ee2ed778844719905cf9b5872&utm_source=gh-comment&runConclusion=FAILURE&runId=8a138046-bcff-507e-b283-c34c28b224fa) |
| <code>scenarios > visualizations > table > conditional formatting rules should be able to remove, add, and re-order rows</code> | The expected element \`.popover[data-state~='visible'],[data-element-id=mantine-popover]\` was not found within 4000ms. | [Logs ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/54502?repo=metabase/metabase&commitHash=e089a20331e45e9ee2ed778844719905cf9b5872&utm_source=gh-comment&runConclusion=FAILURE&runId=24b352cf-9fda-54fd-a0ce-60492f563f8c) |
| <code>scenarios > visualizations > pivot tables should apply conditional formatting</code> | The test timed out while trying to find an element with the text "is equal to". | [Logs ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/54502?repo=metabase/metabase&commitHash=e089a20331e45e9ee2ed778844719905cf9b5872&utm_source=gh-comment&runConclusion=FAILURE&runId=2e0f12ce-e87b-5614-a0e1-00032e59d1aa) |
<sub>

[View Full Report ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/54502?repo=metabase/metabase&commitHash=e089a20331e45e9ee2ed778844719905cf9b5872&utm_source=gh-comment) ⋅ [Docs](https://docs.trunk.io/flaky-tests?utm_source=gh-comment)

</sub>
author_association: NONE