mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user