From b5df000928f3a842ae4201e9884a867794bfbeb1 Mon Sep 17 00:00:00 2001 From: Boris Goldowsky Date: Wed, 3 Apr 2024 14:45:44 -0400 Subject: [PATCH 01/13] Basic implementation with volatile state --- src/components/workspace/workspace.tsx | 23 +++++++-- src/lib/db.ts | 3 +- src/lib/firebase.ts | 4 ++ src/lib/logger.ts | 2 + src/models/stores/documents.ts | 14 +++++- src/models/stores/exemplar-controller.ts | 62 ++++++++++++++++++++++++ src/models/stores/stores.ts | 4 ++ 7 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 src/models/stores/exemplar-controller.ts diff --git a/src/components/workspace/workspace.tsx b/src/components/workspace/workspace.tsx index b03c0ba365..13fd2dfd91 100644 --- a/src/components/workspace/workspace.tsx +++ b/src/components/workspace/workspace.tsx @@ -1,5 +1,5 @@ import { observer } from "mobx-react"; -import React from "react"; +import React, { useCallback, useEffect, useRef } from "react"; import { IBaseProps } from "../base"; import { useStores } from "../../hooks/use-stores"; import { DocumentWorkspaceComponent } from "../document/document-workspace"; @@ -7,6 +7,7 @@ import { ImageDragDrop } from "../utilities/image-drag-drop"; import { NavTabPanel } from "../navigation/nav-tab-panel"; import { ResizePanelDivider } from "./resize-panel-divider"; import { ResizablePanel } from "./resizable-panel"; +import { HotKeys } from "../../utilities/hot-keys"; import "./workspace.sass"; @@ -16,17 +17,33 @@ interface IProps extends IBaseProps { export const WorkspaceComponent: React.FC = observer((props) => { const stores = useStores(); const { appConfig: { navTabs: navTabSpecs }, - persistentUI: { navTabContentShown, workspaceShown } + persistentUI: { navTabContentShown, workspaceShown }, + documents } = stores; + const hotKeys = useRef(new HotKeys()); let imageDragDrop: ImageDragDrop; + // For testing purposes, have cmd-shift-e reset all exemplars to their default state + const resetAllExemplars = useCallback(() => { + documents.resetAllExemplars(); + }, [documents]); + + useEffect(() => { + hotKeys.current.register({ + "cmd-shift-e": resetAllExemplars + }); + + }, [resetAllExemplars]); + const handleDragOverWorkspace = (e: React.DragEvent) => { imageDragDrop?.dragOver(e); }; return ( -
+
hotKeys.current.dispatch(e)}>
{ this.listeners.start().then(resolve).catch(reject); + exemplarController.initialize(user, db); }); } } diff --git a/src/lib/firebase.ts b/src/lib/firebase.ts index dd7ffa8c72..102b0e084f 100644 --- a/src/lib/firebase.ts +++ b/src/lib/firebase.ts @@ -112,6 +112,10 @@ export class Firebase { return `${this.getUserPath(user)}/exemplars`; } + public getExemplarStatePath(user: UserModelType) { + return `${this.getUserExemplarsPath(user)}/state`; + } + // Published learning logs metadata public getLearningLogPublicationsPath(user: UserModelType) { return `${this.getClassPath(user)}/publications`; diff --git a/src/lib/logger.ts b/src/lib/logger.ts index f902f7d926..29da8cf06c 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -80,6 +80,8 @@ export class Logger { const time = Date.now(); // eventually we will want server skew (or to add this via FB directly) if (this._instance) { this._instance.formatAndSend(time, event, parameters, method); + // Log messages are also shared with the ExemplarController + this._instance.stores.exemplarController.recordLogEvent(time, event, parameters, method); } else { debugLog(DEBUG_LOGGER, "Queueing log message for later delivery", LogEventName[event]); this.pendingMessages.push({ time, event, parameters, method }); diff --git a/src/models/stores/documents.ts b/src/models/stores/documents.ts index 84fa7a0a59..384a8788a5 100644 --- a/src/models/stores/documents.ts +++ b/src/models/stores/documents.ts @@ -4,7 +4,7 @@ import { observable } from "mobx"; import { AppConfigModelType } from "./app-config-model"; import { DocumentModelType } from "../document/document"; import { - DocumentType, LearningLogDocument, LearningLogPublication, OtherDocumentType, OtherPublicationType, + DocumentType, ExemplarDocument, LearningLogDocument, LearningLogPublication, OtherDocumentType, OtherPublicationType, PersonalDocument, PersonalPublication, PlanningDocument, ProblemDocument, ProblemPublication } from "../document/document-types"; import { getTileEnvironment } from "../tiles/tile-environment"; @@ -152,6 +152,14 @@ export const DocumentsModel = types ? user1.lastName.localeCompare(user2.lastName) : user1.firstName.localeCompare(user2.firstName); }); + }, + + get visibleExemplarDocuments() { + return self.byType(ExemplarDocument).filter(e => self.isExemplarVisible(e.key)); + }, + + get invisibleExemplarDocuments() { + return self.byType(ExemplarDocument).filter(e => !self.isExemplarVisible(e.key)); } })) .views(self => ({ @@ -187,8 +195,10 @@ export const DocumentsModel = types } else { self.visibleExemplars.delete(exemplarId); } + }, + resetAllExemplars() { + self.visibleExemplars.clear(); } - })) .actions((self) => { const add = (document: DocumentModelType) => { diff --git a/src/models/stores/exemplar-controller.ts b/src/models/stores/exemplar-controller.ts new file mode 100644 index 0000000000..4ff9ae6869 --- /dev/null +++ b/src/models/stores/exemplar-controller.ts @@ -0,0 +1,62 @@ +import { applySnapshot, types, onSnapshot } from "mobx-state-tree"; +import { UserModelType } from "./user"; +import { DB } from "../../lib/db"; +import { safeJsonParse } from "../../utilities/js-utils"; +import { LogEventMethod, LogEventName } from "../../lib/logger-types"; +import { DocumentsModelType } from "./documents"; + + +export const ExemplarControllerModel = types + .model("ExemplarController", { + }) + .volatile((self) => ({ + graphActions: 0, + documentsStore: undefined as DocumentsModelType | undefined + })) + .actions((self) => ({ + async initialize(user: UserModelType, db: DB) { + self.documentsStore = db.stores.documents; + const path = db.firebase.getExemplarStatePath(user); + const getRef = db.firebase.ref(path); + const theData: string | undefined = (await getRef.once("value"))?.val(); + const asObj = safeJsonParse(theData); + console.log("initializing ExemplarController; state=", asObj); + if (asObj) { + applySnapshot(self, asObj); + } + + onSnapshot(self, (snapshot)=>{ + const snapshotStr = JSON.stringify(snapshot); + const updateRef = db.firebase.ref(path); + updateRef.set(snapshotStr); + }); + }, + updateExemplars() { + if (self.graphActions >= 3) { + const invisible = self.documentsStore?.invisibleExemplarDocuments; + + if (!invisible || !invisible.length) { + console.log("No hidden exemplars to reveal"); + } else { + const random = invisible[0]; // TODO make random + console.log("Revealing exemplar", random.key, "from", invisible.map(e=>e.key)); + self.documentsStore?.setExemplarVisible(random.key, true); + } + self.graphActions = 0; + } + } + })) + .actions((self) => ({ + recordLogEvent(date: number, event: LogEventName, parameters?: Record, method?: LogEventMethod) { + console.log("Examining log event", event, parameters); + if (event === LogEventName.DRAWING_TOOL_CHANGE) { + const operation = parameters?.operation; + if (typeof operation === "string" && operation === "addAndSelectObject") { + self.graphActions ++; + self.updateExemplars(); + } + } + } +})); + +export type ExemplarControllerModelType = typeof ExemplarControllerModel.Type; diff --git a/src/models/stores/stores.ts b/src/models/stores/stores.ts index fbdf475546..38bfb9a6fa 100644 --- a/src/models/stores/stores.ts +++ b/src/models/stores/stores.ts @@ -33,6 +33,7 @@ import { urlParams } from "../../utilities/url-params"; import { createAndLoadExemplarDocs } from "./create-exemplar-docs"; import curriculumConfigJson from "../../clue/curriculum-config.json"; import { gImageMap } from "../image-map"; +import { ExemplarControllerModel, ExemplarControllerModelType } from "./exemplar-controller"; export interface IStores extends IBaseStores { problemPath: string; @@ -48,6 +49,7 @@ export interface IStores extends IBaseStores { unitLoadedPromise: Promise; sectionsLoadedPromise: Promise; startedLoadingUnitAndProblem: boolean; + exemplarController: ExemplarControllerModelType; } export interface ICreateStores extends Partial { @@ -91,6 +93,7 @@ class Stores implements IStores{ unitLoadedPromise: Promise; sectionsLoadedPromise: Promise; startedLoadingUnitAndProblem: boolean; + exemplarController: ExemplarControllerModelType; constructor(params?: ICreateStores){ // This will mark all properties as observable @@ -150,6 +153,7 @@ class Stores implements IStores{ this.unitLoadedPromise = when(() => this.unit !== defaultUnit); this.sectionsLoadedPromise = when(() => this.problem.sections.length > 0); + this.exemplarController = ExemplarControllerModel.create(); } get tabsToDisplay() { From 5fc8b175d6132e6753f71342329c166dd451ecce Mon Sep 17 00:00:00 2001 From: Boris Goldowsky Date: Wed, 3 Apr 2024 15:59:03 -0400 Subject: [PATCH 02/13] Write new visibility to database Clean ups --- src/components/workspace/workspace.tsx | 6 +- src/lib/db-listeners/db-exemplars-listener.ts | 2 +- src/lib/logger.ts | 6 +- src/models/stores/documents.ts | 3 - src/models/stores/exemplar-controller.ts | 92 +++++++++++++------ 5 files changed, 73 insertions(+), 36 deletions(-) diff --git a/src/components/workspace/workspace.tsx b/src/components/workspace/workspace.tsx index 13fd2dfd91..c81bf33202 100644 --- a/src/components/workspace/workspace.tsx +++ b/src/components/workspace/workspace.tsx @@ -18,7 +18,7 @@ export const WorkspaceComponent: React.FC = observer((props) => { const stores = useStores(); const { appConfig: { navTabs: navTabSpecs }, persistentUI: { navTabContentShown, workspaceShown }, - documents + exemplarController } = stores; const hotKeys = useRef(new HotKeys()); @@ -26,8 +26,8 @@ export const WorkspaceComponent: React.FC = observer((props) => { // For testing purposes, have cmd-shift-e reset all exemplars to their default state const resetAllExemplars = useCallback(() => { - documents.resetAllExemplars(); - }, [documents]); + exemplarController.resetAllExemplars(); + }, [exemplarController]); useEffect(() => { hotKeys.current.register({ diff --git a/src/lib/db-listeners/db-exemplars-listener.ts b/src/lib/db-listeners/db-exemplars-listener.ts index 2ceee9ab43..d7cf0a4662 100644 --- a/src/lib/db-listeners/db-exemplars-listener.ts +++ b/src/lib/db-listeners/db-exemplars-listener.ts @@ -55,7 +55,7 @@ export class DBExemplarsListener extends BaseListener { }; private updateExemplarBasedOnValue = (exemplarId: string, value: any) => { - const visible = "visible" in value && value.visible; + const visible = typeof value === "object" && value !== null && "visible" in value && value.visible; this.db.stores.documents.setExemplarVisible(exemplarId, visible); }; diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 29da8cf06c..65b35c9c31 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -15,7 +15,7 @@ const logManagerUrl: Record = { const productionPortal = "learn.concord.org"; -interface LogMessage { +export interface LogMessage { // these top-level properties are treated specially by the log-ingester: // https://github.com/concord-consortium/log-ingester/blob/a8b16fdb02f4cef1f06965a55c5ec6c1f5d3ae1b/canonicalize.js#L3 application: string; @@ -80,8 +80,6 @@ export class Logger { const time = Date.now(); // eventually we will want server skew (or to add this via FB directly) if (this._instance) { this._instance.formatAndSend(time, event, parameters, method); - // Log messages are also shared with the ExemplarController - this._instance.stores.exemplarController.recordLogEvent(time, event, parameters, method); } else { debugLog(DEBUG_LOGGER, "Queueing log message for later delivery", LogEventName[event]); this.pendingMessages.push({ time, event, parameters, method }); @@ -122,6 +120,8 @@ export class Logger { const eventString = LogEventName[event]; const logMessage = this.createLogMessage(time, eventString, parameters, method); sendToLoggingService(logMessage, this.stores.user); + // Log messages are also shared with the ExemplarController + this.stores.exemplarController.processLogMessage(logMessage); } private createLogMessage( diff --git a/src/models/stores/documents.ts b/src/models/stores/documents.ts index 384a8788a5..1a7aa7ed74 100644 --- a/src/models/stores/documents.ts +++ b/src/models/stores/documents.ts @@ -195,9 +195,6 @@ export const DocumentsModel = types } else { self.visibleExemplars.delete(exemplarId); } - }, - resetAllExemplars() { - self.visibleExemplars.clear(); } })) .actions((self) => { diff --git a/src/models/stores/exemplar-controller.ts b/src/models/stores/exemplar-controller.ts index 4ff9ae6869..f410d31f6d 100644 --- a/src/models/stores/exemplar-controller.ts +++ b/src/models/stores/exemplar-controller.ts @@ -1,61 +1,101 @@ import { applySnapshot, types, onSnapshot } from "mobx-state-tree"; +import _ from "lodash"; import { UserModelType } from "./user"; import { DB } from "../../lib/db"; import { safeJsonParse } from "../../utilities/js-utils"; -import { LogEventMethod, LogEventName } from "../../lib/logger-types"; +import { LogEventName } from "../../lib/logger-types"; import { DocumentsModelType } from "./documents"; +import { LogMessage } from "../../lib/logger"; +// The database structure of this object is expected to change frequently as we develop +// this feature, so keep an explicit version number to make migrations easier. +const kExemplarControllerStateVersion = "1"; export const ExemplarControllerModel = types .model("ExemplarController", { + version: types.optional(types.literal(kExemplarControllerStateVersion), kExemplarControllerStateVersion), + drawingActions: 0 }) .volatile((self) => ({ - graphActions: 0, - documentsStore: undefined as DocumentsModelType | undefined + documentsStore: undefined as DocumentsModelType | undefined, + db: undefined as DB|undefined, + firebasePath: undefined as string|undefined })) .actions((self) => ({ async initialize(user: UserModelType, db: DB) { + self.db = db; self.documentsStore = db.stores.documents; - const path = db.firebase.getExemplarStatePath(user); - const getRef = db.firebase.ref(path); - const theData: string | undefined = (await getRef.once("value"))?.val(); - const asObj = safeJsonParse(theData); - console.log("initializing ExemplarController; state=", asObj); - if (asObj) { - applySnapshot(self, asObj); + self.firebasePath = db.firebase.getUserExemplarsPath(user); + const statePath = db.firebase.getExemplarStatePath(user); + const stateRef = db.firebase.ref(statePath); + const stateVal = (await stateRef.once("value"))?.val(); + const state = safeJsonParse(stateVal); + console.log("initializing ExemplarController; state=", state); + if (state) { + applySnapshot(self, state); } onSnapshot(self, (snapshot)=>{ const snapshotStr = JSON.stringify(snapshot); - const updateRef = db.firebase.ref(path); + const updateRef = db.firebase.ref(statePath); updateRef.set(snapshotStr); }); }, + /** + * Writes to the database to indicate whether the current user has access to the given exemplar. + */ + setExemplarVisibility(key: string, isVisible: boolean) { + if (self.db) { + self.db.firebase.ref(self.firebasePath).child(`${key}/visible`).set(isVisible); + } + } + })) + .actions((self) => ({ + /** + * Writes to the database to clear any visible exemplars. + */ + resetAllExemplars() { + for (const key of self.documentsStore?.visibleExemplars || []) { + self.setExemplarVisibility(key, false); + } + }, + /** + * Makes any appropriate changes to exemplars based on the current state. + * This should be called after any state changes that may require action. + */ updateExemplars() { - if (self.graphActions >= 3) { - const invisible = self.documentsStore?.invisibleExemplarDocuments; - - if (!invisible || !invisible.length) { + // At the moment we only have one rule: after 3 drawing actions are recorded, reveal a random exemplar. + if (self.drawingActions >= 3) { + const chosen = _.sample(self.documentsStore?.invisibleExemplarDocuments); + if (!chosen) { console.log("No hidden exemplars to reveal"); } else { - const random = invisible[0]; // TODO make random - console.log("Revealing exemplar", random.key, "from", invisible.map(e=>e.key)); - self.documentsStore?.setExemplarVisible(random.key, true); + self.setExemplarVisibility(chosen.key, true); } - self.graphActions = 0; + self.drawingActions = 0; } } })) .actions((self) => ({ - recordLogEvent(date: number, event: LogEventName, parameters?: Record, method?: LogEventMethod) { - console.log("Examining log event", event, parameters); - if (event === LogEventName.DRAWING_TOOL_CHANGE) { - const operation = parameters?.operation; - if (typeof operation === "string" && operation === "addAndSelectObject") { - self.graphActions ++; - self.updateExemplars(); + /** + * Take any needed actions after a user action, as represented by a log event. + * @param logMessage + */ + processLogMessage(logMessage: LogMessage) { + let needsUpdate = false; + // At the moment we are only looking at one type of event: adding an object in a Draw tile. + if (logMessage.event === LogEventName[LogEventName.DRAWING_TOOL_CHANGE]) { + const operation = logMessage.parameters?.operation; + if (typeof operation === "string" + && ["addObject", "addAndSelectObject", "duplicateObjects"].includes(operation)) { + self.drawingActions ++; + console.log("Graph actions noted:", self.drawingActions); + needsUpdate = true; } } + if (needsUpdate) { + self.updateExemplars(); + } } })); From 9594d193579019432cc634746bfbf266520ae0cd Mon Sep 17 00:00:00 2001 From: Boris Goldowsky Date: Thu, 4 Apr 2024 07:57:47 -0400 Subject: [PATCH 03/13] Add cypress test --- .../document_tests/exemplar_test_spec.js | 42 +++++++++++++++++++ cypress/support/elements/common/SortedWork.js | 10 ++++- 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 cypress/e2e/functional/document_tests/exemplar_test_spec.js diff --git a/cypress/e2e/functional/document_tests/exemplar_test_spec.js b/cypress/e2e/functional/document_tests/exemplar_test_spec.js new file mode 100644 index 0000000000..648c3f55c2 --- /dev/null +++ b/cypress/e2e/functional/document_tests/exemplar_test_spec.js @@ -0,0 +1,42 @@ +import SortedWork from "../../../support/elements/common/SortedWork"; +import ClueCanvas from '../../../support/elements/common/cCanvas'; +import DrawToolTile from '../../../support/elements/tile/DrawToolTile'; + +let sortWork = new SortedWork, + clueCanvas = new ClueCanvas, + drawToolTile = new DrawToolTile; + +const exemplarName = "Ivan Idea: First Exemplar"; + +const queryParams1 = `${Cypress.config("qaConfigSubtabsUnitStudent5")}`; + +function beforeTest(params) { + cy.clearQAData('all'); + cy.visit(params); + cy.waitForLoad(); +} + +function drawSmallRectangle(x, y) { + drawToolTile.getDrawToolRectangle().click(); + drawToolTile.getDrawTile() + .trigger("mousedown", x, y) + .trigger("mousemove", x+25, y+25) + .trigger("mouseup", x+25, y+25); +} + +context('Exemplar Documents', function () { + it('At least one exemplar should be on sort view page, but hidden initially', function () { + beforeTest(queryParams1); + cy.openTopTab('sort-work'); + sortWork.checkDocumentInGroup("No Group", exemplarName); + sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("have.class", "private"); + + cy.log("Create drawing tile and perform 3 events to reveal one exemplar"); + clueCanvas.addTile("drawing"); + drawSmallRectangle(100, 50); + drawSmallRectangle(200, 50); + drawSmallRectangle(300, 50); + + sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("not.have.class", "private"); + }); +}); diff --git a/cypress/support/elements/common/SortedWork.js b/cypress/support/elements/common/SortedWork.js index 6a6af6fcb0..f17950a5bb 100644 --- a/cypress/support/elements/common/SortedWork.js +++ b/cypress/support/elements/common/SortedWork.js @@ -14,11 +14,17 @@ class SortedWork { getSortWorkItem() { return cy.get(".sort-work-view .sorted-sections .list-item .footer .info"); } + getSortWorkItemByTitle(title) { + return this.getSortWorkItem().contains(title); + } + getSortWorkGroup(groupName) { + return cy.get(".sort-work-view .sorted-sections .section-header-label").contains(groupName).parent().parent(); + } checkDocumentInGroup(groupName, doc) { - cy.get(".sort-work-view .sorted-sections .section-header-label").contains(groupName).parent().parent().find(".list .list-item .footer .info").should("contain", doc); + this.getSortWorkGroup(groupName).find(".list .list-item .footer .info").should("contain", doc); } checkDocumentNotInGroup(groupName, doc) { - cy.get(".sort-work-view .sorted-sections .section-header-label").contains(groupName).parent().parent().find(".list .list-item .footer .info").should("not.contain", doc); + this.getSortWorkGroup(groupName).find(".list .list-item .footer .info").should("not.contain", doc); } checkGroupIsEmpty(groupName){ cy.get(".sort-work-view .sorted-sections .section-header-label") From edfb1f4df1210a24a9e9c56a363cb55bc406aacd Mon Sep 17 00:00:00 2001 From: Boris Goldowsky Date: Thu, 4 Apr 2024 08:03:34 -0400 Subject: [PATCH 04/13] Clarify --- .../e2e/functional/document_tests/exemplar_test_spec.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cypress/e2e/functional/document_tests/exemplar_test_spec.js b/cypress/e2e/functional/document_tests/exemplar_test_spec.js index 648c3f55c2..f082bf2198 100644 --- a/cypress/e2e/functional/document_tests/exemplar_test_spec.js +++ b/cypress/e2e/functional/document_tests/exemplar_test_spec.js @@ -6,9 +6,9 @@ let sortWork = new SortedWork, clueCanvas = new ClueCanvas, drawToolTile = new DrawToolTile; -const exemplarName = "Ivan Idea: First Exemplar"; - +// This unit has `initiallyHideExemplars` set, and an exemplar defined in curriculum const queryParams1 = `${Cypress.config("qaConfigSubtabsUnitStudent5")}`; +const exemplarName = "Ivan Idea: First Exemplar"; function beforeTest(params) { cy.clearQAData('all'); @@ -25,13 +25,13 @@ function drawSmallRectangle(x, y) { } context('Exemplar Documents', function () { - it('At least one exemplar should be on sort view page, but hidden initially', function () { + it('Unit with exemplars hidden initially, revealed by drawing actions', function () { beforeTest(queryParams1); cy.openTopTab('sort-work'); sortWork.checkDocumentInGroup("No Group", exemplarName); sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("have.class", "private"); - cy.log("Create drawing tile and perform 3 events to reveal one exemplar"); + cy.log("Create drawing tile and perform 3 events to reveal exemplar"); clueCanvas.addTile("drawing"); drawSmallRectangle(100, 50); drawSmallRectangle(200, 50); From 75846f1b70a22caf596ccc251ffcf496816f7310 Mon Sep 17 00:00:00 2001 From: Boris Goldowsky Date: Thu, 4 Apr 2024 11:28:30 -0400 Subject: [PATCH 05/13] Implement sticky note alert with plain text --- .../functional/document_tests/exemplar_test_spec.js | 13 +++++++++---- cypress/support/elements/common/cCanvas.js | 4 ++++ src/models/stores/exemplar-controller.ts | 9 +++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/cypress/e2e/functional/document_tests/exemplar_test_spec.js b/cypress/e2e/functional/document_tests/exemplar_test_spec.js index f082bf2198..374f56ab25 100644 --- a/cypress/e2e/functional/document_tests/exemplar_test_spec.js +++ b/cypress/e2e/functional/document_tests/exemplar_test_spec.js @@ -8,7 +8,8 @@ let sortWork = new SortedWork, // This unit has `initiallyHideExemplars` set, and an exemplar defined in curriculum const queryParams1 = `${Cypress.config("qaConfigSubtabsUnitStudent5")}`; -const exemplarName = "Ivan Idea: First Exemplar"; +const exemplarName = "First Exemplar"; +const exemplarInfo = "Ivan Idea: First Exemplar"; function beforeTest(params) { cy.clearQAData('all'); @@ -28,8 +29,9 @@ context('Exemplar Documents', function () { it('Unit with exemplars hidden initially, revealed by drawing actions', function () { beforeTest(queryParams1); cy.openTopTab('sort-work'); - sortWork.checkDocumentInGroup("No Group", exemplarName); - sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("have.class", "private"); + sortWork.checkDocumentInGroup("No Group", exemplarInfo); + sortWork.getSortWorkItemByTitle(exemplarInfo).parents('.list-item').should("have.class", "private"); + clueCanvas.getStickyNotePopup().should("not.exist"); cy.log("Create drawing tile and perform 3 events to reveal exemplar"); clueCanvas.addTile("drawing"); @@ -37,6 +39,9 @@ context('Exemplar Documents', function () { drawSmallRectangle(200, 50); drawSmallRectangle(300, 50); - sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("not.have.class", "private"); + sortWork.getSortWorkItemByTitle(exemplarInfo).parents('.list-item').should("not.have.class", "private"); + clueCanvas.getStickyNotePopup().should("exist").should("be.visible") + .should("contain.text", "Nice work, you can now see a new example for this lesson.") + .should("contain.text", exemplarName); }); }); diff --git a/cypress/support/elements/common/cCanvas.js b/cypress/support/elements/common/cCanvas.js index f32954e5df..985d27703c 100644 --- a/cypress/support/elements/common/cCanvas.js +++ b/cypress/support/elements/common/cCanvas.js @@ -132,6 +132,10 @@ class ClueCanvas { this.getShareButton().find('.ball').invoke('attr', 'class').should('not.contain', 'toggle-on'); } + getStickyNotePopup() { + return cy.get('div.sticky-note-popup'); + } + getToolPalette() { return cy.get('.primary-workspace> .toolbar'); } diff --git a/src/models/stores/exemplar-controller.ts b/src/models/stores/exemplar-controller.ts index f410d31f6d..753edeaa8e 100644 --- a/src/models/stores/exemplar-controller.ts +++ b/src/models/stores/exemplar-controller.ts @@ -6,6 +6,8 @@ import { safeJsonParse } from "../../utilities/js-utils"; import { LogEventName } from "../../lib/logger-types"; import { DocumentsModelType } from "./documents"; import { LogMessage } from "../../lib/logger"; +import { AudienceEnum, AudienceModel } from "./supports"; +import { createStickyNote } from "../curriculum/support"; // The database structure of this object is expected to change frequently as we develop // this feature, so keep an explicit version number to make migrations easier. @@ -47,6 +49,13 @@ export const ExemplarControllerModel = types setExemplarVisibility(key: string, isVisible: boolean) { if (self.db) { self.db.firebase.ref(self.firebasePath).child(`${key}/visible`).set(isVisible); + if (isVisible) { + // Notify user with a sticky note + const audience = AudienceModel.create({type: AudienceEnum.user, identifier: self.db.stores.user.id}); + const title = self.documentsStore?.getDocument(key)?.title; + const message = `Nice work, you can now see a new example for this lesson. ${title}`; + self.db.createSupport(createStickyNote(message), "", audience); + } } } })) From 2ec8b393673814c576789ae99f5ab56ead889134 Mon Sep 17 00:00:00 2001 From: Boris Goldowsky Date: Thu, 4 Apr 2024 15:39:57 -0400 Subject: [PATCH 06/13] Add links --- src/components/document/document.tsx | 19 +++++++++++++++++++ src/models/curriculum/support.ts | 9 ++++++--- src/models/stores/exemplar-controller.ts | 5 ++--- src/models/stores/persistent-ui.ts | 8 +++++++- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/components/document/document.tsx b/src/components/document/document.tsx index 9c95d28c1a..1caa75250f 100644 --- a/src/components/document/document.tsx +++ b/src/components/document/document.tsx @@ -304,6 +304,23 @@ export class DocumentComponent extends BaseComponent { ); } + private openDocument(key: string) { + const doc = this.stores.documents.getDocument(key); + if (doc) { + this.stores.persistentUI.openResourceDocument(doc); + } + } + + private renderDocumentLink(key: string|undefined) { + if (!key) return null; + const title = this.stores.documents.getDocument(key)?.title; + if (title) { + return ( this.openDocument(key)} href="#">{title}); + } else { + return "[broken link!]"; + } + } + private renderStickyNotesPopup() { const { user } = this.stores; const { stickyNotes, showNotes} = this.getStickyNoteData(); @@ -337,6 +354,8 @@ export class DocumentComponent extends BaseComponent {
{support.content} + { ' ' } + { this.renderDocumentLink(support.linkedDocumentKey) }
); diff --git a/src/models/curriculum/support.ts b/src/models/curriculum/support.ts index 3afc0e2036..4da706ea79 100644 --- a/src/models/curriculum/support.ts +++ b/src/models/curriculum/support.ts @@ -32,8 +32,9 @@ export function createTextSupport(text: string) { return SupportModel.create({ type: ESupportType.text, content: text }); } -export function createStickyNote(text: string) { - return SupportModel.create({ type: ESupportType.text, mode: ESupportMode.stickyNote, content: text }); +export function createStickyNote(text: string, linkedDocumentKey?: string) { + return SupportModel.create({ + type: ESupportType.text, mode: ESupportMode.stickyNote, content: text, linkedDocumentKey }); } export const SupportModel = types @@ -41,7 +42,9 @@ export const SupportModel = types type: types.enumeration("SupportType", Object.values(ESupportType)), mode: types.maybe(types.enumeration("SupportMode", Object.values(ESupportMode))), // text string or path to document - content: types.string + content: types.string, + // if set, sticky note will link to this + linkedDocumentKey: types.maybe(types.string) }) .preProcessSnapshot(snapshot => { const legacySupport = snapshot as any as LegacySupportSnapshot; diff --git a/src/models/stores/exemplar-controller.ts b/src/models/stores/exemplar-controller.ts index 753edeaa8e..235ea4d22c 100644 --- a/src/models/stores/exemplar-controller.ts +++ b/src/models/stores/exemplar-controller.ts @@ -52,9 +52,8 @@ export const ExemplarControllerModel = types if (isVisible) { // Notify user with a sticky note const audience = AudienceModel.create({type: AudienceEnum.user, identifier: self.db.stores.user.id}); - const title = self.documentsStore?.getDocument(key)?.title; - const message = `Nice work, you can now see a new example for this lesson. ${title}`; - self.db.createSupport(createStickyNote(message), "", audience); + const message = "Nice work, you can now see a new example for this lesson:"; + self.db.createSupport(createStickyNote(message, key), "", audience); } } } diff --git a/src/models/stores/persistent-ui.ts b/src/models/stores/persistent-ui.ts index 3f4e9326e5..599c3b8a8d 100644 --- a/src/models/stores/persistent-ui.ts +++ b/src/models/stores/persistent-ui.ts @@ -7,7 +7,7 @@ import { isWorkspaceModelSnapshot, WorkspaceModel } from "./workspace"; import { DocumentModelType } from "../document/document"; import { ENavTab } from "../view/nav-tabs"; import { buildSectionPath, getCurriculumMetadata } from "../../../functions/src/shared"; -import { LearningLogDocument, LearningLogPublication, PersonalDocument, +import { ExemplarDocument, LearningLogDocument, LearningLogPublication, PersonalDocument, PersonalPublication, PlanningDocument, ProblemDocument, ProblemPublication, SupportPublication } from "../document/document-types"; import { UserModelType } from "./user"; @@ -223,6 +223,9 @@ export const PersistentUIModel = types subTab = groupId; } } + if (navTab === ENavTab.kSortWork) { + subTab = ENavTab.kSortWork; + } if (!subTab) { console.warn("Can't find subTab for doc", getSnapshot(doc)); @@ -293,6 +296,9 @@ const docTypeToNavTab: Record = { [LearningLogPublication]: ENavTab.kClassWork, [PersonalPublication]: ENavTab.kClassWork, [SupportPublication]: ENavTab.kClassWork, + + // Other + [ExemplarDocument]: ENavTab.kSortWork, }; From 6bcd679b6f9b8e64757cc03af62aad5ba293ac75 Mon Sep 17 00:00:00 2001 From: Boris Goldowsky Date: Thu, 4 Apr 2024 15:51:12 -0400 Subject: [PATCH 07/13] Remove console.logs --- src/models/stores/exemplar-controller.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/models/stores/exemplar-controller.ts b/src/models/stores/exemplar-controller.ts index f410d31f6d..01daab7429 100644 --- a/src/models/stores/exemplar-controller.ts +++ b/src/models/stores/exemplar-controller.ts @@ -30,7 +30,6 @@ export const ExemplarControllerModel = types const stateRef = db.firebase.ref(statePath); const stateVal = (await stateRef.once("value"))?.val(); const state = safeJsonParse(stateVal); - console.log("initializing ExemplarController; state=", state); if (state) { applySnapshot(self, state); } @@ -67,9 +66,7 @@ export const ExemplarControllerModel = types // At the moment we only have one rule: after 3 drawing actions are recorded, reveal a random exemplar. if (self.drawingActions >= 3) { const chosen = _.sample(self.documentsStore?.invisibleExemplarDocuments); - if (!chosen) { - console.log("No hidden exemplars to reveal"); - } else { + if (chosen) { self.setExemplarVisibility(chosen.key, true); } self.drawingActions = 0; @@ -89,7 +86,6 @@ export const ExemplarControllerModel = types if (typeof operation === "string" && ["addObject", "addAndSelectObject", "duplicateObjects"].includes(operation)) { self.drawingActions ++; - console.log("Graph actions noted:", self.drawingActions); needsUpdate = true; } } From 9bebeeed188d09ce5baec01c97c220f03566965d Mon Sep 17 00:00:00 2001 From: Boris Goldowsky Date: Fri, 5 Apr 2024 12:57:47 -0400 Subject: [PATCH 08/13] Add test --- .../e2e/functional/document_tests/exemplar_test_spec.js | 8 +++++++- cypress/support/elements/common/SortedWork.js | 9 +++++++++ cypress/support/elements/common/cCanvas.js | 4 ++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/cypress/e2e/functional/document_tests/exemplar_test_spec.js b/cypress/e2e/functional/document_tests/exemplar_test_spec.js index 374f56ab25..23862bd08f 100644 --- a/cypress/e2e/functional/document_tests/exemplar_test_spec.js +++ b/cypress/e2e/functional/document_tests/exemplar_test_spec.js @@ -41,7 +41,13 @@ context('Exemplar Documents', function () { sortWork.getSortWorkItemByTitle(exemplarInfo).parents('.list-item').should("not.have.class", "private"); clueCanvas.getStickyNotePopup().should("exist").should("be.visible") - .should("contain.text", "Nice work, you can now see a new example for this lesson.") + .should("contain.text", "Nice work, you can now see a new example for this lesson") .should("contain.text", exemplarName); + + cy.log("Open exemplar"); + sortWork.getFocusDocument().should('not.exist'); + clueCanvas.getStickyNoteLink().should("be.visible").click(); + sortWork.getFocusDocument().should('be.visible'); + sortWork.getFocusDocumentTitle().should("contain.text", exemplarName); }); }); diff --git a/cypress/support/elements/common/SortedWork.js b/cypress/support/elements/common/SortedWork.js index f17950a5bb..6f74b79057 100644 --- a/cypress/support/elements/common/SortedWork.js +++ b/cypress/support/elements/common/SortedWork.js @@ -38,6 +38,15 @@ class SortedWork { cy.get(".sort-work-view .sorted-sections .section-header-label").should("contain", label); }); } + + getFocusDocument() { + return cy.get('.sort-work-view .focus-document.sort-work'); + } + + getFocusDocumentTitle() { + return this.getFocusDocument().find('.document-title'); + } + } export default SortedWork; diff --git a/cypress/support/elements/common/cCanvas.js b/cypress/support/elements/common/cCanvas.js index 985d27703c..90c4c6de5e 100644 --- a/cypress/support/elements/common/cCanvas.js +++ b/cypress/support/elements/common/cCanvas.js @@ -136,6 +136,10 @@ class ClueCanvas { return cy.get('div.sticky-note-popup'); } + getStickyNoteLink() { + return this.getStickyNotePopup().find('a'); + } + getToolPalette() { return cy.get('.primary-workspace> .toolbar'); } From 7b8d000c989e434e341d3ce9a9dac7518e6a18e6 Mon Sep 17 00:00:00 2001 From: Boris Goldowsky Date: Fri, 5 Apr 2024 15:00:42 -0400 Subject: [PATCH 09/13] Logging updates --- src/components/document/document.tsx | 3 ++- src/components/document/sort-work-view.tsx | 6 ++++++ src/components/navigation/document-view.tsx | 8 ++------ .../section-document-or-browser.tsx | 7 ++----- src/lib/logger-types.ts | 5 ++++- src/models/document/log-document-event.ts | 15 ++++++++++++++ .../document/log-exemplar-document-event.ts | 20 +++++++++++++++++++ src/models/stores/exemplar-controller.ts | 7 +++++++ 8 files changed, 58 insertions(+), 13 deletions(-) create mode 100644 src/models/document/log-exemplar-document-event.ts diff --git a/src/components/document/document.tsx b/src/components/document/document.tsx index 1caa75250f..6627cb31e2 100644 --- a/src/components/document/document.tsx +++ b/src/components/document/document.tsx @@ -8,7 +8,7 @@ import { MyWorkDocumentOrBrowser } from "./mywork-document-or-browser"; import { BaseComponent, IBaseProps } from "../base"; import { DocumentModelType } from "../../models/document/document"; import { LearningLogDocument, LearningLogPublication } from "../../models/document/document-types"; -import { logDocumentEvent } from "../../models/document/log-document-event"; +import { logDocumentEvent, logDocumentViewEvent } from "../../models/document/log-document-event"; import { IToolbarModel } from "../../models/stores/problem-configuration"; import { SupportType, TeacherSupportModelType, AudienceEnum } from "../../models/stores/supports"; import { WorkspaceModelType } from "../../models/stores/workspace"; @@ -308,6 +308,7 @@ export class DocumentComponent extends BaseComponent { const doc = this.stores.documents.getDocument(key); if (doc) { this.stores.persistentUI.openResourceDocument(doc); + logDocumentViewEvent(doc); } } diff --git a/src/components/document/sort-work-view.tsx b/src/components/document/sort-work-view.tsx index 0657ce102b..2d386cf395 100644 --- a/src/components/document/sort-work-view.tsx +++ b/src/components/document/sort-work-view.tsx @@ -10,10 +10,15 @@ import { DEBUG_DOC_LIST } from "../../lib/debug"; import { SortWorkDocumentArea } from "./sort-work-document-area"; import { ENavTab } from "../../models/view/nav-tabs"; import { DocListDebug } from "./doc-list-debug"; +import { logDocumentViewEvent } from "../../models/document/log-document-event"; import "../thumbnail/document-type-collection.sass"; import "./sort-work-view.scss"; +/** + * Resources pane view of class work and exemplars. + * Various options for sorting the display are available - by user, by group, by tools used, etc. + */ export const SortWorkView: React.FC = observer(function SortWorkView() { const { appConfig, persistentUI, sortedDocuments } = useStores(); @@ -56,6 +61,7 @@ export const SortWorkView: React.FC = observer(function SortWorkView() { //******************************* Click to Open Document *************************************** const handleSelectDocument = (document: DocumentModelType) => { persistentUI.openSubTabDocument(ENavTab.kSortWork, ENavTab.kSortWork, document.key); + logDocumentViewEvent(document); }; const tabState = persistentUI.tabs.get(ENavTab.kSortWork); diff --git a/src/components/navigation/document-view.tsx b/src/components/navigation/document-view.tsx index 703a40c054..09e953d530 100644 --- a/src/components/navigation/document-view.tsx +++ b/src/components/navigation/document-view.tsx @@ -7,8 +7,7 @@ import { useAppConfig, useLocalDocuments, useProblemStore, useStores, import { useUserContext } from "../../hooks/use-user-context"; import { ISubTabSpec, NavTabModelType, kBookmarksTabTitle } from "../../models/view/nav-tabs"; import { DocumentType } from "../../models/document/document-types"; -import { LogEventName } from "../../lib/logger-types"; -import { logDocumentEvent } from "../../models/document/log-document-event"; +import { logDocumentViewEvent } from "../../models/document/log-document-event"; import { DocumentModelType } from "../../models/document/document"; import { EditableDocumentContent } from "../document/editable-document-content"; import { getDocumentDisplayTitle } from "../../models/document/document-utils"; @@ -99,10 +98,7 @@ export const DocumentView = observer(function DocumentView({tabSpec, subTab}: IP loadDocumentContent(document); } persistentUI.openSubTabDocument(tabSpec.tab, subTab.label, document.key); - const logEvent = document.isRemote - ? LogEventName.VIEW_SHOW_TEACHER_NETWORK_COMPARISON_DOCUMENT - : LogEventName.VIEW_SHOW_COMPARISON_DOCUMENT; - logDocumentEvent(logEvent, { document }); + logDocumentViewEvent(document); } }; diff --git a/src/components/navigation/section-document-or-browser.tsx b/src/components/navigation/section-document-or-browser.tsx index 04d38d2d7a..90416b25a9 100644 --- a/src/components/navigation/section-document-or-browser.tsx +++ b/src/components/navigation/section-document-or-browser.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from "react"; import { observer } from "mobx-react"; import { useQueryClient } from 'react-query'; import { DocumentModelType } from "../../models/document/document"; -import { logDocumentEvent } from "../../models/document/log-document-event"; +import { logDocumentViewEvent } from "../../models/document/log-document-event"; import { ISubTabSpec, NavTabModelType, kBookmarksTabTitle } from "../../models/view/nav-tabs"; import { useAppConfig, useClassStore, useProblemStore, useStores, useUserStore, usePersistentUIStore } from "../../hooks/use-stores"; @@ -72,10 +72,7 @@ export const SectionDocumentOrBrowser: React.FC = observer(function Sect loadDocumentContent(document); } persistentUI.openSubTabDocument(tabSpec.tab, selectedSubTab.label, document.key); - const logEvent = document.isRemote - ? LogEventName.VIEW_SHOW_TEACHER_NETWORK_COMPARISON_DOCUMENT - : LogEventName.VIEW_SHOW_COMPARISON_DOCUMENT; - logDocumentEvent(logEvent, { document }); + logDocumentViewEvent(document); } }; diff --git a/src/lib/logger-types.ts b/src/lib/logger-types.ts index d901e51489..a566aa6b7c 100644 --- a/src/lib/logger-types.ts +++ b/src/lib/logger-types.ts @@ -14,6 +14,7 @@ export enum LogEventName { VIEW_SHOW_DOCUMENT, VIEW_SHOW_COMPARISON_DOCUMENT, VIEW_SHOW_TEACHER_NETWORK_COMPARISON_DOCUMENT, + VIEW_SHOW_EXEMPLAR_DOCUMENT, VIEW_ENTER_FOUR_UP, VIEW_ENTER_ONE_UP, VIEW_FOUR_UP_RESIZED, @@ -90,5 +91,7 @@ export enum LogEventName { SPARROW_TITLE_CHANGE, SPARROW_SHOW_HIDE, - LOADING_MEASUREMENTS + LOADING_MEASUREMENTS, + + EXEMPLAR_VISIBILITY_UPDATE } diff --git a/src/models/document/log-document-event.ts b/src/models/document/log-document-event.ts index 47b285e965..f0bfed98e1 100644 --- a/src/models/document/log-document-event.ts +++ b/src/models/document/log-document-event.ts @@ -2,6 +2,7 @@ import { Logger } from "../../lib/logger"; import { LogEventMethod, LogEventName } from "../../lib/logger-types"; import { UserModelType } from "../stores/user"; import { DocumentModelType } from "./document"; +import { ExemplarDocument } from "./document-types"; interface ITeacherNetworkInfo { networkClassHash?: string; @@ -44,3 +45,17 @@ export function logDocumentEvent(event: LogEventName, _params: IDocumentLogEvent const params = processDocumentEventParams(_params, Logger.stores); Logger.log(event, params, method); } + +/** + * Convenience function to log the appropriate type of VIEW_SHOW_*_DOCUMENT event for the document. + * @param document + */ +export function logDocumentViewEvent(document: DocumentModelType) { + const event = + document.type === ExemplarDocument + ? LogEventName.VIEW_SHOW_EXEMPLAR_DOCUMENT + : document.isRemote + ? LogEventName.VIEW_SHOW_TEACHER_NETWORK_COMPARISON_DOCUMENT + : LogEventName.VIEW_SHOW_COMPARISON_DOCUMENT; + logDocumentEvent(event, { document }); +} diff --git a/src/models/document/log-exemplar-document-event.ts b/src/models/document/log-exemplar-document-event.ts new file mode 100644 index 0000000000..92733c3d89 --- /dev/null +++ b/src/models/document/log-exemplar-document-event.ts @@ -0,0 +1,20 @@ +import { Logger } from "../../lib/logger"; +import { LogEventMethod, LogEventName } from "../../lib/logger-types"; +import { IDocumentLogEvent } from "./log-document-event"; + +// Events that have to do with making Exemplar documents visible, or invisible, to users. + +export interface IExemplarDocumentLogEvent extends IDocumentLogEvent { + // True if after this change the exemplar is visible to the user + visibleToUser: boolean, + // What caused the change? Manual action, or a rule in the system. + // In the future we might have other options, eg AI + changeSource: "rule" | "manual", + // If the change was based on a rule, this is the name of the rule. + rule?: string +} + +export function logExemplarDocumentEvent (event: LogEventName.EXEMPLAR_VISIBILITY_UPDATE, + params: IExemplarDocumentLogEvent, method?: LogEventMethod) { + Logger.log(event, params, method); +} diff --git a/src/models/stores/exemplar-controller.ts b/src/models/stores/exemplar-controller.ts index 19d396fec4..fc0c2f9bd3 100644 --- a/src/models/stores/exemplar-controller.ts +++ b/src/models/stores/exemplar-controller.ts @@ -8,6 +8,7 @@ import { DocumentsModelType } from "./documents"; import { LogMessage } from "../../lib/logger"; import { AudienceEnum, AudienceModel } from "./supports"; import { createStickyNote } from "../curriculum/support"; +import { logExemplarDocumentEvent } from "../document/log-exemplar-document-event"; // The database structure of this object is expected to change frequently as we develop // this feature, so keep an explicit version number to make migrations easier. @@ -76,6 +77,12 @@ export const ExemplarControllerModel = types const chosen = _.sample(self.documentsStore?.invisibleExemplarDocuments); if (chosen) { self.setExemplarVisibility(chosen.key, true); + logExemplarDocumentEvent(LogEventName.EXEMPLAR_VISIBILITY_UPDATE, + { document: chosen, + visibleToUser: true, + changeSource: "rule", + rule: "3 drawing actions" + }); } self.drawingActions = 0; } From df64392cfd317b0ef995c2d6a1cce8e95df191c5 Mon Sep 17 00:00:00 2001 From: Boris Goldowsky Date: Tue, 9 Apr 2024 17:08:15 -0400 Subject: [PATCH 10/13] Add a more complex rule. Store more state; abstract the idea of a rule and make a file for them. --- .../document_tests/exemplar_test_spec.js | 77 +++++++++++- src/components/tiles/text/text-tile.tsx | 9 +- .../stores/exemplar-controller-rules.ts | 67 +++++++++++ src/models/stores/exemplar-controller.ts | 112 +++++++++++++++--- src/models/tiles/text/text-content.ts | 10 +- src/utilities/string-utils.ts | 5 + 6 files changed, 256 insertions(+), 24 deletions(-) create mode 100644 src/models/stores/exemplar-controller-rules.ts diff --git a/cypress/e2e/functional/document_tests/exemplar_test_spec.js b/cypress/e2e/functional/document_tests/exemplar_test_spec.js index f082bf2198..04ec515df1 100644 --- a/cypress/e2e/functional/document_tests/exemplar_test_spec.js +++ b/cypress/e2e/functional/document_tests/exemplar_test_spec.js @@ -1,10 +1,12 @@ import SortedWork from "../../../support/elements/common/SortedWork"; import ClueCanvas from '../../../support/elements/common/cCanvas'; import DrawToolTile from '../../../support/elements/tile/DrawToolTile'; +import TextToolTile from "../../../support/elements/tile/TextToolTile"; let sortWork = new SortedWork, clueCanvas = new ClueCanvas, - drawToolTile = new DrawToolTile; + drawToolTile = new DrawToolTile, + textToolTile = new TextToolTile; // This unit has `initiallyHideExemplars` set, and an exemplar defined in curriculum const queryParams1 = `${Cypress.config("qaConfigSubtabsUnitStudent5")}`; @@ -17,26 +19,91 @@ function beforeTest(params) { } function drawSmallRectangle(x, y) { - drawToolTile.getDrawToolRectangle().click(); - drawToolTile.getDrawTile() + drawToolTile.getDrawToolRectangle().last().click(); + drawToolTile.getDrawTile().last() .trigger("mousedown", x, y) .trigger("mousemove", x+25, y+25) .trigger("mouseup", x+25, y+25); } +function addText(x, y, text) { + drawToolTile.getDrawToolText().last().click(); + drawToolTile.getDrawTile().last() + .trigger("mousedown", x, y) + .trigger("mouseup", x, y); + drawToolTile.getDrawTile().last() + .trigger("mousedown", x, y) + .trigger("mouseup", x, y); + drawToolTile.getTextDrawing().get('textarea').type(text + "{enter}"); +} + context('Exemplar Documents', function () { - it('Unit with exemplars hidden initially, revealed by drawing actions', function () { + it('Unit with exemplars hidden initially, revealed 3 drawings and 3 text tiles', function () { beforeTest(queryParams1); cy.openTopTab('sort-work'); sortWork.checkDocumentInGroup("No Group", exemplarName); sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("have.class", "private"); - cy.log("Create drawing tile and perform 3 events to reveal exemplar"); + cy.log("Create 3 drawing tiles with 3 events"); + clueCanvas.addTile("drawing"); + drawSmallRectangle(100, 50); + drawSmallRectangle(200, 50); + drawSmallRectangle(300, 50); + clueCanvas.addTile("drawing"); drawSmallRectangle(100, 50); drawSmallRectangle(200, 50); drawSmallRectangle(300, 50); + clueCanvas.addTile("drawing"); + drawSmallRectangle(100, 50); + drawSmallRectangle(200, 50); + drawSmallRectangle(300, 50); + + cy.log("Create 3 text tiles and put 10 words in them"); + clueCanvas.addTile("text"); + textToolTile.enterText("one two three four five six seven eight nine ten"); + + clueCanvas.addTile("text"); + textToolTile.enterText("one two three four five six seven eight nine ten"); + + clueCanvas.addTile("text"); + textToolTile.enterText("one two three four five six seven eight nine"); + drawToolTile.getDrawTile().eq(0).click(); // text is saved in onBlur + // Still private? + sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("have.class", "private"); + textToolTile.enterText(" ten"); + drawToolTile.getDrawTile().eq(0).click(); + + // Now the exemplar should be revealed + sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("not.have.class", "private"); + }); + + it.only('Exemplar revealed by 3 drawings that include labels', function () { + beforeTest(queryParams1); + cy.openTopTab('sort-work'); + sortWork.checkDocumentInGroup("No Group", exemplarName); + sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("have.class", "private"); + + cy.log("Create 3 drawing tiles with 3 events and a label"); + clueCanvas.addTile("drawing"); + drawSmallRectangle(100, 50); + drawSmallRectangle(200, 50); + addText(300, 50, "one two three four five six seven eight nine ten"); + + clueCanvas.addTile("drawing"); + drawSmallRectangle(100, 50); + drawSmallRectangle(200, 50); + addText(300, 50, "one two three four five six seven eight nine ten"); + + clueCanvas.addTile("drawing"); + drawSmallRectangle(100, 50); + drawSmallRectangle(200, 50); + + // Still private? + sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("have.class", "private"); + addText(300, 50, "one two three four five six seven eight nine ten"); + // Now the exemplar should be revealed sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("not.have.class", "private"); }); }); diff --git a/src/components/tiles/text/text-tile.tsx b/src/components/tiles/text/text-tile.tsx index 2249165fb7..bf3ed4600d 100644 --- a/src/components/tiles/text/text-tile.tsx +++ b/src/components/tiles/text/text-tile.tsx @@ -16,6 +16,7 @@ import { createTextPluginInstances, ITextPlugin } from "../../../models/tiles/te import { LogEventName } from "../../../lib/logger-types"; import { TextPluginsContext } from "./text-plugins-context"; import { TileToolbar } from "../../toolbar/tile-toolbar"; +import { countWords } from "../../../utilities/string-utils"; import "./toolbar/text-toolbar-registration"; import "./text-tile.sass"; @@ -250,8 +251,14 @@ export default class TextToolComponent extends BaseComponent // If the text has changed since the editor was focused, log the new text. const text = this.getContent().text; if (text !== this.textOnFocus) { + const plainText = this.getContent().asPlainText(); + const wordCount = countWords(plainText); const change = {args:[{ text }]}; - logTileChangeEvent(LogEventName.TEXT_TOOL_CHANGE, { operation: 'update', change, tileId: this.props.model.id }); + logTileChangeEvent(LogEventName.TEXT_TOOL_CHANGE, { + operation: 'update', + change, + wordCount, + tileId: this.props.model.id }); } this.setState({ revision: this.state.revision + 1 }); // Force a rerender }; diff --git a/src/models/stores/exemplar-controller-rules.ts b/src/models/stores/exemplar-controller-rules.ts new file mode 100644 index 0000000000..6fd383cdbf --- /dev/null +++ b/src/models/stores/exemplar-controller-rules.ts @@ -0,0 +1,67 @@ +import { detach } from "@concord-consortium/mobx-state-tree"; +import { kDrawingTileType } from "../../plugins/drawing/model/drawing-types"; +import { kTextTileType } from "../tiles/text/text-content"; +import { BaseExemplarControllerModelType } from "./exemplar-controller"; + +// Rules for the ExemplarController are specified as: +// - A name +// - A "test" function that is run over the controller's state. +// If the rule matches it should return a list of tiles that satisfy the rule +// If it does not match, it should return false. +// - A "reset" function that updates the controller's state after +// the rule has matched. This typically involves transferring the involved +// tile records to the 'complete' list. + +interface IExemplarControllerRule { + name: string; + test: (model: BaseExemplarControllerModelType) => string[] | false; + reset: (model: BaseExemplarControllerModelType, tiles: string[]) => void; +} + +// "3 drawings/3 labels" Rule: reveal an exemplar for each: +// 3 drawing tiles, each with at least 3 actions AND +// 3 labels with least 10 words each, where a label can be a text tile or a text object in a drawing. + +const kDrawingMinActivityLevel = 3; +const kLabelMinWords = 10; + +const threeDrawingsRule: IExemplarControllerRule = { + name: "3 drawings/3 labels", + test: (model: BaseExemplarControllerModelType) => { + let foundDrawings = 0, foundLabels = 0; + const tileIds: string[] = []; + for (const [key, tile] of model.inProgressTiles.entries()) { + if (tile.type === kTextTileType && tile.wordCount >= kLabelMinWords && foundLabels < 3) { + foundLabels++; + tileIds.push(key); + } + if (tile.type === kDrawingTileType) { + if (tile.activityLevel >= kDrawingMinActivityLevel && foundDrawings < 3) { + foundDrawings++; + tileIds.push(key); + } + if (tile.wordCount >= kLabelMinWords && foundLabels < 3) { + foundLabels++; + if (!tileIds.includes(key)) tileIds.push(key); + } + } + + if (foundDrawings >= 3 && foundLabels >= 3) { + return tileIds; + } + } + return false; + }, + reset: (model: BaseExemplarControllerModelType, tiles: string[]) => { + // Move tiles into the "complete" list. + for (const key of tiles) { + const tile = model.inProgressTiles.get(key); + if (tile) { + detach(tile); + model.completeTiles.put(tile); + } + } + } +}; + +export const allExemplarControllerRules = [ threeDrawingsRule ]; diff --git a/src/models/stores/exemplar-controller.ts b/src/models/stores/exemplar-controller.ts index 01daab7429..11aa2c841b 100644 --- a/src/models/stores/exemplar-controller.ts +++ b/src/models/stores/exemplar-controller.ts @@ -6,15 +6,35 @@ import { safeJsonParse } from "../../utilities/js-utils"; import { LogEventName } from "../../lib/logger-types"; import { DocumentsModelType } from "./documents"; import { LogMessage } from "../../lib/logger"; +import { allExemplarControllerRules } from "./exemplar-controller-rules"; +import { kDrawingTileType } from "../../plugins/drawing/model/drawing-types"; +import { kTextTileType } from "../tiles/text/text-content"; +import { countWords } from "../../utilities/string-utils"; + +/** + * Information that the exemplar controller stores about specific tiles. + * At the moment this includes two numbers representing the level of activity, and the + * length of text in the tile. So far we are only tracking Text and Drawing tiles. + */ +export const InProgressTileModel = types + .model("InProgressTile", { + id: types.identifier, + type: types.string, + activityLevel: 0, + wordCount: 0 + }); // The database structure of this object is expected to change frequently as we develop // this feature, so keep an explicit version number to make migrations easier. const kExemplarControllerStateVersion = "1"; -export const ExemplarControllerModel = types - .model("ExemplarController", { +export const BaseExemplarControllerModel = types + .model("BaseExemplarController", { version: types.optional(types.literal(kExemplarControllerStateVersion), kExemplarControllerStateVersion), - drawingActions: 0 + // Store separate maps of tiles that have already been "used" to open up + // a new exemplar, and those that have not. + completeTiles: types.map(InProgressTileModel), + inProgressTiles: types.map(InProgressTileModel) }) .volatile((self) => ({ documentsStore: undefined as DocumentsModelType | undefined, @@ -58,19 +78,35 @@ export const ExemplarControllerModel = types self.setExemplarVisibility(key, false); } }, + showRandomExemplar() { + const chosen = _.sample(self.documentsStore?.invisibleExemplarDocuments); + if (chosen) { + self.setExemplarVisibility(chosen.key, true); + } + } + })); + +export const ExemplarControllerModel = BaseExemplarControllerModel + .actions((self) => ({ /** * Makes any appropriate changes to exemplars based on the current state. * This should be called after any state changes that may require action. */ - updateExemplars() { - // At the moment we only have one rule: after 3 drawing actions are recorded, reveal a random exemplar. - if (self.drawingActions >= 3) { - const chosen = _.sample(self.documentsStore?.invisibleExemplarDocuments); - if (chosen) { - self.setExemplarVisibility(chosen.key, true); + runAllRules() { + for (const rule of allExemplarControllerRules) { + const result = rule.test(self); + if (result) { + self.showRandomExemplar(); + rule.reset(self, result); } - self.drawingActions = 0; } + }, + getOrCreateInProgressTile(id: string, type: string) { + let tile = self.inProgressTiles.get(id); + if (!tile) { + tile = self.inProgressTiles.put({ id, type }); + } + return tile; } })) .actions((self) => ({ @@ -80,19 +116,61 @@ export const ExemplarControllerModel = types */ processLogMessage(logMessage: LogMessage) { let needsUpdate = false; - // At the moment we are only looking at one type of event: adding an object in a Draw tile. + + // Text tiles + if (logMessage.event === LogEventName[LogEventName.TEXT_TOOL_CHANGE]) { + // For text tiles, track the current number of words in the tile. + const tileId = logMessage.parameters.tileId; + if (self.completeTiles.has(tileId)) { + // Don't need to update status on tiles that are 'completed' + return; + } + const operation = logMessage.parameters?.operation; + if (_.isString(operation) && operation === "update") { + const tile = self.getOrCreateInProgressTile(tileId, kTextTileType); + const wordCount = logMessage.parameters?.wordCount; + if (wordCount && wordCount !== tile.wordCount) { + tile.wordCount = wordCount; + needsUpdate = true; + } + } + } + if (logMessage.event === LogEventName[LogEventName.DRAWING_TOOL_CHANGE]) { + // For drawing tile, "activityLevel" counts the number of objects that the user has added. + // WordCount tracks the content of the longest 'text' object in the drawing. + const tileId = logMessage.parameters.tileId; + if (self.completeTiles.has(tileId)) { + return; + } const operation = logMessage.parameters?.operation; - if (typeof operation === "string" - && ["addObject", "addAndSelectObject", "duplicateObjects"].includes(operation)) { - self.drawingActions ++; - needsUpdate = true; + if (_.isString(operation) + && ["setText", "addObject", "addAndSelectObject", "duplicateObjects"].includes(operation)) { + const tile = self.getOrCreateInProgressTile(tileId, kDrawingTileType); + + if (operation === "setText") { + const args = logMessage.parameters?.args; + if (args && _.isArray(args) && args.length > 0) { + const words = countWords(args[0]); + if (words > tile.wordCount) { + tile.wordCount = words; + needsUpdate = true; + } + } + } else { + // Add or duplicate object operation + tile.activityLevel ++; + needsUpdate = true; + } } } + if (needsUpdate) { - self.updateExemplars(); + self.runAllRules(); } } -})); + })); + +export type BaseExemplarControllerModelType = typeof BaseExemplarControllerModel.Type; export type ExemplarControllerModelType = typeof ExemplarControllerModel.Type; diff --git a/src/models/tiles/text/text-content.ts b/src/models/tiles/text/text-content.ts index f6afc3d966..77642babfd 100644 --- a/src/models/tiles/text/text-content.ts +++ b/src/models/tiles/text/text-content.ts @@ -1,6 +1,6 @@ import { types, Instance, SnapshotIn } from "mobx-state-tree"; import { - convertDocument, CustomEditor, Editor, EditorValue, htmlToSlate, serializeValue, slateToHtml, textToSlate + convertDocument, CustomEditor, Editor, EditorValue, htmlToSlate, serializeValue, slateToHtml, textToSlate, slateToText } from "@concord-consortium/slate-editor"; import { ITileExportOptions } from "../tile-content-info"; import { TileContentModel } from "../tile-content"; @@ -81,6 +81,14 @@ export const TextContentModel = TileContentModel default: return textToSlate(self.joinText); } + }, + asPlainText(): string { + if (self.format) { + return slateToText(this.asSlate()); + } else { + // Undefined format means it's plain text already + return self.joinText; + } } })) .views(self => ({ diff --git a/src/utilities/string-utils.ts b/src/utilities/string-utils.ts index 4040759eb3..7db955c285 100644 --- a/src/utilities/string-utils.ts +++ b/src/utilities/string-utils.ts @@ -2,3 +2,8 @@ export const escapeBackslashes = (text: string) => text.replaceAll(`\\`, `\\\\`) export const escapeDoubleQuotes = (text: string) => text.replaceAll(`"`, `\\"`); export const removeNewlines = (text: string) => text.replace(/\r?\n|\r/g, ""); export const removeTabs = (text: string) => text.replace(/\t/g, ""); + +export const countWords = (text: string) => { + const matches = text.match(/\b\w+\b/g); // transitions from non-word to word character. + return matches ? matches.length : 0; +}; From b232ab735363b17f8e42437c65af7e1ae8226763 Mon Sep 17 00:00:00 2001 From: Boris Goldowsky Date: Wed, 10 Apr 2024 09:37:01 -0400 Subject: [PATCH 11/13] Register log listeners; cleanups. --- .../document_tests/exemplar_test_spec.js | 2 +- src/components/tiles/text/text-tile.tsx | 1 + src/lib/logger.ts | 12 +++- .../stores/exemplar-controller-rules.ts | 10 +--- src/models/stores/exemplar-controller.ts | 56 ++++++++++++------- 5 files changed, 49 insertions(+), 32 deletions(-) diff --git a/cypress/e2e/functional/document_tests/exemplar_test_spec.js b/cypress/e2e/functional/document_tests/exemplar_test_spec.js index 04ec515df1..8bc2ee1815 100644 --- a/cypress/e2e/functional/document_tests/exemplar_test_spec.js +++ b/cypress/e2e/functional/document_tests/exemplar_test_spec.js @@ -79,7 +79,7 @@ context('Exemplar Documents', function () { sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("not.have.class", "private"); }); - it.only('Exemplar revealed by 3 drawings that include labels', function () { + it('Exemplar revealed by 3 drawings that include labels', function () { beforeTest(queryParams1); cy.openTopTab('sort-work'); sortWork.checkDocumentInGroup("No Group", exemplarName); diff --git a/src/components/tiles/text/text-tile.tsx b/src/components/tiles/text/text-tile.tsx index bf3ed4600d..31ecc637ff 100644 --- a/src/components/tiles/text/text-tile.tsx +++ b/src/components/tiles/text/text-tile.tsx @@ -257,6 +257,7 @@ export default class TextToolComponent extends BaseComponent logTileChangeEvent(LogEventName.TEXT_TOOL_CHANGE, { operation: 'update', change, + plainText, wordCount, tileId: this.props.model.id }); } diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 65b35c9c31..e2800fd1ad 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -55,6 +55,8 @@ interface PendingMessage { method?: LogEventMethod; } +type ILogListener = (logMessage: LogMessage) => void; + export class Logger { public static isLoggingEnabled = false; private static _instance: Logger; @@ -108,6 +110,7 @@ export class Logger { private stores: IStores; private appContext: Record = {}; private session: string; + private logListeners: ILogListener[] = []; private constructor(stores: IStores, appContext = {}) { this.stores = stores; @@ -115,13 +118,18 @@ export class Logger { this.session = uuid(); } + public registerLogListener(listener: ILogListener) { + this.logListeners.push(listener); + } + private formatAndSend(time: number, event: LogEventName, parameters?: Record, method?: LogEventMethod) { const eventString = LogEventName[event]; const logMessage = this.createLogMessage(time, eventString, parameters, method); sendToLoggingService(logMessage, this.stores.user); - // Log messages are also shared with the ExemplarController - this.stores.exemplarController.processLogMessage(logMessage); + for (const listener of this.logListeners) { + listener(logMessage); + } } private createLogMessage( diff --git a/src/models/stores/exemplar-controller-rules.ts b/src/models/stores/exemplar-controller-rules.ts index 6fd383cdbf..57d14342aa 100644 --- a/src/models/stores/exemplar-controller-rules.ts +++ b/src/models/stores/exemplar-controller-rules.ts @@ -1,4 +1,3 @@ -import { detach } from "@concord-consortium/mobx-state-tree"; import { kDrawingTileType } from "../../plugins/drawing/model/drawing-types"; import { kTextTileType } from "../tiles/text/text-content"; import { BaseExemplarControllerModelType } from "./exemplar-controller"; @@ -53,14 +52,7 @@ const threeDrawingsRule: IExemplarControllerRule = { return false; }, reset: (model: BaseExemplarControllerModelType, tiles: string[]) => { - // Move tiles into the "complete" list. - for (const key of tiles) { - const tile = model.inProgressTiles.get(key); - if (tile) { - detach(tile); - model.completeTiles.put(tile); - } - } + model.markTilesComplete(tiles); } }; diff --git a/src/models/stores/exemplar-controller.ts b/src/models/stores/exemplar-controller.ts index 11aa2c841b..45570635bd 100644 --- a/src/models/stores/exemplar-controller.ts +++ b/src/models/stores/exemplar-controller.ts @@ -1,11 +1,11 @@ -import { applySnapshot, types, onSnapshot } from "mobx-state-tree"; +import { applySnapshot, types, onSnapshot, detach } from "mobx-state-tree"; import _ from "lodash"; import { UserModelType } from "./user"; import { DB } from "../../lib/db"; import { safeJsonParse } from "../../utilities/js-utils"; import { LogEventName } from "../../lib/logger-types"; import { DocumentsModelType } from "./documents"; -import { LogMessage } from "../../lib/logger"; +import { Logger, LogMessage } from "../../lib/logger"; import { allExemplarControllerRules } from "./exemplar-controller-rules"; import { kDrawingTileType } from "../../plugins/drawing/model/drawing-types"; import { kTextTileType } from "../tiles/text/text-content"; @@ -42,24 +42,6 @@ export const BaseExemplarControllerModel = types firebasePath: undefined as string|undefined })) .actions((self) => ({ - async initialize(user: UserModelType, db: DB) { - self.db = db; - self.documentsStore = db.stores.documents; - self.firebasePath = db.firebase.getUserExemplarsPath(user); - const statePath = db.firebase.getExemplarStatePath(user); - const stateRef = db.firebase.ref(statePath); - const stateVal = (await stateRef.once("value"))?.val(); - const state = safeJsonParse(stateVal); - if (state) { - applySnapshot(self, state); - } - - onSnapshot(self, (snapshot)=>{ - const snapshotStr = JSON.stringify(snapshot); - const updateRef = db.firebase.ref(statePath); - updateRef.set(snapshotStr); - }); - }, /** * Writes to the database to indicate whether the current user has access to the given exemplar. */ @@ -83,6 +65,19 @@ export const BaseExemplarControllerModel = types if (chosen) { self.setExemplarVisibility(chosen.key, true); } + }, + /** + * Moves our records of the tiles from the 'inProgress' map to the 'complete' map. + * @param keys + */ + markTilesComplete(keys: string[]) { + for (const key of keys) { + const tile = self.inProgressTiles.get(key); + if (tile) { + detach(tile); + self.completeTiles.put(tile); + } + } } })); @@ -169,6 +164,27 @@ export const ExemplarControllerModel = BaseExemplarControllerModel self.runAllRules(); } } + })) + .actions(self => ({ + async initialize(user: UserModelType, db: DB) { + self.db = db; + self.documentsStore = db.stores.documents; + self.firebasePath = db.firebase.getUserExemplarsPath(user); + const statePath = db.firebase.getExemplarStatePath(user); + const stateRef = db.firebase.ref(statePath); + const stateVal = (await stateRef.once("value"))?.val(); + const state = safeJsonParse(stateVal); + if (state) { + applySnapshot(self, state); + } + Logger.Instance.registerLogListener(self.processLogMessage); + + onSnapshot(self, (snapshot)=>{ + const snapshotStr = JSON.stringify(snapshot); + const updateRef = db.firebase.ref(statePath); + updateRef.set(snapshotStr); + }); + } })); export type BaseExemplarControllerModelType = typeof BaseExemplarControllerModel.Type; From 3b2609b4d743cc96ed37dc297853a5d3e8ff0ac2 Mon Sep 17 00:00:00 2001 From: Boris Goldowsky Date: Wed, 10 Apr 2024 09:51:01 -0400 Subject: [PATCH 12/13] Fix merge issues --- .../document_tests/exemplar_test_spec.js | 6 ------ src/models/stores/exemplar-controller.ts | 17 +++++++++++------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/cypress/e2e/functional/document_tests/exemplar_test_spec.js b/cypress/e2e/functional/document_tests/exemplar_test_spec.js index 291b97db04..ec2f0d12fb 100644 --- a/cypress/e2e/functional/document_tests/exemplar_test_spec.js +++ b/cypress/e2e/functional/document_tests/exemplar_test_spec.js @@ -121,11 +121,5 @@ context('Exemplar Documents', function () { clueCanvas.getStickyNotePopup().should("exist").should("be.visible") .should("contain.text", "Nice work, you can now see a new example for this lesson") .should("contain.text", exemplarName); - - cy.log("Open exemplar"); - sortWork.getFocusDocument().should('not.exist'); - clueCanvas.getStickyNoteLink().should("be.visible").click(); - sortWork.getFocusDocument().should('be.visible'); - sortWork.getFocusDocumentTitle().should("contain.text", exemplarName); }); }); diff --git a/src/models/stores/exemplar-controller.ts b/src/models/stores/exemplar-controller.ts index 384545b1ed..60fea107a1 100644 --- a/src/models/stores/exemplar-controller.ts +++ b/src/models/stores/exemplar-controller.ts @@ -74,6 +74,7 @@ export const BaseExemplarControllerModel = types if (chosen) { self.setExemplarVisibility(chosen.key, true); } + return chosen; }, /** * Moves our records of the tiles from the 'inProgress' map to the 'complete' map. @@ -102,12 +103,16 @@ export const ExemplarControllerModel = BaseExemplarControllerModel if (result) { const chosen = self.showRandomExemplar(); rule.reset(self, result); - logExemplarDocumentEvent(LogEventName.EXEMPLAR_VISIBILITY_UPDATE, - { document: chosen, - visibleToUser: true, - changeSource: "rule", - rule: rule.name - }); } + if (chosen) { + logExemplarDocumentEvent(LogEventName.EXEMPLAR_VISIBILITY_UPDATE, + { + document: chosen, + visibleToUser: true, + changeSource: "rule", + rule: rule.name + }); + } + } } }, getOrCreateInProgressTile(id: string, type: string) { From d2267987f4bfe74822fad724291112633bfb3787 Mon Sep 17 00:00:00 2001 From: Boris Goldowsky Date: Tue, 16 Apr 2024 20:01:56 -0400 Subject: [PATCH 13/13] Doc fix --- cypress/e2e/functional/document_tests/exemplar_test_spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/e2e/functional/document_tests/exemplar_test_spec.js b/cypress/e2e/functional/document_tests/exemplar_test_spec.js index ec2f0d12fb..f9dfce25c5 100644 --- a/cypress/e2e/functional/document_tests/exemplar_test_spec.js +++ b/cypress/e2e/functional/document_tests/exemplar_test_spec.js @@ -8,7 +8,7 @@ let sortWork = new SortedWork, drawToolTile = new DrawToolTile, textToolTile = new TextToolTile; -// This unit has `initiallyHideExemplars` set, and an exemplar defined in curriculum +// The qaConfigSubtabs unit referenced here has `initiallyHideExemplars` set, and an exemplar defined in curriculum const queryParams1 = `${Cypress.config("qaConfigSubtabsUnitStudent5")}`; const exemplarName = "First Exemplar"; const exemplarInfo = "Ivan Idea: First Exemplar";