diff --git a/v3/README.md b/v3/README.md index bdebee71d..e2b248121 100644 --- a/v3/README.md +++ b/v3/README.md @@ -130,6 +130,7 @@ Inside of your `package.json` file: Various developer features can be enabled by adding a `debug` local storage key with one or more of the following flags separated by spaces. Local storage is specific to the domain that CODAP is running on. In Chrome local storage can be edited by opening the developer tools and going to the "Application" tab. Then find "Storage/Local storage" and the domain that CODAP is running on. +- `cfmEvents` console log all events received from the CFM - `cfmLocalStorage` enable the CFM local storage provider so documents can be saved and loaded from the browser's local storage - `document` this will add the active document as `window.currentDocument`, you can use `currentDocument.toJSON()` to views the current documents content. You can also use `currentDocument.treeManagerAPI.document.toJSON()` to inspect the history of the document. - `formulas` print info about recalculating formulas diff --git a/v3/src/lib/debug.ts b/v3/src/lib/debug.ts index 9a2c6729b..e17add45e 100644 --- a/v3/src/lib/debug.ts +++ b/v3/src/lib/debug.ts @@ -21,6 +21,7 @@ if (debug.length > 0) { const debugContains = (key: string) => debug.indexOf(key) !== -1 +export const DEBUG_CFM_EVENTS = debugContains("cfmEvents") export const DEBUG_CFM_LOCAL_STORAGE = debugContains("cfmLocalStorage") export const DEBUG_DOCUMENT = debugContains("document") export const DEBUG_FORMULAS = debugContains("formulas") diff --git a/v3/src/lib/handle-cfm-event.test.ts b/v3/src/lib/handle-cfm-event.test.ts index e639305d4..cfa2135e2 100644 --- a/v3/src/lib/handle-cfm-event.test.ts +++ b/v3/src/lib/handle-cfm-event.test.ts @@ -1,4 +1,7 @@ -import { CloudFileManagerClient, CloudFileManagerClientEvent } from "@concord-consortium/cloud-file-manager" +import { cloneDeep } from "lodash" +import { + ClientEventCallback, CloudFileManagerClient, CloudFileManagerClientEvent +} from "@concord-consortium/cloud-file-manager" import { getSnapshot } from "mobx-state-tree" import { appState } from "../models/app-state" import { createCodapDocument, isCodapDocument } from "../models/codap/create-codap-document" @@ -65,6 +68,51 @@ describe("handleCFMEvent", () => { expect(isCodapDocument(contentArg)).toBe(true) }) + it("handles the `getContent` message with sharing info", async () => { + const mockCfmClient = {} as CloudFileManagerClient + // This is not real metadata for sharing. Our CFM handler should + // not care and just add it to the returned content whatever it is. + const mockSharingInfo = { sharingInfo: "value" } + const mockCfmEvent = { + type: "getContent", + callback: jest.fn(), + data: { + shared: mockSharingInfo + } + } + const mockCfmEventArg = mockCfmEvent as unknown as CloudFileManagerClientEvent + await handleCFMEvent(mockCfmClient, mockCfmEventArg) + + const contentArg = mockCfmEvent.callback.mock.calls[0][0] + expect(isCodapDocument(contentArg)).toBe(true) + + expect(contentArg.metadata.shared).toEqual(mockSharingInfo) + }) + + it("handles the sharedFile message", async () => { + const mockCfmClient = { + dirty: jest.fn() as CloudFileManagerClient["dirty"] + } as CloudFileManagerClient + const mockCfmEvent = { + type: "sharedFile" + } + const mockCfmEventArg = mockCfmEvent as unknown as CloudFileManagerClientEvent + await handleCFMEvent(mockCfmClient, mockCfmEventArg) + expect(mockCfmClient.dirty).toHaveBeenCalledWith(true) + }) + + it("handles the unsharedFile message", async () => { + const mockCfmClient = { + dirty: jest.fn() as CloudFileManagerClient["dirty"] + } as CloudFileManagerClient + const mockCfmEvent = { + type: "unsharedFile" + } + const mockCfmEventArg = mockCfmEvent as unknown as CloudFileManagerClientEvent + await handleCFMEvent(mockCfmClient, mockCfmEventArg) + expect(mockCfmClient.dirty).toHaveBeenCalledWith(true) + }) + it("handles the willOpenFile message", async () => { const mockCfmClient = {} as CloudFileManagerClient const mockCfmEvent = { @@ -89,11 +137,14 @@ describe("handleCFMEvent", () => { type: "openedFile", data: { content: mockV2Document - } + }, + callback: jest.fn() as ClientEventCallback } as CloudFileManagerClientEvent const spy = jest.spyOn(ImportV2Document, "importV2Document") await handleCFMEvent(mockCfmClient, cfmEvent) expect(ImportV2Document.importV2Document).toHaveBeenCalledTimes(1) + // No error and no shared data + expect(cfmEvent.callback).toHaveBeenCalledWith(null, {}) spy.mockRestore() }) @@ -104,12 +155,41 @@ describe("handleCFMEvent", () => { type: "openedFile", data: { content: getSnapshot(v3Document) - } + }, + callback: jest.fn() as ClientEventCallback } as CloudFileManagerClientEvent const spy = jest.spyOn(appState, "setDocument") await handleCFMEvent(mockCfmClient, cfmEvent) expect(spy).toHaveBeenCalledTimes(1) + // No error and no shared data + expect(cfmEvent.callback).toHaveBeenCalledWith(null, {}) spy.mockRestore() }) + it("handles the `openedFile` message with sharing info", async () => { + const mockCfmClient = {} as CloudFileManagerClient + const v3Document = createCodapDocument() + // This is not real metadata for sharing. Our CFM handler should + // not care and just return it whatever it is. + const mockSharingInfo = { sharingInfo: "value" } + const snapshot = getSnapshot(v3Document) + const content = cloneDeep(snapshot) as any + content.metadata = { + shared: mockSharingInfo + } + + const cfmEvent = { + type: "openedFile", + data: { + content + }, + callback: jest.fn() as ClientEventCallback + } as CloudFileManagerClientEvent + const spy = jest.spyOn(appState, "setDocument") + await handleCFMEvent(mockCfmClient, cfmEvent) + expect(spy).toHaveBeenCalledTimes(1) + // No error and the sharing info is returned + expect(cfmEvent.callback).toHaveBeenCalledWith(null, mockSharingInfo) + spy.mockRestore() + }) }) diff --git a/v3/src/lib/handle-cfm-event.ts b/v3/src/lib/handle-cfm-event.ts index ffc249a18..81480e4da 100644 --- a/v3/src/lib/handle-cfm-event.ts +++ b/v3/src/lib/handle-cfm-event.ts @@ -1,14 +1,19 @@ +import { cloneDeep } from "lodash" import { CloudFileManagerClient, CloudFileManagerClientEvent } from "@concord-consortium/cloud-file-manager" import { appState } from "../models/app-state" import { removeDevUrlParams, urlParams } from "../utilities/url-params" import { wrapCfmCallback } from "./cfm-utils" +import { DEBUG_CFM_EVENTS } from "./debug" import build from "../../build_number.json" import pkg from "../../package.json" -export function handleCFMEvent(cfmClient: CloudFileManagerClient, event: CloudFileManagerClientEvent) { - // const { data, state, ...restEvent } = event - // console.log("cfmEventCallback", JSON.stringify({ ...restEvent })) +export async function handleCFMEvent(cfmClient: CloudFileManagerClient, event: CloudFileManagerClientEvent) { + if (DEBUG_CFM_EVENTS) { + // We clone the event because the CFM reuses the same objects across multiple events + // eslint-disable-next-line no-console + console.log("cfmEvent", event.type, cloneDeep(event)) + } switch (event.type) { case "connected": @@ -33,10 +38,21 @@ export function handleCFMEvent(cfmClient: CloudFileManagerClient, event: CloudFi // case "closedFile": // break case "getContent": { - // return the promise so tests can make sure it is complete - return appState.getDocumentSnapshot().then(content => { - event.callback(content) - }) + const content = await appState.getDocumentSnapshot() + // getDocumentSnapshot makes a clone of the snapshot so it is safe to mutate in place. + const cfmContent = content as any + + // Add 'metadata.shared' property based on the CFM event shared data + // The CFM assumes this is where the shared metadata is when it tries + // to strip it out in `getDownloadBlob` + const cfmSharedMetadata = event.data?.shared + if (cfmSharedMetadata) { + // In CODAPv2 the CFM metadata is cloned, so we do the same here to be safe + cfmContent.metadata = { shared: cloneDeep(cfmSharedMetadata) } + } + event.callback(cfmContent) + + break } case "willOpenFile": removeDevUrlParams() @@ -46,8 +62,21 @@ export function handleCFMEvent(cfmClient: CloudFileManagerClient, event: CloudFi case "openedFile": { const content = event.data.content const metadata = event.data.metadata - // return the promise so tests can make sure it is complete - return appState.setDocument(content, metadata) + + // Pull the shared metadata out of the content if it exists + // Otherwise use the shared metadata passed from the CFM + const cfmSharedMetadata = content?.metadata?.shared || metadata?.shared || {} + + // Clone this metadata because that is what CODAPv2 did so we do the + // same to be safe + const clonedCfmSharedMetadata = cloneDeep(cfmSharedMetadata) + + await appState.setDocument(content, metadata) + + // acknowledge a successful open and return shared metadata + event.callback(null, clonedCfmSharedMetadata) + + break } case "savedFile": { const { content } = event.data @@ -66,10 +95,18 @@ export function handleCFMEvent(cfmClient: CloudFileManagerClient, event: CloudFi } break } - // case "sharedFile": - // break - // case "unsharedFile": - // break + case "sharedFile": + case "unsharedFile": + // Make the document dirty to trigger a save with the updated sharing info + // If the file is already shared, and the user updates the shared document, the + // "sharedFile" event will happen again. + // Currently it isn't necessary to update the sharing info in this case, but + // perhaps in the future the sharing info will include properties that change + // each time, such as a timestamp for when the document was shared. + // Due to the design of the CFM event system we need to do this in the next time slice + await new Promise(resolve => setTimeout(resolve, 0)) + cfmClient.dirty(true) + break // case "importedData": // break case "renamedFile": {