Skip to content

Commit 8c43e5b

Browse files
authored
[Analyze 2718] New wizards flow in wizards page (#2897)
* feat(wizards): add MRI query cache identity * feat(wizards): add cohort cache lifecycle * feat(wizards): add direct dashboard orchestration * feat(wizards): embed ShinyLive dashboard * test(cohorts): preserve wizard cache visibility * chore(wizards): add safe dashboard diagnostics * fix(wizards): encode cohort materialization query * fix(wizards): fill dashboard modal height * fix(wizards): grow form card with content * feat(wizards): show dashboard action only * style(wizards): align dashboard loading modal * feat(wizards): add back navigation to wizard title * docs(wizards): explain MRI query comparison * refactor(wizards): retain hidden cohort action * feat(bookmarks): return created bookmark id * refactor(wizards): resolve cohort from bookmark id * refactor(wizards): remove dashboard diagnostics
1 parent c647936 commit 8c43e5b

34 files changed

Lines changed: 2984 additions & 46 deletions

plugins/functions/bookmark-svc/src/bookmark/bookmark.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,8 @@ export async function _insertBookmark(
195195
userName
196196
)
197197
const portalAPI = new PortalAPI(token)
198-
const result = await portalAPI.createBookmark({ serviceArtifact: bookmarkDto }, datasetId)
199-
callback(null, result)
198+
await portalAPI.createBookmark({ serviceArtifact: bookmarkDto }, datasetId)
199+
callback(null, { status: 'success', bmkId: bookmarkDto.id })
200200
} catch (error) {
201201
console.error(error)
202202
callback(error, null)
@@ -493,7 +493,7 @@ export async function queryBookmarks(
493493
shareBookmark,
494494
token,
495495
datasetId,
496-
cb
496+
callback
497497
)
498498
break
499499
case 'delete':
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { processBookmarksData } from '../BookmarkUtils'
3+
4+
describe('processBookmarksData Wizard cache regression', () => {
5+
it('keeps reserved Wizard cache artifacts visible until a hiding policy is explicitly approved', () => {
6+
const wizardBookmark = bookmark({
7+
bmkId: 'wizard-bookmark',
8+
bookmarkname: 'wizards-1783670400000',
9+
cohortDefinitionId: 42,
10+
})
11+
const linkedCohort = materializedCohort({
12+
id: 42,
13+
cohortDefinitionName: 'wizards-1783670400000',
14+
syntax: JSON.stringify({ datasetId: 'dataset-1', bookmarkId: 'wizard-bookmark' }),
15+
})
16+
17+
const result = processBookmarksData([wizardBookmark, linkedCohort], 'pa-config')
18+
19+
expect(result.bookmarks).toEqual([wizardBookmark])
20+
expect(result.materializedCohorts).toEqual([linkedCohort])
21+
})
22+
23+
it('keeps ordinary embedded-Wizard bookmarks and cohorts unchanged', () => {
24+
const namedBookmark = bookmark({
25+
bmkId: 'named-bookmark',
26+
bookmarkname: 'My embedded Wizard cohort',
27+
cohortDefinitionId: 7,
28+
})
29+
const linkedCohort = materializedCohort({ id: 7, cohortDefinitionName: 'My embedded Wizard cohort' })
30+
31+
const result = processBookmarksData([namedBookmark, linkedCohort], 'pa-config')
32+
33+
expect(result.bookmarks).toEqual([namedBookmark])
34+
expect(result.materializedCohorts).toEqual([linkedCohort])
35+
})
36+
})
37+
38+
function bookmark(overrides: Record<string, unknown> = {}) {
39+
return {
40+
bmkId: 'bookmark-1',
41+
bookmarkname: 'My cohort',
42+
bookmark: JSON.stringify({ filter: { cards: { content: [] } } }),
43+
viewname: null,
44+
modified: '2026-07-10T10:00:00.000Z',
45+
version: 1,
46+
user_id: 'researcher',
47+
shared: false,
48+
paConfigId: 'pa-config',
49+
...overrides,
50+
}
51+
}
52+
53+
function materializedCohort(overrides: Record<string, unknown> = {}) {
54+
return {
55+
id: 1,
56+
patientCount: 10,
57+
cohortDefinitionName: 'My cohort',
58+
createdOn: '2026-07-10T10:00:00.000Z',
59+
description: 'Generated cohort',
60+
...overrides,
61+
}
62+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import client from "../../axios/request";
3+
import { buildMriBookmark } from "../../utils/mriQuery";
4+
import { buildMriMaterializationQuery } from "../../utils/mriMaterializationQuery";
5+
import { compressBase64, decompress } from "../../utils/cohortUrlCodec";
6+
import {
7+
createWizardBookmark,
8+
listPatientAnalyticsCohorts,
9+
materializeWizardBookmark,
10+
WizardCohortApiError,
11+
} from "../wizardCohortApi";
12+
13+
vi.mock("../../axios/request", () => ({
14+
default: {
15+
get: vi.fn(),
16+
post: vi.fn(),
17+
},
18+
}));
19+
20+
const mockedGet = vi.mocked(client.get);
21+
const mockedPost = vi.mocked(client.post);
22+
23+
describe("Wizard cohort API", () => {
24+
beforeEach(() => {
25+
vi.clearAllMocks();
26+
});
27+
28+
it("lists Patient Analytics cohorts with dataset context", async () => {
29+
const controller = new AbortController();
30+
const response = [{ bmkId: "bookmark-1" }];
31+
mockedGet.mockResolvedValue({ data: response });
32+
33+
await expect(listPatientAnalyticsCohorts("dataset-1", controller.signal)).resolves.toEqual(response);
34+
expect(mockedGet).toHaveBeenCalledWith("/d2e/d2e-webapi/cohortdefinition", {
35+
params: { source: "pa" },
36+
headers: { datasetid: "dataset-1" },
37+
signal: controller.signal,
38+
});
39+
});
40+
41+
it("rejects a malformed cohort-list response", async () => {
42+
mockedGet.mockResolvedValue({ data: { unexpected: true } });
43+
44+
await expect(listPatientAnalyticsCohorts("dataset-1")).rejects.toMatchObject({
45+
name: "WizardCohortApiError",
46+
operation: "list-cohorts",
47+
code: "invalid-response",
48+
});
49+
});
50+
51+
it("creates a private Wizard bookmark with existing endpoint fields", async () => {
52+
mockedPost.mockResolvedValue({ data: { status: "success", bmkId: "bookmark-1" } });
53+
const bookmark = buildMriBookmark([], {}, { configId: "pa-config", configVersion: "7" }, "dataset-1").bookmark;
54+
55+
await expect(
56+
createWizardBookmark({
57+
datasetId: "dataset-1",
58+
bookmarkname: "wizards-1783670400000",
59+
bookmark,
60+
paConfigId: "pa-config",
61+
cdmConfigId: "cdm-config",
62+
cdmConfigVersion: "3",
63+
}),
64+
).resolves.toEqual({ status: "success", bmkId: "bookmark-1" });
65+
66+
expect(mockedPost).toHaveBeenCalledWith(
67+
"/d2e/analytics-svc/api/services/bookmark",
68+
{
69+
cmd: "insert",
70+
bookmarkname: "wizards-1783670400000",
71+
bookmark: JSON.stringify(bookmark),
72+
shareBookmark: false,
73+
paConfigId: "pa-config",
74+
cdmConfigId: "cdm-config",
75+
cdmConfigVersion: "3",
76+
datasetId: "dataset-1",
77+
},
78+
{ headers: { datasetid: "dataset-1" } },
79+
);
80+
});
81+
82+
it("rejects bookmark creation when the backend omits the bookmark id", async () => {
83+
mockedPost.mockResolvedValue({ data: { status: "success" } });
84+
const bookmark = buildMriBookmark([], {}, { configId: "pa-config", configVersion: "7" }, "dataset-1").bookmark;
85+
86+
await expect(
87+
createWizardBookmark({
88+
datasetId: "dataset-1",
89+
bookmarkname: "wizards-1783670400000",
90+
bookmark,
91+
paConfigId: "pa-config",
92+
cdmConfigId: "cdm-config",
93+
cdmConfigVersion: "3",
94+
}),
95+
).rejects.toMatchObject({ operation: "create-bookmark", code: "invalid-response" });
96+
});
97+
98+
it("materializes a Wizard bookmark with the existing cohort payload", async () => {
99+
mockedPost.mockResolvedValue({ data: "Cohort successfully materialized" });
100+
const bookmark = buildMriBookmark([], {}, { configId: "pa-config", configVersion: "7" }, "dataset-1").bookmark;
101+
const mriQuery = buildMriMaterializationQuery(bookmark, "dataset-1");
102+
103+
await materializeWizardBookmark({
104+
datasetId: "dataset-1",
105+
bookmarkId: "bookmark-1",
106+
bookmarkName: "wizards-1783670400000",
107+
description: "Generated by Wizards",
108+
mriQuery,
109+
});
110+
111+
expect(mockedPost).toHaveBeenCalledWith(
112+
"/d2e/analytics-svc/api/services/cohort",
113+
{
114+
datasetId: "dataset-1",
115+
mriquery: compressBase64(mriQuery),
116+
name: "wizards-1783670400000",
117+
description: "Generated by Wizards",
118+
syntax: JSON.stringify({ datasetId: "dataset-1", bookmarkId: "bookmark-1" }),
119+
},
120+
{ headers: { datasetid: "dataset-1" } },
121+
);
122+
const submittedPayload = mockedPost.mock.calls[0][1] as { mriquery: string };
123+
expect(decompress(submittedPayload.mriquery)).toEqual(mriQuery);
124+
});
125+
126+
it("rejects bookmark creation across datasets before making a request", async () => {
127+
const bookmark = buildMriBookmark([], {}, { configId: "pa-config", configVersion: "7" }, "dataset-2").bookmark;
128+
129+
await expect(
130+
createWizardBookmark({
131+
datasetId: "dataset-1",
132+
bookmarkname: "wizards-1783670400000",
133+
bookmark,
134+
paConfigId: "pa-config",
135+
cdmConfigId: "cdm-config",
136+
cdmConfigVersion: "3",
137+
}),
138+
).rejects.toMatchObject({ code: "invalid-input" });
139+
expect(mockedPost).not.toHaveBeenCalled();
140+
});
141+
142+
it("wraps transport failures without exposing the backend body", async () => {
143+
mockedGet.mockRejectedValue({
144+
message: "sensitive backend detail",
145+
response: { status: 503, data: { message: "database unavailable" } },
146+
});
147+
148+
let caught: unknown;
149+
try {
150+
await listPatientAnalyticsCohorts("dataset-1");
151+
} catch (error) {
152+
caught = error;
153+
}
154+
155+
expect(caught).toBeInstanceOf(WizardCohortApiError);
156+
expect(caught).toMatchObject({
157+
operation: "list-cohorts",
158+
code: "request-failed",
159+
status: 503,
160+
message: "Unable to check previous Wizard analyses",
161+
});
162+
expect(String(caught)).not.toContain("database unavailable");
163+
});
164+
});
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import type { AxiosError } from "axios";
2+
import client from "../axios/request";
3+
import type { MriMaterializationQuery } from "../utils/mriMaterializationQuery";
4+
import type { MriBookmark } from "../utils/mriQuery";
5+
import { compressBase64 } from "../utils/cohortUrlCodec";
6+
7+
export type WizardCohortApiOperation = "list-cohorts" | "create-bookmark" | "materialize-cohort";
8+
export type WizardCohortApiErrorCode = "request-failed" | "invalid-input" | "invalid-response";
9+
10+
export class WizardCohortApiError extends Error {
11+
readonly operation: WizardCohortApiOperation;
12+
readonly code: WizardCohortApiErrorCode;
13+
readonly status?: number;
14+
15+
constructor(message: string, operation: WizardCohortApiOperation, code: WizardCohortApiErrorCode, status?: number) {
16+
super(message);
17+
this.name = "WizardCohortApiError";
18+
this.operation = operation;
19+
this.code = code;
20+
this.status = status;
21+
}
22+
}
23+
24+
export type PatientAnalyticsCohortListItem = Record<string, unknown>;
25+
26+
export interface CreateWizardBookmarkInput {
27+
datasetId: string;
28+
bookmarkname: string;
29+
bookmark: MriBookmark;
30+
paConfigId: string;
31+
cdmConfigId: string;
32+
cdmConfigVersion: string;
33+
}
34+
35+
export interface CreateWizardBookmarkResult {
36+
status: "success";
37+
bmkId: string;
38+
}
39+
40+
export interface MaterializeWizardBookmarkInput {
41+
datasetId: string;
42+
bookmarkId: string;
43+
bookmarkName: string;
44+
description?: string;
45+
mriQuery: MriMaterializationQuery;
46+
}
47+
48+
const operationMessages: Record<WizardCohortApiOperation, string> = {
49+
"list-cohorts": "Unable to check previous Wizard analyses",
50+
"create-bookmark": "Unable to save the Wizard analysis",
51+
"materialize-cohort": "Unable to generate the Wizard cohort",
52+
};
53+
54+
function getHttpStatus(error: unknown): number | undefined {
55+
const response = (error as Partial<AxiosError>)?.response;
56+
return typeof response?.status === "number" ? response.status : undefined;
57+
}
58+
59+
function wrapApiError(error: unknown, operation: WizardCohortApiOperation): WizardCohortApiError {
60+
if (error instanceof WizardCohortApiError) {
61+
return error;
62+
}
63+
return new WizardCohortApiError(operationMessages[operation], operation, "request-failed", getHttpStatus(error));
64+
}
65+
66+
function requireValue(value: string, fieldName: string, operation: WizardCohortApiOperation): string {
67+
if (value.length === 0) {
68+
throw new WizardCohortApiError(`${fieldName} is required`, operation, "invalid-input");
69+
}
70+
return value;
71+
}
72+
73+
export async function listPatientAnalyticsCohorts(
74+
datasetId: string,
75+
signal?: AbortSignal,
76+
): Promise<PatientAnalyticsCohortListItem[]> {
77+
const operation: WizardCohortApiOperation = "list-cohorts";
78+
requireValue(datasetId, "datasetId", operation);
79+
try {
80+
const response = await client.get("/d2e/d2e-webapi/cohortdefinition", {
81+
params: { source: "pa" },
82+
headers: { datasetid: datasetId },
83+
signal,
84+
});
85+
if (!Array.isArray(response.data)) {
86+
throw new WizardCohortApiError(operationMessages[operation], operation, "invalid-response");
87+
}
88+
return response.data as PatientAnalyticsCohortListItem[];
89+
} catch (error) {
90+
throw wrapApiError(error, operation);
91+
}
92+
}
93+
94+
export async function createWizardBookmark(input: CreateWizardBookmarkInput): Promise<CreateWizardBookmarkResult> {
95+
const operation: WizardCohortApiOperation = "create-bookmark";
96+
requireValue(input.datasetId, "datasetId", operation);
97+
requireValue(input.bookmarkname, "bookmarkname", operation);
98+
requireValue(input.paConfigId, "paConfigId", operation);
99+
requireValue(input.cdmConfigId, "cdmConfigId", operation);
100+
requireValue(input.cdmConfigVersion, "cdmConfigVersion", operation);
101+
if (input.bookmark.datasetId !== input.datasetId) {
102+
throw new WizardCohortApiError("Bookmark dataset does not match the active dataset", operation, "invalid-input");
103+
}
104+
try {
105+
const response = await client.post(
106+
"/d2e/analytics-svc/api/services/bookmark",
107+
{
108+
cmd: "insert",
109+
bookmarkname: input.bookmarkname,
110+
bookmark: JSON.stringify(input.bookmark),
111+
shareBookmark: false,
112+
paConfigId: input.paConfigId,
113+
cdmConfigId: input.cdmConfigId,
114+
cdmConfigVersion: input.cdmConfigVersion,
115+
datasetId: input.datasetId,
116+
},
117+
{ headers: { datasetid: input.datasetId } },
118+
);
119+
const result = response.data as Partial<CreateWizardBookmarkResult> | null;
120+
if (result?.status !== "success" || typeof result.bmkId !== "string" || result.bmkId.length === 0) {
121+
throw new WizardCohortApiError(operationMessages[operation], operation, "invalid-response");
122+
}
123+
return { status: "success", bmkId: result.bmkId };
124+
} catch (error) {
125+
throw wrapApiError(error, operation);
126+
}
127+
}
128+
129+
export async function materializeWizardBookmark(input: MaterializeWizardBookmarkInput): Promise<void> {
130+
const operation: WizardCohortApiOperation = "materialize-cohort";
131+
requireValue(input.datasetId, "datasetId", operation);
132+
requireValue(input.bookmarkId, "bookmarkId", operation);
133+
requireValue(input.bookmarkName, "bookmarkName", operation);
134+
try {
135+
await client.post(
136+
"/d2e/analytics-svc/api/services/cohort",
137+
{
138+
datasetId: input.datasetId,
139+
mriquery: compressBase64(input.mriQuery),
140+
name: input.bookmarkName,
141+
description: input.description ?? "Generated by Wizards",
142+
syntax: JSON.stringify({ datasetId: input.datasetId, bookmarkId: input.bookmarkId }),
143+
},
144+
{ headers: { datasetid: input.datasetId } },
145+
);
146+
} catch (error) {
147+
throw wrapApiError(error, operation);
148+
}
149+
}

0 commit comments

Comments
 (0)