Repository: CSCfi/rems
Pull request #1619 (2019-09-12 11:56:40)

Flash message improvements, remove modal
#1580

# Definition of Done / Review checklist

## Reviewability
- [x] link to issue



Patch:
diff --git a/resources/translations/en.edn b/resources/translations/en.edn
index 37a2f6e4e..47509111c 100644
--- a/resources/translations/en.edn
+++ b/resources/translations/en.edn
@@ -1,7 +1,7 @@
 {:t
  {:actions {:accept-invitation "Accept invitation"
-            :accept-invitation-success [:div [:p "Successfully joined the application."] [:p "Proceed to the application."]]
-            :accept-invitation-already-member [:div [:p "You are already a member."] [:p "Proceed to the application."]]
+            :accept-invitation-success "Successfully joined the application."
+            :accept-invitation-already-member "You are already a member."
             :accept-licenses "Accept terms of use"
             :add-licenses "Add terms of use"
             :add-member "Add member"
@@ -27,7 +27,6 @@
             :member-email "Email"
             :name-and-email-required "Name and email are required."
             :no-handled-yet "No processed applications."
-            :ok "OK"
             :todo-applications "Open applications"
             :reject "Reject"
             :remark "Remark"
@@ -241,7 +240,7 @@
           :comment-requested {:subject "Review request (%3)"
                               :message "Dear %1,\n\n%2 has requested your review on application %3 submitted by %4.\n\nYou can review the application: %6"}
           :commented {:subject "Application has been reviewed (%3)"
-                     :message "Dear %1,\n\n%2 has reviewed the application %3 submitted by %4.\n\nYou can view the application and the review: %6"}
+                      :message "Dear %1,\n\n%2 has reviewed the application %3 submitted by %4.\n\nYou can view the application and the review: %6"}
           :decided {:subject "Decision notification (%3)"
                     :message "Dear %1,\n\n%2 has sent a decision on application %3 submitted by %4.\n\nYou can view the application and the decision: %6"}
           :decision-requested {:subject "Decision requested (%3)"
@@ -306,7 +305,6 @@
           :text "Login with Haka credentials"
           :title "Login"}
   :missing "Missing translation %1"
-  :modal {:close-dialog "Close dialog"}
   :navigation {:actions "Actions"
                :administration "Administration"
                :applications "Applications"
diff --git a/resources/translations/fi.edn b/resources/translations/fi.edn
index 526e64a29..5bd9816a0 100644
--- a/resources/translations/fi.edn
+++ b/resources/translations/fi.edn
@@ -1,7 +1,7 @@
 {:t
  {:actions {:accept-invitation "Hyväksy kutsu"
-            :accept-invitation-success [:div [:p "Hakemukseen liittyminen onnistui."] [:p "Siirry hakemukseen."]]
-            :accept-invitation-already-member [:div. [:p "Olet jo jäsen."] [:p "Siirry hakemukseen."]]
+            :accept-invitation-success "Hakemukseen liittyminen onnistui."
+            :accept-invitation-already-member "Olet jo jäsen."
             :accept-licenses "Hyväksy käyttöehdot"
             :add-licenses "Lisää käyttöehto"
             :add-member "Lisää jäsen"
@@ -27,7 +27,6 @@
             :member-email "Sähköposti"
             :name-and-email-required "Nimi ja sähköposti ovat pakollisia."
             :no-handled-yet "Ei käsiteltyjä hakemuksia."
-            :ok "OK"
             :todo-applications "Avoimet hakemukset"
             :reject "Hylkää"
             :remark "Kirjaa huomio"
@@ -304,7 +303,6 @@
           :text "Kirjaudu sisään Haka-tunnuksin"
           :title "Kirjaudu sisään"}
   :missing "Käännös puuttuu %1"
-  :modal {:close-dialog "Sulje ilmoitus"}
   :navigation {:actions "Toiminnot"
                :administration "Ylläpito"
                :applications "Hakemukset"
diff --git a/src/clj/rems/application/commands.clj b/src/clj/rems/application/commands.clj
index 3d3bed8c6..d418a60ae 100644
--- a/src/clj/rems/application/commands.clj
+++ b/src/clj/rems/application/commands.clj
@@ -203,7 +203,7 @@
                      :when (or (not (getx item :catalogue-item/enabled))
                                (getx item :catalogue-item/archived)
                                (getx item :catalogue-item/expired))]
-        {:type :disabled-catalogue-item :catalogue-item-id (getx item :catalogue-item/id)})]
+                 {:type :disabled-catalogue-item :catalogue-item-id (getx item :catalogue-item/id)})]
     (when (not (empty? errors))
       {:errors (vec errors)})))
 
@@ -268,7 +268,7 @@
 
 (defn already-member-error [application userid]
   (when (contains? (all-members application) userid)
-    {:errors [{:type :already-member :application-id (:id application)}]}))
+    {:errors [{:type :already-member :application-id (:application/id application)}]}))
 
 (defn- ok-with-data [data events]
   (assoc data :events events))
diff --git a/src/cljc/rems/common_util.cljc b/src/cljc/rems/common_util.cljc
index 881cbee89..f067620de 100644
--- a/src/cljc/rems/common_util.cljc
+++ b/src/cljc/rems/common_util.cljc
@@ -13,16 +13,9 @@
 
 (defn index-by
   "Index the collection coll with given keys `ks`.
-  Result is a nested map, `(count ks)` levels deep, e.g.
 
-    (index-by [:a :b] [{:a 1 :b \"x\"} {:a 1 :b \"y\"}])
-      ==> {1 {\"x\" {:a 1 :b \"x\"}
-              \"y\" {:a 1 :b \"y\"}}}
-
-  In case of non-unique keys, index-by picks the first value, e.g.
-
-    (index-by [:a] [{:a 1 :b \"x\"} {:a 1 :b \"y\"}])
-      ==> {1 {:a 1 :b \"x\"}}"
+  Result is a map indexed by the first key
+  that contains a map indexed by the second key."
   [ks coll]
   (if (empty? ks)
     (first coll)
@@ -31,13 +24,6 @@
          (map (fn [[k v]] [k (index-by (rest ks) v)]))
          (into {}))))
 
-(deftest test-index-by
-  (is (= {1 {"x" {:a 1 :b "x"}
-             "y" {:a 1 :b "y"}}}
-         (index-by [:a :b] [{:a 1 :b "x"} {:a 1 :b "y"}])))
-  (is (= {false 1 true 2}
-         (index-by [even?] [1 2 3 4]))))
-
 (defn distinct-by
   "Remove duplicates from sequence, comparing the value returned by key-fn.
    The first element that key-fn returns a given value for is retained.
diff --git a/src/cljs/rems/actions.cljs b/src/cljs/rems/actions.cljs
index e3cdb67a2..ed92ab8ef 100644
--- a/src/cljs/rems/actions.cljs
+++ b/src/cljs/rems/actions.cljs
@@ -4,6 +4,7 @@
             [rems.application-list :as application-list]
             [rems.atoms :refer [document-title]]
             [rems.collapsible :as collapsible]
+            [rems.flash-message :as flash-message]
             [rems.guide-functions]
             [rems.search :as search]
             [rems.text :refer [text]]))
@@ -52,6 +53,7 @@
 (defn actions-page []
   [:div
    [document-title (text :t.navigation/actions)]
+   [flash-message/component :top]
    [:div.spaced-sections
     [collapsible/component
      {:id "todo-applications"
diff --git a/src/cljs/rems/actions/accept_invitation.cljs b/src/cljs/rems/actions/accept_invitation.cljs
index 0bc5972e9..bdcb74aad 100644
--- a/src/cljs/rems/actions/accept_invitation.cljs
+++ b/src/cljs/rems/actions/accept_invitation.cljs
@@ -1,52 +1,48 @@
 (ns rems.actions.accept-invitation
   (:require [re-frame.core :as rf]
             [rems.atoms :refer [document-title]]
+            [rems.flash-message :as flash-message]
             [rems.spinner :as spinner]
-            [rems.status-modal :as status-modal]
             [rems.text :refer [text text-format]]
             [rems.util :refer [dispatch! post!]]))
 
 (rf/reg-event-fx
  ::enter-page
  (fn [{:keys [db]} [_ token]]
-   (status-modal/common-pending-handler! (text :t.actions/accept-invitation))
    {:db (assoc db ::token token)
     ::accept-invitation token}))
 
 (rf/reg-sub ::token (fn [db] (::token db "")))
 
-(defn- errors-to-content [errors]
-  [:div (for [{:keys [type token]} errors]
-          [:p
-           (case type
-             :t.actions.errors/invalid-token (text-format :t.actions.errors/invalid-token token)
-             (text type))])])
-
-(defn- error-handler [response]
-  (status-modal/set-error!
-   (merge {:on-close #(dispatch! "#/catalogue")}
-          (if (:error response)
-            {:result {:error response}}
-            {:error-content (errors-to-content (:errors response))}))))
-
-(defn- success-handler [response]
-  (cond (:success response)
-        (status-modal/set-success! {:content (text :t.actions/accept-invitation-success)
-                                    :on-close #(dispatch! (str "#/application/" (:application-id response)))})
-
-        (= :already-member (:type (first (:errors response))))
-        (status-modal/set-success! {:content (text :t.actions/accept-invitation-already-member)
-                                    :on-close #(dispatch! (str "#/application/" (:application-id (first (:errors response)))))})
-
-        :else (error-handler response)))
-
 (rf/reg-fx
  ::accept-invitation
  (fn [token]
-   (post! "/api/applications/accept-invitation"
-          {:url-params {:invitation-token token}
-           :handler success-handler
-           :error-handler error-handler})))
+   (let [error-handler (fn [response]
+                         ((flash-message/default-error-handler :top (text :t.actions/accept-invitation))
+                          response)
+                         (dispatch! "#/catalogue"))]
+     (post! "/api/applications/accept-invitation"
+            {:url-params {:invitation-token token}
+             :handler (fn [response]
+                        (let [error (first (:errors response))]
+                          (cond
+                            (:success response)
+                            (do
+                              (flash-message/show-success! :top (text :t.actions/accept-invitation-success))
+                              (dispatch! (str "#/application/" (:application-id response))))
+
+                            (= :already-member (:type error))
+                            (do
+                              (flash-message/show-success! :top (text :t.actions/accept-invitation-already-member))
+                              (dispatch! (str "#/application/" (:application-id error))))
+
+                            (= :t.actions.errors/invalid-token (:type error))
+                            (do
+                              (flash-message/show-error! :top (text-format :t.actions.errors/invalid-token (:token error)))
+                              (dispatch! "#/catalogue"))
+
+                            :else (error-handler response))))
+             :error-handler error-handler}))))
 
 (defn accept-invitation-page []
   [:div
diff --git a/src/cljs/rems/actions/invite_member.cljs b/src/cljs/rems/actions/invite_member.cljs
index e723198eb..096c63111 100644
--- a/src/cljs/rems/actions/invite_member.cljs
+++ b/src/cljs/rems/actions/invite_member.cljs
@@ -2,7 +2,6 @@
   (:require [re-frame.core :as rf]
             [rems.actions.action :refer [action-button action-form-view button-wrapper collapse-action-form]]
             [rems.flash-message :as flash-message]
-            [rems.status-modal :as status-modal]
             [rems.text :refer [text]]
             [rems.util :refer [post!]]))
 
@@ -31,7 +30,7 @@
  (fn [_ [_ {:keys [member application-id on-finished]}]]
    (let [description (text :t.actions/invite-member)]
      (if-let [errors (validate-member member)]
-       (flash-message/show-error! :invite-member-errors (status-modal/format-errors errors))
+       (flash-message/show-error! :invite-member-errors (flash-message/format-errors errors))
        (post! "/api/applications/invite-member"
               {:params {:application-id application-id
                         :member member}
diff --git a/src/cljs/rems/administration/administration.cljs b/src/cljs/rems/administration/administration.cljs
index 9d48dc234..636a4b3b4 100644
--- a/src/cljs/rems/administration/administration.cljs
+++ b/src/cljs/rems/administration/administration.cljs
@@ -1,6 +1,7 @@
 (ns rems.administration.administration
   (:require [re-frame.core :as rf]
             [rems.atoms :as atoms :refer [document-title]]
+            [rems.flash-message :as flash-message]
             [rems.spinner :as spinner]
             [rems.text :refer [text]]
             [rems.util :refer [dispatch!]])
@@ -79,6 +80,7 @@
       [:div
        [administration-navigator-container]
        [document-title (text :t.navigation/administration)]
+       [flash-message/component :top]
        (if @loading?
          [spinner/big]
          (text :t.administration/intro))])))
diff --git a/src/cljs/rems/administration/catalogue_items.cljs b/src/cljs/rems/administration/catalogue_items.cljs
index 7debdb687..e069262fb 100644
--- a/src/cljs/rems/administration/catalogue_items.cljs
+++ b/src/cljs/rems/administration/catalogue_items.cljs
@@ -6,7 +6,6 @@
             [rems.atoms :as atoms :refer [readonly-checkbox document-title]]
             [rems.flash-message :as flash-message]
             [rems.spinner :as spinner]
-            [rems.status-modal :as status-modal]
             [rems.table :as table]
             [rems.text :refer [localize-time text get-localized-title]]
             [rems.util :refer [dispatch! fetch put!]]))
@@ -21,12 +20,14 @@
 (rf/reg-event-fx
  ::fetch-catalogue
  (fn [{:keys [db]}]
-   (fetch "/api/catalogue-items/" {:url-params {:expand :names
-                                                :disabled true
-                                                :expired (::display-old? db)
-                                                :archived (::display-old? db)}
-                                   :handler #(rf/dispatch [::fetch-catalogue-result %])
-                                   :error-handler status-modal/common-error-handler!})
+   (let [description (text :t.administration/catalogue-items)]
+     (fetch "/api/catalogue-items/"
+            {:url-params {:expand :names
+                          :disabled true
+                          :expired (::display-old? db)
+                          :archived (::display-old? db)}
+             :handler #(rf/dispatch [::fetch-catalogue-result %])
+             :error-handler (flash-message/default-error-handler :top description)}))
    {:db (assoc db ::loading? true)}))
 
 (rf/reg-event-db
diff --git a/src/cljs/rems/administration/forms.cljs b/src/cljs/rems/administration/forms.cljs
index ea0d7c2b6..c6bac0896 100644
--- a/src/cljs/rems/administration/forms.cljs
+++ b/src/cljs/rems/administration/forms.cljs
@@ -20,10 +20,13 @@
 (rf/reg-event-db
  ::fetch-forms
  (fn [db]
-   (fetch "/api/forms/" {:url-params {:disabled true
-                                      :expired (::display-old? db)
-                                      :archived (::display-old? db)}
-                         :handler #(rf/dispatch [::fetch-forms-result %])})
+   (let [description (text :t.administration/forms)]
+     (fetch "/api/forms/"
+            {:url-params {:disabled true
+                          :expired (::display-old? db)
+                          :archived (::display-old? db)}
+             :handler #(rf/dispatch [::fetch-forms-result %])
+             :error-handler (flash-message/default-error-handler :top description)}))
    (assoc db ::loading? true)))
 
 (rf/reg-event-db
diff --git a/src/cljs/rems/administration/licenses.cljs b/src/cljs/rems/administration/licenses.cljs
index e2a7e7e26..041cd19ec 100644
--- a/src/cljs/rems/administration/licenses.cljs
+++ b/src/cljs/rems/administration/licenses.cljs
@@ -19,10 +19,13 @@
 (rf/reg-event-db
  ::fetch-licenses
  (fn [db]
-   (fetch "/api/licenses/" {:url-params {:disabled true
-                                         :expired (::display-old? db)
-                                         :archived (::display-old? db)}
-                            :handler #(rf/dispatch [::fetch-licenses-result %])})
+   (let [description (text :t.administration/licenses)]
+     (fetch "/api/licenses/"
+            {:url-params {:disabled true
+                          :expired (::display-old? db)
+                          :archived (::display-old? db)}
+             :handler #(rf/dispatch [::fetch-licenses-result %])
+             :error-handler (flash-message/default-error-handler :top description)}))
    (assoc db ::loading? true)))
 
 (rf/reg-event-db
diff --git a/src/cljs/rems/administration/resources.cljs b/src/cljs/rems/administration/resources.cljs
index b90a371a4..4fc001f53 100644
--- a/src/cljs/rems/administration/resources.cljs
+++ b/src/cljs/rems/administration/resources.cljs
@@ -5,7 +5,6 @@
             [rems.atoms :as atoms :refer [readonly-checkbox document-title]]
             [rems.flash-message :as flash-message]
             [rems.spinner :as spinner]
-            [rems.status-modal :as status-modal]
             [rems.table :as table]
             [rems.text :refer [localize-time text]]
             [rems.util :refer [fetch put!]]))
@@ -20,11 +19,13 @@
 (rf/reg-event-fx
  ::fetch-resources
  (fn [{:keys [db]}]
-   (fetch "/api/resources" {:url-params {:disabled true
-                                         :expired (::display-old? db)
-                                         :archived (::display-old? db)}
-                            :handler #(rf/dispatch [::fetch-resources-result %])
-                            :error-handler status-modal/common-error-handler!})
+   (let [description (text :t.administration/resources)]
+     (fetch "/api/resources"
+            {:url-params {:disabled true
+                          :expired (::display-old? db)
+                          :archived (::display-old? db)}
+             :handler #(rf/dispatch [::fetch-resources-result %])
+             :error-handler (flash-message/default-error-handler :top description)}))
    {:db (assoc db ::loading? true)}))
 
 (rf/reg-event-db
diff --git a/src/cljs/rems/administration/workflows.cljs b/src/cljs/rems/administration/workflows.cljs
index 9433fafe5..694dae84c 100644
--- a/src/cljs/rems/administration/workflows.cljs
+++ b/src/cljs/rems/administration/workflows.cljs
@@ -20,10 +20,12 @@
 (rf/reg-event-db
  ::fetch-workflows
  (fn [db]
-   (fetch "/api/workflows/" {:url-params {:disabled true
-                                          :archived (::display-old? db)
-                                          :expired (::display-old? db)}
-                             :handler #(rf/dispatch [::fetch-workflows-result %])})
+   (let [description (text :t.administration/workflows)]
+     (fetch "/api/workflows/" {:url-params {:disabled true
+                                            :archived (::display-old? db)
+                                            :expired (::display-old? db)}
+                               :handler #(rf/dispatch [::fetch-workflows-result %])
+                               :error-handler (flash-message/default-error-handler :top description)}))
    (assoc db ::loading? true)))
 
 (rf/reg-event-db
diff --git a/src/cljs/rems/ajax.cljs b/src/cljs/rems/ajax.cljs
index b4cedfbd0..10b39da16 100644
--- a/src/cljs/rems/ajax.cljs
+++ b/src/cljs/rems/ajax.cljs
@@ -20,7 +20,8 @@
     (str/split str " ")))
 
 (defn save-roles [response]
-  (set-roles! (split-words (-get-response-header response "x-rems-roles")))
+  (if-let [roles (-get-response-header response "x-rems-roles")]
+    (set-roles! (split-words roles)))
   response)
 
 (defn load-interceptors! []
diff --git a/src/cljs/rems/application_list.cljs b/src/cljs/rems/application_list.cljs
index caa2c9196..0807451b8 100644
--- a/src/cljs/rems/application_list.cljs
+++ b/src/cljs/rems/application_list.cljs
@@ -133,6 +133,9 @@
     (not @(rf/subscribe [applications :initialized?]))
     [spinner/big]
 
+    @(rf/subscribe [applications :error])
+    [:div.applications.alert.alert-danger @(rf/subscribe [applications :error])]
+
     (empty? @(rf/subscribe [applications]))
     [:div.applications.alert.alert-success (text (or empty-message :t.applications/empty))]
 
diff --git a/src/cljs/rems/applications.cljs b/src/cljs/rems/applications.cljs
index 44f3ddf25..8a2c38868 100644
--- a/src/cljs/rems/applications.cljs
+++ b/src/cljs/rems/applications.cljs
@@ -2,6 +2,7 @@
   (:require [re-frame.core :as rf]
             [rems.application-list :as application-list]
             [rems.atoms :refer [document-title]]
+            [rems.flash-message :as flash-message]
             [rems.roles :as roles]
             [rems.search :as search]
             [rems.spinner :as spinner]
@@ -27,6 +28,7 @@
   (let [identity @(rf/subscribe [:identity])]
     [:<>
      [document-title (text :t.applications/applications)]
+     [flash-message/component :top]
      (if (not @(rf/subscribe [::my-applications :initialized?]))
        [spinner/big]
        [:<>
diff --git a/src/cljs/rems/auth/ldap.cljs b/src/cljs/rems/auth/ldap.cljs
index bfb3e357a..90f2e0a7e 100644
--- a/src/cljs/rems/auth/ldap.cljs
+++ b/src/cljs/rems/auth/ldap.cljs
@@ -1,10 +1,12 @@
 (ns rems.auth.ldap
   (:require [rems.atoms :refer [document-title]]
+            [rems.flash-message :as flash-message]
             [rems.text :refer [text]]))
 
 (defn login-component []
   [:div
    [document-title (text :t.ldap/title)]
+   [flash-message/component :top]
    [:form
     {:action "/ldap/login" :method "post"}
     [:input.form-control {:type "text" :placeholder (text :t.ldap/username) :name "username" :required true}]
diff --git a/src/cljs/rems/auth/shibboleth.cljs b/src/cljs/rems/auth/shibboleth.cljs
index e531ab6c1..35272eead 100644
--- a/src/cljs/rems/auth/shibboleth.cljs
+++ b/src/cljs/rems/auth/shibboleth.cljs
@@ -1,11 +1,13 @@
 (ns rems.auth.shibboleth
   (:require [rems.atoms :as atoms :refer [document-title]]
+            [rems.flash-message :as flash-message]
             [rems.navbar :as nav]
             [rems.text :refer [text]]))
 
 (defn login-component [alternative-endpoint]
   [:div
    [document-title (text :t.login/title)]
+   [flash-message/component :top]
    [:p (text :t.login/text)]
    [:div
     [atoms/link nil
diff --git a/src/cljs/rems/catalogue.cljs b/src/cljs/rems/catalogue.cljs
index 4354189de..c23a29101 100644
--- a/src/cljs/rems/catalogue.cljs
+++ b/src/cljs/rems/catalogue.cljs
@@ -5,6 +5,7 @@
             [rems.atoms :refer [external-link document-title document-title]]
             [rems.cart :as cart]
             [rems.catalogue-util :refer [urn-catalogue-item-link urn-catalogue-item?]]
+            [rems.flash-message :as flash-message]
             [rems.guide-functions]
             [rems.roles :as roles]
             [rems.spinner :as spinner]
@@ -130,6 +131,7 @@
         loading-drafts? @(rf/subscribe [::loading-drafts?])]
     [:div
      [document-title (text :t.catalogue/catalogue)]
+     [flash-message/component :top]
      (text :t.catalogue/intro)
      (if (or loading-catalogue? loading-drafts?)
        [spinner/big]
diff --git a/src/cljs/rems/extra_pages.cljs b/src/cljs/rems/extra_pages.cljs
index f1fc147e2..e536940f4 100644
--- a/src/cljs/rems/extra_pages.cljs
+++ b/src/cljs/rems/extra_pages.cljs
@@ -2,6 +2,7 @@
   (:require [markdown.core :as md]
             [re-frame.core :as rf]
             [rems.atoms :refer [document-title]]
+            [rems.flash-message :as flash-message]
             [rems.spinner :as spinner]
             [rems.text :refer [text]]
             [rems.util :refer [fetch]]))
@@ -58,6 +59,7 @@
         language @(rf/subscribe [:language])]
     [:div
      [document-title title]
+     [flash-message/component :top]
      (if loading?
        [spinner/big]
        (if (= extra-page :not-found)
diff --git a/src/cljs/rems/flash_message.cljs b/src/cljs/rems/flash_message.cljs
index 484331749..a561198bb 100644
--- a/src/cljs/rems/flash_message.cljs
+++ b/src/cljs/rems/flash_message.cljs
@@ -81,6 +81,24 @@
                                     (str description ": " (text :t.form/failed))]]
                               more)))
 
+(defn format-errors [errors]
+  ;; TODO: copied as-is from status-modal; consider refactoring
+  (into [:<>]
+        (for [error errors]
+          [:p
+           (when (:key error)
+             (text (:key error)))
+           (when (:type error)
+             (text (:type error)))
+           (when-let [text (:status-text error)] text)
+           (when-let [text (:status error)]
+             (str " (" text ")"))])))
+
+(defn format-response-error [response]
+  (if (:errors response)
+    (format-errors (:errors response))
+    (:status-text response)))
+
 (defn default-success-handler [location description on-success]
   (fn [response]
     (if (:success response)
@@ -88,7 +106,7 @@
         (show-default-success! location description)
         (when on-success
           (on-success response)))
-      (show-default-error! location description))))
+      (show-default-error! location description (format-response-error response)))))
 
 (defn status-update-handler [location description on-success]
   (fn [response]
@@ -101,4 +119,4 @@
 
 (defn default-error-handler [location description]
   (fn [response]
-    (show-default-error! location description (:status-text response))))
+    (show-default-error! location description (format-response-error response))))
diff --git a/src/cljs/rems/guide_page.cljs b/src/cljs/rems/guide_page.cljs
index ff7dd0f4b..5602a0b4a 100644
--- a/src/cljs/rems/guide_page.cljs
+++ b/src/cljs/rems/guide_page.cljs
@@ -9,12 +9,11 @@
             [rems.collapsible :as collapsible]
             [rems.dropdown :as dropdown]
             [rems.fields :as fields]
+            [rems.flash-message :as flash-message]
             [rems.language-switcher :as language-switcher]
-            [rems.modal :as modal]
             [rems.navbar :as nav]
             [rems.phase :as phase]
             [rems.spinner :as spinner]
-            [rems.status-modal :as status-modal]
             [rems.table :as table])
   (:require-macros [rems.guide-macros :refer [example]]))
 
@@ -79,6 +78,7 @@
   [:div.container
    [:div.example-page
     [document-title "Component Guide"]
+    [flash-message/component :top]
 
     [:h2 "Colors"]
     (example "Brand colors" [color-boxes])
@@ -109,10 +109,6 @@
     [:h2 "Collapsible component"]
     [collapsible/guide]
 
-    [:h2 "Modal"]
-    [modal/guide]
-    [status-modal/guide]
-
     [:h2 "Applications"]
     [application/guide]
 
diff --git a/src/cljs/rems/modal.cljs b/src/cljs/rems/modal.cljs
deleted file mode 100644
index 806f6615e..000000000
--- a/src/cljs/rems/modal.cljs
+++ /dev/null
@@ -1,98 +0,0 @@
-(ns rems.modal
-  (:require [rems.guide-functions]
-            [rems.collapsible :as collapsible]
-            [rems.text :refer [text]])
-  (:require-macros [rems.guide-macros :refer [component-info example]]))
-
-(defn- shade-wrapper [content on-close]
-  [:div.modal--shade
-   {:style {:position :fixed
-            :z-index 10000
-            :left 0
-            :top 0
-            :right 0
-            :bottom 0
-            :background-color "rgba(0,0,0,0.5)"
-            :display :flex
-            :justify-content :center
-            :align-items :center}
-    :on-click on-close}
-   [:div {:style {:border-radius "0.25rem"
-                  :min-width "50%"
-                  :background-color "#fff"}
-          :on-click (fn [e] (.stopPropagation e))}
-    content]])
-
-(defn component
-  "Displays a modal dialog
-
-  Pass a map of options with the following keys:
-  `:title` component displayed in title area
-  `:title-class` class for the title area
-  `:content` component displayed in content area
-  `:commands` seq of components displayed in commands area
-  `:on-close` triggers the function callback given as an argument when modal should be closed
-  `:shade?` should the modal have a dark blocking shade behind it? Default true"
-  [{:keys [title title-class content commands on-close shade?]}]
-  (let [content [collapsible/component
-                 {:title [:div.modal--title {:style {:display :flex
-                                                     :justify-content
-                                                     :space-between
-                                                     :align-items :center}
-                                             :ref (fn [elem] (when elem
-                                                               (.focus elem)))
-                                             :tab-index "-1"}
-                          title
-                          [:button.btn.btn-link.link.ml-3
-                           {:type :button
-                            :on-click on-close
-                            :aria-label (text :t.modal/close-dialog)}
-                           [:i.fa.fa-times {:style {:margin 0}}]]]
-                  :title-class title-class
-                  :always [:div.full
-                           ;; max-height in order to keep header and controls visible
-                           [:div.modal--content
-                            {:style {:max-height "70vh" :overflow :auto}}
-                            content]
-                           (into [:div.modal--commands.commands {:style {:padding 0}}]
-                                 commands)]
-                  :open? true}]]
-    [:div.modal--container
-     {:on-key-press
-      (fn [e]
-        (if (= (.-key e) "Enter")
-          (on-close)))
-      :tab-index "-1"}
-     (if (false? shade?)
-       content
-       [shade-wrapper content on-close])]))
-
-(defn notification
-  "Displays a modal notification dialog.
-
-  A notification dialog shows an OK button to close the dialog.
-
-  See `modal/component` for options."
-  [{:keys [title title-class content on-close shade?] :as opts}]
-  [component (assoc opts :commands [[:button#modal-ok.btn.btn-primary
-                                     {:type :button
-                                      :on-click on-close}
-                                     (text :t.actions/ok)]])])
-
-(defn guide
-  []
-  [:div
-   (component-info component)
-   (example "modal component"
-            [component {:title "Hello World"
-                        :content [:div "Hello world!"]
-                        :commands [[:button.btn.btn-secondary {:type :button} "Say"]
-                                   [:button.btn.btn-primary {:type :button} "IT"]]
-                        :on-close #(.alert js/window "close")
-                        :shade? false}])
-   (component-info notification)
-   (example "notification modal"
-            [notification {:title "Hello World"
-                           :content [:div "Hello world!"]
-                           :on-close #(.alert js/window "close")
-                           :shade? false}])])
diff --git a/src/cljs/rems/new_application.cljs b/src/cljs/rems/new_application.cljs
index 9a664b983..2199e3b3e 100644
--- a/src/cljs/rems/new_application.cljs
+++ b/src/cljs/rems/new_application.cljs
@@ -2,6 +2,7 @@
   (:require [re-frame.core :as rf]
             [rems.application :as application]
             [rems.atoms :refer [document-title]]
+            [rems.flash-message :as flash-message]
             [rems.spinner :as spinner]
             [rems.text :refer [text]]
             [rems.util :refer [post!]]))
@@ -13,14 +14,17 @@
 (rf/reg-event-fx
  ::enter-new-application-page
  (fn [{:keys [db]} [_ catalogue-item-ids]]
-   (post! "/api/applications/create"
-          {:params {:catalogue-item-ids catalogue-item-ids}
-           :handler (fn [response]
-                      (remove-catalogue-items-from-cart! catalogue-item-ids)
-                      (application/navigate-to (:application-id response) true))})
+   (let [description (text :t.applications/application)]
+     (post! "/api/applications/create"
+            {:params {:catalogue-item-ids catalogue-item-ids}
+             :handler (fn [response]
+                        (remove-catalogue-items-from-cart! catalogue-item-ids)
+                        (application/navigate-to (:application-id response) true))
+             :error-handler (flash-message/default-error-handler :top description)}))
    {}))
 
 (defn new-application-page []
   [:div
    [document-title (text :t.applications/application)]
+   [flash-message/component :top]
    [spinner/big]])
diff --git a/src/cljs/rems/search.cljs b/src/cljs/rems/search.cljs
index 45a800b94..d9dd05feb 100644
--- a/src/cljs/rems/search.cljs
+++ b/src/cljs/rems/search.cljs
@@ -2,8 +2,8 @@
   (:require [re-frame.core :as rf]
             [reagent.core :as r]
             [rems.atoms :refer [close-symbol]]
+            [rems.flash-message :as flash-message]
             [rems.spinner :as spinner]
-            [rems.status-modal :as status-modal]
             [rems.text :refer [text]]
             [rems.util :refer [fetch]]))
 
@@ -17,6 +17,7 @@
   - Handler `[::foo]` fetches data from `/url`, i.e. get all items
   - Handler `[::foo \"bar\"]` fetches data from `/url?query=bar`, i.e. do a search
   - Subscription `[::foo]` returns the data that was last fetched
+  - Subscription `[::foo :error]` returns the error message if the fetch failed
   - Subscription `[::foo :initialized?]` returns `true` if the data has been
     fetched at least once
   - Subscription `[::foo :fetching?]` returns `true` if a fetch is in progress
@@ -35,11 +36,13 @@
          (fetch url
                 {:url-params (when query
                                {:query query})
-                 :handler #(rf/dispatch [result-id % query])
-                 ;; TODO: no title for the error modal. That's fine since this will only fail very exceptionally.
-                 :error-handler #(do
-                                   (status-modal/common-error-handler! %)
-                                   (rf/dispatch [result-id nil query]))}))
+                 :handler #(rf/dispatch [result-id {:data %} query])
+                 :error-handler (fn [response]
+                                  ;; We use a custom error reporting mechanism instead of flash-message
+                                  ;; because grabbing the focus is problematic for continuous search.
+                                  (rf/dispatch [result-id
+                                                {:error (flash-message/format-response-error response)}
+                                                query]))}))
        {:db (-> db
                 (assoc-in [id :query] query)
                 (assoc-in [id :fetching?] true))}))
@@ -52,7 +55,8 @@
          (when-not (= query latest-query)
            (rf/dispatch [id latest-query])))
        (-> db
-           (assoc-in [id :data] result)
+           (assoc-in [id :data] (:data result))
+           (assoc-in [id :error] (:error result))
            (assoc-in [id :initialized?] true)
            (assoc-in [id :fetching?] false))))
 
diff --git a/src/cljs/rems/spa.cljs b/src/cljs/rems/spa.cljs
index 8b7ce9054..4dfc76fcd 100644
--- a/src/cljs/rems/spa.cljs
+++ b/src/cljs/rems/spa.cljs
@@ -30,11 +30,11 @@
             [rems.catalogue :refer [catalogue-page]]
             [rems.config :as config]
             [rems.extra-pages :refer [extra-pages]]
+            [rems.flash-message :as flash-message]
             [rems.guide-page :refer [guide-page]]
             [rems.navbar :as nav]
             [rems.new-application :refer [new-application-page]]
             [rems.roles :as roles]
-            [rems.status-modal :as status-modal]
             [rems.text :refer [text]]
             [rems.user-settings :refer [fetch-user-settings!]]
             [rems.util :refer [dispatch! fetch parse-int]]
@@ -168,16 +168,19 @@
 (defn unauthorized-page []
   [:div
    [document-title (text :t.unauthorized-page/unauthorized)]
+   [flash-message/component :top]
    [:p (text :t.unauthorized-page/you-are-unauthorized)]])
 
 (defn forbidden-page []
   [:div
    [document-title (text :t.forbidden-page/forbidden)]
+   [flash-message/component :top]
    [:p (text :t.forbidden-page/you-are-forbidden)]])
 
 (defn not-found-page []
   [:div
    [document-title (text :t.not-found-page/not-found)]
+   [flash-message/component :top]
    [:p (text :t.not-found-page/page-was-not-found)]])
 
 (def pages
@@ -247,7 +250,6 @@
         grab-focus? @(rf/subscribe [::grab-focus?])]
     [:div
      [nav/navigation-widget page-id]
-     [status-modal/status-modal]
      [logo]
      [main-content page-id grab-focus?]
      [footer]]))
diff --git a/src/cljs/rems/status_modal.cljs b/src/cljs/rems/status_modal.cljs
deleted file mode 100644
index 9621ee926..000000000
--- a/src/cljs/rems/status_modal.cljs
+++ /dev/null
@@ -1,169 +0,0 @@
-(ns rems.status-modal
-  "Component for showing a status modal dialog.
-
-  There should only be one `status-modal` at the root of the component hierarchy.
-
-  Use the functions `set-pending!`, `set-success!` and `set-error!` to control its state.
-  See `rems.status-modal/status-modal` for values to use in the calls."
-  (:require [re-frame.core :as rf]
-            [rems.atoms :as atoms]
-            [rems.common-util :refer [deep-merge]]
-            [rems.guide-functions]
-            [rems.modal :as modal]
-            [rems.spinner :as spinner]
-            [rems.text :refer [text]])
-  (:require-macros [rems.guide-macros :refer [component-info example]]))
-
-(defn format-errors [errors]
-  (into [:<>]
-        (for [error errors]
-          [:p
-           (when (:key error)
-             (text (:key error)))
-           (when (:type error)
-             (text (:type error)))
-           (when-let [text (:status-text error)] text)
-           (when-let [text (:status error)]
-             (str " (" text ")"))])))
-
-(defn- status-icon [success? error-content]
-  (cond
-    (and (not success?) (not error-content)) [spinner/small]
-    success? (atoms/success-symbol)
-    error-content [:span.fa-stack {:aria-label (text :t.form/failed)}
-                   [:i {:class "fas fa-circle fa-stack-1x icon-stack-background"}]
-                   [:i {:class "fas fa-times-circle fa-stack-1x text-danger"}]]))
-
-(defn- status-text [success? error-content]
-  (cond
-    (and (not success?) (not error-content)) [:p (text :t.form/please-wait)]
-    success? [:p#status-success (text :t.form/success)]
-    error-content [:<>
-                   [:p#status-failed (text :t.form/failed)]
-                   error-content]))
-
-(rf/reg-event-db ::set-state (fn [db [_ state]] (assoc db ::state state)))
-(rf/reg-event-db ::merge-state (fn [db [_ state]] (update db ::state deep-merge state)))
-(rf/reg-sub ::state (fn [db _] (::state db)))
-
-(defn close []
-  (rf/dispatch [::set-state nil]))
-
-(defn status-modal
-  "Modal component showing the status of an action.
-
-   `initial-state` can contain:
-
-  `:open?`         - A boolean indicating if the modal is shown or not.
-  `:result`        - Either {:success? true} one of {:error ...} or {:errors ...}
-                     Shows the status based on the values or a spinner while neither.
-    `:error`       - error that may contain `:key`, `:type`, `:status` and `:status-text`
-                     like translated errors or http errors
-    `:errors`      - seq like `:error`
-
-  `:title`         - title of the modal, i.e. name of the operation
-  `:content`       - additional content to show after the status widget
-  `:error-content` - content to show instead of generated content from result errors
-  `:on-close`      - callback is called when the modal wants to close itself
-
-  Also when setting global state with `set-pending!`, `set-success!` or `set-error!`
-  the same structure applies."
-  [& [initial-state]]
-  (let [internal-state @(rf/subscribe [::state])
-        state (deep-merge initial-state internal-state)
-        {:keys [title content error-content result on-close shade? open?]} state
-        success? (:success? result)
-        errors (if (:error result) [(:error result)] (:errors result))
-        error-content (or error-content (and errors (format-errors errors)))]
-    (when open?
-      [modal/notification {:title [:div [status-icon success? error-content] " " title]
-                           :title-class (when (or errors error-content) "alert alert-danger")
-                           :content [:<> [status-text success? error-content] content]
-                           :on-close (fn []
-                                       (close)
-                                       (when on-close (on-close))
-                                       (rf/dispatch [:rems.spa/user-triggered-navigation]))
-                           :shade? shade?}])))
-
-(defn set-pending!
-  "Globally set the modal state to reflect a pending operation.
-
-  The given `state` is like in `status-modal` and it replaces the
-  current state. It's convenient here to set the `:title` of the operation.
-
-  The modal will be shown."
-  [state]
-  (rf/dispatch [::set-state state]))
-
-(defn set-success!
-  "Globally set the modal state to reflect a successful operation.
-
-  The given `state` is like in `status-modal` and it's merged to the
-  current state so state set by a previous `set-pending!` can be shared.
-
-  The modal will be shown."
-  [state]
-  (rf/dispatch [::merge-state (deep-merge {:open? true
-                                           :result {:success? true}}
-                                          state)]))
-(defn set-error!
-  "Globally set the modal state to reflect a failed operation.
-
-  The given `state` is like in `status-modal` and it's merged to the
-  current state so state set by a previous `set-pending!` can be shared.
-
-  The modal will be shown."
-  [state]
-  (rf/dispatch [::merge-state (deep-merge {:open? true
-                                           :result {:success? false}}
-                                          state)]))
-
-(defn common-pending-handler!
-  "Common variant of `set-pending!` where you wish to open the modal and
-  customize the `title`."
-  [title]
-  (set-pending! {:open? true
-                 :title title}))
-
-(defn common-success-handler!
-  "Common variant of `set-success!` where you only wish to customize the `on-close`
-  by currying."
-  [on-close response]
-  (assert on-close)
-  (if (:success response)
-    (set-success! {:on-close (partial on-close response)})
-    (set-error! {:result response})))
-
-(defn common-error-handler!
-  "Common variant of `set-error!` where you don't need to customize handling."
-  [response]
-  (set-error! {:result {:error response}}))
-
-(defn guide
-  []
-  [:div
-   (component-info status-modal)
-   (example "status-modal while result is pending"
-            [status-modal {:open? true
-                           :shade? false
-                           :title "Example"
-                           :content [:p "We are experiencing unexpected slowness"]}])
-   (example "status-modal for success"
-            [status-modal {:open? true
-                           :result {:success? true}
-                           :shade? false
-                           :title "Example"
-                           :content [:p "This was a great success for all!"]}])
-   (example "status-modal for result with a single error"
-            [status-modal {:open? true
-                           :result {:error {:status 404
-                                            :status-text "Not found"}}
-                           :shade? false
-                           :title "Example"
-                           :content [:p "This did not go as planned"]}])
-   (example "status-modal for result with errors"
-            [status-modal {:open? true
-                           :result {:errors [{:type :t.form.validation/errors}]}
-                           :shade? false
-                           :title "Example"
-                           :content [:p "You should check the errors"]}])])
diff --git a/src/cljs/rems/user_settings.cljs b/src/cljs/rems/user_settings.cljs
index 8f829ddf2..d563e9f56 100644
--- a/src/cljs/rems/user_settings.cljs
+++ b/src/cljs/rems/user_settings.cljs
@@ -1,7 +1,7 @@
 (ns rems.user-settings
   (:require [re-frame.core :as rf]
+            [rems.flash-message :as flash-message]
             [rems.language :as language]
-            [rems.status-modal :as status-modal]
             [rems.util :refer [fetch put!]]))
 
 (rf/reg-event-fx
@@ -11,7 +11,9 @@
    {:db (assoc db :user-settings user-settings)}))
 
 (defn fetch-user-settings! []
-  (fetch "/api/user-settings" {:handler #(rf/dispatch [:loaded-user-settings %])}))
+  (fetch "/api/user-settings"
+         {:handler #(rf/dispatch [:loaded-user-settings %])
+          :error-handler (flash-message/default-error-handler :top "Fetch user settings")}))
 
 (rf/reg-event-fx
  ::update-user-settings
@@ -19,5 +21,5 @@
    (put! "/api/user-settings"
          {:params settings
           :handler (fn [_] (fetch-user-settings!))
-          :error-handler status-modal/common-error-handler!})
+          :error-handler (flash-message/default-error-handler :top "Update user settings")})
    nil))
diff --git a/src/cljs/rems/util.cljs b/src/cljs/rems/util.cljs
index ff8fe8bce..a31aba65e 100644
--- a/src/cljs/rems/util.cljs
+++ b/src/cljs/rems/util.cljs
@@ -3,7 +3,6 @@
             [clojure.string :as str]
             [goog.string :refer [parseInt]]
             [re-frame.core :as rf]
-            [rems.status-modal :as status-modal]
             [secretary.core :as secretary]))
 
 ;; TODO move to cljc
@@ -139,7 +138,6 @@
 (defn in-page-anchor-link [id]
   (fn [event]
     (.preventDefault event)
-    (status-modal/close) ; in case this link is from a modal dialog to the page behind it
     (when-let [element (.getElementById js/document id)]
       (.focus element))))
 


Test patch:
diff --git a/test/clj/rems/application/test_commands.clj b/test/clj/rems/application/test_commands.clj
index 9daafbd5b..b12e43450 100644
--- a/test/clj/rems/application/test_commands.clj
+++ b/test/clj/rems/application/test_commands.clj
@@ -863,7 +863,7 @@
                                         :event/actor applicant-user-id
                                         :application/id app-id
                                         :application/member {:userid "somebody"}}])]
-        (is (= {:errors [{:type :already-member :application-id (:id application)}]}
+        (is (= {:errors [{:type :already-member :application-id app-id}]}
                (fail-command application
                              {:type :application.command/accept-invitation
                               :actor "somebody"