diff --git a/src/components/app.tsx b/src/components/app.tsx index 6c3ead31..33792d39 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -6,7 +6,7 @@ import { SequenceNav } from "./activity-header/sequence-nav"; import { ActivityPageContent } from "./activity-page/activity-page-content"; import { IntroductionPageContent } from "./activity-introduction/introduction-page-content"; import { Footer } from "./activity-introduction/footer"; -import { ActivityLayouts, PageLayouts, numQuestionsOnPreviousPages, enableReportButton, setDocumentTitle, getPagePositionFromQueryValue, getAllUrlsInActivity, isNotSampleActivityUrl } from "../utilities/activity-utils"; +import { ActivityLayouts, PageLayouts, numQuestionsOnPreviousPages, enableReportButton, setDocumentTitle, getPagePositionFromQueryValue } from "../utilities/activity-utils"; import { getActivityDefinition, getResourceUrl, getSequenceDefinition } from "../lara-api"; import { ThemeButtons } from "./theme-buttons"; import { SinglePageContent } from "./single-page/single-page-content"; @@ -14,7 +14,7 @@ import { WarningBanner } from "./warning-banner"; import { CompletionPageContent } from "./activity-completion/completion-page-content"; import { queryValue, queryValueBoolean } from "../utilities/url-query"; import { IPortalData, firebaseAppName } from "../portal-api"; -import { Activity, IEmbeddablePlugin, OfflineManifest, OfflineManifestActivity, Sequence, ServiceWorkerStatus } from "../types"; +import { Activity, IEmbeddablePlugin, Sequence, ServiceWorkerStatus } from "../types"; import { TrackOfflineResourceUrl, initStorage } from "../storage/storage-facade"; import { initializeLara, LaraGlobalType } from "../lara-plugin/index"; import { LaraGlobalContext } from "./lara-global-context"; @@ -31,11 +31,9 @@ import { Logger, LogEventName } from "../lib/logger"; import { GlossaryPlugin } from "../components/activity-page/plugins/glossary-plugin"; import { IdleDetector } from "../utilities/idle-detector"; import { Workbox } from "workbox-window/index"; -import { getOfflineManifest, getOfflineManifestAuthoringData, getOfflineManifestAuthoringId, OfflineManifestAuthoringData, mergeOfflineManifestWithAuthoringData, saveOfflineManifestToOfflineActivities, setOfflineManifestAuthoringData, setOfflineManifestAuthoringId } from "../offline-manifest-api"; import { OfflineInstalling } from "./offline-installing"; import { OfflineActivities } from "./offline-activities"; import { OfflineNav } from "./offline-nav"; -import { OfflineManifestAuthoringNav } from "./offline-manifest-authoring-nav"; import { DEFAULT_STUDENT_LOGGING_USERNAME, DEFAULT_STUDENT_NAME, StudentInfo } from "../student-info"; import { StudentInfoModal } from "./student-info-modal"; import { isNetworkConnected, monitorNetworkConnection } from "../utilities/network-connection"; @@ -62,7 +60,6 @@ interface IncompleteQuestion { interface IState { activity?: Activity; - offlineManifest?: OfflineManifest; currentPage: number; teacherEditionMode?: boolean; showThemeButtons?: boolean; @@ -80,10 +77,6 @@ interface IState { errorType: null | ErrorType; idle: boolean; offlineMode: boolean; - offlineManifestId?: string; - offlineManifestAuthoringId?: string; - offlineManifestAuthoringActivities: OfflineManifestActivity[]; - offlineManifestAuthoringCacheList: string[]; serviceWorkerStatus: ServiceWorkerStatus; showEditUserName: boolean; networkConnected: boolean; @@ -103,14 +96,6 @@ export class App extends React.PureComponent { const offlineMode = isOfflineHost(); - if (offlineMode) { - // set the offline manifest authoring localstorage item if it exists in the params and then read from localstorage - // this is done in the constructor as the state value is needed in the UNSAFE_componentWillMount method - setOfflineManifestAuthoringId(queryValue("setOfflineManifestAuthoringId")); - } - const offlineManifestAuthoringId = offlineMode ? getOfflineManifestAuthoringId() : undefined; - const offlineManifestId = offlineMode ? queryValue("offlineManifest") : undefined; - this.state = { currentPage: 0, teacherEditionMode: false, @@ -125,10 +110,6 @@ export class App extends React.PureComponent { errorType: null, idle: false, offlineMode, - offlineManifestAuthoringActivities: [], - offlineManifestAuthoringCacheList: [], - offlineManifestAuthoringId, - offlineManifestId, serviceWorkerStatus: "unknown", showEditUserName: false, networkConnected: isNetworkConnected() @@ -150,7 +131,7 @@ export class App extends React.PureComponent { // start monitoring the network connection this.unmonitorNetworkConnection = monitorNetworkConnection((networkConnected) => this.setState({networkConnected})); - // only enable the service worker in offline mode (or in authoring mode which automatically turns on offline mode) + // only enable the service worker in offline mode const enableServiceWorker = this.state.offlineMode; if (enableServiceWorker && ("serviceWorker" in navigator)) { @@ -163,20 +144,6 @@ export class App extends React.PureComponent { console.log("A new service worker has installed."); }); wb.addEventListener("waiting", (event) => { - // TODO: in future work we should show a dialog using this recipe: - // https://developers.google.com/web/tools/workbox/guides/advanced-recipes#offer_a_page_reload_for_users - // For now just send a message to skip waiting so we don't have to do it manually in the devtools - // This will trigger a 'controlling' event which we are listening for below and reload the page when - // we get it - // - // Note: with the current setup this will cause 2 reloads for each change while developing - // first webpack-dev-server will try to hot load the changes, it will find it can't do this - // (I'm not sure why yet), it will reload the page because the hot load failed, this reload - // will trigger a service worker update because each change to the activity-player - // triggers a new service-worker since it includes a pre-cache manifest that includes - // the javascript files. The service worker update will then trigger this - // waiting event which we "skip". The new service worker will activate and start - // controlling the page which triggers thre reload below. wb.messageSkipWaiting(); }); wb.addEventListener("controlling", (event) => { @@ -199,37 +166,6 @@ export class App extends React.PureComponent { console.log("Service worker activated with an update"); } }); - wb.addEventListener("message", (event) => { - const {offlineManifestAuthoringId} = this.state; - switch (event.data.type) { - case "CACHE_UPDATED": - console.log(`A newer version of ${event.data.payload.updatedURL} is available!`); - break; - - case "GET_REQUEST": - if (offlineManifestAuthoringId) { - this.setState((prevState) => { - // TODO: we only allow cors requests, so it would be helpful to authors - // if we checked whether the url can be requested with cors and if not - // we notify the author about the invalid url - - // make sure all models-resources requests use the base folder - const url = event.data.url.replace(/.*models-resources\//, "models-resources/"); - let {offlineManifestAuthoringCacheList} = prevState; - const {offlineManifestAuthoringActivities} = prevState; - if (!/api\/v1\/activities/.test(url) && (offlineManifestAuthoringCacheList.indexOf(url) === -1)) { - offlineManifestAuthoringCacheList = offlineManifestAuthoringCacheList.concat(url); - } - setOfflineManifestAuthoringData(offlineManifestAuthoringId, { - activities: offlineManifestAuthoringActivities, - cacheList: offlineManifestAuthoringCacheList - }); - return {...prevState, offlineManifestAuthoringCacheList}; - }); - } - break; - } - }); wb.register().then((_registration) => { console.log("Workbox register() promise resolved", _registration); @@ -285,6 +221,9 @@ export class App extends React.PureComponent { } } + // TODO update this to work the same as the install-app + // If a new service worker is downloaded and activitated it will start + // controlling the page so this version info will become stale console.log("Sending GET_VERSION_INFO to service worker..."); this.setState({serviceWorkerVersionInfo: "Checking..."}); wb.messageSW({type: "GET_VERSION_INFO"}) @@ -300,33 +239,7 @@ export class App extends React.PureComponent { async componentDidMount() { try { - const {offlineMode, offlineManifestId, offlineManifestAuthoringId } = this.state; - - let offlineManifestAuthoringData: OfflineManifestAuthoringData | undefined; - if (offlineManifestAuthoringId) { - offlineManifestAuthoringData = getOfflineManifestAuthoringData(offlineManifestAuthoringId); - } - - let offlineManifest: OfflineManifest | undefined = undefined; - if (offlineManifestId) { - offlineManifest = await getOfflineManifest(offlineManifestId); - - if (offlineManifest) { - if (offlineManifestAuthoringId && offlineManifestAuthoringData) { - offlineManifestAuthoringData = mergeOfflineManifestWithAuthoringData(offlineManifest, offlineManifestAuthoringData); - setOfflineManifestAuthoringData(offlineManifestAuthoringId, offlineManifestAuthoringData); - } - - await saveOfflineManifestToOfflineActivities(offlineManifest); - } - } - - if (offlineManifestAuthoringData) { - this.setState({ - offlineManifestAuthoringActivities: offlineManifestAuthoringData.activities, - offlineManifestAuthoringCacheList: offlineManifestAuthoringData.cacheList - }); - } + const {offlineMode } = this.state; let activity: Activity | undefined = undefined; let resourceUrl: string | undefined = undefined; @@ -342,9 +255,6 @@ export class App extends React.PureComponent { // resourceUrl that would be used if the resource was online const contentUrl = queryValue("contentUrl") || activityPath; activity = await getActivityDefinition(contentUrl); - if (offlineManifestAuthoringId) { - await this.addActivityToOfflineManifest(offlineManifestAuthoringId, activity, resourceUrl, contentUrl); - } } const sequencePath = queryValue("sequence"); @@ -362,7 +272,7 @@ export class App extends React.PureComponent { // Teacher Edition mode is equal to preview mode. RunKey won't be used and the data won't be persisted. const preview = queryValueBoolean("preview") || teacherEditionMode; - const newState: Partial = {activity, offlineManifest, currentPage, showThemeButtons, showWarning, showSequenceIntro, sequence, teacherEditionMode, offlineManifestAuthoringId}; + const newState: Partial = {activity, currentPage, showThemeButtons, showWarning, showSequenceIntro, sequence, teacherEditionMode }; setDocumentTitle(activity, currentPage); // Initialize Storage provider @@ -409,7 +319,7 @@ export class App extends React.PureComponent { } render() { - const {serviceWorkerVersionInfo} = this.state; + const {serviceWorkerVersionInfo, networkConnected } = this.state; const showOfflineNav = this.state.offlineMode && !!this.state.activity; return ( @@ -417,19 +327,14 @@ export class App extends React.PureComponent {
{ this.state.showWarning && } { this.state.teacherEditionMode && } - { this.state.offlineManifestAuthoringId && - } { showOfflineNav && } { this.renderContent() } { this.state.showThemeButtons && }
Application: {__VERSION_INFO__} {serviceWorkerVersionInfo && ` | Service Worker: ${serviceWorkerVersionInfo}`} + | + {networkConnected ? "network connected" : "no network connection" }
{ this.setState({ pluginsLoaded: true }); } - private addActivityToOfflineManifest = async (offlineManifestAuthoringId: string, activity: Activity, - resourceUrl: string, contentUrl: string) => { - if (offlineManifestAuthoringId && isNotSampleActivityUrl(contentUrl)) { - const urls = await getAllUrlsInActivity(activity); - - this.setState(prevState => { - let {offlineManifestAuthoringActivities} = prevState; - const {offlineManifestAuthoringCacheList} = prevState; - - urls.forEach(urlInActivity => { - if (offlineManifestAuthoringCacheList.indexOf(urlInActivity) === -1) { - offlineManifestAuthoringCacheList.push(urlInActivity); - } - }); - - if (!prevState.offlineManifestAuthoringActivities.find(a => a.resourceUrl === resourceUrl)) { - offlineManifestAuthoringActivities = - offlineManifestAuthoringActivities.concat({ name: activity.name, resourceUrl, contentUrl }); - setOfflineManifestAuthoringData(offlineManifestAuthoringId, { - activities: offlineManifestAuthoringActivities, - cacheList: offlineManifestAuthoringCacheList - }); - } - - return {offlineManifestAuthoringActivities, offlineManifestAuthoringCacheList}; - }); - } - } - private handleShowOfflineActivities = () => this.setState({ activity: undefined }); private trackOfflineResourceUrl(resourceUrl: string) { diff --git a/src/components/offline-manifest-authoring-nav.scss b/src/components/offline-manifest-authoring-nav.scss deleted file mode 100644 index 53954b59..00000000 --- a/src/components/offline-manifest-authoring-nav.scss +++ /dev/null @@ -1,36 +0,0 @@ -@import "./vars.scss"; - -.offline-manifest-authoring-nav { - height: 60px; - width: 100%; - margin-bottom: 3px; - font-size: 18px; - font-weight: bold; - - .inner { - display: flex; - flex-direction: row; - align-items: center; - height: 100%; - width: $content-width; - box-sizing: border-box; - margin: auto; - background-color: $cc-orange; - color: $cc-charcoal; - - &.full { - width: 100%; - } - - .nav-center { - flex: 1; - min-width: 0; - height: 20px; - padding: 8px 10px; - } - - .nav-right { - margin-right: 10px; - } - } -} diff --git a/src/components/offline-manifest-authoring-nav.tsx b/src/components/offline-manifest-authoring-nav.tsx deleted file mode 100644 index 4a0bbf1c..00000000 --- a/src/components/offline-manifest-authoring-nav.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from "react"; -import { clearOfflineManifestAuthoringData, clearOfflineManifestAuthoringId, getOfflineManifestAuthoringDownloadJSON } from "../offline-manifest-api"; -import { OfflineManifest, OfflineManifestActivity } from "../types"; -import queryString from "query-string"; - -import "./offline-manifest-authoring-nav.scss"; - -interface IProps { - offlineManifest?: OfflineManifest; - offlineManifestAuthoringId: string; - offlineManifestAuthoringActivities: OfflineManifestActivity[], - offlineManifestAuthoringCacheList: string[], -} - -export class OfflineManifestAuthoringNav extends React.PureComponent { - render() { - const { offlineManifest, offlineManifestAuthoringId, offlineManifestAuthoringActivities, offlineManifestAuthoringCacheList } = this.props; - - const handleDownloadClicked = (e: React.MouseEvent) => { - const json = getOfflineManifestAuthoringDownloadJSON( - offlineManifest?.name ?? "Untitled Offline Manifest", - {activities: offlineManifestAuthoringActivities, cacheList: offlineManifestAuthoringCacheList.slice().sort()} - ); - const blob = new Blob([ JSON.stringify(json, null, 2) ], { type: "application/json" }); - e.currentTarget.href = URL.createObjectURL(blob); - }; - - const handleClearAuthoringId = () => { - if (confirm("Exit authoring (clears the offline manifest authoring id in localstorage)?")) { - clearOfflineManifestAuthoringId(); - const query = queryString.parse(window.location.search); - delete query.setOfflineManifestAuthoringId; - window.location.replace(`?${queryString.stringify(query)}`); - } - }; - - const handleClearAuthoringData = () => { - if (confirm("Are your SURE you want to clear ALL the saved authoring data?")) { - clearOfflineManifestAuthoringData(offlineManifestAuthoringId); - window.location.reload(); - } - }; - - return ( -
-
-
- Authoring: "{offlineManifestAuthoringId}" ({offlineManifestAuthoringActivities.length} activities / {offlineManifestAuthoringCacheList.length} cached items) -
-
- Download JSON - - -
-
-
- ); - } -} diff --git a/src/offline-manifest-api.test.ts b/src/offline-manifest-api.test.ts index 6ccd6a77..1e9b047c 100644 --- a/src/offline-manifest-api.test.ts +++ b/src/offline-manifest-api.test.ts @@ -1,11 +1,7 @@ import fetch from "jest-fetch-mock"; -import { clearOfflineManifestAuthoringData, clearOfflineManifestAuthoringId, - getOfflineManifest, getOfflineManifestAuthoringData, getOfflineManifestAuthoringDownloadJSON, - getOfflineManifestAuthoringId, getOfflineManifestUrl, mergeOfflineManifestWithAuthoringData, - normalizeAndSortOfflineActivities, OfflineManifestAuthoringData, - OfflineManifestAuthoringDataKeyPrefix, OfflineManifestAuthoringIdKey, - setOfflineManifestAuthoringData, setOfflineManifestAuthoringId } from "./offline-manifest-api"; +import { getOfflineManifest, getOfflineManifestUrl, + normalizeAndSortOfflineActivities } from "./offline-manifest-api"; import { OfflineManifest } from "./types"; (window as any).fetch = fetch; @@ -84,110 +80,6 @@ describe("offline manifest api", () => { // }); }); - it("handles #setOfflineManifestAuthoringId", () => { - jest.spyOn(window.localStorage.__proto__, "setItem"); - setOfflineManifestAuthoringId(undefined); - expect(window.localStorage.setItem).not.toHaveBeenCalled(); - setOfflineManifestAuthoringId("test"); - expect(window.localStorage.setItem).toHaveBeenCalledWith(OfflineManifestAuthoringIdKey, "test"); - }); - - it("handles #clearOfflineManifestAuthoringId", () => { - jest.spyOn(window.localStorage.__proto__, "removeItem"); - clearOfflineManifestAuthoringId(); - expect(window.localStorage.removeItem).toHaveBeenCalledWith(OfflineManifestAuthoringIdKey); - }); - - it("handles #getOfflineManifestAuthoringId", () => { - jest.spyOn(window.localStorage.__proto__, "getItem"); - getOfflineManifestAuthoringId(); - expect(window.localStorage.getItem).toHaveBeenCalledWith(OfflineManifestAuthoringIdKey); - }); - - it("handles #getOfflineManifestAuthoringData", () => { - jest.spyOn(window.localStorage.__proto__, "getItem"); - getOfflineManifestAuthoringData("test"); - expect(window.localStorage.getItem).toHaveBeenCalledWith(`${OfflineManifestAuthoringDataKeyPrefix}:test`); - }); - - it("handles #setOfflineManifestAuthoringData", () => { - const data: OfflineManifestAuthoringData = {activities: [], cacheList: []}; - jest.spyOn(window.localStorage.__proto__, "setItem"); - setOfflineManifestAuthoringData("test", data); - expect(window.localStorage.setItem).toHaveBeenCalledWith(`${OfflineManifestAuthoringDataKeyPrefix}:test`, JSON.stringify(data)); - }); - - it("handles #clearOfflineManifestAuthoringData", () => { - jest.spyOn(window.localStorage.__proto__, "removeItem"); - clearOfflineManifestAuthoringData("test"); - expect(window.localStorage.removeItem).toHaveBeenCalledWith(`${OfflineManifestAuthoringDataKeyPrefix}:test`); - }); - - it("handles #getOfflineManifestAuthoringDownloadJSON", () => { - const data: OfflineManifestAuthoringData = {activities: [], cacheList: []}; - expect(getOfflineManifestAuthoringDownloadJSON("test", data)).toEqual({name: "test", activities: [], cacheList: []}); - }); - - it("handles #mergeOfflineManifestWithAuthoringData", () => { - const testManifest: OfflineManifest = { - name: "Test Manifest", - activities: [ - { - name: "Activity 1", - resourceUrl: "http://example.com/activity-1-resource-url", - contentUrl: "http://example.com/activity-1-content-url" - }, - { - name: "Activity 2", - resourceUrl: "http://example.com/activity-2-resource-url", - contentUrl: "http://example.com/activity-2-content-url" - } - ], - cacheList: [ - "http://example.com/cache-list-item-1", - "http://example.com/cache-list-item-2" - ] - }; - const authoringData: OfflineManifestAuthoringData = { - activities: [ - { - name: "Activity 3", - resourceUrl: "http://example.com/activity-3-resource-url", - contentUrl: "http://example.com/activity-3-content-url" - } - ], - cacheList: [ - "http://example.com/cache-list-item-3", - "http://example.com/cache-list-item-4" - ] - }; - expect(mergeOfflineManifestWithAuthoringData(testManifest, authoringData)).toEqual({ - activities: [ - { - name: "Activity 3", - resourceUrl: "http://example.com/activity-3-resource-url", - contentUrl: "http://example.com/activity-3-content-url" - }, - { - name: "Activity 1", - resourceUrl: "http://example.com/activity-1-resource-url", - contentUrl: "http://example.com/activity-1-content-url" - }, - { - name: "Activity 2", - resourceUrl: "http://example.com/activity-2-resource-url", - contentUrl: "http://example.com/activity-2-content-url" - }, - ], - cacheList: [ - "http://example.com/cache-list-item-3", - "http://example.com/cache-list-item-4", - "http://example.com/cache-list-item-1", - "http://example.com/cache-list-item-2", - ] - }); - }); - it("handles #saveOfflineManifestToOfflineActivities", () => { // TODO: add Dexie stubs, example here: https://stackoverflow.com/a/54134903 expect(true).toEqual(true); diff --git a/src/offline-manifest-api.ts b/src/offline-manifest-api.ts index dbc4140a..bc4d4c53 100644 --- a/src/offline-manifest-api.ts +++ b/src/offline-manifest-api.ts @@ -1,12 +1,7 @@ import { dexieStorage } from "./storage/dexie-storage"; -import { OfflineActivity, OfflineManifest, OfflineManifestActivity } from "./types"; +import { OfflineActivity, OfflineManifest } from "./types"; import { Workbox } from "workbox-window/index"; -export interface OfflineManifestAuthoringData { - activities: OfflineManifestActivity[]; - cacheList: string[] -} - export const getOfflineManifestUrl = (id: string): string => { if (/^\s*https?:\/\//.test(id)) { return id; @@ -95,67 +90,6 @@ export const cacheOfflineManifest = (options: CacheOfflineManifestOptions) => { return cacheUrlsWithProgress(cacheUrlsOptions); }; -export const OfflineManifestAuthoringIdKey = "offlineManifestAuthoringId"; -export const OfflineManifestAuthoringDataKeyPrefix = "offlineManifestAuthoringData:"; - -export const setOfflineManifestAuthoringId = (value: string | undefined) => { - if (value !== undefined) { - window.localStorage.setItem(OfflineManifestAuthoringIdKey, value); - } -}; - -export const clearOfflineManifestAuthoringId = () => { - window.localStorage.removeItem(OfflineManifestAuthoringIdKey); -}; - -export const getOfflineManifestAuthoringId = (): string|undefined => { - return window.localStorage.getItem(OfflineManifestAuthoringIdKey) || undefined; -}; - -export const getOfflineManifestAuthoringData = (authoringId: string): OfflineManifestAuthoringData => { - try { - const key = `${OfflineManifestAuthoringDataKeyPrefix}:${authoringId}`; - return JSON.parse(window.localStorage.getItem(key) || "fail"); - } catch (e) { - return ({ - activities: [], - cacheList: [] - }); - } -}; - -export const setOfflineManifestAuthoringData = (authoringId: string, data: OfflineManifestAuthoringData) => { - const key = `${OfflineManifestAuthoringDataKeyPrefix}:${authoringId}`; - window.localStorage.setItem(key, JSON.stringify(data)); -}; - -export const clearOfflineManifestAuthoringData = (authoringId: string) => { - const key = `${OfflineManifestAuthoringDataKeyPrefix}:${authoringId}`; - window.localStorage.removeItem(key); -}; - -export const getOfflineManifestAuthoringDownloadJSON = (name: string, data: OfflineManifestAuthoringData): OfflineManifest => { - const {activities, cacheList} = data; - return { name, activities, cacheList }; -}; - -export const mergeOfflineManifestWithAuthoringData = (offlineManifest: OfflineManifest, authoringData: OfflineManifestAuthoringData) => { - const {activities, cacheList} = authoringData; - offlineManifest.activities.forEach(activity => { - // Note: if the contentUrl has changed this won't update it in the manifest - // but in that case it seems reasonable that the author will just manually fix it. - if (!activities.find(a => a.resourceUrl === activity.resourceUrl)) { - activities.push(activity); - } - }); - offlineManifest.cacheList.forEach(url => { - if (!cacheList.find(item => item === url)) { - cacheList.push(url); - } - }); - return {activities, cacheList}; -}; - export const saveOfflineManifestToOfflineActivities = async (offlineManifest: OfflineManifest) => { const manifestName = offlineManifest.name; const promises = offlineManifest.activities.map(async (offlineManifestActivity, order) => {