Skip to content

Commit ec5b83d

Browse files
reviews
1 parent 89b3f65 commit ec5b83d

3 files changed

Lines changed: 357 additions & 22 deletions

File tree

airflow-core/docs/core-concepts/params.rst

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,43 @@ The following features are supported in the Trigger UI Form:
341341
-
342342
- ``Param(None, type=["null", "string"])``
343343

344-
- If a form field is left empty, it is passed as ``None`` value to the params dict.
344+
* - Multiple non-null types
345+
346+
e.g. ``["string", "object"]``,
347+
``["integer", "string"]``
348+
- | Generates a plain multi-line textarea.
349+
| The stored value type is resolved at
350+
| input time: the input is first parsed as
351+
| JSON; if the parsed type matches one of
352+
| the declared schema types it is stored as
353+
| that type, otherwise the raw string is
354+
| stored. This means:
355+
|
356+
| * ``"nightly"`` → always stored as a string
357+
| (JSON parse fails).
358+
| * ``"45"`` with ``["string", "object"]`` →
359+
| stored as the string ``"45"`` (number is
360+
| not in the schema).
361+
| * ``"45"`` with ``["integer", "string"]`` →
362+
| stored as the integer ``45`` (number
363+
| matches ``"integer"``).
364+
| * ``'{"key": "val"}'`` with
365+
| ``["string", "object"]`` → stored as an
366+
| object.
367+
368+
.. note::
369+
370+
If the schema also defines ``enum`` or
371+
``examples``, the normal dropdown or
372+
multi-select widget is used instead of
373+
the textarea, because the set of valid
374+
values is already constrained.
375+
- none.
376+
- ``Param("nightly", type=["string", "object"])``
377+
378+
``Param(5, type=["integer", "string"])``
379+
380+
345381
- Form fields are rendered in the order of definition of ``params`` in the Dag.
346382
- If you want to add sections to the Form, add the attribute ``section`` to each field. The text will be used as section label.
347383
Fields w/o ``section`` will be rendered in the default area.
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/*!
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import { fireEvent, render, screen } from "@testing-library/react";
20+
import { beforeEach, describe, expect, it, vi } from "vitest";
21+
22+
import { Wrapper } from "src/utils/Wrapper";
23+
24+
import { FieldMultiType } from "./FieldMultiType";
25+
26+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
27+
const mockParamsDict: Record<string, any> = {};
28+
const mockSetParamsDict = vi.fn();
29+
30+
vi.mock("src/queries/useParamStore", () => ({
31+
paramPlaceholder: {
32+
schema: { type: undefined },
33+
value: null,
34+
},
35+
useParamStore: () => ({
36+
disabled: false,
37+
paramsDict: mockParamsDict,
38+
setParamsDict: mockSetParamsDict,
39+
}),
40+
}));
41+
42+
describe("FieldMultiType", () => {
43+
beforeEach(() => {
44+
Object.keys(mockParamsDict).forEach((key) => {
45+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
46+
delete mockParamsDict[key];
47+
});
48+
mockSetParamsDict.mockClear();
49+
});
50+
51+
describe("display", () => {
52+
it("renders a textarea", () => {
53+
mockParamsDict.test_param = {
54+
schema: { type: ["string", "object"] },
55+
value: "nightly",
56+
};
57+
render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { wrapper: Wrapper });
58+
expect(screen.getByRole("textbox")).toBeDefined();
59+
});
60+
61+
it("displays an object default as pretty-printed JSON", () => {
62+
const obj = { name: "my_pipeline", retries: 3 };
63+
64+
mockParamsDict.test_param = {
65+
schema: { type: ["string", "object"] },
66+
value: obj,
67+
};
68+
render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { wrapper: Wrapper });
69+
expect(screen.getByRole("textbox")).toHaveProperty("value", JSON.stringify(obj, undefined, 2));
70+
});
71+
72+
it("displays a string default as-is", () => {
73+
mockParamsDict.test_param = {
74+
schema: { type: ["string", "object"] },
75+
value: "my_pipeline",
76+
};
77+
render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { wrapper: Wrapper });
78+
expect(screen.getByRole("textbox")).toHaveProperty("value", "my_pipeline");
79+
});
80+
81+
it("displays a number default as a string", () => {
82+
mockParamsDict.test_param = {
83+
schema: { type: ["integer", "string"] },
84+
value: 42,
85+
};
86+
render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { wrapper: Wrapper });
87+
expect(screen.getByRole("textbox")).toHaveProperty("value", "42");
88+
});
89+
90+
it("displays a boolean default as a string", () => {
91+
mockParamsDict.test_param = {
92+
schema: { type: ["boolean", "string"] },
93+
value: true,
94+
};
95+
render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { wrapper: Wrapper });
96+
expect(screen.getByRole("textbox")).toHaveProperty("value", "true");
97+
});
98+
});
99+
100+
describe("type resolution on change", () => {
101+
it("stores a valid JSON object when schema includes 'object'", () => {
102+
mockParamsDict.test_param = {
103+
schema: { type: ["string", "object"] },
104+
value: {},
105+
};
106+
render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { wrapper: Wrapper });
107+
fireEvent.change(screen.getByRole("textbox"), { target: { value: '{"key": "val"}' } });
108+
expect(mockParamsDict.test_param.value).toEqual({ key: "val" });
109+
});
110+
111+
it("stores a plain string when JSON parse fails", () => {
112+
mockParamsDict.test_param = {
113+
schema: { type: ["string", "object"] },
114+
value: {},
115+
};
116+
render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { wrapper: Wrapper });
117+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "nightly" } });
118+
expect(mockParamsDict.test_param.value).toBe("nightly");
119+
});
120+
121+
it("stores '45' as a string for type=['string','object'] — number not in schema", () => {
122+
mockParamsDict.test_param = {
123+
schema: { type: ["string", "object"] },
124+
value: {},
125+
};
126+
render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { wrapper: Wrapper });
127+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "45" } });
128+
expect(mockParamsDict.test_param.value).toBe("45");
129+
expect(typeof mockParamsDict.test_param.value).toBe("string");
130+
});
131+
132+
it("stores 45 as a number for type=['integer','string']", () => {
133+
mockParamsDict.test_param = {
134+
schema: { type: ["integer", "string"] },
135+
value: "nightly",
136+
};
137+
render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { wrapper: Wrapper });
138+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "45" } });
139+
expect(mockParamsDict.test_param.value).toBe(45);
140+
expect(typeof mockParamsDict.test_param.value).toBe("number");
141+
});
142+
143+
it("stores a string for type=['integer','string'] when input is non-numeric text", () => {
144+
mockParamsDict.test_param = {
145+
schema: { type: ["integer", "string"] },
146+
value: 0,
147+
};
148+
render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { wrapper: Wrapper });
149+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "nightly" } });
150+
expect(mockParamsDict.test_param.value).toBe("nightly");
151+
});
152+
153+
it("stores true as boolean for type=['boolean','string']", () => {
154+
mockParamsDict.test_param = {
155+
schema: { type: ["boolean", "string"] },
156+
value: "pending",
157+
};
158+
render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { wrapper: Wrapper });
159+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "true" } });
160+
expect(mockParamsDict.test_param.value).toBe(true);
161+
});
162+
163+
it("stores a string for type=['number','string'] when input is not a number", () => {
164+
mockParamsDict.test_param = {
165+
schema: { type: ["number", "string"] },
166+
value: 0,
167+
};
168+
render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { wrapper: Wrapper });
169+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "nightly" } });
170+
expect(mockParamsDict.test_param.value).toBe("nightly");
171+
});
172+
173+
it("stores null on empty input", () => {
174+
mockParamsDict.test_param = {
175+
schema: { type: ["string", "object"] },
176+
value: "something",
177+
};
178+
render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { wrapper: Wrapper });
179+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "" } });
180+
expect(mockParamsDict.test_param.value).toBeNull();
181+
});
182+
183+
it("calls onUpdate with the raw input string", () => {
184+
const onUpdate = vi.fn();
185+
186+
mockParamsDict.test_param = {
187+
schema: { type: ["string", "object"] },
188+
value: {},
189+
};
190+
render(<FieldMultiType name="test_param" onUpdate={onUpdate} />, { wrapper: Wrapper });
191+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "nightly" } });
192+
expect(onUpdate).toHaveBeenCalledWith("nightly");
193+
});
194+
});
195+
196+
describe("validation errors for schemas without 'string'", () => {
197+
it("signals error and preserves old value when input doesn't match type=['integer','object']", () => {
198+
const onUpdate = vi.fn();
199+
200+
mockParamsDict.test_param = {
201+
schema: { type: ["integer", "object"] },
202+
value: 0,
203+
};
204+
render(<FieldMultiType name="test_param" onUpdate={onUpdate} />, { wrapper: Wrapper });
205+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "nightly" } });
206+
expect(onUpdate).toHaveBeenCalledWith("", expect.stringContaining("integer"));
207+
expect(mockParamsDict.test_param.value).toBe(0);
208+
});
209+
210+
it("accepts a valid integer for type=['integer','object']", () => {
211+
const onUpdate = vi.fn();
212+
213+
mockParamsDict.test_param = {
214+
schema: { type: ["integer", "object"] },
215+
value: 0,
216+
};
217+
render(<FieldMultiType name="test_param" onUpdate={onUpdate} />, { wrapper: Wrapper });
218+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "42" } });
219+
expect(onUpdate).toHaveBeenCalledWith("42");
220+
expect(mockParamsDict.test_param.value).toBe(42);
221+
});
222+
223+
it("accepts a valid JSON object for type=['integer','object']", () => {
224+
const onUpdate = vi.fn();
225+
226+
mockParamsDict.test_param = {
227+
schema: { type: ["integer", "object"] },
228+
value: 0,
229+
};
230+
render(<FieldMultiType name="test_param" onUpdate={onUpdate} />, { wrapper: Wrapper });
231+
fireEvent.change(screen.getByRole("textbox"), { target: { value: '{"k": 1}' } });
232+
expect(onUpdate).toHaveBeenCalledWith('{"k": 1}');
233+
expect(mockParamsDict.test_param.value).toEqual({ k: 1 });
234+
});
235+
236+
it("signals error for type=['boolean','object'] when input is neither", () => {
237+
const onUpdate = vi.fn();
238+
239+
mockParamsDict.test_param = {
240+
schema: { type: ["boolean", "object"] },
241+
value: true,
242+
};
243+
render(<FieldMultiType name="test_param" onUpdate={onUpdate} />, { wrapper: Wrapper });
244+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "nightly" } });
245+
expect(onUpdate).toHaveBeenCalledWith("", expect.stringContaining("boolean"));
246+
expect(mockParamsDict.test_param.value).toBe(true);
247+
});
248+
249+
it("stores '4.5' as a string for type=['integer','string'] — float is not a valid integer", () => {
250+
mockParamsDict.test_param = {
251+
schema: { type: ["integer", "string"] },
252+
value: 0,
253+
};
254+
render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { wrapper: Wrapper });
255+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "4.5" } });
256+
expect(mockParamsDict.test_param.value).toBe("4.5");
257+
expect(typeof mockParamsDict.test_param.value).toBe("string");
258+
});
259+
});
260+
});

0 commit comments

Comments
 (0)