mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Debug nested template arguments (#788)
This commit is contained in:
1 parent
c9ad753672
commit
87a30200f9
6 files changed
+557
-5
No files matched your search
@@ -0,0 +1,152 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { Workspace } from "@yaakapp-internal/models";
|
||||
import type { TemplateFunction } from "@yaakapp-internal/plugins";
|
||||
import type { Tokens } from "@yaakapp-internal/templates";
|
||||
import { act, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeAll, beforeEach, expect, test, vi } from "vite-plus/test";
|
||||
import { TemplateFunctionDialog } from "./TemplateFunctionDialog";
|
||||
|
||||
interface FormProps {
|
||||
data: Record<string, string | boolean>;
|
||||
onChange: (values: Record<string, string | boolean>) => void;
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
parse: vi.fn<(text: string) => Tokens>(),
|
||||
print: vi.fn<(tokens: Tokens) => Promise<string>>(),
|
||||
serialize: vi.fn<(tokens: Tokens) => { data: string }>(),
|
||||
preview: vi.fn<({ template }: { template: string }) => { data: string }>(),
|
||||
form: null as FormProps | null,
|
||||
config: {
|
||||
name: "timestamp.format",
|
||||
args: [
|
||||
{ type: "text", name: "date" },
|
||||
{ type: "text", name: "format" },
|
||||
],
|
||||
} as TemplateFunction,
|
||||
}));
|
||||
|
||||
vi.mock("@yaakapp-internal/templates", () => ({ parseTemplate: mocks.parse }));
|
||||
vi.mock("@yaakapp-internal/ui", () => ({
|
||||
HStack: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
InlineCode: "code",
|
||||
LoadingIcon: () => null,
|
||||
useDebouncedValue: (value: string) => value,
|
||||
}));
|
||||
vi.mock("../hooks/useActiveWorkspace", () => ({ activeWorkspaceAtom: null }));
|
||||
vi.mock("../hooks/useRenderTemplate", () => ({ useRenderTemplate: mocks.preview }));
|
||||
vi.mock("../hooks/useTemplateFunctionConfig", () => ({
|
||||
useTemplateFunctionConfig: () => ({ data: mocks.config }),
|
||||
}));
|
||||
vi.mock("../hooks/useTemplateTokensToString", () => ({
|
||||
templateTokensToString: mocks.print,
|
||||
useTemplateTokensToString: mocks.serialize,
|
||||
}));
|
||||
vi.mock("../lib/dialog", () => ({ showDialog: vi.fn() }));
|
||||
vi.mock("../lib/encryption", () => ({ convertTemplateToInsecure: vi.fn() }));
|
||||
vi.mock("../lib/jotai", () => ({ jotaiStore: { get: vi.fn() } }));
|
||||
vi.mock("../lib/setupOrConfigureEncryption", () => ({ setupOrConfigureEncryption: vi.fn() }));
|
||||
vi.mock("./core/Dialog", () => ({ DialogFooter: () => <button type="submit">Save</button> }));
|
||||
vi.mock("./core/IconButton", () => ({ IconButton: () => <button type="button" /> }));
|
||||
vi.mock("./core/PlainInput", () => ({ PlainInput: () => null }));
|
||||
vi.mock("./DynamicForm", () => ({
|
||||
DYNAMIC_FORM_NULL_ARG: "__NULL__",
|
||||
DynamicForm: (props: FormProps) => {
|
||||
mocks.form = props;
|
||||
return <input aria-label="Timestamp" readOnly value={String(props.data.date ?? "")} />;
|
||||
},
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
const base = pathToFileURL(
|
||||
resolve(import.meta.dirname, "../../../crates/yaak-templates/pkg") + "/",
|
||||
);
|
||||
const bindings = await import(new URL("yaak_templates_bg.js", base).href);
|
||||
const { instance } = await WebAssembly.instantiate(
|
||||
await readFile(new URL("yaak_templates_bg.wasm", base)),
|
||||
{ "./yaak_templates_bg.js": bindings },
|
||||
);
|
||||
bindings.__wbg_set_wasm(instance.exports);
|
||||
(instance.exports.__wbindgen_start as () => void)();
|
||||
mocks.parse.mockImplementation(bindings.parse_template);
|
||||
});
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
mocks.form = null;
|
||||
// The transport returns an opaque string. Carry the AST as JSON so the assertions can
|
||||
// inspect what Preview and Save receive, without duplicating the backend's printer.
|
||||
mocks.serialize.mockImplementation((tokens) => ({ data: JSON.stringify(tokens) }));
|
||||
mocks.preview.mockImplementation(({ template }) => ({ data: template }));
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function functionValue(tokens: Tokens) {
|
||||
const first = tokens.tokens[0];
|
||||
if (first?.type !== "tag" || first.val.type !== "fn") throw new Error("Expected function");
|
||||
return first.val;
|
||||
}
|
||||
|
||||
test.each([
|
||||
["timestamp.offset(expression='-1h')", "${[ timestamp.offset(expression='-1h') ]}"],
|
||||
["date_var", "${[ date_var ]}"],
|
||||
["dates.previous", "${[ dates.previous ]}"],
|
||||
["'${[ date_var ]}'", "${[ date_var ]}"],
|
||||
])("Preview and Save preserve %s until that input changes", async (expression, display) => {
|
||||
const initialTokens = mocks.parse(`\${[ timestamp.format(date=${expression}, format='HH:mm') ]}`);
|
||||
const originalDate = functionValue(initialTokens).args[0];
|
||||
const saved = vi.fn<(value: string) => void>();
|
||||
mocks.print.mockResolvedValue(display);
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<TemplateFunctionDialog
|
||||
templateFunction={mocks.config}
|
||||
initialTokens={initialTokens}
|
||||
model={{ id: "wk_test" } as Workspace}
|
||||
onChange={saved}
|
||||
hide={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(host.querySelector<HTMLInputElement>("input")?.value).toBe(display);
|
||||
|
||||
async function submit() {
|
||||
const form = host.querySelector("form");
|
||||
if (form == null) throw new Error("Dialog did not initialize");
|
||||
await act(async () => {
|
||||
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
const value = saved.mock.calls.at(-1)?.[0];
|
||||
expect(value).toBe(mocks.preview.mock.calls.at(-1)?.[0].template);
|
||||
return functionValue(JSON.parse(value ?? "null") as Tokens).args;
|
||||
}
|
||||
|
||||
expect((await submit())[0]).toEqual(originalDate);
|
||||
await act(async () => mocks.form?.onChange({ ...mocks.form.data, format: "yyyy" }));
|
||||
expect(await submit()).toEqual([
|
||||
originalDate,
|
||||
{ name: "format", value: { type: "str", text: "yyyy" } },
|
||||
]);
|
||||
|
||||
const edited = "${[ timestamp.offset(expression='-2h') ]}";
|
||||
await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: edited }));
|
||||
expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: edited } });
|
||||
await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: "" }));
|
||||
expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: "" } });
|
||||
});
|
||||
@@ -24,8 +24,11 @@ import { showDialog } from "../lib/dialog";
|
||||
import { convertTemplateToInsecure } from "../lib/encryption";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
||||
import {
|
||||
collectEditableArgumentValues,
|
||||
preserveUnchangedArgumentExpressions,
|
||||
} from "../lib/templateFunctionArguments";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { collectArgumentValues } from "./core/Editor/twig/util";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from "./DynamicForm";
|
||||
@@ -48,7 +51,11 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const initial = collectArgumentValues(initialTokens, templateFunction);
|
||||
const initial = await collectEditableArgumentValues(
|
||||
initialTokens,
|
||||
templateFunction,
|
||||
templateTokensToString,
|
||||
);
|
||||
|
||||
// HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so
|
||||
// we can display it in the editor input.
|
||||
@@ -74,6 +81,7 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
|
||||
<InitializedTemplateFunctionDialog
|
||||
{...props}
|
||||
templateFunction={templateFunction}
|
||||
initialTokens={initialTokens}
|
||||
initialArgValues={initialArgValues}
|
||||
/>
|
||||
);
|
||||
@@ -82,10 +90,11 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
|
||||
function InitializedTemplateFunctionDialog({
|
||||
templateFunction: { name, previewType: ogPreviewType },
|
||||
initialArgValues,
|
||||
initialTokens,
|
||||
hide,
|
||||
onChange,
|
||||
model,
|
||||
}: Omit<Props, "initialTokens"> & {
|
||||
}: Props & {
|
||||
initialArgValues: Record<string, string | boolean>;
|
||||
}) {
|
||||
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
|
||||
@@ -111,12 +120,17 @@ function InitializedTemplateFunctionDialog({
|
||||
val: {
|
||||
type: "fn",
|
||||
name,
|
||||
args: argTokens,
|
||||
args: preserveUnchangedArgumentExpressions(
|
||||
argTokens,
|
||||
initialTokens,
|
||||
initialArgValues,
|
||||
argValues,
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [argValues, name]);
|
||||
}, [argValues, initialArgValues, initialTokens, name]);
|
||||
|
||||
const tagText = useTemplateTokensToString(tokens);
|
||||
const templateFunction = useTemplateFunctionConfig(name, argValues, model);
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { TemplateFunction } from "@yaakapp-internal/plugins";
|
||||
import type { FnArg, Tokens } from "@yaakapp-internal/templates";
|
||||
import { beforeAll, describe, expect, test, vi } from "vite-plus/test";
|
||||
import {
|
||||
collectEditableArgumentValues,
|
||||
preserveUnchangedArgumentExpressions,
|
||||
} from "./templateFunctionArguments";
|
||||
|
||||
// Use the shipped WASM parser without the browser-only `?init` loader.
|
||||
let parse: (text: string) => Tokens;
|
||||
beforeAll(async () => {
|
||||
const base = new URL("../../../crates/yaak-templates/pkg/", import.meta.url);
|
||||
const bindings = await import(new URL("yaak_templates_bg.js", base).href);
|
||||
const { instance } = await WebAssembly.instantiate(
|
||||
await readFile(new URL("yaak_templates_bg.wasm", base)),
|
||||
{ "./yaak_templates_bg.js": bindings },
|
||||
);
|
||||
bindings.__wbg_set_wasm(instance.exports);
|
||||
(instance.exports.__wbindgen_start as () => void)();
|
||||
parse = bindings.parse_template;
|
||||
});
|
||||
|
||||
const definition: TemplateFunction = {
|
||||
name: "timestamp.iso8601",
|
||||
args: [{ type: "text", name: "date", optional: true }],
|
||||
};
|
||||
|
||||
function args(tokens: Tokens): FnArg[] {
|
||||
const first = tokens.tokens[0];
|
||||
if (first?.type !== "tag" || first.val.type !== "fn") throw new Error("Expected a function");
|
||||
return first.val.args;
|
||||
}
|
||||
|
||||
function stringArg(name: string, text: string): FnArg {
|
||||
return { name, value: { type: "str", text } };
|
||||
}
|
||||
|
||||
// Printing is an existing async RPC. Assert the exact expression handed to it and return
|
||||
// its expected text, including real parser round-tripping of quoted/escaped fixtures.
|
||||
function printer(text: string) {
|
||||
return vi.fn(async (tokens: Tokens) => {
|
||||
expect(tokens.tokens).toEqual(parse(text).tokens.filter((t) => t.type !== "eof"));
|
||||
return text;
|
||||
});
|
||||
}
|
||||
|
||||
describe("editable template function arguments", () => {
|
||||
test.each([
|
||||
[
|
||||
"reported call",
|
||||
"${[timestamp.iso8601(date=timestamp.offset(expression='-1h')) ]}",
|
||||
"${[ timestamp.offset(expression='-1h') ]}",
|
||||
],
|
||||
["variable", "${[ timestamp.iso8601(date=date_var) ]}", "${[ date_var ]}"],
|
||||
["dotted variable", "${[ timestamp.iso8601(date=dates.previous) ]}", "${[ dates.previous ]}"],
|
||||
[
|
||||
"nested escaping",
|
||||
String.raw`${"${["} timestamp.iso8601(date=outer(value=inner(text='it\'s \\ nested'))) ]}`,
|
||||
String.raw`${"${["} outer(value=inner(text='it\'s \\ nested')) ]}`,
|
||||
],
|
||||
])("loads and preserves %s on unchanged Save", async (_, source, display) => {
|
||||
const initialTokens = parse(source);
|
||||
const snapshot = structuredClone(initialTokens);
|
||||
const print = printer(display);
|
||||
const initial = await collectEditableArgumentValues(initialTokens, definition, print);
|
||||
expect(initial).toEqual({ date: display });
|
||||
expect(print).toHaveBeenCalledOnce();
|
||||
|
||||
const saved = preserveUnchangedArgumentExpressions(
|
||||
[stringArg("date", display)],
|
||||
initialTokens,
|
||||
initial,
|
||||
{ ...initial },
|
||||
);
|
||||
expect(saved).toEqual(args(initialTokens));
|
||||
expect(initialTokens).toEqual(snapshot);
|
||||
});
|
||||
|
||||
test.each([
|
||||
String.raw`${"${["} timestamp.iso8601(date='${"${["} timestamp.offset(expression=\'-1h\') ]}') ]}`,
|
||||
"${[ timestamp.iso8601(date='${[ date_var ]}') ]}",
|
||||
"${[ timestamp.iso8601(date='${[ dates.previous ]}') ]}",
|
||||
"${[ timestamp.iso8601(date='2026-10-27T11:00:00.000Z') ]}",
|
||||
])("leaves existing quoted arguments unchanged: %s", async (source) => {
|
||||
const initialTokens = parse(source);
|
||||
const print = vi.fn<(_: Tokens) => Promise<string>>();
|
||||
const initial = await collectEditableArgumentValues(initialTokens, definition, print);
|
||||
const value = args(initialTokens)[0]?.value;
|
||||
if (value?.type !== "str") throw new Error("Expected a string fixture");
|
||||
expect(initial).toEqual({ date: value.text });
|
||||
expect(print).not.toHaveBeenCalled();
|
||||
expect(
|
||||
preserveUnchangedArgumentExpressions(
|
||||
[stringArg("date", value.text)],
|
||||
initialTokens,
|
||||
initial,
|
||||
initial,
|
||||
),
|
||||
).toEqual(args(initialTokens));
|
||||
});
|
||||
|
||||
test("editing a sibling preserves the original expression", async () => {
|
||||
const initialTokens = parse(
|
||||
"${[ timestamp.format(date=timestamp.offset(expression='-1h'), format='HH:mm') ]}",
|
||||
);
|
||||
const initial = await collectEditableArgumentValues(
|
||||
initialTokens,
|
||||
{
|
||||
name: "timestamp.format",
|
||||
args: [...definition.args, { type: "text", name: "format" }],
|
||||
},
|
||||
printer("${[ timestamp.offset(expression='-1h') ]}"),
|
||||
);
|
||||
const values: Record<string, string | boolean> = { ...initial, format: "yyyy-MM-dd" };
|
||||
const saved = preserveUnchangedArgumentExpressions(
|
||||
[stringArg("date", String(values.date)), stringArg("format", String(values.format))],
|
||||
initialTokens,
|
||||
initial,
|
||||
values,
|
||||
);
|
||||
expect(saved).toEqual([args(initialTokens)[0], stringArg("format", "yyyy-MM-dd")]);
|
||||
});
|
||||
|
||||
test.each(["${[ timestamp.offset(expression='-2h') ]}", "2026-10-26", ""])(
|
||||
"intentional edits use the form's quoted-string convention: %s",
|
||||
async (edited) => {
|
||||
const initialTokens = parse("${[ timestamp.iso8601(date=date_var) ]}");
|
||||
const initial = await collectEditableArgumentValues(
|
||||
initialTokens,
|
||||
definition,
|
||||
printer("${[ date_var ]}"),
|
||||
);
|
||||
const changed = [stringArg("date", edited)];
|
||||
expect(
|
||||
preserveUnchangedArgumentExpressions(changed, initialTokens, initial, { date: edited }),
|
||||
).toEqual(changed);
|
||||
// Returning the input to its original text restores the original expression type.
|
||||
expect(
|
||||
preserveUnchangedArgumentExpressions(
|
||||
[stringArg("date", String(initial.date))],
|
||||
initialTokens,
|
||||
initial,
|
||||
{ ...initial },
|
||||
),
|
||||
).toEqual(args(initialTokens));
|
||||
},
|
||||
);
|
||||
|
||||
test("does not resurrect removed or explicitly cleared expression arguments", async () => {
|
||||
const initialTokens = parse("${[ timestamp.iso8601(date=date_var) ]}");
|
||||
const initial = await collectEditableArgumentValues(
|
||||
initialTokens,
|
||||
definition,
|
||||
printer("${[ date_var ]}"),
|
||||
);
|
||||
expect(preserveUnchangedArgumentExpressions([], initialTokens, initial, {})).toEqual([]);
|
||||
const nullArg: FnArg = { name: "date", value: { type: "null" } };
|
||||
expect(
|
||||
preserveUnchangedArgumentExpressions([nullArg], initialTokens, initial, { date: "__NULL__" }),
|
||||
).toEqual([nullArg]);
|
||||
// DynamicForm clears some inputs by leaving a key with undefined; the dialog turns it into ''.
|
||||
expect(
|
||||
preserveUnchangedArgumentExpressions([stringArg("date", "")], initialTokens, initial, {}),
|
||||
).toEqual([stringArg("date", "")]);
|
||||
expect(
|
||||
preserveUnchangedArgumentExpressions([stringArg("date", "")], initialTokens, {}, {}),
|
||||
).toEqual([stringArg("date", "")]);
|
||||
});
|
||||
|
||||
test("keeps absence, defaults, explicit empty strings, and false distinct", async () => {
|
||||
const fn: TemplateFunction = {
|
||||
name: "test",
|
||||
args: [
|
||||
{ type: "text", name: "absent" },
|
||||
{ type: "text", name: "defaulted", defaultValue: "fallback" },
|
||||
{ type: "text", name: "empty", defaultValue: "fallback" },
|
||||
{ type: "checkbox", name: "flag", defaultValue: "true" },
|
||||
{ type: "checkbox", name: "defaultFlag", defaultValue: "false" },
|
||||
],
|
||||
};
|
||||
const initialTokens = parse("${[ test(empty='', flag=false) ]}");
|
||||
const print = vi.fn<(_: Tokens) => Promise<string>>();
|
||||
const initial = await collectEditableArgumentValues(initialTokens, fn, print);
|
||||
expect(initial).toEqual({
|
||||
defaulted: "fallback",
|
||||
empty: "",
|
||||
flag: false,
|
||||
defaultFlag: "false",
|
||||
});
|
||||
expect(print).not.toHaveBeenCalled();
|
||||
const formArgs: FnArg[] = [
|
||||
stringArg("defaulted", "fallback"),
|
||||
stringArg("empty", ""),
|
||||
{ name: "flag", value: { type: "bool", value: false } },
|
||||
stringArg("defaultFlag", "false"),
|
||||
];
|
||||
expect(preserveUnchangedArgumentExpressions(formArgs, initialTokens, initial, initial)).toEqual(
|
||||
formArgs,
|
||||
);
|
||||
});
|
||||
|
||||
test("an expression overrides defaults, including inputs in a group", async () => {
|
||||
const initialTokens = parse("${[ test(date=dates.previous) ]}");
|
||||
const initial = await collectEditableArgumentValues(
|
||||
initialTokens,
|
||||
{
|
||||
name: "test",
|
||||
args: [
|
||||
{ type: "h_stack", inputs: [{ type: "text", name: "date", defaultValue: "fallback" }] },
|
||||
],
|
||||
},
|
||||
printer("${[ dates.previous ]}"),
|
||||
);
|
||||
expect(initial).toEqual({ date: "${[ dates.previous ]}" });
|
||||
});
|
||||
|
||||
test("explicit null keeps the existing default and omission behavior", async () => {
|
||||
const initialTokens = parse("${[ test(defaulted=null, omitted=null) ]}");
|
||||
const print = vi.fn<(_: Tokens) => Promise<string>>();
|
||||
const initial = await collectEditableArgumentValues(
|
||||
initialTokens,
|
||||
{
|
||||
name: "test",
|
||||
args: [
|
||||
{ type: "text", name: "defaulted", defaultValue: "fallback" },
|
||||
{ type: "text", name: "omitted" },
|
||||
],
|
||||
},
|
||||
print,
|
||||
);
|
||||
expect(initial).toEqual({ defaulted: "fallback" });
|
||||
expect(print).not.toHaveBeenCalled();
|
||||
expect(
|
||||
preserveUnchangedArgumentExpressions(
|
||||
[stringArg("defaulted", "fallback")],
|
||||
initialTokens,
|
||||
initial,
|
||||
initial,
|
||||
),
|
||||
).toEqual([stringArg("defaulted", "fallback")]);
|
||||
});
|
||||
|
||||
test("keeps a deeply nested expression's AST without adding string wrappers", async () => {
|
||||
// This shape reaches the native renderer's depth limit. A quoted wrapper around
|
||||
// the date expression would exceed it, even though shallow calls render identically.
|
||||
const expression =
|
||||
"timestamp.iso8601(date=".repeat(44) + "'2026-10-26T10:20:30.000Z'" + ")".repeat(44);
|
||||
const initialTokens = parse(`\${[ timestamp.iso8601(date=${expression}) ]}`);
|
||||
const initial = await collectEditableArgumentValues(
|
||||
initialTokens,
|
||||
definition,
|
||||
printer(`\${[ ${expression} ]}`),
|
||||
);
|
||||
const saved = preserveUnchangedArgumentExpressions(
|
||||
[stringArg("date", String(initial.date))],
|
||||
initialTokens,
|
||||
initial,
|
||||
initial,
|
||||
);
|
||||
expect(saved).toEqual(args(initialTokens));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { TemplateFunction } from "@yaakapp-internal/plugins";
|
||||
import type { FnArg, Tokens } from "@yaakapp-internal/templates";
|
||||
import { collectArgumentValues } from "../components/core/Editor/twig/util";
|
||||
|
||||
type ArgumentValues = Record<string, string | boolean>;
|
||||
|
||||
function functionArgs(tokens: Tokens): FnArg[] {
|
||||
const first = tokens.tokens[0];
|
||||
return first?.type === "tag" && first.val.type === "fn" ? first.val.args : [];
|
||||
}
|
||||
|
||||
/** Adapt expressions to the template text that the form's editors understand. */
|
||||
export async function collectEditableArgumentValues(
|
||||
tokens: Tokens,
|
||||
templateFunction: TemplateFunction,
|
||||
tokensToString: (tokens: Tokens) => Promise<string>,
|
||||
): Promise<ArgumentValues> {
|
||||
const first = tokens.tokens[0];
|
||||
if (first?.type !== "tag" || first.val.type !== "fn") {
|
||||
return collectArgumentValues(tokens, templateFunction);
|
||||
}
|
||||
|
||||
const args = await Promise.all(
|
||||
first.val.args.map(async (arg): Promise<FnArg> => {
|
||||
if (arg.value.type !== "fn" && arg.value.type !== "var") return arg;
|
||||
const text = await tokensToString({ tokens: [{ type: "tag", val: arg.value }] });
|
||||
return { ...arg, value: { type: "str", text } };
|
||||
}),
|
||||
);
|
||||
|
||||
return collectArgumentValues(
|
||||
{ tokens: [{ ...first, val: { ...first.val, args } }] },
|
||||
templateFunction,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displaying an expression as template text must not change its stored type: a string
|
||||
* wrapper adds rendering depth. Only deliberately changed inputs use the form's new values.
|
||||
*/
|
||||
export function preserveUnchangedArgumentExpressions(
|
||||
args: FnArg[],
|
||||
initialTokens: Tokens,
|
||||
initialValues: ArgumentValues,
|
||||
values: ArgumentValues,
|
||||
): FnArg[] {
|
||||
const initialArgs = functionArgs(initialTokens);
|
||||
return args.map((arg) => {
|
||||
const initial = initialArgs.find((a) => a.name === arg.name);
|
||||
if (
|
||||
(initial?.value.type === "fn" || initial?.value.type === "var") &&
|
||||
Object.hasOwn(initialValues, arg.name) &&
|
||||
values[arg.name] === initialValues[arg.name]
|
||||
) {
|
||||
return initial;
|
||||
}
|
||||
return arg;
|
||||
});
|
||||
}
|
||||
@@ -93,6 +93,7 @@
|
||||
"@yaakapp-internal/theme": "^1.0.0",
|
||||
"@yaakapp-internal/ui": "^1.0.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"happy-dom": "^21.0.4",
|
||||
"rollup": "^4.66.0",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^1.4.0",
|
||||
"vite-plugin-static-copy": "^4.1.1",
|
||||
|
||||
Generated
+63
@@ -190,6 +190,7 @@
|
||||
"@yaakapp-internal/theme": "^1.0.0",
|
||||
"@yaakapp-internal/ui": "^1.0.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"happy-dom": "^21.0.4",
|
||||
"rollup": "^4.66.0",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^1.4.0",
|
||||
"vite-plugin-static-copy": "^4.1.1",
|
||||
@@ -6043,6 +6044,19 @@
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-image-size": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
|
||||
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -6948,6 +6962,19 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||
@@ -7945,6 +7972,42 @@
|
||||
"graphql": "^15.5.0 || ^16.0.0 || ^17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/happy-dom": {
|
||||
"version": "21.0.4",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-21.0.4.tgz",
|
||||
"integrity": "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"buffer-image-size": "^0.6.4",
|
||||
"entities": "^7.0.1",
|
||||
"whatwg-mimetype": "^3.0.0",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/happy-dom/node_modules/@types/whatwg-mimetype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/happy-dom/node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
|
||||
Reference in new issue
Block a user