diff --git a/v3/src/components/web-view/component-handler-web-view.test.ts b/v3/src/components/web-view/component-handler-web-view.test.ts index f56e138a2..7430362fe 100644 --- a/v3/src/components/web-view/component-handler-web-view.test.ts +++ b/v3/src/components/web-view/component-handler-web-view.test.ts @@ -75,8 +75,8 @@ describe("DataInteractive ComponentHandler WebView and Game", () => { expect(tileLayout?.width).toBe(newValue) // Create game with url - const multidataUrl = "https://codap.concord.org/multidata-plugin/" - const result3 = handler.create!({}, { type: "game", URL: multidataUrl }) + const multiDataUrl = "https://codap.concord.org/multidata-plugin/" + const result3 = handler.create!({}, { type: "game", URL: multiDataUrl }) expect(result3.success).toBe(true) expect(documentContent.tileMap.size).toBe(2) const result3Values = result3.values as DIComponentInfo @@ -84,10 +84,10 @@ describe("DataInteractive ComponentHandler WebView and Game", () => { expect(tile3).toBeDefined() expect(isWebViewModel(tile3.content)).toBe(true) const gameModel = tile3.content as IWebViewModel - expect(gameModel.url).toBe(multidataUrl) + expect(gameModel.url).toBe(multiDataUrl) // Get game - gameModel.setIsPlugin(true) // This would normally be set automatically when the plugin connects to codap + gameModel.setSubType("plugin") // This would normally be set automatically when the plugin connects to codap testGetComponent(tile3, handler, (gameTile, values) => { const { URL } = values as V2Game expect(URL).toBe((gameTile.content as IWebViewModel).url) diff --git a/v3/src/components/web-view/use-data-interactive-controller.ts b/v3/src/components/web-view/use-data-interactive-controller.ts index 84352b966..75a0cd226 100644 --- a/v3/src/components/web-view/use-data-interactive-controller.ts +++ b/v3/src/components/web-view/use-data-interactive-controller.ts @@ -36,7 +36,7 @@ export function useDataInteractiveController(iframeRef: React.RefObject { - webViewModel?.setIsPlugin(true) + webViewModel?.setSubType("plugin") debugLog(DEBUG_PLUGINS, "connection with iframe established") }) const handler: iframePhone.IframePhoneRpcEndpointHandlerFn = diff --git a/v3/src/components/web-view/web-view-defs.ts b/v3/src/components/web-view/web-view-defs.ts index 7f47b99c1..40f92d169 100644 --- a/v3/src/components/web-view/web-view-defs.ts +++ b/v3/src/components/web-view/web-view-defs.ts @@ -4,3 +4,6 @@ export const kV2GuideViewType = "guideView" export const kV2WebViewType = "webView" export const kWebViewTileClass = "codap-web-view" export const kWebViewBodyClass = "codap-web-view-body" + +export const webViewSubTypes = ["guide", "plugin"] as const +export type WebViewSubType = typeof webViewSubTypes[number] diff --git a/v3/src/components/web-view/web-view-model.ts b/v3/src/components/web-view/web-view-model.ts index 7d577280f..81c95172a 100644 --- a/v3/src/components/web-view/web-view-model.ts +++ b/v3/src/components/web-view/web-view-model.ts @@ -2,7 +2,8 @@ import iframePhone from "iframe-phone" import { Instance, SnapshotIn, types } from "mobx-state-tree" import { DIMessage } from "../../data-interactive/iframe-phone-types" import { ITileContentModel, TileContentModel } from "../../models/tiles/tile-content" -import { kWebViewTileType } from "./web-view-defs" +import { withoutUndo } from "../../models/history/without-undo" +import { kWebViewTileType, WebViewSubType, webViewSubTypes } from "./web-view-defs" export const kDefaultAllowEmptyAttributeDeletion = true export const kDefaultBlockAPIRequestsWhileEditing = false @@ -17,6 +18,7 @@ export const WebViewModel = TileContentModel .named("WebViewModel") .props({ type: types.optional(types.literal(kWebViewTileType), kWebViewTileType), + subType: types.maybe(types.enumeration(webViewSubTypes)), url: "", state: types.frozen(), // fields controlled by plugins (like Collaborative) via interactiveFrame requests @@ -30,20 +32,26 @@ export const WebViewModel = TileContentModel }) .volatile(self => ({ dataInteractiveController: undefined as iframePhone.IframePhoneRpcEndpoint | undefined, - isPlugin: false, version: kDefaultWebViewVersion })) .views(self => ({ get allowBringToFront() { return !self.preventBringToFront + }, + get isGuide() { + return self.subType === "guide" + }, + get isPlugin() { + return self.subType === "plugin" } })) .actions(self => ({ setDataInteractiveController(controller?: iframePhone.IframePhoneRpcEndpoint) { self.dataInteractiveController = controller }, - setIsPlugin(isPlugin: boolean) { - self.isPlugin = isPlugin + setSubType(subType?: WebViewSubType) { + withoutUndo() + self.subType = subType }, setSavedState(state: unknown) { self.state = state diff --git a/v3/src/components/web-view/web-view-registration.test.ts b/v3/src/components/web-view/web-view-registration.test.ts index 12a119059..d18a19916 100644 --- a/v3/src/components/web-view/web-view-registration.test.ts +++ b/v3/src/components/web-view/web-view-registration.test.ts @@ -4,11 +4,11 @@ import { getTileComponentInfo } from "../../models/tiles/tile-component-info" import { getTileContentInfo } from "../../models/tiles/tile-content-info" import { getSharedModelManager } from "../../models/tiles/tile-environment" import { ITileModelSnapshotIn } from "../../models/tiles/tile-model" -import { safeJsonParse } from "../../utilities/js-utils" +import { hasOwnProperty, safeJsonParse } from "../../utilities/js-utils" import { CodapV2Document } from "../../v2/codap-v2-document" import { exportV2Component } from "../../v2/codap-v2-tile-exporters" import { importV2Component } from "../../v2/codap-v2-tile-importers" -import { ICodapV2DocumentJson, ICodapV2WebViewStorage } from "../../v2/codap-v2-types" +import { ICodapV2DocumentJson, ICodapV2GameViewStorage, ICodapV2WebViewStorage } from "../../v2/codap-v2-types" import { kWebViewTileType } from "./web-view-defs" import { isWebViewModel } from "./web-view-model" import "./web-view-registration" @@ -22,7 +22,7 @@ describe("WebView registration", () => { expect(contentInfo).toBeDefined() expect(getTileComponentInfo(kWebViewTileType)).toBeDefined() const defaultContent = contentInfo?.defaultContent() - expect(defaultContent).toBeDefined(); + expect(defaultContent).toBeDefined() }) it("imports/exports v2 web view components", () => { @@ -50,13 +50,68 @@ describe("WebView registration", () => { expect(tile).toBeDefined() expect(mockInsertTile).toHaveBeenCalledTimes(1) const content = isWebViewModel(tile?.content) ? tile?.content : undefined + expect(tile.name).toBe("https://codap-resources.concord.org/images/walkingrates-50-percent.png") + expect(tile._title).toBe("Walking Rates") expect(getTileContentInfo(kWebViewTileType)!.getTitle(tile)).toBe("Walking Rates") expect(content?.url).toBe("https://codap-resources.concord.org/images/walkingrates-50-percent.png") + expect(content?.isPlugin).toBe(false) const row = docContent.getRowByIndex(0) as IFreeTileRow const componentExport = exportV2Component({ tile, row, sharedModelManager }) expect(componentExport?.type).toBe("DG.WebView") const contentStorage = componentExport?.componentStorage as ICodapV2WebViewStorage expect(contentStorage.URL).toBe("https://codap-resources.concord.org/images/walkingrates-50-percent.png") + expect(contentStorage.name).toBe("https://codap-resources.concord.org/images/walkingrates-50-percent.png") + expect(contentStorage.title).toBe("Walking Rates") + expect(contentStorage.userSetTitle).toBe(true) + }) + + it("imports/exports v2 game view components", () => { + const file = path.join(__dirname, "../../test/v2", "game-view-microdata.codap") + const json = fs.readFileSync(file, "utf8") + const doc = safeJsonParse(json)! + const v2Document = new CodapV2Document(doc) + + const codapDoc = createCodapDocument() + const docContent = codapDoc.content! + docContent.setRowCreator(() => FreeTileRow.create()) + const sharedModelManager = getSharedModelManager(docContent) + const mockInsertTile = jest.fn((tileSnap: ITileModelSnapshotIn) => { + return docContent?.insertTileSnapshotInDefaultRow(tileSnap) + }) + + const tile = importV2Component({ + v2Component: v2Document.components[0], + v2Document, + sharedModelManager, + insertTile: mockInsertTile + })! + expect(tile).toBeDefined() + expect(mockInsertTile).toHaveBeenCalledTimes(1) + const content = isWebViewModel(tile?.content) ? tile?.content : undefined + // Note: a component with a userSetTitle false (like in this case) does not import the + // title into _title. However the name is imported. + // When the _title is undefined the name is used as the title + expect(tile._title).toBeUndefined() + expect(tile.name).toBe("Microdata Portal") + expect(getTileContentInfo(kWebViewTileType)!.getTitle(tile)).toBe("Microdata Portal") + expect(content?.url).toBe("https://codap-resources.concord.org/plugins/sdlc/plugin/index.html") + expect(content?.isPlugin).toBe(true) + + const row = docContent.getRowByIndex(0) as IFreeTileRow + const componentExport = exportV2Component({ tile, row, sharedModelManager }) + expect(componentExport?.type).toBe("DG.GameView") + const contentStorage = componentExport?.componentStorage as ICodapV2GameViewStorage + // shouldn't write out `name` property for GameView components + expect(hasOwnProperty(contentStorage, "name")).toBe(false) + expect(contentStorage.currentGameName).toBe("Microdata Portal") + // Note: the value of the exported title can probably be anything here, but undefined seems + // to be a safe value to make it clear to CODAPv2 that user hasn't set the title + expect(contentStorage.title).toBeUndefined() + expect(contentStorage.userSetTitle).toBe(false) + + // Note: we do not convert the URL back to the relative one that is used by CODAPv2 + // this seems OK to do. + expect(contentStorage.currentGameUrl).toBe("https://codap-resources.concord.org/plugins/sdlc/plugin/index.html") }) }) diff --git a/v3/src/components/web-view/web-view-registration.ts b/v3/src/components/web-view/web-view-registration.ts index 8cfdbfe10..c8645183a 100644 --- a/v3/src/components/web-view/web-view-registration.ts +++ b/v3/src/components/web-view/web-view-registration.ts @@ -7,11 +7,11 @@ import { ITileModelSnapshotIn } from "../../models/tiles/tile-model" import { toV3Id } from "../../utilities/codap-utils" import { t } from "../../utilities/translation/translate" import { registerV2TileImporter, V2TileImportArgs } from "../../v2/codap-v2-tile-importers" -import { registerV2TileExporter, V2ExportedComponent } from "../../v2/codap-v2-tile-exporters" +import { registerV2TileExporter, V2ExportedComponent, V2TileExportFn } from "../../v2/codap-v2-tile-exporters" import { - isV2WebViewComponent, isV2GameViewComponent, ICodapV2WebViewComponent + isV2WebViewComponent, isV2GameViewComponent, ICodapV2WebViewComponent, ICodapV2GameViewComponent } from "../../v2/codap-v2-types" -import { kV2GameType, kV2WebViewType, kWebViewTileType } from "./web-view-defs" +import { kV2GameType, kV2WebViewType, kWebViewTileType, WebViewSubType } from "./web-view-defs" import { isWebViewModel, IWebViewSnapshot, WebViewModel } from "./web-view-model" import { WebViewComponent } from "./web-view" import { WebViewInspector } from "./web-view-inspector" @@ -43,32 +43,65 @@ registerTileComponentInfo({ defaultHeight: kDefaultWebViewHeight }) -registerV2TileExporter(kWebViewTileType, ({ tile }) => { +const exportFn: V2TileExportFn = ({ tile }) => { // This really should be a WebView Model. We shouldn't be called unless // the tile type is kWebViewTileType which is what isWebViewModel is using. const webViewContent = isWebViewModel(tile.content) ? tile.content : undefined const url = webViewContent?.url ?? "" - const v2WebView: V2ExportedComponent = { - type: "DG.WebView", - componentStorage: { - URL: url + if (webViewContent?.isPlugin) { + const v2GameView: V2ExportedComponent = { + type: "DG.GameView", + componentStorage: { + currentGameUrl: url, + currentGameName: tile.name, + savedGameState: webViewContent?.state ?? undefined + // TODO_V2_EXPORT add the rest of the game properties + } } + return v2GameView + } else { + const v2WebView: V2ExportedComponent = { + type: "DG.WebView", + componentStorage: { + URL: url + } + } + return v2WebView } - return v2WebView -}) +} +exportFn.options = ({ tile }) => { + const webViewContent = isWebViewModel(tile.content) ? tile.content : undefined + // v2 doesn't write out a `name` property for game view components + return { suppressName: webViewContent?.isPlugin } +} +registerV2TileExporter(kWebViewTileType, exportFn) -function addWebViewSnapshot(args: V2TileImportArgs, guid: number, url?: string, state?: unknown) { +function addWebViewSnapshot(args: V2TileImportArgs, name?: string, url?: string, state?: unknown) { const { v2Component, insertTile } = args - const { name, title, userSetTitle } = v2Component.componentStorage || {} + const { guid } = v2Component + const { title, userSetTitle } = v2Component.componentStorage || {} + const subTypeMap: Record = { + "DG.GameView": "plugin", + "DG.GuideView": "guide" + } const content: IWebViewSnapshot = { type: kWebViewTileType, + subType: subTypeMap[v2Component.type], state, url } const webViewTileSnap: ITileModelSnapshotIn = { id: toV3Id(kWebViewIdPrefix, guid), name, + // Note: when a game view is imported the userSetTitle is often + // false, and often the title property is set to title which the plugin provided + // to CODAPv2. This value is also set in componentStorage.currentGameName. This + // currentGameName value is imported as the name field above. + // If round tripping a v2 document through v3 the title will be lost because + // of this. The name will be preserved though. Even when the name was not preserved + // CODAPv2 seemed to handle this correctly and updated the the title when the document + // is loaded. _title: (userSetTitle && title) || undefined, content } @@ -81,10 +114,12 @@ function importWebView(args: V2TileImportArgs) { if (!isV2WebViewComponent(v2Component)) return // parse the v2 content - const { guid, componentStorage: { URL } } = v2Component + const { componentStorage: { name, URL } } = v2Component // create webView model - return addWebViewSnapshot(args, guid, URL) + // Note: a renamed WebView has the componentStorage.name set to the URL, + // only the componentStorage.title is updated + return addWebViewSnapshot(args, name, URL) } registerV2TileImporter("DG.WebView", importWebView) @@ -93,13 +128,17 @@ function importGameView(args: V2TileImportArgs) { if (!isV2GameViewComponent(v2Component)) return // parse the v2 content - const { guid, componentStorage: { currentGameUrl, savedGameState} } = v2Component + const { componentStorage: { currentGameUrl, currentGameName, savedGameState} } = v2Component // create webView model - return addWebViewSnapshot(args, guid, processPluginUrl(currentGameUrl), savedGameState) + // Note: a renamed GameView has the componentStorage.currentGameName set to the value + // provided by the plugin, only the componentStorage.title is updated + return addWebViewSnapshot(args, currentGameName, processPluginUrl(currentGameUrl), savedGameState) } registerV2TileImporter("DG.GameView", importGameView) +// TODO add importer for DG.GuideView + const webViewComponentHandler: DIComponentHandler = { create({ values }) { const { URL } = values as V2WebView diff --git a/v3/src/data-interactive/data-interactive-type-utils.ts b/v3/src/data-interactive/data-interactive-type-utils.ts index 7bafe74e4..f5cc216e3 100644 --- a/v3/src/data-interactive/data-interactive-type-utils.ts +++ b/v3/src/data-interactive/data-interactive-type-utils.ts @@ -202,10 +202,11 @@ export function convertDataSetToV2(dataSet: IDataSet, exportCases = false): ICod description, // metadata, // preventReorg, - // TODO_V2_EXPORT + // TODO_V2_EXPORT setAsideItems setAsideItems: [], - // TODO_V2_IMPORT, TODO_V2_EXPORT - contextStorage: { _links_: { selectedCases: [] } } + // TODO_V2_EXPORT contextStorage + // providing an empty object makes it possible for CODAPv2 to load more exported documents + contextStorage: {} } } diff --git a/v3/src/test/v2/game-view-microdata.codap b/v3/src/test/v2/game-view-microdata.codap new file mode 100644 index 000000000..4696c78f5 --- /dev/null +++ b/v3/src/test/v2/game-view-microdata.codap @@ -0,0 +1,61 @@ +{ + "name": "Untitled Document", + "guid": 1, + "id": 1, + "components": [ + { + "type": "DG.GameView", + "guid": 2, + "id": 2, + "componentStorage": { + "currentGameName": "Microdata Portal", + "currentGameUrl": "../../../../extn/plugins/sdlc/plugin/index.html", + "allowInitGameOverride": true, + "savedGameState": { + "sampleNumber": 1, + "sampleSize": 16, + "selectedYears": [ + 2020 + ], + "selectedStates": [], + "selectedAttributes": [ + "Sex", + "Age", + "Year", + "State", + "Boundaries" + ], + "keepExistingData": false, + "activityLog": [ + { + "time": "12/17/2024, 9:08:16 AM", + "message": "Connection: /4g/false/50/10/" + } + ] + }, + "title": "Microdata Portal", + "userSetTitle": false, + "cannotClose": false + }, + "layout": { + "width": 380, + "height": 520, + "left": 5, + "top": 5, + "zIndex": 101, + "isVisible": true, + "right": 385, + "bottom": 525 + }, + "savedHeight": null + } + ], + "contexts": [], + "globalValues": [], + "appName": "DG", + "appVersion": "2.0", + "appBuildNum": "0730", + "lang": "en", + "idCount": 2, + "metadata": {} +} diff --git a/v3/src/v2/codap-v2-tile-exporters.ts b/v3/src/v2/codap-v2-tile-exporters.ts index 1acd700e5..00a15711b 100644 --- a/v3/src/v2/codap-v2-tile-exporters.ts +++ b/v3/src/v2/codap-v2-tile-exporters.ts @@ -15,7 +15,13 @@ export interface V2TileExportArgs { row?: IFreeTileRow sharedModelManager?: ISharedModelManager } -export type V2TileExportFn = (args: V2TileExportArgs) => Maybe +interface V2TileExportFnOptions { + suppressName?: boolean +} +interface V2TileExportFnOptionsProp { + options?: (args: V2TileExportArgs) => V2TileExportFnOptions +} +export type V2TileExportFn = ((args: V2TileExportArgs) => Maybe) & V2TileExportFnOptionsProp // map from v2 component type to export function const gV2TileExporters = new Map() @@ -34,13 +40,16 @@ export function registerV2TileExporter(tileType: string, exportFn: V2TileExportF // export the specified v2 component using the appropriate registered exporter export function exportV2Component(args: V2TileExportArgs): Maybe { - const output = gV2TileExporters.get(args.tile.content.type)?.(args) + const v2ExportFn = gV2TileExporters.get(args.tile.content.type) + const suppressName = v2ExportFn?.options?.(args).suppressName + const output = v2ExportFn?.(args) if (!output) return const layout = args.row?.getTileLayout(args.tile.id) if (!isFreeTileLayout(layout)) return const id = toV2Id(args.tile.id) + const name = suppressName ? undefined : { name: args.tile.name } const tileWidth = layout.width ?? kDefaultTileWidth const tileHeight = layout.height ?? kDefaultTileHeight @@ -50,7 +59,7 @@ export function exportV2Component(args: V2TileExportArgs): Maybe { } // when exporting a v3 data set to v2 data context -type DCNotYetExported = "flexibleGroupingChangeFlag" export interface ICodapV2DataContextV3 - extends SetOptional, DCNotYetExported> { + extends Omit { collections: ICodapV2CollectionV3[] } @@ -340,9 +339,6 @@ export interface ICodapV2WebViewStorage extends ICodapV2BaseComponentStorage { export interface ICodapV2GameViewStorage extends ICodapV2BaseComponentStorage { currentGameUrl: string savedGameState?: unknown - // TODO_V2_IMPORT currentGameName is not imported - // it occurs in 17,000 files in cfm-shared - // it might not be optional currentGameName?: string // TODO_V2_IMPORT allowInitGameOverride is not imported // it occurs in at least 12,500 files in cfm-shared