Repository: CSCfi/rems
Pull request #1299 (2019-05-27 11:28:52)

rems.application.model: use json form templates
instead of forms stored as relations

required allocating ids for the fields stored in the templates

for #913


Patch:
diff --git a/resources/migrations/20190527064110-form-templates-for-old-forms.edn b/resources/migrations/20190527064110-form-templates-for-old-forms.edn
new file mode 100644
index 000000000..890b929a0
--- /dev/null
+++ b/resources/migrations/20190527064110-form-templates-for-old-forms.edn
@@ -0,0 +1,3 @@
+{:ns rems.migrations.form-templates-for-old-forms
+ :up-fn migrate-up
+ :down-fn nil}
diff --git a/resources/translations/en.edn b/resources/translations/en.edn
index fad7581ce..cf598b346 100644
--- a/resources/translations/en.edn
+++ b/resources/translations/en.edn
@@ -284,6 +284,7 @@
            :rejected "Rejected"}
   :search {:close-search "Close search"
            :open-search "Open search"
+           :search "Search"
            :search-parameters "Search parameters"}
   :unauthorized-page {:unauthorized "Unauthorized"
                       :you-are-unauthorized "You are unauthorized to use this page."}
diff --git a/resources/translations/fi.edn b/resources/translations/fi.edn
index eb8d26138..7f5ac368a 100644
--- a/resources/translations/fi.edn
+++ b/resources/translations/fi.edn
@@ -284,6 +284,7 @@
            :rejected "Hylätty"}
   :search {:close-search "Sulje haku"
            :open-search "Avaa haku"
+           :search "Hae"
            :search-parameters "Hakutermit"}
   :unauthorized-page {:unauthorized "Ei käyttöoikeuksia"
                       :you-are-unauthorized "Sinulla ei ole käyttöoikeuksia tähän sivuun."}
diff --git a/src/clj/rems/api/forms.clj b/src/clj/rems/api/forms.clj
index c4dbcd5d5..b0a060d4e 100644
--- a/src/clj/rems/api/forms.clj
+++ b/src/clj/rems/api/forms.clj
@@ -29,9 +29,13 @@
                                :label {s/Keyword s/Str}}]
    (s/optional-key :input-prompt) {s/Keyword s/Str}})
 
+(s/defschema FormFieldWithId
+  (merge FormField
+         {:id s/Int}))
+
 (s/defschema FullForm
   (merge Form
-         {:fields [FormField]}))
+         {:fields [FormFieldWithId]}))
 
 (s/defschema Forms
   [Form])
diff --git a/src/clj/rems/application/model.clj b/src/clj/rems/application/model.clj
index 60a4b4e15..2b7a86296 100644
--- a/src/clj/rems/application/model.clj
+++ b/src/clj/rems/application/model.clj
@@ -401,16 +401,16 @@
 (defn- enrich-form [app-form get-form]
   (let [form (get-form (:form/id app-form))
         app-fields (:form/fields app-form)
-        rich-fields (map (fn [item]
-                           {:field/id (:id item)
+        rich-fields (map (fn [field]
+                           {:field/id (:id field)
                             :field/value "" ; default for new forms
-                            :field/type (keyword (:type item))
-                            :field/title (localization-for :title item)
-                            :field/placeholder (localization-for :inputprompt item)
-                            :field/optional (:optional item)
-                            :field/options (:options item)
-                            :field/max-length (:maxlength item)})
-                         (:items form))
+                            :field/type (keyword (:type field))
+                            :field/title (:title field)
+                            :field/placeholder (get field :input-prompt {})
+                            :field/optional (:optional field)
+                            :field/options (:options field)
+                            :field/max-length (:maxlength field)})
+                         (:fields form))
         fields (merge-lists-by :field/id rich-fields app-fields)]
     (assoc app-form
            :form/title (:title form)
@@ -496,7 +496,7 @@
   (-> application
       (permissions/give-role-to-users :reporter (get-users-with-role :reporter))))
 
-(defn enrich-with-injections [application {:keys [get-form get-catalogue-item get-license
+(defn enrich-with-injections [application {:keys [get-form-template get-catalogue-item get-license
                                                   get-user get-users-with-role get-workflow
                                                   get-attachments-for-application]}]
   (let [answer-versions (remove nil? [(::draft-answers application)
@@ -515,7 +515,7 @@
                                                                           {:field/id field-id
                                                                            :field/value value})
                                                                         current-answers)))
-        (update :application/form enrich-form get-form)
+        (update :application/form enrich-form get-form-template)
         set-application-description
         (update :application/resources enrich-resources get-catalogue-item)
         (update :application/licenses enrich-licenses get-license)
diff --git a/src/clj/rems/db/applications.clj b/src/clj/rems/db/applications.clj
index 645a1ecde..76e462b3c 100644
--- a/src/clj/rems/db/applications.clj
+++ b/src/clj/rems/db/applications.clj
@@ -150,7 +150,7 @@
 
 (def ^:private injections
   {:get-attachments-for-application attachments/get-attachments-for-application
-   :get-form form/get-form
+   :get-form-template form/get-form-template
    :get-catalogue-item catalogue/get-localized-catalogue-item
    :get-license licenses/get-license
    :get-user users/get-user-attributes
diff --git a/src/clj/rems/db/form.clj b/src/clj/rems/db/form.clj
index f1fb79f40..8373ced5a 100644
--- a/src/clj/rems/db/form.clj
+++ b/src/clj/rems/db/form.clj
@@ -39,20 +39,27 @@
       (db/localize-form-item! {:item item-id
                                :langcode (name lang)
                                :title (get title lang)
-                               :inputprompt (get input-prompt lang)}))))
+                               :inputprompt (get input-prompt lang)}))
+    item-id))
 
 (defn create-form! [user-id {:keys [organization title fields] :as form}]
   ;; FIXME Remove saving old style forms only when we have a db migration.
   ;;       Otherwise it will get reeealy tricky to return both versions in get-api.
   (let [form-id (:id (db/create-form! {:organization organization
                                        :title title
-                                       :user user-id}))]
+                                       :user user-id}))
+        ;; Mirror field ids to form template so that form templates
+        ;; can be cross-referenced with form answers. Once old-style
+        ;; forms are gone, will need to allocate ids here (just use
+        ;; order, or generate UUIDs)
+        fields-with-ids (map-indexed (fn [order field]
+                                       (let [id (create-form-item! user-id form-id order field)]
+                                         (assoc field :id id)))
+                                     fields)]
     (db/save-form-template! (assoc form
                                    :id form-id
                                    :user user-id
-                                   :fields (json/generate-string fields)))
-    (doseq [[index item] (map-indexed vector fields)]
-      (create-form-item! user-id form-id index item))
+                                   :fields (json/generate-string fields-with-ids)))
     {:success (not (nil? form-id))
      :id form-id}))
 
@@ -119,4 +126,6 @@
                          (mapv process-field)))))
 
 (comment
-  (get-form 1))
+  (clojure.pprint/pprint
+   [(get-form 1)
+    (get-form-template 1)]))
diff --git a/src/clj/rems/migrations/form_templates_for_old_forms.clj b/src/clj/rems/migrations/form_templates_for_old_forms.clj
new file mode 100644
index 000000000..b833f072a
--- /dev/null
+++ b/src/clj/rems/migrations/form_templates_for_old_forms.clj
@@ -0,0 +1,102 @@
+(ns rems.migrations.form-templates-for-old-forms
+  (:require [clojure.test :refer :all]
+            [hugsql.core :as hugsql]
+            [rems.json :as json]))
+
+;; SQL queries repeated here so that this migration is standalone
+
+(hugsql/def-db-fns-from-string
+  "
+-- :name get-forms :? :*
+SELECT id FROM application_form;
+
+-- :name get-form-template-impl :? :1
+SELECT
+  id,
+  organization,
+  title,
+  start,
+  endt as \"end\",
+  fields::TEXT,
+  enabled,
+  archived
+FROM form_template
+WHERE id = :id;
+
+-- :name set-template-fields! :!
+UPDATE form_template
+SET fields = :fields::jsonb
+WHERE id = :id;
+
+-- :name get-form-items :? :*
+SELECT
+  item.id,
+  formitemoptional,
+  type,
+  value,
+  itemorder,
+  item.visibility,
+  itemmap.maxlength
+FROM application_form form
+LEFT OUTER JOIN application_form_item_map itemmap ON form.id = itemmap.formId
+LEFT OUTER JOIN application_form_item item ON item.id = itemmap.formItemId
+WHERE form.id = :id AND item.id IS NOT NULL
+ORDER BY itemorder;")
+
+(defn get-template-fields [db params]
+  (-> (get-form-template-impl db params)
+      :fields
+      json/parse-string))
+
+(defn should-update? [fields]
+  (not (every? #(contains? % :id) fields)))
+
+(defn update-fields [template-fields items]
+  (assert (= (count template-fields)
+             (count items)))
+  (let [result (mapv #(assoc %2 :id (:id %1)) items template-fields)]
+    (assert (not (should-update? result)))
+    result))
+
+(deftest test-update-template
+  (is (= [{:type "description"
+           :title {:fi "Projektin nimi" :en "Project name"}
+           :input-prompt {:fi "Projekti" :en "Project"}
+           :id 1
+           :optional false}
+          {:type "texta"
+           :title {:fi "Projektin tarkoitus" :en "Purpose of the project"}
+           :input-prompt
+           {:fi "Projektin tarkoitus on..."
+            :en "The purpose of the project is to..."}
+           :id 7
+           :optional false}
+          {:type "date"
+           :title
+           {:fi "Projektin aloituspäivä" :en "Start date of the project"}
+           :id 3
+           :optional true}]
+         (update-fields
+          [{:type "description"
+            :title {:fi "Projektin nimi" :en "Project name"}
+            :input-prompt {:fi "Projekti" :en "Project"}
+            :optional false}
+           {:type "texta"
+            :title {:fi "Projektin tarkoitus" :en "Purpose of the project"}
+            :input-prompt
+            {:fi "Projektin tarkoitus on..."
+             :en "The purpose of the project is to..."}
+            :optional false}
+           {:type "date"
+            :title
+            {:fi "Projektin aloituspäivä" :en "Start date of the project"}
+            :optional true}]
+          [{:id 1} {:id 7} {:id 3}]))))
+
+(defn migrate-up [{:keys [conn]}]
+  (doseq [{:keys [id]} (get-forms conn)]
+    (let [fields (get-template-fields conn {:id id})]
+      (when (should-update? fields)
+        (let [items (get-form-items conn {:id id})
+              updated (update-fields fields items)]
+          (set-template-fields! conn {:id id :fields (json/generate-string updated)}))))))
diff --git a/src/cljs/rems/actions.cljs b/src/cljs/rems/actions.cljs
index 32cb9a478..17e86f497 100644
--- a/src/cljs/rems/actions.cljs
+++ b/src/cljs/rems/actions.cljs
@@ -14,7 +14,7 @@
  (fn [{:keys [db]} _]
    {:db (dissoc db ::todo-applications ::handled-applications)
     :dispatch-n [[::fetch-todo-applications]
-                 [:rems.table/reset]]}))
+                 [:rems.table2/reset]]}))
 
 ;;;; applications to do
 
diff --git a/src/cljs/rems/administration/catalogue_items.cljs b/src/cljs/rems/administration/catalogue_items.cljs
index 17d74516e..7a1d7ac82 100644
--- a/src/cljs/rems/administration/catalogue_items.cljs
+++ b/src/cljs/rems/administration/catalogue_items.cljs
@@ -14,8 +14,7 @@
  ::enter-page
  (fn [{:keys [db]}]
    {:db (assoc db ::display-old? false)
-    :dispatch-n [[::fetch-catalogue]
-                 [:rems.table/reset]]}))
+    :dispatch [::fetch-catalogue]}))
 
 (rf/reg-event-fx
  ::fetch-catalogue
@@ -48,6 +47,17 @@
           :error-handler status-modal/common-error-handler!})
    {}))
 
+(rf/reg-event-db ::set-sorting (fn [db [_ sorting]] (assoc db ::sorting sorting)))
+(rf/reg-sub
+ ::sorting
+ (fn [db _]
+   (or (::sorting db)
+       {:sort-column :name
+        :sort-order :asc})))
+
+(rf/reg-event-db ::set-filtering (fn [db [_ filtering]] (assoc db ::filtering filtering)))
+(rf/reg-sub ::filtering (fn [db _] (::filtering db)))
+
 (rf/reg-event-fx
  ::set-display-old?
  (fn [{:keys [db]} [_ display-old?]]
@@ -65,72 +75,51 @@
    {:href (str "/#/administration/catalogue-items/" catalogue-item-id)}
    (text :t.administration/view)])
 
-(rf/reg-sub
- ::catalogue-table-rows
- (fn [_ _]
-   [(rf/subscribe [::catalogue])
-    (rf/subscribe [:language])])
- (fn [[catalogue language] _]
-   (map (fn [item]
-          {:key (:id item)
-           :name {:value (get-catalogue-item-title item language)}
-           :resource (let [value (:resource-name item)]
-                       {:value value
-                        :td [:td.resource
-                             [:a {:href (str "#/administration/resources/" (:resource-id item))}
-                              value]]})
-           :form (let [value (:form-name item)]
-                   {:value value
-                    :td [:td.form
-                         [:a {:href (str "#/administration/forms/" (:formid item))}
-                          value]]})
-           :workflow (let [value (:workflow-name item)]
-                       {:value value
-                        :td [:td.workflow
-                             [:a {:href (str "#/administration/workflows/" (:wfid item))}
-                              value]]})
-           :created (let [value (:start item)]
-                      {:value value
-                       :display-value (localize-time value)})
-           :end (let [value (:end item)]
-                  {:value value
-                   :display-value (localize-time value)})
-           ;; TODO: active means not-expired currently. it should maybe mean (and not-expired enabled not-archived)
-           :active (let [checked? (not (:expired item))]
-                     {:td [:td.active
-                           [readonly-checkbox checked?]]
-                      :sort-value (if checked? 1 2)})
-           :commands {:td [:td.commands
-                           [to-catalogue-item (:id item)]
-                           [status-flags/enabled-toggle item #(rf/dispatch [::update-catalogue-item %1 %2])]
-                           [status-flags/archived-toggle item #(rf/dispatch [::update-catalogue-item %1 %2])]]}})
-        catalogue)))
+(defn- catalogue-columns [language]
+  {:name {:header #(text :t.catalogue/header)
+          :value #(get-catalogue-item-title % language)}
+   :resource {:header #(text :t.administration/resource)
+              :value (fn [row]
+                       [:a {:href (str "#/administration/resources/" (:resource-id row))}
+                        (:resource-name row)])
+              :sort-value :resource-name
+              :filter-value :resource-name}
+   :form {:header #(text :t.administration/form)
+          :value (fn [row]
+                   [:a {:href (str "#/administration/forms/" (:formid row))}
+                    (:form-name row)])
+          :sort-value :form-name
+          :filter-value :form-name}
+   :workflow {:header #(text :t.administration/workflow)
+              :value (fn [row]
+                       [:a {:href (str "#/administration/workflows/" (:wfid row))}
+                        (:workflow-name row)])
+              :sort-value :workflow-name
+              :filter-value :workflow-name}
+   :created {:header #(text :t.administration/created)
+             :value (comp localize-time :start)}
+   :end {:header #(text :t.administration/end)
+         :value (comp localize-time :end)}
+   ;; TODO: active means not-expired currently. it should maybe mean (and not-expired enabled not-archived)
+   :active {:header #(text :t.administration/active)
+            :value (comp readonly-checkbox not :expired)}
+   :commands {:values (fn [item]
+                        [[to-catalogue-item (:id item)]
+                         [status-flags/enabled-toggle item #(rf/dispatch [::update-catalogue-item %1 %2])]
+                         [status-flags/archived-toggle item #(rf/dispatch [::update-catalogue-item %1 %2])]])
+              :sortable? false
+              :filterable? false}})
 
-(defn- catalogue-list []
-  (let [catalogue-table {:id ::catalogue
-                         :columns [{:key :name
-                                    :title (text :t.catalogue/header)}
-                                   {:key :resource
-                                    :title (text :t.administration/resource)}
-                                   {:key :form
-                                    :title (text :t.administration/form)}
-                                   {:key :workflow
-                                    :title (text :t.administration/workflow)}
-                                   {:key :created
-                                    :title (text :t.administration/created)}
-                                   {:key :end
-                                    :title (text :t.administration/end)}
-                                   {:key :active
-                                    :title (text :t.administration/active)
-                                    :filterable? false}
-                                   {:key :commands
-                                    :sortable? false
-                                    :filterable? false}]
-                         :rows [::catalogue-table-rows]
-                         :default-sort-column :name}]
-    [:div
-     [table/search catalogue-table]
-     [table/table catalogue-table]]))
+(defn- catalogue-list
+  "List of catalogue items"
+  [items language sorting filtering]
+  [table/component
+   {:column-definitions (catalogue-columns language)
+    :visible-columns [:name :resource :form :workflow :created :end :active :commands]
+    :sorting sorting
+    :filtering filtering
+    :id-function :id
+    :items items}])
 
 (defn catalogue-items-page []
   (into [:div
@@ -142,4 +131,8 @@
            [status-flags/display-old-toggle
             @(rf/subscribe [::display-old?])
             #(rf/dispatch [::set-display-old? %])]
-           [catalogue-list]])))
+           [catalogue-list
+            @(rf/subscribe [::catalogue])
+            @(rf/subscribe [:language])
+            (assoc @(rf/subscribe [::sorting]) :set-sorting #(rf/dispatch [::set-sorting %]))
+            (assoc @(rf/subscribe [::filtering]) :set-filtering #(rf/dispatch [::set-filtering %]))]])))
diff --git a/src/cljs/rems/administration/forms.cljs b/src/cljs/rems/administration/forms.cljs
index 62331ce50..bef3d3057 100644
--- a/src/cljs/rems/administration/forms.cljs
+++ b/src/cljs/rems/administration/forms.cljs
@@ -13,8 +13,7 @@
  ::enter-page
  (fn [{:keys [db]}]
    {:db (assoc db ::display-old? false)
-    :dispatch-n [[::fetch-forms]
-                 [:rems.table/reset]]}))
+    :dispatch [::fetch-forms]}))
 
 (rf/reg-event-db
  ::fetch-forms
@@ -45,6 +44,18 @@
           :error-handler status-modal/common-error-handler!})
    {}))
 
+(rf/reg-event-db ::set-sorting (fn [db [_ sorting]] (assoc db ::sorting sorting)))
+
+(rf/reg-sub
+ ::sorting
+ (fn [db _]
+   (or (::sorting db)
+       {:sort-column :title
+        :sort-order :asc})))
+
+(rf/reg-event-db ::set-filtering (fn [db [_ filtering]] (assoc db ::filtering filtering)))
+(rf/reg-sub ::filtering (fn [db _] (::filtering db)))
+
 (rf/reg-event-fx
  ::set-display-old?
  (fn [{:keys [db]} [_ display-old?]]
@@ -63,52 +74,34 @@
    {:href (str "/#/administration/forms/" (:id form))}
    (text :t.administration/view)])
 
-(rf/reg-sub
- ::forms-table-rows
- (fn [_ _]
-   [(rf/subscribe [::forms])])
- (fn [[forms] _]
-   (map (fn [form]
-          {:key (:id form)
-           :organization {:value (:organization form)}
-           :title {:value (:title form)}
-           :start (let [value (:start form)]
-                    {:value value
-                     :display-value (localize-time value)})
-           :end (let [value (:end form)]
-                  {:value value
-                   :display-value (localize-time value)})
-           :active (let [checked? (not (:expired form))]
-                     {:td [:td.active
-                           [readonly-checkbox checked?]]
-                      :sort-value (if checked? 1 2)})
-           :commands {:td [:td.commands
-                           [to-view-form form]
-                           [status-flags/enabled-toggle form #(rf/dispatch [::update-form %1 %2])]
-                           [status-flags/archived-toggle form #(rf/dispatch [::update-form %1 %2])]]}})
-        forms)))
+(defn- forms-columns []
+  {:organization {:header #(text :t.administration/organization)
+                  :value :organization}
+   :title {:header #(text :t.administration/title)
+           :value :title}
+   :start {:header #(text :t.administration/created)
+           :value (comp localize-time :start)}
+   :end {:header #(text :t.administration/end)
+         :value (comp localize-time :end)}
+   :active {:header #(text :t.administration/active)
+            :value (comp readonly-checkbox not :expired)}
+   :commands {:values (fn [item]
+                        [[to-view-form item]
+                         [status-flags/enabled-toggle item #(rf/dispatch [::update-form %1 %2])]
+                         [status-flags/archived-toggle item #(rf/dispatch [::update-form %1 %2])]])
+              :sortable? false
+              :filterable? false}})
 
-(defn- forms-list []
-  (let [forms-table {:id ::forms
-                     :columns [{:key :organization
-                                :title (text :t.administration/organization)}
-                               {:key :title
-                                :title (text :t.administration/title)}
-                               {:key :start
-                                :title (text :t.administration/created)}
-                               {:key :end
-                                :title (text :t.administration/end)}
-                               {:key :active
-                                :title (text :t.administration/active)
-                                :filterable? false}
-                               {:key :commands
-                                :sortable? false
-                                :filterable? false}]
-                     :rows [::forms-table-rows]
-                     :default-sort-column :title}]
-    [:div
-     [table/search forms-table]
-     [table/table forms-table]]))
+(defn- forms-list
+  "List of forms"
+  [forms sorting filtering]
+  [table/component
+   {:column-definitions (forms-columns)
+    :visible-columns [:organization :title :start :end :active :commands]
+    :sorting sorting
+    :filtering filtering
+    :id-function :id
+    :items forms}])
 
 (defn forms-page []
   (into [:div
@@ -120,4 +113,7 @@
            [status-flags/display-old-toggle
             @(rf/subscribe [::display-old?])
             #(rf/dispatch [::set-display-old? %])]
-           [forms-list]])))
+           [forms-list
+            @(rf/subscribe [::forms])
+            (assoc @(rf/subscribe [::sorting]) :set-sorting #(rf/dispatch [::set-sorting %]))
+            (assoc @(rf/subscribe [::filtering]) :set-filtering #(rf/dispatch [::set-filtering %]))]])))
diff --git a/src/cljs/rems/administration/licenses.cljs b/src/cljs/rems/administration/licenses.cljs
index 667a181f2..cde3fe4cb 100644
--- a/src/cljs/rems/administration/licenses.cljs
+++ b/src/cljs/rems/administration/licenses.cljs
@@ -13,8 +13,7 @@
  ::enter-page
  (fn [{:keys [db]}]
    {:db (assoc db ::display-old? false)
-    :dispatch-n [[::fetch-licenses]
-                 [:rems.table/reset]]}))
+    :dispatch [::fetch-licenses]}))
 
 (rf/reg-event-db
  ::fetch-licenses
@@ -35,6 +34,8 @@
 (rf/reg-sub ::licenses (fn [db _] (::licenses db)))
 (rf/reg-sub ::loading? (fn [db _] (::loading? db)))
 
+(rf/reg-event-db ::set-sorting (fn [db [_ sorting]] (assoc db ::sorting sorting)))
+
 (rf/reg-event-fx
  ::update-license
  (fn [_ [_ item description]]
@@ -45,6 +46,16 @@
           :error-handler status-modal/common-error-handler!})
    {}))
 
+(rf/reg-sub
+ ::sorting
+ (fn [db _]
+   (or (::sorting db)
+       {:sort-column :title
+        :sort-order :asc})))
+
+(rf/reg-event-db ::set-filtering (fn [db [_ filtering]] (assoc db ::filtering filtering)))
+(rf/reg-sub ::filtering (fn [db _] (::filtering db)))
+
 (rf/reg-event-fx
  ::set-display-old?
  (fn [{:keys [db]} [_ display-old?]]
@@ -63,52 +74,34 @@
    {:href (str "/#/administration/licenses/" license-id)}
    (text :t.administration/view)])
 
-(rf/reg-sub
- ::licenses-table-rows
- (fn [_ _]
-   [(rf/subscribe [::licenses])])
- (fn [[licenses] _]
-   (map (fn [license]
-          {:key (:id license)
-           :title {:value (:title license)}
-           :type {:value (:licensetype license)}
-           :start (let [value (:start license)]
-                    {:value value
-                     :display-value (localize-time value)})
-           :end (let [value (:end license)]
-                  {:value value
-                   :display-value (localize-time value)})
-           :active (let [checked? (not (:expired license))]
-                     {:td [:td.active
-                           [readonly-checkbox checked?]]
-                      :sort-value (if checked? 1 2)})
-           :commands {:td [:td.commands
-                           [to-view-license (:id license)]
-                           [status-flags/enabled-toggle license #(rf/dispatch [::update-license %1 %2])]
-                           [status-flags/archived-toggle license #(rf/dispatch [::update-license %1 %2])]]}})
-        licenses)))
+(defn- licenses-columns []
+  {:title {:header #(text :t.administration/licenses)
+           :value :title}
+   :type {:header #(text :t.administration/type)
+          :value :licensetype}
+   :start {:header #(text :t.administration/created)
+           :value (comp localize-time :start)}
+   :end {:header #(text :t.administration/end)
+         :value (comp localize-time :end)}
+   :active {:header #(text :t.administration/active)
+            :value (comp readonly-checkbox not :expired)}
+   :commands {:values (fn [license]
+                        [[to-view-license (:id license)]
+                         [status-flags/enabled-toggle license #(rf/dispatch [::update-license %1 %2])]
+                         [status-flags/archived-toggle license #(rf/dispatch [::update-license %1 %2])]])
+              :sortable? false
+              :filterable? false}})
 
-(defn- licenses-list []
-  (let [licenses-table {:id ::licenses
-                        :columns [{:key :title
-                                   :title (text :t.administration/licenses)}
-                                  {:key :type
-                                   :title (text :t.administration/type)}
-                                  {:key :start
-                                   :title (text :t.administration/created)}
-                                  {:key :end
-                                   :title (text :t.administration/end)}
-                                  {:key :active
-                                   :title (text :t.administration/active)
-                                   :filterable? false}
-                                  {:key :commands
-                                   :sortable? false
-                                   :filterable? false}]
-                        :rows [::licenses-table-rows]
-                        :default-sort-column :title}]
-    [:div
-     [table/search licenses-table]
-     [table/table licenses-table]]))
+(defn- licenses-list
+  "List of licenses"
+  [licenses sorting filtering]
+  [table/component
+   {:column-definitions (licenses-columns)
+    :visible-columns [:title :type :start :end :active :commands]
+    :sorting sorting
+    :filtering filtering
+    :id-function :id
+    :items licenses}])
 
 (defn licenses-page []
   (into [:div
@@ -120,4 +113,7 @@
            [status-flags/display-old-toggle
             @(rf/subscribe [::display-old?])
             #(rf/dispatch [::set-display-old? %])]
-           [licenses-list]])))
+           [licenses-list
+            @(rf/subscribe [::licenses])
+            (assoc @(rf/subscribe [::sorting]) :set-sorting #(rf/dispatch [::set-sorting %]))
+            (assoc @(rf/subscribe [::filtering]) :set-filtering #(rf/dispatch [::set-filtering %]))]])))
diff --git a/src/cljs/rems/administration/resources.cljs b/src/cljs/rems/administration/resources.cljs
index 1f6e15437..5154d8ec5 100644
--- a/src/cljs/rems/administration/resources.cljs
+++ b/src/cljs/rems/administration/resources.cljs
@@ -13,8 +13,7 @@
  ::enter-page
  (fn [{:keys [db]}]
    {:db (assoc db ::display-old? false)
-    :dispatch-n [[::fetch-resources]
-                 [:rems.table/reset]]}))
+    :dispatch [::fetch-resources]}))
 
 (rf/reg-event-fx
  ::fetch-resources
@@ -46,6 +45,13 @@
           :error-handler status-modal/common-error-handler!})
    {}))
 
+(rf/reg-event-db ::set-sorting (fn [db [_ sorting]] (assoc db ::sorting sorting)))
+(rf/reg-sub ::sorting (fn [db _] (::sorting db {:sort-order :asc
+                                                :sort-column :title})))
+
+(rf/reg-event-db ::set-filtering (fn [db [_ filtering]] (assoc db ::filtering filtering)))
+(rf/reg-sub ::filtering (fn [db _] (or (::filtering db))))
+
 (rf/reg-event-fx
  ::set-display-old?
  (fn [{:keys [db]} [_ display-old?]]
@@ -63,52 +69,34 @@
    {:href (str "/#/administration/resources/" resource-id)}
    (text :t.administration/view)])
 
-(rf/reg-sub
- ::resources-table-rows
- (fn [_ _]
-   [(rf/subscribe [::resources])])
- (fn [[resources] _]
-   (map (fn [resource]
-          {:key (:id resource)
-           :organization {:value (:organization resource)}
-           :title {:value (:resid resource)}
-           :start (let [value (:start resource)]
-                    {:value value
-                     :display-value (localize-time value)})
-           :end (let [value (:end resource)]
-                  {:value value
-                   :display-value (localize-time value)})
-           :active (let [checked? (not (:expired resource))]
-                     {:td [:td.active
-                           [readonly-checkbox checked?]]
-                      :sort-value (if checked? 1 2)})
-           :commands {:td [:td.commands
-                           [to-view-resource (:id resource)]
-                           [status-flags/enabled-toggle resource #(rf/dispatch [::update-resource %1 %2])]
-                           [status-flags/archived-toggle resource #(rf/dispatch [::update-resource %1 %2])]]}})
-        resources)))
+(defn- resources-columns []
+  {:organization {:header #(text :t.administration/organization)
+                  :value :organization}
+   :title {:header #(text :t.administration/resource)
+           :value :resid}
+   :start {:header #(text :t.administration/created)
+           :value (comp localize-time :start)}
+   :end {:header #(text :t.administration/end)
+         :value (comp localize-time :end)}
+   :active {:header #(text :t.administration/active)
+            :value (comp readonly-checkbox not :expired)}
+   :commands {:values (fn [resource]
+                        [[to-view-resource (:id resource)]
+                         [status-flags/enabled-toggle resource #(rf/dispatch [::update-resource %1 %2])]
+                         [status-flags/archived-toggle resource #(rf/dispatch [::update-resource %1 %2])]])
+              :sortable? false
+              :filterable? false}})
 
-(defn- resources-list []
-  (let [resources-table {:id ::resources
-                         :columns [{:key :organization
-                                    :title (text :t.administration/organization)}
-                                   {:key :title
-                                    :title (text :t.administration/resource)}
-                                   {:key :start
-                                    :title (text :t.administration/created)}
-                                   {:key :end
-                                    :title (text :t.administration/end)}
-                                   {:key :active
-                                    :title (text :t.administration/active)
-                                    :filterable? false}
-                                   {:key :commands
-                                    :sortable? false
-                                    :filterable? false}]
-                         :rows [::resources-table-rows]
-                         :default-sort-column :title}]
-    [:div
-     [table/search resources-table]
-     [table/table resources-table]]))
+(defn- resources-list
+  "List of resources"
+  [resources sorting filtering]
+  [table/component
+   {:column-definitions (resources-columns)
+    :visible-columns [:organization :title :start :end :active :commands]
+    :sorting sorting
+    :filtering filtering
+    :id-function :id
+    :items resources}])
 
 (defn resources-page []
   (into [:div
@@ -120,4 +108,7 @@
            [status-flags/display-old-toggle
             @(rf/subscribe [::display-old?])
             #(rf/dispatch [::set-display-old? %])]
-           [resources-list]])))
+           [resources-list
+            @(rf/subscribe [::resources])
+            (assoc @(rf/subscribe [::sorting]) :set-sorting #(rf/dispatch [::set-sorting %]))
+            (assoc @(rf/subscribe [::filtering]) :set-filtering #(rf/dispatch [::set-filtering %]))]])))
diff --git a/src/cljs/rems/administration/workflows.cljs b/src/cljs/rems/administration/workflows.cljs
index ddbde8bb1..73783b301 100644
--- a/src/cljs/rems/administration/workflows.cljs
+++ b/src/cljs/rems/administration/workflows.cljs
@@ -13,8 +13,7 @@
  ::enter-page
  (fn [{:keys [db]}]
    {:db (assoc db ::display-old? false)
-    :dispatch-n [[::fetch-workflows]
-                 [:rems.table/reset]]}))
+    :dispatch [::fetch-workflows]}))
 
 (rf/reg-event-db
  ::fetch-workflows
@@ -45,6 +44,18 @@
           :error-handler status-modal/common-error-handler!})
    {}))
 
+(rf/reg-event-db ::set-sorting (fn [db [_ sorting]] (assoc db ::sorting sorting)))
+
+(rf/reg-sub
+ ::sorting
+ (fn [db _]
+   (or (::sorting db)
+       {:sort-column :title
+        :sort-order :asc})))
+
+(rf/reg-event-db ::set-filtering (fn [db [_ filtering]] (assoc db ::filtering filtering)))
+(rf/reg-sub ::filtering (fn [db _] (::filtering db)))
+
 (rf/reg-event-fx
  ::set-display-old?
  (fn [{:keys [db]} [_ display-old?]]
@@ -52,6 +63,7 @@
     :dispatch [::fetch-workflows]}))
 (rf/reg-sub ::display-old? (fn [db _] (::display-old? db)))
 
+
 (defn- to-create-workflow []
   [:a.btn.btn-primary
    {:href "/#/administration/create-workflow"}
@@ -62,52 +74,34 @@
    {:href (str "/#/administration/workflows/" workflow-id)}
    (text :t.administration/view)])
 
-(rf/reg-sub
- ::workflows-table-rows
- (fn [_ _]
-   [(rf/subscribe [::workflows])])
- (fn [[workflows] _]
-   (map (fn [workflow]
-          {:key (:id workflow)
-           :organization {:value (:organization workflow)}
-           :title {:value (:title workflow)}
-           :start (let [value (:start workflow)]
-                    {:value value
-                     :display-value (localize-time value)})
-           :end (let [value (:end workflow)]
-                  {:value value
-                   :display-value (localize-time value)})
-           :active (let [checked? (not (:expired workflow))]
-                     {:td [:td.active
-                           [readonly-checkbox checked?]]
-                      :sort-value (if checked? 1 2)})
-           :commands {:td [:td.commands
-                           [to-view-workflow (:id workflow)]
-                           [status-flags/enabled-toggle workflow #(rf/dispatch [::update-workflow %1 %2])]
-                           [status-flags/archived-toggle workflow #(rf/dispatch [::update-workflow %1 %2])]]}})
-        workflows)))
+(defn- workflows-columns []
+  {:organization {:header #(text :t.administration/organization)
+                  :value :organization}
+   :title {:header #(text :t.administration/workflow)
+           :value :title}
+   :start {:header #(text :t.administration/created)
+           :value (comp localize-time :start)}
+   :end {:header #(text :t.administration/end)
+         :value (comp localize-time :end)}
+   :active {:header #(text :t.administration/active)
+            :value (comp readonly-checkbox not :expired)}
+   :commands {:values (fn [workflow]
+                        [[to-view-workflow (:id workflow)]
+                         [status-flags/enabled-toggle workflow #(rf/dispatch [::update-workflow %1 %2])]
+                         [status-flags/archived-toggle workflow #(rf/dispatch [::update-workflow %1 %2])]])
+              :sortable? false
+              :filterable? false}})
 
-(defn- workflows-list []
-  (let [workflows-table {:id ::workflows
-                         :columns [{:key :organization
-                                    :title (text :t.administration/organization)}
-                                   {:key :title
-                                    :title (text :t.administration/workflow)}
-                                   {:key :start
-                                    :title (text :t.administration/created)}
-                                   {:key :end
-                                    :title (text :t.administration/end)}
-                                   {:key :active
-                                    :title (text :t.administration/active)
-                                    :filterable? false}
-                                   {:key :commands
-                                    :sortable? false
-                                    :filterable? false}]
-                         :rows [::workflows-table-rows]
-                         :default-sort-column :title}]
-    [:div
-     [table/search workflows-table]
-     [table/table workflows-table]]))
+(defn- workflows-list
+  "List of workflows"
+  [workflows sorting filtering]
+  [table/component
+   {:column-definitions (workflows-columns)
+    :visible-columns [:organization :title :start :end :active :commands]
+    :sorting sorting
+    :filtering filtering
+    :id-function :id
+    :items workflows}])
 
 ;; TODO Very similar components are used in here, licenses, forms, resources
 (defn workflows-page []
@@ -120,4 +114,7 @@
            [status-flags/display-old-toggle
             @(rf/subscribe [::display-old?])
             #(rf/dispatch [::set-display-old? %])]
-           [workflows-list]])))
+           [workflows-list
+            @(rf/subscribe [::workflows])
+            (assoc @(rf/subscribe [::sorting]) :set-sorting #(rf/dispatch [::set-sorting %]))
+            (assoc @(rf/subscribe [::filtering]) :set-filtering #(rf/dispatch [::set-filtering %]))]])))
diff --git a/src/cljs/rems/application_list.cljs b/src/cljs/rems/application_list.cljs
index 3e98cf082..1c59b7303 100644
--- a/src/cljs/rems/application_list.cljs
+++ b/src/cljs/rems/application_list.cljs
@@ -3,7 +3,7 @@
             [re-frame.core :as rf]
             [rems.application-util :as application-util]
             [rems.guide-functions]
-            [rems.table :as table]
+            [rems.table2 :as table2]
             [rems.text :refer [localize-state localize-time localized text]])
   (:require-macros [rems.guide-macros :refer [component-info example]]))
 
@@ -30,27 +30,48 @@
  (fn [[apps] _]
    (map (fn [app]
           {:key (:application/id app)
-           :id {:value (:application/id app)}
-           :external-id {:value (:application/external-id app)}
-           :description {:value (:application/description app)
-                         :td [:td.description (format-description app)]}
-           :resource {:value (format-catalogue-items app)}
-           :applicant {:value (:application/applicant app)}
+           :id (let [value (:application/id app)]
+                 {:td [:td.id value]
+                  :sort-value value
+                  :filter-value (str/lower-case (str value))})
+           :external-id (let [value (:application/external-id app)]
+                          {:td [:td.external-id value]
+                           :sort-value value
+                           :filter-value (str/lower-case (str value))})
+           :description (let [value (:application/description app)]
+                          {:td [:td.description (format-description app)]
+                           :sort-value value
+                           :filter-value (str/lower-case (str value))})
+           :resource (let [value (format-catalogue-items app)]
+                       {:td [:td.resource value]
+                        :sort-value value
+                        :filter-value (str/lower-case (str value))})
+           :applicant (let [value (:application/applicant app)]
+                        {:td [:td.applicant value]
+                         :sort-value value
+                         :filter-value (str/lower-case (str value))})
            :state (let [value (localize-state (:application/state app))]
-                    {:value value
-                     :td [:td.state
+                    {:td [:td.state
                           {:class (when (application-util/form-fields-editable? app)
                                     "text-highlight")}
-                          value]})
-           :created (let [value (:application/created app)]
-                      {:value value
-                       :display-value (localize-time value)})
-           :submitted (let [value (:application/first-submitted app)]
-                        {:value value
-                         :display-value (localize-time value)})
-           :last-activity (let [value (:application/last-activity app)]
-                            {:value value
-                             :display-value (localize-time value)})
+                          value]
+                     :sort-value value
+                     :filter-value (str/lower-case (str value))})
+           :created (let [value (:application/created app)
+                          display-value (localize-time value)]
+                      {:td [:td.created display-value]
+                       :sort-value value
+                       :filter-value (str/lower-case (str display-value))})
+           :submitted (let [value (:application/first-submitted app)
+                            display-value (localize-time value)]
+                        {:td [:td.submitted display-value]
+                         :sort-value value
+                         :filter-value (str/lower-case (str display-value))})
+           :last-activity (let [value (:application/last-activity app)
+                                display-value (localize-time value)]
+                            {:td [:td.last-activity display-value]
+                             :sort-value value
+                             :filter-value (str/lower-case (str display-value))})
            :view {:td [:td.view [view-button app]]}})
         apps)))
 
@@ -84,8 +105,8 @@
                            :default-sort-order default-sort-order}]
     [:div
      (when filterable?
-       [table/search application-table])
-     [table/table application-table]]))
+       [table2/search application-table])
+     [table2/table application-table]]))
 
 (defn guide []
   (rf/reg-sub
diff --git a/src/cljs/rems/applications.cljs b/src/cljs/rems/applications.cljs
index 1460c4583..e97dcf80c 100644
--- a/src/cljs/rems/applications.cljs
+++ b/src/cljs/rems/applications.cljs
@@ -14,7 +14,7 @@
     :dispatch-n [[::fetch-my-applications]
                  (when (roles/show-all-applications? (:roles (:identity db)))
                    [::fetch-all-applications])
-                 [:rems.table/reset]]}))
+                 [:rems.table2/reset]]}))
 
 ;;;; my applications
 
diff --git a/src/cljs/rems/catalogue.cljs b/src/cljs/rems/catalogue.cljs
index 7a21009af..adb573143 100644
--- a/src/cljs/rems/catalogue.cljs
+++ b/src/cljs/rems/catalogue.cljs
@@ -9,7 +9,7 @@
             [rems.guide-functions]
             [rems.roles :as roles]
             [rems.spinner :as spinner]
-            [rems.table :as table]
+            [rems.table2 :as table2]
             [rems.text :refer [localize-time text]]
             [rems.util :refer [fetch unauthorized!]])
   (:require-macros [rems.guide-macros :refer [component-info example]]))
@@ -21,7 +21,7 @@
      {:db (dissoc db ::catalogue ::draft-applications)
       :dispatch-n [[::fetch-catalogue]
                    [::fetch-drafts]
-                   [:rems.table/reset]]}
+                   [:rems.table2/reset]]}
      (do
        (unauthorized!)
        {}))))
@@ -90,7 +90,10 @@
    (map (fn [item]
           (let [title (get-catalogue-item-title item language)]
             {:key (:id item)
-             :name {:value title}
+             ;; TODO: helpers for the common case of simple values
+             :name {:td [:td.name title]
+                    :sort-value title
+                    :filter-value (str/lower-case title)}
              :commands {:td [:td.commands
                              [catalogue-item-more-info item {}]
                              [cart/add-to-cart-button item]]}}))
@@ -119,8 +122,8 @@
                    :rows [::catalogue-table-rows]
                    :default-sort-column :name}]
     [:div
-     [table/search catalogue]
-     [table/table catalogue]]))
+     [table2/search catalogue]
+     [table2/table catalogue]]))
 
 (defn catalogue-page []
   (let [loading-catalogue? @(rf/subscribe [::loading-catalogue?])
diff --git a/src/cljs/rems/guide_page.cljs b/src/cljs/rems/guide_page.cljs
index e21d98688..3110222f7 100644
--- a/src/cljs/rems/guide_page.cljs
+++ b/src/cljs/rems/guide_page.cljs
@@ -15,7 +15,7 @@
             [rems.phase :as phase]
             [rems.spinner :as spinner]
             [rems.status-modal :as status-modal]
-            [rems.table :as table])
+            [rems.table2 :as table2])
   (:require-macros [rems.guide-macros :refer [example]]))
 
 (defn color-box [id hex]
@@ -123,7 +123,7 @@
     [administration/guide]
 
     [:h2 "Misc components"]
-    [table/guide]
+    [table2/guide]
     [autocomplete/guide]
     [phase/guide]
     [atoms/guide]]])
diff --git a/src/cljs/rems/table.cljs b/src/cljs/rems/table.cljs
index 518182656..fb7aadc51 100644
--- a/src/cljs/rems/table.cljs
+++ b/src/cljs/rems/table.cljs
@@ -1,15 +1,52 @@
 (ns rems.table
-  (:require [cljs.test :refer-macros [deftest is testing]]
+  "Generic sortable table widget"
+  (:require [clojure.data :refer [diff]]
             [clojure.string :as str]
-            [re-frame.core :as rf]
+            [clojure.test :refer [deftest is testing]]
+            [reagent.core :as reagent]
             [rems.atoms :refer [close-symbol search-symbol sort-symbol]]
-            [rems.text :refer [text]])
-  (:require-macros [rems.guide-macros :refer [component-info example]]))
+            [rems.text :refer [text]]
+            [schema.core :as s]))
 
-(rf/reg-event-db
- ::reset
- (fn [db _]
-   (dissoc db ::sorting ::filtering)))
+(defn column-header [column-definitions col]
+  ((get-in column-definitions [col :header] (constantly ""))))
+
+(defn column-value [column-definitions col item]
+  ((get-in column-definitions [col :value]) item))
+
+(defn column-values [column-definitions col item]
+  (let [values (get-in column-definitions [col :values])]
+    (if values
+      (values item)
+      [(column-value column-definitions col item)])))
+
+(defn column-class [column-definitions col item]
+  (let [class (get-in column-definitions [col :class] (name col))]
+    (cond (string? class) class
+          (fn? class) (class item))))
+
+(defn column-sort-value [column-definitions col item]
+  (let [sort-value-fn (or (get-in column-definitions [col :sort-value])
+                          (get-in column-definitions [col :value]))]
+    (if sort-value-fn
+      (sort-value-fn item)
+      (throw (js/Error. (str "No `:sort-value` or `:value` defined for column \"" col "\""))))))
+
+(defn column-filter-value [column-definitions col item]
+  (if (get-in column-definitions [col :filterable?] true)
+    (let [filter-value-fn (or (get-in column-definitions [col :filter-value])
+                              (get-in column-definitions [col :value]))]
+      (if filter-value-fn
+        (str (filter-value-fn item))
+        (throw (js/Error. (str "No `:filter-value` or `:value` defined for column \"" col "\"")))))
+    ""))
+
+(defn- row [id column-definitions columns item]
+  (into [:tr.action {:id id}]
+        (for [col columns]
+          (into [:td {:class (column-class column-definitions col item)
+                      :data-th (column-header column-definitions col)}]
+                (column-values column-definitions col item)))))
 
 (defn- flip [order]
   (case order
@@ -21,276 +58,205 @@
     (flip old-order)
     :asc))
 
-(rf/reg-event-db
- ::toggle-sorting
- (fn [db [_ table sort-column]]
-   (update-in db [::sorting (:id table)]
-              (fn [sorting]
-                (-> sorting
-                    (assoc :sort-column sort-column)
-                    (assoc :sort-order (change-sort-order (:sort-column sorting)
-                                                          (:sort-order sorting)
-                                                          sort-column)))))))
+(defn- apply-sorting [column-definitions sort-column sort-order items]
+  (sort-by #(column-sort-value column-definitions sort-column %)
+           (case sort-order
+             :desc #(compare %2 %1)
+             #(compare %1 %2))
+           items))
+
+(defn- apply-initial-sorting [column-definitions initial-sort items]
+  (reduce (fn [items {:keys [sort-column sort-order]}]
+            (apply-sorting column-definitions sort-column sort-order items)) items initial-sort))
+
+(defn match-filter? [column-definitions filter-word item]
+  (some (fn [col]
+          (str/includes? (str/lower-case (column-filter-value column-definitions col item))
+                         (str/lower-case filter-word)))
+        (keys column-definitions)))
 
-(rf/reg-sub
- ::sorting
- (fn [db [_ table]]
-   (or (get-in db [::sorting (:id table)])
-       {:sort-order (or (:default-sort-order table)
-                        :asc)
-        :sort-column (or (:default-sort-column table)
-                         (-> table :columns first :key))})))
+(deftest test-match-filter?
+  (is (match-filter? {:id {:value :id}
+                      :description {:value :description}}
+                     "bar"
+                     {:id "foo"
+                      :description "bar"})
+      "matches")
+  (is (not (match-filter? {:id {:value :id}
+                           :description {:value :its-a-trap
+                                         :filter-value :description}}
+                          "willnotmatch"
+                          {:id "foo"
+                           :its-a-trap "willnotmatch"
+                           :description "bar"}))
+      "doesn't match with value if filter-value is provided")
+  (is (not (match-filter? {:id {:value :id}
+                           :description {:value :description
+                                         :filterable? false}}
+                          "shouldnotmatch"
+                          {:id "foo"
+                           :description "shouldnotmatch"}))
+      "doesn't match if filterable is false"))
 
-(rf/reg-event-db
- ::set-filtering
- (fn [db [_ table filtering]]
-   (assoc-in db [::filtering (:id table)] filtering)))
+(defn match-filters? [column-definitions filters item]
+  (every? (fn [filter-word]
+            (match-filter? column-definitions filter-word item))
+          (str/split filters #"\s+")))
 
-(rf/reg-sub
- ::filtering
- (fn [db [_ table]]
-   (get-in db [::filtering (:id table)])))
+(defn apply-filtering [column-definitions filters items]
+  (filter #(match-filters? column-definitions filters %) items))
 
-(defn- default-if-missing [m k make-default]
-  (if (contains? m k)
-    m
-    (assoc m k (make-default m))))
+(s/defschema Applicable
+  (s/cond-pre s/Keyword (s/pred fn?)))
 
-(defn- apply-row-defaults [row]
-  (->> row
-       (map (fn [[column opts]]
-              [column
-               (if (= :key column) ; not a column, but the row key
-                 opts
-                 (-> opts
-                     (default-if-missing :sort-value :value)
-                     (default-if-missing :display-value (comp str :value))
-                     (default-if-missing :filter-value (fn [opts]
-                                                         (if (string? (:display-value opts))
-                                                           (str/lower-case (:display-value opts))
-                                                           "")))
-                     (default-if-missing :td (fn [opts]
-                                               [:td {:class (name column)}
-                                                (:display-value opts)]))))]))
-       (into {})))
+(s/defschema TableParams
+  {:column-definitions {s/Keyword {(s/optional-key :header) s/Any
+                                   (s/optional-key :value) Applicable
+                                   (s/optional-key :values) Applicable
+                                   (s/optional-key :sortable?) s/Bool
+                                   (s/optional-key :sort-value) Applicable
+                                   (s/optional-key :filterable?) s/Bool
+                                   (s/optional-key :filter-value) Applicable
+                                   (s/optional-key :class) (s/cond-pre s/Str Applicable)}}
+   :visible-columns [s/Keyword]
+   (s/optional-key :sorting) {(s/optional-key :initial-sort) [{:sort-column s/Keyword
+                                                               (s/optional-key :sort-order) (s/enum :asc :desc)}]
+                              (s/optional-key :sort-column) s/Keyword
+                              (s/optional-key :sort-order) (s/enum :asc :desc)
+                              (s/optional-key :set-sorting) Applicable}
+   (s/optional-key :filtering) {(s/optional-key :filters) s/Str
+                                (s/optional-key :filters-new) s/Str
+                                (s/optional-key :show-filters) s/Bool
+                                (s/optional-key :set-filtering) Applicable}
+   :id-function Applicable
+   :items [s/Any]
+   (s/optional-key :class) s/Str})
 
-(deftest test-apply-row-defaults
-  (testing "all custom"
-    (is (= {:key 123
-            :foo {:sort-value "foo1"
-                  :display-value "foo2"
-                  :filter-value "foo3"
-                  :td [:td "foo4"]}}
-           (apply-row-defaults {:key 123
-                                :foo {:sort-value "foo1"
-                                      :display-value "foo2"
-                                      :filter-value "foo3"
-                                      :td [:td "foo4"]}}))))
-  (testing "all defaults"
-    (is (= {:key 123
-            :foo {:value 42
-                  :sort-value 42
-                  :display-value "42"
-                  :filter-value "42"
-                  :td [:td {:class "foo"} "42"]}}
-           (apply-row-defaults {:key 123
-                                :foo {:value 42}}))))
-  (testing "component only"
-    (is (= {:key 123
-            :foo {:sort-value nil
-                  :display-value ""
-                  :filter-value ""
-                  :td [:td.foo [:button "Button"]]}}
-           (apply-row-defaults {:key 123
-                                :foo {:td [:td.foo [:button "Button"]]}}))))
-  (testing ":filter-value is normalized to lowercase"
-    (is (= {:key 123
-            :foo {:sort-value ""
-                  :display-value "FooBar"
-                  :filter-value "foobar"
-                  :td [:td ""]}}
-           (apply-row-defaults {:key 123
-                                :foo {:sort-value ""
-                                      :display-value "FooBar"
-                                      :td [:td ""]}}))))
-  (testing "cannot calculate :filter-value from non-string :display-value"
-    (is (= {:key 123
-            :foo {:sort-value ""
-                  :display-value [:p "foo"]
-                  :filter-value ""
-                  :td [:td ""]}}
-           (apply-row-defaults {:key 123
-                                :foo {:sort-value ""
-                                      :display-value [:p "foo"]
-                                      :td [:td ""]}})))))
+(defn- filter-view [filtering]
+  (let [{:keys [filters-new set-filtering]} filtering
+        update-current-filters (fn [event]
+                                 (set-filtering (assoc filtering :filters filters-new)))]
+    [:div.flex-grow-1.d-flex
+     [:input.flex-grow-1
+      {:type "text"
+       :name "table-search"
+       :default-value filters-new
+       :placeholder ""
+       :aria-label (text :t.search/search-parameters)
+       :on-change (fn [event]
+                    (set-filtering (assoc filtering :filters-new (-> event .-target .-value))))
+       :on-blur update-current-filters
+       :on-key-press (fn [event]
+                       (when (= 13 (.-which event)) ; enter
+                         (.preventDefault event)
+                         (update-current-filters event)))}]
+     [:button.btn.btn-primary
+      {:type :button
+       :on-click update-current-filters
+       :aria-label (text :t.search/search)}
+      [search-symbol]]]))
 
-(rf/reg-sub
- ::rows
- (fn [[_ table] _]
-   [(rf/subscribe (:rows table))])
- (fn [[rows] _]
-   (map apply-row-defaults rows)))
+(defn- filter-toggle [{:keys [show-filters set-filtering] :as filtering}]
+  (when filtering
+    [:div.rems-table-search-toggle.d-flex.flex-row-reverse
+     [:button.btn
+      {:type :button
+       :class (if show-filters "btn-secondary" "btn-primary")
+       :aria-label (if show-filters (text :t.search/close-search) (text :t.search/open-search))
+       ;; TODO: focus needs to be moved to the search field after opening it, especially for screen readers
+       :on-click #(set-filtering (-> filtering
+                                     (update :show-filters not)
+                                     (assoc :filters ""
+                                            :filters-new "")))}
+      (if show-filters [close-symbol] [search-symbol])]
+     (when show-filters
+       [filter-view filtering])]))
 
-(rf/reg-sub
- ::sorted-rows
- (fn [[_ table] _]
-   [(rf/subscribe [::rows table])
-    (rf/subscribe [::sorting table])])
- (fn [[rows sorting] _]
-   (->> rows
-        (sort-by #(get-in % [(:sort-column sorting) :sort-value])
-                 (case (:sort-order sorting)
-                   :desc #(compare %2 %1)
-                   #(compare %1 %2))))))
+(defn- column-header-view [column column-definitions sorting]
+  (let [{:keys [sort-column sort-order set-sorting]} sorting
+        sortable? (get-in column-definitions [column :sortable?] true)]
+    [:th
+     (when (and sortable? set-sorting)
+       {:on-click (fn []
+                    (set-sorting (-> sorting
+                                     (assoc :sort-column column)
+                                     (assoc :sort-order (change-sort-order sort-column sort-order column)))))})
+     (column-header column-definitions column)
+     " "
+     (when (= column sort-column)
+       [sort-symbol sort-order])]))
 
-(defn- sortable? [column]
-  (:sortable? column true))
+(defn- head [{:keys [column-definitions visible-columns sorting filtering id-function items class] :as params}]
+  [:thead
+   (into [:tr]
+         (for [column visible-columns]
+           [column-header-view column column-definitions sorting]))])
 
-(defn- filterable? [column]
-  (:filterable? column true))
+(defn- comparable-params [params]
+  (let [params-without-fns (-> params
+                               (dissoc :id-function)
+                               (dissoc :column-definitions)
+                               (update :filtering dissoc :set-filtering :filters-new)
+                               (update :sorting dissoc :set-sorting))]
+    [(dissoc params-without-fns :items)
+     (:items params)]))
 
-(defn- display-row? [row filtered-columns filters]
-  (if (empty? filtered-columns)
-    true ; table has no filtering enabled
-    (some (fn [column]
-            (str/includes? (str (get-in row [(:key column) :filter-value]))
-                           filters))
-          filtered-columns)))
+(defn- body
+  "Create a Form 3 component because we don't want to compare potentially anonymous callback fns in the params. It would cause us to re-render every time."
+  [params]
+  (reagent/create-class
+   {:display-name "table-body"
+    :should-component-update (fn [this [_ old] [_ new]]
+                               (not= (comparable-params old)
+                                     (comparable-params new)))
+    :reagent-render (fn [{:keys [column-definitions visible-columns sorting filtering id-function items class]}]
+                      (let [{:keys [initial-sort sort-column sort-order]} sorting
+                            {:keys [show-filters filters]} filtering]
+                        [:tbody
+                         (map (fn [item] ^{:key (id-function item)} [row (id-function item) column-definitions visible-columns item])
+                              (cond->> items
+                                (and initial-sort (not sort-column)) (apply-initial-sorting column-definitions initial-sort)
+                                (and show-filters filters) (apply-filtering column-definitions filters)
+                                (and sorting sort-column) (apply-sorting column-definitions sort-column sort-order)))]))}))
 
-(rf/reg-sub
- ::sorted-and-filtered-rows
- (fn [[_ table] _]
-   [(rf/subscribe [::sorted-rows table])
-    (rf/subscribe [::filtering table])])
- (fn [[rows filtering] [_ table]]
-   (let [filters (str/lower-case (str (:filters filtering)))
-         columns (->> (:columns table)
-                      (filter filterable?))]
-     (->> rows
-          (map (fn [row]
-                 ;; performance optimization: hide DOM nodes instead of destroying them
-                 (assoc row ::display-row? (display-row? row columns filters))))))))
+(defn component
+  "Table component.
 
-(defn search [table]
-  (let [filtering @(rf/subscribe [::filtering table])
-        on-search (fn [event]
-                    (rf/dispatch [::set-filtering table (-> filtering
-                                                            (assoc :filters (-> event .-target .-value)))]))
-        ;; TODO: focus needs to be moved to the search field after opening it, especially for screen readers
-        on-toggle (fn [_event]
-                    (rf/dispatch [::set-filtering table (-> filtering
-                                                            (update :show-filters not)
-                                                            (assoc :filters ""))]))]
-    (if (:show-filters filtering)
-      [:div.rems-table-search-toggle.d-flex.flex-row
-       [:div.flex-grow-1.d-flex
-        [:input.flex-grow-1 {:type :text
-                             :default-value (:filters filtering)
-                             :aria-label (text :t.search/search-parameters)
-                             :on-change on-search}]]
-       [:button.btn.btn-secondary {:type :button
-                                   :aria-label (text :t.search/close-search)
-                                   :on-click on-toggle}
-        [close-symbol]]]
-      [:div.rems-table-search-toggle.d-flex.flex-row-reverse
-       [:button.btn.btn-primary {:type :button
-                                 :aria-label (text :t.search/open-search)
-                                 :on-click on-toggle}
-        [search-symbol]]])))
+  `:column-definitions` - a map like {:column-id definition} where definition is
+    `:header`           - (fn [item] ...) column header component
+    `:value`            - (fn [item] ...) single value component
+    `:values`           - (fn [item] ...) sequence of value components
+    `:sortable?`        - is the column sortable?
+    `:sort-value`       - (fn [item] ...) value to sort by
+    `:filterable?`      - is the column filterable?
+    `:filter-value`     - (fn [item] ...) value to filter by
+    `:class`            - class for the column (defaults to column name),
+                          can be (fn [item] ...)
 
-(defn- table-header [table]
-  (let [sorting @(rf/subscribe [::sorting table])]
-    (into [:tr]
-          (for [column (:columns table)]
-            [:th
-             (when (sortable? column)
-               {:on-click #(rf/dispatch [::toggle-sorting table (:key column)])})
-             (:title column)
-             " "
-             (when (sortable? column)
-               (when (= (:key column) (:sort-column sorting))
-                 [sort-symbol (:sort-order sorting)]))]))))
+  `:visible-columns`    - a sequence of keys that occur in column-definitions
 
-(defn- table-row [row table]
-  (into [:tr {:data-row (:key row)
-              ;; performance optimization: hide DOM nodes instead of destroying them
-              :style {:display (if (::display-row? row)
-                                 "table-row"
-                                 "none")}}]
-        (for [column (:columns table)]
-          (get-in row [(:key column) :td]))))
+  `:sorting`            - sorting options map with keys
+    `:initial-sort`     - seq of {`:sort-column` :xxx} and optionally `:sort-order` to initially sort by
+    `:sort-column`      - the column to sort by
+    `:sort-order`       - direction of sort (`:asc` or `:desc`)
+    `:set-sorting`      - callback that is called when sorting changes
 
-(defn table [table]
-  (let [rows @(rf/subscribe [::sorted-and-filtered-rows table])
-        language @(rf/subscribe [:language])]
-    [:div.table-border
-     [:table.rems-table {:class (:id table)}
-      [:thead
-       [table-header table]]
-      [:tbody {:key language} ; performance optimization: rebuild instead of update existing components
-       (for [row rows]
-         ^{:key (:key row)} [table-row row table])]]]))
+  `:filtering`          - filtering options that can have
+    `:filters`          - the current filter values
+    `:filters-new`      - the new filter values (internal)
+    `:show-filters`     - are the filters currently visible?
+    `:set-filtering`    - callback that is called when filtering changes
 
-(defn guide []
-  (rf/reg-sub
-   ::example-table-rows
-   (fn [_ _]
-     (->> [{:id 1
-            :first-name "Cody"
-            :last-name "Turner"}
-           {:id 2
-            :first-name "Melanie"
-            :last-name "Palmer"}
-           {:id 3
-            :first-name "Henry"
-            :last-name "Herring"}
-           {:id 4
-            :first-name "Reagan"
-            :last-name "Melton"}]
-          (map (fn [person]
-                 (let [{:keys [id first-name last-name]} person]
-                   {:key id
-                    :first-name {:value first-name}
-                    :last-name {:value last-name}
-                    :commands {:td [:td.commands
-                                    [:button.btn.btn-primary
-                                     {:type :button
-                                      :on-click (fn [] (js/alert (str "View user " id)))}
-                                     "View"]
-                                    [:button.btn.btn-secondary
-                                     {:type :button
-                                      :on-click (fn [] (js/alert (str "Delete user " id)))}
-                                     "Delete"]]}}))))))
+  `:id-function`        - function for setting unique React key for row
+  `:items`              - sequence of items to render
+  `:class`              - class for the table
 
+  See also `TableParams`."
+  [{:keys [column-definitions visible-columns sorting filtering id-function items class] :as params}]
+  {:pre [(s/validate TableParams params)]}
   [:div
-   (component-info table)
-   (example "static table"
-            (let [example1 {:id ::example1
-                            :columns [{:key :first-name
-                                       :title "First name"
-                                       :sortable? false
-                                       :filterable? false}
-                                      {:key :last-name
-                                       :title "Last name"
-                                       :sortable? false
-                                       :filterable? false}
-                                      {:key :commands
-                                       :sortable? false
-                                       :filterable? false}]
-                            :rows [::example-table-rows]
-                            :default-sort-column :first-name}]
-              [table example1]))
-   (example "sortable and filterable table"
-            (let [example2 {:id ::example2
-                            :columns [{:key :first-name
-                                       :title "First name"}
-                                      {:key :last-name
-                                       :title "Last name"}
-                                      {:key :commands
-                                       :sortable? false
-                                       :filterable? false}]
-                            :rows [::example-table-rows]
-                            :default-sort-column :first-name}]
-              [:div
-               [search example2]
-               [table example2]]))])
+   [filter-toggle filtering]
+   [:div.table-border
+    [:table.rems-table (when class {:class class})
+     [head params]
+     [body params]]]])
diff --git a/src/cljs/rems/table2.cljs b/src/cljs/rems/table2.cljs
new file mode 100644
index 000000000..4c398a60c
--- /dev/null
+++ b/src/cljs/rems/table2.cljs
@@ -0,0 +1,215 @@
+(ns rems.table2
+  (:require [clojure.string :as str]
+            [re-frame.core :as rf]
+            [rems.atoms :refer [close-symbol search-symbol sort-symbol]]
+            [rems.text :refer [text]])
+  (:require-macros [rems.guide-macros :refer [component-info example]]))
+
+(rf/reg-event-db
+ ::reset
+ (fn [db _]
+   (dissoc db ::sorting ::filtering)))
+
+(defn- flip [order]
+  (case order
+    :desc :asc
+    :desc))
+
+(defn- change-sort-order [old-column old-order new-column]
+  (if (= old-column new-column)
+    (flip old-order)
+    :asc))
+
+(rf/reg-event-db
+ ::toggle-sorting
+ (fn [db [_ table sort-column]]
+   (update-in db [::sorting (:id table)]
+              (fn [sorting]
+                (-> sorting
+                    (assoc :sort-column sort-column)
+                    (assoc :sort-order (change-sort-order (:sort-column sorting)
+                                                          (:sort-order sorting)
+                                                          sort-column)))))))
+
+(rf/reg-sub
+ ::sorting
+ (fn [db [_ table]]
+   (or (get-in db [::sorting (:id table)])
+       {:sort-order (or (:default-sort-order table)
+                        :asc)
+        :sort-column (or (:default-sort-column table)
+                         (-> table :columns first :key))})))
+
+(rf/reg-event-db
+ ::set-filtering
+ (fn [db [_ table filtering]]
+   (assoc-in db [::filtering (:id table)] filtering)))
+
+(rf/reg-sub
+ ::filtering
+ (fn [db [_ table]]
+   (get-in db [::filtering (:id table)])))
+
+(rf/reg-sub
+ ::sorted-rows
+ (fn [[_ table] _]
+   [(rf/subscribe (:rows table))
+    (rf/subscribe [::sorting table])])
+ (fn [[rows sorting] _]
+   (->> rows
+        (sort-by #(get-in % [(:sort-column sorting) :sort-value])
+                 (case (:sort-order sorting)
+                   :desc #(compare %2 %1)
+                   #(compare %1 %2))))))
+
+(defn- sortable? [column]
+  (:sortable? column true))
+
+(defn- filterable? [column]
+  (:filterable? column true))
+
+(defn- display-row? [row filtered-columns filters]
+  (if (empty? filtered-columns)
+    true ; table has no filtering enabled
+    (some (fn [column]
+            (str/includes? (str (get-in row [(:key column) :filter-value]))
+                           filters))
+          filtered-columns)))
+
+(rf/reg-sub
+ ::sorted-and-filtered-rows
+ (fn [[_ table] _]
+   [(rf/subscribe [::sorted-rows table])
+    (rf/subscribe [::filtering table])])
+ (fn [[rows filtering] [_ table]]
+   (let [filters (str/lower-case (str (:filters filtering)))
+         columns (->> (:columns table)
+                      (filter filterable?))]
+     (->> rows
+          (map (fn [row]
+                 ;; performance optimization: hide DOM nodes instead of destroying them
+                 (assoc row ::display-row? (display-row? row columns filters))))))))
+
+(defn search [table]
+  (let [filtering @(rf/subscribe [::filtering table])
+        on-search (fn [event]
+                    (rf/dispatch [::set-filtering table (-> filtering
+                                                            (assoc :filters (-> event .-target .-value)))]))
+        ;; TODO: focus needs to be moved to the search field after opening it, especially for screen readers
+        on-toggle (fn [_event]
+                    (rf/dispatch [::set-filtering table (-> filtering
+                                                            (update :show-filters not)
+                                                            (assoc :filters ""))]))]
+    (if (:show-filters filtering)
+      [:div.rems-table-search-toggle.d-flex.flex-row
+       [:div.flex-grow-1.d-flex
+        [:input.flex-grow-1 {:type :text
+                             :default-value (:filters filtering)
+                             :aria-label (text :t.search/search-parameters)
+                             :on-change on-search}]]
+       [:button.btn.btn-secondary {:type :button
+                                   :aria-label (text :t.search/close-search)
+                                   :on-click on-toggle}
+        [close-symbol]]]
+      [:div.rems-table-search-toggle.d-flex.flex-row-reverse
+       [:button.btn.btn-primary {:type :button
+                                 :aria-label (text :t.search/open-search)
+                                 :on-click on-toggle}
+        [search-symbol]]])))
+
+(defn- table-header [table]
+  (let [sorting @(rf/subscribe [::sorting table])]
+    (into [:tr]
+          (for [column (:columns table)]
+            [:th
+             (when (sortable? column)
+               {:on-click #(rf/dispatch [::toggle-sorting table (:key column)])})
+             (:title column)
+             " "
+             (when (sortable? column)
+               (when (= (:key column) (:sort-column sorting))
+                 [sort-symbol (:sort-order sorting)]))]))))
+
+(defn- table-row [row table]
+  (into [:tr {:data-row (:key row)
+              ;; performance optimization: hide DOM nodes instead of destroying them
+              :style {:display (if (::display-row? row)
+                                 "table-row"
+                                 "none")}}]
+        (for [column (:columns table)]
+          (get-in row [(:key column) :td]))))
+
+(defn table [table]
+  (let [rows @(rf/subscribe [::sorted-and-filtered-rows table])
+        language @(rf/subscribe [:language])]
+    [:div.table-border
+     [:table.rems-table {:class (:id table)}
+      [:thead
+       [table-header table]]
+      [:tbody {:key language} ; performance optimization: rebuild instead of update existing components
+       (for [row rows]
+         ^{:key (:key row)} [table-row row table])]]]))
+
+(defn guide []
+  (rf/reg-sub
+   ::example-table-rows
+   (fn [_ _]
+     (->> [{:id 1
+            :first-name "Cody"
+            :last-name "Turner"}
+           {:id 2
+            :first-name "Melanie"
+            :last-name "Palmer"}
+           {:id 3
+            :first-name "Henry"
+            :last-name "Herring"}
+           {:id 4
+            :first-name "Reagan"
+            :last-name "Melton"}]
+          (map (fn [person]
+                 (let [{:keys [id first-name last-name]} person]
+                   {:key id
+                    :first-name {:td [:td.first-name first-name]
+                                 :sort-value first-name
+                                 :filter-value (str/lower-case first-name)}
+                    :last-name {:td [:td.last-name last-name]
+                                :sort-value last-name
+                                :filter-value (str/lower-case last-name)}
+                    :commands {:td [:td.commands
+                                    [:button.btn.btn-primary
+                                     {:type :button
+                                      :on-click (fn [] (js/alert (str "View user " id)))}
+                                     "View"]
+                                    [:button.btn.btn-secondary
+                                     {:type :button
+                                      :on-click (fn [] (js/alert (str "Delete user " id)))}
+                                     "Delete"]]}}))))))
+
+  [:div
+   (component-info table)
+   (example "static table"
+            (let [example1 {:id ::example1
+                            :columns [{:key :first-name
+                                       :title "First name"
+                                       :sortable? false
+                                       :filterable? false}
+                                      {:key :last-name
+                                       :title "Last name"
+                                       :sortable? false
+                                       :filterable? false}
+                                      {:key :commands}]
+                            :rows [::example-table-rows]
+                            :default-sort-column :first-name}]
+              [table example1]))
+   (example "sortable and filterable table"
+            (let [example2 {:id ::example2
+                            :columns [{:key :first-name
+                                       :title "First name"}
+                                      {:key :last-name
+                                       :title "Last name"}
+                                      {:key :commands}]
+                            :rows [::example-table-rows]
+                            :default-sort-column :first-name}]
+              [:div
+               [search example2]
+               [table example2]]))])


Test patch:
diff --git a/test/clj/rems/api/test_forms.clj b/test/clj/rems/api/test_forms.clj
index 996267c0d..bee43e783 100644
--- a/test/clj/rems/api/test_forms.clj
+++ b/test/clj/rems/api/test_forms.clj
@@ -69,7 +69,7 @@
                   (is (= (select-keys command [:title :organization])
                          (select-keys form-template [:title :organization])))
                   (is (= (:fields command)
-                         (:fields form-template))))))))))))
+                         (mapv #(dissoc % :id) (:fields form-template)))))))))))))
 
 (deftest forms-api-all-field-types-test
   (let [api-key "42"
@@ -123,7 +123,10 @@
                          (authenticate api-key user-id)
                          handler
                          read-ok-body)]
-            (is (= form-spec (select-keys form [:organization :title :fields])))))))))
+            (is (= (select-keys form-spec [:organization :title])
+                   (select-keys form [:organization :title])))
+            (is (= (:fields form-spec)
+                   (mapv #(dissoc % :id) (:fields form))))))))))
 
 (deftest form-update-test
   (let [api-key "42"
@@ -196,7 +199,7 @@
                          handler
                          read-ok-body)]
             (is (= (:fields command)
-                   (:fields form)))))))))
+                   (mapv #(dissoc % :id) (:fields form))))))))))
 
 (deftest forms-api-filtering-test
   (let [unfiltered (-> (request :get "/api/forms" {:expired true})
diff --git a/test/clj/rems/application/test_commands.clj b/test/clj/rems/application/test_commands.clj
index b639865bf..f736a8be4 100644
--- a/test/clj/rems/application/test_commands.clj
+++ b/test/clj/rems/application/test_commands.clj
@@ -33,7 +33,7 @@
 
 (def ^:private injections
   {:get-workflow dummy-workflows
-   :get-form dummy-forms
+   :get-form-template dummy-forms
    :get-catalogue-item (constantly nil)
    :get-license (constantly nil)
    :get-user (constantly nil)
diff --git a/test/clj/rems/application/test_model.clj b/test/clj/rems/application/test_model.clj
index ba6b8643a..41d4ce7cb 100644
--- a/test/clj/rems/application/test_model.clj
+++ b/test/clj/rems/application/test_model.clj
@@ -7,28 +7,24 @@
   (:import [java.util UUID]
            [org.joda.time DateTime]))
 
-(def ^:private get-form
+(def ^:private get-form-template
   {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"}]
+       :fields [{:id 41
+                 :title {:en "en title" :fi "fi title"}
+                 :input-prompt {:en "en placeholder" :fi "fi placeholder"}
+                 :optional false
+                 :options []
+                 :maxlength 100
+                 :type "description"}
+                {:id 42
+                 :title {:en "en title" :fi "fi title"}
+                 :input-prompt {:en "en placeholder" :fi "fi placeholder"}
+                 :optional false
+                 :options []
+                 :maxlength 100
+                 :type "text"}]
        :start (DateTime. 100)
        :end nil}})
 
@@ -179,7 +175,7 @@
   [])
 
 (deftest test-application-view
-  (let [injections {:get-form get-form
+  (let [injections {:get-form-template get-form-template
                     :get-catalogue-item get-catalogue-item
                     :get-license get-license
                     :get-user get-user
diff --git a/test/clj/rems/poller/test_email.clj b/test/clj/rems/poller/test_email.clj
index e146c0171..5f097742f 100644
--- a/test/clj/rems/poller/test_email.clj
+++ b/test/clj/rems/poller/test_email.clj
@@ -38,7 +38,7 @@
   (let [application (-> (reduce model/application-view nil events)
                         (model/enrich-with-injections {:get-workflow get-workflow
                                                        :get-catalogue-item get-catalogue-item
-                                                       :get-form get-nothing
+                                                       :get-form-template get-nothing
                                                        :get-license get-nothing
                                                        :get-user get-nothing
                                                        :get-users-with-role get-nothing
diff --git a/test/cljs/rems/cljs_tests.cljs b/test/cljs/rems/cljs_tests.cljs
index 559bcfbcc..2876c005d 100644
--- a/test/cljs/rems/cljs_tests.cljs
+++ b/test/cljs/rems/cljs_tests.cljs
@@ -6,7 +6,6 @@
             rems.administration.test-create-resource
             rems.administration.test-create-workflow
             rems.administration.test-items
-            rems.table
             rems.test-fields
             rems.test-util))
 
@@ -16,6 +15,5 @@
            'rems.administration.test-create-resource
            'rems.administration.test-create-workflow
            'rems.administration.test-items
-           'rems.table
            'rems.test-fields
            'rems.test-util)