Skip to content

Commit

Permalink
exemplars on action (#2256)
Browse files Browse the repository at this point in the history
* Basic implementation with volatile state

* Write new visibility to database
Clean ups

* Add cypress test

* Clarify

* Remove console.logs

* Add a more complex rule.
Store more state; abstract the idea of a rule and make a file for them.

* Register log listeners; cleanups.
  • Loading branch information
bgoldowsky authored Apr 16, 2024
1 parent 408eb9b commit 3d07df1
Show file tree
Hide file tree
Showing 14 changed files with 442 additions and 12 deletions.
109 changes: 109 additions & 0 deletions cypress/e2e/functional/document_tests/exemplar_test_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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,
textToolTile = new TextToolTile;

// 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');
cy.visit(params);
cy.waitForLoad();
}

function drawSmallRectangle(x, y) {
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 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 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('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");
});
});
10 changes: 8 additions & 2 deletions cypress/support/elements/common/SortedWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
10 changes: 9 additions & 1 deletion src/components/tiles/text/text-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -250,8 +251,15 @@ export default class TextToolComponent extends BaseComponent<ITileProps, IState>
// 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,
plainText,
wordCount,
tileId: this.props.model.id });
}
this.setState({ revision: this.state.revision + 1 }); // Force a rerender
};
Expand Down
23 changes: 20 additions & 3 deletions src/components/workspace/workspace.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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";
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";

Expand All @@ -16,17 +17,33 @@ interface IProps extends IBaseProps {
export const WorkspaceComponent: React.FC<IProps> = observer((props) => {
const stores = useStores();
const { appConfig: { navTabs: navTabSpecs },
persistentUI: { navTabContentShown, workspaceShown }
persistentUI: { navTabContentShown, workspaceShown },
exemplarController
} = 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(() => {
exemplarController.resetAllExemplars();
}, [exemplarController]);

useEffect(() => {
hotKeys.current.register({
"cmd-shift-e": resetAllExemplars
});

}, [resetAllExemplars]);

const handleDragOverWorkspace = (e: React.DragEvent<HTMLDivElement>) => {
imageDragDrop?.dragOver(e);
};

return (
<div className="workspace">
<div
className="workspace"
onKeyDown={(e) => hotKeys.current.dispatch(e)}>
<div
className="drag-handler"
onDragOver={handleDragOverWorkspace}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/db-listeners/db-exemplars-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};

Expand Down
3 changes: 2 additions & 1 deletion src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export class DB {
this.firebase.setFirebaseUser(firebaseUser);
this.firestore.setFirebaseUser(firebaseUser);
if (!options.dontStartListeners) {
const { persistentUI, user, db, unitLoadedPromise} = this.stores;
const { persistentUI, user, db, unitLoadedPromise, exemplarController} = this.stores;
// Start fetching the persistent UI. We want this to happen as early as possible.
persistentUI.initializePersistentUISync(user, db);

Expand All @@ -163,6 +163,7 @@ export class DB {
// We need those types to be registered so the listeners can safely create documents.
unitLoadedPromise.then(() => {
this.listeners.start().then(resolve).catch(reject);
exemplarController.initialize(user, db);
});
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/lib/firebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
12 changes: 11 additions & 1 deletion src/lib/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const logManagerUrl: Record<LoggerEnvironment, string> = {

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;
Expand Down Expand Up @@ -55,6 +55,8 @@ interface PendingMessage {
method?: LogEventMethod;
}

type ILogListener = (logMessage: LogMessage) => void;

export class Logger {
public static isLoggingEnabled = false;
private static _instance: Logger;
Expand Down Expand Up @@ -108,18 +110,26 @@ export class Logger {
private stores: IStores;
private appContext: Record<string, any> = {};
private session: string;
private logListeners: ILogListener[] = [];

private constructor(stores: IStores, appContext = {}) {
this.stores = stores;
this.appContext = appContext;
this.session = uuid();
}

public registerLogListener(listener: ILogListener) {
this.logListeners.push(listener);
}

private formatAndSend(time: number,
event: LogEventName, parameters?: Record<string, unknown>, method?: LogEventMethod) {
const eventString = LogEventName[event];
const logMessage = this.createLogMessage(time, eventString, parameters, method);
sendToLoggingService(logMessage, this.stores.user);
for (const listener of this.logListeners) {
listener(logMessage);
}
}

private createLogMessage(
Expand Down
11 changes: 9 additions & 2 deletions src/models/stores/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 => ({
Expand Down Expand Up @@ -188,7 +196,6 @@ export const DocumentsModel = types
self.visibleExemplars.delete(exemplarId);
}
}

}))
.actions((self) => {
const add = (document: DocumentModelType) => {
Expand Down
59 changes: 59 additions & 0 deletions src/models/stores/exemplar-controller-rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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[]) => {
model.markTilesComplete(tiles);
}
};

export const allExemplarControllerRules = [ threeDrawingsRule ];
Loading

0 comments on commit 3d07df1

Please sign in to comment.