feat: ux improvement main (#11838)

#11720

Design at:

https://github.com/openobserve/designs/blob/main/dashboards/ux-improvement-dialog/design.md

---------

---------

Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com>
Co-authored-by: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com>
Co-authored-by: Sai Nikhil <nikhil@openobserve.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: ktx-akshay <akshay.patil@kiara.tech>
Co-authored-by: Bhargav <BJP232004@GMAIL.COM>
Co-authored-by: ktx-mihir <mihir.patel@kiara.tech>
Co-authored-by: ktx-riya <riya.kukadiya@kiara.tech>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Omkar Kesarkhane <127078473+omkarK06@users.noreply.github.com>
Co-authored-by: omkark06 <omkar@zinclabs.io>
Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech>
Co-authored-by: ktx-abhay <abhay.padamani@kiara.tech>
Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
This commit is contained in:
ktx-kirtan
2026-05-28 11:00:49 +00:00
committed by GitHub
co-authored by ktx-vaidehi Abhay Padamani Sai Nikhil Claude Sonnet 4.6 ktx-akshay Bhargav ktx-mihir ktx-riya github-actions[bot] Omkar Kesarkhane omkark06 ktx-vaidehi ktx-abhay Shrinath Rao
parent 4466ff2250
commit ae22a74e62
1762 changed files with 165896 additions and 135824 deletions
@@ -15,7 +15,6 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { installQuasar } from "@/test/unit/helpers/install-quasar-plugin";
import * as quasar from "quasar";
import ActionScripts from "@/components/actionScripts/ActionScripts.vue";
import i18n from "@/locales";
@@ -35,9 +34,6 @@ vi.mock("@/composables/useStreams", () => ({
}),
}));
installQuasar({
plugins: [quasar.Dialog, quasar.Notify],
});
const mockActionScripts = [
{
@@ -124,6 +120,72 @@ describe("ActionScripts", () => {
],
emits: ["update:changeRecordPerPage"],
},
OTable: {
name: "OTable",
inheritAttrs: false,
template: `
<div data-test="action-scripts-table">
<table>
<tbody>
<tr v-for="(row, i) in data" :key="i">
<td><slot name="cell-actions" :row="row" /></td>
</tr>
</tbody>
</table>
<slot name="bottom" />
<slot name="empty" />
</div>
`,
props: [
"data",
"columns",
"rowKey",
"loading",
"selectedIds",
"selection",
"pagination",
"pageSize",
"pageSizeOptions",
"sorting",
"filterMode",
"defaultColumns",
"showGlobalFilter",
],
emits: ["update:selected-ids"],
},
ODialog: {
name: "ODialog",
inheritAttrs: false,
template:
'<div data-test="o-dialog" :data-open="open ? \'true\' : \'false\'"><slot name="header-left" /><slot name="header" /><slot /><slot name="footer" /></div>',
props: [
"open",
"persistent",
"size",
"title",
"subTitle",
"showClose",
"width",
"primaryButtonLabel",
"secondaryButtonLabel",
"neutralButtonLabel",
"primaryButtonVariant",
"secondaryButtonVariant",
"neutralButtonVariant",
"primaryButtonDisabled",
"secondaryButtonDisabled",
"neutralButtonDisabled",
"primaryButtonLoading",
"secondaryButtonLoading",
"neutralButtonLoading",
],
emits: [
"update:open",
"click:primary",
"click:secondary",
"click:neutral",
],
},
},
},
});
@@ -507,17 +569,36 @@ describe("ActionScripts", () => {
});
describe("Pagination", () => {
it("should handle pagination changes", async () => {
const mockVal = { label: "10", value: 10 };
wrapper.vm.changePagination(mockVal);
// The component uses OTable with built-in client-side pagination (pagination="client").
// There is no changePagination method or QTablePagination component in this version.
// The OTable component handles all pagination state internally.
expect(wrapper.vm.selectedPerPage).toBe(10);
expect(wrapper.vm.pagination.rowsPerPage).toBe(10);
it("should display the action scripts table which owns pagination", () => {
// Pagination is handled by OTable internally; verify the table is rendered
const table = wrapper.find('[data-test="action-scripts-table"]');
expect(table.exists()).toBe(true);
});
it("should display pagination components", () => {
const topPagination = wrapper.find('[data-test="table-pagination"]');
expect(topPagination.exists()).toBe(true);
it("should show correct row count in footer when rows are present", async () => {
wrapper.vm.actionsScriptRows = [
{
"#": "01",
id: "test-action-1",
name: "Test Action 1",
uuid: "uuid-1",
created_by: "test@example.com",
created_at: "01/01/2023, 12:00:00 AM",
execution_details_type: "Cron Job",
last_run_at: "-",
last_successful_at: "-",
status: "active",
},
];
wrapper.vm.filterQuery = "";
await wrapper.vm.$nextTick();
// resultTotal should be 1
expect(wrapper.vm.resultTotal).toBe(1);
});
});
@@ -911,6 +992,102 @@ describe("ActionScripts", () => {
});
});
describe("Clone ODialog migration", () => {
const findODialog = () => wrapper.findComponent({ name: "ODialog" });
it("should mount the ODialog component (Quasar q-dialog migration)", () => {
const dialog = findODialog();
expect(dialog.exists()).toBe(true);
// The stub renders regardless of `open` so we can verify slot wiring.
expect(wrapper.find('[data-test="o-dialog"]').exists()).toBe(true);
});
it("should pass the persistent, size and showClose migration props", () => {
const dialog = findODialog();
// `persistent` is a boolean shorthand attribute → Vue passes "" to a stub
// that declares it as an untyped prop. Both truthy values are acceptable.
expect(["", true]).toContain(dialog.props("persistent"));
expect(dialog.props("size")).toBe("md");
expect(dialog.props("showClose")).toBe(false);
});
it("should pass i18n-resolved title and button label props", () => {
const dialog = findODialog();
expect(typeof dialog.props("title")).toBe("string");
expect((dialog.props("title") as string).length).toBeGreaterThan(0);
expect(typeof dialog.props("primaryButtonLabel")).toBe("string");
expect((dialog.props("primaryButtonLabel") as string).length).toBeGreaterThan(0);
expect(typeof dialog.props("secondaryButtonLabel")).toBe("string");
expect((dialog.props("secondaryButtonLabel") as string).length).toBeGreaterThan(0);
});
it("should reflect isSubmitting via primaryButtonDisabled prop", async () => {
wrapper.vm.isSubmitting = true;
await wrapper.vm.$nextTick();
expect(findODialog().props("primaryButtonDisabled")).toBe(true);
wrapper.vm.isSubmitting = false;
await wrapper.vm.$nextTick();
expect(findODialog().props("primaryButtonDisabled")).toBe(false);
});
it("should declare the expected ODialog migration emits", () => {
const dialog = findODialog();
const emitsOption = (dialog.vm.$options as any).emits;
expect(emitsOption).toEqual(
expect.arrayContaining([
"update:open",
"click:primary",
"click:secondary",
]),
);
});
it("should not throw when click:primary is emitted from ODialog", async () => {
const dialog = findODialog();
expect(() => dialog.vm.$emit("click:primary")).not.toThrow();
await wrapper.vm.$nextTick();
// Component should remain mounted regardless
expect(wrapper.exists()).toBe(true);
});
it("should not throw when click:secondary is emitted from ODialog", async () => {
const dialog = findODialog();
await dialog.vm.$emit("click:secondary");
expect(wrapper.exists()).toBe(true);
});
it("should not throw when update:open is emitted from ODialog", async () => {
const dialog = findODialog();
await dialog.vm.$emit("update:open", false);
expect(wrapper.exists()).toBe(true);
});
it("should render the back button inside the header-left slot", () => {
const backBtn = wrapper.find('[data-test="add-action-back-btn"]');
expect(backBtn.exists()).toBe(true);
});
it("should keep component mounted when back button in header-left is clicked", async () => {
const backBtn = wrapper.find('[data-test="add-action-back-btn"]');
expect(backBtn.exists()).toBe(true);
await backBtn.trigger("click");
expect(wrapper.exists()).toBe(true);
});
it("should render clone form fields inside the ODialog default slot", () => {
expect(
wrapper.find('[data-test="to-be-clone-action-name"]').exists(),
).toBe(true);
expect(
wrapper.find('[data-test="to-be-clone-stream-type"]').exists(),
).toBe(true);
expect(
wrapper.find('[data-test="to-be-clone-stream-name"]').exists(),
).toBe(true);
});
});
describe("execution type display names", () => {
it("should convert 'repeat' execution type to 'Cron Job'", async () => {
store.state.organizationData.actions = [
+210 -327
View File
@@ -1,4 +1,4 @@
<!-- Copyright 2026 OpenObserve Inc.
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div data-test="action-scripts-list-page">
<div
v-if="!showAddActionScriptDialog"
class="tw:w-full tw:h-full tw:px-[0.625rem] tw:pb-[0.625rem] q-pt-xs"
class="tw:w-full tw:h-full tw:px-[0.625rem] tw:pb-[0.625rem] tw:pt-1"
>
<div class="card-container tw:mb-[0.625rem]">
<div
@@ -35,18 +35,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
class="tw:full-width tw:flex tw:items-center tw:justify-end tw:gap-3"
>
<q-input
<OInput
v-model="filterQuery"
borderless
dense
class="q-ml-auto no-border o2-search-input"
class="tw:ml-auto no-border o2-search-input"
:placeholder="t('actions.search')"
data-test="action-list-search-input"
>
<template #prepend>
<q-icon class="o2-search-input-icon" name="search" />
<template #icon-left>
<OIcon class="o2-search-input-icon" name="search" size="sm" />
</template>
</q-input>
</OInput>
<OButton
data-test="action-list-add-btn"
variant="primary"
@@ -59,79 +57,55 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<div class="tw:w-full tw:h-full tw:pb-[0.625rem]">
<div class="card-container tw:h-[calc(100vh-124px)]">
<q-table
<OTable
data-test="action-scripts-table"
ref="qTable"
:rows="visibleRows"
:data="visibleRows"
:columns="columns"
row-key="id"
:pagination="pagination"
style="width: 100%"
:style="{
height: hasVisibleRows
? 'calc(100vh - var(--navbar-height) - 77px)'
: '',
}"
class="o2-quasar-table o2-row-md o2-quasar-table-header-sticky o2-last-row-border"
:loading="loading"
:selected-ids="selectedActionScriptIds"
selection="multiple"
v-model:selected="selectedActionScripts"
pagination="client"
:page-size="20"
:page-size-options="[5, 10, 20, 50, 100]"
sorting="client"
filter-mode="client"
:default-columns="false"
:show-global-filter="false"
@update:selected-ids="handleSelectedIdsUpdate"
>
<template #no-data>
<template #empty>
<NoData />
</template>
<template v-slot:body-selection="scope">
<q-checkbox
v-model="scope.selected"
size="sm"
class="o2-table-checkbox"
/>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<div
data-test="action-scripts-loading"
v-if="alertStateLoadingMap[props.row.uuid]"
style="display: inline-block; width: 33.14px; height: auto"
class="flex justify-center items-center q-ml-xs"
:title="`Turning ${props.row.enabled ? 'Off' : 'On'}`"
>
<q-circular-progress
indeterminate
rounded
size="16px"
:value="1"
color="secondary"
/>
</div>
<OButton
:data-test="`alert-list-${props.row.name}-update-alert`"
variant="ghost"
size="icon-circle-sm"
:title="t('alerts.edit')"
@click="showAddUpdateFn(props)"
><q-icon name="edit" size="16px"
/></OButton>
<OButton
:data-test="`alert-list-${props.row.name}-delete-alert`"
variant="ghost"
size="icon-circle-sm"
:title="t('alerts.delete')"
@click="showDeleteDialogFn(props)"
><q-icon :name="outlinedDelete" size="16px"
/></OButton>
</q-td>
<template #cell-actions="{ row }">
<div
data-test="action-scripts-loading"
v-if="alertStateLoadingMap[row.uuid]"
style="display: inline-block; width: 33.14px; height: auto"
class="tw:flex tw:justify-center tw:items-center tw:ml-1"
:title="`Turning ${row.enabled ? 'Off' : 'On'}`"
>
<OSpinner size="xs" />
</div>
<OButton
:data-test="`alert-list-${row.name}-update-alert`"
variant="ghost"
size="icon-sm"
:title="t('alerts.edit')"
@click="showAddUpdateFn({ row })"
><OIcon name="edit" size="sm"
/></OButton>
<OButton
:data-test="`alert-list-${row.name}-delete-alert`"
variant="ghost"
size="icon-sm"
:title="t('alerts.delete')"
@click="showDeleteDialogFn({ row })"
><OIcon name="delete" size="sm"
/></OButton>
</template>
<template v-slot:body-cell-function="props">
<q-td :props="props">
<q-tooltip>
<pre>{{ props.row.sql }}</pre>
</q-tooltip>
<pre style="white-space: break-spaces">{{ props.row.sql }}</pre>
</q-td>
</template>
<template #bottom="scope">
<template #bottom>
<div
class="tw:flex tw:items-center tw:justify-between tw:w-full tw:h-[48px]"
>
@@ -147,51 +121,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="secondary"
size="sm"
@click="openBulkDeleteDialog"
><q-icon name="delete" size="16px" /><span class="tw:ml-1.5"
><OIcon name="delete" size="sm" /><span class="tw:ml-1.5"
>Delete</span
></OButton
>
</div>
<QTablePagination
:scope="scope"
:position="'bottom'"
:resultTotal="resultTotal"
:perPageOptions="perPageOptions"
@update:changeRecordPerPage="changePagination"
/>
</div>
</template>
<template v-slot:header="props">
<q-tr :props="props">
<!-- Adding this block to render the select-all checkbox -->
<q-th v-if="columns.length > 0" auto-width>
<q-checkbox
v-model="props.selected"
size="sm"
:class="
store.state.theme === 'dark'
? 'o2-table-checkbox-dark'
: 'o2-table-checkbox-light'
"
class="o2-table-checkbox"
/>
</q-th>
<!-- Rendering the rest of the columns -->
<!-- here we can add the classes class so that the head will be sticky -->
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
:class="col.classes"
:style="col.style"
>
{{ col.label }}
</q-th>
</q-tr>
</template>
</q-table>
</OTable>
</div>
</div>
</div>
@@ -219,80 +156,54 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@update:cancel="confirmBulkDelete = false"
v-model="confirmBulkDelete"
/>
<template>
<q-dialog class="q-pa-md" v-model="showForm" persistent>
<q-card class="clone-alert-popup">
<div class="row items-center no-wrap q-mx-md q-my-sm">
<div class="flex items-center">
<div
data-test="add-action-back-btn"
class="flex justify-center items-center q-mr-md cursor-pointer"
style="
border: 1.5px solid;
border-radius: 50%;
width: 22px;
height: 22px;
"
title="Go Back"
@click="showForm = false"
>
<q-icon name="arrow_back_ios_new" size="14px" />
</div>
<div class="text-h6" data-test="clone-alert-title">
{{ t("alerts.cloneTitle") }}
</div>
</div>
</div>
<q-card-section>
<q-form @submit="submitForm">
<q-input
data-test="to-be-clone-action-name"
v-model="toBeCloneAlertName"
label="Alert Name"
/>
<q-select
data-test="to-be-clone-stream-type"
v-model="toBeClonestreamType"
label="Stream Type"
:options="streamTypes"
@update:model-value="updateStreams()"
/>
<q-select
data-test="to-be-clone-stream-name"
v-model="toBeClonestreamName"
:loading="isFetchingStreams"
:disable="!toBeClonestreamType"
label="Stream Name"
:options="streamNames"
@change="updateStreamName"
@filter="filterStreams"
use-input
fill-input
hide-selected
:input-debounce="400"
/>
<div class="tw:flex tw:gap-2 tw:justify-center q-mt-lg">
<OButton
data-test="clone-action-cancel-btn"
variant="outline"
size="sm-action"
@click="showForm = false"
>{{ t("alerts.cancel") }}</OButton
>
<OButton
data-test="clone-action-submit-btn"
variant="primary"
size="sm-action"
type="submit"
:disabled="isSubmitting"
>{{ t("alerts.save") }}</OButton
>
</div>
</q-form>
</q-card-section>
</q-card>
</q-dialog>
</template>
<ODialog
data-test="action-scripts-form-dialog"
v-model:open="showForm"
persistent
size="md"
:show-close="false"
:title="t('alerts.cloneTitle')"
:secondary-button-label="t('alerts.cancel')"
:primary-button-label="t('alerts.save')"
:primary-button-disabled="isSubmitting"
@click:secondary="showForm = false"
@click:primary="submitForm"
>
<template #header-left>
<div
data-test="add-action-back-btn"
class="tw:flex tw:justify-center tw:items-center tw:cursor-pointer"
style="border: 1.5px solid; border-radius: 50%; width: 22px; height: 22px;"
title="Go Back"
@click="showForm = false"
>
<OIcon name="arrow-back-ios-new" size="xs" />
</div>
</template>
<OForm id="action-script-clone-form" :default-values="{}" @submit="submitForm">
<OInput
data-test="to-be-clone-action-name"
v-model="toBeCloneAlertName"
label="Alert Name"
/>
<OSelect
data-test="to-be-clone-stream-type"
v-model="toBeClonestreamType"
label="Stream Type"
:options="streamTypes"
@update:model-value="updateStreams()"
/>
<OSelect
data-test="to-be-clone-stream-name"
v-model="toBeClonestreamName"
:loading="isFetchingStreams"
:disabled="!toBeClonestreamType"
label="Stream Name"
:options="streamNames"
@update:model-value="updateStreamName"
/>
</OForm>
</ODialog>
</div>
</template>
@@ -311,9 +222,7 @@ import { useStore } from "vuex";
import { useRouter } from "vue-router";
import useStreams from "@/composables/useStreams";
import { QTable, date, useQuasar, type QTableProps } from "quasar";
import { useI18n } from "vue-i18n";
import QTablePagination from "@/components/shared/grid/Pagination.vue";
import alertsService from "@/services/alerts";
import destinationService from "@/services/alert_destination";
import templateService from "@/services/alert_templates";
@@ -328,41 +237,52 @@ import {
convertUnixToQuasarFormat,
} from "@/utils/zincutils";
import type { Alert, AlertListItem } from "@/ts/interfaces/index";
import {
outlinedDelete,
outlinedPause,
outlinedPlayArrow,
} from "@quasar/extras/material-icons-outlined";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import actions from "@/services/action_scripts";
import useActions from "@/composables/useActions";
import { useReo } from "@/services/reodotdev_analytics";
import OButton from "@/lib/core/Button/OButton.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
import OTable from "@/lib/core/Table/OTable.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import { toast } from "@/lib/feedback/Toast/useToast";
interface ActionScriptList {
"#": string | number; // If this represents a serial number or row index
id: any; // The unique identifier, specify the type (e.g., string, number) if known
name: any; // Name of the action script
uuid: any; // Unique UUID for the action script
created_by: any; // The user who created the script
created_at: string; // Creation timestamp, in ISO format or a specific format
last_run_at: string; // Timestamp of the last run, in ISO format or a specific format
last_successful_at: string; // Timestamp of the last successful run
status: any; // Current status of the action script
"#": string | number;
id: any;
name: any;
uuid: any;
created_by: any;
created_at: string;
last_run_at: string;
last_successful_at: string;
status: any;
}
// import alertList from "./alerts";
// TODO code clean up needs to be done
export default defineComponent({
name: "AlertList",
components: {
QTablePagination,
OIcon,
EditScript: defineAsyncComponent(
() => import("@/components/actionScripts/EditScript.vue"),
),
NoData,
ConfirmDialog,
OButton,
ODialog,
OSpinner,
OInput,
OCheckbox,
OTooltip,
OSelect,
OForm,
OTable,
},
emits: [
"updated:fields",
@@ -372,14 +292,12 @@ export default defineComponent({
setup() {
const store = useStore();
const { t } = useI18n();
const $q = useQuasar();
const router = useRouter();
const alerts: Ref<Alert[]> = ref([]);
const actionsScriptRows: Ref<ActionScriptList[]> = ref([]);
const formData: Ref<Alert | {}> = ref({});
const toBeClonedAlert: Ref<Alert | {}> = ref({});
const showAddActionScriptDialog: any = ref(false);
const qTable: Ref<InstanceType<typeof QTable> | null> = ref(null);
const selectedDelete: any = ref(null);
const isUpdated: any = ref(false);
const confirmDelete = ref<boolean>(false);
@@ -390,6 +308,7 @@ export default defineComponent({
const schemaList = ref([]);
const streams: any = ref({});
const isFetchingStreams = ref(false);
const loading = ref(false);
const isSubmitting = ref(false);
const resultTotal = ref<number>(0);
const filterQuery = ref("");
@@ -413,83 +332,98 @@ export default defineComponent({
name: "folder2",
},
]);
const columns: any = ref<QTableProps["columns"]>([
const columns: OTableColumnDef[] = [
{
name: "#",
label: "#",
field: "#",
align: "center",
style: "width: 67px;",
id: "#",
header: "#",
accessorKey: "#",
size: 67,
meta: { align: "center" },
},
{
name: "name",
field: "name",
label: t("alerts.name"),
align: "left",
id: "name",
header: t("alerts.name"),
accessorKey: "name",
sortable: true,
meta: { align: "left" },
},
{
name: "created_by",
field: "created_by",
label: t("alerts.createdBy"),
align: "center",
id: "created_by",
header: t("alerts.createdBy"),
accessorKey: "created_by",
sortable: true,
meta: { align: "center" },
},
{
name: "created_at",
field: "created_at",
label: t("alerts.createdAt"),
align: "left",
id: "created_at",
header: t("alerts.createdAt"),
accessorKey: "created_at",
sortable: true,
meta: { align: "left" },
},
{
name: "execution_details_type",
field: "execution_details_type",
label: t("actions.type"),
align: "left",
id: "execution_details_type",
header: t("actions.type"),
accessorKey: "execution_details_type",
sortable: true,
meta: { align: "left" },
},
{
name: "last_run_at",
field: "last_run_at",
label: t("alerts.lastRunAt"),
align: "left",
id: "last_run_at",
header: t("alerts.lastRunAt"),
accessorKey: "last_run_at",
sortable: true,
meta: { align: "left" },
},
{
name: "last_successful_at",
field: "last_successful_at",
label: t("alerts.lastSuccessfulAt"),
align: "left",
id: "last_successful_at",
header: t("alerts.lastSuccessfulAt"),
accessorKey: "last_successful_at",
sortable: true,
meta: { align: "left" },
},
{
name: "status",
field: "status",
label: t("alerts.status"),
align: "left",
id: "status",
header: t("alerts.status"),
accessorKey: "status",
sortable: true,
meta: { align: "left" },
},
{
name: "actions",
field: "actions",
label: t("alerts.actions"),
align: "center",
sortable: false,
classes: "actions-column",
style: "width: 100px",
id: "actions",
header: t("alerts.actions"),
isAction: true,
pinned: "right",
size: 100,
meta: { align: "center", actionCount: 2 },
},
]);
];
const selectedActionScriptIds = computed(() =>
selectedActionScripts.value.map((s: any) => s.id),
);
const handleSelectedIdsUpdate = (ids: string[]) => {
const map = new Map(
actionsScriptRows.value.map((r: any) => [r.id, r]),
);
selectedActionScripts.value = ids
.map((id: any) => map.get(id))
.filter(Boolean);
};
const activeTab: any = ref("alerts");
const destinations = ref([0]);
const templates = ref([0]);
const getActionScripts = () => {
const dismiss = $q.notify({
spinner: true,
const dismiss = toast({
variant: "loading",
message: "Please wait while loading actions...",
});
loading.value = true;
getAllActions()
.then(() => {
var counter = 1;
@@ -545,11 +479,14 @@ export default defineComponent({
.catch((e) => {
console.error(e);
dismiss();
$q.notify({
type: "negative",
toast({
variant: "error",
message: "Error while pulling Actions.",
timeout: 2000,
});
})
.finally(() => {
loading.value = false;
});
};
@@ -568,24 +505,7 @@ export default defineComponent({
},
);
const perPageOptions: any = [
{ label: "5", value: 5 },
{ label: "10", value: 10 },
{ label: "20", value: 20 },
{ label: "50", value: 50 },
{ label: "100", value: 100 },
{ label: "All", value: 0 },
];
const maxRecordToReturn = ref<number>(100);
const selectedPerPage = ref<number>(20);
const pagination: any = ref({
rowsPerPage: 20,
});
const changePagination = (val: { label: string; value: any }) => {
selectedPerPage.value = val.value;
pagination.value.rowsPerPage = val.value;
qTable.value?.setPagination(pagination.value);
};
const addAlert = () => {
track("Button Click", {
@@ -599,10 +519,6 @@ export default defineComponent({
formData.value = alerts.value.find(
(alert: any) => alert.uuid === props.row?.uuid,
) as Alert;
//use this comment for testing multi_time_range shifts
// if( formData.value){
// formData.value.query_condition.multi_time_range = [{offSet:"30m"}];
// }
let action;
if (!props.row) {
isUpdated.value = false;
@@ -658,15 +574,15 @@ export default defineComponent({
)
.then((res: any) => {
if (res.data.code == 200) {
$q.notify({
type: "positive",
toast({
variant: "success",
message: res.data.message,
timeout: 2000,
});
getActionScripts();
} else {
$q.notify({
type: "negative",
toast({
variant: "error",
message: res.data.message,
timeout: 2000,
});
@@ -676,8 +592,8 @@ export default defineComponent({
if (err.response?.status == 403) {
return;
}
$q.notify({
type: "negative",
toast({
variant: "error",
message: err?.data?.message || "Error while deleting alert.",
timeout: 2000,
});
@@ -705,8 +621,8 @@ export default defineComponent({
const bulkDeleteActionScripts = async () => {
try {
if (selectedActionScripts.value.length === 0) {
$q.notify({
type: "warning",
toast({
variant: "warning",
message: "No action scripts selected",
timeout: 2000,
});
@@ -728,20 +644,20 @@ export default defineComponent({
}
if (successful.length > 0 && unsuccessful.length === 0) {
$q.notify({
type: "positive",
toast({
variant: "success",
message: `Successfully deleted ${successful.length} action script(s)`,
timeout: 2000,
});
} else if (successful.length > 0 && unsuccessful.length > 0) {
$q.notify({
type: "warning",
toast({
variant: "warning",
message: `Deleted ${successful.length} action script(s). Failed to delete ${unsuccessful.length} action script(s)`,
timeout: 3000,
});
} else if (unsuccessful.length > 0) {
$q.notify({
type: "negative",
toast({
variant: "error",
message: `Failed to delete ${unsuccessful.length} action script(s)`,
timeout: 2000,
});
@@ -752,8 +668,8 @@ export default defineComponent({
confirmBulkDelete.value = false;
} catch (error: any) {
if (error.response?.status != 403 || error?.status != 403) {
$q.notify({
type: "negative",
toast({
variant: "error",
message:
error.response?.data?.message ||
error?.message ||
@@ -818,30 +734,6 @@ export default defineComponent({
streamNames.value = filterColumns(indexOptions.value, val, update);
};
// const toggleAlertState = (row: any) => {
// alertStateLoadingMap.value[row.uuid] = true;
// const alert: Alert = alerts.value.find(
// (alert) => alert.uuid === row.uuid,
// ) as Alert;
// alertsService
// .toggleState(
// store.state.selectedOrganization.identifier,
// alert.stream_name,
// alert.name,
// !alert?.enabled,
// alert.stream_type,
// )
// .then(() => {
// alert.enabled = !alert.enabled;
// actionsScriptRows.value.forEach((alert) => {
// alert.uuid === row.uuid ? (alert.enabled = !alert.enabled) : null;
// });
// })
// .finally(() => {
// alertStateLoadingMap.value[row.uuid] = false;
// });
// };
const routeTo = (name: string) => {
router.push({
name: name,
@@ -852,10 +744,6 @@ export default defineComponent({
});
};
// const refreshDestination = async () => {
// await getDestinations();
// };
const filterData = (rows: any, terms: any) => {
var filtered = [];
terms = terms.toLowerCase();
@@ -891,7 +779,6 @@ export default defineComponent({
return {
t,
qTable,
store,
router,
alerts,
@@ -901,17 +788,13 @@ export default defineComponent({
confirmDelete,
selectedDelete,
getActionScripts,
pagination,
resultTotal,
refreshList,
perPageOptions,
selectedPerPage,
addAlert,
deleteAlert,
isUpdated,
showAddUpdateFn,
showDeleteDialogFn,
changePagination,
maxRecordToReturn,
showAddActionScriptDialog,
toBeCloneAlertName,
@@ -924,8 +807,8 @@ export default defineComponent({
indexOptions,
streams,
isFetchingStreams,
loading,
isSubmitting,
outlinedDelete,
filterQuery,
filterData,
getImageURL,
@@ -934,8 +817,6 @@ export default defineComponent({
verifyOrganizationStatus,
folders,
splitterModel,
outlinedPause,
outlinedPlayArrow,
actionsScriptRows,
alertStateLoadingMap,
templates,
@@ -943,6 +824,8 @@ export default defineComponent({
hasVisibleRows,
confirmBulkDelete,
selectedActionScripts,
selectedActionScriptIds,
handleSelectedIdsUpdate,
openBulkDeleteDialog,
bulkDeleteActionScripts,
getAlertByName,
@@ -15,7 +15,6 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { installQuasar } from "@/test/unit/helpers/install-quasar-plugin";
import * as quasar from "quasar";
import EditScript from "@/components/actionScripts/EditScript.vue";
import i18n from "@/locales";
@@ -66,9 +65,6 @@ vi.mock("@/services/service_accounts", () => ({
},
}));
installQuasar({
plugins: [quasar.Dialog, quasar.Notify],
});
describe("EditScript", () => {
let wrapper: any;
@@ -179,21 +175,26 @@ describe("EditScript", () => {
});
it("should validate name field", async () => {
const form = wrapper.find("form");
// EditScript uses OButton @click (not a <form> submit).
// Validation is driven via wrapper.vm.nameError and the save button click.
wrapper.vm.formData.name = "";
await form.trigger("submit");
// Form validation should prevent submission with empty name
// Clicking save triggers inline validation and sets nameError
const saveBtn = wrapper.find('[data-test="add-action-script-save-btn"]');
await saveBtn.trigger("click");
await wrapper.vm.$nextTick();
// With empty name the nameError ref should be populated
expect(wrapper.vm.formData.name).toBe("");
});
});
describe("Step navigation", () => {
it("should display stepper component", () => {
const stepper = wrapper.find(
'[data-cy="stepper"], q-stepper, .q-stepper',
);
expect(stepper.exists()).toBe(true);
// The component uses OStepper (O2 lib), not Quasar q-stepper.
// Verify the stepper is present by checking its first step.
const step1 = wrapper.find('[data-test="add-action-script-step-1"]');
expect(step1.exists()).toBe(true);
});
it("should display step 1: Upload Code ZIP", () => {
@@ -475,9 +476,18 @@ describe("EditScript", () => {
});
it("should validate form data before submission", async () => {
// The component does not use a Quasar q-form with addActionScriptFormRef.
// Validation is performed inline in saveActionScript via nameError/typeError etc.
// Verify that clicking save with an empty name does NOT navigate away (nameError is set).
wrapper.vm.formData.name = "";
const isValid = await wrapper.vm.addActionScriptFormRef?.validate();
expect(isValid).toBe(false);
wrapper.vm.formData.type = "scheduled";
const saveBtn = wrapper.find('[data-test="add-action-script-save-btn"]');
await saveBtn.trigger("click");
await wrapper.vm.$nextTick();
// nameError should now be set to signal a validation failure
expect(wrapper.vm.nameError).toBeTruthy();
});
});
@@ -704,7 +714,8 @@ describe("EditScript", () => {
});
describe("Form Validation", () => {
it("should validate action script data via form submission", async () => {
it("should validate action script data via save button click", async () => {
// EditScript uses OButton @click, not a <form> submit — there is no <form> element.
// Set invalid form data
wrapper.vm.formData = {
name: "", // Invalid - empty name
@@ -717,15 +728,14 @@ describe("EditScript", () => {
conditions: [],
};
const form = wrapper.find("form");
expect(form.exists()).toBe(true);
const saveBtn = wrapper.find('[data-test="add-action-script-save-btn"]');
expect(saveBtn.exists()).toBe(true);
// Try to submit form - should trigger validation
await form.trigger("submit");
// Clicking save with empty name triggers inline validation
await saveBtn.trigger("click");
await wrapper.vm.$nextTick();
// Should not proceed with save due to validation errors
// The form validation will prevent submission
// nameError should signal validation failure
expect(wrapper.vm.formData.name).toBe("");
});
+213 -281
View File
@@ -16,28 +16,28 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div data-test="add-action-script-section">
<div class="tw:w-full tw:h-full tw:px-[0.625rem] tw:pb-[0.625rem] q-pt-xs">
<div class="tw:w-full tw:h-full tw:px-[0.625rem] tw:pb-[0.625rem] tw:pt-1">
<div class="card-container">
<div
class="tw:flex tw:items-center tw:justify-between tw:py-3 tw:pl-4 tw:pr-2 tw:h-[68px]"
>
<div
data-test="add-action-script-back-btn"
class="flex justify-center items-center q-mr-md cursor-pointer"
class="tw:flex tw:justify-center tw:items-center tw:mr-3 tw:cursor-pointer"
title="Go Back"
@click="router.back()"
>
<q-icon name="arrow_back_ios_new" size="14px" />
<OIcon name="arrow-back-ios-new" size="xs" />
<div
v-if="isEditingActionScript"
class="text-h6 q-pl-sm"
class="tw:text-xl tw:font-semibold tw:pl-2"
data-test="add-action-script-title"
>
{{ t("actions.update") }}
</div>
<div
v-else
class="text-h6 q-pl-sm"
class="tw:text-xl tw:font-semibold tw:pl-2"
data-test="add-action-script-title"
>
{{ t("actions.add") }}
@@ -54,126 +54,97 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<div
ref="addAlertFormRef"
class="q-px-lg q-pb-md"
class="tw:px-4 tw:pb-3"
style="width: 1024px"
>
<q-form
<div
class="create-report-form"
ref="addActionScriptFormRef"
@submit="onSubmit"
>
<div
data-test="add-action-script-name-input"
class="report-name-input"
style="padding-top: 12px"
>
<q-input
<OInput
v-model.trim="formData.name"
:label="t('alerts.name') + ' *'"
class="showLabelOnTop"
stack-label
borderless
dense
:rules="[
(val: any) =>
!!val
? isValidResourceName(val) ||
`Characters like :, ?, /, #, and spaces are not allowed.`
: t('common.nameRequired'),
]"
:error="!!nameError"
:error-message="nameError"
tabindex="0"
style="width: 400px"
@update:model-value="(v: string) => {
if (!v) { nameError = t('common.nameRequired'); }
else if (!isValidResourceName(v)) { nameError = 'Characters like :, ?, /, #, and spaces are not allowed.'; }
else { nameError = ''; }
}"
>
<template v-slot:hint>
<template #hint>
Characters like :, ?, /, #, and spaces are not allowed.
</template>
</q-input>
</OInput>
</div>
<div
data-test="add-action-script-description-input"
class="report-name-input q-pb-sm"
>
<q-input
<div data-test="add-action-script-description-input" class="report-name-input tw:pb-2">
<OInput
v-model="formData.description"
:label="t('reports.description')"
class="showLabelOnTop"
stack-label
borderless
dense
tabindex="0"
style="width: 800px"
/>
</div>
<div data-test="add-action-script-type" class="report-name-input">
<q-select
<div data-test="add-action-script-type" class="report-name-input tw:mb-3">
<OSelect
data-test="add-action-script-type-select"
v-model="formData.type"
:label="t('common.type') + ' *'"
:options="actionTypes"
labelKey="label"
valueKey="value"
class="showLabelOnTop no-case tw:w-[400px]"
stack-label
map-options
emit-value
borderless
dense
:rules="[(val: any) => !!val || 'Field is required!']"
:disable="isEditingActionScript"
:readonly="isEditingActionScript"
:error="!!typeError"
:error-message="typeError"
:disabled="isEditingActionScript"
@update:model-value="(v: any) => { typeError = v ? '' : 'Field is required!'; }"
/>
</div>
<q-stepper
<OStepper
v-model="step"
vertical
color="primary"
orientation="vertical"
animated
class="q-mb-md"
header-nav
navigable
class="tw:mb-3"
>
<q-step
<OStep
data-test="add-action-script-step-1"
:name="1"
:title="t('actions.uploadCodeZip')"
:icon="outlinedDashboard"
icon="edit"
:done="step > 1"
>
<div
data-test="add-action-script-file-input"
class="flex items-center"
class="tw:flex tw:items-center"
>
<q-file
<OFile
v-if="
!isEditingActionScript || formData.fileNameToShow == ''
"
ref="fileInput"
color="primary"
filled
v-model="formData.codeZip"
:label="t('actions.zipFile') + ' *'"
bg-color="input-bg"
class="tw:w-[300px] q-pt-md q-pb-sm showLabelOnTop lookup-table-file-uploader"
stack-label
outlined
dense
accept=".zip"
:rules="[
(val: any) => {
if (!isEditingActionScript) {
return !!val || 'ZIP File is required!';
}
return true;
},
]"
data-test="add-action-script-file-input"
:error-message="!isEditingActionScript && !formData.codeZip ? 'ZIP File is required!' : undefined"
:error="!isEditingActionScript && !formData.codeZip && step > 1"
>
<template v-slot:prepend>
<q-icon name="attachment" />
</template>
<template v-slot:hint>
<template #hint>
Note: Only .zip files are accepted and it may contain
various resources such as .py, .txt and main.py file etc.
</template>
</q-file>
</OFile>
<div
v-else-if="
@@ -186,14 +157,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="ghost"
size="icon-sm"
@click="editFileToUpload"
><q-icon name="edit" size="16px"
><OIcon name="edit" size="sm"
/></OButton>
</div>
<div
v-if="
isEditingActionScript && formData.fileNameToShow == ''
"
class="q-pt-md q-mt-xs q-pl-md"
class="tw:pt-3 tw:mt-1 tw:pl-3"
>
<OButton
data-test="cancel-upload-new-btn-file"
@@ -204,38 +175,35 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
</div>
</div>
<div class="tw:flex tw:gap-2 tw:mt-8">
<OButton
data-test="add-action-script-step1-continue-btn"
variant="primary"
size="sm"
@click="step++"
>Continue</OButton
>
</div>
</OStep>
<q-stepper-navigation>
<div class="q-mt-sm">
<OButton
data-test="add-action-script-step1-continue-btn"
variant="primary"
size="sm"
@click="step = 2"
>Continue</OButton
>
</div>
</q-stepper-navigation>
</q-step>
<q-step
<OStep
v-if="formData.type === 'scheduled'"
data-test="add-action-script-step-2"
:name="2"
title="Schedule"
icon="schedule"
:done="step > 2"
class="q-mt-md"
class="tw:mt-3"
>
<div class="q-my-sm q-px-sm">
<div class="tw:my-2 tw:px-2">
<div
style="font-size: 14px"
class="text-bold text-grey-8 q-mb-sm"
class="tw:font-bold tw:text-gray-500 tw:mb-2"
data-test="add-action-script-frequency-title"
>
{{ t("actions.frequency") }} *
</div>
<div class="q-pa-xs el-border-radius el-border tw:w-fit">
<div class="tw:p-1 el-border-radius el-border tw:w-fit">
<template
v-for="visual in frequencyTabs"
:key="visual.value"
@@ -254,249 +222,207 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
v-if="frequency.type === 'once'"
class="flex justify-start items-center q-mt-md"
class="tw:flex tw:justify-start tw:items-center tw:mt-3"
data-test="add-action-script-frequency-info"
>
<q-icon name="event" class="q-mr-sm" />
<OIcon name="event" size="sm" class="tw:mr-2" />
<div style="font-size: 14px">
The script will be triggered immediately after it is saved
</div>
</div>
<template v-if="frequency.type === 'repeat'">
<div class="flex">
<div class="tw:flex">
<div
data-test="add-action-script-cron-input"
class="q-mr-sm"
class="tw:mr-2"
style="padding-top: 8px; width: 320px"
>
<div
class="q-mb-xs text-bold text-grey-8"
class="tw:mb-1 tw:font-bold tw:text-gray-500"
data-test="add-action-script-cron-expression-title"
>
{{ t("reports.cronExpression") + " *" }}
<q-icon
<OIcon
data-test="add-action-script-cron-info"
:name="outlinedInfo"
size="17px"
class="q-ml-xs cursor-pointer"
:class="
store.state.theme === 'dark'
? 'text-grey-5'
: 'text-grey-7'
"
name="info"
size="sm"
class="tw:ml-1 tw:cursor-pointer tw:text-gray-400"
>
<q-tooltip anchor="center right" self="center left">
<span style="font-size: 14px">
Pattern: * * * * * means every minute .
<br />
Format: [Minute 0-59] [Hour 0-23] [Day of Month
1-31, 'L'] [Month 1-12] [Day of Week 0-7 or
'1L-7L', 0 and 7 for Sunday].
<br />
Use '*' to represent any value, 'L' for the last
day/weekday. <br />
Example: 0 12 * * ? - Triggers at 12:00 PM
daily. It specifies minute, hour, day of month,
month, and day of week, respectively.</span
>
</q-tooltip>
</q-icon>
<OTooltip side="right" align="center">
<template #content>
<span style="font-size: 14px">
Pattern: * * * * * means every minute .
<br />
Format: [Minute 0-59] [Hour 0-23] [Day of Month
1-31, 'L'] [Month 1-12] [Day of Week 0-7 or
'1L-7L', 0 and 7 for Sunday].
<br />
Use '*' to represent any value, 'L' for the last
day/weekday. <br />
Example: 0 12 * * ? - Triggers at 12:00 PM
daily. It specifies minute, hour, day of month,
month, and day of week, respectively.
</span>
</template>
</OTooltip>
</OIcon>
</div>
<q-input
<OInput
v-model="frequency.cron"
class="showLabelOnTop"
type="text"
borderless
:rules="[
(val: any) =>
!!val.length
? cronError.length
? cronError
: true
: 'Field is required!',
]"
dense
:error="!!cronFieldError"
:error-message="cronFieldError"
debounce="300"
style="width: 100%"
@update:model-value="
validateFrequency(frequency.cron)
"
:disable="isEditingActionScript"
@update:model-value="(v: string) => {
cronFieldError = !v?.length ? 'Field is required!' : (cronError.length ? cronError : '');
validateFrequency(frequency.cron);
}"
:disabled="isEditingActionScript"
:readonly="isEditingActionScript"
/>
</div>
<div class="flex">
<q-select
<div class="tw:flex">
<OSelect
data-test="add-action-script-timezone-select"
v-model="formData.timezone"
:options="['UTC']"
:label="t('actions.timezone') + ' *'"
:loading="isFetchingServiceAccounts"
:popup-content-style="{ textTransform: 'lowercase' }"
class="showLabelOnTop no-case tw:mb-[2.4rem]"
borderless
stack-label
dense
use-input
hide-selected
fill-input
:input-debounce="400"
behavior="menu"
disable
:rules="[(val: any) => !!val || 'Field is required!']"
style="
min-width: 250px !important;
width: 250px !important;
"
disabled
style="min-width: 250px !important; width: 250px !important;"
/>
</div>
</div>
</template>
</div>
<div class="tw:flex tw:gap-2 tw:mt-4">
<OButton
data-test="add-action-script-step2-back-btn"
variant="outline"
size="sm"
@click="step--"
>Back</OButton
>
<OButton
data-test="add-action-script-step2-continue-btn"
variant="primary"
size="sm"
@click="step++"
>Continue</OButton
>
</div>
</OStep>
<q-stepper-navigation>
<div class="tw:flex tw:gap-2">
<OButton
data-test="add-action-script-step2-back-btn"
variant="outline"
size="sm"
@click="step = 1"
>Back</OButton
>
<OButton
data-test="add-action-script-step2-continue-btn"
variant="primary"
size="sm"
@click="step = 3"
>Continue</OButton
>
</div>
</q-stepper-navigation>
</q-step>
<q-step
<OStep
data-test="add-action-script-step-3"
:name="3"
title="Select Service Account"
:icon="outlinedDashboard"
icon="dashboard"
:done="step > 3"
class="q-mt-md"
class="tw:mt-3"
>
<div class="flex items-center">
<div class="tw:flex tw:items-center">
<div class="service-account-selector">
<div
data-test="add-action-script-service-account-title"
class="q-mb-xs text-bold text-grey-8"
class="tw:mb-1 tw:font-bold tw:text-gray-500"
>
{{ t("actions.serviceAccount") + " *" }}
<q-icon
:name="outlinedInfo"
size="17px"
class="q-ml-xs cursor-pointer"
:class="
store.state.theme === 'dark'
? 'text-grey-5'
: 'text-grey-7'
"
<OIcon
name="info"
size="sm"
class="tw:ml-1 tw:cursor-pointer tw:text-gray-400"
>
<q-tooltip anchor="center right" self="center left">
<span style="font-size: 14px">
Make sure service account has permissions to access
Actions.
</span>
</q-tooltip>
</q-icon>
<OTooltip side="right" align="center">
<template #content>
<span style="font-size: 14px">
Make sure service account has permissions to access
Actions.
</span>
</template>
</OTooltip>
</OIcon>
</div>
<q-select
<OSelect
data-test="add-action-script-service-account-select"
v-model="formData.service_account"
:options="filteredServiceAccounts"
:loading="isFetchingServiceAccounts"
:popup-content-style="{ textTransform: 'lowercase' }"
class="q-py-sm no-case"
borderless
dense
use-input
hide-selected
fill-input
:input-debounce="400"
@filter="filterServiceAccounts"
behavior="menu"
:rules="[(val: any) => !!val || 'Field is required!']"
style="
min-width: 250px !important;
width: 250px !important;
"
class="tw:py-2 no-case"
labelKey="label"
valueKey="value"
:error="!!serviceAccountError"
:error-message="serviceAccountError"
@update:model-value="(v: any) => { serviceAccountError = v ? '' : 'Field is required!'; }"
@search="(val: string) => {
filteredServiceAccounts.value = val
? serviceAccountsOptions.filter((s: any) => s.label.toLowerCase().includes(val.toLowerCase()))
: [...serviceAccountsOptions];
}"
style="min-width: 250px !important; width: 250px !important;"
/>
</div>
</div>
<q-stepper-navigation>
<div class="tw:flex tw:gap-2">
<OButton
data-test="add-action-script-step3-back-btn"
variant="outline"
size="sm"
@click="step = formData.type === 'scheduled' ? 2 : 1"
>Back</OButton
>
<OButton
data-test="add-action-script-step3-continue-btn"
variant="primary"
size="sm"
@click="step = 4"
>Continue</OButton
>
</div>
</q-stepper-navigation>
</q-step>
<q-step
<div class="tw:flex tw:gap-2 tw:mt-4">
<OButton
data-test="add-action-script-step3-back-btn"
variant="outline"
size="sm"
@click="step === 3 ? (step = formData.type === 'scheduled' ? 2 : 1) : step--"
>Back</OButton
>
<OButton
data-test="add-action-script-step3-continue-btn"
variant="primary"
size="sm"
@click="step++"
>Continue</OButton
>
</div>
</OStep>
<OStep
data-test="add-action-script-step-4"
:name="4"
title="Environmental Variables"
icon="lock"
:done="step > 4"
class="q-mt-md"
class="tw:mt-3"
>
<div
v-for="(header, index) in environmentalVariables"
:key="header.uuid"
class="row q-col-gutter-sm"
class="tw:flex tw:gap-2"
data-test="add-action-script-env-variable"
>
<div class="col-5 q-ml-none">
<q-input
<div class="tw:w-5/12 tw:ml-0">
<OInput
:data-test="`add-action-script-header-${header['key']}-key-input`"
v-model="header.key"
stack-label
borderless
:placeholder="'Key'"
dense
tabindex="0"
/>
</div>
<div class="col-5 q-ml-none q-mb-sm">
<q-input
<div class="tw:w-5/12 tw:ml-0 tw:mb-2">
<OInput
:data-test="`add-action-script-header-${header['key']}-value-input`"
v-model="header.value"
:placeholder="t('alert_destinations.api_header_value')"
stack-label
borderless
dense
isUpdatingDestination
tabindex="0"
/>
</div>
<div class="col-2 q-ml-none">
<div class="tw:w-1/6 tw:ml-0">
<OButton
:data-test="`add-action-script-header-${header['key']}-delete-btn`"
variant="ghost"
size="icon-circle-sm"
:title="t('alert_templates.delete')"
@click="deleteApiHeader(header)"
><q-icon name="delete" size="16px"
><OIcon name="delete" size="sm"
/></OButton>
<OButton
data-test="add-action-script-add-header-btn"
@@ -505,28 +431,29 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
size="icon-circle-sm"
:title="t('alert_templates.edit')"
@click="addApiHeader()"
><q-icon name="add" size="16px"
><OIcon name="add" size="sm"
/></OButton>
</div>
</div>
<q-stepper-navigation>
<div class="tw:flex tw:gap-2 tw:mt-4">
<OButton
data-test="add-action-script-step4-back-btn"
variant="outline"
size="sm"
@click="step = 3"
@click="step--"
>Back</OButton
>
</q-stepper-navigation>
</q-step>
</q-stepper>
</q-form>
</div>
</OStep>
</OStepper>
</div>
</div>
</div>
</div>
<div class="tw:mx-2">
<div
class="tw:flex tw:justify-end tw:gap-2 q-px-md full-width tw:py-[0.625rem] card-container"
class="tw:flex tw:justify-end tw:gap-2 tw:px-3 tw:w-full tw:py-[0.625rem] card-container"
style="position: sticky; bottom: 0px; z-index: 2"
>
<OButton
@@ -571,19 +498,24 @@ import {
} from "@/utils/zincutils";
import VariablesInput from "@/components/alerts/VariablesInput.vue";
import { useStore } from "vuex";
import { outlinedDashboard } from "@quasar/extras/material-icons-outlined";
import dashboardService from "@/services/dashboards";
import { onBeforeMount } from "vue";
import type { Ref } from "vue";
import { DateTime as _DateTime } from "luxon";
import actions from "@/services/action_scripts";
import { useQuasar } from "quasar";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import CronExpressionParser from "cron-parser";
import { outlinedInfo } from "@quasar/extras/material-icons-outlined";
import { convertDateToTimestamp } from "@/utils/date";
import service_accounts from "@/services/service_accounts";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OStepper from "@/lib/navigation/Stepper/OStepper.vue";
import OStep from "@/lib/navigation/Stepper/OStep.vue";
import OFile from "@/lib/forms/File/OFile.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
defineProps({
report: {
@@ -627,13 +559,10 @@ const fileInput = ref(null);
const originalActionScriptData: Ref<string> = ref("");
const addActionScriptFormRef: Ref<any> = ref(null);
const step = ref(1);
const formData = ref(defaultActionScript);
const q = useQuasar();
const actionTypes = [
{
@@ -699,6 +628,10 @@ const isFetchingActionScript = ref(false);
const environmentalVariables = ref([{ key: "", value: "", uuid: getUUID() }]);
const cronError = ref("");
const nameError = ref("");
const typeError = ref("");
const cronFieldError = ref("");
const serviceAccountError = ref("");
const frequency = ref({
type: "once",
@@ -720,8 +653,6 @@ onBeforeMount(async () => {
await handleActionScript();
});
const onSubmit = () => {};
const scheduling = ref({
date: "",
time: "",
@@ -882,9 +813,18 @@ const saveActionScript = async () => {
try {
validateActionScriptData();
await nextTick();
await nextTick();
const isValidForm = await addActionScriptFormRef.value.validate();
if (!isValidForm) return;
// Inline validation for migrated O2 fields
nameError.value = !formData.value.name
? t('common.nameRequired')
: !isValidResourceName(formData.value.name)
? 'Characters like :, ?, /, #, and spaces are not allowed.'
: '';
typeError.value = formData.value.type ? '' : 'Field is required!';
serviceAccountError.value = formData.value.service_account ? '' : 'Field is required!';
if (formData.value.execution_details === 'repeat') {
cronFieldError.value = !frequency.value.cron?.length ? 'Field is required!' : (cronError.value.length ? cronError.value : '');
}
if (nameError.value || typeError.value || serviceAccountError.value || cronFieldError.value) return;
} catch (err) {
console.log(err);
}
@@ -894,8 +834,8 @@ const saveActionScript = async () => {
? actions.update
: actions.create;
const dismiss = q.notify({
spinner: true,
const dismiss = toast({
variant: "loading",
message: "Please wait...",
timeout: 2000,
});
@@ -904,8 +844,8 @@ const saveActionScript = async () => {
updateAction(store.state.selectedOrganization.identifier, actionId, form)
.then(() => {
q.notify({
type: "positive",
toast({
variant: "success",
message: `Action ${
isEditingActionScript.value ? "updated" : "saved"
} successfully.`,
@@ -917,8 +857,8 @@ const saveActionScript = async () => {
.catch((error) => {
step.value = 3;
if (error.response.status != 403) {
q.notify({
type: "negative",
toast({
variant: "error",
message:
error?.response?.data?.message ||
`Error while ${
@@ -1129,8 +1069,8 @@ const handleActionScript = async () => {
})
.catch((err) => {
if (err.response.status != 403) {
q.notify({
type: "negative",
toast({
variant: "error",
message: err?.data?.message || "Error while fetching Action!",
timeout: 4000,
});
@@ -1171,8 +1111,8 @@ const getServiceAccounts = async () => {
filteredServiceAccounts.value = [...serviceAccountsOptions];
} catch (err: any) {
if (err.response?.status != 403) {
q.notify({
type: "negative",
toast({
variant: "error",
message:
err.response?.data?.message ||
"Error while fetching service accounts.",
@@ -1189,14 +1129,6 @@ onMounted(async () => {
});
</script>
<style lang="scss">
.create-report-page {
.q-expansion-item .q-item {
padding: 0 8px;
}
}
</style>
<style scoped lang="scss">
.lookup-table-file-uploader {
:deep(.q-field__label) {
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mount, VueWrapper } from "@vue/test-utils";
import { nextTick } from "vue";
import FileItem from "@/components/actionScripts/FileItem.vue";
import { Quasar } from "quasar";
describe("FileItem.vue", () => {
let wrapper: VueWrapper;
@@ -24,9 +24,9 @@ describe("FileItem.vue", () => {
return mount(FileItem, {
props: { ...defaultProps, ...props },
global: {
plugins: [Quasar],
plugins: [],
stubs: {
"q-icon": true,
"OIcon": true,
},
},
});
@@ -65,8 +65,9 @@ describe("FileItem.vue", () => {
const editButton = buttons[0];
const deleteButton = buttons[1];
expect(editButton.find("q-icon-stub").attributes("name")).toBe("edit");
expect(deleteButton.find("q-icon-stub").attributes("name")).toBe(
// When stubs: { OIcon: true }, Vue renders the stub as <o-icon-stub> (lowercase kebab)
expect(editButton.find("o-icon-stub").attributes("name")).toBe("edit");
expect(deleteButton.find("o-icon-stub").attributes("name")).toBe(
"delete",
);
});
@@ -284,7 +285,7 @@ describe("FileItem.vue", () => {
describe("Button Styling", () => {
it("applies light gray styling to buttons when active", () => {
wrapper = createWrapper({ isActive: true });
const icons = wrapper.findAll(".file-actions .q-icon");
const icons = wrapper.findAll(".file-actions .OIcon");
icons.forEach((icon) => {
expect(icon.classes()).toContain("tw:text-gray-100");
@@ -294,7 +295,7 @@ describe("FileItem.vue", () => {
it("applies dark gray styling to buttons when not active", () => {
wrapper = createWrapper({ isActive: false });
const icons = wrapper.findAll(".file-actions .q-icon");
const icons = wrapper.findAll(".file-actions .OIcon");
icons.forEach((icon) => {
expect(icon.classes()).toContain("tw:text-gray-600");
@@ -24,10 +24,10 @@
class="tw:w-[36px] tw:flex tw:items-center tw:space-x-2 tw:ml-auto file-actions"
>
<OButton variant="ghost" size="icon-xs-sq" @click.stop="editFile">
<q-icon name="edit" :class="isActive ? 'tw:text-gray-100' : 'tw:text-gray-600'" />
<OIcon name="edit" size="sm" :class="isActive ? 'tw:text-gray-100' : 'tw:text-gray-600'" />
</OButton>
<OButton variant="ghost" size="icon-xs-sq" @click.stop="deleteFile">
<q-icon name="delete" :class="isActive ? 'tw:text-gray-100' : 'tw:text-gray-600'" />
<OIcon name="delete" size="sm" :class="isActive ? 'tw:text-gray-100' : 'tw:text-gray-600'" />
</OButton>
</div>
</li>
@@ -36,6 +36,7 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch } from "vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
const props = defineProps<{
name: string;
@@ -1,7 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount, VueWrapper } from '@vue/test-utils';
import ScriptEditor from '@/components/actionScripts/ScriptEditor.vue';
import { Quasar } from 'quasar';
// Mock dependencies
vi.mock('axios');
@@ -11,6 +10,7 @@ vi.mock('vue-i18n', () => ({
})
}));
describe('ScriptEditor.vue', () => {
let wrapper: VueWrapper;
@@ -35,7 +35,7 @@ describe('ScriptEditor.vue', () => {
return mount(ScriptEditor, {
props: { ...defaultProps, ...props },
global: {
plugins: [Quasar],
plugins: [],
stubs: {
'FullViewContainer': {
template: '<div><slot name="left"></slot><slot></slot></div>',
@@ -45,17 +45,18 @@ describe('ScriptEditor.vue', () => {
template: '<div data-test="vrl-function-test-events-editor"></div>',
props: ['editorId', 'language', 'query']
},
'q-spinner-hourglass': {
template: '<div class="spinner"></div>',
props: ['size']
},
'q-icon': {
template: '<div class="icon"><q-tooltip-stub v-if="$slots.default"><slot></slot></q-tooltip-stub></div>',
'OIcon': {
template: '<div class="icon" :data-name="name"><slot></slot></div>',
props: ['name', 'size']
},
'q-tooltip': {
template: '<div class="tooltip"><slot></slot></div>',
props: ['anchor', 'self', 'offset']
// The source uses OTooltip (O2 lib), not q-tooltip.
'OTooltip': {
template: '<div class="o-tooltip" :data-content="content"><slot></slot></div>',
props: ['side', 'align', 'sideOffset', 'content']
},
'OSpinner': {
template: '<div data-test="script-editor-loading-indicator"></div>',
props: ['size']
}
}
}
@@ -149,7 +150,7 @@ describe('ScriptEditor.vue', () => {
it('shows loading spinner when loading is true', () => {
wrapper = createWrapper({ loading: true });
const spinner = wrapper.find('.spinner');
const spinner = wrapper.find('[data-test="script-editor-loading-indicator"]');
expect(spinner.exists()).toBe(true);
});
@@ -162,7 +163,7 @@ describe('ScriptEditor.vue', () => {
it('does not show loading elements when loading is false', () => {
wrapper = createWrapper({ loading: false });
const spinner = wrapper.find('.spinner');
const spinner = wrapper.find('[data-test="script-editor-loading-indicator"]');
expect(spinner.exists()).toBe(false);
});
@@ -191,8 +192,9 @@ describe('ScriptEditor.vue', () => {
it('shows error tooltip with correct message', () => {
const errorMessage = 'Test error message';
wrapper = createWrapper({ error: errorMessage });
const tooltip = wrapper.find('.tooltip');
// OTooltip stub renders with class "o-tooltip" and data-content attribute
const tooltip = wrapper.find('.o-tooltip');
expect(tooltip.exists()).toBe(true);
});
@@ -205,8 +207,9 @@ describe('ScriptEditor.vue', () => {
it('configures tooltip positioning correctly', () => {
wrapper = createWrapper({ error: 'Error' });
const tooltip = wrapper.find('.tooltip');
// OTooltip stub renders with class "o-tooltip"
const tooltip = wrapper.find('.o-tooltip');
expect(tooltip.exists()).toBe(true);
});
});
@@ -367,7 +370,7 @@ describe('ScriptEditor.vue', () => {
it('handles both loading and error states simultaneously', () => {
wrapper = createWrapper({ loading: true, error: 'Test error' });
const spinner = wrapper.find('.spinner');
const spinner = wrapper.find('[data-test="script-editor-loading-indicator"]');
const errorIcon = wrapper.find('.icon');
expect(spinner.exists()).toBe(true);
@@ -377,8 +380,9 @@ describe('ScriptEditor.vue', () => {
it('handles very long error messages', () => {
const longError = 'A very long error message that might cause layout issues. '.repeat(10);
wrapper = createWrapper({ error: longError });
const tooltip = wrapper.find('.tooltip');
// OTooltip stub renders with class "o-tooltip"
const tooltip = wrapper.find('.o-tooltip');
expect(tooltip.exists()).toBe(true);
});
});
@@ -11,26 +11,18 @@
v-if="loading"
class="text-weight-bold tw:flex tw:items-center tw:text-gray-500 tw:ml-2 tw:text-[13px]"
>
<q-spinner-hourglass size="18px" />
<OSpinner size="xs" data-test="script-editor-loading-indicator" />
<div class="tw:relative tw:top-[2px]">
{{ t("confirmDialog.loading") }}
</div>
</div>
<q-icon
<OIcon
v-if="!!error"
name="info"
class="tw:text-red-600 tw:mx-1 tw:cursor-pointer"
size="16px"
>
<q-tooltip
anchor="center right"
self="center left"
:offset="[10, 10]"
class="tw:text-[12px]"
>
{{ error }}
</q-tooltip>
</q-icon>
size="sm"
/>
<OTooltip side="right" align="center" :sideOffset="10" :content="error" />
</template>
</FullViewContainer>
<div
@@ -56,6 +48,9 @@ import FullViewContainer from "@/components/functions/FullViewContainer.vue";
const QueryEditor = defineAsyncComponent(() => import("@/components/CodeQueryEditor.vue"));
import axios from "axios";
import { useI18n } from "vue-i18n";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
const props = defineProps({
loading: {
@@ -3,7 +3,6 @@ import { mount, VueWrapper } from '@vue/test-utils';
import { createRouter, createWebHistory } from 'vue-router';
import { createStore } from 'vuex';
import ScriptToolbar from '@/components/actionScripts/ScriptToolbar.vue';
import { Quasar } from 'quasar';
// Mock vue-i18n
vi.mock('vue-i18n', () => ({
@@ -12,6 +11,13 @@ vi.mock('vue-i18n', () => ({
})
}));
// ScriptToolbar calls toggleFullscreen() from @/utils/dom directly — mock at the module boundary.
const mockToggleFullscreen = vi.fn();
vi.mock('@/utils/dom', () => ({
toggleFullscreen: () => mockToggleFullscreen(),
}));
describe('ScriptToolbar.vue', () => {
let wrapper: VueWrapper;
let router: any;
@@ -75,8 +81,8 @@ describe('ScriptToolbar.vue', () => {
_q_: mockQuasar
},
stubs: {
'q-icon': {
template: '<div class="q-icon-stub" :name="name"><q-tooltip-stub v-if="$slots.default"><slot></slot></q-tooltip-stub></div>',
'OIcon': {
template: '<div class="OIcon-stub" :name="name"><q-tooltip-stub v-if="$slots.default"><slot></slot></q-tooltip-stub></div>',
props: ['name', 'size']
},
'q-input': {
@@ -118,18 +124,19 @@ describe('ScriptToolbar.vue', () => {
it('renders back button with correct styling', () => {
wrapper = createWrapper();
const backBtn = wrapper.find('[data-test="add-script-back-btn"]');
expect(backBtn.exists()).toBe(true);
expect(backBtn.attributes('title')).toBe('Go Back');
expect(backBtn.classes()).toContain('cursor-pointer');
// The back button uses Tailwind tw: prefix classes, not bare cursor-pointer
expect(backBtn.classes()).toContain('tw:cursor-pointer');
});
it('renders name input field', () => {
wrapper = createWrapper();
const nameInput = wrapper.find('.q-input-stub');
// The component uses OInput (O2 lib), queried by its data-test attribute
const nameInput = wrapper.find('[data-test="add-script-name-input"]');
expect(nameInput.exists()).toBe(true);
expect(nameInput.attributes('data-test')).toBe('add-script-name-input');
});
it('renders action buttons', () => {
@@ -161,10 +168,10 @@ describe('ScriptToolbar.vue', () => {
it('passes disableName to input field', () => {
wrapper = createWrapper({ disableName: true });
const nameInput = wrapper.find('.q-input-stub');
// The component uses OInput (O2 lib), queried by its data-test attribute
const nameInput = wrapper.find('[data-test="add-script-name-input"]');
expect(nameInput.exists()).toBe(true);
// In actual component, these props would be passed through
});
});
@@ -265,11 +272,11 @@ describe('ScriptToolbar.vue', () => {
it('handles fullscreen toggle', async () => {
wrapper = createWrapper();
const fullscreenBtn = wrapper.find('[data-test="add-script-fullscreen-btn"]');
const vm = wrapper.vm as any;
await fullscreenBtn.trigger('click');
expect(mockQuasar.fullscreen.toggle).toHaveBeenCalled();
// Component calls toggleFullscreen() from @/utils/dom — verified via module mock
expect(mockToggleFullscreen).toHaveBeenCalled();
});
it('emits save event and shows input error when save button is clicked', async () => {
@@ -317,8 +324,9 @@ describe('ScriptToolbar.vue', () => {
describe('Input Field Configuration', () => {
it('configures input field', () => {
wrapper = createWrapper();
const nameInput = wrapper.find('.q-input-stub');
// The component uses OInput (O2 lib), queried by its data-test attribute
const nameInput = wrapper.find('[data-test="add-script-name-input"]');
expect(nameInput.exists()).toBe(true);
});
@@ -6,7 +6,7 @@
<div class="tw:mr-2 add-script-back-btn">
<div
data-test="add-script-back-btn"
class="flex justify-center items-center cursor-pointer"
class="tw:flex tw:justify-center tw:items-center tw:cursor-pointer"
style="
border: 1.5px solid;
border-radius: 50%;
@@ -16,57 +16,39 @@
title="Go Back"
@click="redirectToScripts"
>
<q-icon name="arrow_back_ios_new" size="14px" />
<OIcon name="arrow-back-ios-new" size="xs" />
</div>
</div>
<div class="tw:text-lg tw:w-full add-script-title q-mr-md">
<div class="tw:text-lg tw:w-full add-script-title tw:mr-3">
Add Action
</div>
<q-form ref="addScriptForm" class="o2-input">
<div class="o2-input">
<div class="tw:flex tw:items-center">
<q-input
<OInput
data-test="add-script-name-input"
v-model.trim="actionName"
:label="t('actions.name')"
color="input-border"
bg-color="input-bg"
class="q-pa-none tw:w-full"
stack-label
outlined
filled
dense
v-bind:readonly="disableName"
v-bind:disable="disableName"
:rules="[
(val: any) => !!val || 'Field is required!',
isValidMethodName,
]"
no-error-icon
tabindex="0"
style="min-width: 300px"
class="tw:p-0 tw:w-full"
:readonly="disableName"
:disabled="disableName"
:error="!!scriptNameError"
:error-message="scriptNameError"
@update:model-value="onUpdate"
@blur="onUpdate"
style="min-width: 300px"
/>
<q-icon
<OIcon
:key="actionName"
v-if="isValidMethodName() !== true && showInputError"
:name="outlinedInfo"
size="20px"
class="q-ml-xs cursor-pointer"
name="info"
size="md"
class="tw:ml-1 tw:cursor-pointer"
:class="store.state.theme === 'dark' ? 'text-red-5' : 'text-red-7'"
>
<q-tooltip
anchor="center right"
self="center left"
max-width="300px"
:offset="[2, 0]"
class="tw:text-[12px]"
>
{{ isValidMethodName() }}
</q-tooltip>
</q-icon>
<OTooltip side="right" :content="String(isValidMethodName())" />
</OIcon>
</div>
</q-form>
</div>
</div>
<div class="add-script-actions tw:flex tw:items-center tw:gap-2">
<OButton
@@ -75,7 +57,7 @@
variant="outline"
size="sm"
@click="handleFullScreen"
><q-icon name="fullscreen" size="18px" class="tw:mr-1" />{{
><OIcon name="fullscreen" size="sm" class="tw:mr-1" />{{
t("common.fullscreen")
}}</OButton
>
@@ -100,16 +82,16 @@
<script setup lang="ts">
import { defineComponent, ref, onMounted, computed, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useQuasar } from "quasar";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import { outlinedInfo } from "@quasar/extras/material-icons-outlined";
import OButton from "@/lib/core/Button/OButton.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import { toggleFullscreen } from "@/utils/dom";
const { t } = useI18n();
const q = useQuasar();
const router = useRouter();
const store = useStore();
@@ -129,6 +111,13 @@ const emit = defineEmits(["test", "save", "update:name", "back", "cancel"]);
const addScriptForm = ref(null);
const scriptNameError = computed(() => {
if (!showInputError.value) return '';
if (!actionName.value) return 'Field is required!';
const result = isValidMethodName();
return result === true ? '' : result;
});
const showInputError = ref(false);
const isValidMethodName = () => {
@@ -150,7 +139,7 @@ const actionName = computed({
});
const handleFullScreen = () => {
q.fullscreen.toggle();
toggleFullscreen();
};
const redirectToScripts = () => {