diff --git a/src/quo2/components/inputs/address_input/component_spec.cljs b/src/quo2/components/inputs/address_input/component_spec.cljs new file mode 100644 index 0000000000..d5923b4807 --- /dev/null +++ b/src/quo2/components/inputs/address_input/component_spec.cljs @@ -0,0 +1,161 @@ +(ns quo2.components.inputs.address-input.component-spec + (:require [quo2.components.inputs.address-input.view :as address-input] + [test-helpers.component :as h] + [react-native.clipboard :as clipboard] + [quo2.foundations.colors :as colors])) + +(def ens-regex #"^(?=.{5,255}$)([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.[a-zA-Z]{2,}$") + +(h/describe "Address input" + (h/test "default render" + (with-redefs [clipboard/get-string #(% "")] + (h/render [address-input/address-input {:ens-regex ens-regex}]) + (h/is-truthy (h/get-by-label-text :address-text-input)))) + + (h/test "on focus with blur? false" + (with-redefs [clipboard/get-string #(% "")] + (h/render [address-input/address-input {:ens-regex ens-regex}]) + (h/fire-event :on-focus (h/get-by-label-text :address-text-input)) + (h/has-prop (h/get-by-label-text :address-text-input) :placeholder-text-color colors/neutral-40))) + + (h/test "on focus with blur? true" + (with-redefs [clipboard/get-string #(% "")] + (h/render [address-input/address-input + {:blur? true + :ens-regex ens-regex}]) + (h/fire-event :on-focus (h/get-by-label-text :address-text-input)) + (h/has-prop (h/get-by-label-text :address-text-input) + :placeholder-text-color + colors/neutral-80-opa-40))) + + (h/test "scanned value is properly set" + (let [on-change-text (h/mock-fn) + scanned-value "scanned-value"] + (with-redefs [clipboard/get-string #(% "")] + (h/render [address-input/address-input + {:scanned-value scanned-value + :on-change-text on-change-text + :ens-regex ens-regex}]) + (h/wait-for #(h/is-truthy (h/get-by-label-text :clear-button))) + (h/was-called-with on-change-text scanned-value) + (h/has-prop (h/get-by-label-text :address-text-input) :default-value scanned-value)))) + + (h/test "clear icon is shown when input has text" + (with-redefs [clipboard/get-string #(% "")] + (h/render [address-input/address-input + {:scanned-value "scanned value" + :ens-regex ens-regex}]) + (h/wait-for #(h/is-truthy (h/get-by-label-text :clear-button-container))) + (h/wait-for #(h/is-truthy (h/get-by-label-text :clear-button))))) + + (h/test "on blur with text and blur? false" + (with-redefs [clipboard/get-string #(% "")] + (h/render [address-input/address-input + {:scanned-value "scanned value" + :ens-regex ens-regex}]) + (h/wait-for #(h/is-truthy (h/get-by-label-text :clear-button))) + (h/fire-event :on-focus (h/get-by-label-text :address-text-input)) + (h/fire-event :on-blur (h/get-by-label-text :address-text-input)) + (h/has-prop (h/get-by-label-text :address-text-input) :placeholder-text-color colors/neutral-30))) + + (h/test "on blur with text blur? true" + (with-redefs [clipboard/get-string #(% "")] + (h/render [address-input/address-input + {:scanned-value "scanned value" + :blur? true + :ens-regex ens-regex}]) + (h/wait-for #(h/is-truthy (h/get-by-label-text :clear-button))) + (h/fire-event :on-focus (h/get-by-label-text :address-text-input)) + (h/fire-event :on-blur (h/get-by-label-text :address-text-input)) + (h/has-prop (h/get-by-label-text :address-text-input) + :placeholder-text-color + colors/neutral-80-opa-20))) + + (h/test "on blur with no text and blur? false" + (with-redefs [clipboard/get-string #(% "")] + (h/render [address-input/address-input {:ens-regex ens-regex}]) + (h/fire-event :on-focus (h/get-by-label-text :address-text-input)) + (h/fire-event :on-blur (h/get-by-label-text :address-text-input)) + (h/has-prop (h/get-by-label-text :address-text-input) :placeholder-text-color colors/neutral-40))) + + (h/test "on blur with no text blur? true" + (with-redefs [clipboard/get-string #(% "")] + (h/render [address-input/address-input + {:blur? true + :ens-regex ens-regex}]) + (h/fire-event :on-focus (h/get-by-label-text :address-text-input)) + (h/fire-event :on-blur (h/get-by-label-text :address-text-input)) + (h/has-prop (h/get-by-label-text :address-text-input) + :placeholder-text-color + colors/neutral-80-opa-40))) + + (h/test "on-clear is called" + (let [on-clear (h/mock-fn)] + (with-redefs [clipboard/get-string #(% "")] + (h/render [address-input/address-input + {:scanned-value "scanned value" + :on-clear on-clear + :ens-regex ens-regex}]) + (h/wait-for #(h/is-truthy (h/get-by-label-text :clear-button))) + (h/fire-event :press (h/get-by-label-text :clear-button)) + (h/was-called on-clear)))) + + (h/test "on-focus is called" + (let [on-focus (h/mock-fn)] + (with-redefs [clipboard/get-string #(% "")] + (h/render [address-input/address-input {:on-focus on-focus}]) + (h/fire-event :on-focus (h/get-by-label-text :address-text-input)) + (h/was-called on-focus)))) + + (h/test "on-blur is called" + (let [on-blur (h/mock-fn)] + (with-redefs [clipboard/get-string #(% "")] + (h/render [address-input/address-input + {:on-blur on-blur + :ens-regex ens-regex}]) + (h/fire-event :on-blur (h/get-by-label-text :address-text-input)) + (h/was-called on-blur)))) + + (h/test "on-scan is called" + (let [on-scan (h/mock-fn)] + (with-redefs [clipboard/get-string #(% "")] + (h/render [address-input/address-input {:on-scan on-scan}]) + (h/wait-for #(h/is-truthy (h/get-by-label-text :scan-button))) + (h/fire-event :press (h/get-by-label-text :scan-button)) + (h/was-called on-scan)))) + + (h/test "paste from clipboard" + (let [clipboard "clipboard"] + (with-redefs [clipboard/get-string #(% clipboard)] + (h/render [address-input/address-input {:ens-regex ens-regex}]) + (h/wait-for #(h/is-truthy (h/get-by-label-text :paste-button))) + (h/fire-event :press (h/get-by-label-text :paste-button)) + (h/wait-for #(h/is-truthy (h/get-by-label-text :clear-button))) + (h/has-prop (h/get-by-label-text :address-text-input) :default-value clipboard)))) + + (h/test "ENS loading state and call on-detect-ens" + (let [clipboard "test.eth" + on-detect-ens (h/mock-fn)] + (with-redefs [clipboard/get-string #(% clipboard)] + (h/render [address-input/address-input + {:on-detect-ens on-detect-ens + :ens-regex ens-regex}]) + (h/wait-for #(h/is-truthy (h/get-by-label-text :paste-button))) + (h/fire-event :press (h/get-by-label-text :paste-button)) + (h/wait-for #(h/is-falsy (h/get-by-label-text :clear-button))) + (h/wait-for #(h/is-truthy (h/get-by-label-text :loading-button-container))) + (h/was-called on-detect-ens)))) + + (h/test "ENS valid state and call on-detect-ens" + (let [clipboard "test.eth" + on-detect-ens (h/mock-fn)] + (with-redefs [clipboard/get-string #(% clipboard)] + (h/render [address-input/address-input + {:on-detect-ens on-detect-ens + :valid-ens? true + :ens-regex ens-regex}]) + (h/wait-for #(h/is-truthy (h/get-by-label-text :paste-button))) + (h/fire-event :press (h/get-by-label-text :paste-button)) + (h/wait-for #(h/is-falsy (h/get-by-label-text :clear-button))) + (h/wait-for #(h/is-truthy (h/get-by-label-text :positive-button-container))) + (h/was-called on-detect-ens))))) diff --git a/src/quo2/components/inputs/address_input/style.cljs b/src/quo2/components/inputs/address_input/style.cljs new file mode 100644 index 0000000000..98f763ecfe --- /dev/null +++ b/src/quo2/components/inputs/address_input/style.cljs @@ -0,0 +1,40 @@ +(ns quo2.components.inputs.address-input.style + (:require [quo2.foundations.colors :as colors] + [quo2.components.markdown.text :as text] + [react-native.platform :as platform])) + +(def container + {:padding-horizontal 20 + :padding-top 8 + :padding-bottom 16 + :height 48 + :flex-direction :row + :align-items :flex-start}) + +(def buttons-container + {:flex-direction :row + :align-items :center + :padding-top (when platform/android? 2)}) + +(def clear-icon-container + {:justify-content :center + :align-items :center + :padding-top (if platform/ios? 6 2) + :height 24 + :width 20}) + +(defn input-text + [theme] + (assoc (text/text-style {:size :paragraph-1 + :weight :monospace}) + :flex 1 + :color (colors/theme-colors colors/neutral-100 colors/white theme) + :margin-top (if platform/ios? 0 -4) + :margin-right 8 + :height (if platform/ios? 24 40))) + +(defn accessory-button + [blur? theme] + {:border-color (if blur? + (colors/theme-colors colors/neutral-80-opa-10 colors/white-opa-10 theme) + (colors/theme-colors colors/neutral-30 colors/neutral-70 theme))}) diff --git a/src/quo2/components/inputs/address_input/view.cljs b/src/quo2/components/inputs/address_input/view.cljs new file mode 100644 index 0000000000..9104a5176e --- /dev/null +++ b/src/quo2/components/inputs/address_input/view.cljs @@ -0,0 +1,163 @@ +(ns quo2.components.inputs.address-input.view + (:require [react-native.core :as rn] + [react-native.clipboard :as clipboard] + [quo2.theme :as quo.theme] + [quo2.foundations.colors :as colors] + [quo2.components.icon :as icon] + [quo2.components.buttons.button.view :as button] + [quo2.components.inputs.address-input.style :as style] + [utils.i18n :as i18n] + [reagent.core :as reagent] + [react-native.platform :as platform])) + +(defn- icon-color + [blur? theme] + (if blur? + (colors/theme-colors colors/neutral-80-opa-30 colors/white-opa-10 theme) + (colors/theme-colors colors/neutral-40 colors/neutral-60 theme))) + +(defn- clear-button + [{:keys [on-press blur? theme]}] + [rn/touchable-opacity + {:accessibility-label :clear-button + :style style/clear-icon-container + :on-press on-press} + [icon/icon :i/clear + {:color (icon-color blur? theme) + :size 20}]]) + +(defn- loading-icon + [blur? theme] + [rn/view {:style style/clear-icon-container} + [icon/icon :i/loading + {:color (icon-color blur? theme) + :size 20}]]) + +(defn- positive-state-icon + [theme] + [rn/view {:style style/clear-icon-container} + [icon/icon :i/positive-state + {:color (colors/theme-colors (colors/custom-color :success 50) + (colors/custom-color :success 60) + theme) + :size 20}]]) + +(defn- get-placeholder-text-color + [status theme blur?] + (cond + (and (= status :default) blur?) + (colors/theme-colors colors/neutral-80-opa-40 colors/white-opa-30 theme) + (and (= status :default) (not blur?)) + (colors/theme-colors colors/neutral-40 colors/neutral-50 theme) + (and (not= status :default) blur?) + (colors/theme-colors colors/neutral-80-opa-20 colors/white-opa-20 theme) + (and (not= status :default) (not blur?)) + (colors/theme-colors colors/neutral-30 colors/neutral-60 theme))) + +(defn- f-address-input-internal + [] + (let [status (reagent/atom :default) + value (reagent/atom "") + clipboard (reagent/atom nil) + focused? (atom false)] + (fn [{:keys [scanned-value theme blur? on-change-text on-blur on-focus on-clear on-scan on-detect-ens + ens-regex + valid-ens?]}] + (let [on-change (fn [text] + (let [ens? (boolean (re-matches ens-regex text))] + (if (> (count text) 0) + (reset! status :typing) + (reset! status :active)) + (reset! value text) + (when on-change-text + (on-change-text text)) + (when (and ens? on-detect-ens) + (reset! status :loading) + (on-detect-ens text)))) + on-paste (fn [] + (when-not (empty? @clipboard) + (on-change @clipboard) + (reset! value @clipboard))) + on-clear (fn [] + (reset! value "") + (reset! status (if @focused? :active :default)) + (when on-clear + (on-clear))) + on-scan #(when on-scan + (on-scan)) + on-focus (fn [] + (when (= (count @value) 0) + (reset! status :active)) + (reset! focused? true) + (when on-focus (on-focus))) + on-blur (fn [] + (when (= @status :active) + (reset! status :default)) + (reset! focused? false) + (when on-blur (on-blur))) + placeholder-text-color (get-placeholder-text-color @status theme blur?)] + (rn/use-effect (fn [] + (when-not (empty? scanned-value) + (on-change scanned-value))) + [scanned-value]) + (clipboard/get-string #(reset! clipboard %)) + [rn/view {:style style/container} + [rn/text-input + {:accessibility-label :address-text-input + :style (style/input-text theme) + :placeholder (i18n/label :t/name-ens-or-address) + :placeholder-text-color placeholder-text-color + :default-value @value + :auto-complete (when platform/ios? :none) + :auto-capitalize :none + :auto-correct false + :keyboard-appearance (quo.theme/theme-value :light :dark theme) + :on-focus on-focus + :on-blur on-blur + :on-change-text on-change}] + (when (or (= @status :default) + (= @status :active)) + [rn/view + {:style style/buttons-container + :accessibility-label :paste-scan-buttons-container} + [button/button + {:accessibility-label :paste-button + :type :outline + :size 24 + :container-style {:margin-right 8} + :inner-style (style/accessory-button blur? theme) + :on-press on-paste} + (i18n/label :t/paste)] + [button/button + {:accessibility-label :scan-button + :icon-only? true + :type :outline + :size 24 + :inner-style (style/accessory-button blur? theme) + :on-press on-scan} + :main-icons/scan]]) + (when (= @status :typing) + [rn/view + {:style style/buttons-container + :accessibility-label :clear-button-container} + [clear-button + {:on-press on-clear + :blur? blur? + :theme theme}]]) + (when (and (= @status :loading) (not valid-ens?)) + [rn/view + {:style style/buttons-container + :accessibility-label :loading-button-container} + [loading-icon blur? theme]]) + (when (and (= @status :loading) valid-ens?) + [rn/view + {:style style/buttons-container + :accessibility-label :positive-button-container} + [positive-state-icon theme]])])))) + +(defn address-input-internal + [props] + [:f> f-address-input-internal props]) + +(def address-input + (quo.theme/with-theme address-input-internal)) diff --git a/src/quo2/core.cljs b/src/quo2/core.cljs index a7656ebc2a..a8842a2353 100644 --- a/src/quo2/core.cljs +++ b/src/quo2/core.cljs @@ -52,6 +52,7 @@ quo2.components.info.info-message quo2.components.info.information-box.view quo2.components.inputs.input.view + quo2.components.inputs.address-input.view quo2.components.inputs.locked-input.view quo2.components.inputs.profile-input.view quo2.components.inputs.recovery-phrase.view @@ -231,6 +232,7 @@ ;;;; Inputs (def input quo2.components.inputs.input.view/input) +(def address-input quo2.components.inputs.address-input.view/address-input) (def locked-input quo2.components.inputs.locked-input.view/locked-input) (def profile-input quo2.components.inputs.profile-input.view/profile-input) (def recovery-phrase-input quo2.components.inputs.recovery-phrase.view/recovery-phrase-input) diff --git a/src/quo2/core_spec.cljs b/src/quo2/core_spec.cljs index 992858440a..c50d2bd41a 100644 --- a/src/quo2/core_spec.cljs +++ b/src/quo2/core_spec.cljs @@ -27,9 +27,10 @@ [quo2.components.dropdowns.network-dropdown.component-spec] [quo2.components.gradient.gradient-cover.component-spec] [quo2.components.graph.wallet-graph.component-spec] + [quo2.components.inputs.address-input.component-spec] + [quo2.components.inputs.locked-input.component-spec] [quo2.components.inputs.input.component-spec] [quo2.components.inputs.profile-input.component-spec] - [quo2.components.inputs.locked-input.component-spec] [quo2.components.inputs.recovery-phrase.component-spec] [quo2.components.inputs.title-input.component-spec] [quo2.components.keycard.component-spec] diff --git a/src/status_im2/constants.cljs b/src/status_im2/constants.cljs index d4753e76bf..65dc0d2f76 100644 --- a/src/status_im2/constants.cljs +++ b/src/status_im2/constants.cljs @@ -182,6 +182,7 @@ (def regx-universal-link #"((^https?://join.status.im/)|(^status-im://))[\x00-\x7F]+$") (def regx-community-universal-link #"((^https?://join.status.im/)|(^status-im://))c/([\x00-\x7F]+)$") (def regx-deep-link #"((^ethereum:.*)|(^status-im://[\x00-\x7F]+$))") +(def regx-ens #"^(?=.{5,255}$)([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.[a-zA-Z]{2,}$") (def ^:const dapp-permission-contact-code "contact-code") (def ^:const dapp-permission-web3 "web3") diff --git a/src/status_im2/contexts/quo_preview/inputs/address_input.cljs b/src/status_im2/contexts/quo_preview/inputs/address_input.cljs new file mode 100644 index 0000000000..40a3f458ab --- /dev/null +++ b/src/status_im2/contexts/quo_preview/inputs/address_input.cljs @@ -0,0 +1,35 @@ +(ns status-im2.contexts.quo-preview.inputs.address-input + (:require [quo2.core :as quo] + [reagent.core :as reagent] + [status-im2.contexts.quo-preview.preview :as preview] + [status-im2.constants :as constants])) + +(def descriptor + [{:label "Scanned value:" + :key :scanned-value + :type :text} + {:key :blur? + :type :boolean}]) + +(defn view + [] + (let [state (reagent/atom {:scanned-value "" + :blur? false + :valid-ens? false}) + timer (atom nil)] + (fn [] + [preview/preview-container + {:state state + :descriptor descriptor + :blur? (:blur? @state) + :show-blur-background? true} + [quo/address-input + (merge @state + {:on-scan #(js/alert "Not implemented yet") + :ens-regex constants/regx-ens + :on-detect-ens (fn [_] + (swap! state assoc :valid-ens? false) + (when @timer + (js/clearTimeout @timer)) + (reset! timer (js/setTimeout #(swap! state assoc :valid-ens? true) + 2000)))})]]))) diff --git a/src/status_im2/contexts/quo_preview/main.cljs b/src/status_im2/contexts/quo_preview/main.cljs index 23b91d6b1d..0b4b661984 100644 --- a/src/status_im2/contexts/quo_preview/main.cljs +++ b/src/status_im2/contexts/quo_preview/main.cljs @@ -53,6 +53,7 @@ [status-im2.contexts.quo-preview.info.info-message :as info-message] [status-im2.contexts.quo-preview.info.information-box :as information-box] [status-im2.contexts.quo-preview.inputs.input :as input] + [status-im2.contexts.quo-preview.inputs.address-input :as address-input] [status-im2.contexts.quo-preview.inputs.locked-input :as locked-input] [status-im2.contexts.quo-preview.inputs.recovery-phrase-input :as recovery-phrase-input] [status-im2.contexts.quo-preview.inputs.profile-input :as profile-input] @@ -235,6 +236,8 @@ :component information-box/view}] :inputs [{:name :input :component input/view} + {:name :address-input + :component address-input/view} {:name :locked-input :component locked-input/view} {:name :profile-input diff --git a/src/test_helpers/component.cljs b/src/test_helpers/component.cljs index 8cb23946a7..435f386db7 100644 --- a/src/test_helpers/component.cljs +++ b/src/test_helpers/component.cljs @@ -224,3 +224,8 @@ (defn has-style [mock styles] (.toHaveStyle (js/expect mock) (clj->js styles))) + +(defn has-prop + ([element prop] (has-prop element prop js/undefined)) + ([element prop value] + (.toHaveProp (js/expect element) (camel-snake-kebab/->camelCaseString prop) value))) diff --git a/translations/en.json b/translations/en.json index 0981289b69..df58156b0a 100644 --- a/translations/en.json +++ b/translations/en.json @@ -2305,5 +2305,6 @@ "destroy": "Destroy", "mint": "Mint", "via": "via", - "x-counter": "x{{counter}}" + "x-counter": "x{{counter}}", + "name-ens-or-address": "Name, ENS, or address" }