feat(functions): replace language radios with a segmented toggle (#13766)

The Add Function header selected VRL vs JavaScript with a radio group
and a single info icon. That icon only ever described the
ALREADY-SELECTED language, so learning what the other one was for meant
switching to it first.

- Radio group -> OFormToggleGroup segmented control with a sliding
indicator, each option carrying a language mark (V / JS). The badge
variants match the transType column in FunctionList so VRL/JS read the
same everywhere.
- Give EACH option its own info icon and static tooltip, so both
languages are readable without selecting either. Forced-language hosts
(workflow function nodes are JS-only) hide the toggle, so the locked
language's tip moves to a standalone icon.
- Add a pipe divider between the two options, and one between the
function-name title and the toggle.

data-test renames `...-vrl-radio` -> `...-vrl-option` (same for js):
"radio" described the old implementation. E2E page objects would have
broken on this change regardless of naming -- toggle items expose
data-state="on|off", not the radio group's "checked|unchecked".

Verified: FunctionsToolbar.spec.ts 18/18 pass, vue-tsc clean. ESLint
could not be run locally (@intlify/eslint-plugin-vue-i18n missing from
node_modules).
This commit is contained in:
Bhargav
2026-08-11 13:25:45 +00:00
committed by GitHub
parent bf27a488d6
commit b07b3d4229
4 changed files with 193 additions and 79 deletions
@@ -63,9 +63,9 @@ class FunctionsFormValidationPage {
// ==================== FunctionsToolbar locators ====================
// VRL / JS transform type radios
this.vrlRadio = page.locator('[data-test="function-transform-type-vrl-radio"]');
this.jsRadio = page.locator('[data-test="function-transform-type-js-radio"]');
// VRL / JS language toggle (OToggleGroup items)
this.vrlOption = page.locator('[data-test="function-transform-type-vrl-option"]');
this.jsOption = page.locator('[data-test="function-transform-type-js-option"]');
// ==================== StreamRouting locators ====================
@@ -253,14 +253,11 @@ class FunctionsFormValidationPage {
// ==================== FunctionsToolbar assertion helpers ====================
async assertVrlRadioSelected() {
testLogger.info('Asserting VRL radio is checked');
await this.vrlRadio.waitFor({ state: 'visible', timeout: 10000 });
// ORadio renders a native input — check aria-checked or checked attribute
const checked = await this.vrlRadio.evaluate(el => {
const input = el.querySelector('input[type="radio"]') || el.closest('[role="radio"]') || el;
return input.checked || el.getAttribute('aria-checked') === 'true' || el.getAttribute('data-state') === 'checked';
});
expect(checked).toBe(true);
testLogger.info('Asserting the VRL language option is selected');
await this.vrlOption.waitFor({ state: 'visible', timeout: 10000 });
// OToggleGroupItem exposes selection via Reka-UI's `data-state="on|off"`
// (and mirrors it on aria-pressed).
await expect(this.vrlOption).toHaveAttribute('data-state', 'on');
}
// ==================== StreamRouting actions ====================
@@ -20,12 +20,11 @@ class FunctionsPage {
this.rowEditButton = this.page.locator(this.rowEditButtonSelector);
this.rowDeleteButton = this.page.locator(this.rowDeleteButtonSelector);
// Function type selection (Reka-UI radios — wrapper divs carry the data-test).
// The inner RadioGroupItem renders data-state="checked|unchecked" — we read it
// via page.evaluate scoped from the wrapper's data-test locator (no element-tag
// or class selectors per AGENT_RULES §2).
this.vrlRadio = this.page.locator('[data-test="function-transform-type-vrl-radio"]');
this.jsRadio = this.page.locator('[data-test="function-transform-type-js-radio"]');
// Language selection — a segmented toggle (OToggleGroup). The data-test lands
// on the ToggleGroupItem button itself, which Reka-UI marks with
// data-state="on|off" (NOT the radio group's "checked|unchecked").
this.vrlOption = this.page.locator('[data-test="function-transform-type-vrl-option"]');
this.jsOption = this.page.locator('[data-test="function-transform-type-js-option"]');
// Function form elements. The name is an inline-edited title (OFormInlineEdit):
// a display trigger swaps to an input on click.
@@ -126,18 +125,18 @@ class FunctionsPage {
}
async selectJavaScriptType() {
// ORadio forwards the consumer's data-test onto the inner RadioGroupItem
// (the ARIA-focusable radio button) — `this.jsRadio` IS the clickable item.
await expect(this.jsRadio).toBeVisible();
await this.jsRadio.click();
// OToggleGroupItem forwards the consumer's data-test onto the item button —
// `this.jsOption` IS the clickable item.
await expect(this.jsOption).toBeVisible();
await this.jsOption.click();
// Wait for CodeQueryEditor to remount Monaco for the new language
// (editor-id is re-keyed on currentLanguage in QueryEditor.vue)
await this.page.waitForTimeout(1500);
}
async selectVRLType() {
await expect(this.vrlRadio).toBeVisible();
await this.vrlRadio.click();
await expect(this.vrlOption).toBeVisible();
await this.vrlOption.click();
await this.page.waitForTimeout(1500);
}
@@ -316,23 +315,23 @@ class FunctionsPage {
}
async expectVrlRadioVisible() {
await expect(this.vrlRadio).toBeVisible();
await expect(this.vrlOption).toBeVisible();
}
async expectJsRadioVisible() {
await expect(this.jsRadio).toBeVisible();
await expect(this.jsOption).toBeVisible();
}
async expectJsRadioSelected() {
// ORadio forwards the consumer's data-test onto the inner RadioGroupItem,
// which Reka-UI exposes via `data-state="checked|unchecked"`.
await this.jsRadio.waitFor({ state: 'visible' });
await expect(this.jsRadio).toHaveAttribute('data-state', 'checked');
// OToggleGroupItem forwards the consumer's data-test onto the item button,
// which Reka-UI exposes via `data-state="on|off"`.
await this.jsOption.waitFor({ state: 'visible' });
await expect(this.jsOption).toHaveAttribute('data-state', 'on');
}
async expectVrlRadioSelected() {
await this.vrlRadio.waitFor({ state: 'visible' });
await expect(this.vrlRadio).toHaveAttribute('data-state', 'checked');
await this.vrlOption.waitFor({ state: 'visible' });
await expect(this.vrlOption).toHaveAttribute('data-state', 'on');
}
async expectTestOutputContains(text) {
@@ -377,11 +376,11 @@ class FunctionsPage {
}
async isJsRadioVisible() {
return await this.jsRadio.isVisible().catch(() => false);
return await this.jsOption.isVisible().catch(() => false);
}
async expectJsRadioHidden() {
await expect(this.jsRadio).not.toBeVisible();
await expect(this.jsOption).not.toBeVisible();
}
// ==================== Complex Workflows ====================
@@ -479,16 +478,16 @@ class FunctionsPage {
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
await this.page.waitForTimeout(2000);
// Wait for radio buttons to be visible
await this.jsRadio.waitFor({ state: 'visible', timeout: 10000 });
// Wait for the language toggle to be visible
await this.jsOption.waitFor({ state: 'visible', timeout: 10000 });
// ORadio forwards the consumer's data-test onto the inner RadioGroupItem,
// which Reka-UI exposes via `data-state="checked|unchecked"`.
const jsState = await this.jsRadio.getAttribute('data-state');
const vrlState = await this.vrlRadio.getAttribute('data-state');
// OToggleGroupItem forwards the consumer's data-test onto the item button,
// which Reka-UI exposes via `data-state="on|off"`.
const jsState = await this.jsOption.getAttribute('data-state');
const vrlState = await this.vrlOption.getAttribute('data-state');
if (jsState === 'checked') return 'js';
if (vrlState === 'checked') return 'vrl';
if (jsState === 'on') return 'js';
if (vrlState === 'on') return 'vrl';
return null;
}
@@ -125,28 +125,73 @@ describe("FunctionsToolbar", () => {
expect(wrapper.find('[data-test="add-function-name-input-value"]').exists()).toBe(true);
});
it("should render VRL radio button", () => {
it("should render the VRL language option", () => {
const wrapper = mountToolbar({
transformTypeOptions: [{ label: "VRL", value: "0" }],
});
expect(wrapper.find('[data-test="function-transform-type-vrl-radio"]').exists()).toBe(true);
expect(wrapper.find('[data-test="function-transform-type-vrl-option"]').exists()).toBe(true);
});
it("should render JavaScript radio button when option is provided", () => {
it("should render the JavaScript language option when it is provided", () => {
const wrapper = mountToolbar({
transformTypeOptions: [
{ label: "VRL", value: "0" },
{ label: "JavaScript", value: "1" },
],
});
expect(wrapper.find('[data-test="function-transform-type-js-radio"]').exists()).toBe(true);
expect(wrapper.find('[data-test="function-transform-type-js-option"]').exists()).toBe(true);
});
it("should NOT render the JavaScript radio when only one option is provided", () => {
it("should NOT render the JavaScript option when only one option is provided", () => {
const wrapper = mountToolbar({
transformTypeOptions: [{ label: "VRL", value: "0" }],
});
expect(wrapper.find('[data-test="function-transform-type-js-radio"]').exists()).toBe(false);
expect(wrapper.find('[data-test="function-transform-type-js-option"]').exists()).toBe(false);
});
it("marks the option matching the form's transType as selected", () => {
const wrapper = mountToolbar(
{
transformTypeOptions: [
{ label: "VRL", value: "0" },
{ label: "JavaScript", value: "1" },
],
},
{ name: "fn", transType: "1" },
);
expect(
wrapper.find('[data-test="function-transform-type-js-option"]').attributes("data-state"),
).toBe("on");
expect(
wrapper.find('[data-test="function-transform-type-vrl-option"]').attributes("data-state"),
).toBe("off");
});
it("writes the picked language to the form when an option is clicked", async () => {
const wrapper = mountToolbar({
transformTypeOptions: [
{ label: "VRL", value: "0" },
{ label: "JavaScript", value: "1" },
],
});
await wrapper.find('[data-test="function-transform-type-js-option"]').trigger("click");
expect(getForm(wrapper).state.values.transType).toBe("1");
});
it("offers an info tip for BOTH languages without selecting either", () => {
const wrapper = mountToolbar({
transformTypeOptions: [
{ label: "VRL", value: "0" },
{ label: "JavaScript", value: "1" },
],
});
// transType defaults to "0" (VRL) — the JS tip must still be reachable.
expect(wrapper.find('[data-test="function-transform-type-vrl-info"]').exists()).toBe(true);
expect(wrapper.find('[data-test="function-transform-type-js-info"]').exists()).toBe(true);
});
it("hides the language toggle entirely when hideTransType is set", () => {
@@ -164,8 +209,10 @@ describe("FunctionsToolbar", () => {
},
});
expect(wrapper.find('[data-test="function-transform-type-vrl-radio"]').exists()).toBe(false);
expect(wrapper.find('[data-test="function-transform-type-js-radio"]').exists()).toBe(false);
expect(wrapper.find('[data-test="function-transform-type-vrl-option"]').exists()).toBe(false);
expect(wrapper.find('[data-test="function-transform-type-js-option"]').exists()).toBe(false);
// The locked language's tip stays reachable.
expect(wrapper.find('[data-test="function-transform-type-info"]').exists()).toBe(true);
});
it("should emit test event when test button is clicked", async () => {
+101 -30
View File
@@ -1,7 +1,7 @@
<template>
<!-- Standard app header: back tile + the function NAME as the title (inline-
edited in place, so it is not a boxed field wedged into the toolbar), the
mode as the subtitle, the transform-type radios inline (#tabs) and the
mode as the subtitle, the language toggle inline (#tabs) and the
action buttons (#actions). The name + transType controls are form-owned
(OForm*); the parent AddFunction.vue provides the <OForm> context they
inject — which is what lets the title live in the #title slot. -->
@@ -26,38 +26,107 @@
/>
</template>
<template #tabs>
<div class="o2-input flex items-center gap-6">
<!-- Transform Type Radio Buttons -->
<div class="o2-input flex h-full items-center gap-4">
<!-- Divider between the function name (header #title) and the language
toggle — they are separate controls sharing one header row. h-full +
the separator's own self-stretch span the full h-15 header row; my-2
insets it to ~2.75rem, so it runs past the subtitle line ("Add
Function") rather than stopping at the name. -->
<OSeparator vertical class="my-2" />
<!-- Transform-type (language) selector -->
<div class="flex h-9 items-center gap-4">
<!-- Language toggle hidden when a host forces a single language
(e.g. workflow function nodes are JS-only); the info tip stays. -->
<OFormRadioGroup
<!-- A segmented toggle rather than a radio group: the two languages are
mutually exclusive VIEWS of the same editor, so they read as a
switch. Each option carries its OWN info tip, so both languages can
be understood WITHOUT selecting one first (the previous single tip
only ever described the already-selected language).
Hidden entirely when a host forces a single language (e.g. workflow
function nodes are JS-only) — the locked language's tip moves to the
standalone icon below. -->
<OFormToggleGroup
v-if="!hideTransType"
name="transType"
orientation="horizontal"
class="items-center gap-4"
type="single"
data-test="function-transform-type-toggle"
>
<div class="flex items-center gap-1">
<ORadio value="0" data-test="function-transform-type-vrl-radio" />
<span class="text-compact leading-none font-medium">{{
transformTypeOptions[0]?.label
}}</span>
</div>
<OToggleGroupItem value="0" data-test="function-transform-type-vrl-option">
<template #icon-left>
<!-- Language mark. Colours match the transType column badge in
FunctionList so VRL/JS read the same everywhere. -->
<OBadge size="xs" shape="rounded" variant="blue-soft">{{ raw("V") }}</OBadge>
</template>
{{ transformTypeOptions[0]?.label }}
<template #icon-right>
<OIcon
name="info-outline"
size="sm"
class="cursor-pointer opacity-70"
data-test="function-transform-type-vrl-info"
>
<OTooltip>
<template #content>
<!-- Wrap in one column container: OTooltip renders the
#content slot inside an inline-flex row, so sibling
blocks would sit side-by-side. A single flex-col child
keeps title over body. -->
<div class="flex flex-col">
<div class="mb-1 font-semibold">
{{ t("function.vrl") }} {{ t("function.tipLabel") }}
</div>
<div>{{ t("function.vrlFunctionHint") }}</div>
</div>
</template>
</OTooltip>
</OIcon>
</template>
</OToggleGroupItem>
<!-- Pipe divider between the two options -->
<OSeparator v-if="transformTypeOptions[1]" vertical class="my-1.5" />
<!-- JavaScript option only shown in _meta organization -->
<div v-if="transformTypeOptions[1]" class="flex items-center gap-1">
<ORadio value="1" data-test="function-transform-type-js-radio" />
<span class="text-compact leading-none font-medium">{{
transformTypeOptions[1]?.label
}}</span>
</div>
</OFormRadioGroup>
<!-- Info icon with tooltip -->
<OIcon name="info-outline" size="sm" class="text-icon-color shrink-0 cursor-pointer">
<OToggleGroupItem
v-if="transformTypeOptions[1]"
value="1"
data-test="function-transform-type-js-option"
>
<template #icon-left>
<OBadge size="xs" shape="rounded" variant="amber-soft">{{ raw("JS") }}</OBadge>
</template>
{{ transformTypeOptions[1]?.label }}
<template #icon-right>
<OIcon
name="info-outline"
size="sm"
class="cursor-pointer opacity-70"
data-test="function-transform-type-js-info"
>
<OTooltip>
<template #content>
<div class="flex flex-col">
<div class="mb-1 font-semibold">
{{ t("function.javascript") }} {{ t("function.tipLabel") }}
</div>
<div>{{ t("function.jsFunctionHint") }}</div>
</div>
</template>
</OTooltip>
</OIcon>
</template>
</OToggleGroupItem>
</OFormToggleGroup>
<!-- Forced-language hosts get no toggle, so the tip for the locked
language stays reachable here. -->
<OIcon
v-else
name="info-outline"
size="sm"
class="text-icon-color shrink-0 cursor-pointer"
data-test="function-transform-type-info"
>
<OTooltip>
<template #content>
<!-- Wrap in one column container: OTooltip renders the #content
slot inside an inline-flex row, so sibling blocks would sit
side-by-side. A single flex-col child keeps title over body. -->
<div class="flex flex-col">
<div class="mb-1 font-semibold">
{{ transTypeValue === "1" ? t("function.javascript") : t("function.vrl") }}
@@ -144,7 +213,7 @@
<script setup lang="ts">
import { ref, computed, type PropType } from "vue";
import { inject } from "vue";
import { useI18nTyped } from "@/types/i18n";
import { raw, useI18nTyped } from "@/types/i18n";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import { useTheme } from "@/composables/useTheme";
@@ -154,8 +223,10 @@ import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OFormInlineEdit from "@/lib/forms/InlineEdit/OFormInlineEdit.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OFormRadioGroup from "@/lib/forms/Radio/OFormRadioGroup.vue";
import ORadio from "@/lib/forms/Radio/ORadio.vue";
import OBadge from "@/lib/core/Badge/OBadge.vue";
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
import OFormToggleGroup from "@/lib/core/ToggleGroup/OFormToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import OPageHeader from "@/lib/core/PageHeader/OPageHeader.vue";
import { toggleFullscreen } from "@/utils/dom";
import { FORM_CONTEXT_KEY } from "@/lib/forms/Form/OForm.types";
@@ -193,7 +264,7 @@ const emit = defineEmits(["test", "back", "cancel", "open:chat"]);
const isHovered = ref(false);
// The name + transType fields are form-owned (OForm*). We only READ transType
// here (for the info tooltip) via the injected OForm context.
// here (for the forced-language info tooltip) via the injected OForm context.
const form = inject(FORM_CONTEXT_KEY, null);
const transTypeValue = form
? form.useStore((s: any) => String(s.values.transType ?? "0"))