Repository: CSCfi/rems
Pull request #2950 (2022-05-31 12:51:52)

Into shadow
Closes all of #2755 except the new release.

# Checklist for author

Remove items that aren't applicable, check items that are done.

## Reviewability
- [x] Link to issue

## Documentation
- [x] Update changelog if necessary
- [x] Update docs/ (if applicable)

## Testing
- All the current tests pass
- Manual testing seems to show it works

## Follow-up
- [ ] New tasks are created for pending or remaining tasks



Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index fb6af1a32..ee319b4ac 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -12,15 +12,15 @@ defaults: &defaults
     JVM_OPTS: -Xmx3200m
 
 orbs:
-  browser-tools: circleci/browser-tools@1.2.4
+  browser-tools: circleci/browser-tools@1.3.0
 
 executors:
   clojure:
     docker:
-      - image: cimg/clojure:1.10.3-browsers
+      - image: cimg/clojure:1.11.1-browsers
   db:
     docker:
-      - image: cimg/clojure:1.10.3-browsers
+      - image: cimg/clojure:1.11.1-browsers
       - image: postgres:13
         environment:
           POSTGRES_HOST_AUTH_METHOD: trust
@@ -34,20 +34,17 @@ jobs:
       # Download and cache dependencies
       - restore_cache:
           keys:
-            - v3-dependencies-{{ checksum "project.clj" }}
+            - v3-dependencies-{{ checksum "project.clj" }}-{{ checksum "package.json" }}-{{ checksum "shadow-cljs.edn" }}
             # fallback to using the latest cache if no exact match is found
             - v3-dependencies-
       - run: lein deps
-      - run: lein cljsbuild deps
+      - run: npm install
       # TODO using persist_to_workspace might be more robust than using cache
       - save_cache:
           paths:
             - ~/.m2
-            # node_modules won't pick up updates to sub dependencies
-            # unless the project.clj is changed. This might create differences
-            # between dev, CI and actual deployment.
             - node_modules
-          key: v3-dependencies-{{ checksum "project.clj" }}
+          key: v3-dependencies-{{ checksum "project.clj" }}-{{ checksum "package.json" }}-{{ checksum "shadow-cljs.edn" }}
 
   unit-test:
     executor: clojure
@@ -56,11 +53,11 @@ jobs:
       - browser-tools/install-chromedriver
       - checkout
       - restore_cache:
-          key: v3-dependencies-{{ checksum "project.clj" }}
+          key: v3-dependencies-{{ checksum "project.clj" }}-{{ checksum "package.json" }}-{{ checksum "shadow-cljs.edn" }}
       # verify that we can run unit tests without the database:
       - run: DATABASE_URL=invalid lein kaocha --reporter kaocha.report/documentation unit
-      - run: lein cljsbuild once
-      - run: lein doo chrome-headless test once
+      - run: lein shadow-build
+      - run: lein shadow-test
       - store_test_results:
           path: target/test-results
 
@@ -69,7 +66,7 @@ jobs:
     steps:
       - checkout
       - restore_cache:
-          key: v3-dependencies-{{ checksum "project.clj" }}
+          key: v3-dependencies-{{ checksum "project.clj" }}-{{ checksum "package.json" }}-{{ checksum "shadow-cljs.edn" }}
       - run: lein cljfmt check
 
   integration-test:
@@ -79,7 +76,7 @@ jobs:
       - browser-tools/install-chromedriver
       - checkout
       - restore_cache:
-          key: v3-dependencies-{{ checksum "project.clj" }}
+          key: v3-dependencies-{{ checksum "project.clj" }}-{{ checksum "package.json" }}-{{ checksum "shadow-cljs.edn" }}
       - run: lein kaocha --reporter kaocha.report/documentation integration
       - store_test_results:
           path: target/test-results
@@ -91,8 +88,8 @@ jobs:
       - browser-tools/install-chromedriver
       - checkout
       - restore_cache:
-          key: v3-dependencies-{{ checksum "project.clj" }}
-      - run: lein cljsbuild once
+          key: v3-dependencies-{{ checksum "project.clj" }}-{{ checksum "package.json" }}-{{ checksum "shadow-cljs.edn" }}
+      - run: lein shadow-build
       - run: lein kaocha --reporter kaocha.report/documentation browser
       - store_test_results:
           path: target/test-results
@@ -108,7 +105,7 @@ jobs:
     steps:
       - checkout
       - restore_cache:
-          key: v3-dependencies-{{ checksum "project.clj" }}
+          key: v3-dependencies-{{ checksum "project.clj" }}-{{ checksum "package.json" }}-{{ checksum "shadow-cljs.edn" }}
       - run: lein uberjar
       - store_artifacts:
           path: target/uberjar/rems.jar
diff --git a/.gitignore b/.gitignore
index 1cbb7ba25..51e367ee5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,4 @@
 /.lsp/
 /.clj-kondo/
 /.vscode/
+/.shadow-cljs/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f61d9930..48dc8c321 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,8 @@ have notable changes.
 
 Changes since v2.26
 
+NB: This is the first release with a new build tool – Shadow-CLJS.
+
 ### Additions
 - Shopping cart can now be enabled or disabled in user interface with configuration option `:enable-cart`, which defaults to `true`. (#2720)
 
diff --git a/README.md b/README.md
index 2eb3aff54..6c37d570c 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ You can try out REMS using the publicly available demo instance at <https://rems
 ## Releases
 
 Currently, the REMS project aims to make small, frequent releases.
-Releases are compatible with old data, using migrations where needed. Since REMS is undergoing active
+Releases are compatible with old data. Since REMS is undergoing active
 development, we can't guarantee backwards compatibility for the API.
 However, all breaking changes are highlighted in the release notes.
 
@@ -29,11 +29,11 @@ See the [release page](https://github.com/CSCfi/rems/releases) for the releases.
 
 ## Contributing
 
-- REMS is an open source project. We use the [MIT License](./LICENSE).
-- We welcome [issues through GitHub](https://github.com/CSCfi/rems/issues).
-- You can [contact the team on the discussion board](https://github.com/CSCfi/rems/discussions) or [via email](mailto:rems@csc.fi).
-- In case you would like to contribute to the development, perhaps you participate in Hacktoberfest, then please refer to the [contributing document](CONTRIBUTING.md). We'll guide you through to get your pull request reviewed and merged.
-- You can follow the progress of the project on the [GitHub project board](https://github.com/CSCfi/rems/projects/1).
+REMS is an open source project.
+We welcome [ideas and issues through GitHub](https://github.com/CSCfi/rems/issues).
+You can also [contact the team via email](mailto:rems@csc.fi).
+In case you would like to contribute to its development, please refer to the [contributing document](CONTRIBUTING.md).
+You can follow the progress of the project on the [GitHub project board](https://github.com/CSCfi/rems/projects/1).
 
 ## Documentation
 
diff --git a/dev-config.edn b/dev-config.edn
index 9ae528461..18b63a195 100644
--- a/dev-config.edn
+++ b/dev-config.edn
@@ -20,7 +20,6 @@
                          {:attribute "organizations"}]
  :languages [:en :fi :sv]
  :public-url "http://localhost:3000/"
- :render-css-file? true
  :extra-pages [{:id "about"
                 :translations {:fi {:title "Info"
                                     :filename "about-fi.md"}
diff --git a/docs/bots.md b/docs/bots.md
index d4808b4b1..3364b5095 100644
--- a/docs/bots.md
+++ b/docs/bots.md
@@ -1,10 +1,10 @@
 # Bots
 
 REMS has a number of features implemented as _bots_, automatic
-handlers that can be attached to a workflow.
+users, such as handlers that can be attached to a workflow.
 
 To use a bot, create a user with the corresponding user id, and add
-that user as a handler to a workflow. The 'email' attribute for the
+that user as a handler to a workflow, if necessary. The 'email' attribute for the
 user can be null, or the attribute can be left out completely. The
 name of the bot will be visible to the applicants in the event log
 as the person who approved or rejected their application so make it
diff --git a/docs/development.md b/docs/development.md
index 7f8fc0b0a..ea288439f 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -6,7 +6,9 @@ In order to get started with REMS, you need to have the following software insta
 
    - Docker
    - Java. Currently REMS should be working with Java versions 11 to 17
-   - leiningen. As of 2021-11-01, the project should work with lein version Leiningen 2.9.7 on Java 17 OpenJDK 64-Bit Server VM.
+   - Leiningen. As of 2022-06-01, the project should work with lein version Leiningen 2.9.8 on Java 17 OpenJDK 64-Bit Server VM.
+   - npm and npx
+
 ### Mac OS installation for Apple M1 Chip (Apple Silicon M1)
 
 1. Java Installation: install Java via Azul, make sure you download the version of [Java for MacOS for ARM architecture](https://www.azul.com/downloads/zulu-community/?os=macos&architecture=arm-64-bit&package=jdk).
@@ -43,25 +45,28 @@ lein run test-data
 
 ## Running the application
 
-To start the (clojure) backend:
+To start the (Clojure) backend:
 
 ```
 lein run
 ```
 
-To start the (clojurescript) frontend, run in another terminal:
+To start the (ClojureScript) frontend, run in another terminal:
 
 ```
-lein figwheel
+lein shadow-watch
 ```
 
-Point your browser to <http://localhost:3000>
+Normally we run the application in port 3000. Point your browser to <http://localhost:3000>.
+This supports live reload. Shadow-CLJS also exposes port 3100 for development.
+The API and the static files are all served from the port 3000.
+Shadow-CLJS has its own console in <http://localhost:9630>.
 
-You can also use e.g. Emacs with CIDER integration and `cider-jack-in-clj&cljs`.
+### Editor setup
 
-You should start in the development profile i.e. set `Cider Lein Parameters` to `with-profile +dev repl :headless`
+You can also use e.g. Emacs with CIDER integration and `cider-jack-in-clj&cljs`. Calva and Cursive have also been used successfully. And we regularly use Linux and Mac for development. YMMV.
 
-We have had success with other editors as well.
+In whatever editor you decide, you should start in the development profile, i.e., in Emacs set `Cider Lein Parameters` to `with-profile +dev repl :headless`.
 
 ## Building an uberjar
 
@@ -101,7 +106,7 @@ To run build the JS bundle and run browser tests (requires chromedriver in $PATH
 lein browsertests
 ```
 
-If browser tests fail, screenshots and DOM are written in the directory `browsertest-errors`.
+As the tests run, and especially if browser tests fail, screenshots and DOM are written in the directory `browsertest-errors`.
 
 For fixing or especially the development of the browser tests, you can run a windowed regular browser and see what the tests are doing.
 
@@ -116,21 +121,15 @@ HEADLESS=0 lein browsertests                                     # to see all br
 HEADLESS=0 lein kaocha --focus rems.test-browser/test-blacklist  # to see a specific browser test
 ```
 
-### Clojurescript tests
-
-First make sure you have the npm depenencies with
-
-```
-lein deps
-```
+### ClojureScript tests
 
-and then just run
+We have some tests for specifically ClojureScript code. A helper runs them with
 
 ```
-lein doo once
+lein shadow-test
 ```
 
-which will run the tests in a headless Chrome via Karma.
+They will run the tests in a headless Chrome via Karma and Shadow-CLJS.
 
 You may need to run `npm install karma karma-cljs-test karma-chrome-launcher` first to install the necessary tools and packages.
 
@@ -141,6 +140,7 @@ To conveniently run all the tests you can run the lein alias
 ```
 lein alltests
 ```
+
 ### Automated accessibility test report
 
 We use [axe](https://www.deque.com/axe/) for automated accessibility tests.
@@ -189,4 +189,6 @@ lein migratus down 20211105110533
 To run specific migration up:
 ```sh
 lein migratus up 20211105110533
-```
\ No newline at end of file
+```
+
+NB: The migrations can also be written in Clojure. Then you should replace the `.sql` files with a `.edn` file. See examples in the migrations-directory.
diff --git a/docs/release.md b/docs/release.md
index a1e1e07bf..812112a29 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -58,7 +58,7 @@ Releases can be found on the github [release page](https://github.com/CSCfi/rems
    https://github.com/CSCfi/rems/releases/tag/v2.6
 
 10. From "Edit tag", change the name of the tag to the release title,
-   e.g., `v2.6 "Kalevalantie"`.
+   e.g., "Kalevalantie".
 
 11. Copy changes under the new release from CHANGELOG.md to "Description"
     field:
diff --git a/karma.conf.js b/karma.conf.js
new file mode 100644
index 000000000..bfae08e6d
--- /dev/null
+++ b/karma.conf.js
@@ -0,0 +1,17 @@
+module.exports = function (config) {
+  config.set({
+    browsers: ['ChromeHeadless'],
+    basePath: 'target/shadow',
+    files: ['cljs-test.js'],
+    frameworks: ['cljs-test'],
+    plugins: ['karma-cljs-test', 'karma-chrome-launcher', 'karma-junit-reporter'],
+    reporters: ['progress', 'junit'],
+    junitReporter: {outputDir: 'target/test-results'},
+    colors: true,
+    logLevel: config.LOG_INFO,
+    singleRun: true,
+    client: {
+      args: ["shadow.test.karma.init"],
+    }
+  })
+};
diff --git a/package.json b/package.json
new file mode 100644
index 000000000..6773d557a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+  "dependencies": {
+    "react": "16.9.0",
+    "react-dom": "16.9.0",
+    "react-select": "2.4.4"
+  },
+  "devDependencies": {
+    "karma": "^6.3.20",
+    "karma-chrome-launcher": "^3.1.1",
+    "karma-cljs-test": "^0.1.0",
+    "karma-junit-reporter": "^2.0.1",
+    "puppeteer": "^14.1.2",
+    "shadow-cljs": "^2.16.6"
+  },
+  "scripts": {
+    "dev": "shadow-cljs watch app"
+  }
+}
diff --git a/project.clj b/project.clj
index aa81dbba2..5cfb790c0 100644
--- a/project.clj
+++ b/project.clj
@@ -6,17 +6,10 @@
   :dependencies [[buddy/buddy-auth "3.0.323"]
                  [buddy/buddy-sign "3.4.333"]
                  [ch.qos.logback/logback-classic "1.2.11"]
-                 [clj-commons/secretary "1.2.4"]
                  [clj-http "3.12.3"]
                  [cheshire "5.10.2" :exclusions [com.fasterxml.jackson.core/jackson-core]] ;; clj-http uses cheshire's json parsing
                  [clj-pdf "2.5.8"]
                  [clj-time "0.15.2"]
-                 [cljs-ajax "0.8.4"]
-                 [cljsjs/react "16.9.0-1"]
-                 [cljsjs/react-dom "16.9.0-1"]
-                 [cljsjs/react-dom-server "16.9.0-1"]
-                 [cljsjs/react-select "2.4.4-0" :exclusions [cljsjs/react cljsjs/react-dom cljsjs/react-dom-server]]
-                 [com.andrewmcveigh/cljs-time "0.5.2"]
                  [com.attendify/schema-refined "0.3.0-alpha5"]
                  [com.draines/postal "2.0.5"]
                  [com.fasterxml.jackson.datatype/jackson-datatype-joda "2.13.2"]
@@ -26,7 +19,6 @@
                  [compojure "1.6.2"]
                  [conman "0.8.4"] ;; 0.8.5 switches to next.jdbc, which breaks stuff and requires proper testing in production
                  [cprop "0.1.19"]
-                 [funcool/promesa "8.0.450"]
                  [garden "1.3.10"]
                  [hiccup "1.0.5"]
                  [javax.xml.bind/jaxb-api "2.4.0-b180830.0359"]
@@ -40,15 +32,13 @@
                  [medley "1.3.0"]
                  [metosin/compojure-api "2.0.0-alpha30" :exclusions [cheshire com.fasterxml.jackson.core/jackson-core]]
                  [metosin/jsonista "0.3.5"]
-                 [metosin/komponentit "0.3.11"]
                  [metosin/ring-swagger "0.26.2"]
                  [metosin/ring-swagger-ui "4.5.0"]
                  [mount "0.1.16"]
                  [ns-tracker "0.4.0"]
                  [org.apache.lucene/lucene-core "9.1.0"]
                  [org.apache.lucene/lucene-queryparser "9.1.0"]
-                 [org.clojure/clojure "1.11.0"]
-                 [org.clojure/clojurescript "1.10.764" :exclusions [com.fasterxml.jackson.core/jackson-core]] ; later ones don't work with lein-cljsbuild 1.1.8
+                 [org.clojure/clojure "1.11.1"]
                  [org.clojure/core.cache "1.0.225"]
                  [org.clojure/core.memoize "1.0.257"]
                  [org.clojure/data.csv "1.0.0"]
@@ -66,33 +56,27 @@
                  [org.webjars/font-awesome "6.1.0"]
                  [org.webjars/jquery "3.6.0"]
                  [prismatic/schema-generators "0.1.4"]
-                 [re-frame "1.2.0"]
-                 [reagent "1.1.1"]
-                 [reagent-utils "0.3.4"]
                  [ring-cors "0.1.13"]
                  [ring-middleware-format "0.7.5"]
                  [ring-webjars "0.2.0"]
                  [ring/ring-core "1.9.5"]
                  [ring/ring-defaults "0.3.3"]
                  [ring/ring-devel "1.9.5"]
-                 [ring/ring-servlet "1.9.5"]
-                 [venantius/accountant "0.2.5"]]
+                 [ring/ring-servlet "1.9.5"]]
 
-  :min-lein-version "2.0.0"
+  :min-lein-version "2.9.8"
 
   :source-paths ["src/clj" "src/cljc"]
   :java-source-paths ["src/java"]
   :javac-options ["-source" "8" "-target" "8"]
   :test-paths ["src/clj" "src/cljc" "test/clj" "test/cljc"] ;; also run tests from src files
-  :resource-paths ["resources" "target/cljsbuild"]
+  :resource-paths ["resources" "target/shadow"]
   :target-path "target/%s/"
   :main rems.standalone
   :migratus {:store :database :db ~(get (System/getenv) "DATABASE_URL" "postgresql://localhost/rems?user=rems")}
 
   :plugins [[lein-cljfmt "0.6.7"]
-            [lein-cljsbuild "1.1.8"]
             [lein-cprop "1.0.3"]
-            [lein-npm "0.6.2"]
             [lein-shell "0.5.0"]
             [migratus-lein "0.5.7"]]
 
@@ -101,33 +85,14 @@
 
   :clean-targets ["target"]
 
-  :figwheel {:http-server-root "public"
-             :server-logfile "log/figwheel_server.log"
-             :nrepl-port 7002
-             :css-dirs ["target/resources/public/css/en" "target/resources/public/css/fi" "target/resources/public/css/sv"]
-             :nrepl-middleware [cider.piggieback/wrap-cljs-repl]}
-
-  :npm {:devDependencies [;; cljs testing
-                          [karma "3.1.1"]
-                          [karma-junit-reporter "2.0.1"]
-                          [karma-cljs-test "0.1.0"]
-                          [karma-chrome-launcher "2.2.0"]
-                          ;; printing to pdf
-                          [puppeteer "2.0.0"]]}
-
-  :doo {:build "test"
-        :paths {:karma "node_modules/karma/bin/karma"}
-        :alias {:default [:chrome-headless]}
-        :karma {:config {"plugins" ["karma-junit-reporter"]
-                         "reporters" ["progress" "junit"]
-                         "junitReporter" {"outputDir" "target/test-results"}}}}
-
-  :aliases {"kaocha" ["with-profile" "test" "run" "-m" "kaocha.runner"]
-            "browsertests" ["do" ["cljsbuild" "once"] ["kaocha" "browser"]]
-            "cljtests" ["do" ["cljsbuild" "once"] ["kaocha"]]
-            "cljtests-ci" ["do" ["cljsbuild" "once"] ["kaocha" "--reporter" "kaocha.report/documentation"]]
-            "alltests" ["do" ["cljsbuild" "once"] ["kaocha"] ["doo" "once"]]
-            "test-ancient" ["do" ["cljsbuild" "once"] ["kaocha"] ["doo" "once"]]} ; for lein ancient to work and run all tests
+  :aliases {"shadow-build" ["shell" "sh" "-c" "npm install && npx shadow-cljs compile app"]
+            "shadow-release" ["shell" "sh" "-c" "npm install && npx shadow-cljs release app"]
+            "shadow-test" ["shell" "sh" "-c" "npm install --include=dev && npx shadow-cljs compile cljs-test && ./node_modules/karma/bin/karma start"]
+            "shadow-watch" ["shell" "sh" "-c" "npm install --include=dev && npx shadow-cljs watch app"]
+            "kaocha" ["with-profile" "test" "run" "-m" "kaocha.runner"]
+            "browsertests" ["do" ["shadow-build"] ["kaocha" "browser"]]
+            "alltests" ["do" ["shadow-build"] ["kaocha"] ["shadow-test"]]
+            "test-ancient" ["do" ["shadow-build"] ["kaocha"] ["shadow-test"]]} ; for lein ancient to work and run all tests
 
   :profiles
   {:uberjar {:omit-source true
@@ -136,18 +101,7 @@
                           ["shell" "sh" "-c" "git rev-parse HEAD > target/uberjar/resources/git-revision.txt"]
                           "javac"
                           "compile"
-                          ["cljsbuild" "once" "min"]]
-             :cljsbuild {:builds {:min {:source-paths ["src/cljc" "src/cljs"]
-                                        :compiler {:output-dir "target/cljsbuild/public/js"
-                                                   :output-to "target/cljsbuild/public/js/app.js"
-                                                   :source-map "target/cljsbuild/public/js/app.js.map"
-                                                   :optimizations :advanced
-                                                   :pretty-print false
-                                                   :closure-warnings {:externs-validation :off
-                                                                      :non-standard-jsdoc :off}
-                                                   :warnings {:munged-namespace false} ;; for rems.actions.delete
-                                                   :infer-externs :true ;; for window.rems.hooks to work
-                                                   :externs ["react/externs/react.js"]}}}}
+                          "shadow-release"]
              :aot :all
              :uberjar-name "rems.jar"
              :source-paths ["env/prod/clj"]
@@ -157,22 +111,16 @@
    :test [:project/dev :project/test :profiles/test]
 
    :project/dev {:dependencies [[binaryage/devtools "1.0.5"]
-                                [cider/piggieback "0.5.3"]
                                 [com.clojure-goes-fast/clj-memory-meter "0.1.3"]
                                 [criterium "0.4.6"]
-                                [doo "0.1.11"]
                                 [lambdaisland/kaocha "1.64.1010"]
                                 [lambdaisland/kaocha-junit-xml "0.0.76"]
                                 [etaoin "0.4.6"]
-                                [figwheel-sidecar "0.5.20" :exclusions [org.clojure/tools.nrepl com.fasterxml.jackson.core/jackson-core]]
-                                [re-frisk "1.5.2"] ;; coupled to the reagent version
                                 [ring/ring-mock "0.4.0" :exclusions [cheshire]]
                                 [se.haleby/stub-http "0.2.12"]
                                 [com.icegreen/greenmail "1.6.7"]]
 
-                 :plugins [[lein-ancient "0.6.15"]
-                           [lein-doo "0.1.11"]
-                           [lein-figwheel "0.5.20"]]
+                 :plugins [[lein-ancient "0.6.15"]]
 
                  :jvm-opts ["-Drems.config=dev-config.edn"
                             "-Djdk.attach.allowAttachSelf" ; needed by clj-memory-meter on Java 9+
@@ -180,24 +128,7 @@
                  :source-paths ["env/dev/clj"]
                  :resource-paths ["env/dev/resources"]
                  :repl-options {:init-ns rems
-                                :welcome (rems/repl-help)}
-
-                 :cljsbuild {:builds {:dev {:source-paths ["src/cljs" "src/cljc"]
-                                            :figwheel {:on-jsload "rems.spa/mount-components"}
-                                            :compiler {:main "rems.app"
-                                                       :asset-path "/js/out"
-                                                       :output-to "target/cljsbuild/public/js/app.js"
-                                                       :output-dir "target/cljsbuild/public/js/out"
-                                                       :source-map true
-                                                       :optimizations :none
-                                                       :pretty-print true
-                                                       :warnings {:munged-namespace false} ;; for rems.actions.delete
-                                                       :preloads [devtools.preload re-frisk.preload]}}
-                                      :test {:source-paths ["src/cljs" "src/cljc" "test/cljs"]
-                                             :compiler {:output-to "target/cljsbuild/test/test.js"
-                                                        :output-dir "target/cljsbuild/test/out"
-                                                        :main rems.cljs-tests
-                                                        :optimizations :none}}}}}
+                                :welcome (rems/repl-help)}}
    :project/test {:jvm-opts ["-Drems.config=test-config.edn"]
                   :resource-paths ["env/test/resources"]}
    :profiles/dev {}
diff --git a/resources/config-defaults.edn b/resources/config-defaults.edn
index 247150d23..6c0d8f454 100644
--- a/resources/config-defaults.edn
+++ b/resources/config-defaults.edn
@@ -376,7 +376,6 @@
  :catalogue-is-public false
 
  ;; Options used only for development
- :render-css-file? false
  :test-database-url nil
  :accessibility-report false ; should the axe accessibility tooling be included?
  :enable-ega false ; should the EGA features be shown in the UI
diff --git a/shadow-cljs.edn b/shadow-cljs.edn
new file mode 100644
index 000000000..e0ee13b8e
--- /dev/null
+++ b/shadow-cljs.edn
@@ -0,0 +1,30 @@
+{:dependencies [[cljs-ajax "0.8.4"]
+                [clj-commons/secretary "1.2.4"]
+                [com.andrewmcveigh/cljs-time "0.5.2"]
+                [com.rpl/specter "1.1.4"]
+                [com.taoensso/tempura "1.2.1"]
+                [funcool/promesa "8.0.450"]
+                [markdown-clj "1.10.9"]
+                [medley "1.3.0"]
+                [metosin/komponentit "0.3.11"]
+                [re-frame "1.2.0"]
+                [reagent "1.1.1"]
+                [re-frisk "1.6.0"]
+                [reagent-utils "0.3.4"]
+                [venantius/accountant "0.2.5"]
+                [prismatic/schema "1.2.1"]]
+
+ :source-paths ["env/dev/clj" "src/cljs" "src/cljc" "src/clj" "test/cljs"]
+
+ :nrepl {:port 7002}
+
+ :builds {:app {:target :browser
+                :output-dir "target/shadow/public/js"
+                :asset-path "/js"
+                :modules {:app {:entries [rems.app]}}
+                :compiler-options {:optimizations :advanced}
+                :devtools {:preloads [re-frisk.preload]
+                           :watch-dir "target/resources/public"}}
+          :cljs-test {:target :karma
+                      :output-to "target/shadow/cljs-test.js"
+                      :ns-regexp "test-"}}}
diff --git a/src/clj/rems/application/search.clj b/src/clj/rems/application/search.clj
index 95ca2d532..436c241b6 100644
--- a/src/clj/rems/application/search.clj
+++ b/src/clj/rems/application/search.clj
@@ -8,9 +8,9 @@
             [rems.config :refer [env]]
             [rems.db.applications :as applications]
             [rems.db.events :as events]
-            [rems.text :as text])
-  (:import [com.google.common.io MoreFiles RecursiveDeleteOption]
-           [org.apache.lucene.analysis Analyzer]
+            [rems.text :as text]
+            [rems.util :refer [delete-directory-contents-recursively]])
+  (:import [org.apache.lucene.analysis Analyzer]
            [org.apache.lucene.analysis.standard StandardAnalyzer]
            [org.apache.lucene.document Document StringField Field$Store TextField]
            [org.apache.lucene.index IndexWriter IndexWriterConfig IndexWriterConfig$OpenMode Term]
@@ -31,8 +31,8 @@
   :start (let [index-dir (.toPath (io/file (:search-index-path env)))]
            (locking index-lock
              ;; delete old index
-             (when (.exists (.toFile index-dir))
-               (MoreFiles/deleteDirectoryContents index-dir (into-array [RecursiveDeleteOption/ALLOW_INSECURE])))
+             (delete-directory-contents-recursively (.toFile index-dir))
+
              (let [directory (NIOFSDirectory. index-dir)]
                ;; create a new empty index by creating and closing an IndexWriter, otehrwise SearcherManager will fail
                (.close (IndexWriter. directory (IndexWriterConfig. analyzer)))
diff --git a/src/clj/rems/css/styles.clj b/src/clj/rems/css/styles.clj
index 6f1344317..88217345d 100644
--- a/src/clj/rems/css/styles.clj
+++ b/src/clj/rems/css/styles.clj
@@ -3,9 +3,9 @@
   accessing the application on a browser. The garden styles can also
   be manually compiled by calling the function `rems.css.styles/screen-css`.
 
-  For development purposes with Figwheel the styles are rendered to
+  For development purposes with live reload, the styles are rendered to
   `target/resources/public/css/:language/screen.css` whenever this ns is evaluated
-  so that Figwheel can autoreload them."
+  so that we can autoreload them."
   (:require [clojure.test :refer [deftest is testing]]
             [clojure.tools.logging :as log]
             [compojure.core :refer [GET defroutes]]
@@ -919,14 +919,14 @@
 (defn screen-css []
   (g/css {:pretty-print? false} (remove-nil-vals (build-screen))))
 
-;; For development use and Figwheel updates render all configured CSS
-;; files so that Figwheel will notice this change and force our app
+;; For development use and live reload, render all configured CSS
+;; files so that the devtools will notice this change and force our app
 ;; to reload CSS files from the usual route.
-;; The files are not used for anything besides this signaling to Figwheel.
+;; The files are not used for anything besides this signaling.
 (mount/defstate
   rendered-css-files
   :start
-  (when (env :render-css-file?)
+  (when (env :dev)
     (doseq [language (env :languages)]
       (binding [context/*lang* language]
         (render-css-file language
diff --git a/src/clj/rems/handler.clj b/src/clj/rems/handler.clj
index 4d4d7f44e..bec5d9674 100644
--- a/src/clj/rems/handler.clj
+++ b/src/clj/rems/handler.clj
@@ -115,15 +115,6 @@
 (def ^:private resource-handler
   (route/resources "/" {:root "public"}))
 
-(defn- dev-js-handler
-  "In dev mode, serve compiled js files without caching. This is needed
-  because figwheel injects a reference to app.js without our
-  cache-busting query parameter."
-  []
-  (if (:dev env)
-    (route/resources "/js" {:root "public/js"})
-    never-match-route))
-
 (defn app-routes []
   (routes
    home-route
@@ -134,11 +125,10 @@
             entitlements/entitlements-routes))
    (auth/auth-routes)
    #'api-routes
-   (dev-js-handler)
    ;; TODO should we disable logging of resource requests?
    (wrap-cacheable
     (routes
-     styles/css-routes ; figwheel livereload does cache-busting for the css, so we don't need a dev-css-handler hack
+     styles/css-routes
      resource-handler
      (extra-script-routes (:extra-scripts env))
      (static-resources (:extra-static-resources env))
diff --git a/src/clj/rems/middleware.clj b/src/clj/rems/middleware.clj
index a6ab592ed..c6744ff6f 100644
--- a/src/clj/rems/middleware.clj
+++ b/src/clj/rems/middleware.clj
@@ -154,10 +154,10 @@
       (catch ForbiddenException e
         (on-forbidden-error req)))))
 
-;; When using figwheel in dev mode, the browser getches a gazillion js
-;; files from under /js/out. We filter them out to keep the log
-;; cleaner. This helps e.g. when debugging browser test failures.
-(def silence-logging-regex #"^/js/out/.*")
+;; When using devtools, the browser fetches source maps etc.
+;; We filter them out to keep the log cleaner.
+;; This helps e.g. when debugging browser test failures.
+(def silence-logging-regex #"^/js/cljs-runtime/.*")
 
 (defn wrap-logging
   [handler]
diff --git a/src/clj/rems/util.clj b/src/clj/rems/util.clj
index bba39c7ed..65274a047 100644
--- a/src/clj/rems/util.clj
+++ b/src/clj/rems/util.clj
@@ -85,3 +85,28 @@
           (recur files))
         files))))
 
+
+(defn delete-directory-contents-recursively
+  "Deletes `dir` contents recursively, if the `dir` exists.
+
+  Does not follow symlinks in contents."
+  [dir]
+  (when (.exists dir)
+    (doseq [file (.listFiles dir)]
+      (when (and (.isDirectory file)
+                 (not (.isSymbolicLink file)))
+        (delete-directory-contents-recursively file))
+      (io/delete-file file true))))
+
+(defn delete-directory-recursively
+  "Deletes `dir` and its contents recursively, if the `dir` exists.
+
+  Does not follow symlinks in contents."
+  [dir]
+  (delete-directory-contents-recursively dir)
+  (io/delete-file dir true))
+
+(defn ensure-empty-directory!
+  [dir]
+  (.mkdirs dir)
+  (delete-directory-contents-recursively dir))
diff --git a/src/cljc/rems/common/util.cljc b/src/cljc/rems/common/util.cljc
index 0f9cea65c..5968ab472 100644
--- a/src/cljc/rems/common/util.cljc
+++ b/src/cljc/rems/common/util.cljc
@@ -535,8 +535,11 @@
   "A file path may contain local filesystem parts that we want to remove
   so that we can use the path to refer to e.g. project GitHub."
   [path]
-  (str/replace (subs path (str/index-of path "src"))
-               "\\" "/"))
+  (if-let [index (if path (str/index-of path "src") 0)]
+    (some-> path
+            (subs index)
+            (str/replace "\\" "/"))
+    path))
 
 (deftest normalize-file-path-test
   (is (= "src/foo/bar.clj" (normalize-file-path "/home/john/rems/src/foo/bar.clj")))
diff --git a/src/cljs/rems/app.cljs b/src/cljs/rems/app.cljs
index 664023bd1..fb9184483 100644
--- a/src/cljs/rems/app.cljs
+++ b/src/cljs/rems/app.cljs
@@ -1,4 +1,4 @@
-(ns ^:figwheel-no-load rems.app
+(ns ^:dev/once rems.app
   (:require [rems.identity :refer [set-identity!]]
             [rems.spa :as spa]))
 
diff --git a/src/cljs/rems/dropdown.cljs b/src/cljs/rems/dropdown.cljs
index 9e2498a74..5b392fbe4 100644
--- a/src/cljs/rems/dropdown.cljs
+++ b/src/cljs/rems/dropdown.cljs
@@ -1,6 +1,6 @@
 (ns rems.dropdown
   (:require [reagent.core :as r]
-            [cljsjs.react-select]
+            ["react-select" :rename {Async AsyncSelect} :default Select]
             [clojure.string :as str]
             [medley.core :refer [assoc-some]]
             [rems.guide-util :refer [component-info example]]
@@ -29,22 +29,22 @@
          item-selected? (constantly false)
          item-disabled? (constantly false)}}]
   ;; some of the callbacks may be keywords which aren't JS fns so we wrap them in anonymous fns
-  [:> js/Select {:className (str/trimr (str "dropdown-container " class))
-                 :classNamePrefix "dropdown-select"
-                 :getOptionLabel #(item-label %)
-                 :getOptionValue #(item-key %)
-                 :inputId id
-                 :isMulti multi?
-                 :isClearable clearable?
-                 :isDisabled disabled?
-                 :isOptionDisabled #(item-disabled? %)
-                 :maxMenuHeight 200
-                 :noOptionsMessage #(text :t.dropdown/no-results)
-                 :hideSelectedOptions hide-selected?
-                 :options (into-array items)
-                 :value (into-array (filter item-selected? items))
-                 :onChange #(on-change (if (array? %) (array-seq %) %))
-                 :placeholder (or placeholder (text :t.dropdown/placeholder))}])
+  [:> Select {:className (str/trimr (str "dropdown-container " class))
+              :classNamePrefix "dropdown-select"
+              :getOptionLabel #(item-label %)
+              :getOptionValue #(item-key %)
+              :inputId id
+              :isMulti multi?
+              :isClearable clearable?
+              :isDisabled disabled?
+              :isOptionDisabled #(item-disabled? %)
+              :maxMenuHeight 200
+              :noOptionsMessage #(text :t.dropdown/no-results)
+              :hideSelectedOptions hide-selected?
+              :options (into-array items)
+              :value (into-array (filter item-selected? items))
+              :onChange #(on-change (if (array? %) (array-seq %) %))
+              :placeholder (or placeholder (text :t.dropdown/placeholder))}])
 
 (defn async-dropdown
   "Single- or multi-choice, searchable dropdown menu with support for asynchronous data loading.
@@ -68,26 +68,26 @@
          hide-selected? multi?
          item-disabled? (constantly false)}}]
   ;; some of the callbacks may be keywords which aren't JS fns so we wrap them in anonymous fns
-  [:> js/Select.Async (-> {:className (str/trimr (str "dropdown-container " class))
-                           :classNamePrefix "dropdown-select-async"
-                           :getOptionLabel #(item-label (js->clj % :keywordize-keys true))
-                           :getOptionValue #(item-key (js->clj % :keywordize-keys true))
-                           :inputId id
-                           :isMulti multi?
-                           :isClearable clearable?
-                           :isDisabled disabled?
-                           :isOptionDisabled #(item-disabled? (js->clj % :keywordize-keys true))
-                           :maxMenuHeight 200
-                           :noOptionsMessage #(text :t.dropdown/no-results)
-                           :hideSelectedOptions hide-selected?
-                           :onChange #(let [items (js->clj % :keywordize-keys true)]
-                                        (on-change (if (array? items) (array-seq items) items)))
-                           :placeholder (or placeholder (text :t.dropdown/placeholder))
-                           :loadOptions (fn [query-string callback]
-                                          (on-load-options {:query-string query-string
-                                                            :on-data #(callback (clj->js %))}))
-                           :loadingMessage #(text :t.dropdown/loading)}
-                          (assoc-some :value (when (seq items) (into-array items))))])
+  [:> AsyncSelect (-> {:className (str/trimr (str "dropdown-container " class))
+                       :classNamePrefix "dropdown-select-async"
+                       :getOptionLabel #(item-label (js->clj % :keywordize-keys true))
+                       :getOptionValue #(item-key (js->clj % :keywordize-keys true))
+                       :inputId id
+                       :isMulti multi?
+                       :isClearable clearable?
+                       :isDisabled disabled?
+                       :isOptionDisabled #(item-disabled? (js->clj % :keywordize-keys true))
+                       :maxMenuHeight 200
+                       :noOptionsMessage #(text :t.dropdown/no-results)
+                       :hideSelectedOptions hide-selected?
+                       :onChange #(let [items (js->clj % :keywordize-keys true)]
+                                    (on-change (if (array? items) (array-seq items) items)))
+                       :placeholder (or placeholder (text :t.dropdown/placeholder))
+                       :loadOptions (fn [query-string callback]
+                                      (on-load-options {:query-string query-string
+                                                        :on-data #(callback (clj->js %))}))
+                       :loadingMessage #(text :t.dropdown/loading)}
+                      (assoc-some :value (when (seq items) (into-array items))))])
 
 (defn guide
   []
diff --git a/src/cljs/rems/spa.cljs b/src/cljs/rems/spa.cljs
index 2af4adf55..d6367ff6b 100644
--- a/src/cljs/rems/spa.cljs
+++ b/src/cljs/rems/spa.cljs
@@ -592,16 +592,13 @@
       (set-location! url))
     (rf/dispatch [:landing-page-redirect!])))
 
-(secretary/defroute "*" []
-  (rf/dispatch [:set-active-page :not-found]))
-
 ;;;; History
 ;; must be called after routes have been defined
 
 (defn hook-browser-navigation! []
   (events/listen accountant/history
                  HistoryEventType/NAVIGATE
-                 (fn [event]
+                 (fn [^js event]
                    (js/window.rems.hooks.navigate (.-token event))))
 
   (accountant/configure-navigation!
@@ -631,7 +628,7 @@
 
 ;;;; Initialize app
 
-(defn mount-components []
+(defn ^:dev/after-load mount-components []
   (rf/clear-subscription-cache!)
   (rd/render [page] (.getElementById js/document "app")))
 
diff --git a/src/cljs/rems/table.cljs b/src/cljs/rems/table.cljs
index 6b8d80914..8d28fd798 100644
--- a/src/cljs/rems/table.cljs
+++ b/src/cljs/rems/table.cljs
@@ -403,7 +403,7 @@
 
 (defn guide []
   [:div
-   (namespace-info rems.table)
+  ;;  (namespace-info rems.table)
    (component-info table)
 
    (example "empty table"
diff --git a/src/cljs/rems/user_settings.cljs b/src/cljs/rems/user_settings.cljs
index 27b5144a5..fea816a73 100644
--- a/src/cljs/rems/user_settings.cljs
+++ b/src/cljs/rems/user_settings.cljs
@@ -38,10 +38,7 @@
          (:languages db))))
 
 (defn- update-css [language]
-  ;; TODO retain cache-busting set by figwheel or our layout.clj
   (let [localized-css (str "/css/" (name language) "/screen.css")]
-    ;; Figwheel replaces the linked stylesheet
-    ;; so we need to search dynamically
     (doseq [element (array-seq (.getElementsByTagName js/document "link"))]
       (when (str/includes? (.-href element) "screen.css")
         ;; preserve cache-busting query params if any


Test patch:
diff --git a/test/clj/rems/browser_test_util.clj b/test/clj/rems/browser_test_util.clj
index 6fcf618e2..7c0494ffc 100644
--- a/test/clj/rems/browser_test_util.clj
+++ b/test/clj/rems/browser_test_util.clj
@@ -18,8 +18,9 @@
             [rems.db.test-data :as test-data]
             [rems.json :as json]
             [rems.standalone]
+            [rems.util :refer [ensure-empty-directory!]]
             [slingshot.slingshot :refer [try+]])
-  (:import (java.net SocketException)))
+  (:import [java.net SocketException]))
 
 ;;; test setup
 
@@ -39,15 +40,6 @@
 (defn context-assoc! [& args] (swap! test-context #(apply assoc % args)))
 (defn context-update! [& args] (swap! test-context #(apply update % args)))
 
-(defn- delete-files! [dir]
-  (doseq [file (.listFiles dir)]
-    (io/delete-file file true)))
-
-(defn- ensure-empty-directory!
-  [dir]
-  (.mkdirs dir)
-  (delete-files! dir))
-
 (defn- ensure-empty-directories! []
   (ensure-empty-directory! (:reporting-dir @test-context))
   (ensure-empty-directory! (:accessibility-report-dir @test-context))
@@ -496,7 +488,7 @@
   Returns the test report.
 
   Ignores:
-  - all divs of body except #app like #figwheel-heads-up-container and a re-frisk unnamed div
+  - all divs of body except #app and a re-frisk unnamed div
   - our development tooling like .dev-reload-button
 
   See https://www.deque.com/axe/"
diff --git a/test/clj/rems/test_handler.clj b/test/clj/rems/test_handler.clj
index 46894e335..c81aa93c1 100644
--- a/test/clj/rems/test_handler.clj
+++ b/test/clj/rems/test_handler.clj
@@ -50,7 +50,8 @@
             body (read-ok-body response)]
         (is (= 200 (:status response)))
         (is (= "1;\n" body)) ; check that we didn't get e.g. the fallback HTML
-        (is (= "no-store" (get-in response [:headers "Cache-Control"])))))
+        (is (= (str "max-age=" (* 60 60 23))
+               (get-in response [:headers "Cache-Control"])))))
     (testing "in prod mode"
       (with-redefs [env (assoc env :dev false)]
         (mount/stop #'rems.handler/handler)
diff --git a/test/clj/rems/test_locales.clj b/test/clj/rems/test_locales.clj
index 896cb6f02..1cbce0f07 100644
--- a/test/clj/rems/test_locales.clj
+++ b/test/clj/rems/test_locales.clj
@@ -7,8 +7,8 @@
             [clojure.tools.logging]
             [rems.common.util :refer [recursive-keys]]
             [rems.locales :as locales]
-            [rems.testing-util :refer [create-temp-dir delete-recursively]]
-            [rems.util :refer [getx-in]]
+            [rems.testing-util :refer [create-temp-dir]]
+            [rems.util :refer [getx-in delete-directory-recursively]]
             [taoensso.tempura.impl :refer [compile-dictionary]])
   (:import (java.io FileNotFoundException)))
 
@@ -103,7 +103,7 @@
               (pr-str translation))
         (is (= translation (:xx (locales/load-translations config))))
         (finally
-          (delete-recursively translations-dir)))))
+          (delete-directory-recursively translations-dir)))))
 
   (testing "loads translations only for listed languages"
     (is (= [:en] (keys (locales/load-translations {:languages [:en]
diff --git a/test/clj/rems/testing_util.clj b/test/clj/rems/testing_util.clj
index dd979ae56..d4b403ef4 100644
--- a/test/clj/rems/testing_util.clj
+++ b/test/clj/rems/testing_util.clj
@@ -1,18 +1,16 @@
 (ns rems.testing-util
-  (:require [clj-time.core :as time]
-            [clojure.java.io :as io]
+  (:require [clojure.java.io :as io]
             [clojure.set :as set]
             [rems.context :as context]
             [rems.db.applications :as applications]
             [rems.db.roles :as roles]
             [rems.db.users :as users]
             [rems.db.organizations :as organizations])
-  (:import (ch.qos.logback.classic Level Logger)
-           (com.google.common.io MoreFiles RecursiveDeleteOption)
-           (java.nio.file Files)
-           (java.nio.file.attribute FileAttribute)
-           (org.joda.time DateTimeZone DateTimeUtils)
-           (org.slf4j LoggerFactory)))
+  (:import [ch.qos.logback.classic Level Logger]
+           [java.nio.file Files]
+           [java.nio.file.attribute FileAttribute]
+           [org.joda.time DateTimeZone DateTimeUtils]
+           [org.slf4j LoggerFactory]))
 
 (defn utc-fixture [f]
   (let [old (DateTimeZone/getDefault)]
@@ -44,10 +42,6 @@
                                       "test"
                                       (make-array FileAttribute 0))))
 
-(defn delete-recursively [dir]
-  (MoreFiles/deleteRecursively (.toPath (io/file dir))
-                               (into-array [RecursiveDeleteOption/ALLOW_INSECURE])))
-
 (defmacro with-user [user & body]
   `(binding [context/*user* (users/get-raw-user-attributes ~user)
              context/*roles* (set/union (roles/get-roles ~user)