Repository: CSCfi/rems
Pull request #1111 (2019-04-11 11:13:38)

Add licenses
Implements #830 by the way of using the regular licenses.
- `add-licenses` command, `licenses-added` event and an action UI `add_licenses.cljs`.
- Regular accept licenses workflow for the acceptance part.

Open questions:
- Currently owner is able to add new licenses and a handler doesn't. Should it be possible for a handler to admin or how would an ad-hoc license for one application be created?
- Does not refactor adding licenses away from the created away. Perhaps it does not make sense after all.


Patch:
diff --git a/resources/translations/en.edn b/resources/translations/en.edn
index 579eb5a45..63d940b2d 100644
--- a/resources/translations/en.edn
+++ b/resources/translations/en.edn
@@ -50,6 +50,7 @@
             :accept-invitation-success [:div [:p "Joining the application was successful."] [:p "Next you will be taken to the application."]]
             :accept-invitation-already-member [:div [:p "You are already a member."] [:p "Next you will be taken to the application."]]
             :accept-licenses "Accept terms of use"
+            :add-licenses "Add terms of use"
             :add-member "Add member"
             :applicant "Applicant"
             :application "Application"
@@ -68,6 +69,7 @@
             :handled-approvals "Handled applications"
             :invite-member "Invite member"
             :last-activity "Last activity"
+            :licenses-selection "Require following additional terms of use"
             :load-application-states "Load application states"
             :member "Member"
             :member-name "Name"
@@ -79,7 +81,7 @@
             :remove-member "Remove member"
             :request-comment "Request comment"
             :request-decision "Request decision"
-            :request-selection "Send request to user(s):"
+            :request-selection "Send request to users"
             :resource "Resource"
             :return "Return to applicant"
             :show-publications "Show publications"
@@ -145,6 +147,7 @@
                           :decided "Decided"
                           :decision-requested "Decision requested"
                           :draft-saved "Saved"
+                          :licenses-added "Added tems of use"
                           :licenses-accepted "Accepted terms of use"
                           :member-added "Member added"
                           :member-invited "Member invited"
diff --git a/resources/translations/fi.edn b/resources/translations/fi.edn
index e9f068065..5d6823c66 100644
--- a/resources/translations/fi.edn
+++ b/resources/translations/fi.edn
@@ -50,6 +50,7 @@
             :accept-invitation-success [:div [:p "Liittyminen hakemukseen onnistui."] [:p "Seuraavaksi pääset katsomaan hakemuksen tietoja."]]
             :accept-invitation-already-member [:div. [:p "Olet jo jäsen."] [:p "Seuraavaksi pääset katsomaan hakemuksen tietoja."]]
             :accept-licenses "Hyväksy käyttöehdot"
+            :add-licenses "Lisää käyttöehto"
             :add-member "Lisää jäsen"
             :applicant "Hakija"
             :application "Hakemus"
@@ -68,6 +69,7 @@
             :handled-approvals "Käsitellyt hakemukset"
             :invite-member "Kutsu jäsen"
             :last-activity "Muokattu"
+            :licenses-selection "Lisää seuraavat käyttöehdot"
             :load-application-states "Lataa hakemusten tilat"
             :member "Jäsen"
             :member-name "Nimi"
@@ -79,7 +81,7 @@
             :remove-member "Poista jäsen"
             :request-comment "Pyydä kommenttia"
             :request-decision "Pyydä päätöstä"
-            :request-selection "Lähetä pyyntö henkilölle/henkilöille:"
+            :request-selection "Lähetä pyyntö seuraaville henkilöille"
             :resource "Resurssi"
             :return "Palauta hakijalle"
             :show-publications "Näytä julkaisut"
@@ -145,6 +147,7 @@
                           :decided "Päätetty"
                           :decision-requested "Pyydetty päätöstä"
                           :draft-saved "Tallennettu"
+                          :licenses-added "Käyttöehtoja lisätty"
                           :licenses-accepted "Käyttöehdot hyväksytty"
                           :member-added "Lisätty jäsen"
                           :member-invited "Kutsuttu jäsen"
diff --git a/src/clj/rems/api/applications.clj b/src/clj/rems/api/applications.clj
index c2be81b4a..f34fc3cfa 100644
--- a/src/clj/rems/api/applications.clj
+++ b/src/clj/rems/api/applications.clj
@@ -186,6 +186,7 @@
 
     (command-endpoint :application.command/accept-invitation commands/AcceptInvitationCommand)
     (command-endpoint :application.command/accept-licenses commands/AcceptLicensesCommand)
+    (command-endpoint :application.command/add-licenses commands/AddLicensesCommand)
     (command-endpoint :application.command/add-member commands/AddMemberCommand)
     (command-endpoint :application.command/invite-member commands/InviteMemberCommand)
     (command-endpoint :application.command/approve commands/ApproveCommand)
diff --git a/src/clj/rems/api/applications_v2.clj b/src/clj/rems/api/applications_v2.clj
index a9b2091c9..815755527 100644
--- a/src/clj/rems/api/applications_v2.clj
+++ b/src/clj/rems/api/applications_v2.clj
@@ -1,8 +1,5 @@
 (ns rems.api.applications-v2
-  (:require [clojure.tools.logging :as log]
-            [medley.core :refer [map-vals]]
-            [mount.core :as mount]
-            [rems.application.model :as model]
+  (:require [rems.application.model :as model]
             [rems.auth.util :refer [throw-forbidden]]
             [rems.db.applications :as applications]
             [rems.db.catalogue :as catalogue]
@@ -68,26 +65,15 @@
           :application/form
           :application/licenses))
 
-(mount/defstate all-applications-cache
-  :start (atom {:last-processed-event-id 0
-                :applications nil}))
-
 (defn get-all-applications [user-id]
-  (let [cache @all-applications-cache
-        events (applications/get-dynamic-application-events-since (:last-processed-event-id cache))
-        applications (reduce all-applications-view (:applications cache) events)
-        ;; TODO: for a shared cache it may be necessary to make assoc-injections idempotent and consider cache invalidation
-        cached-injections (map-vals memoize injections)]
-    (when-let [event-id (:event/id (last events))]
-      (when (compare-and-set! all-applications-cache
-                              cache
-                              {:last-processed-event-id event-id
-                               :applications applications})
-        (log/info "Updated all-applications-cache from" (:last-processed-event-id cache) "to" event-id)))
+  ;; TODO: cache the applications and build the projection incrementally as new events are published
+  (let [events (applications/get-dynamic-application-events-since 0)
+        applications (reduce all-applications-view nil events)]
     (->> (vals applications)
          (map #(model/apply-user-permissions % user-id))
          (remove nil?)
-         (map #(model/enrich-with-injections % cached-injections))
+         ;; TODO: for caching it may be necessary to make assoc-injections idempotent and consider cache invalidation
+         (map #(model/enrich-with-injections % injections))
          (map exclude-unnecessary-keys-from-overview))))
 
 (defn- own-application? [application]
diff --git a/src/clj/rems/api/licenses.clj b/src/clj/rems/api/licenses.clj
index 78cc0aace..ef98f92e2 100644
--- a/src/clj/rems/api/licenses.clj
+++ b/src/clj/rems/api/licenses.clj
@@ -46,7 +46,7 @@
 
     (GET "/" []
       :summary "Get licenses"
-      :roles #{:owner}
+      :roles #{:handler :owner}
       :query-params [{disabled :- (describe s/Bool "whether to include disabled licenses") false}
                      {expired :- (describe s/Bool "whether to include expired licenses") false}
                      {archived :- (describe s/Bool "whether to include archived licenses") false}]
diff --git a/src/clj/rems/api/schema.clj b/src/clj/rems/api/schema.clj
index 99720937b..65705e25c 100644
--- a/src/clj/rems/api/schema.clj
+++ b/src/clj/rems/api/schema.clj
@@ -135,6 +135,8 @@
    :application/applicant-attributes {s/Keyword s/Str}
    :application/members #{{:userid s/Str
                            s/Keyword s/Str}}
+   :application/past-members #{{:userid s/Str
+                                s/Keyword s/Str}}
    :application/invited-members #{{:name s/Str
                                    :email s/Str}}
    :application/resources [V2Resource]
diff --git a/src/clj/rems/application/commands.clj b/src/clj/rems/application/commands.clj
index d12eb14b0..8385cf8f8 100644
--- a/src/clj/rems/application/commands.clj
+++ b/src/clj/rems/application/commands.clj
@@ -62,6 +62,11 @@
   (assoc CommandBase
          :comment s/Str))
 
+(s/defschema AddLicensesCommand
+  (assoc CommandBase
+         :comment s/Str
+         :licenses [s/Num]))
+
 (s/defschema AddMemberCommand
   (assoc CommandBase
          :member {:userid UserId}))
@@ -90,6 +95,7 @@
   {#_:application.command/require-license
    :application.command/accept-invitation AcceptInvitationCommand
    :application.command/accept-licenses AcceptLicensesCommand
+   :application.command/add-licenses AddLicensesCommand
    :application.command/add-member AddMemberCommand
    :application.command/invite-member InviteMemberCommand
    :application.command/approve ApproveCommand
diff --git a/src/clj/rems/application/events.clj b/src/clj/rems/application/events.clj
index 7792daf44..7b66f33b6 100644
--- a/src/clj/rems/application/events.clj
+++ b/src/clj/rems/application/events.clj
@@ -68,6 +68,11 @@
   (assoc EventBase
          :event/type (s/enum :application.event/licenses-accepted)
          :application/accepted-licenses #{s/Int}))
+(s/defschema LicensesAddedEvent
+  (assoc EventBase
+         :event/type (s/enum :application.event/licenses-added)
+         :application/comment s/Str
+         :application/licenses [{:license/id s/Int}]))
 (s/defschema MemberAddedEvent
   (assoc EventBase
          :event/type (s/enum :application.event/member-added)
@@ -115,6 +120,7 @@
    :application.event/decision-requested DecisionRequestedEvent
    :application.event/draft-saved DraftSavedEvent
    :application.event/licenses-accepted LicensesAcceptedEvent
+   :application.event/licenses-added LicensesAddedEvent
    :application.event/member-added MemberAddedEvent
    :application.event/member-invited MemberInvitedEvent
    :application.event/member-joined MemberJoinedEvent
diff --git a/src/clj/rems/application/model.clj b/src/clj/rems/application/model.clj
index f2d59c849..3f92930cc 100644
--- a/src/clj/rems/application/model.clj
+++ b/src/clj/rems/application/model.clj
@@ -38,6 +38,7 @@
                                                   :application.command/accept-licenses]
                                       :member [:application.command/accept-licenses]
                                       :handler [:see-everything
+                                                :application.command/add-licenses
                                                 :application.command/add-member
                                                 :application.command/remove-member
                                                 :application.command/invite-member
@@ -171,12 +172,9 @@
              :application/members #{}
              :application/past-members #{}
              :application/invitation-tokens {}
-             :application/resources (map (fn [resource]
-                                           {:catalogue-item/id (:catalogue-item/id resource)
-                                            :resource/ext-id (:resource/ext-id resource)})
+             :application/resources (map #(select-keys % [:catalogue-item/id :resource/ext-id])
                                          (:application/resources event))
-             :application/licenses (map (fn [license]
-                                          {:license/id (:license/id license)})
+             :application/licenses (map #(select-keys % [:license/id])
                                         (:application/licenses event))
              :application/accepted-licenses {}
              :application/events []
@@ -201,6 +199,17 @@
       (assoc :application/modified (:event/time event))
       (assoc-in [:application/accepted-licenses (:event/actor event)] (:application/accepted-licenses event))))
 
+(defmethod event-type-specific-application-view :application.event/licenses-added
+  [application event]
+  (-> application
+      (assoc :application/modified (:event/time event))
+      (update :application/licenses
+              (fn [licenses]
+                (-> licenses
+                    (into (:application/licenses event))
+                     distinct
+                     vec)))))
+
 (defmethod event-type-specific-application-view :application.event/member-invited
   [application event]
   (-> application
@@ -524,7 +533,7 @@
       (update :application/events hide-sensitive-events)
       (update :application/workflow dissoc :workflow.dynamic/handlers)))
 
-(defn- hide-non-public-information [application]
+(defn- hide-very-sensitive-information [application]
   (-> application
       ;; the keys are invitation tokens and must be kept secret
       (dissoc :application/invitation-tokens)
@@ -532,8 +541,7 @@
       ;; these are not used by the UI, so no need to expose them (especially the user IDs)
       (update-in [:application/workflow] dissoc
                  :workflow.dynamic/awaiting-commenters
-                 :workflow.dynamic/awaiting-deciders)
-      (dissoc :application/past-members)))
+                 :workflow.dynamic/awaiting-deciders)))
 
 (defn apply-user-permissions [application user-id]
   (let [see-application? (see-application? application user-id)
@@ -544,7 +552,7 @@
       (-> (if see-everything?
             application
             (hide-sensitive-information application))
-          (hide-non-public-information)
+          (hide-very-sensitive-information)
           (assoc :application/permissions permissions)
           (assoc :application/roles roles)
           (permissions/cleanup)))))
diff --git a/src/clj/rems/auth/fake_shibboleth.clj b/src/clj/rems/auth/fake_shibboleth.clj
index c4929024a..13d4663a7 100644
--- a/src/clj/rems/auth/fake_shibboleth.clj
+++ b/src/clj/rems/auth/fake_shibboleth.clj
@@ -1,12 +1,12 @@
 (ns rems.auth.fake-shibboleth
-  (:require [clojure.string :as str]
-            [compojure.core :refer [GET defroutes]]
+  (:require [compojure.core :refer [GET defroutes]]
             [hiccup.page :refer [html5]]
             [hiccup.util :refer [url]]
             [rems.db.core :as db]
             [rems.db.test-data :refer [+fake-user-data+]]
             [rems.json :as json]
-            [ring.util.response :refer [content-type redirect response]]))
+            [ring.util.response :refer [content-type redirect
+                                        response]]))
 
 (def ^{:private true
        :doc "Inlined CSS declaration for fake login."}
@@ -63,7 +63,6 @@ a:visited { color: #fff; }
                 [:div.login
                  [:h1 "Development Login"]
                  [:div.users (->> (map :userid (db/get-users))
-                                  (remove #(str/starts-with? % "perftester"))
                                   (sort)
                                   (distinct)
                                   (map user-selection))]]])
diff --git a/src/clj/rems/db/workflow.clj b/src/clj/rems/db/workflow.clj
index b6e4c57fc..21c112b36 100644
--- a/src/clj/rems/db/workflow.clj
+++ b/src/clj/rems/db/workflow.clj
@@ -34,9 +34,6 @@
 
 (defn- create-dynamic-workflow! [{:keys [user-id organization title handlers]}]
   (assert user-id)
-  (assert organization)
-  (assert title)
-  (assert (every? string? handlers) {:handlers handlers})
   (let [wfid (:id (db/create-workflow! {:organization organization,
                                         :owneruserid user-id,
                                         :modifieruserid user-id,
diff --git a/src/clj/rems/standalone.clj b/src/clj/rems/standalone.clj
index 230dfec02..57c30b536 100644
--- a/src/clj/rems/standalone.clj
+++ b/src/clj/rems/standalone.clj
@@ -91,8 +91,7 @@
     (= "test-data" (first args))
     (do
       (mount/start #'rems.config/env #'rems.db.core/*db*)
-      (test-data/create-test-data!)
-      (test-data/create-performance-test-data!))
+      (test-data/create-test-data!))
     (= "demo-data" (first args))
     (do
       (mount/start #'rems.config/env #'rems.db.core/*db*)
diff --git a/src/clj/rems/workflow/dynamic.clj b/src/clj/rems/workflow/dynamic.clj
index ef3d229bc..fc0a7d1e4 100644
--- a/src/clj/rems/workflow/dynamic.clj
+++ b/src/clj/rems/workflow/dynamic.clj
@@ -189,6 +189,13 @@
              :application/request-id last-request-for-actor
              :application/comment (:comment cmd)}))))
 
+(defmethod command-handler :application.command/add-licenses
+  [cmd _application injections]
+  (or (must-not-be-empty cmd :licenses)
+      (ok {:event/type :application.event/licenses-added
+           :application/licenses (mapv (fn [id] {:license/id id}) (:licenses cmd))
+           :application/comment (:comment cmd)})))
+
 (defmethod command-handler :application.command/add-member
   [cmd application injections]
   (or (invalid-user-error (:userid (:member cmd)) injections)
@@ -204,8 +211,7 @@
 
 (defmethod command-handler :application.command/accept-invitation
   [cmd application injections]
-  (or (invalid-user-error (:actor cmd) injections)
-      (already-member-error application (:actor cmd))
+  (or (already-member-error application (:actor cmd))
       (invitation-token-error application (:token cmd))
       (ok {:event/type :application.event/member-joined
            :application/id (:application-id cmd)
diff --git a/src/cljc/rems/text.cljc b/src/cljc/rems/text.cljc
index 8f1ca4b9d..9b8a38953 100644
--- a/src/cljc/rems/text.cljc
+++ b/src/cljc/rems/text.cljc
@@ -68,6 +68,7 @@
    :application.event/decided :t.applications.events/decided
    :application.event/decision-requested :t.applications.events/decision-requested
    :application.event/draft-saved :t.applications.events/draft-saved
+   :application.event/licenses-added :t.applications.events/licenses-added
    :application.event/licenses-accepted :t.applications.events/licenses-accepted
    :application.event/member-added :t.applications.events/member-added
    :application.event/member-invited :t.applications.events/member-invited
diff --git a/src/cljs/rems/actions/add_licenses.cljs b/src/cljs/rems/actions/add_licenses.cljs
new file mode 100644
index 000000000..164b74ad4
--- /dev/null
+++ b/src/cljs/rems/actions/add_licenses.cljs
@@ -0,0 +1,116 @@
+(ns rems.actions.add-licenses
+  (:require [clojure.string :as str]
+            [re-frame.core :as rf]
+            [rems.actions.action :refer [action-button action-form-view action-comment button-wrapper collapse-action-form]]
+            [rems.autocomplete :as autocomplete]
+            [rems.status-modal :as status-modal]
+            [rems.text :refer [text]]
+            [rems.util :refer [fetch post!]]))
+
+(rf/reg-fx
+ ::fetch-licenses
+ (fn [[user on-success]]
+   (fetch "/api/licenses"
+          {:handler on-success
+           :headers {"x-rems-user-id" (:eppn user)}})))
+
+(rf/reg-event-fx
+ ::open-form
+ (fn
+   [{:keys [db]} _]
+   {:db (assoc db
+               ::comment ""
+               ::potential-licenses #{}
+               ::selected-licenses #{})
+    ::fetch-licenses [(get-in db [:identity :user])
+                      #(rf/dispatch [::set-potential-licenses %])]}))
+
+(defn- assoc-all-titles
+  "Prepopulate `:all-titles` property to facilitate searching with localized names and unlocalized title"
+  [license]
+  (assoc license :all-titles (str/join "" (conj (mapcat :title (vals (:localizations license)))
+                                                 (:title license)))))
+
+(rf/reg-sub ::potential-licenses (fn [db _] (::potential-licenses db)))
+(rf/reg-event-db
+ ::set-potential-licenses
+ (fn [db [_ licenses]]
+   (assoc db
+          ::potential-licenses (set (map assoc-all-titles licenses))
+          ::selected-licenses #{})))
+
+(rf/reg-sub ::selected-licenses (fn [db _] (::selected-licenses db)))
+(rf/reg-event-db ::set-selected-licenses (fn [db [_ licenses]] (assoc db ::selected-licenses licenses)))
+(rf/reg-event-db ::add-selected-licenses (fn [db [_ license]] (update db ::selected-licenses conj license)))
+(rf/reg-event-db ::remove-selected-license (fn [db [_ license]] (update db ::selected-licenses disj license)))
+
+(rf/reg-sub ::comment (fn [db _] (::comment db)))
+(rf/reg-event-db ::set-comment (fn [db [_ value]] (assoc db ::comment value)))
+
+(def ^:private action-form-id "add-licenses")
+
+(rf/reg-event-fx
+ ::send-add-licenses
+ (fn [_ [_ {:keys [application-id licenses comment on-finished]}]]
+   (status-modal/common-pending-handler! (text :t.actions/add-licenses))
+   (post! "/api/applications/add-licenses"
+          {:params {:application-id application-id
+                    :comment comment
+                    :licenses (map :id licenses)}
+           :handler (partial status-modal/common-success-handler! (fn [_]
+                                                                    (collapse-action-form action-form-id)
+                                                                    (on-finished)))
+           :error-handler status-modal/common-error-handler!})
+   {}))
+
+(defn add-licenses-action-button []
+  [action-button {:id action-form-id
+                  :text (text :t.actions/add-licenses)
+                  :on-click #(rf/dispatch [::open-form])}])
+
+(defn title-of-license [license language]
+  (or (get-in license [:localizations language :title])
+      (:title license)))
+
+(defn add-licenses-view
+  [{:keys [selected-licenses potential-licenses comment language on-set-comment on-add-licenses on-remove-license on-send]}]
+  [action-form-view action-form-id
+   (text :t.actions/add-licenses)
+   [[button-wrapper {:id "add-licenses"
+                     :text (text :t.actions/add-licenses)
+                     :class "btn-primary"
+                     :on-click on-send}]]
+   [:div
+    [action-comment {:id action-form-id
+                     :label (text :t.form/add-comments-shown-to-applicant)
+                     :comment comment
+                     :on-comment on-set-comment}]
+    [:div.form-group
+     [:label (text :t.actions/licenses-selection)]
+     [autocomplete/component
+      {:value (sort-by #(title-of-license % language) selected-licenses)
+       :items potential-licenses
+       :value->text #(title-of-license %2 language)
+       :item->key :id
+       :item->text #(title-of-license % language)
+       :item->value identity
+       :search-fields [:all-titles]
+       :add-fn on-add-licenses
+       :remove-fn on-remove-license}]]]])
+
+(defn add-licenses-form [application-id on-finished]
+  (let [selected-licenses @(rf/subscribe [::selected-licenses])
+        potential-licenses @(rf/subscribe [::potential-licenses])
+        comment @(rf/subscribe [::comment])
+        language @(rf/subscribe [:language])]
+    [add-licenses-view {:selected-licenses selected-licenses
+                        :potential-licenses potential-licenses
+                        :comment comment
+                        :language language
+                        :on-set-comment #(rf/dispatch [::set-comment %])
+                        :on-add-licenses #(rf/dispatch [::add-selected-licenses %])
+                        :on-remove-license #(rf/dispatch [::remove-selected-license %])
+                        :on-send #(rf/dispatch [::send-add-licenses {:application-id application-id
+                                                                     :licenses selected-licenses
+                                                                     :comment comment
+                                                                     :on-finished on-finished}])}]))
diff --git a/src/cljs/rems/administration/create_form.cljs b/src/cljs/rems/administration/create_form.cljs
index 0c3fe530d..007364fde 100644
--- a/src/cljs/rems/administration/create_form.cljs
+++ b/src/cljs/rems/administration/create_form.cljs
@@ -5,10 +5,10 @@
             [rems.administration.administration :refer [administration-navigator-container]]
             [rems.administration.components :refer [checkbox localized-text-field number-field radio-button-group text-field]]
             [rems.administration.items :as items]
+            [rems.application :refer [normalize-option-key]]
             [rems.atoms :refer [enrich-user]]
             [rems.collapsible :as collapsible]
             [rems.config :refer [dev-environment?]]
-            [rems.fields :refer [normalize-option-key]]
             [rems.text :refer [text text-format localize-item]]
             [rems.util :refer [dispatch! post!]]
             [rems.status-modal :as status-modal]
diff --git a/src/cljs/rems/application.cljs b/src/cljs/rems/application.cljs
index 54521a7e7..9ea35c26d 100644
--- a/src/cljs/rems/application.cljs
+++ b/src/cljs/rems/application.cljs
@@ -4,6 +4,7 @@
             [re-frame.core :as rf]
             [rems.actions.accept-licenses :refer [accept-licenses-action-button]]
             [rems.actions.action :refer [action-button action-form-view action-comment action-collapse-id button-wrapper]]
+            [rems.actions.add-licenses :refer [add-licenses-action-button add-licenses-form]]
             [rems.actions.add-member :refer [add-member-action-button add-member-form]]
             [rems.actions.approve-reject :refer [approve-reject-action-button approve-reject-form]]
             [rems.actions.close :refer [close-action-button close-form]]
@@ -19,7 +20,6 @@
             [rems.catalogue-util :refer [get-catalogue-item-title]]
             [rems.collapsible :as collapsible]
             [rems.common-util :refer [index-by]]
-            [rems.fields :as fields]
             [rems.guide-utils :refer [lipsum lipsum-short lipsum-paragraphs]]
             [rems.phase :refer [phases]]
             [rems.spinner :as spinner]
@@ -203,23 +203,22 @@
 
 (rf/reg-event-fx ::save-attachment save-attachment)
 
-(defn- remove-attachment [{:keys [db]} [_ field-id description]]
-  (let [application-id (get-in db [::application :application/id])]
-    (status-modal/common-pending-handler! description)
-    (post! "/api/applications/remove-attachment"
-           {:url-params {:application-id application-id
-                         :field-id field-id}
-            :body {}
-            :handler (fn [response]
-                       ;; if we just remove the attachment from the backend but
-                       ;; don't save the field, the application will be left in an
-                       ;; inconsistent state (referring to a nonexistant attachment)
-                       ;; TODO: save only the value for the attachment field
-                       (if (:success response)
-                         (rf/dispatch [::save-application (text :t.form/save)])
-                         (status-modal/common-error-handler! response)))
-            :error-handler status-modal/common-error-handler!})
-    {}))
+(defn- remove-attachment [_ [_ application-id field-id description]]
+  (status-modal/common-pending-handler! description)
+  (post! "/api/applications/remove-attachment"
+         {:url-params {:application-id application-id
+                       :field-id field-id}
+          :body {}
+          :handler (fn [response]
+                     ;; if we just remove the attachment from the backend but
+                     ;; don't save the field, the application will be left in an
+                     ;; inconsistent state (referring to a nonexistant attachment)
+                     ;; TODO: save only the value for the attachment field
+                     (if (:success response)
+                       (rf/dispatch [::save-application (text :t.form/save)])
+                       (status-modal/common-error-handler! response)))
+          :error-handler status-modal/common-error-handler!})
+  {})
 
 (rf/reg-event-fx ::remove-attachment remove-attachment)
 
@@ -245,6 +244,273 @@
  (fn [db [_ field-id]]
    (update-in db [::edit-application :show-diff field-id] not)))
 
+(defn- id-to-name [id]
+  (str "field" id))
+
+(defn- set-attachment
+  [field-id description]
+  (fn [event]
+    (let [filecontent (aget (.. event -target -files) 0)
+          form-data (doto (js/FormData.)
+                      (.append "file" filecontent))]
+      (rf/dispatch [::set-field-value field-id (.-name filecontent)])
+      (rf/dispatch [::save-attachment field-id form-data description]))))
+
+(defn- remove-attachment-action
+  [app-id field-id description]
+  (fn [event]
+    (rf/dispatch [::set-field-value field-id ""])
+    (rf/dispatch [::remove-attachment app-id field-id description])))
+
+(defn- readonly-field [{:keys [id value]}]
+  [:div.form-control {:id id} (str/trim (str value))])
+
+(defn- diff [value previous-value]
+  (let [dmp (js/diff_match_patch.)
+        diff (.diff_main dmp
+                         (str/trim (str previous-value))
+                         (str/trim (str value)))]
+    (.diff_cleanupSemantic dmp diff)
+    diff))
+
+(defn- formatted-diff [value previous-value]
+  (->> (diff value previous-value)
+       (map (fn [[change text]]
+              (cond
+                (pos? change) [:ins text]
+                (neg? change) [:del text]
+                :else text)))))
+
+(defn- diff-field [{:keys [id value previous-value]}]
+  (into [:div.form-control.diff {:id id}]
+        (formatted-diff value previous-value)))
+
+(defn- field-validation-message [validation title]
+  (when validation
+    [:div {:class "text-danger"}
+     (text-format (:type validation) title)]))
+
+(defn- toggle-diff-button [item-id diff-visible]
+  [:a.toggle-diff {:href "#"
+                   :on-click (fn [event]
+                               (.preventDefault event)
+                               (rf/dispatch [::toggle-diff item-id]))}
+   [:i.fas.fa-exclamation-circle]
+   " "
+   (if diff-visible
+     (text :t.form/diff-hide)
+     (text :t.form/diff-show))])
+
+(defn basic-field
+  "Common parts of a form field.
+
+  :field/id - number (required), field id
+  :field/title - string (required), field title to show to the user
+  :field/max-length - maximum number of characters (optional)
+  :field/optional - boolean, true if the field is not required
+  :field/value - string, the current value of the field
+  :field/previous-value - string, the previously submitted value of the field
+  :readonly - boolean, true if the field should not be editable
+  :readonly-component - HTML, custom component for a readonly field
+  :diff - boolean, true if should show the diff between :value and :previous-value
+  :diff-component - HTML, custom component for rendering a diff
+  :validation - validation errors
+
+  editor-component - HTML, form component for editing the field"
+  [{:keys [readonly readonly-component diff diff-component validation] :as opts} editor-component]
+  (let [id (:field/id opts)
+        title (localized (:field/title opts))
+        optional (:field/optional opts)
+        value (:field/value opts)
+        previous-value (:field/previous-value opts)
+        max-length (:field/max-length opts)]
+    [:div.form-group.field
+     [:label {:for (id-to-name id)}
+      title " "
+      (when max-length
+        (text-format :t.form/maxlength (str max-length)))
+      " "
+      (when optional
+        (text :t.form/optional))]
+     (when (and previous-value
+                (not= value previous-value))
+       [toggle-diff-button id diff])
+     (cond
+       diff (or diff-component
+                [diff-field {:id (id-to-name id)
+                             :value value
+                             :previous-value previous-value}])
+       readonly (or readonly-component
+                    [readonly-field {:id (id-to-name id)
+                                     :value value}])
+       :else editor-component)
+     [field-validation-message validation title]]))
+
+(defn- set-field-value [field-id]
+  (fn [event]
+    (rf/dispatch [::set-field-value field-id (.. event -target -value)])))
+
+(defn- text-field
+  [{:keys [validation] :as opts}]
+  (let [id (:field/id opts)
+        placeholder (localized (:field/placeholder opts))
+        value (:field/value opts)
+        max-length (:field/max-length opts)]
+    [basic-field opts
+     [:input.form-control {:type "text"
+                           :id (id-to-name id)
+                           :name (id-to-name id)
+                           :placeholder placeholder
+                           :max-length max-length
+                           :class (when validation "is-invalid")
+                           :defaultValue value
+                           :on-change (set-field-value id)}]]))
+
+(defn- texta-field
+  [{:keys [validation] :as opts}]
+  (let [id (:field/id opts)
+        placeholder (localized (:field/placeholder opts))
+        value (:field/value opts)
+        max-length (:field/max-length opts)]
+    [basic-field opts
+     [textarea {:id (id-to-name id)
+                :name (id-to-name id)
+                :placeholder placeholder
+                :max-length max-length
+                :class (if validation "form-control is-invalid" "form-control")
+                :defaultValue value
+                :on-change (set-field-value id)}]]))
+
+;; TODO: custom :diff-component, for example link to both old and new attachment
+(defn attachment-field
+  [{:keys [validation app-id] :as opts}]
+  (let [id (:field/id opts)
+        title (localized (:field/title opts))
+        value (:field/value opts)
+        click-upload (fn [e] (when-not (:readonly opts) (.click (.getElementById js/document (id-to-name id)))))
+        filename-field [:div.field
+                        [:a.btn.btn-secondary.mr-2
+                         {:href (str "/api/applications/attachments?application-id=" app-id "&field-id=" id)
+                          :target :_new}
+                         value " " (external-link)]]
+        upload-field [:div.upload-file.mr-2
+                      [:input {:style {:display "none"}
+                               :type "file"
+                               :id (id-to-name id)
+                               :name (id-to-name id)
+                               :accept ".pdf, .doc, .docx, .ppt, .pptx, .txt, image/*"
+                               :class (when validation "is-invalid")
+                               :on-change (set-attachment id title)}]
+                      [:button.btn.btn-secondary {:on-click click-upload}
+                       (text :t.form/upload)]]
+        remove-button [:button.btn.btn-secondary.mr-2
+                       {:on-click (remove-attachment-action app-id id (text :t.form/attachment-remove))}
+                       (text :t.form/attachment-remove)]]
+    [basic-field (assoc opts :readonly-component (if (empty? value)
+                                                   [:span]
+                                                   filename-field))
+     (if (empty? value)
+       upload-field
+       [:div {:style {:display :flex :justify-content :flex-start}}
+        filename-field
+        remove-button])]))
+
+(defn- date-field
+  [{:keys [min max validation] :as opts}]
+  (let [id (:field/id opts)
+        value (:field/value opts)]
+    ;; TODO: format readonly value in user locale (give basic-field a formatted :value and :previous-value in opts)
+    [basic-field opts
+     [:input.form-control {:type "date"
+                           :id (id-to-name id)
+                           :name (id-to-name id)
+                           :class (when validation "is-invalid")
+                           :defaultValue value
+                           :min min
+                           :max max
+                           :on-change (set-field-value id)}]]))
+
+(defn- option-label [value options]
+  (let [label (->> options
+                   (filter #(= value (:key %)))
+                   first
+                   :label)]
+    (localized label)))
+
+(defn option-field [{:keys [validation] :as opts}]
+  (let [id (:field/id opts)
+        value (:field/value opts)
+        options (:field/options opts)]
+    [basic-field
+     (assoc opts :readonly-component [readonly-field {:id (id-to-name id)
+                                                      :value (option-label value options)}])
+     (into [:select.form-control {:id (id-to-name id)
+                                  :name (id-to-name id)
+                                  :class (when validation "is-invalid")
+                                  :defaultValue value
+                                  :on-change (set-field-value id)}
+            [:option {:value ""}]]
+           (for [{:keys [key label]} options]
+             [:option {:value key}
+              (localized label)]))]))
+
+(defn normalize-option-key
+  "Strips disallowed characters from an option key"
+  [key]
+  (str/replace key #"\s+" ""))
+
+(defn encode-option-keys
+  "Encodes a set of option keys to a string"
+  [keys]
+  (->> keys
+       sort
+       (str/join " ")))
+
+(defn decode-option-keys
+  "Decodes a set of option keys from a string"
+  [value]
+  (-> value
+      (str/split #"\s+")
+      set
+      (disj "")))
+
+(defn multiselect-field [{:keys [validation] :as opts}]
+  (let [id (:field/id opts)
+        value (:field/value opts)
+        options (:field/options opts)
+        selected-keys (decode-option-keys value)]
+    ;; TODO: for accessibility these checkboxes would be best wrapped in a fieldset
+    [basic-field
+     (assoc opts :readonly-component [readonly-field {:id (id-to-name id)
+                                                      :value (->> options
+                                                                  (filter #(contains? selected-keys (:key %)))
+                                                                  (map #(localized (:label %)))
+                                                                  (str/join ", "))}])
+     (into [:div]
+           (for [{:keys [key label]} options]
+             (let [option-id (str (id-to-name id) "-" key)
+                   on-change (fn [event]
+                               (let [checked (.. event -target -checked)
+                                     selected-keys (if checked
+                                                     (conj selected-keys key)
+                                                     (disj selected-keys key))]
+                                 (rf/dispatch [::set-field-value id (encode-option-keys selected-keys)])))]
+               [:div.form-check
+                [:input.form-check-input {:type "checkbox"
+                                          :id option-id
+                                          :name option-id
+                                          :class (when validation "is-invalid")
+                                          :value key
+                                          :checked (contains? selected-keys key)
+                                          :on-change on-change}]
+                [:label.form-check-label {:for option-id}
+                 (localized label)]])))]))
+
+(defn- label [opts]
+  (let [title (:field/title opts)]
+    [:div.form-group
+     [:label (localized title)]]))
+
 (defn- link-license
   [{:keys [accepted readonly] :as opts}]
   (let [id (:license/id opts)
@@ -270,11 +536,27 @@
       [:div.collapse {:id (str "collapse" id)}
        [:div.license-block (str/trim (str text))]]]]))
 
+(defn- unsupported-field
+  [f]
+  [:p.alert.alert-warning "Unsupported field " (pr-str f)])
+
 (defn license-field [f]
   (case (:license/type f)
     :link [link-license f]
     :text [text-license f]
-    [fields/unsupported-field f]))
+    [unsupported-field f]))
+
+(defn- field [f]
+  (case (:field/type f)
+    :attachment [attachment-field f]
+    :date [date-field f]
+    :description [text-field f]
+    :label [label f]
+    :multiselect [multiselect-field f]
+    :option [option-field f]
+    :text [text-field f]
+    :texta [texta-field f]
+    [unsupported-field f]))
 
 (defn- save-button []
   [button-wrapper {:id "save"
@@ -300,16 +582,12 @@
       [:div
        (into [:div]
              (for [fld (get-in application [:application/form :form/fields])]
-               [fields/field (assoc fld
-                                    :on-change #(rf/dispatch [::set-field-value (:field/id fld) %])
-                                    :on-set-attachment #(rf/dispatch [::save-attachment (:field/id fld) %1 %2])
-                                    :on-remove-attachment #(rf/dispatch [::remove-attachment (:field/id fld) %1 %2])
-                                    :on-toggle-diff #(rf/dispatch [::toggle-diff (:field/id fld)])
-                                    :field/value (get field-values (:field/id fld))
-                                    :diff (get show-diff (:field/id fld))
-                                    :validation (field-validations (:field/id fld))
-                                    :readonly readonly?
-                                    :app-id (:application/id application))]))]}]))
+               [field (assoc fld
+                             :field/value (get field-values (:field/id fld))
+                             :diff (get show-diff (:field/id fld))
+                             :validation (field-validations (:field/id fld))
+                             :readonly readonly?
+                             :app-id (:application/id application))]))]}]))
 
 (defn- application-licenses [application edit-application userid]
   (when-let [licenses (not-empty (:application/licenses application))]
@@ -522,6 +800,7 @@
                               :application.command/decide [decide-action-button]
                               :application.command/request-comment [request-comment-action-button]
                               :application.command/comment [comment-action-button]
+                              :application.command/add-licenses [add-licenses-action-button]
                               :application.command/approve [approve-reject-action-button]
                               :application.command/reject [approve-reject-action-button]
                               :application.command/close [close-action-button]]]
@@ -540,6 +819,7 @@
                 [close-form app-id reload]
                 [decide-form app-id reload]
                 [return-form app-id reload]
+                [add-licenses-form app-id reload]
                 [approve-reject-form app-id reload]]]]
     (when (seq actions)
       [collapsible/component
@@ -659,6 +939,202 @@
                                       {:catalogue-item/enabled true :catalogue-item/archived false
                                        :catalogue-item/title {:default "Catalogue item 3"}}]}])
 
+   (component-info field)
+   (example "field of type \"text\""
+            [:form
+             [field {:field/type :text
+                     :field/title {:en "Title"}
+                     :field/placeholder {:en "prompt"}}]])
+   (example "field of type \"text\" with maximum length"
+            [:form
+             [field {:field/type :text
+                     :field/title {:en "Title"}
+                     :field/placeholder {:en "prompt"}
+                     :field/max-length 10}]])
+   (example "field of type \"text\" with validation error"
+            [:form
+             [field {:field/type :text
+                     :field/title {:en "Title"}
+                     :field/placeholder {:en "prompt"}
+                     :validation {:type :t.form.validation/required}}]])
+   (example "non-editable field of type \"text\" without text"
+            [:form
+             [field {:field/type :text
+                     :field/title {:en "Title"}
+                     :field/placeholder {:en "prompt"}
+                     :readonly true}]])
+   (example "non-editable field of type \"text\" with text"
+            [:form
+             [field {:field/type :text
+                     :field/title {:en "Title"}
+                     :field/placeholder {:en "prompt"}
+                     :readonly true
+                     :field/value lipsum-short}]])
+   (example "field of type \"texta\""
+            [:form
+             [field {:field/type :texta
+                     :field/title {:en "Title"}
+                     :field/placeholder {:en "prompt"}}]])
+   (example "field of type \"texta\" with maximum length"
+            [:form
+             [field {:field/type :texta
+                     :field/title {:en "Title"}
+                     :field/placeholder {:en "prompt"}
+                     :field/max-length 10}]])
+   (example "field of type \"texta\" with validation error"
+            [:form
+             [field {:field/type :texta
+                     :field/title {:en "Title"}
+                     :field/placeholder {:en "prompt"}
+                     :validation {:type :t.form.validation/required}}]])
+   (example "non-editable field of type \"texta\""
+            [:form
+             [field {:field/type :texta
+                     :field/title {:en "Title"}
+                     :field/placeholder {:en "prompt"}
+                     :readonly true
+                     :field/value lipsum-paragraphs}]])
+   (let [previous-lipsum-paragraphs (-> lipsum-paragraphs
+                                        (str/replace "ipsum primis in faucibus orci luctus" "eu mattis purus mi eu turpis")
+                                        (str/replace "per inceptos himenaeos" "justo erat hendrerit magna"))]
+     [:div
+      (example "editable field of type \"texta\" with previous value, diff hidden"
+               [:form
+                [field {:field/type :texta
+                        :field/title {:en "Title"}
+                        :field/placeholder {:en "prompt"}
+                        :field/value lipsum-paragraphs
+                        :field/previous-value previous-lipsum-paragraphs}]])
+      (example "editable field of type \"texta\" with previous value, diff shown"
+               [:form
+                [field {:field/type :texta
+                        :field/title {:en "Title"}
+                        :field/placeholder {:en "prompt"}
+                        :field/value lipsum-paragraphs
+                        :field/previous-value previous-lipsum-paragraphs
+                        :diff true}]])
+      (example "non-editable field of type \"texta\" with previous value, diff hidden"
+               [:form
+                [field {:field/type :texta
+                        :field/title {:en "Title"}
+                        :field/placeholder {:en "prompt"}
+                        :readonly true
+                        :field/value lipsum-paragraphs
+                        :field/previous-value previous-lipsum-paragraphs}]])
+      (example "non-editable field of type \"texta\" with previous value, diff shown"
+               [:form
+                [field {:field/type :texta
+                        :field/title {:en "Title"}
+                        :field/placeholder {:en "prompt"}
+                        :readonly true
+                        :field/value lipsum-paragraphs
+                        :field/previous-value previous-lipsum-paragraphs
+                        :diff true}]])
+      (example "non-editable field of type \"texta\" with previous value equal to current value"
+               [:form
+                [field {:field/type :texta
+                        :field/title {:en "Title"}
+                        :field/placeholder {:en "prompt"}
+                        :readonly true
+                        :field/value lipsum-paragraphs
+                        :field/previous-value lipsum-paragraphs}]])])
+   (example "field of type \"attachment\""
+            [:form
+             [field {:app-id 5
+                     :field/id 6
+                     :field/type :attachment
+                     :field/title {:en "Title"}}]])
+   (example "field of type \"attachment\", file uploaded"
+            [:form
+             [field {:app-id 5
+                     :field/id 6
+                     :field/type :attachment
+                     :field/title {:en "Title"}
+                     :field/value "test.txt"}]])
+   (example "non-editable field of type \"attachment\""
+            [:form
+             [field {:app-id 5
+                     :field/id 6
+                     :field/type :attachment
+                     :field/title {:en "Title"}
+                     :readonly true}]])
+   (example "non-editable field of type \"attachment\", file uploaded"
+            [:form
+             [field {:app-id 5
+                     :field/id 6
+                     :field/type :attachment
+                     :field/title {:en "Title"}
+                     :readonly true
+                     :field/value "test.txt"}]])
+   (example "field of type \"date\""
+            [:form
+             [field {:field/type :date
+                     :field/title {:en "Title"}}]])
+   (example "field of type \"date\" with value"
+            [:form
+             [field {:field/type :date
+                     :field/title {:en "Title"}
+                     :field/value "2000-12-31"}]])
+   (example "non-editable field of type \"date\""
+            [:form
+             [field {:field/type :date
+                     :field/title {:en "Title"}
+                     :readonly true
+                     :field/value ""}]])
+   (example "non-editable field of type \"date\" with value"
+            [:form
+             [field {:field/type :date
+                     :field/title {:en "Title"}
+                     :readonly true
+                     :field/value "2000-12-31"}]])
+   (example "field of type \"option\""
+            [:form
+             [field {:field/type :option
+                     :field/title {:en "Title"}
+                     :field/value "y"
+                     :field/options [{:key "y" :label {:en "Yes" :fi "Kyllä"}}
+                                     {:key "n" :label {:en "No" :fi "Ei"}}]}]])
+   (example "non-editable field of type \"option\""
+            [:form
+             [field {:field/type :option
+                     :field/title {:en "Title"}
+                     :field/value "y"
+                     :readonly true
+                     :field/options [{:key "y" :label {:en "Yes" :fi "Kyllä"}}
+                                     {:key "n" :label {:en "No" :fi "Ei"}}]}]])
+   (example "field of type \"multiselect\""
+            [:form
+             [field {:field/type :multiselect
+                     :field/title {:en "Title"}
+                     :field/value "egg bacon"
+                     :field/options [{:key "egg" :label {:en "Egg" :fi "Munaa"}}
+                                     {:key "bacon" :label {:en "Bacon" :fi "Pekonia"}}
+                                     {:key "spam" :label {:en "Spam" :fi "Lihasäilykettä"}}]}]])
+   (example "non-editable field of type \"multiselect\""
+            [:form
+             [field {:field/type :multiselect
+                     :field/title {:en "Title"}
+                     :field/value "egg bacon"
+                     :readonly true
+                     :field/options [{:key "egg" :label {:en "Egg" :fi "Munaa"}}
+                                     {:key "bacon" :label {:en "Bacon" :fi "Pekonia"}}
+                                     {:key "spam" :label {:en "Spam" :fi "Lihasäilykettä"}}]}]])
+   (example "optional field"
+            [:form
+             [field {:field/type :texta
+                     :field/optional true
+                     :field/title {:en "Title"}
+                     :field/placeholder {:en "prompt"}}]])
+   (example "field of type \"label\""
+            [:form
+             [field {:field/type :label
+                     :field/title {:en "Lorem ipsum dolor sit amet"}}]])
+   (example "field of type \"description\""
+            [:form
+             [field {:field/type :description
+                     :field/title {:en "Title"}
+                     :field/placeholder {:en "prompt"}}]])
+
    (example "link license"
             [:form
              [license-field {:license/id 1
diff --git a/src/cljs/rems/fields.cljs b/src/cljs/rems/fields.cljs
deleted file mode 100644
index 105cbc78d..000000000
--- a/src/cljs/rems/fields.cljs
+++ /dev/null
@@ -1,483 +0,0 @@
-(ns rems.fields
-  "UI components for form fields"
-  (:require [clojure.string :as str]
-            [rems.atoms :refer [external-link textarea]]
-            [rems.guide-utils :refer [lipsum-short lipsum-paragraphs]]
-            [rems.text :refer [localized text text-format]])
-  (:require-macros [rems.guide-macros :refer [component-info example]]))
-
-(defn- id-to-name [id]
-  (str "field" id))
-
-(defn- diff [value previous-value]
-  (let [dmp (js/diff_match_patch.)
-        diff (.diff_main dmp
-                         (str/trim (str previous-value))
-                         (str/trim (str value)))]
-    (.diff_cleanupSemantic dmp diff)
-    diff))
-
-(defn- formatted-diff [value previous-value]
-  (->> (diff value previous-value)
-       (map (fn [[change text]]
-              (cond
-                (pos? change) [:ins text]
-                (neg? change) [:del text]
-                :else text)))))
-
-(defn- diff-field [{:keys [id value previous-value]}]
-  (into [:div.form-control.diff {:id id}]
-        (formatted-diff value previous-value)))
-
-(defn- field-validation-message [validation title]
-  (when validation
-    [:div {:class "text-danger"}
-     (text-format (:type validation) title)]))
-
-(defn- toggle-diff-button [item-id diff-visible on-toggle-diff]
-  [:a.toggle-diff {:href "#"
-                   :on-click (fn [event]
-                               (.preventDefault event)
-                               (on-toggle-diff))}
-   [:i.fas.fa-exclamation-circle]
-   " "
-   (if diff-visible
-     (text :t.form/diff-hide)
-     (text :t.form/diff-show))])
-
-(defn readonly-field [{:keys [id value]}]
-  [:div.form-control {:id id} (str/trim (str value))])
-
-(defn field-wrapper
-  "Common parts of a form field.
-
-  :field/id - number (required), field id
-  :field/title - string (required), field title to show to the user
-  :field/max-length - maximum number of characters (optional)
-  :field/optional - boolean, true if the field is not required
-  :field/value - string, the current value of the field
-  :field/previous-value - string, the previously submitted value of the field
-  :readonly - boolean, true if the field should not be editable
-  :readonly-component - HTML, custom component for a readonly field
-  :diff - boolean, true if should show the diff between :value and :previous-value
-  :diff-component - HTML, custom component for rendering a diff
-  :validation - validation errors
-
-  editor-component - HTML, form component for editing the field"
-  [{:keys [readonly readonly-component diff diff-component validation on-toggle-diff] :as opts} editor-component]
-  (let [id (:field/id opts)
-        title (localized (:field/title opts))
-        optional (:field/optional opts)
-        value (:field/value opts)
-        previous-value (:field/previous-value opts)
-        max-length (:field/max-length opts)]
-    [:div.form-group.field
-     [:label {:for (id-to-name id)}
-      title " "
-      (when max-length
-        (text-format :t.form/maxlength (str max-length)))
-      " "
-      (when optional
-        (text :t.form/optional))]
-     (when (and previous-value
-                (not= value previous-value))
-       [toggle-diff-button id diff on-toggle-diff])
-     (cond
-       diff (or diff-component
-                [diff-field {:id (id-to-name id)
-                             :value value
-                             :previous-value previous-value}])
-       readonly (or readonly-component
-                    [readonly-field {:id (id-to-name id)
-                                     :value value}])
-       :else editor-component)
-     [field-validation-message validation title]]))
-
-(defn- event-value [event]
-  (.. event -target -value))
-
-(defn text-field
-  [{:keys [validation on-change] :as opts}]
-  (let [id (:field/id opts)
-        placeholder (localized (:field/placeholder opts))
-        value (:field/value opts)
-        max-length (:field/max-length opts)]
-    [field-wrapper opts
-     [:input.form-control {:type "text"
-                           :id (id-to-name id)
-                           :name (id-to-name id)
-                           :placeholder placeholder
-                           :max-length max-length
-                           :class (when validation "is-invalid")
-                           :defaultValue value
-                           :on-change (comp on-change event-value)}]]))
-
-(defn texta-field
-  [{:keys [validation on-change] :as opts}]
-  (let [id (:field/id opts)
-        placeholder (localized (:field/placeholder opts))
-        value (:field/value opts)
-        max-length (:field/max-length opts)]
-    [field-wrapper opts
-     [textarea {:id (id-to-name id)
-                :name (id-to-name id)
-                :placeholder placeholder
-                :max-length max-length
-                :class (if validation "form-control is-invalid" "form-control")
-                :defaultValue value
-                :on-change (comp on-change event-value)}]]))
-
-(defn date-field
-  [{:keys [min max validation on-change] :as opts}]
-  (let [id (:field/id opts)
-        value (:field/value opts)]
-    ;; TODO: format readonly value in user locale (give field-wrapper a formatted :value and :previous-value in opts)
-    [field-wrapper opts
-     [:input.form-control {:type "date"
-                           :id (id-to-name id)
-                           :name (id-to-name id)
-                           :class (when validation "is-invalid")
-                           :defaultValue value
-                           :min min
-                           :max max
-                           :on-change (comp on-change event-value)}]]))
-
-(defn- option-label [value options]
-  (let [label (->> options
-                   (filter #(= value (:key %)))
-                   first
-                   :label)]
-    (localized label)))
-
-(defn option-field [{:keys [validation on-change] :as opts}]
-  (let [id (:field/id opts)
-        value (:field/value opts)
-        options (:field/options opts)]
-    [field-wrapper
-     (assoc opts :readonly-component [readonly-field {:id (id-to-name id)
-                                                      :value (option-label value options)}])
-     (into [:select.form-control {:id (id-to-name id)
-                                  :name (id-to-name id)
-                                  :class (when validation "is-invalid")
-                                  :defaultValue value
-                                  :on-change (comp on-change event-value)}
-            [:option {:value ""}]]
-           (for [{:keys [key label]} options]
-             [:option {:value key}
-              (localized label)]))]))
-
-(defn label [opts]
-  (let [title (:field/title opts)]
-    [:div.form-group
-     [:label (localized title)]]))
-
-;; TODO move to util?
-(defn normalize-option-key
-  "Strips disallowed characters from an option key"
-  [key]
-  (str/replace key #"\s+" ""))
-
-(defn encode-option-keys
-  "Encodes a set of option keys to a string"
-  [keys]
-  (->> keys
-       sort
-       (str/join " ")))
-
-(defn decode-option-keys
-  "Decodes a set of option keys from a string"
-  [value]
-  (-> value
-      (str/split #"\s+")
-      set
-      (disj "")))
-
-(defn multiselect-field [{:keys [validation on-change] :as opts}]
-  (let [id (:field/id opts)
-        value (:field/value opts)
-        options (:field/options opts)
-        selected-keys (decode-option-keys value)]
-    ;; TODO: for accessibility these checkboxes would be best wrapped in a fieldset
-    [field-wrapper
-     (assoc opts :readonly-component [readonly-field {:id (id-to-name id)
-                                                      :value (->> options
-                                                                  (filter #(contains? selected-keys (:key %)))
-                                                                  (map #(localized (:label %)))
-                                                                  (str/join ", "))}])
-     (into [:div]
-           (for [{:keys [key label]} options]
-             (let [option-id (str (id-to-name id) "-" key)
-                   on-change (fn [event]
-                               (let [checked (.. event -target -checked)
-                                     selected-keys (if checked
-                                                     (conj selected-keys key)
-                                                     (disj selected-keys key))]
-                                 (on-change (encode-option-keys selected-keys))))]
-               [:div.form-check
-                [:input.form-check-input {:type "checkbox"
-                                          :id option-id
-                                          :name option-id
-                                          :class (when validation "is-invalid")
-                                          :value key
-                                          :checked (contains? selected-keys key)
-                                          :on-change on-change}]
-                [:label.form-check-label {:for option-id}
-                 (localized label)]])))]))
-
-;; TODO: custom :diff-component, for example link to both old and new attachment
-(defn attachment-field
-  [{:keys [validation app-id on-change on-set-attachment on-remove-attachment] :as opts}]
-  (let [id (:field/id opts)
-        title (localized (:field/title opts))
-        value (:field/value opts)
-        click-upload (fn [e] (when-not (:readonly opts) (.click (.getElementById js/document (id-to-name id)))))
-        filename-field [:div.field
-                        [:a.btn.btn-secondary.mr-2
-                         {:href (str "/api/applications/attachments?application-id=" app-id "&field-id=" id)
-                          :target :_new}
-                         value " " (external-link)]]
-        upload-field [:div.upload-file.mr-2
-                      [:input {:style {:display "none"}
-                               :type "file"
-                               :id (id-to-name id)
-                               :name (id-to-name id)
-                               :accept ".pdf, .doc, .docx, .ppt, .pptx, .txt, image/*"
-                               :class (when validation "is-invalid")
-                               :on-change (fn [event]
-                                            (let [filecontent (aget (.. event -target -files) 0)
-                                                  filename (.-name filecontent)
-                                                  form-data (doto (js/FormData.)
-                                                              (.append "file" filecontent))]
-                                              (on-change filename)
-                                              (on-set-attachment form-data title)))}]
-                      [:button.btn.btn-secondary {:on-click click-upload}
-                       (text :t.form/upload)]]
-        remove-button [:button.btn.btn-secondary.mr-2
-                       {:on-click (fn [event]
-                                    (on-change "")
-                                    (on-remove-attachment (text :t.form/attachment-remove)))}
-                       (text :t.form/attachment-remove)]]
-    [field-wrapper (assoc opts :readonly-component (if (empty? value)
-                                                     [:span]
-                                                     filename-field))
-     (if (empty? value)
-       upload-field
-       [:div {:style {:display :flex :justify-content :flex-start}}
-        filename-field
-        remove-button])]))
-
-(defn unsupported-field
-  [f]
-  [:p.alert.alert-warning "Unsupported field " (pr-str f)])
-
-(defn field [f]
-  (case (:field/type f)
-    :attachment [attachment-field f]
-    :date [date-field f]
-    :description [text-field f]
-    :label [label f]
-    :multiselect [multiselect-field f]
-    :option [option-field f]
-    :text [text-field f]
-    :texta [texta-field f]
-    [unsupported-field f]))
-
-;;;; Guide
-
-(defn guide []
-  [:div
-   (component-info field)
-   (example "field of type \"text\""
-            [:form
-             [field {:field/type :text
-                     :field/title {:en "Title"}
-                     :field/placeholder {:en "prompt"}}]])
-   (example "field of type \"text\" with maximum length"
-            [:form
-             [field {:field/type :text
-                     :field/title {:en "Title"}
-                     :field/placeholder {:en "prompt"}
-                     :field/max-length 10}]])
-   (example "field of type \"text\" with validation error"
-            [:form
-             [field {:field/type :text
-                     :field/title {:en "Title"}
-                     :field/placeholder {:en "prompt"}
-                     :validation {:type :t.form.validation/required}}]])
-   (example "non-editable field of type \"text\" without text"
-            [:form
-             [field {:field/type :text
-                     :field/title {:en "Title"}
-                     :field/placeholder {:en "prompt"}
-                     :readonly true}]])
-   (example "non-editable field of type \"text\" with text"
-            [:form
-             [field {:field/type :text
-                     :field/title {:en "Title"}
-                     :field/placeholder {:en "prompt"}
-                     :readonly true
-                     :field/value lipsum-short}]])
-   (example "field of type \"texta\""
-            [:form
-             [field {:field/type :texta
-                     :field/title {:en "Title"}
-                     :field/placeholder {:en "prompt"}}]])
-   (example "field of type \"texta\" with maximum length"
-            [:form
-             [field {:field/type :texta
-                     :field/title {:en "Title"}
-                     :field/placeholder {:en "prompt"}
-                     :field/max-length 10}]])
-   (example "field of type \"texta\" with validation error"
-            [:form
-             [field {:field/type :texta
-                     :field/title {:en "Title"}
-                     :field/placeholder {:en "prompt"}
-                     :validation {:type :t.form.validation/required}}]])
-   (example "non-editable field of type \"texta\""
-            [:form
-             [field {:field/type :texta
-                     :field/title {:en "Title"}
-                     :field/placeholder {:en "prompt"}
-                     :readonly true
-                     :field/value lipsum-paragraphs}]])
-   (let [previous-lipsum-paragraphs (-> lipsum-paragraphs
-                                        (str/replace "ipsum primis in faucibus orci luctus" "eu mattis purus mi eu turpis")
-                                        (str/replace "per inceptos himenaeos" "justo erat hendrerit magna"))]
-     [:div
-      (example "editable field of type \"texta\" with previous value, diff hidden"
-               [:form
-                [field {:field/type :texta
-                        :field/title {:en "Title"}
-                        :field/placeholder {:en "prompt"}
-                        :field/value lipsum-paragraphs
-                        :field/previous-value previous-lipsum-paragraphs}]])
-      (example "editable field of type \"texta\" with previous value, diff shown"
-               [:form
-                [field {:field/type :texta
-                        :field/title {:en "Title"}
-                        :field/placeholder {:en "prompt"}
-                        :field/value lipsum-paragraphs
-                        :field/previous-value previous-lipsum-paragraphs
-                        :diff true}]])
-      (example "non-editable field of type \"texta\" with previous value, diff hidden"
-               [:form
-                [field {:field/type :texta
-                        :field/title {:en "Title"}
-                        :field/placeholder {:en "prompt"}
-                        :readonly true
-                        :field/value lipsum-paragraphs
-                        :field/previous-value previous-lipsum-paragraphs}]])
-      (example "non-editable field of type \"texta\" with previous value, diff shown"
-               [:form
-                [field {:field/type :texta
-                        :field/title {:en "Title"}
-                        :field/placeholder {:en "prompt"}
-                        :readonly true
-                        :field/value lipsum-paragraphs
-                        :field/previous-value previous-lipsum-paragraphs
-                        :diff true}]])
-      (example "non-editable field of type \"texta\" with previous value equal to current value"
-               [:form
-                [field {:field/type :texta
-                        :field/title {:en "Title"}
-                        :field/placeholder {:en "prompt"}
-                        :readonly true
-                        :field/value lipsum-paragraphs
-                        :field/previous-value lipsum-paragraphs}]])])
-   (example "field of type \"attachment\""
-            [:form
-             [field {:app-id 5
-                     :field/id 6
-                     :field/type :attachment
-                     :field/title {:en "Title"}}]])
-   (example "field of type \"attachment\", file uploaded"
-            [:form
-             [field {:app-id 5
-                     :field/id 6
-                     :field/type :attachment
-                     :field/title {:en "Title"}
-                     :field/value "test.txt"}]])
-   (example "non-editable field of type \"attachment\""
-            [:form
-             [field {:app-id 5
-                     :field/id 6
-                     :field/type :attachment
-                     :field/title {:en "Title"}
-                     :readonly true}]])
-   (example "non-editable field of type \"attachment\", file uploaded"
-            [:form
-             [field {:app-id 5
-                     :field/id 6
-                     :field/type :attachment
-                     :field/title {:en "Title"}
-                     :readonly true
-                     :field/value "test.txt"}]])
-   (example "field of type \"date\""
-            [:form
-             [field {:field/type :date
-                     :field/title {:en "Title"}}]])
-   (example "field of type \"date\" with value"
-            [:form
-             [field {:field/type :date
-                     :field/title {:en "Title"}
-                     :field/value "2000-12-31"}]])
-   (example "non-editable field of type \"date\""
-            [:form
-             [field {:field/type :date
-                     :field/title {:en "Title"}
-                     :readonly true
-                     :field/value ""}]])
-   (example "non-editable field of type \"date\" with value"
-            [:form
-             [field {:field/type :date
-                     :field/title {:en "Title"}
-                     :readonly true
-                     :field/value "2000-12-31"}]])
-   (example "field of type \"option\""
-            [:form
-             [field {:field/type :option
-                     :field/title {:en "Title"}
-                     :field/value "y"
-                     :field/options [{:key "y" :label {:en "Yes" :fi "Kyllä"}}
-                                     {:key "n" :label {:en "No" :fi "Ei"}}]}]])
-   (example "non-editable field of type \"option\""
-            [:form
-             [field {:field/type :option
-                     :field/title {:en "Title"}
-                     :field/value "y"
-                     :readonly true
-                     :field/options [{:key "y" :label {:en "Yes" :fi "Kyllä"}}
-                                     {:key "n" :label {:en "No" :fi "Ei"}}]}]])
-   (example "field of type \"multiselect\""
-            [:form
-             [field {:field/type :multiselect
-                     :field/title {:en "Title"}
-                     :field/value "egg bacon"
-                     :field/options [{:key "egg" :label {:en "Egg" :fi "Munaa"}}
-                                     {:key "bacon" :label {:en "Bacon" :fi "Pekonia"}}
-                                     {:key "spam" :label {:en "Spam" :fi "Lihasäilykettä"}}]}]])
-   (example "non-editable field of type \"multiselect\""
-            [:form
-             [field {:field/type :multiselect
-                     :field/title {:en "Title"}
-                     :field/value "egg bacon"
-                     :readonly true
-                     :field/options [{:key "egg" :label {:en "Egg" :fi "Munaa"}}
-                                     {:key "bacon" :label {:en "Bacon" :fi "Pekonia"}}
-                                     {:key "spam" :label {:en "Spam" :fi "Lihasäilykettä"}}]}]])
-   (example "optional field"
-            [:form
-             [field {:field/type :texta
-                     :field/optional true
-                     :field/title {:en "Title"}
-                     :field/placeholder {:en "prompt"}}]])
-   (example "field of type \"label\""
-            [:form
-             [field {:field/type :label
-                     :field/title {:en "Lorem ipsum dolor sit amet"}}]])
-   (example "field of type \"description\""
-            [:form
-             [field {:field/type :description
-                     :field/title {:en "Title"}
-                     :field/placeholder {:en "prompt"}}]])])
diff --git a/src/cljs/rems/guide_page.cljs b/src/cljs/rems/guide_page.cljs
index 6ef11474c..0f887e850 100644
--- a/src/cljs/rems/guide_page.cljs
+++ b/src/cljs/rems/guide_page.cljs
@@ -10,7 +10,6 @@
             [rems.cart :as cart]
             [rems.catalogue :as catalogue]
             [rems.collapsible :as collapsible]
-            [rems.fields :as fields]
             [rems.language-switcher :as language-switcher]
             [rems.modal :as modal]
             [rems.navbar :as nav]
@@ -120,9 +119,6 @@
     [:h2 "Applications"]
     [application/guide]
 
-    [:h2 "Form fields"]
-    [fields/guide]
-
     [:h2 "Administration"]
     [administration/guide]
 


Test patch:
diff --git a/src/clj/rems/db/test_data.clj b/src/clj/rems/db/test_data.clj
index 5861b9cb6..ecf302f9c 100644
--- a/src/clj/rems/db/test_data.clj
+++ b/src/clj/rems/db/test_data.clj
@@ -6,16 +6,13 @@
             [rems.db.catalogue :as catalogue]
             [rems.db.core :as db]
             [rems.db.form :as form]
-            [rems.db.licenses :as licenses]
-            [rems.db.resource :as resource]
             [rems.db.roles :as roles]
             [rems.db.users :as users]
             [rems.db.workflow :as workflow]
             [rems.db.workflow-actors :as actors]
             [rems.locales :as locales]
             [ring.util.http-response :refer [bad-request!]])
-  (:import [java.util UUID]
-           [org.joda.time DateTimeUtils DateTime]))
+  (:import (org.joda.time DateTimeUtils DateTime)))
 
 (def ^DateTime creation-time (time/now)) ; TODO: no more used, remove?
 
@@ -631,92 +628,6 @@
       (let [application (create-draft! applicant item-without-new-license wfid "applied before license was valid" (time/minus (time/now) (time/days 2)))]
         (applications/submit-application applicant application)))))
 
-(defn create-performance-test-data! []
-  (let [resource-count 1000
-        application-count 1000
-        user-count 1000
-        handlers [(+fake-users+ :approver1)
-                  (+fake-users+ :approver2)]
-        owner (+fake-users+ :owner)
-        workflow-id (:id (workflow/create-workflow! {:user-id owner
-                                                     :organization "perf"
-                                                     :title "Performance tests"
-                                                     :type :dynamic
-                                                     :handlers handlers}))
-        form-id (:id (form/create-form!
-                      owner
-                      {:organization "perf"
-                       :title "Performance tests"
-                       :items [{:title {:en "Project name"
-                                        :fi "Projektin nimi"}
-                                :optional false
-                                :type "description"
-                                :input-prompt {:en "Project"
-                                               :fi "Projekti"}}
-
-                               {:title {:en "Project description"
-                                        :fi "Projektin kuvaus"}
-                                :optional false
-                                :type "texta"
-                                :input-prompt {:en "The purpose of the project is to..."
-                                               :fi "Projektin tarkoitus on..."}}]}))
-        form (form/get-form form-id)
-        license-id (:id (licenses/create-license!
-                         {:licensetype "text"
-                          :title "Performance License"
-                          :textcontent "Be fast."
-                          :localizations {:en {:title "Performance license"
-                                               :textcontent "Be fast."}
-                                          :fi {:title "Suorituskykylisenssi"
-                                               :textcontent "Ole nopea."}}}
-                         owner))
-        cat-item-ids (vec (for [_ (range resource-count)]
-                            (let [uuid (UUID/randomUUID)
-                                  resource (resource/create-resource!
-                                            {:resid (str "urn:uuid:" uuid)
-                                             :organization "perf"
-                                             :licenses [license-id]}
-                                            owner)
-                                  _ (assert (:success resource))
-                                  cat-item (catalogue/create-catalogue-item! {:title (str "Performance test resource " uuid)
-                                                                              :form form-id
-                                                                              :resid (:id resource)
-                                                                              :wfid workflow-id})]
-                              (:id cat-item))))
-        user-ids (vec (for [n (range 1 (inc user-count))]
-                        (let [user-id (str "perftester" n)]
-                          (users/add-user! user-id {:eppn user-id
-                                                    :mail (str user-id "@example.com")
-                                                    :commonName (str "Performance Tester " n)})
-                          user-id)))]
-    (dotimes [_ application-count]
-      (let [cat-item-id (rand-nth cat-item-ids)
-            user-id (rand-nth user-ids)
-            handler (rand-nth handlers)
-            app-id (:application-id (applications/create-application! user-id [cat-item-id]))]
-        (assert (nil? (applications/command!
-                       {:type :application.command/save-draft
-                        :actor user-id
-                        :time (time/now)
-                        :application-id app-id
-                        :field-values [{:field (:id (first (:fields form)))
-                                        :value (str "Performance test application " (UUID/randomUUID))}
-                                       {:field (:id (second (:fields form)))
-                                        ;; 5000 characters (10 KB) of lorem ipsum generated with www.lipsum.com
-                                        ;; to increase the memory requirements of an application
-                                        :value "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut non diam vel erat dapibus facilisis vel vitae nunc. Curabitur at fermentum lorem. Cras et bibendum ante. Etiam convallis erat justo. Phasellus cursus molestie vehicula. Etiam molestie tellus vitae consectetur dignissim. Pellentesque euismod hendrerit mi sed tincidunt. Integer quis lorem ut ipsum egestas hendrerit. Aenean est nunc, mattis euismod erat in, sodales rutrum mauris. Praesent sit amet risus quis felis congue ultricies. Nulla facilisi. Sed mollis justo id tristique volutpat.\n\nPhasellus augue mi, facilisis ac velit et, pharetra tristique nunc. Pellentesque eget arcu quam. Curabitur dictum nulla varius hendrerit varius. Proin vulputate, ex lacinia commodo varius, ipsum velit viverra est, eget molestie dui nisi non eros. Nulla lobortis odio a magna mollis placerat. Interdum et malesuada fames ac ante ipsum primis in faucibus. Integer consectetur libero ut gravida ullamcorper. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Donec aliquam feugiat mollis. Quisque massa lacus, efficitur vel justo vel, elementum mollis magna. Maecenas at sem sem. Praesent sed ex mattis, egestas dui non, volutpat lorem. Nulla tempor, nisi rutrum accumsan varius, tellus elit faucibus nulla, vel mattis lacus justo at ante. Sed ut mollis ex, sed tincidunt ex.\n\nMauris laoreet nibh eget erat tincidunt pharetra. Aenean sagittis maximus consectetur. Curabitur interdum nibh sed tincidunt finibus. Sed blandit nec lorem at iaculis. Morbi non augue nec tortor hendrerit mollis ut non arcu. Suspendisse maximus nec ligula a efficitur. Etiam ultrices rhoncus leo quis dapibus. Integer vel rhoncus est. Integer blandit varius auctor. Vestibulum suscipit suscipit risus, sit amet venenatis lacus iaculis a. Duis eu turpis sit amet nibh sagittis convallis at quis ligula. Sed eget justo quis risus iaculis lacinia vitae a justo. In hac habitasse platea dictumst. Maecenas euismod et lorem vel viverra.\n\nDonec bibendum nec ipsum in volutpat. Vivamus in elit venenatis, venenatis libero ac, ultrices dolor. Morbi quis odio in neque consequat rutrum. Suspendisse quis sapien id sapien fermentum dignissim. Nam eu est vel risus volutpat mollis sed quis eros. Proin leo nulla, dictum id hendrerit vitae, scelerisque in elit. Proin consectetur sodales arcu ac tristique. Suspendisse ut elementum ligula, at rhoncus mauris. Aliquam lacinia at diam eget mattis. Phasellus quam leo, hendrerit sit amet mi eget, porttitor aliquet velit. Proin turpis ante, consequat in enim nec, tempus consequat magna. Vestibulum fringilla ac turpis nec malesuada. Proin id lectus iaculis, suscipit erat at, volutpat turpis. In quis faucibus elit, ut maximus nibh. Sed egestas egestas dolor.\n\nNulla varius orci quam, id auctor enim ultrices nec. Morbi et tellus ac metus sodales convallis sed vehicula neque. Pellentesque rhoncus mattis massa a bibendum. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Fusce tincidunt nulla non aliquet facilisis. Praesent nisl nisi, finibus id odio sed, consectetur feugiat mauris. Suspendisse sed lacinia ligula. Duis vitae nisl leo. Donec erat arcu, feugiat sit amet sagittis ac, scelerisque nec est. Pellentesque finibus mauris nulla, in maximus sapien pharetra vitae. Sed leo elit, consequat eu aliquam vitae, feugiat ut eros. Pellentesque dictum feugiat odio sed commodo. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin neque quam, varius vel libero sit amet, rhoncus sollicitudin ex. In a dui non neque malesuada pellentesque.\n\nProin tincidunt nisl non commodo faucibus. Sed porttitor arcu neque, vitae bibendum sapien placerat nec. Integer eget tristique orci. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec eu molestie eros. Nunc iaculis rhoncus enim, vel mattis felis fringilla condimentum. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean ac augue nulla. Phasellus vitae nulla lobortis, mattis magna ac, gravida ipsum. Aenean ornare non nunc non luctus. Aenean lacinia lectus nec velit finibus egestas vel ut ipsum. Cras hendrerit rhoncus erat, vel maximus nunc.\n\nPraesent quis imperdiet quam. Praesent ligula tellus, consectetur sed lacus eu, malesuada condimentum tellus. Donec et diam hendrerit, dictum diam quis, aliquet purus. Suspendisse pulvinar neque at efficitur iaculis. Nulla erat orci, euismod id velit sed, dictum hendrerit arcu. Nulla aliquam molestie aliquam. Duis et semper nisi, eget commodo arcu. Praesent rhoncus, nulla id sodales eleifend, ante ipsum pellentesque augue, id iaculis sem est vitae est. Phasellus cursus diam a lorem vestibulum sodales. Nullam lacinia tortor vel tellus commodo, sit amet sodales quam malesuada.\n\nNulla tempor lectus vel arcu feugiat, vel dapibus ex dapibus. Maecenas purus justo, aliquet et sem sit amet, tincidunt venenatis dui. Nulla eget purus id sapien elementum rutrum eu vel libero. Cras non accumsan justo posuere."}]})))
-        (assert (nil? (applications/command!
-                       {:type :application.command/submit
-                        :actor user-id
-                        :time (time/now)
-                        :application-id app-id})))
-        (assert (nil? (applications/command!
-                       {:type :application.command/approve
-                        :actor handler
-                        :time (time/now)
-                        :application-id app-id
-                        :comment "Looks fine."})))))))
-
 (defn create-test-data! []
   (DateTimeUtils/setCurrentMillisFixed (.getMillis creation-time))
   (try
diff --git a/test/clj/rems/api/test_applications.clj b/test/clj/rems/api/test_applications.clj
index ed3f069c2..c8f8d3989 100644
--- a/test/clj/rems/api/test_applications.clj
+++ b/test/clj/rems/api/test_applications.clj
@@ -172,6 +172,7 @@
         handler-id "developer"
         commenter-id "carl"
         decider-id "bob"
+        license-id 5 ;; additional licenses from test data
         application-id 11] ;; submitted dynamic application from test data
 
     (testing "getting dynamic application as applicant"
@@ -195,6 +196,7 @@
                  "application.command/reject"
                  "application.command/approve"
                  "application.command/return"
+                 "application.command/add-licenses"
                  "application.command/add-member"
                  "application.command/remove-member"
                  "application.command/invite-member"
@@ -243,6 +245,21 @@
                   comment-event (get-in application [:application/events (inc eventcount)])]
               (is (= (:application/request-id request-event)
                      (:application/request-id comment-event)))))))
+
+      (testing "adding and then accepting additonal licenses"
+        (let [eventcount (count (get (get-application application-id handler-id) :events))]
+          (testing "add licenses"
+            (is (= {:success true} (send-command handler-id
+                                                 {:type :application.command/add-licenses
+                                                  :application-id application-id
+                                                  :licenses [license-id]
+                                                  :comment "Please approve these new terms"}))))
+          (testing "applicant can now accept licenses"
+            (is (= {:success true} (send-command user-id
+                                                 {:type :application.command/accept-licenses
+                                                  :application-id application-id
+                                                  :accepted-licenses [license-id]}))))))
+
       (testing "request-decision"
         (is (= {:success true} (send-command handler-id
                                              {:type :application.command/request-decision
@@ -273,6 +290,8 @@
                     "application.event/submitted"
                     "application.event/comment-requested"
                     "application.event/commented"
+                    "application.event/licenses-added"
+                    "application.event/licenses-accepted"
                     "application.event/decision-requested"
                     "application.event/decided"
                     "application.event/approved"]
@@ -282,6 +301,8 @@
                     "application.event/licenses-accepted"
                     "application.event/draft-saved"
                     "application.event/submitted"
+                    "application.event/licenses-added"
+                    "application.event/licenses-accepted"
                     "application.event/approved"]
                    applicant-event-types))))))))
 
diff --git a/test/clj/rems/api/test_entitlements.clj b/test/clj/rems/api/test_entitlements.clj
index fb2b22b0d..bba4004fd 100644
--- a/test/clj/rems/api/test_entitlements.clj
+++ b/test/clj/rems/api/test_entitlements.clj
@@ -1,9 +1,8 @@
 (ns ^:integration rems.api.test-entitlements
   (:require [clojure.test :refer :all]
+            [rems.handler :refer [handler]]
             [rems.api.testing :refer :all]
             [rems.db.users :as users]
-            [rems.handler :refer [handler]]
-            [rems.poller.entitlements :as entitlements-poller]
             [ring.mock.request :refer :all]))
 
 (use-fixtures
@@ -18,7 +17,7 @@
       (is (= "unauthorized" body)))))
 
 (deftest entitlements-test
-  (entitlements-poller/run)
+  (rems.poller.entitlements/run)
   (let [api-key "42"]
     (testing "all"
       (let [data (-> (request :get "/api/entitlements")
diff --git a/test/clj/rems/api/test_resources.clj b/test/clj/rems/api/test_resources.clj
index 98ac5dad7..6e12627e0 100644
--- a/test/clj/rems/api/test_resources.clj
+++ b/test/clj/rems/api/test_resources.clj
@@ -87,15 +87,14 @@
     (testing "create"
       (let [licid 1
             resid "RESOURCE-API-TEST"
-            result (-> (request :post "/api/resources/create")
-                       (authenticate api-key user-id)
-                       (json-body {:resid resid
-                                   :organization "TEST-ORGANIZATION"
-                                   :licenses [licid]})
-                       handler
-                       read-ok-body)
-            id (:id result)]
-        (is (true? (:success result)))
+            id (-> (request :post "/api/resources/create")
+                   (authenticate api-key user-id)
+                   (json-body {:resid resid
+                               :organization "TEST-ORGANIZATION"
+                               :licenses [licid]})
+                   handler
+                   read-ok-body
+                   :id)]
         (is id)
         (testing "and fetch"
           (let [resource (-> (request :get (str "/api/resources/" id))
@@ -106,34 +105,36 @@
             (is resource)
             (is (= [licid] (map :id (:licenses resource))))))
         (testing "duplicate resource ID is not allowed within one organization"
-          (let [result (-> (request :post "/api/resources/create")
-                           (authenticate api-key user-id)
-                           (json-body {:resid resid
-                                       :organization "TEST-ORGANIZATION"
-                                       :licenses [licid]})
-                           handler
-                           read-ok-body)]
-            (is (false? (:success result)))
-            (is (= [{:type "t.administration.errors/duplicate-resid" :resid resid}] (:errors result)))))
+          (let [response (-> (request :post "/api/resources/create")
+                             (authenticate api-key user-id)
+                             (json-body {:resid resid
+                                         :organization "TEST-ORGANIZATION"
+                                         :licenses [licid]})
+                             handler)
+                body (read-body response)]
+            (is (= 200 (:status response)))
+            (is (= false (:success body)))
+            (is (= [{:type "t.administration.errors/duplicate-resid" :resid resid}] (:errors body)))))
         (testing "duplicate resource ID is allowed between organizations"
-          (let [result (-> (request :post "/api/resources/create")
-                           (authenticate api-key user-id)
-                           (json-body {:resid resid
-                                       :organization "TEST-ORGANIZATION2"
-                                       :licenses [licid]})
-                           handler
-                           read-ok-body)]
-            (is (true? (:success result)))))))))
+          (-> (request :post "/api/resources/create")
+              (authenticate api-key user-id)
+              (json-body {:resid resid
+                          :organization "TEST-ORGANIZATION2"
+                          :licenses [licid]})
+              handler
+              assert-response-is-ok))))))
 
 (deftest resources-api-filtering-test
   (let [unfiltered (-> (request :get "/api/resources" {:expired true})
                        (authenticate "42" "owner")
                        handler
-                       read-ok-body)
+                       assert-response-is-ok
+                       read-body)
         filtered (-> (request :get "/api/resources")
                      (authenticate "42" "owner")
                      handler
-                     read-ok-body)]
+                     assert-response-is-ok
+                     read-body)]
     (is (coll-is-not-empty? unfiltered))
     (is (coll-is-not-empty? filtered))
     (is (every? #(contains? % :expired) unfiltered))
diff --git a/test/clj/rems/application/test_model.clj b/test/clj/rems/application/test_model.clj
index ddd00f1a9..d2a00b042 100644
--- a/test/clj/rems/application/test_model.clj
+++ b/test/clj/rems/application/test_model.clj
@@ -7,114 +7,139 @@
   (:import [java.util UUID]
            [org.joda.time DateTime]))
 
+(def ^:private get-form
+  {40 {:id 40
+       :organization "org"
+       :title "form title"
+       :items [{:id 41
+                :localizations {:en {:title "en title"
+                                     :inputprompt "en placeholder"}
+                                :fi {:title "fi title"
+                                     :inputprompt "fi placeholder"}}
+                :optional false
+                :options []
+                :maxlength 100
+                :type "description"}
+               {:id 42
+                :localizations {:en {:title "en title"
+                                     :inputprompt "en placeholder"}
+                                :fi {:title "fi title"
+                                     :inputprompt "fi placeholder"}}
+                :optional false
+                :options []
+                :maxlength 100
+                :type "text"}]
+       :start (DateTime. 100)
+       :end nil}})
+
+(def ^:private get-catalogue-item
+  {10 {:id 10
+       :resource-id 11
+       :resid "urn:11"
+       :wfid 50
+       :formid 40
+       :title "non-localized title"
+       :localizations {:en {:id 10
+                            :langcode :en
+                            :title "en title"}
+                       :fi {:id 10
+                            :langcode :fi
+                            :title "fi title"}}
+       :start (DateTime. 100)
+       :end nil
+       :enabled true
+       :archived false
+       :expired false
+       :state "enabled"}
+   20 {:id 20
+       :resource-id 21
+       :resid "urn:21"
+       :wfid 50
+       :formid 40
+       :title "non-localized title"
+       :localizations {:en {:id 20
+                            :langcode :en
+                            :title "en title"}
+                       :fi {:id 20
+                            :langcode :fi
+                            :title "fi title"}}
+       :start (DateTime. 100)
+       :end nil
+       :enabled true
+       :archived false
+       :expired false
+       :state "enabled"}})
+
+(def ^:private get-license
+  {30 {:id 30
+       :licensetype "link"
+       :title "non-localized title"
+       :textcontent "http://non-localized-license-link"
+       :localizations {:en {:title "en title"
+                            :textcontent "http://en-license-link"}
+                       :fi {:title "fi title"
+                            :textcontent "http://fi-license-link"}}
+       :start (DateTime. 100)
+       :end nil
+       :enabled true
+       :expired false
+       :archived false}
+   31 {:id 31
+       :licensetype "text"
+       :title "non-localized title"
+       :textcontent "non-localized license text"
+       :localizations {:en {:title "en title"
+                            :textcontent "en license text"}
+                       :fi {:title "fi title"
+                            :textcontent "fi license text"}}
+       :start (DateTime. 100)
+       :end nil
+       :enabled true
+       :expired false
+       :archived false}
+   32 {:id 32
+       :licensetype "attachment"
+       :title "non-localized title"
+       :textcontent "non-localized filename"
+       :attachment-id 3200
+       :localizations {:en {:title "en title"
+                            :textcontent "en filename"
+                            :attachment-id 3201}
+                       :fi {:title "fi title"
+                            :textcontent "fi filename"
+                            :attachment-id 3202}}
+       :start (DateTime. 100)
+       :end nil
+       :enabled true
+       :expired false
+       :archived false}
+   33 {:id 33
+       :licensetype "attachment"
+       :title "non-localized title"
+       :textcontent "non-localized filename"
+       :attachment-id 3300
+       :localizations {:en {:title "en title"
+                            :textcontent "en filename"
+                            :attachment-id 3301}
+                       :fi {:title "fi title"
+                            :textcontent "fi filename"
+                            :attachment-id 3302}}
+       :start (DateTime. 100)
+       :end nil
+       :enabled true
+       :expired false
+       :archived false}})
+
+(def ^:private get-user
+  {"applicant" {:eppn "applicant"
+                :mail "applicant@example.com"
+                :commonName "Applicant"}})
+
 (deftest test-application-view
-  (let [injections {:get-form {40 {:id 40
-                                   :organization "org"
-                                   :title "form title"
-                                   :items [{:id 41
-                                            :localizations {:en {:title "en title"
-                                                                 :inputprompt "en placeholder"}
-                                                            :fi {:title "fi title"
-                                                                 :inputprompt "fi placeholder"}}
-                                            :optional false
-                                            :options []
-                                            :maxlength 100
-                                            :type "description"}
-                                           {:id 42
-                                            :localizations {:en {:title "en title"
-                                                                 :inputprompt "en placeholder"}
-                                                            :fi {:title "fi title"
-                                                                 :inputprompt "fi placeholder"}}
-                                            :optional false
-                                            :options []
-                                            :maxlength 100
-                                            :type "text"}]
-                                   :start (DateTime. 100)
-                                   :end nil}}
-
-                    :get-catalogue-item {10 {:id 10
-                                             :resource-id 11
-                                             :resid "urn:11"
-                                             :wfid 50
-                                             :formid 40
-                                             :title "non-localized title"
-                                             :localizations {:en {:id 10
-                                                                  :langcode :en
-                                                                  :title "en title"}
-                                                             :fi {:id 10
-                                                                  :langcode :fi
-                                                                  :title "fi title"}}
-                                             :start (DateTime. 100)
-                                             :end nil
-                                             :enabled true
-                                             :archived false
-                                             :expired false
-                                             :state "enabled"}
-                                         20 {:id 20
-                                             :resource-id 21
-                                             :resid "urn:21"
-                                             :wfid 50
-                                             :formid 40
-                                             :title "non-localized title"
-                                             :localizations {:en {:id 20
-                                                                  :langcode :en
-                                                                  :title "en title"}
-                                                             :fi {:id 20
-                                                                  :langcode :fi
-                                                                  :title "fi title"}}
-                                             :start (DateTime. 100)
-                                             :end nil
-                                             :enabled true
-                                             :archived false
-                                             :expired false
-                                             :state "enabled"}}
-
-                    :get-license {30 {:id 30
-                                      :licensetype "link"
-                                      :title "non-localized title"
-                                      :textcontent "http://non-localized-license-link"
-                                      :localizations {:en {:title "en title"
-                                                           :textcontent "http://en-license-link"}
-                                                      :fi {:title "fi title"
-                                                           :textcontent "http://fi-license-link"}}
-                                      :start (DateTime. 100)
-                                      :end nil
-                                      :enabled true
-                                      :expired false
-                                      :archived false}
-                                  31 {:id 31
-                                      :licensetype "text"
-                                      :title "non-localized title"
-                                      :textcontent "non-localized license text"
-                                      :localizations {:en {:title "en title"
-                                                           :textcontent "en license text"}
-                                                      :fi {:title "fi title"
-                                                           :textcontent "fi license text"}}
-                                      :start (DateTime. 100)
-                                      :end nil
-                                      :enabled true
-                                      :expired false
-                                      :archived false}
-                                  32 {:id 32
-                                      :licensetype "attachment"
-                                      :title "non-localized title"
-                                      :textcontent "non-localized filename"
-                                      :attachment-id 3200
-                                      :localizations {:en {:title "en title"
-                                                           :textcontent "en filename"
-                                                           :attachment-id 3201}
-                                                      :fi {:title "fi title"
-                                                           :textcontent "fi filename"
-                                                           :attachment-id 3202}}
-                                      :start (DateTime. 100)
-                                      :end nil
-                                      :enabled true
-                                      :expired false
-                                      :archived false}}
-
-                    :get-user {"applicant" {:eppn "applicant"
-                                            :mail "applicant@example.com"
-                                            :commonName "Applicant"}}}
+  (let [injections {:get-form get-form
+                    :get-catalogue-item get-catalogue-item
+                    :get-license get-license
+                    :get-user get-user}
         apply-events (fn [events]
                        (let [application (-> events
                                              events/validate-events
@@ -274,262 +299,349 @@
                                                       :application/last-activity (DateTime. 2500)
                                                       :application/events events
                                                       :application/accepted-licenses {"applicant" #{30 31 32}}})]
-                (is (= expected-application (apply-events events))))
-
-              (testing "> submitted"
-                (let [events (conj events {:event/type :application.event/submitted
-                                           :event/time (DateTime. 3000)
-                                           :event/actor "applicant"
-                                           :application/id 1})
-                      expected-application (merge expected-application
-                                                  {:application/last-activity (DateTime. 3000)
-                                                   :application/events events
-                                                   :application/state :application.state/submitted})]
-                  (is (= expected-application (apply-events events)))
-
-                  (testing "> returned"
-                    (let [events (conj events {:event/type :application.event/returned
-                                               :event/time (DateTime. 4000)
-                                               :event/actor "handler"
-                                               :application/id 1
-                                               :application/comment "fix stuff"})
-                          expected-application (deep-merge expected-application
-                                                           {:application/last-activity (DateTime. 4000)
-                                                            :application/events events
-                                                            :application/state :application.state/returned
-                                                            :application/form {:form/fields [{:field/previous-value "foo"}
-                                                                                             {:field/previous-value "bar"}]}})]
-                      (is (= expected-application (apply-events events)))
-
-                      (testing "> draft saved x2"
-                        (let [events (conj events
-                                           {:event/type :application.event/draft-saved
-                                            :event/time (DateTime. 5000)
-                                            :event/actor "applicant"
-                                            :application/id 1
-                                            ;; non-submitted versions should not show up as the previous value
-                                            :application/field-values {41 "intermediate draft"
-                                                                       42 "intermediate draft"}}
-                                           {:event/type :application.event/draft-saved
-                                            :event/time (DateTime. 6000)
-                                            :event/actor "applicant"
-                                            :application/id 1
-                                            :application/field-values {41 "new foo"
-                                                                       42 "new bar"}})
-                              expected-application (deep-merge expected-application
-                                                               {:application/modified (DateTime. 6000)
-                                                                :application/last-activity (DateTime. 6000)
-                                                                :application/events events
-                                                                :application/description "new foo"
-                                                                :application/form {:form/fields [{:field/value "new foo"
-                                                                                                  :field/previous-value "foo"}
-                                                                                                 {:field/value "new bar"
-                                                                                                  :field/previous-value "bar"}]}})]
-                          (is (= expected-application (apply-events events)))
-
-                          (testing "> submitted"
-                            (let [events (conj events
-                                               {:event/type :application.event/submitted
-                                                :event/time (DateTime. 7000)
-                                                :event/actor "applicant"
-                                                :application/id 1})
-                                  expected-application (merge expected-application
-                                                              {:application/last-activity (DateTime. 7000)
-                                                               :application/events events
-                                                               :application/state :application.state/submitted})]
-                              (is (= expected-application (apply-events events)))))))
-
-                      (testing "> submitted (no draft saved)"
-                        (let [events (conj events
-                                           {:event/type :application.event/submitted
-                                            :event/time (DateTime. 7000)
-                                            :event/actor "applicant"
-                                            :application/id 1})
-                              expected-application (deep-merge expected-application
-                                                               {:application/last-activity (DateTime. 7000)
-                                                                :application/events events
-                                                                :application/state :application.state/submitted
-                                                                ;; when there was no draft-saved event, the current and
-                                                                ;; previous submitted answers must be the same
-                                                                :application/form {:form/fields [{:field/value "foo"
-                                                                                                  :field/previous-value "foo"}
-                                                                                                 {:field/value "bar"
-                                                                                                  :field/previous-value "bar"}]}})]
-                          (is (= expected-application (apply-events events)))))))
-
-                  (testing "> approved"
-                    (let [events (conj events
-                                       {:event/type :application.event/approved
-                                        :event/time (DateTime. 4000)
-                                        :event/actor "handler"
-                                        :application/id 1
-                                        :application/comment "looks good"})
-                          expected-application (merge expected-application
-                                                      {:application/last-activity (DateTime. 4000)
-                                                       :application/events events
-                                                       :application/state :application.state/approved})]
-                      (is (= expected-application (apply-events events)))
-
-                      (testing "> closed"
-                        (let [events (conj events
-                                           {:event/type :application.event/closed
-                                            :event/time (DateTime. 5000)
-                                            :event/actor "handler"
-                                            :application/id 1
-                                            :application/comment "the project is finished"})
-                              expected-application (merge expected-application
-                                                          {:application/last-activity (DateTime. 5000)
-                                                           :application/events events
-                                                           :application/state :application.state/closed})]
-                          (is (= expected-application (apply-events events)))))))
-
-                  (testing "> rejected"
-                    (let [events (conj events
-                                       {:event/type :application.event/rejected
-                                        :event/time (DateTime. 4000)
-                                        :event/actor "handler"
-                                        :application/id 1
-                                        :application/comment "never gonna happen"})
-                          expected-application (merge expected-application
-                                                      {:application/last-activity (DateTime. 4000)
-                                                       :application/events events
-                                                       :application/state :application.state/rejected})]
-                      (is (= expected-application (apply-events events)))))
-
-                  (testing "> comment requested"
-                    (let [request-id (UUID/fromString "4de6c2b0-bb2e-4745-8f92-bd1d1f1e8298")
-                          events (conj events
-                                       {:event/type :application.event/comment-requested
-                                        :event/time (DateTime. 4000)
-                                        :event/actor "handler"
-                                        :application/id 1
-                                        :application/request-id request-id
-                                        :application/commenters ["commenter"]
-                                        :application/comment "please comment"})
-                          expected-application (deep-merge expected-application
-                                                           {:application/last-activity (DateTime. 4000)
-                                                            :application/events events
-                                                            :application/workflow {:workflow.dynamic/awaiting-commenters #{"commenter"}}})]
-                      (is (= expected-application (apply-events events)))
-
-                      (testing "> commented"
-                        (let [events (conj events
-                                           {:event/type :application.event/commented
-                                            :event/time (DateTime. 5000)
-                                            :event/actor "commenter"
-                                            :application/id 1
-                                            :application/request-id request-id
-                                            :application/comment "looks good"})
-                              expected-application (deep-merge expected-application
-                                                               {:application/last-activity (DateTime. 5000)
-                                                                :application/events events
-                                                                :application/workflow {:workflow.dynamic/awaiting-commenters #{}}})]
-                          (is (= expected-application (apply-events events)))))))
-
-                  (testing "> decision requested"
-                    (let [request-id (UUID/fromString "db9c7fd6-53be-4b04-b15d-a3a8e0a45e49")
-                          events (conj events
-                                       {:event/type :application.event/decision-requested
-                                        :event/time (DateTime. 4000)
-                                        :event/actor "handler"
-                                        :application/id 1
-                                        :application/request-id request-id
-                                        :application/deciders ["decider"]
-                                        :application/comment "please decide"})
-                          expected-application (deep-merge expected-application
-                                                           {:application/last-activity (DateTime. 4000)
-                                                            :application/events events
-                                                            :application/workflow {:workflow.dynamic/awaiting-deciders #{"decider"}}})]
-                      (is (= expected-application (apply-events events)))
-
-                      (testing "> decided"
-                        (let [events (conj events
-                                           {:event/type :application.event/decided
-                                            :event/time (DateTime. 5000)
-                                            :event/actor "decider"
-                                            :application/id 1
-                                            :application/request-id request-id
-                                            :application/decision :approved
-                                            :application/comment "I approve this"})
-                              expected-application (deep-merge expected-application
-                                                               {:application/last-activity (DateTime. 5000)
-                                                                :application/events events
-                                                                :application/workflow {:workflow.dynamic/awaiting-deciders #{}}})]
-                          (is (= expected-application (apply-events events)))))))
-
-                  (testing "> member invited"
-                    (let [token "b187bda7b9da9053a5d8b815b029e4ba"
-                          events (conj events
-                                       {:event/type :application.event/member-invited
-                                        :event/time (DateTime. 4000)
-                                        :event/actor "applicant"
-                                        :application/id 1
-                                        :application/member {:name "Mr. Member"
-                                                             :email "member@example.com"}
-                                        :invitation/token token})
-                          expected-application (deep-merge expected-application
-                                                           {:application/last-activity (DateTime. 4000)
-                                                            :application/events events
-                                                            :application/invitation-tokens {token {:name "Mr. Member"
-                                                                                                   :email "member@example.com"}}})]
-                      (is (= expected-application (apply-events events)))
-
-                      (testing "> member uninvited"
-                        (let [events (conj events
-                                           {:event/type :application.event/member-uninvited
-                                            :event/time (DateTime. 5000)
-                                            :event/actor "applicant"
-                                            :application/id 1
-                                            :application/member {:name "Mr. Member"
-                                                                 :email "member@example.com"}
-                                            :application/comment "he left the project"})
-                              expected-application (merge expected-application
-                                                          {:application/last-activity (DateTime. 5000)
-                                                           :application/events events
-                                                           :application/invitation-tokens {}})]
-                          (is (= expected-application (apply-events events)))))
-
-                      (testing "> member joined"
-                        (let [events (conj events
-                                           {:event/type :application.event/member-joined
-                                            :event/time (DateTime. 5000)
-                                            :event/actor "member"
-                                            :application/id 1
-                                            :invitation/token token})
-                              expected-application (merge expected-application
-                                                          {:application/last-activity (DateTime. 5000)
-                                                           :application/events events
-                                                           :application/members #{{:userid "member"}}
-                                                           :application/invitation-tokens {}})]
-                          (is (= expected-application (apply-events events)))))))
-
-                  (testing "> member added"
-                    (let [events (conj events
-                                       {:event/type :application.event/member-added
-                                        :event/time (DateTime. 4000)
-                                        :event/actor "handler"
-                                        :application/id 1
-                                        :application/member {:userid "member"}})
-                          expected-application (merge expected-application
-                                                      {:application/last-activity (DateTime. 4000)
-                                                       :application/events events
-                                                       :application/members #{{:userid "member"}}})]
-                      (is (= expected-application (apply-events events)))
-
-                      (testing "> member removed"
-                        (let [events (conj events
-                                           {:event/type :application.event/member-removed
-                                            :event/time (DateTime. 5000)
-                                            :event/actor "applicant"
-                                            :application/id 1
-                                            :application/member {:userid "member"}
-                                            :application/comment "he left the project"})
-                              expected-application (merge expected-application
-                                                          {:application/last-activity (DateTime. 5000)
-                                                           :application/events events
-                                                           :application/members #{}
-                                                           :application/past-members #{{:userid "member"}}})]
-                          (is (= expected-application (apply-events events))))))))))))))))
+                (is (= expected-application (apply-events events)))
+
+                (testing "> submitted"
+                  (let [events (conj events {:event/type :application.event/submitted
+                                             :event/time (DateTime. 3000)
+                                             :event/actor "applicant"
+                                             :application/id 1})
+                        expected-application (merge expected-application
+                                                    {:application/last-activity (DateTime. 3000)
+                                                     :application/events events
+                                                     :application/state :application.state/submitted})]
+                    (is (= expected-application (apply-events events)))
+
+                    (testing "> returned"
+                      (let [events (conj events {:event/type :application.event/returned
+                                                 :event/time (DateTime. 4000)
+                                                 :event/actor "handler"
+                                                 :application/id 1
+                                                 :application/comment "fix stuff"})
+                            expected-application (deep-merge expected-application
+                                                             {:application/last-activity (DateTime. 4000)
+                                                              :application/events events
+                                                              :application/state :application.state/returned
+                                                              :application/form {:form/fields [{:field/previous-value "foo"}
+                                                                                               {:field/previous-value "bar"}]}})]
+                        (is (= expected-application (apply-events events)))
+
+                        (testing "> draft saved x2"
+                          (let [events (conj events
+                                             {:event/type :application.event/draft-saved
+                                              :event/time (DateTime. 5000)
+                                              :event/actor "applicant"
+                                              :application/id 1
+                                              ;; non-submitted versions should not show up as the previous value
+                                              :application/field-values {41 "intermediate draft"
+                                                                         42 "intermediate draft"}}
+                                             {:event/type :application.event/draft-saved
+                                              :event/time (DateTime. 6000)
+                                              :event/actor "applicant"
+                                              :application/id 1
+                                              :application/field-values {41 "new foo"
+                                                                         42 "new bar"}})
+                                expected-application (deep-merge expected-application
+                                                                 {:application/modified (DateTime. 6000)
+                                                                  :application/last-activity (DateTime. 6000)
+                                                                  :application/events events
+                                                                  :application/description "new foo"
+                                                                  :application/form {:form/fields [{:field/value "new foo"
+                                                                                                    :field/previous-value "foo"}
+                                                                                                   {:field/value "new bar"
+                                                                                                    :field/previous-value "bar"}]}})]
+                            (is (= expected-application (apply-events events)))
+
+                            (testing "> submitted"
+                              (let [events (conj events
+                                                 {:event/type :application.event/submitted
+                                                  :event/time (DateTime. 7000)
+                                                  :event/actor "applicant"
+                                                  :application/id 1})
+                                    expected-application (merge expected-application
+                                                                {:application/last-activity (DateTime. 7000)
+                                                                 :application/events events
+                                                                 :application/state :application.state/submitted})]
+                                (is (= expected-application (apply-events events)))))))
+
+                        (testing "> submitted (no draft saved)"
+                          (let [events (conj events
+                                             {:event/type :application.event/submitted
+                                              :event/time (DateTime. 7000)
+                                              :event/actor "applicant"
+                                              :application/id 1})
+                                expected-application (deep-merge expected-application
+                                                                 {:application/last-activity (DateTime. 7000)
+                                                                  :application/events events
+                                                                  :application/state :application.state/submitted
+                                                                  ;; when there was no draft-saved event, the current and
+                                                                  ;; previous submitted answers must be the same
+                                                                  :application/form {:form/fields [{:field/value "foo"
+                                                                                                    :field/previous-value "foo"}
+                                                                                                   {:field/value "bar"
+                                                                                                    :field/previous-value "bar"}]}})]
+                            (is (= expected-application (apply-events events)))))))
+
+                    (testing "> licenses added"
+                      (let [events (conj events
+                                         {:event/type :application.event/licenses-added
+                                          :event/time (DateTime. 3500)
+                                          :event/actor "handler"
+                                          :application/id 1
+                                          :application/licenses [{:license/id 33}]
+                                          :application/comment "Please sign these terms also"})
+                            expected-application (merge expected-application
+                                                        {:application/last-activity (DateTime. 3500)
+                                                         :application/modified (DateTime. 3500)
+                                                         :application/events events
+                                                         :application/licenses (conj (:application/licenses expected-application)
+                                                                                {:license/id 33
+                                                                                 :license/type :attachment
+                                                                                 :license/title {:en "en title"
+                                                                                                 :fi "fi title"
+                                                                                                 :default "non-localized title"}
+                                                                                 :license/attachment-id {:en 3301
+                                                                                                         :fi 3302
+                                                                                                         :default 3300}
+                                                                                 :license/attachment-filename {:en "en filename"
+                                                                                                               :fi "fi filename"
+                                                                                                               :default "non-localized filename"}
+                                                                                 :license/start (DateTime. 100)
+                                                                                 :license/end nil
+                                                                                 :license/expired false
+                                                                                 :license/enabled true
+                                                                                      :license/archived false})})]
+                        (is (= expected-application (apply-events events)))
+
+                        (testing "> approved"
+                          (let [events (conj events
+                                             {:event/type :application.event/approved
+                                              :event/time (DateTime. 4000)
+                                              :event/actor "handler"
+                                              :application/id 1
+                                              :application/comment "looks good"})
+                                expected-application (merge expected-application
+                                                            {:application/last-activity (DateTime. 4000)
+                                                             :application/events events
+                                                             :application/state :application.state/approved})]
+                            (is (= expected-application (apply-events events)))
+
+                            (testing "> licenses accepted"
+                              (let [events (conj events
+                                                 {:event/type :application.event/licenses-accepted
+                                                  :event/time (DateTime. 4500)
+                                                  :event/actor "applicant"
+                                                  :application/id 1
+                                                  :application/accepted-licenses #{30 31 32 33}})
+                                    expected-application (merge expected-application
+                                                                {:application/modified (DateTime. 4500)
+                                                                 :application/last-activity (DateTime. 4500)
+                                                                 :application/events events
+                                                                 :application/accepted-licenses {"applicant" #{30 31 32 33}}})]
+                                (is (= expected-application (apply-events events)))
+
+                                (testing "> member added"
+                                  (let [events (conj events
+                                                     {:event/type :application.event/member-added
+                                                      :event/time (DateTime. 4000)
+                                                      :event/actor "handler"
+                                                      :application/id 1
+                                                      :application/member {:userid "member"}})
+                                        expected-application (merge expected-application
+                                                                    {:application/last-activity (DateTime. 4000)
+                                                                     :application/events events
+                                                                     :application/members #{{:userid "member"}}})]
+                                    (is (= expected-application (apply-events events)))
+                                    (testing "> licenses accepted for new member"
+                                      (let [events (conj events
+                                                         {:event/type :application.event/licenses-accepted
+                                                          :event/time (DateTime. 4500)
+                                                          :event/actor "member"
+                                                          :application/id 1
+                                                          :application/accepted-licenses #{30 33}})
+                                            expected-application (merge expected-application
+                                                                        {:application/modified (DateTime. 4500)
+                                                                         :application/last-activity (DateTime. 4500)
+                                                                         :application/events events
+                                                                         :application/accepted-licenses {"applicant" #{30 31 32 33}
+                                                                                                         "member" #{30 33}}})]
+                                        (is (= expected-application (apply-events events)))
+                                        (testing "> licenses accepted overwrites previous"
+                                          (let [events (conj events
+                                                             {:event/type :application.event/licenses-accepted
+                                                              :event/time (DateTime. 4500)
+                                                              :event/actor "member"
+                                                              :application/id 1
+                                                              :application/accepted-licenses #{31 32}})
+                                                expected-application (merge expected-application
+                                                                            {:application/modified (DateTime. 4500)
+                                                                             :application/last-activity (DateTime. 4500)
+                                                                             :application/events events
+                                                                             :application/accepted-licenses {"applicant" #{30 31 32 33}
+                                                                                                             "member" #{31 32}}})]
+                                            (is (= expected-application (apply-events events)))))))))
+
+
+                                (testing "> closed"
+                                  (let [events (conj events
+                                                     {:event/type :application.event/closed
+                                                      :event/time (DateTime. 5000)
+                                                      :event/actor "handler"
+                                                      :application/id 1
+                                                      :application/comment "the project is finished"})
+                                        expected-application (merge expected-application
+                                                                    {:application/last-activity (DateTime. 5000)
+                                                                     :application/events events
+                                                                     :application/state :application.state/closed})]
+                                    (is (= expected-application (apply-events events)))))))))
+
+                        (testing "> rejected"
+                          (let [events (conj events
+                                             {:event/type :application.event/rejected
+                                              :event/time (DateTime. 4000)
+                                              :event/actor "handler"
+                                              :application/id 1
+                                              :application/comment "never gonna happen"})
+                                expected-application (merge expected-application
+                                                            {:application/last-activity (DateTime. 4000)
+                                                             :application/events events
+                                                             :application/state :application.state/rejected})]
+                            (is (= expected-application (apply-events events)))))
+
+                        (testing "> comment requested"
+                          (let [request-id (UUID/fromString "4de6c2b0-bb2e-4745-8f92-bd1d1f1e8298")
+                                events (conj events
+                                             {:event/type :application.event/comment-requested
+                                              :event/time (DateTime. 4000)
+                                              :event/actor "handler"
+                                              :application/id 1
+                                              :application/request-id request-id
+                                              :application/commenters ["commenter"]
+                                              :application/comment "please comment"})
+                                expected-application (deep-merge expected-application
+                                                                 {:application/last-activity (DateTime. 4000)
+                                                                  :application/events events
+                                                                  :application/workflow {:workflow.dynamic/awaiting-commenters #{"commenter"}}})]
+                            (is (= expected-application (apply-events events)))
+
+                            (testing "> commented"
+                              (let [events (conj events
+                                                 {:event/type :application.event/commented
+                                                  :event/time (DateTime. 5000)
+                                                  :event/actor "commenter"
+                                                  :application/id 1
+                                                  :application/request-id request-id
+                                                  :application/comment "looks good"})
+                                    expected-application (deep-merge expected-application
+                                                                     {:application/last-activity (DateTime. 5000)
+                                                                      :application/events events
+                                                                      :application/workflow {:workflow.dynamic/awaiting-commenters #{}}})]
+                                (is (= expected-application (apply-events events)))))))
+
+                        (testing "> decision requested"
+                          (let [request-id (UUID/fromString "db9c7fd6-53be-4b04-b15d-a3a8e0a45e49")
+                                events (conj events
+                                             {:event/type :application.event/decision-requested
+                                              :event/time (DateTime. 4000)
+                                              :event/actor "handler"
+                                              :application/id 1
+                                              :application/request-id request-id
+                                              :application/deciders ["decider"]
+                                              :application/comment "please decide"})
+                                expected-application (deep-merge expected-application
+                                                                 {:application/last-activity (DateTime. 4000)
+                                                                  :application/events events
+                                                                  :application/workflow {:workflow.dynamic/awaiting-deciders #{"decider"}}})]
+                            (is (= expected-application (apply-events events)))
+
+                            (testing "> decided"
+                              (let [events (conj events
+                                                 {:event/type :application.event/decided
+                                                  :event/time (DateTime. 5000)
+                                                  :event/actor "decider"
+                                                  :application/id 1
+                                                  :application/request-id request-id
+                                                  :application/decision :approved
+                                                  :application/comment "I approve this"})
+                                    expected-application (deep-merge expected-application
+                                                                     {:application/last-activity (DateTime. 5000)
+                                                                      :application/events events
+                                                                      :application/workflow {:workflow.dynamic/awaiting-deciders #{}}})]
+                                (is (= expected-application (apply-events events)))))))
+
+                        (testing "> member invited"
+                          (let [token "b187bda7b9da9053a5d8b815b029e4ba"
+                                events (conj events
+                                             {:event/type :application.event/member-invited
+                                              :event/time (DateTime. 4000)
+                                              :event/actor "applicant"
+                                              :application/id 1
+                                              :application/member {:name "Mr. Member"
+                                                                   :email "member@example.com"}
+                                              :invitation/token token})
+                                expected-application (deep-merge expected-application
+                                                                 {:application/last-activity (DateTime. 4000)
+                                                                  :application/events events
+                                                                  :application/invitation-tokens {token {:name "Mr. Member"
+                                                                                                         :email "member@example.com"}}})]
+                            (is (= expected-application (apply-events events)))
+
+                            (testing "> member uninvited"
+                              (let [events (conj events
+                                                 {:event/type :application.event/member-uninvited
+                                                  :event/time (DateTime. 5000)
+                                                  :event/actor "applicant"
+                                                  :application/id 1
+                                                  :application/member {:name "Mr. Member"
+                                                                       :email "member@example.com"}
+                                                  :application/comment "he left the project"})
+                                    expected-application (merge expected-application
+                                                                {:application/last-activity (DateTime. 5000)
+                                                                 :application/events events
+                                                                 :application/invitation-tokens {}})]
+                                (is (= expected-application (apply-events events)))))
+
+                            (testing "> member joined"
+                              (let [events (conj events
+                                                 {:event/type :application.event/member-joined
+                                                  :event/time (DateTime. 5000)
+                                                  :event/actor "member"
+                                                  :application/id 1
+                                                  :invitation/token token})
+                                    expected-application (merge expected-application
+                                                                {:application/last-activity (DateTime. 5000)
+                                                                 :application/events events
+                                                                 :application/members #{{:userid "member"}}
+                                                                 :application/invitation-tokens {}})]
+                                (is (= expected-application (apply-events events)))))))
+
+                        (testing "> member added"
+                          (let [events (conj events
+                                             {:event/type :application.event/member-added
+                                              :event/time (DateTime. 4000)
+                                              :event/actor "handler"
+                                              :application/id 1
+                                              :application/member {:userid "member"}})
+                                expected-application (merge expected-application
+                                                            {:application/last-activity (DateTime. 4000)
+                                                             :application/events events
+                                                             :application/members #{{:userid "member"}}})]
+                            (is (= expected-application (apply-events events)))
+
+                            (testing "> member removed"
+                              (let [events (conj events
+                                                 {:event/type :application.event/member-removed
+                                                  :event/time (DateTime. 5000)
+                                                  :event/actor "applicant"
+                                                  :application/id 1
+                                                  :application/member {:userid "member"}
+                                                  :application/comment "he left the project"})
+                                    expected-application (merge expected-application
+                                                                {:application/last-activity (DateTime. 5000)
+                                                                 :application/events events
+                                                                 :application/members #{}
+                                                                 :application/past-members #{{:userid "member"}}})]
+                                (is (= expected-application (apply-events events)))))))))))))))))))
 
 (deftest test-calculate-permissions
   ;; TODO: is this what we want? wouldn't it be useful to be able to write more than one comment?
diff --git a/test/clj/rems/db/test_entitlements.clj b/test/clj/rems/db/test_entitlements.clj
index 669e1e04b..b2ef23f6a 100644
--- a/test/clj/rems/db/test_entitlements.clj
+++ b/test/clj/rems/db/test_entitlements.clj
@@ -2,13 +2,12 @@
   (:require [cheshire.core :as cheshire]
             [clj-time.core :as time]
             [clojure.test :refer :all]
-            [rems.db.applications :as applications]
             [rems.db.core :as db]
+            [rems.db.applications :as applications]
             [rems.db.entitlements :as entitlements]
             [rems.db.licenses :as licenses]
             [rems.db.resource :as resource]
             [rems.db.testing :refer [test-db-fixture rollback-db-fixture test-data-fixture]]
-            [rems.poller.entitlements :as entitlements-poller]
             [rems.testing-util :refer [suppress-logging]]
             [stub-http.core :as stub]))
 
@@ -70,7 +69,7 @@
             (is (= +expected-payload+ (cheshire/parse-string payload)))))))))
 
 (deftest test-entitlement-granting
-  (entitlements-poller/run) ; clear previous entitlements
+  (rems.poller.entitlements/run) ; clear previous entitlements
   (with-open [server (stub/start! {"/add" {:status 200}
                                    "/remove" {:status 200}})]
     (with-redefs [rems.config/env {:entitlements-target
@@ -111,7 +110,7 @@
                                             :member {:userid memberid}
                                             :time (time/now)})))
           (testing "submitted application should not yet cause entitlements"
-            (entitlements-poller/run)
+            (rems.poller.entitlements/run)
             (is (empty? (db/get-entitlements {:application app-id})))
             (is (empty? (stub/recorded-requests server))))
 
@@ -122,7 +121,7 @@
                                             :time (time/now)})))
 
           (testing "approved application should not yet cause entitlements"
-            (entitlements-poller/run)
+            (rems.poller.entitlements/run)
             (is (empty? (db/get-entitlements {:application app-id})))
             (is (empty? (stub/recorded-requests server))))
 
@@ -143,8 +142,8 @@
                  (:application/accepted-licenses (applications/get-application-state app-id))))
 
           (testing "approved application, licenses accepted by one user generates entitlements for that user"
-            (entitlements-poller/run)
-            (entitlements-poller/run) ;; run twice to check idempotence
+            (rems.poller.entitlements/run)
+            (rems.poller.entitlements/run) ;; run twice to check idempotence
             (is (= 2 (count (stub/recorded-requests server))))
             (testing "db"
               (is (= [[uid "resource1"] [uid "resource2"]]
@@ -168,7 +167,7 @@
                                             :time (time/now)})))
 
           (testing "approved application, more accepted licenses generates more entitlements"
-            (entitlements-poller/run)
+            (rems.poller.entitlements/run)
             (is (= 4 (count (stub/recorded-requests server))))
             (testing "db"
               (is (= [[uid "resource1"] [uid "resource2"]
@@ -194,7 +193,7 @@
                                             :time (time/now)})))
 
           (testing "removing a member ends entitlements"
-            (entitlements-poller/run)
+            (rems.poller.entitlements/run)
             (is (= 5 (count (stub/recorded-requests server))))
             (testing "db"
               (is (= [[uid "resource1"] [uid "resource2"]]
@@ -215,7 +214,7 @@
                                             :time (time/now)})))
 
           (testing "closed application should end entitlements"
-            (entitlements-poller/run)
+            (rems.poller.entitlements/run)
             (is (= 6 (count (stub/recorded-requests server))))
             (testing "db"
               (is (= []
diff --git a/test/clj/rems/db/testing.clj b/test/clj/rems/db/testing.clj
index 33dccb91b..33032e00d 100644
--- a/test/clj/rems/db/testing.clj
+++ b/test/clj/rems/db/testing.clj
@@ -11,8 +11,7 @@
   (mount/stop) ;; during interactive development, app might be running when tests start. we need to tear it down
   (mount/start-with-args {:test true}
                          #'rems.config/env
-                         #'rems.db.core/*db*
-                         #'rems.api.applications-v2/all-applications-cache)
+                         #'rems.db.core/*db*)
   (db/assert-test-database!)
   (migrations/migrate ["reset"] {:database-url (:test-database-url env)})
   (f)
diff --git a/test/clj/rems/test_browser.clj b/test/clj/rems/test_browser.clj
index 3a96bd0dd..9c4d8cd97 100644
--- a/test/clj/rems/test_browser.clj
+++ b/test/clj/rems/test_browser.clj
@@ -40,7 +40,6 @@
   (mount/start)
   (migrations/migrate ["reset"] (select-keys rems.config/env [:database-url]))
   (test-data/create-test-data!)
-  (test-data/create-performance-test-data!)
   (f)
   (mount/stop))
 
diff --git a/test/clj/rems/test_db.clj b/test/clj/rems/test_db.clj
index 3977290b8..30ab2fae4 100644
--- a/test/clj/rems/test_db.clj
+++ b/test/clj/rems/test_db.clj
@@ -17,9 +17,10 @@
             [rems.db.testing :refer [test-db-fixture rollback-db-fixture]]
             [rems.db.users :as users]
             [rems.db.workflow-actors :as actors]
-            [rems.poller.entitlements :as entitlements-poller]
+            [rems.poller.entitlements]
             [rems.testing-tempura :refer [fake-tempura-fixture]]
-            [rems.util :refer [get-user-id]])
+            [rems.util :refer [get-user-id]]
+            [stub-http.core :as stub])
   (:import (rems.auth ForbiddenException)))
 
 (use-fixtures :once fake-tempura-fixture test-db-fixture)
@@ -199,7 +200,7 @@
                                       :comment ""})))
     (is (= :application.state/approved (:application/state (applications-v2/get-application uid app-id))))
 
-    (entitlements-poller/run)
+    (rems.poller.entitlements/run)
     (is (= ["resid111" "resid222"] (sort (map :resid (db/get-entitlements {:application app-id}))))
         "should create entitlements for both resources")))
 
@@ -647,7 +648,7 @@
                                           :actor "handler"
                                           :application-id jill-app
                                           :comment ""})))
-    (entitlements-poller/run)
+    (rems.poller.entitlements/run)
 
     (binding [context/*roles* #{:handler}]
       (let [lines (split-lines (entitlements/get-entitlements-for-export))]
diff --git a/test/clj/rems/workflow/test_dynamic.clj b/test/clj/rems/workflow/test_dynamic.clj
index a52f7d398..5a2747471 100644
--- a/test/clj/rems/workflow/test_dynamic.clj
+++ b/test/clj/rems/workflow/test_dynamic.clj
@@ -136,16 +136,39 @@
 
 (deftest test-accept-licenses
   (let [application (apply-events nil [dummy-created-event])]
-    (testing "accepts licenses"
-      (is (= [{:event/type :application.event/licenses-accepted
-               :event/time test-time
-               :event/actor applicant-user-id
-               :application/id 123
-               :application/accepted-licenses #{1 2}}]
-             (ok-command application
-                         {:type :application.command/accept-licenses
-                          :actor applicant-user-id
-                          :accepted-licenses #{1 2}}))))))
+    (is (= [{:event/type :application.event/licenses-accepted
+             :event/time test-time
+             :event/actor applicant-user-id
+             :application/id 123
+             :application/accepted-licenses #{1 2}}]
+           (ok-command application
+                       {:type :application.command/accept-licenses
+                        :actor applicant-user-id
+                        :accepted-licenses #{1 2}})))))
+
+(deftest test-add-licenses
+  (let [application (apply-events nil [dummy-created-event
+                                       {:event/type :application.event/submitted
+                                        :event/time test-time
+                                        :event/actor applicant-user-id
+                                        :application/id 123}])]
+    (is (= [{:event/type :application.event/licenses-added
+             :event/time test-time
+             :event/actor handler-user-id
+             :application/id 123
+             :application/comment "comment"
+             :application/licenses [{:license/id 1} {:license/id 2}]}]
+           (ok-command application
+                       {:type :application.command/add-licenses
+                        :actor handler-user-id
+                        :comment "comment"
+                        :licenses [1 2]})))
+    (is (= {:errors [{:type :must-not-be-empty :key :licenses}]}
+           (fail-command application
+                         {:type :application.command/add-licenses
+                          :actor handler-user-id
+                          :comment "comment"
+                          :licenses []})))))
 
 (deftest test-submit
   (let [injections {:validate-form-answers fake-validate-form-answers}
diff --git a/test/cljs/rems/cljs_tests.cljs b/test/cljs/rems/cljs_tests.cljs
index 2876c005d..ead3f16ab 100644
--- a/test/cljs/rems/cljs_tests.cljs
+++ b/test/cljs/rems/cljs_tests.cljs
@@ -6,7 +6,7 @@
             rems.administration.test-create-resource
             rems.administration.test-create-workflow
             rems.administration.test-items
-            rems.test-fields
+            rems.test-application
             rems.test-util))
 
 (doo-tests 'rems.administration.test-create-catalogue-item
@@ -15,5 +15,5 @@
            'rems.administration.test-create-resource
            'rems.administration.test-create-workflow
            'rems.administration.test-items
-           'rems.test-fields
+           'rems.test-application
            'rems.test-util)
diff --git a/test/cljs/rems/test_fields.cljs b/test/cljs/rems/test_application.cljs
similarity index 72%
rename from test/cljs/rems/test_fields.cljs
rename to test/cljs/rems/test_application.cljs
index de4f8b415..c7cec4f2c 100644
--- a/test/cljs/rems/test_fields.cljs
+++ b/test/cljs/rems/test_application.cljs
@@ -1,7 +1,7 @@
-(ns rems.test-fields
+(ns rems.test-application
   (:require [cljs.test :refer-macros [deftest is testing use-fixtures]]
             [re-frame.core :as rf]
-            [rems.fields :refer [field-wrapper toggle-diff-button decode-option-keys encode-option-keys normalize-option-key]]
+            [rems.application :refer [basic-field text-field texta-field toggle-diff-button decode-option-keys encode-option-keys normalize-option-key]]
             [rems.atoms :refer [textarea]]
             [rems.spa]
             [rems.text :refer [text]]))
@@ -17,15 +17,15 @@
 (defn contains-hiccup? [needle haystack]
   (some #(= % needle) (tree-seq vector? identity haystack)))
 
-(deftest field-wrapper-test
+(deftest basic-field-test
   ;; TODO: experiment of writing tests for view components; is there better tooling for testing components?
   (testing "link to show diff"
     (testing "no previous value"
-      (is (not (contains-hiccup? toggle-diff-button (field-wrapper {:field/value "foo"} "<editor-component>")))))
+      (is (not (contains-hiccup? toggle-diff-button (basic-field {:field/value "foo"} "<editor-component>")))))
     (testing "has previous value"
-      (is (contains-hiccup? toggle-diff-button (field-wrapper {:field/value "foo", :field/previous-value "bar"} "<editor-component>"))))
+      (is (contains-hiccup? toggle-diff-button (basic-field {:field/value "foo", :field/previous-value "bar"} "<editor-component>"))))
     (testing "previous value is same as current value"
-      (is (not (contains-hiccup? toggle-diff-button (field-wrapper {:field/value "foo", :field/previous-value "foo"} "<editor-component>")))))))
+      (is (not (contains-hiccup? toggle-diff-button (basic-field {:field/value "foo", :field/previous-value "foo"} "<editor-component>")))))))
 
 (deftest option-keys-test
   (testing "whitespace is not allowed in a key"