diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..f10a6b0
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,3 @@
+docker-compose.yml
+Dockerfile
+Jenkinsfile
diff --git a/.gitignore b/.gitignore
index 4f4f73c..39c20db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,7 @@ profiles.clj
.idea
resources/contracts
node_modules
+/config-prod.edn
+/config-dev.edn
+/config-test.edn
+/src/java
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..ce2947a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,30 @@
+FROM clojure as builder
+
+WORKDIR /tmp
+
+RUN wget -O /usr/local/bin/solc https://github.com/ethereum/solidity/releases/download/v0.4.15/solc-static-linux
+RUN chmod +x /usr/local/bin/solc
+
+RUN wget https://github.com/web3j/web3j/releases/download/v2.3.0/web3j-2.3.0.tar
+RUN tar -xf web3j-2.3.0.tar
+RUN cp -r web3j-2.3.0/* /usr/local/
+
+
+COPY . /usr/src/app
+WORKDIR /usr/src/app
+
+ENV LEIN_SNAPSHOTS_IN_RELEASE=1
+
+
+RUN lein less once
+RUN lein uberjar
+
+
+FROM clojure
+WORKDIR /root/
+
+COPY --from=builder /usr/src/app/target/uberjar/commiteth.jar .
+
+CMD [""]
+ENTRYPOINT ["/usr/bin/java", "-Duser.timezone=UTC", "-Dconf=config-test.edn", "-jar", "/root/commiteth.jar"]
+
diff --git a/Jenkinsfile b/Jenkinsfile
new file mode 100644
index 0000000..ec6b860
--- /dev/null
+++ b/Jenkinsfile
@@ -0,0 +1,35 @@
+#!/usr/bin/env groovy
+
+node('linux') {
+checkout scm
+
+def dockerreponame = "statusim/openbounty-app"
+
+ try {
+ stage('Build & push') {
+
+ GIT_COMMIT_HASH = sh (script: "git rev-parse --short HEAD | tr -d '\n'", returnStdout: true)
+
+ docker.withRegistry('https://index.docker.io/v1/', 'dockerhub-statusvan') {
+ def openbountyApp = docker.build("${dockerreponame}:${env.BUILD_NUMBER}")
+ openbountyApp.push("${env.BRANCH_NAME}")
+ if (env.BRANCH_NAME == 'develop') {
+ openbountyApp.push("develop")
+ } else if (env.BRANCH_NAME == 'master') {
+ openbountyApp.push("master")
+ } else {
+ println "Not named branch have no custom tag"
+ }
+ }
+
+ }
+
+ stage('Deploy') {
+ build job: 'status-openbounty/openbounty-cluster', parameters: [[$class: 'StringParameterValue', name: 'DEPLOY_ENVIRONMENT', value: "dev"], [$class: 'StringParameterValue', name: 'BRANCH', value: env.BRANCH_NAME]]
+ }
+
+ } catch (e) {
+ // slackSend color: 'bad', message: REPO + ":" + BRANCH_NAME + ' failed to build. ' + env.BUILD_URL
+ throw e
+ }
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 597831f..98216f5 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ The `develop` branch is automatically deployed here.
## Prerequisites
-You will need [Leiningen](https://github.com/technomancy/leiningen) 2.0 or above installed.
+You will need [Leiningen](https://github.com/technomancy/leiningen) 2.0 or above installed. Also, make sure that you have [wkhtmltoimage](https://wkhtmltopdf.org/downloads.html) available in your PATH. On macOS, it can be installed via `brew cask install wkhtmltopdf`.
### PostgreSQL
@@ -55,7 +55,7 @@ Web3j [2.3.0](https://github.com/web3j/web3j/releases/tag/v2.3.0) is required an
## Application config
-Make sure that `env/dev/resources/config.edn` is correctly populated. Description of config fields is given below:
+Make sure to create `/config-dev.edn` and populate it correctly, which is based on `env/dev/resources/config.edn`. Description of config fields is given below:
Key | Description
--- | ---
@@ -81,7 +81,7 @@ testnet-token-data | Token data map, useful if there are Geth connectivity probl
Open Bounty uses both OAuth App and GitHub App integration.
### OAuth App
-Follow the steps [here](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/). Specify the value of `:server-address` as "Homepage URL", and `:server-address` + `/callback` as "Authorization callback URL". Be sure to copy Client ID and Client Secret values to `config.edn`.
+Follow the steps [here](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/). Specify the value of `:server-address` as "Homepage URL", and `:server-address` + `/callback` as "Authorization callback URL". Be sure to copy Client ID and Client Secret values in the config file.
### GitHub App
Follow the steps [here](https://developer.github.com/apps/building-github-apps/creating-a-github-app/). Be sure to specify `:server-address` + `/webhook-app` as "Webhook URL", and `:webhook-secret` as "Webhook Secret".
diff --git a/doc/testing.md b/doc/testing.md
index 91f955a..f9dc7a1 100644
--- a/doc/testing.md
+++ b/doc/testing.md
@@ -59,3 +59,11 @@ To remove issue from the Bounties list you can close it in GitHub.
All bugs should be reported as issues in the [OpenBounty Github repository](https://github.com/status-im/open-bounty/issues).
Please first check that there is not already a duplicate issue. Issues should contain exact and minimal step-by-step instructions for reproducing the problem.
+
+### Status Open Bounty end-to-end tests
+
+Framework for testing located in: `open-bounty/test/end-to-end`
+
+Full installation and configuring manual: [Status Open Bounty end-to-end tests](https://wiki.status.im/Status_Open_Bounty_end-to-end_tests)
+
+Currently supports local and Jenkins environment running (you can find example of JenkinsFile in `open-bounty/test` )
diff --git a/env/dev/resources/config.edn b/env/dev/resources/config.edn
index 1ee9264..571a6ae 100644
--- a/env/dev/resources/config.edn
+++ b/env/dev/resources/config.edn
@@ -11,16 +11,18 @@
:eth-password "XXX"
;; RPC URL to ethereum node to be used
- :eth-rpc-url "http://localhost:8547"
+ :eth-rpc-url "http://localhost:8545"
:eth-wallet-file "/some/location"
;; address of token registry to be used
- :tokenreg-addr "0x..."
+ ;; this is the default value for ropsten
+ :tokenreg-addr "0x7d127a3e3b5e72cd8f15e7dee650abe4fcced2b9"
;; format of tokenreg records' base field, possible values :status, :parity
- :tokenreg-base-format :parity
+ :tokenreg-base-format :status
;; address of factory contract used for deploying bounty contracts
- :contract-factory-addr "0x..."
+ ;; this is the default value for ropsten
+ :contract-factory-addr "0x3B9A3c062Bdb640b5039C0cCda4157737d732F95"
;; commiteth-test-tpatja
:github-client-id "CLIENT ID"
@@ -43,5 +45,7 @@
;; needeed when :hubspot-contact-create-enabled
:hubspot-api-key "xxxxxxx-xxxx-x-xxxx-xxxx"
+ :user-whitelist #{}
+
;; used for blacklisting tokens from token registry data
:token-blacklist #{}}
diff --git a/project.clj b/project.clj
index e6af24a..ccb500f 100644
--- a/project.clj
+++ b/project.clj
@@ -69,7 +69,6 @@
[lein-auto "0.1.2"]
[lein-less "1.7.5"]
[lein-shell "0.5.0"]
- [cider/cider-nrepl "0.15.0-SNAPSHOT"]
[lein-sha-version "0.1.1"]]
@@ -94,7 +93,8 @@
:profiles
- {:uberjar {:omit-source true
+ {:uberjar {:jvm-opts ["-server" "-Dconf=config-prod.edn"]
+ :omit-source true
:prep-tasks ["build-contracts" "javac" "compile" ["cljsbuild" "once" "min"] ["less" "once"]]
:cljsbuild
{:builds
@@ -116,7 +116,8 @@
:uberjar-name "commiteth.jar"
:source-paths ["env/prod/clj"]
:resource-paths ["env/prod/resources"]}
- :dev {:dependencies [[prone "1.1.4"]
+ :dev {:jvm-opts ["-server" "-Dconf=config-dev.edn"]
+ :dependencies [[prone "1.1.4"]
[ring/ring-mock "0.3.1"]
[ring/ring-devel "1.6.2"]
[pjstadig/humane-test-output "0.8.3"]
@@ -150,7 +151,8 @@
:nrepl-middleware [cemerick.piggieback/wrap-cljs-repl]}
:injections [(require 'pjstadig.humane-test-output)
(pjstadig.humane-test-output/activate!)]}
- :test {:resource-paths ["env/dev/resources" "env/test/resources"]
+ :test {:jvm-opts ["-server" "-Dconf=config-test.edn"]
+ :resource-paths ["env/dev/resources" "env/test/resources"]
:dependencies [[devcards "0.2.4"]]
:cljsbuild
{:builds
diff --git a/resources/public/bounty-filter-remove.svg b/resources/public/bounty-filter-remove.svg
new file mode 100644
index 0000000..b4e091f
--- /dev/null
+++ b/resources/public/bounty-filter-remove.svg
@@ -0,0 +1,6 @@
+
diff --git a/resources/public/fonts/PostGrotesk-Book.eot b/resources/public/fonts/PostGrotesk-Book.eot
new file mode 100644
index 0000000..06846d3
Binary files /dev/null and b/resources/public/fonts/PostGrotesk-Book.eot differ
diff --git a/resources/public/fonts/PostGrotesk-Book.svg b/resources/public/fonts/PostGrotesk-Book.svg
new file mode 100644
index 0000000..dd80f2c
--- /dev/null
+++ b/resources/public/fonts/PostGrotesk-Book.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/resources/public/fonts/PostGrotesk-Book.woff b/resources/public/fonts/PostGrotesk-Book.woff
new file mode 100644
index 0000000..47b48d0
Binary files /dev/null and b/resources/public/fonts/PostGrotesk-Book.woff differ
diff --git a/resources/public/fonts/PostGrotesk-Medium.eot b/resources/public/fonts/PostGrotesk-Medium.eot
new file mode 100644
index 0000000..e0171ff
Binary files /dev/null and b/resources/public/fonts/PostGrotesk-Medium.eot differ
diff --git a/resources/public/fonts/PostGrotesk-Medium.svg b/resources/public/fonts/PostGrotesk-Medium.svg
new file mode 100644
index 0000000..2e26fe7
--- /dev/null
+++ b/resources/public/fonts/PostGrotesk-Medium.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/resources/public/fonts/PostGrotesk-Medium.woff b/resources/public/fonts/PostGrotesk-Medium.woff
new file mode 100644
index 0000000..933c51b
Binary files /dev/null and b/resources/public/fonts/PostGrotesk-Medium.woff differ
diff --git a/resources/public/fonts/RobotoRegular/RobotoRegular.eot b/resources/public/fonts/RobotoRegular/RobotoRegular.eot
new file mode 100755
index 0000000..466f3a7
Binary files /dev/null and b/resources/public/fonts/RobotoRegular/RobotoRegular.eot differ
diff --git a/resources/public/fonts/RobotoRegular/RobotoRegular.ttf b/resources/public/fonts/RobotoRegular/RobotoRegular.ttf
new file mode 100755
index 0000000..a4ebaf7
Binary files /dev/null and b/resources/public/fonts/RobotoRegular/RobotoRegular.ttf differ
diff --git a/resources/public/fonts/RobotoRegular/RobotoRegular.woff b/resources/public/fonts/RobotoRegular/RobotoRegular.woff
new file mode 100755
index 0000000..0871062
Binary files /dev/null and b/resources/public/fonts/RobotoRegular/RobotoRegular.woff differ
diff --git a/resources/public/icon-forward-white.svg b/resources/public/icon-forward-white.svg
new file mode 100644
index 0000000..9842677
--- /dev/null
+++ b/resources/public/icon-forward-white.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/clj/commiteth/core.clj b/src/clj/commiteth/core.clj
index e617708..d3c46a7 100644
--- a/src/clj/commiteth/core.clj
+++ b/src/clj/commiteth/core.clj
@@ -1,7 +1,7 @@
(ns commiteth.core
(:require [commiteth.handler :as handler]
[clojure.tools.nrepl.server :as nrepl-server]
- [cider.nrepl :refer [cider-nrepl-handler]]
+ [luminus.repl-server :as repl]
[luminus.http-server :as http]
[luminus-migrations.core :as migrations]
[commiteth.config :refer [env]]
@@ -31,7 +31,7 @@ repl-server
(when-let [nrepl-port (env :nrepl-port)]
(log/info "Starting NREPL server on port" nrepl-port)
(nrepl-server/start-server :port nrepl-port
- :handler cider-nrepl-handler))
+ :handler nrepl-server/default-handler))
:stop
(when repl-server
(nrepl-server/stop-server repl-server)))
diff --git a/src/clj/commiteth/eth/multisig_wallet.clj b/src/clj/commiteth/eth/multisig_wallet.clj
index 67bbb06..f5393ab 100644
--- a/src/clj/commiteth/eth/multisig_wallet.clj
+++ b/src/clj/commiteth/eth/multisig_wallet.clj
@@ -29,7 +29,7 @@
:confirmation (eth/event-sig->topic-id "Confirmation(address,uint256)")})
(defn factory-contract-addr []
- (env :contract-factory-addr "0x47F56FD26EEeCda4FdF5DB5843De1fe75D2A64A6"))
+ (env :contract-factory-addr))
(defn tokenreg-base-format
;; status tokenreg uses eg :base 18, while parity uses :base 1000000000000
diff --git a/src/cljs/commiteth/bounties.cljs b/src/cljs/commiteth/bounties.cljs
index bfa73a4..bbdceec 100644
--- a/src/cljs/commiteth/bounties.cljs
+++ b/src/cljs/commiteth/bounties.cljs
@@ -1,26 +1,31 @@
(ns commiteth.bounties
- (:require [re-frame.core :as rf]
- [reagent.core :as r]
+ (:require [reagent.core :as r]
+ [re-frame.core :as rf]
[commiteth.common :refer [moment-timestamp
display-data-page
items-per-page
- issue-url]]))
+ issue-url]]
+ [commiteth.handlers :as handlers]
+ [commiteth.db :as db]
+ [commiteth.ui-model :as ui-model]
+ [commiteth.subscriptions :as subs]
+ [commiteth.util :as util]))
(defn bounty-item [bounty]
- (let [{avatar-url :repo_owner_avatar_url
- owner :repo-owner
- repo-name :repo-name
- issue-title :issue-title
+ (let [{avatar-url :repo_owner_avatar_url
+ owner :repo-owner
+ repo-name :repo-name
+ issue-title :issue-title
issue-number :issue-number
- updated :updated
- tokens :tokens
- balance-eth :balance-eth
- value-usd :value-usd
- claim-count :claim-count} bounty
- full-repo (str owner "/" repo-name)
- repo-url (str "https://github.com/" full-repo)
- repo-link [:a {:href repo-url} full-repo]
+ updated :updated
+ tokens :tokens
+ balance-eth :balance-eth
+ value-usd :value-usd
+ claim-count :claim-count} bounty
+ full-repo (str owner "/" repo-name)
+ repo-url (str "https://github.com/" full-repo)
+ repo-link [:a {:href repo-url} full-repo]
issue-link [:a
{:href (issue-url owner repo-name issue-number)}
issue-title]]
@@ -40,35 +45,189 @@
[:span.usd-value-label "Value "] [:span.usd-balance-label (str "$" value-usd)]
(when (> claim-count 0)
[:span.open-claims-label (str claim-count " open claim"
- (when (> claim-count 1) "s"))]) ]]
+ (when (> claim-count 1) "s"))])]]
[:div.open-bounty-item-icon
[:div.ui.tiny.circular.image
[:img {:src avatar-url}]]]]))
-(defn bounties-list [{:keys [items item-count page-number total-count]
- :as bounty-page-data}
+(defn bounties-filter-tooltip-value-input-view [label tooltip-open? opts]
+ [:div.open-bounties-filter-element-tooltip-value-input-container
+ [:div.:input.open-bounties-filter-element-tooltip-value-input-label
+ label]
+ [:input.open-bounties-filter-element-tooltip-value-input
+ {:type "range"
+ :min (:min opts)
+ :max (:max opts)
+ :step (:step opts)
+ :value (:current-val opts)
+ :on-change (when-let [f (:on-change-val opts)]
+ #(-> % .-target .-value int f))
+ :on-focus #(reset! tooltip-open? true)}]])
+
+(defmulti bounties-filter-tooltip-view #(-> %2 ::ui-model/bounty-filter-type.category))
+
+(defmethod bounties-filter-tooltip-view ::ui-model/bounty-filter-type-category|range
+ [filter-type filter-type-def current-filter-value tooltip-open?]
+ (let [default-min (::ui-model/bounty-filter-type.min-val filter-type-def)
+ default-max (::ui-model/bounty-filter-type.max-val filter-type-def)
+ common-range-opts {:min default-min :max default-max}
+ current-min (or (first current-filter-value) default-min)
+ current-max (or (second current-filter-value) default-max)
+ on-change-fn (fn [min-val max-val]
+ (rf/dispatch [::handlers/set-open-bounty-filter-type
+ filter-type
+ [(min min-val default-max)
+ (max max-val default-min)]]))
+ on-min-change-fn (fn [new-min]
+ (let [new-max (max current-max (min default-max new-min))]
+ (on-change-fn new-min new-max)))
+ on-max-change-fn (fn [new-max]
+ (let [new-min (min current-min (max default-min new-max))]
+ (on-change-fn new-min new-max)))]
+ [:div.open-bounties-filter-list
+ (::ui-model/bounty-filter.type.header filter-type-def)
+ [bounties-filter-tooltip-value-input-view "Min" tooltip-open? (merge common-range-opts
+ {:current-val current-min
+ :on-change-val on-min-change-fn})]
+ [bounties-filter-tooltip-value-input-view "Max" tooltip-open? (merge common-range-opts
+ {:current-val current-max
+ :on-change-val on-max-change-fn})]]))
+
+(defmethod bounties-filter-tooltip-view ::ui-model/bounty-filter-type-category|single-static-option
+ [filter-type filter-type-def current-filter-value tooltip-open?]
+ [:div.open-bounties-filter-list
+ (for [[option-type option-text] (::ui-model/bounty-filter-type.options filter-type-def)]
+ ^{:key (str option-type)}
+ [:div.open-bounties-filter-list-option
+ (merge {:on-click #(do (rf/dispatch [::handlers/set-open-bounty-filter-type
+ filter-type
+ option-type])
+ (reset! tooltip-open? false))
+ :tab-index 0
+ :on-focus #(reset! tooltip-open? true)}
+ (when (= option-type current-filter-value)
+ {:class "active"}))
+ option-text])])
+
+(defmethod bounties-filter-tooltip-view ::ui-model/bounty-filter-type-category|multiple-dynamic-options
+ [filter-type filter-type-def current-filter-value tooltip-open?]
+ (let [options (rf/subscribe [(::ui-model/bounty-filter-type.re-frame-subs-key-for-options filter-type-def)])]
+ [:div.open-bounties-filter-list
+ (for [option @options]
+ (let [active? (boolean (and current-filter-value (current-filter-value option)))]
+ ^{:key (str option)}
+ [:div.open-bounties-filter-list-option-checkbox
+ [:label
+ {:on-click #(rf/dispatch [::handlers/set-open-bounty-filter-type
+ filter-type
+ (cond
+ (and active? (= #{option} current-filter-value)) nil
+ active? (disj current-filter-value option)
+ :else (into #{option} current-filter-value))])
+ :tab-index 0
+ :on-focus #(do (.stopPropagation %) (reset! tooltip-open? true))}
+ [:input
+ {:type "checkbox"
+ :checked active?
+ :on-focus #(reset! tooltip-open? true)}]
+ [:div.text option]]]))]))
+
+(defn bounty-filter-view [filter-type current-filter-value]
+ (let [tooltip-open? (r/atom false)]
+ (fn [filter-type current-filter-value]
+ [:div.open-bounties-filter-element-container
+ {:tab-index 0
+ :on-focus #(reset! tooltip-open? true)
+ :on-blur #(reset! tooltip-open? false)}
+ [:div.open-bounties-filter-element
+ {:on-mouse-down #(swap! tooltip-open? not)
+ :class (when (or current-filter-value @tooltip-open?)
+ "open-bounties-filter-element-active")}
+ [:div.text
+ (if current-filter-value
+ (ui-model/bounty-filter-value->short-text filter-type current-filter-value)
+ (ui-model/bounty-filter-type->name filter-type))]
+ (when current-filter-value
+ [:div.remove-container
+ {:tab-index 0
+ :on-focus #(.stopPropagation %)}
+ [:img.remove
+ {:src "bounty-filter-remove.svg"
+ :on-mouse-down (fn [e]
+ (.stopPropagation e)
+ (rf/dispatch [::handlers/set-open-bounty-filter-type filter-type nil])
+ (reset! tooltip-open? false))}]])]
+ (when @tooltip-open?
+ [:div.open-bounties-filter-element-tooltip
+ [bounties-filter-tooltip-view
+ filter-type
+ (ui-model/bounty-filter-types-def filter-type)
+ current-filter-value
+ tooltip-open?]])])))
+
+(defn bounty-filters-view []
+ (let [current-filters (rf/subscribe [::subs/open-bounties-filters])]
+ [:div.open-bounties-filter
+ ; doall because derefs are not supported in lazy seqs: https://github.com/reagent-project/reagent/issues/18
+ (doall
+ (for [filter-type ui-model/bounty-filter-types]
+ ^{:key (str filter-type)}
+ [bounty-filter-view
+ filter-type
+ (get @current-filters filter-type)]))]))
+
+(defn bounties-sort-view []
+ (let [open? (r/atom false)]
+ (fn []
+ (let [current-sorting (rf/subscribe [::subs/open-bounties-sorting-type])]
+ [:div.open-bounties-sort
+ {:tab-index 0
+ :on-blur #(reset! open? false)}
+ [:div.open-bounties-sort-element
+ {:on-click #(swap! open? not)}
+ (ui-model/bounty-sorting-type->name @current-sorting)
+ [:div.icon-forward-white-box
+ [:img
+ {:src "icon-forward-white.svg"}]]]
+ (when @open?
+ [:div.open-bounties-sort-element-tooltip
+ (for [sorting-type (keys ui-model/bounty-sorting-types-def)]
+ ^{:key (str sorting-type)}
+ [:div.open-bounties-sort-type
+ {:on-click #(do
+ (reset! open? false)
+ (rf/dispatch [::handlers/set-open-bounties-sorting-type sorting-type]))}
+ (ui-model/bounty-sorting-type->name sorting-type)])])]))))
+
+(defn bounties-list [{:keys [items item-count page-number total-count]
+ :as bounty-page-data}
container-element]
(if (empty? items)
[:div.view-no-data-container
- [:p "No recent activity yet"]]
+ [:p "No matching bounties found."]]
[:div
- (let [left (inc (* (dec page-number) items-per-page))
+ (let [left (inc (* (dec page-number) items-per-page))
right (dec (+ left item-count))]
- [:div.item-counts-label
- [:span (str "Showing " left "-" right " of " total-count)]])
+ [:div.item-counts-label-and-sorting-container
+ [:div.item-counts-label
+ [:span (str "Showing " left "-" right " of " total-count)]]
+ (when-not (util/os-windows?)
+ [bounties-sort-view])])
(display-data-page bounty-page-data bounty-item container-element)]))
(defn bounties-page []
- (let [bounty-page-data (rf/subscribe [:open-bounties-page])
+ (let [bounty-page-data (rf/subscribe [:open-bounties-page])
open-bounties-loading? (rf/subscribe [:get-in [:open-bounties-loading?]])
- container-element (atom nil)]
+ container-element (atom nil)]
(fn []
(if @open-bounties-loading?
[:div.view-loading-container
[:div.ui.active.inverted.dimmer
[:div.ui.text.loader.view-loading-label "Loading"]]]
[:div.ui.container.open-bounties-container
- {:ref #(reset! container-element %1)}
+ {:ref #(reset! container-element %1)}
[:div.open-bounties-header "Bounties"]
- [bounties-list @bounty-page-data container-element]]))
- ))
+ (when-not (util/os-windows?)
+ [:div.open-bounties-filter-and-sort
+ [bounty-filters-view]])
+ [bounties-list @bounty-page-data container-element]]))))
diff --git a/src/cljs/commiteth/core.cljs b/src/cljs/commiteth/core.cljs
index 45bb1b8..2034449 100644
--- a/src/cljs/commiteth/core.cljs
+++ b/src/cljs/commiteth/core.cljs
@@ -208,12 +208,12 @@
[:div.ui.vertical.segment
[:div.ui.container
[:div.ui.grid.stackable
- [:div {:class (str (if (show-top-hunters?) "ten" "sixteen")
+ [:div {:class (str (if (show-top-hunters?) "eleven" "sixteen")
" wide computer sixteen wide tablet column")}
[:div.ui.container
[(pages @current-page)]]]
(when (show-top-hunters?)
- [:div.six.wide.column.computer.only
+ [:div.five.wide.column.computer.only
[:div.ui.container.top-hunters
[:h3.top-hunters-header "Top 5 hunters"]
[:div.top-hunters-subheader "All time"]
diff --git a/src/cljs/commiteth/db.cljs b/src/cljs/commiteth/db.cljs
index 76b9a1c..c0ebfb8 100644
--- a/src/cljs/commiteth/db.cljs
+++ b/src/cljs/commiteth/db.cljs
@@ -1,14 +1,22 @@
-(ns commiteth.db)
+(ns commiteth.db
+ (:require [commiteth.ui-model :as ui-model]))
(def default-db
- {:page :bounties
- :user nil
- :repos-loading? false
- :repos {}
- :activity-feed-loading? false
- :open-bounties-loading? false
- :open-bounties []
- :page-number 1
- :owner-bounties {}
- :top-hunters []
- :activity-feed []})
+ {:page :bounties
+ :user nil
+ :repos-loading? false
+ :repos {}
+ :activity-feed-loading? false
+ :open-bounties-loading? false
+ :open-bounties []
+ :page-number 1
+ :bounty-page-number 1
+ :activity-page-number 1
+ ::open-bounties-sorting-type ::ui-model/bounty-sorting-type|most-recent
+ ::open-bounties-filters {::ui-model/bounty-filter-type|value nil
+ ::ui-model/bounty-filter-type|currency nil
+ ::ui-model/bounty-filter-type|date nil
+ ::ui-model/bounty-filter-type|owner nil}
+ :owner-bounties {}
+ :top-hunters []
+ :activity-feed []})
diff --git a/src/cljs/commiteth/handlers.cljs b/src/cljs/commiteth/handlers.cljs
index 077d280..eb61374 100644
--- a/src/cljs/commiteth/handlers.cljs
+++ b/src/cljs/commiteth/handlers.cljs
@@ -11,7 +11,8 @@
[cljs-web3.eth :as web3-eth]
[akiroz.re-frame.storage
:as rf-storage
- :refer [reg-co-fx!]]))
+ :refer [reg-co-fx!]]
+ [commiteth.ui-model :as ui-model]))
(rf-storage/reg-co-fx! :commiteth-sob {:fx :store
@@ -63,10 +64,12 @@
(reg-event-db
- :set-active-page
- (fn [db [_ page]]
- (assoc db :page page
- :page-number 1)))
+ :set-active-page
+ (fn [db [_ page]]
+ (assoc db :page page
+ :page-number 1
+ ::db/open-bounties-filters {}
+ ::db/open-bounties-sorting-type ::ui-model/bounty-sorting-type|most-recent)))
(reg-event-db
:set-page-number
@@ -457,3 +460,16 @@
(fn [db [_]]
(.removeEventListener js/window "click" close-dropdown)
(assoc db :user-dropdown-open? false)))
+
+(reg-event-db
+ ::set-open-bounties-sorting-type
+ (fn [db [_ sorting-type]]
+ (merge db {::db/open-bounties-sorting-type sorting-type
+ :page-number 1})))
+
+(reg-event-db
+ ::set-open-bounty-filter-type
+ (fn [db [_ filter-type filter-value]]
+ (-> db
+ (assoc-in [::db/open-bounties-filters filter-type] filter-value)
+ (assoc :page-number 1))))
diff --git a/src/cljs/commiteth/subscriptions.cljs b/src/cljs/commiteth/subscriptions.cljs
index c1b8952..bb2a82a 100644
--- a/src/cljs/commiteth/subscriptions.cljs
+++ b/src/cljs/commiteth/subscriptions.cljs
@@ -1,10 +1,13 @@
(ns commiteth.subscriptions
(:require [re-frame.core :refer [reg-sub]]
- [commiteth.common :refer [items-per-page]]))
+ [commiteth.db :as db]
+ [commiteth.ui-model :as ui-model]
+ [commiteth.common :refer [items-per-page]]
+ [clojure.string :as string]))
(reg-sub
- :db
- (fn [db _] db))
+ :db
+ (fn [db _] db))
(reg-sub
:page
@@ -43,7 +46,7 @@
(reg-sub
:open-bounties-page
- :<- [:open-bounties]
+ :<- [::filtered-and-sorted-open-bounties]
:<- [:page-number]
(fn [[open-bounties page-number] _]
(let [total-count (count open-bounties)
@@ -105,16 +108,62 @@
(get-in db path)))
(reg-sub
- :usage-metrics
- (fn [db _]
- (:usage-metrics db)))
+ :usage-metrics
+ (fn [db _]
+ (:usage-metrics db)))
(reg-sub
- :metrics-loading?
- (fn [db _]
- (:metrics-loading? db)))
+ :metrics-loading?
+ (fn [db _]
+ (:metrics-loading? db)))
(reg-sub
- :user-dropdown-open?
+ :user-dropdown-open?
(fn [db _]
(:user-dropdown-open? db)))
+
+(reg-sub
+ ::open-bounties-sorting-type
+ (fn [db _]
+ (::db/open-bounties-sorting-type db)))
+
+(reg-sub
+ ::open-bounties-filters
+ (fn [db _]
+ (::db/open-bounties-filters db)))
+
+(reg-sub
+ ::open-bounties-owners
+ :<- [:open-bounties]
+ (fn [open-bounties _]
+ (->> open-bounties
+ (map :repo-owner)
+ set)))
+
+(reg-sub
+ ::open-bounties-owners-sorted
+ :<- [::open-bounties-owners]
+ (fn [owners _]
+ (sort-by string/lower-case owners)))
+
+(reg-sub
+ ::open-bounties-currencies
+ :<- [:open-bounties]
+ (fn [open-bounties _]
+ (let [token-ids (->> open-bounties
+ (map :tokens)
+ (mapcat keys)
+ (filter identity)
+ set)]
+ (into #{"ETH"} token-ids))))
+
+(reg-sub
+ ::filtered-and-sorted-open-bounties
+ :<- [:open-bounties]
+ :<- [::open-bounties-filters]
+ :<- [::open-bounties-sorting-type]
+ (fn [[open-bounties filters sorting-type] _]
+ (cond->> open-bounties
+ true (ui-model/filter-bounties filters)
+ sorting-type (ui-model/sort-bounties-by-sorting-type sorting-type)
+ true vec)))
diff --git a/src/cljs/commiteth/ui_model.cljs b/src/cljs/commiteth/ui_model.cljs
new file mode 100644
index 0000000..fd7d41c
--- /dev/null
+++ b/src/cljs/commiteth/ui_model.cljs
@@ -0,0 +1,165 @@
+(ns commiteth.ui-model
+ (:require [clojure.set :as set]
+ [cljs-time.core :as t]
+ [cljs-time.coerce :as t-coerce]
+ [cljs-time.format :as t-format]))
+
+;;;; bounty sorting types
+
+(def bounty-sorting-types-def
+ {::bounty-sorting-type|most-recent {::bounty-sorting-type.name "Most recent"
+ ::bounty-sorting-type.sort-key-fn (fn [bounty]
+ (:updated bounty))
+ ::bounty-sorting-type.sort-comparator-fn (comp - compare)}
+ ::bounty-sorting-type|lowest-value {::bounty-sorting-type.name "Lowest value"
+ ::bounty-sorting-type.sort-key-fn (fn [bounty]
+ (js/parseFloat (:value-usd bounty)))
+ ::bounty-sorting-type.sort-comparator-fn compare}
+ ::bounty-sorting-type|highest-value {::bounty-sorting-type.name "Highest value"
+ ::bounty-sorting-type.sort-key-fn (fn [bounty]
+ (js/parseFloat (:value-usd bounty)))
+ ::bounty-sorting-type.sort-comparator-fn (comp - compare)}
+ ::bounty-sorting-type|owner {::bounty-sorting-type.name "Owner"
+ ::bounty-sorting-type.sort-key-fn (fn [bounty]
+ (:repo-owner bounty))
+ ::bounty-sorting-type.sort-comparator-fn compare}})
+
+(defn bounty-sorting-type->name [sorting-type]
+ (-> bounty-sorting-types-def (get sorting-type) ::bounty-sorting-type.name))
+
+(defn sort-bounties-by-sorting-type [sorting-type bounties]
+ (let [keyfn (-> bounty-sorting-types-def
+ sorting-type
+ ::bounty-sorting-type.sort-key-fn)
+ comparator (-> bounty-sorting-types-def
+ sorting-type
+ ::bounty-sorting-type.sort-comparator-fn)]
+ (sort-by keyfn comparator bounties)))
+
+;;;; bounty filter types
+
+(def bounty-filter-type-date-options-def {::bounty-filter-type-date-option|last-week "Last week"
+ ::bounty-filter-type-date-option|last-month "Last month"
+ ::bounty-filter-type-date-option|last-3-months "Last 3 months"})
+
+(def bounty-filter-type-date-options (keys bounty-filter-type-date-options-def))
+
+(defn bounty-filter-type-date-option->name [option]
+ (bounty-filter-type-date-options-def option))
+
+(def bounty-filter-type-date-pre-predicate-value-processor
+ "It converts an option of the filter type date to a cljs-time interval in which
+ that option is valid, so that you can check `cljs-time.core.within?` against that
+ interval and know if a `cljs-time` date is valid for that filter type date option."
+ (fn [filter-value]
+ (let [filter-from (condp = filter-value
+ ::bounty-filter-type-date-option|last-week (t/minus (t/now) (t/weeks 1))
+ ::bounty-filter-type-date-option|last-month (t/minus (t/now) (t/months 1))
+ ::bounty-filter-type-date-option|last-3-months (t/minus (t/now) (t/months 3)))]
+ (t/interval filter-from (t/now)))))
+(def bounty-filter-type-date-predicate
+ (fn [filter-value-interval bounty]
+ (when-let [date-inst (:updated bounty)]
+ (let [date (-> date-inst inst-ms t-coerce/from-long)]
+ (t/within? filter-value-interval date)))))
+
+(def bounty-filter-type-claims-options-def {::bounty-filter-type-claims-option|no-claims "Not claimed yet"})
+
+(def bounty-filter-type-claims-options (keys bounty-filter-type-claims-options-def))
+
+(defn bounty-filter-type-claims-option->name [option]
+ (bounty-filter-type-claims-options-def option))
+
+(def bounty-filter-types-def
+ {::bounty-filter-type|value
+ {::bounty-filter-type.name "Value"
+ ::bounty-filter-type.category ::bounty-filter-type-category|range
+ ::bounty-filter-type.min-val 0
+ ::bounty-filter-type.max-val 10000
+ ::bounty-filter.type.header "$0 - $10000+"
+ ::bounty-filter-type.predicate (fn [filter-value bounty]
+ (let [min-val (first filter-value)
+ max-val (second filter-value)]
+ (<= min-val (:value-usd bounty) max-val)))}
+
+ ::bounty-filter-type|currency
+ {::bounty-filter-type.name "Currency"
+ ::bounty-filter-type.category ::bounty-filter-type-category|multiple-dynamic-options
+ ::bounty-filter-type.re-frame-subs-key-for-options :commiteth.subscriptions/open-bounties-currencies
+ ::bounty-filter-type.predicate (fn [filter-value bounty]
+ (and (or (not-any? #{"ETH"} filter-value)
+ (< 0 (:balance-eth bounty)))
+ (set/subset? (->> filter-value (remove #{"ETH"}) set)
+ (-> bounty :tokens keys set))))}
+
+ ::bounty-filter-type|date
+ {::bounty-filter-type.name "Date"
+ ::bounty-filter-type.category ::bounty-filter-type-category|single-static-option
+ ::bounty-filter-type.options bounty-filter-type-date-options-def
+ ::bounty-filter-type.pre-predicate-value-processor bounty-filter-type-date-pre-predicate-value-processor
+ ::bounty-filter-type.predicate bounty-filter-type-date-predicate}
+
+ ::bounty-filter-type|owner
+ {::bounty-filter-type.name "Owner"
+ ::bounty-filter-type.category ::bounty-filter-type-category|multiple-dynamic-options
+ ::bounty-filter-type.re-frame-subs-key-for-options :commiteth.subscriptions/open-bounties-owners-sorted
+ ::bounty-filter-type.predicate (fn [filter-value bounty]
+ (->> filter-value
+ (some #{(:repo-owner bounty)})
+ boolean))}
+
+ ::bounty-filter-type|claims
+ {::bounty-filter-type.name "Claims"
+ ::bounty-filter-type.category ::bounty-filter-type-category|single-static-option
+ ::bounty-filter-type.options bounty-filter-type-claims-options-def
+ ::bounty-filter-type.predicate (fn [filter-value bounty]
+ (condp = filter-value
+ ::bounty-filter-type-claims-option|no-claims
+ (= 0 (:claim-count bounty))))}})
+
+(def bounty-filter-types (keys bounty-filter-types-def))
+
+(defn bounty-filter-type->name [filter-type]
+ (-> bounty-filter-types-def (get filter-type) ::bounty-filter-type.name))
+
+(defn bounty-filter-value->short-text [filter-type filter-value]
+ (cond
+ (= filter-type ::bounty-filter-type|date)
+ (bounty-filter-type-date-option->name filter-value)
+
+ (#{::bounty-filter-type|owner
+ ::bounty-filter-type|currency} filter-type)
+ (str (bounty-filter-type->name filter-type) " (" (count filter-value) ")")
+
+ (= filter-type ::bounty-filter-type|value)
+ (str "$" (first filter-value) "-$" (second filter-value))
+
+ (= filter-type ::bounty-filter-type|claims)
+ (bounty-filter-type-claims-option->name filter-value)
+
+ :else
+ (str filter-type " with val " filter-value)))
+
+(defn- bounty-filter-values-by-type->predicates [filters-by-type]
+ "It receives a map with filter types as keys and filter values as values and
+ returns a lazy seq of predicates, one for each pair of filter type and value.
+ Those predicate can receive a bounty and tell whether that bounty passes
+ the filter type with that filter value. It removes filter types with a `nil`
+ filter value."
+ (->> filters-by-type
+ ; used `nil?` because a valid filter value can be `false`
+ (remove #(nil? (val %)))
+ (map (fn [[filter-type filter-value]]
+ (let [filter-type-def (bounty-filter-types-def filter-type)
+ pred (::bounty-filter-type.predicate filter-type-def)
+ pre-pred-processor (::bounty-filter-type.pre-predicate-value-processor filter-type-def)
+ filter-value (cond-> filter-value
+ pre-pred-processor pre-pred-processor)]
+ (partial pred filter-value))))))
+
+(defn filter-bounties [filters-by-type bounties]
+ (let [filter-preds (bounty-filter-values-by-type->predicates filters-by-type)
+ filters-pred (fn [bounty]
+ (every? #(% bounty) filter-preds))]
+ (cond->> bounties
+ (not-empty filter-preds) (filter filters-pred))))
diff --git a/src/cljs/commiteth/util.cljs b/src/cljs/commiteth/util.cljs
new file mode 100644
index 0000000..8b6f852
--- /dev/null
+++ b/src/cljs/commiteth/util.cljs
@@ -0,0 +1,5 @@
+(ns commiteth.util
+ (:require [clojure.string :as string]))
+
+(defn os-windows? []
+ (string/includes? (-> js/navigator .-platform) "Win"))
diff --git a/src/less/style.less b/src/less/style.less
index 063f7bb..e9f80a2 100644
--- a/src/less/style.less
+++ b/src/less/style.less
@@ -413,6 +413,312 @@
font-weight: 500;
color: #42505c;
}
+
+ .item-counts-label-and-sorting-container {
+ display: flex;
+ justify-content: space-between;
+ }
+
+ .open-bounties-filter-and-sort {
+ margin-top: 24px;
+ display: flex;
+ justify-content: space-between;
+ }
+
+ @media (max-width: 767px) {
+ .open-bounties-filter-and-sort {
+ display: none;
+ }
+ }
+
+ .open-bounties-filter {
+ display: flex;
+ }
+
+ .open-bounties-filter-element {
+ font-family: "PostGrotesk-Book";
+ font-size: 15px;
+ font-weight: 500;
+ line-height: 1.0;
+ color: #8d99a4;
+ //padding: 8px 12px;
+ border-radius: 8px;
+ border: solid 1px rgba(151, 151, 151, 0.2);
+ margin-right: 10px;
+ position: relative;
+ display: flex;
+ z-index: 1000;
+
+ .text {
+ margin: 8px 12px;
+ }
+
+ .remove-container {
+ display: flex;
+ }
+
+ .remove {
+ margin-left: -6px;
+ margin-right: 5px;
+ }
+ }
+
+ .open-bounties-filter-element:hover {
+ cursor: pointer;
+ }
+
+ .open-bounties-filter-element-active
+ {
+ background-color: #57a7ed;
+ color: #ffffff;
+ }
+
+ .open-bounties-filter-element-container:focus {
+ outline: none;
+ }
+
+ .open-bounties-filter-element-tooltip {
+ position: absolute;
+ min-width: 227px;
+ margin-top: 12px;
+ border-radius: 10px;
+ background-color: #ffffff;
+ box-shadow: 0 15px 12px 0 rgba(161, 174, 182, 0.53), 0 0 38px 0 rgba(0, 0, 0, 0.05);
+ font-family: "PostGrotesk-Book";
+ font-size: 16px;
+ line-height: 1.5;
+ z-index: 999;
+ max-height: 300px;
+ overflow: scroll;
+
+ .open-bounties-filter-element-tooltip-value-input-container {
+ display: flex;
+ margin-top: 10px;
+ }
+
+ .open-bounties-filter-element-tooltip-value-input-label {
+ width: 60px;
+ }
+
+ .open-bounties-filter-element-tooltip-value-input {
+ margin-top: 24px;
+ width: 288.5px;
+ height: 4px;
+ background-color: #55a5ea;
+ }
+
+ // generated with http://danielstern.ca/range.css/#/
+ input[type=range] {
+ -webkit-appearance: none;
+ //width: 100%;
+ margin: 14.5px 0;
+ }
+ input[type=range]:focus {
+ outline: 0;
+ }
+ input[type=range]::-moz-focus-outer {
+ border: 0;
+ }
+ input[type=range]::-webkit-slider-runnable-track {
+ width: 100%;
+ height: 4px;
+ cursor: pointer;
+ box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d;
+ background: #55a5ea;
+ border-radius: 0px;
+ border: 0px solid #010101;
+ }
+ input[type=range]::-webkit-slider-thumb {
+ box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d;
+ border: 0px solid #000000;
+ height: 33px;
+ width: 33px;
+ border-radius: 50px;
+ background: #55a5ea;
+ cursor: pointer;
+ -webkit-appearance: none;
+ margin-top: -14.5px;
+ }
+ input[type=range]:focus::-webkit-slider-runnable-track {
+ background: #5aa7eb;
+ }
+ input[type=range]::-moz-range-track {
+ width: 100%;
+ height: 4px;
+ cursor: pointer;
+ box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d;
+ background: #55a5ea;
+ border-radius: 0px;
+ border: 0px solid #010101;
+ }
+ input[type=range]::-moz-range-thumb {
+ box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d;
+ border: 0px solid #000000;
+ height: 33px;
+ width: 33px;
+ border-radius: 50px;
+ background: #55a5ea;
+ cursor: pointer;
+ }
+ input[type=range]::-ms-track {
+ width: 100%;
+ height: 4px;
+ cursor: pointer;
+ background: transparent;
+ border-color: transparent;
+ color: transparent;
+ }
+ input[type=range]::-ms-fill-lower {
+ background: #50a3e9;
+ border: 0px solid #010101;
+ border-radius: 0px;
+ box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d;
+ }
+ input[type=range]::-ms-fill-upper {
+ background: #55a5ea;
+ border: 0px solid #010101;
+ border-radius: 0px;
+ box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d;
+ }
+ input[type=range]::-ms-thumb {
+ box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d;
+ border: 0px solid #000000;
+ height: 33px;
+ width: 33px;
+ border-radius: 50px;
+ background: #55a5ea;
+ cursor: pointer;
+ height: 4px;
+ }
+ input[type=range]:focus::-ms-fill-lower {
+ background: #55a5ea;
+ }
+ input[type=range]:focus::-ms-fill-upper {
+ background: #5aa7eb;
+ }
+
+ .open-bounties-filter-list {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ margin: 24px 16px;
+ }
+
+ .open-bounties-filter-list-option {
+ font-family: "PostGrotesk-Book";
+ font-size: 15px;
+ font-weight: 500;
+ line-height: 1.0;
+ color: #42505c;
+ padding: 8px 12px;
+ border-radius: 8px;
+ border: solid 1px rgba(151, 151, 151, 0.2);
+ }
+
+ .open-bounties-filter-list-option.active {
+ background-color: #57a7ed;
+ color: #ffffff;
+ }
+
+ .open-bounties-filter-list-option:not(:first-child) {
+ margin-top: 8px;
+ }
+
+ .open-bounties-filter-list-option:hover {
+ cursor: pointer;
+ background-color: #57a7ed;
+ color: #ffffff;
+ }
+
+ .open-bounties-filter-list-option-checkbox {
+ label {
+ display: flex;
+ align-items: baseline;
+ }
+
+ label:hover {
+ cursor: pointer;
+ }
+
+ label:focus {
+ outline: none;
+ }
+
+ input:hover {
+ cursor: pointer;
+ }
+
+ .text {
+ font-size: 15px;
+ font-weight: 500;
+ line-height: 1.0;
+ margin-left: 12px;
+ }
+ }
+
+ .open-bounties-filter-list-option-checkbox:not(:first-child) {
+ margin-top: 12px;
+ }
+ }
+
+ .open-bounties-sort {
+ position: relative;
+ }
+
+ .open-bounties-sort:focus {
+ outline: none;
+ }
+
+ .open-bounties-sort-element {
+ display: flex;
+ font-size: 15px;
+ font-weight: 500;
+ line-height: 1.0;
+ color: #8d99a4;
+ padding: 16px 12px 0;
+ display: flex;
+ align-items: center;
+ }
+
+ .open-bounties-sort-element:hover {
+ cursor: pointer;
+ }
+
+ .open-bounties-sort-element-tooltip {
+ position: absolute;
+ right: 0;
+ //padding: 16px 0;
+ min-width: 200px;
+ border-radius: 8px;
+ background-color: #1e3751;
+ font-family: "PostGrotesk-Book";
+ font-size: 15px;
+ line-height: 1.0;
+ text-align: left;
+ color: #ffffff;
+ z-index: 999;
+ }
+
+ .open-bounties-sort-type {
+ padding: 19px 24px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ }
+
+ .open-bounties-sort-type:hover {
+ cursor: pointer;
+ }
+
+ .icon-forward-white-box {
+ width: 24px;
+ height: 24px;
+ display: flex;
+ justify-content: center;
+ align-content: center;
+ }
+}
+
+.open-bounty-item:first-child {
+ border-top: #eaecee 1px solid !important;
}
.open-bounty-item {
@@ -496,7 +802,7 @@
}
.open-claims-label {
- padding-left: 5px;
+ padding-left: 15px;
font-size: 15px;
color: #57a7ed;
}
@@ -971,12 +1277,11 @@ body {
}
.item-counts-label {
- margin: auto;
+ //margin: auto;
font-family: "PostGrotesk-Book";
font-size: 15px;
color: #8d99a4;
- padding-top: 8px;
- padding-bottom: 8px;
+ padding-top: 20px;
}
diff --git a/test/Jenkinsfile b/test/Jenkinsfile
new file mode 100644
index 0000000..8169c6f
--- /dev/null
+++ b/test/Jenkinsfile
@@ -0,0 +1,11 @@
+node ('linux1') {sauce('1be1b688-e0e7-4314-92a0-db11f52d3c00') {
+ checkout([$class: 'GitSCM', branches: [[name: '*/develop']], doGenerateSubmoduleConfigurations: false, extensions: [[$class: 'CleanBeforeCheckout']], submoduleCfg: [], userRemoteConfigs: [[url: 'https://github.com/status-im/open-bounty.git']]])
+ configFileProvider([configFile(fileId: 'sob_automation_test_config', targetLocation: 'test/end-to-end/tests')]) {
+ try {sh 'cd test/end-to-end/tests && python3 -m pytest -m sanity --build=$BUILD_NAME -v -n 1'
+ }
+ finally {
+ saucePublisher()
+ junit testDataPublishers: [[$class: 'SauceOnDemandReportPublisher', jobVisibility: 'public']], testResults: 'test/end-to-end/tests/*.xml' }
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/end-to-end/pages/base_page.py b/test/end-to-end/pages/base_page.py
index de61cd4..f602775 100644
--- a/test/end-to-end/pages/base_page.py
+++ b/test/end-to-end/pages/base_page.py
@@ -15,3 +15,4 @@ class BasePageObject(object):
@property
def time_now(self):
return datetime.now().strftime('%-m%-d%-H%-M%-S')
+
diff --git a/test/end-to-end/pages/thirdparty/github.py b/test/end-to-end/pages/thirdparty/github.py
index 0d00335..60333be 100644
--- a/test/end-to-end/pages/thirdparty/github.py
+++ b/test/end-to-end/pages/thirdparty/github.py
@@ -2,6 +2,10 @@ import time, pytest
from pages.base_element import *
from pages.base_page import BasePageObject
from tests import test_data
+from git import Repo
+import os
+import shutil
+import logging
class EmailEditbox(BaseEditBox):
@@ -106,7 +110,46 @@ class ContractBody(BaseText):
super(ContractBody, self).__init__(driver)
self.locator = self.Locator.xpath_selector("//tbody//p[contains(text(), "
"'Current balance: 0.000000 ETH')]")
+class IssueId(BaseText):
+ def __init__(self, driver):
+ super(IssueId, self).__init__(driver)
+ self.locator = self.Locator.css_selector(".gh-header-number")
+class ForkButton(BaseButton):
+ def __init__(self, driver):
+ super(ForkButton, self).__init__(driver)
+ self.locator = self.Locator.css_selector("[href='#fork-destination-box']")
+
+class HeaderInForkPopup(BaseText):
+ def __init__(self, driver):
+ super(HeaderInForkPopup, self).__init__(driver)
+ self.locator = self.Locator.css_selector("#facebox-header")
+
+class UserAccountInForkPopup(BaseButton):
+ def __init__(self, driver):
+ super(UserAccountInForkPopup, self).__init__(driver)
+ self.locator = self.Locator.css_selector("[value=%s]"%test_data.config['DEV']['gh_username'])
+
+
+class ForkedRepoText(BaseText):
+ def __init__(self, driver):
+ super(ForkedRepoText, self).__init__(driver)
+ self.locator = self.Locator.css_selector(".commit-tease")
+
+class DeleteRepo(BaseButton):
+ def __init__(self, driver):
+ super(DeleteRepo, self).__init__(driver)
+ self.locator = self.Locator.xpath_selector("//button[text()[contains(.,' Delete this repository')]]")
+
+class RepoNameBoxInPopup(BaseEditBox):
+ def __init__(self, driver):
+ super(RepoNameBoxInPopup, self).__init__(driver)
+ self.locator = self.Locator.css_selector("input[aria-label='Type in the name of the repository to confirm that you want to delete this repository.']")
+
+class ConfirmDeleteButton(BaseButton):
+ def __init__(self, driver):
+ super(ConfirmDeleteButton, self).__init__(driver)
+ self.locator = self.Locator.xpath_selector("//button[text()[contains(.,'I understand the consequences, delete')]]")
class GithubPage(BasePageObject):
def __init__(self, driver):
@@ -133,18 +176,32 @@ class GithubPage(BasePageObject):
self.cross_button = LabelsButton.CrossButton(self.driver)
self.submit_new_issue_button = SubmitNewIssueButton(self.driver)
self.contract_body = ContractBody(self.driver)
+ self.issue_id = IssueId(self.driver)
+ self.fork_button = ForkButton(self.driver)
+ self.header_in_fork_popup = HeaderInForkPopup(self.driver)
+ self.user_account_in_fork_popup = UserAccountInForkPopup(self.driver)
+ self.forked_repo_text = ForkedRepoText(self.driver)
+
+ self.delete_repo = DeleteRepo(self.driver)
+ self.repo_name_confirm_delete = RepoNameBoxInPopup(self.driver)
+ self.confirm_delete = ConfirmDeleteButton(self.driver)
+
def get_issues_page(self):
- self.driver.get('https://github.com/Org4/nov13/issues')
+ self.driver.get(test_data.config['ORG']['gh_repo'] + 'issues')
+
+ def get_issue_page(self, issue_id):
+ self.driver.get(test_data.config['ORG']['gh_repo'] + 'issues/' + issue_id)
def get_sob_plugin_page(self):
- self.driver.get('http://github.com/apps/status-open-bounty-app-test')
+ self.driver.get(test_data.config['Common']['sob_test_app'])
def sign_in(self, email, password):
self.email_input.send_keys(email)
self.password_input.send_keys(password)
self.sign_in_button.click()
+
def install_sob_plugin(self):
initial_url = self.driver.current_url
self.get_sob_plugin_page()
@@ -164,7 +221,18 @@ class GithubPage(BasePageObject):
self.bounty_label.click()
self.cross_button.click()
self.submit_new_issue_button.click()
- return test_data.issue['title']
+ test_data.issue['id'] = self.issue_id.text[1:]
+ logging.info("Issue title is %s" % test_data.issue['title'])
+
+ def fork_repo(self, initial_repo, wait=60):
+ self.driver.get(initial_repo)
+ self.fork_button.click()
+ if self.header_in_fork_popup.text == 'Where should we fork this repository?':
+ self.user_account_in_fork_popup.click()
+ self.forked_repo_text.wait_for_element(wait)
+
+ def get_login_page(self):
+ self.driver.get(test_data.config['Common']['gh_login'])
def get_deployed_contract(self, wait=120):
for i in range(wait):
@@ -175,3 +243,34 @@ class GithubPage(BasePageObject):
time.sleep(10)
pass
pytest.fail('Contract is not deployed in %s minutes!' % str(wait/60))
+
+ #cloning via HTTPS
+ def clone_repo(self, initial_repo=None, username=None, repo_name=None, repo_path='git_repo'):
+ os.mkdir(repo_path)
+ os.chdir(repo_path)
+ test_data.local_repo_path = os.getcwd()
+ fork = 'https://github.com/%s/%s.git' % (username, repo_name)
+ logging.info(('Cloning from %s to %s' % (fork, repo_path)))
+ r = Repo.clone_from(fork, repo_path)
+ logging.info(('Successefully cloned to: %s' % test_data.local_repo_path))
+ logging.info('Set upstream to %s'% initial_repo)
+ upstream = r.create_remote('upstream', initial_repo)
+ upstream.fetch()
+ assert upstream.exists()
+ r.heads.master.checkout()
+
+ def clean_repo_local_folder(self):
+ logging.info('Removing %s' % test_data.local_repo_path)
+ if test_data.local_repo_path:
+ shutil.rmtree(test_data.local_repo_path)
+
+ def delete_fork(self):
+ self.get_url(test_data.config['DEV']['gh_forked_repo'] + 'settings')
+ self.delete_repo.click()
+ self.repo_name_confirm_delete.send_keys(test_data.config['ORG']['gh_repo_name'])
+ self.confirm_delete.click()
+
+
+
+
+
diff --git a/test/end-to-end/requirements.txt b/test/end-to-end/requirements.txt
index 397c508..32b99e7 100644
--- a/test/end-to-end/requirements.txt
+++ b/test/end-to-end/requirements.txt
@@ -16,3 +16,6 @@ selenium==2.53.6
six==1.10.0
urllib3==1.22
yarl==0.12.0
+gitpython==2.1.8
+gitdb2==2.0.3
+smmap2==2.0.3
\ No newline at end of file
diff --git a/test/end-to-end/tests/__init__.py b/test/end-to-end/tests/__init__.py
index 41ae6be..49b8c0e 100644
--- a/test/end-to-end/tests/__init__.py
+++ b/test/end-to-end/tests/__init__.py
@@ -7,11 +7,14 @@ class TestData(object):
self.config = configparser.ConfigParser()
# define here path to your config.ini file
- #example - config_example.ini
+ # example - config_example.ini
self.config.read('config.ini')
- self.base_case_issue = dict()
- self.base_case_issue['title'] = 'Very first auto_test_bounty'
+
+ # self.issue['title'] is set in GithubPage::create_new_bounty
+ # self.issue['id'] is set in GithubPage::create_new_bounty
+ # self.local_repo_path is set in GithubPage::clone_repo
+
test_data = TestData()
diff --git a/test/end-to-end/tests/basetestcase.py b/test/end-to-end/tests/basetestcase.py
index 9ba9409..aa113c4 100644
--- a/test/end-to-end/tests/basetestcase.py
+++ b/test/end-to-end/tests/basetestcase.py
@@ -4,63 +4,25 @@ from selenium.common.exceptions import WebDriverException
from tests.postconditions import remove_application, remove_installation
from os import environ, path
from tests import test_data
-
+from pages.thirdparty.github import GithubPage
+from pages.openbounty.landing import LandingPage
class BaseTestCase:
- @property
- def sauce_username(self):
- return environ.get('SAUCE_USERNAME')
-
-
- @property
- def sauce_access_key(self):
- return environ.get('SAUCE_ACCESS_KEY')
-
-
- @property
- def executor_sauce_lab(self):
- return 'http://%s:%s@ondemand.saucelabs.com:80/wd/hub' % (self.sauce_username, self.sauce_access_key)
def print_sauce_lab_info(self, driver):
sys.stdout = sys.stderr
print("SauceOnDemandSessionID=%s job-name=%s" % (driver.session_id,
pytest.config.getoption('build')))
-
- @property
- def capabilities_sauce_lab(self):
-
- desired_caps = dict()
- desired_caps['name'] = test_data.test_name
- desired_caps['build'] = pytest.config.getoption('build')
- desired_caps['platform'] = "MAC"
- desired_caps['browserName'] = 'Chrome'
- desired_caps['screenResolution'] = '2048x1536'
- desired_caps['captureHtml'] = False
- return desired_caps
-
- @property
- def environment(self):
- return pytest.config.getoption('env')
-
- def setup_method(self):
-
- self.errors = []
- self.cleanup = None
-
- if self.environment == 'local':
- options = webdriver.ChromeOptions()
- options.add_argument('--start-fullscreen')
- options.add_extension(
- path.abspath(test_data.config['Paths']['tests_absolute'] + 'resources/metamask3_12_0.crx'))
- # for chromedriver 2.35
- self.driver = webdriver.Chrome(chrome_options=options)
- if self.environment == 'sauce':
- self.driver = webdriver.Remote(self.executor_sauce_lab,
- desired_capabilities=self.capabilities_sauce_lab)
- self.driver.implicitly_wait(5)
-
-
+ def get_remote_caps(self):
+ sauce_lab_cap = dict()
+ sauce_lab_cap['name'] = test_data.test_name
+ sauce_lab_cap['build'] = pytest.config.getoption('build')
+ sauce_lab_cap['platform'] = "MAC"
+ sauce_lab_cap['browserName'] = 'Chrome'
+ sauce_lab_cap['screenResolution'] = '2048x1536'
+ sauce_lab_cap['captureHtml'] = False
+ return sauce_lab_cap
def verify_no_errors(self):
if self.errors:
@@ -69,13 +31,115 @@ class BaseTestCase:
msg += (error + '\n')
pytest.fail(msg, pytrace=False)
- def teardown_method(self):
- if self.cleanup:
- remove_application(self.driver)
- remove_installation(self.driver)
+ @classmethod
+ def setup_class(cls):
+ cls.errors = []
+ cls.environment = pytest.config.getoption('env')
+###################################################################################################################
+######### Drivers setup
+###################################################################################################################
+
+ #
+ # Dev Chrome options
+ #
+ cls.capabilities_dev = webdriver.ChromeOptions()
+ cls.capabilities_dev.add_argument('--start-fullscreen')
+
+ #
+ # Org Chrome options
+ #
+ cls.capabilities_org = webdriver.ChromeOptions()
+ # doesn't work on sauce env
+ # cls.capabilities_org.add_extension(path.abspath(test_data.config['Paths']['tests_absolute'] + 'resources/metamask3_12_0.crx'))
+
+ #
+ # SauceLab capabilities
+ #
+ cls.executor_sauce_lab = 'http://%s:%s@ondemand.saucelabs.com:80/wd/hub' % (
+ environ.get('SAUCE_USERNAME'), environ.get('SAUCE_ACCESS_KEY'))
+ drivers = []
+
+ if cls.environment == 'local':
+ for caps in cls.capabilities_dev, cls.capabilities_org:
+ driver = webdriver.Chrome(chrome_options=caps)
+ drivers.append(driver)
+
+ if cls.environment == 'sauce':
+ for caps in cls.capabilities_dev, cls.capabilities_org:
+ cls.get_remote_caps(cls)
+ new_caps = caps.to_capabilities()
+ driver = webdriver.Remote(cls.executor_sauce_lab,
+ desired_capabilities=new_caps)
+ drivers.append(driver)
+
+ for driver in drivers:
+ cls.print_sauce_lab_info(cls, driver)
+
+ cls.driver_dev = drivers[0]
+ cls.driver_org = drivers[1]
+
+ for driver in drivers:
+ driver.implicitly_wait(10)
+
+###################################################################################################################
+######### Actions for each driver before class
+###################################################################################################################
+
+ ######ORG
+ landing = LandingPage(cls.driver_org)
+ landing.get_landing_page()
+
+ # Sign Up to SOB
+ cls.github_org = landing.login_button.click()
+ cls.github_org.sign_in(test_data.config['ORG']['gh_login'],
+ test_data.config['ORG']['gh_password'])
+ assert cls.github_org.permission_type.text == 'Personal user data'
+ bounties_page = cls.github_org.authorize_sob.click()
+
+ # SOB Plugin installation and navigate to "Open bounties"
+ cls.github_org.install_sob_plugin()
+ assert bounties_page.bounties_header.text == 'Bounties'
+ assert bounties_page.top_hunters_header.text == 'Top 5 hunters'
+
+ ######DEV
+ cls.github_dev = GithubPage(cls.driver_dev)
+ # Sign In to GH as Developer
+ cls.github_dev.get_login_page()
+ cls.github_dev.sign_in(test_data.config['DEV']['gh_login'],
+ test_data.config['DEV']['gh_password'])
+
+ # Fork repo as Developer from Organization
+ cls.github_dev.fork_repo(test_data.config['ORG']['gh_repo'])
+
+ # Cloning repo to local git as Developer and set upstream to Organization (via HTTPS)
+ cls.github_dev.clone_repo(test_data.config['ORG']['gh_repo'],
+ test_data.config['DEV']['gh_username'],
+ test_data.config['ORG']['gh_repo_name'],
+ 'git_repo')
+ cls.verify_no_errors(cls)
+
+
+
+
+ @classmethod
+ def teardown_class(cls):
+
+ ######ORG
+
+ # SOB Plugin remove installation
+ remove_application(cls.driver_org)
+ remove_installation(cls.driver_org)
+
+ ######DEV
+
+ cls.github_dev.delete_fork()
+ cls.github_dev.clean_repo_local_folder()
try:
- self.print_sauce_lab_info(self.driver)
- self.driver.quit()
+ cls.driver_dev.quit()
+ cls.driver_org.quit()
except WebDriverException:
pass
+
+
+
diff --git a/test/end-to-end/tests/config_example.ini b/test/end-to-end/tests/config_example.ini
index 4b52a24..6d2f8c3 100644
--- a/test/end-to-end/tests/config_example.ini
+++ b/test/end-to-end/tests/config_example.ini
@@ -1,13 +1,30 @@
[Common]
;app URL
url = https://openbounty.status.im:444/
+;URL to GH openbounty app
+sob_test_app = http://github.com/apps/status-open-bounty-app-test
+gh_login = https://github.com/login
[Paths]
-;AbsolutePath to 'tests' folder
-tests_absolute = /usr/dir/open-bounty/test/end-to-end/tests/
+;AbsolutePath to 'end-to-end' folder
+tests_absolute = /usr/dir/open-bounty/test/end-to-end/
[ORG]
-;GitHub credentials for organization
+
+;GitHub credentials for organization owner
gh_login = login
gh_password = password
+;GitHub organization path
+gh_org_profile = https://github.com/organizations/organization/
+;GitHub repo path
+gh_repo = https://github.com/org_name/repo_name/
+;GitHub repo name
+gh_repo_name = repo_name
+
;MetaMask password for organization
mm_password = password
+[DEV]
+;GitHub credentials for developer
+gh_login = login
+gh_password = password
+gh_username = username
+gh_forked_repo = https://github.com/username/reponame/
\ No newline at end of file
diff --git a/test/end-to-end/tests/postconditions.py b/test/end-to-end/tests/postconditions.py
index b9f4c7d..1b09d42 100644
--- a/test/end-to-end/tests/postconditions.py
+++ b/test/end-to-end/tests/postconditions.py
@@ -1,5 +1,6 @@
from selenium.webdriver.common.by import By
from selenium.common.exceptions import NoSuchElementException
+from tests import test_data
def remove_application(driver):
@@ -13,7 +14,7 @@ def remove_application(driver):
def remove_installation(driver):
try:
- driver.get('https://github.com/organizations/Org4/settings/installations')
+ driver.get(test_data.config['ORG']['gh_org_profile'] + 'settings/installations')
driver.find_element(By.CSS_SELECTOR, '.iconbutton').click()
driver.find_element(By.XPATH, "//a[@class='btn btn-danger']").click()
driver.find_element(By.CSS_SELECTOR, '.facebox-popup .btn-danger').click()
diff --git a/test/end-to-end/tests/test_contracts.py b/test/end-to-end/tests/test_contracts.py
index 5564b7b..17c66e2 100644
--- a/test/end-to-end/tests/test_contracts.py
+++ b/test/end-to-end/tests/test_contracts.py
@@ -2,6 +2,7 @@ import pytest
from os import environ
from pages.openbounty.landing import LandingPage
from pages.openbounty.bounties import BountiesPage
+from pages.thirdparty.github import GithubPage
from tests.basetestcase import BaseTestCase
from tests import test_data
@@ -10,28 +11,13 @@ from tests import test_data
class TestLogin(BaseTestCase):
def test_deploy_new_contract(self):
- self.cleanup = True
- landing = LandingPage(self.driver)
- landing.get_landing_page()
-
- # Sign Up to SOB
- github = landing.login_button.click()
- github.sign_in(test_data.config['ORG']['gh_login'],
- test_data.config['ORG']['gh_password'])
- assert github.permission_type.text == 'Personal user data'
- bounties_page = github.authorize_sob.click()
-
- # SOB Plugin installation and navigate to "Open bounties"
- github.install_sob_plugin()
- assert bounties_page.bounties_header.text == 'Bounties'
- assert bounties_page.top_hunters_header.text == 'Top 5 hunters'
# Waiting for deployed contract; test_data.issue created here
- github.create_new_bounty()
- github.get_deployed_contract()
+ self.github_org.create_new_bounty()
+ self.github_org.get_deployed_contract()
# Navigate and check top bounty in "Open bounties"
- bounties_page = BountiesPage(self.driver)
+ bounties_page = BountiesPage(self.driver_org)
bounties_page.get_bounties_page()
titles = bounties_page.bounty_titles.find_elements()
assert titles[0].text == test_data.issue['title']
@@ -40,3 +26,6 @@ class TestLogin(BaseTestCase):
+
+
+