diff --git a/.browserslistrc b/.browserslistrc deleted file mode 100644 index ff27e7a12f..0000000000 --- a/.browserslistrc +++ /dev/null @@ -1,3 +0,0 @@ -Electron 9.2.0 -ChromeAndroid >= 84 -iOS >= 13.6 diff --git a/.env b/.env deleted file mode 100644 index bd9b2bf34d..0000000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -REACT_APP_SASS=true diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 60f4f366c0..0000000000 --- a/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -public/protocols/**/*Worker.js -src/utils/networkQuery -src/utils/network-exporters -src/utils/protocol diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 2efb0a6b38..0000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,143 +0,0 @@ -{ - "extends": "airbnb", - "env": { - "browser": true, - "commonjs": true, - "es2020": true, - "node": true - }, - "root": true, - "parser": "@babel/eslint-parser", - "parserOptions": { - "ecmaVersion": 2018, - "ecmaFeatures": { - "jsx": true, - "spread": true, - "experimentalObjectRestSpread": true, - "object-shorthand": ["error", "always"] - }, - "sourceType": "module" - }, - "plugins": ["react", "@codaco/spellcheck"], - "rules": { - "react/prop-types": [0], - "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], - "react/jsx-props-no-spreading": "off", - "react/forbid-prop-types": "off", - "react/no-array-index-key": "off", - "jsx-a11y/no-static-element-interactions": "off", - "jsx-a11y/click-events-have-key-events": "off", - "import/no-extraneous-dependencies": "off", - "import/no-named-as-default": "off", - "no-mixed-operators": [1, {"allowSamePrecedence": true}], - "@codaco/spellcheck/spell-checker": [1, - { - "comments": false, - "strings": true, - "identifiers": false, - "lang": "en_US", - "ignoreRequire": true, - "skipWords": [ - "Dev", - "darwin", - "https", - "selectall", - "resetzoom", - "togglefullscreen", - "forcereload", - "toggledevtools", - "devtools", - "netcanvas", - "cancelled", - "utf", - "html", - "argv", - "asc", - "tcp", - "mdns", - "href", - "http", - "crapple", - "ontouchstart", - "touchend", - "touchstart", - "touchmove", - "mousedown", - "mouseup", - "mousemove", - "dragover", - "deviceready", - "datetime", - "graphml", - "networkcanvas", - "undirected", - "edgedefault", - "Formatter", - "noopener", - "noreferrer", - "csv", - "linux", - "params", - "Checkbox", - "Likert", - "Sociogram", - "localhost", - "desc", - "calc", - "codaco", - "dialogs", - "filesystem", - "Cordova", - "sha256", - "sortable", - "codebook", - "uid", - "cdvfile", - "Tmp", - "swiper", - "Swiper", - "prev", - "fullscreen", - "ord", - "scroller", - "resize", - "rerender", - "Rerender", - "vmin", - "320px", - "0px", - "titlebar", - "minimizer", - "Unpair", - "discoverability", - "scrollable", - "draggable", - "dyad", - "Dyad", - "hostname", - "searchable" - ], - "skipIfMatch": [ - "http(s)?://[^s]*", - // pre/post prefixes both in kebab case and camel case - "(\\s|^)(pre|post)([-\\w]|[A-Z])[a-zA-Z]+(\\s|$)", - // mimetypes - "^[-\\w]+\/[-\\w\\.]+$", - // xml tags - "<(?:\/)?[\\w-]+>", - // cryptographic octal hashes - "^[0-9a-f]{5,999}$", - // hex colors - "^#[0-9a-f]{3,6}$", - // vh - "vh$", - // px - "px$" - ], - "skipWordIfMatch": [ - ], - "minLength": 3 - } - ] - } -} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 40a9ed1273..0000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,71 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [master, legacy] - pull_request: - # The branches below must be a subset of the branches above - branches: [master] - schedule: - - cron: "45 2 * * 5" - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'javascript' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/dist.yml b/.github/workflows/dist.yml deleted file mode 100644 index 246912188b..0000000000 --- a/.github/workflows/dist.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Dist -on: - push: - branches: - - "release/**" - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -jobs: - dist: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - # Set python version to 2.x due to node-gyp and sass - - uses: actions/setup-python@v4 - with: - python-version: "3.10.12" - # Set node version - - uses: actions/setup-node@v4 - with: - node-version: "14.21.3" - - name: Set NPM 7 - run: npm install -g npm@8.19.4 - # Cache node_modules - - uses: actions/cache@v4 - env: - cache-name: cache-node-modules - with: - path: "**/node_modules" - key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }} - - - name: Install MDNS build dependencies - run: | - sudo apt-get update - sudo apt-get install libavahi-compat-libdnssd-dev - - - name: Install node modules - run: npm install - - - name: Dist - run: npm run dist:linux -- --publish always - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index debd856ccc..0000000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: CI -on: - push: - branches: [master] - pull_request: - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - # Set python version to 2.x due to node-gyp and sass - - uses: actions/setup-python@v4 - with: - python-version: "3.10.12" - # Set node version - - uses: actions/setup-node@v4 - with: - node-version: "14.21.3" - - name: Set NPM 7 - run: npm install -g npm@8.19.4 - # Cache node_modules - - uses: actions/cache@v4 - env: - cache-name: cache-node-modules - with: - path: "**/node_modules" - key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }} - - - name: Install MDNS build dependencies - run: | - sudo apt-get update - sudo apt-get install libavahi-compat-libdnssd-dev - - - name: Install node modules - run: npm install - - - name: Lint - run: npm run lint -- --max-warnings 0 && npm run sass-lint -- --max-warnings 0 - - - name: Run tests - run: npm run test - - build: - needs: test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - # Set python version to 2.x due to node-gyp and sass - - uses: actions/setup-python@v4 - with: - python-version: "3.10.12" - # Set node version - - uses: actions/setup-node@v4 - with: - node-version: "14.21.3" - - name: Set NPM 7 - run: npm install -g npm@8.19.4 - # Cache node_modules - - uses: actions/cache@v4 - env: - cache-name: cache-node-modules - with: - path: "**/node_modules" - key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }} - - - name: Install MDNS build dependencies - run: | - sudo apt-get update - sudo apt-get install libavahi-compat-libdnssd-dev - - - name: Install node modules - run: npm install - - - name: Build - run: npm run build:electron diff --git a/.gitignore b/.gitignore index 8a73b0875a..e1f46b234a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,8 @@ +node_modules/ +dist/ +out/ +release-builds/ +.turbo/ +coverage/ +*.log .DS_Store -/node_modules -/bower_components -/platforms -/coverage -/release-builds -/www -/.idea -/docs-build -/plugins - -npm-debug.log* -yarn-debug.log* -yarn-error.log* -yarn.lock -build.json - -*.zip -plugins/**/*.pbxproj -Icon^M^M - -.devserver -config.xml.original -chromedriver.log - -# Xcode (Cordova plugin) -xcuserdata/ -IDEWorkspaceChecks.plist - -# Ignore protocol template used for schema generation -src/schemas/abstract-protocol.json - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 434e4987cf..0000000000 --- a/.gitmodules +++ /dev/null @@ -1,9 +0,0 @@ -[submodule "src/utils/networkQuery"] - path = src/utils/networkQuery - url = https://github.com/complexdatacollective/networkQuery -[submodule "src/utils/protocol/protocol-validation"] - path = src/utils/protocol/protocol-validation - url = https://github.com/complexdatacollective/protocol-validation.git -[submodule "src/utils/network-exporters"] - path = src/utils/network-exporters - url = https://github.com/complexdatacollective/network-exporters diff --git a/.node-version b/.node-version deleted file mode 100644 index f46d5e3942..0000000000 --- a/.node-version +++ /dev/null @@ -1 +0,0 @@ -14.21.3 diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 5a89ce15d7..0000000000 --- a/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -save-prefix=~ -arch=x64 \ No newline at end of file diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000000..61f7ef4496 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,17 @@ +{ + "extends": ["../../.oxlintrc.json", "../../tooling/oxlint/react.json"], + "jsPlugins": ["oxlint-tailwindcss"], + "settings": { + "tailwindcss": { + "entryPoint": "tooling/tailwind/fresco/fresco.css" + } + }, + "rules": { + "react/exhaustive-deps": "warn", + "tailwindcss/no-unknown-classes": "off", + "tailwindcss/no-duplicate-classes": "error", + "tailwindcss/no-conflicting-classes": "warn", + "tailwindcss/no-unnecessary-whitespace": "error", + "tailwindcss/enforce-canonical": "warn" + } +} diff --git a/.stylelintrc.json b/.stylelintrc.json deleted file mode 100644 index d75964a3fb..0000000000 --- a/.stylelintrc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "stylelint-config-standard-scss", - "rules": { - "selector-class-pattern": null, - "custom-property-pattern": null, - "scss/at-extend-no-missing-placeholder": null - } -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index ded4328833..0000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "scss.validate": false, - "stylelint.validate": [ - "css", - "scss" - ], -} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..b6c4cd8b5d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# network-canvas-interviewer + +## 6.6.0 + +- **Updated core dependencies.** The technology the app is built on has been brought up to + date, which improves stability and performance and lays the groundwork for future + improvements. +- **Compatibility with upcoming macOS versions.** This release ensures the app continues to + run smoothly on the latest and upcoming versions of macOS. +- **Improved security.** We've adopted current security best practices for building and + distributing the app — including properly signed and notarized macOS builds — so you can be + confident the software you download is genuine and safe to run. + +## 6.5.10 + +### Patch Changes + +- Updated dependencies [ae81956] + - @codaco/network-exporters@1.0.2 + +## 6.5.9 + +### Patch Changes + +- Updated dependencies + - @codaco/network-query@1.0.1 + +## 6.5.8 + +### Patch Changes + +- Updated dependencies [23efeeb] + - @codaco/network-exporters@1.0.1 + +## 6.5.7 + +### Patch Changes + +- Updated dependencies [4335dee] +- Updated dependencies [fe48a62] +- Updated dependencies [e31e28d] + - @codaco/network-exporters@1.0.0 + - @codaco/network-query@1.0.0 + +## 6.5.6 + +### Patch Changes + +- @codaco/network-exporters@0.1.2 +- @codaco/network-query@0.1.2 + +## 6.5.5 + +### Patch Changes + +- @codaco/network-exporters@0.1.1 +- @codaco/network-query@0.1.1 diff --git a/README.md b/README.md index 67d347e421..281905c633 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,13 @@ There are some additional requirements for the [MDNS](https://www.npmjs.com/pack ### Before running `npm install` 1. Run powershell as admin (right-click option) and then run: + ``` npm --add-python-to-path install --global windows-build-tools ``` + 2. Install [Bonjour SDK for Windows](https://developer.apple.com/download/more/?=Bonjour%20SDK%20for%20Windows) -(requires an apple id associated with a paid team account). Select "Bonjour SDK for Windows v.3.0". `BONJOUR_SDK_HOME` should be set for you after installation completes. + (requires an apple id associated with a paid team account). Select "Bonjour SDK for Windows v.3.0". `BONJOUR_SDK_HOME` should be set for you after installation completes. 3. Restart powershell and continue with [project installation](#installation). ### After running `npm install` @@ -56,14 +58,14 @@ Is added to to `/etc/msswitch.conf`. ### Troubleshooting - Native dependencies won't compile - + `windows-build-tools` should have installed the required compilers - + [MS notes on config for native modules](https://github.com/Microsoft/nodejs-guidelines/blob/master/windows-environment.md#compiling-native-addon-modules) - + ...You could install python and VS Build Tools manually; you should *not* need all of Visual Studio + - `windows-build-tools` should have installed the required compilers + - [MS notes on config for native modules](https://github.com/Microsoft/nodejs-guidelines/blob/master/windows-environment.md#compiling-native-addon-modules) + - ...You could install python and VS Build Tools manually; you should _not_ need all of Visual Studio - Runtime error related to DLL initialization - + Make sure the "rebuild" step above works - + [More Info](https://github.com/electron/electron/blob/master/docs/tutorial/using-native-node-modules.md#using-native-node-modules) + - Make sure the "rebuild" step above works + - [More Info](https://github.com/electron/electron/blob/master/docs/tutorial/using-native-node-modules.md#using-native-node-modules) - MDNS doesn't work on linux (getaddr - + Try adding `hosts: files mdns4_minimal mdns6_minimal [NOTFOUND=return] dns` to `/etc/msswitch.conf` + - Try adding `hosts: files mdns4_minimal mdns6_minimal [NOTFOUND=return] dns` to `/etc/msswitch.conf` ## Installation @@ -81,22 +83,22 @@ Note: for Apple Silicon users, you need to install the `electron` package manual ## Development Tasks -|`npm run
+ diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 687347568a..0000000000 --- a/src/index.js +++ /dev/null @@ -1,114 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import { PersistGate } from 'redux-persist/integration/react'; -import { ready as secureCommsReady } from 'secure-comms-api/cipher'; -import { Provider } from 'react-redux'; -import { ConnectedRouter, push } from 'connected-react-router'; -import initFileOpener from './utils/initFileOpener'; -import initMenuActions from './utils/initMenuActions'; -import { history, store, persistor as storePersistor } from './ducks/store'; -import { actionCreators as deviceActions } from './ducks/modules/deviceSettings'; -import App from './containers/App'; -import { - isCordova, - isElectron, - getEnv, - isAndroid, -} from './utils/Environment'; -import AppRouter from './routes'; -import remote from './utils/remote'; - -// This prevents user from being able to drop a file anywhere on the app -document.addEventListener('drop', (e) => { - e.preventDefault(); - e.stopPropagation(); -}); -document.addEventListener('dragover', (e) => { - e.preventDefault(); - e.stopPropagation(); -}); - -const env = getEnv(); - -const Persist = ({ persistor, children }) => { - if (env.REACT_APP_NO_PERSIST) { - return children; - } - - return ( - - {children} - - ); -}; - -const startApp = () => { - store.dispatch(deviceActions.deviceReady()); - - // Enable fullscreen mode on Android using cordova-plugin-fullscreen - if (isAndroid()) { - window.AndroidFullScreen.isImmersiveModeSupported(() => { - window.AndroidFullScreen.immersiveMode(() => { - // eslint-disable-next-line no-console - console.info('Set app into immersive mode.'); - - window.addEventListener('keyboardDidHide', () => { - // Describe your logic which will be run each time keyboard is closed. - // eslint-disable-next-line no-console - console.log('keyboard hidden'); - window.AndroidFullScreen.immersiveMode(); - }); - }, () => { - // eslint-disable-next-line no-console - console.warn('Failed to set app into immersive mode!'); - }); - }, () => { - // eslint-disable-next-line no-console - console.warn('Wanted to set immersive mode, but not supported!'); - }); - } - - ReactDOM.render( - - - - - - - - - , - document.getElementById('root'), - ); -}; - -if (isElectron()) { - const { webFrame, ipcRenderer } = window.require('electron'); // eslint-disable-line global-require - webFrame.setVisualZoomLevelLimits(1, 1); // Prevents pinch-to-zoom - remote.init(); - - // Listen for native menu UI events - initMenuActions(); - - ipcRenderer.on('RESET_STATE', () => { - store.dispatch(push('/reset')); - }); -} - -secureCommsReady.then(() => { - if (isCordova()) { - document.addEventListener('deviceready', startApp, false); - } else if (document.readyState === 'complete') { - startApp(); - // Listen for file open events. - initFileOpener(); - } else { - document.onreadystatechange = () => { - if (document.readyState === 'complete') { - startApp(); - // Listen for file open events. - initFileOpener(); - } - }; - } -}); diff --git a/src/index.jsx b/src/index.jsx new file mode 100644 index 0000000000..3790adb8e4 --- /dev/null +++ b/src/index.jsx @@ -0,0 +1,92 @@ +/** + * Main entry point. + */ + +import { ConnectedRouter, push } from 'connected-react-router'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import { PersistGate } from 'redux-persist/integration/react'; + +import App from './containers/App'; +import { actionCreators as deviceActions } from './ducks/modules/deviceSettings'; +import { history, store, persistor as storePersistor } from './ducks/store'; +import AppRouter from './routes'; +import { getEnv, isElectron } from './utils/Environment'; +import initFileOpener from './utils/initFileOpener'; +import initMenuActions from './utils/initMenuActions'; +import remote from './utils/remote'; + +// This prevents user from being able to drop a file anywhere on the app +document.addEventListener('drop', (e) => { + e.preventDefault(); + e.stopPropagation(); +}); +document.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); +}); + +const env = getEnv(); + +const Persist = ({ persistor, children }) => { + if (env.REACT_APP_NO_PERSIST) { + return children; + } + + return ( + + {children} + + ); +}; + +const startApp = () => { + store.dispatch(deviceActions.deviceReady()); + + ReactDOM.render( + + + + + + + + + , + document.getElementById('root'), + ); +}; + +if (isElectron()) { + // Use secure API instead of window.require('electron') + if (window.electronAPI?.webFrame?.setVisualZoomLevelLimits) { + window.electronAPI.webFrame.setVisualZoomLevelLimits(1, 1); // Prevents pinch-to-zoom + } + + remote.init(); + + // Listen for native menu UI events + initMenuActions(); + + // Listen for RESET_STATE events via secure IPC + if (window.electronAPI?.ipc?.on) { + window.electronAPI.ipc.on('RESET_STATE', () => { + store.dispatch(push('/reset')); + }); + } +} + +const boot = () => { + startApp(); + initFileOpener(); +}; + +if (document.readyState === 'complete') { + boot(); +} else { + document.onreadystatechange = () => { + if (document.readyState === 'complete') { + boot(); + } + }; +} diff --git a/src/main/appManager.js b/src/main/appManager.js new file mode 100644 index 0000000000..5669698c1f --- /dev/null +++ b/src/main/appManager.js @@ -0,0 +1,91 @@ +import path from 'node:path'; + +import { app, ipcMain } from 'electron'; + +import { registerProtocol as registerAssetProtocol } from './assetProtocol.js'; +import { openDialog } from './dialogs.js'; +import windowManager from './windowManager.js'; + +function getFileFromArgs(argv) { + if (argv.length >= 2) { + const filePath = argv[1]; + if (path.extname(filePath) === '.netcanvas') { + return filePath; + } + } + return null; +} + +const appManager = { + openFileWhenReady: null, + init() { + ipcMain.on('GET_ARGF', (event) => { + if (process.platform === 'win32') { + const filePath = getFileFromArgs(process.argv); + if (filePath) { + event.sender.send('OPEN_FILE', filePath); + } + } + + if (this.openFileWhenReady) { + event.sender.send('OPEN_FILE', this.openFileWhenReady); + this.openFileWhenReady = null; + } + }); + + ipcMain.on('OPEN_DIALOG', () => + openDialog() + .then((filePath) => + windowManager + .getWindow() + .then((window) => window.webContents.send('OPEN_FILE', filePath)), + ) + .catch((_err) => {}), + ); + }, + openFileFromArgs: function openFileFromArgs(argv) { + return this.restore().then((window) => { + if (process.platform === 'win32') { + const filePath = getFileFromArgs(argv); + if (filePath) { + window.webContents.send('OPEN_FILE', filePath); + } + } + + return window; + }); + }, + restore: function restore() { + if (!app.isReady()) { + return Promise.reject(); + } + + return windowManager.getWindow().then((window) => { + if (window.isMinimized()) { + window.restore(); + } + + window.focus(); + + return window; + }); + }, + openFile: function openFile(fileToOpen) { + if (!app.isReady()) { + // defer action + this.openFileWhenReady = fileToOpen; + } else { + windowManager.getWindow().then((window) => { + window.webContents.send('OPEN_FILE', fileToOpen); + }); + this.openFileWhenReady = null; + } + }, + start: function start() { + registerAssetProtocol(); + + return windowManager.getWindow(); + }, +}; + +export default appManager; diff --git a/src/main/appURL.js b/src/main/appURL.js new file mode 100644 index 0000000000..4f51707c64 --- /dev/null +++ b/src/main/appURL.js @@ -0,0 +1,31 @@ +import path from 'node:path'; +import url, { fileURLToPath } from 'node:url'; + +import log from './log.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const appUrl = (function getAppUrl() { + // In development, electron-vite provides the dev server URL + if (process.env.NODE_ENV === 'development') { + // electron-vite uses ELECTRON_RENDERER_URL for the dev server + if (process.env.ELECTRON_RENDERER_URL) { + log.info(`Using dev server URL: ${process.env.ELECTRON_RENDERER_URL}`); + return process.env.ELECTRON_RENDERER_URL; + } + // Fallback to localhost:3000 + log.info('Using fallback dev server URL: http://localhost:3000'); + return 'http://localhost:3000'; + } + + // In production, load from the built renderer + // __dirname is out/main/ since main process is bundled there + const rendererPath = path.join(__dirname, '../renderer/index.html'); + log.info(`Loading production renderer from: ${rendererPath}`); + return url.format({ + pathname: rendererPath, + protocol: 'file:', + }); +})(); + +export default appUrl; diff --git a/src/main/assetProtocol.js b/src/main/assetProtocol.js new file mode 100644 index 0000000000..1fc9018564 --- /dev/null +++ b/src/main/assetProtocol.js @@ -0,0 +1,25 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { app, protocol } from 'electron'; + +const userDataPath = app.getPath('userData'); +const appPath = app.getAppPath(); + +// default to asset from factory protocol (with same name) first +export const registerProtocol = () => + protocol.registerFileProtocol('asset', (request, callback) => { + const file = request.url.substr(8); + const decodedPath = decodeURIComponent(file); + const appFilePath = path.normalize( + path.join(appPath, 'protocols', decodedPath), + ); + const userDataFilePath = path.normalize( + path.join(userDataPath, 'protocols', decodedPath), + ); + + fs.access(appFilePath, fs.constants.R_OK, (err) => { + const filePath = err ? userDataFilePath : appFilePath; + callback({ path: filePath }); + }); + }); diff --git a/src/main/dialogs.js b/src/main/dialogs.js new file mode 100644 index 0000000000..0cd1d308e9 --- /dev/null +++ b/src/main/dialogs.js @@ -0,0 +1,31 @@ +import { BrowserWindow, dialog } from 'electron'; + +const openDialogOptions = { + buttonLabel: 'Open', + nameFieldLabel: 'Open:', + defaultPath: 'Protocol.netcanvas', + filters: [ + { name: 'Network Canvas Interviewer protocol', extensions: ['netcanvas'] }, + ], + properties: ['openFile'], +}; + +export const openDialog = () => { + const browserWindow = BrowserWindow.getFocusedWindow(); + + return new Promise((resolve, reject) => + dialog + .showOpenDialog(browserWindow, openDialogOptions) + .then(({ canceled, filePaths }) => { + if (canceled || !filePaths) { + reject(new Error('Import protocol dialog cancelled.')); + } + if (!filePaths.length || filePaths.length !== 1) { + reject( + new Error('Only a single protocol may be imported at a time.'), + ); + } + resolve(filePaths[0]); + }), + ); +}; diff --git a/src/main/index.js b/src/main/index.js new file mode 100644 index 0000000000..0bd7f684f4 --- /dev/null +++ b/src/main/index.js @@ -0,0 +1,79 @@ +import { app, protocol } from 'electron'; + +import appManager from './appManager.js'; +import { registerIpcHandlers } from './ipcHandlers.js'; +import loadDevTools from './loadDevTools.js'; +import log from './log.js'; + +// When Architect runs the Interviewer purely to serve its preview window's +// renderer (NC_PREVIEW_HOST), stay headless: Architect owns the visible window +// and hosts the preview IPC, so we only keep the Vite dev server alive and skip +// this instance's own window, dev tools, and CDP port. +const isPreviewHost = process.env.NC_PREVIEW_HOST === 'true'; + +// Dev-only: expose the Chrome DevTools Protocol so the renderer can be driven +// and inspected over CDP (e.g. for automated testing). Never enabled in a +// packaged build. Must be set before the app is ready. +if (!app.isPackaged && !isPreviewHost) { + app.commandLine.appendSwitch('remote-debugging-port', '9222'); +} + +protocol.registerSchemesAsPrivileged([ + { + scheme: 'asset', + privileges: { + secure: true, + supportFetchAPI: true, + bypassCSP: true, + corsEnabled: true, + }, + }, +]); + +log.info('App starting...'); +appManager.init(); + +const gotTheLock = app.requestSingleInstanceLock(); +if (!gotTheLock) { + app.quit(); +} else { + app.on('second-instance', (argv) => appManager.openFileFromArgs(argv)); + + // This method will be called when Electron has finished + // initialization and is ready to create browser windows. + // Some APIs can only be used after this event occurs. + app.on('ready', () => { + registerIpcHandlers(); + + // Headless preview-host: serve the renderer (the Vite dev server stays up + // because no window is created and `window-all-closed` never fires) without + // opening this instance's own window. + if (isPreviewHost) { + return; + } + + appManager.start(); + loadDevTools(); + }); + + // Quit when all windows are closed. + app.on('window-all-closed', () => { + // On OS X it is common for applications and their menu bar + // to stay active until the user quits explicitly with Cmd + Q + if (process.platform !== 'darwin') { + app.quit(); + } + }); + + app.on('activate', () => { + // On OS X it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + if (process.platform === 'darwin') { + appManager.restore(); + } + }); + + app.on('open-file', (_event, filePath) => { + appManager.openFile(filePath); + }); +} diff --git a/src/main/ipcHandlers.js b/src/main/ipcHandlers.js new file mode 100644 index 0000000000..e4db22b3b5 --- /dev/null +++ b/src/main/ipcHandlers.js @@ -0,0 +1,325 @@ +/** + * IPC Handlers for Network Canvas main process. + * Registers all ipcMain.handle() handlers for secure IPC communication. + * + * These handlers replace direct Node.js access in the renderer process, + * providing a controlled API for file system, dialogs, and other operations. + */ + +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; + +import archiver from 'archiver'; +import decompress from 'decompress'; +import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'; +import fse from 'fs-extra'; + +import log from './log.js'; + +/** + * Resolve `target` and require it to be inside the app's temp or userData + * directory before a destructive filesystem operation, mirroring the allowlist + * in network-exporters' `removeDirectory`. Throws otherwise. + */ +const assertUnderSafeRoot = (target, op) => { + const resolved = path.resolve(target); + const roots = [app.getPath('temp'), app.getPath('userData')]; + const allowed = roots.some( + (root) => resolved === root || resolved.startsWith(root + path.sep), + ); + if (!allowed) { + throw new Error( + `Refusing to ${op} outside the app's temp/userData directories: ${target}`, + ); + } + return resolved; +}; + +/** + * Register all IPC handlers + */ +export const registerIpcHandlers = () => { + log.info('Registering IPC handlers...'); + + // =================== + // Dialog Handlers + // =================== + + ipcMain.handle('dialog:showOpen', async (event, options) => { + const window = BrowserWindow.fromWebContents(event.sender); + return dialog.showOpenDialog(window, options); + }); + + ipcMain.handle('dialog:showSave', async (event, options) => { + const window = BrowserWindow.fromWebContents(event.sender); + return dialog.showSaveDialog(window, options); + }); + + ipcMain.handle('dialog:showMessageBox', async (event, options) => { + const window = BrowserWindow.fromWebContents(event.sender); + return dialog.showMessageBox(window, options); + }); + + // =================== + // Protocol Download (runs in main to avoid renderer CORS) + // =================== + + ipcMain.handle('protocol:download', async (_, uri) => { + const url = new URL(uri); + // Only http(s); reject other schemes (file:, etc.) and embedded + // credentials (so they are never fetched or written to the log). + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`Unsupported protocol for download: ${url.protocol}`); + } + if (url.username || url.password) { + throw new Error('Credentials are not allowed in a protocol download URL'); + } + // Log origin + path only (no credentials or query string). + log.info('protocol:download', `${url.origin}${url.pathname}`); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 60_000); + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) { + throw new Error( + `Failed to download protocol (HTTP ${response.status})`, + ); + } + const buffer = Buffer.from(await response.arrayBuffer()); + const destination = path.join(app.getPath('temp'), randomUUID()); + await fse.writeFile(destination, buffer); + return destination; + } finally { + clearTimeout(timeout); + } + }); + + // =================== + // App Info Handlers + // =================== + + ipcMain.handle('app:getPath', async (_, name) => { + const validPaths = [ + 'home', + 'appData', + 'userData', + 'temp', + 'desktop', + 'documents', + 'downloads', + ]; + if (!validPaths.includes(name)) { + throw new Error(`Invalid path name: ${name}`); + } + return app.getPath(name); + }); + + ipcMain.handle('app:getAppPath', async () => app.getAppPath()); + + ipcMain.handle('app:getVersion', async () => app.getVersion()); + + // =================== + // File System Handlers + // =================== + + ipcMain.handle('fs:readFile', async (_, filePath, encoding) => { + log.info('fs:readFile', filePath); + if (encoding) { + return fse.readFile(filePath, encoding); + } + // Return as base64 for binary files + const buffer = await fse.readFile(filePath); + return buffer.toString('base64'); + }); + + ipcMain.handle('fs:writeFile', async (_, filePath, data, isBinary) => { + log.info('fs:writeFile', filePath); + // Explicit binary flag from the renderer is authoritative (any size). + if (isBinary) { + return fse.writeFile(filePath, Buffer.from(data, 'base64')); + } + // Fallback heuristic for callers that don't set the flag (e.g. streams). + if (typeof data === 'string' && data.length > 0) { + const isBase64 = /^[A-Za-z0-9+/]+=*$/.test(data.substring(0, 100)); + if (isBase64 && data.length > 1000) { + return fse.writeFile(filePath, Buffer.from(data, 'base64')); + } + } + return fse.writeFile(filePath, data); + }); + + ipcMain.handle('fs:rename', async (_, oldPath, newPath) => { + log.info('fs:rename', oldPath, '->', newPath); + return fse.rename(oldPath, newPath); + }); + + ipcMain.handle('fs:mkdirp', async (_, dirPath) => { + log.info('fs:mkdirp', dirPath); + return fse.mkdirp(dirPath); + }); + + ipcMain.handle('fs:mkdir', async (_, dirPath, options) => { + log.info('fs:mkdir', dirPath); + return fse.mkdir(dirPath, options); + }); + + ipcMain.handle('fs:rmdir', async (_, dirPath) => { + log.info('fs:rmdir', dirPath); + // Destructive: only allow removing inside the app's temp / userData dirs. + // `remove` is idempotent (no ENOENT on missing path) and not deprecated. + return fse.remove(assertUnderSafeRoot(dirPath, 'remove a directory')); + }); + + // =================== + // Path Handlers + // =================== + + ipcMain.handle('path:join', async (_, ...args) => path.join(...args)); + + ipcMain.handle('path:basename', async (_, filePath, ext) => { + if (ext) { + return path.basename(filePath, ext); + } + return path.basename(filePath); + }); + + ipcMain.handle('path:dirname', async (_, filePath) => path.dirname(filePath)); + + ipcMain.handle('path:extname', async (_, filePath) => path.extname(filePath)); + + ipcMain.handle('path:parse', async (_, filePath) => path.parse(filePath)); + + ipcMain.handle('path:resolve', async (_, ...args) => path.resolve(...args)); + + ipcMain.handle('path:normalize', async (_, filePath) => + path.normalize(filePath), + ); + + ipcMain.handle('path:relative', async (_, from, to) => + path.relative(from, to), + ); + + // =================== + // Archive Handlers + // =================== + + ipcMain.handle('archive:create', async (_, sourcePath, destPath) => { + log.info('archive:create', sourcePath, '->', destPath); + return new Promise((resolve, reject) => { + const output = fse.createWriteStream(destPath); + const zip = archiver('zip', { store: true }); + + output.on('close', () => { + log.info('archive:create complete', destPath); + resolve(destPath); + }); + + output.on('error', (err) => { + log.error('archive:create output error', err); + reject(err); + }); + + zip.on('error', (err) => { + log.error('archive:create zip error', err); + reject(err); + }); + + zip.pipe(output); + zip.directory(sourcePath, false); + zip.finalize(); + }); + }); + + ipcMain.handle('archive:extract', async (_, sourcePath, destPath) => { + log.info('archive:extract', sourcePath, '->', destPath); + await decompress(sourcePath, destPath); + return destPath; + }); + + // =================== + // Shell Handlers + // =================== + + ipcMain.handle('shell:openExternal', async (_, url) => { + log.info('shell:openExternal', url); + // Validate URL to prevent arbitrary command execution + const validProtocols = ['http:', 'https:', 'mailto:']; + const urlObj = new URL(url); + if (!validProtocols.includes(urlObj.protocol)) { + throw new Error(`Invalid URL protocol: ${urlObj.protocol}`); + } + return shell.openExternal(url); + }); + + ipcMain.handle('shell:openPath', async (_, filePath) => { + log.info('shell:openPath', filePath); + return shell.openPath(filePath); + }); + + // =================== + // Window Handlers + // =================== + + ipcMain.handle('window:hide', async (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + if (window) { + window.hide(); + } + }); + + ipcMain.handle('window:show', async (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + if (window) { + window.show(); + } + }); + + ipcMain.handle('window:close', async (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + if (window) { + window.close(); + } + }); + + ipcMain.handle('window:setFullScreen', async (event, flag) => { + const window = BrowserWindow.fromWebContents(event.sender); + if (window) { + window.setFullScreen(flag); + } + }); + + ipcMain.handle('window:isFullScreen', async (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + if (window) { + return window.isFullScreen(); + } + return false; + }); + + // =================== + // WebFrame Handlers + // =================== + + ipcMain.handle( + 'webFrame:setVisualZoomLevelLimits', + async (event, min, max) => { + const webContents = event.sender; + if (webContents) { + webContents.setVisualZoomLevelLimits(min, max); + } + }, + ); + + // =================== + // WebContents Handlers + // =================== + + ipcMain.handle('webContents:printToPDF', async (event, options) => { + log.info('webContents:printToPDF', options); + const pdf = await event.sender.printToPDF(options || {}); + return pdf.toString('base64'); + }); + + log.info('IPC handlers registered successfully'); +}; diff --git a/src/main/loadDevTools.js b/src/main/loadDevTools.js new file mode 100644 index 0000000000..6cd039c957 --- /dev/null +++ b/src/main/loadDevTools.js @@ -0,0 +1,24 @@ +import log from './log.js'; + +const loadDevTools = () => { + if (process.env.NODE_ENV !== 'development') { + return Promise.resolve(null); + } + + // Only require devtools-installer in development mode + // Using dynamic import for dev-only dependency + return import('electron-devtools-installer') + .then( + ({ default: installExtension, REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS }) => + Promise.all([ + installExtension(REACT_DEVELOPER_TOOLS), + installExtension(REDUX_DEVTOOLS), + ]), + ) + .then((tools) => log.info(`Added Extension: ${tools.toString()}`)) + .catch((err) => { + log.warn('An error occurred: ', err); + }); +}; + +export default loadDevTools; diff --git a/public/components/log.js b/src/main/log.js similarity index 83% rename from public/components/log.js rename to src/main/log.js index bb9e4dbff6..ab991cea68 100644 --- a/public/components/log.js +++ b/src/main/log.js @@ -1,4 +1,4 @@ -const log = require('electron-log'); +import log from 'electron-log'; if (process.env.NODE_ENV !== 'development' || process.env.TEST) { log.transports.console.level = false; @@ -8,4 +8,4 @@ log.transports.file.format = '{h}:{i}:{s}:{ms} {text}'; log.transports.file.maxSize = 5 * 1024 * 1024; log.transports.file.streamConfig = { flags: 'w' }; -module.exports = log; +export default log; diff --git a/public/components/mainMenu.js b/src/main/mainMenu.js similarity index 73% rename from public/components/mainMenu.js rename to src/main/mainMenu.js index 24633d26c0..c456da2a33 100644 --- a/public/components/mainMenu.js +++ b/src/main/mainMenu.js @@ -1,9 +1,11 @@ -const { dialog } = require('electron'); -const { openDialog } = require('./dialogs'); +import { dialog } from 'electron'; -const openFile = (window) => () => openDialog() - .then((filePath) => window.webContents.send('OPEN_FILE', filePath)) - .catch((err) => console.log(err)); +import { openDialog } from './dialogs.js'; + +const openFile = (window) => () => + openDialog() + .then((filePath) => window.webContents.send('OPEN_FILE', filePath)) + .catch((_err) => {}); const MenuTemplate = (window) => { const appMenu = [ @@ -59,11 +61,13 @@ const MenuTemplate = (window) => { { label: 'Reset App Data...', click: () => { - dialog.showMessageBox({ - message: 'Destroy all application files and data?', - detail: 'This includes all application settings, imported protocols, and interview data.', - buttons: ['Reset Data', 'Cancel'], - }) + dialog + .showMessageBox({ + message: 'Destroy all application files and data?', + detail: + 'This includes all application settings, imported protocols, and interview data.', + buttons: ['Reset Data', 'Cancel'], + }) .then(({ response }) => { if (response === 0) { window.webContents.send('RESET_STATE'); @@ -75,10 +79,7 @@ const MenuTemplate = (window) => { }, { role: 'window', - submenu: [ - { role: 'minimize' }, - { role: 'close' }, - ], + submenu: [{ role: 'minimize' }, { role: 'close' }], }, ]; @@ -96,4 +97,4 @@ const MenuTemplate = (window) => { return menu; }; -module.exports = MenuTemplate; +export default MenuTemplate; diff --git a/public/components/windowManager.js b/src/main/windowManager.js similarity index 67% rename from public/components/windowManager.js rename to src/main/windowManager.js index 8fd9e0254a..3b63ce8b22 100644 --- a/public/components/windowManager.js +++ b/src/main/windowManager.js @@ -1,11 +1,26 @@ -const { BrowserWindow, Menu, shell } = require('electron'); -const mainMenu = require('./mainMenu'); -const appUrl = require('./appURL'); +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { BrowserWindow, Menu, shell } from 'electron'; + +import appUrl from './appURL.js'; +import mainMenu from './mainMenu.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const isMacOS = () => process.platform === 'darwin'; const isTest = () => !!process.env.TEST; -const titlebarParameters = isMacOS() ? { titleBarStyle: 'hidden', frame: false } : {}; +// Get path to the preload script +// electron-vite builds preload to out/preload/ in both dev and prod +function getPreloadPath() { + // __dirname is out/main/ since main process is bundled there + return path.join(__dirname, '../preload/index.js'); +} + +const titlebarParameters = isMacOS() + ? { titleBarStyle: 'hidden', frame: false } + : {}; let window; @@ -21,13 +36,13 @@ function loadApp(appWindow, cb) { } function createWindow() { - if (window) { return Promise.resolve(window); } + if (window) { + return Promise.resolve(window); + } return new Promise((resolve) => { // TODO: custom env var? NO_CONSTRAIN? - const minDimensions = isTest() - ? {} - : { minWidth: 1280, minHeight: 800 }; + const minDimensions = isTest() ? {} : { minWidth: 1280, minHeight: 800 }; // Create the browser window. const windowParameters = { @@ -36,9 +51,12 @@ function createWindow() { center: true, title: 'Network Canvas Interviewer', webPreferences: { - nodeIntegration: true, + nodeIntegration: false, + contextIsolation: true, + preload: getPreloadPath(), spellcheck: false, backgroundThrottling: false, // animations continue when the app isn't focused. + webSecurity: true, }, ...minDimensions, ...titlebarParameters, @@ -78,10 +96,12 @@ function createWindow() { } const windowManager = { - get hasWindow() { return !!window; }, + get hasWindow() { + return !!window; + }, getWindow: function getWindow() { return createWindow(); }, }; -module.exports = windowManager; +export default windowManager; diff --git a/src/preload/index.js b/src/preload/index.js new file mode 100644 index 0000000000..11b153be26 --- /dev/null +++ b/src/preload/index.js @@ -0,0 +1,178 @@ +/** + * Preload script for the Network Canvas preview window. + * Exposes a secure API via contextBridge for renderer process access. + * + * This replaces direct Node.js access (nodeIntegration: true) with a + * controlled, whitelisted set of IPC channels and operations. + * + * Note: This is loaded when Network Canvas runs as a preview window + * inside Architect. It provides the same API surface as the main + * Architect preload but with preview-specific channels. + */ +const { contextBridge, ipcRenderer } = require('electron'); + +// Capture process values before exposing (for sandbox compatibility) +const platformValue = process.platform; +const isDevelopment = process.env.NODE_ENV === 'development'; +const isProduction = process.env.NODE_ENV === 'production'; + +// Check if running in preview mode (launched by Architect) +// Architect passes --preview flag when launching preview window +const isPreviewMode = process.argv.includes('--preview'); + +// Whitelist of valid IPC channels for send (renderer -> main) +const validSendChannels = ['READY', 'add-cert', 'GET_ARGF', 'OPEN_DIALOG']; + +// Whitelist of valid IPC channels for receive (main -> renderer) +const validReceiveChannels = [ + 'remote:preview', + 'remote:reset', + 'OPEN_FILE', + 'RESET_STATE', + 'OPEN_SETTINGS_MENU', + 'EXIT_INTERVIEW', + 'add-cert-complete', + 'GET_ARGF', +]; + +contextBridge.exposeInMainWorld('electronAPI', { + // =================== + // IPC Communication + // =================== + ipc: { + send: (channel, ...args) => { + if (validSendChannels.includes(channel)) { + ipcRenderer.send(channel, ...args); + } else { + } + }, + on: (channel, callback) => { + if (validReceiveChannels.includes(channel)) { + const subscription = (_event, ...args) => callback(...args); + ipcRenderer.on(channel, subscription); + return () => ipcRenderer.removeListener(channel, subscription); + } + return () => {}; + }, + once: (channel, callback) => { + if (validReceiveChannels.includes(channel)) { + ipcRenderer.once(channel, (_event, ...args) => callback(...args)); + } else { + } + }, + removeAllListeners: (channel) => { + if (validReceiveChannels.includes(channel)) { + ipcRenderer.removeAllListeners(channel); + } + }, + }, + + // =================== + // Dialog Operations + // =================== + dialog: { + showOpenDialog: (options) => ipcRenderer.invoke('dialog:showOpen', options), + showSaveDialog: (options) => ipcRenderer.invoke('dialog:showSave', options), + showMessageBox: (options) => + ipcRenderer.invoke('dialog:showMessageBox', options), + }, + + // =================== + // App Info + // =================== + app: { + getPath: (name) => ipcRenderer.invoke('app:getPath', name), + getAppPath: () => ipcRenderer.invoke('app:getAppPath'), + getVersion: () => ipcRenderer.invoke('app:getVersion'), + }, + + // =================== + // File System Operations + // =================== + fs: { + readFile: (filePath, encoding) => + ipcRenderer.invoke('fs:readFile', filePath, encoding), + writeFile: (filePath, data, isBinary) => + ipcRenderer.invoke('fs:writeFile', filePath, data, isBinary), + rename: (oldPath, newPath) => + ipcRenderer.invoke('fs:rename', oldPath, newPath), + mkdirp: (dirPath) => ipcRenderer.invoke('fs:mkdirp', dirPath), + mkdir: (dirPath, options) => + ipcRenderer.invoke('fs:mkdir', dirPath, options), + rmdir: (dirPath) => ipcRenderer.invoke('fs:rmdir', dirPath), + }, + + // =================== + // Path Operations + // =================== + path: { + join: (...args) => ipcRenderer.invoke('path:join', ...args), + basename: (filePath, ext) => + ipcRenderer.invoke('path:basename', filePath, ext), + dirname: (filePath) => ipcRenderer.invoke('path:dirname', filePath), + extname: (filePath) => ipcRenderer.invoke('path:extname', filePath), + parse: (filePath) => ipcRenderer.invoke('path:parse', filePath), + resolve: (...args) => ipcRenderer.invoke('path:resolve', ...args), + normalize: (filePath) => ipcRenderer.invoke('path:normalize', filePath), + relative: (from, to) => ipcRenderer.invoke('path:relative', from, to), + }, + + // =================== + // Protocol Operations + // =================== + protocol: { + download: (uri) => ipcRenderer.invoke('protocol:download', uri), + }, + + // =================== + // Archive Operations + // =================== + archive: { + create: (sourcePath, destPath) => + ipcRenderer.invoke('archive:create', sourcePath, destPath), + extract: (sourcePath, destPath) => + ipcRenderer.invoke('archive:extract', sourcePath, destPath), + }, + + // =================== + // Shell Operations + // =================== + shell: { + openExternal: (url) => ipcRenderer.invoke('shell:openExternal', url), + openPath: (filePath) => ipcRenderer.invoke('shell:openPath', filePath), + }, + + // =================== + // Window Operations + // =================== + window: { + hide: () => ipcRenderer.invoke('window:hide'), + show: () => ipcRenderer.invoke('window:show'), + close: () => ipcRenderer.invoke('window:close'), + setFullScreen: (flag) => ipcRenderer.invoke('window:setFullScreen', flag), + isFullScreen: () => ipcRenderer.invoke('window:isFullScreen'), + }, + + // =================== + // WebFrame Operations (for zoom control) + // =================== + webFrame: { + setVisualZoomLevelLimits: (min, max) => + ipcRenderer.invoke('webFrame:setVisualZoomLevelLimits', min, max), + }, + + // =================== + // Platform Info + // =================== + platform: platformValue, + + // =================== + // Environment Info + // =================== + env: { + isDevelopment, + isProduction, + isPreview: isPreviewMode, + platform: platformValue, + }, +}); diff --git a/src/protocol-consts.js b/src/protocol-consts.js index bacd3dfad1..48c4f7e392 100644 --- a/src/protocol-consts.js +++ b/src/protocol-consts.js @@ -1,17 +1,27 @@ -import { VariableType } from './utils/network-exporters/src/utils/protocol-consts'; - // String consts used by protocol files // Note: these values are no longer used to produce JSON schemas; the schemas must // be kept in sync manually. +// Docs: https://github.com/codaco/Network-Canvas/wiki/Variable-Types +const VariableType = Object.freeze({ + boolean: 'boolean', + text: 'text', + number: 'number', + ordinal: 'ordinal', + categorical: 'categorical', + layout: 'layout', + scalar: 'scalar', + datetime: 'datetime', +}); + // Docs: https://github.com/complexdatacollective/Network-Canvas/wiki/protocol.json#variable-registry -export const Entity = Object.freeze({ +const Entity = Object.freeze({ edge: 'edge', node: 'node', }); // Docs: https://github.com/complexdatacollective/Network-Canvas/wiki/Skip-Logic -export const FilterJoin = Object.freeze({ +const FilterJoin = Object.freeze({ OR: 'OR', AND: 'AND', }); @@ -19,7 +29,7 @@ export const FilterJoin = Object.freeze({ // Docs: https://github.com/complexdatacollective/Network-Canvas/wiki/Skip-Logic // TODO: expected to match https://github.com/complexdatacollective/networkQuery/blob/master/predicate.js; // could support node syntax there, or introduce babel-node here. -export const FilterOptionsOperator = Object.freeze({ +const FilterOptionsOperator = Object.freeze({ EXISTS: 'EXISTS', NOT_EXISTS: 'NOT_EXISTS', EXACTLY: 'EXACTLY', @@ -61,7 +71,7 @@ export const AssetType = Object.freeze({ }); // Docs: https://github.com/complexdatacollective/Network-Canvas/wiki/Skip-Logic#skip-logic-api -export const RuleType = Object.freeze({ +const RuleType = Object.freeze({ alter: 'alter', ego: 'ego', edge: 'edge', @@ -74,7 +84,7 @@ export const SkipLogicAction = Object.freeze({ }); // Docs: https://github.com/complexdatacollective/Network-Canvas/wiki/Skip-Logic -export const SkipLogicOperator = Object.freeze({ +const SkipLogicOperator = Object.freeze({ ANY: 'ANY', NONE: 'NONE', EXACTLY: 'EXACTLY', @@ -104,9 +114,6 @@ export const StageType = Object.freeze({ TieStrengthCensus: 'TieStrengthCensus', }); -// VariableTYpe imported from network-exporters submodule -// Docs: https://github.com/complexdatacollective/Network-Canvas/wiki/Variable-Types - const enumValueMaps = Object.freeze({ Entity, FilterJoin, diff --git a/src/routes.js b/src/routes.jsx similarity index 64% rename from src/routes.js rename to src/routes.jsx index c65d97e806..8658cb6711 100644 --- a/src/routes.js +++ b/src/routes.jsx @@ -1,17 +1,8 @@ -/* eslint-disable react/jsx-props-no-spreading */ -import React from 'react'; import PropTypes from 'prop-types'; -import { - Route, - Redirect, - Switch, -} from 'react-router-dom'; import { connect } from 'react-redux'; +import { Redirect, Route, Switch } from 'react-router-dom'; -import { - LoadParamsRoute, - ProtocolScreen, -} from './containers'; +import { LoadParamsRoute, ProtocolScreen } from './containers'; import { StartScreen } from './containers/StartScreen'; function mapStateToProps(state) { @@ -21,15 +12,12 @@ function mapStateToProps(state) { } // If there is an activeSessionId, redirect to it -let SetupRequiredRoute = ( - { component: Component, sessionId, ...rest }, -) => ( +let SetupRequiredRoute = ({ component: Component, sessionId, ...rest }) => sessionId ? ( ) : ( - ) -); + ); SetupRequiredRoute.propTypes = { component: PropTypes.func.isRequired, @@ -41,9 +29,17 @@ SetupRequiredRoute = connect(mapStateToProps)(SetupRequiredRoute); export default () => ( - + - + diff --git a/src/selectors/__mocks__/interface.js b/src/selectors/__mocks__/interface.js index 91014c5e80..d1bb6c9d03 100644 --- a/src/selectors/__mocks__/interface.js +++ b/src/selectors/__mocks__/interface.js @@ -1,4 +1,3 @@ -/* eslint-env jest */ -/* eslint-disable import/prefer-default-export */ +import { vi } from 'vitest'; -export const makeNetworkEntitiesForType = jest.fn(() => () => ({})); +export const makeNetworkEntitiesForType = vi.fn(() => () => ({})); diff --git a/src/selectors/__mocks__/session.js b/src/selectors/__mocks__/session.js index 6cc7ffd85d..a197584f1c 100644 --- a/src/selectors/__mocks__/session.js +++ b/src/selectors/__mocks__/session.js @@ -1,4 +1,3 @@ -/* eslint-env jest */ -/* eslint-disable import/prefer-default-export */ +import { vi } from 'vitest'; -export const getCodebookVariablesForType = jest.fn(() => () => ({})); +export const getCodebookVariablesForType = vi.fn(() => () => ({})); diff --git a/src/selectors/__tests__/canvas.test.js b/src/selectors/__tests__/canvas.test.js index 81391930a3..5c66f19fc3 100644 --- a/src/selectors/__tests__/canvas.test.js +++ b/src/selectors/__tests__/canvas.test.js @@ -1,23 +1,94 @@ -/* eslint-disable @codaco/spellcheck/spell-checker */ -/* eslint-env jest */ import { entityAttributesProperty } from '@codaco/shared-consts'; -import { - getNextUnplacedNode, - getPlacedNodes, -} from '../canvas'; - -const node1 = { _uid: 1, type: 'person', [entityAttributesProperty]: { role: ['a'], name: 'alpha', personLayout: [1, 1] } }; -const node2 = { _uid: 2, type: 'person', [entityAttributesProperty]: { role: ['a'], name: 'foxtrot', personLayout: null } }; -const node3 = { _uid: 3, type: 'person', [entityAttributesProperty]: { role: ['a'], name: 'bravo', personLayout: null } }; -const node4 = { _uid: 4, type: 'person', [entityAttributesProperty]: { role: ['a'], name: 'echo', personLayout: [1, 1] } }; -const node5 = { _uid: 5, type: 'person', [entityAttributesProperty]: { role: [2], name: 'charlie', personLayout: [1, 1] } }; -const node6 = { _uid: 6, type: 'place', [entityAttributesProperty]: { role: [2], name: 'delta', placeLayout: [1, 1] } }; -const node7 = { _uid: 7, type: 'place', [entityAttributesProperty]: { role: [2], name: 'golf', placeLayout: null } }; -const node8 = { _uid: 8, type: 'place', [entityAttributesProperty]: { role: [2], name: 'hotel', placeLayout: null } }; -const node9 = { _uid: 9, type: 'place', [entityAttributesProperty]: { role: [2], name: 'india', placeLayout: [1, 1] } }; -const node10 = { _uid: 10, type: 'place', [entityAttributesProperty]: { role: [2], name: 'juliet', placeLayout: [1, 1] } }; - -const mockNodes = [node1, node2, node3, node4, node5, node6, node7, node8, node9, node10]; + +import { getNextUnplacedNode, getPlacedNodes } from '../canvas'; + +const node1 = { + _uid: 1, + type: 'person', + [entityAttributesProperty]: { + role: ['a'], + name: 'alpha', + personLayout: [1, 1], + }, +}; +const node2 = { + _uid: 2, + type: 'person', + [entityAttributesProperty]: { + role: ['a'], + name: 'foxtrot', + personLayout: null, + }, +}; +const node3 = { + _uid: 3, + type: 'person', + [entityAttributesProperty]: { + role: ['a'], + name: 'bravo', + personLayout: null, + }, +}; +const node4 = { + _uid: 4, + type: 'person', + [entityAttributesProperty]: { + role: ['a'], + name: 'echo', + personLayout: [1, 1], + }, +}; +const node5 = { + _uid: 5, + type: 'person', + [entityAttributesProperty]: { + role: [2], + name: 'charlie', + personLayout: [1, 1], + }, +}; +const node6 = { + _uid: 6, + type: 'place', + [entityAttributesProperty]: { role: [2], name: 'delta', placeLayout: [1, 1] }, +}; +const node7 = { + _uid: 7, + type: 'place', + [entityAttributesProperty]: { role: [2], name: 'golf', placeLayout: null }, +}; +const node8 = { + _uid: 8, + type: 'place', + [entityAttributesProperty]: { role: [2], name: 'hotel', placeLayout: null }, +}; +const node9 = { + _uid: 9, + type: 'place', + [entityAttributesProperty]: { role: [2], name: 'india', placeLayout: [1, 1] }, +}; +const node10 = { + _uid: 10, + type: 'place', + [entityAttributesProperty]: { + role: [2], + name: 'juliet', + placeLayout: [1, 1], + }, +}; + +const mockNodes = [ + node1, + node2, + node3, + node4, + node5, + node6, + node7, + node8, + node9, + node10, +]; const mockSingleSubject = { subject: { @@ -58,8 +129,8 @@ const mockState = { mockProtocol: { codebook: { node: { - person: {}, - place: {}, + person: { variables: { name: { type: 'text' } } }, + place: { variables: { name: { type: 'text' } } }, }, }, stages: [ @@ -92,8 +163,8 @@ const mockTwoModeState = { mockProtocol: { codebook: { node: { - person: {}, - place: {}, + person: { variables: { name: { type: 'text' } } }, + place: { variables: { name: { type: 'text' } } }, }, }, stages: [ @@ -121,16 +192,12 @@ describe('canvas selectors', () => { const subject = getPlacedNodes(mockState, props); - expect(subject).toEqual([ - node1, - node4, - node5, - ]); + expect(subject).toEqual([node1, node4, node5]); }); }); describe('makeGetNextUnplacedNode()', () => { - it.only('selects the next unplaced node', () => { + it('selects the next unplaced node', () => { const props = { prompt: { layout: { @@ -141,9 +208,7 @@ describe('canvas selectors', () => { const subject = getNextUnplacedNode(mockState, props); - expect(subject).toMatchObject( - { _uid: 2 }, - ); + expect(subject).toMatchObject({ _uid: 2 }); }); }); @@ -165,9 +230,7 @@ describe('canvas selectors', () => { const subject = getNextUnplacedNode(mockState, props); - expect(subject).toMatchObject( - { _uid: 3 }, - ); + expect(subject).toMatchObject({ _uid: 3 }); }); }); }); @@ -188,24 +251,15 @@ describe('canvas selectors', () => { it('selects all placed nodes', () => { const subject = getPlacedNodes(mockTwoModeState, props); - expect(subject).toEqual([ - node1, - node4, - node5, - node6, - node9, - node10, - ]); + expect(subject).toEqual([node1, node4, node5, node6, node9, node10]); }); }); describe('makeGetNextUnplacedNode()', () => { - it.only('selects the next unplaced node', () => { + it('selects the next unplaced node', () => { const subject = getNextUnplacedNode(mockTwoModeState, props); - expect(subject).toMatchObject( - { _uid: 2 }, - ); + expect(subject).toMatchObject({ _uid: 2 }); }); }); @@ -232,9 +286,7 @@ describe('canvas selectors', () => { const subject = getNextUnplacedNode(mockTwoModeState, propsWithSort); - expect(subject).toMatchObject( - { _uid: 7 }, - ); + expect(subject).toMatchObject({ _uid: 7 }); }); }); }); diff --git a/src/selectors/__tests__/externalData.test.js b/src/selectors/__tests__/externalData.test.js index 466014bf52..b1ab468011 100644 --- a/src/selectors/__tests__/externalData.test.js +++ b/src/selectors/__tests__/externalData.test.js @@ -1,6 +1,3 @@ -/* eslint-env jest */ -/* eslint-disable @codaco/spellcheck/spell-checker */ - import { getExternalData } from '../externalData'; const externalData = { diff --git a/src/selectors/__tests__/interface.test.js b/src/selectors/__tests__/interface.test.js index 51ded47d9d..e16923f9f0 100644 --- a/src/selectors/__tests__/interface.test.js +++ b/src/selectors/__tests__/interface.test.js @@ -1,6 +1,3 @@ -/* eslint-env jest */ -/* eslint-disable @codaco/spellcheck/spell-checker */ - import * as Interface from '../interface'; const mockPrompt = { @@ -78,7 +75,7 @@ const mockProtocol = { node: { person: { displayVariable: 'name', - iconVariant: 'add-a-person', + icon: 'add-a-person', variables: { nickname: { type: 'text', @@ -110,20 +107,32 @@ const emptyProps = { }; const personNode = { - uid: 1, promptIDs: ['promptIdxxx'], type: 'person', attributes: { name: 'foo' }, + uid: 1, + promptIDs: ['promptIdxxx'], + type: 'person', + attributes: { name: 'foo' }, }; const closeFriendNode = { - uid: 2, promptIDs: ['promptId123'], type: 'person', attributes: { name: 'bar', close_friend: true }, + uid: 2, + promptIDs: ['promptId123'], + type: 'person', + attributes: { name: 'bar', close_friend: true }, }; const nodes = [ personNode, closeFriendNode, { - uid: 3, promptIDs: ['promptId456'], attributes: { name: 'baz' }, type: 'venue', + uid: 3, + promptIDs: ['promptId456'], + attributes: { name: 'baz' }, + type: 'venue', }, ]; -const edges = [{ to: 'bar', from: 'foo' }, { to: 'asdf', from: 'qwerty' }]; +const edges = [ + { to: 'bar', from: 'foo' }, + { to: 'asdf', from: 'qwerty' }, +]; const mockState = { sessions: { @@ -149,7 +158,9 @@ describe('interface selector', () => { it('makeGetAdditionalAttributes()', () => { const selected = Interface.makeGetAdditionalAttributes(); - expect(selected(mockState, mockProps)).toEqual({ 'b6f2c4b9-e42f-459b-8f59-a11a685f460d': 2 }); + expect(selected(mockState, mockProps)).toEqual({ + 'b6f2c4b9-e42f-459b-8f59-a11a685f460d': 2, + }); expect(selected(null, emptyProps)).toEqual({}); }); @@ -166,8 +177,18 @@ describe('interface selector', () => { it('makeGetVariableOptions', () => { const selected = Interface.makeGetVariableOptions(); - expect(selected(mockState, { ...mockProps, prompt: { ...mockPrompt, variable: 'cat1' } })).toEqual([123, 456]); - expect(selected(mockState, { ...mockProps, prompt: { ...mockPrompt, variable: 'ord1' } })).toEqual([]); + expect( + selected(mockState, { + ...mockProps, + prompt: { ...mockPrompt, variable: 'cat1' }, + }), + ).toEqual([123, 456]); + expect( + selected(mockState, { + ...mockProps, + prompt: { ...mockPrompt, variable: 'ord1' }, + }), + ).toEqual([]); }); it('makeNetworkNodesForType()', () => { @@ -190,9 +211,7 @@ describe('interface selector', () => { it('makeNetworkNodesForPrompt()', () => { const selected = Interface.makeNetworkNodesForPrompt(); - expect(selected(mockState, mockProps)).toEqual([ - closeFriendNode, - ]); + expect(selected(mockState, mockProps)).toEqual([closeFriendNode]); expect(selected(mockState, emptyProps).length).toEqual(0); }); diff --git a/src/selectors/__tests__/name-generator.test.js b/src/selectors/__tests__/name-generator.test.js index 00ddb25eea..b25e53f66d 100644 --- a/src/selectors/__tests__/name-generator.test.js +++ b/src/selectors/__tests__/name-generator.test.js @@ -1,6 +1,3 @@ -/* eslint-env jest */ -/* eslint-disable @codaco/spellcheck/spell-checker */ - import * as NameGen from '../name-generator'; const mockPrompt = { @@ -20,19 +17,19 @@ const mockStage = { entity: 'node', type: 'person', }, - panels: [ - { foo: 'bar' }, - ], + panels: [{ foo: 'bar' }], cardOptions: { displayLabel: 'card label', additionalProperties: ['blue'], }, sortOptions: { sortableProperties: ['age'], - sortOrder: [{ - property: 'name', - direction: 'asc', - }], + sortOrder: [ + { + property: 'name', + direction: 'asc', + }, + ], }, dataSource: 'schoolPupils', }; @@ -41,7 +38,7 @@ const mockProtocol = { codebook: { node: { person: { - iconVariant: 'add-a-person', + icon: 'add-a-person', }, }, }, @@ -61,11 +58,21 @@ const emptyProps = { }; const personNode = { uid: 1, attributes: { name: 'foo', type: 'person' } }; -const closeFriendNode = { uid: 2, attributes: { name: 'bar', type: 'person', close_friend: true } }; +const closeFriendNode = { + uid: 2, + attributes: { name: 'bar', type: 'person', close_friend: true }, +}; -const nodes = [personNode, closeFriendNode, { uid: 3, attributes: { name: 'baz', type: 'venue' } }]; +const nodes = [ + personNode, + closeFriendNode, + { uid: 3, attributes: { name: 'baz', type: 'venue' } }, +]; -const edges = [{ to: 'bar', from: 'foo' }, { to: 'asdf', from: 'qwerty' }]; +const edges = [ + { to: 'bar', from: 'foo' }, + { to: 'asdf', from: 'qwerty' }, +]; const mockState = { activeSessionId: 'a', @@ -86,31 +93,27 @@ describe('name generator selector', () => { it('returns an array of panel configurations', () => { const subject = getPanelConfiguration(mockState, mockProps); - expect(subject[0]).toMatchObject( - { - dataSource: 'existing', - foo: 'bar', - title: '', - }, - ); + expect(subject[0]).toMatchObject({ + dataSource: 'existing', + foo: 'bar', + title: '', + }); expect(subject[0]).toHaveProperty('filter'); }); it('always returns an array', () => { - const subject = getPanelConfiguration( - mockState, - { - stage: { - }, - }, - ); + const subject = getPanelConfiguration(mockState, { + stage: {}, + }); expect(subject).toEqual([]); }); }); describe('memoed selectors', () => { it('should get card additional properties', () => { - expect(NameGen.getCardAdditionalProperties(mockState, mockProps)).toEqual(['blue']); + expect(NameGen.getCardAdditionalProperties(mockState, mockProps)).toEqual( + ['blue'], + ); expect(NameGen.getCardAdditionalProperties(null, emptyProps)).toEqual([]); }); @@ -120,7 +123,9 @@ describe('name generator selector', () => { }); it('should get sort defaults', () => { - expect(NameGen.getInitialSortOrder(mockState, mockProps)).toEqual([{ property: 'name', direction: 'asc' }]); + expect(NameGen.getInitialSortOrder(mockState, mockProps)).toEqual([ + { property: 'name', direction: 'asc' }, + ]); expect(NameGen.getInitialSortOrder(null, emptyProps)).toEqual([]); }); diff --git a/src/selectors/__tests__/network.test.js b/src/selectors/__tests__/network.test.js index 1cd1c0212d..971fa295e0 100644 --- a/src/selectors/__tests__/network.test.js +++ b/src/selectors/__tests__/network.test.js @@ -1,6 +1,3 @@ -/* eslint-env jest */ -/* eslint-disable @codaco/spellcheck/spell-checker */ - import * as Network from '../network'; const mockNodeCodebookDefinition = { @@ -67,12 +64,15 @@ describe('network selector', () => { }); it('handles irregularly capitalized codebook variable name', () => { - const label = Network.labelLogic(mockNodeCodebookDefinitionWithCaps, mockNode1); + const label = Network.labelLogic( + mockNodeCodebookDefinitionWithCaps, + mockNode1, + ); expect(label).toEqual('Node Label'); }); // Handles external data - it('uses the a variable called \'name\' on the node itself', () => { + it("uses the a variable called 'name' on the node itself", () => { const label = Network.labelLogic(mockNodeCodebookDefinition, mockNode2); expect(label).toEqual('Node Label'); }); @@ -84,7 +84,7 @@ describe('network selector', () => { it('returns fallback message when no suitable label is available', () => { const label = Network.labelLogic(mockNodeCodebookDefinition, mockNode3); - expect(label).toEqual('No \'name\' variable!'); + expect(label).toEqual("No 'name' variable!"); }); it('correctly handles multiple possible node attributes by using the first', () => { @@ -105,7 +105,10 @@ describe('network selector', () => { }, }; - expect(Network.getNodeLabel(mockState, 'person')(mockNode1)) - .toEqual('Node Label'); + it('returns the correct node label', () => { + expect(Network.getNodeLabel(mockState, 'person')(mockNode1)).toEqual( + 'Node Label', + ); + }); }); }); diff --git a/src/selectors/__tests__/protocol.test.js b/src/selectors/__tests__/protocol.test.js index 3fdeed0b64..26f79c6b9a 100644 --- a/src/selectors/__tests__/protocol.test.js +++ b/src/selectors/__tests__/protocol.test.js @@ -1,11 +1,8 @@ -/* eslint-env jest */ -/* eslint-disable @codaco/spellcheck/spell-checker */ - import * as Protocol from '../protocol'; const nodeVariables = { person: { - iconVariant: 'add-a-person', + icon: 'add-a-person', color: 'node-color-seq-2', }, }; @@ -28,7 +25,10 @@ const mockProtocol = { const mockState = { activeSessionId: 'mockSession', - activeSessionWorkers: { nodeLabelWorker: 'blob:http://192.168.1.196:3000/b6cac5c5-1b4d-4db0-be86-fa55239fd62c' }, + activeSessionWorkers: { + nodeLabelWorker: + 'blob:http://192.168.1.196:3000/b6cac5c5-1b4d-4db0-be86-fa55239fd62c', + }, deviceSettings: { description: 'Kirby (macOS)', useDynamicScaling: true, @@ -50,7 +50,6 @@ const mockState = { }, mockProtocol, }, - pairedServer: null, search: { collapsed: true, selectedResults: [], @@ -64,7 +63,7 @@ const mockState = { stageIndex: 0, updatedAt: 1554130548004, }, - mockSession: { + 'mockSession': { protocolUID: 'mockProtocol', }, }, @@ -78,7 +77,9 @@ const emptyState = { describe('protocol selector', () => { describe('memoed selectors', () => { it('should get protocol codebook', () => { - expect(Protocol.getProtocolCodebook(mockState)).toEqual({ node: nodeVariables }); + expect(Protocol.getProtocolCodebook(mockState)).toEqual({ + node: nodeVariables, + }); expect(Protocol.getProtocolCodebook(emptyState)).toEqual(undefined); }); }); diff --git a/src/selectors/__tests__/search.test.js b/src/selectors/__tests__/search.test.js index cf1a8e1179..1a22527939 100644 --- a/src/selectors/__tests__/search.test.js +++ b/src/selectors/__tests__/search.test.js @@ -1,6 +1,3 @@ -/* eslint-env jest */ -/* eslint-disable @codaco/spellcheck/spell-checker */ - import * as Search from '../search'; const DefaultFuseOpts = { @@ -25,9 +22,12 @@ const mockProps = { const mockState = { externalData: { schoolPupils: { - nodes: [externalNode, { - name: 'C. Ronaldo', - }], + nodes: [ + externalNode, + { + name: 'C. Ronaldo', + }, + ], }, }, }; diff --git a/src/selectors/__tests__/session.test.js b/src/selectors/__tests__/session.test.js index dc77f953e6..676a3eba0d 100644 --- a/src/selectors/__tests__/session.test.js +++ b/src/selectors/__tests__/session.test.js @@ -1,5 +1,3 @@ -/* eslint-env jest */ - import { getSessionPath } from '../session'; const activeSessionId = 'foo'; @@ -11,8 +9,12 @@ describe('getSessionPath()', () => { }); it('returns stage path for any index', () => { - expect(getSessionPath(mockState, 0)).toEqual(`/session/${activeSessionId}/0`); + expect(getSessionPath(mockState, 0)).toEqual( + `/session/${activeSessionId}/0`, + ); - expect(getSessionPath(mockState, 5)).toEqual(`/session/${activeSessionId}/5`); + expect(getSessionPath(mockState, 5)).toEqual( + `/session/${activeSessionId}/5`, + ); }); }); diff --git a/src/selectors/__tests__/skip-logic.test.js b/src/selectors/__tests__/skip-logic.test.js index 30d2383dd5..a0feabdf68 100644 --- a/src/selectors/__tests__/skip-logic.test.js +++ b/src/selectors/__tests__/skip-logic.test.js @@ -1,9 +1,5 @@ -/* eslint-env jest */ -/* eslint-disable @codaco/spellcheck/spell-checker */ - -import { getNextIndex, isStageSkipped } from '../skip-logic'; - import { getProtocolStages } from '../protocol'; +import { getNextIndex, isStageSkipped } from '../skip-logic'; const mockState = { activeSessionId: 'a', @@ -13,7 +9,10 @@ const mockState = { network: { edges: [ { - id: 1, type: 'friend', to: 1, from: 2, + id: 1, + type: 'friend', + to: 1, + from: 2, }, ], ego: {}, diff --git a/src/selectors/activeSessionWorkers.js b/src/selectors/activeSessionWorkers.js index 1010d206be..7003c12589 100644 --- a/src/selectors/activeSessionWorkers.js +++ b/src/selectors/activeSessionWorkers.js @@ -1,4 +1,5 @@ import { createSelector } from 'reselect'; + import { NodeLabelWorkerName } from '../utils/WorkerAgent'; const getActiveSessionWorkers = (state) => state.activeSessionWorkers; @@ -6,9 +7,7 @@ const getActiveSessionWorkers = (state) => state.activeSessionWorkers; export const getNodeLabelWorkerUrl = createSelector( getActiveSessionWorkers, // null if URLs haven't yet loaded; false if worker does not exist - (activeSessionWorkers) => ( - activeSessionWorkers && (activeSessionWorkers[NodeLabelWorkerName] || false) - ), + (activeSessionWorkers) => + activeSessionWorkers && + (activeSessionWorkers[NodeLabelWorkerName] || false), ); - -export default getNodeLabelWorkerUrl; diff --git a/src/selectors/canvas.js b/src/selectors/canvas.js index fcd8cd8311..1dcd68b7a2 100644 --- a/src/selectors/canvas.js +++ b/src/selectors/canvas.js @@ -1,17 +1,17 @@ +import { first, has, isArray, isNil } from 'lodash'; + import { - first, - has, - isArray, - isNil, -} from 'lodash'; -import { entityAttributesProperty, entityPrimaryKeyProperty } from '@codaco/shared-consts'; -import { getNetworkNodes, getNetworkEdges } from './network'; -import { createDeepEqualSelector } from './utils'; -import createSorter, { processProtocolSortRule } from '../utils/createSorter'; + entityAttributesProperty, + entityPrimaryKeyProperty, +} from '@codaco/shared-consts'; + import { getEntityAttributes } from '../ducks/modules/network'; -import { getStageSubject } from './session'; +import createSorter, { processProtocolSortRule } from '../utils/createSorter'; import { get } from '../utils/lodash-replacements'; +import { getNetworkEdges, getNetworkNodes } from './network'; import { getAllVariableUUIDsByEntity } from './protocol'; +import { getStageSubject } from './session'; +import { createDeepEqualSelector } from './utils'; const getLayout = (_, props) => get(props, 'prompt.layout.layoutVariable'); const getSortOptions = (_, props) => get(props, 'prompt.sortOrder', null); @@ -32,33 +32,47 @@ export const getNextUnplacedNode = createDeepEqualSelector( getSortOptions, getAllVariableUUIDsByEntity, (nodes, subject, layoutVariable, sortOptions, codebookVariables) => { - if (nodes && nodes.length === 0) { return null; } - if (!subject) { return null; } + if (nodes && nodes.length === 0) { + return null; + } + if (!subject) { + return null; + } // Stage subject is either a single object or a collection of objects - const types = isArray(subject) ? subject.map((s) => s.type) : [subject.type]; + const types = isArray(subject) + ? subject.map((s) => s.type) + : [subject.type]; // Layout variable is either a string (single stage subject) or an object // keyed by node type (two-mode stage subject) const layoutVariableForType = (type) => { - if (typeof layoutVariable === 'string') { return layoutVariable; } + if (typeof layoutVariable === 'string') { + return layoutVariable; + } return layoutVariable[type]; }; const unplacedNodes = nodes.filter((node) => { const attributes = getEntityAttributes(node); return ( - types.includes(node.type) - && (has(attributes, layoutVariableForType(node.type))) - && isNil(attributes[layoutVariableForType(node.type)]) + types.includes(node.type) && + has(attributes, layoutVariableForType(node.type)) && + isNil(attributes[layoutVariableForType(node.type)]) ); }); - if (unplacedNodes.length === 0) { return undefined; } - if (!sortOptions) { return first(unplacedNodes); } + if (unplacedNodes.length === 0) { + return undefined; + } + if (!sortOptions) { + return first(unplacedNodes); + } // Protocol sort rules must be processed to be used by createSorter - const processedSortRules = sortOptions.map(processProtocolSortRule(codebookVariables)); + const processedSortRules = sortOptions.map( + processProtocolSortRule(codebookVariables), + ); const sorter = createSorter(processedSortRules); return first(sorter(unplacedNodes)); }, @@ -77,24 +91,32 @@ export const getPlacedNodes = createDeepEqualSelector( getStageSubject(), getLayout, (nodes, subject, layoutVariable) => { - if (nodes && nodes.length === 0) { return []; } - if (!subject) { return []; } + if (nodes && nodes.length === 0) { + return []; + } + if (!subject) { + return []; + } // Stage subject is either a single object or a collecton of objects - const types = isArray(subject) ? subject.map((s) => s.type) : [subject.type]; + const types = isArray(subject) + ? subject.map((s) => s.type) + : [subject.type]; // Layout variable is either a string or an object keyed by node type const layoutVariableForType = (type) => { - if (typeof layoutVariable === 'string') { return layoutVariable; } + if (typeof layoutVariable === 'string') { + return layoutVariable; + } return layoutVariable[type]; }; return nodes.filter((node) => { const attributes = getEntityAttributes(node); return ( - types.includes(node.type) - && has(attributes, layoutVariableForType(node.type)) - && !isNil(attributes[layoutVariableForType(node.type)]) + types.includes(node.type) && + has(attributes, layoutVariableForType(node.type)) && + !isNil(attributes[layoutVariableForType(node.type)]) ); }); }, @@ -104,7 +126,9 @@ const edgeCoords = (edge, { nodes, layout }) => { const from = nodes.find((n) => n[entityPrimaryKeyProperty] === edge.from); const to = nodes.find((n) => n[entityPrimaryKeyProperty] === edge.to); - if (!from || !to) { return { from: null, to: null }; } + if (!from || !to) { + return { from: null, to: null }; + } return { key: `${edge.from}_${edge.type}_${edge.to}`, @@ -114,12 +138,8 @@ const edgeCoords = (edge, { nodes, layout }) => { }; }; -export const edgesToCoords = (edges, { nodes, layout }) => edges.map( - (edge) => edgeCoords( - edge, - { nodes, layout }, - ), -); +export const edgesToCoords = (edges, { nodes, layout }) => + edges.map((edge) => edgeCoords(edge, { nodes, layout })); /** * Selector for edges. @@ -130,9 +150,8 @@ export const edgesToCoords = (edges, { nodes, layout }) => edges.map( export const getEdges = createDeepEqualSelector( getNetworkEdges, getDisplayEdges, - (edges, displayEdges) => edges.filter( - (edge) => displayEdges.includes(edge.type), - ), + (edges, displayEdges) => + edges.filter((edge) => displayEdges.includes(edge.type)), ); // Selector for stage nodes @@ -140,7 +159,9 @@ export const getNodes = createDeepEqualSelector( getNetworkNodes, getStageSubject(), // This is either a subject object or a collection of subject objects (nodes, subject) => { - if (!subject) { return nodes; } + if (!subject) { + return nodes; + } if (isArray(subject)) { const subjects = subject.map((s) => s.type); diff --git a/src/selectors/externalData.js b/src/selectors/externalData.js index fd0d58eb03..2db15a7ceb 100644 --- a/src/selectors/externalData.js +++ b/src/selectors/externalData.js @@ -1,4 +1,3 @@ -/* eslint-disable import/prefer-default-export */ import { createDeepEqualSelector } from './utils'; export const getExternalData = createDeepEqualSelector( diff --git a/src/selectors/forms.js b/src/selectors/forms.js index 61ce3a71e0..41ee8d8b03 100644 --- a/src/selectors/forms.js +++ b/src/selectors/forms.js @@ -1,4 +1,5 @@ import { createSelector } from 'reselect'; + import { get } from '../utils/lodash-replacements'; import { getProtocolCodebook } from './protocol'; @@ -9,14 +10,18 @@ const propStageSubject = (_, props) => props.subject || { entity: 'ego' }; // MemoedSelectors -export const rehydrateField = ({ - codebook, entity, type, field, -}) => { - if (!field.variable) { return field; } +const rehydrateField = ({ codebook, entity, type, field }) => { + if (!field.variable) { + return field; + } const entityPath = entity === 'ego' ? [entity] : [entity, type]; - const entityProperties = get(codebook, [...entityPath, 'variables', field.variable], {}); + const entityProperties = get( + codebook, + [...entityPath, 'variables', field.variable], + {}, + ); return { ...entityProperties, @@ -26,13 +31,18 @@ export const rehydrateField = ({ }; }; -export const makeRehydrateFields = () => createSelector( - propStageSubject, - propFields, - (state, props) => getProtocolCodebook(state, props), - ({ entity, type }, fields, codebook) => fields.map( - (field) => rehydrateField({ - codebook, entity, type, field, - }), - ), -); +export const makeRehydrateFields = () => + createSelector( + propStageSubject, + propFields, + (state, props) => getProtocolCodebook(state, props), + ({ entity, type }, fields, codebook) => + fields.map((field) => + rehydrateField({ + codebook, + entity, + type, + field, + }), + ), + ); diff --git a/src/selectors/interface.js b/src/selectors/interface.js index 79a86cf8a7..79bd5d8574 100644 --- a/src/selectors/interface.js +++ b/src/selectors/interface.js @@ -1,12 +1,14 @@ -/* eslint-disable import/prefer-default-export */ - -import { createSelector } from 'reselect'; import { filter, includes, intersection } from 'lodash'; -import { assert, createDeepEqualSelector } from './utils'; -import { getProtocolCodebook } from './protocol'; +import { createSelector } from 'reselect'; + +import { + getAdditionalAttributes, + getSubject, +} from '../utils/protocol/accessors'; import { getNetwork, getNetworkEdges, getNetworkNodes } from './network'; -import { getAdditionalAttributes, getSubject } from '../utils/protocol/accessors'; +import { getProtocolCodebook } from './protocol'; import { getStageSubject } from './session'; +import { assert, createDeepEqualSelector } from './utils'; // Selectors that are generic between interfaces @@ -24,120 +26,136 @@ const propStageId = (_, props) => props.stage.id; const propPromptId = (_, props) => props.prompt.id; // Returns current stage and prompt ID -export const makeGetIds = () => createSelector( - propStageId, propPromptId, - (stageId, promptId) => ({ stageId, promptId }), -); - -export const makeGetAdditionalAttributes = () => createSelector( - propStage, propPrompt, - (stage, prompt) => getAdditionalAttributes(stage, prompt), -); +export const makeGetIds = () => + createSelector(propStageId, propPromptId, (stageId, promptId) => ({ + stageId, + promptId, + })); + +export const makeGetAdditionalAttributes = () => + createSelector(propStage, propPrompt, (stage, prompt) => + getAdditionalAttributes(stage, prompt), + ); -export const makeGetSubject = () => createSelector( - propStage, propPrompt, - (stage, prompt) => getSubject(stage, prompt), -); +export const makeGetSubject = () => + createSelector(propStage, propPrompt, (stage, prompt) => + getSubject(stage, prompt), + ); const nodeTypeIsDefined = (codebook, nodeType) => { - if (!codebook) { return false; } + if (!codebook) { + return false; + } return codebook.node[nodeType]; }; // TODO: Once schema validation is in place, we don't need these asserts. -export const makeGetSubjectType = () => (createSelector( - (state, props) => getProtocolCodebook(state, props), - makeGetSubject(), - (codebook, subject) => { - assert(subject, 'The "subject" property is not defined for this prompt'); - assert(nodeTypeIsDefined(codebook, subject.type), `Node type "${subject.type}" is not defined in the registry`); - return subject && subject.type; - }, -)); - -export const makeGetNodeVariables = () => createDeepEqualSelector( - (state, props) => getProtocolCodebook(state, props), - makeGetSubjectType(), - (codebook, nodeType) => { - const nodeInfo = codebook.node; - return nodeInfo && nodeInfo[nodeType] && nodeInfo[nodeType].variables; - }, -); +export const makeGetSubjectType = () => + createSelector( + (state, props) => getProtocolCodebook(state, props), + makeGetSubject(), + (codebook, subject) => { + assert(subject, 'The "subject" property is not defined for this prompt'); + assert( + nodeTypeIsDefined(codebook, subject.type), + `Node type "${subject.type}" is not defined in the registry`, + ); + return subject?.type; + }, + ); + +export const makeGetNodeVariables = () => + createDeepEqualSelector( + (state, props) => getProtocolCodebook(state, props), + makeGetSubjectType(), + (codebook, nodeType) => { + const nodeInfo = codebook.node; + return nodeInfo?.[nodeType]?.variables; + }, + ); // TODO: Not sure this needs to be a createSelector -export const makeGetPromptVariable = () => createSelector( - propPrompt, - (prompt) => prompt.variable, -); +export const makeGetPromptVariable = () => + createSelector(propPrompt, (prompt) => prompt.variable); export const getPromptOtherVariable = (state, props) => { const prompt = propPrompt(state, props); - return [prompt.otherVariable, prompt.otherOptionLabel, prompt.otherVariablePrompt]; + return [ + prompt.otherVariable, + prompt.otherOptionLabel, + prompt.otherVariablePrompt, + ]; }; -export const makeGetVariableOptions = (includeOtherVariable = false) => createSelector( - makeGetNodeVariables(), makeGetPromptVariable(), getPromptOtherVariable, - ( - nodeVariables, - promptVariable, - [promptOtherVariable, promptOtherOptionLabel, promptOtherVariablePrompt], - ) => { - const optionValues = nodeVariables[promptVariable].options || []; - const otherValue = { - label: promptOtherOptionLabel, - value: null, - otherVariablePrompt: promptOtherVariablePrompt, - otherVariable: promptOtherVariable, - }; - - return includeOtherVariable && promptOtherVariable - ? [...optionValues, otherValue] - : optionValues; - }, -); +export const makeGetVariableOptions = (includeOtherVariable = false) => + createSelector( + makeGetNodeVariables(), + makeGetPromptVariable(), + getPromptOtherVariable, + ( + nodeVariables, + promptVariable, + [promptOtherVariable, promptOtherOptionLabel, promptOtherVariablePrompt], + ) => { + const optionValues = nodeVariables[promptVariable].options || []; + const otherValue = { + label: promptOtherOptionLabel, + value: null, + otherVariablePrompt: promptOtherVariablePrompt, + otherVariable: promptOtherVariable, + }; + + return includeOtherVariable && promptOtherVariable + ? [...optionValues, otherValue] + : optionValues; + }, + ); /** * makeNetworkEdgesForType() * Get the current prompt/stage subject, and filter the network by this edge type. -*/ + */ -export const makeNetworkEdgesForType = () => createSelector( - (state, props) => getNetworkEdges(state, props), - makeGetSubject(), - (edges, subject) => filter(edges, ['type', subject.type]), -); +export const makeNetworkEdgesForType = () => + createSelector( + (state, props) => getNetworkEdges(state, props), + makeGetSubject(), + (edges, subject) => filter(edges, ['type', subject.type]), + ); /** * makeNetworkEntitiesForType() * Get the current prompt/stage subject, and filter the network by this entity type. -*/ -export const makeNetworkEntitiesForType = () => createSelector( - (state, props) => getNetwork(state, props), - getStageSubject(), - (network, subject) => { - if (!subject || !network) { - return []; - } - if (subject.entity === 'node') { - return filter(network.nodes, ['type', subject.type]); - } - if (subject.entity === 'edge') { - return filter(network.edges, ['type', subject.type]); - } - return [network.ego]; - }, -); + */ +export const makeNetworkEntitiesForType = () => + createSelector( + (state, props) => getNetwork(state, props), + getStageSubject(), + (network, subject) => { + if (!subject || !network) { + return []; + } + if (subject.entity === 'node') { + return filter(network.nodes, ['type', subject.type]); + } + if (subject.entity === 'edge') { + return filter(network.edges, ['type', subject.type]); + } + return [network.ego]; + }, + ); /** * makeNetworkNodesForType() * Get the current prompt/stage subject, and filter the network by this node type. -*/ + */ -export const makeNetworkNodesForType = () => createSelector( - (state, props) => getNetworkNodes(state, props), - makeGetSubject(), - (nodes, subject) => filter(nodes, ['type', subject.type]), -); +export const makeNetworkNodesForType = () => + createSelector( + (state, props) => getNetworkNodes(state, props), + makeGetSubject(), + (nodes, subject) => filter(nodes, ['type', subject.type]), + ); const stagePromptIds = (state, props) => { const { prompts } = propStage(state, props); @@ -149,10 +167,13 @@ export const makeGetStageNodeCount = () => { const getNetworkNodesForSubject = makeNetworkNodesForType(); return createSelector( - getNetworkNodesForSubject, stagePromptIds, - (nodes, promptIds) => filter( - nodes, (node) => intersection(node.promptIDs, promptIds).length > 0, - ).length, + getNetworkNodesForSubject, + stagePromptIds, + (nodes, promptIds) => + filter( + nodes, + (node) => intersection(node.promptIDs, promptIds).length > 0, + ).length, ); }; @@ -160,13 +181,15 @@ export const makeGetStageNodeCount = () => { * makeNetworkNodesForPrompt * * Return a filtered node list containing only nodes where node IDs contains the current promptId. -*/ + */ export const makeNetworkNodesForPrompt = () => { const getNetworkNodesForSubject = makeNetworkNodesForType(); return createSelector( - getNetworkNodesForSubject, propPromptId, - (nodes, promptId) => filter(nodes, (node) => includes(node.promptIDs, promptId)), + getNetworkNodesForSubject, + propPromptId, + (nodes, promptId) => + filter(nodes, (node) => includes(node.promptIDs, promptId)), ); }; @@ -175,13 +198,15 @@ export const makeNetworkNodesForPrompt = () => { * * Same as above, except returns a filtered node list that **excludes** nodes that match the current * prompt's promptId. -*/ + */ export const makeNetworkNodesForOtherPrompts = () => { const getNetworkNodesForSubject = makeNetworkNodesForType(); return createSelector( - getNetworkNodesForSubject, propPromptId, - (nodes, promptId) => filter(nodes, (node) => !includes(node.promptIDs, promptId)), + getNetworkNodesForSubject, + propPromptId, + (nodes, promptId) => + filter(nodes, (node) => !includes(node.promptIDs, promptId)), ); }; diff --git a/src/selectors/name-generator.js b/src/selectors/name-generator.js index 7f6248b3bc..851f1b04e4 100644 --- a/src/selectors/name-generator.js +++ b/src/selectors/name-generator.js @@ -1,8 +1,7 @@ -/* eslint-disable import/prefer-default-export */ - -import { createSelector } from 'reselect'; import { has } from 'lodash'; -import { makeGetSubject, makeGetIds, makeGetSubjectType } from './interface'; +import { createSelector } from 'reselect'; + +import { makeGetIds, makeGetSubject, makeGetSubjectType } from './interface'; import { getProtocolCodebook } from './protocol'; // Selectors that are specific to the name generator @@ -29,27 +28,29 @@ export const makeGetPromptNodeModelData = () => { const getSubject = makeGetSubject(); const getIds = makeGetIds(); - return createSelector( - getSubject, - getIds, - ({ type }, ids) => ({ - type, - ...ids, - }), - ); + return createSelector(getSubject, getIds, ({ type }, ids) => ({ + type, + ...ids, + })); }; // Returns any additional properties to be displayed on cards. // Returns an empty array if no additional properties are specified in the protocol. export const getCardAdditionalProperties = createSelector( stageCardOptions, - (cardOptions) => (has(cardOptions, 'additionalProperties') ? cardOptions.additionalProperties : []), + (cardOptions) => + has(cardOptions, 'additionalProperties') + ? cardOptions.additionalProperties + : [], ); // Returns the properties that are specified as sortable in sortOptions export const getSortableFields = createSelector( stageSortOptions, - (sortOptions) => (has(sortOptions, 'sortableProperties') ? sortOptions.sortableProperties : []), + (sortOptions) => + has(sortOptions, 'sortableProperties') + ? sortOptions.sortableProperties + : [], ); export const getInitialSortOrder = createSelector( @@ -57,16 +58,19 @@ export const getInitialSortOrder = createSelector( (sortOptions) => (has(sortOptions, 'sortOrder') ? sortOptions.sortOrder : []), ); -export const makeGetNodeIconName = () => createSelector( - getProtocolCodebook, - makeGetSubjectType(), - (codebook, nodeType) => { - const nodeInfo = codebook.node; - return (nodeInfo && nodeInfo[nodeType] && nodeInfo[nodeType].iconVariant) || 'add-a-person'; - }, -); +export const makeGetNodeIconName = () => + createSelector( + getProtocolCodebook, + makeGetSubjectType(), + (codebook, nodeType) => { + const nodeInfo = codebook.node; + return nodeInfo?.[nodeType]?.icon || 'add-a-person'; + }, + ); -export const makeGetPanelConfiguration = () => createSelector( - propPanels, - (panels) => (panels ? panels.map((panel) => ({ ...defaultPanelConfiguration, ...panel })) : []), -); +export const makeGetPanelConfiguration = () => + createSelector(propPanels, (panels) => + panels + ? panels.map((panel) => ({ ...defaultPanelConfiguration, ...panel })) + : [], + ); diff --git a/src/selectors/network.js b/src/selectors/network.jsx similarity index 57% rename from src/selectors/network.js rename to src/selectors/network.jsx index 83eef64632..c66761782e 100644 --- a/src/selectors/network.js +++ b/src/selectors/network.jsx @@ -1,26 +1,26 @@ -import { findKey, find, get } from 'lodash'; -import { - getActiveSession, - getStageSubjectType, -} from './session'; -import { createDeepEqualSelector } from './utils'; -import { getProtocolCodebook } from './protocol'; -import { asWorkerAgentNetwork } from '../utils/networkFormat'; +import { find, findKey, get } from 'lodash'; + +import { filter as customFilter } from '@codaco/network-query'; + import { getEntityAttributes } from '../ducks/modules/network'; -import customFilter from '../utils/networkQuery/filter'; +import { asWorkerAgentNetwork } from '../utils/networkFormat'; +import { adaptFilter } from '../utils/networkQueryFilter'; +import { getProtocolCodebook } from './protocol'; +import { getActiveSession, getStageSubjectType } from './session'; +import { createDeepEqualSelector } from './utils'; export const getNetwork = createDeepEqualSelector( (state, props) => getActiveSession(state, props), - (session) => (session && session.network) || { nodes: [], edges: [], ego: {} }, + (session) => session?.network || { nodes: [], edges: [], ego: {} }, ); // Filtered network -export const getFilteredNetwork = createDeepEqualSelector( +const getFilteredNetwork = createDeepEqualSelector( getNetwork, - (_, props) => props && props.stage && props.stage.filter, + (_, props) => props?.stage?.filter, (network, nodeFilter) => { if (nodeFilter && typeof nodeFilter !== 'function') { - const filterFunction = customFilter(nodeFilter); + const filterFunction = customFilter(adaptFilter(nodeFilter)); return filterFunction(network); } return network; @@ -49,26 +49,28 @@ export const getWorkerNetwork = createDeepEqualSelector( ); // The user-defined name of a node type; e.g. `codebook.node[uuid].name == 'person'` -export const makeGetNodeTypeDefinition = () => createDeepEqualSelector( - (state, props) => getProtocolCodebook(state, props), - (state, props) => get(props, 'type') // When used in // TODO: should use makeGetSubject - || get(props, 'stage.subject.type') // Standard location - || get(state, 'type'), // Unknown - perhaps worker? - (codebook, nodeType) => { - const nodeDefinitions = codebook && codebook.node; - return nodeDefinitions && nodeDefinitions[nodeType]; - }, -); +export const makeGetNodeTypeDefinition = () => + createDeepEqualSelector( + (state, props) => getProtocolCodebook(state, props), + (state, props) => + get(props, 'type') || // When used in // TODO: should use makeGetSubject + get(props, 'stage.subject.type') || // Standard location + get(state, 'type'), // Unknown - perhaps worker? + (codebook, nodeType) => { + const nodeDefinitions = codebook?.node; + return nodeDefinitions?.[nodeType]; + }, + ); // See: https://github.com/complexdatacollective/Network-Canvas/wiki/Node-Labeling export const labelLogic = (codebookForNodeType, nodeAttributes) => { // 1. In the codebook for the stage's subject, look for a variable with a name // property of "name", and try to retrieve this value by key in the node's // attributes - const variableCalledName = codebookForNodeType - && codebookForNodeType.variables + const variableCalledName = + codebookForNodeType?.variables && // Ignore case when looking for 'name' - && findKey( + findKey( codebookForNodeType.variables, (variable) => variable.name.toLowerCase() === 'name', ); @@ -96,13 +98,15 @@ export const labelLogic = (codebookForNodeType, nodeAttributes) => { // Gets the node label variable and returns its value, or "No label". // See: https://github.com/complexdatacollective/Network-Canvas/wiki/Node-Labeling -export const makeGetNodeLabel = () => createDeepEqualSelector( - (state, props) => { - const getNodeTypeDefinition = makeGetNodeTypeDefinition(state, props); - return getNodeTypeDefinition(state, props); - }, - (nodeTypeDefinition) => (node) => labelLogic(nodeTypeDefinition, getEntityAttributes(node)), -); +export const makeGetNodeLabel = () => + createDeepEqualSelector( + (state, props) => { + const getNodeTypeDefinition = makeGetNodeTypeDefinition(state, props); + return getNodeTypeDefinition(state, props); + }, + (nodeTypeDefinition) => (node) => + labelLogic(nodeTypeDefinition, getEntityAttributes(node)), + ); // Pure state selector variant of makeGetNodeLabel export const getNodeLabel = (state, nodeType) => { @@ -111,19 +115,20 @@ export const getNodeLabel = (state, nodeType) => { return (nodeAttributes) => labelLogic(nodeTypeDefinition, nodeAttributes); }; -export const makeGetNodeColor = () => createDeepEqualSelector( - getProtocolCodebook, - (_, props) => props.type, - (codebook, nodeType) => { - const nodeDefinitions = codebook.node; - const nodeColor = get( - nodeDefinitions, - [nodeType, 'color'], - 'node-color-seq-1', - ); - return nodeColor; - }, -); +export const makeGetNodeColor = () => + createDeepEqualSelector( + getProtocolCodebook, + (_, props) => props.type, + (codebook, nodeType) => { + const nodeDefinitions = codebook.node; + const nodeColor = get( + nodeDefinitions, + [nodeType, 'color'], + 'node-color-seq-1', + ); + return nodeColor; + }, + ); // Pure state selector variant of makeGetNodeColor export const getNodeColor = (nodeType) => (state) => { @@ -144,37 +149,40 @@ export const getNodeTypeLabel = (nodeType) => (state) => { return nodeLabel; }; -export const makeGetEdgeLabel = () => createDeepEqualSelector( - getProtocolCodebook, - (_, props) => props.type, - (codebook, edgeType) => { - const edgeInfo = codebook.edge; - const edgeLabel = get(edgeInfo, [edgeType, 'name'], ''); - return edgeLabel; - }, -); +export const makeGetEdgeLabel = () => + createDeepEqualSelector( + getProtocolCodebook, + (_, props) => props.type, + (codebook, edgeType) => { + const edgeInfo = codebook.edge; + const edgeLabel = get(edgeInfo, [edgeType, 'name'], ''); + return edgeLabel; + }, + ); -export const makeGetEdgeColor = () => createDeepEqualSelector( - getProtocolCodebook, - (_, props) => props.type, - (codebook, edgeType) => { - const edgeInfo = codebook.edge; - const edgeColor = get(edgeInfo, [edgeType, 'color'], 'edge-color-seq-1'); - return edgeColor; - }, -); +export const makeGetEdgeColor = () => + createDeepEqualSelector( + getProtocolCodebook, + (_, props) => props.type, + (codebook, edgeType) => { + const edgeInfo = codebook.edge; + const edgeColor = get(edgeInfo, [edgeType, 'color'], 'edge-color-seq-1'); + return edgeColor; + }, + ); -export const makeGetNodeAttributeLabel = () => createDeepEqualSelector( - getProtocolCodebook, - getStageSubjectType(), - (_, props) => props.variableId, - (codebook, subjectType, variableId) => { - const nodeDefinitions = codebook.node; - const variables = get(nodeDefinitions, [subjectType, 'variables'], {}); - const attributeLabel = get(variables, [variableId, 'name'], variableId); - return attributeLabel; - }, -); +export const makeGetNodeAttributeLabel = () => + createDeepEqualSelector( + getProtocolCodebook, + getStageSubjectType(), + (_, props) => props.variableId, + (codebook, subjectType, variableId) => { + const nodeDefinitions = codebook.node; + const variables = get(nodeDefinitions, [subjectType, 'variables'], {}); + const attributeLabel = get(variables, [variableId, 'name'], variableId); + return attributeLabel; + }, + ); export const getCategoricalOptions = createDeepEqualSelector( (state, props) => getProtocolCodebook(state, props), diff --git a/src/selectors/protocol.js b/src/selectors/protocol.js index a3e4610bbc..974907259b 100644 --- a/src/selectors/protocol.js +++ b/src/selectors/protocol.js @@ -1,12 +1,9 @@ -import uuid from 'uuid/v4'; -import { - orderBy, - values, - mapValues, - omit, -} from 'lodash'; +import { mapValues, omit, orderBy, values } from 'lodash'; import { createSelector } from 'reselect'; +import { v4 as uuid } from 'uuid'; + import { entityAttributesProperty } from '@codaco/shared-consts'; + import { get } from '../utils/lodash-replacements'; const DefaultFinishStage = { @@ -16,21 +13,26 @@ const DefaultFinishStage = { label: 'Finish Interview', }; -const getActiveSession = (state) => ( - state.activeSessionId && state.sessions[state.activeSessionId] -); +const getActiveSession = (state) => + state.activeSessionId && state.sessions[state.activeSessionId]; const getLastActiveSession = (state) => { if (Object.keys(state.sessions).length === 0) { return {}; } - const sessionsCollection = values(mapValues(state.sessions, (session, sessionUUID) => ({ - sessionUUID, - ...session, - }))); - - const lastActive = orderBy(sessionsCollection, ['updatedAt', 'caseId'], ['desc', 'asc'])[0]; + const sessionsCollection = values( + mapValues(state.sessions, (session, sessionUUID) => ({ + sessionUUID, + ...session, + })), + ); + + const lastActive = orderBy( + sessionsCollection, + ['updatedAt', 'caseId'], + ['desc', 'asc'], + )[0]; return { sessionUUID: lastActive.sessionUUID, [entityAttributesProperty]: { @@ -39,7 +41,7 @@ const getLastActiveSession = (state) => { }; }; -export const getInstalledProtocols = (state) => state.installedProtocols; +const getInstalledProtocols = (state) => state.installedProtocols; export const getCurrentSessionProtocol = createSelector( (state, props) => getActiveSession(state, props), @@ -64,20 +66,27 @@ export const getLastActiveProtocol = (state) => { const lastActiveSession = getLastActiveSession(state); const lastActiveAttributes = lastActiveSession[entityAttributesProperty]; - const protocolsCollection = values(mapValues(installedProtocols, (protocol, protocolUID) => ({ - protocolUID, - ...protocol, - }))); + const protocolsCollection = values( + mapValues(installedProtocols, (protocol, protocolUID) => ({ + protocolUID, + ...protocol, + })), + ); - const lastInstalledProtocol = orderBy(protocolsCollection, ['installationDate'], ['desc'])[0]; + const lastInstalledProtocol = orderBy( + protocolsCollection, + ['installationDate'], + ['desc'], + )[0]; if ( - lastActiveAttributes - && lastActiveAttributes.updatedAt // Last active session exists - && lastActiveAttributes.updatedAt > lastInstalledProtocol.installationDate + lastActiveAttributes?.updatedAt && // Last active session exists + lastActiveAttributes.updatedAt > lastInstalledProtocol.installationDate ) { return { - ...installedProtocols[lastActiveSession[entityAttributesProperty].protocolUID], + ...installedProtocols[ + lastActiveSession[entityAttributesProperty].protocolUID + ], protocolUID: lastActiveSession[entityAttributesProperty].protocolUID, }; } @@ -87,7 +96,7 @@ export const getLastActiveProtocol = (state) => { export const getActiveProtocolName = createSelector( getCurrentSessionProtocol, - (protocol) => protocol && protocol.name, + (protocol) => protocol?.name, ); export const getAssetManifest = createSelector( @@ -145,7 +154,9 @@ export const getAllVariableUUIDsByEntity = createSelector( ); const withFinishStage = (stages = []) => { - if (!stages) { return []; } + if (!stages) { + return []; + } return [...stages, DefaultFinishStage]; }; diff --git a/src/selectors/search.js b/src/selectors/search.js index ddfd679211..175ee41138 100644 --- a/src/selectors/search.js +++ b/src/selectors/search.js @@ -1,6 +1,6 @@ -/* eslint-disable import/prefer-default-export */ import FuseLegacy from 'fuse.js-legacy'; import { createSelector } from 'reselect'; + import { get } from '../utils/lodash-replacements'; const getSearchOpts = (_, props) => props.options; @@ -11,20 +11,20 @@ const getSearchData = (_, props) => get(props.externalData, 'nodes', []); * to 6.x.x. This would modify the behaviour of the existing legacy * roster interfaces, so we need to keep this selector for them, which * uses the fuse.js-legacy npm alias. -*/ -// eslint-disable-next-line camelcase -export const LEGACY_makeGetFuse = (fuseOpts) => createSelector( - getSearchData, - getSearchOpts, - (searchData = [], searchOpts = {}) => { - let threshold = searchOpts.fuzziness; - if (typeof threshold !== 'number') { - threshold = fuseOpts.threshold; - } - return new FuseLegacy(searchData, { - ...fuseOpts, - keys: searchOpts.matchProperties, - threshold, - }); - }, -); + */ +export const LEGACY_makeGetFuse = (fuseOpts) => + createSelector( + getSearchData, + getSearchOpts, + (searchData = [], searchOpts = {}) => { + let threshold = searchOpts.fuzziness; + if (typeof threshold !== 'number') { + threshold = fuseOpts.threshold; + } + return new FuseLegacy(searchData, { + ...fuseOpts, + keys: searchOpts.matchProperties, + threshold, + }); + }, + ); diff --git a/src/selectors/session.js b/src/selectors/session.js index 32c1fdebea..cd573d15ce 100644 --- a/src/selectors/session.js +++ b/src/selectors/session.js @@ -1,34 +1,45 @@ -/* eslint-disable no-shadow */ +import { clamp, mapValues, omit, orderBy, values } from 'lodash'; import { createSelector } from 'reselect'; -import { - clamp, orderBy, values, mapValues, omit, -} from 'lodash'; + import { entityAttributesProperty } from '@codaco/shared-consts'; + +import { get } from '../utils/lodash-replacements'; import { currentStageIndex } from '../utils/matchSessionPath'; -import { getAdditionalAttributes, getSubject } from '../utils/protocol/accessors'; +import { + getAdditionalAttributes, + getSubject, +} from '../utils/protocol/accessors'; +import { + getCurrentSessionProtocol, + getProtocolCodebook, + getProtocolStages, +} from './protocol'; import { createDeepEqualSelector } from './utils'; -import { initialState } from '../ducks/modules/session'; -import { getProtocolCodebook, getProtocolStages, getCurrentSessionProtocol } from './protocol'; -import { get } from '../utils/lodash-replacements'; -const currentPathname = (router) => router && router.location && router.location.pathname; -const stageIndexForCurrentSession = (state) => currentStageIndex(currentPathname(state.router)); +const currentPathname = (router) => router?.location?.pathname; +const stageIndexForCurrentSession = (state) => + currentStageIndex(currentPathname(state.router)); -export const getActiveSession = (state) => ( - state.activeSessionId && state.sessions[state.activeSessionId] -); +export const getActiveSession = (state) => + state.activeSessionId && state.sessions[state.activeSessionId]; export const getLastActiveSession = (state) => { if (Object.keys(state.sessions).length === 0) { return {}; } - const sessionsCollection = values(mapValues(state.sessions, (session, uuid) => ({ - sessionUUID: uuid, - ...session, - }))); + const sessionsCollection = values( + mapValues(state.sessions, (session, uuid) => ({ + sessionUUID: uuid, + ...session, + })), + ); - const lastActive = orderBy(sessionsCollection, ['updatedAt', 'caseId'], ['desc', 'asc'])[0]; + const lastActive = orderBy( + sessionsCollection, + ['updatedAt', 'caseId'], + ['desc', 'asc'], + )[0]; return { sessionUUID: lastActive.sessionUUID, [entityAttributesProperty]: { @@ -39,21 +50,25 @@ export const getLastActiveSession = (state) => { export const getStageState = (state) => { const session = getActiveSession(state); - if (!session) { return undefined; } + if (!session) { + return undefined; + } const { stageIndex } = session; return get(session, ['stages', stageIndex], undefined); }; export const getCaseId = createDeepEqualSelector( getActiveSession, - (session) => (session && session.caseId), + (session) => session?.caseId, ); export const getSessionPath = (state, stageIndex) => { const sessionId = state.activeSessionId; const sessionPath = `/session/${sessionId}`; - if (stageIndex === undefined) { return sessionPath; } + if (stageIndex === undefined) { + return sessionPath; + } return `${sessionPath}/${stageIndex}`; }; @@ -76,7 +91,7 @@ export const getSessionProgress = (state) => { // This can go over 100% when finish screen is not present, // so it needs to be clamped const percentProgress = clamp( - (stageProgress + (promptProgress / (screenCount - 1))) * 100, + (stageProgress + promptProgress / (screenCount - 1)) * 100, 0, 100, ); @@ -107,44 +122,41 @@ export const getSessionProgress = (state) => { }; }; -export const anySessionIsActive = (state) => ( - state.activeSessionId && state.activeSessionId !== initialState -); - export const getStageForCurrentSession = createSelector( (state, props) => getProtocolStages(state, props), stageIndexForCurrentSession, (stages, stageIndex) => stages[stageIndex], ); -export const getStageSubject = () => createDeepEqualSelector( - getStageForCurrentSession, - (stage) => stage.subject, -); - -export const getStageSubjectType = () => createDeepEqualSelector( - getStageSubject(), - (subject) => subject && subject.type, -); - -export const getCodebookVariablesForType = () => createSelector( - (state) => getProtocolCodebook(state), - getStageSubject(), - (codebook, subject) => codebook - && (subject ? codebook[subject.entity][subject.type].variables : codebook.ego.variables), -); +export const getStageSubject = () => + createDeepEqualSelector(getStageForCurrentSession, (stage) => stage.subject); + +export const getStageSubjectType = () => + createDeepEqualSelector(getStageSubject(), (subject) => subject?.type); + +export const getCodebookVariablesForType = () => + createSelector( + (state) => getProtocolCodebook(state), + getStageSubject(), + (codebook, subject) => + codebook && + (subject + ? codebook[subject.entity][subject.type].variables + : codebook.ego.variables), + ); export const getPromptIndexForCurrentSession = createSelector( - (state) => ( - state.sessions[state.activeSessionId] && state.sessions[state.activeSessionId].promptIndex - ) || 0, + (state) => + (state.sessions[state.activeSessionId] && + state.sessions[state.activeSessionId].promptIndex) || + 0, (promptIndex) => promptIndex, ); const getPromptForCurrentSession = createSelector( getStageForCurrentSession, getPromptIndexForCurrentSession, - (stage, promptIndex) => stage && stage.prompts && stage.prompts[promptIndex], + (stage, promptIndex) => stage?.prompts?.[promptIndex], ); // @return {Array} An object entry ([key, object]) for the current node type @@ -158,7 +170,7 @@ export const getNodeEntryForCurrentPrompt = createSelector( return null; } const subject = getSubject(stage, prompt); - const nodeType = subject && subject.type; + const nodeType = subject?.type; const nodeTypeDefinition = nodeType && registry.node[nodeType]; if (nodeTypeDefinition) { return [nodeType, nodeTypeDefinition]; diff --git a/src/selectors/skip-logic.js b/src/selectors/skip-logic.js index 64b510cab5..b115f53a75 100644 --- a/src/selectors/skip-logic.js +++ b/src/selectors/skip-logic.js @@ -1,29 +1,29 @@ import { createSelector } from 'reselect'; -import getQuery from '../utils/networkQuery/query'; -import { getProtocolStages } from './protocol'; -import { getNetwork } from './network'; + +import { getQuery } from '@codaco/network-query'; + import { SkipLogicAction } from '../protocol-consts'; +import { adaptFilter } from '../utils/networkQueryFilter'; +import { getNetwork } from './network'; +import { getProtocolStages } from './protocol'; const rotateIndex = (max, nextIndex) => (nextIndex + max) % max; const maxLength = (state) => getProtocolStages(state).length; -export const getNextIndex = (index) => createSelector( - maxLength, - (max) => rotateIndex(max, index), -); +export const getNextIndex = (index) => + createSelector(maxLength, (max) => rotateIndex(max, index)); -const getSkipLogic = (index) => createSelector( - getProtocolStages, - (stages) => stages && stages[index] && stages[index].skipLogic, -); +const getSkipLogic = (index) => + createSelector(getProtocolStages, (stages) => stages?.[index]?.skipLogic); /** * @returns {boolean} true for skip (when query matches), false for show (when query matches) */ -const isSkipAction = (index) => createSelector( - getSkipLogic(index), - (logic) => logic && logic.action === SkipLogicAction.SKIP, -); +const isSkipAction = (index) => + createSelector( + getSkipLogic(index), + (logic) => logic && logic.action === SkipLogicAction.SKIP, + ); const formatQueryParameters = (params) => ({ rules: [], @@ -31,28 +31,30 @@ const formatQueryParameters = (params) => ({ ...params, }); -export const isStageSkipped = (index) => createSelector( - getSkipLogic(index), - isSkipAction(index), - getNetwork, - (logic, skipOnMatch, network) => { - if (!logic) { return false; } - - // Handle skipLogic with no rules defined differently depending on action. - // skipLogic.action === SHOW <- always show the stage - // skipLogic.action === SKIP <- always skip the stage - // Allows for a quick way to disable a stage by setting SKIP if, and then - // not defining rules. - // Should be changed with https://github.com/complexdatacollective/Architect/issues/517 - if (!logic.filter.rules || !logic.filter.rules.length === 0) { - console.warn('Encountered skip logic with no rules defined at index', index); // eslint-disable-line no-console - return !!skipOnMatch; - } - - const queryParameters = formatQueryParameters(logic.filter); - const result = getQuery(queryParameters)(network); - const isSkipped = ((skipOnMatch && result) || (!skipOnMatch && !result)); - - return isSkipped; - }, -); +export const isStageSkipped = (index) => + createSelector( + getSkipLogic(index), + isSkipAction(index), + getNetwork, + (logic, skipOnMatch, network) => { + if (!logic) { + return false; + } + + // Handle skipLogic with no rules defined differently depending on action. + // skipLogic.action === SHOW <- always show the stage + // skipLogic.action === SKIP <- always skip the stage + // Allows for a quick way to disable a stage by setting SKIP if, and then + // not defining rules. + // Should be changed with https://github.com/complexdatacollective/Architect/issues/517 + if (!logic.filter.rules || !logic.filter.rules.length === 0) { + return !!skipOnMatch; + } + + const queryParameters = formatQueryParameters(adaptFilter(logic.filter)); + const result = getQuery(queryParameters)(network); + const isSkipped = (skipOnMatch && result) || (!skipOnMatch && !result); + + return isSkipped; + }, + ); diff --git a/src/selectors/utils.js b/src/selectors/utils.js index aa9a725edc..37b335d7d2 100644 --- a/src/selectors/utils.js +++ b/src/selectors/utils.js @@ -1,5 +1,5 @@ -import { createSelectorCreator, defaultMemoize } from 'reselect'; import { isEqual } from 'lodash'; +import { createSelectorCreator, defaultMemoize } from 'reselect'; // create a "selector creator" that uses lodash.isEqual instead of === export const createDeepEqualSelector = createSelectorCreator( diff --git a/src/shims/react-resize-aware.js b/src/shims/react-resize-aware.js new file mode 100644 index 0000000000..d0fef21134 --- /dev/null +++ b/src/shims/react-resize-aware.js @@ -0,0 +1,102 @@ +/** + * Shim for react-resize-aware that provides the jsx function + * which is missing from the package's dist build. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { jsx } from 'react/jsx-runtime'; + +// Styles for the resize listener iframe +const listenerStyle = { + display: 'block', + opacity: 0, + position: 'absolute', + top: 0, + left: 0, + height: '100%', + width: '100%', + overflow: 'hidden', + pointerEvents: 'none', + zIndex: -1, + maxHeight: 'inherit', + maxWidth: 'inherit', +}; + +// ResizeListener component +const ResizeListener = ({ onResize }) => { + const ref = useRef(); + + // `onResize` (and thus the handlers below) is recreated each render; the effect + // must mount once and the listener it adds must keep the same identity for the + // add/remove pair to match. Keep the latest `onResize` in a ref and use stable + // handlers, so the effect can run with empty deps (mount/unmount only). + const onResizeRef = useRef(onResize); + onResizeRef.current = onResize; + + const getWindow = useCallback(() => { + return ref.current?.contentDocument?.defaultView; + }, []); + + const handleResize = useCallback(() => { + onResizeRef.current(ref); + }, []); + + const handleLoad = useCallback(() => { + handleResize(); + const win = getWindow(); + if (win) { + win.addEventListener('resize', handleResize); + } + }, [getWindow, handleResize]); + + useEffect(() => { + if (getWindow()) { + handleLoad(); + } else if (ref.current?.addEventListener) { + ref.current.addEventListener('load', handleLoad); + } + + return () => { + const win = getWindow(); + if (win && typeof win.removeEventListener === 'function') { + win.removeEventListener('resize', handleResize); + } + }; + }, [getWindow, handleLoad, handleResize]); + + return jsx('iframe', { + 'style': listenerStyle, + 'src': 'about:blank', + 'ref': ref, + 'aria-hidden': true, + 'tabIndex': -1, + 'frameBorder': 0, + }); +}; + +// Default size reporter +const defaultReporter = (el) => ({ + width: el != null ? el.offsetWidth : null, + height: el != null ? el.offsetHeight : null, +}); + +// Main hook +const useResizeAware = (reporter = defaultReporter) => { + const [sizes, setSizes] = useState(reporter(null)); + + const onResize = useCallback( + (ref) => { + setSizes(reporter(ref.current)); + }, + [reporter], + ); + + const resizeListener = useMemo( + () => jsx(ResizeListener, { onResize }), + [onResize], + ); + + return [resizeListener, sizes]; +}; + +export default useResizeAware; diff --git a/src/styles/_ua-compat.scss b/src/styles/_ua-compat.scss new file mode 100644 index 0000000000..ca3e0fc928 --- /dev/null +++ b/src/styles/_ua-compat.scss @@ -0,0 +1,84 @@ +// User-agent stylesheet compatibility (Electron 9 / Chrome 83 -> Electron 42 / +// Chrome 148). +// +// Newer Chromium changed two user-agent defaults the app relied on. We restore +// the old defaults inside a cascade LAYER. Layered rules rank below every +// unlayered author rule regardless of specificity, which mirrors the old +// user-agent ORIGIN priority exactly: where the app sets a property its own +// rule still wins, but where the app leaves a property to the browser it gets +// the previous default back (instead of the new Chrome 148 default). +@layer legacy-ua { + // 1. Chrome 148 gates the nested-

font-size/margin cascade behind the + // default-off `SpecialRulesForNestedH1Elements` feature, so nested

s + // fall back to the base `margin-block: 0.67em`. The app overrides

+ // font-size (via @codaco/ui) but not its margins, so the visible change is + // the margin. Restore the Chrome 83 cascade (font-size included for + // faithfulness; it loses to the app's own h1 size, as it did before). + :is(article, aside, nav, section) h1 { + font-size: 1.5em; + margin-block: 0.83em; + } + + :is(article, aside, nav, section) :is(article, aside, nav, section) h1 { + font-size: 1.17em; + margin-block: 1em; + } + + :is(article, aside, nav, section) + :is(article, aside, nav, section) + :is(article, aside, nav, section) + h1 { + font-size: 1em; + margin-block: 1.33em; + } + + :is(article, aside, nav, section) + :is(article, aside, nav, section) + :is(article, aside, nav, section) + :is(article, aside, nav, section) + h1 { + font-size: 0.83em; + margin-block: 1.67em; + } + + :is(article, aside, nav, section) + :is(article, aside, nav, section) + :is(article, aside, nav, section) + :is(article, aside, nav, section) + :is(article, aside, nav, section) + h1 { + font-size: 0.67em; + margin-block: 2.33em; + } + + // 2. Form controls now inherit `text-align` from their ancestors; previously + // they defaulted to `start` and did not inherit. Without this, inputs in a + // centered container (case-id and developer-options forms) have their text + // centered instead of left-aligned. + // The reset is `input,textarea,select { text-align: start }`, but + // lightningcss strips it from the production bundle as a redundant initial + // value, so it lives instead as an inline