From 3420b631c152748da7ef764dbd31097ca1cbe260 Mon Sep 17 00:00:00 2001 From: Duane Nykamp Date: Fri, 3 Mar 2023 14:43:28 -0600 Subject: [PATCH] navigateToTarget action on ref tag (#1961) Resolves #1835 --- .../multipageActivities.cy.js | 82 ++++++++++- cypress/e2e/DoenetML/tagSpecific/ref.cy.js | 89 ++++++++++++ src/Core/Core.js | 9 ++ src/Core/components/Ref.js | 35 +++++ src/Viewer/PageViewer.jsx | 136 +++++++++++++++++- src/Viewer/renderers/ref.jsx | 77 ++-------- 6 files changed, 356 insertions(+), 72 deletions(-) diff --git a/cypress/e2e/AssignedActivity/multipageActivities.cy.js b/cypress/e2e/AssignedActivity/multipageActivities.cy.js index c454419c38..3ad123f650 100644 --- a/cypress/e2e/AssignedActivity/multipageActivities.cy.js +++ b/cypress/e2e/AssignedActivity/multipageActivities.cy.js @@ -184,7 +184,7 @@ describe('Multipage activity tests', function () { cy.get('[data-test="View Activity"]').click(); cy.get('#page1\\/top').should('contain.text', 'top 1') - cy.get('#page2\\/top').should('contain.text', 'top 2') + cy.get('#page2\\/top').should('contain.text', 'top 2') cy.url().should('match', /#page1$/) @@ -2350,7 +2350,7 @@ describe('Multipage activity tests', function () { cy.get('.navigationRow').eq(0).find('.navigationColumn1').click(); cy.get('[data-test="Assign Activity"]').click(); cy.wait(1500); // wait for update - + cy.get('[data-test="RoleDropDown"] > div:nth-child(2)').click().type("{downArrow}{downArrow}{enter}") @@ -2559,5 +2559,83 @@ describe('Multipage activity tests', function () { }) + it('Change pages with navigateToTarget action and choiceinput', () => { + const doenetML1 = ` + + + + + + +

Page 1

+ + + Page 1 + Page 2 + Page 3 + + + + + + ` + + const doenetML2 = `Page 2` + const doenetML3 = `Page 3` + + cy.createMultipageActivity({ courseId, doenetId, parentDoenetId: courseId, pageDoenetId1, pageDoenetId2, pageDoenetId3, doenetML1, doenetML2, doenetML3 }); + + cy.visit(`http://localhost/course?tool=navigation&courseId=${courseId}`) + + cy.get('.navigationRow').should('have.length', 1); //Need this to wait for the row to appear + cy.get('.navigationRow').eq(0).get('.navigationColumn1').click(); + + cy.get('[data-test="Assign Activity"]').click(); + cy.get('[data-test="Unassign Activity"]').should('be.visible') + + cy.get('[data-test="View Assigned Activity"]').click(); + + cy.get('#page1').should('contain.text', 'Page 1') + + cy.url().should('match', /#page1$/) + + + cy.get(`#page1\\/moveToPage`).select('2'); + + cy.get('#page2').should('contain.text', 'Page 2') + + cy.url().should('match', /#page2$/) + + + cy.get('[data-test=previous]').click(); + + cy.get('#page1').should('contain.text', 'Page 1') + + cy.url().should('match', /#page1$/) + + + cy.get(`#page1\\/moveToPage`).select('3'); + + cy.get('#page3').should('contain.text', 'Page 3') + + cy.url().should('match', /#page3$/) + + + cy.get('[data-test=previous]').click(); + + cy.get('#page2').should('contain.text', 'Page 2') + + cy.url().should('match', /#page2$/) + + + cy.get('[data-test=previous]').click(); + + cy.get('#page1').should('contain.text', 'Page 1') + + cy.url().should('match', /#page1$/) + + + + }) }) \ No newline at end of file diff --git a/cypress/e2e/DoenetML/tagSpecific/ref.cy.js b/cypress/e2e/DoenetML/tagSpecific/ref.cy.js index 335c1ab3cd..5813f86276 100644 --- a/cypress/e2e/DoenetML/tagSpecific/ref.cy.js +++ b/cypress/e2e/DoenetML/tagSpecific/ref.cy.js @@ -295,5 +295,94 @@ describe('ref Tag Tests', function () { }); + it('navigate to target action opens aside', () => { + cy.window().then(async (win) => { + win.postMessage({ + doenetML: ` + Aside +

+ +

+ + + + + + + `}, "*"); + }); + + // to wait for page to load + cy.get('#\\/asideTitle').should('have.text', 'The aside') + + cy.log('Aside closed at the beginning') + cy.get('#\\/inside').should('not.exist') + cy.window().then(async (win) => { + let stateVariables = await win.returnAllStateVariables1(); + expect(stateVariables["/aside"].stateValues.open).eq(false); + }) + + cy.log('clicking action opens aside') + cy.get('#\\/go').click(); + + cy.get('#\\/inside').should('have.text', "Inside the aside"); + cy.window().then(async (win) => { + let stateVariables = await win.returnAllStateVariables1(); + expect(stateVariables["/aside"].stateValues.open).eq(true); + }) + + }); + + it('chain action to navigate to target', () => { + cy.window().then(async (win) => { + win.postMessage({ + doenetML: ` + + + + + + + + + + Aside +

+ +

+ + + + + + + + `}, "*"); + }); + + // to wait for page to load + cy.get('#\\/asideTitle').should('have.text', 'Counting') + + cy.log('Aside closed at the beginning') + cy.get('#\\/n').should('not.exist') + + cy.log('clicking action opens aside and starts counting') + cy.get('#\\/startCount').click(); + + cy.get('#\\/n').should('have.text', '1'); + cy.get('#\\/n').should('have.text', '2'); + cy.get('#\\/n').should('have.text', '3'); + cy.get('#\\/n').should('have.text', '4'); + cy.get('#\\/n').should('have.text', '5'); + + }); + + }); \ No newline at end of file diff --git a/src/Core/Core.js b/src/Core/Core.js index 2278fbabc4..4fe224f9cd 100644 --- a/src/Core/Core.js +++ b/src/Core/Core.js @@ -74,6 +74,7 @@ export default class Core { recordSolutionView: this.recordSolutionView.bind(this), requestComponentDoenetML: this.requestComponentDoenetML.bind(this), copyToClipboard: this.copyToClipboard.bind(this), + navigateToTarget: this.navigateToTarget.bind(this), } this.updateInfo = { @@ -10445,6 +10446,14 @@ export default class Core { }) } } + + navigateToTarget(args) { + postMessage({ + messageType: "navigateToTarget", + coreId: this.coreId, + args + }) + } } diff --git a/src/Core/components/Ref.js b/src/Core/components/Ref.js index 3f9ae324ac..9c0116511b 100644 --- a/src/Core/components/Ref.js +++ b/src/Core/components/Ref.js @@ -1,6 +1,14 @@ import InlineComponent from './abstract/InlineComponent'; export default class Ref extends InlineComponent { + constructor(args) { + super(args); + + Object.assign(this.actions, { + navigateToTarget: this.navigateToTarget.bind(this) + }); + + } static componentType = "ref"; static renderChildren = true; @@ -292,4 +300,31 @@ export default class Ref extends InlineComponent { } + async navigateToTarget({ actionId }) { + + if (await this.stateValues.disabled) { + this.coreFunctions.resolveAction({ actionId }); + } else { + let cid = await this.stateValues.cid; + let doenetId = await this.stateValues.doenetId; + let variantIndex = await this.stateValues.variantIndex; + let edit = await this.stateValues.edit; + let hash = await this.stateValues.hash; + let page = await this.stateValues.page; + let uri = await this.stateValues.uri; + let targetName = await this.stateValues.targetName; + + let effectiveName = this.componentOrAdaptedName + + + this.coreFunctions.navigateToTarget({ + cid, doenetId, variantIndex, edit, hash, page, uri, targetName, + actionId, componentName: this.componentName, effectiveName + }) + } + + + } + + } \ No newline at end of file diff --git a/src/Viewer/PageViewer.jsx b/src/Viewer/PageViewer.jsx index fda1aa5411..7c76698fc0 100644 --- a/src/Viewer/PageViewer.jsx +++ b/src/Viewer/PageViewer.jsx @@ -5,13 +5,16 @@ import { serializedComponentsReplacer, serializedComponentsReviver } from '../Co import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; import { rendererState } from './renderers/useDoenetRenderer'; -import { atom, atomFamily, useRecoilCallback } from 'recoil'; +import { atom, atomFamily, useRecoilCallback, useRecoilValue } from 'recoil'; import { get as idb_get, set as idb_set } from 'idb-keyval'; import { cidFromText } from '../Core/utils/cid'; import { retrieveTextFileForCid } from '../Core/utils/retrieveTextFile'; import axios from 'axios'; import { returnAllPossibleVariants } from '../Core/utils/returnAllPossibleVariants'; -import { useLocation } from "react-router"; +import { useLocation, useNavigate } from "react-router"; +import { pageToolViewAtom } from '../Tools/_framework/NewToolRoot'; +import { itemByDoenetId } from '../_reactComponents/Course/CourseActions'; + import cssesc from 'cssesc'; const rendererUpdatesToIgnore = atomFamily({ @@ -140,6 +143,13 @@ export default function PageViewer(props) { const previousLocationKeys = useRef([]); + const pageToolView = useRecoilValue(pageToolViewAtom); + const itemInCourse = useRecoilValue(itemByDoenetId(props.doenetId)); + const scrollableContainer = useRecoilValue(scrollableContainerAtom); + + let navigate = useNavigate(); + + let location = useLocation(); let hash = location.hash; @@ -194,6 +204,8 @@ export default function PageViewer(props) { resetPage(e.data.args); } else if (e.data.messageType === "copyToClipboard") { copyToClipboard(e.data.args); + } else if (e.data.messageType === "navigateToTarget") { + navigateToTarget(e.data.args); } else if (e.data.messageType === "terminated") { terminateCoreAndAnimations(); } @@ -890,6 +902,44 @@ export default function PageViewer(props) { } + async function navigateToTarget({ cid, doenetId, variantIndex, edit, hash, page, uri, targetName, actionId, componentName, effectiveName }) { + + let id = prefixForIds + effectiveName; + let { targetForATag, url, haveValidTarget, externalUri } = getURLFromRef({ + cid, doenetId, variantIndex, edit, hash, page, + givenUri: uri, + targetName, + pageToolView, + inCourse: Object.keys(itemInCourse).length > 0, + search: location.search, + id + }); + + + if (haveValidTarget) { + + if (targetForATag === "_blank") { + window.open(url, targetForATag); + } else { + + // TODO: when fix regular ref navigation to scroll back to previous scroll position + // when click the back button + // add that ability to this navigation as well + + // let scrollAttribute = scrollableContainer === window ? "scrollY" : "scrollTop"; + // let stateObj = { fromLink: true } + // Object.defineProperty(stateObj, 'previousScrollPosition', { get: () => scrollableContainer?.[scrollAttribute], enumerable: true }); + + navigate(url) + } + + + } + + + resolveAction({ actionId }); + } + if (errMsg !== null) { let errorIcon = return
{errorIcon} {errMsg}
@@ -1062,4 +1112,84 @@ class ErrorBoundary extends React.Component { } return this.props.children; } -} \ No newline at end of file +} + + +export function getURLFromRef({ + cid, doenetId, variantIndex, + edit, hash, page, + givenUri, + targetName = "", + pageToolView = {}, + inCourse = false, + search = "", + id = "" +}) { + + let url = ""; + let targetForATag = "_blank"; + let haveValidTarget = false; + let externalUri = false; + if (cid || doenetId) { + if (cid) { + url = `cid=${cid}`; + } else { + url = `doenetId=${doenetId}`; + } + if (variantIndex) { + url += `&variant=${variantIndex}`; + } + + let usePublic = false; + if (pageToolView.page === "public") { + usePublic = true; + } else if (!inCourse) { + usePublic = true; + } + if (usePublic) { + if (edit === true || edit === null && pageToolView.page === "public" && pageToolView.tool === "editor") { + url = `tool=editor&${url}`; + } + url = `/public?${url}`; + } else if (pageToolView.page === "placementexam") { + url = `?tool=exam&${url}`; + } else { + url = `?tool=assignment&${url}`; + } + + haveValidTarget = true; + + if (hash) { + url += hash; + } else { + if (page) { + url += `#page${page}`; + if (targetName) { + url += targetName; + } + } else if (targetName) { + url += '#' + targetName; + } + } + } else if (givenUri) { + url = givenUri; + if (url.substring(0, 8) === "https://" || url.substring(0, 7) === "http://") { + haveValidTarget = true; + externalUri = true; + } + } else { + url += search; + + if (page) { + url += `#page${page}`; + } else { + let firstSlash = id.indexOf("/"); + let prefix = id.substring(0, firstSlash); + url += "#" + prefix; + } + url += targetName; + targetForATag = null; + haveValidTarget = true; + } + return { targetForATag, url, haveValidTarget, externalUri }; +} diff --git a/src/Viewer/renderers/ref.jsx b/src/Viewer/renderers/ref.jsx index c3b34abe10..d0e19e7749 100644 --- a/src/Viewer/renderers/ref.jsx +++ b/src/Viewer/renderers/ref.jsx @@ -4,7 +4,7 @@ import { Link } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; import { pageToolViewAtom } from '../../Tools/_framework/NewToolRoot'; import { itemByDoenetId } from '../../_reactComponents/Course/CourseActions'; -import { scrollableContainerAtom } from '../PageViewer'; +import { getURLFromRef, scrollableContainerAtom } from '../PageViewer'; import useDoenetRender from './useDoenetRenderer'; import styled from 'styled-components'; @@ -62,72 +62,15 @@ export default React.memo(function Ref(props) { linkContent = SVs.linkText; } - let url = ""; - let targetForATag = "_blank"; - let haveValidTarget = false; - let externalUri = false; - if (SVs.cid || SVs.doenetId) { - if (SVs.cid) { - url = `cid=${SVs.cid}` - } else { - url = `doenetId=${SVs.doenetId}` - } - if (SVs.variantIndex) { - url += `&variant=${SVs.variantIndex}`; - } - - let usePublic = false; - if (pageToolView.page === "public") { - usePublic = true; - } else if (Object.keys(itemInCourse).length === 0) { - usePublic = true; - } - if (usePublic) { - if (SVs.edit === true || SVs.edit === null && pageToolView.page === "public" && pageToolView.tool === "editor") { - url = `tool=editor&${url}`; - } - url = `/public?${url}` - } else if (pageToolView.page === "placementexam") { - url = `?tool=exam&${url}` - } else { - url = `?tool=assignment&${url}` - } - - haveValidTarget = true; - - if (SVs.hash) { - url += SVs.hash; - } else { - if (SVs.page) { - url += `#page${SVs.page}` - if (SVs.targetName) { - url += SVs.targetName; - } - } else if (SVs.targetName) { - url += '#' + SVs.targetName; - } - } - } else if (SVs.uri) { - url = SVs.uri; - if (url.substring(0, 8) === "https://" || url.substring(0, 7) === "http://") { - haveValidTarget = true; - externalUri = true; - } - } else { - url += search; - - if (SVs.page) { - url += `#page${SVs.page}` - } else { - let firstSlash = id.indexOf("/"); - let prefix = id.substring(0, firstSlash); - url += "#" + prefix; - } - url += SVs.targetName; - targetForATag = null; - haveValidTarget = true; - } - + let { targetForATag, url, haveValidTarget, externalUri } = getURLFromRef({ + cid: SVs.cid, doenetId: SVs.doenetId, variantIndex: SVs.variantIndex, + edit: SVs.edit, hash: SVs.hash, page: SVs.page, + givenUri: SVs.uri, + targetName: SVs.targetName, + pageToolView, + inCourse: Object.keys(itemInCourse).length > 0, + search, id + }); if (SVs.createButton) { if (targetForATag === "_blank") {