diff --git a/clients/tui/__tests__/uriTemplateToForm.test.ts b/clients/tui/__tests__/uriTemplateToForm.test.ts
index 9dc1a32c..c44b6b37 100644
--- a/clients/tui/__tests__/uriTemplateToForm.test.ts
+++ b/clients/tui/__tests__/uriTemplateToForm.test.ts
@@ -64,3 +64,15 @@ describe("uriTemplateToForm", () => {
expect(form.sections[0]!.fields).toEqual([]);
});
});
+
+describe("a name repeated inside one expression", () => {
+ it("is required, not treated as a shared group", () => {
+ // `{a,a}` is one requirement named twice. Before core deduplicated the
+ // group, `requiredGroups` returned ["a","a"], so this form's
+ // `length === 1` test left the field optional while ResourceTestModal's
+ // submit guard still refused a blank -- an un-submittable form.
+ const [field] = uriTemplateToForm("x://{a,a}", "T").sections[0].fields;
+ expect(field.name).toBe("a");
+ expect(field.required).toBe(true);
+ });
+});
diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx
index 87b2ba15..b7d6c934 100644
--- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx
+++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx
@@ -266,6 +266,58 @@ describe("ResourceTemplatePanel", () => {
expect(onReadResource).not.toHaveBeenCalled();
});
+ it("names every unmet group, so a disabled button always has a reason", async () => {
+ // With `{a,b}{b,c}{a,c}`, filling `b` satisfies the first two groups. A
+ // per-field hint then looks satisfied while Read Resource stays disabled
+ // on the third -- so the outstanding requirement is stated form-level.
+ const user = userEvent.setup();
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByText(/Still needed:/)).toHaveTextContent(
+ "Still needed: a or b; b or c; a or c",
+ );
+ await user.type(screen.getByLabelText("b"), "1");
+ expect(screen.getByText(/Still needed:/)).toHaveTextContent(
+ "Still needed: a or c",
+ );
+ expect(
+ screen.getByRole("button", { name: "Read Resource" }),
+ ).toBeDisabled();
+ await user.type(screen.getByLabelText("a"), "2");
+ expect(screen.queryByText(/Still needed:/)).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Read Resource" }),
+ ).toBeEnabled();
+ });
+
+ it("hints every shared group a variable sits in, not just the first", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(
+ screen.getByText("Any one of each: a, b; a, c"),
+ ).toBeInTheDocument();
+ });
+
+ it("treats a name repeated in one expression as a plain requirement", () => {
+ renderWithMantine(
+ ,
+ );
+ // Not "Any one of: a, a" -- it is one field, required.
+ expect(screen.queryByText(/Any one of/)).not.toBeInTheDocument();
+ expect(screen.getByText("Still needed: a")).toBeInTheDocument();
+ });
+
it("shows the malformed template unexpanded in the preview", () => {
renderWithMantine(
names.length === 1 && names[0] === varName,
);
- const sharedGroup = individuallyRequired
- ? undefined
- : groups.find(
+ // EVERY shared group this variable sits in, not the first. With
+ // `{a,b}{b,c}{a,c}`, showing only the first left `b` looking like it
+ // satisfied everything while Read Resource stayed disabled on the
+ // unmet `{a,c}` -- a hidden requirement is worse than a wordy hint.
+ // The form-level "Still needed" line below names what is actually
+ // outstanding, which is the part a per-field hint cannot express.
+ const sharedGroups = individuallyRequired
+ ? []
+ : groups.filter(
(names) => names.length > 1 && names.includes(varName),
);
const description = !required
? "Optional"
- : sharedGroup
- ? `Any one of: ${sharedGroup.join(", ")}`
+ : sharedGroups.length > 0
+ ? `Any one of${sharedGroups.length > 1 ? " each" : ""}: ${sharedGroups
+ .map((names) => names.join(", "))
+ .join("; ")}`
: undefined;
return useAutocomplete ? (
{expansion.error}
)}
+ {expansion.error === undefined && unmet.length > 0 && (
+
+ Still needed: {unmet.map((names) => names.join(" or ")).join("; ")}
+
+ )}