Skip to content

Commit eeac1b1

Browse files
author
DavidQ
committed
Add explicit Promote to Tools actions for active and saved Workspace V2 toolStates - PR_26124_017-promote-selected-toolstate-to-tools
1 parent 7a4eb9a commit eeac1b1

8 files changed

Lines changed: 192 additions & 3 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# PR_26124_017
2+
3+
## Scope
4+
- `tools/workspace-v2/index.html`
5+
- `tools/workspace-v2/index.js`
6+
- `tests/ui/workspace-v2.asset-manager.spec.js`
7+
- `tests/playwright/workspace-v2.validation.spec.js`
8+
9+
## Changes
10+
- Added explicit active promotion action in Workspace V2 Producer:
11+
- `Promote Active Tool State to Tools`
12+
- Added explicit saved-entry promotion action per Tool State Library row:
13+
- `Promote to Tools`
14+
- Promotion behavior:
15+
- validates selected tool state
16+
- reads `toolState.toolId`
17+
- reads `toolState.payloadJson`
18+
- writes cloned payload into `tools[toolId]` in workspace manifest JSON
19+
- Guard behavior:
20+
- blocks `palette-manager-v2` promotion
21+
- blocks invalid tool states
22+
- keeps `tools.workspace-v2` and `tools.palette-browser` intact
23+
- keeps `activeToolState` and `savedToolStates` unchanged
24+
- no auto-promotion on save/export
25+
26+
## Validation
27+
- `node --check tools/workspace-v2/index.js` -> pass
28+
- `node --check tests/ui/workspace-v2.asset-manager.spec.js` -> pass
29+
- `node --check tests/playwright/workspace-v2.validation.spec.js` -> pass
30+
- `npm run test:workspace-v2` -> pass (`20 passed`, `0 failed`)
31+
32+
## Notes
33+
- Full samples smoke was skipped because this PR is limited to Workspace V2 tool-state promotion actions and Playwright coverage updates.

docs/dev/reports/tool_completion_audit.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
- `vector-map-editor-v2`
1212

1313
## Evidence Used
14-
- `npm run test:workspace-v2` -> PASS (`19 passed`, `0 failed`).
14+
- `npm run test:workspace-v2` -> PASS (`20 passed`, `0 failed`).
1515
- `node tests/runtime/V2CrossToolFlow.test.mjs` -> PASS.
1616
- `node tests/runtime/V2ToolLaunch.test.mjs` -> FAIL (palette fixture contract drift in test logic).
1717
- `node tests/runtime/V2ToolActionFlow.test.mjs` -> FAIL (string-token matcher drift in test logic).
@@ -154,6 +154,24 @@
154154

155155

156156

157+
158+
159+
160+
161+
162+
163+
164+
165+
166+
167+
168+
169+
170+
171+
172+
173+
174+
157175

158176

159177

tests/playwright.zip

-4.45 KB
Binary file not shown.

tests/playwright/workspace-v2.validation.spec.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,46 @@ test.describe("Workspace V2 validation coverage", () => {
176176
let manifest = JSON.parse(await page.locator("#workspaceV2ImportJson").inputValue());
177177
expect(manifest.tools?.["workspace-v2"]?.activeToolId).toBe("tilemap-studio-v2");
178178
expect(manifest.tools?.["workspace-v2"]?.activeToolState?.toolId).toBe("tilemap-studio-v2");
179+
expect(Object.prototype.hasOwnProperty.call(manifest.tools || {}, "tilemap-studio-v2")).toBe(false);
179180

180181
await page.locator("#workspaceV2ToolSelect").selectOption("vector-map-editor-v2");
181182
await page.locator("#workspaceV2LoadFixtureButton").click();
182183
manifest = JSON.parse(await page.locator("#workspaceV2ImportJson").inputValue());
183184
expect(manifest.tools?.["workspace-v2"]?.activeToolId).toBe("vector-map-editor-v2");
184185
expect(manifest.tools?.["workspace-v2"]?.activeToolState?.toolId).toBe("vector-map-editor-v2");
186+
expect(Object.prototype.hasOwnProperty.call(manifest.tools || {}, "vector-map-editor-v2")).toBe(false);
187+
} finally {
188+
await server.close();
189+
}
190+
});
191+
192+
test("promote to tools is explicit for active and saved tool states", async ({ page }) => {
193+
const server = await startRepoServer();
194+
try {
195+
await page.goto(`${server.baseUrl}/tools/workspace-v2/index.html`);
196+
await ctrlTapClick(page, page.getByRole("button", { name: "Full Reset" }));
197+
198+
await page.locator("#workspaceV2ToolSelect").selectOption("tilemap-studio-v2");
199+
await page.locator("#workspaceV2LoadFixtureButton").click();
200+
await page.locator("#workspaceV2ToolStateName").fill("tile-state-a");
201+
await page.locator("#workspaceV2SaveToolStateButton").click();
202+
203+
let manifest = JSON.parse(await page.locator("#workspaceV2ImportJson").inputValue());
204+
expect(Object.prototype.hasOwnProperty.call(manifest.tools || {}, "tilemap-studio-v2")).toBe(false);
205+
206+
await page.getByRole("button", { name: "Promote Active Tool State to Tools" }).click();
207+
manifest = JSON.parse(await page.locator("#workspaceV2ImportJson").inputValue());
208+
expect(manifest.tools?.["tilemap-studio-v2"]?.tileMapDocument).toBeTruthy();
209+
210+
await page.locator("#workspaceV2ToolSelect").selectOption("vector-map-editor-v2");
211+
await page.locator("#workspaceV2LoadFixtureButton").click();
212+
manifest = JSON.parse(await page.locator("#workspaceV2ImportJson").inputValue());
213+
expect(Object.prototype.hasOwnProperty.call(manifest.tools || {}, "vector-map-editor-v2")).toBe(false);
214+
215+
await page.getByRole("button", { name: "Promote to Tools" }).first().click();
216+
manifest = JSON.parse(await page.locator("#workspaceV2ImportJson").inputValue());
217+
expect(manifest.tools?.["tilemap-studio-v2"]?.tileMapDocument).toBeTruthy();
218+
expect(Object.prototype.hasOwnProperty.call(manifest.tools || {}, "vector-map-editor-v2")).toBe(false);
185219
} finally {
186220
await server.close();
187221
}

tests/ui/workspace-v2.asset-manager.spec.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ test("workspace v2 launches asset manager and add/remove is reflected in export"
6767
await ctrlTapClick(page, page.getByRole("button", { name: /Back to Workspace V2/ }));
6868
await expect(page).toHaveURL(/\/tools\/workspace-v2\/index\.html/);
6969
await ctrlTapClick(page, page.getByRole("button", { name: "Export Workspace Tool State JSON" }));
70+
const manifestBeforePromoteText = await page.locator("#workspaceV2ImportJson").inputValue();
71+
const manifestBeforePromote = JSON.parse(manifestBeforePromoteText);
72+
expect(Object.prototype.hasOwnProperty.call(manifestBeforePromote.tools || {}, "asset-manager-v2")).toBe(false);
73+
await ctrlTapClick(page, page.getByRole("button", { name: "Promote Active Tool State to Tools" }));
74+
await expect(page.locator("#workspaceV2Status")).toContainText("promoted to tools.asset-manager-v2");
75+
await ctrlTapClick(page, page.getByRole("button", { name: "Export Workspace Tool State JSON" }));
7076
const exportedJsonText = await page.locator("#workspaceV2ImportJson").inputValue();
7177
const exported = JSON.parse(exportedJsonText);
7278
const entries = exported?.tools?.["workspace-v2"]?.activeToolState?.payloadJson?.assetCatalog?.entries;
@@ -79,9 +85,15 @@ test("workspace v2 launches asset manager and add/remove is reflected in export"
7985
expect(exported.tools?.["workspace-v2"]?.activeToolState?.toolId).toBe("asset-manager-v2");
8086
expect(entries.some((entry) => entry?.id === "asset-001")).toBe(true);
8187
expect(entries.some((entry) => entry?.id === "asset-002")).toBe(false);
88+
const promotedEntries = exported?.tools?.["asset-manager-v2"]?.assetCatalog?.entries;
89+
if (!Array.isArray(promotedEntries)) {
90+
throw new Error("Exported manifest is missing tools.asset-manager-v2.assetCatalog.entries after promotion.");
91+
}
92+
expect(promotedEntries.some((entry) => entry?.id === "asset-001")).toBe(true);
93+
expect(promotedEntries.some((entry) => entry?.id === "asset-002")).toBe(false);
8294
expect(Object.prototype.hasOwnProperty.call(exported, "workspaceSession")).toBe(false);
8395
expect(Object.prototype.hasOwnProperty.call(exported, "games")).toBe(false);
84-
expect(Object.prototype.hasOwnProperty.call(exported.tools || {}, "asset-manager-v2")).toBe(false);
96+
expect(Object.prototype.hasOwnProperty.call(exported.tools || {}, "asset-manager-v2")).toBe(true);
8597
const exportedString = JSON.stringify(exported);
8698
expect(exportedString.includes("selectedAssetId")).toBe(false);
8799
expect(exportedString.includes("assetBrowserV2Detail")).toBe(false);

tools/workspace-v2.zip

-30.9 KB
Binary file not shown.

tools/workspace-v2/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ <h2>Producer</h2>
2929
<div>
3030
<button id="workspaceV2LoadFixtureButton" type="button">Load Tool State</button>
3131
<button id="workspaceV2LaunchButton" type="button">Create & Open Tool State</button>
32+
<button id="workspaceV2PromoteActiveToolStateButton" type="button">Promote Active Tool State to Tools</button>
3233
</div>
3334
</section>
3435

tools/workspace-v2/index.js

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class WorkspaceV2ToolStateProducer {
2222
this.backButton = document.getElementById("workspaceV2BackButton");
2323
this.loadFixtureButton = document.getElementById("workspaceV2LoadFixtureButton");
2424
this.launchButton = document.getElementById("workspaceV2LaunchButton");
25+
this.promoteActiveToolStateButton = document.getElementById("workspaceV2PromoteActiveToolStateButton");
2526
this.openAssetManagerButton = document.getElementById("workspaceV2OpenAssetManagerButton");
2627
this.importJsonNode = document.getElementById("workspaceV2ImportJson");
2728
this.importFileNode = document.getElementById("workspaceV2ImportFile");
@@ -101,6 +102,9 @@ class WorkspaceV2ToolStateProducer {
101102
this.launchButton.addEventListener("click", () => {
102103
this.createToolStateAndLaunch();
103104
});
105+
this.promoteActiveToolStateButton.addEventListener("click", () => {
106+
this.promoteActiveToolStateToWorkspaceTools();
107+
});
104108
this.openAssetManagerButton.addEventListener("click", () => {
105109
this.openAssetManagerFromWorkspace();
106110
});
@@ -1275,6 +1279,62 @@ class WorkspaceV2ToolStateProducer {
12751279
return this.readActiveToolStatePayloadForLibraryActions();
12761280
}
12771281

1282+
validateToolStatePromotionPayload(toolStatePayload, toolStatePath) {
1283+
if (!this.isValidToolStatePayload(toolStatePayload)) {
1284+
return { ok: false, message: `${toolStatePath} is invalid.`, toolId: "", payloadJson: null };
1285+
}
1286+
const payloadValidation = this.validateWorkspaceToolStatePayload(toolStatePayload, toolStatePath);
1287+
if (!payloadValidation.ok) {
1288+
return { ok: false, message: payloadValidation.message, toolId: "", payloadJson: null };
1289+
}
1290+
const toolId = typeof toolStatePayload.toolId === "string" ? toolStatePayload.toolId.trim() : "";
1291+
if (!toolId) {
1292+
return { ok: false, message: `${toolStatePath}.toolId is required for promotion.`, toolId: "", payloadJson: null };
1293+
}
1294+
if (toolId === "palette-manager-v2") {
1295+
return { ok: false, message: "Promotion blocked. palette-manager-v2 cannot be promoted to tools; palette is workspace-owned at tools.palette-browser.", toolId: "", payloadJson: null };
1296+
}
1297+
if (!toolStatePayload.payloadJson || typeof toolStatePayload.payloadJson !== "object" || Array.isArray(toolStatePayload.payloadJson)) {
1298+
return { ok: false, message: `${toolStatePath}.payloadJson must be an object for promotion.`, toolId: "", payloadJson: null };
1299+
}
1300+
return {
1301+
ok: true,
1302+
message: "",
1303+
toolId,
1304+
payloadJson: this.cloneToolStateValue(toolStatePayload.payloadJson)
1305+
};
1306+
}
1307+
1308+
promoteToolStatePayloadToWorkspaceTools(toolStatePayload, toolStatePath, successPrefix) {
1309+
const promotionValidation = this.validateToolStatePromotionPayload(toolStatePayload, toolStatePath);
1310+
if (!promotionValidation.ok) {
1311+
this.statusNode.textContent = promotionValidation.message;
1312+
return false;
1313+
}
1314+
this.workspaceImportedToolEntries[promotionValidation.toolId] = promotionValidation.payloadJson;
1315+
if (!this.syncWorkspaceManifestTextarea()) {
1316+
this.statusNode.textContent = "Promotion failed. Workspace manifest sync failed.";
1317+
return false;
1318+
}
1319+
this.renderWorkspaceToolsSummary();
1320+
this.statusNode.textContent = `${successPrefix} promoted to tools.${promotionValidation.toolId}.`;
1321+
return true;
1322+
}
1323+
1324+
promoteActiveToolStateToWorkspaceTools() {
1325+
const activeToolState = this.readActiveToolStatePayloadForLibraryActions();
1326+
if (!this.isValidToolStatePayload(activeToolState)) {
1327+
this.statusNode.textContent = "Promotion blocked. No active tool state is available.";
1328+
return;
1329+
}
1330+
const activeToolId = typeof activeToolState.toolId === "string" ? activeToolState.toolId.trim() : "";
1331+
this.promoteToolStatePayloadToWorkspaceTools(
1332+
activeToolState,
1333+
"tools.workspace-v2.activeToolState",
1334+
`Active tool state '${activeToolId || "unknown"}'`
1335+
);
1336+
}
1337+
12781338
readToolStatePayloadFromRecentToolStateId(toolStateId) {
12791339
if (typeof toolStateId !== "string" || !toolStateId.trim()) {
12801340
return null;
@@ -1700,6 +1760,7 @@ class WorkspaceV2ToolStateProducer {
17001760
const idCode = document.createElement("code");
17011761
const copyIdButton = document.createElement("button");
17021762
const useInLibraryButton = document.createElement("button");
1763+
const promoteToToolsButton = document.createElement("button");
17031764
const loadButton = document.createElement("button");
17041765
const overwriteButton = document.createElement("button");
17051766
const deleteSavedButton = document.createElement("button");
@@ -1726,6 +1787,11 @@ class WorkspaceV2ToolStateProducer {
17261787
useInLibraryButton.addEventListener("click", () => {
17271788
this.useSavedToolStateIdInLibraryInput(toolStateName);
17281789
});
1790+
promoteToToolsButton.type = "button";
1791+
promoteToToolsButton.textContent = "Promote to Tools";
1792+
promoteToToolsButton.addEventListener("click", () => {
1793+
this.promoteSavedToolStateById(toolStateName);
1794+
});
17291795
loadButton.type = "button";
17301796
loadButton.textContent = "Load";
17311797
loadButton.disabled = paletteRowLocked;
@@ -1743,7 +1809,7 @@ class WorkspaceV2ToolStateProducer {
17431809
deleteSavedButton.addEventListener("click", () => {
17441810
this.deleteSavedToolStateById(toolStateName);
17451811
});
1746-
item.append(label, idLine, copyIdButton, useInLibraryButton, loadButton, overwriteButton, deleteSavedButton);
1812+
item.append(label, idLine, copyIdButton, useInLibraryButton, promoteToToolsButton, loadButton, overwriteButton, deleteSavedButton);
17471813
this.toolStateListNode.appendChild(item);
17481814
});
17491815
this.renderToolStateDiffInputs();
@@ -1779,6 +1845,31 @@ class WorkspaceV2ToolStateProducer {
17791845
this.setLibraryStatus(`Saved tool state ID ready for Diff/Merge and Library actions: ${toolStateId.trim()}`);
17801846
}
17811847

1848+
promoteSavedToolStateById(toolStateId) {
1849+
if (typeof toolStateId !== "string" || !toolStateId.trim()) {
1850+
this.setLibraryStatus("Promotion blocked. Enter a saved tool state ID before promoting.");
1851+
return;
1852+
}
1853+
const library = this.readToolStateLibrary();
1854+
if (library === null) {
1855+
return;
1856+
}
1857+
if (!Object.prototype.hasOwnProperty.call(library, toolStateId.trim())) {
1858+
this.setLibraryStatus("Promotion blocked. Saved tool state not found.");
1859+
return;
1860+
}
1861+
const savedToolStatePayload = library[toolStateId.trim()];
1862+
const promoted = this.promoteToolStatePayloadToWorkspaceTools(
1863+
savedToolStatePayload,
1864+
`tools.workspace-v2.savedToolStates.${toolStateId.trim()}`,
1865+
`Saved tool state '${toolStateId.trim()}'`
1866+
);
1867+
if (!promoted) {
1868+
return;
1869+
}
1870+
this.setLibraryStatus(`Saved tool state '${toolStateId.trim()}' promoted to tools.`);
1871+
}
1872+
17821873
loadSavedToolStateById(toolStateId) {
17831874
if (typeof toolStateId !== "string" || !toolStateId.trim()) {
17841875
this.setLibraryStatus("Enter a saved tool state ID before loading.");

0 commit comments

Comments
 (0)