Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@
"loadingFailed": "Failed to load Dag information. Please try again.",
"manualRunDenied": "Manual runs are not allowed for this Dag",
"partitionKeyHelp": "Optional - only applies to partitioned Dags",
"recentConfig": "Recent configurations",
"recentConfigPlaceholder": "Select a recent configuration",
"runIdHelp": "Optional - will be generated if not provided",
"selectDescription": "Trigger a single run of this Dag",
"selectLabel": "Single Run",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

import { Wrapper } from "src/utils/Wrapper";

import RecentConfigSelect from "./RecentConfigSelect";

const buildRun = (overrides: Record<string, unknown>) => ({
bundle_version: null,
conf: null,
dag_display_name: "test_dag",
dag_id: "test_dag",
dag_run_id: "run_1",
dag_versions: [],
data_interval_end: null,
data_interval_start: null,
duration: null,
end_date: null,
last_scheduling_decision: null,
logical_date: null,
note: null,
partition_key: null,
queued_at: null,
run_after: "2025-01-01T00:00:00Z",
run_type: "manual" as const,
start_date: null,
state: "success" as const,
triggered_by: "ui" as const,
triggering_user_name: null,
...overrides,
});

let mockData: { dag_runs: Array<ReturnType<typeof buildRun>> } | undefined;
const mockIsLoading = false;

vi.mock("openapi/queries", () => ({
useDagRunServiceGetDagRuns: vi.fn(() => ({
data: mockData,
isLoading: mockIsLoading,
})),
}));

const getItems = (container: HTMLElement) => container.querySelectorAll(".chakra-select__item");

describe("RecentConfigSelect", () => {
it("renders one item per distinct non-empty conf", () => {
mockData = {
dag_runs: [
buildRun({ conf: { message: "First" }, dag_run_id: "run_1", run_after: "2025-01-03T00:00:00Z" }),
buildRun({ conf: { message: "Second" }, dag_run_id: "run_2", run_after: "2025-01-02T00:00:00Z" }),
],
};

const { container } = render(<RecentConfigSelect dagId="test_dag" onSelectConf={vi.fn()} open />, {
wrapper: Wrapper,
});

expect(getItems(container)).toHaveLength(2);
});

it("dedups identical confs", () => {
mockData = {
dag_runs: [
buildRun({ conf: { message: "Same" }, dag_run_id: "run_1", run_after: "2025-01-03T00:00:00Z" }),
buildRun({ conf: { message: "Same" }, dag_run_id: "run_2", run_after: "2025-01-02T00:00:00Z" }),
],
};

const { container } = render(<RecentConfigSelect dagId="test_dag" onSelectConf={vi.fn()} open />, {
wrapper: Wrapper,
});

expect(getItems(container)).toHaveLength(1);
});

it("excludes null/empty conf runs and renders nothing when none remain", () => {
mockData = {
dag_runs: [buildRun({ conf: null, dag_run_id: "run_1" }), buildRun({ conf: {}, dag_run_id: "run_2" })],
};

const { container } = render(<RecentConfigSelect dagId="test_dag" onSelectConf={vi.fn()} open />, {
wrapper: Wrapper,
});

expect(container).toBeEmptyDOMElement();
});

it("renders all distinct confs without capping the list", () => {
mockData = {
dag_runs: Array.from({ length: 8 }, (_unused, index) =>
buildRun({
conf: { message: `Message ${index}` },
dag_run_id: `run_${index}`,
run_after: `2025-01-${(10 - index).toString().padStart(2, "0")}T00:00:00Z`,
}),
),
};

const { container } = render(<RecentConfigSelect dagId="test_dag" onSelectConf={vi.fn()} open />, {
wrapper: Wrapper,
});

expect(getItems(container)).toHaveLength(8);
});

it("calls onSelectConf with the selected run's conf", async () => {
mockData = {
dag_runs: [buildRun({ conf: { message: "Pick me" }, dag_run_id: "run_1" })],
};
const onSelectConf = vi.fn();

render(<RecentConfigSelect dagId="test_dag" onSelectConf={onSelectConf} open />, { wrapper: Wrapper });

fireEvent.click(screen.getByRole("combobox"));

await waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument());

fireEvent.click(screen.getByText("run_1"));

await waitFor(() => expect(onSelectConf).toHaveBeenCalledWith({ message: "Pick me" }));
});

it("displays the selected run id in the trigger after selection", async () => {
mockData = {
dag_runs: [buildRun({ conf: { message: "Pick me" }, dag_run_id: "run_1" })],
};

render(<RecentConfigSelect dagId="test_dag" onSelectConf={vi.fn()} open />, { wrapper: Wrapper });

const trigger = screen.getByRole("combobox");

fireEvent.click(trigger);
await waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument());
fireEvent.click(screen.getByText("run_1"));

await waitFor(() => expect(trigger).toHaveTextContent("run_1"));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createListCollection, Flex, Select, type SelectValueChangeDetails, Text } from "@chakra-ui/react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";

import { useDagRunServiceGetDagRuns } from "openapi/queries";
import type { DAGRunResponse } from "openapi/requests/types.gen";

import Time from "../Time";

type RecentConfigOption = {
run: DAGRunResponse;
value: string;
};

type RecentConfigSelectProps = {
readonly dagId: string;
readonly onSelectConf: (conf: Record<string, unknown>) => void;
readonly open: boolean;
};

const RecentConfigSelect = ({ dagId, onSelectConf, open }: RecentConfigSelectProps) => {
const { t: translate } = useTranslation("components");
const [selectedValue, setSelectedValue] = useState<Array<string>>([]);
const { data, isLoading } = useDagRunServiceGetDagRuns(
{ dagId, limit: 25, orderBy: ["-run_after"] },
undefined,
{ enabled: open },
);

const options = useMemo(() => {
const seenConfs = new Set<string>();

return (data?.dag_runs ?? []).reduce<Array<RecentConfigOption>>((items, run) => {
const hasConf = run.conf !== null && Object.keys(run.conf).length > 0;
const confKey = hasConf ? JSON.stringify(run.conf) : undefined;
const isNewConf = confKey !== undefined && !seenConfs.has(confKey);

if (isNewConf) {
seenConfs.add(confKey);
items.push({ run, value: run.dag_run_id });
}

return items;
}, []);
}, [data?.dag_runs]);

const recentConfigOptions = createListCollection({
items: options,
itemToString: (item: RecentConfigOption) => item.run.dag_run_id,
});

const handleValueChange = ({ items, value }: SelectValueChangeDetails<RecentConfigOption>) => {
const [selected] = items;

setSelectedValue(value);
if (selected?.run.conf) {
onSelectConf(selected.run.conf);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will reset user defined logicalDate, runId, and note to defaults.
But here it fires interactively: a user who sets a logical date / custom run ID / manual data interval / note and then picks a recent config to reuse its params loses all of those to defaults. The dropdown is meant to apply a past run's conf, not reset the run's scheduling fields.

Either fix that or move this config selector at the top of the form, so we know that all fields are affected and will be reset to

}
};

if (!isLoading && options.length === 0) {
return undefined;
}

return (
<Select.Root
collection={recentConfigOptions}
data-testid="recent-config-select"
disabled={isLoading || options.length === 0}
onValueChange={handleValueChange}
size="sm"
value={selectedValue}
>
<Select.Label fontSize="xs">{translate("triggerDag.recentConfig")}</Select.Label>
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder={translate("triggerDag.recentConfigPlaceholder")} />
</Select.Trigger>
Comment on lines +83 to +96

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we run through 'trigger again with this conf button', this drop down probably shouldn't be there at all.

It's confusing to have it (with nothing selected), but actually a conf is passed down. And if it's to change the run conf to another run, there's no point running through that 'trigger again with this conf' button.

<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content maxH="200px" overflowY="auto">
{recentConfigOptions.items.map((option) => (
<Select.Item item={option} key={option.run.dag_run_id}>
<Flex justifyContent="space-between" width="100%">
<Text>{option.run.dag_run_id}</Text>
<Time datetime={option.run.run_after} />
</Flex>
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
);
};

export default RecentConfigSelect;
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* under the License.
*/
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { Wrapper } from "src/utils/Wrapper";
Expand Down Expand Up @@ -53,6 +53,21 @@ vi.mock("src/queries/useDagParams", () => ({
useDagParams: useDagParamsMock,
}));

vi.mock("openapi/queries", () => ({
useDagRunServiceGetDagRuns: vi.fn(() => ({
data: {
dag_runs: [
{
conf: { message: "From recent" },
dag_run_id: "run_recent",
run_after: "2025-01-01T00:00:00Z",
},
],
},
isLoading: false,
})),
}));

vi.mock("src/queries/useTogglePause", () => ({
useTogglePause: () => ({
mutate: vi.fn(),
Expand Down Expand Up @@ -214,4 +229,46 @@ describe("TriggerDAGForm", () => {
await waitFor(() => expect(screen.getByText("dagRun.partitionKey")).toBeInTheDocument());
expect(screen.getByText("components:triggerDag.partitionKeyHelp")).toBeInTheDocument();
});

it("prefills the form when a recent configuration is selected from the dropdown", async () => {
const { container } = render(
<TriggerDAGForm
dagDisplayName="Params Trigger UI"
dagId="example_params_trigger_ui"
error={undefined}
hasSchedule={false}
isPartitioned={false}
isPaused={false}
isPending={false}
onSubmitTrigger={vi.fn()}
open
prefillConfig={undefined}
/>,
{ wrapper: Wrapper },
);

const recentConfigSelect = screen.getByTestId("recent-config-select");

fireEvent.click(within(recentConfigSelect).getByRole("combobox"));
await waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument());
fireEvent.click(screen.getByText("run_recent"));

await waitFor(() =>
expect(container.querySelector<HTMLInputElement>('input[name="element_message"]')?.value).toBe(
"From recent",
),
);

fireEvent.click(screen.getByText("Advanced Options"));

await waitFor(() => {
const configJson = screen.getByLabelText("Configuration JSON");

if (!(configJson instanceof HTMLTextAreaElement)) {
throw new TypeError("Expected Configuration JSON to render as a textarea");
}

expect(configJson.value).toContain('"From recent"');
});
});
});
Loading
Loading