Skip to content

Commit b36772b

Browse files
aaronbrezelclaude
andauthored
feat: recipe panels system with Document Summarization (#27)
* docs: add recipe panels design document Covers RecipeParams/PrepRecipeParams/PrepRecipeResult type separation, generic RecipePanel with RecipePrepCook component, RECIPES registry pattern, server-side prepRecipe orchestrator, and full testing strategy. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add recipe panels implementation plan 12-task TDD plan covering shared types, sheet helpers, prepRecipe server function, services wrapper, LockableField onUnlock, RecipePrepCook component, RecipePanel, RecipesListPanel, and coverage thresholds. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add RecipeFieldConfig, RecipeParams, PrepRecipeParams, PrepRecipeResult types * feat: add 'recipe' to PanelId, add RecipeDefinition interface * feat: add findOrCreateColumn and writeColumn helpers to utils Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: update utils.ts header comment; guard writeColumn against empty array - Replace misleading "no dependency on Apps Script globals" header with accurate language about GAS objects received as arguments vs. singletons. - Clean up makeSheet helper in tests: replace identity map with headers.slice(). - Guard writeColumn against empty values array to prevent GAS runtime error when getRange is called with numRows=0; add corresponding test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add prepRecipe server function and rollup global stub Implements the prepRecipe() server function that accepts PrepRecipeParams, writes Drive file URLs, system prompt, user prompts, and output column headers to the active sheet using findOrCreateColumn/writeColumn helpers, and returns a PrepRecipeResult with the populated row range and column names. Also adds the matching global stub in rollup.config.js so Apps Script can discover the function, and fixes a pre-existing PanelId type gap by adding "document-summarization" to the union. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: revert out-of-scope document-summarization PanelId addition Task 4 subagent incorrectly re-added "document-summarization" to PanelId to suppress typecheck errors in sidebar-entry.ts. Those errors are expected and will be resolved in Task 11 when sidebar-entry.ts is updated. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add prepRecipe service wrapper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use PrepRecipeParams type in google.d.ts stub Consistent with how runBatchAI uses RunConfig — unknown breaks type-level contract for direct callers of google.script.run.prepRecipe. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add onUnlock callback to LockableField * feat: add RecipePrepCook component with prep/cook state machine * feat: add RECIPES registry with Document Summarization entry * feat: add generic RecipePanel driven by RecipeParams * feat: replace RecipesListPanel stub with data-driven RECIPES implementation * fix: suppress double-alert on silent validation cancellation When buildPrepParams() returns null (drive folder URL empty), recipe.ts now rejects with null instead of new Error("cancelled"). RecipePrepCook checks err !== null before alerting, so only the validation alert from buildPrepParams fires. Test updated to assert alertMock called exactly once. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: register RecipePanel in router; remove DocumentSummarizationPanel stub Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add coverage thresholds for recipe-prep-cook, recipe, recipes-list Also adds explicit return types to onPrep/onCook arrow functions in recipe.ts to satisfy the ESLint explicit-function-return-type rule. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use single tsconfig in jest transform to avoid ts-jest ConfigSet caching bug ts-jest's TsJestTransformer._cachedConfigSets is a static array keyed by the global Jest config object reference, not by per-transform tsconfig options. On CI (1 worker), a server transformer running first would cache ConfigSet(tsconfig.json, no DOM), causing all subsequent client transforms to reuse the wrong tsconfig and fail with "Cannot find name 'document'". Consolidating to a single rule using tsconfig.client.json eliminates the conflict — server code compiles cleanly with DOM types in scope since it never references them. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 56d5a9f commit b36772b

23 files changed

Lines changed: 3394 additions & 45 deletions

__tests__/components/lockable-field.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,26 @@ describe("LockableField", () => {
6565
expect(c.querySelector("input")).toBeNull();
6666
});
6767
});
68+
69+
describe("onUnlock callback", () => {
70+
it("calls onUnlock when the unlock button is clicked", () => {
71+
const container = document.createElement("div");
72+
const onUnlock = jest.fn();
73+
new LockableField(container, {
74+
label: "Test",
75+
defaultValue: "hello",
76+
locked: true,
77+
onUnlock,
78+
});
79+
const btn = container.querySelector<HTMLButtonElement>(".unlock-btn")!;
80+
btn.click();
81+
expect(onUnlock).toHaveBeenCalledTimes(1);
82+
});
83+
84+
it("does not error when onUnlock is not provided", () => {
85+
const container = document.createElement("div");
86+
new LockableField(container, { label: "Test", defaultValue: "hello" });
87+
const btn = container.querySelector<HTMLButtonElement>(".unlock-btn")!;
88+
expect(() => btn.click()).not.toThrow();
89+
});
90+
});
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
import { RecipePrepCook } from "../../src/client/components/recipe-prep-cook";
5+
6+
function mount(config: ConstructorParameters<typeof RecipePrepCook>[1]) {
7+
const container = document.createElement("div");
8+
const component = new RecipePrepCook(container, config);
9+
return { container, component };
10+
}
11+
12+
describe("idle state", () => {
13+
it("renders Prep enabled and Cook disabled", () => {
14+
const { container } = mount({ onPrep: jest.fn(), onCook: jest.fn() });
15+
const prep = container.querySelector<HTMLButtonElement>("#prep-btn")!;
16+
const cook = container.querySelector<HTMLButtonElement>("#cook-btn")!;
17+
expect(prep.disabled).toBe(false);
18+
expect(prep.textContent).toBe("Prep Recipe");
19+
expect(cook.disabled).toBe(true);
20+
});
21+
});
22+
23+
describe("prepping state", () => {
24+
it("disables Prep and shows Prepping... while onPrep is pending", async () => {
25+
let resolvePrep!: () => void;
26+
const onPrep = jest.fn(
27+
() =>
28+
new Promise<void>((res) => {
29+
resolvePrep = res;
30+
}),
31+
);
32+
const { container } = mount({ onPrep, onCook: jest.fn() });
33+
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
34+
const prep = container.querySelector<HTMLButtonElement>("#prep-btn")!;
35+
expect(prep.disabled).toBe(true);
36+
expect(prep.textContent).toBe("Prepping...");
37+
resolvePrep();
38+
});
39+
});
40+
41+
describe("prep-complete state", () => {
42+
async function mountPrepped(onCook = jest.fn()) {
43+
const onPrep = jest.fn().mockResolvedValue(undefined);
44+
const { container, component } = mount({ onPrep, onCook });
45+
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
46+
await Promise.resolve();
47+
await Promise.resolve();
48+
return { container, component, onCook };
49+
}
50+
51+
it("enables Cook and shows Re-prep after onPrep resolves", async () => {
52+
const { container } = await mountPrepped();
53+
const prep = container.querySelector<HTMLButtonElement>("#prep-btn")!;
54+
const cook = container.querySelector<HTMLButtonElement>("#cook-btn")!;
55+
expect(prep.disabled).toBe(false);
56+
expect(prep.textContent).toBe("Re-prep");
57+
expect(cook.disabled).toBe(false);
58+
});
59+
60+
it("isPrepComplete returns true", async () => {
61+
const { component } = await mountPrepped();
62+
expect(component.isPrepComplete()).toBe(true);
63+
});
64+
65+
it("calls onCook when Cook is clicked (sync)", async () => {
66+
const { container, onCook } = await mountPrepped();
67+
container.querySelector<HTMLButtonElement>("#cook-btn")!.click();
68+
expect(onCook).toHaveBeenCalledTimes(1);
69+
});
70+
71+
it("does not enter cooking state when onCook is synchronous", async () => {
72+
const { container } = await mountPrepped(jest.fn(() => undefined));
73+
container.querySelector<HTMLButtonElement>("#cook-btn")!.click();
74+
const cook = container.querySelector<HTMLButtonElement>("#cook-btn")!;
75+
// remains in prep-complete (cook is still enabled)
76+
expect(cook.disabled).toBe(false);
77+
});
78+
});
79+
80+
describe("cooking state", () => {
81+
it("disables both buttons when onCook returns a Promise", async () => {
82+
const onPrep = jest.fn().mockResolvedValue(undefined);
83+
let resolveCook!: () => void;
84+
const onCook = jest.fn(
85+
() =>
86+
new Promise<void>((res) => {
87+
resolveCook = res;
88+
}),
89+
);
90+
const { container } = mount({ onPrep, onCook });
91+
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
92+
await Promise.resolve();
93+
await Promise.resolve();
94+
container.querySelector<HTMLButtonElement>("#cook-btn")!.click();
95+
const prep = container.querySelector<HTMLButtonElement>("#prep-btn")!;
96+
const cook = container.querySelector<HTMLButtonElement>("#cook-btn")!;
97+
expect(prep.disabled).toBe(true);
98+
expect(cook.disabled).toBe(true);
99+
expect(cook.textContent).toBe("Cooking...");
100+
resolveCook();
101+
});
102+
});
103+
104+
describe("error handling", () => {
105+
it("returns to idle and shows alert when onPrep rejects", async () => {
106+
const alertMock = jest.fn();
107+
globalThis.alert = alertMock;
108+
const onPrep = jest.fn().mockRejectedValue(new Error("prep failed"));
109+
const { container, component } = mount({ onPrep, onCook: jest.fn() });
110+
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
111+
await Promise.resolve();
112+
await Promise.resolve();
113+
expect(alertMock).toHaveBeenCalledWith("Error: prep failed");
114+
expect(component.isPrepComplete()).toBe(false);
115+
const prep = container.querySelector<HTMLButtonElement>("#prep-btn")!;
116+
expect(prep.disabled).toBe(false);
117+
expect(prep.textContent).toBe("Prep Recipe");
118+
});
119+
});
120+
121+
describe("reset()", () => {
122+
it("returns to idle and disables Cook", async () => {
123+
const onPrep = jest.fn().mockResolvedValue(undefined);
124+
const { container, component } = mount({ onPrep, onCook: jest.fn() });
125+
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
126+
await Promise.resolve();
127+
await Promise.resolve();
128+
component.reset();
129+
const prep = container.querySelector<HTMLButtonElement>("#prep-btn")!;
130+
const cook = container.querySelector<HTMLButtonElement>("#cook-btn")!;
131+
expect(prep.textContent).toBe("Prep Recipe");
132+
expect(cook.disabled).toBe(true);
133+
expect(component.isPrepComplete()).toBe(false);
134+
});
135+
});
136+
137+
describe("initialState restoration", () => {
138+
it("mounts in prep-complete state when prepComplete: true", () => {
139+
const { container, component } = mount({
140+
onPrep: jest.fn(),
141+
onCook: jest.fn(),
142+
prepComplete: true,
143+
});
144+
const cook = container.querySelector<HTMLButtonElement>("#cook-btn")!;
145+
expect(cook.disabled).toBe(false);
146+
expect(component.isPrepComplete()).toBe(true);
147+
});
148+
});

0 commit comments

Comments
 (0)