diff --git a/shadow-cljs.edn b/shadow-cljs.edn index ace816b..95a5f01 100644 --- a/shadow-cljs.edn +++ b/shadow-cljs.edn @@ -14,6 +14,8 @@ ;; for CIDER [cider/cider-nrepl "0.18.0-SNAPSHOT"]] + :nrepl {:port 9000} + :builds {:app {:target :browser :output-dir "public/app/js" diff --git a/src/cljs/airsonic_ui/db.cljs b/src/cljs/airsonic_ui/db.cljs index 7f3b701..b8cbcac 100644 --- a/src/cljs/airsonic_ui/db.cljs +++ b/src/cljs/airsonic_ui/db.cljs @@ -3,4 +3,5 @@ (def default-db {;; because navigate! executes asynchronously we force to display the login screen first - :current-route [routes/default-route]}) + :current-route [routes/default-route] + :notifications (sorted-map)}) diff --git a/src/cljs/airsonic_ui/events.cljs b/src/cljs/airsonic_ui/events.cljs index 6b82793..c067841 100644 --- a/src/cljs/airsonic_ui/events.cljs +++ b/src/cljs/airsonic_ui/events.cljs @@ -87,14 +87,16 @@ (let [creds (:credentials db)] (api/url (:server creds) endpoint (merge params (select-keys creds [:u :p]))))) +(defn api-request [{:keys [db]} [_ endpoint k params]] + {:http-xhrio {:method :get + :uri (api-url db endpoint params) + :response-format (ajax/json-response-format {:keywords? true}) + :on-success [::api-success k] + :on-failure [::api-failure]}}) + (re-frame/reg-event-fx :api-request - (fn [{:keys [db]} [_ endpoint k params]] - {:http-xhrio {:method :get - :uri (api-url db endpoint params) - :response-format (ajax/json-response-format {:keywords? true}) - :on-success [::api-success k] - :on-failure [::api-failure]}})) + api-request) (re-frame/reg-event-db ::api-success @@ -173,3 +175,29 @@ {:routes/navigate [routes/default-route] :routes/unset-credentials nil :db db/default-db})) + +;; user messages + +(defn show-notification + "Displays an informative message to the user" + [db [_ level message]] + (let [id (.now js/performance)] + (if (nil? message) + (let [message level + level :info] + (assoc-in db [:notifications id] {:level level + :message message})) + (assoc-in db [:notifications id] {:level level + :message message})))) + +(re-frame/reg-event-db + :notification/show + show-notification) + +(defn hide-notification + [db [_ notification-id]] + (update db :notifications dissoc notification-id)) + +(re-frame/reg-event-db + :notification/hide + hide-notification) diff --git a/src/cljs/airsonic_ui/subs.cljs b/src/cljs/airsonic_ui/subs.cljs index 5cfb1a2..36048a2 100644 --- a/src/cljs/airsonic_ui/subs.cljs +++ b/src/cljs/airsonic_ui/subs.cljs @@ -6,40 +6,38 @@ ;; FIXME: this is used for cover images and it's quite ugly tbh (re-frame/reg-sub ::login - (fn [db] + (fn [db _] (select-keys (:credentials db) [:u :p]))) (re-frame/reg-sub ::user - (fn [{:keys [credentials]}] + (fn [{:keys [credentials]} [_]] {:name (:u credentials)})) (re-frame/reg-sub ::server - (fn [db] + (fn [db _] (get-in db [:credentials :server]))) ;; current hashbang (re-frame/reg-sub ::current-route - (fn [db] + (fn [db _] (:current-route db))) -;; --- - ;; TODO: Make this nice and clean (re-frame/reg-sub ::current-content - (fn [db] - (db :response))) + (fn [db _] + (:response db))) (re-frame/reg-sub ; returns info on the current song as is (basically the metadata you can read from the file system) ::currently-playing - (fn [db] - (db :currently-playing))) + (fn [db _] + (:currently-playing db))) (re-frame/reg-sub ::is-playing? @@ -49,3 +47,10 @@ (let [status (:status currently-playing)] (and (not (:paused? status)) (not (:ended? status)))))) + +;; user notifications + +(re-frame/reg-sub + ::notifications + (fn [db _] + (:notifications db))) diff --git a/src/cljs/airsonic_ui/utils/api.cljs b/src/cljs/airsonic_ui/utils/api.cljs index 09aeecf..a182123 100644 --- a/src/cljs/airsonic_ui/utils/api.cljs +++ b/src/cljs/airsonic_ui/utils/api.cljs @@ -22,3 +22,18 @@ (defn cover-url [server credentials item size] (url server "getCoverArt" (merge {:id (:coverArt item) :size size} credentials))) + +(defn is-error? [response] + (= "failed" (get-in response [:subsonic-response :status]))) + +(defn unwrap-response + "Retrieves the actual response body" + [response] + (if (is-error? response) + (let [error (:error response)] + (throw (ex-info (:message response) error))) + (-> (get-in response [:subsonic-response]) + (dissoc :status :version) + vals + first))) + diff --git a/src/cljs/airsonic_ui/views.cljs b/src/cljs/airsonic_ui/views.cljs index 1aa7d6a..3005461 100644 --- a/src/cljs/airsonic_ui/views.cljs +++ b/src/cljs/airsonic_ui/views.cljs @@ -46,12 +46,26 @@ {:on-click #(dispatch [::events/initialize-db]) :href "#"} (str "Logout (" (:name user) ")")]]]]) +;; user notifications + +(defn notification-list [notifications] + [:div.notifications + (for [[id notification] notifications] + (let [class (case (:level notification) + :error "danger" + "info")] + ^{:key id} [:div {:class-name (str "notification is-small is-" class)} + [:button.delete {:on-click #(dispatch [:notification/hide id])}] + (:message notification)]))]) + ;; putting everything together (defn app [route params query] (let [user @(subscribe [::subs/user]) + notifications @(subscribe [::subs/notifications]) content @(subscribe [::subs/current-content])] [:div + [notification-list notifications] [:main.columns [:div.column.is-2.sidebar [sidebar user]] diff --git a/src/sass/app.sass b/src/sass/app.sass index ff7972c..aa522c2 100644 --- a/src/sass/app.sass +++ b/src/sass/app.sass @@ -73,3 +73,11 @@ .table .grow width: 100% + +// floating notifications +.notifications + @extend .container + padding-top: 3.2rem + position: fixed + left: 0 + right: 0 diff --git a/test/cljs/airsonic_ui/events_test.cljs b/test/cljs/airsonic_ui/events_test.cljs index 0d88f42..6c6580b 100644 --- a/test/cljs/airsonic_ui/events_test.cljs +++ b/test/cljs/airsonic_ui/events_test.cljs @@ -43,3 +43,26 @@ (testing "When there's no previous login data" (testing "remembering has no effect" (is (nil? (events/try-remember-user {} [:_])))))) + +(defn- first-notification [db] + (-> (:notifications db) vals first)) + +(deftest user-notifications + (testing "Should be able to display a message with an assigned level" + (is (= :error (:level (first-notification (events/show-notification {} [:_ :error "foo"]))))) + (is (= :info (:level (first-notification (events/show-notification {} [:_ :info "some other message"])))))) + (testing "Should default to level :info" + (is (= :info (:level (first-notification (events/show-notification {} [:_ "and another one"])))))) + (testing "Should create a unique id for each message" + (let [state (-> + {} + (events/show-notification [:_ :info "Something something"]) + (events/show-notification [:_ :error "Something important"])) + ids (keys (:notifications state))] + (is (= (count ids) (count (set ids)))))) + (testing "Should remove a message, given it's id" + (let [state (events/show-notification {} [:_ "This is a notification"]) + id (-> (:notifications state) + keys + first)] + (is (empty? (:notifications (events/hide-notification state [:_ id]))))))) diff --git a/test/cljs/airsonic_ui/utils/api_test.cljs b/test/cljs/airsonic_ui/utils/api_test.cljs index 7e12ee0..9d579ad 100644 --- a/test/cljs/airsonic_ui/utils/api_test.cljs +++ b/test/cljs/airsonic_ui/utils/api_test.cljs @@ -9,7 +9,17 @@ (api/url server endpoint {})) (def fixtures - {:default-url (url "http://localhost:8080" "ping")}) + {:default-url (url "http://localhost:8080" "ping") + :responses {:error {:subsonic-response + {:error {:code 40 + :message "Wrong username or password"} + :status "failed" + :version "1.15.0"}}} + :ok {:subsonic-response + {:scanStatus {:count 10326 + :scanning false} + :status "ok" + :version "1.15.0"}}}) (deftest general-url-construction (testing "Handles missing slashes" @@ -30,3 +40,19 @@ (is (true? (str/includes? (api/cover-url "http://server.tld" {} album -1) (str "id=" (:coverArt album)))))) (testing "Should scale an image to a given size" (is (true? (str/includes? (api/cover-url "http://server.tld" {} album 48) "size=48")))))) + +(deftest response-handling + (testing "Should unwrap responses" + (let [response (get-in fixtures [:responses :ok])] + (is (= (get-in response [:subsonic-response :scanStatus]) + (api/unwrap-response response))))) + (testing "Should detect errors" + (is (true? (api/is-error? (get-in fixtures [:responses :error])))) + (is (false? (api/is-error? (get-in fixtures [:responses :ok]))))) + (testing "Should throw an informative error when trying to unwrap an erroneous response" + (let [error-response (get-in fixtures [:responses :error])] + (is (thrown? ExceptionInfo (api/unwrap-response error-response))) + (try + (api/unwrap-response error-response) + (catch ExceptionInfo e + (= (:error error-response) (ex-data e)))))))